Files
Physilinks/main.js
2025-12-13 20:31:40 +01:00

986 lines
29 KiB
JavaScript

(() => {
const {
Engine,
Render,
Runner,
World,
Body,
Bodies,
Constraint,
Composites,
Events,
Query,
Vector,
} = Matter;
const { scenes = [], defaultSceneId } = window.PhysilinksScenes || {};
const config = {
gravity: 1,
spawnIntervalMs: 520,
autoSpawn: true,
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 ui = window.PhysilinksUI.create();
const { sceneEl } = ui;
let width = sceneEl.clientWidth;
let height = sceneEl.clientHeight;
const BALL_BASELINE = 680; // reference height used for relative ball sizing
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);
let runnerActive = true;
const startRunner = () => {
if (!runnerActive) {
Runner.run(runner, engine);
runnerActive = true;
}
};
const stopRunner = () => {
if (runnerActive) {
Runner.stop(runner);
runnerActive = false;
}
};
// Static boundaries and scene-specific obstacles.
let boundaries = [];
let rotators = [];
let oscillators = [];
let currentScene =
scenes.find((s) => s.id === defaultSceneId) || scenes[0] || null;
if (currentScene && currentScene.config) {
Object.assign(config, currentScene.config);
config.link = { ...currentScene.config.link };
}
const rebuildSceneBodies = () => {
boundaries.forEach((b) => World.remove(world, b));
boundaries = currentScene.createBodies(width, height);
rotators = boundaries.filter((b) => b.plugin && b.plugin.rotSpeed);
oscillators = boundaries.filter((b) => b.plugin && b.plugin.oscillate);
World.add(world, boundaries);
};
const balls = [];
const blobConstraints = new Map();
let spawnTimer = null;
let score = 0;
let highScore = 0;
let clearedCount = 0;
let clearedByColor = {};
let gameOver = false;
let isPaused = false;
let levelWon = false;
let timerEndMs = null;
let lastTimerDisplay = null;
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 applyScene = (sceneId) => {
const next = scenes.find((s) => s.id === sceneId) || scenes[0];
currentScene = next;
ui.setSceneSelection(next.id);
const prevRadius = config.ballRadius;
Object.assign(config, next.config);
config.link = { ...next.config.link };
updateBallRadius(prevRadius);
engine.gravity.y = config.gravity;
clearedCount = 0;
levelWon = false;
clearedByColor = {};
highScore = loadHighScore(next.id);
rebuildSceneBodies();
buildLegend();
restartGame();
updateHud();
};
const getNextSceneId = () => {
const order = window.PhysilinksScenes?.order || [];
const currentId = currentScene?.id;
const orderedExisting = order.filter((id) =>
scenes.some((s) => s.id === id),
);
if (orderedExisting.length > 0 && currentId) {
const idx = orderedExisting.indexOf(currentId);
if (idx >= 0 && idx < orderedExisting.length - 1) {
return orderedExisting[idx + 1];
}
if (idx === orderedExisting.length - 1) {
return orderedExisting[0];
}
}
const fallbackIdx = scenes.findIndex((s) => s.id === currentId);
if (fallbackIdx >= 0 && fallbackIdx < scenes.length - 1) {
return scenes[fallbackIdx + 1].id;
}
return scenes[0]?.id || defaultSceneId;
};
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 isGridScene = () =>
currentScene && currentScene.config && currentScene.config.gridLayouts;
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 spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
const y = spawnFromBottom
? height + config.ballRadius * 2
: -config.ballRadius * 2;
const batchMin = currentScene?.config?.spawnBatchMin ?? 1;
const batchMax = currentScene?.config?.spawnBatchMax ?? 1;
const batchCount =
batchMin === batchMax
? batchMin
: Math.max(
batchMin,
Math.floor(Math.random() * (batchMax - batchMin + 1)) + batchMin,
);
for (let i = 0; i < batchCount; i += 1) {
const blob = createBallBodies(
Math.min(
Math.max(
config.ballRadius + 10,
x + (i - batchCount / 2) * config.ballRadius * 1.5,
),
width - config.ballRadius - 10,
),
y +
i *
(spawnFromBottom
? -config.ballRadius * 0.5
: config.ballRadius * 0.5),
color,
);
if (blob.constraints.length > 0 && blob.blobId) {
blobConstraints.set(blob.blobId, blob.constraints);
}
blob.bodies.forEach((body) => {
balls.push(body);
World.add(world, body);
if (!currentScene?.config?.noGameOver) {
body.plugin.entryCheckId = setTimeout(() => {
body.plugin.entryCheckId = null;
if (gameOver) return;
if (!body.plugin.hasEntered && Math.abs(body.velocity.y) < 0.2) {
triggerGameOver();
}
}, 1500);
}
});
if (blob.constraints.length > 0) {
World.add(world, blob.constraints);
}
}
};
const startSpawner = () => {
if (isGridScene()) return;
if (currentScene?.config?.autoSpawn === false) return;
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 getGridDimensions = () => {
const padding = currentScene?.config?.gridPadding ?? 0.08;
const usableW = width * (1 - padding * 2);
const usableH = height * (1 - padding * 2);
const gridSize = Math.min(usableW, usableH);
const cellSize = gridSize / 8;
const startX = (width - gridSize) / 2 + cellSize / 2;
const startY = (height - gridSize) / 2 + cellSize / 2;
return { cellSize, startX, startY };
};
const getGridColor = (letter) => {
if (!letter || letter === "." || letter === " ") return null;
const legend = currentScene?.config?.gridLegend || {};
if (legend[letter]) return legend[letter];
const paletteToUse = currentScene?.config?.palette || config.palette;
const index =
(letter.toUpperCase().charCodeAt(0) - 65) % paletteToUse.length;
return paletteToUse[index];
};
const spawnGridBalls = () => {
const layouts = currentScene?.config?.gridLayouts || [];
if (!layouts.length) return;
const layout = layouts[Math.floor(Math.random() * layouts.length)];
if (!Array.isArray(layout)) return;
const { cellSize, startX, startY } = getGridDimensions();
const radius = computeBallRadius();
config.ballRadius = radius;
layout.forEach((row, rowIdx) => {
if (typeof row !== "string") return;
for (let colIdx = 0; colIdx < 8; colIdx += 1) {
const letter = row[colIdx] || ".";
const color = getGridColor(letter);
if (!color) continue;
const x = startX + colIdx * cellSize;
const y = startY + rowIdx * cellSize;
const ball = Bodies.circle(x, y, radius, {
restitution: 0.12,
friction: 0.01,
frictionAir: 0.01,
render: {
fillStyle: color,
strokeStyle: "#0b1222",
lineWidth: 2,
},
});
ball.plugin = { color, hasEntered: true, entryCheckId: null };
balls.push(ball);
World.add(world, ball);
}
});
};
const triggerGameOver = () => {
if (currentScene?.config?.noGameOver) return;
if (gameOver) return;
gameOver = true;
isPaused = false;
resetChainVisuals();
stopSpawner();
stopRunner();
engine.timing.timeScale = 0;
ui.setPauseState(false);
ui.showGameOver(score);
};
const restartGame = () => {
stopSpawner();
gameOver = false;
isPaused = false;
levelWon = false;
score = 0;
clearedCount = 0;
clearedByColor = {};
const winCond = currentScene?.config?.winCondition;
if (winCond?.type === "timer") {
const duration = winCond.durationSec ?? 120;
timerEndMs = Date.now() + duration * 1000;
lastTimerDisplay = null;
} else {
timerEndMs = null;
lastTimerDisplay = null;
}
resetChainVisuals();
balls.forEach((ball) => {
cleanupBall(ball);
World.remove(world, ball);
});
balls.length = 0;
ui.hideGameOver();
ui.hideWin();
ui.setPauseState(false);
engine.gravity.y = config.gravity;
engine.timing.timeScale = 1;
startRunner();
updateHud();
if (isGridScene()) {
spawnGridBalls();
} else {
startSpawner();
}
};
const setHighlight = (body, on) => {
body.render.lineWidth = on ? 4 : 2;
body.render.strokeStyle = on ? "#f8fafc" : "#0b1222";
};
const setPaused = (state) => {
if (gameOver || levelWon) return;
if (state === isPaused) return;
isPaused = state;
ui.setPauseState(isPaused);
if (isPaused) {
resetChainVisuals();
stopSpawner();
stopRunner();
engine.timing.timeScale = 0;
} else {
startRunner();
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 applyWinEffects = () => {
const winCond = currentScene?.config?.winCondition;
if (!winCond || !winCond.onWin) return;
if (typeof winCond.onWin.setGravity === "number") {
engine.gravity.y = winCond.onWin.setGravity;
}
if (winCond.onWin.shoveBalls) {
balls.forEach((ball) => {
const angle = Math.random() * Math.PI * 2;
const magnitude = 12 + Math.random() * 10;
const force = {
x: Math.cos(angle) * magnitude,
y: Math.sin(angle) * magnitude,
};
Body.applyForce(ball, ball.position, force);
});
}
if (winCond.onWin.swirlBalls) {
balls.forEach((ball) => {
const angle = Math.random() * Math.PI * 2;
const mag = 0.06;
Body.applyForce(ball, ball.position, {
x: Math.cos(angle) * mag,
y: -Math.abs(Math.sin(angle)) * mag * 1.5,
});
Body.setAngularVelocity(ball, (Math.random() - 0.5) * 0.3);
});
}
if (winCond.onWin.removeCurves) {
const remaining = [];
boundaries.forEach((b) => {
if (b.plugin && b.plugin.curve) {
World.remove(world, b);
} else {
remaining.push(b);
}
});
boundaries = remaining;
}
};
const checkWinCondition = () => {
if (levelWon) return;
const goal = getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 });
if (!goal || !goal.met) return;
applyWinEffects();
levelWon = true;
stopSpawner();
engine.timing.timeScale = 1;
startRunner();
ui.setPauseState(false);
ui.showWin(goal.label.replace("left", "done"));
};
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 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;
clearedCount += chain.bodies.length;
if (score > highScore) {
highScore = score;
saveHighScore();
}
ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color);
chain.constraints.forEach((c) => World.remove(world, c));
const blobIds = new Set();
chain.bodies.forEach((body) => {
if (body.plugin?.blobId) {
blobIds.add(body.plugin.blobId);
} else {
if (body.plugin?.color) {
const key = normalizeColor(body.plugin.color);
clearedByColor[key] = (clearedByColor[key] || 0) + 1;
}
cleanupBall(body);
World.remove(world, body);
}
});
blobIds.forEach((id) => {
balls
.filter((b) => b.plugin?.blobId === id)
.forEach((b) => {
if (b.plugin?.color) {
const key = normalizeColor(b.plugin.color);
clearedByColor[key] = (clearedByColor[key] || 0) + 1;
}
});
removeBlob(id);
});
// Remove cleared balls from tracking list.
for (let i = balls.length - 1; i >= 0; i -= 1) {
if (
chain.bodies.includes(balls[i]) ||
(balls[i].plugin?.blobId && blobIds.has(balls[i].plugin?.blobId))
) {
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();
checkWinCondition();
};
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 || levelWon) 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 || levelWon) 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 = () => {
ui.updateHud({
spawnIntervalMs: config.spawnIntervalMs,
minChain: config.minChain,
chainLength: chain.bodies.length,
score,
highScore,
activeColor: chain.color,
});
const goal = getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 });
};
const buildLegend = () => {
ui.buildLegend(config.palette);
};
const computeBallRadius = () => {
if (isGridScene()) {
const padding = currentScene?.config?.gridPadding ?? 0.08;
const usableW = width * (1 - padding * 2);
const usableH = height * (1 - padding * 2);
const gridSize = Math.min(usableW, usableH);
const cellSize = gridSize / 8;
const scale = currentScene?.config?.gridBallScale ?? 0.36;
const scaled = cellSize * scale;
return Math.round(Math.max(10, Math.min(60, scaled)));
}
const baseRadius =
(currentScene && currentScene.config && currentScene.config.ballRadius) ||
config.ballRadius ||
18;
const dim = Math.max(1, Math.min(width, height));
const scaled = (baseRadius * dim) / BALL_BASELINE;
return Math.round(Math.max(12, Math.min(52, scaled)));
};
const updateBallRadius = (prevRadius) => {
const nextRadius = computeBallRadius();
if (
Number.isFinite(prevRadius) &&
prevRadius > 0 &&
nextRadius !== prevRadius
) {
const scale = nextRadius / prevRadius;
balls.forEach((ball) => {
Body.scale(ball, scale, scale);
ball.circleRadius = nextRadius;
});
}
config.ballRadius = nextRadius;
};
const createBallBodies = (x, y, color) => {
const commonOpts = {
restitution: 0.72,
friction: 0.01,
frictionAir: 0.012,
render: {
fillStyle: color,
strokeStyle: "#0b1222",
lineWidth: 2,
},
};
if (currentScene?.config?.blobBalls) {
const cols = 3;
const rows = 2;
const radius = Math.max(10, config.ballRadius * 0.55);
const soft = Composites.softBody(
x - cols * radius * 1.2,
y - rows * radius * 1.2,
cols,
rows,
0,
0,
true,
radius,
commonOpts,
);
const blobId = `blob-${Date.now()}-${Math.random().toString(16).slice(2)}`;
soft.bodies.forEach((b) => {
b.plugin = {
color,
hasEntered: false,
entryCheckId: null,
blobId,
};
});
soft.constraints.forEach((c) => {
c.plugin = { blobId, blobConstraint: true };
c.render = c.render || {};
c.render.type = "line";
});
return { bodies: soft.bodies, constraints: soft.constraints, blobId };
}
const body = Bodies.circle(x, y, config.ballRadius, commonOpts);
return { bodies: [body], constraints: [], blobId: null };
};
const normalizeColor = (c) => (c || "").trim().toLowerCase();
const getGoalState = () => {
const winCond = currentScene?.config?.winCondition;
if (!winCond) return null;
if (winCond.type === "timer") {
const duration = winCond.durationSec ?? 120;
const now = Date.now();
const end = timerEndMs || now + duration * 1000;
const remainingMs = Math.max(0, end - now);
const remainingSec = Math.ceil(remainingMs / 1000);
const elapsed = Math.max(0, duration - remainingSec);
return {
label: `${String(Math.floor(remainingSec / 60)).padStart(2, "0")}:${String(remainingSec % 60).padStart(2, "0")}`,
progress: duration > 0 ? (100 * elapsed) / duration : 0,
met: remainingMs <= 0,
};
}
if (winCond.type === "clearCount") {
const target = winCond.target ?? 0;
const remaining = Math.max(0, target - clearedCount);
return {
label: `Clear ${target} balls (${remaining} left)`,
progress: target > 0 ? (100 * clearedCount) / target : 0,
met: clearedCount >= target,
};
}
if (winCond.type === "score") {
const target = winCond.target ?? 0;
const remaining = Math.max(0, target - score);
return {
label: `Score ${target} (${remaining} left)`,
progress: target > 0 ? (100 * score) / target : 0,
met: score >= target,
};
}
if (winCond.type === "colorClear" && Array.isArray(winCond.targets)) {
const targets = winCond.targets.map((t) => ({
color: normalizeColor(t.color),
count: t.count || 0,
}));
const totalTarget = targets.reduce((sum, t) => sum + t.count, 0);
let totalAchieved = 0;
const parts = targets.map((t) => {
const got = Math.min(
t.count,
clearedByColor[normalizeColor(t.color)] || 0,
);
totalAchieved += got;
const remaining = Math.max(0, t.count - got);
return `${got}/${t.count} (${remaining} left)`;
});
return {
label: parts.join(" • "),
progress: totalTarget > 0 ? (100 * totalAchieved) / totalTarget : 0,
met: targets.every(
(t) =>
(clearedByColor[normalizeColor(t.color)] || 0) >= (t.count || 0),
),
colors: targets.map((t) => t.color),
};
}
return null;
};
const clampBodiesIntoView = (prevWidth, prevHeight) => {
const scaleX = width / (prevWidth || width);
const scaleY = height / (prevHeight || height);
const margin = config.ballRadius * 1.2;
balls.forEach((ball) => {
const nextX = Math.min(
Math.max(ball.position.x * scaleX, margin),
Math.max(margin, width - margin),
);
const nextY = Math.min(
ball.position.y * scaleY,
Math.max(height - margin, margin),
);
Body.setPosition(ball, { x: nextX, y: nextY });
Body.setVelocity(ball, { x: 0, y: 0 });
});
resetChainVisuals();
};
const handleResize = () => {
const prevWidth = width;
const prevHeight = height;
const prevRadius = config.ballRadius;
width = sceneEl.clientWidth;
height = sceneEl.clientHeight;
const pixelRatio = window.devicePixelRatio || 1;
render.options.width = width;
render.options.height = height;
render.options.pixelRatio = pixelRatio;
render.canvas.style.width = `${width}px`;
render.canvas.style.height = `${height}px`;
render.canvas.width = width * pixelRatio;
render.canvas.height = height * pixelRatio;
Render.setPixelRatio(render, pixelRatio);
render.bounds.min.x = 0;
render.bounds.min.y = 0;
render.bounds.max.x = width;
render.bounds.max.y = height;
Render.lookAt(render, render.bounds);
updateBallRadius(prevRadius);
clampBodiesIntoView(prevWidth, prevHeight);
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 ||
(currentScene?.config?.spawnFrom === "bottom"
? ball.position.y < -500
: ball.position.y > height + 500)
) {
cleanupBall(ball);
ball.plugin.hasEntered = true;
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
Matter.Body.setPosition(ball, {
x: Math.random() * width,
y: spawnFromBottom ? height + 40 : -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;
const timeScale = engine.timing?.timeScale ?? 1;
if (isPaused || gameOver || timeScale === 0) return;
rotators.forEach((b) => {
const speed = b.plugin.rotSpeed || 0;
if (speed !== 0) {
Body.rotate(b, speed * ((dt * timeScale) / 1000));
}
});
oscillators.forEach((b) => {
const osc = b.plugin.oscillate;
if (!osc) return;
if (!osc.base) {
osc.base = { x: b.position.x, y: b.position.y };
}
const now = (engine.timing.timestamp || 0) / 1000;
const amplitude = osc.amplitude ?? 0;
const speed = osc.speed ?? 1;
const phase = osc.phase ?? 0;
const offset = Math.sin(now * speed + phase) * amplitude;
const target =
osc.axis === "x"
? { x: osc.base.x + offset, y: osc.base.y }
: { x: osc.base.x, y: osc.base.y + offset };
Body.setPosition(b, target);
Body.setVelocity(b, { x: 0, y: 0 });
});
if (timerEndMs) {
const winCond = currentScene?.config?.winCondition;
const duration = winCond?.durationSec ?? 120;
const now = Date.now();
const remainingMs = Math.max(0, timerEndMs - now);
const remainingSec = Math.ceil(remainingMs / 1000);
if (lastTimerDisplay !== remainingSec) {
lastTimerDisplay = remainingSec;
updateHud();
}
if (remainingMs <= 0 && !levelWon) {
checkWinCondition();
}
}
});
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);
ui.setHandlers({
onPauseToggle: () => setPaused(!isPaused),
onRestart: restartGame,
onSceneChange: (id) => applyScene(id),
onWinNext: () => applyScene(getNextSceneId()),
});
ui.setSceneOptions(
scenes,
(currentScene && currentScene.id) || defaultSceneId,
);
updateBallRadius();
applyScene((currentScene && currentScene.id) || defaultSceneId);
})();