feat: upgrade prologue exploration presentation

This commit is contained in:
2026-07-27 01:01:27 +09:00
parent 9beed0ac02
commit 40dca5321e
29 changed files with 2215 additions and 196 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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.`);
}

View File

@@ -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}"`;
}

View File

@@ -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`;
}

View File

@@ -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(() => {

View File

@@ -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',

View File

@@ -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}"`);

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -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<ExplorationCharacterDirection, number> = {
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<string, string>;
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<ExplorationCharacterTextureKey>(
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<string> = explorationCharacterUnitTextureKeys
) {
const assets: ExplorationCharacterAssetInfo[] = [];
const seenTextureKeys = new Set<ExplorationCharacterTextureKey>();
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<string>
) {
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<string> = []
) {
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<string>
) {
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);
}
});
});
}

View File

@@ -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<string, string>;
@@ -48,6 +48,11 @@ const speakerPortraitKeys: Record<string, string> = {
: 'liuBei',
: 'guanYu',
: 'zhangFei',
'탁현 모병 관리': 'zhuoRecruitingClerk',
'탁현 주민': 'zhuoVillager',
'의용군 군수관': 'zhuoQuartermaster',
: 'zouJing',
'젊은 의병': 'zhuoYoungVolunteer',
: 'zhugeLiang',
: 'zhaoYun',
: 'jiangWei',

View File

@@ -51,6 +51,7 @@ export type PrologueVillageDialogueLine = {
speaker: string;
text: string;
textureKey?: string;
portrait?: string;
};
export type PrologueVillageNpcDefinition = {

View File

@@ -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<Record<string, ExplorationCharacterTextureKey>>;
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<ExplorationCharacterTextureKey>([
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<PrologueMilitiaCampRequiredObjectiveId, Campa
'inspect-arms': prologueMilitiaCampCampaignTutorialIds.inspectArms
};
function campDialoguePortraitEntries() {
return campDialoguePortraitKeys
.map((portraitKey) => 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<string, CampNpcView>();
private npcMoveTweens = new Map<string, Phaser.Tweens.Tween>();
private npcMovementPending = false;
private blockers: Phaser.Geom.Rectangle[] = [];
private objectiveRows = new Map<string, ObjectiveRowView>();
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<string>();
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}`);
}
}

View File

@@ -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<Record<string, ExplorationCharacterTextureKey>>;
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<ExplorationCharacterTextureKey>([
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<PrologueVillageRequiredObjectiveId, CampaignTutorialId> = {
'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<string, VillageNpcView>();
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<string, Phaser.Tweens.Tween>();
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<string>();
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}`);
}
}