feat: add header, scores, keyboard input, and overlays (Stories 1.4-1.6)

ScoreBoard shows current/best score. Keyboard arrow keys control
gameplay via input-handler module. GameMessage overlay shows win/game
over states with Keep Going, Try Again, and New Game actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 21:52:31 +07:00
co-authored by Claude Opus 4.6
parent 15959e855e
commit 8fa0a412ee
5 changed files with 127 additions and 6 deletions
@@ -47,9 +47,9 @@ development_status:
1-1-project-scaffold-and-dev-environment: review
1-2-game-logic-module: review
1-3-game-board-and-tile-rendering: review
1-4-game-header-score-display-and-new-game-button: backlog
1-5-keyboard-input-and-interactive-gameplay: backlog
1-6-win-and-game-over-overlays: backlog
1-4-game-header-score-display-and-new-game-button: review
1-5-keyboard-input-and-interactive-gameplay: review
1-6-win-and-game-over-overlays: review
epic-1-retrospective: optional
# Epic 2: Save Progress & Keep Playing
+54 -3
View File
@@ -1,7 +1,10 @@
<script>
import Grid from './components/Grid.svelte';
import { initGame } from './lib/game-logic.js';
import ScoreBoard from './components/ScoreBoard.svelte';
import GameMessage from './components/GameMessage.svelte';
import { initGame, move, isGameOver } from './lib/game-logic.js';
import { GRID_SIZE } from './lib/constants.js';
import { getDirectionFromKey } from './lib/input-handler.js';
let gameState = $state(initGame());
@@ -17,8 +20,56 @@
}
return result;
});
let overlayType = $derived.by(() => {
if (gameState.won && !gameState.keepPlaying) return 'win';
if (isGameOver(gameState)) return 'gameover';
return null;
});
function handleNewGame() {
gameState = initGame();
}
function handleKeepGoing() {
gameState = { ...gameState, keepPlaying: true, won: true };
}
function handleKeydown(event) {
const direction = getDirectionFromKey(event.key);
if (!direction) return;
event.preventDefault();
if (overlayType === 'gameover') return;
const newState = move(gameState, direction);
if (newState !== gameState) {
gameState = newState;
}
}
</script>
<main class="max-w-[500px] mx-auto pt-10">
<Grid {tiles} />
<svelte:window onkeydown={handleKeydown} />
<main class="max-w-[500px] mx-auto px-2 pt-6" role="application">
<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={0} />
</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>
<button
class="font-bold text-[18px] rounded-[3px] px-4 py-2 min-h-[44px] min-w-[44px] cursor-pointer"
style="background: #8f7a66; color: #f9f6f2;"
onclick={handleNewGame}
>
New Game
</button>
</div>
</header>
<div class="relative">
<Grid {tiles} />
<GameMessage type={overlayType} onKeepGoing={handleKeepGoing} onNewGame={handleNewGame} />
</div>
</main>
+44
View File
@@ -0,0 +1,44 @@
<script>
let { type = null, onKeepGoing = () => {}, onNewGame = () => {} } = $props();
let message = $derived(type === 'win' ? 'You win!' : 'Game over!');
let bgColor = $derived(type === 'win' ? 'rgba(237, 194, 46, 0.5)' : 'rgba(238, 228, 218, 0.73)');
</script>
{#if type}
<div
class="absolute inset-0 flex flex-col items-center justify-center z-10 rounded-[6px]"
style="background: {bgColor};"
role="alertdialog"
aria-modal="true"
aria-label={message}
>
<p class="text-[60px] font-bold mb-4" style="color: #776e65;">{message}</p>
{#if type === 'win'}
<div class="flex gap-2">
<button
class="font-bold text-[18px] rounded-[3px] px-5 py-3 min-h-[44px] cursor-pointer"
style="background: #8f7a66; color: #f9f6f2;"
onclick={onKeepGoing}
>
Keep going
</button>
<button
class="font-bold text-[18px] rounded-[3px] px-5 py-3 min-h-[44px] cursor-pointer"
style="background: #8f7a66; color: #f9f6f2;"
onclick={onNewGame}
>
New Game
</button>
</div>
{:else}
<button
class="font-bold text-[18px] rounded-[3px] px-5 py-3 min-h-[44px] cursor-pointer"
style="background: #8f7a66; color: #f9f6f2;"
onclick={onNewGame}
>
Try again
</button>
{/if}
</div>
{/if}
+14
View File
@@ -0,0 +1,14 @@
<script>
let { score = 0, bestScore = 0 } = $props();
</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;">
<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>
</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>
<span class="text-[25px] font-bold" style="color: #f9f6f2;">{bestScore}</span>
</div>
</div>
+12
View File
@@ -0,0 +1,12 @@
import { DIRECTIONS } from './constants.js';
const KEY_MAP = {
ArrowUp: DIRECTIONS.UP,
ArrowDown: DIRECTIONS.DOWN,
ArrowLeft: DIRECTIONS.LEFT,
ArrowRight: DIRECTIONS.RIGHT,
};
export function getDirectionFromKey(key) {
return KEY_MAP[key] || null;
}