feat: upgrade prologue exploration presentation

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -0,0 +1,296 @@
import type Phaser from 'phaser';
import { releaseTextureSource } from '../render/loaderLifecycle';
export type ExplorationCharacterDirection = 'south' | 'east' | 'north' | 'west';
export type ExplorationCharacterMotion = 'idle' | 'walk';
export const explorationCharacterFrameSize = 192;
export const explorationCharacterIdleFrameCount = 8;
export const explorationCharacterWalkFrameCount = 8;
export const explorationCharacterFramesPerDirection =
explorationCharacterIdleFrameCount + explorationCharacterWalkFrameCount;
export const explorationCharacterTextureKeyByUnitTextureKey = {
'unit-liu-bei': 'exploration-liu-bei',
'unit-guan-yu': 'exploration-guan-yu',
'unit-zhang-fei': 'exploration-zhang-fei',
'unit-shu-officer': 'exploration-shu-officer',
'unit-shu-infantry': 'exploration-shu-infantry'
} as const;
export const explorationCharacterNamedTextureKeyFallbacks = {
'exploration-zhuo-recruiting-clerk': 'unit-shu-officer',
'exploration-zhuo-villager': 'unit-shu-infantry',
'exploration-zhuo-quartermaster': 'unit-shu-officer',
'exploration-zou-jing': 'unit-shu-officer'
} as const;
export type ExplorationCharacterUnitTextureKey =
keyof typeof explorationCharacterTextureKeyByUnitTextureKey;
export type ExplorationCharacterBaseTextureKey =
(typeof explorationCharacterTextureKeyByUnitTextureKey)[ExplorationCharacterUnitTextureKey];
export type ExplorationCharacterNamedTextureKey =
keyof typeof explorationCharacterNamedTextureKeyFallbacks;
export type ExplorationCharacterTextureKey =
| ExplorationCharacterBaseTextureKey
| ExplorationCharacterNamedTextureKey;
export type ExplorationCharacterAssetInfo = {
unitTextureKey: ExplorationCharacterUnitTextureKey;
key: ExplorationCharacterTextureKey;
url: string;
frameSize: number;
frameWidth: number;
frameHeight: number;
format: 'webp';
};
export const explorationCharacterUnitTextureKeys = Object.freeze(
Object.keys(
explorationCharacterTextureKeyByUnitTextureKey
) as ExplorationCharacterUnitTextureKey[]
);
export const explorationCharacterTextureKeys = Object.freeze(
[
...Object.values(explorationCharacterTextureKeyByUnitTextureKey),
...Object.keys(explorationCharacterNamedTextureKeyFallbacks)
] as ExplorationCharacterTextureKey[]
);
const explorationCharacterDirections = [
'south',
'east',
'north',
'west'
] as const satisfies readonly ExplorationCharacterDirection[];
const explorationCharacterSheetRows: Record<ExplorationCharacterDirection, number> = {
south: 0,
east: 1,
north: 2,
west: 3
};
const explorationCharacterIdleFrameRate = 6;
const explorationCharacterWalkFrameRate = 10;
const explorationCharacterSheetModules = import.meta.glob(
'../../assets/images/exploration/characters/exploration-*.webp',
{
eager: true,
import: 'default'
}
) as Record<string, string>;
function textureKeyFromPath(path: string) {
const fileName = path.split('/').pop() ?? path;
return fileName.replace(/\.webp$/i, '');
}
const explorationCharacterSheetUrlsByKey = new Map(
Object.entries(explorationCharacterSheetModules).map(([path, url]) => [
textureKeyFromPath(path),
url
])
);
const explorationCharacterTextureKeySet = new Set<ExplorationCharacterTextureKey>(
explorationCharacterTextureKeys
);
const unitTextureKeyByExplorationCharacterTextureKey = new Map<
ExplorationCharacterBaseTextureKey,
ExplorationCharacterUnitTextureKey
>(
Object.entries(explorationCharacterTextureKeyByUnitTextureKey).map(
([unitTextureKey, textureKey]) => [
textureKey,
unitTextureKey as ExplorationCharacterUnitTextureKey
]
)
);
export function explorationCharacterTextureKeyForUnitTextureKey(
unitTextureKey: string
): ExplorationCharacterBaseTextureKey | undefined {
if (!(unitTextureKey in explorationCharacterTextureKeyByUnitTextureKey)) {
return undefined;
}
return explorationCharacterTextureKeyByUnitTextureKey[
unitTextureKey as ExplorationCharacterUnitTextureKey
];
}
export function explorationCharacterAssetInfo(
textureKeyOrUnitTextureKey: string
): ExplorationCharacterAssetInfo | undefined {
const key = explorationCharacterTextureKeyForSourceKey(textureKeyOrUnitTextureKey);
const url = key ? explorationCharacterSheetUrlsByKey.get(key) : undefined;
const unitTextureKey = key ? fallbackUnitTextureKeyForExplorationTextureKey(key) : undefined;
if (!key || !url || !unitTextureKey) {
return undefined;
}
return {
unitTextureKey,
key,
url,
frameSize: explorationCharacterFrameSize,
frameWidth: explorationCharacterFrameSize,
frameHeight: explorationCharacterFrameSize,
format: 'webp'
};
}
export function explorationCharacterAssetInfos(
textureKeysOrUnitTextureKeys: Iterable<string> = explorationCharacterUnitTextureKeys
) {
const assets: ExplorationCharacterAssetInfo[] = [];
const seenTextureKeys = new Set<ExplorationCharacterTextureKey>();
Array.from(new Set(textureKeysOrUnitTextureKeys)).forEach((textureKeyOrUnitTextureKey) => {
const asset = explorationCharacterAssetInfo(textureKeyOrUnitTextureKey);
if (!asset || seenTextureKeys.has(asset.key)) {
return;
}
seenTextureKeys.add(asset.key);
assets.push(asset);
});
return assets;
}
export function hasExplorationCharacterAsset(textureKeyOrUnitTextureKey: string) {
return explorationCharacterAssetInfo(textureKeyOrUnitTextureKey) !== undefined;
}
function explorationCharacterTextureKeyForSourceKey(
textureKeyOrUnitTextureKey: string
): ExplorationCharacterTextureKey | undefined {
return explorationCharacterTextureKeyForUnitTextureKey(textureKeyOrUnitTextureKey) ??
(
explorationCharacterTextureKeySet.has(
textureKeyOrUnitTextureKey as ExplorationCharacterTextureKey
)
? textureKeyOrUnitTextureKey as ExplorationCharacterTextureKey
: undefined
);
}
function fallbackUnitTextureKeyForExplorationTextureKey(
textureKey: ExplorationCharacterTextureKey
): ExplorationCharacterUnitTextureKey | undefined {
const baseUnitTextureKey = unitTextureKeyByExplorationCharacterTextureKey.get(
textureKey as ExplorationCharacterBaseTextureKey
);
if (baseUnitTextureKey) {
return baseUnitTextureKey;
}
if (textureKey in explorationCharacterNamedTextureKeyFallbacks) {
return explorationCharacterNamedTextureKeyFallbacks[
textureKey as ExplorationCharacterNamedTextureKey
];
}
return undefined;
}
export function explorationCharacterAnimationKey(
textureKey: ExplorationCharacterTextureKey,
motion: ExplorationCharacterMotion,
direction: ExplorationCharacterDirection
) {
return `${textureKey}-${motion}-${direction}`;
}
export function ensureExplorationCharacterAnimations(
scene: Phaser.Scene,
textureKeysOrUnitTextureKeys: Iterable<string>
) {
explorationCharacterAssetInfos(textureKeysOrUnitTextureKeys).forEach(({ key }) => {
if (!scene.textures.exists(key)) {
return;
}
explorationCharacterDirections.forEach((direction) => {
const rowStart =
explorationCharacterSheetRows[direction] * explorationCharacterFramesPerDirection;
const idleFrames = Array.from(
{ length: explorationCharacterIdleFrameCount },
(_, frameIndex) => ({
key,
frame: rowStart + frameIndex
})
);
const walkFrames = Array.from(
{ length: explorationCharacterWalkFrameCount },
(_, frameIndex) => ({
key,
frame: rowStart + explorationCharacterIdleFrameCount + frameIndex
})
);
const idleAnimationKey = explorationCharacterAnimationKey(key, 'idle', direction);
const walkAnimationKey = explorationCharacterAnimationKey(key, 'walk', direction);
if (!scene.anims.exists(idleAnimationKey)) {
scene.anims.create({
key: idleAnimationKey,
frames: idleFrames,
frameRate: explorationCharacterIdleFrameRate,
repeat: -1
});
}
if (!scene.anims.exists(walkAnimationKey)) {
scene.anims.create({
key: walkAnimationKey,
frames: walkFrames,
frameRate: explorationCharacterWalkFrameRate,
repeat: -1
});
}
});
});
}
export function releaseExplorationCharacterTextures(
scene: Phaser.Scene,
keepTextureKeysOrUnitTextureKeys: Iterable<string> = []
) {
const keepTextureKeys = new Set(
Array.from(keepTextureKeysOrUnitTextureKeys)
.map((textureKey) => explorationCharacterTextureKeyForSourceKey(textureKey))
.filter((key): key is ExplorationCharacterTextureKey => key !== undefined)
);
return releaseExplorationCharacterTextureKeys(
scene,
explorationCharacterTextureKeys.filter((textureKey) => !keepTextureKeys.has(textureKey))
);
}
export function releaseExplorationCharacterTextureKeys(
scene: Phaser.Scene,
textureKeysOrUnitTextureKeys: Iterable<string>
) {
let releasedTextureCount = 0;
const textureKeys = new Set(
Array.from(textureKeysOrUnitTextureKeys)
.map((textureKey) => explorationCharacterTextureKeyForSourceKey(textureKey))
.filter((key): key is ExplorationCharacterTextureKey => key !== undefined)
);
textureKeys.forEach((textureKey) => {
releaseExplorationCharacterAnimations(scene, textureKey);
if (releaseTextureSource(scene, textureKey)) {
releasedTextureCount += 1;
}
});
return releasedTextureCount;
}
function releaseExplorationCharacterAnimations(
scene: Phaser.Scene,
textureKey: ExplorationCharacterTextureKey
) {
explorationCharacterDirections.forEach((direction) => {
(['idle', 'walk'] as ExplorationCharacterMotion[]).forEach((motion) => {
const animationKey = explorationCharacterAnimationKey(textureKey, motion, direction);
if (scene.anims.exists(animationKey)) {
scene.anims.remove(animationKey);
}
});
});
}

