From 4e7b58405a136ccb311ec49564b78e49664da60c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 26 Apr 2026 08:27:30 +0700 Subject: [PATCH] feat(ui): add throw-targeting, affordance gates, suspicion overlays, key inventory Render new tile types (door/key/one-way/warm) and new guard sprites (sniper triangle+beam, suspicion ring). Five new HUD components. Throw-targeting state machine (E enter, arrows/click move, Enter/click confirm, Esc cancel). Per-level affordance gates wired to Z/Y/V key handlers. GameHistory snapshots extended to round-trip keysHeld + key/door cell state for correct undo. --- src/components/AffordanceBanner.svelte | 48 ++++ src/components/GameBoard.svelte | 167 ++++++++++- src/components/GameHud.svelte | 46 +-- src/components/GuardSprite.svelte | 162 ++++++++++- src/components/KeyInventory.svelte | 64 +++++ src/components/StonesCounter.svelte | 42 +++ src/components/SuspicionRing.svelte | 39 +++ src/components/ThrowTargetingOverlay.svelte | 171 +++++++++++ src/lib/game/game-history.js | 50 +++- src/lib/game/game-history.test.js | 97 +++++++ src/scenes/Game.svelte | 301 +++++++++++++++++--- src/scenes/LevelIntro.svelte | 9 +- 12 files changed, 1115 insertions(+), 81 deletions(-) create mode 100644 src/components/AffordanceBanner.svelte create mode 100644 src/components/KeyInventory.svelte create mode 100644 src/components/StonesCounter.svelte create mode 100644 src/components/SuspicionRing.svelte create mode 100644 src/components/ThrowTargetingOverlay.svelte diff --git a/src/components/AffordanceBanner.svelte b/src/components/AffordanceBanner.svelte new file mode 100644 index 0000000..76c08bb --- /dev/null +++ b/src/components/AffordanceBanner.svelte @@ -0,0 +1,48 @@ + + +{#if hasAny} +
+ {#if noUndo} + + {/if} + {#if noPreview} + + {/if} +
+{/if} + + diff --git a/src/components/GameBoard.svelte b/src/components/GameBoard.svelte index be70313..f3d27de 100644 --- a/src/components/GameBoard.svelte +++ b/src/components/GameBoard.svelte @@ -8,13 +8,27 @@ TILE_PREVIEW, TILE_PREVIEW_PAL, } from '../lib/pixel/art-tiles.js'; + // Key color palette by keyId: 1=gold, 2=silver, 3=copper + const KEY_COLORS = { 1: '#d4af37', 2: '#c0c0c0', 3: '#b87333' }; + // One-way arrow glyphs by numeric direction encoding (0=up,1=right,2=down,3=left) + const ONEWAY_ARROWS = { 0: '↑', 1: '→', 2: '↓', 3: '←' }; + // Human-readable direction names for ARIA + const DIR_NAMES = { 0: 'up', 1: 'right', 2: 'down', 3: 'left' }; + let { cells = [], rows = 6, cols = 6, cellSize = 50, previewCells = new Set(), - detectedCell = null, oncellclick } = $props(); + detectedCell = null, throwTargetCells = new Set(), throwCursor = null, + oncellclick } = $props(); function cellLabel(cell) { let label = `Row ${cell.row + 1}, Column ${cell.col + 1}`; if (cell.isWall) label += ', wall'; - else if (cell.isGoal) label += ', goal'; + else if (cell.isDoor) { + const color = KEY_COLORS[cell.doorKeyId] ? ` key ${cell.doorKeyId}` : ''; + label += `, door, locked${color}`; + } else if (cell.isGoal) label += ', goal'; + else if (cell.isKey) label += `, key ${cell.keyId}`; + else if (cell.isOneWay) label += `, one-way arrow ${DIR_NAMES[cell.oneWayDir] ?? cell.oneWayDir}`; + else if (cell.isWarm) label += ', warm cell'; if (cell.isLight) label += ', lit'; return label; } @@ -23,13 +37,29 @@ return detectedCell && detectedCell.row === cell.row && detectedCell.col === cell.col; } - // Resolve the tile art/palette for a cell. Priority: lit > goal > wall > empty. + // Is this cell a valid throw target (green halo)? + function isThrowTarget(cell) { + return throwTargetCells.has(`${cell.row},${cell.col}`); + } + + // Is this cell the throw cursor (bright ring)? + function isThrowCursor(cell) { + return throwCursor && throwCursor.row === cell.row && throwCursor.col === cell.col; + } + + // Resolve the base tile art/palette. Priority: lit > door > goal > wall > empty. + // Key, one-way, warm overlays are rendered on top of the base tile separately. function tileFor(cell) { if (cell.isLight) return { art: TILE_LIT, pal: TILE_LIT_PAL }; if (cell.isGoal) return { art: TILE_GOAL, pal: TILE_GOAL_PAL }; if (cell.isWall) return { art: TILE_WALL, pal: TILE_WALL_PAL }; return { art: TILE_EMPTY, pal: TILE_EMPTY_PAL }; } + + // Border color for door overlay (keyed by keyId) + function doorBorderColor(keyId) { + return KEY_COLORS[keyId] ?? '#888888'; + }
oncellclick?.(cell.row, cell.col)} > + + {#if previewCells.has(`${cell.row},${cell.col}`) && !cell.isLight}
{/if} + + + {#if cell.isDoor} + + {/if} + + + {#if cell.isKey} + + {/if} + + + {#if cell.isOneWay} + + {/if} + + + {#if cell.isWarm && !cell.isLight} + + {/if} + + + {#if isThrowCursor(cell)} + + {:else if isThrowTarget(cell)} + + {/if}
{/each} @@ -74,12 +149,16 @@ cursor: pointer; } .cell.wall { cursor: default; } + + /* Preview ghost overlay */ .preview-overlay { position: absolute; inset: 0; pointer-events: none; opacity: 0.7; } + + /* Detection flash animation */ .cell.detected-flash { animation: cell-flash 0.4s ease-out; } @@ -87,4 +166,86 @@ 0% { filter: brightness(2) hue-rotate(-60deg); } 100% { filter: none; } } + + /* ── Door overlay ── */ + .door-overlay { + position: absolute; + inset: 3px; + border: 3px solid; /* color set inline */ + border-radius: 3px; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + } + .door-glyph { + font-size: 1.1em; + line-height: 1; + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.8)); + } + + /* ── Key overlay ── */ + .key-overlay { + position: absolute; + inset: 0; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + } + .key-glyph { + font-size: 1em; + line-height: 1; + filter: drop-shadow(0 1px 3px rgba(0,0,0,0.9)); + } + + /* ── One-way overlay ── */ + .oneway-overlay { + position: absolute; + inset: 0; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + background: rgba(100, 180, 255, 0.08); + } + .oneway-arrow { + font-size: 1.3em; + color: rgba(140, 210, 255, 0.75); + line-height: 1; + text-shadow: 0 0 4px rgba(0,0,0,0.8); + } + + /* ── Warm cell overlay (distinct orange from yellow isLight) ── */ + .warm-overlay { + position: absolute; + inset: 0; + pointer-events: none; + background: rgba(255, 140, 0, 0.25); + border-radius: 1px; + } + + /* ── Throw targeting rings ── */ + .throw-target-ring { + position: absolute; + inset: 4px; + border: 2px solid rgba(0, 220, 80, 0.7); + border-radius: 3px; + pointer-events: none; + box-shadow: 0 0 6px rgba(0, 220, 80, 0.4); + } + .throw-cursor-ring { + position: absolute; + inset: 2px; + border: 3px solid rgba(255, 255, 0, 0.9); + border-radius: 3px; + pointer-events: none; + box-shadow: 0 0 10px rgba(255, 255, 0, 0.6); + animation: cursor-pulse 0.8s ease-in-out infinite alternate; + } + @keyframes cursor-pulse { + from { opacity: 0.7; } + to { opacity: 1.0; } + } diff --git a/src/components/GameHud.svelte b/src/components/GameHud.svelte index 79057c8..c952005 100644 --- a/src/components/GameHud.svelte +++ b/src/components/GameHud.svelte @@ -2,10 +2,15 @@ import { getText } from '../lib/localization.js'; import { isMuted, setMuted, playClick } from '../lib/audio.js'; import Button from './Button.svelte'; + import StonesCounter from './StonesCounter.svelte'; + import KeyInventory from './KeyInventory.svelte'; import Pixel from '../lib/pixel/Pixel.svelte'; - import { HEART_ART, HEART_ART_EMPTY, HEART_PAL, - ICON_UNDO, ICON_EYE, ICON_PAUSE, ICON_PAL } from '../lib/pixel/art-ui.js'; - let { lives = 3, level = 1, turns = 0, showPreview = false, canUndo = false, + import { ICON_UNDO, ICON_EYE, ICON_PAUSE, ICON_PAL } from '../lib/pixel/art-ui.js'; + + let { level = 1, turns = 0, showPreview = false, canUndo = false, + allowUndo = true, allowPreview = true, + stonesLeft = 0, showStones = false, + keysHeld = 0, showKeys = false, ontogglepreview, onpause, onmenu, onundo, onshowcontrols } = $props(); let muted = $state(isMuted()); @@ -14,29 +19,31 @@ setMuted(muted); if (!muted) playClick(); } - - const MAX_LIVES = 3; - let hearts = $derived(Array.from({ length: MAX_LIVES }, (_, i) => i < lives));
-
- {#each hearts as full} - - {/each} -
Turns: {turns} + {#if showStones} + + {/if} + {#if showKeys} + + {/if}
{getText('level')}{level} - + {#if allowUndo} + + {/if} + {#if allowPreview} + + {/if}
showPreview = !showPreview} + allowUndo={affordances.undo} + allowPreview={affordances.preview} + showStones={levelHasStones} + {stonesLeft} + showKeys={levelHasKeys} + {keysHeld} + ontogglepreview={() => { if (affordances.preview) showPreview = !showPreview; }} onpause={() => isPaused = true} onmenu={() => navigate('MainMenu')} onundo={handleUndo} @@ -352,14 +557,32 @@ {detectedCell} oncellclick={onCellClick} /> + + {#if throwMode === 'targeting'} + + {/if} {#each guardSnapshots as guard} - + {/each}
{/if} + + {#if grid && player && levelHasStones && throwMode === 'idle' && stonesLeft > 0} +
+ {getText('throw.hintEnter')} +
+ {/if} + {#if finalMessage}

{getText('princessDetected')}

@@ -447,6 +670,20 @@ border-radius: 4px; z-index: 50; } + .throw-hint { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.65); + border: 1px solid rgba(180, 130, 80, 0.4); + border-radius: 4px; + padding: 4px 12px; + font: var(--font-small); + color: var(--text-secondary); + pointer-events: none; + z-index: 10; + } .final-message p { font: var(--font-body); color: var(--text-danger); diff --git a/src/scenes/LevelIntro.svelte b/src/scenes/LevelIntro.svelte index 7d12cd9..4c28dc1 100644 --- a/src/scenes/LevelIntro.svelte +++ b/src/scenes/LevelIntro.svelte @@ -2,14 +2,18 @@ import { getText } from '../lib/localization.js'; import { LEVELS } from '../lib/levels/levels.js'; import Button from '../components/Button.svelte'; + import AffordanceBanner from '../components/AffordanceBanner.svelte'; import Pixel from '../lib/pixel/Pixel.svelte'; import { sceneForLevel } from '../lib/pixel/art-scenes.js'; - let { navigate, level = 1, lives = 3 } = $props(); + let { navigate, level = 1 } = $props(); let levelData = $derived(LEVELS[level - 1]); let levelName = $derived(levelData?.name || `Level ${level}`); let storyText = $derived(levelData?.storyKey ? getText(levelData.storyKey) : ''); let scene = $derived(sceneForLevel(level)); + + // Affordance gates — default both enabled until phase 04 populates levels.js + let affordances = $derived(levelData?.affordances ?? { undo: true, preview: true });
@@ -22,7 +26,8 @@ {#if storyText}

{storyText}

{/if} -