849 lines
25 KiB
JavaScript
849 lines
25 KiB
JavaScript
(() => {
|
|
const {
|
|
Engine,
|
|
Render,
|
|
Runner,
|
|
World,
|
|
Body,
|
|
Bodies,
|
|
Constraint,
|
|
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 = [];
|
|
let spawnTimer = null;
|
|
let score = 0;
|
|
let highScore = 0;
|
|
let clearedCount = 0;
|
|
let gameOver = false;
|
|
let isPaused = false;
|
|
let levelWon = 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 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;
|
|
highScore = loadHighScore(next.id);
|
|
rebuildSceneBodies();
|
|
buildLegend();
|
|
restartGame();
|
|
updateHud();
|
|
};
|
|
|
|
const getNextSceneId = () => {
|
|
const winCond = currentScene?.config?.winCondition;
|
|
if (winCond?.nextSceneId) return winCond.nextSceneId;
|
|
const idx = scenes.findIndex((s) => s.id === currentScene?.id);
|
|
if (idx >= 0 && idx < scenes.length - 1) return scenes[idx + 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 ball = createBallBody(x, y, color);
|
|
ball.plugin = {
|
|
color,
|
|
hasEntered: false,
|
|
entryCheckId: null,
|
|
squishX: 1,
|
|
squishY: 1,
|
|
};
|
|
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 (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 (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;
|
|
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.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));
|
|
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();
|
|
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 createBallBody = (x, y, color) => {
|
|
const commonOpts = {
|
|
restitution: 0.72,
|
|
friction: 0.01,
|
|
frictionAir: 0.015,
|
|
render: {
|
|
fillStyle: color,
|
|
strokeStyle: "#0b1222",
|
|
lineWidth: 2,
|
|
},
|
|
};
|
|
if (currentScene?.config?.blobBalls) {
|
|
const points = [];
|
|
const segments = 12;
|
|
for (let i = 0; i < segments; i += 1) {
|
|
const angle = (i / segments) * Math.PI * 2;
|
|
const variance = 0.75 + Math.random() * 0.35;
|
|
const r = config.ballRadius * variance;
|
|
points.push({
|
|
x: x + Math.cos(angle) * r,
|
|
y: y + Math.sin(angle) * r,
|
|
});
|
|
}
|
|
return Bodies.fromVertices(x, y, [points], commonOpts);
|
|
}
|
|
return Bodies.circle(x, y, config.ballRadius, commonOpts);
|
|
};
|
|
|
|
const getGoalState = () => {
|
|
const winCond = currentScene?.config?.winCondition;
|
|
if (!winCond) return null;
|
|
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 target = winCond.targets.reduce(
|
|
(sum, t) => sum + (t.count || 0),
|
|
0,
|
|
);
|
|
return {
|
|
label: "Clear target colors",
|
|
progress: target > 0 ? (100 * clearedCount) / target : 0,
|
|
met: false,
|
|
};
|
|
}
|
|
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 (currentScene?.config?.blobBalls) {
|
|
balls.forEach((ball) => {
|
|
if (!ball.plugin) return;
|
|
const speed = Vector.magnitude(ball.velocity || { x: 0, y: 0 });
|
|
const squeeze = Math.min(0.22, speed / 20);
|
|
const targetX = 1 + squeeze;
|
|
const targetY = Math.max(0.7, 1 - squeeze);
|
|
const currX = ball.plugin.squishX || 1;
|
|
const currY = ball.plugin.squishY || 1;
|
|
const factorX = targetX / currX;
|
|
const factorY = targetY / currY;
|
|
if (Math.abs(factorX - 1) > 0.02 || Math.abs(factorY - 1) > 0.02) {
|
|
Body.scale(ball, factorX, factorY);
|
|
ball.plugin.squishX = targetX;
|
|
ball.plugin.squishY = targetY;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
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);
|
|
})();
|