Add Physilinks prototype with scoring and game over
This commit is contained in:
413
main.js
Normal file
413
main.js
Normal file
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user