mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-16 08:15:26 +00:00
feat(ux): responsive board, touch controls, hints and mercy unlock
Difficulty should come from the puzzle, not the interface. The stage was hard-coded to 1024x768 with no media queries, stone throwing was bound to the E key with no on-screen control, and a stuck player had no way forward because progress advanced only on a win. - scale the board down to 360px so the 13x13 grid fits a phone - on-screen throw entry with 44px confirm and cancel targets - escalating hints at three, five and seven attempts; mercy unlock at eight - level-complete popup no longer renders the literal key name - keyboard retry is immediate instead of seven tabs through hidden buttons - delay the detection overlay so the death flash stays visible - suspicion range shown before the guard reaches tier one - dialog roles, focus trap and scene-transition focus management - localized level names and turn counter, dead aria keys removed
This commit is contained in:
1 parent
eeb3a711ee
commit
2a361c09b4
26 files changed
+1050
-173
No files matched your search
+35
-1
@@ -20,6 +20,29 @@
|
||||
let currentScene = $state('MainMenu');
|
||||
let sceneData = $state({});
|
||||
|
||||
// Focus management across scene transitions (F18): {#key currentScene}
|
||||
// fully replaces the DOM on every navigate(), so without this, focus
|
||||
// silently drops to <body> and a keyboard/screen-reader user has no
|
||||
// indication anything changed. Focusing the scene wrapper + announcing
|
||||
// its name via aria-live covers both cases with one small mechanism
|
||||
// rather than threading autofocus into every individual scene.
|
||||
let sceneEl = $state(null);
|
||||
const SCENE_LABEL_KEYS = {
|
||||
MainMenu: 'gameTitle', LevelSelect: 'levelSelectTitle', Guide: 'guideTitle',
|
||||
Settings: 'settings', GameOver: 'gameOver', StoryIntro: 'storyTitle',
|
||||
};
|
||||
function sceneAnnouncement(scene) {
|
||||
const key = SCENE_LABEL_KEYS[scene];
|
||||
return key ? getText(key) : scene;
|
||||
}
|
||||
$effect(() => {
|
||||
// Re-run whenever currentScene changes; wait a tick for the new DOM.
|
||||
currentScene;
|
||||
if (typeof queueMicrotask === 'function') {
|
||||
queueMicrotask(() => sceneEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
// Migration modal — shown once on first v2 boot if legacy save was detected
|
||||
let showMigrationModal = $state(false);
|
||||
|
||||
@@ -70,8 +93,9 @@
|
||||
</script>
|
||||
|
||||
<div class="game-container">
|
||||
<div class="visually-hidden" aria-live="polite">{sceneAnnouncement(currentScene)}</div>
|
||||
{#key currentScene}
|
||||
<div class="scene" transition:fade={{ duration: 250 }}>
|
||||
<div class="scene" bind:this={sceneEl} tabindex="-1" transition:fade={{ duration: 250 }}>
|
||||
{#if currentScene === 'MainMenu'}
|
||||
<MainMenu {navigate} />
|
||||
{:else if currentScene === 'StoryIntro'}
|
||||
@@ -116,6 +140,16 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.scene:focus {
|
||||
outline: none;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
<script>
|
||||
let { text, onclick, small = false, disabled = false } = $props();
|
||||
import { onMount } from 'svelte';
|
||||
// autofocus: focuses this button on mount. Used for the primary action of
|
||||
// modal overlays (DetectionPopup, PauseMenu, ControlsOverlay, ...) so a
|
||||
// keyboard player can press Enter/Space immediately instead of Tabbing
|
||||
// through hidden HUD controls to reach it.
|
||||
let { text, onclick, small = false, disabled = false, autofocus = false } = $props();
|
||||
let btnEl = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
if (autofocus && btnEl) btnEl.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<button class="btn" class:small {disabled} onclick={onclick}>
|
||||
<button bind:this={btnEl} class="btn" class:small {disabled} onclick={onclick}>
|
||||
{text}
|
||||
</button>
|
||||
|
||||
@@ -15,6 +25,7 @@
|
||||
border-radius: 4px;
|
||||
padding: 10px 32px;
|
||||
min-width: 220px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
@@ -24,5 +35,6 @@
|
||||
font: var(--font-button-small);
|
||||
padding: 6px 16px;
|
||||
min-width: 110px;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,16 @@
|
||||
<script>
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { trapFocus } from '../lib/focus-trap.js';
|
||||
import Button from './Button.svelte';
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { ICON_UNDO, ICON_REDO, ICON_EYE, ICON_ARROW, ICON_PAL } from '../lib/pixel/art-ui.js';
|
||||
let { onclose } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overlay" transition:fade={{ duration: 200 }}>
|
||||
<div class="overlay" transition:fade={{ duration: 200 }} use:trapFocus role="dialog" aria-modal="true" aria-labelledby="controls-title">
|
||||
<div class="popup">
|
||||
<h2>{getText('controlsTitle')}</h2>
|
||||
<h2 id="controls-title">{getText('controlsTitle')}</h2>
|
||||
<div class="controls-list">
|
||||
<div class="control-row">
|
||||
<span class="icon"><Pixel art={ICON_ARROW} palette={ICON_PAL} width={20} height={20} /></span>
|
||||
@@ -30,10 +31,13 @@
|
||||
<span class="icon"><Pixel art={ICON_REDO} palette={ICON_PAL} width={20} height={20} /></span>
|
||||
<kbd>Y</kbd><span>— {getText('controlRedo')}</span>
|
||||
</div>
|
||||
<div class="control-row"><kbd>E</kbd><span>— {getText('controlThrow')}</span></div>
|
||||
<div class="control-row"><kbd>Esc</kbd><span>— {getText('controlPause')}</span></div>
|
||||
<div class="control-row"><kbd>Click</kbd><span>/ Tap — {getText('controlTap')}</span></div>
|
||||
<div class="control-row"><kbd>Tap self</kbd><span>— {getText('controlTapSelf')}</span></div>
|
||||
<div class="control-row"><kbd>Swipe</kbd><span>— {getText('controlSwipe')}</span></div>
|
||||
</div>
|
||||
<Button text={getText('back')} onclick={onclose} />
|
||||
<Button text={getText('back')} onclick={onclose} autofocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<script>
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { trapFocus } from '../lib/focus-trap.js';
|
||||
import Button from './Button.svelte';
|
||||
let { onplayagain } = $props();
|
||||
// mercyEligible: shown after repeated failures on this level (see
|
||||
// progress.js MERCY_THRESHOLD). Deliberately a separate, clearly-labelled
|
||||
// button — never auto-triggered — so skipping never feels sprung on the
|
||||
// player.
|
||||
let { onplayagain, mercyEligible = false, onmercyskip } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overlay" transition:fade={{ duration: 200 }}>
|
||||
<div class="overlay" transition:fade={{ duration: 200 }} use:trapFocus role="dialog" aria-modal="true" aria-labelledby="detected-title">
|
||||
<div class="popup">
|
||||
<h2>{getText('detected')}</h2>
|
||||
<Button text={getText('playAgain')} onclick={onplayagain} />
|
||||
<h2 id="detected-title">{getText('detected')}</h2>
|
||||
<Button text={getText('playAgain')} onclick={onplayagain} autofocus />
|
||||
{#if mercyEligible}
|
||||
<div class="mercy-block">
|
||||
<p class="mercy-note">{getText('mercy.available')}</p>
|
||||
<Button text={getText('mercy.button')} onclick={onmercyskip} small />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,4 +43,19 @@
|
||||
font: var(--font-heading);
|
||||
color: var(--text-danger);
|
||||
}
|
||||
.mercy-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(180, 130, 80, 0.4);
|
||||
}
|
||||
.mercy-note {
|
||||
font: var(--font-small);
|
||||
color: #ffaa44;
|
||||
text-align: center;
|
||||
max-width: 260px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,26 @@
|
||||
<script>
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { GENERATED_TILES } from '../lib/generated-assets.js';
|
||||
|
||||
// Key color palette by keyId: 1=gold, 2=silver, 3=copper
|
||||
const KEY_COLORS = { 1: '#d4af37', 2: '#c0c0c0', 3: '#b87333' };
|
||||
// Human-readable direction names for ARIA
|
||||
const DIR_NAMES = { 0: 'up', 1: 'right', 2: 'down', 3: 'left' };
|
||||
// Locale keys for direction names, keyed by the engine's numeric direction
|
||||
const DIR_KEYS = { 0: 'board.dirUp', 1: 'board.dirRight', 2: 'board.dirDown', 3: 'board.dirLeft' };
|
||||
|
||||
let { cells = [], rows = 6, cols = 6, cellSize = 50, previewCells = new Set(),
|
||||
detectedCell = null, throwTargetCells = new Set(), throwCursor = null,
|
||||
oncellclick } = $props();
|
||||
detectedCell = null, oncellclick } = $props();
|
||||
|
||||
function cellLabel(cell) {
|
||||
let label = `Row ${cell.row + 1}, Column ${cell.col + 1}`;
|
||||
if (cell.isWall) label += ', wall';
|
||||
let label = getText('board.rowCol').replace('{row}', cell.row + 1).replace('{col}', cell.col + 1);
|
||||
if (cell.isWall) label += `, ${getText('board.wall')}`;
|
||||
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';
|
||||
label += `, ${getText('board.doorLocked').replace('{keyId}', cell.doorKeyId)}`;
|
||||
} else if (cell.isGoal) label += `, ${getText('board.goal')}`;
|
||||
else if (cell.isKey) label += `, ${getText('board.key').replace('{keyId}', cell.keyId)}`;
|
||||
else if (cell.isOneWay) {
|
||||
const dirText = getText(DIR_KEYS[cell.oneWayDir] ?? 'board.dirUp');
|
||||
label += `, ${getText('board.oneWay').replace('{dir}', dirText)}`;
|
||||
}
|
||||
else if (cell.isWarm) label += `, ${getText('board.warm')}`;
|
||||
if (cell.isLight) label += `, ${getText('board.lit')}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -28,15 +28,9 @@
|
||||
return detectedCell && detectedCell.row === cell.row && detectedCell.col === cell.col;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Note: throw-targeting visuals are rendered by ThrowTargetingOverlay
|
||||
// (a dedicated overlay component), not here — this board has no
|
||||
// throw-related props.
|
||||
|
||||
// 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.
|
||||
@@ -55,7 +49,7 @@
|
||||
<div
|
||||
class="board"
|
||||
role="grid"
|
||||
aria-label="Game board, {rows} rows by {cols} columns"
|
||||
aria-label={getText('board.gridLabel').replace('{rows}', rows).replace('{cols}', cols)}
|
||||
style="grid-template-columns: repeat({cols}, {cellSize}px); grid-template-rows: repeat({rows}, {cellSize}px);"
|
||||
>
|
||||
{#each cells as cell (cell.row * cols + cell.col)}
|
||||
@@ -67,8 +61,6 @@
|
||||
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)}
|
||||
class:lit={cell.isLight}
|
||||
class:warm={cell.isWarm && !cell.isLight}
|
||||
class:key-cell={cell.isKey}
|
||||
@@ -118,13 +110,6 @@
|
||||
<img class="key-image" src={GENERATED_TILES.keys[cell.keyId] ?? GENERATED_TILES.keys[1]} alt="" draggable="false" />
|
||||
</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>
|
||||
@@ -247,33 +232,10 @@
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cell .tile-image,
|
||||
.tile-overlay,
|
||||
.key-overlay,
|
||||
.throw-cursor-ring {
|
||||
.key-overlay {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
|
||||
let { level = 1, turns = 0, showPreview = false, canUndo = false,
|
||||
allowUndo = true, allowPreview = true,
|
||||
stonesLeft = 0, showStones = false,
|
||||
stonesLeft = 0, showStones = false, canThrow = false,
|
||||
keysHeld = 0, showKeys = false,
|
||||
ontogglepreview, onpause, onmenu, onundo, onshowcontrols } = $props();
|
||||
hintTiersAvailable = 0, hintsShown = false,
|
||||
ontogglepreview, onpause, onmenu, onundo, onshowcontrols,
|
||||
onenterthrow, onshowhint } = $props();
|
||||
let muted = $state(isMuted());
|
||||
|
||||
function toggleMute() {
|
||||
@@ -27,9 +29,24 @@
|
||||
|
||||
<div class="hud">
|
||||
<div class="hud-left">
|
||||
<span class="turns">Turns: {turns}</span>
|
||||
<span class="turns">{getText('turnsLabel')}: {turns}</span>
|
||||
{#if !allowUndo}
|
||||
<span class="affordance-chip" title={getText('banner.noUndo')}>{getText('chip.noUndo')}</span>
|
||||
{/if}
|
||||
{#if !allowPreview}
|
||||
<span class="affordance-chip" title={getText('banner.noPreview')}>{getText('chip.noPreview')}</span>
|
||||
{/if}
|
||||
{#if showStones}
|
||||
<StonesCounter {stonesLeft} />
|
||||
{#if canThrow}
|
||||
<!-- On-screen entry point for throw-targeting — the only way a
|
||||
touch-only player can reach it (E key has no on-screen
|
||||
equivalent otherwise). -->
|
||||
<button class="stones-btn" onclick={onenterthrow} aria-label={getText('throw.enter')}>
|
||||
<StonesCounter {stonesLeft} />
|
||||
</button>
|
||||
{:else}
|
||||
<StonesCounter {stonesLeft} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showKeys}
|
||||
<KeyInventory {keysHeld} />
|
||||
@@ -37,14 +54,19 @@
|
||||
</div>
|
||||
<div class="hud-right">
|
||||
<span class="level-label">{getText('level')}{level}</span>
|
||||
{#if hintTiersAvailable > 0}
|
||||
<button class="icon-btn hint-btn" class:active={hintsShown} onclick={onshowhint} aria-label={getText('hint.button')}>
|
||||
{getText('hint.button')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if allowUndo}
|
||||
<button class="icon-btn" onclick={onundo} disabled={!canUndo} aria-label="Undo">
|
||||
<button class="icon-btn" onclick={onundo} disabled={!canUndo} aria-label={getText('controlUndo')}>
|
||||
<Pixel art={ICON_UNDO} palette={ICON_PAL} width={20} height={20} />
|
||||
</button>
|
||||
{/if}
|
||||
<Button text={muted ? getText('muted') : getText('sound')} onclick={toggleMute} small />
|
||||
{#if allowPreview}
|
||||
<button class="icon-btn" class:active={showPreview} onclick={ontogglepreview} aria-label="Toggle preview">
|
||||
<button class="icon-btn" class:active={showPreview} onclick={ontogglepreview} aria-label={getText('controlPreview')}>
|
||||
<Pixel art={ICON_EYE} palette={ICON_PAL} width={20} height={20} />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -82,8 +104,36 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
/* Minimum comfortable touch target (WCAG 2.5.5 / Apple HIG) */
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { background: var(--btn-hover); }
|
||||
.icon-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.icon-btn.active { background: var(--btn-hover); border-color: var(--text-accent); }
|
||||
.hint-btn {
|
||||
font: var(--font-small);
|
||||
color: #ffdd44;
|
||||
border-color: #ffdd44;
|
||||
padding: 4px 10px;
|
||||
width: auto;
|
||||
}
|
||||
.stones-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
.affordance-chip {
|
||||
font: var(--font-small);
|
||||
color: #ffaa44;
|
||||
background: rgba(200, 120, 0, 0.2);
|
||||
border: 1px solid rgba(200, 120, 0, 0.5);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<!-- SuspicionRing overlay positioned over this sprite -->
|
||||
<SuspicionRing tier={guard.tier ?? 0} {size} />
|
||||
<SuspicionRing tier={guard.tier ?? 0} {size} range={guard.range ?? 0} {cellSize} />
|
||||
|
||||
<!-- ── Standard guard types (existing behavior unchanged) ── -->
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
// HintPanel — modal listing progressive hints for the current level.
|
||||
// Hints unlock in escalating tiers (nudge → mechanic reminder → concrete
|
||||
// next step) as the player racks up failed attempts; already-unlocked
|
||||
// tiers show their text, locked ones show a generic "keep trying" line
|
||||
// so the player knows more help is coming without spoiling it early.
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { trapFocus } from '../lib/focus-trap.js';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let { hintKeys = [], unlockedTiers = 0, onclose } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overlay" transition:fade={{ duration: 200 }} use:trapFocus role="dialog" aria-modal="true" aria-labelledby="hint-title">
|
||||
<div class="popup">
|
||||
<h2 id="hint-title">{getText('hint.title')}</h2>
|
||||
<ol class="hint-list">
|
||||
{#each hintKeys as key, i (key)}
|
||||
<li class:locked={i >= unlockedTiers}>
|
||||
{i < unlockedTiers ? getText(key) : getText('hint.locked')}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<Button text={getText('back')} onclick={onclose} autofocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-overlay);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: 2px solid var(--btn-border);
|
||||
background: var(--bg-panel);
|
||||
padding: 24px 32px;
|
||||
border-radius: 8px;
|
||||
max-width: 420px;
|
||||
}
|
||||
h2 {
|
||||
font: var(--font-heading);
|
||||
color: #ffdd44;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hint-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
.hint-list li {
|
||||
font: var(--font-small);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hint-list li.locked {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,24 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { trapFocus } from '../lib/focus-trap.js';
|
||||
let { stars = 0, moves = 0, parMoves = 0, onnext } = $props();
|
||||
let nextBtn = $state(null);
|
||||
onMount(() => nextBtn?.focus());
|
||||
</script>
|
||||
|
||||
<div class="level-complete-overlay">
|
||||
<div class="level-complete-overlay" use:trapFocus role="dialog" aria-modal="true" aria-labelledby="level-complete-title">
|
||||
<div class="level-complete-card">
|
||||
<h2>{getText('levelComplete') || 'Level Complete!'}</h2>
|
||||
<h2 id="level-complete-title">{getText('levelComplete')}</h2>
|
||||
<div class="stars-row">
|
||||
{#each [1, 2, 3] as i}
|
||||
<span class="star" class:filled={i <= stars}
|
||||
style="animation-delay: {i * 0.15}s">★</span>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="move-count">{getText('moves')}: {moves} / Par: {parMoves}</p>
|
||||
<button class="next-btn" onclick={onnext}>
|
||||
{getText('continue') || 'Continue'}
|
||||
<p class="move-count">{getText('moves')}: {moves} / {getText('parLabel')}: {parMoves}</p>
|
||||
<button bind:this={nextBtn} class="next-btn" onclick={onnext}>
|
||||
{getText('continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script>
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { trapFocus } from '../lib/focus-trap.js';
|
||||
import Button from './Button.svelte';
|
||||
let { onresume, onrestart, onmainmenu } = $props();
|
||||
let { onresume, onrestart, onmainmenu, onguide } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overlay" transition:fade={{ duration: 200 }}>
|
||||
<div class="overlay" transition:fade={{ duration: 200 }} use:trapFocus role="dialog" aria-modal="true" aria-labelledby="pause-title">
|
||||
<div class="popup">
|
||||
<h2>{getText('paused')}</h2>
|
||||
<Button text={getText('resume')} onclick={onresume} />
|
||||
<h2 id="pause-title">{getText('paused')}</h2>
|
||||
<Button text={getText('resume')} onclick={onresume} autofocus />
|
||||
<Button text={getText('restartLevel')} onclick={onrestart} />
|
||||
{#if onguide}
|
||||
<Button text={getText('guide')} onclick={onguide} />
|
||||
{/if}
|
||||
<Button text={getText('mainMenu')} onclick={onmainmenu} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
<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();
|
||||
// tier 0: only the (always-visible) range boundary; tier 1: yellow ring;
|
||||
// tier 2: red + pulsing animation.
|
||||
//
|
||||
// `range` draws the Manhattan danger-zone boundary as a diamond so the
|
||||
// player can see the zone *before* triggering it — previously the only
|
||||
// tell was the tier ring itself, which only appears after tier ≥ 1 (i.e.
|
||||
// after the guard has already started tracking the player).
|
||||
let { tier = 0, size = 48, range = 0, cellSize = 50 } = $props();
|
||||
|
||||
let ringColor = $derived(tier >= 2 ? '#ff3300' : '#ffcc00');
|
||||
let visible = $derived(tier > 0);
|
||||
let pulsing = $derived(tier >= 2);
|
||||
// A square rotated 45° draws a Manhattan-distance diamond: its vertices
|
||||
// (after rotation) sit at distance side/√2 from center along each
|
||||
// cardinal axis. Solving side/√2 = range*cellSize gives the side length
|
||||
// below, so the diamond's edge lands exactly `range` cells away in every
|
||||
// direction — matching the guard's actual Manhattan detection radius.
|
||||
let zoneSize = $derived(range > 0 ? range * cellSize * Math.SQRT2 : 0);
|
||||
</script>
|
||||
|
||||
{#if zoneSize > 0}
|
||||
<div
|
||||
class="suspicion-zone"
|
||||
style="width: {zoneSize}px; height: {zoneSize}px;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="suspicion-ring"
|
||||
@@ -19,6 +39,16 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.suspicion-zone {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
border: 1px dashed rgba(255, 204, 0, 0.45);
|
||||
background: rgba(255, 204, 0, 0.06);
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
.suspicion-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
// cellSize pixels per cell
|
||||
|
||||
let { cursor = null, validTargets = new Set(), playerPos = null,
|
||||
rows = 6, cols = 6, cellSize = 50 } = $props();
|
||||
rows = 6, cols = 6, cellSize = 50,
|
||||
onconfirm, oncancel } = $props();
|
||||
|
||||
const MAX_THROW_DIST = 3;
|
||||
|
||||
@@ -93,10 +94,23 @@
|
||||
{#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>
|
||||
<span class="hint-invalid">{getText('throw.hintInvalid')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Touch-usable Confirm/Cancel controls — the only way a touch-only player
|
||||
can confirm or back out of targeting mode without a keyboard. Sized well
|
||||
above the 44px minimum touch target regardless of board scale, since the
|
||||
board itself can shrink below that on small screens. -->
|
||||
<div class="targeting-controls">
|
||||
<button class="targeting-btn cancel" onclick={oncancel} aria-label={getText('throw.cancel')}>
|
||||
✕
|
||||
</button>
|
||||
<button class="targeting-btn confirm" onclick={onconfirm} disabled={!cursorIsValid} aria-label={getText('throw.confirm')}>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.targeting-overlay {
|
||||
position: absolute;
|
||||
@@ -168,4 +182,42 @@
|
||||
}
|
||||
.hint-valid { color: #aaff88; }
|
||||
.hint-invalid { color: #ff9966; }
|
||||
|
||||
/* Pinned near the bottom of the board viewport (not the scrollable
|
||||
content), always reachable regardless of board scroll position. */
|
||||
.targeting-controls {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
z-index: 21;
|
||||
}
|
||||
.targeting-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.targeting-btn.cancel {
|
||||
background: rgba(60, 10, 10, 0.85);
|
||||
border: 2px solid rgba(255, 80, 40, 0.8);
|
||||
color: #ff9966;
|
||||
}
|
||||
.targeting-btn.confirm {
|
||||
background: rgba(10, 40, 10, 0.85);
|
||||
border: 2px solid rgba(0, 220, 80, 0.8);
|
||||
color: #aaff88;
|
||||
}
|
||||
.targeting-btn.confirm:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
// Svelte action: traps Tab/Shift+Tab focus cycling within a modal container.
|
||||
// Use on the outermost overlay element: `<div use:trapFocus role="dialog" ...>`.
|
||||
// Pairs with `autofocus` on the modal's primary button (see Button.svelte) —
|
||||
// that establishes the initial focus; this action just keeps Tab from
|
||||
// escaping to the hidden HUD behind the overlay.
|
||||
export function trapFocus(node) {
|
||||
function focusableElements() {
|
||||
return Array.from(
|
||||
node.querySelectorAll(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key !== 'Tab') return;
|
||||
const items = focusableElements();
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', onKeydown);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', onKeydown);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Progressive hint content lookup. Hint TEXT lives in the locale files (per the
|
||||
// UX rule that player-facing strings never get hardcoded) as flat keys
|
||||
// `hint.level{N}.{tier}` — this module only knows which levels have hints and
|
||||
// how many escalating tiers exist. Keeps levels.js (owned by another
|
||||
// workstream this session) untouched.
|
||||
//
|
||||
// L1 has no guards (nothing can detect the player), and L12 is intentionally
|
||||
// unsolvable — hints for either would be either unreachable or dishonest, so
|
||||
// both are excluded.
|
||||
export const HINT_TIERS = 3;
|
||||
const FIRST_HINTABLE_LEVEL = 2;
|
||||
const LAST_HINTABLE_LEVEL = 11;
|
||||
|
||||
/** Whether a level has any authored hints at all. */
|
||||
export function levelHasHints(levelNum) {
|
||||
return levelNum >= FIRST_HINTABLE_LEVEL && levelNum <= LAST_HINTABLE_LEVEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale keys for every hint tier of a level, in escalating order
|
||||
* (nudge → mechanic reminder → concrete next step). Empty array if the level
|
||||
* has no hints.
|
||||
*/
|
||||
export function hintKeysForLevel(levelNum) {
|
||||
if (!levelHasHints(levelNum)) return [];
|
||||
const keys = [];
|
||||
for (let tier = 1; tier <= HINT_TIERS; tier++) {
|
||||
keys.push(`hint.level${levelNum}.${tier}`);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Maps a level number to the mechanic id(s) it introduces for the first time,
|
||||
// so LevelIntro can render a short "New this level" strip. Mirrors the
|
||||
// "Mechanic intro" authoring comments already in levels.js, but lives here
|
||||
// (not in levels.js, owned by another workstream this session) so it can ship
|
||||
// independently. Display text for each mechanic id lives in the locale files
|
||||
// as `teaches.{id}` — this module only says which id(s) apply per level.
|
||||
//
|
||||
// L1 teaches bare movement only (no guard/mechanic) and L10 is a pure combo
|
||||
// level with nothing new, so neither appears here.
|
||||
const LEVEL_TEACHES = {
|
||||
2: ['static'],
|
||||
3: ['oneWay'],
|
||||
4: ['suspicion'],
|
||||
5: ['keysDoors'],
|
||||
6: ['warm'],
|
||||
7: ['mirror'],
|
||||
8: ['sniper'],
|
||||
9: ['stones'],
|
||||
11: ['chaser'],
|
||||
};
|
||||
|
||||
/** Mechanic ids newly introduced by a level, or []. */
|
||||
export function teachesForLevel(levelNum) {
|
||||
return LEVEL_TEACHES[levelNum] ?? [];
|
||||
}
|
||||
+94
-17
@@ -21,6 +21,9 @@
|
||||
"levelSelectTitle": "SELECT LEVEL",
|
||||
"levelNumber": "Level",
|
||||
"level": "Level: ",
|
||||
"turnsLabel": "Turns",
|
||||
"parLabel": "Par",
|
||||
"levelComplete": "Level Complete!",
|
||||
"pause": "PAUSE",
|
||||
"menu": "MENU",
|
||||
"paused": "PAUSED",
|
||||
@@ -49,31 +52,88 @@
|
||||
"controlsTitle": "CONTROLS",
|
||||
"controlMove": "Move",
|
||||
"controlWait": "Wait one turn",
|
||||
"controlPreview": "Toggle next-turn preview",
|
||||
"controlPreview": "Toggle next-turn preview (assumes you wait)",
|
||||
"controlUndo": "Undo last move",
|
||||
"controlRedo": "Redo move",
|
||||
"controlThrow": "Enter stone-throw mode",
|
||||
"controlTap": "Move to adjacent cell",
|
||||
"controlTapSelf": "Tap your own cell — wait",
|
||||
"controlSwipe": "Move (mobile)",
|
||||
"controlPause": "Pause / Resume",
|
||||
"moves": "Moves",
|
||||
"skip": "SKIP",
|
||||
"continue": "CONTINUE",
|
||||
"storyTitle": "THE MISSING CARROT PRINCESS",
|
||||
"storyText": "In the peaceful Vegetable Kingdom, where carrots, cabbages, and all manner of vegetables lived in harmony, a terrible tragedy has occurred.\n\nThe beloved Carrot Princess, daughter of King Carrot III, has mysteriously disappeared from the royal palace.\n\nThe kingdom is in chaos. The Vegetable Guards have been placed on high alert, patrolling every corner of the realm with their watchful eyes.\n\nAs the kingdom's last hope, you, a skilled ninja rabbit from the shadows, have been called upon for this dangerous mission.\n\nYou must infiltrate the heavily guarded areas, avoid detection at all costs, and rescue the missing Carrot Princess.\n\nThe fate of the Vegetable Kingdom rests in your paws...\n\nGood luck, Night Ninja!",
|
||||
|
||||
"level1Name": "Garden Path",
|
||||
"level2Name": "The Watchtower",
|
||||
"level3Name": "Vegetable Patrol",
|
||||
"level4Name": "The Searchlight",
|
||||
"level5Name": "Fortress Gate",
|
||||
"level6Name": "The Flickering Corridor",
|
||||
"level7Name": "The Underground Passage",
|
||||
"level8Name": "The Gauntlet",
|
||||
"level9Name": "The Decoy Path",
|
||||
"level10Name": "Hall of Mirrors",
|
||||
"level11Name": "The Throne Room",
|
||||
"level12Name": "The Princess Chamber",
|
||||
|
||||
"level1Story": "The outskirts of the Vegetable Kingdom. A quiet garden path lies ahead.\nNo guards in sight. Learn your footing before the dark closes in.",
|
||||
"level2Story": "A watchtower stands at the garden's edge. Three wilting sentries keep watch.\nTheir lanterns burn bright at first — but patience is a blade. Wait for the glow to fade.",
|
||||
"level3Story": "The garden is laced with one-way passages — ancient ratchets that only open one direction.\nChoose your route before you step. There is no going back.",
|
||||
"level4Story": "A rotating searchlight sweeps the garden entrance.\nIts beam turns clockwise each turn. Count the rhythm. Move between the sweeps.",
|
||||
"level5Story": "The fortress torches flicker with an eerie rhythm.\nA blinking guard's light pulses on and off. Move on the dark turns — the gaps are brief.",
|
||||
"level6Story": "The fortress gate combines fixed lanterns with flickering torches.\nThree layers of defense. Stay patient, count the flickers, find the seam.",
|
||||
"level7Story": "An underground passage winds beneath the fortress walls.\nA patrolling guard marches the corridor — and somewhere, a beam bends off a crystal mirror.",
|
||||
"level8Story": "Two mirrors. Two rotating beams. The light web shifts every turn.\nFind the gap that exists for exactly one step. It will not wait for you.",
|
||||
"level9Story": "The royal palace. Two routes lie ahead — one short, one long.\nThe chaser guards the short path. Sometimes the long way is the only way.",
|
||||
"level4Story": "A sentinel guards the garden threshold. Its detection range grows as suspicion rises.\nStay out of range, or move fast before its awareness locks in.",
|
||||
"level5Story": "The fortress gate is sealed. Two keys open its doors — but one unlock leads to a pocket with only one exit.\nCollect in the right order, or trap yourself.",
|
||||
"level6Story": "The fortress corridor flickers with an eerie pulse. A guard blinks on and off, but decay keeps its afterglow lethal.\nWait for the full darkness before crossing.",
|
||||
"level7Story": "An underground passage winds beneath the fortress walls. A rotating beam bounces off mirrors, never where you expect.\nTrace where the light actually lands.",
|
||||
"level8Story": "A sniper holds a fixed aim down this corridor. Its beam rotates every two turns on a rigid cadence.\nCount from the start, not from what you see.",
|
||||
"level9Story": "The palace courtyard. A patrol bounces between two walls, blocking the only crossing.\nYou carry stones to distract them. Use them wisely.",
|
||||
"level10Story": "The Hall of Mirrors. Reflected beams multiply with every turn.\nThey say the Princess can sense any living thing nearby — even ghosts of past attempts...",
|
||||
"level11Story": "The Throne Room. Every guard the kingdom commands stands between you and that door.\nNo one who entered the chamber beyond has returned. The throne itself was a warning.",
|
||||
"level12Story": "At last... the Princess Chamber. A strange warmth emanates from within.\nThe air itself feels watchful. She already knows you're here.",
|
||||
|
||||
"teaches.label": "New this level:",
|
||||
"teaches.static": "Static guards wilt over time — their light shrinks each turn. Wait them out.",
|
||||
"teaches.oneWay": "One-way tiles only let you pass in one direction — plan the full route first.",
|
||||
"teaches.suspicion": "Suspicion guards build alertness the longer you stay in range, even without a visible beam.",
|
||||
"teaches.keysDoors": "Keys unlock only their matching door — collect before you commit to a route.",
|
||||
"teaches.warm": "Cells stay dangerously warm for a turn after going dark — treat the after-glow like the light itself.",
|
||||
"teaches.mirror": "Mirrors reflect a beam 90° — trace where the light actually lands.",
|
||||
"teaches.sniper": "Snipers hold a fixed aim and rotate on a countable cadence.",
|
||||
"teaches.stones": "Throw a stone to distract a patrolling or chasing guard for one turn.",
|
||||
"teaches.chaser": "Chasers hunt you once you're within their detection radius — stay outside it or distract them.",
|
||||
|
||||
"hint.level2.1": "Those red lanterns don't move — but they don't stay lit forever either.",
|
||||
"hint.level2.2": "Static guards wilt: their light shrinks by one cell every turn. Press Space to wait nearby until the glow fades, then step through.",
|
||||
"hint.level2.3": "Skirt each guard's shrinking aura for 2-3 turns before crossing its cell — the same zigzag path from level 1 still works, timed.",
|
||||
"hint.level3.1": "Some tiles only let you through if you enter from a specific direction.",
|
||||
"hint.level3.2": "One-way tiles are one-time ratchets: step onto one from the wrong side and you're rejected, not warned in advance. Plan your full route before committing.",
|
||||
"hint.level3.3": "Head down through the gap at the top-middle wall, cross right, then take the second one-way gap lower down before turning toward the goal.",
|
||||
"hint.level4.1": "Something is watching from the right side of the garden — you won't see it react until it's already noticed you.",
|
||||
"hint.level4.2": "The violet guard's suspicion rises the longer you stay within its range, even if you never see a beam. Keep at least 4 cells away, or back off for a turn to let it cool.",
|
||||
"hint.level4.3": "Time your crossing past the rotating beam first, then swing wide around the right flank to stay outside the suspicion guard's zone before angling in to the goal.",
|
||||
"hint.level5.1": "The gate ahead is locked — you'll need to find something small and shiny first.",
|
||||
"hint.level5.2": "Doors only open for the matching key. Collect key 1 before the first door, then look for key 2 on the far side before the second door.",
|
||||
"hint.level5.3": "Grab the key in the top row first, come back to the vertical barrier's upper gap, then push through to the second key and the lower gap.",
|
||||
"hint.level6.1": "The torches here don't just turn off — cells stay a little warm right after they do.",
|
||||
"hint.level6.2": "A cell that just went dark is still 'warm' for one extra turn — and warm is just as dangerous as lit. Wait for the afterglow to fully fade before stepping there.",
|
||||
"hint.level6.3": "Count each guard's blink rhythm and only step in once a cell has been fully dark (not just recently dark) for a turn — don't gamble on the afterglow.",
|
||||
"hint.level7.1": "A rotating beam down here doesn't behave like the ones you've seen before.",
|
||||
"hint.level7.2": "Mirrors bounce a beam 90 degrees. Trace where it lands after each bounce, not just where the guard is facing.",
|
||||
"hint.level7.3": "Follow the beam's reflected path along the right wall to reach the key, then loop back to the lower door before making for the goal.",
|
||||
"hint.level8.1": "One guard here doesn't rotate every turn — it holds its aim, then snaps.",
|
||||
"hint.level8.2": "The sniper's beam only rotates every 2 turns. Count from the moment it last turned — there's a predictable safe window between rotations.",
|
||||
"hint.level8.3": "Cross the sniper's corridor right after it rotates, then time your pass through the patrol's back-and-forth lane below.",
|
||||
"hint.level9.1": "You're carrying something you can throw — it's not just for keeping.",
|
||||
"hint.level9.2": "A thrown stone distracts a patrolling or chasing guard for a turn. Aim it at the crossing you need, then dash through while it's looking the wrong way.",
|
||||
"hint.level9.3": "Throw your first stone to open the row-5 crossing between the two patrols, then save your second for the sniper guarding the lower exit.",
|
||||
"hint.level10.1": "Every mechanic you've learned so far is layered into this one hallway.",
|
||||
"hint.level10.2": "Track both beam-mirror chains separately, use warm decay windows to cross recently-lit cells, and save your one stone for the patrol blocking the final stretch.",
|
||||
"hint.level10.3": "Cross during a decay window after the first beam sweeps past, loop around the second mirror's reflection, then throw your stone to clear the patrol before the sniper's rotation opens the goal approach.",
|
||||
"hint.level11.1": "Nothing here can be undone, and you can't preview what's coming — every guard type you've faced stands between you and that door.",
|
||||
"hint.level11.2": "The chaser only notices you within its detection radius — stay outside it, or lure it away with a stone like a patrolling guard.",
|
||||
"hint.level11.3": "Clear the static guard's aura first, thread the mirror's reflected beam, then use your two stones on the sniper and the patrol in sequence before making the final dash to the goal.",
|
||||
|
||||
"banner.noUndo": "No undo this level — every move counts.",
|
||||
"banner.noPreview": "No preview this level — you can't see ahead.",
|
||||
"migration.title": "Welcome to Night Ninja v2",
|
||||
@@ -81,17 +141,34 @@
|
||||
"migration.dismiss": "Continue",
|
||||
"gameOver.runComplete": "Run Complete!",
|
||||
"gameOver.runCompleteBody": "You guided the ninja through the kingdom. The Princess Chamber awaits...",
|
||||
"throw.hintEnter": "Press E to throw a stone",
|
||||
"throw.hintEnter": "Press E, or tap the stone icon, to throw",
|
||||
"throw.hintTargeting": "Choose a target — Enter to throw, Esc to cancel",
|
||||
"mechanics.sniper.name": "Sniper Pepper",
|
||||
"mechanics.suspicion.name": "Suspicious Onion",
|
||||
"mechanics.suspicion.alerted": "Alerted",
|
||||
"mechanics.suspicion.firing": "Firing!",
|
||||
"throw.hintInvalid": "Invalid target — move cursor or press Esc to cancel",
|
||||
"throw.enter": "Throw a stone",
|
||||
"throw.confirm": "Confirm throw",
|
||||
"throw.cancel": "Cancel throw",
|
||||
"hint.button": "HINT",
|
||||
"hint.title": "Hint",
|
||||
"hint.locked": "Keep trying — more help unlocks after a few more attempts.",
|
||||
"mercy.available": "Stuck? You've earned a mercy skip to the next level.",
|
||||
"mercy.button": "SKIP LEVEL",
|
||||
"mercy.badge": "SKIPPED",
|
||||
"chip.noUndo": "NO UNDO",
|
||||
"chip.noPreview": "NO PREVIEW",
|
||||
"mechanics.stones.label": "Stones",
|
||||
"mechanics.keys.label": "Keys",
|
||||
"mechanics.oneWay.aria": "One-way arrow {dir}",
|
||||
"mechanics.warm.aria": "Warm cell, will cool next turn",
|
||||
"mechanics.door.locked": "Locked door, key {keyId}",
|
||||
"mechanics.door.open": "Open door",
|
||||
"mechanics.key.aria": "Key {keyId}"
|
||||
"mechanics.key.aria": "Key {keyId}",
|
||||
"board.gridLabel": "Game board, {rows} rows by {cols} columns",
|
||||
"board.rowCol": "Row {row}, Column {col}",
|
||||
"board.wall": "wall",
|
||||
"board.doorLocked": "door, locked, key {keyId}",
|
||||
"board.goal": "goal",
|
||||
"board.key": "key {keyId}",
|
||||
"board.oneWay": "one-way arrow {dir}",
|
||||
"board.warm": "warm cell",
|
||||
"board.lit": "lit",
|
||||
"board.dirUp": "up",
|
||||
"board.dirRight": "right",
|
||||
"board.dirDown": "down",
|
||||
"board.dirLeft": "left"
|
||||
}
|
||||
+94
-17
@@ -21,6 +21,9 @@
|
||||
"levelSelectTitle": "CHỌN CẤP ĐỘ",
|
||||
"levelNumber": "Cấp độ",
|
||||
"level": "Cấp độ: ",
|
||||
"turnsLabel": "Lượt",
|
||||
"parLabel": "Mốc",
|
||||
"levelComplete": "Hoàn Thành Cấp Độ!",
|
||||
"pause": "TẠM DỪNG",
|
||||
"menu": "MENU",
|
||||
"paused": "TẠM DỪNG",
|
||||
@@ -49,31 +52,88 @@
|
||||
"controlsTitle": "ĐIỀU KHIỂN",
|
||||
"controlMove": "Di chuyển",
|
||||
"controlWait": "Đợi một lượt",
|
||||
"controlPreview": "Xem trước ánh sáng lượt sau",
|
||||
"controlPreview": "Xem trước ánh sáng lượt sau (giả định bạn đứng yên)",
|
||||
"controlUndo": "Hoàn tác bước đi",
|
||||
"controlRedo": "Làm lại bước đi",
|
||||
"controlThrow": "Chế độ ném đá",
|
||||
"controlTap": "Di chuyển đến ô kề bên",
|
||||
"controlTapSelf": "Chạm vào ô của bạn — đợi",
|
||||
"controlSwipe": "Di chuyển (điện thoại)",
|
||||
"controlPause": "Tạm dừng / Tiếp tục",
|
||||
"moves": "Bước đi",
|
||||
"skip": "BỎ QUA",
|
||||
"continue": "TIẾP TỤC",
|
||||
"storyTitle": "CÔNG CHÚA CÀ RỐT MẤT TÍCH",
|
||||
"storyText": "Trong vương quốc Rau Củ Quả thanh bình, nơi cà rốt, bắp cải và đủ loại rau củ sống hòa thuận với nhau, một bi kịch khủng khiếp đã xảy ra.\n\nCông chúa Cà Rốt yêu quý, con gái của Vua Cà Rốt Đệ Tam, đã bí ẩn biến mất khỏi cung điện hoàng gia.\n\nVương quốc đang trong tình trạng hỗn loạn. Các Vệ Binh Rau Củ đã được đặt trong tình trạng báo động cao, tuần tra mọi ngóc ngách của vương quốc với đôi mắt cảnh giác của họ.\n\nLà hy vọng cuối cùng của vương quốc, bạn, một ninja thỏ tài năng từ bóng tối, đã được gọi đến cho nhiệm vụ nguy hiểm này.\n\nBạn phải xâm nhập vào các khu vực được canh gác nghiêm ngặt, tránh bị phát hiện bằng mọi giá, và giải cứu Công chúa Cà Rốt mất tích.\n\nSố phận của Vương quốc Rau Củ Quả nằm trong đôi bàn chân của bạn...\n\nChúc may mắn, Ninja Đêm!",
|
||||
|
||||
"level1Name": "Con Đường Vườn",
|
||||
"level2Name": "Tháp Canh",
|
||||
"level3Name": "Tuần Tra Rau Củ",
|
||||
"level4Name": "Đèn Pha",
|
||||
"level5Name": "Cổng Pháo Đài",
|
||||
"level6Name": "Hành Lang Nhấp Nháy",
|
||||
"level7Name": "Đường Hầm Ngầm",
|
||||
"level8Name": "Hành Lang Thử Thách",
|
||||
"level9Name": "Con Đường Nghi Binh",
|
||||
"level10Name": "Đại Sảnh Gương",
|
||||
"level11Name": "Phòng Ngai Vàng",
|
||||
"level12Name": "Phòng Công Chúa",
|
||||
|
||||
"level1Story": "Ngoại ô Vương quốc Rau Củ. Con đường vườn yên tĩnh trải dài phía trước.\nKhông có lính canh. Hãy làm quen với bước chân trước khi bóng tối ập đến.",
|
||||
"level2Story": "Một tháp canh đứng ở rìa khu vườn. Ba người lính tàn phai đang canh gác.\nĐèn lồng của họ lúc đầu sáng rực — nhưng kiên nhẫn chính là vũ khí. Hãy chờ ánh sáng tắt dần.",
|
||||
"level3Story": "Khu vườn rải rác những lối đi một chiều — những cổng cổ xưa chỉ mở theo một hướng.\nHãy chọn đường trước khi bước. Không có đường lui.",
|
||||
"level4Story": "Đèn pha xoay quét qua cổng khu vườn.\nTia sáng quay theo chiều kim đồng hồ mỗi lượt. Đếm nhịp. Di chuyển giữa các lần quét.",
|
||||
"level5Story": "Ngọn đuốc pháo đài nhấp nháy theo nhịp kỳ lạ.\nÁnh sáng lính vàng nhấp nháy bật tắt. Di chuyển vào lượt tối — khoảng hở rất ngắn.",
|
||||
"level6Story": "Cổng pháo đài kết hợp đèn lồng cố định với đuốc nhấp nháy.\nBa lớp phòng thủ. Kiên nhẫn, đếm nhịp nhấp nháy, tìm kẽ hở.",
|
||||
"level7Story": "Đường hầm uốn lượn dưới tường pháo đài.\nLính tuần tra đi dọc hành lang — và ở đâu đó, một tia sáng bẻ cong qua gương pha lê.",
|
||||
"level8Story": "Hai gương. Hai tia xoay. Mạng lưới ánh sáng thay đổi mỗi lượt.\nTìm khoảng trống tồn tại đúng một bước. Nó sẽ không chờ bạn.",
|
||||
"level9Story": "Cung điện hoàng gia. Hai con đường — một ngắn, một dài.\nLính truy đuổi canh đường tắt. Đôi khi con đường dài mới là con đường duy nhất.",
|
||||
"level4Story": "Một người canh gác đứng ở ngưỡng khu vườn. Vùng phát hiện của họ mở rộng khi nghi ngờ tăng lên.\nHãy ở ngoài tầm hoặc di chuyển nhanh trước khi nhận thức của họ bị khóa.",
|
||||
"level5Story": "Cổng pháo đài bị đóng cửa. Hai chìa khóa mở các cửa của nó — nhưng mở khóa của một chiếc dẫn đến một khoảng có một lối thoát.\nThu thập theo đúng thứ tự, hoặc tự mắc kẹt.",
|
||||
"level6Story": "Hành lang pháo đài nhấp nháy với nhịp kỳ lạ. Một lính canh bật tắt, nhưng sự suy giảm tia sáng vẫn nguy hiểm.\nHãy chờ cho đến khi tối hẳn trước khi vượt qua.",
|
||||
"level7Story": "Đường hầm uốn lượn dưới tường pháo đài. Một tia xoay nảy qua gương, không bao giờ ở nơi bạn mong đợi.\nHãy theo dõi nơi ánh sáng thực sự đi tới.",
|
||||
"level8Story": "Một xạ thủ giữ ngắm cố định xuống hành lang này. Tia của họ quay mỗi hai lượt theo nhịp cứng nhắc.\nĐếm từ lúc bắt đầu, không phải từ những gì bạn thấy.",
|
||||
"level9Story": "Sân vận động cung điện. Một người tuần tra nảy giữa hai tường, chặn lối giao cắt duy nhất.\nBạn mang theo đá để đánh lạc hướng họ. Sử dụng nó một cách khôn ngoan.",
|
||||
"level10Story": "Đại sảnh Gương. Các tia phản chiếu nhân lên theo từng lượt.\nNghe đồn Công chúa có thể cảm nhận mọi sinh vật sống gần nàng — kể cả bóng ma của những lần thử trước...",
|
||||
"level11Story": "Phòng Ngai Vàng. Mọi loại lính canh vương quốc có trong tay đều đứng giữa bạn và cánh cửa đó.\nKhông ai vào phòng bên kia trở về. Chính ngai vàng là lời cảnh báo.",
|
||||
"level12Story": "Cuối cùng... Phòng Công chúa. Một hơi ấm lạ tỏa ra từ bên trong.\nKhông khí như đang dõi theo bạn. Nàng đã biết bạn ở đây rồi.",
|
||||
|
||||
"teaches.label": "Mới trong màn này:",
|
||||
"teaches.static": "Lính tĩnh héo dần theo thời gian — ánh sáng thu nhỏ mỗi lượt. Hãy kiên nhẫn chờ.",
|
||||
"teaches.oneWay": "Ô một chiều chỉ cho đi qua theo một hướng — hãy lên kế hoạch cho cả lộ trình trước.",
|
||||
"teaches.suspicion": "Lính nghi ngờ tích lũy cảnh giác càng lâu bạn ở trong tầm, kể cả khi không thấy tia sáng.",
|
||||
"teaches.keysDoors": "Chìa khóa chỉ mở đúng cửa tương ứng — hãy thu thập trước khi chọn lộ trình.",
|
||||
"teaches.warm": "Ô vẫn còn nguy hiểm trong một lượt sau khi tắt — hãy coi dư sáng đó như ánh sáng thật.",
|
||||
"teaches.mirror": "Gương phản chiếu tia sáng 90 độ — hãy theo dõi nơi ánh sáng thực sự đi tới.",
|
||||
"teaches.sniper": "Lính bắn tỉa giữ hướng ngắm cố định và xoay theo nhịp có thể đếm được.",
|
||||
"teaches.stones": "Ném đá để đánh lạc hướng lính tuần tra hoặc truy đuổi trong một lượt.",
|
||||
"teaches.chaser": "Lính truy đuổi sẽ săn bạn khi bạn vào bán kính phát hiện — hãy đứng ngoài hoặc đánh lạc hướng chúng.",
|
||||
|
||||
"hint.level2.1": "Những chiếc đèn lồng đỏ đó không di chuyển — nhưng chúng cũng không sáng mãi.",
|
||||
"hint.level2.2": "Lính tĩnh sẽ héo dần: ánh sáng của chúng thu nhỏ một ô mỗi lượt. Nhấn Space để chờ gần đó cho đến khi ánh sáng tắt, rồi bước qua.",
|
||||
"hint.level2.3": "Né vùng hào quang đang thu nhỏ của từng lính trong 2-3 lượt trước khi băng qua ô của nó — vẫn là lối đi ngoằn ngoèo ở màn 1, chỉ cần canh thời điểm.",
|
||||
"hint.level3.1": "Một số ô chỉ cho bạn đi qua nếu bạn bước vào từ một hướng nhất định.",
|
||||
"hint.level3.2": "Ô một chiều là cơ chế chốt một lần: bước vào từ sai hướng sẽ bị từ chối mà không báo trước. Hãy lên kế hoạch cho cả lộ trình trước khi cam kết.",
|
||||
"hint.level3.3": "Đi xuống qua khe hở ở tường trên-giữa, băng qua bên phải, rồi đi qua khe một chiều thứ hai ở phía dưới trước khi rẽ về đích.",
|
||||
"hint.level4.1": "Có thứ gì đó đang theo dõi từ phía bên phải khu vườn — bạn sẽ không thấy nó phản ứng cho đến khi nó đã chú ý tới bạn.",
|
||||
"hint.level4.2": "Mức độ nghi ngờ của lính tím tăng dần theo thời gian bạn ở trong tầm của nó, kể cả khi bạn không thấy tia sáng nào. Giữ khoảng cách ít nhất 4 ô, hoặc lùi lại một lượt để nó nguội bớt.",
|
||||
"hint.level4.3": "Canh thời điểm băng qua tia sáng xoay trước, rồi vòng rộng qua bên phải để tránh vùng nghi ngờ trước khi tiến vào đích.",
|
||||
"hint.level5.1": "Cánh cổng phía trước đã bị khóa — bạn cần tìm thứ gì đó nhỏ và sáng bóng trước đã.",
|
||||
"hint.level5.2": "Cửa chỉ mở với chìa khóa tương ứng. Lấy chìa khóa 1 trước cửa đầu tiên, sau đó tìm chìa khóa 2 ở phía bên kia trước cửa thứ hai.",
|
||||
"hint.level5.3": "Lấy chìa khóa ở hàng trên cùng trước, quay lại khe hở trên của rào chắn dọc, rồi tiến qua để lấy chìa khóa thứ hai và khe hở phía dưới.",
|
||||
"hint.level6.1": "Những ngọn đuốc ở đây không chỉ tắt — các ô vẫn còn ấm một chút ngay sau khi tắt.",
|
||||
"hint.level6.2": "Một ô vừa tắt vẫn 'ấm' thêm một lượt nữa — và ấm cũng nguy hiểm như sáng. Hãy chờ dư sáng tắt hẳn trước khi bước vào.",
|
||||
"hint.level6.3": "Đếm nhịp nhấp nháy của từng lính canh, chỉ bước vào khi ô đã tối hẳn (không chỉ vừa tắt) trong một lượt — đừng đánh cược vào dư sáng.",
|
||||
"hint.level7.1": "Một tia sáng xoay ở đây không hoạt động giống những gì bạn từng thấy.",
|
||||
"hint.level7.2": "Gương phản chiếu tia sáng 90 độ. Hãy dõi theo nơi tia sáng đi tới sau mỗi lần phản chiếu, không chỉ hướng lính đang nhìn.",
|
||||
"hint.level7.3": "Theo đường tia phản chiếu dọc tường bên phải để lấy chìa khóa, rồi vòng lại cửa phía dưới trước khi tiến về đích.",
|
||||
"hint.level8.1": "Có một lính canh ở đây không xoay mỗi lượt — nó giữ nguyên hướng ngắm rồi đột ngột xoay.",
|
||||
"hint.level8.2": "Tia của lính bắn tỉa chỉ xoay mỗi 2 lượt. Đếm từ lúc nó xoay lần cuối — có một khoảng an toàn dự đoán được giữa các lần xoay.",
|
||||
"hint.level8.3": "Băng qua hành lang của lính bắn tỉa ngay sau khi nó vừa xoay, rồi canh thời điểm đi qua lối tuần tra qua lại phía dưới.",
|
||||
"hint.level9.1": "Bạn đang mang theo thứ có thể ném đi — không chỉ để giữ.",
|
||||
"hint.level9.2": "Một viên đá ném ra sẽ đánh lạc hướng lính tuần tra hoặc truy đuổi trong một lượt. Nhắm vào điểm bạn cần băng qua, rồi lao qua khi nó đang nhìn sai hướng.",
|
||||
"hint.level9.3": "Ném viên đá đầu để mở lối băng qua hàng 5 giữa hai lính tuần tra, rồi giữ viên thứ hai cho lính bắn tỉa canh lối ra phía dưới.",
|
||||
"hint.level10.1": "Mọi cơ chế bạn đã học được xếp chồng lên nhau trong hành lang này.",
|
||||
"hint.level10.2": "Theo dõi cả hai chuỗi gương-tia riêng biệt, tận dụng khoảng ấm để băng qua các ô vừa tắt, và giữ viên đá duy nhất cho lính tuần tra chặn đoạn cuối.",
|
||||
"hint.level10.3": "Băng qua trong khoảng ấm sau khi tia đầu tiên quét qua, vòng qua phản chiếu của gương thứ hai, rồi ném đá để dọn lính tuần tra trước khi lính bắn tỉa xoay mở lối vào đích.",
|
||||
"hint.level11.1": "Không có gì ở đây có thể hoàn tác, và bạn không thể xem trước điều sắp tới — mọi loại lính bạn từng gặp đều đứng giữa bạn và cánh cửa đó.",
|
||||
"hint.level11.2": "Lính truy đuổi chỉ chú ý đến bạn trong bán kính phát hiện của nó — hãy đứng ngoài phạm vi đó, hoặc dùng đá dụ nó đi như một lính tuần tra.",
|
||||
"hint.level11.3": "Dọn vùng hào quang của lính tĩnh trước, luồn qua tia phản chiếu của gương, rồi dùng hai viên đá lần lượt cho lính bắn tỉa và lính tuần tra trước khi lao đến đích.",
|
||||
|
||||
"banner.noUndo": "Không hoàn tác được ở cấp độ này — mỗi bước đi đều quan trọng.",
|
||||
"banner.noPreview": "Không xem trước được ở cấp độ này — bạn không thể nhìn trước.",
|
||||
"migration.title": "Chào mừng đến Night Ninja v2",
|
||||
@@ -81,17 +141,34 @@
|
||||
"migration.dismiss": "Tiếp tục",
|
||||
"gameOver.runComplete": "Hoàn thành hành trình!",
|
||||
"gameOver.runCompleteBody": "Bạn đã dẫn dắt ninja qua vương quốc. Phòng Công chúa đang chờ...",
|
||||
"throw.hintEnter": "Nhấn E để ném đá",
|
||||
"throw.hintEnter": "Nhấn E, hoặc chạm biểu tượng đá, để ném",
|
||||
"throw.hintTargeting": "Chọn mục tiêu — Enter để ném, Esc để hủy",
|
||||
"mechanics.sniper.name": "Ớt Bắn Tỉa",
|
||||
"mechanics.suspicion.name": "Hành Tím Nghi Ngờ",
|
||||
"mechanics.suspicion.alerted": "Cảnh giác",
|
||||
"mechanics.suspicion.firing": "Khai hỏa!",
|
||||
"throw.hintInvalid": "Mục tiêu không hợp lệ — di chuyển con trỏ hoặc nhấn Esc để hủy",
|
||||
"throw.enter": "Ném đá",
|
||||
"throw.confirm": "Xác nhận ném",
|
||||
"throw.cancel": "Hủy ném",
|
||||
"hint.button": "GỢI Ý",
|
||||
"hint.title": "Gợi Ý",
|
||||
"hint.locked": "Cứ thử tiếp — thêm gợi ý sẽ mở khóa sau vài lần thử nữa.",
|
||||
"mercy.available": "Bị kẹt à? Bạn đã đủ điều kiện để bỏ qua màn này và sang màn tiếp theo.",
|
||||
"mercy.button": "BỎ QUA CẤP ĐỘ",
|
||||
"mercy.badge": "ĐÃ BỎ QUA",
|
||||
"chip.noUndo": "KHÔNG HOÀN TÁC",
|
||||
"chip.noPreview": "KHÔNG XEM TRƯỚC",
|
||||
"mechanics.stones.label": "Đá",
|
||||
"mechanics.keys.label": "Chìa khóa",
|
||||
"mechanics.oneWay.aria": "Mũi tên một chiều {dir}",
|
||||
"mechanics.warm.aria": "Ô ấm, sẽ nguội vào lượt tiếp theo",
|
||||
"mechanics.door.locked": "Cửa khóa, chìa khóa {keyId}",
|
||||
"mechanics.door.open": "Cửa mở",
|
||||
"mechanics.key.aria": "Chìa khóa {keyId}"
|
||||
"mechanics.key.aria": "Chìa khóa {keyId}",
|
||||
"board.gridLabel": "Bảng chơi, {rows} hàng, {cols} cột",
|
||||
"board.rowCol": "Hàng {row}, Cột {col}",
|
||||
"board.wall": "tường",
|
||||
"board.doorLocked": "cửa, đã khóa, chìa khóa {keyId}",
|
||||
"board.goal": "đích",
|
||||
"board.key": "chìa khóa {keyId}",
|
||||
"board.oneWay": "mũi tên một chiều {dir}",
|
||||
"board.warm": "ô ấm",
|
||||
"board.lit": "sáng",
|
||||
"board.dirUp": "lên",
|
||||
"board.dirRight": "phải",
|
||||
"board.dirDown": "xuống",
|
||||
"board.dirLeft": "trái"
|
||||
}
|
||||
+13
-4
@@ -16,15 +16,24 @@ const localizationData = {
|
||||
|
||||
// Function to get text in the current language
|
||||
function getText(key) {
|
||||
if (localizationData[currentLanguage] && localizationData[currentLanguage][key]) {
|
||||
// `!== undefined` (not truthiness) matters: an empty-string translation is
|
||||
// a valid, intentional value and must not fall through to English/missing.
|
||||
if (localizationData[currentLanguage] && localizationData[currentLanguage][key] !== undefined) {
|
||||
return localizationData[currentLanguage][key];
|
||||
}
|
||||
// Fallback to English if the key is not found in current language
|
||||
if (localizationData['en'] && localizationData['en'][key]) {
|
||||
if (localizationData['en'] && localizationData['en'][key] !== undefined) {
|
||||
return localizationData['en'][key];
|
||||
}
|
||||
// Return the key itself if no translation is found
|
||||
return key;
|
||||
// Missing key in both languages — fail loud instead of silently rendering
|
||||
// the raw key (a raw key like "levelComplete" reads as plausible text and
|
||||
// hides the bug rather than surfacing it). Never returns a falsy value, so
|
||||
// callers relying on `getText(x) || fallback` still behave, they just never
|
||||
// need the fallback branch anymore.
|
||||
if (typeof console !== 'undefined') {
|
||||
console.warn(`[i18n] missing translation key: "${key}"`);
|
||||
}
|
||||
return `⚠ missing:${key}`;
|
||||
}
|
||||
|
||||
// Function to change the language
|
||||
|
||||
+96
-8
@@ -3,12 +3,28 @@
|
||||
const STORAGE_KEY = 'nntv-progress';
|
||||
const MIGRATION_KEY = 'nntv-migration-v2';
|
||||
|
||||
const DEFAULT_PROGRESS = {
|
||||
maxLevel: 1,
|
||||
completedLevels: [],
|
||||
levelStars: {},
|
||||
levelBestMoves: {},
|
||||
};
|
||||
// Attempt counts required to unlock each progressive hint tier (nudge → mechanic
|
||||
// reminder → concrete next step), and to offer the mercy unlock. An "attempt" is
|
||||
// one detection (death) on the currently-loaded level.
|
||||
export const HINT_THRESHOLDS = [3, 5, 7];
|
||||
export const MERCY_THRESHOLD = 8;
|
||||
|
||||
// Factory, not a shared object literal — every caller that falls back to
|
||||
// "no saved data" must get its own fresh nested objects/arrays. A plain
|
||||
// `{ ...DEFAULT_PROGRESS }` spread only shallow-copies the top level, so
|
||||
// `attempts`/`completedLevels`/etc would alias the same nested object across
|
||||
// every call (and mutations from one level would leak into another whenever
|
||||
// localStorage is unavailable, e.g. private browsing).
|
||||
function freshProgress() {
|
||||
return {
|
||||
maxLevel: 1,
|
||||
completedLevels: [],
|
||||
levelStars: {},
|
||||
levelBestMoves: {},
|
||||
attempts: {},
|
||||
skippedLevels: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns { progress, needsMigrationModal }.
|
||||
@@ -23,7 +39,7 @@ export function loadProgress() {
|
||||
// Detect v1 save shape (has `lives` field)
|
||||
if (parsed.lives !== undefined) {
|
||||
// Wipe legacy data, save clean v2 shape
|
||||
const fresh = { ...DEFAULT_PROGRESS };
|
||||
const fresh = freshProgress();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
|
||||
} catch (_) {
|
||||
@@ -37,6 +53,8 @@ export function loadProgress() {
|
||||
completedLevels: parsed.completedLevels || [],
|
||||
levelStars: parsed.levelStars || {},
|
||||
levelBestMoves: parsed.levelBestMoves || {},
|
||||
attempts: parsed.attempts || {},
|
||||
skippedLevels: parsed.skippedLevels || [],
|
||||
},
|
||||
needsMigrationModal: false,
|
||||
};
|
||||
@@ -44,7 +62,7 @@ export function loadProgress() {
|
||||
} catch (_) {
|
||||
// Corrupted data, reset
|
||||
}
|
||||
return { progress: { ...DEFAULT_PROGRESS }, needsMigrationModal: false };
|
||||
return { progress: freshProgress(), needsMigrationModal: false };
|
||||
}
|
||||
|
||||
/** Legacy compat shim — returns progress only. Use loadProgress() for migration detection. */
|
||||
@@ -96,6 +114,11 @@ export function completeLevel(levelNum, totalLevels, moves, parMoves) {
|
||||
const prevBest = progress.levelBestMoves[key];
|
||||
if (prevBest == null || moves < prevBest) progress.levelBestMoves[key] = moves;
|
||||
}
|
||||
// A real clear resets the fail streak and clears any "mercy skipped" mark —
|
||||
// the player earned this level properly now.
|
||||
const levelKey = String(levelNum);
|
||||
if (progress.attempts[levelKey]) delete progress.attempts[levelKey];
|
||||
progress.skippedLevels = progress.skippedLevels.filter((n) => n !== levelNum);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
|
||||
} catch (e) {
|
||||
@@ -103,3 +126,68 @@ export function completeLevel(levelNum, totalLevels, moves, parMoves) {
|
||||
}
|
||||
return { ...progress, stars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one failed attempt (detection) on a level. Returns the updated
|
||||
* attempt count for that level. Safe under localStorage failures (private
|
||||
* browsing, quota exceeded) — falls back to an in-memory count of 1.
|
||||
*/
|
||||
export function recordDetection(levelNum) {
|
||||
const progress = getProgress();
|
||||
const key = String(levelNum);
|
||||
progress.attempts[key] = (progress.attempts[key] || 0) + 1;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
|
||||
} catch (e) {
|
||||
// Storage full or unavailable — count still returned for this session
|
||||
}
|
||||
return progress.attempts[key];
|
||||
}
|
||||
|
||||
/** Current attempt (detection) count for a level. */
|
||||
export function getAttempts(levelNum) {
|
||||
const progress = getProgress();
|
||||
return progress.attempts[String(levelNum)] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many progressive hint tiers are unlocked for a level, given its current
|
||||
* attempt count (0 = none unlocked yet). Tiers escalate: nudge → mechanic
|
||||
* reminder → concrete next step.
|
||||
*/
|
||||
export function getUnlockedHintTiers(levelNum) {
|
||||
const attempts = getAttempts(levelNum);
|
||||
let tiers = 0;
|
||||
for (const threshold of HINT_THRESHOLDS) {
|
||||
if (attempts >= threshold) tiers++;
|
||||
}
|
||||
return tiers;
|
||||
}
|
||||
|
||||
/** Whether the player has failed enough to be offered a mercy skip. */
|
||||
export function isMercyEligible(levelNum) {
|
||||
return getAttempts(levelNum) >= MERCY_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a mercy unlock: unlocks levelNum + 1 without requiring a real clear,
|
||||
* and marks levelNum as "skipped" (distinct from completed) so 100% completion
|
||||
* still requires coming back to actually solve it. No-op if already completed.
|
||||
*/
|
||||
export function mercySkipLevel(levelNum, totalLevels) {
|
||||
const progress = getProgress();
|
||||
if (progress.completedLevels.includes(levelNum)) return progress;
|
||||
if (!progress.skippedLevels.includes(levelNum)) {
|
||||
progress.skippedLevels.push(levelNum);
|
||||
}
|
||||
const nextLevel = Math.min(levelNum + 1, totalLevels);
|
||||
if (nextLevel > progress.maxLevel) {
|
||||
progress.maxLevel = nextLevel;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
|
||||
} catch (e) {
|
||||
// Storage full or unavailable
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
getProgress, completeLevel, calculateStars,
|
||||
recordDetection, getAttempts, getUnlockedHintTiers,
|
||||
isMercyEligible, mercySkipLevel, HINT_THRESHOLDS, MERCY_THRESHOLD,
|
||||
} from './progress.js';
|
||||
|
||||
// Minimal in-memory localStorage stand-in — vitest's default (node) environment
|
||||
// has no localStorage global, and progress.js talks to it directly.
|
||||
function makeMemoryStorage() {
|
||||
const store = new Map();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('progress.js — attempt tracking and mercy unlock', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage = makeMemoryStorage();
|
||||
});
|
||||
|
||||
it('starts a level with zero attempts', () => {
|
||||
expect(getAttempts(4)).toBe(0);
|
||||
expect(getUnlockedHintTiers(4)).toBe(0);
|
||||
expect(isMercyEligible(4)).toBe(false);
|
||||
});
|
||||
|
||||
it('increments and persists attempts across recordDetection calls', () => {
|
||||
expect(recordDetection(4)).toBe(1);
|
||||
expect(recordDetection(4)).toBe(2);
|
||||
expect(getAttempts(4)).toBe(2);
|
||||
// A different level tracks independently
|
||||
expect(getAttempts(5)).toBe(0);
|
||||
});
|
||||
|
||||
it('unlocks hint tiers at the configured thresholds, escalating', () => {
|
||||
expect(HINT_THRESHOLDS).toEqual([3, 5, 7]);
|
||||
for (let i = 0; i < HINT_THRESHOLDS[0]; i++) recordDetection(4);
|
||||
expect(getUnlockedHintTiers(4)).toBe(1);
|
||||
for (let i = 0; i < HINT_THRESHOLDS[1] - HINT_THRESHOLDS[0]; i++) recordDetection(4);
|
||||
expect(getUnlockedHintTiers(4)).toBe(2);
|
||||
for (let i = 0; i < HINT_THRESHOLDS[2] - HINT_THRESHOLDS[1]; i++) recordDetection(4);
|
||||
expect(getUnlockedHintTiers(4)).toBe(3);
|
||||
});
|
||||
|
||||
it('becomes mercy-eligible only at the mercy threshold', () => {
|
||||
for (let i = 0; i < MERCY_THRESHOLD - 1; i++) recordDetection(7);
|
||||
expect(isMercyEligible(7)).toBe(false);
|
||||
recordDetection(7);
|
||||
expect(isMercyEligible(7)).toBe(true);
|
||||
});
|
||||
|
||||
it('mercySkipLevel unlocks the next level and marks the current one skipped', () => {
|
||||
for (let i = 0; i < MERCY_THRESHOLD; i++) recordDetection(7);
|
||||
const progress = mercySkipLevel(7, 11);
|
||||
expect(progress.skippedLevels).toContain(7);
|
||||
expect(progress.maxLevel).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('mercySkipLevel never unlocks past the total level count', () => {
|
||||
const progress = mercySkipLevel(11, 11);
|
||||
expect(progress.maxLevel).toBe(11);
|
||||
});
|
||||
|
||||
it('mercySkipLevel is a no-op for an already-completed level', () => {
|
||||
completeLevel(3, 11, 20, 20);
|
||||
const before = getProgress();
|
||||
const after = mercySkipLevel(3, 11);
|
||||
expect(after.skippedLevels).not.toContain(3);
|
||||
expect(after.maxLevel).toBe(before.maxLevel);
|
||||
});
|
||||
|
||||
it('completing a level resets its attempt streak and clears a skip mark', () => {
|
||||
for (let i = 0; i < MERCY_THRESHOLD; i++) recordDetection(4);
|
||||
mercySkipLevel(4, 11);
|
||||
expect(getProgress().skippedLevels).toContain(4);
|
||||
|
||||
completeLevel(4, 11, 15, 20);
|
||||
const progress = getProgress();
|
||||
expect(getAttempts(4)).toBe(0);
|
||||
expect(progress.skippedLevels).not.toContain(4);
|
||||
expect(progress.completedLevels).toContain(4);
|
||||
});
|
||||
|
||||
it('calculateStars still ranks 3/2/1 stars correctly (unchanged behavior)', () => {
|
||||
expect(calculateStars(20, 20)).toBe(3);
|
||||
expect(calculateStars(22, 20)).toBe(2);
|
||||
expect(calculateStars(30, 20)).toBe(1);
|
||||
});
|
||||
|
||||
it('survives a localStorage that throws on every call (private-mode guard)', () => {
|
||||
globalThis.localStorage = {
|
||||
getItem() { throw new Error('blocked'); },
|
||||
setItem() { throw new Error('blocked'); },
|
||||
};
|
||||
// Persistence is impossible, so each call resolves against a fresh
|
||||
// in-memory default rather than crashing or silently no-op'ing.
|
||||
expect(recordDetection(2)).toBe(1);
|
||||
expect(() => mercySkipLevel(2, 11)).not.toThrow();
|
||||
// Without persistence, state cannot survive between separate calls —
|
||||
// this asserts the *fallback* is fresh defaults, not a shared/leaking
|
||||
// object across levels or calls (a bug this guards against).
|
||||
expect(getAttempts(3)).toBe(0);
|
||||
});
|
||||
});
|
||||
+141
-31
@@ -6,7 +6,11 @@
|
||||
import { GameHistory } from '../lib/game/game-history.js';
|
||||
import { PrincessMechanic } from '../lib/game/princess-mechanic.js';
|
||||
import { TouchControls } from '../lib/game/touch-controls.js';
|
||||
import { completeLevel, calculateStars } from '../lib/progress.js';
|
||||
import {
|
||||
completeLevel, calculateStars, recordDetection, getUnlockedHintTiers,
|
||||
isMercyEligible, mercySkipLevel,
|
||||
} from '../lib/progress.js';
|
||||
import { hintKeysForLevel, levelHasHints } from '../lib/level-hints.js';
|
||||
import { LEVELS } from '../lib/levels/levels.js';
|
||||
import {
|
||||
playMove, playWait, playDetection, playLevelComplete, playUndo,
|
||||
@@ -22,6 +26,7 @@
|
||||
import LevelCompletePopup from '../components/LevelCompletePopup.svelte';
|
||||
import PauseMenu from '../components/PauseMenu.svelte';
|
||||
import ControlsOverlay from '../components/ControlsOverlay.svelte';
|
||||
import HintPanel from '../components/HintPanel.svelte';
|
||||
import { generatedSceneForLevel } from '../lib/generated-assets.js';
|
||||
|
||||
let { navigate, level = 1 } = $props();
|
||||
@@ -47,6 +52,10 @@
|
||||
// UI state
|
||||
let isPaused = $state(false);
|
||||
let detected = $state(false);
|
||||
// showDetectionPopup lags `detected` by a short delay (see triggerDetection)
|
||||
// so the 400ms death flash is visible before the dark popup overlay covers
|
||||
// it — otherwise the player never sees the lit cell that caught them.
|
||||
let showDetectionPopup = $state(false);
|
||||
let playerShake = $state(false);
|
||||
let detectedCell = $state(null);
|
||||
let showFlash = $state(false);
|
||||
@@ -54,8 +63,21 @@
|
||||
let showLevelComplete = $state(false);
|
||||
let completionStars = $state(0);
|
||||
let completionMoves = $state(0);
|
||||
// Not reset on level load/restart — a player's preview preference should
|
||||
// persist across levels rather than snapping back to off every time.
|
||||
let showPreview = $state(false);
|
||||
let showControls = $state(false);
|
||||
let showHintPanel = $state(false);
|
||||
|
||||
// Stuck-player support: how many times the player has been detected on
|
||||
// the *current* level. The count itself lives in progress.js (persisted,
|
||||
// survives restarts); `attempts` here just forces these $derived values to
|
||||
// recompute after each recordDetection() call (progress.js reads are not
|
||||
// themselves reactive — same pattern as `renderVersion` above).
|
||||
let attempts = $state(0);
|
||||
let hintTiersAvailable = $derived((attempts, levelHasHints(currentLevel) ? getUnlockedHintTiers(currentLevel) : 0));
|
||||
let mercyEligible = $derived((attempts, !isFinalLevel && currentLevel < getTotalLevels() && isMercyEligible(currentLevel)));
|
||||
let hintKeys = $derived(hintKeysForLevel(currentLevel));
|
||||
|
||||
// Throw-targeting state machine: 'idle' | 'targeting'
|
||||
// When in targeting mode, arrow keys move cursor instead of player.
|
||||
@@ -79,7 +101,7 @@
|
||||
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, tier: g.tier
|
||||
row: g.row, col: g.col, type: g.type, direction: g.direction, isOn: g.isOn, isChasing: g.isChasing, tier: g.tier, range: g.range
|
||||
}))));
|
||||
let previewCells = $derived((renderVersion, showPreview && affordances.preview && grid && player && guards.length
|
||||
? turnManager.previewNextTurn(grid, player, guards, throwSystem)
|
||||
@@ -89,6 +111,7 @@
|
||||
|
||||
// HUD reactive values
|
||||
let stonesLeft = $derived((renderVersion, throwSystem ? throwSystem.stonesLeft : 0));
|
||||
let canThrow = $derived(levelHasStones && stonesLeft > 0 && throwMode === 'idle');
|
||||
let keysHeld = $derived((renderVersion, player ? player.getKeysHeld() : 0));
|
||||
let lockedDoorCount = $derived((renderVersion, grid ? grid.getDoorSnapshot().length : 0));
|
||||
|
||||
@@ -176,6 +199,12 @@
|
||||
let detectionTimeout = null;
|
||||
// Restart-after-detection timer
|
||||
let restartTimeout = null;
|
||||
// Delay before the DetectionPopup actually mounts (see triggerDetection)
|
||||
let popupTimeout = null;
|
||||
// The 400ms cell-flash/player-shake needs to finish being visible before
|
||||
// the popup's dark overlay covers the board.
|
||||
const DETECTION_FLASH_MS = 400;
|
||||
const DETECTION_POPUP_DELAY_MS = 500;
|
||||
|
||||
// Viewport scroll-follow — scrolls the board container so the player stays in view
|
||||
// on grids larger than the viewport. Binds to .board-container element.
|
||||
@@ -194,6 +223,8 @@
|
||||
|
||||
// Initialize (or restart) the current level
|
||||
function initLevel() {
|
||||
if (detectionTimeout) { clearTimeout(detectionTimeout); detectionTimeout = null; }
|
||||
if (popupTimeout) { clearTimeout(popupTimeout); popupTimeout = null; }
|
||||
const state = loadLevel(currentLevel);
|
||||
if (!state) {
|
||||
// Invalid level id — bail to main menu instead of leaving stale state
|
||||
@@ -217,19 +248,22 @@
|
||||
history = new GameHistory();
|
||||
princess = new PrincessMechanic();
|
||||
detected = false;
|
||||
showDetectionPopup = false;
|
||||
isPaused = false;
|
||||
finalMessage = false;
|
||||
showFlash = false;
|
||||
showControls = false;
|
||||
showHintPanel = false;
|
||||
playerShake = false;
|
||||
detectedCell = null;
|
||||
showLevelComplete = false;
|
||||
completionStars = 0;
|
||||
completionMoves = 0;
|
||||
showPreview = false;
|
||||
// showPreview intentionally NOT reset — persists across levels/restarts.
|
||||
// Reset targeting state on level load
|
||||
throwMode = 'idle';
|
||||
throwCursor = null;
|
||||
attempts++; // force hint/mercy $derived values to re-read progress.js for the (new) current level
|
||||
renderVersion++;
|
||||
}
|
||||
|
||||
@@ -237,6 +271,7 @@
|
||||
function restartLevel() {
|
||||
if (detectionTimeout) { clearTimeout(detectionTimeout); detectionTimeout = null; }
|
||||
if (restartTimeout) { clearTimeout(restartTimeout); restartTimeout = null; }
|
||||
if (popupTimeout) { clearTimeout(popupTimeout); popupTimeout = null; }
|
||||
// Reset throwSystem stone count from level data
|
||||
if (throwSystem) {
|
||||
const levelData = LEVELS[currentLevel - 1];
|
||||
@@ -273,6 +308,7 @@
|
||||
onDestroy(() => {
|
||||
if (detectionTimeout) clearTimeout(detectionTimeout);
|
||||
if (restartTimeout) clearTimeout(restartTimeout);
|
||||
if (popupTimeout) clearTimeout(popupTimeout);
|
||||
if (typeof window !== 'undefined') delete window.__nntvDev;
|
||||
});
|
||||
|
||||
@@ -289,8 +325,31 @@
|
||||
|
||||
// Input handling
|
||||
function onKeyDown(e) {
|
||||
if (showControls) { if (e.key === 'Escape') showControls = false; return; }
|
||||
if (isPaused || detected || showLevelComplete) return;
|
||||
// Normalize single-character keys so both cases of e.g. 'e'/'E' match one
|
||||
// check; multi-char keys ('Escape', 'ArrowUp', 'Enter', ' ') pass through.
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
|
||||
if (showControls) { if (key === 'Escape') showControls = false; return; }
|
||||
if (showHintPanel) { if (key === 'Escape') showHintPanel = false; return; }
|
||||
|
||||
// ── Detection popup: once it's actually visible, Enter/Space/R dismiss
|
||||
// it instantly (the primary button is also autofocused, so Enter/Space
|
||||
// already work natively — this covers Space, which some browsers don't
|
||||
// treat as a native button activator via keydown, plus the R shortcut). ──
|
||||
if (showDetectionPopup) {
|
||||
if (key === 'Enter' || key === ' ' || key === 'r') {
|
||||
e.preventDefault();
|
||||
handleDetectionDismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (detected) return; // detected but popup not shown yet — input stays frozen
|
||||
|
||||
if (isPaused) {
|
||||
if (key === 'Escape') { isPaused = false; }
|
||||
return;
|
||||
}
|
||||
if (showLevelComplete) return;
|
||||
|
||||
const dirMap = {
|
||||
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
||||
@@ -300,31 +359,26 @@
|
||||
// ── 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;
|
||||
}
|
||||
if (key === 'Escape') { cancelThrow(); return; }
|
||||
if (key === 'e' || key === 'Enter') { confirmThrow(); return; }
|
||||
// Arrow / WASD moves cursor
|
||||
const cursorDir = dirMap[e.key];
|
||||
const cursorDir = dirMap[key];
|
||||
if (cursorDir) { moveCursor(cursorDir); return; }
|
||||
return; // swallow any other key in targeting mode
|
||||
}
|
||||
|
||||
// ── Normal (idle) mode ──
|
||||
if (e.key === 'v') {
|
||||
if (key === 'Escape') { isPaused = true; return; }
|
||||
if (key === 'r') { restartLevel(); return; }
|
||||
if (key === 'v') {
|
||||
if (affordances.preview) showPreview = !showPreview;
|
||||
return;
|
||||
}
|
||||
if (e.key === ' ') { e.preventDefault(); handleWait(); 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 (key === ' ') { e.preventDefault(); handleWait(); return; }
|
||||
if (key === 'z') { if (affordances.undo) handleUndo(); return; }
|
||||
if (key === 'y') { if (affordances.undo) handleRedo(); return; }
|
||||
if (key === 'e') { enterTargeting(); return; }
|
||||
const dir = dirMap[key];
|
||||
if (dir) { e.preventDefault(); handleMove(dir); }
|
||||
}
|
||||
|
||||
@@ -333,6 +387,8 @@
|
||||
snapshotBeforeAction();
|
||||
playWait();
|
||||
const result = turnManager.nextTurn(grid, player, guards, throwSystem);
|
||||
// INTENTIONAL — same L12 ordering as handleMove(); see the comment
|
||||
// there. Do not reorder.
|
||||
if (isFinalLevel && checkFinalLevel()) { renderVersion++; return; }
|
||||
renderVersion++;
|
||||
if (result.levelComplete) handleLevelComplete();
|
||||
@@ -340,7 +396,7 @@
|
||||
}
|
||||
|
||||
function onCellClick(row, col) {
|
||||
if (isPaused || detected || showLevelComplete || showControls || !player) return;
|
||||
if (isPaused || detected || showLevelComplete || showControls || showHintPanel || !player) return;
|
||||
|
||||
// In targeting mode: move cursor to clicked cell, auto-confirm if valid
|
||||
if (throwMode === 'targeting') {
|
||||
@@ -375,6 +431,12 @@
|
||||
|
||||
const result = turnManager.nextTurn(grid, player, guards, throwSystem);
|
||||
|
||||
// INTENTIONAL ordering — do not "fix": checkFinalLevel() runs before
|
||||
// result.levelComplete is handled, and returns early on a hit. On L12
|
||||
// this makes reaching the goal cell unreachable in practice: the
|
||||
// princess's expanding light always catches the player first. L12 is
|
||||
// a deliberate unsolvable easter egg (owner decision) — swapping this
|
||||
// order would accidentally make it winnable by normal play.
|
||||
if (isFinalLevel && checkFinalLevel()) {
|
||||
renderVersion++;
|
||||
return;
|
||||
@@ -399,6 +461,13 @@
|
||||
throwCursor = { row: player.row, col: player.col };
|
||||
}
|
||||
|
||||
// Cancel targeting mode without throwing — shared by Escape and the
|
||||
// on-screen Cancel button (the only way a touch-only player can back out).
|
||||
function cancelThrow() {
|
||||
throwMode = 'idle';
|
||||
throwCursor = null;
|
||||
}
|
||||
|
||||
// Move cursor one step in a direction (clamped to grid bounds)
|
||||
function moveCursor(dir) {
|
||||
if (!throwCursor || !grid) return;
|
||||
@@ -432,27 +501,49 @@
|
||||
throwCursor = null;
|
||||
}
|
||||
|
||||
// Detection feedback — flash cell, shake player, play sound, then restart level.
|
||||
// DetectionPopup is shown briefly; on dismiss (or after timeout) level reloads.
|
||||
// Detection feedback — flash cell, shake player, play sound, then (after a
|
||||
// short delay so the flash is actually visible) show the restart popup.
|
||||
// `detected` freezes input immediately; `showDetectionPopup` lags behind it
|
||||
// so the dark popup overlay never hides the very cell that caught the
|
||||
// player — the core "why did I die" feedback loop of a stealth puzzle.
|
||||
function triggerDetection() {
|
||||
detectedCell = { row: player.row, col: player.col };
|
||||
playerShake = true;
|
||||
playDetection();
|
||||
detected = true;
|
||||
attempts = recordDetection(currentLevel);
|
||||
if (detectionTimeout) clearTimeout(detectionTimeout);
|
||||
detectionTimeout = setTimeout(() => {
|
||||
playerShake = false;
|
||||
detectedCell = null;
|
||||
detectionTimeout = null;
|
||||
}, 400);
|
||||
}, DETECTION_FLASH_MS);
|
||||
if (popupTimeout) clearTimeout(popupTimeout);
|
||||
popupTimeout = setTimeout(() => {
|
||||
showDetectionPopup = true;
|
||||
popupTimeout = null;
|
||||
}, DETECTION_POPUP_DELAY_MS);
|
||||
}
|
||||
|
||||
// Called when the player dismisses the DetectionPopup — restart current level
|
||||
function handleDetectionDismiss() {
|
||||
detected = false;
|
||||
showDetectionPopup = false;
|
||||
restartLevel();
|
||||
}
|
||||
|
||||
// Mercy unlock — offered after repeated failures (see progress.js
|
||||
// MERCY_THRESHOLD). Explicit, player-initiated action: unlocks the next
|
||||
// level without requiring a real clear of this one, then moves on.
|
||||
function handleMercySkip() {
|
||||
detected = false;
|
||||
showDetectionPopup = false;
|
||||
const total = getTotalLevels();
|
||||
mercySkipLevel(currentLevel, total);
|
||||
const next = Math.min(currentLevel + 1, total);
|
||||
navigate('LevelIntro', { level: next });
|
||||
}
|
||||
|
||||
// Undo/redo handlers
|
||||
function applyHistoryState(state) {
|
||||
princess.apply(state.princess);
|
||||
@@ -483,7 +574,7 @@
|
||||
function onTouchStart(e) { touch.onTouchStart(e); }
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (isPaused || detected || showLevelComplete || showControls) return;
|
||||
if (isPaused || detected || showLevelComplete || showControls || showHintPanel) return;
|
||||
const dir = touch.onTouchEnd(e);
|
||||
if (dir) handleMove(dir);
|
||||
}
|
||||
@@ -544,13 +635,18 @@
|
||||
allowPreview={affordances.preview}
|
||||
showStones={levelHasStones}
|
||||
{stonesLeft}
|
||||
{canThrow}
|
||||
showKeys={levelHasKeys}
|
||||
{keysHeld}
|
||||
{hintTiersAvailable}
|
||||
hintsShown={showHintPanel}
|
||||
ontogglepreview={() => { if (affordances.preview) showPreview = !showPreview; }}
|
||||
onpause={() => isPaused = true}
|
||||
onmenu={() => navigate('MainMenu')}
|
||||
onundo={handleUndo}
|
||||
onshowcontrols={() => showControls = true}
|
||||
onenterthrow={enterTargeting}
|
||||
onshowhint={() => showHintPanel = true}
|
||||
/>
|
||||
|
||||
{#if grid && player}
|
||||
@@ -574,6 +670,8 @@
|
||||
rows={grid.rows}
|
||||
cols={grid.cols}
|
||||
{cellSize}
|
||||
onconfirm={confirmThrow}
|
||||
oncancel={cancelThrow}
|
||||
/>
|
||||
{/if}
|
||||
<PlayerSprite row={playerRow} col={playerCol} {cellSize} shake={playerShake} />
|
||||
@@ -606,8 +704,8 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if detected}
|
||||
<DetectionPopup onplayagain={handleDetectionDismiss} />
|
||||
{#if showDetectionPopup}
|
||||
<DetectionPopup onplayagain={handleDetectionDismiss} {mercyEligible} onmercyskip={handleMercySkip} />
|
||||
{/if}
|
||||
|
||||
{#if isPaused}
|
||||
@@ -615,12 +713,17 @@
|
||||
onresume={() => isPaused = false}
|
||||
onrestart={initLevel}
|
||||
onmainmenu={() => navigate('MainMenu')}
|
||||
onguide={() => navigate('Guide')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showControls}
|
||||
<ControlsOverlay onclose={() => showControls = false} />
|
||||
{/if}
|
||||
|
||||
{#if showHintPanel}
|
||||
<HintPanel {hintKeys} unlockedTiers={hintTiersAvailable} onclose={() => showHintPanel = false} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -669,11 +772,18 @@
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 24px rgba(0, 0, 0, 0.6);
|
||||
/* Cap viewport; grids larger than this scroll with camera-follow */
|
||||
max-width: min(720px, 85vw, 85vh);
|
||||
max-height: min(720px, 85vw, 85vh);
|
||||
/* Cap size within the fixed 1024x768 design box; grids larger than this
|
||||
scroll with camera-follow. Uses px, not vw/vh — the whole #app stage
|
||||
is scaled as one unit (see theme.css), so viewport units here would
|
||||
be measured against the real (unscaled) window and shrink twice. */
|
||||
max-width: 720px;
|
||||
max-height: 620px;
|
||||
overflow: auto;
|
||||
scroll-behavior: smooth;
|
||||
/* Without this, a touch swipe intended as a move gesture can also
|
||||
trigger the browser's native scroll/pan on this scrollable
|
||||
container, fighting the custom swipe handling in touch-controls.js. */
|
||||
touch-action: none;
|
||||
}
|
||||
.final-message {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { getText } from '../lib/localization.js';
|
||||
import Button from '../components/Button.svelte';
|
||||
import { GENERATED_CHARACTERS, GENERATED_SCENES } from '../lib/generated-assets.js';
|
||||
import { GENERATED_CHARACTERS, GENERATED_SCENES, KEYART } from '../lib/generated-assets.js';
|
||||
|
||||
// flow: 'runComplete' (L11 cleared) | 'bittersweet' (L12 reached goal)
|
||||
let { navigate, level = 1, flow = 'runComplete' } = $props();
|
||||
@@ -13,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<div class="gameover">
|
||||
<img class="gameover-bg" src={isRunComplete ? GENERATED_SCENES[10] : GENERATED_SCENES[11]} alt="" draggable="false" />
|
||||
<img class="gameover-bg" src={isRunComplete ? GENERATED_SCENES[10] : KEYART.gameover} alt="" draggable="false" />
|
||||
{#if !isRunComplete}
|
||||
<img class="princess" src={GENERATED_CHARACTERS.princess} alt="" draggable="false" />
|
||||
{/if}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script>
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { LEVELS } from '../lib/levels/levels.js';
|
||||
import { teachesForLevel } from '../lib/level-teaches.js';
|
||||
import Button from '../components/Button.svelte';
|
||||
import AffordanceBanner from '../components/AffordanceBanner.svelte';
|
||||
import { generatedSceneForLevel } from '../lib/generated-assets.js';
|
||||
let { navigate, level = 1 } = $props();
|
||||
|
||||
let levelData = $derived(LEVELS[level - 1]);
|
||||
let levelName = $derived(levelData?.name || `Level ${level}`);
|
||||
// Localized name first (VI-default game, level names were English-only);
|
||||
// levelData.name is only a fallback for a level that has none authored.
|
||||
let levelName = $derived(getText(`level${level}Name`) ?? levelData?.name ?? `Level ${level}`);
|
||||
let storyText = $derived(levelData?.storyKey ? getText(levelData.storyKey) : '');
|
||||
let scene = $derived(generatedSceneForLevel(level));
|
||||
let teaches = $derived(teachesForLevel(level));
|
||||
|
||||
// Affordance gates — default both enabled until phase 04 populates levels.js
|
||||
let affordances = $derived(levelData?.affordances ?? { undo: true, preview: true });
|
||||
@@ -25,6 +29,16 @@
|
||||
{#if storyText}
|
||||
<p class="story">{storyText}</p>
|
||||
{/if}
|
||||
{#if teaches.length > 0}
|
||||
<div class="teaches">
|
||||
<span class="teaches-label">{getText('teaches.label')}</span>
|
||||
<ul>
|
||||
{#each teaches as id (id)}
|
||||
<li>{getText(`teaches.${id}`)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
<AffordanceBanner undo={affordances.undo} preview={affordances.preview} />
|
||||
<Button text={getText('continue')} onclick={() => navigate('Game', { level })} />
|
||||
</div>
|
||||
@@ -82,4 +96,26 @@
|
||||
white-space: pre-line;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.teaches {
|
||||
max-width: 70%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(187, 134, 252, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.teaches-label {
|
||||
font: var(--font-small);
|
||||
color: var(--text-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
.teaches ul {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
.teaches li {
|
||||
font: var(--font-small);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -23,12 +23,14 @@
|
||||
{@const num = i + 1}
|
||||
{@const unlocked = num <= progress.maxLevel}
|
||||
{@const completed = progress.completedLevels.includes(num)}
|
||||
{@const skipped = !completed && progress.skippedLevels.includes(num)}
|
||||
{@const stars = progress.levelStars[String(num)] || 0}
|
||||
{@const bestMoves = progress.levelBestMoves[String(num)]}
|
||||
<button
|
||||
class="level-btn"
|
||||
class:unlocked
|
||||
class:completed
|
||||
class:skipped
|
||||
class:locked={!unlocked}
|
||||
onclick={() => selectLevel(num)}
|
||||
disabled={!unlocked}
|
||||
@@ -40,6 +42,8 @@
|
||||
<span class="star-sm" class:filled={i <= stars}>★</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else if skipped}
|
||||
<span class="skipped-badge" title={getText('mercy.available')}>{getText('mercy.badge')}</span>
|
||||
{/if}
|
||||
{#if bestMoves != null}
|
||||
<span class="best-moves">{bestMoves}m</span>
|
||||
@@ -83,6 +87,15 @@
|
||||
}
|
||||
.level-btn.unlocked:hover { background: var(--btn-hover); }
|
||||
.level-btn.completed { border-color: var(--grid-goal); }
|
||||
.level-btn.skipped { border-color: #776633; }
|
||||
.skipped-badge {
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
color: #bbaa77;
|
||||
border: 1px solid #776633;
|
||||
border-radius: 2px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.level-btn.locked {
|
||||
background: #111122;
|
||||
color: #555566;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Button from '../components/Button.svelte';
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { MOON_ART, MOON_PAL } from '../lib/pixel/art-ui.js';
|
||||
import { GENERATED_CHARACTERS } from '../lib/generated-assets.js';
|
||||
import { GENERATED_CHARACTERS, KEYART } from '../lib/generated-assets.js';
|
||||
let { navigate } = $props();
|
||||
|
||||
const stars = Array.from({ length: 40 }, () => ({
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="menu">
|
||||
<div class="scene-backdrop">
|
||||
<img src="assets/scene-menu.png" alt="" draggable="false" />
|
||||
<img src={KEYART.garden} alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
{#each stars as star}
|
||||
|
||||
+21
-3
@@ -62,12 +62,30 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* Responsive stage: #app keeps its fixed 1024x768 design box (every child
|
||||
* component below still lays out in that coordinate space — no per-component
|
||||
* media queries needed) and is uniformly scaled to fit the real viewport.
|
||||
* min(1, ...) means desktops at/above 1024x768 render at native 1x exactly
|
||||
* like before; anything smaller (phones down to ~360px wide) scales down
|
||||
* proportionally instead of being clipped by the old max-width/max-height.
|
||||
* transform-origin: center + body's flex centering keeps it centered at
|
||||
* every scale factor.
|
||||
*/
|
||||
#app {
|
||||
width: 1024px;
|
||||
height: 768px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
aspect-ratio: 1024 / 768;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform: scale(min(1, calc(100vw / 1024), calc(100vh / 768)));
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user