feat: upgrade prologue exploration presentation
|
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||