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

755 lines
22 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 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);
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 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 (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;
}
};
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 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 ||
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;
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));
}
});
});
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);
})();