mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-16 08:15:26 +00:00
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:
1 parent
bb8d3a61d0
commit
4e7b58405a
12 files changed
+1115
-81
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user