feat: upgrade prologue exploration presentation
79
docs/visual-asset-prologue-exploration-report.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
202
scripts/verify-exploration-character-asset-data.mjs
Normal 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.`);
|
||||
}
|
||||
331
scripts/verify-prologue-dialogue-portrait-data.mjs
Normal 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}"`;
|
||||
}
|
||||
223
scripts/verify-prologue-exploration-asset-data.mjs
Normal 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`;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 365 KiB |
BIN
src/assets/images/exploration/prologue-militia-camp.webp
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
src/assets/images/exploration/prologue-village.webp
Normal file
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 242 KiB |
BIN
src/assets/images/portraits/zhuo-villager-yellow-turban.webp
Normal file
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 204 KiB |
BIN
src/assets/images/portraits/zou-jing-yellow-turban.webp
Normal file
|
After Width: | Height: | Size: 244 KiB |
296
src/game/data/explorationCharacterAssets.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -51,6 +51,7 @@ export type PrologueVillageDialogueLine = {
|
||||
speaker: string;
|
||||
text: string;
|
||||
textureKey?: string;
|
||||
portrait?: string;
|
||||
};
|
||||
|
||||
export type PrologueVillageNpcDefinition = {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||