Add Physilinks prototype with scoring and game over
This commit is contained in:
305
index.html
Normal file
305
index.html
Normal 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
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