refactor(death): replace lives counter with level-restart on detection

Drop run-wide lives state. Detection routes to level-restart instead of
game-over. Repurpose GameOver scene for run-complete (L11) and bittersweet
(L12) flows. One-shot save migration wipes legacy lives field with welcome
modal.
This commit is contained in:
tiennm99 committed 2026-04-26 08:27:25 +07:00
1 parent e550600218
commit 8f22138774
5 files changed
+139 -22

No files matched your search

+75 -3
View File
@@ -1,6 +1,9 @@
<script>
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { getText } from './lib/localization.js';
import { initLanguage } from './lib/localization.js';
import { loadProgress, acknowledgeMigration, isMigrationAcknowledged } from './lib/progress.js';
import MainMenu from './scenes/MainMenu.svelte';
import StoryIntro from './scenes/StoryIntro.svelte';
import LevelIntro from './scenes/LevelIntro.svelte';
@@ -16,10 +19,25 @@
let currentScene = $state('MainMenu');
let sceneData = $state({});
// Migration modal — shown once on first v2 boot if legacy save was detected
let showMigrationModal = $state(false);
function navigate(scene, data = {}) {
sceneData = data;
currentScene = scene;
}
onMount(() => {
const { needsMigrationModal } = loadProgress();
if (needsMigrationModal && !isMigrationAcknowledged()) {
showMigrationModal = true;
}
});
function dismissMigration() {
showMigrationModal = false;
acknowledgeMigration();
}
</script>
<div class="game-container">
@@ -30,13 +48,13 @@
{:else if currentScene === 'StoryIntro'}
<StoryIntro {navigate} />
{:else if currentScene === 'LevelIntro'}
<LevelIntro {navigate} level={sceneData.level} lives={sceneData.lives} />
<LevelIntro {navigate} level={sceneData.level} />
{:else if currentScene === 'LevelSelect'}
<LevelSelect {navigate} />
{:else if currentScene === 'Game'}
<Game {navigate} level={sceneData.level} lives={sceneData.lives} />
<Game {navigate} level={sceneData.level} />
{:else if currentScene === 'GameOver'}
<GameOver {navigate} level={sceneData.level} isLastLevel={sceneData.isLastLevel} />
<GameOver {navigate} level={sceneData.level} flow={sceneData.flow} />
{:else if currentScene === 'Settings'}
<Settings {navigate} />
{:else if currentScene === 'Guide'}
@@ -44,6 +62,17 @@
{/if}
</div>
{/key}
{#if showMigrationModal}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-backdrop" role="presentation" onclick={dismissMigration}>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="migration-title">
<h2 id="migration-title">{getText('migration.title')}</h2>
<p>{getText('migration.body')}</p>
<button class="modal-btn" onclick={dismissMigration}>{getText('migration.dismiss')}</button>
</div>
</div>
{/if}
</div>
<style>
@@ -58,4 +87,47 @@
position: absolute;
inset: 0;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-dark, #0a0a1a);
border: 2px solid var(--btn-border, #334);
border-radius: 8px;
padding: 32px 40px;
max-width: 420px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.modal h2 {
font: var(--font-title, bold 28px Arial, sans-serif);
color: var(--text-accent, #44ffaa);
}
.modal p {
font: var(--font-body, 16px Arial, sans-serif);
color: var(--text-primary, #ccccdd);
line-height: 1.5;
}
.modal-btn {
margin-top: 8px;
padding: 10px 32px;
font: var(--font-button, bold 14px Arial, sans-serif);
background: var(--btn-default, #1a1a2e);
color: var(--text-primary, #ccccdd);
border: 2px solid var(--btn-border, #334);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.modal-btn:hover { background: var(--btn-hover, #252540); }
</style>
+49 -7
View File
@@ -1,6 +1,7 @@
// Level progress persistence via localStorage
const STORAGE_KEY = 'nntv-progress';
const MIGRATION_KEY = 'nntv-migration-v2';
const DEFAULT_PROGRESS = {
maxLevel: 1,
@@ -9,22 +10,63 @@ const DEFAULT_PROGRESS = {
levelBestMoves: {},
};
export function getProgress() {
/**
* Returns { progress, needsMigrationModal }.
* If saved data has legacy `lives` field (v1 shape), discards it and sets
* needsMigrationModal = true so App.svelte can show the one-time modal.
*/
export function loadProgress() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
// Detect v1 save shape (has `lives` field)
if (parsed.lives !== undefined) {
// Wipe legacy data, save clean v2 shape
const fresh = { ...DEFAULT_PROGRESS };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
} catch (_) {
// Storage full — proceed with in-memory defaults
}
return { progress: fresh, needsMigrationModal: true };
}
return {
maxLevel: parsed.maxLevel || 1,
completedLevels: parsed.completedLevels || [],
levelStars: parsed.levelStars || {},
levelBestMoves: parsed.levelBestMoves || {},
progress: {
maxLevel: parsed.maxLevel || 1,
completedLevels: parsed.completedLevels || [],
levelStars: parsed.levelStars || {},
levelBestMoves: parsed.levelBestMoves || {},
},
needsMigrationModal: false,
};
}
} catch (e) {
} catch (_) {
// Corrupted data, reset
}
return { ...DEFAULT_PROGRESS };
return { progress: { ...DEFAULT_PROGRESS }, needsMigrationModal: false };
}
/** Legacy compat shim — returns progress only. Use loadProgress() for migration detection. */
export function getProgress() {
return loadProgress().progress;
}
/** Whether the v2 migration modal has been acknowledged. */
export function isMigrationAcknowledged() {
try {
return localStorage.getItem(MIGRATION_KEY) === 'done';
} catch (_) {
return true;
}
}
export function acknowledgeMigration() {
try {
localStorage.setItem(MIGRATION_KEY, 'done');
} catch (_) {
// ignore
}
}
// Calculate star rating: 3★ = ≤par, 2★ = par+1 to par+3, 1★ = completed
+12 -9
View File
@@ -1,21 +1,24 @@
<script>
import { getText } from '../lib/localization.js';
import Button from '../components/Button.svelte';
let { navigate, level = 1, isLastLevel = false } = $props();
let title = $derived(isLastLevel ? getText('theEnd') : getText('gameOver'));
let desc = $derived(isLastLevel ? getText('ninjaFailed') : getText('caughtInLight'));
// flow: 'runComplete' (L11 cleared) | 'bittersweet' (L12 reached goal)
let { navigate, level = 1, flow = 'runComplete' } = $props();
let isRunComplete = $derived(flow === 'runComplete');
let title = $derived(isRunComplete ? getText('gameOver.runComplete') : getText('theEnd'));
let desc = $derived(isRunComplete ? getText('gameOver.runCompleteBody') : getText('ninjaFailed'));
</script>
<div class="gameover">
<h1 class:accent={isLastLevel} class:danger={!isLastLevel}>{title}</h1>
<h1 class:accent={isRunComplete} class:bittersweet={!isRunComplete}>{title}</h1>
<p>{desc}</p>
<div class="buttons">
<Button text={getText('tryAgain')} onclick={() => {
if (isLastLevel) navigate('MainMenu');
else navigate('Game', { level, lives: 3 });
}} />
<Button text={getText('mainMenu')} onclick={() => navigate('MainMenu')} />
{#if isRunComplete}
<Button text={getText('levelSelect')} onclick={() => navigate('LevelSelect')} />
{/if}
</div>
</div>
@@ -29,7 +32,7 @@
}
h1 { font: bold 48px Arial, sans-serif; }
h1.accent { color: var(--text-accent); }
h1.danger { color: var(--text-danger); }
h1.bittersweet { color: var(--text-danger); }
p {
font: var(--font-body);
color: var(--text-secondary);
+1 -1
View File
@@ -10,7 +10,7 @@
function selectLevel(num) {
if (num <= progress.maxLevel) {
navigate('LevelIntro', { level: num, lives: 3 });
navigate('LevelIntro', { level: num });
}
}
</script>
+2 -2
View File
@@ -9,13 +9,13 @@
const el = document.querySelector('.story-scroll');
if (el) {
el.addEventListener('animationend', () => {
if (!scrollDone) { scrollDone = true; navigate('LevelIntro', { level: 1, lives: 3 }); }
if (!scrollDone) { scrollDone = true; navigate('LevelIntro', { level: 1 }); }
});
}
});
function skip() {
if (!scrollDone) { scrollDone = true; navigate('LevelIntro', { level: 1, lives: 3 }); }
if (!scrollDone) { scrollDone = true; navigate('LevelIntro', { level: 1 }); }
}
</script>