Files
heros_web/src/game/scenes/PrologueVillageScene.ts

1876 lines
63 KiB
TypeScript

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,
prologueVillageObjectiveDefinitions,
prologueVillageRequiredObjectiveIds,
type PrologueVillageDialogueLine,
type PrologueVillageNpcDefinition,
type PrologueVillageRequiredObjectiveId
} from '../data/prologueVillage';
import {
ensureExplorationCharacterAnimations,
explorationCharacterAssetInfos,
explorationCharacterTextureKeyForUnitTextureKey,
explorationCharacterTextureKeyByUnitTextureKey,
hasExplorationCharacterAsset,
releaseExplorationCharacterTextureKeys,
type ExplorationCharacterDirection,
type ExplorationCharacterTextureKey
} from '../data/explorationCharacterAssets';
import {
completeCampaignTutorial,
getCampaignState,
hasCompletedCampaignTutorial,
markCampaignStep,
prologueVillageCampaignTutorialIds,
type CampaignTutorialId
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
const sceneWidth = 1920;
const sceneHeight = 1080;
const mapRight = 1495;
const movementBounds = new Phaser.Geom.Rectangle(42, 108, 1410, 814);
const playerSpeed = 300;
const playerCollisionRadius = 25;
const interactionRadius = 122;
const promptRadius = 160;
const characterDisplaySize = 104;
const inputCarryoverGuardMs = 320;
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;
marker?: Phaser.GameObjects.Text;
};
type DialogueState = {
lines: PrologueVillageDialogueLine[];
lineIndex: number;
onComplete?: () => void;
sourceNpcId?: string;
};
type InteractionTarget =
| { kind: 'npc'; id: string; name: string; x: number; y: number }
| { kind: 'oath'; id: 'make-oath'; name: string; x: number; y: number };
type ObjectiveRowView = {
id: PrologueVillageRequiredObjectiveId | 'make-oath';
background: Phaser.GameObjects.Rectangle;
status: Phaser.GameObjects.Text;
label: Phaser.GameObjects.Text;
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: ExplorationCharacterDirection = 'north';
private playerMoving = false;
private npcViews = new Map<string, VillageNpcView>();
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 oathMarker?: Phaser.GameObjects.Text;
private oathLabel?: Phaser.GameObjects.Text;
private dialoguePanel?: Phaser.GameObjects.Container;
private dialoguePortrait?: Phaser.GameObjects.Image;
private dialogueNameText?: Phaser.GameObjects.Text;
private dialogueBodyText?: Phaser.GameObjects.Text;
private dialogueProgressText?: Phaser.GameObjects.Text;
private dialogueState?: DialogueState;
private loadingOverlay?: Phaser.GameObjects.Container;
private transitionOverlay?: Phaser.GameObjects.Container;
private completionToast?: Phaser.GameObjects.Container;
private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;
private moveKeys?: {
up: Phaser.Input.Keyboard.Key;
down: Phaser.Input.Keyboard.Key;
left: Phaser.Input.Keyboard.Key;
right: Phaser.Input.Keyboard.Key;
};
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;
private interactionQueued = false;
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();
this.drawVillage();
this.drawHud();
this.createLoadingOverlay();
this.setupInput();
soundDirector.playSoundscape({
musicKey: 'militia-theme',
ambienceKey: 'mountain-wind-ambience',
musicVolume: 0.72,
ambienceVolume: 0.11
});
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.ready = false;
this.navigationPending = false;
this.oathGatherPending = false;
this.moveTarget = undefined;
this.dialogueState = undefined;
this.stopAllNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
this.releasePresentationTextures();
});
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([
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.'
}
]);
}
}
update(time: number, delta: number) {
if (!this.ready || this.navigationPending || time < this.inputReadyAt) {
this.interactionQueued = false;
return;
}
if (this.oathGatherPending) {
this.interactionQueued = false;
this.stopPlayerMovement();
return;
}
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
this.advanceDialogue();
}
return;
}
if (this.consumeInteractionRequest()) {
this.tryInteract();
return;
}
this.updateMovement(time, delta);
this.refreshInteractionPrompt();
}
getDebugState() {
const campaign = getCampaignState();
const playerPosition = this.player
? { x: this.player.x, y: this.player.y }
: null;
const target = this.nearestInteractionTarget(promptRadius);
const dialogueBounds = this.dialoguePanel?.visible
? this.boundsSnapshot(this.dialoguePanel.getBounds())
: null;
return {
scene: this.scene.key,
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,
movement: {
speed: playerSpeed,
target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null,
walkBounds: this.boundsSnapshot(movementBounds),
collisionRadius: playerCollisionRadius
},
controls: {
movement: ['WASD', '방향키'],
interact: ['E', 'Space', 'Enter'],
pointerMove: true,
carryoverGuardMs: inputCarryoverGuardMs
},
interaction: {
radius: interactionRadius,
promptRadius,
targetId: target?.id ?? null,
canInteract: Boolean(target && this.distanceTo(target.x, target.y) <= interactionRadius),
promptVisible: this.promptBackground?.visible ?? false,
promptBounds: this.promptBackground?.visible
? this.boundsSnapshot(this.promptBackground.getBounds())
: null
},
objectives: prologueVillageObjectiveDefinitions.map((objective) => ({
id: objective.id,
label: objective.label,
location: objective.location,
completed: objective.id === 'make-oath'
? hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete)
: this.isObjectiveComplete(objective.id),
unlocked: objective.id === 'make-oath'
? this.allRequiredObjectivesComplete()
: this.objectiveUnlocked(objective.id)
})),
completedObjectiveIds: this.completedRequiredObjectiveIds(),
exitUnlocked: this.allRequiredObjectivesComplete(),
oath: {
x: oathPosition.x,
y: oathPosition.y,
visible: this.oathMarker?.visible ?? false,
completed: hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete)
},
npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({
id: definition.id,
name: definition.name,
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())
})),
dialogue: this.dialogueState
? {
active: true,
speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '',
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
}
: {
active: false,
speaker: null,
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))
};
}
debugTeleportTo(targetId: string) {
if (!this.player || !this.ready || this.navigationPending) {
return false;
}
const target = this.interactionTargetById(targetId);
if (!target) {
return false;
}
const candidate = this.safeApproachPosition(target.x, target.y);
this.setPlayerPosition(candidate.x, candidate.y);
this.moveTarget = undefined;
this.refreshInteractionPrompt();
return true;
}
debugInteractWith(targetId: string) {
if (!this.debugTeleportTo(targetId)) {
return false;
}
this.interactWithTarget(this.interactionTargetById(targetId)!);
return true;
}
debugCompleteRequiredObjectives() {
prologueVillageRequiredObjectiveIds.forEach((objectiveId) => {
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
});
this.refreshObjectiveHud();
this.refreshNpcMarkers();
return this.completedRequiredObjectiveIds();
}
debugCompleteVillage() {
if (!this.ready || this.navigationPending) {
return false;
}
this.debugCompleteRequiredObjectives();
this.finishVillage();
return true;
}
private resetRuntimeState() {
this.stopAllNpcMovement();
this.backgroundImage = undefined;
this.backgroundFallback = false;
this.player = undefined;
this.playerShadow = undefined;
this.playerDirection = 'north';
this.playerMoving = false;
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;
}
private prepareCampaignProgress() {
const campaign = getCampaignState();
this.firstVisit = !campaign.completedTutorialIds.includes(
prologueVillageCampaignTutorialIds.entered
);
if (campaign.step === 'new' || campaign.step === 'prologue') {
markCampaignStep('prologue-town');
}
completeCampaignTutorial(prologueVillageCampaignTutorialIds.entered);
}
private drawVillage() {
this.cameras.main.setBackgroundColor('#28372a');
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.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.96)
.setOrigin(0)
.setDepth(1400);
this.add.rectangle(mapRight, 0, 3, sceneHeight, palette.gold, 0.72)
.setOrigin(0)
.setDepth(1401);
this.add.rectangle(0, 0, sceneWidth, 92, 0x111720, 0.96)
.setOrigin(0)
.setDepth(1400);
this.add.rectangle(0, 90, sceneWidth, 2, palette.gold, 0.68)
.setOrigin(0)
.setDepth(1401);
this.add.rectangle(0, 958, mapRight, 122, 0x10151c, 0.94)
.setOrigin(0)
.setDepth(1400);
this.add.rectangle(0, 956, mapRight, 2, palette.gold, 0.54)
.setOrigin(0)
.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);
const y = 116 + ((index * 137) % 815);
const tone = index % 3 === 0 ? 0x55704a : index % 3 === 1 ? 0x2c4430 : 0x6a7447;
graphics.fillStyle(tone, 0.28);
graphics.fillCircle(x, y, index % 4 === 0 ? 3 : 2);
}
}
private drawRoads(graphics: Phaser.GameObjects.Graphics) {
graphics.fillStyle(0xb59362, 1);
graphics.fillRoundedRect(650, 92, 255, 868, 42);
graphics.fillRoundedRect(70, 525, 1340, 190, 55);
graphics.fillRoundedRect(865, 390, 390, 190, 48);
graphics.fillStyle(0xd0b47c, 0.42);
graphics.fillRoundedRect(685, 92, 58, 868, 28);
graphics.fillRoundedRect(72, 566, 1338, 38, 18);
graphics.fillStyle(0x8a6c48, 0.44);
for (let y = 126; y < 930; y += 64) {
graphics.fillEllipse(804 + (y % 3) * 9, y, 44, 13);
}
for (let x = 110; x < 1390; x += 76) {
graphics.fillEllipse(x, 655 + (x % 4) * 5, 38, 12);
}
}
private drawVillageBuildings(graphics: Phaser.GameObjects.Graphics) {
this.drawBuilding(graphics, {
x: 105,
y: 160,
width: 410,
height: 236,
wallColor: 0xb99a68,
roofColor: 0x6d2f26,
sign: '탁현 관아'
});
this.addBlocker(105, 160, 410, 236);
this.drawBuilding(graphics, {
x: 990,
y: 148,
width: 390,
height: 226,
wallColor: 0xc2a974,
roofColor: 0x31475a,
sign: '장터 주점'
});
this.addBlocker(990, 148, 390, 226);
this.drawBuilding(graphics, {
x: 1120,
y: 665,
width: 310,
height: 215,
wallColor: 0xa98d61,
roofColor: 0x493a32,
sign: '탁현 모병소'
});
this.addBlocker(1120, 665, 310, 215);
graphics.fillStyle(0x47362a, 1);
graphics.fillRect(706, 94, 150, 48);
graphics.fillStyle(0x252018, 1);
graphics.fillRect(733, 94, 18, 120);
graphics.fillRect(812, 94, 18, 120);
graphics.fillStyle(palette.gold, 0.9);
graphics.fillRect(710, 140, 142, 5);
this.add.text(781, 117, '북문', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#f3dfaa',
fontStyle: 'bold'
}).setOrigin(0.5).setDepth(40);
}
private drawBuilding(
graphics: Phaser.GameObjects.Graphics,
building: {
x: number;
y: number;
width: number;
height: number;
wallColor: number;
roofColor: number;
sign: string;
}
) {
const { x, y, width, height, wallColor, roofColor, sign } = building;
graphics.fillStyle(0x15120f, 0.24);
graphics.fillRoundedRect(x + 16, y + 18, width, height, 14);
graphics.fillStyle(wallColor, 1);
graphics.fillRoundedRect(x, y + 58, width, height - 58, 10);
graphics.lineStyle(3, 0x392a22, 0.8);
graphics.strokeRoundedRect(x, y + 58, width, height - 58, 10);
graphics.fillStyle(roofColor, 1);
graphics.fillPoints([
new Phaser.Geom.Point(x - 24, y + 72),
new Phaser.Geom.Point(x + width / 2, y),
new Phaser.Geom.Point(x + width + 24, y + 72),
new Phaser.Geom.Point(x + width - 3, y + 100),
new Phaser.Geom.Point(x + 3, y + 100)
], true);
graphics.lineStyle(5, 0x211b19, 0.72);
graphics.lineBetween(x - 22, y + 72, x + width + 22, y + 72);
graphics.fillStyle(0x34271f, 1);
graphics.fillRect(x + width / 2 - 30, y + height - 72, 60, 72);
graphics.fillStyle(0x16110e, 0.8);
graphics.fillRect(x + 56, y + 132, 62, 45);
graphics.fillRect(x + width - 118, y + 132, 62, 45);
this.add.text(x + width / 2, y + 104, sign, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '23px',
color: '#2a2019',
fontStyle: 'bold',
backgroundColor: '#dfc792',
padding: { left: 13, right: 13, top: 6, bottom: 6 }
}).setOrigin(0.5).setDepth(42);
}
private drawVillageDetails() {
[
[585, 180, 0.9],
[935, 210, 0.85],
[570, 350, 0.78],
[930, 345, 0.82],
[575, 800, 0.9],
[950, 840, 0.82],
[95, 820, 0.82],
[250, 875, 0.75]
].forEach(([x, y, scale]) => this.drawTree(x, y, scale));
const stall = this.add.graphics().setDepth(32);
stall.fillStyle(0x6a4330, 1);
stall.fillRect(1280, 442, 150, 78);
stall.fillStyle(0xd7bc78, 1);
stall.fillTriangle(1260, 445, 1450, 445, 1355, 394);
stall.fillStyle(0x982f2c, 0.78);
stall.fillRect(1278, 430, 154, 18);
this.addBlocker(1270, 414, 170, 112);
const weapons = this.add.graphics().setDepth(34);
weapons.lineStyle(6, 0x544334, 1);
weapons.lineBetween(1090, 710, 1090, 828);
weapons.lineBetween(1062, 808, 1118, 808);
weapons.lineStyle(4, 0x9eabb5, 1);
weapons.lineBetween(1070, 710, 1110, 805);
weapons.lineBetween(1110, 710, 1070, 805);
const notice = this.add.graphics().setDepth(34);
notice.fillStyle(0x4d3826, 1);
notice.fillRect(264, 412, 12, 92);
notice.fillRect(440, 412, 12, 92);
notice.fillRect(250, 402, 216, 18);
notice.fillStyle(0xe3d4a8, 1);
notice.fillRect(286, 427, 144, 62);
notice.lineStyle(2, 0x765e3d, 0.8);
notice.strokeRect(286, 427, 144, 62);
this.add.text(358, 457, '의병 모집', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#3c2a1c',
fontStyle: 'bold'
}).setOrigin(0.5).setDepth(35);
this.add.text(780, 244, '복숭아 동산', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '28px',
color: '#ffe1df',
fontStyle: 'bold',
stroke: '#4b252d',
strokeThickness: 6
}).setOrigin(0.5).setDepth(45);
const blossomGraphics = this.add.graphics().setDepth(25);
[
[665, 280],
[715, 325],
[850, 305],
[900, 260]
].forEach(([x, y]) => {
blossomGraphics.fillStyle(0x51352e, 1);
blossomGraphics.fillRect(x - 5, y, 10, 54);
blossomGraphics.fillStyle(0xf3b4bd, 0.95);
blossomGraphics.fillCircle(x - 18, y, 29);
blossomGraphics.fillCircle(x + 18, y - 5, 31);
blossomGraphics.fillCircle(x, y - 24, 32);
blossomGraphics.fillStyle(0xffd0d5, 0.9);
blossomGraphics.fillCircle(x + 4, y - 4, 22);
});
}
private drawTree(x: number, y: number, scale: number) {
const graphics = this.add.graphics().setDepth(20 + y / 100);
graphics.fillStyle(0x3f2b20, 1);
graphics.fillRoundedRect(x - 8 * scale, y, 16 * scale, 58 * scale, 5);
graphics.fillStyle(0x25432c, 1);
graphics.fillCircle(x - 21 * scale, y - 4 * scale, 37 * scale);
graphics.fillCircle(x + 22 * scale, y - 9 * scale, 40 * scale);
graphics.fillStyle(0x3c6740, 1);
graphics.fillCircle(x, y - 33 * scale, 43 * scale);
graphics.fillStyle(0x648057, 0.7);
graphics.fillCircle(x - 9 * scale, y - 44 * scale, 20 * scale);
this.addBlocker(x - 16 * scale, y + 12 * scale, 32 * scale, 43 * scale);
}
private addBlocker(x: number, y: number, width: number, height: number) {
this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height));
}
private drawHud() {
this.add.text(48, 27, '탁현 · 의병 모집 격문이 붙은 날', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '34px',
color: '#f4e3b5',
fontStyle: 'bold'
}).setDepth(1500);
this.add.text(650, 36, '유비의 첫걸음', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '20px',
color: '#b4c0cb'
}).setDepth(1500);
this.add.text(1536, 45, '출전 준비', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '31px',
color: '#f4e3b5',
fontStyle: 'bold'
}).setDepth(1500);
this.add.text(1538, 92, '마을을 걸어 다니며 사람들과 직접 이야기하세요.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#9fabb8',
wordWrap: { width: 330 }
}).setDepth(1500);
prologueVillageObjectiveDefinitions.forEach((objective, index) => {
const y = 168 + index * 154;
const background = this.add.rectangle(1530, y, 350, 132, 0x1b222d, 0.94)
.setOrigin(0)
.setStrokeStyle(2, 0x3d4858, 1)
.setDepth(1500);
const status = this.add.text(1550, y + 19, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1501);
const label = this.add.text(1550, y + 48, objective.shortLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#e8dfca',
fontStyle: 'bold'
}).setDepth(1501);
const location = this.add.text(1550, y + 87, objective.location, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px',
color: '#9aa3ad'
}).setDepth(1501);
this.objectiveRows.set(objective.id, { id: objective.id, background, status, label, location });
});
this.objectiveSummaryText = this.add.text(1538, 810, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#cbd3dc',
lineSpacing: 8,
wordWrap: { width: 330 }
}).setDepth(1501);
this.add.text(46, 980, '이동', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1502);
this.add.text(46, 1012, 'WASD / 방향키 · 바닥 클릭', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#e3e7eb'
}).setDepth(1502);
this.add.text(445, 980, '대화', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d8b15f',
fontStyle: 'bold'
}).setDepth(1502);
this.add.text(445, 1012, '가까이에서 E / Space / Enter', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#e3e7eb'
}).setDepth(1502);
this.promptBackground = this.add.rectangle(1110, 1018, 560, 58, 0x2a2119, 0.98)
.setStrokeStyle(2, palette.gold, 0.9)
.setDepth(1510)
.setVisible(false);
this.promptText = this.add.text(1110, 1018, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: '#f6e3af',
fontStyle: 'bold'
}).setOrigin(0.5).setDepth(1511).setVisible(false);
}
private createLoadingOverlay() {
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.72).setOrigin(0);
const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 500, 126, 0x151b24, 0.98)
.setStrokeStyle(2, palette.gold, 0.8);
const text = this.add.text(sceneWidth / 2, sceneHeight / 2, '탁현의 사람들을 불러오는 중...', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#f3dfaa'
}).setOrigin(0.5);
this.loadingOverlay = this.add.container(0, 0, [shade, panel, text]).setDepth(4000);
}
private createActors() {
this.playerShadow = this.add.ellipse(750, 870, 66, 25, 0x07100a, 0.45).setDepth(800);
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(
textureKey,
definition.x,
definition.y,
characterDisplaySize,
definition.direction
);
sprite.setDepth(110 + definition.y);
const nameplate = this.add.text(definition.x, definition.y + 63, definition.name, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#f5ead0',
backgroundColor: '#14181ccc',
padding: { left: 8, right: 8, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(1200);
const marker = definition.objectiveId
? this.add.text(definition.x, definition.y - 72, '!', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '31px',
color: '#2a2013',
fontStyle: 'bold',
backgroundColor: '#e5bd68',
padding: { left: 11, right: 11, top: 2, bottom: 2 }
}).setOrigin(0.5).setDepth(1201)
: undefined;
if (marker) {
this.tweens.add({
targets: marker,
y: marker.y - 8,
duration: 720,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
}
this.npcViews.set(definition.id, { definition, textureKey, sprite, shadow, nameplate, marker });
});
this.oathMarker = this.add.text(oathPosition.x, oathPosition.y - 58, '◇', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '35px',
color: '#88756c',
fontStyle: 'bold',
stroke: '#341e27',
strokeThickness: 5
}).setOrigin(0.5).setDepth(1202);
this.oathLabel = this.add.text(oathPosition.x, oathPosition.y + 42, '결의의 자리', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#d9c0c1',
backgroundColor: '#2c2025dd',
padding: { left: 10, right: 10, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(1201);
this.tweens.add({
targets: [this.oathMarker, this.oathLabel],
alpha: { from: 0.7, to: 1 },
duration: 900,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
}
private createCharacterSprite(
textureKey: ExplorationCharacterTextureKey,
x: number,
y: number,
size: number,
direction: ExplorationCharacterDirection
) {
const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT';
const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size);
if (this.textures.exists(textureKey)) {
sprite.play(`${textureKey}-idle-${direction}`);
}
return sprite;
}
private createDialoguePanel() {
const x = 72;
const y = 710;
const width = 1776;
const height = 304;
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x06080b, 0.44)
.setOrigin(0)
.setInteractive();
const background = this.add.rectangle(x, y, width, height, 0x141a23, 0.985)
.setOrigin(0)
.setStrokeStyle(3, palette.gold, 0.92);
const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82)
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.7);
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
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 + 278, y + 101, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#f0ede5',
lineSpacing: 11,
wordWrap: { width: 1382, useAdvancedWrap: true }
});
this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#aeb8c4'
}).setOrigin(1, 0.5);
this.dialoguePanel = this.add.container(0, 0, [
shade,
background,
inner,
portraitFrame,
this.dialoguePortrait,
this.dialogueNameText,
this.dialogueBodyText,
this.dialogueProgressText
]).setDepth(3000).setVisible(false);
}
private setupInput() {
const keyboard = this.input.keyboard;
if (keyboard) {
this.cursorKeys = keyboard.createCursorKeys();
this.moveKeys = {
up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
};
this.interactKeys = [
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE),
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
];
this.interactKeys.forEach((key) => {
key.on('down', () => {
this.interactionQueued = true;
});
});
keyboard.addCapture([
Phaser.Input.Keyboard.KeyCodes.UP,
Phaser.Input.Keyboard.KeyCodes.DOWN,
Phaser.Input.Keyboard.KeyCodes.LEFT,
Phaser.Input.Keyboard.KeyCodes.RIGHT,
Phaser.Input.Keyboard.KeyCodes.SPACE
]);
}
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (
!this.ready ||
this.navigationPending ||
this.oathGatherPending ||
this.time.now < this.inputReadyAt
) {
return;
}
if (this.dialogueState) {
this.advanceDialogue();
return;
}
if (pointer.x >= movementBounds.left && pointer.x <= movementBounds.right &&
pointer.y >= movementBounds.top && pointer.y <= movementBounds.bottom) {
this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y);
}
});
}
private updateMovement(time: number, delta: number) {
if (!this.player) {
return;
}
const keyboardDirection = this.keyboardMovementDirection();
let movement = keyboardDirection;
if (movement.lengthSq() > 0) {
this.moveTarget = undefined;
} else if (this.moveTarget) {
const targetDelta = this.moveTarget.clone().subtract(new Phaser.Math.Vector2(this.player.x, this.player.y));
if (targetDelta.length() <= 7) {
this.moveTarget = undefined;
movement = new Phaser.Math.Vector2();
} else {
movement = targetDelta.normalize();
}
}
if (movement.lengthSq() <= 0) {
this.setPlayerMoving(false);
return;
}
movement.normalize();
this.playerDirection = this.directionForVector(movement);
const distance = playerSpeed * Math.min(delta, 50) / 1000;
const startX = this.player.x;
const startY = this.player.y;
const nextX = startX + movement.x * distance;
const nextY = startY + movement.y * distance;
let moved = false;
if (this.canPlayerOccupy(nextX, startY)) {
this.player.x = nextX;
moved = true;
}
if (this.canPlayerOccupy(this.player.x, nextY)) {
this.player.y = nextY;
moved = true;
}
if (!moved && this.moveTarget) {
this.moveTarget = undefined;
}
this.syncPlayerView();
this.setPlayerMoving(moved);
if (moved && time - this.lastStepAt >= 280) {
this.lastStepAt = time;
this.stepIndex += 1;
soundDirector.playMovementStep(false, this.stepIndex, {
terrain: 'plain',
volume: 0.09,
rate: 1.04,
stopAfterMs: 620
});
}
}
private consumeInteractionRequest() {
const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key));
const requested = this.interactionQueued || keyPressedThisFrame;
this.interactionQueued = false;
return requested;
}
private keyboardMovementDirection() {
const vector = new Phaser.Math.Vector2();
if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) {
vector.x -= 1;
}
if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) {
vector.x += 1;
}
if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) {
vector.y -= 1;
}
if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) {
vector.y += 1;
}
return vector;
}
private directionForVector(vector: Phaser.Math.Vector2): ExplorationCharacterDirection {
if (Math.abs(vector.x) > Math.abs(vector.y)) {
return vector.x > 0 ? 'east' : 'west';
}
return vector.y > 0 ? 'south' : 'north';
}
private canPlayerOccupy(x: number, y: number) {
if (
x - playerCollisionRadius < movementBounds.left ||
x + playerCollisionRadius > movementBounds.right ||
y - playerCollisionRadius < movementBounds.top ||
y + playerCollisionRadius > movementBounds.bottom
) {
return false;
}
const feet = new Phaser.Geom.Rectangle(
x - playerCollisionRadius,
y + 18,
playerCollisionRadius * 2,
28
);
if (this.blockers.some((blocker) => Phaser.Geom.Intersects.RectangleToRectangle(feet, blocker))) {
return false;
}
return Array.from(this.npcViews.values()).every(({ sprite }) => (
Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48
));
}
private setPlayerMoving(moving: boolean) {
if (!this.player) {
return;
}
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;
}
private stopPlayerMovement() {
this.moveTarget = undefined;
this.setPlayerMoving(false);
}
private syncPlayerView() {
if (!this.player) {
return;
}
this.playerShadow?.setPosition(this.player.x, this.player.y + 20);
this.playerShadow?.setDepth(90 + this.player.y);
this.player.setDepth(100 + this.player.y);
}
private setPlayerPosition(x: number, y: number) {
if (!this.player) {
return;
}
this.player.setPosition(x, y);
this.syncPlayerView();
this.stopPlayerMovement();
}
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', narrativePositions.recruitment.guanYu);
this.setNpcPosition('zhang-fei', narrativePositions.recruitment.zhangFei);
return;
}
if (this.isObjectiveComplete('meet-zhang-fei')) {
this.setNpcPosition('zhang-fei', narrativePositions.tavern.zhangFei);
}
}
private setNpcPosition(
npcId: string,
target: {
x: number;
y: number;
direction: ExplorationCharacterDirection;
}
) {
const view = this.npcViews.get(npcId);
if (!view) {
return;
}
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);
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);
}
}
private safeApproachPosition(targetX: number, targetY: number) {
const candidates = [
{ x: targetX, y: targetY + 88 },
{ x: targetX - 88, y: targetY },
{ x: targetX + 88, y: targetY },
{ x: targetX, y: targetY - 88 }
];
return candidates.find((candidate) => this.canPlayerOccupy(candidate.x, candidate.y)) ??
{ x: Phaser.Math.Clamp(targetX, movementBounds.left + 40, movementBounds.right - 40),
y: Phaser.Math.Clamp(targetY + 100, movementBounds.top + 40, movementBounds.bottom - 40) };
}
private tryInteract() {
const target = this.nearestInteractionTarget(interactionRadius);
if (!target) {
this.showPromptMessage('조금 더 가까이 다가가세요.');
return;
}
this.interactWithTarget(target);
}
private interactWithTarget(target: InteractionTarget) {
this.stopPlayerMovement();
if (target.kind === 'oath') {
this.interactWithOath();
return;
}
const view = this.npcViews.get(target.id);
if (!view) {
return;
}
this.faceCharactersForConversation(view);
const completed = view.definition.objectiveId
? this.isObjectiveComplete(view.definition.objectiveId)
: false;
const unlocked = !view.definition.objectiveId || this.objectiveUnlocked(view.definition.objectiveId);
const dialogue = !unlocked && view.definition.lockedDialogue?.length
? view.definition.lockedDialogue
: completed && view.definition.repeatDialogue?.length
? view.definition.repeatDialogue
: view.definition.dialogue;
this.startDialogue(dialogue, () => {
if (view.definition.objectiveId && unlocked && !completed) {
this.completeObjective(view.definition.objectiveId);
}
}, view.definition.id);
}
private interactWithOath() {
if (!this.allRequiredObjectivesComplete()) {
const remaining = prologueVillageObjectiveDefinitions
.filter((objective) => objective.id !== 'make-oath' && !this.isObjectiveComplete(objective.id))
.map((objective) => objective.shortLabel)
.join(' · ');
this.startDialogue([
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: `아직 준비가 끝나지 않았다. 먼저 ${remaining}을 마치자.`
}
]);
return;
}
this.gatherOathParty(() => this.startOathDialogue());
}
private startOathDialogue() {
this.startDialogue([
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '내 장원 뒤 복숭아꽃이 한창이오. 싸움에 앞서 서로의 등을 맡길 뜻부터 하늘과 땅에 고합시다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '좋소. 성은 서로 다르지만 백성을 구하려는 뜻은 하나이니, 오늘부터 형제가 되어 힘을 합칩시다.'
},
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '의를 저버리지 않고 형제를 저버리지 않겠습니다. 어려움이 닥쳐도 함께 맞서겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '형님들의 뜻을 내 목숨처럼 지키겠소. 결의를 마치면 내 장원 밖 막사에서 의병들을 함께 살핍시다!'
}
], () => this.finishVillage(), 'make-oath');
}
private startDialogue(
lines: PrologueVillageDialogueLine[],
onComplete?: () => void,
sourceNpcId?: string
) {
if (!lines.length || this.navigationPending) {
return;
}
this.stopPlayerMovement();
this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId };
this.dialoguePanel?.setVisible(true);
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
soundDirector.playSelect();
this.renderDialogueLine();
}
private renderDialogueLine() {
const dialogue = this.dialogueState;
if (!dialogue) {
return;
}
const line = dialogue.lines[dialogue.lineIndex];
this.dialogueNameText?.setText(line.speaker);
this.dialogueBodyText?.setText(line.text);
this.dialogueProgressText?.setText(
`${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속`
);
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.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) {
return;
}
if (dialogue.lineIndex < dialogue.lines.length - 1) {
dialogue.lineIndex += 1;
soundDirector.playStoryAdvanceCue();
this.renderDialogueLine();
return;
}
const onComplete = dialogue.onComplete;
this.dialogueState = undefined;
this.dialoguePanel?.setVisible(false);
this.stopPlayerMovement();
onComplete?.();
this.refreshInteractionPrompt();
}
private completeObjective(objectiveId: PrologueVillageRequiredObjectiveId) {
if (this.isObjectiveComplete(objectiveId)) {
return;
}
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
if (objectiveId === 'meet-zhang-fei') {
this.walkNpcTo('zhang-fei', narrativePositions.tavern.zhangFei);
} else if (objectiveId === 'meet-guan-yu') {
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();
this.refreshNpcMarkers();
const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId);
this.showCompletionToast(`${objective?.shortLabel ?? '준비'} 완료`);
}
private finishVillage() {
if (this.navigationPending) {
return;
}
if (!this.allRequiredObjectivesComplete()) {
this.interactWithOath();
return;
}
completeCampaignTutorial(prologueVillageCampaignTutorialIds.complete);
this.refreshObjectiveHud();
this.navigationPending = true;
this.stopPlayerMovement();
soundDirector.playObjectiveAchieved();
this.showTransitionOverlay();
void startGameScene(this, 'StoryScene', {
pages: prologueBrotherhoodPages(),
nextScene: 'PrologueMilitiaCampScene',
presentationBattleId: 'first-battle-zhuo-commandery',
presentationStage: 'story'
}).catch((error) => {
console.error('Failed to continue from the prologue village.', error);
this.navigationPending = false;
this.transitionOverlay?.destroy();
this.transitionOverlay = undefined;
this.refreshInteractionPrompt();
});
}
private showTransitionOverlay() {
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.78).setOrigin(0);
const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, '도원결의 · 세 사람의 맹세', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '44px',
color: '#f3dda2',
fontStyle: 'bold'
}).setOrigin(0.5);
const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '맹세를 마친 뒤 의용군 막사에서 첫 출전을 준비합니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#d7dde4'
}).setOrigin(0.5);
this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle])
.setDepth(5000)
.setAlpha(0);
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
}
private showCompletionToast(label: string) {
this.completionToast?.destroy();
const background = this.add.rectangle(748, 132, 380, 70, 0x17251c, 0.98)
.setStrokeStyle(2, 0x8fbd72, 0.9);
const text = this.add.text(748, 132, `${label}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '24px',
color: '#dff0c9',
fontStyle: 'bold'
}).setOrigin(0.5);
this.completionToast = this.add.container(0, 0, [background, text]).setDepth(2200);
this.tweens.add({
targets: this.completionToast,
alpha: 0,
y: -18,
delay: 1150,
duration: 360,
onComplete: () => {
this.completionToast?.destroy();
this.completionToast = undefined;
}
});
}
private showPromptMessage(message: string) {
this.promptBackground?.setVisible(true);
this.promptText?.setText(message).setVisible(true);
this.time.delayedCall(850, () => this.refreshInteractionPrompt());
}
private refreshObjectiveHud() {
const requiredComplete = this.completedRequiredObjectiveIds();
prologueVillageObjectiveDefinitions.forEach((objective) => {
const row = this.objectiveRows.get(objective.id);
if (!row) {
return;
}
const isOath = objective.id === 'make-oath';
let completed: boolean;
if (objective.id === 'make-oath') {
completed = hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete);
} else {
completed = this.isObjectiveComplete(objective.id);
}
const unlocked = isOath
? this.allRequiredObjectivesComplete()
: this.objectiveUnlocked(objective.id);
row.status.setText(completed ? '✓ 완료' : unlocked ? '◆ 진행 가능' : '◇ 준비 후 개방');
row.status.setColor(completed ? '#a8d58e' : unlocked ? '#e1bc69' : '#7f8994');
row.label.setColor(completed ? '#b8c4b3' : unlocked ? '#f0e6d2' : '#7f8994');
row.location.setColor(completed ? '#7f9b78' : unlocked ? '#a4afba' : '#666f79');
row.background.setStrokeStyle(
2,
completed ? 0x628653 : unlocked ? 0x8a7043 : 0x3d4858,
completed ? 0.9 : 0.75
);
});
const remaining = prologueVillageRequiredObjectiveIds.length - requiredComplete.length;
this.objectiveSummaryText?.setText(
remaining > 0
? `만남의 흐름 ${requiredComplete.length} / ${prologueVillageRequiredObjectiveIds.length}\n◆ 표시된 다음 인물을 찾아 이야기하세요.`
: '모든 준비 완료\n복숭아 동산의 빛나는 표식으로 이동하세요.'
);
if (this.oathMarker) {
const unlocked = this.allRequiredObjectivesComplete();
this.oathMarker.setText(unlocked ? '!' : '◇');
this.oathMarker.setColor(unlocked ? '#2a2013' : '#88756c');
this.oathMarker.setBackgroundColor(unlocked ? '#f1c96f' : '');
this.oathLabel?.setText(unlocked ? '세 사람의 결의' : '결의의 자리');
this.oathLabel?.setColor(unlocked ? '#ffe7b0' : '#a78f91');
}
}
private refreshNpcMarkers() {
this.npcViews.forEach(({ definition, marker }) => {
if (!definition.objectiveId || !marker) {
return;
}
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');
});
}
private refreshInteractionPrompt() {
if (!this.ready || this.dialogueState || this.navigationPending) {
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
return;
}
const target = this.nearestInteractionTarget(promptRadius);
if (!target) {
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
return;
}
const closeEnough = this.distanceTo(target.x, target.y) <= interactionRadius;
this.promptBackground?.setVisible(true);
this.promptText
?.setText(closeEnough ? `E / Space ${target.name}` : `${target.name}에게 가까이 가세요`)
.setColor(closeEnough ? '#f6e3af' : '#b2bbc4')
.setVisible(true);
}
private nearestInteractionTarget(radius: number) {
if (!this.player) {
return undefined;
}
const targets: InteractionTarget[] = [
...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,
name: this.allRequiredObjectivesComplete() ? '세 사람의 결의' : '결의의 자리',
x: oathPosition.x,
y: oathPosition.y
}
];
return targets
.map((target) => ({ target, distance: this.distanceTo(target.x, target.y) }))
.filter(({ distance }) => distance <= radius)
.sort((left, right) => left.distance - right.distance)[0]?.target;
}
private interactionTargetById(targetId: string): InteractionTarget | undefined {
if (targetId === 'make-oath') {
return {
kind: 'oath',
id: 'make-oath',
name: '세 사람의 결의',
x: oathPosition.x,
y: oathPosition.y
};
}
const view = this.npcViews.get(targetId);
return view
? {
kind: 'npc',
id: view.definition.id,
name: view.definition.name,
x: view.sprite.x,
y: view.sprite.y
}
: undefined;
}
private faceCharactersForConversation(view: VillageNpcView) {
if (!this.player) {
return;
}
const toNpc = new Phaser.Math.Vector2(view.sprite.x - this.player.x, view.sprite.y - this.player.y);
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${npcDirection}`);
}
}
private isObjectiveComplete(objectiveId: PrologueVillageRequiredObjectiveId) {
return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]);
}
private objectiveUnlocked(objectiveId: PrologueVillageRequiredObjectiveId | 'make-oath') {
if (objectiveId === 'make-oath') {
return this.allRequiredObjectivesComplete();
}
const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId);
return !objective?.prerequisiteId || this.isObjectiveComplete(objective.prerequisiteId);
}
private completedRequiredObjectiveIds() {
return prologueVillageRequiredObjectiveIds.filter((objectiveId) => this.isObjectiveComplete(objectiveId));
}
private allRequiredObjectivesComplete() {
return this.completedRequiredObjectiveIds().length === prologueVillageRequiredObjectiveIds.length;
}
private distanceTo(x: number, y: number) {
return this.player ? Phaser.Math.Distance.Between(this.player.x, this.player.y, x, y) : Number.POSITIVE_INFINITY;
}
private boundsSnapshot(bounds: Phaser.Geom.Rectangle) {
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
};
}
}