Files
Physilinks/src/main.js
2025-12-16 20:21:58 +01:00

532 lines
15 KiB
JavaScript

(() => {
const { World, Body } = Matter;
const load = (key, { create, fallback = {} } = {}) => {
const mod = window[key] ?? fallback;
if (!create) return mod;
return typeof mod[create] === "function" ? mod[create].bind(mod) : fallback;
};
const createConfig = load("PhysilinksConfig", {
create: "create",
fallback: () => ({
config: {},
defaultMessageConfig: {
durationMs: 4200,
position: { xPercent: 50, yPercent: 10 },
text: null,
colors: null,
},
}),
});
const { config: baseConfig, defaultMessageConfig } = createConfig();
const normalizeMessages = (
sceneMessages = {},
defaults = defaultMessageConfig,
) => ({
durationMs: Number.isFinite(sceneMessages.durationMs)
? sceneMessages.durationMs
: defaults.durationMs,
text:
typeof sceneMessages.text === "string" && sceneMessages.text.trim()
? sceneMessages.text.trim()
: defaults.text,
position: {
xPercent:
typeof sceneMessages.position?.xPercent === "number"
? sceneMessages.position.xPercent
: defaults.position.xPercent,
yPercent:
typeof sceneMessages.position?.yPercent === "number"
? sceneMessages.position.yPercent
: defaults.position.yPercent,
},
colors: Array.isArray(sceneMessages.colors)
? sceneMessages.colors
: defaults.colors,
});
const normalizeLink = (link = {}, defaults = baseConfig.link || {}) => ({
...defaults,
...link,
clearAnimation: {
...(defaults.clearAnimation || {}),
...(link.clearAnimation || {}),
},
});
const normalizeSceneConfig = (sceneConfig = {}, defaults = baseConfig) => {
const { link = {}, messages = {}, ...rest } = sceneConfig;
const base = defaults || {};
return {
...base,
...rest,
link: normalizeLink(link, base.link),
messages: normalizeMessages(
messages,
base.messages || defaultMessageConfig,
),
};
};
const config = { ...normalizeSceneConfig(baseConfig) };
const setConfigForScene = (sceneConfig) =>
Object.assign(config, normalizeSceneConfig(sceneConfig));
const scenesMod = load("PhysilinksScenes", { fallback: {} });
const { scenes = [], defaultSceneId, order: sceneOrder = [] } = scenesMod;
const registry = load("PhysilinksSceneRegistry", { fallback: {} });
const { getSceneById, getSceneIdFromUrl, setSceneIdInUrl, getNextSceneId } =
registry;
const createUI = load("PhysilinksUI", { create: "create" });
const ui = createUI();
const { sceneEl } = ui;
const state = {
width: sceneEl.clientWidth,
height: sceneEl.clientHeight,
boundaries: [],
rotators: [],
oscillators: [],
balls: [],
blobConstraints: new Map(),
score: 0,
highScore: 0,
longestChainRecord: 0,
clearedCount: 0,
clearedByColor: {},
gameOver: false,
paused: false,
levelWon: false,
timerEndMs: null,
lastTimerDisplay: null,
};
const BALL_BASELINE = 680; // reference height used for relative ball sizing
const createEngine = load("PhysilinksEngine", { create: "create" });
const { engine, render, runner, startRunner, stopRunner, setRenderSize } =
createEngine({
sceneEl,
width: state.width,
height: state.height,
background: "transparent",
wireframes: false,
showAngleIndicator: false,
});
const defaultGravityScale = engine.gravity.scale;
const defaultTimeScale = engine.timing.timeScale || 1;
engine.gravity.y = config.gravity;
const world = engine.world;
const initialSceneId =
(getSceneIdFromUrl && getSceneIdFromUrl(scenes)) ||
(getSceneById && getSceneById(scenes, defaultSceneId)
? defaultSceneId
: null) ||
scenes[0]?.id;
let currentScene =
(getSceneById && getSceneById(scenes, initialSceneId)) || scenes[0] || null;
if (currentScene && currentScene.config) {
setConfigForScene(currentScene.config);
}
const rebuildSceneBodies = () => {
state.boundaries.forEach((b) => World.remove(world, b));
const nextBoundaries = currentScene.createBodies(state.width, state.height);
state.boundaries.length = 0;
state.boundaries.push(...nextBoundaries);
state.rotators = state.boundaries.filter(
(b) => b.plugin && b.plugin.rotSpeed,
);
state.oscillators = state.boundaries.filter(
(b) => b.plugin && b.plugin.oscillate,
);
World.add(world, state.boundaries);
};
let spawnSystem = null;
let input = null;
const storage = load("PhysilinksStorage", { fallback: {} });
const {
loadHighScore = () => 0,
loadLongestChain = () => 0,
saveHighScore = () => {},
saveLongestChain = () => {},
} = storage;
const optNum = (value, defaultValue) =>
typeof value === "number" ? value : defaultValue;
const resetEngineForScene = (
sceneConfig,
{ prevRadius, timeScaleOverride, resetPlugins = true } = {},
) => {
if (resetPlugins) {
world.plugin ??= {};
world.plugin.stormSteps = Array.isArray(sceneConfig?.spawnIntervals)
? sceneConfig.spawnIntervals
: null;
world.plugin.squareOffset = 0;
engine.plugin ??= {};
engine.plugin.stormState = null;
}
spawnSystem.updateBallRadius(prevRadius);
engine.gravity.scale = optNum(
sceneConfig?.gravityScale,
defaultGravityScale,
);
engine.gravity.x = 0;
engine.gravity.y = config.gravity;
engine.timing.timeScale = optNum(
timeScaleOverride,
optNum(sceneConfig?.timeScale, defaultTimeScale),
);
};
const applyScene = (sceneId) => {
const next = (getSceneById && getSceneById(scenes, sceneId)) || scenes[0];
if (!next) return;
ui.clearMessages();
currentScene = next;
ui.setSceneSelection(next.id);
setSceneIdInUrl(next.id);
const prevRadius = config.ballRadius;
setConfigForScene(next.config);
ui.setMessageDefaults(config.messages);
resetEngineForScene(next.config, { prevRadius });
state.clearedCount = 0;
state.levelWon = false;
state.clearedByColor = {};
state.highScore = loadHighScore(next.id);
state.longestChainRecord = loadLongestChain(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 isGridScene = () =>
currentScene && currentScene.config && currentScene.config.gridLayouts;
const triggerGameOver = () => {
if (currentScene?.config?.noGameOver) return;
if (state.gameOver) return;
state.gameOver = true;
state.paused = false;
resetChainVisuals();
spawnSystem?.stopSpawner();
stopRunner();
engine.timing.timeScale = 0;
ui.setPauseState(false);
ui.showGameOver(state.score);
};
const createSpawn = load("PhysilinksSpawn", { create: "create" });
spawnSystem = createSpawn({
config,
world,
balls: state.balls,
blobConstraints: state.blobConstraints,
getCurrentScene: () => currentScene,
getDimensions: () => ({ width: state.width, height: state.height }),
isGridScene,
triggerGameOver,
isGameOver: () => state.gameOver,
ballBaseline: BALL_BASELINE,
});
const createGoals = load("PhysilinksGoals", { create: "create" });
const goals = createGoals({
config,
getCurrentScene: () => currentScene,
getScore: () => state.score,
getClearedCount: () => state.clearedCount,
getClearedByColor: () => state.clearedByColor,
getTimerEndMs: () => state.timerEndMs,
ui,
});
const normalizeColor = goals.normalizeColor;
const createChainController = load("PhysilinksChainController", {
create: "create",
});
const createLoop = load("PhysilinksLoop", { create: "create" });
const restartGame = () => {
spawnSystem.stopSpawner();
state.gameOver = false;
state.paused = false;
state.levelWon = false;
spawnSystem.resetSpawnState();
state.score = 0;
state.clearedCount = 0;
state.clearedByColor = {};
goals.resetMilestones();
input?.endDrag();
const winCond = currentScene?.config?.winCondition;
if (winCond?.type === "timer") {
const duration = winCond.durationSec ?? 120;
state.timerEndMs = Date.now() + duration * 1000;
state.lastTimerDisplay = null;
} else {
state.timerEndMs = null;
state.lastTimerDisplay = null;
}
resetChainVisuals();
ui.clearMessages();
state.balls.forEach((ball) => {
spawnSystem.cleanupBall(ball);
World.remove(world, ball);
});
state.balls.length = 0;
ui.hideGameOver();
ui.hideWin();
ui.setPauseState(false);
resetEngineForScene(currentScene?.config || {}, {
timeScaleOverride: 1,
resetPlugins: false,
});
startRunner();
updateHud();
if (isGridScene()) {
spawnSystem.spawnGridBalls();
} else {
const spawnedGrid = spawnSystem.spawnInitialColumns();
if (!spawnedGrid) {
spawnSystem.spawnInitialBurst();
}
spawnSystem.startSpawner();
}
goals.showGoalIntro();
};
const setPaused = (nextState) => {
if (state.gameOver || state.levelWon) return;
if (nextState === state.paused) return;
state.paused = nextState;
ui.setPauseState(state.paused);
if (state.paused) {
resetChainVisuals();
spawnSystem.stopSpawner();
stopRunner();
engine.timing.timeScale = 0;
} else {
startRunner();
spawnSystem.startSpawner();
engine.timing.timeScale = 1;
}
};
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) {
state.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) {
state.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 = [];
state.boundaries.forEach((b) => {
if (b.plugin && b.plugin.curve) {
World.remove(world, b);
} else {
remaining.push(b);
}
});
state.boundaries.length = 0;
state.boundaries.push(...remaining);
state.rotators = state.rotators.filter((b) =>
state.boundaries.includes(b),
);
state.oscillators = state.oscillators.filter((b) =>
state.boundaries.includes(b),
);
}
};
const checkWinCondition = () => {
if (state.levelWon) return;
const goal = goals.getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 });
if (!goal || !goal.met) return;
applyWinEffects();
state.levelWon = true;
spawnSystem.stopSpawner();
engine.timing.timeScale = 1;
startRunner();
ui.setPauseState(false);
ui.showWin(goal.label.replace("left", "done"));
};
const updateHud = () => {
ui.updateHud({
spawnIntervalMs: config.spawnIntervalMs,
minChain: config.minChain,
chainLength: chain.bodies.length,
score: state.score,
highScore: state.highScore,
activeColor: chain.color,
});
const goal = goals.getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 });
goals.maybeAnnounceGoalProgress(goal);
};
const {
setHighlight,
resetChainVisuals,
removeLastFromChain,
addToChain,
finishChain,
} = createChainController({
config,
state,
chain,
world,
spawnSystem,
getCurrentScene: () => currentScene,
normalizeColor,
saveHighScore,
saveLongestChain,
updateHud,
checkWinCondition,
ui,
});
const createInput = load("PhysilinksInput", { create: "create" });
input = createInput({
render,
world,
balls: state.balls,
boundaries: state.boundaries,
chain,
config,
getCurrentScene: () => currentScene,
isPaused: () => state.paused,
isLevelWon: () => state.levelWon,
isGameOver: () => state.gameOver,
getMaxLinkDistance,
setHighlight,
removeLastFromChain,
addToChain,
finishChain,
updateHud,
});
const buildLegend = () => {
ui.buildLegend(config.palette);
};
const clampBodiesIntoView = (prevWidth, prevHeight) => {
const scaleX = state.width / (prevWidth || state.width);
const scaleY = state.height / (prevHeight || state.height);
const margin = config.ballRadius * 1.2;
state.balls.forEach((ball) => {
const nextX = Math.min(
Math.max(ball.position.x * scaleX, margin),
Math.max(margin, state.width - margin),
);
const nextY = Math.min(
ball.position.y * scaleY,
Math.max(state.height - margin, margin),
);
Body.setPosition(ball, { x: nextX, y: nextY });
Body.setVelocity(ball, { x: 0, y: 0 });
});
resetChainVisuals();
input?.endDrag();
};
const handleResize = () => {
const prevWidth = state.width;
const prevHeight = state.height;
const prevRadius = config.ballRadius;
state.width = sceneEl.clientWidth;
state.height = sceneEl.clientHeight;
setRenderSize(state.width, state.height);
spawnSystem.updateBallRadius(prevRadius);
clampBodiesIntoView(prevWidth, prevHeight);
rebuildSceneBodies();
};
createLoop({
engine,
render,
config,
state,
chain,
spawnSystem,
getCurrentScene: () => currentScene,
getMaxLinkDistance,
updateHud,
checkWinCondition,
});
window.addEventListener("resize", handleResize);
ui.setHandlers({
onPauseToggle: () => setPaused(!state.paused),
onRestart: restartGame,
onSceneChange: (id) => applyScene(id),
onWinNext: () =>
applyScene(
getNextSceneId
? getNextSceneId(scenes, sceneOrder, currentScene) || currentScene?.id
: currentScene?.id,
),
});
ui.setSceneOptions(
scenes,
(currentScene && currentScene.id) || defaultSceneId,
);
spawnSystem.updateBallRadius();
applyScene((currentScene && currentScene.id) || defaultSceneId);
})();