277 lines
8.4 KiB
JavaScript
277 lines
8.4 KiB
JavaScript
(() => {
|
|
const { Constraint, Vector, World } = Matter;
|
|
|
|
const create = ({
|
|
config,
|
|
state,
|
|
chain,
|
|
world,
|
|
spawnSystem,
|
|
getCurrentScene,
|
|
normalizeColor,
|
|
saveHighScore,
|
|
saveLongestChain,
|
|
updateHud,
|
|
checkWinCondition,
|
|
ui,
|
|
}) => {
|
|
const setHighlight = (body, on) => {
|
|
body.render.lineWidth = on ? 4 : 2;
|
|
body.render.strokeStyle = on ? "#f8fafc" : "#0b1222";
|
|
};
|
|
|
|
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 getChainScoreState = () => {
|
|
const baseGain = 10 * Math.pow(chain.bodies.length, 2);
|
|
const currentScene = getCurrentScene();
|
|
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 collectClearTargets = () => {
|
|
const targets = [];
|
|
const seen = new Set();
|
|
const blobIds = new Set();
|
|
|
|
const addBall = (ball) => {
|
|
if (!ball || seen.has(ball.id)) return;
|
|
seen.add(ball.id);
|
|
const radius =
|
|
typeof ball.circleRadius === "number"
|
|
? ball.circleRadius
|
|
: Math.max(
|
|
Math.abs(ball.bounds.max.x - ball.bounds.min.x),
|
|
Math.abs(ball.bounds.max.y - ball.bounds.min.y),
|
|
) / 2 || config.ballRadius;
|
|
targets.push({
|
|
x: ball.position.x,
|
|
y: ball.position.y,
|
|
color: ball.plugin?.color || ball.render?.fillStyle,
|
|
radius,
|
|
shape: ball.plugin?.shape || "circle",
|
|
});
|
|
};
|
|
|
|
chain.bodies.forEach((body) => {
|
|
if (body.plugin?.blobId) {
|
|
blobIds.add(body.plugin.blobId);
|
|
}
|
|
addBall(body);
|
|
});
|
|
|
|
blobIds.forEach((id) => {
|
|
state.balls
|
|
.filter((b) => b.plugin?.blobId === id)
|
|
.forEach((b) => addBall(b));
|
|
});
|
|
|
|
return targets;
|
|
};
|
|
|
|
const runClearAnimation = (targets) => {
|
|
if (!targets.length || !ui?.spawnClearEffects) return;
|
|
const linkCfg = config.link || {};
|
|
const clearCfg = linkCfg.clearAnimation || {};
|
|
const { render: renderOverride, ...animCfg } = clearCfg;
|
|
const currentScene = getCurrentScene();
|
|
let handled = false;
|
|
if (typeof renderOverride === "function") {
|
|
try {
|
|
const result = renderOverride({
|
|
targets,
|
|
config: animCfg,
|
|
scene: currentScene,
|
|
ui,
|
|
});
|
|
handled = result !== false;
|
|
} catch (err) {
|
|
console.error("clearAnimation render failed", err);
|
|
}
|
|
}
|
|
if (!handled) {
|
|
ui.spawnClearEffects(targets, { type: clearCfg.type, ...animCfg });
|
|
}
|
|
};
|
|
|
|
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 currentScene = getCurrentScene();
|
|
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 updateLongestChain = (chainLength) => {
|
|
if (chainLength <= state.longestChainRecord) return;
|
|
const currentScene = getCurrentScene();
|
|
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 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;
|
|
const currentScene = getCurrentScene();
|
|
if (chainLength >= config.minChain) {
|
|
updateLongestChain(chainLength);
|
|
const { gain, isNegativeProgress } = getChainScoreState();
|
|
state.score += gain;
|
|
const clearTargets = collectClearTargets();
|
|
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();
|
|
runClearAnimation(clearTargets);
|
|
removeChainBodies();
|
|
if (isNegativeProgress) {
|
|
applyNegativeProgressPenalty(chainLength);
|
|
}
|
|
} else {
|
|
removeChainConstraints();
|
|
chain.bodies.forEach((b) => setHighlight(b, false));
|
|
}
|
|
resetChainState();
|
|
};
|
|
|
|
return {
|
|
addToChain,
|
|
finishChain,
|
|
removeLastFromChain,
|
|
resetChainVisuals,
|
|
setHighlight,
|
|
};
|
|
};
|
|
|
|
window.PhysilinksChainController = { create };
|
|
})();
|