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 ? '⭐' : '☆';
}
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();
});