import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { prologueBrotherhoodPages, prologueVillageNpcDefinitions, prologueVillageObjectiveDefinitions, prologueVillageRequiredObjectiveIds, type PrologueVillageDialogueLine, type PrologueVillageNpcDefinition, type PrologueVillageRequiredObjectiveId } from '../data/prologueVillage'; import { ensureUnitAnimations, loadUnitBaseSheets, releaseUnitBaseSheetTextures, type UnitDirection } from '../data/unitAssets'; import { completeCampaignTutorial, getCampaignState, hasCompletedCampaignTutorial, markCampaignStep, prologueVillageCampaignTutorialIds, type CampaignTutorialId } from '../state/campaignState'; 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 oathPosition = { x: 780, y: 405 }; const characterTextureKeys = [ 'unit-liu-bei', 'unit-guan-yu', 'unit-zhang-fei', 'unit-shu-officer', 'unit-shu-infantry' ] as const; type VillageNpcView = { definition: PrologueVillageNpcDefinition; 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; }; const objectiveTutorialIds: Record = { 'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei, 'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu, 'register-volunteers': prologueVillageCampaignTutorialIds.registerVolunteers }; export class PrologueVillageScene extends Phaser.Scene { private player?: Phaser.GameObjects.Sprite; private playerShadow?: Phaser.GameObjects.Ellipse; private playerDirection: UnitDirection = 'north'; private playerMoving = false; private npcViews = new Map(); private blockers: Phaser.Geom.Rectangle[] = []; private objectiveRows = new Map(); 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 dialogueAvatar?: Phaser.GameObjects.Sprite; 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 ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; private interactionQueued = false; private stepIndex = 0; private lastStepAt = 0; private firstVisit = false; constructor() { super('PrologueVillageScene'); } 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.moveTarget = undefined; this.dialogueState = undefined; releaseUnitBaseSheetTextures(this); }); 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(); 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.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, player: playerPosition ? { ...playerPosition, direction: this.playerDirection, moving: this.playerMoving, 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, 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, bounds: dialogueBounds } : { active: false, speaker: null, lineIndex: null, totalLines: 0, sourceNpcId: null, bounds: null }, blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), navigationPending: this.navigationPending, 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.player = undefined; this.playerShadow = undefined; this.playerDirection = 'north'; this.playerMoving = false; this.npcViews.clear(); this.blockers = []; this.objectiveRows.clear(); this.dialogueState = undefined; this.moveTarget = undefined; this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; this.interactionQueued = 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'); 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 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('unit-liu-bei', 750, 850, characterDisplaySize + 8, 'north'); this.player.setDepth(851); prologueVillageNpcDefinitions.forEach((definition) => { 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, 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, 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: string, x: number, y: number, size: number, direction: UnitDirection ) { 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 avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 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, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '29px', color: '#f1d691', fontStyle: 'bold' }); this.dialogueBodyText = this.add.text(x + 222, y + 101, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#f0ede5', lineSpacing: 11, wordWrap: { width: 1440, 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, avatarFrame, this.dialogueAvatar, 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.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): UnitDirection { 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 = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; if (this.textures.exists('unit-liu-bei') && 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() { 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 restoreNarrativeActorPositions() { if (this.isObjectiveComplete('meet-guan-yu')) { this.setNpcPosition('guan-yu', 850, 655, 'east'); this.setNpcPosition('zhang-fei', 810, 805, 'east'); return; } if (this.isObjectiveComplete('meet-zhang-fei')) { this.setNpcPosition('zhang-fei', 1000, 540, 'east'); } } private setNpcPosition( npcId: string, x: number, y: number, direction: UnitDirection ) { 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 + 63); view.marker?.setPosition(x, y - 72).setVisible(false); if (this.textures.exists(view.definition.textureKey)) { view.sprite.play(`${view.definition.textureKey}-idle-${direction}`); } } 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.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.dialogueAvatar) { const textureKey = line.textureKey; if (textureKey && this.textures.exists(textureKey)) { this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true); this.dialogueAvatar.play(`${textureKey}-idle-south`); } else { this.dialogueAvatar.setVisible(false); } } } 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.setNpcPosition('zhang-fei', 1000, 540, 'east'); } else if (objectiveId === 'meet-guan-yu') { this.setNpcPosition('guan-yu', 850, 655, 'east'); this.setNpcPosition('zhang-fei', 810, 805, 'east'); } 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); 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()).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.definition.textureKey)) { view.sprite.play(`${view.definition.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 }; } }