Add pop clear animation for cleared chains
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
20
src/main.js
20
src/main.js
@@ -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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
Bodies.rectangle(w / 2, h + floorHeight / 2, w * 1.2, floorHeight, {
|
||||
isStatic: true,
|
||||
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
|
||||
},
|
||||
),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
40
src/ui.js
40
src/ui.js
@@ -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;
|
||||
};
|
||||
|
||||
18
styles.css
18
styles.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user