Add Physilinks prototype with scoring and game over

This commit is contained in:
Daddy32
2025-12-12 09:30:32 +01:00
commit 0547578f9a
2 changed files with 718 additions and 0 deletions

305
index.html Normal file
View File

@@ -0,0 +1,305 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Physilinks</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--accent: #22d3ee;
--text: #e2e8f0;
--muted: #94a3b8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
"Manrope",
system-ui,
-apple-system,
sans-serif;
background:
radial-gradient(
circle at 25% 20%,
rgba(56, 189, 248, 0.12),
transparent 25%
),
radial-gradient(
circle at 80% 10%,
rgba(167, 139, 250, 0.16),
transparent 30%
),
radial-gradient(
circle at 40% 80%,
rgba(52, 211, 153, 0.12),
transparent 25%
),
var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.shell {
width: min(1100px, 100%);
background: rgba(17, 24, 39, 0.72);
border: 1px solid rgba(148, 163, 184, 0.1);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
border-radius: 18px;
overflow: hidden;
position: relative;
backdrop-filter: blur(10px);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
header h1 {
font-size: 20px;
margin: 0;
letter-spacing: 0.5px;
}
header .meta {
display: flex;
gap: 14px;
align-items: center;
font-size: 13px;
color: var(--muted);
}
header .pill {
padding: 6px 10px;
border-radius: 8px;
background: rgba(34, 211, 238, 0.12);
color: #67e8f9;
border: 1px solid rgba(34, 211, 238, 0.35);
font-weight: 700;
font-size: 12px;
letter-spacing: 0.6px;
text-transform: uppercase;
}
#scene-wrapper {
position: relative;
width: 100%;
height: 680px;
background:
radial-gradient(
circle at 30% 30%,
rgba(255, 255, 255, 0.02),
transparent 40%
),
radial-gradient(
circle at 75% 60%,
rgba(255, 255, 255, 0.02),
transparent 45%
),
#0b1222;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.hud-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.03);
border-top: 1px solid rgba(148, 163, 184, 0.08);
}
.hud-bar .card {
background: rgba(255, 255, 255, 0.04);
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 10px;
font-size: 13px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.hud-bar .card strong {
color: var(--text);
font-size: 13px;
}
.legend {
position: absolute;
top: 16px;
right: 16px;
background: rgba(255, 255, 255, 0.04);
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
pointer-events: none;
}
.legend span {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.12);
display: inline-block;
}
.game-over {
position: absolute;
inset: 0;
background: rgba(10, 13, 25, 0.72);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease;
}
.game-over.visible {
pointer-events: auto;
opacity: 1;
}
.game-over__card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
padding: 20px 24px;
text-align: center;
min-width: 240px;
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35);
}
.game-over__card .title {
font-size: 22px;
font-weight: 700;
margin-bottom: 10px;
}
.game-over__card .score-line {
font-size: 14px;
color: var(--muted);
margin-bottom: 14px;
}
.game-over__card button {
background: linear-gradient(135deg, #22d3ee, #0ea5e9);
border: none;
color: #0b1222;
font-weight: 700;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
}
.game-over__card button:hover {
filter: brightness(1.08);
}
.floating-score {
position: absolute;
color: #e0f2fe;
font-weight: 800;
font-size: 18px;
pointer-events: none;
text-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
animation: floatUp 900ms ease-out forwards;
}
@keyframes floatUp {
0% {
opacity: 1;
transform: translate(-50%, 0);
}
60% {
opacity: 0.9;
transform: translate(-50%, -22px);
}
100% {
opacity: 0;
transform: translate(-50%, -38px);
}
}
.instructions {
padding: 14px 18px;
font-size: 14px;
color: var(--muted);
background: rgba(255, 255, 255, 0.03);
border-top: 1px solid rgba(148, 163, 184, 0.08);
line-height: 1.6;
}
@media (max-width: 800px) {
#scene-wrapper {
height: 520px;
}
header h1 {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="shell">
<header>
<h1>Physilinks</h1>
<div class="meta">
<span class="pill">Physics</span>
<span>Link same-colored balls to clear them.</span>
</div>
</header>
<div id="scene-wrapper">
<div class="legend" id="palette-legend"></div>
<div class="game-over" id="game-over">
<div class="game-over__card">
<div class="title">Game Over</div>
<div class="score-line">
Final score: <span id="final-score">0</span>
</div>
<button id="restart-btn">Restart</button>
</div>
</div>
</div>
<div class="hud-bar">
<div class="card">
<strong>Spawn</strong> <span id="spawn-rate"></span>
</div>
<div class="card">
<strong>Min link</strong> <span id="min-link"></span>
</div>
<div class="card">
<strong>Active color</strong>
<span id="active-color"></span>
</div>
<div class="card">
<strong>Chain length</strong>
<span id="chain-length">0</span>
</div>
<div class="card">
<strong>Score</strong> <span id="score">0</span>
</div>
</div>
<div class="instructions">
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.
</div>
</div>
<script src="https://unpkg.com/matter-js@0.19.0/build/matter.min.js"></script>
<script src="main.js"></script>
</body>
</html>

413
main.js Normal file
View 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();
})();