feat(assets,audio): regenerate broken keyart/welcome, add VI voices, wire BGM
Asset review + regeneration - Regenerate keyart-palace.png to remove STUDIOSWAG watermark - Regenerate keyart-portrait.png as true 16-bit pixel art (was 3D/cinematic) - Regenerate vi-welcome.mp3 as a 31KB short line (was 808KB misplaced story) - Generate 10 missing VI voice lines (levelclear, princess-help, princess-run, gameover, 6 act intros) in Vietnamese_kindhearted_girl - Replace broken PHASER logo + 16x16 favicon with proper NNTV wordmark + 32x32 rabbit sprite - Add UI scene backgrounds (scene-menu/story/guide/settings) for in-app use - Add 6 keyart images (1280x720) + portrait (1024x1024) + poster for store/social - Delete orphan assets: bg.png, nntv-generated-sheet.png, scraps/sketch.napkin - Add MANIFEST.json + MEDIA.json documenting the asset corpus BGM integration - New src/lib/bgm.js: HTMLAudioElement BGM manager with 600ms crossfade, per-track loop, autoplay-blocked fallback, persistent sfxMuted/bgmMuted/ bgmVolume in localStorage (key: nntv-audio-settings) - App.svelte scene router picks the track per scene; L12 uses the climactic chamber track, all other Game scenes use the levels loop - audio.js: setMuted/isMuted now mirror bgm.js so GameHud's SND/MUTE button silences both SFX and BGM (master toggle) - Settings.svelte: new Audio panel with SFX toggle, BGM toggle, BGM volume slider (0-100%, disabled when BGM is off); bilingual - MainMenu/StoryIntro/Guide/Settings: top band scene backdrops at ~0.5 opacity - New i18n keys: audioSettings, sfxLabel, bgmLabel, volumeLabel, on, off, muted, sound (en + vi) - GameHud label localized (SND / MUTED) Tests: 180/180 passing. Build: clean (139KB JS / 28KB CSS gzipped).
No files matched your search
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"generated": "2026-06-17",
|
||||
"game": "Night Ninja: Twilight Voyage",
|
||||
"root": "public/assets/",
|
||||
"files": {
|
||||
"logo.png": {
|
||||
"kind": "wordmark",
|
||||
"size": "216×54",
|
||||
"source": "NNTV_ART (replaces broken PHASER logo)"
|
||||
},
|
||||
"logo-nntv-compact.png": {
|
||||
"kind": "wordmark",
|
||||
"size": "216×54",
|
||||
"source": "NNTV_ART"
|
||||
},
|
||||
"logo-nntv-wordmark.png": {
|
||||
"kind": "wordmark",
|
||||
"size": "360×90",
|
||||
"source": "NNTV_ART (scale 10)"
|
||||
},
|
||||
"favicon-32.png": {
|
||||
"kind": "icon",
|
||||
"size": "32×32",
|
||||
"source": "RABBIT_ART"
|
||||
},
|
||||
"favicon-16.png": {
|
||||
"kind": "icon",
|
||||
"size": "32×32 (downscaled at display)",
|
||||
"source": "RABBIT_ART"
|
||||
},
|
||||
"../favicon.png": {
|
||||
"kind": "icon",
|
||||
"size": "32×32",
|
||||
"source": "RABBIT_ART (replaces old favicon)"
|
||||
},
|
||||
"scene-menu.png": {
|
||||
"kind": "scene",
|
||||
"size": "640×192",
|
||||
"source": "SCENE_MENU (new)"
|
||||
},
|
||||
"scene-story.png": {
|
||||
"kind": "scene",
|
||||
"size": "640×192",
|
||||
"source": "SCENE_STORY (new)"
|
||||
},
|
||||
"scene-guide.png": {
|
||||
"kind": "scene",
|
||||
"size": "640×192",
|
||||
"source": "SCENE_GUIDE (new)"
|
||||
},
|
||||
"scene-settings.png": {
|
||||
"kind": "scene",
|
||||
"size": "640×192",
|
||||
"source": "SCENE_SETTINGS (new)"
|
||||
},
|
||||
"poster.png": {
|
||||
"kind": "key-art",
|
||||
"size": "800×600",
|
||||
"source": "composite of NNTV + scene + characters"
|
||||
}
|
||||
},
|
||||
"existing": {
|
||||
"tiles": [
|
||||
"tile-empty",
|
||||
"tile-wall",
|
||||
"tile-goal",
|
||||
"tile-lit",
|
||||
"tile-warm",
|
||||
"tile-mirror",
|
||||
"tile-oneway",
|
||||
"tile-door-gold",
|
||||
"tile-door-silver",
|
||||
"tile-door-copper"
|
||||
],
|
||||
"icons": [
|
||||
"icon-key-gold",
|
||||
"icon-key-silver",
|
||||
"icon-key-copper",
|
||||
"icon-stone"
|
||||
],
|
||||
"characters": [
|
||||
"player-rabbit",
|
||||
"princess-carrot",
|
||||
"guard-tomato (static)",
|
||||
"guard-blueberry (rotating)",
|
||||
"guard-corn (blinking)",
|
||||
"guard-eggplant (patrolling)",
|
||||
"guard-lettuce (mirror)",
|
||||
"guard-pumpkin (chaser)",
|
||||
"guard-sniper",
|
||||
"guard-suspicion-calm",
|
||||
"guard-suspicion-alert",
|
||||
"guard-suspicion-fire"
|
||||
],
|
||||
"actScenes": [
|
||||
"scene-garden (L1-2)",
|
||||
"scene-walls (L3-4)",
|
||||
"scene-fortress (L5-6)",
|
||||
"scene-underground (L7-8)",
|
||||
"scene-palace (L9-11)",
|
||||
"scene-chamber (L12)"
|
||||
]
|
||||
},
|
||||
"removed": [
|
||||
"public/assets/bg.png — unused generic blue gradient (leftover)",
|
||||
"public/assets/scraps/ — empty napkin design note (v1 placeholder)",
|
||||
"public/assets/generated/nntv-generated-sheet.png — 2.3MB orphan bundle, not referenced",
|
||||
"public/assets/logo.png (PHASER text) — replaced with proper NNTV wordmark"
|
||||
],
|
||||
"renderer": "tools/render-assets.mjs (pure JS, no deps, zlib deflate)",
|
||||
"poster": "tools/make-poster.mjs (composite key-art renderer)"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"generated": "2026-06-17",
|
||||
"game": "Night Ninja: Twilight Voyage",
|
||||
"summary": "Image + Voice + Audio deliverables for the game. Pixel art assets remain in public/assets/generated/. BGM, voice, and key art live alongside.",
|
||||
"images": {
|
||||
"directory": "public/assets/images/",
|
||||
"files": {
|
||||
"keyart-garden.png": { "kind": "key-art", "size": "1280x720", "subject": "Ninja rabbit in moonlit vegetable garden", "use": "Main menu background / store screenshot" },
|
||||
"keyart-portrait.png": { "kind": "portrait", "size": "1024x1024", "subject": "Cinematic close-up of ninja rabbit", "use": "App icon / social card", "regen_note": "Regenerated 2026-06-17 to true 16-bit pixel art (was 3D/cinematic)" },
|
||||
"keyart-gameover.png": { "kind": "key-art", "size": "1280x720", "subject": "Rabbit silhouette before distant palace", "use": "Game over screen background" },
|
||||
"keyart-throne.png": { "kind": "key-art", "size": "1280x720", "subject": "Carrot princess on golden throne", "use": "Princess chamber / final level intro" },
|
||||
"keyart-underground.png": { "kind": "key-art", "size": "1280x720", "subject": "Underground tunnel with distant torch", "use": "Act 4 underground level intro" },
|
||||
"keyart-palace.png": { "kind": "key-art", "size": "1280x720", "subject": "Royal palace mirrored hall with chandelier", "use": "Act 5 palace level intro", "regen_note": "Regenerated 2026-06-17 to remove STUDIOSWAG watermark (regen prompt explicitly forbids watermark/signature/text overlay)" }
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"directory": "public/assets/voice/",
|
||||
"files": {
|
||||
"en-story.mp3": { "lang": "en", "voice": "English_expressive_narrator", "kind": "narration", "subject": "Story intro — the missing carrot princess" },
|
||||
"en-welcome.mp3": { "lang": "en", "voice": "English_CaptivatingStoryteller", "kind": "greeting", "subject": "Welcome to Night Ninja" },
|
||||
"en-detected.mp3": { "lang": "en", "voice": "English_magnetic_voiced_man", "kind": "sfx-callout", "subject": "Detected — restarting level" },
|
||||
"en-levelclear.mp3": { "lang": "en", "voice": "English_Upbeat_Woman", "kind": "sfx-callout", "subject": "Level clear — well done" },
|
||||
"en-princess-help.mp3": { "lang": "en", "voice": "English_Whispering_girl", "kind": "character", "subject": "Princess whisper: help me" },
|
||||
"en-princess-run.mp3": { "lang": "en", "voice": "English_StressedLady", "kind": "sfx-callout", "subject": "Run — princess sensed you" },
|
||||
"en-gameover.mp3": { "lang": "en", "voice": "English_SentimentalLady", "kind": "sfx-callout", "subject": "Some rescues were never meant to be" },
|
||||
"en-act1-garden.mp3": { "lang": "en", "voice": "English_CaptivatingStoryteller", "kind": "level-intro", "subject": "Act 1 — The Outskirts" },
|
||||
"en-act2-walls.mp3": { "lang": "en", "voice": "English_CaptivatingStoryteller", "kind": "level-intro", "subject": "Act 2 — The Vegetable Garden" },
|
||||
"en-act3-fortress.mp3": { "lang": "en", "voice": "English_CaptivatingStoryteller", "kind": "level-intro", "subject": "Act 3 — The Fortress" },
|
||||
"en-act4-underground.mp3":{ "lang": "en", "voice": "English_CaptivatingStoryteller", "kind": "level-intro", "subject": "Act 4 — The Underground" },
|
||||
"en-act5-palace.mp3": { "lang": "en", "voice": "English_CaptivatingStoryteller", "kind": "level-intro", "subject": "Act 5 — The Royal Palace" },
|
||||
"en-act6-chamber.mp3": { "lang": "en", "voice": "English_CaptivatingStoryteller", "kind": "level-intro", "subject": "Act 6 — The Princess Chamber" },
|
||||
"vi-story.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "narration", "subject": "Câu chuyện — công chúa cà rốt mất tích" },
|
||||
"vi-welcome.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "greeting", "subject": "Chào mừng Ninja Đêm (regen 2026-06-17: 31KB short line, was 808KB misplaced story)" },
|
||||
"vi-detected.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "sfx-callout", "subject": "Bạn đã bị phát hiện" },
|
||||
"vi-levelclear.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "sfx-callout", "subject": "Hoàn thành — giỏi lắm ninja (added 2026-06-17)" },
|
||||
"vi-princess-help.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "character", "subject": "Công chúa thì thầm: cứu tôi (added 2026-06-17)" },
|
||||
"vi-princess-run.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "sfx-callout", "subject": "Chạy đi — công chúa đã cảm nhận bạn (added 2026-06-17)" },
|
||||
"vi-gameover.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "sfx-callout", "subject": "Có những cuộc giải cứu chưa định sẵn (added 2026-06-17)" },
|
||||
"vi-act1-garden.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "level-intro", "subject": "Hồi 1 — Vùng ngoại ô (added 2026-06-17)" },
|
||||
"vi-act2-walls.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "level-intro", "subject": "Hồi 2 — Vườn Rau Củ (added 2026-06-17)" },
|
||||
"vi-act3-fortress.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "level-intro", "subject": "Hồi 3 — Pháo đài (added 2026-06-17)" },
|
||||
"vi-act4-underground.mp3":{ "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "level-intro", "subject": "Hồi 4 — Tầng ngầm (added 2026-06-17)" },
|
||||
"vi-act5-palace.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "level-intro", "subject": "Hồi 5 — Hoàng cung (added 2026-06-17)" },
|
||||
"vi-act6-chamber.mp3": { "lang": "vi", "voice": "Vietnamese_kindhearted_girl", "kind": "level-intro", "subject": "Hồi 6 — Phòng Công chúa (added 2026-06-17)" }
|
||||
}
|
||||
},
|
||||
"keyart_regen": {
|
||||
"date": "2026-06-17",
|
||||
"regenerated": {
|
||||
"keyart-palace.png": { "reason": "STUDIOSWAG watermark in bottom-left of original; regenerated as clean pixel art palace hall" },
|
||||
"keyart-portrait.png": { "reason": "Original was 3D/cinematic rendered, style mismatch with game's pixel art language; regenerated as strict 16-bit pixel art" },
|
||||
"vi-welcome.mp3": { "reason": "Original was 808KB — contained the long story narration misplaced under welcome filename; regenerated as a 31KB short welcome line" }
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"directory": "public/assets/audio/",
|
||||
"files": {
|
||||
"bgm-menu.mp3": { "kind": "bgm", "scene": "main-menu", "mood": "calm, mysterious, night", "duration_estimate": "~60s loopable" },
|
||||
"bgm-story.mp3": { "kind": "bgm", "scene": "story-intro", "mood": "dramatic, cinematic, suspenseful", "duration_estimate": "~60s" },
|
||||
"bgm-levels.mp3": { "kind": "bgm", "scene": "levels 1-11", "mood": "stealthy, tense, quiet", "duration_estimate": "~60s loopable" },
|
||||
"bgm-chamber.mp3": { "kind": "bgm", "scene": "level 12 princess chamber", "mood": "climactic, intense, epic", "duration_estimate": "~60s" },
|
||||
"bgm-gameover.mp3": { "kind": "bgm", "scene": "game-over", "mood": "melancholic, sad, resigned", "duration_estimate": "~60s" }
|
||||
}
|
||||
},
|
||||
"existing_audio": {
|
||||
"directory": "src/lib/audio.js",
|
||||
"kind": "procedural (Web Audio API)",
|
||||
"calls": [
|
||||
"playMove", "playWait", "playDetection", "playLevelComplete", "playClick",
|
||||
"playUndo", "playStoneThrow", "playStoneImpact", "playKeyPickup",
|
||||
"playDoorUnlock", "playSuspicionAlert", "playSuspicionFire"
|
||||
]
|
||||
},
|
||||
"renderer_tools": {
|
||||
"tools/render-assets.mjs": "Pixel-art PNG renderer (logo, favicon, scene backgrounds)",
|
||||
"tools/make-poster.mjs": "Composite key-art renderer"
|
||||
},
|
||||
"notes": [
|
||||
"BGMs are loopable chiptune/8-bit flavored tracks; no explicit loop markers are encoded — playback apps should loop them via audio element loop attribute.",
|
||||
"Voice lines are short enough (most <15s) for in-game playback.",
|
||||
"Key art PNGs are 1280x720 (16:9) except portrait (1024x1024). Suitable for README/store hero/social card use.",
|
||||
"All generated media is created via the matrix MCP — image (text-to-image), voice (TTS), audio (text-to-music)."
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 300 B |
|
Before Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 335 B |
|
After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 335 B |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1021 B |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"created": "2026-04-20T14:24:41.949Z",
|
||||
"modified": "2026-04-20T14:24:41.949Z",
|
||||
"objects": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 300 B |
@@ -4,6 +4,7 @@
|
||||
import { getText } from './lib/localization.js';
|
||||
import { initLanguage } from './lib/localization.js';
|
||||
import { loadProgress, acknowledgeMigration, isMigrationAcknowledged } from './lib/progress.js';
|
||||
import { playBgm } from './lib/bgm.js';
|
||||
import MainMenu from './scenes/MainMenu.svelte';
|
||||
import StoryIntro from './scenes/StoryIntro.svelte';
|
||||
import LevelIntro from './scenes/LevelIntro.svelte';
|
||||
@@ -22,6 +23,34 @@
|
||||
// Migration modal — shown once on first v2 boot if legacy save was detected
|
||||
let showMigrationModal = $state(false);
|
||||
|
||||
// Pick the BGM track based on the active scene. L12 gets the climactic chamber
|
||||
// track; everything else in the Game scene uses the generic levels loop.
|
||||
function pickBgmTrack(scene, data) {
|
||||
switch (scene) {
|
||||
case 'MainMenu':
|
||||
case 'LevelSelect':
|
||||
case 'Guide':
|
||||
case 'Settings':
|
||||
return 'menu';
|
||||
case 'StoryIntro':
|
||||
case 'LevelIntro':
|
||||
return 'story';
|
||||
case 'Game':
|
||||
return data?.level === 12 ? 'chamber' : 'levels';
|
||||
case 'GameOver':
|
||||
return 'gameover';
|
||||
default:
|
||||
return 'menu';
|
||||
}
|
||||
}
|
||||
|
||||
let bgmTrack = $derived(pickBgmTrack(currentScene, sceneData));
|
||||
|
||||
$effect(() => {
|
||||
// Track is the reactive dep — fire playBgm whenever it changes
|
||||
playBgm(bgmTrack);
|
||||
});
|
||||
|
||||
function navigate(scene, data = {}) {
|
||||
sceneData = data;
|
||||
currentScene = scene;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { getText } from '../lib/localization.js';
|
||||
import { isMuted, setMuted, playClick } from '../lib/audio.js';
|
||||
import { setMasterMuted } from '../lib/bgm.js';
|
||||
import Button from './Button.svelte';
|
||||
import StonesCounter from './StonesCounter.svelte';
|
||||
import KeyInventory from './KeyInventory.svelte';
|
||||
@@ -15,8 +16,11 @@
|
||||
let muted = $state(isMuted());
|
||||
|
||||
function toggleMute() {
|
||||
// Master mute: silences both SFX and BGM in one click. Most natural
|
||||
// behavior for an in-game "I want quiet" button.
|
||||
muted = !muted;
|
||||
setMuted(muted);
|
||||
setMasterMuted(muted);
|
||||
if (!muted) playClick();
|
||||
}
|
||||
</script>
|
||||
@@ -38,7 +42,7 @@
|
||||
<Pixel art={ICON_UNDO} palette={ICON_PAL} width={20} height={20} />
|
||||
</button>
|
||||
{/if}
|
||||
<Button text={muted ? 'MUTE' : 'SND'} onclick={toggleMute} small />
|
||||
<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">
|
||||
<Pixel art={ICON_EYE} palette={ICON_PAL} width={20} height={20} />
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// Procedural audio system using Web Audio API
|
||||
// Lazy AudioContext creation to comply with browser autoplay policy
|
||||
|
||||
import { setSfxMuted as persistSfxMuted, isSfxMuted } from './bgm.js';
|
||||
|
||||
let audioCtx = null;
|
||||
let masterGain = null;
|
||||
// muted mirrors bgm.js's sfxMuted at boot. Source of truth lives in bgm.js
|
||||
// so that BGM and SFX mute settings are kept in sync across the app.
|
||||
let muted = false;
|
||||
try { muted = isSfxMuted(); } catch (_) { /* bgm.js requires localStorage; ignore during SSR */ }
|
||||
|
||||
function getContext() {
|
||||
if (!audioCtx) {
|
||||
@@ -203,8 +208,10 @@ export function playSuspicionFire() {
|
||||
|
||||
export function setMuted(value) {
|
||||
muted = value;
|
||||
try { persistSfxMuted(value); } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
export function isMuted() {
|
||||
return muted;
|
||||
// Always re-read from bgm.js so that changes elsewhere (Settings, etc.) propagate.
|
||||
try { return isSfxMuted(); } catch (_) { return muted; }
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// BGM (background music) manager.
|
||||
// Uses an HTMLAudioElement per active track, with a crossfade between tracks.
|
||||
// All state persists to localStorage so the user's volume / mute choice survives reloads.
|
||||
//
|
||||
// Why HTMLAudioElement (not Web Audio API): the BGM files are pre-rendered MP3s, so we
|
||||
// just need simple looped playback with gain. Web Audio is reserved for procedural SFX
|
||||
// (see audio.js). The two systems share a single mute toggle via setMutedAll().
|
||||
|
||||
const STORAGE_KEY = 'nntv-audio-settings';
|
||||
const DEFAULT = { sfxMuted: false, bgmMuted: false, bgmVolume: 0.6 };
|
||||
|
||||
let settings = loadSettings();
|
||||
|
||||
// When autoplay is blocked (browsers require a user gesture), we remember the
|
||||
// intended track and replay it as soon as the user clicks / presses a key.
|
||||
let pendingPlay = null;
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
sfxMuted: !!parsed.sfxMuted,
|
||||
bgmMuted: !!parsed.bgmMuted,
|
||||
bgmVolume: typeof parsed.bgmVolume === 'number'
|
||||
? Math.max(0, Math.min(1, parsed.bgmVolume))
|
||||
: DEFAULT.bgmVolume,
|
||||
};
|
||||
}
|
||||
} catch (_) {
|
||||
// localStorage may be unavailable (SSR, private mode) — fall through to defaults
|
||||
}
|
||||
return { ...DEFAULT };
|
||||
}
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Active BGM audio elements. We keep two slots so we can crossfade between them.
|
||||
let current = null; // { audio, url, gainNode? } — currently audible
|
||||
let fading = null; // { audio, url, gainNode, raf } — being faded out
|
||||
let activeTrack = null; // url of the track the scene router asked for
|
||||
|
||||
// Track → file URL map. Update this when adding new BGM.
|
||||
const BGM_TRACKS = {
|
||||
menu: 'assets/audio/bgm-menu.mp3',
|
||||
story: 'assets/audio/bgm-story.mp3',
|
||||
levels: 'assets/audio/bgm-levels.mp3',
|
||||
chamber: 'assets/audio/bgm-chamber.mp3',
|
||||
gameover: 'assets/audio/bgm-gameover.mp3',
|
||||
};
|
||||
|
||||
export function getBgmTrackUrl(name) {
|
||||
return BGM_TRACKS[name] || null;
|
||||
}
|
||||
|
||||
export function listBgmTracks() {
|
||||
return Object.keys(BGM_TRACKS);
|
||||
}
|
||||
|
||||
function effectiveVolume() {
|
||||
return settings.bgmMuted ? 0 : settings.bgmVolume;
|
||||
}
|
||||
|
||||
function makeAudio(url) {
|
||||
const a = new Audio(url);
|
||||
a.loop = true;
|
||||
a.preload = 'auto';
|
||||
a.crossOrigin = 'anonymous';
|
||||
a.volume = 0; // start silent, fade in
|
||||
return a;
|
||||
}
|
||||
|
||||
function cancelFade(entry) {
|
||||
if (!entry) return;
|
||||
if (entry.raf) cancelAnimationFrame(entry.raf);
|
||||
if (entry.audio) {
|
||||
try { entry.audio.pause(); } catch (_) {}
|
||||
entry.audio.src = '';
|
||||
}
|
||||
}
|
||||
|
||||
function fadeIn(audio, targetVolume, durationMs = 600) {
|
||||
if (settings.bgmMuted) targetVolume = 0;
|
||||
const start = performance.now();
|
||||
function step(now) {
|
||||
const t = Math.min(1, (now - start) / durationMs);
|
||||
audio.volume = targetVolume * t;
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function fadeOut(audio, durationMs = 600) {
|
||||
const startVolume = audio.volume;
|
||||
const start = performance.now();
|
||||
let raf;
|
||||
function step(now) {
|
||||
const t = Math.min(1, (now - start) / durationMs);
|
||||
audio.volume = startVolume * (1 - t);
|
||||
if (t < 1) {
|
||||
raf = requestAnimationFrame(step);
|
||||
} else {
|
||||
try { audio.pause(); } catch (_) {}
|
||||
audio.src = '';
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active BGM to a named track, with a crossfade.
|
||||
* Calling with the same track name as currently active is a no-op.
|
||||
* Pass `null` to fade out and stop.
|
||||
*/
|
||||
export async function playBgm(trackName, { fadeMs = 600 } = {}) {
|
||||
const url = trackName ? BGM_TRACKS[trackName] : null;
|
||||
if (url === activeTrack) return; // already playing
|
||||
activeTrack = url;
|
||||
|
||||
if (fading) {
|
||||
cancelFade(fading);
|
||||
fading = null;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
// Fade out current, then drop
|
||||
if (current) {
|
||||
const c = current;
|
||||
current = null;
|
||||
fadeOut(c.audio, fadeMs);
|
||||
fading = c;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const next = makeAudio(url);
|
||||
try {
|
||||
await next.play();
|
||||
} catch (e) {
|
||||
// Autoplay blocked — wait for first user interaction.
|
||||
// We'll retry on the next user gesture; track the intent.
|
||||
pendingPlay = trackName;
|
||||
return;
|
||||
}
|
||||
fadeIn(next, effectiveVolume(), fadeMs);
|
||||
|
||||
if (current) {
|
||||
const prev = current;
|
||||
current = { audio: next, url };
|
||||
fadeOut(prev.audio, fadeMs);
|
||||
fading = prev;
|
||||
} else {
|
||||
current = { audio: next, url };
|
||||
}
|
||||
}
|
||||
|
||||
// Retry playBgm after first user interaction if autoplay was blocked.
|
||||
function installUserGestureRetry() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handler = () => {
|
||||
if (pendingPlay) {
|
||||
const p = pendingPlay;
|
||||
pendingPlay = null;
|
||||
playBgm(p);
|
||||
}
|
||||
window.removeEventListener('pointerdown', handler);
|
||||
window.removeEventListener('keydown', handler);
|
||||
};
|
||||
window.addEventListener('pointerdown', handler, { once: true });
|
||||
window.addEventListener('keydown', handler, { once: true });
|
||||
}
|
||||
installUserGestureRetry();
|
||||
|
||||
export function stopBgm(opts = {}) {
|
||||
return playBgm(null, opts);
|
||||
}
|
||||
|
||||
export function getSettings() {
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
export function setBgmVolume(vol) {
|
||||
settings.bgmVolume = Math.max(0, Math.min(1, vol));
|
||||
persist();
|
||||
if (current && !settings.bgmMuted) {
|
||||
current.audio.volume = settings.bgmVolume;
|
||||
}
|
||||
}
|
||||
|
||||
export function setBgmMuted(muted) {
|
||||
settings.bgmMuted = !!muted;
|
||||
persist();
|
||||
if (current) {
|
||||
if (settings.bgmMuted) {
|
||||
current.audio.volume = 0;
|
||||
} else {
|
||||
current.audio.volume = settings.bgmVolume;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setSfxMuted(muted) {
|
||||
settings.sfxMuted = !!muted;
|
||||
persist();
|
||||
}
|
||||
|
||||
// Convenience: when the user toggles SFX, we want to also expose the BGM dim separately.
|
||||
// This function applies both states — used by the GameHud SND/MUTE button.
|
||||
export function setMasterMuted(muted) {
|
||||
setSfxMuted(muted);
|
||||
setBgmMuted(muted);
|
||||
}
|
||||
|
||||
export function isBgmMuted() { return settings.bgmMuted; }
|
||||
export function isSfxMuted() { return settings.sfxMuted; }
|
||||
@@ -10,6 +10,14 @@
|
||||
"english": "English",
|
||||
"vietnamese": "Vietnamese",
|
||||
"back": "Back",
|
||||
"audioSettings": "Audio",
|
||||
"sfxLabel": "Sound effects",
|
||||
"bgmLabel": "Music",
|
||||
"volumeLabel": "Music volume",
|
||||
"on": "ON",
|
||||
"off": "OFF",
|
||||
"muted": "MUTED",
|
||||
"sound": "SND",
|
||||
"levelSelectTitle": "SELECT LEVEL",
|
||||
"levelNumber": "Level",
|
||||
"level": "Level: ",
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
"english": "Tiếng Anh",
|
||||
"vietnamese": "Tiếng Việt",
|
||||
"back": "Quay lại",
|
||||
"audioSettings": "Âm thanh",
|
||||
"sfxLabel": "Hiệu ứng âm thanh",
|
||||
"bgmLabel": "Nhạc nền",
|
||||
"volumeLabel": "Âm lượng nhạc nền",
|
||||
"on": "BẬT",
|
||||
"off": "TẮT",
|
||||
"muted": "TẮT TIẾNG",
|
||||
"sound": "ÂM",
|
||||
"levelSelectTitle": "CHỌN CẤP ĐỘ",
|
||||
"levelNumber": "Cấp độ",
|
||||
"level": "Cấp độ: ",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
</script>
|
||||
|
||||
<div class="guide">
|
||||
<div class="scene-backdrop">
|
||||
<img src="assets/scene-guide.png" alt="" draggable="false" />
|
||||
</div>
|
||||
<h1>{getText('guideTitle')}</h1>
|
||||
|
||||
<div class="content">
|
||||
@@ -34,7 +37,25 @@
|
||||
align-items: center;
|
||||
background: var(--bg-dark);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scene-backdrop {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 192px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scene-backdrop img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
h1, .content, .guide :global(button) { position: relative; z-index: 1; }
|
||||
h1 {
|
||||
font: var(--font-title);
|
||||
color: var(--text-title);
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<div class="scene-backdrop">
|
||||
<img src="assets/scene-menu.png" alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
{#each stars as star}
|
||||
<div
|
||||
class="star"
|
||||
@@ -47,6 +51,21 @@
|
||||
position: relative;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
.scene-backdrop {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 192px;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scene-backdrop img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.star {
|
||||
position: absolute;
|
||||
background: white;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script>
|
||||
import { getText, setLanguage, getLanguage } from '../lib/localization.js';
|
||||
import Button from '../components/Button.svelte';
|
||||
import {
|
||||
getSettings, setBgmVolume, setBgmMuted, setSfxMuted,
|
||||
isBgmMuted, isSfxMuted,
|
||||
} from '../lib/bgm.js';
|
||||
let { navigate } = $props();
|
||||
let lang = $state(getLanguage());
|
||||
// Force re-render on language change
|
||||
let tick = $state(0);
|
||||
let tick = $state(0); // re-render on language change
|
||||
|
||||
// Reactive local copies of audio settings — re-derive whenever the global changes.
|
||||
let sfxOff = $derived((tick, isSfxMuted()));
|
||||
let bgmOff = $derived((tick, isBgmMuted()));
|
||||
let bgmVol = $derived((tick, getSettings().bgmVolume));
|
||||
|
||||
function changeLang(l) {
|
||||
if (lang !== l) {
|
||||
@@ -13,11 +21,31 @@
|
||||
tick++;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSfx() {
|
||||
setSfxMuted(!sfxOff);
|
||||
tick++;
|
||||
}
|
||||
|
||||
function toggleBgm() {
|
||||
setBgmMuted(!bgmOff);
|
||||
tick++;
|
||||
}
|
||||
|
||||
function onVolume(e) {
|
||||
const v = Number(e.target.value);
|
||||
setBgmVolume(v);
|
||||
tick++;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key tick}
|
||||
<div class="settings">
|
||||
<div class="scene-backdrop">
|
||||
<img src="assets/scene-settings.png" alt="" draggable="false" />
|
||||
</div>
|
||||
<h1>{getText('settings')}</h1>
|
||||
|
||||
<p class="label">{getText('languageSettings')}</p>
|
||||
<div class="lang-buttons">
|
||||
<button class="lang-btn" class:active={lang === 'en'} onclick={() => changeLang('en')}>
|
||||
@@ -27,6 +55,35 @@
|
||||
{getText('vietnamese')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="label">{getText('audioSettings')}</p>
|
||||
<div class="audio-row">
|
||||
<span class="audio-label">{getText('sfxLabel')}</span>
|
||||
<button class="lang-btn" class:active={!sfxOff} onclick={toggleSfx}>
|
||||
{sfxOff ? getText('off') : getText('on')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="audio-row">
|
||||
<span class="audio-label">{getText('bgmLabel')}</span>
|
||||
<button class="lang-btn" class:active={!bgmOff} onclick={toggleBgm}>
|
||||
{bgmOff ? getText('off') : getText('on')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="audio-row volume-row" class:disabled={bgmOff}>
|
||||
<span class="audio-label">{getText('volumeLabel')}</span>
|
||||
<input
|
||||
class="volume-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={Math.round(bgmVol * 100)}
|
||||
oninput={onVolume}
|
||||
disabled={bgmOff}
|
||||
aria-label={getText('volumeLabel')}
|
||||
/>
|
||||
<span class="volume-num">{Math.round(bgmVol * 100)}</span>
|
||||
</div>
|
||||
|
||||
<Button text={getText('back')} onclick={() => navigate('MainMenu')} />
|
||||
</div>
|
||||
{/key}
|
||||
@@ -36,22 +93,61 @@
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
justify-content: center; align-items: center;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
background: var(--bg-dark);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scene-backdrop {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 192px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scene-backdrop img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
h1, .label, .lang-buttons, .audio-row, .settings :global(button) { position: relative; z-index: 1; }
|
||||
h1 { font: var(--font-title); color: var(--text-title); }
|
||||
.label { font: var(--font-body); color: var(--text-secondary); }
|
||||
.label { font: var(--font-body); color: var(--text-secondary); margin-top: 8px; }
|
||||
.lang-buttons { display: flex; gap: 16px; }
|
||||
.audio-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
font: var(--font-ui); color: var(--text-primary);
|
||||
min-width: 320px; justify-content: space-between;
|
||||
}
|
||||
.audio-row.disabled { opacity: 0.5; }
|
||||
.audio-label { flex: 0 0 120px; text-align: left; }
|
||||
.lang-btn {
|
||||
font: var(--font-button);
|
||||
font: var(--font-button-small);
|
||||
color: var(--text-primary);
|
||||
background: var(--btn-default);
|
||||
border: 2px solid var(--btn-border);
|
||||
border-radius: 4px;
|
||||
padding: 10px 24px;
|
||||
padding: 6px 18px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.lang-btn:hover { background: var(--btn-hover); }
|
||||
.lang-btn.active { background: var(--btn-hover); }
|
||||
.lang-btn.active { background: var(--btn-hover); border-color: var(--text-accent); }
|
||||
.volume-row { padding-top: 4px; }
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
accent-color: var(--text-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.volume-slider:disabled { cursor: not-allowed; }
|
||||
.volume-num {
|
||||
flex: 0 0 32px;
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font: var(--font-small);
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,9 @@
|
||||
</script>
|
||||
|
||||
<div class="intro">
|
||||
<div class="scene-backdrop">
|
||||
<img src="assets/scene-story.png" alt="" draggable="false" />
|
||||
</div>
|
||||
<h2>{getText('storyTitle')}</h2>
|
||||
<div class="scroll-area">
|
||||
<p class="story-scroll">{getText('storyText')}</p>
|
||||
@@ -38,6 +41,22 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scene-backdrop {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 192px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scene-backdrop img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
h2, .scroll-area, .skip-btn { position: relative; z-index: 1; }
|
||||
h2 {
|
||||
font: var(--font-title);
|
||||
color: var(--text-accent);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"prompt": "Calm, peaceful night-time menu BGM, chiptune 8-bit retro game style, gentle pizzicato strings, soft marimba melody, mysterious night theme, lunar atmosphere, light bells, slow tempo, loopable, instrumental",
|
||||
"sample_rate": 44100
|
||||
},
|
||||
{
|
||||
"prompt": "Dramatic cinematic story intro BGM, mysterious dark fantasy, deep cello and strings, ethereal choir, suspenseful build, chiptune 8-bit elements, twilight moonlit atmosphere, slow tempo, instrumental, 60 seconds",
|
||||
"sample_rate": 44100
|
||||
},
|
||||
{
|
||||
"prompt": "Stealthy puzzle game BGM, tense but quiet, chiptune 8-bit style, soft synth pad, subtle percussion, sneaking tension, moonlight vibes, mysterious loop, 60 BPM, instrumental, 60 seconds",
|
||||
"sample_rate": 44100
|
||||
},
|
||||
{
|
||||
"prompt": "Climactic dramatic final boss BGM, epic dark fantasy, intense strings, powerful choir, building tension, chiptune 8-bit heroic elements, fast tempo, epic climax, instrumental, 60 seconds",
|
||||
"sample_rate": 44100
|
||||
},
|
||||
{
|
||||
"prompt": "Melancholic game over BGM, sad and contemplative, chiptune 8-bit, slow piano melody, soft strings, bittersweet atmosphere, twilight moonlit, resigned mood, instrumental, 60 seconds",
|
||||
"sample_rate": 44100
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"prompt": "Pixel art style, dark twilight garden scene. A small cute ninja rabbit wearing a dark scarf stands in a moonlit vegetable garden. The rabbit has large red eyes and is sneaking between rows of carrots and cabbages. A full moon shines bright in a deep navy sky scattered with stars. Hedge silhouette in the background. Retro 16-bit game art style, 8-bit, low resolution pixel art aesthetic. Color palette: deep navy, moonlight white, sage green, carrot orange, dark plum. Studio quality digital art.",
|
||||
"aspect_ratio": "16:9"
|
||||
},
|
||||
{
|
||||
"prompt": "Cinematic close-up portrait of a small cute ninja rabbit, pixel art style. The rabbit has determined red eyes, dark ninja wrap, and a red scarf. Soft moonlight rim lighting from the left, deep purple and navy background. Bokeh stars. 16-bit retro game art aesthetic, studio quality, dramatic lighting, low resolution pixel art with rich shading.",
|
||||
"aspect_ratio": "1:1"
|
||||
},
|
||||
{
|
||||
"prompt": "Pixel art game over scene. A ninja rabbit silhouette stands in a vast moonlit carrot field at twilight, looking up at a distant palace window where a faint glowing princess silhouette stands. Deep navy sky, full moon, scattered stars, a single carrot lies abandoned on the ground. Sad, contemplative, bittersweet mood. 16-bit retro game art aesthetic. Color palette: deep navy, plum, moonlight, carrot orange, sage green.",
|
||||
"aspect_ratio": "16:9"
|
||||
},
|
||||
{
|
||||
"prompt": "Pixel art dramatic throne room scene. A carrot princess sitting on a golden throne at the center, illuminated by a single shaft of warm light from above. Dark cavernous room with vegetable guards in shadow on either side. Onion guards with violet glow, pumpkin guards with orange glow. Mysterious, ominous, climactic. 16-bit retro game art aesthetic, deep navy and plum background, warm golden throne highlight. Studio quality.",
|
||||
"aspect_ratio": "16:9"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"prompt": "Pixel art underground tunnel scene. A long dark stone tunnel lit by a single distant torch. Moss-covered bricks, water dripping, a winding path disappearing into shadow. Tiny glowing fireflies. 16-bit retro game art aesthetic, deep navy and dark green palette, mysterious atmosphere. Studio quality.",
|
||||
"aspect_ratio": "16:9"
|
||||
},
|
||||
{
|
||||
"prompt": "Pixel art royal palace hall scene. A grand mirrored hall with golden pillars, polished marble floor reflecting the light from a chandelier. Carved vegetable motifs on the walls. Empty and majestic, awaiting the princess. 16-bit retro game art aesthetic, deep plum and gold palette, opulent but quiet. Studio quality.",
|
||||
"aspect_ratio": "16:9"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"prompt": "Pixel art royal palace hall scene, 16-bit retro game art, 1280x720. Grand mirrored hall with golden pillars, polished marble floor reflecting light from a single chandelier. Carved vegetable motifs on the walls (carrots, cabbages, pumpkins in bas-relief). Empty and majestic, awaiting the princess. No people. Deep plum and gold palette. Opulent but quiet. Strict pixel art with no anti-aliasing, no gradients, no watermark, no signature, no text overlay. Studio quality, low resolution pixel art aesthetic, 8-bit retro game style.",
|
||||
"aspect_ratio": "16:9"
|
||||
},
|
||||
{
|
||||
"prompt": "Strict pixel art 16-bit retro game character portrait, 1024x1024. Front-facing close-up of a small cute ninja rabbit, pixel art sprite style, 8-bit. Large determined red glowing eyes, dark navy ninja hood wrapping the head, red scarf around the neck, soft moonlight rim lighting from the upper left, deep purple and navy background. No anti-aliasing, no gradients, no smooth shading, no watermark, no signature, no text overlay. Low resolution pixel art aesthetic consistent with 16-bit JRPG/SNES-era character portrait. Clean crisp pixels, dithered shading. Studio quality.",
|
||||
"aspect_ratio": "1:1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// make-poster.mjs
|
||||
// Compose a single "key art" PNG poster by re-rendering all assets
|
||||
// onto one canvas. No PNG decoding — reuses the same renderArt() pipeline
|
||||
// from the design source files.
|
||||
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { deflateSync, crc32 } from 'node:zlib';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
|
||||
// ─── NNTV palette (kept in sync with src/lib/pixel/palette.js) ──────────
|
||||
const NNTV = {
|
||||
guardStatic: '#ff4444', guardRotating: '#4488ff', guardBlinking: '#ffdd44',
|
||||
guardBlinkingOff: '#887722', guardPatrolling: '#bb44ff', guardMirror: '#44ddaa',
|
||||
guardChaser: '#ff6622', gridEmpty: '#1a1a2e', gridWall: '#4a4a5e',
|
||||
gridGoal: '#00c853', gridLit: '#ffea00', gridBorder: '#2a2a3e',
|
||||
midnight: '#0a0a1a', twilight: '#141428', dusk: '#1f1b3a', plum: '#2d2150',
|
||||
moonlight: '#e8e4ff', silver: '#9a9ac0', shadow: '#05050f', ink: '#0b0a18',
|
||||
cream: '#fff4d6', moonGlow: '#ffe88a', carrot: '#ff8844', carrotDark: '#cc5a1f',
|
||||
leafGreen: '#3ea85c', leafDark: '#1e6b38',
|
||||
furLight: '#f5eeff', furMid: '#b8b0d0', furShadow: '#6a607a',
|
||||
cloth: '#1a1a2e', clothShadow: '#05050f', eyeShine: '#ffffff',
|
||||
eyeRed: '#ff3355', scarfRed: '#c73e3a',
|
||||
tomatoRed: '#e23a3a', tomatoDark: '#991f1f', tomatoLeaf: '#3ea85c',
|
||||
eggplantPurp: '#8a3fc9', eggplantDark: '#4a1a6e', cornYellow: '#ffd84a',
|
||||
cornDark: '#b88a1a', cornHusk: '#6aa84a', lettuceGreen: '#8fd66a',
|
||||
lettuceDark: '#3e7a3e', pumpkinOrange: '#ff7722', pumpkinDark: '#aa4411',
|
||||
blueberry: '#4a7acc', blueberryDark: '#2a4a8c', onionPurp: '#a968c9', onionDark: '#5a2a7a',
|
||||
stone: '#5a5a6e', stoneDark: '#2a2a3e', stoneLight: '#7a7a9a',
|
||||
dirt: '#4a3a2a', dirtDark: '#2a1a1a', grass: '#3e7a3e', grassDark: '#2a5a2a',
|
||||
grassLight: '#5aaa5a', mossGreen: '#2a5a3a', brick: '#6a3a2a', brickDark: '#3a1a0a',
|
||||
wood: '#7a5a2a', woodDark: '#3a2a1a', gold: '#ffcc44', goldDark: '#aa7722',
|
||||
};
|
||||
|
||||
// ─── Art (sourced from public/assets/src/characters.jsx & tiles-ui.jsx) ──
|
||||
const LOGO_ART = [
|
||||
'....................................',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'.kk.k.kk..k.k.k..kk..k.k....kk..k...',
|
||||
'.k.kk.k.k.k.k.k..k.k.k.k....k.k.k...',
|
||||
'.k..k.k..kk.k.k..k..kk.k....k..kk...',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'....................................',
|
||||
'....................................',
|
||||
'....................................',
|
||||
];
|
||||
const LOGO_PAL = { k: NNTV.moonlight };
|
||||
|
||||
const RABBIT_ART = [
|
||||
'................................',
|
||||
'................................',
|
||||
'.......KK........KKKK...........',
|
||||
'......KFFK......KFFFFK..........',
|
||||
'......KFFK......KFFFFK..........',
|
||||
'......KFCK......KFFCFK..........',
|
||||
'......KFCK......KFFCFK..........',
|
||||
'.......KFK......KFFFK...........',
|
||||
'.......KKKKKKKKKFFFFK...........',
|
||||
'......KFFFFFFFFFFFFFFK..........',
|
||||
'.....KFFFFFFFFFFFFFFFFK.........',
|
||||
'....KFSSSSSSSSSSSSSSSSFK........',
|
||||
'...KFSSSSSSSSSSSSSSSSSSFK.......',
|
||||
'...KFSKKKKSSSSSSSSKKKKSFK.......',
|
||||
'...KFSKWWKSSSSSSSSKWWKSFK.......',
|
||||
'...KFSKWEKSSSSSSSSKWEKSFK.......',
|
||||
'...KFSKKKKSSSSSSSSKKKKSFK.......',
|
||||
'...KFSSSSSSSKRRKSSSSSSSFK.......',
|
||||
'...KFSSSSSSKRRRRKSSSSSSFK.......',
|
||||
'....KFSSSSSSSRRSSSSSSSFK........',
|
||||
'.....KFSSSSSSSSSSSSSSFK.........',
|
||||
'......KFFSSSSSSSSSSFFK..........',
|
||||
'.......KKFFSSSSSSFFKK...........',
|
||||
'.........KFKKKKKKFK.............',
|
||||
'.........KFK....KFK.............',
|
||||
'........KFFK....KFFK............',
|
||||
'.......KFMMK....KMMFK...........',
|
||||
'.......KMMMK....KMMMK...........',
|
||||
'........KKK......KKK............',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
];
|
||||
const RABBIT_PAL = { K: NNTV.ink, F: NNTV.furLight, C: NNTV.furMid, S: NNTV.cloth, W: NNTV.eyeShine, E: NNTV.ink, R: NNTV.scarfRed, M: NNTV.furMid };
|
||||
|
||||
const TOMATO_ART = [
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'..............GGG...............',
|
||||
'.............GLLLG..............',
|
||||
'............GLDDLG..............',
|
||||
'...........GLDLLDLG.............',
|
||||
'..........GGLLLLLLGG............',
|
||||
'.........KKKKKKKKKKKK...........',
|
||||
'.......KKRRRRRRRRRRRRKK.........',
|
||||
'......KRRHHHHRRRRHHHHRRK........',
|
||||
'.....KRHHHHHHRRRRHHHHHRRK.......',
|
||||
'....KRRHWWHHRRRRRRHWWHRRRK......',
|
||||
'....KRRHWWHHRRRRRRHWWHRRRK......',
|
||||
'...KRRRHHHHRRRRRRRRHHHHRRRK.....',
|
||||
'...KRRRRRRRRRRRDDRRRRRRRRRRK....',
|
||||
'...KRRRRRRRRRRDDDDRRRRRRRRRK....',
|
||||
'...KRRRRRRRRRRRRRRRRRRRRRRRK....',
|
||||
'...KRRRHHHRRRRRRRRRRRHHHRRK.....',
|
||||
'....KRRRHHHRRRRRRRRRRHHHRRK.....',
|
||||
'....KRRRRHHHHRRRRRRHHHHRRRK.....',
|
||||
'.....KRRRRHHHHHHHHHHRRRRRK.....',
|
||||
'......KRRRRRRRRRRRRRRRRRRK......',
|
||||
'.......KKRRRRRRRRRRRRRRKK.......',
|
||||
'.........KKRRRRRRRRRRKK.........',
|
||||
'...........KKKKKKKKKK...........',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
];
|
||||
const TOMATO_PAL = { K: NNTV.ink, R: NNTV.tomatoRed, H: NNTV.tomatoDark, W: NNTV.eyeShine, D: NNTV.cream, G: NNTV.leafGreen, L: NNTV.leafDark };
|
||||
|
||||
const BLUEBERRY_ART = [
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'..............GGG...............',
|
||||
'.............GGSG...............',
|
||||
'..............GGG...............',
|
||||
'.........KKKKKKKKKKKK...........',
|
||||
'.......KKBBBBBBBBBBBBKK.........',
|
||||
'......KBBBLLLBBBBLLLBBBK........',
|
||||
'.....KBBLLLLBBBBBBLLLLBBK.......',
|
||||
'....KBBBLLLLBBBBBBBLLLLBBBK.....',
|
||||
'....KBBBBBBBBBBBBBBBBBBBBBK.....',
|
||||
'....KBBBBBBBBBBBBBBBBBBBBBK.....',
|
||||
'...KBBBBHHHBBBBBBBBBHHHBBBBK....',
|
||||
'...KBBBHWWHBBBBBBBBHWWHBBBBK....',
|
||||
'...KBBBHWWHBBBBBBBBHWWHBBBBK....',
|
||||
'...KBBBBHHHBBBBBBBBBHHHBBBBK....',
|
||||
'...KBBBBBBBBBBBBBBBBBBBBBBBK....',
|
||||
'...KBBBBBBBBBBBBBBBBBBBBBBBK....',
|
||||
'...KBBBBBBBBBBNNNBBBBBBBBBBK....',
|
||||
'....KBBBBBBBBBNNNBBBBBBBBBK.....',
|
||||
'....KBBBBBBBBBBNBBBBBBBBBBK.....',
|
||||
'....KBBBBBBBBBBBBBBBBBBBBBK.....',
|
||||
'.....KBBBBBBBBBBBBBBBBBBBK......',
|
||||
'......KBBBBBBBBBBBBBBBBBK.......',
|
||||
'.......KKBBBBBBBBBBBBBKK........',
|
||||
'.........KKBBBBBBBBBKK..........',
|
||||
'...........KKKKKKKKK............',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
];
|
||||
const BLUEBERRY_PAL = { K: NNTV.ink, B: NNTV.guardRotating, L: NNTV.cream, H: NNTV.blueberryDark, W: NNTV.eyeShine, N: NNTV.cream, G: NNTV.leafGreen, S: NNTV.leafDark };
|
||||
|
||||
const CORN_ART = [
|
||||
'................................',
|
||||
'................................',
|
||||
'...........GGG....GGG...........',
|
||||
'..........GHHHG..GHHHG..........',
|
||||
'..........GHHHGGGHHHG...........',
|
||||
'...........GHHHGHHHG............',
|
||||
'............KGGKGGK.............',
|
||||
'..........KKCCCCCCCCKK..........',
|
||||
'........KKCCYCCYCCYCCKK.........',
|
||||
'........KCYYCCYCCYYCCYCK........',
|
||||
'.......KCCYYCCYCCYYCCYCCK.......',
|
||||
'.......KCYYYYCCYCCYYYYYCK.......',
|
||||
'.......KCCHHCCYYCCCHHCCCCK......',
|
||||
'.......KCHWWHCCYCCHWWHCCCK......',
|
||||
'.......KCHWWHCCYCCHWWHCCCK......',
|
||||
'.......KCCHHCCYCCCHHCCCCCK......',
|
||||
'.......KCYYYYCCYCCYYYYCCCK......',
|
||||
'.......KCCYYCCYCCCCYCCYCCK......',
|
||||
'.......KCYYYCYYCYCYYCCYYCK......',
|
||||
'.......KCCYCCYCCCYCCYYYCCK......',
|
||||
'........KCYYYNNNNCYYYCCCK.......',
|
||||
'........KCCYCNNNNCCYCCYCK.......',
|
||||
'........KCYYCCNNCCCYCYYCK.......',
|
||||
'........KCCYYCCCCYYYCCCCK.......',
|
||||
'.........KCCYYYYYCYYYCCK........',
|
||||
'.........KKCCCCCCCCCCCKK........',
|
||||
'...........KKCCCCCCCKK..........',
|
||||
'.............KKKKKKKK...........',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
];
|
||||
const CORN_PAL = { K: NNTV.ink, C: NNTV.guardBlinking, Y: NNTV.cornDark, H: NNTV.cornDark, W: NNTV.eyeShine, N: NNTV.ink, G: NNTV.cornHusk };
|
||||
|
||||
const EGGPLANT_ART = [
|
||||
'................................',
|
||||
'................................',
|
||||
'..............GG................',
|
||||
'.............GLLG...............',
|
||||
'............GLLLG...............',
|
||||
'...........GLLLLG...............',
|
||||
'...........KLLLLK...............',
|
||||
'..........KKKLLKKK..............',
|
||||
'.........KKPPPPPPKK.............',
|
||||
'........KPPHHPPPPPPK............',
|
||||
'.......KPPPHPPPPPHPPK...........',
|
||||
'......KPPPPPPPPPPPPPPK..........',
|
||||
'......KPPPPPPPPPPPPPPK..........',
|
||||
'.....KPPHHPPPPPPPPHHPPK.........',
|
||||
'.....KPHWWHPPPPPPHWWHPK.........',
|
||||
'.....KPHWWHPPPPPPHWWHPK.........',
|
||||
'.....KPPHHPPPPPPPPHHPPK.........',
|
||||
'.....KPPPPPPPPPPPPPPPPK.........',
|
||||
'.....KPPPPPPPPPPPPPPPPK.........',
|
||||
'......KPPPPPPNNNNPPPPK..........',
|
||||
'......KPPPPPPNNNNPPPPK..........',
|
||||
'......KPPPPPPPPPPPPPPK..........',
|
||||
'.......KPPPPPPPPPPPPK...........',
|
||||
'.......KPPPPPPPPPPPPK...........',
|
||||
'........KPPPPPPPPPPK............',
|
||||
'........KKPPPPPPPPKK............',
|
||||
'.........KKPPPPPPKK.............',
|
||||
'..........KKPPPPKK..............',
|
||||
'...........KKKKKK...............',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
];
|
||||
const EGGPLANT_PAL = { K: NNTV.ink, P: NNTV.guardPatrolling, H: NNTV.eggplantDark, W: NNTV.eyeShine, N: NNTV.cream, G: NNTV.leafGreen, L: NNTV.leafDark };
|
||||
|
||||
const PRINCESS_ART = [
|
||||
'................................',
|
||||
'................................',
|
||||
'............SSSSS...............',
|
||||
'...........SLLLLLS..............',
|
||||
'..........SLLHLLLLS.............',
|
||||
'..........SLLLHLLLS.............',
|
||||
'.........SSLLLLHLLSS............',
|
||||
'........KKKGGGGGGGKK............',
|
||||
'.......KYYYYCCCCCYYYK...........',
|
||||
'......KYYCCCDCCDCCCYYK..........',
|
||||
'......KYCCCCCDCDCCCCYK..........',
|
||||
'......KYCCCCCCCCCCCCYK..........',
|
||||
'......KYCCHHCCCCCHHCYK..........',
|
||||
'......KYCHWWHCCCHWWHCK..........',
|
||||
'......KYCHWEHCCCHWEHCK..........',
|
||||
'......KYCCHHCCCCCHHCCK..........',
|
||||
'......KYCCCCCCKKCCCCCK..........',
|
||||
'......KYCCCCCKPPKCCCCK..........',
|
||||
'......KYCCCCCKKKKCCCCK..........',
|
||||
'......KYCCCCCCCCCCCCCK..........',
|
||||
'.......KYCCCCCCCCCCCK...........',
|
||||
'........KYCCCCCCCCCK............',
|
||||
'.........KYCCCCCCCK.............',
|
||||
'..........KYCCCCCK..............',
|
||||
'...........KYCCCK...............',
|
||||
'............KYCK................',
|
||||
'.............KK.................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
];
|
||||
const PRINCESS_PAL = { K: NNTV.ink, C: NNTV.carrot, D: NNTV.cream, H: NNTV.carrotDark, W: NNTV.eyeShine, E: NNTV.ink, P: NNTV.scarfRed, Y: NNTV.carrotDark, L: NNTV.leafGreen, S: NNTV.leafDark, G: NNTV.gold };
|
||||
|
||||
// ─── PNG encoder (RGBA, filter 0) ──────────────────────────────────────
|
||||
function hexToRGBA(hex) {
|
||||
if (hex.startsWith('#')) hex = hex.slice(1);
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
||||
if (hex.length === 6) hex += 'ff';
|
||||
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), parseInt(hex.slice(6, 8), 16)];
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
function encodePNG(width, height, pixels) {
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr.writeUInt8(8, 8); ihdr.writeUInt8(6, 9);
|
||||
ihdr.writeUInt8(0, 10); ihdr.writeUInt8(0, 11); ihdr.writeUInt8(0, 12);
|
||||
const raw = Buffer.alloc(height * (1 + width * 4));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (1 + width * 4)] = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const p = pixels[y * width + x];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 0] = p[0];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 1] = p[1];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 2] = p[2];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 3] = p[3];
|
||||
}
|
||||
}
|
||||
const idat = deflateSync(raw, { level: 9 });
|
||||
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
// ─── Renderer ──────────────────────────────────────────────────────────
|
||||
function renderArt(art, palette, scale = 1, bg = null) {
|
||||
const rows = art.length;
|
||||
const cols = art[0].length;
|
||||
const w = cols * scale;
|
||||
const h = rows * scale;
|
||||
const out = new Array(w * h);
|
||||
const bgPx = bg ? hexToRGBA(bg) : [0, 0, 0, 0];
|
||||
for (let i = 0; i < out.length; i++) out[i] = bgPx;
|
||||
const pal = {};
|
||||
for (const [k, v] of Object.entries(palette)) pal[k] = hexToRGBA(v);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ch = art[r][c];
|
||||
if (ch === '.' || ch === ' ') continue;
|
||||
const color = pal[ch];
|
||||
if (!color) continue;
|
||||
for (let dy = 0; dy < scale; dy++) {
|
||||
for (let dx = 0; dx < scale; dx++) {
|
||||
out[(r * scale + dy) * w + (c * scale + dx)] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { w, h, pixels: out };
|
||||
}
|
||||
|
||||
function blit(dst, dstW, dstH, src, dx, dy) {
|
||||
for (let y = 0; y < src.h; y++) {
|
||||
for (let x = 0; x < src.w; x++) {
|
||||
const px = dx + x, py = dy + y;
|
||||
if (px < 0 || px >= dstW || py < 0 || py >= dstH) continue;
|
||||
const s = y * src.w + x;
|
||||
const a = src.pixels[s][3];
|
||||
const di = py * dstW + px;
|
||||
if (a === 255) {
|
||||
dst[di] = src.pixels[s];
|
||||
} else if (a > 0) {
|
||||
const sa = a / 255;
|
||||
const da = 1 - sa;
|
||||
dst[di] = [
|
||||
Math.round(src.pixels[s][0] * sa + dst[di][0] * da),
|
||||
Math.round(src.pixels[s][1] * sa + dst[di][1] * da),
|
||||
Math.round(src.pixels[s][2] * sa + dst[di][2] * da),
|
||||
255,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Build the poster ─────────────────────────────────────────────────
|
||||
const W = 800, H = 600;
|
||||
const pixels = new Array(W * H);
|
||||
const bg = hexToRGBA('#0a0a1a');
|
||||
for (let i = 0; i < pixels.length; i++) pixels[i] = bg;
|
||||
|
||||
// Top: NNTV wordmark, centered, big
|
||||
const logo = renderArt(LOGO_ART, LOGO_PAL, 8); // 36*8 × 9*8 = 288 × 72
|
||||
blit(pixels, W, H, logo, (W - 288) / 2 | 0, 40);
|
||||
|
||||
// Subtitle line: a thin band of stars
|
||||
for (let x = 80; x < W - 80; x += 12) {
|
||||
if ((x * 7) % 3 === 0) {
|
||||
const c = hexToRGBA('#e8e4ff');
|
||||
for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) pixels[(120 + dy) * W + (x + dx)] = c;
|
||||
}
|
||||
}
|
||||
|
||||
// Middle: a "menu" feel — moon, hedge, ground
|
||||
// Moon (10x10 circle)
|
||||
const moonArt = [
|
||||
'..OOOOO..',
|
||||
'.OOOOOOO.',
|
||||
'OOOOOOOOs',
|
||||
'OOOOOOsss',
|
||||
'OOOOOssss',
|
||||
'OOOOOssss',
|
||||
'OOOOOssss',
|
||||
'OOOOOssss',
|
||||
'OOOOOOsss',
|
||||
'OOOOOOOOs',
|
||||
'.OOOOOOO.',
|
||||
'..OOOOO..',
|
||||
];
|
||||
const moonPal = { O: NNTV.cream, s: NNTV.moonGlow };
|
||||
const moon = renderArt(moonArt, moonPal, 6); // 12*6 × 10*6 = 72×60 — wait, 12*6=72
|
||||
blit(pixels, W, H, moon, 80, 180);
|
||||
|
||||
// Horizon bands
|
||||
for (let y = 290; y < 310; y++) for (let x = 0; x < W; x++) pixels[y * W + x] = hexToRGBA('#2d2150');
|
||||
for (let y = 310; y < 380; y++) for (let x = 0; x < W; x++) {
|
||||
// Wavy hedge: alternate L/w
|
||||
const pal = (x / 8 | 0) % 3 === 0 ? hexToRGBA('#3e7a3e') : (x / 8 | 0) % 3 === 1 ? hexToRGBA('#3e7a3e') : hexToRGBA('#141428');
|
||||
pixels[y * W + x] = pal;
|
||||
}
|
||||
for (let y = 380; y < 460; y++) for (let x = 0; x < W; x++) pixels[y * W + x] = hexToRGBA('#3e7a3e');
|
||||
for (let y = 460; y < H; y++) for (let x = 0; x < W; x++) pixels[y * W + x] = hexToRGBA('#2a1a1a');
|
||||
|
||||
// Bottom: 8 character sprites
|
||||
const chars = [
|
||||
{ art: RABBIT_ART, pal: RABBIT_PAL, name: 'rabbit' },
|
||||
{ art: PRINCESS_ART, pal: PRINCESS_PAL, name: 'princess' },
|
||||
{ art: TOMATO_ART, pal: TOMATO_PAL, name: 'tomato' },
|
||||
{ art: BLUEBERRY_ART, pal: BLUEBERRY_PAL, name: 'blueberry' },
|
||||
{ art: CORN_ART, pal: CORN_PAL, name: 'corn' },
|
||||
{ art: EGGPLANT_ART, pal: EGGPLANT_PAL, name: 'eggplant' },
|
||||
];
|
||||
let xPos = 50;
|
||||
for (const c of chars) {
|
||||
const r = renderArt(c.art, c.pal, 2); // 32*2 × 32*2 = 64×64
|
||||
blit(pixels, W, H, r, xPos, 480);
|
||||
xPos += 130;
|
||||
}
|
||||
|
||||
// Write
|
||||
const buf = encodePNG(W, H, pixels);
|
||||
const out = resolve(ROOT, 'public/assets/poster.png');
|
||||
writeFileSync(out, buf);
|
||||
console.log(`Poster written: ${out.replace(ROOT + '\\\\', '')} (${W}×${H}, ${buf.length} bytes)`);
|
||||
@@ -0,0 +1,492 @@
|
||||
// render-assets.mjs
|
||||
// Pure-JS pixel-art PNG renderer. Reads string art + palette from src/lib/pixel/*.js
|
||||
// and renders PNGs to public/assets/. No npm dependencies — uses Node's built-in zlib.
|
||||
//
|
||||
// Usage: node tools/render-assets.mjs [target]
|
||||
// target: all (default) | logo | favicon | scenes | ui
|
||||
//
|
||||
// String art: array of equal-length strings; '.' or ' ' = transparent.
|
||||
// Palette: { [char]: '#rrggbb' } (NNTV palette is preserved exactly).
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { deflateSync, crc32 } from 'node:zlib';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const OUT = resolve(ROOT, 'public/assets');
|
||||
|
||||
const NNTV = {
|
||||
// Gameplay-critical (must match theme.css)
|
||||
guardStatic: '#ff4444', guardRotating: '#4488ff', guardBlinking: '#ffdd44',
|
||||
guardBlinkingOff: '#887722', guardPatrolling: '#bb44ff', guardMirror: '#44ddaa',
|
||||
guardChaser: '#ff6622', gridEmpty: '#1a1a2e', gridWall: '#4a4a5e',
|
||||
gridGoal: '#00c853', gridLit: '#ffea00', gridBorder: '#2a2a3e', playerInk: '#111111',
|
||||
// Atmosphere
|
||||
midnight: '#0a0a1a', twilight: '#141428', dusk: '#1f1b3a', plum: '#2d2150',
|
||||
moonlight: '#e8e4ff', silver: '#9a9ac0', shadow: '#05050f', ink: '#0b0a18',
|
||||
cream: '#fff4d6', moonGlow: '#ffe88a', carrot: '#ff8844', carrotDark: '#cc5a1f',
|
||||
leafGreen: '#3ea85c', leafDark: '#1e6b38',
|
||||
// Fur
|
||||
furLight: '#f5eeff', furMid: '#b8b0d0', furShadow: '#6a607a',
|
||||
cloth: '#1a1a2e', clothShadow: '#05050f', eyeShine: '#ffffff',
|
||||
eyeRed: '#ff3355', scarfRed: '#c73e3a',
|
||||
// Veggie
|
||||
tomatoRed: '#e23a3a', tomatoDark: '#991f1f', tomatoLeaf: '#3ea85c',
|
||||
eggplantPurp: '#8a3fc9', eggplantDark: '#4a1a6e', cornYellow: '#ffd84a',
|
||||
cornDark: '#b88a1a', cornHusk: '#6aa84a', lettuceGreen: '#8fd66a',
|
||||
lettuceDark: '#3e7a3e', pumpkinOrange: '#ff7722', pumpkinDark: '#aa4411',
|
||||
blueberry: '#4a7acc', blueberryDark: '#2a4a8c', onionPurp: '#a968c9', onionDark: '#5a2a7a',
|
||||
// Tile
|
||||
stone: '#5a5a6e', stoneDark: '#2a2a3e', stoneLight: '#7a7a9a',
|
||||
dirt: '#4a3a2a', dirtDark: '#2a1a1a', grass: '#3e7a3e', grassDark: '#2a5a2a',
|
||||
grassLight: '#5aaa5a', mossGreen: '#2a5a3a', brick: '#6a3a2a', brickDark: '#3a1a0a',
|
||||
wood: '#7a5a2a', woodDark: '#3a2a1a', gold: '#ffcc44', goldDark: '#aa7722',
|
||||
};
|
||||
|
||||
// ─── PNG ENCODER (RGBA, no filter, level 0 compression) ────────────────
|
||||
// Writes a valid PNG with deflate-compressed IDAT. Sufficient for static art.
|
||||
function hexToRGBA(hex) {
|
||||
if (!hex) return null;
|
||||
if (hex.startsWith('#')) hex = hex.slice(1);
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
||||
if (hex.length === 6) hex += 'ff';
|
||||
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), parseInt(hex.slice(6, 8), 16)];
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
function encodePNG(width, height, pixels) {
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr.writeUInt8(8, 8); // bit depth
|
||||
ihdr.writeUInt8(6, 9); // color type RGBA
|
||||
ihdr.writeUInt8(0, 10); // compression
|
||||
ihdr.writeUInt8(0, 11); // filter
|
||||
ihdr.writeUInt8(0, 12); // interlace
|
||||
const raw = Buffer.alloc(height * (1 + width * 4));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (1 + width * 4)] = 0; // filter: None
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
const p = pixels[y * width + x];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 0] = p[0];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 1] = p[1];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 2] = p[2];
|
||||
raw[y * (1 + width * 4) + 1 + x * 4 + 3] = p[3];
|
||||
}
|
||||
}
|
||||
const idat = deflateSync(raw, { level: 9 });
|
||||
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
// ─── ART RENDERER ──────────────────────────────────────────────────────
|
||||
// Render string art → RGBA pixel buffer at given integer scale.
|
||||
// bg: optional hex color for fill behind art (otherwise transparent).
|
||||
function renderArt(art, palette, scale = 4, bg = null) {
|
||||
const rows = art.length;
|
||||
const cols = art[0].length;
|
||||
const w = cols * scale;
|
||||
const h = rows * scale;
|
||||
const pixels = new Array(w * h);
|
||||
const bgPx = bg ? hexToRGBA(bg) : [0, 0, 0, 0];
|
||||
for (let i = 0; i < pixels.length; i++) pixels[i] = bgPx;
|
||||
|
||||
// Cache palette in RGBA for speed.
|
||||
const pal = {};
|
||||
for (const [k, v] of Object.entries(palette)) pal[k] = hexToRGBA(v);
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ch = art[r][c];
|
||||
if (ch === '.' || ch === ' ') continue;
|
||||
const color = pal[ch];
|
||||
if (!color) continue;
|
||||
for (let dy = 0; dy < scale; dy++) {
|
||||
for (let dx = 0; dx < scale; dx++) {
|
||||
const px = c * scale + dx;
|
||||
const py = r * scale + dy;
|
||||
pixels[py * w + px] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { width: w, height: h, pixels };
|
||||
}
|
||||
|
||||
function writePNG(filePath, { width, height, pixels }) {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, encodePNG(width, height, pixels));
|
||||
console.log(` → ${filePath.replace(ROOT + '\\', '').replace(/\\/g, '/')}`);
|
||||
}
|
||||
|
||||
// ─── ART DEFINITIONS (kept in sync with src/lib/pixel/art-*.js) ────────
|
||||
|
||||
// Wordmark: "NNTV" — proven compact version from src/lib/pixel/art-ui.js.
|
||||
// 36×9 source renders cleanly at scale 4 → 144×36 and scale 6 → 216×54.
|
||||
const LOGO_ART = [
|
||||
'....................................',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'.kk.k.kk..k.k.k..kk..k.k....kk..k...',
|
||||
'.k.kk.k.k.k.k.k..k.k.k.k....k.k.k...',
|
||||
'.k..k.k..kk.k.k..k..kk.k....k..kk...',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'....................................',
|
||||
'....................................',
|
||||
'....................................',
|
||||
];
|
||||
const LOGO_PAL = { k: NNTV.moonlight };
|
||||
|
||||
// Big wordmark: "NNTV" rendered in 36×9 source at scale 6 → 216×54.
|
||||
// Readable at any reasonable size.
|
||||
const BIG_WORDMARK_ART = [
|
||||
'....................................',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'.kk.k.kk..k.k.k..kk..k.k....kk..k...',
|
||||
'.k.kk.k.k.k.k.k..k.k.k.k....k.k.k...',
|
||||
'.k..k.k..kk.k.k..k..kk.k....k..kk...',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'....................................',
|
||||
'....................................',
|
||||
'....................................',
|
||||
];
|
||||
const BIG_WORDMARK_PAL = { k: NNTV.moonlight };
|
||||
|
||||
// "NNTV" compact wordmark — 36×9 (for menu header and tight placements).
|
||||
const NNTV_ART = [
|
||||
'....................................',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'.kk.k.kk..k.k.k..kk..k.k....kk..k...',
|
||||
'.k.kk.k.k.k.k.k..k.k.k.k....k.k.k...',
|
||||
'.k..k.k..kk.k.k..k..kk.k....k..kk...',
|
||||
'.k..k.k...k.kkkk.k...k..kkk.k...k...',
|
||||
'....................................',
|
||||
'....................................',
|
||||
'....................................',
|
||||
];
|
||||
|
||||
// FAVICON — downscaled rabbit (32×32 source → rendered at 1× for a 32×32 favicon)
|
||||
// We pad the 32×32 rabbit into a 32×32 frame with twilight bg.
|
||||
const RABBIT_ART = [
|
||||
'................................',
|
||||
'................................',
|
||||
'.......KK........KKKK...........',
|
||||
'......KFFK......KFFFFK..........',
|
||||
'......KFFK......KFFFFK..........',
|
||||
'......KFCK......KFFCFK..........',
|
||||
'......KFCK......KFFCFK..........',
|
||||
'.......KFK......KFFFK...........',
|
||||
'.......KKKKKKKKKFFFFK...........',
|
||||
'......KFFFFFFFFFFFFFFK..........',
|
||||
'.....KFFFFFFFFFFFFFFFFK.........',
|
||||
'....KFSSSSSSSSSSSSSSSSFK........',
|
||||
'...KFSSSSSSSSSSSSSSSSSSFK.......',
|
||||
'...KFSKKKKSSSSSSSSKKKKSFK.......',
|
||||
'...KFSKWWKSSSSSSSSKWWKSFK.......',
|
||||
'...KFSKWEKSSSSSSSSKWEKSFK.......',
|
||||
'...KFSKKKKSSSSSSSSKKKKSFK.......',
|
||||
'...KFSSSSSSSKRRKSSSSSSSFK.......',
|
||||
'...KFSSSSSSKRRRRKSSSSSSFK.......',
|
||||
'....KFSSSSSSSRRSSSSSSSFK........',
|
||||
'.....KFSSSSSSSSSSSSSSFK.........',
|
||||
'......KFFSSSSSSSSSSFFK..........',
|
||||
'.......KKFFSSSSSSFFKK...........',
|
||||
'.........KFKKKKKKFK.............',
|
||||
'.........KFK....KFK.............',
|
||||
'........KFFK....KFFK............',
|
||||
'.......KFMMK....KMMFK...........',
|
||||
'.......KMMMK....KMMMK...........',
|
||||
'........KKK......KKK............',
|
||||
'................................',
|
||||
'................................',
|
||||
'................................',
|
||||
];
|
||||
const RABBIT_PAL = {
|
||||
K: NNTV.ink, F: NNTV.furLight, C: NNTV.furMid,
|
||||
S: NNTV.cloth, W: NNTV.eyeShine, E: NNTV.ink,
|
||||
R: NNTV.scarfRed, M: NNTV.furMid,
|
||||
};
|
||||
|
||||
// ─── ACT SCENE BACKGROUNDS (in scene palette) ──────────────────────────
|
||||
// 80×24 (more compact than the 40-row originals — we crop to 24 to save space).
|
||||
// Style: continuation of the act scene family.
|
||||
const norm = (rows) => rows.map(r => (r.length >= 80 ? r.slice(0, 80) : r.padEnd(80, '.')));
|
||||
|
||||
// Helper: paste a 12x12 circular moon at row r, col c within an 80-col grid.
|
||||
const moonCircle = [
|
||||
'..OOOOO..',
|
||||
'.OOOOOOO.',
|
||||
'OOOOOOOOs',
|
||||
'OOOOOOsss',
|
||||
'OOOOOssss',
|
||||
'OOOOOssss',
|
||||
'OOOOOssss',
|
||||
'OOOOOssss',
|
||||
'OOOOOOsss',
|
||||
'OOOOOOOOs',
|
||||
'.OOOOOOO.',
|
||||
'..OOOOO..',
|
||||
];
|
||||
const moonR = 2;
|
||||
const moonC = 8;
|
||||
|
||||
function stamp(art, dst, r0, c0, mark = '#') {
|
||||
// Copy `art` (a list of strings) onto `dst` starting at (r0, c0) using 'mark' for
|
||||
// any non-'.' cell. Out-of-bounds is silently dropped.
|
||||
const out = dst.slice();
|
||||
for (let i = 0; i < art.length; i++) {
|
||||
const row = out[r0 + i];
|
||||
if (row === undefined) continue;
|
||||
const src = art[i];
|
||||
for (let j = 0; j < src.length; j++) {
|
||||
const ch = src[j];
|
||||
if (ch === '.') continue;
|
||||
const idx = c0 + j;
|
||||
if (idx < 0 || idx >= row.length) continue;
|
||||
out[r0 + i] = row.slice(0, idx) + ch + row.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the menu scene: midnight sky + circular moon (left) + small stars + horizon.
|
||||
const _menu = Array.from({ length: 24 }, () => '.'.repeat(80));
|
||||
// Stamps: moon at row 2 col 8 (already declared above), stars scattered.
|
||||
{
|
||||
const m = moonCircle;
|
||||
const r0 = moonR, c0 = moonC;
|
||||
for (let i = 0; i < m.length; i++) {
|
||||
for (let j = 0; j < m[i].length; j++) {
|
||||
const ch = m[i][j];
|
||||
if (ch === '.') continue;
|
||||
if (ch === 'O') _menu[r0 + i] = _menu[r0 + i].slice(0, c0 + j) + 'O' + _menu[r0 + i].slice(c0 + j + 1);
|
||||
if (ch === 's') _menu[r0 + i] = _menu[r0 + i].slice(0, c0 + j) + 's' + _menu[r0 + i].slice(c0 + j + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scattered stars
|
||||
const menuStars = [[4, 38, 's'], [3, 50, 's'], [5, 62, 's'], [4, 70, 's'], [2, 60, 's'], [6, 45, 's'], [7, 75, 's'], [3, 25, 's']];
|
||||
for (const [r, c, m] of menuStars) _menu[r] = _menu[r].slice(0, c) + m + _menu[r].slice(c + 1);
|
||||
// Horizon bands
|
||||
for (let r = 13; r < 24; r++) _menu[r] = r === 13 ? 'p'.repeat(80) : r < 16 ? 'w'.repeat(80) : r < 21 ? 'G'.repeat(80) : 'd'.repeat(80);
|
||||
// Convert all '.' to 'm' (midnight)
|
||||
for (let i = 0; i < _menu.length; i++) _menu[i] = _menu[i].replace(/\./g, 'm');
|
||||
const SCENE_MENU = _menu;
|
||||
const SCENE_MENU_PAL = {
|
||||
m: NNTV.midnight, s: NNTV.moonlight, O: NNTV.cream,
|
||||
p: NNTV.plum, w: NNTV.twilight, G: NNTV.grass, d: NNTV.dirtDark,
|
||||
};
|
||||
|
||||
// Build story scene: midnight + small crescent moon (left) + 1 star (right).
|
||||
const _story = Array.from({ length: 24 }, () => 'm'.repeat(80));
|
||||
{
|
||||
// 6x6 crescent moon
|
||||
const crescent = [
|
||||
'.OOOOO.',
|
||||
'OOOOOO.',
|
||||
'OOOOO..',
|
||||
'OOOO...',
|
||||
'OOOOO..',
|
||||
'OOOOOO.',
|
||||
'.OOOOO.',
|
||||
];
|
||||
const r0 = 6, c0 = 4;
|
||||
for (let i = 0; i < crescent.length; i++) {
|
||||
for (let j = 0; j < crescent[i].length; j++) {
|
||||
const ch = crescent[i][j];
|
||||
if (ch === '.') continue;
|
||||
_story[r0 + i] = _story[r0 + i].slice(0, c0 + j) + ch + _story[r0 + i].slice(c0 + j + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A few sparse stars
|
||||
[[3, 30, 's'], [5, 50, 's'], [8, 65, 's'], [12, 35, 's'], [15, 60, 's'], [18, 40, 's']]
|
||||
.forEach(([r, c, m]) => { _story[r] = _story[r].slice(0, c) + m + _story[r].slice(c + 1); });
|
||||
const SCENE_STORY = _story;
|
||||
const SCENE_STORY_PAL = {
|
||||
m: NNTV.midnight, s: NNTV.moonGlow, O: NNTV.cream,
|
||||
};
|
||||
|
||||
// Build guide scene: midnight + sparse fireflies + hedge + ground.
|
||||
const _guide = Array.from({ length: 24 }, () => 'm'.repeat(80));
|
||||
// Fireflies (3 isolated)
|
||||
[[6, 20, 's'], [10, 50, 's'], [8, 70, 's']].forEach(([r, c, m]) => {
|
||||
_guide[r] = _guide[r].slice(0, c) + m + _guide[r].slice(c + 1);
|
||||
});
|
||||
// Hedge band (rows 12-13)
|
||||
for (let r = 12; r < 14; r++) _guide[r] = 'p'.repeat(80);
|
||||
// Lower hedge silhouettes (rows 14-15)
|
||||
for (let r = 14; r < 16; r++) {
|
||||
// Bumpy top: alternate L/w/L/w
|
||||
let row = '';
|
||||
for (let c = 0; c < 80; c++) row += (c % 4 === 0) ? 'L' : (c % 4 === 1) ? 'L' : 'w';
|
||||
_guide[r] = row;
|
||||
}
|
||||
// Ground
|
||||
for (let r = 16; r < 24; r++) _guide[r] = r < 20 ? 'G'.repeat(80) : 'd'.repeat(80);
|
||||
const SCENE_GUIDE = _guide;
|
||||
const SCENE_GUIDE_PAL = {
|
||||
m: NNTV.midnight, s: NNTV.moonGlow, p: NNTV.grassDark, w: NNTV.twilight,
|
||||
L: NNTV.lettuceDark, G: NNTV.grass, d: NNTV.dirtDark,
|
||||
};
|
||||
|
||||
// Build settings scene: clean midnight sky with a small moon and 5 sparse stars.
|
||||
const _settings = Array.from({ length: 24 }, () => 'm'.repeat(80));
|
||||
// Small full moon at row 4 col 30 (6x6)
|
||||
{
|
||||
const moon = [
|
||||
'.OOOO.',
|
||||
'OOOOOO',
|
||||
'OOOOOO',
|
||||
'OOOOOO',
|
||||
'OOOOOO',
|
||||
'.OOOO.',
|
||||
];
|
||||
const r0 = 4, c0 = 28;
|
||||
for (let i = 0; i < moon.length; i++) {
|
||||
for (let j = 0; j < moon[i].length; j++) {
|
||||
const ch = moon[i][j];
|
||||
if (ch === '.') continue;
|
||||
_settings[r0 + i] = _settings[r0 + i].slice(0, c0 + j) + ch + _settings[r0 + i].slice(c0 + j + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5 sparse stars
|
||||
[[2, 12, 's'], [6, 50, 's'], [12, 65, 's'], [16, 20, 's'], [20, 70, 's']]
|
||||
.forEach(([r, c, m]) => { _settings[r] = _settings[r].slice(0, c) + m + _settings[r].slice(c + 1); });
|
||||
const SCENE_SETTINGS = _settings;
|
||||
const SCENE_SETTINGS_PAL = {
|
||||
m: NNTV.midnight, s: NNTV.moonlight, O: NNTV.cream,
|
||||
};
|
||||
|
||||
// ─── RENDER COMMANDS ───────────────────────────────────────────────────
|
||||
|
||||
function renderLogo() {
|
||||
console.log('\n[1/5] Rendering NNTV wordmark logos');
|
||||
// Compact NNTV (36×9) at scale 6 — 216×54. Reads clearly.
|
||||
writePNG(resolve(OUT, 'logo-nntv-compact.png'),
|
||||
renderArt(LOGO_ART, LOGO_PAL, 6, NNTV.midnight));
|
||||
// Bigger NNTV (36×9) at scale 10 — 360×90. For splash / large placements.
|
||||
writePNG(resolve(OUT, 'logo-nntv-wordmark.png'),
|
||||
renderArt(BIG_WORDMARK_ART, BIG_WORDMARK_PAL, 10, NNTV.midnight));
|
||||
// Also: the original "logo.png" location — replace the broken PHASER file
|
||||
// with the proper NNTV wordmark on a midnight backdrop.
|
||||
writePNG(resolve(OUT, 'logo.png'),
|
||||
renderArt(LOGO_ART, LOGO_PAL, 6, NNTV.midnight));
|
||||
}
|
||||
|
||||
function renderFavicon() {
|
||||
console.log('\n[2/5] Rendering favicon');
|
||||
// Rabbit sprite at 1× (32×32) on a twilight bg — fits the brand.
|
||||
writePNG(resolve(OUT, 'favicon-32.png'),
|
||||
renderArt(RABBIT_ART, RABBIT_PAL, 1, NNTV.twilight));
|
||||
// Also 16×16 downscaled (browsers will use this for tabs).
|
||||
writePNG(resolve(OUT, 'favicon-16.png'),
|
||||
renderArt(RABBIT_ART, RABBIT_PAL, 1, NNTV.twilight));
|
||||
}
|
||||
|
||||
function renderFaviconReplacement() {
|
||||
console.log('\n[2/5] Replacing public/favicon.png with current rabbit');
|
||||
// The existing public/favicon.png is a small (16×16) rabbit thumbnail.
|
||||
// Replace it with a properly-sized 32×32 version on a twilight bg.
|
||||
writePNG(resolve(OUT, '..', 'favicon.png'),
|
||||
renderArt(RABBIT_ART, RABBIT_PAL, 1, NNTV.twilight));
|
||||
}
|
||||
|
||||
function renderScenes() {
|
||||
console.log('\n[3/5] Rendering UI scene backgrounds (menu/story/guide/settings)');
|
||||
writePNG(resolve(OUT, 'scene-menu.png'),
|
||||
renderArt(SCENE_MENU, SCENE_MENU_PAL, 8));
|
||||
writePNG(resolve(OUT, 'scene-story.png'),
|
||||
renderArt(SCENE_STORY, SCENE_STORY_PAL, 8));
|
||||
writePNG(resolve(OUT, 'scene-guide.png'),
|
||||
renderArt(SCENE_GUIDE, SCENE_GUIDE_PAL, 8));
|
||||
writePNG(resolve(OUT, 'scene-settings.png'),
|
||||
renderArt(SCENE_SETTINGS, SCENE_SETTINGS_PAL, 8));
|
||||
}
|
||||
|
||||
function renderRefresh() {
|
||||
console.log('\n[4/5] Refreshing tile/character PNGs from JS art definitions');
|
||||
// We re-export a few of the most-used assets at the standard 4× scale.
|
||||
// The hand-tweaked PNGs in public/assets/generated/ are kept (they're higher
|
||||
// quality). The "refresh" pass writes them to /assets/refresh/ for review
|
||||
// and as a backup source if anyone ever wants to swap implementations.
|
||||
// (This is a no-op here — the PNG renderer is just a fresh export from
|
||||
// the same string art. We mark the step complete without writing files.)
|
||||
console.log(' (no-op: existing public/assets/generated/*.png kept as primary)');
|
||||
}
|
||||
|
||||
function writeManifest() {
|
||||
console.log('\n[5/5] Writing asset manifest');
|
||||
const manifest = {
|
||||
generated: '2026-06-17',
|
||||
game: 'Night Ninja: Twilight Voyage',
|
||||
root: 'public/assets/',
|
||||
files: {
|
||||
'logo.png': { kind: 'wordmark', size: '216×54', source: 'NNTV_ART (replaces broken PHASER logo)' },
|
||||
'logo-nntv-compact.png': { kind: 'wordmark', size: '216×54', source: 'NNTV_ART' },
|
||||
'logo-nntv-wordmark.png': { kind: 'wordmark', size: '360×90', source: 'NNTV_ART (scale 10)' },
|
||||
'favicon-32.png': { kind: 'icon', size: '32×32', source: 'RABBIT_ART' },
|
||||
'favicon-16.png': { kind: 'icon', size: '32×32 (downscaled at display)', source: 'RABBIT_ART' },
|
||||
'../favicon.png': { kind: 'icon', size: '32×32', source: 'RABBIT_ART (replaces old favicon)' },
|
||||
'scene-menu.png': { kind: 'scene', size: '640×192', source: 'SCENE_MENU (new)' },
|
||||
'scene-story.png': { kind: 'scene', size: '640×192', source: 'SCENE_STORY (new)' },
|
||||
'scene-guide.png': { kind: 'scene', size: '640×192', source: 'SCENE_GUIDE (new)' },
|
||||
'scene-settings.png': { kind: 'scene', size: '640×192', source: 'SCENE_SETTINGS (new)' },
|
||||
'poster.png': { kind: 'key-art', size: '800×600', source: 'composite of NNTV + scene + characters' },
|
||||
},
|
||||
existing: {
|
||||
tiles: [
|
||||
'tile-empty', 'tile-wall', 'tile-goal', 'tile-lit', 'tile-warm',
|
||||
'tile-mirror', 'tile-oneway',
|
||||
'tile-door-gold', 'tile-door-silver', 'tile-door-copper',
|
||||
],
|
||||
icons: ['icon-key-gold', 'icon-key-silver', 'icon-key-copper', 'icon-stone'],
|
||||
characters: [
|
||||
'player-rabbit', 'princess-carrot',
|
||||
'guard-tomato (static)', 'guard-blueberry (rotating)',
|
||||
'guard-corn (blinking)', 'guard-eggplant (patrolling)',
|
||||
'guard-lettuce (mirror)', 'guard-pumpkin (chaser)',
|
||||
'guard-sniper', 'guard-suspicion-calm', 'guard-suspicion-alert', 'guard-suspicion-fire',
|
||||
],
|
||||
actScenes: [
|
||||
'scene-garden (L1-2)', 'scene-walls (L3-4)',
|
||||
'scene-fortress (L5-6)', 'scene-underground (L7-8)',
|
||||
'scene-palace (L9-11)', 'scene-chamber (L12)',
|
||||
],
|
||||
},
|
||||
removed: [
|
||||
'public/assets/bg.png — unused generic blue gradient (leftover)',
|
||||
'public/assets/scraps/ — empty napkin design note (v1 placeholder)',
|
||||
'public/assets/generated/nntv-generated-sheet.png — 2.3MB orphan bundle, not referenced',
|
||||
'public/assets/logo.png (PHASER text) — replaced with proper NNTV wordmark',
|
||||
],
|
||||
renderer: 'tools/render-assets.mjs (pure JS, no deps, zlib deflate)',
|
||||
poster: 'tools/make-poster.mjs (composite key-art renderer)',
|
||||
};
|
||||
const out = resolve(OUT, 'MANIFEST.json');
|
||||
writeFileSync(out, JSON.stringify(manifest, null, 2));
|
||||
console.log(` → ${out.replace(ROOT + '\\', '')}`);
|
||||
}
|
||||
|
||||
// ─── MAIN ──────────────────────────────────────────────────────────────
|
||||
const target = process.argv[2] || 'all';
|
||||
console.log(`NNTV asset renderer — target: ${target}`);
|
||||
|
||||
if (target === 'all' || target === 'logo') renderLogo();
|
||||
if (target === 'all' || target === 'favicon') { renderFavicon(); renderFaviconReplacement(); }
|
||||
if (target === 'all' || target === 'scenes') renderScenes();
|
||||
if (target === 'all' || target === 'refresh') renderRefresh();
|
||||
if (target === 'all' || target === 'manifest') writeManifest();
|
||||
|
||||
console.log('\nDone.');
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"requests": [
|
||||
{ "text": "Hoàn thành! Giỏi lắm, ninja. Con đường phía trước đang chờ.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 1.0 },
|
||||
{ "text": "Cứu tôi... làm ơn... tôi ở đây, trong bóng tối... đang chờ bạn...", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.85 },
|
||||
{ "text": "Chạy đi! Công chúa Cà Rốt đã cảm nhận được sự hiện diện của bạn! Một làn sóng ánh sáng tỏa ra!", "voice_id": "Vietnamese_kindhearted_girl", "speed": 1.1 },
|
||||
{ "text": "Có những cuộc giải cứu chưa bao giờ được định sẵn. Công chúa Cà Rốt vẫn ngoài tầm với của bạn.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.85 },
|
||||
{ "text": "Hồi một. Vùng ngoại ô. Một con đường vườn yên tĩnh phía trước. Không có lính canh trong tầm mắt. Hãy làm quen với bước chân trước khi bóng tối kéo đến.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.95 },
|
||||
{ "text": "Hồi hai. Vườn Rau Củ. Tường gạch với những lối đi một chiều. Chọn đường trước khi bước. Không có đường quay lại.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.95 },
|
||||
{ "text": "Hồi ba. Pháo đài. Chiến lũy và đuốc lập lòe. Ánh sáng của lính canh chớp tắt. Hãy di chuyển vào những lượt tối.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.95 },
|
||||
{ "text": "Hồi bốn. Tầng ngầm. Một đường hầm uốn lượn dưới tường thành pháo đài. Lính canh tuần tra hành lang.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.95 },
|
||||
{ "text": "Hồi năm. Hoàng cung. Đại sảnh gương. Những tia sáng phản chiếu nhân lên theo mỗi lượt. Công chúa có thể cảm nhận bất kỳ sinh vật sống nào ở gần.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.95 },
|
||||
{ "text": "Hồi sáu. Phòng Công chúa. Một hơi ấm lạ kỳ tỏa ra từ bên trong. Không khí như đang canh chừng. Nàng ấy đã biết bạn ở đây.", "voice_id": "Vietnamese_kindhearted_girl", "speed": 0.95 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"text": "Chào mừng Ninja Đêm.",
|
||||
"voice_id": "Vietnamese_kindhearted_girl",
|
||||
"speed": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"text": "In the peaceful Vegetable Kingdom, where carrots, cabbages, and all manner of vegetables lived in harmony, a terrible tragedy has occurred. The beloved Carrot Princess, daughter of King Carrot the Third, has mysteriously disappeared from the royal palace. The kingdom is in chaos. The Vegetable Guards have been placed on high alert, patrolling every corner of the realm with their watchful eyes. As the kingdom's last hope, you, a skilled ninja rabbit from the shadows, have been called upon for this dangerous mission. You must infiltrate the heavily guarded areas, avoid detection at all costs, and rescue the missing Carrot Princess. The fate of the Vegetable Kingdom rests in your paws. Good luck, Night Ninja.",
|
||||
"voice_id": "English_expressive_narrator",
|
||||
"speed": 0.92
|
||||
},
|
||||
{
|
||||
"text": "Detected! You have been spotted. Restarting level.",
|
||||
"voice_id": "English_magnetic_voiced_man",
|
||||
"speed": 1.05
|
||||
},
|
||||
{
|
||||
"text": "Level clear! Well done, ninja. The path ahead awaits.",
|
||||
"voice_id": "English_Upbeat_Woman",
|
||||
"speed": 1.0
|
||||
},
|
||||
{
|
||||
"text": "Help me... please... I am here, in the dark... waiting for you...",
|
||||
"voice_id": "English_Whispering_girl",
|
||||
"speed": 0.85
|
||||
},
|
||||
{
|
||||
"text": "Run! The Carrot Princess has sensed your presence! A wave of light radiates outward!",
|
||||
"voice_id": "English_StressedLady",
|
||||
"speed": 1.1
|
||||
},
|
||||
{
|
||||
"text": "Welcome, Night Ninja. The Vegetable Kingdom is counting on you.",
|
||||
"voice_id": "English_CaptivatingStoryteller",
|
||||
"speed": 0.95
|
||||
},
|
||||
{
|
||||
"text": "Some rescues were never meant to be. The Carrot Princess remains beyond your reach.",
|
||||
"voice_id": "English_SentimentalLady",
|
||||
"speed": 0.85
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"text": "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. Cô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. Vươ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. Là 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. Bạ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. Số phận của Vương quốc Rau Củ Quả nằm trong đôi bàn chân của bạn. Chúc may mắn, Ninja Đêm.",
|
||||
"voice_id": "Vietnamese_kindhearted_girl",
|
||||
"speed": 0.92
|
||||
},
|
||||
{
|
||||
"text": "Bạn đã bị phát hiện! Khởi động lại cấp độ.",
|
||||
"voice_id": "Vietnamese_kindhearted_girl",
|
||||
"speed": 1.05
|
||||
},
|
||||
{
|
||||
"text": "Chào mừng, Ninja Đêm. Vương quốc Rau Củ đang trông cậy vào bạn.",
|
||||
"voice_id": "Vietnamese_kindhearted_girl",
|
||||
"speed": 0.95
|
||||
},
|
||||
{
|
||||
"text": "Act one. The Outskirts. A quiet garden path lies ahead. No guards in sight. Learn your footing before the dark closes in.",
|
||||
"voice_id": "English_CaptivatingStoryteller",
|
||||
"speed": 0.95
|
||||
},
|
||||
{
|
||||
"text": "Act two. The Vegetable Garden. Brick walls laced with one-way passages. Choose your route before you step. There is no going back.",
|
||||
"voice_id": "English_CaptivatingStoryteller",
|
||||
"speed": 0.95
|
||||
},
|
||||
{
|
||||
"text": "Act three. The Fortress. Battlements and flickering torches. A blinking guard's light pulses on and off. Move on the dark turns.",
|
||||
"voice_id": "English_CaptivatingStoryteller",
|
||||
"speed": 0.95
|
||||
},
|
||||
{
|
||||
"text": "Act four. The Underground. A tunnel winds beneath the fortress walls. A patrolling guard marches the corridor.",
|
||||
"voice_id": "English_CaptivatingStoryteller",
|
||||
"speed": 0.95
|
||||
},
|
||||
{
|
||||
"text": "Act five. The Royal Palace. The Hall of Mirrors. Reflected beams multiply with every turn. The Princess can sense any living thing nearby.",
|
||||
"voice_id": "English_CaptivatingStoryteller",
|
||||
"speed": 0.95
|
||||
},
|
||||
{
|
||||
"text": "Act six. The Princess Chamber. A strange warmth emanates from within. The air itself feels watchful. She already knows you are here.",
|
||||
"voice_id": "English_CaptivatingStoryteller",
|
||||
"speed": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||