Add pop clear animation for cleared chains
This commit is contained in:
@@ -94,6 +94,71 @@
|
|||||||
chain.constraints.forEach((c) => World.remove(world, c));
|
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 removeChainBodies = () => {
|
||||||
const blobIds = new Set();
|
const blobIds = new Set();
|
||||||
chain.bodies.forEach((body) => {
|
chain.bodies.forEach((body) => {
|
||||||
@@ -178,6 +243,7 @@
|
|||||||
updateLongestChain(chainLength);
|
updateLongestChain(chainLength);
|
||||||
const { gain, isNegativeProgress } = getChainScoreState();
|
const { gain, isNegativeProgress } = getChainScoreState();
|
||||||
state.score += gain;
|
state.score += gain;
|
||||||
|
const clearTargets = collectClearTargets();
|
||||||
state.clearedCount += chainLength;
|
state.clearedCount += chainLength;
|
||||||
if (state.score > state.highScore) {
|
if (state.score > state.highScore) {
|
||||||
state.highScore = state.score;
|
state.highScore = state.score;
|
||||||
@@ -185,6 +251,7 @@
|
|||||||
}
|
}
|
||||||
ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color);
|
ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color);
|
||||||
removeChainConstraints();
|
removeChainConstraints();
|
||||||
|
runClearAnimation(clearTargets);
|
||||||
removeChainBodies();
|
removeChainBodies();
|
||||||
if (isNegativeProgress) {
|
if (isNegativeProgress) {
|
||||||
applyNegativeProgressPenalty(chainLength);
|
applyNegativeProgressPenalty(chainLength);
|
||||||
|
|||||||
@@ -23,13 +23,25 @@
|
|||||||
rope: true,
|
rope: true,
|
||||||
renderType: "line",
|
renderType: "line",
|
||||||
maxLengthMultiplier: 3.1,
|
maxLengthMultiplier: 3.1,
|
||||||
|
clearAnimation: {
|
||||||
|
type: "pop",
|
||||||
|
durationMs: 320,
|
||||||
|
startScale: 1,
|
||||||
|
endScale: 1.8,
|
||||||
|
startOpacity: 0.95,
|
||||||
|
endOpacity: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
messages: { ...defaultMessageConfig },
|
messages: { ...defaultMessageConfig },
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultMessageConfig: { ...defaultMessageConfig },
|
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,
|
: 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 { link = {}, messages = {}, ...rest } = sceneConfig;
|
||||||
|
const base = defaults || {};
|
||||||
return {
|
return {
|
||||||
|
...base,
|
||||||
...rest,
|
...rest,
|
||||||
link: { ...link },
|
link: normalizeLink(link, base.link),
|
||||||
messages: normalizeMessages(messages),
|
messages: normalizeMessages(
|
||||||
|
messages,
|
||||||
|
base.messages || defaultMessageConfig,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,16 @@
|
|||||||
renderType: "line", // Matter render.type for constraints
|
renderType: "line", // Matter render.type for constraints
|
||||||
maxLengthMultiplier: 3.1, // max link length as multiple of ball radius
|
maxLengthMultiplier: 3.1, // max link length as multiple of ball radius
|
||||||
maxLinkLength: null, // optional absolute pixel cap for link reach
|
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)
|
// Messaging overlays (goal hints / milestones)
|
||||||
@@ -119,16 +129,10 @@
|
|||||||
// - plugin.blobId: used automatically for soft/jagged blobs to clear as a group
|
// - plugin.blobId: used automatically for soft/jagged blobs to clear as a group
|
||||||
const floorHeight = Math.max(60, h * 0.12);
|
const floorHeight = Math.max(60, h * 0.12);
|
||||||
return [
|
return [
|
||||||
Bodies.rectangle(
|
Bodies.rectangle(w / 2, h + floorHeight / 2, w * 1.2, floorHeight, {
|
||||||
w / 2,
|
isStatic: true,
|
||||||
h + floorHeight / 2,
|
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
|
||||||
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 winMessageEl = document.getElementById("win-message");
|
||||||
const winNextBtn = document.getElementById("win-next");
|
const winNextBtn = document.getElementById("win-next");
|
||||||
const winRestartBtn = document.getElementById("win-restart");
|
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 = {
|
let messageDefaults = {
|
||||||
durationMs: 4200,
|
durationMs: 4200,
|
||||||
position: { xPercent: 50, yPercent: 12 },
|
position: { xPercent: 50, yPercent: 12 },
|
||||||
@@ -275,6 +281,39 @@
|
|||||||
activeMessages.push(el);
|
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 = {
|
const api = {
|
||||||
sceneEl,
|
sceneEl,
|
||||||
updateHud,
|
updateHud,
|
||||||
@@ -292,6 +331,7 @@
|
|||||||
showFloatingMessage,
|
showFloatingMessage,
|
||||||
setMessageDefaults,
|
setMessageDefaults,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
spawnClearEffects,
|
||||||
};
|
};
|
||||||
return api;
|
return api;
|
||||||
};
|
};
|
||||||
|
|||||||
18
styles.css
18
styles.css
@@ -202,6 +202,24 @@ canvas {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
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 {
|
.floating-message {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user