Files
Physilinks/main.js
2025-12-12 19:32:42 +01:00

706 lines
20 KiB
JavaScript

(() => {
const {
Engine,
Render,
Runner,
World,
Body,
Bodies,
Constraint,
Events,
Query,
Vector,
} = Matter;
const config = {
gravity: 1,
spawnIntervalMs: 520,
minChain: 3,
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
ballRadius: 18,
link: {
stiffness: 0.85,
lengthScale: 1.05, // max stretch factor; slack below this
damping: 0.08,
lineWidth: 3,
rope: true,
renderType: "line",
maxLengthMultiplier: 3.1,
},
};
const scenes = [
{
id: "scene1",
name: "Balanced (default)",
config: {
gravity: 0.88,
spawnIntervalMs: 520,
minChain: 3,
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
ballRadius: 38,
link: {
stiffness: 0.85,
lengthScale: 1.05,
damping: 0.08,
lineWidth: 3,
rope: true,
renderType: "line",
maxLengthMultiplier: 3.2,
},
},
createBodies: (w, h) => [
Bodies.rectangle(w / 2, h + 40, w, 80, {
isStatic: true,
restitution: 0.8,
render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
}),
Bodies.rectangle(-40, h / 2, 80, h * 2, {
isStatic: true,
render: { fillStyle: "#f97316", strokeStyle: "#f97316" },
}),
Bodies.rectangle(w + 40, h / 2, 80, h * 2, {
isStatic: true,
render: { fillStyle: "#f97316", strokeStyle: "#f97316" },
}),
Bodies.rectangle(w * 0.25, h * 0.55, 160, 20, {
isStatic: true,
angle: -0.3,
render: { fillStyle: "#22c55e", strokeStyle: "#22c55e" },
plugin: { rotSpeed: 0.1 },
}),
Bodies.rectangle(w * 0.7, h * 0.4, 220, 24, {
isStatic: true,
angle: 0.26,
render: { fillStyle: "#a855f7", strokeStyle: "#a855f7" },
plugin: { rotSpeed: -0.08 },
}),
],
},
{
id: "scene2",
name: "Low-G terraces",
config: {
gravity: 0.65,
spawnIntervalMs: 600,
minChain: 3,
palette: ["#fb7185", "#fbbf24", "#34d399", "#38bdf8"],
ballRadius: 22,
link: {
stiffness: 0.6,
lengthScale: 1,
damping: 0.01,
lineWidth: 4,
rope: false,
renderType: "spring",
maxLengthMultiplier: 6.7,
},
},
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,
link: {
stiffness: 1,
lengthScale: 0.85,
damping: 0.15,
lineWidth: 3,
rope: false,
renderType: "line",
maxLengthMultiplier: 16.8,
},
},
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 sceneSelectEl = document.getElementById("scene-select");
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 rotators = [];
let currentScene = scenes[0];
const rebuildSceneBodies = () => {
boundaries.forEach((b) => World.remove(world, b));
boundaries = currentScene.createBodies(width, height);
rotators = boundaries.filter((b) => b.plugin && b.plugin.rotSpeed);
World.add(world, boundaries);
};
const balls = [];
let spawnTimer = null;
let score = 0;
let highScore = 0;
let gameOver = false;
let isPaused = false;
const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`;
const loadHighScore = (sceneId) => {
try {
const raw = localStorage.getItem(makeStorageKey(sceneId));
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : 0;
} catch (err) {
return 0;
}
};
const saveHighScore = () => {
try {
localStorage.setItem(makeStorageKey(currentScene.id), String(highScore));
} catch (err) {
// ignore write failures (private mode or blocked storage)
}
};
const populateSceneSelect = () => {
if (!sceneSelectEl) return;
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;
if (sceneSelectEl) sceneSelectEl.value = next.id;
Object.assign(config, next.config);
config.link = { ...next.config.link };
engine.gravity.y = config.gravity;
highScore = loadHighScore(next.id);
rebuildSceneBodies();
buildLegend();
restartGame();
updateHud();
};
const chain = {
active: false,
color: null,
bodies: [],
constraints: [],
pointer: null,
};
const getMaxLinkDistance = () => {
const linkCfg = config.link || {};
if (Number.isFinite(linkCfg.maxLinkLength)) {
return linkCfg.maxLinkLength;
}
const mult = linkCfg.maxLengthMultiplier ?? 3;
return mult * config.ballRadius;
};
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();
engine.timing.timeScale = 0;
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";
engine.timing.timeScale = 1;
updateHud();
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();
engine.timing.timeScale = 0;
} else {
startSpawner();
engine.timing.timeScale = 1;
}
};
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 linkCfg = config.link || {};
const constraint = Constraint.create({
bodyA: last,
bodyB: body,
length: dist * (linkCfg.lengthScale ?? 1),
stiffness: linkCfg.stiffness ?? 0.9,
damping: linkCfg.damping ?? 0,
render: {
strokeStyle: chain.color,
lineWidth: linkCfg.lineWidth ?? 3,
type: linkCfg.renderType || "line",
},
});
constraint.plugin = {
restLength: dist * (linkCfg.lengthScale ?? 1),
rope: linkCfg.rope ?? false,
baseStiffness: linkCfg.stiffness ?? 0.9,
maxLength: dist * (linkCfg.lengthScale ?? 1),
};
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;
const maxLinkDist = getMaxLinkDistance();
const dist = Vector.magnitude(
Vector.sub(chain.bodies[chain.bodies.length - 1].position, body.position),
);
if (dist > maxLinkDist) 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(engine, "beforeUpdate", () => {
// Rope-like constraint handling: allow shortening without push-back, tension when stretched.
chain.constraints.forEach((c) => {
if (!c.plugin || !c.plugin.rope) return;
const current = Vector.magnitude(
Vector.sub(c.bodyA.position, c.bodyB.position),
);
const maxLen = c.plugin.maxLength ?? c.length;
if (current <= maxLen) {
c.length = current;
c.stiffness = 0;
} else {
c.length = maxLen;
c.stiffness = c.plugin.baseStiffness ?? c.stiffness;
}
});
// Rotate any scene rotators slowly.
const dt = (engine.timing && engine.timing.delta) || 16;
rotators.forEach((b) => {
const speed = b.plugin.rotSpeed || 0;
if (speed !== 0) {
Body.rotate(b, speed * (dt / 1000));
}
});
});
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;
const maxLinkDist = getMaxLinkDistance();
const delta = Vector.sub(chain.pointer, last.position);
const dist = Vector.magnitude(delta);
// Pull back the preview line slightly so it does not suggest a link will land on a too-distant ball.
const previewMargin = config.ballRadius;
const cappedDist = Math.max(
0,
Math.min(dist, Math.max(0, maxLinkDist - previewMargin)),
);
const safeTarget =
dist > 0
? Vector.add(
last.position,
Vector.mult(Vector.normalise(delta), cappedDist),
)
: chain.pointer;
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(safeTarget.x, safeTarget.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);
}
});
if (sceneSelectEl) {
sceneSelectEl.addEventListener("change", (e) => applyScene(e.target.value));
}
populateSceneSelect();
applyScene(currentScene.id);
})();