View File

@@ -1,4 +1,4 @@
const portraitModules = import.meta.glob('../../assets/images/portraits/*.png', {
const portraitModules = import.meta.glob('../../assets/images/portraits/*.{png,webp}', {
eager: true,
import: 'default'
}) as Record<string, string>;
@@ -48,6 +48,11 @@ const speakerPortraitKeys: Record<string, string> = {
: 'liuBei',
: 'guanYu',
: 'zhangFei',
'탁현 모병 관리': 'zhuoRecruitingClerk',
'탁현 주민': 'zhuoVillager',
'의용군 군수관': 'zhuoQuartermaster',
: 'zouJing',
'젊은 의병': 'zhuoYoungVolunteer',
: 'zhugeLiang',
: 'zhaoYun',
: 'jiangWei',

View File

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

View File

@@ -1,5 +1,11 @@
import Phaser from 'phaser';
import campBackgroundUrl from '../../assets/images/exploration/prologue-militia-camp.webp';
import { soundDirector } from '../audio/SoundDirector';
import {
portraitAssetEntryForStoryContext,
portraitKeyForSpeaker,
type PortraitAssetEntry
} from '../data/portraitAssets';
import {
prologueMilitiaCampNpcDefinitions,
prologueMilitiaCampObjectiveDefinitions,
@@ -9,11 +15,15 @@ import {
} from '../data/prologueMilitiaCamp';
import { prologueDeparturePages, type PrologueVillageDialogueLine } from '../data/prologueVillage';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
releaseUnitBaseSheetTextures,
type UnitDirection
} from '../data/unitAssets';
ensureExplorationCharacterAnimations,
explorationCharacterAssetInfos,
explorationCharacterTextureKeyForUnitTextureKey,
explorationCharacterTextureKeyByUnitTextureKey,
hasExplorationCharacterAsset,
releaseExplorationCharacterTextureKeys,
type ExplorationCharacterDirection,
type ExplorationCharacterTextureKey
} from '../data/explorationCharacterAssets';
import {
completeCampaignTutorial,
getCampaignState,
@@ -22,6 +32,7 @@ import {
prologueMilitiaCampCampaignTutorialIds,
type CampaignTutorialId
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
@@ -35,16 +46,59 @@ const interactionRadius = 122;
const promptRadius = 164;
const characterDisplaySize = 104;
const inputCarryoverGuardMs = 320;
const characterTextureKeys = [
'unit-liu-bei',
'unit-guan-yu',
'unit-zhang-fei',
'unit-shu-officer',
'unit-shu-infantry'
const campBackgroundKey = 'prologue-militia-camp-background';
const campDialoguePortraitContext = {
id: 'prologue-militia-camp-dialogue',
background: 'story-militia'
} as const;
const campDialoguePortraitKeys = [
'liuBei',
'guanYu',
'zhangFei',
'zhuoQuartermaster',
'zouJing',
'zhuoYoungVolunteer'
] as const;
const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei'];
const campNpcTextureKeyById = {
quartermaster: 'exploration-zhuo-quartermaster',
'zou-jing': 'exploration-zou-jing',
'nervous-volunteer': 'exploration-shu-infantry'
} as const satisfies Readonly<Record<string, ExplorationCharacterTextureKey>>;
function explorationTextureKey(unitTextureKey: string): ExplorationCharacterTextureKey {
const textureKey = explorationCharacterTextureKeyForUnitTextureKey(unitTextureKey);
if (!textureKey) {
throw new Error(`Missing exploration character mapping for ${unitTextureKey}`);
}
return textureKey;
}
function explorationTextureKeyForCampNpc(
npcId: string,
fallbackUnitTextureKey: string
): ExplorationCharacterTextureKey {
const preferredTextureKey =
campNpcTextureKeyById[npcId as keyof typeof campNpcTextureKeyById];
return preferredTextureKey && hasExplorationCharacterAsset(preferredTextureKey)
? preferredTextureKey
: explorationTextureKey(fallbackUnitTextureKey);
}
const characterTextureKeys = Object.freeze(
Array.from(new Set<ExplorationCharacterTextureKey>([
playerTextureKey,
explorationCharacterTextureKeyByUnitTextureKey['unit-guan-yu'],
explorationCharacterTextureKeyByUnitTextureKey['unit-zhang-fei'],
explorationTextureKeyForCampNpc('quartermaster', 'unit-shu-officer'),
explorationTextureKeyForCampNpc('zou-jing', 'unit-shu-officer'),
explorationTextureKeyForCampNpc('nervous-volunteer', 'unit-shu-infantry')
]))
);
type CampNpcView = {
definition: PrologueMilitiaCampNpcDefinition;
textureKey: ExplorationCharacterTextureKey;
sprite: Phaser.GameObjects.Sprite;
shadow: Phaser.GameObjects.Ellipse;
nameplate: Phaser.GameObjects.Text;
@@ -73,19 +127,33 @@ const objectiveTutorialIds: Record<PrologueMilitiaCampRequiredObjectiveId, Campa
'inspect-arms': prologueMilitiaCampCampaignTutorialIds.inspectArms
};
function campDialoguePortraitEntries() {
return campDialoguePortraitKeys
.map((portraitKey) => portraitAssetEntryForStoryContext(
portraitKey,
campDialoguePortraitContext,
`camp-${portraitKey}`
))
.filter((entry): entry is PortraitAssetEntry => entry !== undefined);
}
export class PrologueMilitiaCampScene extends Phaser.Scene {
private backgroundImage?: Phaser.GameObjects.Image;
private backgroundFallback = false;
private player?: Phaser.GameObjects.Sprite;
private playerShadow?: Phaser.GameObjects.Ellipse;
private playerDirection: UnitDirection = 'north';
private playerDirection: ExplorationCharacterDirection = 'north';
private playerMoving = false;
private npcViews = new Map<string, CampNpcView>();
private npcMoveTweens = new Map<string, Phaser.Tweens.Tween>();
private npcMovementPending = false;
private blockers: Phaser.Geom.Rectangle[] = [];
private objectiveRows = new Map<string, ObjectiveRowView>();
private objectiveSummaryText?: Phaser.GameObjects.Text;
private promptBackground?: Phaser.GameObjects.Rectangle;
private promptText?: Phaser.GameObjects.Text;
private dialoguePanel?: Phaser.GameObjects.Container;
private dialogueAvatar?: Phaser.GameObjects.Sprite;
private dialoguePortrait?: Phaser.GameObjects.Image;
private dialogueNameText?: Phaser.GameObjects.Text;
private dialogueBodyText?: Phaser.GameObjects.Text;
private dialogueProgressText?: Phaser.GameObjects.Text;
@@ -109,11 +177,34 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private stepIndex = 0;
private lastStepAt = 0;
private firstVisit = false;
private ownedPresentationTextureKeys = new Set<string>();
constructor() {
super('PrologueMilitiaCampScene');
}
preload() {
explorationCharacterAssetInfos(characterTextureKeys).forEach((asset) => {
if (!this.textures.exists(asset.key)) {
this.load.spritesheet(asset.key, asset.url, {
frameWidth: asset.frameWidth,
frameHeight: asset.frameHeight
});
}
});
if (!this.textures.exists(campBackgroundKey)) {
this.ownedPresentationTextureKeys.add(campBackgroundKey);
this.load.image(campBackgroundKey, campBackgroundUrl);
}
campDialoguePortraitEntries().forEach(({ textureKey, url }) => {
if (this.textures.exists(textureKey)) {
return;
}
this.ownedPresentationTextureKeys.add(textureKey);
this.load.image(textureKey, url);
});
}
create() {
this.resetRuntimeState();
this.prepareCampaignProgress();
@@ -134,38 +225,38 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.navigationPending = false;
this.moveTarget = undefined;
this.dialogueState = undefined;
releaseUnitBaseSheetTextures(this);
this.stopNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
this.releasePresentationTextures();
});
loadUnitBaseSheets(this, characterTextureKeys, () => {
if (!this.scene.isActive()) {
return;
}
ensureUnitAnimations(this, characterTextureKeys);
this.createActors();
this.createDialoguePanel();
this.loadingOverlay?.destroy();
this.loadingOverlay = undefined;
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.refreshObjectiveHud();
this.refreshNpcMarkers();
this.refreshInteractionPrompt();
ensureExplorationCharacterAnimations(this, characterTextureKeys);
this.createActors();
this.createDialoguePanel();
this.loadingOverlay?.destroy();
this.loadingOverlay = undefined;
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.refreshObjectiveHud();
this.refreshNpcMarkers();
this.refreshInteractionPrompt();
if (this.firstVisit) {
this.startDialogue([
{
if (this.firstVisit) {
this.startDialogue([
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 관우의 정찰, 장비의 의병 대열, 군수관의 병기와 후송 준비를 확인하자.'
}
]);
}
});
}
]);
}
}
update(time: number, delta: number) {
if (!this.ready || this.navigationPending || time < this.inputReadyAt) {
if (!this.ready || this.navigationPending || this.npcMovementPending || time < this.inputReadyAt) {
if (this.npcMovementPending) {
this.stopPlayerMovement();
}
this.interactionQueued = false;
return;
}
@@ -197,12 +288,29 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
ready: this.ready,
viewport: { width: this.scale.width, height: this.scale.height },
campaignStep: campaign.step,
requiredTextures: characterTextureKeys.map((key) => ({
key,
ready: this.textures.exists(key)
})),
requiredTexturesReady: characterTextureKeys.every((key) => this.textures.exists(key)),
background: {
key: this.backgroundImage?.texture.key ?? campBackgroundKey,
ready: this.backgroundImage?.active === true,
fallback: this.backgroundFallback,
sourceWidth: this.backgroundImage?.frame.realWidth ?? null,
sourceHeight: this.backgroundImage?.frame.realHeight ?? null,
bounds: this.backgroundImage
? this.boundsSnapshot(this.backgroundImage.getBounds())
: null,
objectCount: this.backgroundImage?.active ? 1 : 0
},
player: playerPosition
? {
...playerPosition,
direction: this.playerDirection,
moving: this.playerMoving,
textureKey: this.player?.texture.key ?? null,
animationKey: this.player?.anims.currentAnim?.key ?? null,
bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null
}
: null,
@@ -210,7 +318,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
speed: playerSpeed,
target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null,
walkBounds: this.boundsSnapshot(movementBounds),
collisionRadius: playerCollisionRadius
collisionRadius: playerCollisionRadius,
npcMovementPending: this.npcMovementPending,
movingNpcIds: [...this.npcMoveTweens.keys()]
},
controls: {
movement: ['WASD', '방향키'],
@@ -250,6 +360,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
departure: definition.departure ?? false,
x: sprite.x,
y: sprite.y,
moving: this.npcMoveTweens.has(definition.id),
textureKey: sprite.texture.key,
animationKey: sprite.anims.currentAnim?.key ?? null,
distance: playerPosition
? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y)
: null,
@@ -264,6 +377,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
lineIndex: this.dialogueState.lineIndex,
totalLines: this.dialogueState.lines.length,
sourceNpcId: this.dialogueState.sourceNpcId ?? null,
portraitTextureKey: this.dialoguePortrait?.visible
? this.dialoguePortrait.texture.key
: null,
portraitVisible: this.dialoguePortrait?.visible ?? false,
bounds: dialogueBounds
}
: {
@@ -272,6 +389,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
lineIndex: -1,
totalLines: 0,
sourceNpcId: null,
portraitTextureKey: null,
portraitVisible: false,
bounds: null
},
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
@@ -280,7 +399,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
debugTeleportTo(targetId: string) {
if (!this.ready || this.navigationPending || this.dialogueState) {
if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) {
return false;
}
const view = this.npcViews.get(targetId);
@@ -294,7 +413,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
debugInteractWith(targetId: string) {
if (!this.ready || this.navigationPending || this.dialogueState) {
if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) {
return false;
}
const view = this.npcViews.get(targetId);
@@ -324,13 +443,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
private resetRuntimeState() {
this.backgroundImage = undefined;
this.backgroundFallback = false;
this.player = undefined;
this.playerShadow = undefined;
this.playerDirection = 'north';
this.playerMoving = false;
this.npcViews.clear();
this.npcMoveTweens.clear();
this.npcMovementPending = false;
this.blockers = [];
this.objectiveRows.clear();
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.moveTarget = undefined;
this.ready = false;
@@ -354,17 +478,27 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private drawCamp() {
this.cameras.main.setBackgroundColor('#1a241e');
const graphics = this.add.graphics().setDepth(-100);
graphics.fillStyle(0x26352b, 1);
graphics.fillRect(0, 0, sceneWidth, sceneHeight);
graphics.fillStyle(0x344536, 1);
graphics.fillRect(0, 92, mapRight, 866);
if (this.textures.exists(campBackgroundKey)) {
this.backgroundImage = this.add.image(0, 0, campBackgroundKey)
.setOrigin(0)
.setDepth(-120);
this.registerCampCollisions();
this.drawCampWayfinding();
this.drawCampFireGlow();
} else {
this.backgroundFallback = true;
const graphics = this.add.graphics().setDepth(-100);
graphics.fillStyle(0x26352b, 1);
graphics.fillRect(0, 0, sceneWidth, sceneHeight);
graphics.fillStyle(0x344536, 1);
graphics.fillRect(0, 92, mapRight, 866);
this.drawGroundTexture(graphics);
this.drawCampRoads(graphics);
this.drawPalisade(graphics);
this.drawCampStructures(graphics);
this.drawCampDetails();
this.drawGroundTexture(graphics);
this.drawCampRoads(graphics);
this.drawPalisade(graphics);
this.drawCampStructures(graphics);
this.drawCampDetails();
}
this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x111720, 0.97)
.setOrigin(0)
@@ -386,6 +520,58 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
.setDepth(1401);
}
private registerCampCollisions() {
[
[622, 178, 325, 125],
[108, 205, 248, 140],
[1188, 212, 214, 132],
[90, 694, 216, 140],
[295, 380, 124, 72],
[1018, 655, 182, 142],
[330, 820, 76, 54],
[413, 832, 62, 43],
[1285, 760, 72, 52],
[728, 520, 104, 64],
[955, 448, 40, 46],
[1080, 448, 40, 46],
[1205, 448, 40, 46]
].forEach(([x, y, width, height]) => this.addBlocker(x, y, width, height));
}
private drawCampWayfinding() {
[
[780, 294, '지휘 막사'],
[250, 340, '정찰대 막사'],
[1290, 340, '의병 숙영지'],
[198, 824, '보급 천막'],
[1100, 760, '병기 훈련장'],
[784, 116, '북문 · 출전로']
].forEach(([x, y, label]) => {
this.add.text(Number(x), Number(y), String(label), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f4e3b5',
fontStyle: 'bold',
backgroundColor: '#15120ed4',
padding: { left: 10, right: 10, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(45);
});
}
private drawCampFireGlow() {
const glow = this.add.ellipse(780, 540, 230, 150, 0xf2a241, 0.08).setDepth(35);
this.tweens.add({
targets: glow,
alpha: { from: 0.05, to: 0.14 },
scaleX: { from: 0.95, to: 1.05 },
scaleY: { from: 0.95, to: 1.05 },
duration: 880,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
}
private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) {
for (let index = 0; index < 180; index += 1) {
const x = 22 + ((index * 89) % 1438);
@@ -672,14 +858,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private createActors() {
this.playerShadow = this.add.ellipse(750, 890, 66, 25, 0x07100a, 0.45).setDepth(800);
this.player = this.createCharacterSprite('unit-liu-bei', 750, 870, characterDisplaySize + 8, 'north');
this.player = this.createCharacterSprite(playerTextureKey, 750, 870, characterDisplaySize + 8, 'north');
this.player.setDepth(970);
prologueMilitiaCampNpcDefinitions.forEach((definition) => {
const textureKey = explorationTextureKeyForCampNpc(
definition.id,
definition.textureKey
);
const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4)
.setDepth(100 + definition.y);
const sprite = this.createCharacterSprite(
definition.textureKey,
textureKey,
definition.x,
definition.y,
characterDisplaySize,
@@ -720,16 +910,24 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
ease: 'Sine.easeInOut'
});
}
this.npcViews.set(definition.id, { definition, sprite, shadow, nameplate, roleplate, marker });
this.npcViews.set(definition.id, {
definition,
textureKey,
sprite,
shadow,
nameplate,
roleplate,
marker
});
});
}
private createCharacterSprite(
textureKey: string,
textureKey: ExplorationCharacterTextureKey,
x: number,
y: number,
size: number,
direction: UnitDirection
direction: ExplorationCharacterDirection
) {
const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT';
const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size);
@@ -753,22 +951,26 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82)
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.7);
const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92)
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0)
.setDisplaySize(186, 186);
this.dialogueNameText = this.add.text(x + 222, y + 42, '', {
const firstPortraitKey = campDialoguePortraitEntries()
.find(({ textureKey }) => this.textures.exists(textureKey))
?.textureKey ?? '__DEFAULT';
this.dialoguePortrait = this.add.image(x + 132, y + 152, firstPortraitKey)
.setDisplaySize(218, 218)
.setVisible(false);
this.dialogueNameText = this.add.text(x + 278, y + 42, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '29px',
color: '#f1d691',
fontStyle: 'bold'
});
this.dialogueBodyText = this.add.text(x + 222, y + 101, '', {
this.dialogueBodyText = this.add.text(x + 278, y + 101, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#f0ede5',
lineSpacing: 11,
wordWrap: { width: 1440, useAdvancedWrap: true }
wordWrap: { width: 1382, useAdvancedWrap: true }
});
this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -780,8 +982,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
shade,
background,
inner,
avatarFrame,
this.dialogueAvatar,
portraitFrame,
this.dialoguePortrait,
this.dialogueNameText,
this.dialogueBodyText,
this.dialogueProgressText
@@ -913,7 +1115,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return vector;
}
private directionForVector(vector: Phaser.Math.Vector2): UnitDirection {
private directionForVector(vector: Phaser.Math.Vector2): ExplorationCharacterDirection {
if (Math.abs(vector.x) > Math.abs(vector.y)) {
return vector.x > 0 ? 'east' : 'west';
}
@@ -947,8 +1149,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (!this.player) {
return;
}
const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) {
const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) {
this.player.play(animationKey);
}
this.playerMoving = moving;
@@ -1007,8 +1209,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.startDialogue(view.definition.lockedDialogue ?? [], undefined, view.definition.id);
return;
}
this.gatherCommandParty();
this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id);
this.gatherCommandParty(() => {
this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id);
});
return;
}
@@ -1053,17 +1256,37 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.dialogueProgressText?.setText(
`${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속`
);
if (this.dialogueAvatar) {
const textureKey = line.textureKey;
if (textureKey && this.textures.exists(textureKey)) {
this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true);
this.dialogueAvatar.play(`${textureKey}-idle-south`);
if (this.dialoguePortrait) {
const entry = this.dialoguePortraitEntry(line);
if (entry && this.textures.exists(entry.textureKey)) {
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
} else {
this.dialogueAvatar.setVisible(false);
this.dialoguePortrait.setVisible(false);
}
}
}
private dialoguePortraitEntry(line: PrologueVillageDialogueLine) {
const portraitKey = line.portrait ?? portraitKeyForSpeaker(line.speaker);
return portraitKey
? portraitAssetEntryForStoryContext(
portraitKey,
campDialoguePortraitContext,
`camp-dialogue-${line.speaker}`
)
: undefined;
}
private releasePresentationTextures() {
this.ownedPresentationTextureKeys.forEach((textureKey) => {
releaseTextureSource(this, textureKey);
});
this.ownedPresentationTextureKeys.clear();
}
private advanceDialogue() {
const dialogue = this.dialogueState;
if (!dialogue || this.navigationPending) {
@@ -1287,33 +1510,113 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
if (this.textures.exists(view.definition.textureKey)) {
view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`);
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${npcDirection}`);
}
}
private gatherCommandParty() {
this.setNpcPosition('guan-yu', 650, 420, 'north');
this.setNpcPosition('zhang-fei', 890, 420, 'north');
private gatherCommandParty(onComplete: () => void) {
if (this.npcMovementPending) {
return;
}
this.npcMovementPending = true;
this.stopPlayerMovement();
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
const movements = [
{ npcId: 'guan-yu', x: 650, y: 420, direction: 'north' as const },
{ npcId: 'zhang-fei', x: 890, y: 420, direction: 'north' as const }
].filter(({ npcId }) => this.npcViews.has(npcId));
if (movements.length === 0) {
this.npcMovementPending = false;
onComplete();
return;
}
let remaining = movements.length;
movements.forEach(({ npcId, x, y, direction }) => {
this.walkNpcTo(npcId, x, y, direction, () => {
remaining -= 1;
if (remaining > 0) {
return;
}
this.npcMovementPending = false;
onComplete();
});
});
}
private walkNpcTo(
npcId: string,
x: number,
y: number,
finalDirection: ExplorationCharacterDirection,
onComplete: () => void
) {
const view = this.npcViews.get(npcId);
if (!view) {
onComplete();
return;
}
this.npcMoveTweens.get(npcId)?.remove();
const delta = new Phaser.Math.Vector2(x - view.sprite.x, y - view.sprite.y);
const walkDirection = delta.lengthSq() > 0
? this.directionForVector(delta)
: finalDirection;
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-walk-${walkDirection}`);
}
const duration = Math.max(220, Math.round(delta.length() / playerSpeed * 1000));
const tween = this.tweens.add({
targets: view.sprite,
x,
y,
duration,
ease: 'Sine.easeInOut',
onUpdate: () => this.syncNpcView(view),
onComplete: () => {
this.npcMoveTweens.delete(npcId);
this.syncNpcView(view);
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${finalDirection}`);
}
onComplete();
}
});
this.npcMoveTweens.set(npcId, tween);
}
private syncNpcView(view: CampNpcView) {
const { x, y } = view.sprite;
view.sprite.setDepth(110 + y);
view.shadow.setPosition(x, y + 20).setDepth(100 + y);
view.nameplate.setPosition(x, y + 59);
view.roleplate.setPosition(x, y + 88);
view.marker?.setPosition(x, y - 72);
}
private stopNpcMovement() {
this.npcMoveTweens.forEach((tween) => tween.remove());
this.npcMoveTweens.clear();
this.npcMovementPending = false;
}
private setNpcPosition(
npcId: string,
x: number,
y: number,
direction: UnitDirection
direction: ExplorationCharacterDirection
) {
const view = this.npcViews.get(npcId);
if (!view) {
return;
}
view.sprite.setPosition(x, y).setDepth(110 + y);
view.shadow.setPosition(x, y + 20).setDepth(100 + y);
view.nameplate.setPosition(x, y + 59);
view.roleplate.setPosition(x, y + 88);
view.sprite.setPosition(x, y);
this.syncNpcView(view);
view.marker?.setPosition(x, y - 72).setVisible(false);
if (this.textures.exists(view.definition.textureKey)) {
view.sprite.play(`${view.definition.textureKey}-idle-${direction}`);
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${direction}`);
}
}

View File

@@ -1,5 +1,11 @@
import Phaser from 'phaser';
import villageBackgroundUrl from '../../assets/images/exploration/prologue-village.webp';
import { soundDirector } from '../audio/SoundDirector';
import {
portraitAssetEntryForStoryContext,
portraitKeyForSpeaker,
type PortraitAssetEntry
} from '../data/portraitAssets';
import {
prologueBrotherhoodPages,
prologueVillageNpcDefinitions,
@@ -10,11 +16,15 @@ import {
type PrologueVillageRequiredObjectiveId
} from '../data/prologueVillage';
import {
ensureUnitAnimations,
loadUnitBaseSheets,
releaseUnitBaseSheetTextures,
type UnitDirection
} from '../data/unitAssets';
ensureExplorationCharacterAnimations,
explorationCharacterAssetInfos,
explorationCharacterTextureKeyForUnitTextureKey,
explorationCharacterTextureKeyByUnitTextureKey,
hasExplorationCharacterAsset,
releaseExplorationCharacterTextureKeys,
type ExplorationCharacterDirection,
type ExplorationCharacterTextureKey
} from '../data/explorationCharacterAssets';
import {
completeCampaignTutorial,
getCampaignState,
@@ -23,6 +33,7 @@ import {
prologueVillageCampaignTutorialIds,
type CampaignTutorialId
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
@@ -36,17 +47,71 @@ const interactionRadius = 122;
const promptRadius = 160;
const characterDisplaySize = 104;
const inputCarryoverGuardMs = 320;
const oathPosition = { x: 780, y: 405 };
const characterTextureKeys = [
'unit-liu-bei',
'unit-guan-yu',
'unit-zhang-fei',
'unit-shu-officer',
'unit-shu-infantry'
const narrativeNpcSpeed = 300;
const villageBackgroundKey = 'prologue-village-background';
const villageDialoguePortraitContext = {
id: 'prologue-village-dialogue',
background: 'story-three-heroes'
} as const;
const villageDialoguePortraitKeys = [
'liuBei',
'guanYu',
'zhangFei',
'zhuoRecruitingClerk',
'zhuoVillager'
] as const;
const oathPosition = { x: 780, y: 405 };
const narrativePositions = {
tavern: {
zhangFei: { x: 1000, y: 540, direction: 'east' as const }
},
recruitment: {
guanYu: { x: 850, y: 655, direction: 'east' as const },
zhangFei: { x: 810, y: 805, direction: 'east' as const }
},
oath: {
guanYu: { x: oathPosition.x - 104, y: oathPosition.y + 32, direction: 'east' as const },
zhangFei: { x: oathPosition.x + 104, y: oathPosition.y + 32, direction: 'west' as const }
}
};
const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei'];
const villageNpcTextureKeyById = {
'recruiting-clerk': 'exploration-zhuo-recruiting-clerk',
'market-villager': 'exploration-zhuo-villager'
} as const satisfies Readonly<Record<string, ExplorationCharacterTextureKey>>;
function explorationTextureKey(unitTextureKey: string): ExplorationCharacterTextureKey {
const textureKey = explorationCharacterTextureKeyForUnitTextureKey(unitTextureKey);
if (!textureKey) {
throw new Error(`Missing exploration character mapping for ${unitTextureKey}`);
}
return textureKey;
}
function explorationTextureKeyForVillageNpc(
npcId: string,
fallbackUnitTextureKey: string
): ExplorationCharacterTextureKey {
const preferredTextureKey =
villageNpcTextureKeyById[npcId as keyof typeof villageNpcTextureKeyById];
return preferredTextureKey && hasExplorationCharacterAsset(preferredTextureKey)
? preferredTextureKey
: explorationTextureKey(fallbackUnitTextureKey);
}
const characterTextureKeys = Object.freeze(
Array.from(new Set<ExplorationCharacterTextureKey>([
playerTextureKey,
explorationCharacterTextureKeyByUnitTextureKey['unit-guan-yu'],
explorationCharacterTextureKeyByUnitTextureKey['unit-zhang-fei'],
explorationTextureKeyForVillageNpc('recruiting-clerk', 'unit-shu-officer'),
explorationTextureKeyForVillageNpc('market-villager', 'unit-shu-infantry')
]))
);
type VillageNpcView = {
definition: PrologueVillageNpcDefinition;
textureKey: ExplorationCharacterTextureKey;
sprite: Phaser.GameObjects.Sprite;
shadow: Phaser.GameObjects.Ellipse;
nameplate: Phaser.GameObjects.Text;
@@ -72,16 +137,37 @@ type ObjectiveRowView = {
location: Phaser.GameObjects.Text;
};
type NarrativeNpcMove = {
npcId: string;
target: {
x: number;
y: number;
direction: ExplorationCharacterDirection;
};
};
const objectiveTutorialIds: Record<PrologueVillageRequiredObjectiveId, CampaignTutorialId> = {
'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei,
'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu,
'register-volunteers': prologueVillageCampaignTutorialIds.registerVolunteers
};
function villageDialoguePortraitEntries() {
return villageDialoguePortraitKeys
.map((portraitKey) => portraitAssetEntryForStoryContext(
portraitKey,
villageDialoguePortraitContext,
`village-${portraitKey}`
))
.filter((entry): entry is PortraitAssetEntry => entry !== undefined);
}
export class PrologueVillageScene extends Phaser.Scene {
private backgroundImage?: Phaser.GameObjects.Image;
private backgroundFallback = false;
private player?: Phaser.GameObjects.Sprite;
private playerShadow?: Phaser.GameObjects.Ellipse;
private playerDirection: UnitDirection = 'north';
private playerDirection: ExplorationCharacterDirection = 'north';
private playerMoving = false;
private npcViews = new Map<string, VillageNpcView>();
private blockers: Phaser.Geom.Rectangle[] = [];
@@ -92,7 +178,7 @@ export class PrologueVillageScene extends Phaser.Scene {
private oathMarker?: Phaser.GameObjects.Text;
private oathLabel?: Phaser.GameObjects.Text;
private dialoguePanel?: Phaser.GameObjects.Container;
private dialogueAvatar?: Phaser.GameObjects.Sprite;
private dialoguePortrait?: Phaser.GameObjects.Image;
private dialogueNameText?: Phaser.GameObjects.Text;
private dialogueBodyText?: Phaser.GameObjects.Text;
private dialogueProgressText?: Phaser.GameObjects.Text;
@@ -109,6 +195,8 @@ export class PrologueVillageScene extends Phaser.Scene {
};
private interactKeys: Phaser.Input.Keyboard.Key[] = [];
private moveTarget?: Phaser.Math.Vector2;
private npcMoveTweens = new Map<string, Phaser.Tweens.Tween>();
private oathGatherPending = false;
private ready = false;
private navigationPending = false;
private inputReadyAt = Number.POSITIVE_INFINITY;
@@ -116,11 +204,34 @@ export class PrologueVillageScene extends Phaser.Scene {
private stepIndex = 0;
private lastStepAt = 0;
private firstVisit = false;
private ownedPresentationTextureKeys = new Set<string>();
constructor() {
super('PrologueVillageScene');
}
preload() {
explorationCharacterAssetInfos(characterTextureKeys).forEach((asset) => {
if (!this.textures.exists(asset.key)) {
this.load.spritesheet(asset.key, asset.url, {
frameWidth: asset.frameWidth,
frameHeight: asset.frameHeight
});
}
});
if (!this.textures.exists(villageBackgroundKey)) {
this.ownedPresentationTextureKeys.add(villageBackgroundKey);
this.load.image(villageBackgroundKey, villageBackgroundUrl);
}
villageDialoguePortraitEntries().forEach(({ textureKey, url }) => {
if (this.textures.exists(textureKey)) {
return;
}
this.ownedPresentationTextureKeys.add(textureKey);
this.load.image(textureKey, url);
});
}
create() {
this.resetRuntimeState();
this.prepareCampaignProgress();
@@ -139,37 +250,35 @@ export class PrologueVillageScene extends Phaser.Scene {
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.ready = false;
this.navigationPending = false;
this.oathGatherPending = false;
this.moveTarget = undefined;
this.dialogueState = undefined;
releaseUnitBaseSheetTextures(this);
this.stopAllNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
this.releasePresentationTextures();
});
loadUnitBaseSheets(this, characterTextureKeys, () => {
if (!this.scene.isActive()) {
return;
}
ensureUnitAnimations(this, characterTextureKeys);
this.createActors();
this.restoreNarrativeActorPositions();
this.createDialoguePanel();
this.loadingOverlay?.destroy();
this.loadingOverlay = undefined;
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.refreshObjectiveHud();
this.refreshNpcMarkers();
this.refreshInteractionPrompt();
ensureExplorationCharacterAnimations(this, characterTextureKeys);
this.createActors();
this.restoreNarrativeActorPositions();
this.createDialoguePanel();
this.loadingOverlay?.destroy();
this.loadingOverlay = undefined;
this.ready = true;
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.refreshObjectiveHud();
this.refreshNpcMarkers();
this.refreshInteractionPrompt();
if (this.firstVisit) {
this.startDialogue([
{
if (this.firstVisit) {
this.startDialogue([
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.'
}
]);
}
});
}
]);
}
}
update(time: number, delta: number) {
@@ -178,6 +287,12 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
if (this.oathGatherPending) {
this.interactionQueued = false;
this.stopPlayerMovement();
return;
}
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
@@ -210,11 +325,24 @@ export class PrologueVillageScene extends Phaser.Scene {
ready: this.ready,
viewport: { width: this.scale.width, height: this.scale.height },
campaignStep: campaign.step,
background: {
key: this.backgroundImage?.texture.key ?? villageBackgroundKey,
ready: this.backgroundImage?.active === true,
fallback: this.backgroundFallback,
sourceWidth: this.backgroundImage?.frame.realWidth ?? null,
sourceHeight: this.backgroundImage?.frame.realHeight ?? null,
bounds: this.backgroundImage
? this.boundsSnapshot(this.backgroundImage.getBounds())
: null,
objectCount: this.backgroundImage?.active ? 1 : 0
},
player: playerPosition
? {
...playerPosition,
direction: this.playerDirection,
moving: this.playerMoving,
textureKey: this.player?.texture.key ?? null,
animationKey: this.player?.anims.currentAnim?.key ?? null,
bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null
}
: null,
@@ -265,6 +393,9 @@ export class PrologueVillageScene extends Phaser.Scene {
objectiveId: definition.objectiveId ?? null,
x: sprite.x,
y: sprite.y,
moving: this.npcMoveTweens.has(definition.id),
textureKey: sprite.texture.key,
animationKey: sprite.anims.currentAnim?.key ?? null,
distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null,
completed: definition.objectiveId ? this.isObjectiveComplete(definition.objectiveId) : false,
bounds: this.boundsSnapshot(sprite.getBounds())
@@ -276,6 +407,10 @@ export class PrologueVillageScene extends Phaser.Scene {
lineIndex: this.dialogueState.lineIndex,
totalLines: this.dialogueState.lines.length,
sourceNpcId: this.dialogueState.sourceNpcId ?? null,
portraitTextureKey: this.dialoguePortrait?.visible
? this.dialoguePortrait.texture.key
: null,
portraitVisible: this.dialoguePortrait?.visible ?? false,
bounds: dialogueBounds
}
: {
@@ -284,10 +419,20 @@ export class PrologueVillageScene extends Phaser.Scene {
lineIndex: null,
totalLines: 0,
sourceNpcId: null,
portraitTextureKey: null,
portraitVisible: false,
bounds: null
},
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
narrativeMovement: {
movingNpcIds: Array.from(this.npcMoveTweens.keys()),
oathGatherPending: this.oathGatherPending
},
navigationPending: this.navigationPending,
requiredTextures: characterTextureKeys.map((key) => ({
key,
ready: this.textures.exists(key)
})),
requiredTexturesReady: characterTextureKeys.every((key) => this.textures.exists(key))
};
}
@@ -334,6 +479,9 @@ export class PrologueVillageScene extends Phaser.Scene {
}
private resetRuntimeState() {
this.stopAllNpcMovement();
this.backgroundImage = undefined;
this.backgroundFallback = false;
this.player = undefined;
this.playerShadow = undefined;
this.playerDirection = 'north';
@@ -341,12 +489,14 @@ export class PrologueVillageScene extends Phaser.Scene {
this.npcViews.clear();
this.blockers = [];
this.objectiveRows.clear();
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.moveTarget = undefined;
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
this.interactionQueued = false;
this.oathGatherPending = false;
this.stepIndex = 0;
this.lastStepAt = 0;
}
@@ -364,16 +514,25 @@ export class PrologueVillageScene extends Phaser.Scene {
private drawVillage() {
this.cameras.main.setBackgroundColor('#28372a');
const graphics = this.add.graphics().setDepth(-100);
graphics.fillStyle(0x354a34, 1);
graphics.fillRect(0, 0, sceneWidth, sceneHeight);
graphics.fillStyle(0x42583a, 1);
graphics.fillRect(0, 92, mapRight, 868);
if (this.textures.exists(villageBackgroundKey)) {
this.backgroundImage = this.add.image(0, 0, villageBackgroundKey)
.setOrigin(0)
.setDepth(-120);
this.registerVillageCollisions();
this.drawVillageWayfinding();
} else {
this.backgroundFallback = true;
const graphics = this.add.graphics().setDepth(-100);
graphics.fillStyle(0x354a34, 1);
graphics.fillRect(0, 0, sceneWidth, sceneHeight);
graphics.fillStyle(0x42583a, 1);
graphics.fillRect(0, 92, mapRight, 868);
this.drawGroundTexture(graphics);
this.drawRoads(graphics);
this.drawVillageBuildings(graphics);
this.drawVillageDetails();
this.drawGroundTexture(graphics);
this.drawRoads(graphics);
this.drawVillageBuildings(graphics);
this.drawVillageDetails();
}
this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.96)
.setOrigin(0)
@@ -395,6 +554,38 @@ export class PrologueVillageScene extends Phaser.Scene {
.setDepth(1401);
}
private registerVillageCollisions() {
[
[105, 160, 410, 236],
[990, 148, 390, 226],
[1120, 665, 310, 215],
[1270, 414, 170, 112],
[647, 270, 38, 56],
[697, 313, 38, 52],
[832, 291, 38, 54],
[882, 261, 38, 56]
].forEach(([x, y, width, height]) => this.addBlocker(x, y, width, height));
}
private drawVillageWayfinding() {
[
[310, 350, '탁현 관아'],
[1184, 350, '장터 주점'],
[1270, 850, '탁현 모병소'],
[780, 206, '복숭아 동산'],
[780, 116, '북문']
].forEach(([x, y, label]) => {
this.add.text(Number(x), Number(y), String(label), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#f6e5bc',
fontStyle: 'bold',
backgroundColor: '#19140fd4',
padding: { left: 10, right: 10, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(45);
});
}
private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) {
for (let index = 0; index < 150; index += 1) {
const x = 25 + ((index * 83) % 1435);
@@ -716,14 +907,18 @@ export class PrologueVillageScene extends Phaser.Scene {
private createActors() {
this.playerShadow = this.add.ellipse(750, 870, 66, 25, 0x07100a, 0.45).setDepth(800);
this.player = this.createCharacterSprite('unit-liu-bei', 750, 850, characterDisplaySize + 8, 'north');
this.player = this.createCharacterSprite(playerTextureKey, 750, 850, characterDisplaySize + 8, 'north');
this.player.setDepth(851);
prologueVillageNpcDefinitions.forEach((definition) => {
const textureKey = explorationTextureKeyForVillageNpc(
definition.id,
definition.textureKey
);
const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4)
.setDepth(100 + definition.y);
const sprite = this.createCharacterSprite(
definition.textureKey,
textureKey,
definition.x,
definition.y,
characterDisplaySize,
@@ -757,7 +952,7 @@ export class PrologueVillageScene extends Phaser.Scene {
ease: 'Sine.easeInOut'
});
}
this.npcViews.set(definition.id, { definition, sprite, shadow, nameplate, marker });
this.npcViews.set(definition.id, { definition, textureKey, sprite, shadow, nameplate, marker });
});
this.oathMarker = this.add.text(oathPosition.x, oathPosition.y - 58, '◇', {
@@ -786,11 +981,11 @@ export class PrologueVillageScene extends Phaser.Scene {
}
private createCharacterSprite(
textureKey: string,
textureKey: ExplorationCharacterTextureKey,
x: number,
y: number,
size: number,
direction: UnitDirection
direction: ExplorationCharacterDirection
) {
const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT';
const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size);
@@ -814,22 +1009,26 @@ export class PrologueVillageScene extends Phaser.Scene {
const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82)
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.7);
const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92)
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0)
.setDisplaySize(186, 186);
this.dialogueNameText = this.add.text(x + 222, y + 42, '', {
const firstPortraitKey = villageDialoguePortraitEntries()
.find(({ textureKey }) => this.textures.exists(textureKey))
?.textureKey ?? '__DEFAULT';
this.dialoguePortrait = this.add.image(x + 132, y + 152, firstPortraitKey)
.setDisplaySize(218, 218)
.setVisible(false);
this.dialogueNameText = this.add.text(x + 278, y + 42, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '29px',
color: '#f1d691',
fontStyle: 'bold'
});
this.dialogueBodyText = this.add.text(x + 222, y + 101, '', {
this.dialogueBodyText = this.add.text(x + 278, y + 101, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#f0ede5',
lineSpacing: 11,
wordWrap: { width: 1440, useAdvancedWrap: true }
wordWrap: { width: 1382, useAdvancedWrap: true }
});
this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -841,8 +1040,8 @@ export class PrologueVillageScene extends Phaser.Scene {
shade,
background,
inner,
avatarFrame,
this.dialogueAvatar,
portraitFrame,
this.dialoguePortrait,
this.dialogueNameText,
this.dialogueBodyText,
this.dialogueProgressText
@@ -879,7 +1078,12 @@ export class PrologueVillageScene extends Phaser.Scene {
}
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
if (
!this.ready ||
this.navigationPending ||
this.oathGatherPending ||
this.time.now < this.inputReadyAt
) {
return;
}
if (this.dialogueState) {
@@ -975,7 +1179,7 @@ export class PrologueVillageScene extends Phaser.Scene {
return vector;
}
private directionForVector(vector: Phaser.Math.Vector2): UnitDirection {
private directionForVector(vector: Phaser.Math.Vector2): ExplorationCharacterDirection {
if (Math.abs(vector.x) > Math.abs(vector.y)) {
return vector.x > 0 ? 'east' : 'west';
}
@@ -1009,8 +1213,8 @@ export class PrologueVillageScene extends Phaser.Scene {
if (!this.player) {
return;
}
const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) {
const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) {
this.player.play(animationKey);
}
this.playerMoving = moving;
@@ -1039,40 +1243,176 @@ export class PrologueVillageScene extends Phaser.Scene {
this.stopPlayerMovement();
}
private gatherOathParty() {
this.playerDirection = 'north';
this.setPlayerPosition(oathPosition.x, oathPosition.y + 125);
this.setNpcPosition('guan-yu', oathPosition.x - 104, oathPosition.y + 32, 'east');
this.setNpcPosition('zhang-fei', oathPosition.x + 104, oathPosition.y + 32, 'west');
private gatherOathParty(onComplete: () => void) {
if (this.oathGatherPending) {
return;
}
this.oathGatherPending = true;
this.stopPlayerMovement();
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
if (this.player) {
const toOath = new Phaser.Math.Vector2(
oathPosition.x - this.player.x,
oathPosition.y - this.player.y
);
if (toOath.lengthSq() > 0) {
this.playerDirection = this.directionForVector(toOath);
}
this.setPlayerMoving(false);
}
this.walkNpcGroup([
{ npcId: 'guan-yu', target: narrativePositions.oath.guanYu },
{ npcId: 'zhang-fei', target: narrativePositions.oath.zhangFei }
], () => {
this.oathGatherPending = false;
if (!this.scene.isActive() || this.navigationPending) {
return;
}
onComplete();
});
}
private restoreNarrativeActorPositions() {
if (this.isObjectiveComplete('register-volunteers')) {
this.setNpcPosition('guan-yu', narrativePositions.oath.guanYu);
this.setNpcPosition('zhang-fei', narrativePositions.oath.zhangFei);
return;
}
if (this.isObjectiveComplete('meet-guan-yu')) {
this.setNpcPosition('guan-yu', 850, 655, 'east');
this.setNpcPosition('zhang-fei', 810, 805, 'east');
this.setNpcPosition('guan-yu', narrativePositions.recruitment.guanYu);
this.setNpcPosition('zhang-fei', narrativePositions.recruitment.zhangFei);
return;
}
if (this.isObjectiveComplete('meet-zhang-fei')) {
this.setNpcPosition('zhang-fei', 1000, 540, 'east');
this.setNpcPosition('zhang-fei', narrativePositions.tavern.zhangFei);
}
}
private setNpcPosition(
npcId: string,
x: number,
y: number,
direction: UnitDirection
target: {
x: number;
y: number;
direction: ExplorationCharacterDirection;
}
) {
const view = this.npcViews.get(npcId);
if (!view) {
return;
}
view.sprite.setPosition(x, y).setDepth(110 + y);
this.stopNpcMovement(npcId);
view.sprite.setPosition(target.x, target.y);
view.marker?.setVisible(false);
this.syncNpcView(view);
this.playNpcAnimation(view, false, target.direction);
}
private walkNpcGroup(moves: NarrativeNpcMove[], onComplete?: () => void) {
if (!moves.length) {
onComplete?.();
return;
}
let remaining = moves.length;
const completeMove = () => {
remaining -= 1;
if (remaining === 0) {
onComplete?.();
}
};
moves.forEach(({ npcId, target }) => this.walkNpcTo(npcId, target, completeMove));
}
private walkNpcTo(
npcId: string,
target: NarrativeNpcMove['target'],
onComplete?: () => void
) {
const view = this.npcViews.get(npcId);
if (!view) {
onComplete?.();
return;
}
this.stopNpcMovement(npcId);
const movement = new Phaser.Math.Vector2(
target.x - view.sprite.x,
target.y - view.sprite.y
);
const distance = movement.length();
view.marker?.setVisible(false);
if (distance <= 1) {
view.sprite.setPosition(target.x, target.y);
this.syncNpcView(view);
this.playNpcAnimation(view, false, target.direction);
this.refreshNpcMarkers();
onComplete?.();
return;
}
const movementDirection = this.directionForVector(movement);
this.playNpcAnimation(view, true, movementDirection);
let tween: Phaser.Tweens.Tween | undefined;
tween = this.tweens.add({
targets: view.sprite,
x: target.x,
y: target.y,
duration: Math.max(120, distance / narrativeNpcSpeed * 1000),
ease: 'Linear',
onUpdate: () => this.syncNpcView(view),
onComplete: () => {
if (!tween || this.npcMoveTweens.get(npcId) !== tween) {
return;
}
this.npcMoveTweens.delete(npcId);
view.sprite.setPosition(target.x, target.y);
this.syncNpcView(view);
this.playNpcAnimation(view, false, target.direction);
this.refreshNpcMarkers();
onComplete?.();
}
});
this.npcMoveTweens.set(npcId, tween);
}
private stopNpcMovement(npcId: string) {
const tween = this.npcMoveTweens.get(npcId);
if (!tween) {
return;
}
this.npcMoveTweens.delete(npcId);
tween.stop();
}
private stopAllNpcMovement() {
Array.from(this.npcMoveTweens.keys()).forEach((npcId) => this.stopNpcMovement(npcId));
}
private syncNpcView(view: VillageNpcView) {
const { x, y } = view.sprite;
view.sprite.setDepth(110 + y);
view.shadow.setPosition(x, y + 20).setDepth(100 + y);
view.nameplate.setPosition(x, y + 63);
view.marker?.setPosition(x, y - 72).setVisible(false);
if (this.textures.exists(view.definition.textureKey)) {
view.sprite.play(`${view.definition.textureKey}-idle-${direction}`);
if (view.marker && !view.marker.visible) {
view.marker.setPosition(x, y - 72);
}
}
private playNpcAnimation(
view: VillageNpcView,
moving: boolean,
direction: ExplorationCharacterDirection
) {
if (!this.textures.exists(view.textureKey)) {
return;
}
const animationKey = `${view.textureKey}-${moving ? 'walk' : 'idle'}-${direction}`;
if (view.sprite.anims.currentAnim?.key !== animationKey) {
view.sprite.play(animationKey);
}
}
@@ -1141,7 +1481,10 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
this.gatherOathParty();
this.gatherOathParty(() => this.startOathDialogue());
}
private startOathDialogue() {
this.startDialogue([
{
speaker: '장비',
@@ -1194,17 +1537,37 @@ export class PrologueVillageScene extends Phaser.Scene {
this.dialogueProgressText?.setText(
`${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속`
);
if (this.dialogueAvatar) {
const textureKey = line.textureKey;
if (textureKey && this.textures.exists(textureKey)) {
this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true);
this.dialogueAvatar.play(`${textureKey}-idle-south`);
if (this.dialoguePortrait) {
const entry = this.dialoguePortraitEntry(line);
if (entry && this.textures.exists(entry.textureKey)) {
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
} else {
this.dialogueAvatar.setVisible(false);
this.dialoguePortrait.setVisible(false);
}
}
}
private dialoguePortraitEntry(line: PrologueVillageDialogueLine) {
const portraitKey = line.portrait ?? portraitKeyForSpeaker(line.speaker);
return portraitKey
? portraitAssetEntryForStoryContext(
portraitKey,
villageDialoguePortraitContext,
`village-dialogue-${line.speaker}`
)
: undefined;
}
private releasePresentationTextures() {
this.ownedPresentationTextureKeys.forEach((textureKey) => {
releaseTextureSource(this, textureKey);
});
this.ownedPresentationTextureKeys.clear();
}
private advanceDialogue() {
const dialogue = this.dialogueState;
if (!dialogue || this.navigationPending) {
@@ -1231,10 +1594,17 @@ export class PrologueVillageScene extends Phaser.Scene {
}
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
if (objectiveId === 'meet-zhang-fei') {
this.setNpcPosition('zhang-fei', 1000, 540, 'east');
this.walkNpcTo('zhang-fei', narrativePositions.tavern.zhangFei);
} else if (objectiveId === 'meet-guan-yu') {
this.setNpcPosition('guan-yu', 850, 655, 'east');
this.setNpcPosition('zhang-fei', 810, 805, 'east');
this.walkNpcGroup([
{ npcId: 'guan-yu', target: narrativePositions.recruitment.guanYu },
{ npcId: 'zhang-fei', target: narrativePositions.recruitment.zhangFei }
]);
} else if (objectiveId === 'register-volunteers') {
this.walkNpcGroup([
{ npcId: 'guan-yu', target: narrativePositions.oath.guanYu },
{ npcId: 'zhang-fei', target: narrativePositions.oath.zhangFei }
]);
}
soundDirector.playObjectiveAchieved();
this.refreshObjectiveHud();
@@ -1378,6 +1748,9 @@ export class PrologueVillageScene extends Phaser.Scene {
}
const completed = this.isObjectiveComplete(definition.objectiveId);
const unlocked = this.objectiveUnlocked(definition.objectiveId);
const joinedCompanion = completed &&
(definition.id === 'zhang-fei' || definition.id === 'guan-yu');
marker.setVisible(!this.npcMoveTweens.has(definition.id) && !joinedCompanion);
marker.setText(completed ? '✓' : unlocked ? '!' : '◇');
marker.setColor(completed ? '#e5f2d5' : unlocked ? '#2a2013' : '#9b8f7d');
marker.setBackgroundColor(completed ? '#4e7549' : unlocked ? '#e5bd68' : '#343b45');
@@ -1409,13 +1782,15 @@ export class PrologueVillageScene extends Phaser.Scene {
return undefined;
}
const targets: InteractionTarget[] = [
...Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({
kind: 'npc' as const,
id: definition.id,
name: definition.name,
x: sprite.x,
y: sprite.y
})),
...Array.from(this.npcViews.values())
.filter(({ definition }) => !this.npcMoveTweens.has(definition.id))
.map(({ definition, sprite }) => ({
kind: 'npc' as const,
id: definition.id,
name: definition.name,
x: sprite.x,
y: sprite.y
})),
{
kind: 'oath' as const,
id: 'make-oath' as const,
@@ -1460,8 +1835,8 @@ export class PrologueVillageScene extends Phaser.Scene {
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
if (this.textures.exists(view.definition.textureKey)) {
view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`);
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${npcDirection}`);
}
}