Extract chain and loop controllers

This commit is contained in:
Daddy32
2025-12-15 22:11:14 +01:00
parent 2a00974d44
commit 0ddab23808
4 changed files with 438 additions and 349 deletions

View File

@@ -1,5 +1,5 @@
(() => {
const { World, Body, Constraint, Events, Vector } = Matter;
const { World, Body } = Matter;
const load = (key, { create, fallback = {} } = {}) => {
const mod = window[key] ?? fallback;
@@ -59,12 +59,8 @@
const config = { ...normalizeSceneConfig(baseConfig) };
const setConfigForScene = (sceneConfig = {}) => {
const normalized = normalizeSceneConfig(sceneConfig);
Object.assign(config, normalized);
config.link = { ...normalized.link };
config.messages = normalized.messages;
};
const setConfigForScene = (sceneConfig) =>
Object.assign(config, normalizeSceneConfig(sceneConfig));
const scenesMod = load("PhysilinksScenes", { fallback: {} });
const { scenes = [], defaultSceneId, order: sceneOrder = [] } = scenesMod;
@@ -151,6 +147,9 @@
saveLongestChain = () => {},
} = storage;
const optNum = (value, defaultValue) =>
typeof value === "number" ? value : defaultValue;
const resetEngineForScene = (
sceneConfig,
{ prevRadius, timeScaleOverride, resetPlugins = true } = {},
@@ -165,18 +164,16 @@
engine.plugin.stormState = null;
}
spawnSystem.updateBallRadius(prevRadius);
engine.gravity.scale =
typeof sceneConfig?.gravityScale === "number"
? sceneConfig.gravityScale
: defaultGravityScale;
engine.gravity.scale = optNum(
sceneConfig?.gravityScale,
defaultGravityScale,
);
engine.gravity.x = 0;
engine.gravity.y = config.gravity;
engine.timing.timeScale =
typeof timeScaleOverride === "number"
? timeScaleOverride
: typeof sceneConfig?.timeScale === "number"
? sceneConfig.timeScale
: defaultTimeScale;
engine.timing.timeScale = optNum(
timeScaleOverride,
optNum(sceneConfig?.timeScale, defaultTimeScale),
);
};
const applyScene = (sceneId) => {
@@ -257,6 +254,10 @@
ui,
});
const normalizeColor = goals.normalizeColor;
const createChainController = load("PhysilinksChainController", {
create: "create",
});
const createLoop = load("PhysilinksLoop", { create: "create" });
const restartGame = () => {
spawnSystem.stopSpawner();
@@ -305,11 +306,6 @@
goals.showGoalIntro();
};
const setHighlight = (body, on) => {
body.render.lineWidth = on ? 4 : 2;
body.render.strokeStyle = on ? "#f8fafc" : "#0b1222";
};
const setPaused = (nextState) => {
if (state.gameOver || state.levelWon) return;
if (nextState === state.paused) return;
@@ -327,17 +323,6 @@
}
};
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;
@@ -405,167 +390,6 @@
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 updateLongestChain = (chainLength) => {
if (chainLength <= state.longestChainRecord) return;
state.longestChainRecord = chainLength;
saveLongestChain(currentScene.id, state.longestChainRecord);
console.log(
"New longest chain record",
chainLength,
"scene",
currentScene?.id,
);
ui.showFloatingMessage(`New chain record: ${chainLength}`, {
durationMs: 3600,
position: config.messages.position,
});
};
const getChainScoreState = () => {
const baseGain = 10 * Math.pow(chain.bodies.length, 2);
const negativeColors = (
currentScene?.config?.negativeScoreColors || []
).map(normalizeColor);
const negativeProgressColors = (
currentScene?.config?.negativeProgressColors || []
).map(normalizeColor);
const normalizedColor = chain.color
? normalizeColor(chain.color || "")
: null;
const isNegative =
normalizedColor && negativeColors.includes(normalizedColor);
const isNegativeProgress =
normalizedColor && negativeProgressColors.includes(normalizedColor);
const gain = isNegative ? -baseGain : baseGain;
return { gain, isNegativeProgress };
};
const removeChainConstraints = () => {
chain.constraints.forEach((c) => World.remove(world, c));
};
const removeChainBodies = () => {
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);
state.clearedByColor[key] = (state.clearedByColor[key] || 0) + 1;
}
spawnSystem.cleanupBall(body);
World.remove(world, body);
}
});
blobIds.forEach((id) => {
state.balls
.filter((b) => b.plugin?.blobId === id)
.forEach((b) => {
if (b.plugin?.color) {
const key = normalizeColor(b.plugin.color);
state.clearedByColor[key] = (state.clearedByColor[key] || 0) + 1;
}
});
spawnSystem.removeBlob(id);
});
for (let i = state.balls.length - 1; i >= 0; i -= 1) {
if (
chain.bodies.includes(state.balls[i]) ||
(state.balls[i].plugin?.blobId &&
blobIds.has(state.balls[i].plugin?.blobId))
) {
state.balls.splice(i, 1);
}
}
};
const applyNegativeProgressPenalty = (chainLength) => {
const winCond = currentScene?.config?.winCondition;
if (winCond?.type !== "colorClear" || !Array.isArray(winCond.targets)) {
return;
}
winCond.targets.forEach((target) => {
const key = normalizeColor(target.color);
const current = state.clearedByColor[key] || 0;
state.clearedByColor[key] = Math.max(0, current - chainLength);
});
};
const resetChainState = () => {
chain.active = false;
chain.color = null;
chain.bodies = [];
chain.constraints = [];
chain.pointer = null;
updateHud();
checkWinCondition();
};
const finishChain = (releasePoint) => {
if (!chain.active || state.gameOver || state.paused) return;
const chainLength = chain.bodies.length;
if (chainLength >= config.minChain) {
updateLongestChain(chainLength);
const { gain, isNegativeProgress } = getChainScoreState();
state.score += gain;
state.clearedCount += chainLength;
if (state.score > state.highScore) {
state.highScore = state.score;
saveHighScore(currentScene.id, state.highScore);
}
ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color);
removeChainConstraints();
removeChainBodies();
if (isNegativeProgress) {
applyNegativeProgressPenalty(chainLength);
}
} else {
removeChainConstraints();
chain.bodies.forEach((b) => setHighlight(b, false));
}
resetChainState();
};
const updateHud = () => {
ui.updateHud({
spawnIntervalMs: config.spawnIntervalMs,
@@ -580,6 +404,27 @@
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,
@@ -636,161 +481,17 @@
rebuildSceneBodies();
};
const runSceneBeforeUpdateHook = () => {
if (
state.levelWon ||
typeof currentScene?.config?.onBeforeUpdate !== "function"
) {
return;
}
currentScene.config.onBeforeUpdate({
engine,
width: state.width,
height: state.height,
});
};
const stepRopeConstraints = () => {
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;
}
});
};
const stepRotators = (dt, timeScale) => {
state.rotators.forEach((b) => {
const speed = b.plugin.rotSpeed || 0;
if (speed !== 0) {
Body.rotate(b, speed * ((dt * timeScale) / 1000));
}
});
};
const stepOscillators = () => {
state.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 });
});
};
const stepTimer = () => {
if (!state.timerEndMs) return;
const winCond = currentScene?.config?.winCondition;
const duration = winCond?.durationSec ?? 120;
const now = Date.now();
const remainingMs = Math.max(0, state.timerEndMs - now);
const remainingSec = Math.ceil(remainingMs / 1000);
if (state.lastTimerDisplay !== remainingSec) {
state.lastTimerDisplay = remainingSec;
updateHud();
}
if (remainingMs <= 0 && !state.levelWon) {
checkWinCondition();
}
};
Events.on(engine, "afterUpdate", () => {
// Keep stray balls within the play area horizontally.
state.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 > state.width + 100 ||
(currentScene?.config?.spawnFrom === "bottom"
? ball.position.y < -500
: ball.position.y > state.height + 500)
) {
spawnSystem.cleanupBall(ball);
ball.plugin.hasEntered = true;
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
Matter.Body.setPosition(ball, {
x:
currentScene?.config?.spawnOrigin === "center"
? state.width / 2
: Math.random() * state.width,
y:
currentScene?.config?.spawnOrigin === "center"
? state.height / 2
: spawnFromBottom
? state.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.
runSceneBeforeUpdateHook();
stepRopeConstraints();
const dt = (engine.timing && engine.timing.delta) || 16;
const timeScale = engine.timing?.timeScale ?? 1;
if (state.paused || state.gameOver || timeScale === 0) return;
// Rotate any scene rotators slowly.
stepRotators(dt, timeScale);
stepOscillators();
stepTimer();
});
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();
createLoop({
engine,
render,
config,
state,
chain,
spawnSystem,
getCurrentScene: () => currentScene,
getMaxLinkDistance,
updateHud,
checkWinCondition,
});
window.addEventListener("resize", handleResize);