Add pop clear animation for cleared chains

This commit is contained in:
Daddy32
2025-12-16 20:21:58 +01:00
parent c4053ad8b7
commit 065023abb4
6 changed files with 169 additions and 14 deletions

View File

@@ -94,6 +94,71 @@
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) => {
@@ -178,6 +243,7 @@
updateLongestChain(chainLength);
const { gain, isNegativeProgress } = getChainScoreState();
state.score += gain;
const clearTargets = collectClearTargets();
state.clearedCount += chainLength;
if (state.score > state.highScore) {
state.highScore = state.score;
@@ -185,6 +251,7 @@
}
ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color);
removeChainConstraints();
runClearAnimation(clearTargets);
removeChainBodies();
if (isNegativeProgress) {
applyNegativeProgressPenalty(chainLength);

View File

@@ -23,13 +23,25 @@
rope: true,
renderType: "line",
maxLengthMultiplier: 3.1,
clearAnimation: {
type: "pop",
durationMs: 320,
startScale: 1,
endScale: 1.8,
startOpacity: 0.95,
endOpacity: 0,
},
},
messages: { ...defaultMessageConfig },
};
return {
defaultMessageConfig: { ...defaultMessageConfig },
config: { ...config, link: { ...config.link }, messages: { ...config.messages } },
config: {
...config,
link: { ...config.link },
messages: { ...config.messages },
},
};
};

View File

@@ -48,12 +48,26 @@
: defaults.colors,
});
const normalizeSceneConfig = (sceneConfig = {}) => {
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: { ...link },
messages: normalizeMessages(messages),
link: normalizeLink(link, base.link),
messages: normalizeMessages(
messages,
base.messages || defaultMessageConfig,
),
};
};

View File

@@ -60,6 +60,16 @@
renderType: "line", // Matter render.type for constraints
maxLengthMultiplier: 3.1, // max link length as multiple of ball radius
maxLinkLength: null, // optional absolute pixel cap for link reach
clearAnimation: {
// Visual “bling” when a chain clears. Defaults to a quick pop/fade.
type: "pop", // built-in default animation type
durationMs: 320, // total animation time
startScale: 1, // initial scale multiplier
endScale: 1.8, // final scale multiplier
startOpacity: 0.95, // starting opacity
endOpacity: 0, // ending opacity
render: null, // optional function({ targets, config, scene, ui }) to fully override rendering
},
},
// Messaging overlays (goal hints / milestones)
@@ -119,16 +129,10 @@
// - plugin.blobId: used automatically for soft/jagged blobs to clear as a group
const floorHeight = Math.max(60, h * 0.12);
return [
Bodies.rectangle(
w / 2,
h + floorHeight / 2,
w * 1.2,
floorHeight,
{
isStatic: true,
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
},
),
Bodies.rectangle(w / 2, h + floorHeight / 2, w * 1.2, floorHeight, {
isStatic: true,
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
}),
];
},
});

View File

@@ -21,6 +21,12 @@
const winMessageEl = document.getElementById("win-message");
const winNextBtn = document.getElementById("win-next");
const winRestartBtn = document.getElementById("win-restart");
const clearFxEl = document.createElement("div");
clearFxEl.className = "clear-effects";
if (sceneEl) {
const firstChild = sceneEl.firstChild;
sceneEl.insertBefore(clearFxEl, firstChild || null);
}
let messageDefaults = {
durationMs: 4200,
position: { xPercent: 50, yPercent: 12 },
@@ -275,6 +281,39 @@
activeMessages.push(el);
};
const spawnClearEffects = (items = [], options = {}) => {
if (!clearFxEl || !Array.isArray(items) || items.length === 0) return;
const {
type = "pop",
durationMs = 320,
startScale = 1,
endScale = 1.8,
startOpacity = 0.95,
endOpacity = 0,
} = options;
items.forEach((item) => {
if (!item) return;
const size = Math.max(4, (item.radius || 18) * 2);
const el = document.createElement("div");
el.className = `clear-effect clear-effect--${type}`;
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.left = `${item.x}px`;
el.style.top = `${item.y}px`;
el.style.background = item.color || "#fff";
el.style.opacity = startOpacity;
el.style.transform = `translate(-50%, -50%) scale(${startScale})`;
el.style.borderRadius = item.shape === "rect" ? "18%" : "50%";
clearFxEl.appendChild(el);
requestAnimationFrame(() => {
el.style.transition = `transform ${durationMs}ms ease-out, opacity ${durationMs}ms ease-out`;
el.style.transform = `translate(-50%, -50%) scale(${endScale})`;
el.style.opacity = endOpacity;
});
setTimeout(() => el.remove(), durationMs + 80);
});
};
const api = {
sceneEl,
updateHud,
@@ -292,6 +331,7 @@
showFloatingMessage,
setMessageDefaults,
clearMessages,
spawnClearEffects,
};
return api;
};

View File

@@ -202,6 +202,24 @@ canvas {
inset: 0;
pointer-events: none;
}
.clear-effects {
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
z-index: 2;
}
.clear-effect {
position: absolute;
transform-origin: center;
border: 2px solid rgba(11, 18, 34, 0.9);
box-shadow:
0 0 18px rgba(255, 255, 255, 0.22),
0 10px 24px rgba(0, 0, 0, 0.28);
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
pointer-events: none;
mix-blend-mode: screen;
}
.floating-message {
position: absolute;
display: inline-flex;