448 lines
15 KiB
JavaScript
448 lines
15 KiB
JavaScript
const { Engine, Render, Runner, World, Bodies, Body, Composite, Vertices, Vector, Events, Common, Mouse, MouseConstraint, Query } = Matter;
|
|
|
|
// Ensure poly-decomp is set up for complex polygons
|
|
if (typeof decomp !== 'undefined') {
|
|
Common.setDecomp(window.decomp);
|
|
} else {
|
|
console.error('poly-decomp.js is required for complex polygon splitting.');
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const canvas = document.getElementById('game-canvas');
|
|
const btnDrop = document.getElementById('btn-drop');
|
|
const btnReset = document.getElementById('btn-reset');
|
|
const btnHint = document.getElementById('btn-hint');
|
|
const starsPreviewEl = document.getElementById('stars-preview');
|
|
const accuracyValueEl = document.getElementById('accuracy-value');
|
|
|
|
let engine, render, runner, mouseConstraint;
|
|
let riceBodies = [];
|
|
let currentCuts = 0;
|
|
let isDropped = false;
|
|
let isDragging = false;
|
|
let lastMousePos = null;
|
|
let currentLevel = 1;
|
|
let ricePattern;
|
|
|
|
const config = {
|
|
width: 800,
|
|
height: 500,
|
|
winThreshold: 0.95,
|
|
gravity: 1.5,
|
|
moldFill: 'rgba(239, 154, 154, 0.4)',
|
|
moldBorder: '#5d4037',
|
|
riceBorder: '#ffffff',
|
|
cutColor: '#ffffff'
|
|
};
|
|
|
|
function resize() {
|
|
const container = canvas.parentElement;
|
|
const cw = container.clientWidth;
|
|
const ch = container.clientHeight;
|
|
canvas.width = cw;
|
|
canvas.height = ch;
|
|
// Adjust config based on actual size but keep internal logic consistent
|
|
config.width = cw;
|
|
config.height = ch;
|
|
}
|
|
window.addEventListener('resize', () => {
|
|
resize();
|
|
init();
|
|
});
|
|
resize();
|
|
|
|
// --- Levels ---
|
|
const getLevelData = (lv) => {
|
|
const w = config.width;
|
|
const h = config.height;
|
|
const levels = {
|
|
1: { // Square
|
|
rice: { x: w * 0.25, y: h * 0.5, w: 140, h: 140 },
|
|
mold: [
|
|
{ x: w * 0.7 - 70, y: h * 0.6 - 70 },
|
|
{ x: w * 0.7 + 70, y: h * 0.6 - 70 },
|
|
{ x: w * 0.7 + 70, y: h * 0.6 + 70 },
|
|
{ x: w * 0.7 - 70, y: h * 0.6 + 70 }
|
|
]
|
|
},
|
|
2: { // Triangle
|
|
rice: { x: w * 0.25, y: h * 0.5, w: 160, h: 160 },
|
|
mold: [
|
|
{ x: w * 0.7, y: h * 0.6 - 80 },
|
|
{ x: w * 0.7 + 90, y: h * 0.6 + 80 },
|
|
{ x: w * 0.7 - 90, y: h * 0.6 + 80 }
|
|
]
|
|
},
|
|
3: { // House shape
|
|
rice: { x: w * 0.25, y: h * 0.5, w: 150, h: 150 },
|
|
mold: [
|
|
{ x: w * 0.7 - 70, y: h * 0.6 + 70 },
|
|
{ x: w * 0.7 + 70, y: h * 0.6 + 70 },
|
|
{ x: w * 0.7 + 70, y: h * 0.6 - 20 },
|
|
{ x: w * 0.7, y: h * 0.6 - 80 },
|
|
{ x: w * 0.7 - 70, y: h * 0.6 - 20 }
|
|
]
|
|
}
|
|
};
|
|
return levels[lv] || levels[1];
|
|
};
|
|
|
|
function createRiceTexture() {
|
|
const tCanvas = document.createElement('canvas');
|
|
tCanvas.width = 64; tCanvas.height = 64;
|
|
const tCtx = tCanvas.getContext('2d');
|
|
tCtx.fillStyle = '#ffffff';
|
|
tCtx.fillRect(0, 0, 64, 64);
|
|
for (let i = 0; i < 150; i++) {
|
|
const x = Math.random() * 64;
|
|
const y = Math.random() * 64;
|
|
const size = 1 + Math.random() * 2;
|
|
tCtx.beginPath();
|
|
tCtx.ellipse(x, y, size, size * 1.8, Math.random() * Math.PI, 0, Math.PI * 2);
|
|
tCtx.fillStyle = i % 15 === 0 ? '#f5f5f5' : '#ffffff';
|
|
tCtx.fill();
|
|
tCtx.strokeStyle = 'rgba(0,0,0,0.02)';
|
|
tCtx.lineWidth = 0.5;
|
|
tCtx.stroke();
|
|
}
|
|
ricePattern = tCtx.createPattern(tCanvas, 'repeat');
|
|
}
|
|
createRiceTexture();
|
|
|
|
function init() {
|
|
if (engine) {
|
|
World.clear(engine.world);
|
|
Engine.clear(engine);
|
|
Render.stop(render);
|
|
Runner.stop(runner);
|
|
}
|
|
|
|
engine = Engine.create();
|
|
engine.world.gravity.y = 0;
|
|
|
|
render = Render.create({
|
|
canvas: canvas,
|
|
engine: engine,
|
|
options: {
|
|
width: config.width,
|
|
height: config.height,
|
|
wireframes: false,
|
|
background: 'transparent'
|
|
}
|
|
});
|
|
|
|
Render.run(render);
|
|
runner = Runner.create();
|
|
Runner.run(runner, engine);
|
|
|
|
const mouse = Mouse.create(render.canvas);
|
|
mouseConstraint = MouseConstraint.create(engine, {
|
|
mouse: mouse,
|
|
constraint: { stiffness: 0.2, render: { visible: false } }
|
|
});
|
|
World.add(engine.world, mouseConstraint);
|
|
render.mouse = mouse;
|
|
|
|
loadLevel(currentLevel);
|
|
setupSlicing(mouse);
|
|
|
|
Events.on(render, 'afterRender', () => {
|
|
const ctx = render.context;
|
|
drawMold(ctx);
|
|
if (isDragging && lastMousePos) {
|
|
const current = mouse.position;
|
|
ctx.beginPath();
|
|
ctx.moveTo(lastMousePos.x, lastMousePos.y);
|
|
ctx.lineTo(current.x, current.y);
|
|
ctx.strokeStyle = config.cutColor;
|
|
ctx.lineWidth = 3;
|
|
ctx.setLineDash([5, 5]);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
});
|
|
|
|
Events.on(engine, 'afterUpdate', () => {
|
|
if (isDropped) updateAccuracy();
|
|
});
|
|
}
|
|
|
|
function loadLevel(lv) {
|
|
const data = getLevelData(lv);
|
|
currentCuts = 0;
|
|
isDropped = false;
|
|
btnDrop.disabled = false;
|
|
engine.world.gravity.y = 0;
|
|
updateAccuracyUI(0);
|
|
updateStars(3);
|
|
|
|
const rice = Bodies.rectangle(data.rice.x, data.rice.y, data.rice.w, data.rice.h, {
|
|
render: { fillStyle: ricePattern, strokeStyle: config.riceBorder, lineWidth: 1 },
|
|
friction: 0.5,
|
|
restitution: 0.1,
|
|
slop: 0.01
|
|
});
|
|
riceBodies = [rice];
|
|
World.add(engine.world, rice);
|
|
|
|
// Ground for dropping
|
|
const ground = Bodies.rectangle(config.width * 0.7, config.height + 20, config.width, 40, { isStatic: true });
|
|
World.add(engine.world, ground);
|
|
}
|
|
|
|
function drawMold(ctx) {
|
|
const data = getLevelData(currentLevel);
|
|
ctx.beginPath();
|
|
ctx.moveTo(data.mold[0].x, data.mold[0].y);
|
|
for (let i = 1; i < data.mold.length; i++) ctx.lineTo(data.mold[i].x, data.mold[i].y);
|
|
ctx.closePath();
|
|
ctx.fillStyle = config.moldFill;
|
|
ctx.fill();
|
|
ctx.strokeStyle = config.moldBorder;
|
|
ctx.lineWidth = 8;
|
|
ctx.lineJoin = 'round';
|
|
ctx.stroke();
|
|
}
|
|
|
|
function setupSlicing(mouse) {
|
|
Events.on(mouseConstraint, 'mousedown', () => {
|
|
if (isDropped) return;
|
|
// Check if we are clicking a body to drag it
|
|
const clicked = Query.point(riceBodies, mouse.position)[0];
|
|
if (!clicked) {
|
|
isDragging = true;
|
|
lastMousePos = { x: mouse.position.x, y: mouse.position.y };
|
|
}
|
|
});
|
|
|
|
Events.on(mouseConstraint, 'mousemove', () => {
|
|
if (isDragging && !isDropped) {
|
|
const current = { x: mouse.position.x, y: mouse.position.y };
|
|
if (lastMousePos) {
|
|
performSlicing(lastMousePos, current);
|
|
}
|
|
lastMousePos = current;
|
|
}
|
|
});
|
|
|
|
Events.on(mouseConstraint, 'mouseup', () => {
|
|
isDragging = false;
|
|
lastMousePos = null;
|
|
});
|
|
}
|
|
|
|
function performSlicing(p1, p2) {
|
|
if (Vector.magnitude(Vector.sub(p1, p2)) < 5) return;
|
|
|
|
let slicedAny = false;
|
|
const toAdd = [];
|
|
const toRemove = [];
|
|
|
|
riceBodies.forEach(body => {
|
|
const result = sliceBody(body, p1, p2);
|
|
if (result && result.length > 1) {
|
|
toRemove.push(body);
|
|
toAdd.push(...result);
|
|
slicedAny = true;
|
|
}
|
|
});
|
|
|
|
if (slicedAny) {
|
|
World.remove(engine.world, toRemove);
|
|
World.add(engine.world, toAdd);
|
|
riceBodies = riceBodies.filter(b => !toRemove.includes(b)).concat(toAdd);
|
|
currentCuts++;
|
|
updateStars(calculateStars());
|
|
}
|
|
}
|
|
|
|
function sliceBody(body, p1, p2) {
|
|
const vertices = body.vertices.map(v => ({ x: v.x, y: v.y }));
|
|
const intersections = [];
|
|
|
|
for (let i = 0; i < vertices.length; i++) {
|
|
const a = vertices[i];
|
|
const b = vertices[(i + 1) % vertices.length];
|
|
const intersect = lineIntersect(p1, p2, a, b);
|
|
if (intersect) {
|
|
intersections.push({ point: intersect, edgeIndex: i });
|
|
}
|
|
}
|
|
|
|
// Only split if we have exactly 2 intersections with this segment
|
|
if (intersections.length !== 2) return null;
|
|
|
|
// Robust splitting
|
|
intersections.sort((a, b) => a.edgeIndex - b.edgeIndex);
|
|
const [i1, i2] = intersections;
|
|
|
|
const poly1 = [i1.point, i2.point];
|
|
for (let i = i2.edgeIndex + 1; i <= i1.edgeIndex + vertices.length; i++) {
|
|
poly1.push(vertices[i % vertices.length]);
|
|
}
|
|
|
|
const poly2 = [i2.point, i1.point];
|
|
for (let i = i1.edgeIndex + 1; i <= i2.edgeIndex; i++) {
|
|
poly2.push(vertices[i % vertices.length]);
|
|
}
|
|
|
|
return [poly1, poly2].map(poly => {
|
|
const center = Vertices.centre(poly);
|
|
return Bodies.fromVertices(center.x, center.y, [poly], {
|
|
render: { fillStyle: ricePattern, strokeStyle: config.riceBorder, lineWidth: 1 },
|
|
friction: 0.5,
|
|
restitution: 0.1
|
|
});
|
|
}).filter(b => b && b.area > 50);
|
|
}
|
|
|
|
function lineIntersect(p1, p2, p3, p4) {
|
|
const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y;
|
|
const x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y;
|
|
const den = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
|
|
if (den === 0) return null;
|
|
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / den;
|
|
const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / den;
|
|
if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
|
|
return { x: x1 + ua * (x2 - x1), y: y1 + ua * (y2 - y1) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const offCanvas = document.createElement('canvas');
|
|
const offCtx = offCanvas.getContext('2d', { willReadFrequently: true });
|
|
|
|
function updateAccuracy() {
|
|
const data = getLevelData(currentLevel);
|
|
const mold = data.mold;
|
|
|
|
// Find bounds of mold to optimize
|
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
mold.forEach(v => {
|
|
minX = Math.min(minX, v.x); maxX = Math.max(maxX, v.x);
|
|
minY = Math.min(minY, v.y); maxY = Math.max(maxY, v.y);
|
|
});
|
|
|
|
const pad = 50;
|
|
offCanvas.width = maxX - minX + pad * 2;
|
|
offCanvas.height = maxY - minY + pad * 2;
|
|
const ox = minX - pad;
|
|
const oy = minY - pad;
|
|
|
|
offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
|
|
|
|
// Draw mold (Target) in Red
|
|
offCtx.fillStyle = '#ff0000';
|
|
offCtx.beginPath();
|
|
offCtx.moveTo(mold[0].x - ox, mold[0].y - oy);
|
|
for (let i = 1; i < mold.length; i++) offCtx.lineTo(mold[i].x - ox, mold[i].y - oy);
|
|
offCtx.closePath();
|
|
offCtx.fill();
|
|
|
|
const moldImgData = offCtx.getImageData(0, 0, offCanvas.width, offCanvas.height).data;
|
|
let moldPixels = 0;
|
|
for (let i = 0; i < moldImgData.length; i += 4) {
|
|
if (moldImgData[i] > 128) moldPixels++;
|
|
}
|
|
|
|
// Draw overlapping rice in Blue using source-atop
|
|
offCtx.globalCompositeOperation = 'source-atop';
|
|
offCtx.fillStyle = '#0000ff';
|
|
riceBodies.forEach(b => {
|
|
offCtx.beginPath();
|
|
offCtx.moveTo(b.vertices[0].x - ox, b.vertices[0].y - oy);
|
|
for (let i = 1; i < b.vertices.length; i++) offCtx.lineTo(b.vertices[i].x - ox, b.vertices[i].y - oy);
|
|
offCtx.closePath();
|
|
offCtx.fill();
|
|
});
|
|
|
|
const overlapImgData = offCtx.getImageData(0, 0, offCanvas.width, offCanvas.height).data;
|
|
let overlapPixels = 0;
|
|
for (let i = 0; i < overlapImgData.length; i += 4) {
|
|
if (overlapImgData[i + 2] > 128) overlapPixels++;
|
|
}
|
|
|
|
const accuracy = moldPixels > 0 ? (overlapPixels / moldPixels) : 0;
|
|
updateAccuracyUI(accuracy);
|
|
|
|
// Auto win check
|
|
const allStopped = riceBodies.every(b => b.speed < 0.2);
|
|
if (allStopped && accuracy >= config.winThreshold) {
|
|
handleWin(accuracy);
|
|
}
|
|
}
|
|
|
|
function updateAccuracyUI(acc) {
|
|
const percent = Math.floor(acc * 100);
|
|
accuracyValueEl.textContent = percent;
|
|
}
|
|
|
|
function calculateStars() {
|
|
if (currentCuts <= 3) return 3;
|
|
if (currentCuts <= 6) return 2;
|
|
return 1;
|
|
}
|
|
|
|
function updateStars(count) {
|
|
let html = '';
|
|
for (let i = 0; i < 3; i++) {
|
|
html += i < count ? '<span class="star-gold">⭐</span>' : '<span class="star-outline">☆</span>';
|
|
}
|
|
starsPreviewEl.innerHTML = html;
|
|
}
|
|
|
|
function handleWin(acc) {
|
|
if (btnDrop.disabled && accuracyValueEl.dataset.win === "true") return;
|
|
accuracyValueEl.dataset.win = "true";
|
|
showToast(`EXCELLENT! ${Math.floor(acc * 100)}% Accuracy`);
|
|
setTimeout(() => {
|
|
currentLevel++;
|
|
if (currentLevel > 3) currentLevel = 1;
|
|
init();
|
|
}, 2500);
|
|
}
|
|
|
|
function showToast(msg) {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast';
|
|
toast.textContent = msg;
|
|
container.appendChild(toast);
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
setTimeout(() => toast.remove(), 500);
|
|
}, 2000);
|
|
}
|
|
|
|
btnDrop.onclick = () => {
|
|
isDropped = true;
|
|
btnDrop.disabled = true;
|
|
|
|
// Move pieces to above the mold
|
|
const data = getLevelData(currentLevel);
|
|
const moldCenter = { x: 0, y: 0 };
|
|
data.mold.forEach(v => { moldCenter.x += v.x; moldCenter.y += v.y; });
|
|
moldCenter.x /= data.mold.length;
|
|
moldCenter.y /= data.mold.length;
|
|
|
|
const riceCenter = data.rice;
|
|
|
|
riceBodies.forEach(b => {
|
|
const dx = b.position.x - riceCenter.x;
|
|
const dy = b.position.y - riceCenter.y;
|
|
Body.setPosition(b, { x: moldCenter.x + dx, y: moldCenter.y - 200 + dy });
|
|
});
|
|
|
|
engine.world.gravity.y = config.gravity;
|
|
};
|
|
|
|
btnReset.onclick = () => init();
|
|
btnHint.onclick = () => showToast("Slice manually then DROP!");
|
|
|
|
// Prevent scrolling on touch
|
|
document.body.addEventListener("touchstart", (e) => { if (e.target == canvas) e.preventDefault(); }, { passive: false });
|
|
document.body.addEventListener("touchend", (e) => { if (e.target == canvas) e.preventDefault(); }, { passive: false });
|
|
document.body.addEventListener("touchmove", (e) => { if (e.target == canvas) e.preventDefault(); }, { passive: false });
|
|
|
|
init();
|
|
}); |