From 065023abb453496627f57069d011afd05679b39f Mon Sep 17 00:00:00 2001 From: Daddy32 Date: Tue, 16 Dec 2025 20:21:58 +0100 Subject: [PATCH] Add pop clear animation for cleared chains --- src/chain-controller.js | 67 ++++++++++++++++++++++++++++++++++++ src/config.js | 14 +++++++- src/main.js | 20 +++++++++-- src/scenes/scene-template.js | 24 +++++++------ src/ui.js | 40 +++++++++++++++++++++ styles.css | 18 ++++++++++ 6 files changed, 169 insertions(+), 14 deletions(-) diff --git a/src/chain-controller.js b/src/chain-controller.js index e73e90c..130eaec 100644 --- a/src/chain-controller.js +++ b/src/chain-controller.js @@ -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); diff --git a/src/config.js b/src/config.js index 0d181f5..7a5971e 100644 --- a/src/config.js +++ b/src/config.js @@ -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 }, + }, }; }; diff --git a/src/main.js b/src/main.js index f542a05..ce43e49 100644 --- a/src/main.js +++ b/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, + ), }; }; diff --git a/src/scenes/scene-template.js b/src/scenes/scene-template.js index b723d21..b01ff0c 100644 --- a/src/scenes/scene-template.js +++ b/src/scenes/scene-template.js @@ -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" }, + }), ]; }, }); diff --git a/src/ui.js b/src/ui.js index f8e2db7..cf959c1 100644 --- a/src/ui.js +++ b/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; }; diff --git a/styles.css b/styles.css index c6545bd..593cd4b 100644 --- a/styles.css +++ b/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;