mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-16 00:15:12 +00:00
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:
1 parent
e550600218
commit
8f22138774
5 files changed
+139
-22
No files matched your search
+75
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
function selectLevel(num) {
|
||||
if (num <= progress.maxLevel) {
|
||||
navigate('LevelIntro', { level: num, lives: 3 });
|
||||
navigate('LevelIntro', { level: num });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user