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.
This commit is contained in:
tiennm99 committed 2026-04-26 08:27:30 +07:00
1 parent bb8d3a61d0
commit 4e7b58405a
12 files changed
+1115 -81

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
<script>
// AffordanceBanner — stacked warning banners shown when level disables undo/preview.
// Props match the affordances object: undo (bool), preview (bool).
// Renders nothing when both are enabled.
import { getText } from '../lib/localization.js';
let { undo = true, preview = true } = $props();
let noUndo = $derived(undo === false);
let noPreview = $derived(preview === false);
let hasAny = $derived(noUndo || noPreview);
</script>
{#if hasAny}
<div class="affordance-banners">
{#if noUndo}
<div class="affordance-banner warning" role="alert">
{getText('banner.noUndo')}
</div>
{/if}
{#if noPreview}
<div class="affordance-banner warning" role="alert">
{getText('banner.noPreview')}
</div>
{/if}
</div>
{/if}
<style>
.affordance-banners {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.affordance-banner {
font: var(--font-small);
padding: 6px 16px;
border-radius: 4px;
text-align: center;
}
.affordance-banner.warning {
background: rgba(200, 120, 0, 0.25);
border: 1px solid rgba(200, 120, 0, 0.6);
color: #ffaa44;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
</style>
+164 -3
View File
@@ -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';
}
</script>
<div
@@ -47,16 +77,61 @@
class:wall={cell.isWall}
class:preview={previewCells.has(`${cell.row},${cell.col}`) && !cell.isLight}
class:detected-flash={isDetected(cell)}
class:throw-target={isThrowTarget(cell)}
class:throw-cursor={isThrowCursor(cell)}
role="gridcell"
aria-label={cellLabel(cell)}
onclick={() => oncellclick?.(cell.row, cell.col)}
>
<Pixel art={tile.art} palette={tile.pal} width={cellSize} height={cellSize} />
<!-- Preview overlay (turn-preview ghost) -->
{#if previewCells.has(`${cell.row},${cell.col}`) && !cell.isLight}
<div class="preview-overlay">
<Pixel art={TILE_PREVIEW} palette={TILE_PREVIEW_PAL} width={cellSize} height={cellSize} />
</div>
{/if}
<!-- Door overlay: colored border + padlock glyph -->
{#if cell.isDoor}
<div
class="door-overlay"
style="border-color: {doorBorderColor(cell.doorKeyId)}; box-shadow: inset 0 0 0 2px {doorBorderColor(cell.doorKeyId)};"
aria-hidden="true"
>
<span class="door-glyph">🔒</span>
</div>
{/if}
<!-- Key overlay: colored circle + key glyph -->
{#if cell.isKey}
<div
class="key-overlay"
style="background: radial-gradient(circle, {KEY_COLORS[cell.keyId] ?? '#888'}44 60%, transparent 100%);"
aria-hidden="true"
>
<span class="key-glyph" style="color: {KEY_COLORS[cell.keyId] ?? '#888'};">🗝</span>
</div>
{/if}
<!-- One-way overlay: arrow glyph in allowed-entry direction -->
{#if cell.isOneWay}
<div class="oneway-overlay" aria-hidden="true">
<span class="oneway-arrow">{ONEWAY_ARROWS[cell.oneWayDir] ?? '?'}</span>
</div>
{/if}
<!-- Warm cell overlay: dim orange glow (distinct from yellow lit cells) -->
{#if cell.isWarm && !cell.isLight}
<div class="warm-overlay" aria-hidden="true"></div>
{/if}
<!-- Throw targeting overlays -->
{#if isThrowCursor(cell)}
<div class="throw-cursor-ring" aria-hidden="true"></div>
{:else if isThrowTarget(cell)}
<div class="throw-target-ring" aria-hidden="true"></div>
{/if}
</div>
{/each}
</div>
@@ -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; }
}
</style>
+24 -22
View File
@@ -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));
</script>
<div class="hud">
<div class="hud-left">
<div class="lives" aria-label="{getText('lives')}{lives}">
{#each hearts as full}
<Pixel art={full ? HEART_ART : HEART_ART_EMPTY} palette={HEART_PAL} width={20} height={20} />
{/each}
</div>
<span class="turns">Turns: {turns}</span>
{#if showStones}
<StonesCounter {stonesLeft} />
{/if}
{#if showKeys}
<KeyInventory {keysHeld} />
{/if}
</div>
<div class="hud-right">
<span class="level-label">{getText('level')}{level}</span>
<button class="icon-btn" onclick={onundo} disabled={!canUndo} aria-label="Undo">
<Pixel art={ICON_UNDO} palette={ICON_PAL} width={20} height={20} />
</button>
{#if allowUndo}
<button class="icon-btn" onclick={onundo} disabled={!canUndo} aria-label="Undo">
<Pixel art={ICON_UNDO} palette={ICON_PAL} width={20} height={20} />
</button>
{/if}
<Button text={muted ? 'MUTE' : 'SND'} onclick={toggleMute} small />
<button class="icon-btn" class:active={showPreview} onclick={ontogglepreview} aria-label="Toggle preview">
<Pixel art={ICON_EYE} palette={ICON_PAL} width={20} height={20} />
</button>
{#if allowPreview}
<button class="icon-btn" class:active={showPreview} onclick={ontogglepreview} aria-label="Toggle preview">
<Pixel art={ICON_EYE} palette={ICON_PAL} width={20} height={20} />
</button>
{/if}
<Button text="?" onclick={onshowcontrols} small />
<button class="icon-btn" onclick={onpause} aria-label={getText('pause')}>
<Pixel art={ICON_PAUSE} palette={ICON_PAL} width={20} height={20} />
@@ -59,11 +66,6 @@
align-items: center;
gap: 12px;
}
.lives {
display: flex;
gap: 2px;
align-items: center;
}
.turns { color: var(--text-secondary); font: var(--font-small); }
.level-label { font: var(--font-ui); color: var(--text-primary); }
.icon-btn {
+155 -7
View File
@@ -1,8 +1,10 @@
<script>
import Pixel from '../lib/pixel/Pixel.svelte';
import SuspicionRing from './SuspicionRing.svelte';
import { GUARD_SPRITES } from '../lib/pixel/art-characters.js';
let { guard, cellSize = 50 } = $props();
let { guard, cellSize = 50, allCells = [] } = $props();
let top = $derived(guard.row * cellSize + cellSize / 2);
let left = $derived(guard.col * cellSize + cellSize / 2);
let size = $derived(Math.floor(cellSize * 0.95));
@@ -12,21 +14,166 @@
);
let sprite = $derived(GUARD_SPRITES[guard.type]);
let activePal = $derived(
guard.type === 'blinking' && !guard.isOn ? sprite.palOff : sprite.pal
guard.type === 'blinking' && !guard.isOn ? sprite?.palOff : sprite?.pal
);
// Sniper: compute beam cells visually by re-walking beam logic from guard position.
// NOTE: this duplicates SniperGuard._castBeam logic. If engine beam logic changes,
// update both here and in guards.js. Kept here to avoid cross-module coupling in UI.
const FACING_DIRS = [
{ row: -1, col: 0 }, // 0=up
{ row: 0, col: 1 }, // 1=right
{ row: 1, col: 0 }, // 2=down
{ row: 0, col: -1 }, // 3=left
];
function computeBeamCells(g, cells) {
if (!cells || cells.length === 0) return [];
// Build a quick wall-lookup from flat cells array
const wallSet = new Set();
let maxRow = 0, maxCol = 0;
for (const c of cells) {
if (c.isWall) wallSet.add(`${c.row},${c.col}`);
if (c.row > maxRow) maxRow = c.row;
if (c.col > maxCol) maxCol = c.col;
}
const isWall = (r, c) => wallSet.has(`${r},${c}`);
const inBounds = (r, c) => r >= 0 && r <= maxRow && c >= 0 && c <= maxCol;
const beam = [];
const dir = FACING_DIRS[g.direction ?? g.facing ?? 0];
let cr = g.row, cc = g.col;
const maxRange = maxRow + maxCol + 2;
for (let i = 1; i <= maxRange; i++) {
const r = cr + dir.row * i;
const c = cc + dir.col * i;
if (!inBounds(r, c) || isWall(r, c)) break;
beam.push({ row: r, col: c });
}
return beam;
}
// Derive beam cells reactively when guard is sniper type
let beamCells = $derived(
guard.type === 'sniper' ? computeBeamCells(guard, allCells) : []
);
// Triangle polygon points for sniper sprite, pointing in `facing` direction
// Triangle is drawn within a [0,size] × [0,size] box
function sniperTrianglePoints(sz, dir) {
const h = sz;
const w = sz;
const m = sz / 2;
// Points: (tip, base-left, base-right) depending on direction
switch (dir) {
case 0: return `${m},2 2,${h - 2} ${w - 2},${h - 2}`; // up
case 1: return `${w - 2},${m} 2,2 2,${h - 2}`; // right
case 2: return `${m},${h - 2} 2,2 ${w - 2},2`; // down
case 3: return `2,${m} ${w - 2},2 ${w - 2},${h - 2}`; // left
default: return `${m},2 2,${h - 2} ${w - 2},${h - 2}`;
}
}
// Suspicion tier color for the guard body tint
function suspicionColor(tier) {
if (tier >= 2) return '#ff3300';
if (tier === 1) return '#ffcc00';
return '#8844aa'; // idle purple
}
// Human-readable ARIA label for the guard sprite
let ariaLabel = $derived((() => {
switch (guard.type) {
case 'sniper': {
const dirs = ['up','right','down','left'];
return `Sniper guard facing ${dirs[guard.direction ?? 0]}`;
}
case 'suspicion':
return `Suspicious guard, tier ${guard.tier ?? 0}`;
default:
return `${guard.type} guard`;
}
})());
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="guard {guard.type}"
class:off={guard.type === 'blinking' && !guard.isOn}
class:alert={guard.type === 'chaser' && guard.isChasing}
style="top: {top}px; left: {left}px; width: {size}px; height: {size}px;"
role="img"
aria-label={ariaLabel}
>
{#if sprite}
<Pixel art={sprite.art} palette={activePal} width={size} height={size} />
{/if}
{#if hasDirection}
<div class="direction-indicator" style="transform: rotate({rotation}deg);"></div>
<!-- ── Sniper guard: triangle SVG + beam line (overflow:visible draws into board) ── -->
{#if guard.type === 'sniper'}
{@const facing = guard.direction ?? guard.facing ?? 0}
{@const beamDir = FACING_DIRS[facing]}
{@const beamLen = beamCells.length}
{@const beamEndX = size / 2 + beamDir.col * beamLen * cellSize}
{@const beamEndY = size / 2 + beamDir.row * beamLen * cellSize}
<svg
width={size}
height={size}
viewBox="0 0 {size} {size}"
style="overflow: visible; position: absolute; top: 0; left: 0;"
aria-hidden="true"
>
<!-- Dashed beam line drawn from guard center to beam terminus (overflow: visible) -->
{#if beamLen > 0}
<line
x1={size / 2} y1={size / 2}
x2={beamEndX} y2={beamEndY}
stroke="rgba(255, 60, 0, 0.45)"
stroke-width="2"
stroke-dasharray="5 4"
/>
{/if}
<!-- Triangle body pointing in facing direction -->
<polygon
points={sniperTrianglePoints(size, facing)}
fill="#cc3300"
stroke="#ff6600"
stroke-width="2"
/>
<!-- Eye dot at center -->
<circle
cx={size / 2} cy={size / 2}
r={size * 0.08}
fill="#ffaa00"
/>
</svg>
<!-- ── Suspicion guard: circle with tier ring overlay ── -->
{:else if guard.type === 'suspicion'}
<svg width={size} height={size} viewBox="0 0 {size} {size}" aria-hidden="true">
<circle
cx={size / 2} cy={size / 2}
r={size * 0.38}
fill={suspicionColor(guard.tier ?? 0)}
stroke="rgba(255,255,255,0.3)"
stroke-width="1.5"
/>
<!-- Suspicion level dots -->
{#if (guard.tier ?? 0) >= 1}
<circle cx={size / 2} cy={size * 0.22} r={size * 0.07} fill="#fff" opacity="0.9" />
{/if}
{#if (guard.tier ?? 0) >= 2}
<circle cx={size * 0.3} cy={size * 0.72} r={size * 0.07} fill="#fff" opacity="0.9" />
<circle cx={size * 0.7} cy={size * 0.72} r={size * 0.07} fill="#fff" opacity="0.9" />
{/if}
</svg>
<!-- SuspicionRing overlay positioned over this sprite -->
<SuspicionRing tier={guard.tier ?? 0} {size} />
<!-- ── Standard guard types (existing behavior unchanged) ── -->
{:else}
{#if sprite}
<Pixel art={sprite.art} palette={activePal} width={size} height={size} />
{/if}
{#if hasDirection}
<div class="direction-indicator" style="transform: rotate({rotation}deg);"></div>
{/if}
{/if}
</div>
@@ -56,4 +203,5 @@
transform-origin: bottom center;
translate: -50% 0;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script>
// KeyInventory — HUD chip row showing collected keys.
// keysHeld is a bitmask: bit 0 = key 1 (gold), bit 1 = key 2 (silver), bit 2 = key 3 (copper).
// Only mounted when level.keys?.length > 0.
import { getText } from '../lib/localization.js';
import Pixel from '../lib/pixel/Pixel.svelte';
import { TILE_KEY, TILE_KEY_GOLD_PAL, TILE_KEY_SILVER_PAL, TILE_KEY_COPPER_PAL } from '../lib/pixel/art-tiles.js';
const KEY_DEFS = [
{ id: 1, color: '#d4af37', pal: TILE_KEY_GOLD_PAL },
{ id: 2, color: '#c0c0c0', pal: TILE_KEY_SILVER_PAL },
{ id: 3, color: '#b87333', pal: TILE_KEY_COPPER_PAL },
];
let { keysHeld = 0 } = $props();
let keyStates = $derived(KEY_DEFS.map(k => ({
...k,
held: (keysHeld & (1 << (k.id - 1))) !== 0,
label: getText('mechanics.key.aria').replace('{keyId}', k.id),
})));
let relevantKeys = $derived(keyStates.filter(k => k.held));
</script>
{#if relevantKeys.length > 0}
<div class="key-inventory" aria-label={getText('mechanics.keys.label')}>
{#each relevantKeys as k (k.id)}
<div
class="key-chip"
style="border-color: {k.color}; background: {k.color}22;"
title={k.label}
aria-label={k.label}
>
<span class="key-icon" aria-hidden="true">
<Pixel art={TILE_KEY} palette={k.pal} scale={1.5} />
</span>
</div>
{/each}
</div>
{/if}
<style>
.key-inventory {
display: flex;
align-items: center;
gap: 5px;
}
.key-chip {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 2px solid;
border-radius: 6px;
user-select: none;
}
.key-icon {
display: flex;
align-items: center;
line-height: 0;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script>
// StonesCounter — HUD pill showing remaining throwable stones.
// Only mounted when level.stones > 0.
// Uses SVG pixel-art stone icon instead of emoji for consistent rendering.
import { getText } from '../lib/localization.js';
import Pixel from '../lib/pixel/Pixel.svelte';
import { ICON_STONE, ICON_STONE_PAL } from '../lib/pixel/art-tiles.js';
let { stonesLeft = 0 } = $props();
let label = $derived(`${getText('mechanics.stones.label')}: ${stonesLeft}`);
</script>
<div class="stones-counter" aria-label={label}>
<span class="stone-icon" aria-hidden="true">
<Pixel art={ICON_STONE} palette={ICON_STONE_PAL} scale={1.5} />
</span>
<span class="stone-times" aria-hidden="true">×</span>
<span class="stone-count">{stonesLeft}</span>
</div>
<style>
.stones-counter {
display: flex;
align-items: center;
gap: 4px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(180, 130, 80, 0.5);
border-radius: 12px;
padding: 3px 10px;
font: var(--font-ui);
color: var(--text-primary);
user-select: none;
}
.stone-icon {
display: flex;
align-items: center;
line-height: 0;
}
.stone-times { color: var(--text-secondary); font-size: 0.75em; }
.stone-count { font-weight: bold; min-width: 1ch; text-align: center; }
</style>
+39
View File
@@ -0,0 +1,39 @@
<script>
// Suspicion ring overlay — positioned absolute over the guard sprite.
// tier 0: invisible; tier 1: yellow ring; tier 2: red + pulsing animation.
let { tier = 0, size = 48 } = $props();
let ringColor = $derived(tier >= 2 ? '#ff3300' : '#ffcc00');
let visible = $derived(tier > 0);
let pulsing = $derived(tier >= 2);
</script>
{#if visible}
<div
class="suspicion-ring"
class:pulsing
style="width: {size}px; height: {size}px; border-color: {ringColor};
box-shadow: 0 0 {pulsing ? 10 : 5}px {ringColor}88;"
aria-hidden="true"
></div>
{/if}
<style>
.suspicion-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 3px solid;
pointer-events: none;
z-index: 6;
}
.suspicion-ring.pulsing {
animation: suspicion-pulse 0.5s ease-in-out infinite alternate;
}
@keyframes suspicion-pulse {
from { opacity: 0.6; transform: translate(-50%, -50%) scale(0.95); }
to { opacity: 1.0; transform: translate(-50%, -50%) scale(1.08); }
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script>
import { getText } from '../lib/localization.js';
// ThrowTargetingOverlay — rendered on top of the board when throw-targeting mode is active.
// Highlights valid targets (green halo), invalid cells reachable by cursor (red tint),
// and the cursor cell (bright yellow focus ring).
//
// Props:
// cursor {row, col} — current cursor position
// validTargets Set<string> — "r,c" strings for cells that are valid throw targets
// playerPos {row, col} — player position (used to compute invalid range cells)
// rows, cols grid dimensions
// cellSize pixels per cell
let { cursor = null, validTargets = new Set(), playerPos = null,
rows = 6, cols = 6, cellSize = 50 } = $props();
const MAX_THROW_DIST = 3;
// Build the set of all cells within Manhattan ≤3 of the player (candidate range).
// Cells in range but NOT valid targets get a faint red tint.
let rangedCells = $derived((() => {
if (!playerPos) return new Set();
const s = new Set();
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const dist = Math.abs(r - playerPos.row) + Math.abs(c - playerPos.col);
if (dist > 0 && dist <= MAX_THROW_DIST) s.add(`${r},${c}`);
}
}
return s;
})());
// Cells in range that are not valid targets (wall, no LoS, no guard nearby)
let invalidCells = $derived((() => {
const s = new Set();
for (const key of rangedCells) {
if (!validTargets.has(key)) s.add(key);
}
return s;
})());
let cursorKey = $derived(cursor ? `${cursor.row},${cursor.col}` : '');
let cursorIsValid = $derived(validTargets.has(cursorKey));
</script>
<!-- Overlay absolutely positioned over the board container -->
<div
class="targeting-overlay"
style="width: {cols * cellSize}px; height: {rows * cellSize}px;"
aria-hidden="true"
>
<!-- Valid target cells: green halo -->
{#each [...validTargets] as key (key)}
{@const [r, c] = key.split(',').map(Number)}
{#if cursorKey !== key}
<div
class="cell-halo valid"
style="top: {r * cellSize}px; left: {c * cellSize}px;
width: {cellSize}px; height: {cellSize}px;"
></div>
{/if}
{/each}
<!-- Invalid in-range cells: faint red tint -->
{#each [...invalidCells] as key (key)}
{@const [r, c] = key.split(',').map(Number)}
{#if cursorKey !== key}
<div
class="cell-halo invalid"
style="top: {r * cellSize}px; left: {c * cellSize}px;
width: {cellSize}px; height: {cellSize}px;"
></div>
{/if}
{/each}
<!-- Cursor cell: bright ring (color depends on validity) -->
{#if cursor}
<div
class="cell-cursor"
class:cursor-valid={cursorIsValid}
class:cursor-invalid={!cursorIsValid}
style="top: {cursor.row * cellSize}px; left: {cursor.col * cellSize}px;
width: {cellSize}px; height: {cellSize}px;"
></div>
{/if}
<!-- Dim overlay on entire board to signal targeting mode -->
<div class="board-dim"></div>
</div>
<!-- Hint bar below board -->
<div class="targeting-hint" role="status" aria-live="polite">
{#if cursorIsValid}
<span class="hint-valid">{getText('throw.hintTargeting')}</span>
{:else}
<span class="hint-invalid">Invalid target — move cursor or press Esc to cancel</span>
{/if}
</div>
<style>
.targeting-overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 10;
}
/* Dim the board slightly when in targeting mode */
.board-dim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.30);
z-index: 0;
pointer-events: none;
}
.cell-halo {
position: absolute;
pointer-events: none;
z-index: 1;
}
.cell-halo.valid {
border: 2px solid rgba(0, 220, 80, 0.7);
box-shadow: inset 0 0 8px rgba(0, 220, 80, 0.25);
background: rgba(0, 220, 80, 0.08);
}
.cell-halo.invalid {
background: rgba(220, 40, 0, 0.12);
border: 1px solid rgba(220, 40, 0, 0.3);
}
.cell-cursor {
position: absolute;
z-index: 2;
pointer-events: none;
border: 3px solid;
animation: cursor-pulse 0.7s ease-in-out infinite alternate;
}
.cell-cursor.cursor-valid {
border-color: rgba(255, 255, 60, 0.95);
box-shadow: 0 0 12px rgba(255, 255, 60, 0.6),
inset 0 0 8px rgba(0, 220, 80, 0.3);
background: rgba(255, 255, 60, 0.08);
}
.cell-cursor.cursor-invalid {
border-color: rgba(255, 80, 40, 0.9);
box-shadow: 0 0 10px rgba(255, 80, 40, 0.5);
background: rgba(255, 80, 40, 0.10);
}
@keyframes cursor-pulse {
from { opacity: 0.75; }
to { opacity: 1.00; }
}
.targeting-hint {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
border-radius: 4px;
padding: 4px 14px;
font: var(--font-small);
z-index: 20;
pointer-events: none;
white-space: nowrap;
}
.hint-valid { color: #aaff88; }
.hint-invalid { color: #ff9966; }
</style>
+35 -15
View File
@@ -1,5 +1,9 @@
// Undo/redo state history for game turns.
// Snapshots player, guards (via guard.capture()), turn count, and princess state.
// Snapshots player (including keysHeld), guards (via guard.capture()), turn count,
// princess state, throwSystem state, and grid key/door cell state.
//
// Phase 04.5 gap fix: keysHeld + key/door cell snapshots added here so that undo
// correctly restores inventory and grid state after key collection or door opening.
const MAX_HISTORY = 50;
@@ -11,13 +15,21 @@ export class GameHistory {
// Create a snapshot object without pushing to history.
// princessState: { alerted, alertRadius, messageShown } via princess.capture()
createSnapshot(player, guards, turnCount, princessState) {
// grid (optional): GridSystem instance — if provided, captures key/door cell state
// throwSystem (optional): ThrowableSystem instance — if provided, captures stone/pending state
createSnapshot(player, guards, turnCount, princessState, grid, throwSystem) {
return {
playerRow: player.row,
playerCol: player.col,
keysHeld: player.getKeysHeld ? player.getKeysHeld() : (player.keysHeld ?? 0),
turnCount,
princess: { ...princessState },
guards: guards.map(g => g.capture()),
// Grid snapshots: sparse arrays; null when grid not provided (e.g. levels without keys/doors)
keySnapshot: grid ? grid.getKeySnapshot() : null,
doorSnapshot: grid ? grid.getDoorSnapshot() : null,
// ThrowSystem state: null when system not present
throwSystem: throwSystem ? throwSystem.capture() : null,
};
}
@@ -27,31 +39,39 @@ export class GameHistory {
this.redoStack = [];
}
// Undo last move, returns restored state or null if nothing to undo
undo(player, guards, turnManager, princessState) {
if (this.undoStack.length === 0) return null;
this.redoStack.push(this.createSnapshot(player, guards, turnManager.turnCount, princessState));
const state = this.undoStack.pop();
// Restore a snapshot into live game objects.
// grid and throwSystem are optional — pass null for levels that don't use them.
_applySnapshot(state, player, guards, turnManager, grid, throwSystem) {
player.row = state.playerRow;
player.col = state.playerCol;
if (player.setKeysHeld) player.setKeysHeld(state.keysHeld ?? 0);
turnManager.turnCount = state.turnCount;
guards.forEach((g, i) => g.apply(state.guards[i]));
if (grid && state.keySnapshot !== null) grid.applyKeySnapshot(state.keySnapshot);
if (grid && state.doorSnapshot !== null) grid.applyDoorSnapshot(state.doorSnapshot);
if (throwSystem && state.throwSystem !== null) throwSystem.apply(state.throwSystem);
}
// Undo last move, returns restored state or null if nothing to undo
// grid and throwSystem are optional extras passed from Game.svelte
undo(player, guards, turnManager, princessState, grid, throwSystem) {
if (this.undoStack.length === 0) return null;
this.redoStack.push(this.createSnapshot(player, guards, turnManager.turnCount, princessState, grid, throwSystem));
const state = this.undoStack.pop();
this._applySnapshot(state, player, guards, turnManager, grid, throwSystem);
return state;
}
redo(player, guards, turnManager, princessState) {
redo(player, guards, turnManager, princessState, grid, throwSystem) {
if (this.redoStack.length === 0) return null;
this.undoStack.push(this.createSnapshot(player, guards, turnManager.turnCount, princessState));
this.undoStack.push(this.createSnapshot(player, guards, turnManager.turnCount, princessState, grid, throwSystem));
const state = this.redoStack.pop();
player.row = state.playerRow;
player.col = state.playerCol;
turnManager.turnCount = state.turnCount;
guards.forEach((g, i) => g.apply(state.guards[i]));
this._applySnapshot(state, player, guards, turnManager, grid, throwSystem);
return state;
}
+97
View File
@@ -4,6 +4,7 @@ import { Player } from './player.js';
import { TurnManager } from './turn-manager.js';
import { GameHistory } from './game-history.js';
import { PrincessMechanic } from './princess-mechanic.js';
import { ThrowableSystem } from './throwable.js';
import { RotatingGuard, ChaserGuard, PatrollingGuard } from './guards.js';
describe('GameHistory', () => {
@@ -115,3 +116,99 @@ describe('GameHistory', () => {
expect(history.redo(player, [], tm, princess.capture())).toBeNull();
});
});
describe('GameHistory — keys/doors/throwSystem snapshots', () => {
let grid, player, tm, history, princess;
beforeEach(() => {
grid = new GridSystem(6, 6, 50);
player = new Player(grid, 0, 0);
tm = new TurnManager();
history = new GameHistory();
princess = new PrincessMechanic();
});
it('snapshot includes keysHeld', () => {
player.addKey(1);
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
expect(snap.keysHeld).toBe(1);
});
it('snapshot includes keySnapshot (sparse array of remaining key cells)', () => {
grid.setKey(2, 3, 1);
grid.setKey(4, 5, 2);
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
expect(snap.keySnapshot).toEqual([[2, 3, 1], [4, 5, 2]]);
});
it('snapshot includes doorSnapshot (sparse array of remaining doors)', () => {
grid.setDoor(1, 2, 1);
grid.setDoor(3, 4, 2);
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
expect(snap.doorSnapshot).toEqual([[1, 2, 1], [3, 4, 2]]);
});
it('snapshot without grid yields null key/door snapshots (backward compat)', () => {
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture());
expect(snap.keySnapshot).toBeNull();
expect(snap.doorSnapshot).toBeNull();
});
it('undo round-trips keysHeld: collect key → snapshot+push → undo → keysHeld=0', () => {
grid.setKey(1, 0, 1);
// Capture before key collection
const before = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
history.pushSnapshot(before);
// Simulate player walking onto key cell
player.moveTo(1, 0, 2); // moveDir=2=down; auto-collects key
expect(player.getKeysHeld()).toBe(1);
expect(grid.isKey(1, 0)).toBe(false); // key cleared from grid
// Undo restores position, keysHeld, and key cell
history.undo(player, [], tm, princess.capture(), grid, null);
expect(player.row).toBe(0);
expect(player.col).toBe(0);
expect(player.getKeysHeld()).toBe(0);
expect(grid.isKey(1, 0)).toBe(true);
expect(grid.getKeyId(1, 0)).toBe(1);
});
it('undo round-trips door open: use key → open door → undo → door back, keysHeld=1', () => {
grid.setKey(1, 0, 1);
grid.setDoor(2, 0, 1);
// Snapshot before key pickup
const snap0 = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
history.pushSnapshot(snap0);
// Collect key
player.moveTo(1, 0, 2);
expect(player.getKeysHeld()).toBe(1);
// Snapshot before opening door
const snap1 = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, null);
history.pushSnapshot(snap1);
// Open door (player has key, door clears on entry)
player.moveTo(2, 0, 2);
expect(grid.isDoor(2, 0)).toBe(false); // door opened
// Undo: door reopens, player back at key cell
history.undo(player, [], tm, princess.capture(), grid, null);
expect(grid.isDoor(2, 0)).toBe(true);
expect(player.row).toBe(1);
expect(player.getKeysHeld()).toBe(1); // still holds key from snap1
});
it('snapshot captures throwSystem state', () => {
const ts = new ThrowableSystem(3);
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, ts);
expect(snap.throwSystem).toEqual({ stonesLeft: 3, pendingTarget: null });
});
it('undo restores stonesLeft via throwSystem', () => {
const ts = new ThrowableSystem(3);
// Snapshot at 3 stones
const snap = history.createSnapshot(player, [], tm.turnCount, princess.capture(), grid, ts);
history.pushSnapshot(snap);
// Use a stone (simulate)
ts.stonesLeft = 2;
// Undo: stone count restored
history.undo(player, [], tm, princess.capture(), grid, ts);
expect(ts.stonesLeft).toBe(3);
});
});
+269 -32
View File
@@ -8,11 +8,16 @@
import { TouchControls } from '../lib/game/touch-controls.js';
import { completeLevel, calculateStars } from '../lib/progress.js';
import { LEVELS } from '../lib/levels/levels.js';
import { playMove, playWait, playDetection, playLevelComplete, playUndo } from '../lib/audio.js';
import {
playMove, playWait, playDetection, playLevelComplete, playUndo,
playStoneThrow, playStoneImpact, playKeyPickup, playDoorUnlock,
playSuspicionAlert, playSuspicionFire,
} from '../lib/audio.js';
import GameBoard from '../components/GameBoard.svelte';
import PlayerSprite from '../components/PlayerSprite.svelte';
import GuardSprite from '../components/GuardSprite.svelte';
import GameHud from '../components/GameHud.svelte';
import ThrowTargetingOverlay from '../components/ThrowTargetingOverlay.svelte';
import DetectionPopup from '../components/DetectionPopup.svelte';
import LevelCompletePopup from '../components/LevelCompletePopup.svelte';
import PauseMenu from '../components/PauseMenu.svelte';
@@ -20,22 +25,26 @@
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();
// Game state
let grid = $state(null);
let player = $state(null);
let guards = $state([]);
let throwSystem = $state(null);
let turnManager = $state(new TurnManager());
let history = $state(new GameHistory());
let princess = $state(new PrincessMechanic());
let touch = new TouchControls();
let currentLevel = $state(level); // svelte-ignore state_referenced_locally
let livesRemaining = $state(lives); // svelte-ignore state_referenced_locally
let isFinalLevel = $state(false);
let goalRow = $state(0);
let goalCol = $state(0);
// Per-level affordance gates — default both enabled so existing levels work
// until phase 04 populates level.affordances in levels.js
let affordances = $state({ undo: true, preview: true });
// UI state
let isPaused = $state(false);
let detected = $state(false);
@@ -49,6 +58,15 @@
let showPreview = $state(false);
let showControls = $state(false);
// Throw-targeting state machine: 'idle' | 'targeting'
// When in targeting mode, arrow keys move cursor instead of player.
let throwMode = $state('idle');
let throwCursor = $state(null); // { row, col }
// Level-feature flags (set on initLevel, used by HUD conditional mounts)
let levelHasStones = $state(false);
let levelHasKeys = $state(false);
// Render version counter — incremented after each state mutation to force
// Svelte 5 to re-derive rendering data (class instances are not proxied)
let renderVersion = $state(0);
@@ -62,16 +80,94 @@
let playerRow = $derived((renderVersion, player ? player.row : 0));
let playerCol = $derived((renderVersion, player ? player.col : 0));
let guardSnapshots = $derived((renderVersion, guards.map(g => ({
row: g.row, col: g.col, type: g.type, direction: g.direction, isOn: g.isOn, isChasing: g.isChasing
row: g.row, col: g.col, type: g.type, direction: g.direction, isOn: g.isOn, isChasing: g.isChasing, tier: g.tier
}))));
let previewCells = $derived((renderVersion, showPreview && grid && player && guards.length
? turnManager.previewNextTurn(grid, player, guards)
let previewCells = $derived((renderVersion, showPreview && affordances.preview && grid && player && guards.length
? turnManager.previewNextTurn(grid, player, guards, throwSystem)
: new Set()));
let canUndo = $derived((renderVersion, history.canUndo()));
let scene = $derived(sceneForLevel(currentLevel));
// HUD reactive values
let stonesLeft = $derived((renderVersion, throwSystem ? throwSystem.stonesLeft : 0));
let keysHeld = $derived((renderVersion, player ? player.getKeysHeld() : 0));
// Audio delta tracking — previous values to detect changes each render
let _prevKeysHeld = $state(0);
let _prevOpenDoors = $state(0); // count of open doors tracked via grid cell flags
let _prevSuspicionTier = $state(0); // highest suspicion tier across guards
// Fire key-pickup / door-unlock / suspicion audio on state changes
$effect(() => {
// Keys: any new bit set in keysHeld → key was just collected
if (keysHeld !== _prevKeysHeld && keysHeld > _prevKeysHeld) {
playKeyPickup();
}
_prevKeysHeld = keysHeld;
});
// Suspicion guard tier monitoring
$effect(() => {
if (!guards || guards.length === 0) return;
// Read renderVersion to re-run after each turn
// eslint-disable-next-line no-unused-expressions
renderVersion;
const maxTier = guards.reduce((mx, g) => {
const t = (g.type === 'suspicion' && typeof g.tier === 'number')
? g.tier : 0;
return Math.max(mx, t);
}, 0);
if (maxTier > _prevSuspicionTier) {
if (maxTier === 1) playSuspicionAlert();
else if (maxTier >= 2) playSuspicionFire();
}
_prevSuspicionTier = maxTier;
});
// Throw-targeting: compute valid targets (Manhattan ≤3, LoS clear, ≥1 distractible guard within 2)
// Uses same logic as ThrowableSystem.throw() validation.
const DISTRACTIBLE = new Set(['rotating', 'patrolling', 'chaser']);
const MAX_THROW = 3;
// Simple Bresenham LoS check — mirrors throwable.js hasLineOfSight
function hasLoS(r0, c0, r1, c1) {
if (!grid) return false;
const dr = r1 - r0, dc = c1 - c0;
const steps = Math.max(Math.abs(dr), Math.abs(dc));
if (steps === 0) return true;
for (let i = 1; i < steps; i++) {
const r = Math.round(r0 + (dr * i) / steps);
const c = Math.round(c0 + (dc * i) / steps);
if (grid.isWall(r, c)) return false;
}
return true;
}
// Build the valid-target set for the overlay whenever targeting mode is active
let validThrowTargets = $derived((() => {
if (throwMode !== 'targeting' || !player || !grid) return new Set();
const result = new Set();
for (let r = 0; r < grid.rows; r++) {
for (let c = 0; c < grid.cols; c++) {
const dist = Math.abs(r - player.row) + Math.abs(c - player.col);
if (dist === 0 || dist > MAX_THROW) continue;
if (grid.isWall(r, c)) continue;
if (!hasLoS(player.row, player.col, r, c)) continue;
// Need at least one distractible guard within 2 of this target
const hasGuard = guards.some(g =>
DISTRACTIBLE.has(g.type) &&
Math.abs(g.row - r) + Math.abs(g.col - c) <= 2
);
if (hasGuard) result.add(`${r},${c}`);
}
}
return result;
})());
// Detection feedback timer — tracked so we can clear on unmount / re-init
let detectionTimeout = null;
// Restart-after-detection timer
let restartTimeout = null;
// Viewport scroll-follow — scrolls the board container so the player stays in view
// on grids larger than the viewport. Binds to .board-container element.
@@ -88,7 +184,7 @@
boardEl.scrollTo({ left: Math.max(0, target.left), top: Math.max(0, target.top), behavior: 'smooth' });
});
// Initialize level
// Initialize (or restart) the current level
function initLevel() {
const state = loadLevel(currentLevel);
if (!state) {
@@ -99,9 +195,16 @@
grid = state.grid;
player = state.player;
guards = state.guards;
throwSystem = state.throwSystem ?? null;
isFinalLevel = state.isFinalLevel;
goalRow = state.goalRow;
goalCol = state.goalCol;
// Read affordances from level data, default both enabled
const levelData = LEVELS[currentLevel - 1];
affordances = levelData?.affordances ?? { undo: true, preview: true };
// Level feature flags for conditional HUD mounts
levelHasStones = (levelData?.stones ?? 0) > 0;
levelHasKeys = (levelData?.keys?.length ?? 0) > 0;
turnManager = new TurnManager();
history = new GameHistory();
princess = new PrincessMechanic();
@@ -115,6 +218,23 @@
showLevelComplete = false;
completionStars = 0;
completionMoves = 0;
showPreview = false;
// Reset targeting state on level load
throwMode = 'idle';
throwCursor = null;
renderVersion++;
}
// Restart current level after detection — same as initLevel but named for clarity
function restartLevel() {
if (detectionTimeout) { clearTimeout(detectionTimeout); detectionTimeout = null; }
if (restartTimeout) { clearTimeout(restartTimeout); restartTimeout = null; }
// Reset throwSystem stone count from level data
if (throwSystem) {
const levelData = LEVELS[currentLevel - 1];
throwSystem.reset(levelData?.stones ?? 0);
}
initLevel();
}
onMount(() => {
@@ -144,12 +264,14 @@
});
onDestroy(() => {
if (detectionTimeout) clearTimeout(detectionTimeout);
if (restartTimeout) clearTimeout(restartTimeout);
if (typeof window !== 'undefined') delete window.__nntvDev;
});
// Capture current state as a snapshot object (does not push to history)
// Includes grid key/door state and throwSystem so undo fully restores inventory
function captureState() {
return history.createSnapshot(player, guards, turnManager.turnCount, princess.capture());
return history.createSnapshot(player, guards, turnManager.turnCount, princess.capture(), grid, throwSystem);
}
// Capture and push snapshot in one step (for wait action)
@@ -161,14 +283,39 @@
function onKeyDown(e) {
if (showControls) { if (e.key === 'Escape') showControls = false; return; }
if (isPaused || detected || showLevelComplete) return;
const dirMap = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
w: 'up', s: 'down', a: 'left', d: 'right',
};
if (e.key === 'v') { showPreview = !showPreview; return; }
// ── Targeting mode intercepts all navigation keys ──
if (throwMode === 'targeting') {
e.preventDefault();
if (e.key === 'Escape') {
throwMode = 'idle';
throwCursor = null;
return;
}
if (e.key === 'e' || e.key === 'E' || e.key === 'Enter') {
confirmThrow();
return;
}
// Arrow / WASD moves cursor
const cursorDir = dirMap[e.key];
if (cursorDir) { moveCursor(cursorDir); return; }
return; // swallow any other key in targeting mode
}
// ── Normal (idle) mode ──
if (e.key === 'v') {
if (affordances.preview) showPreview = !showPreview;
return;
}
if (e.key === ' ') { e.preventDefault(); handleWait(); return; }
if (e.key === 'z') { handleUndo(); return; }
if (e.key === 'y') { handleRedo(); return; }
if (e.key === 'z') { if (affordances.undo) handleUndo(); return; }
if (e.key === 'y') { if (affordances.undo) handleRedo(); return; }
if (e.key === 'e' || e.key === 'E') { enterTargeting(); return; }
const dir = dirMap[e.key];
if (dir) { e.preventDefault(); handleMove(dir); }
}
@@ -177,7 +324,7 @@
if (!player || !grid) return;
snapshotBeforeAction();
playWait();
const result = turnManager.nextTurn(grid, player, guards);
const result = turnManager.nextTurn(grid, player, guards, throwSystem);
if (isFinalLevel && checkFinalLevel()) { renderVersion++; return; }
renderVersion++;
if (result.levelComplete) handleLevelComplete();
@@ -186,6 +333,17 @@
function onCellClick(row, col) {
if (isPaused || detected || showLevelComplete || showControls || !player) return;
// In targeting mode: move cursor to clicked cell, auto-confirm if valid
if (throwMode === 'targeting') {
throwCursor = { row, col };
if (validThrowTargets.has(`${row},${col}`)) {
confirmThrow();
}
return;
}
// Normal mode: adjacent move or wait on own cell
if (row === player.row && col === player.col) { handleWait(); return; }
const rowDiff = Math.abs(row - player.row);
const colDiff = Math.abs(col - player.col);
@@ -207,7 +365,7 @@
history.pushSnapshot(preSnapshot);
playMove();
const result = turnManager.nextTurn(grid, player, guards);
const result = turnManager.nextTurn(grid, player, guards, throwSystem);
if (isFinalLevel && checkFinalLevel()) {
renderVersion++;
@@ -223,7 +381,51 @@
}
}
// Detection feedback — flash cell, shake player, play sound
// ── Throw-targeting state machine ──────────────────────────────────────────
// Enter targeting mode (E key from idle, stones > 0)
function enterTargeting() {
if (!throwSystem || throwSystem.stonesLeft <= 0) return;
if (!player) return;
throwMode = 'targeting';
throwCursor = { row: player.row, col: player.col };
}
// Move cursor one step in a direction (clamped to grid bounds)
function moveCursor(dir) {
if (!throwCursor || !grid) return;
let { row, col } = throwCursor;
if (dir === 'up') row = Math.max(0, row - 1);
if (dir === 'down') row = Math.min(grid.rows - 1, row + 1);
if (dir === 'left') col = Math.max(0, col - 1);
if (dir === 'right') col = Math.min(grid.cols - 1, col + 1);
throwCursor = { row, col };
}
// Confirm throw at current cursor position (if valid)
function confirmThrow() {
if (!throwCursor || !throwSystem || !player || !grid) return;
const { row, col } = throwCursor;
const isValid = validThrowTargets.has(`${row},${col}`);
if (!isValid) return; // stay in targeting mode — let player reposition cursor
snapshotBeforeAction();
const ok = throwSystem.throw(row, col, player, grid);
if (ok) {
playStoneThrow();
setTimeout(() => playStoneImpact(), 80);
const result = turnManager.nextTurn(grid, player, guards, throwSystem);
renderVersion++;
if (result.levelComplete) handleLevelComplete();
else if (result.detected) triggerDetection();
}
// Exit targeting mode whether throw succeeded or not
throwMode = 'idle';
throwCursor = null;
}
// Detection feedback — flash cell, shake player, play sound, then restart level.
// DetectionPopup is shown briefly; on dismiss (or after timeout) level reloads.
function triggerDetection() {
detectedCell = { row: player.row, col: player.col };
playerShake = true;
@@ -237,6 +439,12 @@
}, 400);
}
// Called when the player dismisses the DetectionPopup — restart current level
function handleDetectionDismiss() {
detected = false;
restartLevel();
}
// Undo/redo handlers
function applyHistoryState(state) {
princess.apply(state.princess);
@@ -248,7 +456,7 @@
function handleUndo() {
if (!player || !grid) return;
const state = history.undo(player, guards, turnManager, princess.capture());
const state = history.undo(player, guards, turnManager, princess.capture(), grid, throwSystem);
if (!state) return;
applyHistoryState(state);
playUndo();
@@ -257,7 +465,7 @@
function handleRedo() {
if (!player || !grid) return;
const state = history.redo(player, guards, turnManager, princess.capture());
const state = history.redo(player, guards, turnManager, princess.capture(), grid, throwSystem);
if (!state) return;
applyHistoryState(state);
renderVersion++;
@@ -300,21 +508,13 @@
const total = getTotalLevels();
const next = currentLevel + 1;
if (isFinalLevel) {
navigate('GameOver', { level: currentLevel, isLastLevel: true });
// L12 reached goal — bittersweet princess narrative
navigate('GameOver', { level: currentLevel, flow: 'bittersweet' });
} else if (next > total) {
navigate('GameOver', { level: currentLevel, isLastLevel: false });
// All non-final levels cleared (L11) — run complete celebration
navigate('GameOver', { level: currentLevel, flow: 'runComplete' });
} else {
navigate('LevelIntro', { level: next, lives: livesRemaining });
}
}
function handleDetectionDismiss() {
detected = false;
livesRemaining--;
if (livesRemaining <= 0) {
navigate('GameOver', { level: currentLevel, isLastLevel: false });
} else {
initLevel();
navigate('LevelIntro', { level: next });
}
}
</script>
@@ -328,12 +528,17 @@
<Pixel art={scene.art} palette={scene.pal} width={1024} height={384} />
</div>
<GameHud
lives={livesRemaining}
level={currentLevel}
{turns}
{showPreview}
{canUndo}
ontogglepreview={() => 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}
/>
<!-- Throw-targeting overlay (mounted when in targeting mode) -->
{#if throwMode === 'targeting'}
<ThrowTargetingOverlay
cursor={throwCursor}
validTargets={validThrowTargets}
playerPos={{ row: playerRow, col: playerCol }}
rows={grid.rows}
cols={grid.cols}
{cellSize}
/>
{/if}
<PlayerSprite row={playerRow} col={playerCol} {cellSize} shake={playerShake} />
{#each guardSnapshots as guard}
<GuardSprite {guard} {cellSize} />
<GuardSprite {guard} {cellSize} allCells={cells} />
{/each}
</div>
</div>
{/if}
<!-- Throw mode hint shown below the board when stones are available -->
{#if grid && player && levelHasStones && throwMode === 'idle' && stonesLeft > 0}
<div class="throw-hint" role="status">
{getText('throw.hintEnter')}
</div>
{/if}
{#if finalMessage}
<div class="final-message">
<p>{getText('princessDetected')}</p>
@@ -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);
+7 -2
View File
@@ -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 });
</script>
<div class="intro">
@@ -22,7 +26,8 @@
{#if storyText}
<p class="story">{storyText}</p>
{/if}
<Button text={getText('continue')} onclick={() => navigate('Game', { level, lives })} />
<AffordanceBanner undo={affordances.undo} preview={affordances.preview} />
<Button text={getText('continue')} onclick={() => navigate('Game', { level })} />
</div>
</div>