Files
Physilinks/main.js
2025-12-12 22:21:46 +01:00

542 lines
14 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,
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 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 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 applyScene = (sceneId) => {
const next = scenes.find((s) => s.id === sceneId) || scenes[0];
currentScene = next;
ui.setSceneSelection(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();
stopRunner();
engine.timing.timeScale = 0;
ui.setPauseState(false);
ui.showGameOver(score);
};
const restartGame = () => {
gameOver = false;
isPaused = false;
score = 0;
resetChainVisuals();
balls.forEach((ball) => {
cleanupBall(ball);
World.remove(world, ball);
});
balls.length = 0;
ui.hideGameOver();
ui.setPauseState(false);
engine.timing.timeScale = 1;
startRunner();
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;
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 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;
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();
};
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 = () => {
ui.updateHud({
spawnIntervalMs: config.spawnIntervalMs,
minChain: config.minChain,
chainLength: chain.bodies.length,
score,
highScore,
activeColor: chain.color,
});
};
const buildLegend = () => {
ui.buildLegend(config.palette);
};
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;
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),
});
ui.setSceneOptions(
scenes,
(currentScene && currentScene.id) || defaultSceneId,
);
applyScene((currentScene && currentScene.id) || defaultSceneId);
})();