590 lines
16 KiB
JavaScript
590 lines
16 KiB
JavaScript
(() => {
|
|
const {
|
|
Engine,
|
|
Render,
|
|
Runner,
|
|
World,
|
|
Bodies,
|
|
Constraint,
|
|
Events,
|
|
Query,
|
|
Vector,
|
|
} = Matter;
|
|
|
|
const config = {
|
|
gravity: 1,
|
|
spawnIntervalMs: 520,
|
|
minChain: 3,
|
|
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
|
|
ballRadius: 18,
|
|
};
|
|
|
|
const scenes = [
|
|
{
|
|
id: "scene1",
|
|
name: "Balanced (default)",
|
|
config: {
|
|
gravity: 1,
|
|
spawnIntervalMs: 520,
|
|
minChain: 3,
|
|
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
|
|
ballRadius: 18,
|
|
},
|
|
createBodies: (w, h) => [
|
|
Bodies.rectangle(w / 2, h + 40, w, 80, {
|
|
isStatic: true,
|
|
restitution: 0.8,
|
|
}),
|
|
Bodies.rectangle(-40, h / 2, 80, h * 2, { isStatic: true }),
|
|
Bodies.rectangle(w + 40, h / 2, 80, h * 2, { isStatic: true }),
|
|
Bodies.rectangle(w * 0.25, h * 0.55, 160, 20, {
|
|
isStatic: true,
|
|
angle: -0.3,
|
|
}),
|
|
Bodies.rectangle(w * 0.7, h * 0.4, 220, 24, {
|
|
isStatic: true,
|
|
angle: 0.26,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: "scene2",
|
|
name: "Low-G terraces",
|
|
config: {
|
|
gravity: 0.65,
|
|
spawnIntervalMs: 600,
|
|
minChain: 3,
|
|
palette: ["#fb7185", "#fbbf24", "#34d399", "#38bdf8"],
|
|
ballRadius: 22,
|
|
},
|
|
createBodies: (w, h) => [
|
|
Bodies.rectangle(w / 2, h + 50, w, 100, {
|
|
isStatic: true,
|
|
restitution: 0.9,
|
|
}),
|
|
Bodies.rectangle(-50, h / 2, 100, h * 2, { isStatic: true }),
|
|
Bodies.rectangle(w + 50, h / 2, 100, h * 2, { isStatic: true }),
|
|
Bodies.rectangle(w * 0.2, h * 0.45, 200, 18, {
|
|
isStatic: true,
|
|
angle: 0.08,
|
|
}),
|
|
Bodies.rectangle(w * 0.5, h * 0.6, 260, 18, {
|
|
isStatic: true,
|
|
angle: -0.04,
|
|
}),
|
|
Bodies.rectangle(w * 0.8, h * 0.42, 180, 18, {
|
|
isStatic: true,
|
|
angle: 0.14,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: "scene3",
|
|
name: "Fast drop maze",
|
|
config: {
|
|
gravity: 1.25,
|
|
spawnIntervalMs: 420,
|
|
minChain: 3,
|
|
palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"],
|
|
ballRadius: 16,
|
|
},
|
|
createBodies: (w, h) => {
|
|
const bodies = [
|
|
Bodies.rectangle(w / 2, h + 40, w, 80, {
|
|
isStatic: true,
|
|
restitution: 0.75,
|
|
}),
|
|
Bodies.rectangle(-40, h / 2, 80, h * 2, { isStatic: true }),
|
|
Bodies.rectangle(w + 40, h / 2, 80, h * 2, { isStatic: true }),
|
|
];
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const x = (w * (i + 1)) / 6;
|
|
const y = h * 0.35 + (i % 2 === 0 ? 40 : -30);
|
|
bodies.push(
|
|
Bodies.circle(x, y, 18, { isStatic: true, restitution: 0.9 }),
|
|
);
|
|
}
|
|
bodies.push(
|
|
Bodies.rectangle(w * 0.3, h * 0.55, 140, 16, {
|
|
isStatic: true,
|
|
angle: -0.3,
|
|
}),
|
|
Bodies.rectangle(w * 0.7, h * 0.58, 160, 16, {
|
|
isStatic: true,
|
|
angle: 0.28,
|
|
}),
|
|
);
|
|
return bodies;
|
|
},
|
|
},
|
|
];
|
|
|
|
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 highScoreEl = document.getElementById("high-score");
|
|
const gameOverEl = document.getElementById("game-over");
|
|
const finalScoreEl = document.getElementById("final-score");
|
|
const restartBtn = document.getElementById("restart-btn");
|
|
const pauseBtn = document.getElementById("pause-btn");
|
|
const pauseOverlay = document.getElementById("pause-overlay");
|
|
|
|
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 scene-specific obstacles.
|
|
let boundaries = [];
|
|
let currentScene = scenes[0];
|
|
|
|
const rebuildSceneBodies = () => {
|
|
boundaries.forEach((b) => World.remove(world, b));
|
|
boundaries = currentScene.createBodies(width, height);
|
|
World.add(world, boundaries);
|
|
};
|
|
rebuildSceneBodies();
|
|
|
|
const balls = [];
|
|
let spawnTimer = null;
|
|
let score = 0;
|
|
let highScore = 0;
|
|
let gameOver = false;
|
|
let isPaused = false;
|
|
|
|
const STORAGE_KEY = "physilinks-highscore";
|
|
|
|
const loadHighScore = () => {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
const parsed = parseInt(raw, 10);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
} catch (err) {
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
const saveHighScore = () => {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, String(highScore));
|
|
} catch (err) {
|
|
// ignore write failures (private mode or blocked storage)
|
|
}
|
|
};
|
|
|
|
const populateSceneSelect = () => {
|
|
sceneSelectEl.innerHTML = "";
|
|
scenes.forEach((scene) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = scene.id;
|
|
opt.textContent = scene.name;
|
|
sceneSelectEl.appendChild(opt);
|
|
});
|
|
};
|
|
|
|
const applyScene = (sceneId) => {
|
|
const next = scenes.find((s) => s.id === sceneId) || scenes[0];
|
|
currentScene = next;
|
|
sceneSelectEl.value = next.id;
|
|
Object.assign(config, next.config);
|
|
engine.gravity.y = config.gravity;
|
|
rebuildSceneBodies();
|
|
buildLegend();
|
|
restartGame();
|
|
updateHud();
|
|
};
|
|
|
|
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 stopSpawner = () => {
|
|
if (spawnTimer) {
|
|
clearInterval(spawnTimer);
|
|
spawnTimer = null;
|
|
}
|
|
};
|
|
|
|
const cleanupBall = (ball) => {
|
|
if (ball.plugin && ball.plugin.entryCheckId) {
|
|
clearTimeout(ball.plugin.entryCheckId);
|
|
ball.plugin.entryCheckId = null;
|
|
}
|
|
};
|
|
|
|
const triggerGameOver = () => {
|
|
if (gameOver) return;
|
|
gameOver = true;
|
|
isPaused = false;
|
|
resetChainVisuals();
|
|
stopSpawner();
|
|
Runner.stop(runner);
|
|
pauseOverlay.classList.remove("visible");
|
|
pauseBtn.textContent = "Pause";
|
|
finalScoreEl.textContent = score;
|
|
gameOverEl.classList.add("visible");
|
|
};
|
|
|
|
const restartGame = () => {
|
|
gameOver = false;
|
|
isPaused = false;
|
|
score = 0;
|
|
resetChainVisuals();
|
|
balls.forEach((ball) => {
|
|
cleanupBall(ball);
|
|
World.remove(world, ball);
|
|
});
|
|
balls.length = 0;
|
|
gameOverEl.classList.remove("visible");
|
|
pauseOverlay.classList.remove("visible");
|
|
pauseBtn.textContent = "Pause";
|
|
updateHud();
|
|
Runner.run(runner, engine);
|
|
startSpawner();
|
|
};
|
|
|
|
const setHighlight = (body, on) => {
|
|
body.render.lineWidth = on ? 4 : 2;
|
|
body.render.strokeStyle = on ? "#f8fafc" : "#0b1222";
|
|
};
|
|
|
|
const setPaused = (state) => {
|
|
if (gameOver) return;
|
|
if (state === isPaused) return;
|
|
isPaused = state;
|
|
pauseBtn.textContent = isPaused ? "Resume" : "Pause";
|
|
pauseOverlay.classList.toggle("visible", isPaused);
|
|
if (isPaused) {
|
|
resetChainVisuals();
|
|
stopSpawner();
|
|
Runner.stop(runner);
|
|
} else {
|
|
startSpawner();
|
|
Runner.run(runner, engine);
|
|
}
|
|
};
|
|
|
|
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 || isPaused) return;
|
|
if (chain.bodies.length >= config.minChain) {
|
|
const gain = 10 * Math.pow(chain.bodies.length, 2);
|
|
score += gain;
|
|
if (score > highScore) {
|
|
highScore = score;
|
|
saveHighScore();
|
|
}
|
|
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 || isPaused) 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 || isPaused) 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;
|
|
highScoreEl.textContent = highScore;
|
|
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 },
|
|
});
|
|
rebuildSceneBodies();
|
|
};
|
|
|
|
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);
|
|
pauseBtn.addEventListener("click", () => setPaused(!isPaused));
|
|
window.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") {
|
|
setPaused(!isPaused);
|
|
}
|
|
});
|
|
sceneSelectEl.addEventListener("change", (e) => applyScene(e.target.value));
|
|
populateSceneSelect();
|
|
sceneSelectEl.value = currentScene.id;
|
|
highScore = loadHighScore();
|
|
buildLegend();
|
|
updateHud();
|
|
startSpawner();
|
|
})();
|