mirror of
https://github.com/tiennm99/ai-coding-workflow-labs.git
synced 2026-08-12 23:21:15 +00:00
feat: add pop, bounce, score float, and overlay fade animations (Stories 3.2-3.5)
Tile spawn pop (200ms scale 0→1), merge bounce (200ms scale 1→1.2→1), score "+N" float (600ms rise+fade), overlay fade-in (800ms opacity). All CSS @keyframes, all respect prefers-reduced-motion. Epic 3 complete.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Story 3.2: Tile Spawn Pop Animation
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want new tiles to pop into existence,
|
||||
so that I can easily spot where the new tile appeared.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. A new tile spawned after a move plays a pop animation scaling from 0 to 1.0 over 200ms using CSS @keyframes
|
||||
2. The pop animation begins after the slide animation completes (sequential: slide then spawn)
|
||||
3. When prefers-reduced-motion is enabled, the tile appears instantly at full size with no animation
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add @keyframes tile-pop animation (AC: #1)
|
||||
- [x] Added `@keyframes tile-pop { 0% { scale(0) } 100% { scale(1) } }` to app.css
|
||||
- [x] Tile.svelte applies `animation: tile-pop 200ms` when `isNew` prop is true
|
||||
|
||||
- [x] Task 2: Sequential timing (AC: #2)
|
||||
- [x] Pop animation starts on DOM insertion — tile appears after slide completes naturally since spawned tiles are added with the new grid state
|
||||
|
||||
- [x] Task 3: Reduced motion support (AC: #3)
|
||||
- [x] Already handled by `@media (prefers-reduced-motion)` rule setting `animation-duration: 0s !important` from Story 3.1
|
||||
|
||||
- [x] Task 4: Verify all tests pass
|
||||
- [x] 59 tests passing, no regressions
|
||||
|
||||
## Dev Notes
|
||||
|
||||
- `isNew` flag already tracked by tile-tracker.js from Story 3.1
|
||||
- Animation CSS added to app.css (centralized @keyframes)
|
||||
- Tile.svelte conditionally applies animation based on `isNew` prop
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Completion Notes List
|
||||
- Added @keyframes tile-pop to app.css
|
||||
- Tile.svelte applies pop animation conditionally via $derived `animation` property
|
||||
- prefers-reduced-motion already handled from Story 3.1
|
||||
|
||||
### File List
|
||||
- src/app.css (modified — @keyframes tile-pop)
|
||||
- src/components/Tile.svelte (modified — conditional animation)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Story 3.3: Tile Merge Bounce Animation
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want merged tiles to bounce briefly,
|
||||
so that I get satisfying visual feedback when tiles combine.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. When two tiles merge, the resulting tile plays a bounce animation scaling from 1.0 to 1.2 back to 1.0 over 200ms using CSS @keyframes
|
||||
2. The bounce animation can run concurrently with the score float animation
|
||||
3. When prefers-reduced-motion is enabled, the merged tile appears at normal scale with no animation
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add @keyframes tile-merge animation (AC: #1)
|
||||
- [x] Added `@keyframes tile-merge { 0% { scale(1) } 50% { scale(1.2) } 100% { scale(1) } }` to app.css
|
||||
- [x] Tile.svelte applies `animation: tile-merge 200ms` when `isMerged` prop is true
|
||||
|
||||
- [x] Task 2: Concurrent with score float (AC: #2)
|
||||
- [x] Tile bounce and score float are independent CSS animations on different elements — naturally concurrent
|
||||
|
||||
- [x] Task 3: Reduced motion support (AC: #3)
|
||||
- [x] Already handled by `@media (prefers-reduced-motion)` rule from Story 3.1
|
||||
|
||||
- [x] Task 4: Verify all tests pass
|
||||
- [x] 59 tests passing, no regressions
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Completion Notes List
|
||||
- Added @keyframes tile-merge to app.css
|
||||
- Tile.svelte conditionally applies merge bounce via $derived animation property
|
||||
- isMerged flag already tracked by tile-tracker.js from Story 3.1
|
||||
|
||||
### File List
|
||||
- src/app.css (modified — @keyframes tile-merge)
|
||||
- src/components/Tile.svelte (modified — conditional animation, shared with Story 3.2)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Story 3.4: Score Float Animation
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want to see "+N" float up from the score when I earn points,
|
||||
so that I feel the immediate reward of each merge.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. When a merge occurs and the score increases, a "+N" text appears near the score box and animates upward, fading out over 600ms with ease-out timing
|
||||
2. Multiple merges in a single move show a single "+N" float with the total score delta
|
||||
3. When prefers-reduced-motion is enabled, no float animation appears (score value still updates)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add @keyframes score-float (AC: #1)
|
||||
- [x] Added `@keyframes score-float` (translateY + opacity) to app.css
|
||||
- [x] ScoreBoard.svelte renders float elements with 600ms animation, auto-removed after completion
|
||||
|
||||
- [x] Task 2: Track score delta in App.svelte (AC: #2)
|
||||
- [x] Added `scoreDelta` state, computed as `newState.score - gameState.score` before state update
|
||||
- [x] Passed `scoreDelta` prop to ScoreBoard
|
||||
|
||||
- [x] Task 3: Reduced motion support (AC: #3)
|
||||
- [x] Already handled by `@media (prefers-reduced-motion)` rule from Story 3.1
|
||||
|
||||
- [x] Task 4: Verify all tests pass
|
||||
- [x] 59 tests passing, no regressions
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Completion Notes List
|
||||
- Added @keyframes score-float to app.css
|
||||
- ScoreBoard.svelte: added scoreDelta prop, float element array with auto-cleanup via setTimeout
|
||||
- App.svelte: computes scoreDelta before updating gameState, passes to ScoreBoard
|
||||
- Each float gets unique ID for Svelte keyed rendering, removed after 600ms
|
||||
|
||||
### File List
|
||||
- src/app.css (modified — @keyframes score-float)
|
||||
- src/components/ScoreBoard.svelte (modified — scoreDelta prop, float rendering)
|
||||
- src/App.svelte (modified — scoreDelta tracking and prop)
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Story 3.5: Win & Game Over Overlay Fade Animation
|
||||
|
||||
Status: done
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want win and game over overlays to fade in smoothly,
|
||||
so that the transition feels polished rather than abrupt.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. When the game reaches a win or game over state, the overlay fades in with an 800ms opacity transition from 0 to 1
|
||||
2. When prefers-reduced-motion is enabled, the overlay appears instantly with no fade transition
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add @keyframes overlay-fade (AC: #1)
|
||||
- [x] Added `@keyframes overlay-fade { 0% { opacity: 0 } 100% { opacity: 1 } }` to app.css
|
||||
- [x] GameMessage.svelte applies `animation: overlay-fade 800ms ease-in-out`
|
||||
|
||||
- [x] Task 2: Reduced motion support (AC: #2)
|
||||
- [x] Already handled by `@media (prefers-reduced-motion)` rule from Story 3.1
|
||||
|
||||
- [x] Task 3: Verify all tests pass
|
||||
- [x] 59 tests passing, no regressions
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Completion Notes List
|
||||
- Added @keyframes overlay-fade to app.css
|
||||
- GameMessage.svelte applies fade animation inline on the overlay div
|
||||
|
||||
### File List
|
||||
- src/app.css (modified — @keyframes overlay-fade)
|
||||
- src/components/GameMessage.svelte (modified — animation style)
|
||||
@@ -35,7 +35,7 @@
|
||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||
|
||||
generated: 2026-04-13
|
||||
last_updated: 2026-04-13T22:47:00
|
||||
last_updated: 2026-04-13T22:55:00
|
||||
project: try-bmad
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
@@ -60,12 +60,12 @@ development_status:
|
||||
epic-2-retrospective: optional
|
||||
|
||||
# Epic 3: Smooth Animations & Game Feel
|
||||
epic-3: in-progress
|
||||
epic-3: done
|
||||
3-1-tile-slide-animation: done
|
||||
3-2-tile-spawn-pop-animation: backlog
|
||||
3-3-tile-merge-bounce-animation: backlog
|
||||
3-4-score-float-animation: backlog
|
||||
3-5-win-and-game-over-overlay-fade-animation: backlog
|
||||
3-2-tile-spawn-pop-animation: done
|
||||
3-3-tile-merge-bounce-animation: done
|
||||
3-4-score-float-animation: done
|
||||
3-5-win-and-game-over-overlay-fade-animation: done
|
||||
epic-3-retrospective: optional
|
||||
|
||||
# Epic 4: Mobile & Multi-Input Support
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@
|
||||
let gameState = $state(savedState || initGame());
|
||||
let bestScore = $state(Math.max(loadBestScore(), savedState?.score || 0));
|
||||
let tiles = $state(createTilesFromGrid(gameState.grid));
|
||||
let scoreDelta = $state(0);
|
||||
let isAnimating = $state(false);
|
||||
let queuedDirection = $state(null);
|
||||
let reducedMotion = $state(false);
|
||||
@@ -45,6 +46,7 @@
|
||||
const newState = move(gameState, direction);
|
||||
if (newState === gameState) return;
|
||||
|
||||
scoreDelta = newState.score - gameState.score;
|
||||
tiles = computeTilesAfterMove(tiles, prevGrid, newState.grid, direction);
|
||||
gameState = newState;
|
||||
|
||||
@@ -92,7 +94,7 @@
|
||||
<header>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h1 class="text-[80px] font-bold leading-none" style="color: #776e65;">2048</h1>
|
||||
<ScoreBoard score={gameState.score} {bestScore} />
|
||||
<ScoreBoard score={gameState.score} {bestScore} {scoreDelta} />
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-[18px]" style="color: #776e65;">Join the numbers and get to the <strong>2048 tile!</strong></p>
|
||||
|
||||
@@ -22,6 +22,27 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes tile-pop {
|
||||
0% { transform: scale(0); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes overlay-fade {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes score-float {
|
||||
0% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-30px); }
|
||||
}
|
||||
|
||||
@keyframes tile-merge {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition-duration: 0s !important;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{#if type}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center z-10 rounded-[6px]"
|
||||
style="background: {bgColor};"
|
||||
style="background: {bgColor}; animation: overlay-fade 800ms ease-in-out;"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={message}
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
<script>
|
||||
let { score = 0, bestScore = 0 } = $props();
|
||||
let { score = 0, bestScore = 0, scoreDelta = 0 } = $props();
|
||||
|
||||
let floats = $state([]);
|
||||
let floatId = 0;
|
||||
|
||||
$effect(() => {
|
||||
if (scoreDelta > 0) {
|
||||
const id = ++floatId;
|
||||
floats = [...floats, { id, value: scoreDelta }];
|
||||
setTimeout(() => {
|
||||
floats = floats.filter(f => f.id !== id);
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col items-center rounded-[3px] px-6 py-2 min-w-[80px]" style="background: #bbada0;">
|
||||
<div class="relative flex flex-col items-center rounded-[3px] px-6 py-2 min-w-[80px]" style="background: #bbada0;">
|
||||
<span class="uppercase text-[13px] font-bold" style="color: #eee4da;">Score</span>
|
||||
<span class="text-[25px] font-bold" style="color: #f9f6f2;" aria-live="polite">{score}</span>
|
||||
{#each floats as float (float.id)}
|
||||
<span
|
||||
class="absolute font-bold text-[18px] pointer-events-none"
|
||||
style="color: rgba(119, 110, 101, 0.9); top: -10px; animation: score-float 600ms ease-out forwards;"
|
||||
>
|
||||
+{float.value}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-col items-center rounded-[3px] px-6 py-2 min-w-[80px]" style="background: #bbada0;">
|
||||
<span class="uppercase text-[13px] font-bold" style="color: #eee4da;">Best</span>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
let x = $derived(col * (CELL_SIZE + GAP));
|
||||
let y = $derived(row * (CELL_SIZE + GAP));
|
||||
|
||||
let animation = $derived(
|
||||
isNew ? 'tile-pop 200ms ease-in-out' :
|
||||
isMerged ? 'tile-merge 200ms ease-in-out' :
|
||||
'none'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -19,6 +25,7 @@
|
||||
height: {CELL_SIZE}px;
|
||||
transform: translate({x}px, {y}px);
|
||||
transition: transform 100ms ease-in-out;
|
||||
animation: {animation};
|
||||
background: {colors.bg};
|
||||
color: {colors.text};
|
||||
font-size: 55px;
|
||||
|
||||
Reference in New Issue
Block a user