Files
Physilinks/src/main.js
2025-12-15 18:30:42 +01:00

794 lines
24 KiB
JavaScript

(() => {
const { Engine, Render, Runner, World, Body, Constraint, Events, Vector } =
Matter;
const { scenes = [], defaultSceneId } = window.PhysilinksScenes || {};
const getSceneById = (sceneId) =>
scenes.find((candidate) => candidate.id === sceneId) || null;
const getSceneIdFromUrl = () => {
try {
const params = new URLSearchParams(window.location.search);
const urlScene = params.get("scene");
return getSceneById(urlScene) ? urlScene : null;
} catch (err) {
return null;
}
};
const setSceneIdInUrl = (sceneId) => {
if (!sceneId) return;
try {
const url = new URL(window.location.href);
url.searchParams.set("scene", sceneId);
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
} catch (err) {
// ignore history failures (blocked or unsupported)
}
};
const defaultMessageConfig = {
durationMs: 4200,
position: { xPercent: 50, yPercent: 10 },
text: null,
colors: null,
};
const config = {
gravity: 1,
spawnIntervalMs: 520,
autoSpawn: true,
minChain: 3,
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
ballRadius: 18,
ballShape: "circle",
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,
},
messages: { ...defaultMessageConfig },
};
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();
const defaultGravityScale = engine.gravity.scale;
const defaultTimeScale = engine.timing.timeScale || 1;
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 = [];
const initialSceneId =
getSceneIdFromUrl() ||
(getSceneById(defaultSceneId) ? defaultSceneId : null) ||
scenes[0]?.id;
let currentScene = getSceneById(initialSceneId) || 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 score = 0;
let highScore = 0;
let longestChainRecord = 0;
let clearedCount = 0;
let clearedByColor = {};
let gameOver = false;
let isPaused = false;
let levelWon = false;
let timerEndMs = null;
let lastTimerDisplay = null;
let spawnSystem = null;
let input = null;
const {
loadHighScore = () => 0,
loadLongestChain = () => 0,
saveHighScore = () => {},
saveLongestChain = () => {},
} = window.PhysilinksStorage || {};
const applyScene = (sceneId) => {
const next = getSceneById(sceneId) || scenes[0];
if (!next) return;
currentScene = next;
ui.setSceneSelection(next.id);
setSceneIdInUrl(next.id);
const prevRadius = config.ballRadius;
Object.assign(config, next.config);
config.link = { ...(next.config?.link || {}) };
const sceneMessages = next.config?.messages || {};
config.messages = {
durationMs: Number.isFinite(sceneMessages.durationMs)
? sceneMessages.durationMs
: defaultMessageConfig.durationMs,
text:
typeof sceneMessages.text === "string" && sceneMessages.text.trim()
? sceneMessages.text.trim()
: defaultMessageConfig.text,
position: {
xPercent:
typeof sceneMessages.position?.xPercent === "number"
? sceneMessages.position.xPercent
: defaultMessageConfig.position.xPercent,
yPercent:
typeof sceneMessages.position?.yPercent === "number"
? sceneMessages.position.yPercent
: defaultMessageConfig.position.yPercent,
},
colors: Array.isArray(sceneMessages.colors)
? sceneMessages.colors
: defaultMessageConfig.colors,
};
ui.setMessageDefaults(config.messages);
world.plugin = world.plugin || {};
world.plugin.stormSteps = Array.isArray(next.config?.spawnIntervals)
? next.config.spawnIntervals
: null;
world.plugin.squareOffset = 0;
engine.plugin = engine.plugin || {};
engine.plugin.stormState = null;
engine.gravity.scale =
typeof next.config.gravityScale === "number"
? next.config.gravityScale
: defaultGravityScale;
engine.timing.timeScale =
typeof next.config.timeScale === "number"
? next.config.timeScale
: defaultTimeScale;
spawnSystem.updateBallRadius(prevRadius);
engine.gravity.x = 0;
engine.gravity.y = config.gravity;
clearedCount = 0;
levelWon = false;
clearedByColor = {};
highScore = loadHighScore(next.id);
longestChainRecord = loadLongestChain(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 triggerGameOver = () => {
if (currentScene?.config?.noGameOver) return;
if (gameOver) return;
gameOver = true;
isPaused = false;
resetChainVisuals();
spawnSystem?.stopSpawner();
stopRunner();
engine.timing.timeScale = 0;
ui.setPauseState(false);
ui.showGameOver(score);
};
spawnSystem = window.PhysilinksSpawn.create({
config,
world,
balls,
blobConstraints,
getCurrentScene: () => currentScene,
getDimensions: () => ({ width, height }),
isGridScene,
triggerGameOver,
isGameOver: () => gameOver,
ballBaseline: BALL_BASELINE,
});
const goals = window.PhysilinksGoals.create({
config,
getCurrentScene: () => currentScene,
getScore: () => score,
getClearedCount: () => clearedCount,
getClearedByColor: () => clearedByColor,
getTimerEndMs: () => timerEndMs,
ui,
});
const normalizeColor = goals.normalizeColor;
const restartGame = () => {
spawnSystem.stopSpawner();
gameOver = false;
isPaused = false;
levelWon = false;
spawnSystem.resetSpawnState();
score = 0;
clearedCount = 0;
clearedByColor = {};
goals.resetMilestones();
input?.endDrag();
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) => {
spawnSystem.cleanupBall(ball);
World.remove(world, ball);
});
balls.length = 0;
ui.hideGameOver();
ui.hideWin();
ui.setPauseState(false);
engine.gravity.scale =
typeof currentScene?.config?.gravityScale === "number"
? currentScene.config.gravityScale
: defaultGravityScale;
engine.gravity.x = 0;
engine.gravity.y = config.gravity;
engine.timing.timeScale = 1;
startRunner();
updateHud();
if (isGridScene()) {
spawnSystem.spawnGridBalls();
} else {
const spawnedGrid = spawnSystem.spawnInitialColumns();
if (!spawnedGrid) {
spawnSystem.spawnInitialBurst();
}
spawnSystem.startSpawner();
}
goals.showGoalIntro();
};
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();
spawnSystem.stopSpawner();
stopRunner();
engine.timing.timeScale = 0;
} else {
startRunner();
spawnSystem.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 (winCond.onWin.shoveBalls || winCond.onWin.disableGravity) {
engine.gravity.x = 0;
engine.gravity.y = 0;
engine.gravity.scale = 0;
}
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 = goals.getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 });
if (!goal || !goal.met) return;
applyWinEffects();
levelWon = true;
spawnSystem.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 chainLength = chain.bodies.length;
if (chainLength > longestChainRecord) {
longestChainRecord = chainLength;
saveLongestChain(currentScene.id, longestChainRecord);
console.log(
"New longest chain record",
chainLength,
"scene",
currentScene?.id,
);
ui.showFloatingMessage(`New chain record: ${chainLength}`, {
durationMs: 3600,
position: config.messages.position,
});
}
const baseGain = 10 * Math.pow(chain.bodies.length, 2);
const negativeColors = (
currentScene?.config?.negativeScoreColors || []
).map(normalizeColor);
const negativeProgressColors = (
currentScene?.config?.negativeProgressColors || []
).map(normalizeColor);
const isNegative =
chain.color &&
negativeColors.includes(normalizeColor(chain.color || ""));
const isNegativeProgress =
chain.color &&
negativeProgressColors.includes(normalizeColor(chain.color || ""));
const gain = isNegative ? -baseGain : baseGain;
score += gain;
clearedCount += chain.bodies.length;
if (score > highScore) {
highScore = score;
saveHighScore(currentScene.id, highScore);
}
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;
}
spawnSystem.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;
}
});
spawnSystem.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);
}
}
if (isNegativeProgress) {
const winCond = currentScene?.config?.winCondition;
if (winCond?.type === "colorClear" && Array.isArray(winCond.targets)) {
winCond.targets.forEach((target) => {
const key = normalizeColor(target.color);
const current = clearedByColor[key] || 0;
clearedByColor[key] = Math.max(0, current - chain.bodies.length);
});
}
}
} 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 updateHud = () => {
ui.updateHud({
spawnIntervalMs: config.spawnIntervalMs,
minChain: config.minChain,
chainLength: chain.bodies.length,
score,
highScore,
activeColor: chain.color,
});
const goal = goals.getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 });
goals.maybeAnnounceGoalProgress(goal);
};
input = window.PhysilinksInput.create({
render,
world,
balls,
boundaries,
chain,
config,
getCurrentScene: () => currentScene,
isPaused: () => isPaused,
isLevelWon: () => levelWon,
isGameOver: () => gameOver,
getMaxLinkDistance,
setHighlight,
removeLastFromChain,
addToChain,
finishChain,
updateHud,
});
const buildLegend = () => {
ui.buildLegend(config.palette);
};
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();
input?.endDrag();
};
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);
spawnSystem.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)
) {
spawnSystem.cleanupBall(ball);
ball.plugin.hasEntered = true;
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
Matter.Body.setPosition(ball, {
x:
currentScene?.config?.spawnOrigin === "center"
? width / 2
: Math.random() * width,
y:
currentScene?.config?.spawnOrigin === "center"
? height / 2
: 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.
if (
!levelWon &&
typeof currentScene?.config?.onBeforeUpdate === "function"
) {
currentScene.config.onBeforeUpdate({ engine, width, height });
}
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,
);
spawnSystem.updateBallRadius();
applyScene((currentScene && currentScene.id) || defaultSceneId);
})();