From 4b78bf047f3cc2e7117cf5ae3e66df84f72ad132 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 14 Jun 2026 11:55:47 -0600 Subject: [PATCH] refine 2048 animations and enhance Dot Link sound effects 2048: - Improve tile slide animations with distance-proportional timing - Refactor merge transitions: scale down source tiles before showing merged result - Extract _paintTile helper to reduce code duplication - Simplify spawnTile logic by removing unnecessary mergedAt/moves fields Dot Link: - Add dynamic pitch variation to path-drawing sounds using plink/plonk SFX - Rate scales with path length for more engaging audio feedback - Preload new scifi-plink and scifi-plonk audio assets --- public/src/games/2048/2048Game.js | 225 +++++++++++++++++------- public/src/games/2048/2048Logic.js | 6 +- public/src/games/dotlink/DotLinkGame.js | 7 +- public/src/scenes/PreloadScene.js | 2 + public/src/ui/Sounds.js | 11 ++ 5 files changed, 184 insertions(+), 67 deletions(-) diff --git a/public/src/games/2048/2048Game.js b/public/src/games/2048/2048Game.js index 9aad8a3..12d0b98 100644 --- a/public/src/games/2048/2048Game.js +++ b/public/src/games/2048/2048Game.js @@ -420,6 +420,7 @@ export default class Game2048 extends Phaser.Scene { this._animateMove(this.state, newState, () => { this.state = newState; + this._redrawAllTiles(this.state); this._updateScore(newState.score); if (!this.wonAlready && newState.won) { @@ -438,27 +439,52 @@ export default class Game2048 extends Phaser.Scene { }); } + _createFloatingTile(x, y, value) { + const CS = this.cellSize - 6; + const level = getTileLevel(value); + const color = this.tilePalette[level]; + const textColor = this.tileTextColors[level]; + + const g = this.add.graphics().setDepth(D.tiles + 2); + g.fillStyle(0x000000, 0.35); + g.fillRoundedRect(-CS / 2 + 3, -CS / 2 + 3, CS, CS, 8); + g.fillStyle(color, 1); + g.fillRoundedRect(-CS / 2, -CS / 2, CS, CS, 8); + const sheen = this._mixInts(color, 0xffffff, 0.22); + g.fillStyle(sheen, 0.3); + g.fillRoundedRect(-CS / 2, -CS / 2, CS, Math.floor(CS * 0.45), 8); + + const log = Math.round(Math.log2(value)); + const fsMultiplier = (FS_LEVELS[log] ?? 18) / 100; + const t = this.add.text(0, 0, String(value), { + fontFamily: FONT_TITLE, + fontSize: `${Math.floor(this.cellSize * fsMultiplier)}px`, + color: textColor, + }).setOrigin(0.5).setDepth(D.tiles + 3); + + return this.add.container(x, y, [g, t]).setDepth(D.tiles + 2); + } + _animateMove(oldState, newState, onDone) { - const SLIDE_MS = 95; - const MERGE_MS = 140; - const SPAWN_MS = 115; + const SPAWN_MS = 160; - // Phase 1: slide tiles to their new positions - const movesToAnimate = newState.moves.filter(m => m.fromIdx !== m.toIdx); + const movingEntries = newState.moves.filter(m => m.fromIdx !== m.toIdx); + const mergeSet = new Set(newState.mergedAt); - // For merges, two source tiles go to the same destination. - // We only animate the "first" of a pair (visually one tile slides). - const seen = new Set(); - const uniqueMoves = []; - for (const m of movesToAnimate) { - const key = `${m.fromIdx}-${m.toIdx}`; - if (!seen.has(key)) { seen.add(key); uniqueMoves.push(m); } + // Hide stationary merge participants so the old value doesn't show while the + // other tile slides in. + for (const mv of newState.moves) { + if (mv.fromIdx === mv.toIdx && mergeSet.has(mv.toIdx)) { + const r = Math.floor(mv.toIdx / 4), c = mv.toIdx % 4; + this.tileContainers[r][c].setAlpha(0); + } } const spawnPhase = () => { if (newState.spawned === null) { onDone(); return; } const r = Math.floor(newState.spawned / 4), c = newState.spawned % 4; const cont = this.tileContainers[r][c]; + this._paintTile(r, c, newState.grid[newState.spawned]); cont.setAlpha(1).setScale(0); this.tweens.add({ targets: cont, scaleX: 1, scaleY: 1, @@ -468,82 +494,159 @@ export default class Game2048 extends Phaser.Scene { }; const afterSlides = () => { - this._redrawAllTiles(newState); + if (mergeSet.size === 0) { + this._redrawAllTiles(newState); + spawnPhase(); + return; + } - if (newState.mergedAt.length === 0) { spawnPhase(); return; } + // Build map: merge destination index → moving source containers currently at that position + const mergeSources = new Map(); + for (const mv of movingEntries) { + if (!mergeSet.has(mv.toIdx)) continue; + if (!mergeSources.has(mv.toIdx)) mergeSources.set(mv.toIdx, []); + const fr = Math.floor(mv.fromIdx / 4), fc = mv.fromIdx % 4; + mergeSources.get(mv.toIdx).push({ cont: this.tileContainers[fr][fc], fromIdx: mv.fromIdx }); + } - let mergePending = newState.mergedAt.length; - const afterMerges = () => { mergePending--; if (mergePending === 0) spawnPhase(); }; + // Immediately update all tiles that aren't part of a merge transition + const skipIndices = new Set(newState.mergedAt); + for (const srcs of mergeSources.values()) { + for (const { fromIdx } of srcs) skipIndices.add(fromIdx); + } + this._redrawTilesExcept(newState, skipIndices); - for (const idx of newState.mergedAt) { - const r = Math.floor(idx / 4), c = idx % 4; - const cont = this.tileContainers[r][c]; - const mergeVal = newState.grid[idx]; - this._scorePopup(cont.x, cont.y - 40, mergeVal); - this.tweens.add({ - targets: cont, scaleX: 1.2, scaleY: 1.2, - duration: MERGE_MS / 2, ease: 'Quad.easeOut', - yoyo: true, onComplete: afterMerges, - }); + // Count total source containers across all merges + let totalSrcs = 0; + for (const srcs of mergeSources.values()) totalSrcs += srcs.length; + + if (totalSrcs === 0) { + this._showMergedTiles(newState, spawnPhase); + return; + } + + // Scale down all source containers simultaneously (they're at the merge position) + let scaledDown = 0; + const onAllScaledDown = () => { + // Reset every source to its home cell (invisible) before growing merged tiles + for (const [, srcs] of mergeSources) { + for (const { cont, fromIdx } of srcs) { + const { cx, cy } = this._cellCenter(Math.floor(fromIdx / 4), fromIdx % 4); + cont.setAlpha(0).setScale(1).setPosition(cx, cy); + } + } + this._showMergedTiles(newState, spawnPhase); + }; + + for (const [, srcs] of mergeSources) { + for (const { cont } of srcs) { + this.tweens.add({ + targets: cont, scaleX: 0, scaleY: 0, + duration: 120, ease: 'Quad.easeIn', + onComplete: () => { scaledDown++; if (scaledDown === totalSrcs) onAllScaledDown(); }, + }); + } } }; - let pending = uniqueMoves.length; - if (pending === 0) { afterSlides(); return; } + if (movingEntries.length === 0) { afterSlides(); return; } - for (const mv of uniqueMoves) { + // Tween actual grid containers to destination positions. + // Distance-proportional timing: 1 cell ≈ 200ms, capped at 700ms. + let pending = movingEntries.length; + for (const mv of movingEntries) { const fr = Math.floor(mv.fromIdx / 4), fc = mv.fromIdx % 4; const tr = Math.floor(mv.toIdx / 4), tc = mv.toIdx % 4; - const cont = this.tileContainers[fr][fc]; - const { cx: tx, cy: ty } = this._cellCenter(tr, tc); + const { cx: dstX, cy: dstY } = this._cellCenter(tr, tc); + const cells = Math.abs(tr - fr) + Math.abs(tc - fc); + const duration = Math.min(180 + cells * 200, 700); this.tweens.add({ - targets: cont, x: tx, y: ty, - duration: SLIDE_MS, ease: 'Quad.easeOut', + targets: this.tileContainers[fr][fc], + x: dstX, y: dstY, + duration, + ease: 'Cubic.easeInOut', onComplete: () => { pending--; if (pending === 0) afterSlides(); }, }); } } - _redrawAllTiles(state) { - const CS = this.cellSize - 6; - const baseLevel = 36; + _showMergedTiles(newState, spawnPhase) { + let mergePending = newState.mergedAt.length; + const afterMerges = () => { mergePending--; if (mergePending === 0) spawnPhase(); }; + for (const idx of newState.mergedAt) { + const tr = Math.floor(idx / 4), tc = idx % 4; + const destCont = this.tileContainers[tr][tc]; + this._paintTile(tr, tc, newState.grid[idx]); + this._scorePopup(destCont.x, destCont.y - 40, newState.grid[idx]); + destCont.setAlpha(1).setScale(0); + this.tweens.add({ + targets: destCont, scaleX: 1.2, scaleY: 1.2, + duration: 200, ease: 'Back.easeOut', + onComplete: () => { + this.tweens.add({ + targets: destCont, scaleX: 1, scaleY: 1, + duration: 80, ease: 'Quad.easeOut', + onComplete: afterMerges, + }); + }, + }); + } + } + + _paintTile(r, c, val) { + const CS = this.cellSize - 6; + const gfx = this.tileGraphics[r][c]; + const txt = this.tileTexts[r][c]; + const level = getTileLevel(val); + const color = this.tilePalette[level]; + gfx.clear(); + gfx.fillStyle(0x000000, 0.35); + gfx.fillRoundedRect(-CS / 2 + 3, -CS / 2 + 3, CS, CS, 8); + gfx.fillStyle(color, 1); + gfx.fillRoundedRect(-CS / 2, -CS / 2, CS, CS, 8); + const sheen = this._mixInts(color, 0xffffff, 0.22); + gfx.fillStyle(sheen, 0.3); + gfx.fillRoundedRect(-CS / 2, -CS / 2, CS, Math.floor(CS * 0.45), 8); + txt.setText(String(val)); + txt.setColor(this.tileTextColors[level]); + const log = Math.round(Math.log2(val)); + const fsMultiplier = (FS_LEVELS[log] ?? 18) / 100; + txt.setFontSize(`${Math.floor(this.cellSize * fsMultiplier)}px`); + } + + _redrawTilesExcept(state, skipSet) { for (let r = 0; r < 4; r++) { for (let c = 0; c < 4; c++) { + if (skipSet.has(r * 4 + c)) continue; const val = state.grid[r * 4 + c]; - const { cx, cy } = this._cellCenter(r, c); const cont = this.tileContainers[r][c]; - const gfx = this.tileGraphics[r][c]; - const txt = this.tileTexts[r][c]; - + const { cx, cy } = this._cellCenter(r, c); if (val === 0) { cont.setAlpha(0); cont.setPosition(cx, cy).setScale(1); continue; } cont.setPosition(cx, cy).setScale(1); + this._paintTile(r, c, val); + cont.setAlpha(1); + } + } + } - const level = getTileLevel(val); - const color = this.tilePalette[level]; - - gfx.clear(); - // Shadow - gfx.fillStyle(0x000000, 0.35); - gfx.fillRoundedRect(-CS / 2 + 3, -CS / 2 + 3, CS, CS, 8); - // Main tile - gfx.fillStyle(color, 1); - gfx.fillRoundedRect(-CS / 2, -CS / 2, CS, CS, 8); - // Top sheen - const sheen = this._mixInts(color, 0xffffff, 0.22); - gfx.fillStyle(sheen, 0.3); - gfx.fillRoundedRect(-CS / 2, -CS / 2, CS, Math.floor(CS * 0.45), 8); - - txt.setText(String(val)); - txt.setColor(this.tileTextColors[level]); - const log = Math.round(Math.log2(val)); - const fsMultiplier = (FS_LEVELS[log] ?? 18) / 100; - txt.setFontSize(`${Math.floor(this.cellSize * fsMultiplier)}px`); - + _redrawAllTiles(state) { + for (let r = 0; r < 4; r++) { + for (let c = 0; c < 4; c++) { + const val = state.grid[r * 4 + c]; + const { cx, cy } = this._cellCenter(r, c); + const cont = this.tileContainers[r][c]; + if (val === 0) { + cont.setAlpha(0); + cont.setPosition(cx, cy).setScale(1); + continue; + } + cont.setPosition(cx, cy).setScale(1); + this._paintTile(r, c, val); cont.setAlpha(1); } } diff --git a/public/src/games/2048/2048Logic.js b/public/src/games/2048/2048Logic.js index f319e3e..370a008 100644 --- a/public/src/games/2048/2048Logic.js +++ b/public/src/games/2048/2048Logic.js @@ -55,12 +55,12 @@ export function createState() { export function spawnTile(state) { const empties = []; for (let i = 0; i < 16; i++) if (state.grid[i] === 0) empties.push(i); - if (empties.length === 0) return { ...state, spawned: null, mergedAt: [], moves: [] }; + if (empties.length === 0) return { ...state, spawned: null }; const idx = empties[Math.floor(Math.random() * empties.length)]; const val = Math.random() < 0.9 ? 2 : 4; const grid = new Int32Array(state.grid); grid[idx] = val; - return { ...state, grid, spawned: idx, mergedAt: [], moves: [] }; + return { ...state, grid, spawned: idx }; } export function hasValidMoves(state) { @@ -131,7 +131,7 @@ export function applyMove(state, dir) { let srcConsumed = 0; for (let di = 0; di < 4 && dstIdx < srcCols.length; di++) { if (merged[di] === 0) continue; - if (merged[di] === row[srcCols[srcConsumed]] && srcConsumed + 1 < srcCols.length && row[srcCols[srcConsumed]] === row[srcCols[srcConsumed + 1]]) { + if (srcConsumed + 1 < srcCols.length && row[srcCols[srcConsumed]] === row[srcCols[srcConsumed + 1]]) { // two tiles merged into merged[di] movesRotated.push({ fromIdx: r*4+srcCols[srcConsumed], toIdx: r*4+di, value: merged[di] }); movesRotated.push({ fromIdx: r*4+srcCols[srcConsumed+1], toIdx: r*4+di, value: merged[di] }); diff --git a/public/src/games/dotlink/DotLinkGame.js b/public/src/games/dotlink/DotLinkGame.js index 35ef40f..959ad18 100644 --- a/public/src/games/dotlink/DotLinkGame.js +++ b/public/src/games/dotlink/DotLinkGame.js @@ -2,7 +2,7 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; -import { playSound, SFX } from '../../ui/Sounds.js'; +import { playSound, SFX, playDotLinkSound, playScifiLaunch } from '../../ui/Sounds.js'; import { api } from '../../services/api.js'; import { makeRng, dateSeed, localDateString, generateBoard, isSolved, manhattanAdjacent, @@ -770,7 +770,7 @@ export default class DotLinkGame extends Phaser.Scene { const target = start === this.endA[ec] ? this.endB[ec] : this.endA[ec]; this.draw = { color: ec, target }; this.drawing = true; - playSound(this, SFX.PIECE_CLICK); + playDotLinkSound(this, ec, 1); this.redrawPaths(); return; } @@ -834,7 +834,7 @@ export default class DotLinkGame extends Phaser.Scene { this.drawing = false; this.redrawPaths(); this.pulseCell(next, FLOW[k % FLOW.length].n); - playSound(this, SFX.CARD_PLACE); + playScifiLaunch(this); this.checkWin(); return true; } @@ -855,6 +855,7 @@ export default class DotLinkGame extends Phaser.Scene { this.owner[next] = k; path.push(next); this.redrawPaths(); + playDotLinkSound(this, k, path.length); return true; } diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 7dd0257..359097b 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -112,6 +112,8 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-scifi-riser', '/assets/fx/scifi-riser.mp3'); this.load.audio('sfx-scifi-reveal', '/assets/fx/scifi-reveal.mp3'); this.load.audio('sfx-scifi-woosh', '/assets/fx/scifi-woosh.mp3'); + this.load.audio('sfx-scifi-plink', '/assets/fx/scifi-plink.mp3'); + this.load.audio('sfx-scifi-plonk', '/assets/fx/scifi-plonk.mp3'); this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 }); diff --git a/public/src/ui/Sounds.js b/public/src/ui/Sounds.js index 7e42539..500a94d 100644 --- a/public/src/ui/Sounds.js +++ b/public/src/ui/Sounds.js @@ -32,6 +32,8 @@ export const SFX = { SCIFI_RISER: 'sfx-scifi-riser', SCIFI_REVEAL: 'sfx-scifi-reveal', SCIFI_WOOSH: 'sfx-scifi-woosh', + SCIFI_PLINK: 'sfx-scifi-plink', + SCIFI_PLONK: 'sfx-scifi-plonk', SWORD_HIT: 'sfx-sword-hit', SWORD_SLICE: 'sfx-sword-slice', MONOPOLY_PURCHASE: 'sfx-monopoly-purchase', @@ -93,3 +95,12 @@ export function playScifiWoosh(scene) { _scifiWooshSound = scene.sound.add(SFX.SCIFI_WOOSH); _scifiWooshSound.play(); } + +// Dot Link: plink/plonk with adjustable rate — even colour index → plink, odd → plonk +export function playDotLinkSound(scene, colorIdx, pathLength) { + const soundKey = colorIdx % 2 === 0 ? SFX.SCIFI_PLINK : SFX.SCIFI_PLONK; + const rate = 1 + (pathLength - 1) * 0.5; + const sound = scene.sound.add(soundKey); + sound.setRate(rate); + sound.play(); +}