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}
+
+ {getText('banner.noUndo')}
+
+ {/if}
+ {#if noPreview}
+
+ {getText('banner.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}
+
+ {ONEWAY_ARROWS[cell.oneWayDir] ?? '?'}
+
+ {/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}
-