794 lines
24 KiB
JavaScript
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);
|
|
})();
|