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(); });