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
This commit is contained in:
Brian Fertig 2026-06-14 11:55:47 -06:00
parent 24678602f4
commit 4b78bf047f
5 changed files with 184 additions and 67 deletions

View File

@ -420,6 +420,7 @@ export default class Game2048 extends Phaser.Scene {
this._animateMove(this.state, newState, () => { this._animateMove(this.state, newState, () => {
this.state = newState; this.state = newState;
this._redrawAllTiles(this.state);
this._updateScore(newState.score); this._updateScore(newState.score);
if (!this.wonAlready && newState.won) { 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) { _animateMove(oldState, newState, onDone) {
const SLIDE_MS = 95; const SPAWN_MS = 160;
const MERGE_MS = 140;
const SPAWN_MS = 115;
// Phase 1: slide tiles to their new positions const movingEntries = newState.moves.filter(m => m.fromIdx !== m.toIdx);
const movesToAnimate = newState.moves.filter(m => m.fromIdx !== m.toIdx); const mergeSet = new Set(newState.mergedAt);
// For merges, two source tiles go to the same destination. // Hide stationary merge participants so the old value doesn't show while the
// We only animate the "first" of a pair (visually one tile slides). // other tile slides in.
const seen = new Set(); for (const mv of newState.moves) {
const uniqueMoves = []; if (mv.fromIdx === mv.toIdx && mergeSet.has(mv.toIdx)) {
for (const m of movesToAnimate) { const r = Math.floor(mv.toIdx / 4), c = mv.toIdx % 4;
const key = `${m.fromIdx}-${m.toIdx}`; this.tileContainers[r][c].setAlpha(0);
if (!seen.has(key)) { seen.add(key); uniqueMoves.push(m); } }
} }
const spawnPhase = () => { const spawnPhase = () => {
if (newState.spawned === null) { onDone(); return; } if (newState.spawned === null) { onDone(); return; }
const r = Math.floor(newState.spawned / 4), c = newState.spawned % 4; const r = Math.floor(newState.spawned / 4), c = newState.spawned % 4;
const cont = this.tileContainers[r][c]; const cont = this.tileContainers[r][c];
this._paintTile(r, c, newState.grid[newState.spawned]);
cont.setAlpha(1).setScale(0); cont.setAlpha(1).setScale(0);
this.tweens.add({ this.tweens.add({
targets: cont, scaleX: 1, scaleY: 1, targets: cont, scaleX: 1, scaleY: 1,
@ -468,82 +494,159 @@ export default class Game2048 extends Phaser.Scene {
}; };
const afterSlides = () => { 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; // Immediately update all tiles that aren't part of a merge transition
const afterMerges = () => { mergePending--; if (mergePending === 0) spawnPhase(); }; 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) { // Count total source containers across all merges
const r = Math.floor(idx / 4), c = idx % 4; let totalSrcs = 0;
const cont = this.tileContainers[r][c]; for (const srcs of mergeSources.values()) totalSrcs += srcs.length;
const mergeVal = newState.grid[idx];
this._scorePopup(cont.x, cont.y - 40, mergeVal); if (totalSrcs === 0) {
this.tweens.add({ this._showMergedTiles(newState, spawnPhase);
targets: cont, scaleX: 1.2, scaleY: 1.2, return;
duration: MERGE_MS / 2, ease: 'Quad.easeOut', }
yoyo: true, onComplete: afterMerges,
}); // 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 (movingEntries.length === 0) { afterSlides(); return; }
if (pending === 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 fr = Math.floor(mv.fromIdx / 4), fc = mv.fromIdx % 4;
const tr = Math.floor(mv.toIdx / 4), tc = mv.toIdx % 4; const tr = Math.floor(mv.toIdx / 4), tc = mv.toIdx % 4;
const cont = this.tileContainers[fr][fc]; const { cx: dstX, cy: dstY } = this._cellCenter(tr, tc);
const { cx: tx, cy: ty } = 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({ this.tweens.add({
targets: cont, x: tx, y: ty, targets: this.tileContainers[fr][fc],
duration: SLIDE_MS, ease: 'Quad.easeOut', x: dstX, y: dstY,
duration,
ease: 'Cubic.easeInOut',
onComplete: () => { pending--; if (pending === 0) afterSlides(); }, onComplete: () => { pending--; if (pending === 0) afterSlides(); },
}); });
} }
} }
_redrawAllTiles(state) { _showMergedTiles(newState, spawnPhase) {
const CS = this.cellSize - 6; let mergePending = newState.mergedAt.length;
const baseLevel = 36; 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 r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) { for (let c = 0; c < 4; c++) {
if (skipSet.has(r * 4 + c)) continue;
const val = state.grid[r * 4 + c]; const val = state.grid[r * 4 + c];
const { cx, cy } = this._cellCenter(r, c);
const cont = this.tileContainers[r][c]; const cont = this.tileContainers[r][c];
const gfx = this.tileGraphics[r][c]; const { cx, cy } = this._cellCenter(r, c);
const txt = this.tileTexts[r][c];
if (val === 0) { if (val === 0) {
cont.setAlpha(0); cont.setAlpha(0);
cont.setPosition(cx, cy).setScale(1); cont.setPosition(cx, cy).setScale(1);
continue; continue;
} }
cont.setPosition(cx, cy).setScale(1); cont.setPosition(cx, cy).setScale(1);
this._paintTile(r, c, val);
cont.setAlpha(1);
}
}
}
const level = getTileLevel(val); _redrawAllTiles(state) {
const color = this.tilePalette[level]; for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
gfx.clear(); const val = state.grid[r * 4 + c];
// Shadow const { cx, cy } = this._cellCenter(r, c);
gfx.fillStyle(0x000000, 0.35); const cont = this.tileContainers[r][c];
gfx.fillRoundedRect(-CS / 2 + 3, -CS / 2 + 3, CS, CS, 8); if (val === 0) {
// Main tile cont.setAlpha(0);
gfx.fillStyle(color, 1); cont.setPosition(cx, cy).setScale(1);
gfx.fillRoundedRect(-CS / 2, -CS / 2, CS, CS, 8); continue;
// Top sheen }
const sheen = this._mixInts(color, 0xffffff, 0.22); cont.setPosition(cx, cy).setScale(1);
gfx.fillStyle(sheen, 0.3); this._paintTile(r, c, val);
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`);
cont.setAlpha(1); cont.setAlpha(1);
} }
} }

View File

@ -55,12 +55,12 @@ export function createState() {
export function spawnTile(state) { export function spawnTile(state) {
const empties = []; const empties = [];
for (let i = 0; i < 16; i++) if (state.grid[i] === 0) empties.push(i); 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 idx = empties[Math.floor(Math.random() * empties.length)];
const val = Math.random() < 0.9 ? 2 : 4; const val = Math.random() < 0.9 ? 2 : 4;
const grid = new Int32Array(state.grid); const grid = new Int32Array(state.grid);
grid[idx] = val; grid[idx] = val;
return { ...state, grid, spawned: idx, mergedAt: [], moves: [] }; return { ...state, grid, spawned: idx };
} }
export function hasValidMoves(state) { export function hasValidMoves(state) {
@ -131,7 +131,7 @@ export function applyMove(state, dir) {
let srcConsumed = 0; let srcConsumed = 0;
for (let di = 0; di < 4 && dstIdx < srcCols.length; di++) { for (let di = 0; di < 4 && dstIdx < srcCols.length; di++) {
if (merged[di] === 0) continue; 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] // 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], toIdx: r*4+di, value: merged[di] });
movesRotated.push({ fromIdx: r*4+srcCols[srcConsumed+1], toIdx: r*4+di, value: merged[di] }); movesRotated.push({ fromIdx: r*4+srcCols[srcConsumed+1], toIdx: r*4+di, value: merged[di] });

View File

@ -2,7 +2,7 @@ import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.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 { api } from '../../services/api.js';
import { import {
makeRng, dateSeed, localDateString, generateBoard, isSolved, manhattanAdjacent, 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]; const target = start === this.endA[ec] ? this.endB[ec] : this.endA[ec];
this.draw = { color: ec, target }; this.draw = { color: ec, target };
this.drawing = true; this.drawing = true;
playSound(this, SFX.PIECE_CLICK); playDotLinkSound(this, ec, 1);
this.redrawPaths(); this.redrawPaths();
return; return;
} }
@ -834,7 +834,7 @@ export default class DotLinkGame extends Phaser.Scene {
this.drawing = false; this.drawing = false;
this.redrawPaths(); this.redrawPaths();
this.pulseCell(next, FLOW[k % FLOW.length].n); this.pulseCell(next, FLOW[k % FLOW.length].n);
playSound(this, SFX.CARD_PLACE); playScifiLaunch(this);
this.checkWin(); this.checkWin();
return true; return true;
} }
@ -855,6 +855,7 @@ export default class DotLinkGame extends Phaser.Scene {
this.owner[next] = k; this.owner[next] = k;
path.push(next); path.push(next);
this.redrawPaths(); this.redrawPaths();
playDotLinkSound(this, k, path.length);
return true; return true;
} }

View File

@ -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-riser', '/assets/fx/scifi-riser.mp3');
this.load.audio('sfx-scifi-reveal', '/assets/fx/scifi-reveal.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-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 }); this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -32,6 +32,8 @@ export const SFX = {
SCIFI_RISER: 'sfx-scifi-riser', SCIFI_RISER: 'sfx-scifi-riser',
SCIFI_REVEAL: 'sfx-scifi-reveal', SCIFI_REVEAL: 'sfx-scifi-reveal',
SCIFI_WOOSH: 'sfx-scifi-woosh', SCIFI_WOOSH: 'sfx-scifi-woosh',
SCIFI_PLINK: 'sfx-scifi-plink',
SCIFI_PLONK: 'sfx-scifi-plonk',
SWORD_HIT: 'sfx-sword-hit', SWORD_HIT: 'sfx-sword-hit',
SWORD_SLICE: 'sfx-sword-slice', SWORD_SLICE: 'sfx-sword-slice',
MONOPOLY_PURCHASE: 'sfx-monopoly-purchase', MONOPOLY_PURCHASE: 'sfx-monopoly-purchase',
@ -93,3 +95,12 @@ export function playScifiWoosh(scene) {
_scifiWooshSound = scene.sound.add(SFX.SCIFI_WOOSH); _scifiWooshSound = scene.sound.add(SFX.SCIFI_WOOSH);
_scifiWooshSound.play(); _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();
}