diff --git a/docs/visual-asset-prologue-exploration-report.md b/docs/visual-asset-prologue-exploration-report.md new file mode 100644 index 0000000..c8712ac --- /dev/null +++ b/docs/visual-asset-prologue-exploration-report.md @@ -0,0 +1,79 @@ +# Prologue exploration visual asset report + +Generated on 2026-07-26 with the built-in image generation workflow. These are +original project assets; no commercial-game screenshot, logo, or third-party +character artwork is included in the runtime files. + +## Runtime assets + +| Role | Project assets | Runtime format | +| --- | --- | --- | +| Zhuo village and militia camp | `src/assets/images/exploration/prologue-village.webp`, `prologue-militia-camp.webp` | 1920×1080 opaque WebP | +| Exploration SD characters | `src/assets/images/exploration/characters/exploration-*.webp` | 3072×768 transparent WebP sprite sheets | +| Prologue NPC dialogue portraits | `src/assets/images/portraits/zhuo-*-yellow-turban.webp`, `zou-jing-yellow-turban.webp` | 1254×1254 WebP | + +The two backgrounds reserve the exact top, right, and bottom HUD regions used +by the 1920×1080 desktop layout. The playable portion was aligned to the +existing collision and interaction coordinate system before export. + +Each exploration character sheet contains 192×192 frames: four direction rows +(`south`, `east`, `north`, `west`) and sixteen columns per row (eight idle +frames followed by eight walk frames). The SD characters are used only in +exploration scenes; battle units retain their separate tactical sprites. + +## Final prompt set + +### Exploration backgrounds + +> Premium desktop historical-RPG exploration background, original +> late-Eastern-Han settlement or militia camp, strict preservation of the +> supplied road and landmark layout, elevated three-quarter orthographic +> camera, rich painterly realistic materials, warm natural light, strong +> small-character readability. No people, characters, text, UI, icons, +> watermark, logo, modern objects, or copyrighted game screen. + +The village variant required the county office, tavern, recruiting hall, +notice board, peach orchard, and north gate. The camp variant required the +north gate, command tent, scout tent, sleeping tent, supply area, training +area, and central fire. + +### Dialogue portraits + +> High-resolution realistic historical painting, late-Eastern-Han clothing, +> warm dusty Yellow Turban-era palette, square face-and-shoulders composition +> suitable for a dialogue window, natural skin and worn materials, original +> identity distinct from the named heroes. No copied face, signature armor, +> weapon, emblem, text, border, logo, watermark, or extra person. + +Role variants were a lean recruiting clerk, weathered middle-aged villager, +practical quartermaster, disciplined local commander Zou Jing, and nervous +young volunteer. + +### Exploration SD characters + +> Production game sprite turnaround for a desktop historical RPG. Create a +> completely original 2.5-head-tall SD/chibi overworld character with a crisp +> painterly cel-shaded silhouette. Exactly three equal vertical views: +> front/south, right/east, back/north. Same identity, costume, scale, and +> lighting in every view; full body and feet visible; neutral walking-ready +> stance. Transparent or plain removable background. No floor, cast shadow, +> scenery, grid, labels, text, logo, watermark, extra characters, duplicate +> limbs, cropped body, or copyrighted game screen. + +The three hero turnarounds used this project's existing Yellow Turban-era +portrait only as identity and palette reference. The recruiting clerk, +villager, quartermaster, and Zou Jing then received dedicated turnarounds +matched to their new dialogue portraits, so named characters no longer share +one generic field sprite. The young volunteer uses the original militia +infantry archetype. The west view is a mirrored side view, and restrained +idle/walk motion frames were assembled from the turnarounds for consistent +in-game scale. + +## Optimization and verification + +- Backgrounds are limited to 2 MiB each and 4 MiB combined. +- SD sheets use transparent WebP and remain under 0.6 MiB each. +- New NPC portraits were converted from generated PNG masters to high-quality + WebP, reducing the five-file total from about 11.6 MiB to about 1.3 MiB. +- Static verifiers check background dimensions and opacity, sprite-sheet + dimensions and file coverage, and prologue speaker portrait coverage. diff --git a/package.json b/package.json index d0214b9..1f75340 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ "verify:city-stays": "node scripts/verify-city-stay-data.mjs", "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", + "verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs", + "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", + "verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs", "verify:prologue-village:browser": "node scripts/verify-prologue-village-browser.mjs", "verify:camp-skins": "node scripts/verify-camp-skin-data.mjs", "verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs", diff --git a/scripts/verify-exploration-character-asset-data.mjs b/scripts/verify-exploration-character-asset-data.mjs new file mode 100644 index 0000000..1c10aff --- /dev/null +++ b/scripts/verify-exploration-character-asset-data.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const frameSize = 192; +const idleFrames = 8; +const walkFrames = 8; +const directionCount = 4; +const expectedWidth = frameSize * (idleFrames + walkFrames); +const expectedHeight = frameSize * directionCount; +const characterDirectory = join('src', 'assets', 'images', 'exploration', 'characters'); +const modulePath = join('src', 'game', 'data', 'explorationCharacterAssets.ts'); +const villageScenePath = join('src', 'game', 'scenes', 'PrologueVillageScene.ts'); +const militiaCampScenePath = join('src', 'game', 'scenes', 'PrologueMilitiaCampScene.ts'); +const expectedBaseMappings = { + 'unit-liu-bei': 'exploration-liu-bei', + 'unit-guan-yu': 'exploration-guan-yu', + 'unit-zhang-fei': 'exploration-zhang-fei', + 'unit-shu-officer': 'exploration-shu-officer', + 'unit-shu-infantry': 'exploration-shu-infantry' +}; +const expectedNamedTextureFallbacks = { + 'exploration-zhuo-recruiting-clerk': 'unit-shu-officer', + 'exploration-zhuo-villager': 'unit-shu-infantry', + 'exploration-zhuo-quartermaster': 'unit-shu-officer', + 'exploration-zou-jing': 'unit-shu-officer' +}; +const expectedSceneNpcTextureKeys = [ + { + scenePath: villageScenePath, + sceneLabel: 'prologue village', + mappingName: 'villageNpcTextureKeyById', + mappings: { + 'recruiting-clerk': 'exploration-zhuo-recruiting-clerk', + 'market-villager': 'exploration-zhuo-villager' + } + }, + { + scenePath: militiaCampScenePath, + sceneLabel: 'prologue militia camp', + mappingName: 'campNpcTextureKeyById', + mappings: { + quartermaster: 'exploration-zhuo-quartermaster', + 'zou-jing': 'exploration-zou-jing', + 'nervous-volunteer': 'exploration-shu-infantry' + } + } +]; +const expectedFiles = [ + ...Object.values(expectedBaseMappings), + ...Object.keys(expectedNamedTextureFallbacks) +] + .map((textureKey) => `${textureKey}.webp`) + .sort(); + +assert(existsSync(modulePath), `Missing exploration character asset module: ${modulePath}`); +const moduleSource = readFileSync(modulePath, 'utf8'); +assert.match( + moduleSource, + /import\.meta\.glob\(\s*['"]\.\.\/\.\.\/assets\/images\/exploration\/characters\/exploration-\*\.webp['"]/, + 'Exploration character sheets must be collected through a static import.meta.glob.' +); +assert.match( + moduleSource, + /export const explorationCharacterFrameSize = 192;/, + 'The exploration character frame size must remain 192px.' +); +assert.match( + moduleSource, + /export const explorationCharacterIdleFrameCount = 8;/, + 'Each direction must retain eight idle frames.' +); +assert.match( + moduleSource, + /export const explorationCharacterWalkFrameCount = 8;/, + 'Each direction must retain eight walk frames.' +); +assert.match( + moduleSource, + /export function ensureExplorationCharacterAnimations\(/, + 'The module must expose exploration animation registration.' +); +assert.match( + moduleSource, + /export function releaseExplorationCharacterTextures\(/, + 'The module must expose texture and animation cleanup.' +); + +assert.deepEqual( + readStringMap(moduleSource, 'explorationCharacterTextureKeyByUnitTextureKey'), + expectedBaseMappings, + 'Exploration character unit-to-sheet mappings must exactly match the five reusable base sheets.' +); +assert.deepEqual( + readStringMap(moduleSource, 'explorationCharacterNamedTextureKeyFallbacks'), + expectedNamedTextureFallbacks, + 'Exploration character named-sheet fallbacks must exactly match the four dedicated NPC sheets.' +); + +expectedSceneNpcTextureKeys.forEach(({ scenePath, sceneLabel, mappingName, mappings }) => { + assert(existsSync(scenePath), `Missing ${sceneLabel} scene module: ${scenePath}`); + assert.deepEqual( + readStringMap(readFileSync(scenePath, 'utf8'), mappingName), + mappings, + `${sceneLabel} must select the expected exploration sheet for each overridden NPC id.` + ); +}); + +assert( + existsSync(characterDirectory), + `Missing exploration character asset directory: ${characterDirectory}` +); +const deployedFiles = readdirSync(characterDirectory) + .filter((fileName) => /^exploration-.*\.webp$/i.test(fileName)) + .sort(); +assert.deepEqual( + deployedFiles, + expectedFiles, + `Expected exactly the nine mapped exploration character sheets in ${characterDirectory}.` +); + +deployedFiles.forEach((fileName) => { + const path = join(characterDirectory, fileName); + const dimensions = webpDimensions(path); + assert.equal( + dimensions.width, + expectedWidth, + `${path}: expected ${expectedWidth}px width for 16 frames per direction.` + ); + assert.equal( + dimensions.height, + expectedHeight, + `${path}: expected ${expectedHeight}px height for four direction rows.` + ); +}); + +console.log( + `Verified ${deployedFiles.length} exploration character sheets and ` + + `${expectedSceneNpcTextureKeys.reduce((count, scene) => count + Object.keys(scene.mappings).length, 0)} ` + + `scene NPC mappings: ` + + `${frameSize}px frames, ${directionCount} directions, ${idleFrames} idle + ${walkFrames} walk frames.` +); + +function readStringMap(source, mappingName) { + const escapedMappingName = mappingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const mappingBlock = source.match( + new RegExp( + `(?:export\\s+)?const\\s+${escapedMappingName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const` + ) + ); + assert(mappingBlock, `Could not read ${mappingName}.`); + + const mappings = {}; + for (const entry of mappingBlock[1].matchAll(/(?:['"]([^'"]+)['"]|([A-Za-z0-9_-]+))\s*:\s*['"]([^'"]+)['"]/g)) { + mappings[entry[1] ?? entry[2]] = entry[3]; + } + return mappings; +} + +function webpDimensions(path) { + const bytes = readFileSync(path); + assert( + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP', + `${path}: expected a WebP RIFF container.` + ); + + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); + const chunkSize = bytes.readUInt32LE(offset + 4); + const payloadOffset = offset + 8; + assert( + payloadOffset + chunkSize <= bytes.length, + `${path}: ${chunkType} chunk exceeds the WebP container.` + ); + + if (chunkType === 'VP8X') { + return { + width: bytes.readUIntLE(payloadOffset + 4, 3) + 1, + height: bytes.readUIntLE(payloadOffset + 7, 3) + 1 + }; + } + if (chunkType === 'VP8L') { + const bits = bytes.readUInt32LE(payloadOffset + 1); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >>> 14) & 0x3fff) + 1 + }; + } + if (chunkType === 'VP8 ') { + return { + width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff, + height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff + }; + } + + offset = payloadOffset + chunkSize + (chunkSize % 2); + } + + throw new Error(`${path}: could not read WebP dimensions.`); +} diff --git a/scripts/verify-prologue-dialogue-portrait-data.mjs b/scripts/verify-prologue-dialogue-portrait-data.mjs new file mode 100644 index 0000000..b3fa14d --- /dev/null +++ b/scripts/verify-prologue-dialogue-portrait-data.mjs @@ -0,0 +1,331 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import { createServer } from 'vite'; + +const portraitDirectory = join('src', 'assets', 'images', 'portraits'); +const minimumPortraitDimension = 1000; +const dialogueFields = ['dialogue', 'lockedDialogue', 'repeatDialogue']; +const excludedNonPersonSpeakers = new Set(['전황']); +const expectedPersonPortraits = new Map([ + ['유비', { portraitKey: 'liuBei', slug: 'liu-bei' }], + ['관우', { portraitKey: 'guanYu', slug: 'guan-yu' }], + ['장비', { portraitKey: 'zhangFei', slug: 'zhang-fei' }], + ['탁현 모병 관리', { + portraitKey: 'zhuoRecruitingClerk', + slug: 'zhuo-recruiting-clerk' + }], + ['탁현 주민', { portraitKey: 'zhuoVillager', slug: 'zhuo-villager' }], + ['의용군 군수관', { + portraitKey: 'zhuoQuartermaster', + slug: 'zhuo-quartermaster' + }], + ['추정', { portraitKey: 'zouJing', slug: 'zou-jing' }], + ['젊은 의병', { + portraitKey: 'zhuoYoungVolunteer', + slug: 'zhuo-young-volunteer' + }] +]); +const dialogueContexts = { + village: { + id: 'prologue-village-dialogue', + background: 'story-three-heroes' + }, + camp: { + id: 'prologue-militia-camp-dialogue', + background: 'story-militia' + } +}; +const failures = []; +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts'); + const camp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts'); + const portraits = await server.ssrLoadModule('/src/game/data/portraitAssets.ts'); + const speakerLocations = new Map(); + + collectDialogueSpeakers( + village.prologueVillageNpcDefinitions, + 'village', + speakerLocations + ); + collectDialogueSpeakers( + camp.prologueMilitiaCampNpcDefinitions, + 'camp', + speakerLocations + ); + + const observedSpeakers = new Set(speakerLocations.keys()); + check( + observedSpeakers.has('전황'), + 'the prologue dialogue data must retain at least one intentionally portrait-free "전황" line' + ); + check( + portraits.portraitKeyForSpeaker('전황') === undefined, + '"전황" is narrative context, not a person, and must not be mapped in speakerPortraitKeys' + ); + + const personSpeakers = new Set( + [...observedSpeakers].filter((speaker) => !excludedNonPersonSpeakers.has(speaker)) + ); + checkSetEquals( + personSpeakers, + new Set(expectedPersonPortraits.keys()), + 'actual prologue village/camp person speakers' + ); + + const filesBySlug = portraitFilesBySlug(); + const verifiedFiles = new Map(); + + for (const [speaker, expected] of expectedPersonPortraits) { + const mappedPortraitKey = portraits.portraitKeyForSpeaker(speaker); + check( + mappedPortraitKey === expected.portraitKey, + `${speaker}: expected speakerPortraitKeys mapping "${expected.portraitKey}", ` + + `received ${formatValue(mappedPortraitKey)}` + ); + if (mappedPortraitKey !== expected.portraitKey) { + continue; + } + + const mappedSlug = portraits.portraitSlugForKey(mappedPortraitKey); + check( + mappedSlug === expected.slug, + `${speaker}: expected portrait slug "${expected.slug}", received ${formatValue(mappedSlug)}` + ); + if (mappedSlug !== expected.slug) { + continue; + } + + const locations = speakerLocations.get(speaker); + check( + locations instanceof Set && locations.size > 0, + `${speaker}: expected at least one real village or camp dialogue line` + ); + + for (const location of locations ?? []) { + const context = dialogueContexts[location]; + const entries = portraits.portraitAssetEntriesForStoryContext( + mappedPortraitKey, + context + ); + check( + entries.length > 0, + `${speaker}: no portrait asset is routed for the ${location} dialogue context` + ); + + for (const entry of entries) { + const assetSlug = entry.textureKey.replace(/^portrait-/, ''); + const yellowTurbanSlug = `${expected.slug}-yellow-turban`; + check( + assetSlug === yellowTurbanSlug || + new RegExp(`^${yellowTurbanSlug}-v[1-9]\\d*$`).test(assetSlug), + `${speaker}: ${location} dialogue must use the Yellow Turban-era portrait, ` + + `received "${assetSlug}"` + ); + check( + Boolean(portraits.portraitAssets[assetSlug]), + `${speaker}: portrait slug "${assetSlug}" is absent from the portraitAssets glob` + ); + + const matchingFiles = filesBySlug.get(assetSlug) ?? []; + check( + matchingFiles.length > 0, + `${speaker}: missing PNG or WebP file for portrait slug "${assetSlug}"` + ); + for (const fileName of matchingFiles) { + const assetPath = join(portraitDirectory, fileName); + if (verifiedFiles.has(assetPath)) { + continue; + } + + try { + const dimensions = rasterDimensions(assetPath); + check( + dimensions.width >= minimumPortraitDimension && + dimensions.height >= minimumPortraitDimension, + `${assetPath}: expected at least ${minimumPortraitDimension}x` + + `${minimumPortraitDimension}, received ${dimensions.width}x${dimensions.height}` + ); + verifiedFiles.set(assetPath, dimensions); + } catch (error) { + failures.push(`${assetPath}: ${error.message}`); + } + } + } + } + } + + if (failures.length > 0) { + throw new Error( + `Prologue dialogue portrait verification failed with ${failures.length} issue(s):\n` + + failures.map((failure) => `- ${failure}`).join('\n') + ); + } + + const formatCounts = [...verifiedFiles.keys()].reduce((counts, path) => { + const extension = extname(path).slice(1).toUpperCase(); + counts.set(extension, (counts.get(extension) ?? 0) + 1); + return counts; + }, new Map()); + const formatSummary = [...formatCounts] + .map(([format, count]) => `${count} ${format}`) + .join(', '); + + console.log( + `Verified ${personSpeakers.size} prologue person speakers, ${verifiedFiles.size} ` + + `Yellow Turban-era portrait assets (${formatSummary}), minimum ` + + `${minimumPortraitDimension}x${minimumPortraitDimension}; "전황" remains intentionally excluded.` + ); +} finally { + await server.close(); +} + +function collectDialogueSpeakers(npcs, location, locationsBySpeaker) { + for (const npc of npcs) { + for (const field of dialogueFields) { + for (const line of npc[field] ?? []) { + if (typeof line.speaker !== 'string' || line.speaker.trim().length === 0) { + failures.push(`${location}/${npc.id}/${field}: dialogue speaker is missing`); + continue; + } + + const speaker = line.speaker.trim(); + const locations = locationsBySpeaker.get(speaker) ?? new Set(); + locations.add(location); + locationsBySpeaker.set(speaker, locations); + } + } + } +} + +function portraitFilesBySlug() { + const filesBySlug = new Map(); + if (!existsSync(portraitDirectory)) { + failures.push(`missing portrait directory: ${portraitDirectory}`); + return filesBySlug; + } + + for (const fileName of readdirSync(portraitDirectory)) { + if (!/\.(?:png|webp)$/i.test(fileName)) { + continue; + } + + const slug = fileName.replace(/\.(?:png|webp)$/i, ''); + const files = filesBySlug.get(slug) ?? []; + files.push(fileName); + filesBySlug.set(slug, files); + } + return filesBySlug; +} + +function rasterDimensions(path) { + const extension = extname(path).toLowerCase(); + if (extension === '.png') { + return pngDimensions(path); + } + if (extension === '.webp') { + return webpDimensions(path); + } + throw new Error(`unsupported portrait format "${extension}"`); +} + +function pngDimensions(path) { + const bytes = readFileSync(path); + const pngSignature = '89504e470d0a1a0a'; + if ( + bytes.length < 24 || + bytes.subarray(0, 8).toString('hex') !== pngSignature || + bytes.subarray(12, 16).toString('ascii') !== 'IHDR' + ) { + throw new Error('expected a PNG with a valid signature and IHDR chunk'); + } + + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20) + }; +} + +function webpDimensions(path) { + const bytes = readFileSync(path); + if ( + bytes.length < 20 || + bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || + bytes.subarray(8, 12).toString('ascii') !== 'WEBP' + ) { + throw new Error('expected a WebP RIFF container'); + } + + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); + const chunkSize = bytes.readUInt32LE(offset + 4); + const payloadOffset = offset + 8; + const paddedChunkEnd = payloadOffset + chunkSize + (chunkSize % 2); + if (paddedChunkEnd > bytes.length) { + throw new Error(`"${chunkType}" chunk exceeds the WebP container`); + } + + if (chunkType === 'VP8X') { + if (chunkSize < 10) { + throw new Error('VP8X chunk is too short to contain dimensions'); + } + return { + width: bytes.readUIntLE(payloadOffset + 4, 3) + 1, + height: bytes.readUIntLE(payloadOffset + 7, 3) + 1 + }; + } + if (chunkType === 'VP8L') { + if (chunkSize < 5 || bytes[payloadOffset] !== 0x2f) { + throw new Error('invalid lossless VP8L payload'); + } + const bits = bytes.readUInt32LE(payloadOffset + 1); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >>> 14) & 0x3fff) + 1 + }; + } + if (chunkType === 'VP8 ') { + if ( + chunkSize < 10 || + bytes[payloadOffset + 3] !== 0x9d || + bytes[payloadOffset + 4] !== 0x01 || + bytes[payloadOffset + 5] !== 0x2a + ) { + throw new Error('invalid lossy VP8 payload'); + } + return { + width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff, + height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff + }; + } + + offset = paddedChunkEnd; + } + + throw new Error('could not read WebP dimensions'); +} + +function checkSetEquals(actual, expected, label) { + const missing = [...expected].filter((value) => !actual.has(value)); + const unexpected = [...actual].filter((value) => !expected.has(value)); + check( + missing.length === 0 && unexpected.length === 0, + `${label}: missing [${missing.join(', ')}], unexpected [${unexpected.join(', ')}]` + ); +} + +function check(condition, message) { + if (!condition) { + failures.push(message); + } +} + +function formatValue(value) { + return value === undefined ? 'undefined' : `"${value}"`; +} diff --git a/scripts/verify-prologue-exploration-asset-data.mjs b/scripts/verify-prologue-exploration-asset-data.mjs new file mode 100644 index 0000000..91ea6a2 --- /dev/null +++ b/scripts/verify-prologue-exploration-asset-data.mjs @@ -0,0 +1,223 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration'); +const expectedAssets = [ + 'prologue-village.webp', + 'prologue-militia-camp.webp' +]; +const expectedWidth = 1920; +const expectedHeight = 1080; +const maximumAssetBytes = 2 * 1024 * 1024; +const maximumTotalBytes = 4 * 1024 * 1024; +const rasterAssetPattern = /\.(?:avif|jpe?g|png|webp)$/i; +const errors = []; +let totalBytes = 0; + +validateDirectoryCoverage(errors); + +for (const fileName of expectedAssets) { + const path = join(explorationAssetDirectory, fileName); + const inspection = inspectWebpAsset(errors, path, fileName); + if (!inspection) { + continue; + } + + totalBytes += inspection.size; + + if (inspection.size > maximumAssetBytes) { + errors.push( + `${path}: ${formatBytes(inspection.size)} exceeds the per-background limit of ` + + `${formatBytes(maximumAssetBytes)}` + ); + } + if (inspection.width !== expectedWidth || inspection.height !== expectedHeight) { + errors.push( + `${path}: expected ${expectedWidth}x${expectedHeight}, got ` + + `${inspection.width}x${inspection.height}` + ); + } + if (inspection.hasAlpha) { + errors.push(`${path}: exploration backgrounds must be opaque WebP images without alpha`); + } + if (inspection.animated) { + errors.push(`${path}: exploration backgrounds must be static WebP images`); + } +} + +if (totalBytes > maximumTotalBytes) { + errors.push( + `${explorationAssetDirectory}: combined background size ${formatBytes(totalBytes)} exceeds ` + + `${formatBytes(maximumTotalBytes)}` + ); +} + +if (errors.length > 0) { + console.error(`Prologue exploration asset verification failed with ${errors.length} issue(s):`); + errors.forEach((error) => console.error(`- ${error}`)); + process.exitCode = 1; +} else { + console.log( + `Verified ${expectedAssets.length} opaque ${expectedWidth}x${expectedHeight} exploration ` + + `WebP backgrounds (${formatBytes(totalBytes)} total; ${formatBytes(maximumAssetBytes)} ` + + `per-file / ${formatBytes(maximumTotalBytes)} combined limits).` + ); +} + +function validateDirectoryCoverage(validationErrors) { + let entries; + try { + entries = readdirSync(explorationAssetDirectory, { withFileTypes: true }); + } catch (error) { + validationErrors.push( + `${explorationAssetDirectory}: cannot read exploration asset directory: ${error.message}` + ); + return; + } + + const actualRasterFiles = entries + .filter((entry) => entry.isFile() && rasterAssetPattern.test(entry.name)) + .map((entry) => entry.name) + .sort(); + const expectedRasterFiles = [...expectedAssets].sort(); + + for (const fileName of expectedRasterFiles) { + if (!actualRasterFiles.includes(fileName)) { + validationErrors.push(`${explorationAssetDirectory}: missing required asset "${fileName}"`); + } + } + for (const fileName of actualRasterFiles) { + if (!expectedRasterFiles.includes(fileName)) { + validationErrors.push( + `${explorationAssetDirectory}: unexpected raster asset "${fileName}"; ` + + `only the two optimized runtime WebP backgrounds should be tracked here` + ); + } + } +} + +function inspectWebpAsset(validationErrors, path, label) { + let bytes; + let stats; + try { + bytes = readFileSync(path); + stats = statSync(path); + } catch (error) { + validationErrors.push(`${path}: cannot read ${label}: ${error.message}`); + return undefined; + } + + if (!stats.isFile()) { + validationErrors.push(`${path}: expected a regular WebP file`); + return undefined; + } + if (bytes.length < 20) { + validationErrors.push(`${path}: file is too small to be a valid WebP image`); + return undefined; + } + if ( + bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || + bytes.subarray(8, 12).toString('ascii') !== 'WEBP' + ) { + validationErrors.push(`${path}: missing the WebP RIFF header`); + return undefined; + } + + const declaredLength = bytes.readUInt32LE(4) + 8; + if (declaredLength !== bytes.length) { + validationErrors.push( + `${path}: RIFF container declares ${declaredLength} bytes, file contains ${bytes.length}` + ); + return undefined; + } + + let dimensions; + let hasAlpha = false; + let animated = false; + let foundImagePayload = false; + let offset = 12; + + while (offset + 8 <= bytes.length) { + const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); + const chunkSize = bytes.readUInt32LE(offset + 4); + const dataOffset = offset + 8; + const paddedChunkEnd = dataOffset + chunkSize + (chunkSize % 2); + + if (dataOffset + chunkSize > bytes.length || paddedChunkEnd > bytes.length) { + validationErrors.push(`${path}: "${chunkType}" chunk exceeds the RIFF container`); + return undefined; + } + + if (chunkType === 'VP8X') { + if (chunkSize < 10) { + validationErrors.push(`${path}: VP8X chunk is too short`); + return undefined; + } + const featureFlags = bytes[dataOffset]; + dimensions = { + width: readUInt24LE(bytes, dataOffset + 4) + 1, + height: readUInt24LE(bytes, dataOffset + 7) + 1 + }; + hasAlpha ||= (featureFlags & 0x10) !== 0; + animated ||= (featureFlags & 0x02) !== 0; + } else if (chunkType === 'VP8 ') { + if ( + chunkSize < 10 || + bytes[dataOffset + 3] !== 0x9d || + bytes[dataOffset + 4] !== 0x01 || + bytes[dataOffset + 5] !== 0x2a + ) { + validationErrors.push(`${path}: invalid lossy VP8 image payload`); + return undefined; + } + dimensions ??= { + width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff, + height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff + }; + foundImagePayload = true; + } else if (chunkType === 'VP8L') { + if (chunkSize < 5 || bytes[dataOffset] !== 0x2f) { + validationErrors.push(`${path}: invalid lossless VP8L image payload`); + return undefined; + } + const losslessHeader = bytes.readUInt32LE(dataOffset + 1); + dimensions ??= { + width: (losslessHeader & 0x3fff) + 1, + height: ((losslessHeader >>> 14) & 0x3fff) + 1 + }; + hasAlpha ||= (losslessHeader & 0x10000000) !== 0; + foundImagePayload = true; + } else if (chunkType === 'ALPH') { + hasAlpha = true; + } else if (chunkType === 'ANIM' || chunkType === 'ANMF') { + animated = true; + } + + offset = paddedChunkEnd; + } + + if (offset !== bytes.length) { + validationErrors.push(`${path}: RIFF container has an incomplete trailing chunk header`); + return undefined; + } + if (!dimensions || !foundImagePayload) { + validationErrors.push(`${path}: WebP dimensions or image payload could not be read`); + return undefined; + } + + return { + size: stats.size, + width: dimensions.width, + height: dimensions.height, + hasAlpha, + animated + }; +} + +function readUInt24LE(bytes, offset) { + return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); +} + +function formatBytes(bytes) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} diff --git a/scripts/verify-prologue-village-browser.mjs b/scripts/verify-prologue-village-browser.mjs index 84a9a41..41f2c10 100644 --- a/scripts/verify-prologue-village-browser.mjs +++ b/scripts/verify-prologue-village-browser.mjs @@ -58,7 +58,25 @@ try { await page.waitForTimeout(380); let village = await readVillage(page); assert.equal(village.campaignStep, 'prologue-town'); + assertExplorationBackground(village.background, 'prologue-village-background'); assert.equal(village.requiredTexturesReady, true); + assert.deepEqual( + village.requiredTextures.map(({ key, ready }) => ({ key, ready })), + [ + { key: 'exploration-liu-bei', ready: true }, + { key: 'exploration-guan-yu', ready: true }, + { key: 'exploration-zhang-fei', ready: true }, + { key: 'exploration-zhuo-recruiting-clerk', ready: true }, + { key: 'exploration-zhuo-villager', ready: true } + ] + ); + assert.equal(village.player.textureKey, 'exploration-liu-bei'); + assertNpcTextureKeys(village.npcs, { + 'zhang-fei': 'exploration-zhang-fei', + 'guan-yu': 'exploration-guan-yu', + 'recruiting-clerk': 'exploration-zhuo-recruiting-clerk', + 'market-villager': 'exploration-zhuo-villager' + }, 'prologue village'); assert.equal(village.viewport.width, desktopBrowserViewport.width); assert.equal(village.viewport.height, desktopBrowserViewport.height); assert.equal(village.movement.speed, 300); @@ -68,6 +86,8 @@ try { assert.equal(village.completedObjectiveIds.length, 0); assert.equal(village.exitUnlocked, false); assert.equal(village.dialogue.active, true, 'The first visit should open one short Liu Bei orientation line.'); + assert.equal(village.dialogue.portraitVisible, true); + assert.equal(village.dialogue.portraitTextureKey, 'portrait-liu-bei-yellow-turban'); assertBoundsInsideViewport(village.player.bounds, 'initial player'); village.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `NPC ${npc.id}`)); @@ -100,6 +120,11 @@ try { await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu' )); + assert.equal( + (await readVillage(page)).dialogue.portraitVisible, + false, + 'Narrative context should not impersonate a character portrait.' + ); assert.equal( (await readVillage(page)).dialogue.totalLines, 1, @@ -114,6 +139,14 @@ try { await teleportTo(page, 'zhang-fei'); await page.keyboard.press('e'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'zhang-fei'); + assert.equal((await readVillage(page)).dialogue.portraitVisible, false); + await page.mouse.click(960, 850); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.dialogue?.speaker === '장비' + )); + const zhangDialogue = await readVillage(page); + assert.equal(zhangDialogue.dialogue.portraitVisible, true); + assert.equal(zhangDialogue.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban'); await captureStableScreenshot(page, 'dist/verification-prologue-village-active-dialogue.png'); const dialoguePlayerBefore = (await readVillage(page)).player; await page.keyboard.down('ArrowRight'); @@ -129,6 +162,18 @@ try { village = await readVillage(page); assert(village.completedObjectiveIds.includes('meet-zhang-fei')); assert.equal(village.exitUnlocked, false); + const walkingZhangFei = villageNpc(village, 'zhang-fei'); + assert.equal(walkingZhangFei.moving, true, 'Zhang Fei should visibly walk toward the tavern after joining.'); + assert.match(walkingZhangFei.animationKey, /exploration-zhang-fei-walk-/); + assert( + walkingZhangFei.x > 360 && walkingZhangFei.x < 1000, + `Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}` + ); + await waitForVillageNpcPosition(page, 'zhang-fei', { x: 1000, y: 540 }); + village = await readVillage(page); + const tavernZhangFei = villageNpc(village, 'zhang-fei'); + assert.equal(tavernZhangFei.moving, false); + assert.equal(tavernZhangFei.animationKey, 'exploration-zhang-fei-idle-east'); await captureStableScreenshot(page, 'dist/verification-prologue-village-dialogue.png'); await teleportTo(page, 'make-oath'); @@ -149,12 +194,17 @@ try { village = await readVillage(page); assert(village.completedObjectiveIds.includes('meet-zhang-fei'), 'A completed NPC goal must survive reload and Continue.'); assert.equal(village.dialogue.active, false, 'The one-time orientation line must not replay on Continue.'); + assertNpcPosition(village, 'zhang-fei', { x: 1000, y: 540 }, 'restored Zhang Fei'); await teleportTo(page, 'recruiting-clerk'); await page.keyboard.press('e'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk' )); + assert.equal( + (await readVillage(page)).dialogue.portraitTextureKey, + 'portrait-zhuo-recruiting-clerk-yellow-turban' + ); assert.equal( (await readVillage(page)).dialogue.totalLines, 1, @@ -166,14 +216,38 @@ try { 'A locked recruiting interaction must not complete its objective.' ); - for (const npcId of ['guan-yu', 'recruiting-clerk']) { - await teleportTo(page, npcId); - await page.keyboard.press('e'); - await page.waitForFunction((expectedNpcId) => ( - window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === expectedNpcId - ), npcId); - await advanceDialogueUntilClosed(page); - } + await teleportTo(page, 'guan-yu'); + await page.keyboard.press('e'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu' + )); + await advanceDialogueUntilClosed(page); + village = await readVillage(page); + const walkingGuanYu = villageNpc(village, 'guan-yu'); + const walkingZhangToRecruitment = villageNpc(village, 'zhang-fei'); + assert.equal(walkingGuanYu.moving, true, 'Guan Yu should walk toward the recruiting office after joining.'); + assert.equal(walkingZhangToRecruitment.moving, true, 'Zhang Fei should accompany Guan Yu toward registration.'); + assert.match(walkingGuanYu.animationKey, /exploration-guan-yu-walk-/); + assert.match(walkingZhangToRecruitment.animationKey, /exploration-zhang-fei-walk-/); + assert( + walkingGuanYu.x < 1110 && walkingGuanYu.x > 850, + `Guan Yu should move progressively instead of teleporting: ${JSON.stringify(walkingGuanYu)}` + ); + assert( + walkingZhangToRecruitment.y > 540 && walkingZhangToRecruitment.y < 805, + `Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}` + ); + await waitForVillageNarrativeMovement(page); + village = await readVillage(page); + assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu'); + assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei'); + + await teleportTo(page, 'recruiting-clerk'); + await page.keyboard.press('e'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk' + )); + await advanceDialogueUntilClosed(page); village = await readVillage(page); assert.deepEqual( @@ -182,12 +256,32 @@ try { ); assert.equal(village.exitUnlocked, true); assert.equal(village.oath.visible, true); + const walkingGuanToOath = villageNpc(village, 'guan-yu'); + const walkingZhangToOath = villageNpc(village, 'zhang-fei'); + assert.equal(walkingGuanToOath.moving, true, 'Guan Yu should walk to the oath grove after registration.'); + assert.equal(walkingZhangToOath.moving, true, 'Zhang Fei should walk to the oath grove after registration.'); + assert.match(walkingGuanToOath.animationKey, /exploration-guan-yu-walk-/); + assert.match(walkingZhangToOath.animationKey, /exploration-zhang-fei-walk-/); + await waitForVillageNarrativeMovement(page); + village = await readVillage(page); + assertNpcPosition(village, 'guan-yu', { x: 676, y: 437 }, 'oath Guan Yu'); + assertNpcPosition(village, 'zhang-fei', { x: 884, y: 437 }, 'oath Zhang Fei'); await captureStableScreenshot(page, 'dist/verification-prologue-village-ready.png'); await teleportTo(page, 'make-oath'); + const oathPlayerBefore = (await readVillage(page)).player; await page.keyboard.press('e'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'make-oath'); - assert.equal((await readVillage(page)).dialogue.totalLines, 4); + const oathDialogue = await readVillage(page); + assert.equal(oathDialogue.dialogue.totalLines, 4); + assert( + Math.abs(oathDialogue.player.x - oathPlayerBefore.x) < 1 && + Math.abs(oathDialogue.player.y - oathPlayerBefore.y) < 1, + `Starting the oath should not teleport Liu Bei: ${JSON.stringify({ + before: oathPlayerBefore, + after: oathDialogue.player + })}` + ); await advanceDialogueUntilClosed(page); await waitForStoryReady(page); @@ -212,7 +306,27 @@ try { let militiaCamp = await readMilitiaCamp(page); assert.equal(militiaCamp.campaignStep, 'prologue-camp'); + assertExplorationBackground(militiaCamp.background, 'prologue-militia-camp-background'); assert.equal(militiaCamp.requiredTexturesReady, true); + assert.deepEqual( + militiaCamp.requiredTextures.map(({ key, ready }) => ({ key, ready })), + [ + { key: 'exploration-liu-bei', ready: true }, + { key: 'exploration-guan-yu', ready: true }, + { key: 'exploration-zhang-fei', ready: true }, + { key: 'exploration-zhuo-quartermaster', ready: true }, + { key: 'exploration-zou-jing', ready: true }, + { key: 'exploration-shu-infantry', ready: true } + ] + ); + assert.equal(militiaCamp.player.textureKey, 'exploration-liu-bei'); + assertNpcTextureKeys(militiaCamp.npcs, { + 'guan-yu': 'exploration-guan-yu', + 'zhang-fei': 'exploration-zhang-fei', + quartermaster: 'exploration-zhuo-quartermaster', + 'zou-jing': 'exploration-zou-jing', + 'nervous-volunteer': 'exploration-shu-infantry' + }, 'prologue militia camp'); assert.equal(militiaCamp.viewport.width, desktopBrowserViewport.width); assert.equal(militiaCamp.viewport.height, desktopBrowserViewport.height); assert.equal(militiaCamp.movement.speed, 300); @@ -226,6 +340,8 @@ try { true, 'The first camp visit should explain why Liu Bei must inspect the camp.' ); + assert.equal(militiaCamp.dialogue.portraitVisible, true); + assert.equal(militiaCamp.dialogue.portraitTextureKey, 'portrait-liu-bei-yellow-turban'); assertBoundsInsideViewport(militiaCamp.player.bounds, 'initial militia camp player'); assertBoundsInsideViewport(militiaCamp.dialogue.bounds, 'initial militia camp dialogue'); militiaCamp.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `camp NPC ${npc.id}`)); @@ -269,6 +385,10 @@ try { await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'quartermaster' )); + assert.equal( + (await readMilitiaCamp(page)).dialogue.portraitTextureKey, + 'portrait-zhuo-quartermaster-yellow-turban' + ); const campDialoguePlayerBefore = (await readMilitiaCamp(page)).player; assertBoundsInsideViewport( (await readMilitiaCamp(page)).dialogue.bounds, @@ -334,6 +454,7 @@ try { )); const commandCamp = await readMilitiaCamp(page); assert.equal(commandCamp.dialogue.totalLines, 5); + assert.equal(commandCamp.dialogue.portraitTextureKey, 'portrait-zou-jing-yellow-turban'); assert.deepEqual( commandCamp.npcs .filter((npc) => ['guan-yu', 'zhang-fei'].includes(npc.id)) @@ -526,6 +647,41 @@ async function interactWithMilitiaCampNpc(page, npcId) { await advanceMilitiaCampDialogueUntilClosed(page); } +function villageNpc(village, npcId) { + const npc = village?.npcs?.find((entry) => entry.id === npcId); + assert(npc, `Expected village NPC ${npcId}: ${JSON.stringify(village?.npcs)}`); + return npc; +} + +function assertNpcPosition(village, npcId, expected, label) { + const npc = villageNpc(village, npcId); + assert( + Math.abs(npc.x - expected.x) <= 1 && Math.abs(npc.y - expected.y) <= 1, + `${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(npc)}` + ); + assert.equal(npc.moving, false, `${label} should be idle after reaching the narrative position.`); +} + +async function waitForVillageNpcPosition(page, npcId, expected) { + await page.waitForFunction(({ id, x, y }) => { + const village = window.__HEROS_DEBUG__?.village?.(); + const npc = village?.npcs?.find((entry) => entry.id === id); + return Boolean( + npc && + npc.moving === false && + Math.abs(npc.x - x) <= 1 && + Math.abs(npc.y - y) <= 1 + ); + }, { id: npcId, ...expected }, { timeout: 6000 }); +} + +async function waitForVillageNarrativeMovement(page) { + await page.waitForFunction(() => { + const movement = window.__HEROS_DEBUG__?.village?.()?.narrativeMovement; + return movement && movement.movingNpcIds.length === 0 && movement.oathGatherPending === false; + }, undefined, { timeout: 6000 }); +} + async function advanceDialogueUntilClosed(page) { for (let attempt = 0; attempt < 12; attempt += 1) { const active = (await readVillage(page))?.dialogue?.active; @@ -634,6 +790,37 @@ function assertBoundsInsideViewport(bounds, label) { ); } +function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) { + assert(Array.isArray(npcs), `${sceneLabel}: expected an NPC debug-state array.`); + const actualMappings = npcs + .map(({ id, textureKey }) => ({ id, textureKey })) + .sort((left, right) => left.id.localeCompare(right.id)); + const expectedMappings = Object.entries(expectedTextureKeyByNpcId) + .map(([id, textureKey]) => ({ id, textureKey })) + .sort((left, right) => left.id.localeCompare(right.id)); + assert.deepEqual( + actualMappings, + expectedMappings, + `${sceneLabel}: every named NPC must render with its assigned exploration texture.` + ); +} + +function assertExplorationBackground(background, expectedKey) { + assert(background, `Expected ${expectedKey} debug state.`); + assert.equal(background.key, expectedKey); + assert.equal(background.ready, true); + assert.equal(background.fallback, false); + assert.equal(background.sourceWidth, desktopBrowserViewport.width); + assert.equal(background.sourceHeight, desktopBrowserViewport.height); + assert.equal(background.objectCount, 1); + assert.deepEqual(background.bounds, { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }); +} + async function captureStableScreenshot(page, path) { await page.waitForTimeout(220); const loopSlept = await page.evaluate(() => { diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 0994c95..4ab3f17 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -23,6 +23,9 @@ const checks = [ 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-city-stay-data.mjs', 'scripts/verify-prologue-village-data.mjs', + 'scripts/verify-prologue-exploration-asset-data.mjs', + 'scripts/verify-exploration-character-asset-data.mjs', + 'scripts/verify-prologue-dialogue-portrait-data.mjs', 'scripts/verify-victory-reward-acknowledgements.mjs', 'scripts/verify-camp-skin-data.mjs', 'scripts/verify-camp-soundscape-data.mjs', diff --git a/scripts/verify-story-image-asset-data.mjs b/scripts/verify-story-image-asset-data.mjs index 0fe82a3..af9016c 100644 --- a/scripts/verify-story-image-asset-data.mjs +++ b/scripts/verify-story-image-asset-data.mjs @@ -117,11 +117,22 @@ function validateStoryManifestKeys(errors, storyBackgroundAssets, sourceKeys, al function validatePortraitImages(errors, portraitAssets) { const slugs = new Set(); - for (const fileName of imageFiles(portraitDir)) { - const path = join(portraitDir, fileName); - validatePngFile(errors, path, expectedPortraitSize, 'portrait'); + const files = assetFiles(portraitDir, /\.(?:png|webp)$/i); + const unexpectedFiles = assetFiles(portraitDir, /./) + .filter((fileName) => !/\.(?:png|webp)$/i.test(fileName)); + unexpectedFiles.forEach((fileName) => { + errors.push(`${join(portraitDir, fileName)}: portraits must use PNG or WebP`); + }); - const slug = fileName.replace(/\.png$/i, ''); + for (const fileName of files) { + const path = join(portraitDir, fileName); + if (/\.webp$/i.test(fileName)) { + validateWebpFile(errors, path, expectedPortraitSize, 'portrait'); + } else { + validatePngFile(errors, path, expectedPortraitSize, 'portrait'); + } + + const slug = fileName.replace(/\.(?:png|webp)$/i, ''); slugs.add(slug); if (!portraitAssets[slug]) { errors.push(`${path}: expected portraitAssets slug "${slug}"`); diff --git a/src/assets/images/exploration/characters/exploration-guan-yu.webp b/src/assets/images/exploration/characters/exploration-guan-yu.webp new file mode 100644 index 0000000..79330b0 Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-guan-yu.webp differ diff --git a/src/assets/images/exploration/characters/exploration-liu-bei.webp b/src/assets/images/exploration/characters/exploration-liu-bei.webp new file mode 100644 index 0000000..8b0c8df Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-liu-bei.webp differ diff --git a/src/assets/images/exploration/characters/exploration-shu-infantry.webp b/src/assets/images/exploration/characters/exploration-shu-infantry.webp new file mode 100644 index 0000000..b3f0952 Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-shu-infantry.webp differ diff --git a/src/assets/images/exploration/characters/exploration-shu-officer.webp b/src/assets/images/exploration/characters/exploration-shu-officer.webp new file mode 100644 index 0000000..367254a Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-shu-officer.webp differ diff --git a/src/assets/images/exploration/characters/exploration-zhang-fei.webp b/src/assets/images/exploration/characters/exploration-zhang-fei.webp new file mode 100644 index 0000000..507e41f Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-zhang-fei.webp differ diff --git a/src/assets/images/exploration/characters/exploration-zhuo-quartermaster.webp b/src/assets/images/exploration/characters/exploration-zhuo-quartermaster.webp new file mode 100644 index 0000000..f5a839f Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-zhuo-quartermaster.webp differ diff --git a/src/assets/images/exploration/characters/exploration-zhuo-recruiting-clerk.webp b/src/assets/images/exploration/characters/exploration-zhuo-recruiting-clerk.webp new file mode 100644 index 0000000..39e8750 Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-zhuo-recruiting-clerk.webp differ diff --git a/src/assets/images/exploration/characters/exploration-zhuo-villager.webp b/src/assets/images/exploration/characters/exploration-zhuo-villager.webp new file mode 100644 index 0000000..47d4733 Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-zhuo-villager.webp differ diff --git a/src/assets/images/exploration/characters/exploration-zou-jing.webp b/src/assets/images/exploration/characters/exploration-zou-jing.webp new file mode 100644 index 0000000..090bdeb Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-zou-jing.webp differ diff --git a/src/assets/images/exploration/prologue-militia-camp.webp b/src/assets/images/exploration/prologue-militia-camp.webp new file mode 100644 index 0000000..ff29e4e Binary files /dev/null and b/src/assets/images/exploration/prologue-militia-camp.webp differ diff --git a/src/assets/images/exploration/prologue-village.webp b/src/assets/images/exploration/prologue-village.webp new file mode 100644 index 0000000..b690563 Binary files /dev/null and b/src/assets/images/exploration/prologue-village.webp differ diff --git a/src/assets/images/portraits/zhuo-quartermaster-yellow-turban.webp b/src/assets/images/portraits/zhuo-quartermaster-yellow-turban.webp new file mode 100644 index 0000000..67d0eab Binary files /dev/null and b/src/assets/images/portraits/zhuo-quartermaster-yellow-turban.webp differ diff --git a/src/assets/images/portraits/zhuo-recruiting-clerk-yellow-turban.webp b/src/assets/images/portraits/zhuo-recruiting-clerk-yellow-turban.webp new file mode 100644 index 0000000..dfbcf67 Binary files /dev/null and b/src/assets/images/portraits/zhuo-recruiting-clerk-yellow-turban.webp differ diff --git a/src/assets/images/portraits/zhuo-villager-yellow-turban.webp b/src/assets/images/portraits/zhuo-villager-yellow-turban.webp new file mode 100644 index 0000000..13f4062 Binary files /dev/null and b/src/assets/images/portraits/zhuo-villager-yellow-turban.webp differ diff --git a/src/assets/images/portraits/zhuo-young-volunteer-yellow-turban.webp b/src/assets/images/portraits/zhuo-young-volunteer-yellow-turban.webp new file mode 100644 index 0000000..df6e8a1 Binary files /dev/null and b/src/assets/images/portraits/zhuo-young-volunteer-yellow-turban.webp differ diff --git a/src/assets/images/portraits/zou-jing-yellow-turban.webp b/src/assets/images/portraits/zou-jing-yellow-turban.webp new file mode 100644 index 0000000..4bd6798 Binary files /dev/null and b/src/assets/images/portraits/zou-jing-yellow-turban.webp differ diff --git a/src/game/data/explorationCharacterAssets.ts b/src/game/data/explorationCharacterAssets.ts new file mode 100644 index 0000000..ae28ddb --- /dev/null +++ b/src/game/data/explorationCharacterAssets.ts @@ -0,0 +1,296 @@ +import type Phaser from 'phaser'; +import { releaseTextureSource } from '../render/loaderLifecycle'; + +export type ExplorationCharacterDirection = 'south' | 'east' | 'north' | 'west'; +export type ExplorationCharacterMotion = 'idle' | 'walk'; + +export const explorationCharacterFrameSize = 192; +export const explorationCharacterIdleFrameCount = 8; +export const explorationCharacterWalkFrameCount = 8; +export const explorationCharacterFramesPerDirection = + explorationCharacterIdleFrameCount + explorationCharacterWalkFrameCount; + +export const explorationCharacterTextureKeyByUnitTextureKey = { + 'unit-liu-bei': 'exploration-liu-bei', + 'unit-guan-yu': 'exploration-guan-yu', + 'unit-zhang-fei': 'exploration-zhang-fei', + 'unit-shu-officer': 'exploration-shu-officer', + 'unit-shu-infantry': 'exploration-shu-infantry' +} as const; + +export const explorationCharacterNamedTextureKeyFallbacks = { + 'exploration-zhuo-recruiting-clerk': 'unit-shu-officer', + 'exploration-zhuo-villager': 'unit-shu-infantry', + 'exploration-zhuo-quartermaster': 'unit-shu-officer', + 'exploration-zou-jing': 'unit-shu-officer' +} as const; + +export type ExplorationCharacterUnitTextureKey = + keyof typeof explorationCharacterTextureKeyByUnitTextureKey; +export type ExplorationCharacterBaseTextureKey = + (typeof explorationCharacterTextureKeyByUnitTextureKey)[ExplorationCharacterUnitTextureKey]; +export type ExplorationCharacterNamedTextureKey = + keyof typeof explorationCharacterNamedTextureKeyFallbacks; +export type ExplorationCharacterTextureKey = + | ExplorationCharacterBaseTextureKey + | ExplorationCharacterNamedTextureKey; + +export type ExplorationCharacterAssetInfo = { + unitTextureKey: ExplorationCharacterUnitTextureKey; + key: ExplorationCharacterTextureKey; + url: string; + frameSize: number; + frameWidth: number; + frameHeight: number; + format: 'webp'; +}; + +export const explorationCharacterUnitTextureKeys = Object.freeze( + Object.keys( + explorationCharacterTextureKeyByUnitTextureKey + ) as ExplorationCharacterUnitTextureKey[] +); + +export const explorationCharacterTextureKeys = Object.freeze( + [ + ...Object.values(explorationCharacterTextureKeyByUnitTextureKey), + ...Object.keys(explorationCharacterNamedTextureKeyFallbacks) + ] as ExplorationCharacterTextureKey[] +); + +const explorationCharacterDirections = [ + 'south', + 'east', + 'north', + 'west' +] as const satisfies readonly ExplorationCharacterDirection[]; +const explorationCharacterSheetRows: Record = { + south: 0, + east: 1, + north: 2, + west: 3 +}; +const explorationCharacterIdleFrameRate = 6; +const explorationCharacterWalkFrameRate = 10; + +const explorationCharacterSheetModules = import.meta.glob( + '../../assets/images/exploration/characters/exploration-*.webp', + { + eager: true, + import: 'default' + } +) as Record; + +function textureKeyFromPath(path: string) { + const fileName = path.split('/').pop() ?? path; + return fileName.replace(/\.webp$/i, ''); +} + +const explorationCharacterSheetUrlsByKey = new Map( + Object.entries(explorationCharacterSheetModules).map(([path, url]) => [ + textureKeyFromPath(path), + url + ]) +); +const explorationCharacterTextureKeySet = new Set( + explorationCharacterTextureKeys +); +const unitTextureKeyByExplorationCharacterTextureKey = new Map< + ExplorationCharacterBaseTextureKey, + ExplorationCharacterUnitTextureKey +>( + Object.entries(explorationCharacterTextureKeyByUnitTextureKey).map( + ([unitTextureKey, textureKey]) => [ + textureKey, + unitTextureKey as ExplorationCharacterUnitTextureKey + ] + ) +); + +export function explorationCharacterTextureKeyForUnitTextureKey( + unitTextureKey: string +): ExplorationCharacterBaseTextureKey | undefined { + if (!(unitTextureKey in explorationCharacterTextureKeyByUnitTextureKey)) { + return undefined; + } + return explorationCharacterTextureKeyByUnitTextureKey[ + unitTextureKey as ExplorationCharacterUnitTextureKey + ]; +} + +export function explorationCharacterAssetInfo( + textureKeyOrUnitTextureKey: string +): ExplorationCharacterAssetInfo | undefined { + const key = explorationCharacterTextureKeyForSourceKey(textureKeyOrUnitTextureKey); + const url = key ? explorationCharacterSheetUrlsByKey.get(key) : undefined; + const unitTextureKey = key ? fallbackUnitTextureKeyForExplorationTextureKey(key) : undefined; + if (!key || !url || !unitTextureKey) { + return undefined; + } + return { + unitTextureKey, + key, + url, + frameSize: explorationCharacterFrameSize, + frameWidth: explorationCharacterFrameSize, + frameHeight: explorationCharacterFrameSize, + format: 'webp' + }; +} + +export function explorationCharacterAssetInfos( + textureKeysOrUnitTextureKeys: Iterable = explorationCharacterUnitTextureKeys +) { + const assets: ExplorationCharacterAssetInfo[] = []; + const seenTextureKeys = new Set(); + Array.from(new Set(textureKeysOrUnitTextureKeys)).forEach((textureKeyOrUnitTextureKey) => { + const asset = explorationCharacterAssetInfo(textureKeyOrUnitTextureKey); + if (!asset || seenTextureKeys.has(asset.key)) { + return; + } + seenTextureKeys.add(asset.key); + assets.push(asset); + }); + return assets; +} + +export function hasExplorationCharacterAsset(textureKeyOrUnitTextureKey: string) { + return explorationCharacterAssetInfo(textureKeyOrUnitTextureKey) !== undefined; +} + +function explorationCharacterTextureKeyForSourceKey( + textureKeyOrUnitTextureKey: string +): ExplorationCharacterTextureKey | undefined { + return explorationCharacterTextureKeyForUnitTextureKey(textureKeyOrUnitTextureKey) ?? + ( + explorationCharacterTextureKeySet.has( + textureKeyOrUnitTextureKey as ExplorationCharacterTextureKey + ) + ? textureKeyOrUnitTextureKey as ExplorationCharacterTextureKey + : undefined + ); +} + +function fallbackUnitTextureKeyForExplorationTextureKey( + textureKey: ExplorationCharacterTextureKey +): ExplorationCharacterUnitTextureKey | undefined { + const baseUnitTextureKey = unitTextureKeyByExplorationCharacterTextureKey.get( + textureKey as ExplorationCharacterBaseTextureKey + ); + if (baseUnitTextureKey) { + return baseUnitTextureKey; + } + if (textureKey in explorationCharacterNamedTextureKeyFallbacks) { + return explorationCharacterNamedTextureKeyFallbacks[ + textureKey as ExplorationCharacterNamedTextureKey + ]; + } + return undefined; +} + +export function explorationCharacterAnimationKey( + textureKey: ExplorationCharacterTextureKey, + motion: ExplorationCharacterMotion, + direction: ExplorationCharacterDirection +) { + return `${textureKey}-${motion}-${direction}`; +} + +export function ensureExplorationCharacterAnimations( + scene: Phaser.Scene, + textureKeysOrUnitTextureKeys: Iterable +) { + explorationCharacterAssetInfos(textureKeysOrUnitTextureKeys).forEach(({ key }) => { + if (!scene.textures.exists(key)) { + return; + } + + explorationCharacterDirections.forEach((direction) => { + const rowStart = + explorationCharacterSheetRows[direction] * explorationCharacterFramesPerDirection; + const idleFrames = Array.from( + { length: explorationCharacterIdleFrameCount }, + (_, frameIndex) => ({ + key, + frame: rowStart + frameIndex + }) + ); + const walkFrames = Array.from( + { length: explorationCharacterWalkFrameCount }, + (_, frameIndex) => ({ + key, + frame: rowStart + explorationCharacterIdleFrameCount + frameIndex + }) + ); + const idleAnimationKey = explorationCharacterAnimationKey(key, 'idle', direction); + const walkAnimationKey = explorationCharacterAnimationKey(key, 'walk', direction); + + if (!scene.anims.exists(idleAnimationKey)) { + scene.anims.create({ + key: idleAnimationKey, + frames: idleFrames, + frameRate: explorationCharacterIdleFrameRate, + repeat: -1 + }); + } + if (!scene.anims.exists(walkAnimationKey)) { + scene.anims.create({ + key: walkAnimationKey, + frames: walkFrames, + frameRate: explorationCharacterWalkFrameRate, + repeat: -1 + }); + } + }); + }); +} + +export function releaseExplorationCharacterTextures( + scene: Phaser.Scene, + keepTextureKeysOrUnitTextureKeys: Iterable = [] +) { + const keepTextureKeys = new Set( + Array.from(keepTextureKeysOrUnitTextureKeys) + .map((textureKey) => explorationCharacterTextureKeyForSourceKey(textureKey)) + .filter((key): key is ExplorationCharacterTextureKey => key !== undefined) + ); + return releaseExplorationCharacterTextureKeys( + scene, + explorationCharacterTextureKeys.filter((textureKey) => !keepTextureKeys.has(textureKey)) + ); +} + +export function releaseExplorationCharacterTextureKeys( + scene: Phaser.Scene, + textureKeysOrUnitTextureKeys: Iterable +) { + let releasedTextureCount = 0; + const textureKeys = new Set( + Array.from(textureKeysOrUnitTextureKeys) + .map((textureKey) => explorationCharacterTextureKeyForSourceKey(textureKey)) + .filter((key): key is ExplorationCharacterTextureKey => key !== undefined) + ); + + textureKeys.forEach((textureKey) => { + releaseExplorationCharacterAnimations(scene, textureKey); + if (releaseTextureSource(scene, textureKey)) { + releasedTextureCount += 1; + } + }); + + return releasedTextureCount; +} + +function releaseExplorationCharacterAnimations( + scene: Phaser.Scene, + textureKey: ExplorationCharacterTextureKey +) { + explorationCharacterDirections.forEach((direction) => { + (['idle', 'walk'] as ExplorationCharacterMotion[]).forEach((motion) => { + const animationKey = explorationCharacterAnimationKey(textureKey, motion, direction); + if (scene.anims.exists(animationKey)) { + scene.anims.remove(animationKey); + } + }); + }); +} diff --git a/src/game/data/portraitAssets.ts b/src/game/data/portraitAssets.ts index af3bcee..4c872a4 100644 --- a/src/game/data/portraitAssets.ts +++ b/src/game/data/portraitAssets.ts @@ -1,4 +1,4 @@ -const portraitModules = import.meta.glob('../../assets/images/portraits/*.png', { +const portraitModules = import.meta.glob('../../assets/images/portraits/*.{png,webp}', { eager: true, import: 'default' }) as Record; @@ -48,6 +48,11 @@ const speakerPortraitKeys: Record = { 유비: 'liuBei', 관우: 'guanYu', 장비: 'zhangFei', + '탁현 모병 관리': 'zhuoRecruitingClerk', + '탁현 주민': 'zhuoVillager', + '의용군 군수관': 'zhuoQuartermaster', + 추정: 'zouJing', + '젊은 의병': 'zhuoYoungVolunteer', 제갈량: 'zhugeLiang', 조운: 'zhaoYun', 강유: 'jiangWei', diff --git a/src/game/data/prologueVillage.ts b/src/game/data/prologueVillage.ts index d0c63e9..2bcc5fc 100644 --- a/src/game/data/prologueVillage.ts +++ b/src/game/data/prologueVillage.ts @@ -51,6 +51,7 @@ export type PrologueVillageDialogueLine = { speaker: string; text: string; textureKey?: string; + portrait?: string; }; export type PrologueVillageNpcDefinition = { diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index 9a542bb..e106a2a 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -1,5 +1,11 @@ import Phaser from 'phaser'; +import campBackgroundUrl from '../../assets/images/exploration/prologue-militia-camp.webp'; import { soundDirector } from '../audio/SoundDirector'; +import { + portraitAssetEntryForStoryContext, + portraitKeyForSpeaker, + type PortraitAssetEntry +} from '../data/portraitAssets'; import { prologueMilitiaCampNpcDefinitions, prologueMilitiaCampObjectiveDefinitions, @@ -9,11 +15,15 @@ import { } from '../data/prologueMilitiaCamp'; import { prologueDeparturePages, type PrologueVillageDialogueLine } from '../data/prologueVillage'; import { - ensureUnitAnimations, - loadUnitBaseSheets, - releaseUnitBaseSheetTextures, - type UnitDirection -} from '../data/unitAssets'; + ensureExplorationCharacterAnimations, + explorationCharacterAssetInfos, + explorationCharacterTextureKeyForUnitTextureKey, + explorationCharacterTextureKeyByUnitTextureKey, + hasExplorationCharacterAsset, + releaseExplorationCharacterTextureKeys, + type ExplorationCharacterDirection, + type ExplorationCharacterTextureKey +} from '../data/explorationCharacterAssets'; import { completeCampaignTutorial, getCampaignState, @@ -22,6 +32,7 @@ import { prologueMilitiaCampCampaignTutorialIds, type CampaignTutorialId } from '../state/campaignState'; +import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; @@ -35,16 +46,59 @@ const interactionRadius = 122; const promptRadius = 164; const characterDisplaySize = 104; const inputCarryoverGuardMs = 320; -const characterTextureKeys = [ - 'unit-liu-bei', - 'unit-guan-yu', - 'unit-zhang-fei', - 'unit-shu-officer', - 'unit-shu-infantry' +const campBackgroundKey = 'prologue-militia-camp-background'; +const campDialoguePortraitContext = { + id: 'prologue-militia-camp-dialogue', + background: 'story-militia' +} as const; +const campDialoguePortraitKeys = [ + 'liuBei', + 'guanYu', + 'zhangFei', + 'zhuoQuartermaster', + 'zouJing', + 'zhuoYoungVolunteer' ] as const; +const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei']; +const campNpcTextureKeyById = { + quartermaster: 'exploration-zhuo-quartermaster', + 'zou-jing': 'exploration-zou-jing', + 'nervous-volunteer': 'exploration-shu-infantry' +} as const satisfies Readonly>; + +function explorationTextureKey(unitTextureKey: string): ExplorationCharacterTextureKey { + const textureKey = explorationCharacterTextureKeyForUnitTextureKey(unitTextureKey); + if (!textureKey) { + throw new Error(`Missing exploration character mapping for ${unitTextureKey}`); + } + return textureKey; +} + +function explorationTextureKeyForCampNpc( + npcId: string, + fallbackUnitTextureKey: string +): ExplorationCharacterTextureKey { + const preferredTextureKey = + campNpcTextureKeyById[npcId as keyof typeof campNpcTextureKeyById]; + return preferredTextureKey && hasExplorationCharacterAsset(preferredTextureKey) + ? preferredTextureKey + : explorationTextureKey(fallbackUnitTextureKey); +} + +const characterTextureKeys = Object.freeze( + Array.from(new Set([ + playerTextureKey, + explorationCharacterTextureKeyByUnitTextureKey['unit-guan-yu'], + explorationCharacterTextureKeyByUnitTextureKey['unit-zhang-fei'], + explorationTextureKeyForCampNpc('quartermaster', 'unit-shu-officer'), + explorationTextureKeyForCampNpc('zou-jing', 'unit-shu-officer'), + explorationTextureKeyForCampNpc('nervous-volunteer', 'unit-shu-infantry') + ])) +); type CampNpcView = { definition: PrologueMilitiaCampNpcDefinition; + textureKey: ExplorationCharacterTextureKey; sprite: Phaser.GameObjects.Sprite; shadow: Phaser.GameObjects.Ellipse; nameplate: Phaser.GameObjects.Text; @@ -73,19 +127,33 @@ const objectiveTutorialIds: Record portraitAssetEntryForStoryContext( + portraitKey, + campDialoguePortraitContext, + `camp-${portraitKey}` + )) + .filter((entry): entry is PortraitAssetEntry => entry !== undefined); +} + export class PrologueMilitiaCampScene extends Phaser.Scene { + private backgroundImage?: Phaser.GameObjects.Image; + private backgroundFallback = false; private player?: Phaser.GameObjects.Sprite; private playerShadow?: Phaser.GameObjects.Ellipse; - private playerDirection: UnitDirection = 'north'; + private playerDirection: ExplorationCharacterDirection = 'north'; private playerMoving = false; private npcViews = new Map(); + private npcMoveTweens = new Map(); + private npcMovementPending = false; private blockers: Phaser.Geom.Rectangle[] = []; private objectiveRows = new Map(); private objectiveSummaryText?: Phaser.GameObjects.Text; private promptBackground?: Phaser.GameObjects.Rectangle; private promptText?: Phaser.GameObjects.Text; private dialoguePanel?: Phaser.GameObjects.Container; - private dialogueAvatar?: Phaser.GameObjects.Sprite; + private dialoguePortrait?: Phaser.GameObjects.Image; private dialogueNameText?: Phaser.GameObjects.Text; private dialogueBodyText?: Phaser.GameObjects.Text; private dialogueProgressText?: Phaser.GameObjects.Text; @@ -109,11 +177,34 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private stepIndex = 0; private lastStepAt = 0; private firstVisit = false; + private ownedPresentationTextureKeys = new Set(); constructor() { super('PrologueMilitiaCampScene'); } + preload() { + explorationCharacterAssetInfos(characterTextureKeys).forEach((asset) => { + if (!this.textures.exists(asset.key)) { + this.load.spritesheet(asset.key, asset.url, { + frameWidth: asset.frameWidth, + frameHeight: asset.frameHeight + }); + } + }); + if (!this.textures.exists(campBackgroundKey)) { + this.ownedPresentationTextureKeys.add(campBackgroundKey); + this.load.image(campBackgroundKey, campBackgroundUrl); + } + campDialoguePortraitEntries().forEach(({ textureKey, url }) => { + if (this.textures.exists(textureKey)) { + return; + } + this.ownedPresentationTextureKeys.add(textureKey); + this.load.image(textureKey, url); + }); + } + create() { this.resetRuntimeState(); this.prepareCampaignProgress(); @@ -134,38 +225,38 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.navigationPending = false; this.moveTarget = undefined; this.dialogueState = undefined; - releaseUnitBaseSheetTextures(this); + this.stopNpcMovement(); + releaseExplorationCharacterTextureKeys(this, characterTextureKeys); + this.releasePresentationTextures(); }); - loadUnitBaseSheets(this, characterTextureKeys, () => { - if (!this.scene.isActive()) { - return; - } - ensureUnitAnimations(this, characterTextureKeys); - this.createActors(); - this.createDialoguePanel(); - this.loadingOverlay?.destroy(); - this.loadingOverlay = undefined; - this.ready = true; - this.inputReadyAt = this.time.now + inputCarryoverGuardMs; - this.refreshObjectiveHud(); - this.refreshNpcMarkers(); - this.refreshInteractionPrompt(); + ensureExplorationCharacterAnimations(this, characterTextureKeys); + this.createActors(); + this.createDialoguePanel(); + this.loadingOverlay?.destroy(); + this.loadingOverlay = undefined; + this.ready = true; + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + this.refreshInteractionPrompt(); - if (this.firstVisit) { - this.startDialogue([ - { + if (this.firstVisit) { + this.startDialogue([ + { speaker: '유비', textureKey: 'unit-liu-bei', text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 관우의 정찰, 장비의 의병 대열, 군수관의 병기와 후송 준비를 확인하자.' - } - ]); - } - }); + } + ]); + } } update(time: number, delta: number) { - if (!this.ready || this.navigationPending || time < this.inputReadyAt) { + if (!this.ready || this.navigationPending || this.npcMovementPending || time < this.inputReadyAt) { + if (this.npcMovementPending) { + this.stopPlayerMovement(); + } this.interactionQueued = false; return; } @@ -197,12 +288,29 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { ready: this.ready, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, + requiredTextures: characterTextureKeys.map((key) => ({ + key, + ready: this.textures.exists(key) + })), requiredTexturesReady: characterTextureKeys.every((key) => this.textures.exists(key)), + background: { + key: this.backgroundImage?.texture.key ?? campBackgroundKey, + ready: this.backgroundImage?.active === true, + fallback: this.backgroundFallback, + sourceWidth: this.backgroundImage?.frame.realWidth ?? null, + sourceHeight: this.backgroundImage?.frame.realHeight ?? null, + bounds: this.backgroundImage + ? this.boundsSnapshot(this.backgroundImage.getBounds()) + : null, + objectCount: this.backgroundImage?.active ? 1 : 0 + }, player: playerPosition ? { ...playerPosition, direction: this.playerDirection, moving: this.playerMoving, + textureKey: this.player?.texture.key ?? null, + animationKey: this.player?.anims.currentAnim?.key ?? null, bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null } : null, @@ -210,7 +318,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { speed: playerSpeed, target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null, walkBounds: this.boundsSnapshot(movementBounds), - collisionRadius: playerCollisionRadius + collisionRadius: playerCollisionRadius, + npcMovementPending: this.npcMovementPending, + movingNpcIds: [...this.npcMoveTweens.keys()] }, controls: { movement: ['WASD', '방향키'], @@ -250,6 +360,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { departure: definition.departure ?? false, x: sprite.x, y: sprite.y, + moving: this.npcMoveTweens.has(definition.id), + textureKey: sprite.texture.key, + animationKey: sprite.anims.currentAnim?.key ?? null, distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null, @@ -264,6 +377,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { lineIndex: this.dialogueState.lineIndex, totalLines: this.dialogueState.lines.length, sourceNpcId: this.dialogueState.sourceNpcId ?? null, + portraitTextureKey: this.dialoguePortrait?.visible + ? this.dialoguePortrait.texture.key + : null, + portraitVisible: this.dialoguePortrait?.visible ?? false, bounds: dialogueBounds } : { @@ -272,6 +389,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { lineIndex: -1, totalLines: 0, sourceNpcId: null, + portraitTextureKey: null, + portraitVisible: false, bounds: null }, blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), @@ -280,7 +399,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } debugTeleportTo(targetId: string) { - if (!this.ready || this.navigationPending || this.dialogueState) { + if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) { return false; } const view = this.npcViews.get(targetId); @@ -294,7 +413,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } debugInteractWith(targetId: string) { - if (!this.ready || this.navigationPending || this.dialogueState) { + if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) { return false; } const view = this.npcViews.get(targetId); @@ -324,13 +443,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } private resetRuntimeState() { + this.backgroundImage = undefined; + this.backgroundFallback = false; this.player = undefined; this.playerShadow = undefined; this.playerDirection = 'north'; this.playerMoving = false; this.npcViews.clear(); + this.npcMoveTweens.clear(); + this.npcMovementPending = false; this.blockers = []; this.objectiveRows.clear(); + this.dialoguePortrait = undefined; this.dialogueState = undefined; this.moveTarget = undefined; this.ready = false; @@ -354,17 +478,27 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private drawCamp() { this.cameras.main.setBackgroundColor('#1a241e'); - const graphics = this.add.graphics().setDepth(-100); - graphics.fillStyle(0x26352b, 1); - graphics.fillRect(0, 0, sceneWidth, sceneHeight); - graphics.fillStyle(0x344536, 1); - graphics.fillRect(0, 92, mapRight, 866); + if (this.textures.exists(campBackgroundKey)) { + this.backgroundImage = this.add.image(0, 0, campBackgroundKey) + .setOrigin(0) + .setDepth(-120); + this.registerCampCollisions(); + this.drawCampWayfinding(); + this.drawCampFireGlow(); + } else { + this.backgroundFallback = true; + const graphics = this.add.graphics().setDepth(-100); + graphics.fillStyle(0x26352b, 1); + graphics.fillRect(0, 0, sceneWidth, sceneHeight); + graphics.fillStyle(0x344536, 1); + graphics.fillRect(0, 92, mapRight, 866); - this.drawGroundTexture(graphics); - this.drawCampRoads(graphics); - this.drawPalisade(graphics); - this.drawCampStructures(graphics); - this.drawCampDetails(); + this.drawGroundTexture(graphics); + this.drawCampRoads(graphics); + this.drawPalisade(graphics); + this.drawCampStructures(graphics); + this.drawCampDetails(); + } this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x111720, 0.97) .setOrigin(0) @@ -386,6 +520,58 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { .setDepth(1401); } + private registerCampCollisions() { + [ + [622, 178, 325, 125], + [108, 205, 248, 140], + [1188, 212, 214, 132], + [90, 694, 216, 140], + [295, 380, 124, 72], + [1018, 655, 182, 142], + [330, 820, 76, 54], + [413, 832, 62, 43], + [1285, 760, 72, 52], + [728, 520, 104, 64], + [955, 448, 40, 46], + [1080, 448, 40, 46], + [1205, 448, 40, 46] + ].forEach(([x, y, width, height]) => this.addBlocker(x, y, width, height)); + } + + private drawCampWayfinding() { + [ + [780, 294, '지휘 막사'], + [250, 340, '정찰대 막사'], + [1290, 340, '의병 숙영지'], + [198, 824, '보급 천막'], + [1100, 760, '병기 훈련장'], + [784, 116, '북문 · 출전로'] + ].forEach(([x, y, label]) => { + this.add.text(Number(x), Number(y), String(label), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f4e3b5', + fontStyle: 'bold', + backgroundColor: '#15120ed4', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }).setOrigin(0.5).setDepth(45); + }); + } + + private drawCampFireGlow() { + const glow = this.add.ellipse(780, 540, 230, 150, 0xf2a241, 0.08).setDepth(35); + this.tweens.add({ + targets: glow, + alpha: { from: 0.05, to: 0.14 }, + scaleX: { from: 0.95, to: 1.05 }, + scaleY: { from: 0.95, to: 1.05 }, + duration: 880, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + } + private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) { for (let index = 0; index < 180; index += 1) { const x = 22 + ((index * 89) % 1438); @@ -672,14 +858,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private createActors() { this.playerShadow = this.add.ellipse(750, 890, 66, 25, 0x07100a, 0.45).setDepth(800); - this.player = this.createCharacterSprite('unit-liu-bei', 750, 870, characterDisplaySize + 8, 'north'); + this.player = this.createCharacterSprite(playerTextureKey, 750, 870, characterDisplaySize + 8, 'north'); this.player.setDepth(970); prologueMilitiaCampNpcDefinitions.forEach((definition) => { + const textureKey = explorationTextureKeyForCampNpc( + definition.id, + definition.textureKey + ); const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4) .setDepth(100 + definition.y); const sprite = this.createCharacterSprite( - definition.textureKey, + textureKey, definition.x, definition.y, characterDisplaySize, @@ -720,16 +910,24 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { ease: 'Sine.easeInOut' }); } - this.npcViews.set(definition.id, { definition, sprite, shadow, nameplate, roleplate, marker }); + this.npcViews.set(definition.id, { + definition, + textureKey, + sprite, + shadow, + nameplate, + roleplate, + marker + }); }); } private createCharacterSprite( - textureKey: string, + textureKey: ExplorationCharacterTextureKey, x: number, y: number, size: number, - direction: UnitDirection + direction: ExplorationCharacterDirection ) { const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); @@ -753,22 +951,26 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82) .setOrigin(0) .setStrokeStyle(1, 0x546174, 0.7); - const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92) + const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92) .setStrokeStyle(2, 0x9e8350, 0.9); - this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0) - .setDisplaySize(186, 186); - this.dialogueNameText = this.add.text(x + 222, y + 42, '', { + const firstPortraitKey = campDialoguePortraitEntries() + .find(({ textureKey }) => this.textures.exists(textureKey)) + ?.textureKey ?? '__DEFAULT'; + this.dialoguePortrait = this.add.image(x + 132, y + 152, firstPortraitKey) + .setDisplaySize(218, 218) + .setVisible(false); + this.dialogueNameText = this.add.text(x + 278, y + 42, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '29px', color: '#f1d691', fontStyle: 'bold' }); - this.dialogueBodyText = this.add.text(x + 222, y + 101, '', { + this.dialogueBodyText = this.add.text(x + 278, y + 101, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#f0ede5', lineSpacing: 11, - wordWrap: { width: 1440, useAdvancedWrap: true } + wordWrap: { width: 1382, useAdvancedWrap: true } }); this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -780,8 +982,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { shade, background, inner, - avatarFrame, - this.dialogueAvatar, + portraitFrame, + this.dialoguePortrait, this.dialogueNameText, this.dialogueBodyText, this.dialogueProgressText @@ -913,7 +1115,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return vector; } - private directionForVector(vector: Phaser.Math.Vector2): UnitDirection { + private directionForVector(vector: Phaser.Math.Vector2): ExplorationCharacterDirection { if (Math.abs(vector.x) > Math.abs(vector.y)) { return vector.x > 0 ? 'east' : 'west'; } @@ -947,8 +1149,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if (!this.player) { return; } - const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; - if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) { + const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; + if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) { this.player.play(animationKey); } this.playerMoving = moving; @@ -1007,8 +1209,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.startDialogue(view.definition.lockedDialogue ?? [], undefined, view.definition.id); return; } - this.gatherCommandParty(); - this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id); + this.gatherCommandParty(() => { + this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id); + }); return; } @@ -1053,17 +1256,37 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.dialogueProgressText?.setText( `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` ); - if (this.dialogueAvatar) { - const textureKey = line.textureKey; - if (textureKey && this.textures.exists(textureKey)) { - this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true); - this.dialogueAvatar.play(`${textureKey}-idle-south`); + if (this.dialoguePortrait) { + const entry = this.dialoguePortraitEntry(line); + if (entry && this.textures.exists(entry.textureKey)) { + this.dialoguePortrait + .setTexture(entry.textureKey) + .setDisplaySize(218, 218) + .setVisible(true); } else { - this.dialogueAvatar.setVisible(false); + this.dialoguePortrait.setVisible(false); } } } + private dialoguePortraitEntry(line: PrologueVillageDialogueLine) { + const portraitKey = line.portrait ?? portraitKeyForSpeaker(line.speaker); + return portraitKey + ? portraitAssetEntryForStoryContext( + portraitKey, + campDialoguePortraitContext, + `camp-dialogue-${line.speaker}` + ) + : undefined; + } + + private releasePresentationTextures() { + this.ownedPresentationTextureKeys.forEach((textureKey) => { + releaseTextureSource(this, textureKey); + }); + this.ownedPresentationTextureKeys.clear(); + } + private advanceDialogue() { const dialogue = this.dialogueState; if (!dialogue || this.navigationPending) { @@ -1287,33 +1510,113 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.playerDirection = this.directionForVector(toNpc); this.setPlayerMoving(false); const npcDirection = this.directionForVector(toNpc.scale(-1)); - if (this.textures.exists(view.definition.textureKey)) { - view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`); + if (this.textures.exists(view.textureKey)) { + view.sprite.play(`${view.textureKey}-idle-${npcDirection}`); } } - private gatherCommandParty() { - this.setNpcPosition('guan-yu', 650, 420, 'north'); - this.setNpcPosition('zhang-fei', 890, 420, 'north'); + private gatherCommandParty(onComplete: () => void) { + if (this.npcMovementPending) { + return; + } + this.npcMovementPending = true; + this.stopPlayerMovement(); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + + const movements = [ + { npcId: 'guan-yu', x: 650, y: 420, direction: 'north' as const }, + { npcId: 'zhang-fei', x: 890, y: 420, direction: 'north' as const } + ].filter(({ npcId }) => this.npcViews.has(npcId)); + if (movements.length === 0) { + this.npcMovementPending = false; + onComplete(); + return; + } + + let remaining = movements.length; + movements.forEach(({ npcId, x, y, direction }) => { + this.walkNpcTo(npcId, x, y, direction, () => { + remaining -= 1; + if (remaining > 0) { + return; + } + this.npcMovementPending = false; + onComplete(); + }); + }); + } + + private walkNpcTo( + npcId: string, + x: number, + y: number, + finalDirection: ExplorationCharacterDirection, + onComplete: () => void + ) { + const view = this.npcViews.get(npcId); + if (!view) { + onComplete(); + return; + } + this.npcMoveTweens.get(npcId)?.remove(); + const delta = new Phaser.Math.Vector2(x - view.sprite.x, y - view.sprite.y); + const walkDirection = delta.lengthSq() > 0 + ? this.directionForVector(delta) + : finalDirection; + if (this.textures.exists(view.textureKey)) { + view.sprite.play(`${view.textureKey}-walk-${walkDirection}`); + } + const duration = Math.max(220, Math.round(delta.length() / playerSpeed * 1000)); + const tween = this.tweens.add({ + targets: view.sprite, + x, + y, + duration, + ease: 'Sine.easeInOut', + onUpdate: () => this.syncNpcView(view), + onComplete: () => { + this.npcMoveTweens.delete(npcId); + this.syncNpcView(view); + if (this.textures.exists(view.textureKey)) { + view.sprite.play(`${view.textureKey}-idle-${finalDirection}`); + } + onComplete(); + } + }); + this.npcMoveTweens.set(npcId, tween); + } + + private syncNpcView(view: CampNpcView) { + const { x, y } = view.sprite; + view.sprite.setDepth(110 + y); + view.shadow.setPosition(x, y + 20).setDepth(100 + y); + view.nameplate.setPosition(x, y + 59); + view.roleplate.setPosition(x, y + 88); + view.marker?.setPosition(x, y - 72); + } + + private stopNpcMovement() { + this.npcMoveTweens.forEach((tween) => tween.remove()); + this.npcMoveTweens.clear(); + this.npcMovementPending = false; } private setNpcPosition( npcId: string, x: number, y: number, - direction: UnitDirection + direction: ExplorationCharacterDirection ) { const view = this.npcViews.get(npcId); if (!view) { return; } - view.sprite.setPosition(x, y).setDepth(110 + y); - view.shadow.setPosition(x, y + 20).setDepth(100 + y); - view.nameplate.setPosition(x, y + 59); - view.roleplate.setPosition(x, y + 88); + view.sprite.setPosition(x, y); + this.syncNpcView(view); view.marker?.setPosition(x, y - 72).setVisible(false); - if (this.textures.exists(view.definition.textureKey)) { - view.sprite.play(`${view.definition.textureKey}-idle-${direction}`); + if (this.textures.exists(view.textureKey)) { + view.sprite.play(`${view.textureKey}-idle-${direction}`); } } diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index c4f7408..9a4b372 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -1,5 +1,11 @@ import Phaser from 'phaser'; +import villageBackgroundUrl from '../../assets/images/exploration/prologue-village.webp'; import { soundDirector } from '../audio/SoundDirector'; +import { + portraitAssetEntryForStoryContext, + portraitKeyForSpeaker, + type PortraitAssetEntry +} from '../data/portraitAssets'; import { prologueBrotherhoodPages, prologueVillageNpcDefinitions, @@ -10,11 +16,15 @@ import { type PrologueVillageRequiredObjectiveId } from '../data/prologueVillage'; import { - ensureUnitAnimations, - loadUnitBaseSheets, - releaseUnitBaseSheetTextures, - type UnitDirection -} from '../data/unitAssets'; + ensureExplorationCharacterAnimations, + explorationCharacterAssetInfos, + explorationCharacterTextureKeyForUnitTextureKey, + explorationCharacterTextureKeyByUnitTextureKey, + hasExplorationCharacterAsset, + releaseExplorationCharacterTextureKeys, + type ExplorationCharacterDirection, + type ExplorationCharacterTextureKey +} from '../data/explorationCharacterAssets'; import { completeCampaignTutorial, getCampaignState, @@ -23,6 +33,7 @@ import { prologueVillageCampaignTutorialIds, type CampaignTutorialId } from '../state/campaignState'; +import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; @@ -36,17 +47,71 @@ const interactionRadius = 122; const promptRadius = 160; const characterDisplaySize = 104; const inputCarryoverGuardMs = 320; -const oathPosition = { x: 780, y: 405 }; -const characterTextureKeys = [ - 'unit-liu-bei', - 'unit-guan-yu', - 'unit-zhang-fei', - 'unit-shu-officer', - 'unit-shu-infantry' +const narrativeNpcSpeed = 300; +const villageBackgroundKey = 'prologue-village-background'; +const villageDialoguePortraitContext = { + id: 'prologue-village-dialogue', + background: 'story-three-heroes' +} as const; +const villageDialoguePortraitKeys = [ + 'liuBei', + 'guanYu', + 'zhangFei', + 'zhuoRecruitingClerk', + 'zhuoVillager' ] as const; +const oathPosition = { x: 780, y: 405 }; +const narrativePositions = { + tavern: { + zhangFei: { x: 1000, y: 540, direction: 'east' as const } + }, + recruitment: { + guanYu: { x: 850, y: 655, direction: 'east' as const }, + zhangFei: { x: 810, y: 805, direction: 'east' as const } + }, + oath: { + guanYu: { x: oathPosition.x - 104, y: oathPosition.y + 32, direction: 'east' as const }, + zhangFei: { x: oathPosition.x + 104, y: oathPosition.y + 32, direction: 'west' as const } + } +}; +const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei']; +const villageNpcTextureKeyById = { + 'recruiting-clerk': 'exploration-zhuo-recruiting-clerk', + 'market-villager': 'exploration-zhuo-villager' +} as const satisfies Readonly>; + +function explorationTextureKey(unitTextureKey: string): ExplorationCharacterTextureKey { + const textureKey = explorationCharacterTextureKeyForUnitTextureKey(unitTextureKey); + if (!textureKey) { + throw new Error(`Missing exploration character mapping for ${unitTextureKey}`); + } + return textureKey; +} + +function explorationTextureKeyForVillageNpc( + npcId: string, + fallbackUnitTextureKey: string +): ExplorationCharacterTextureKey { + const preferredTextureKey = + villageNpcTextureKeyById[npcId as keyof typeof villageNpcTextureKeyById]; + return preferredTextureKey && hasExplorationCharacterAsset(preferredTextureKey) + ? preferredTextureKey + : explorationTextureKey(fallbackUnitTextureKey); +} + +const characterTextureKeys = Object.freeze( + Array.from(new Set([ + playerTextureKey, + explorationCharacterTextureKeyByUnitTextureKey['unit-guan-yu'], + explorationCharacterTextureKeyByUnitTextureKey['unit-zhang-fei'], + explorationTextureKeyForVillageNpc('recruiting-clerk', 'unit-shu-officer'), + explorationTextureKeyForVillageNpc('market-villager', 'unit-shu-infantry') + ])) +); type VillageNpcView = { definition: PrologueVillageNpcDefinition; + textureKey: ExplorationCharacterTextureKey; sprite: Phaser.GameObjects.Sprite; shadow: Phaser.GameObjects.Ellipse; nameplate: Phaser.GameObjects.Text; @@ -72,16 +137,37 @@ type ObjectiveRowView = { location: Phaser.GameObjects.Text; }; +type NarrativeNpcMove = { + npcId: string; + target: { + x: number; + y: number; + direction: ExplorationCharacterDirection; + }; +}; + const objectiveTutorialIds: Record = { 'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei, 'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu, 'register-volunteers': prologueVillageCampaignTutorialIds.registerVolunteers }; +function villageDialoguePortraitEntries() { + return villageDialoguePortraitKeys + .map((portraitKey) => portraitAssetEntryForStoryContext( + portraitKey, + villageDialoguePortraitContext, + `village-${portraitKey}` + )) + .filter((entry): entry is PortraitAssetEntry => entry !== undefined); +} + export class PrologueVillageScene extends Phaser.Scene { + private backgroundImage?: Phaser.GameObjects.Image; + private backgroundFallback = false; private player?: Phaser.GameObjects.Sprite; private playerShadow?: Phaser.GameObjects.Ellipse; - private playerDirection: UnitDirection = 'north'; + private playerDirection: ExplorationCharacterDirection = 'north'; private playerMoving = false; private npcViews = new Map(); private blockers: Phaser.Geom.Rectangle[] = []; @@ -92,7 +178,7 @@ export class PrologueVillageScene extends Phaser.Scene { private oathMarker?: Phaser.GameObjects.Text; private oathLabel?: Phaser.GameObjects.Text; private dialoguePanel?: Phaser.GameObjects.Container; - private dialogueAvatar?: Phaser.GameObjects.Sprite; + private dialoguePortrait?: Phaser.GameObjects.Image; private dialogueNameText?: Phaser.GameObjects.Text; private dialogueBodyText?: Phaser.GameObjects.Text; private dialogueProgressText?: Phaser.GameObjects.Text; @@ -109,6 +195,8 @@ export class PrologueVillageScene extends Phaser.Scene { }; private interactKeys: Phaser.Input.Keyboard.Key[] = []; private moveTarget?: Phaser.Math.Vector2; + private npcMoveTweens = new Map(); + private oathGatherPending = false; private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; @@ -116,11 +204,34 @@ export class PrologueVillageScene extends Phaser.Scene { private stepIndex = 0; private lastStepAt = 0; private firstVisit = false; + private ownedPresentationTextureKeys = new Set(); constructor() { super('PrologueVillageScene'); } + preload() { + explorationCharacterAssetInfos(characterTextureKeys).forEach((asset) => { + if (!this.textures.exists(asset.key)) { + this.load.spritesheet(asset.key, asset.url, { + frameWidth: asset.frameWidth, + frameHeight: asset.frameHeight + }); + } + }); + if (!this.textures.exists(villageBackgroundKey)) { + this.ownedPresentationTextureKeys.add(villageBackgroundKey); + this.load.image(villageBackgroundKey, villageBackgroundUrl); + } + villageDialoguePortraitEntries().forEach(({ textureKey, url }) => { + if (this.textures.exists(textureKey)) { + return; + } + this.ownedPresentationTextureKeys.add(textureKey); + this.load.image(textureKey, url); + }); + } + create() { this.resetRuntimeState(); this.prepareCampaignProgress(); @@ -139,37 +250,35 @@ export class PrologueVillageScene extends Phaser.Scene { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.ready = false; this.navigationPending = false; + this.oathGatherPending = false; this.moveTarget = undefined; this.dialogueState = undefined; - releaseUnitBaseSheetTextures(this); + this.stopAllNpcMovement(); + releaseExplorationCharacterTextureKeys(this, characterTextureKeys); + this.releasePresentationTextures(); }); - loadUnitBaseSheets(this, characterTextureKeys, () => { - if (!this.scene.isActive()) { - return; - } - ensureUnitAnimations(this, characterTextureKeys); - this.createActors(); - this.restoreNarrativeActorPositions(); - this.createDialoguePanel(); - this.loadingOverlay?.destroy(); - this.loadingOverlay = undefined; - this.ready = true; - this.inputReadyAt = this.time.now + inputCarryoverGuardMs; - this.refreshObjectiveHud(); - this.refreshNpcMarkers(); - this.refreshInteractionPrompt(); + ensureExplorationCharacterAnimations(this, characterTextureKeys); + this.createActors(); + this.restoreNarrativeActorPositions(); + this.createDialoguePanel(); + this.loadingOverlay?.destroy(); + this.loadingOverlay = undefined; + this.ready = true; + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.refreshObjectiveHud(); + this.refreshNpcMarkers(); + this.refreshInteractionPrompt(); - if (this.firstVisit) { - this.startDialogue([ - { + if (this.firstVisit) { + this.startDialogue([ + { speaker: '유비', textureKey: 'unit-liu-bei', text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.' - } - ]); - } - }); + } + ]); + } } update(time: number, delta: number) { @@ -178,6 +287,12 @@ export class PrologueVillageScene extends Phaser.Scene { return; } + if (this.oathGatherPending) { + this.interactionQueued = false; + this.stopPlayerMovement(); + return; + } + if (this.dialogueState) { this.stopPlayerMovement(); if (this.consumeInteractionRequest()) { @@ -210,11 +325,24 @@ export class PrologueVillageScene extends Phaser.Scene { ready: this.ready, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, + background: { + key: this.backgroundImage?.texture.key ?? villageBackgroundKey, + ready: this.backgroundImage?.active === true, + fallback: this.backgroundFallback, + sourceWidth: this.backgroundImage?.frame.realWidth ?? null, + sourceHeight: this.backgroundImage?.frame.realHeight ?? null, + bounds: this.backgroundImage + ? this.boundsSnapshot(this.backgroundImage.getBounds()) + : null, + objectCount: this.backgroundImage?.active ? 1 : 0 + }, player: playerPosition ? { ...playerPosition, direction: this.playerDirection, moving: this.playerMoving, + textureKey: this.player?.texture.key ?? null, + animationKey: this.player?.anims.currentAnim?.key ?? null, bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null } : null, @@ -265,6 +393,9 @@ export class PrologueVillageScene extends Phaser.Scene { objectiveId: definition.objectiveId ?? null, x: sprite.x, y: sprite.y, + moving: this.npcMoveTweens.has(definition.id), + textureKey: sprite.texture.key, + animationKey: sprite.anims.currentAnim?.key ?? null, distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null, completed: definition.objectiveId ? this.isObjectiveComplete(definition.objectiveId) : false, bounds: this.boundsSnapshot(sprite.getBounds()) @@ -276,6 +407,10 @@ export class PrologueVillageScene extends Phaser.Scene { lineIndex: this.dialogueState.lineIndex, totalLines: this.dialogueState.lines.length, sourceNpcId: this.dialogueState.sourceNpcId ?? null, + portraitTextureKey: this.dialoguePortrait?.visible + ? this.dialoguePortrait.texture.key + : null, + portraitVisible: this.dialoguePortrait?.visible ?? false, bounds: dialogueBounds } : { @@ -284,10 +419,20 @@ export class PrologueVillageScene extends Phaser.Scene { lineIndex: null, totalLines: 0, sourceNpcId: null, + portraitTextureKey: null, + portraitVisible: false, bounds: null }, blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), + narrativeMovement: { + movingNpcIds: Array.from(this.npcMoveTweens.keys()), + oathGatherPending: this.oathGatherPending + }, navigationPending: this.navigationPending, + requiredTextures: characterTextureKeys.map((key) => ({ + key, + ready: this.textures.exists(key) + })), requiredTexturesReady: characterTextureKeys.every((key) => this.textures.exists(key)) }; } @@ -334,6 +479,9 @@ export class PrologueVillageScene extends Phaser.Scene { } private resetRuntimeState() { + this.stopAllNpcMovement(); + this.backgroundImage = undefined; + this.backgroundFallback = false; this.player = undefined; this.playerShadow = undefined; this.playerDirection = 'north'; @@ -341,12 +489,14 @@ export class PrologueVillageScene extends Phaser.Scene { this.npcViews.clear(); this.blockers = []; this.objectiveRows.clear(); + this.dialoguePortrait = undefined; this.dialogueState = undefined; this.moveTarget = undefined; this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; this.interactionQueued = false; + this.oathGatherPending = false; this.stepIndex = 0; this.lastStepAt = 0; } @@ -364,16 +514,25 @@ export class PrologueVillageScene extends Phaser.Scene { private drawVillage() { this.cameras.main.setBackgroundColor('#28372a'); - const graphics = this.add.graphics().setDepth(-100); - graphics.fillStyle(0x354a34, 1); - graphics.fillRect(0, 0, sceneWidth, sceneHeight); - graphics.fillStyle(0x42583a, 1); - graphics.fillRect(0, 92, mapRight, 868); + if (this.textures.exists(villageBackgroundKey)) { + this.backgroundImage = this.add.image(0, 0, villageBackgroundKey) + .setOrigin(0) + .setDepth(-120); + this.registerVillageCollisions(); + this.drawVillageWayfinding(); + } else { + this.backgroundFallback = true; + const graphics = this.add.graphics().setDepth(-100); + graphics.fillStyle(0x354a34, 1); + graphics.fillRect(0, 0, sceneWidth, sceneHeight); + graphics.fillStyle(0x42583a, 1); + graphics.fillRect(0, 92, mapRight, 868); - this.drawGroundTexture(graphics); - this.drawRoads(graphics); - this.drawVillageBuildings(graphics); - this.drawVillageDetails(); + this.drawGroundTexture(graphics); + this.drawRoads(graphics); + this.drawVillageBuildings(graphics); + this.drawVillageDetails(); + } this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.96) .setOrigin(0) @@ -395,6 +554,38 @@ export class PrologueVillageScene extends Phaser.Scene { .setDepth(1401); } + private registerVillageCollisions() { + [ + [105, 160, 410, 236], + [990, 148, 390, 226], + [1120, 665, 310, 215], + [1270, 414, 170, 112], + [647, 270, 38, 56], + [697, 313, 38, 52], + [832, 291, 38, 54], + [882, 261, 38, 56] + ].forEach(([x, y, width, height]) => this.addBlocker(x, y, width, height)); + } + + private drawVillageWayfinding() { + [ + [310, 350, '탁현 관아'], + [1184, 350, '장터 주점'], + [1270, 850, '탁현 모병소'], + [780, 206, '복숭아 동산'], + [780, 116, '북문'] + ].forEach(([x, y, label]) => { + this.add.text(Number(x), Number(y), String(label), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f6e5bc', + fontStyle: 'bold', + backgroundColor: '#19140fd4', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }).setOrigin(0.5).setDepth(45); + }); + } + private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) { for (let index = 0; index < 150; index += 1) { const x = 25 + ((index * 83) % 1435); @@ -716,14 +907,18 @@ export class PrologueVillageScene extends Phaser.Scene { private createActors() { this.playerShadow = this.add.ellipse(750, 870, 66, 25, 0x07100a, 0.45).setDepth(800); - this.player = this.createCharacterSprite('unit-liu-bei', 750, 850, characterDisplaySize + 8, 'north'); + this.player = this.createCharacterSprite(playerTextureKey, 750, 850, characterDisplaySize + 8, 'north'); this.player.setDepth(851); prologueVillageNpcDefinitions.forEach((definition) => { + const textureKey = explorationTextureKeyForVillageNpc( + definition.id, + definition.textureKey + ); const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4) .setDepth(100 + definition.y); const sprite = this.createCharacterSprite( - definition.textureKey, + textureKey, definition.x, definition.y, characterDisplaySize, @@ -757,7 +952,7 @@ export class PrologueVillageScene extends Phaser.Scene { ease: 'Sine.easeInOut' }); } - this.npcViews.set(definition.id, { definition, sprite, shadow, nameplate, marker }); + this.npcViews.set(definition.id, { definition, textureKey, sprite, shadow, nameplate, marker }); }); this.oathMarker = this.add.text(oathPosition.x, oathPosition.y - 58, '◇', { @@ -786,11 +981,11 @@ export class PrologueVillageScene extends Phaser.Scene { } private createCharacterSprite( - textureKey: string, + textureKey: ExplorationCharacterTextureKey, x: number, y: number, size: number, - direction: UnitDirection + direction: ExplorationCharacterDirection ) { const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); @@ -814,22 +1009,26 @@ export class PrologueVillageScene extends Phaser.Scene { const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82) .setOrigin(0) .setStrokeStyle(1, 0x546174, 0.7); - const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92) + const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92) .setStrokeStyle(2, 0x9e8350, 0.9); - this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0) - .setDisplaySize(186, 186); - this.dialogueNameText = this.add.text(x + 222, y + 42, '', { + const firstPortraitKey = villageDialoguePortraitEntries() + .find(({ textureKey }) => this.textures.exists(textureKey)) + ?.textureKey ?? '__DEFAULT'; + this.dialoguePortrait = this.add.image(x + 132, y + 152, firstPortraitKey) + .setDisplaySize(218, 218) + .setVisible(false); + this.dialogueNameText = this.add.text(x + 278, y + 42, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '29px', color: '#f1d691', fontStyle: 'bold' }); - this.dialogueBodyText = this.add.text(x + 222, y + 101, '', { + this.dialogueBodyText = this.add.text(x + 278, y + 101, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#f0ede5', lineSpacing: 11, - wordWrap: { width: 1440, useAdvancedWrap: true } + wordWrap: { width: 1382, useAdvancedWrap: true } }); this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -841,8 +1040,8 @@ export class PrologueVillageScene extends Phaser.Scene { shade, background, inner, - avatarFrame, - this.dialogueAvatar, + portraitFrame, + this.dialoguePortrait, this.dialogueNameText, this.dialogueBodyText, this.dialogueProgressText @@ -879,7 +1078,12 @@ export class PrologueVillageScene extends Phaser.Scene { } this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { - if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { + if ( + !this.ready || + this.navigationPending || + this.oathGatherPending || + this.time.now < this.inputReadyAt + ) { return; } if (this.dialogueState) { @@ -975,7 +1179,7 @@ export class PrologueVillageScene extends Phaser.Scene { return vector; } - private directionForVector(vector: Phaser.Math.Vector2): UnitDirection { + private directionForVector(vector: Phaser.Math.Vector2): ExplorationCharacterDirection { if (Math.abs(vector.x) > Math.abs(vector.y)) { return vector.x > 0 ? 'east' : 'west'; } @@ -1009,8 +1213,8 @@ export class PrologueVillageScene extends Phaser.Scene { if (!this.player) { return; } - const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; - if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) { + const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; + if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) { this.player.play(animationKey); } this.playerMoving = moving; @@ -1039,40 +1243,176 @@ export class PrologueVillageScene extends Phaser.Scene { this.stopPlayerMovement(); } - private gatherOathParty() { - this.playerDirection = 'north'; - this.setPlayerPosition(oathPosition.x, oathPosition.y + 125); - this.setNpcPosition('guan-yu', oathPosition.x - 104, oathPosition.y + 32, 'east'); - this.setNpcPosition('zhang-fei', oathPosition.x + 104, oathPosition.y + 32, 'west'); + private gatherOathParty(onComplete: () => void) { + if (this.oathGatherPending) { + return; + } + + this.oathGatherPending = true; + this.stopPlayerMovement(); + this.promptBackground?.setVisible(false); + this.promptText?.setVisible(false); + if (this.player) { + const toOath = new Phaser.Math.Vector2( + oathPosition.x - this.player.x, + oathPosition.y - this.player.y + ); + if (toOath.lengthSq() > 0) { + this.playerDirection = this.directionForVector(toOath); + } + this.setPlayerMoving(false); + } + + this.walkNpcGroup([ + { npcId: 'guan-yu', target: narrativePositions.oath.guanYu }, + { npcId: 'zhang-fei', target: narrativePositions.oath.zhangFei } + ], () => { + this.oathGatherPending = false; + if (!this.scene.isActive() || this.navigationPending) { + return; + } + onComplete(); + }); } private restoreNarrativeActorPositions() { + if (this.isObjectiveComplete('register-volunteers')) { + this.setNpcPosition('guan-yu', narrativePositions.oath.guanYu); + this.setNpcPosition('zhang-fei', narrativePositions.oath.zhangFei); + return; + } if (this.isObjectiveComplete('meet-guan-yu')) { - this.setNpcPosition('guan-yu', 850, 655, 'east'); - this.setNpcPosition('zhang-fei', 810, 805, 'east'); + this.setNpcPosition('guan-yu', narrativePositions.recruitment.guanYu); + this.setNpcPosition('zhang-fei', narrativePositions.recruitment.zhangFei); return; } if (this.isObjectiveComplete('meet-zhang-fei')) { - this.setNpcPosition('zhang-fei', 1000, 540, 'east'); + this.setNpcPosition('zhang-fei', narrativePositions.tavern.zhangFei); } } private setNpcPosition( npcId: string, - x: number, - y: number, - direction: UnitDirection + target: { + x: number; + y: number; + direction: ExplorationCharacterDirection; + } ) { const view = this.npcViews.get(npcId); if (!view) { return; } - view.sprite.setPosition(x, y).setDepth(110 + y); + + this.stopNpcMovement(npcId); + view.sprite.setPosition(target.x, target.y); + view.marker?.setVisible(false); + this.syncNpcView(view); + this.playNpcAnimation(view, false, target.direction); + } + + private walkNpcGroup(moves: NarrativeNpcMove[], onComplete?: () => void) { + if (!moves.length) { + onComplete?.(); + return; + } + + let remaining = moves.length; + const completeMove = () => { + remaining -= 1; + if (remaining === 0) { + onComplete?.(); + } + }; + moves.forEach(({ npcId, target }) => this.walkNpcTo(npcId, target, completeMove)); + } + + private walkNpcTo( + npcId: string, + target: NarrativeNpcMove['target'], + onComplete?: () => void + ) { + const view = this.npcViews.get(npcId); + if (!view) { + onComplete?.(); + return; + } + + this.stopNpcMovement(npcId); + const movement = new Phaser.Math.Vector2( + target.x - view.sprite.x, + target.y - view.sprite.y + ); + const distance = movement.length(); + view.marker?.setVisible(false); + if (distance <= 1) { + view.sprite.setPosition(target.x, target.y); + this.syncNpcView(view); + this.playNpcAnimation(view, false, target.direction); + this.refreshNpcMarkers(); + onComplete?.(); + return; + } + + const movementDirection = this.directionForVector(movement); + this.playNpcAnimation(view, true, movementDirection); + let tween: Phaser.Tweens.Tween | undefined; + tween = this.tweens.add({ + targets: view.sprite, + x: target.x, + y: target.y, + duration: Math.max(120, distance / narrativeNpcSpeed * 1000), + ease: 'Linear', + onUpdate: () => this.syncNpcView(view), + onComplete: () => { + if (!tween || this.npcMoveTweens.get(npcId) !== tween) { + return; + } + this.npcMoveTweens.delete(npcId); + view.sprite.setPosition(target.x, target.y); + this.syncNpcView(view); + this.playNpcAnimation(view, false, target.direction); + this.refreshNpcMarkers(); + onComplete?.(); + } + }); + this.npcMoveTweens.set(npcId, tween); + } + + private stopNpcMovement(npcId: string) { + const tween = this.npcMoveTweens.get(npcId); + if (!tween) { + return; + } + this.npcMoveTweens.delete(npcId); + tween.stop(); + } + + private stopAllNpcMovement() { + Array.from(this.npcMoveTweens.keys()).forEach((npcId) => this.stopNpcMovement(npcId)); + } + + private syncNpcView(view: VillageNpcView) { + const { x, y } = view.sprite; + view.sprite.setDepth(110 + y); view.shadow.setPosition(x, y + 20).setDepth(100 + y); view.nameplate.setPosition(x, y + 63); - view.marker?.setPosition(x, y - 72).setVisible(false); - if (this.textures.exists(view.definition.textureKey)) { - view.sprite.play(`${view.definition.textureKey}-idle-${direction}`); + if (view.marker && !view.marker.visible) { + view.marker.setPosition(x, y - 72); + } + } + + private playNpcAnimation( + view: VillageNpcView, + moving: boolean, + direction: ExplorationCharacterDirection + ) { + if (!this.textures.exists(view.textureKey)) { + return; + } + const animationKey = `${view.textureKey}-${moving ? 'walk' : 'idle'}-${direction}`; + if (view.sprite.anims.currentAnim?.key !== animationKey) { + view.sprite.play(animationKey); } } @@ -1141,7 +1481,10 @@ export class PrologueVillageScene extends Phaser.Scene { return; } - this.gatherOathParty(); + this.gatherOathParty(() => this.startOathDialogue()); + } + + private startOathDialogue() { this.startDialogue([ { speaker: '장비', @@ -1194,17 +1537,37 @@ export class PrologueVillageScene extends Phaser.Scene { this.dialogueProgressText?.setText( `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` ); - if (this.dialogueAvatar) { - const textureKey = line.textureKey; - if (textureKey && this.textures.exists(textureKey)) { - this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true); - this.dialogueAvatar.play(`${textureKey}-idle-south`); + if (this.dialoguePortrait) { + const entry = this.dialoguePortraitEntry(line); + if (entry && this.textures.exists(entry.textureKey)) { + this.dialoguePortrait + .setTexture(entry.textureKey) + .setDisplaySize(218, 218) + .setVisible(true); } else { - this.dialogueAvatar.setVisible(false); + this.dialoguePortrait.setVisible(false); } } } + private dialoguePortraitEntry(line: PrologueVillageDialogueLine) { + const portraitKey = line.portrait ?? portraitKeyForSpeaker(line.speaker); + return portraitKey + ? portraitAssetEntryForStoryContext( + portraitKey, + villageDialoguePortraitContext, + `village-dialogue-${line.speaker}` + ) + : undefined; + } + + private releasePresentationTextures() { + this.ownedPresentationTextureKeys.forEach((textureKey) => { + releaseTextureSource(this, textureKey); + }); + this.ownedPresentationTextureKeys.clear(); + } + private advanceDialogue() { const dialogue = this.dialogueState; if (!dialogue || this.navigationPending) { @@ -1231,10 +1594,17 @@ export class PrologueVillageScene extends Phaser.Scene { } completeCampaignTutorial(objectiveTutorialIds[objectiveId]); if (objectiveId === 'meet-zhang-fei') { - this.setNpcPosition('zhang-fei', 1000, 540, 'east'); + this.walkNpcTo('zhang-fei', narrativePositions.tavern.zhangFei); } else if (objectiveId === 'meet-guan-yu') { - this.setNpcPosition('guan-yu', 850, 655, 'east'); - this.setNpcPosition('zhang-fei', 810, 805, 'east'); + this.walkNpcGroup([ + { npcId: 'guan-yu', target: narrativePositions.recruitment.guanYu }, + { npcId: 'zhang-fei', target: narrativePositions.recruitment.zhangFei } + ]); + } else if (objectiveId === 'register-volunteers') { + this.walkNpcGroup([ + { npcId: 'guan-yu', target: narrativePositions.oath.guanYu }, + { npcId: 'zhang-fei', target: narrativePositions.oath.zhangFei } + ]); } soundDirector.playObjectiveAchieved(); this.refreshObjectiveHud(); @@ -1378,6 +1748,9 @@ export class PrologueVillageScene extends Phaser.Scene { } const completed = this.isObjectiveComplete(definition.objectiveId); const unlocked = this.objectiveUnlocked(definition.objectiveId); + const joinedCompanion = completed && + (definition.id === 'zhang-fei' || definition.id === 'guan-yu'); + marker.setVisible(!this.npcMoveTweens.has(definition.id) && !joinedCompanion); marker.setText(completed ? '✓' : unlocked ? '!' : '◇'); marker.setColor(completed ? '#e5f2d5' : unlocked ? '#2a2013' : '#9b8f7d'); marker.setBackgroundColor(completed ? '#4e7549' : unlocked ? '#e5bd68' : '#343b45'); @@ -1409,13 +1782,15 @@ export class PrologueVillageScene extends Phaser.Scene { return undefined; } const targets: InteractionTarget[] = [ - ...Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({ - kind: 'npc' as const, - id: definition.id, - name: definition.name, - x: sprite.x, - y: sprite.y - })), + ...Array.from(this.npcViews.values()) + .filter(({ definition }) => !this.npcMoveTweens.has(definition.id)) + .map(({ definition, sprite }) => ({ + kind: 'npc' as const, + id: definition.id, + name: definition.name, + x: sprite.x, + y: sprite.y + })), { kind: 'oath' as const, id: 'make-oath' as const, @@ -1460,8 +1835,8 @@ export class PrologueVillageScene extends Phaser.Scene { this.playerDirection = this.directionForVector(toNpc); this.setPlayerMoving(false); const npcDirection = this.directionForVector(toNpc.scale(-1)); - if (this.textures.exists(view.definition.textureKey)) { - view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`); + if (this.textures.exists(view.textureKey)) { + view.sprite.play(`${view.textureKey}-idle-${npcDirection}`); } }