516 lines
14 KiB
JavaScript
516 lines
14 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 normalizeSceneConfig = (sceneConfig = {}) => {
|
|
const { link = {}, messages = {}, ...rest } = sceneConfig;
|
|
return {
|
|
...rest,
|
|
link: { ...link },
|
|
messages: normalizeMessages(messages),
|
|
};
|
|
};
|
|
|
|
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;
|
|
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();
|
|
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);
|
|
})();
|