commit 0547578f9a8d0d7e93b660baeca85d34e715da0a Author: Daddy32 Date: Fri Dec 12 09:30:32 2025 +0100 Add Physilinks prototype with scoring and game over diff --git a/index.html b/index.html new file mode 100644 index 0000000..cd8ca7b --- /dev/null +++ b/index.html @@ -0,0 +1,305 @@ + + + + + + Physilinks + + + + + + +
+
+

Physilinks

+
+ Physics + Link same-colored balls to clear them. +
+
+
+
+
+
+
Game Over
+
+ Final score: 0 +
+ +
+
+
+
+
+ Spawn +
+
+ Min link +
+
+ Active color + +
+
+ Chain length + 0 +
+
+ Score 0 +
+
+
+ Click or touch a ball to start a chain. Drag through balls of + the same color to link them together. Drag back to the previous + ball to undo the last link. Release to finish: chains of fewer + than the minimum vanish; chains with enough balls clear all + linked balls (score: 10 × length²). If the entry gets blocked + and a new ball cannot drop in, the run ends—restart to try + again. +
+
+ + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..67e796b --- /dev/null +++ b/main.js @@ -0,0 +1,413 @@ +(() => { + const { + Engine, + Render, + Runner, + World, + Bodies, + Constraint, + Events, + Query, + Vector, + } = Matter; + + const config = { + gravity: 0.78, + spawnIntervalMs: 720, + minChain: 3, + palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], + ballRadius: 48, + }; + + const sceneEl = document.getElementById("scene-wrapper"); + const activeColorEl = document.getElementById("active-color"); + const chainLenEl = document.getElementById("chain-length"); + const spawnRateEl = document.getElementById("spawn-rate"); + const minLinkEl = document.getElementById("min-link"); + const paletteLegendEl = document.getElementById("palette-legend"); + const scoreEl = document.getElementById("score"); + const gameOverEl = document.getElementById("game-over"); + const finalScoreEl = document.getElementById("final-score"); + const restartBtn = document.getElementById("restart-btn"); + + let width = sceneEl.clientWidth; + let height = sceneEl.clientHeight; + + const engine = Engine.create(); + engine.gravity.y = config.gravity; + const world = engine.world; + + const render = Render.create({ + element: sceneEl, + engine, + options: { + width, + height, + wireframes: false, + background: "transparent", + showAngleIndicator: false, + pixelRatio: window.devicePixelRatio || 1, + }, + }); + Render.run(render); + + const runner = Runner.create(); + Runner.run(runner, engine); + + // Static boundaries and some obstacles to bounce around. + let boundaries = []; + const createBounds = () => { + boundaries.forEach((b) => World.remove(world, b)); + boundaries = [ + Bodies.rectangle(width / 2, height + 40, width, 80, { + isStatic: true, + restitution: 0.8, + }), + Bodies.rectangle(-40, height / 2, 80, height * 2, { isStatic: true }), + Bodies.rectangle(width + 40, height / 2, 80, height * 2, { + isStatic: true, + }), + ]; + boundaries.push( + Bodies.rectangle(width * 0.25, height * 0.55, 160, 20, { + isStatic: true, + angle: -0.3, + }), + Bodies.rectangle(width * 0.7, height * 0.4, 220, 24, { + isStatic: true, + angle: 0.26, + }), + ); + World.add(world, boundaries); + }; + createBounds(); + + const balls = []; + let spawnTimer = null; + let score = 0; + let gameOver = false; + + const chain = { + active: false, + color: null, + bodies: [], + constraints: [], + pointer: null, + }; + + const spawnBall = () => { + if (gameOver) return; + const color = + config.palette[Math.floor(Math.random() * config.palette.length)]; + const x = Math.max( + config.ballRadius + 10, + Math.min(width - config.ballRadius - 10, Math.random() * width), + ); + const y = -config.ballRadius * 2; + const ball = Bodies.circle(x, y, config.ballRadius, { + restitution: 0.72, + friction: 0.01, + frictionAir: 0.015, + render: { + fillStyle: color, + strokeStyle: "#0b1222", + lineWidth: 2, + }, + }); + ball.plugin = { color, hasEntered: false, entryCheckId: null }; + balls.push(ball); + World.add(world, ball); + ball.plugin.entryCheckId = setTimeout(() => { + ball.plugin.entryCheckId = null; + if (gameOver) return; + if (!ball.plugin.hasEntered && Math.abs(ball.velocity.y) < 0.2) { + triggerGameOver(); + } + }, 1500); + }; + + const startSpawner = () => { + if (spawnTimer) clearInterval(spawnTimer); + spawnTimer = setInterval(spawnBall, config.spawnIntervalMs); + }; + + const cleanupBall = (ball) => { + if (ball.plugin && ball.plugin.entryCheckId) { + clearTimeout(ball.plugin.entryCheckId); + ball.plugin.entryCheckId = null; + } + }; + + const triggerGameOver = () => { + if (gameOver) return; + gameOver = true; + resetChainVisuals(); + if (spawnTimer) clearInterval(spawnTimer); + finalScoreEl.textContent = score; + gameOverEl.classList.add("visible"); + }; + + const restartGame = () => { + gameOver = false; + score = 0; + resetChainVisuals(); + balls.forEach((ball) => { + cleanupBall(ball); + World.remove(world, ball); + }); + balls.length = 0; + gameOverEl.classList.remove("visible"); + updateHud(); + startSpawner(); + }; + + const setHighlight = (body, on) => { + body.render.lineWidth = on ? 4 : 2; + body.render.strokeStyle = on ? "#f8fafc" : "#0b1222"; + }; + + const resetChainVisuals = () => { + chain.bodies.forEach((b) => setHighlight(b, false)); + chain.constraints.forEach((c) => World.remove(world, c)); + chain.active = false; + chain.color = null; + chain.bodies = []; + chain.constraints = []; + chain.pointer = null; + updateHud(); + }; + + const removeLastFromChain = () => { + const removedConstraint = chain.constraints.pop(); + if (removedConstraint) { + World.remove(world, removedConstraint); + } + const removedBody = chain.bodies.pop(); + if (removedBody) setHighlight(removedBody, false); + updateHud(); + }; + + const addToChain = (body) => { + const last = chain.bodies[chain.bodies.length - 1]; + const dist = Vector.magnitude(Vector.sub(last.position, body.position)); + const constraint = Constraint.create({ + bodyA: last, + bodyB: body, + length: dist, + stiffness: 0.9, + render: { + strokeStyle: chain.color, + lineWidth: 3, + }, + }); + chain.constraints.push(constraint); + chain.bodies.push(body); + setHighlight(body, true); + World.add(world, constraint); + updateHud(); + }; + + const spawnScorePopup = (point, amount, color) => { + if (!point) return; + const el = document.createElement("div"); + el.className = "floating-score"; + el.textContent = `+${amount}`; + el.style.left = `${point.x}px`; + el.style.top = `${point.y}px`; + el.style.color = color || "#e0f2fe"; + sceneEl.appendChild(el); + setTimeout(() => el.remove(), 950); + }; + + const finishChain = (releasePoint) => { + if (!chain.active || gameOver) return; + if (chain.bodies.length >= config.minChain) { + const gain = 10 * Math.pow(chain.bodies.length, 2); + score += gain; + spawnScorePopup(releasePoint || chain.pointer, gain, chain.color); + chain.constraints.forEach((c) => World.remove(world, c)); + chain.bodies.forEach((body) => { + cleanupBall(body); + World.remove(world, body); + }); + // Remove cleared balls from tracking list. + for (let i = balls.length - 1; i >= 0; i -= 1) { + if (chain.bodies.includes(balls[i])) { + balls.splice(i, 1); + } + } + } else { + chain.constraints.forEach((c) => World.remove(world, c)); + chain.bodies.forEach((b) => setHighlight(b, false)); + } + chain.active = false; + chain.color = null; + chain.bodies = []; + chain.constraints = []; + chain.pointer = null; + updateHud(); + }; + + const pickBody = (point) => { + const hits = Query.point(balls, point); + return hits[0]; + }; + + const getPointerPosition = (evt) => { + const rect = render.canvas.getBoundingClientRect(); + const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX; + const clientY = evt.touches ? evt.touches[0].clientY : evt.clientY; + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + const handlePointerDown = (evt) => { + if (gameOver) return; + const point = getPointerPosition(evt); + const body = pickBody(point); + if (!body) return; + chain.active = true; + chain.color = body.plugin.color; + chain.bodies = [body]; + chain.constraints = []; + chain.pointer = point; + setHighlight(body, true); + updateHud(); + }; + + const handlePointerMove = (evt) => { + if (!chain.active) return; + if (gameOver) return; + const point = getPointerPosition(evt); + chain.pointer = point; + const body = pickBody(point); + if (!body) return; + const alreadyInChain = chain.bodies.includes(body); + if (alreadyInChain) { + const targetIndex = chain.bodies.indexOf(body); + if (chain.bodies.length > 1 && targetIndex === chain.bodies.length - 2) { + removeLastFromChain(); + } + return; + } + if (body.plugin.color !== chain.color) return; + addToChain(body); + }; + + const handlePointerUp = () => finishChain(chain.pointer); + + render.canvas.addEventListener("mousedown", handlePointerDown); + render.canvas.addEventListener("mousemove", handlePointerMove); + window.addEventListener("mouseup", handlePointerUp); + render.canvas.addEventListener( + "touchstart", + (e) => { + e.preventDefault(); + handlePointerDown(e); + }, + { passive: false }, + ); + render.canvas.addEventListener( + "touchmove", + (e) => { + e.preventDefault(); + handlePointerMove(e); + }, + { passive: false }, + ); + render.canvas.addEventListener( + "touchend", + (e) => { + e.preventDefault(); + handlePointerUp(e); + }, + { passive: false }, + ); + + const updateHud = () => { + spawnRateEl.textContent = `${config.spawnIntervalMs} ms`; + minLinkEl.textContent = config.minChain; + chainLenEl.textContent = chain.bodies.length; + scoreEl.textContent = score; + if (chain.color) { + activeColorEl.textContent = ""; + activeColorEl.style.display = "inline-block"; + activeColorEl.style.width = "14px"; + activeColorEl.style.height = "14px"; + activeColorEl.style.borderRadius = "50%"; + activeColorEl.style.background = chain.color; + activeColorEl.style.border = "1px solid rgba(255,255,255,0.3)"; + } else { + activeColorEl.removeAttribute("style"); + activeColorEl.textContent = "—"; + } + }; + + const buildLegend = () => { + paletteLegendEl.innerHTML = ""; + config.palette.forEach((color) => { + const swatch = document.createElement("span"); + swatch.style.background = color; + paletteLegendEl.appendChild(swatch); + }); + }; + + const handleResize = () => { + width = sceneEl.clientWidth; + height = sceneEl.clientHeight; + render.canvas.width = width; + render.canvas.height = height; + render.options.width = width; + render.options.height = height; + Render.lookAt(render, { + min: { x: 0, y: 0 }, + max: { x: width, y: height }, + }); + createBounds(); + }; + + Events.on(engine, "afterUpdate", () => { + // Keep stray balls within the play area horizontally. + balls.forEach((ball) => { + if ( + !ball.plugin.hasEntered && + ball.position.y > config.ballRadius * 1.5 + ) { + ball.plugin.hasEntered = true; + } + if ( + ball.position.x < -100 || + ball.position.x > width + 100 || + ball.position.y > height + 500 + ) { + cleanupBall(ball); + ball.plugin.hasEntered = true; + Matter.Body.setPosition(ball, { x: Math.random() * width, y: -40 }); + Matter.Body.setVelocity(ball, { x: 0, y: 0 }); + } + }); + }); + + Events.on(render, "afterRender", () => { + if (!chain.active || !chain.pointer || chain.bodies.length === 0) return; + const last = chain.bodies[chain.bodies.length - 1]; + const ctx = render.context; + ctx.save(); + ctx.strokeStyle = chain.color || "#fff"; + ctx.lineWidth = 3; + ctx.setLineDash([6, 6]); + ctx.beginPath(); + ctx.moveTo(last.position.x, last.position.y); + ctx.lineTo(chain.pointer.x, chain.pointer.y); + ctx.stroke(); + ctx.restore(); + }); + + window.addEventListener("resize", handleResize); + restartBtn.addEventListener("click", restartGame); + buildLegend(); + updateHud(); + startSpawner(); +})();