import Phaser from 'phaser'; import campBackgroundUrl from '../../assets/images/exploration/prologue-militia-camp.webp'; import secondReliefBackgroundUrl from '../../assets/images/exploration/second-pursuit-village-ferry.webp'; import thirdCampBackgroundUrl from '../../assets/images/exploration/third-guangzong-sortie-camp.webp'; import { soundDirector } from '../audio/SoundDirector'; import { applyExplorationCharacterMotion, ensureExplorationCharacterAnimations, explorationCharacterAssetInfos, explorationCharacterTextureKeyByUnitTextureKey, releaseExplorationCharacterTextureKeys, type ExplorationCharacterDirection, type ExplorationCharacterTextureKey } from '../data/explorationCharacterAssets'; import { campMusicVolumeForTrackKey } from '../data/campSoundscapes'; import { firstPursuitScoutSourceBattleId, firstPursuitScoutVisitId } from '../data/firstPursuitScoutMemory'; import { firstPursuitScoutVisitDefinition, getFirstPursuitScoutVisitChoice, type FirstPursuitScoutVisitChoice } from '../data/firstPursuitScoutVisit'; import { getSecondBattleReliefChoice, getSecondBattleReliefObjective, secondBattleReliefExplorationDefinition, secondBattleReliefSourceBattleId, secondBattleReliefVisitId, type SecondBattleReliefChoice, type SecondBattleReliefObjectiveId } from '../data/secondBattleReliefExploration'; import { resolveSecondBattleReliefContext } from '../data/secondBattleReliefMemory'; import { resolveThirdCampCompanionDialogue, resolveThirdCampExplorationDefinition, thirdCampExplorationVisitId, type ThirdCampCompanionDialogueChoice, type ThirdCampExplorationActivityId } from '../data/thirdCampExploration'; import { getThirdCampPreparationPriority } from '../data/thirdCampPreparation'; import { portraitAssetEntryForStoryContext, type PortraitAssetEntry } from '../data/portraitAssets'; import { ExplorationInputController } from '../input/ExplorationInputController'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; import { getCampaignState } from '../state/campaignState'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions'; import { completeSecondBattleReliefExploration, recordSecondBattleReliefObjective, secondBattleReliefProgress } from '../state/secondBattleReliefActions'; import { completeThirdCampCompanionActivity, enterThirdCampExploration, recordThirdCampExplorationActivity, thirdCampExplorationProgress } from '../state/thirdCampExplorationActions'; import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay'; import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { startGameScene } from './lazyScenes'; const sceneWidth = 1920; const sceneHeight = 1080; const mapRight = 1495; const movementBounds = new Phaser.Geom.Rectangle(42, 108, 1404, 814); const playerSpeed = 300; const playerCollisionRadius = 25; const interactionRadius = 122; const promptRadius = 164; const characterDisplaySize = 112; const inputCarryoverGuardMs = 320; const navigationArrivalRadius = 7; const navigationDetourClearance = 58; const navigationProbeStep = 18; const directPointerSnapRadius = 96; const transitionFadeDurationMs = 320; const campBackgroundKey = 'first-pursuit-camp-background'; const secondReliefBackgroundKey = 'second-pursuit-village-ferry-background'; const thirdCampBackgroundKey = 'third-guangzong-sortie-camp-background'; const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei']; type CampVisitExplorationSceneData = { visitId?: string; }; type ExplorationActorId = string; type ExplorationActorDefinition = { id: ExplorationActorId; name: string; role: string; textureKey: ExplorationCharacterTextureKey; portraitKey?: string; x: number; y: number; direction: ExplorationCharacterDirection; kind: 'objective' | 'activity' | 'companion'; objectiveId?: SecondBattleReliefObjectiveId; activityId?: ThirdCampExplorationActivityId; dialogue: readonly DialogueLine[]; repeatDialogue?: readonly DialogueLine[]; completedDialogue?: readonly DialogueLine[]; }; type ExplorationActorView = { definition: ExplorationActorDefinition; sprite: Phaser.GameObjects.Sprite; shadow: Phaser.GameObjects.Ellipse; nameplate: Phaser.GameObjects.Text; marker: Phaser.GameObjects.Text; initialPosition: { x: number; y: number }; }; type InteractionTarget = | { kind: 'actor'; id: ExplorationActorId; name: string; x: number; y: number; } | { kind: 'exit'; id: 'camp-exit'; name: string; x: number; y: number; }; type DialogueLine = { speaker: string; text: string; portraitKey?: string; }; type DialogueState = { lines: DialogueLine[]; lineIndex: number; onComplete?: () => void; sourceNpcId?: ExplorationActorId | 'camp-exit'; }; type ChoiceView = { choice: ChoiceOption; button: Phaser.GameObjects.Rectangle; }; type VisitChoice = | FirstPursuitScoutVisitChoice | SecondBattleReliefChoice; type ChoiceOption = VisitChoice | ThirdCampCompanionDialogueChoice; type ChoicePanelMode = 'visit' | 'third-companion'; const firstPursuitActorDefinitions: readonly ExplorationActorDefinition[] = [ { id: 'jian-yong', name: '간옹', role: '북문 추격로 정찰', textureKey: 'exploration-jian-yong', portraitKey: 'jian-yong-campaign', x: 510, y: 492, direction: 'east', kind: 'objective', dialogue: [ { speaker: '간옹', portraitKey: 'jian-yong-campaign', text: '발자국이 강가와 마을길로 갈렸습니다. 어느 길을 먼저 정찰도에 올릴지 정해야 합니다.' }, { speaker: '유비', portraitKey: 'liu-bei-yellow-turban', text: '매복도 찾고 백성의 길도 지켜야 하오. 우리가 먼저 확인할 길을 정합시다.' } ] }, { id: 'guan-yu', name: '관우', role: '추격 전열 점검', textureKey: explorationCharacterTextureKeyByUnitTextureKey['unit-guan-yu'], portraitKey: 'guan-yu-yellow-turban', x: 1015, y: 565, direction: 'west', kind: 'companion', dialogue: [ { speaker: '관우', portraitKey: 'guan-yu-yellow-turban', text: '적이 흩어졌어도 추격로가 좁으면 앞선 병력이 고립됩니다. 간옹의 정찰도를 본 뒤 전열을 정하겠습니다.' }, { speaker: '유비', portraitKey: 'liu-bei-yellow-turban', text: '좋소. 승리의 기세보다 서로 돌아올 길을 먼저 맞춥시다.' } ] }, { id: 'zhang-fei', name: '장비', role: '의병대 재집결', textureKey: explorationCharacterTextureKeyByUnitTextureKey['unit-zhang-fei'], portraitKey: 'zhang-fei-yellow-turban', x: 1270, y: 595, direction: 'west', kind: 'companion', dialogue: [ { speaker: '장비', portraitKey: 'zhang-fei-yellow-turban', text: '이번에는 내가 먼저 달려 나가지 않겠소. 길이 정해지면 의병대를 그 순서대로 세우겠소.' }, { speaker: '유비', portraitKey: 'liu-bei-yellow-turban', text: '익덕이 자리를 지켜 주면 뒤따르는 이들도 흔들리지 않을 것이오.' } ] } ] as const; const secondReliefActorDefinitions: readonly ExplorationActorDefinition[] = secondBattleReliefExplorationDefinition.actors.map((actor) => { const objective = secondBattleReliefExplorationDefinition.objectives.find( (candidate) => candidate.targetActorId === actor.id ); const portraitKey = actor.portraitKey; const withSpeakerPortrait = (line: DialogueLine): DialogueLine => ({ ...line, portraitKey: line.portraitKey ?? (line.speaker === actor.name ? portraitKey : undefined) }); return { ...actor, portraitKey, dialogue: actor.dialogue.map(withSpeakerPortrait), repeatDialogue: actor.repeatDialogue.map(withSpeakerPortrait), kind: 'objective' as const, objectiveId: objective?.id }; }); const portraitKeys = [ 'liu-bei-yellow-turban', ...firstPursuitActorDefinitions.map((actor) => actor.portraitKey), ...secondReliefActorDefinitions.map((actor) => actor.portraitKey) ].filter((key): key is string => Boolean(key)); const firstPursuitPortraitContext = { id: 'first-pursuit-camp-exploration', background: 'story-militia' } as const; const secondReliefPortraitContext = { id: 'second-pursuit-aftermath-relief-exploration', background: 'story-camp' } as const; const thirdCampPortraitContext = { id: 'third-guangzong-sortie-camp-exploration', background: 'story-camp' } as const; const firstPursuitCampExit = { id: 'camp-exit' as const, name: '군영 장부로 돌아가기', x: 780, y: 895 }; function explorationPortraitEntries( portraitContext: | typeof firstPursuitPortraitContext | typeof secondReliefPortraitContext | typeof thirdCampPortraitContext, activePortraitKeys: readonly string[] = portraitKeys ) { return [...new Set(activePortraitKeys)] .map((portraitKey) => portraitAssetEntryForStoryContext( portraitKey, portraitContext, `${portraitContext.id}-${portraitKey}` ) ) .filter((entry): entry is PortraitAssetEntry => entry !== undefined); } export class CampVisitExplorationScene extends Phaser.Scene { private visitId = firstPursuitScoutVisitId; private backgroundImage?: Phaser.GameObjects.Image; private backgroundFallback = false; private player?: Phaser.GameObjects.Sprite; private playerShadow?: Phaser.GameObjects.Ellipse; private playerNameplate?: Phaser.GameObjects.Text; private playerDirection: ExplorationCharacterDirection = 'north'; private playerMoving = false; private actorViews = new Map(); private blockers: Phaser.Geom.Rectangle[] = []; private promptBackground?: Phaser.GameObjects.Rectangle; private promptText?: Phaser.GameObjects.Text; private objectiveStatus?: Phaser.GameObjects.Text; private objectiveCard?: Phaser.GameObjects.Rectangle; private thirdActivityTitleText?: Phaser.GameObjects.Text; private thirdPriorityLocationText?: Phaser.GameObjects.Text; private progressText?: Phaser.GameObjects.Text; private dialoguePanel?: Phaser.GameObjects.Container; private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle; private dialoguePortrait?: Phaser.GameObjects.Image; private dialogueNameText?: Phaser.GameObjects.Text; private dialogueBodyText?: Phaser.GameObjects.Text; private dialogueProgressText?: Phaser.GameObjects.Text; private dialogueState?: DialogueState; private choicePanel?: Phaser.GameObjects.Container; private choicePanelBounds?: Phaser.Geom.Rectangle; private choiceViews: ChoiceView[] = []; private choicePanelMode: ChoicePanelMode = 'visit'; private choiceSourceNpcId?: ExplorationActorId; private choiceReadyAt = Number.POSITIVE_INFINITY; private thirdWorldFeedback = new Map< ThirdCampExplorationActivityId, Phaser.GameObjects.Container >(); private thirdCampFirstEntry = false; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; private explorationInput?: ExplorationInputController; private moveTarget?: Phaser.Math.Vector2; private moveWaypoints: Phaser.Math.Vector2[] = []; private moveTargetId?: InteractionTarget['id']; private lastNavigationTargetId?: InteractionTarget['id']; private navigationReplanAttempts = 0; private navigationDetourCount = 0; private autoInteractionPending = false; private autoInteractionTriggeredCount = 0; private lastAutoInteractionTargetId?: ExplorationActorId; private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; private autoDialogueInputReadyAt = 0; private stepIndex = 0; private lastStepAt = 0; private lastNotice = ''; private ownedPresentationTextureKeys = new Set(); private visualMotionReduced = false; private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; constructor() { super('CampVisitExplorationScene'); } init(data?: CampVisitExplorationSceneData) { this.visitId = data?.visitId === thirdCampExplorationVisitId ? thirdCampExplorationVisitId : data?.visitId === secondBattleReliefVisitId ? secondBattleReliefVisitId : firstPursuitScoutVisitId; } preload() { explorationCharacterAssetInfos(this.currentCharacterTextureKeys()).forEach((asset) => { if (!this.textures.exists(asset.key)) { this.load.spritesheet(asset.key, asset.url, { frameWidth: asset.frameWidth, frameHeight: asset.frameHeight }); } }); const backgroundKey = this.currentBackgroundKey(); if (!this.textures.exists(backgroundKey)) { this.ownedPresentationTextureKeys.add(backgroundKey); this.load.image(backgroundKey, this.currentBackgroundUrl()); } explorationPortraitEntries( this.currentPortraitContext(), this.currentPortraitKeys() ).forEach(({ textureKey, url }) => { if (!this.textures.exists(textureKey)) { this.ownedPresentationTextureKeys.add(textureKey); this.load.image(textureKey, url); } }); } create() { this.resetRuntimeState(); this.visualMotionReduced = isVisualMotionReduced(); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.ready = false; this.navigationPending = false; this.clearNavigationTarget(); this.explorationInput?.destroy(); this.explorationInput = undefined; this.dialogueState = undefined; this.campaignObjectiveJournal?.destroy(); this.campaignObjectiveJournal = undefined; this.closeChoicePanel(); releaseExplorationCharacterTextureKeys( this, this.currentCharacterTextureKeys() ); this.releasePresentationTextures(); }); if (!this.visitAvailable()) { this.navigationPending = true; void startGameScene(this, 'CampScene').catch((error) => { console.error('Failed to leave an unavailable camp visit.', error); this.navigationPending = false; if (this.sys.isActive()) { this.scene.start('TitleScene'); } }); return; } if (this.isThirdCampVisit()) { const entry = enterThirdCampExploration(); if (!entry.ok) { this.navigationPending = true; void startGameScene(this, 'CampScene').catch((error) => { console.error('Failed to leave an unavailable third camp visit.', error); this.navigationPending = false; if (this.sys.isActive()) { this.scene.start('TitleScene'); } }); return; } this.thirdCampFirstEntry = entry.changed; } this.drawCamp(); this.drawHud(); this.setupInput(); ensureExplorationCharacterAnimations( this, this.currentCharacterTextureKeys() ); this.createActors(); this.createDialoguePanel(); this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; this.createCampaignObjectiveJournal(); this.refreshObjectiveHud(); this.refreshActorMarkers(); this.refreshInteractionPrompt(); soundDirector.playSoundscape( this.isSecondReliefVisit() ? { musicKey: 'camp-rally', ambienceKey: 'riverside-village-day-ambience', musicVolume: campMusicVolumeForTrackKey('camp-rally'), ambienceVolume: 0.11 } : { musicKey: 'camp-rally', ambienceKey: 'campfire-ambience', musicVolume: campMusicVolumeForTrackKey('camp-rally'), ambienceVolume: 0.12 } ); if (this.isThirdCampVisit() && this.thirdCampFirstEntry) { const definition = this.thirdCampDefinition(); this.startDialogue( definition ? [...definition.introLines] : [], () => this.showCompletionToast( '세 준비는 선택 활동입니다. 필요한 곳만 살피고 언제든 출진할 수 있습니다.', 'guide', 2100 ) ); } else { this.showCompletionToast( this.isThirdCampVisit() ? this.thirdCampGuideMessage() : this.isSecondReliefVisit() ? this.reliefGuideMessage() : '정찰대 막사의 간옹에게 직접 다가가세요.', 'guide', 1850 ); } } update(time: number, delta: number) { if (!this.ready || this.navigationPending || time < this.inputReadyAt) { this.explorationInput?.discardInteractionRequest(); return; } if (this.campaignObjectiveJournal?.isOpen()) { this.stopPlayerMovement(); this.explorationInput?.discardInteractionRequest(); return; } if (this.choicePanel) { this.stopPlayerMovement(); this.explorationInput?.discardInteractionRequest(); return; } if (this.dialogueState) { this.stopPlayerMovement(); if ( this.consumeInteractionRequest() && time >= this.autoDialogueInputReadyAt ) { 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 completed = this.visitComplete(campaign); const thirdProgress = this.isThirdCampVisit() ? thirdCampExplorationProgress(campaign) : undefined; const thirdDefinition = this.isThirdCampVisit() ? resolveThirdCampExplorationDefinition(campaign) : undefined; const selectedThirdPriority = thirdProgress?.selectedPriorityId ? getThirdCampPreparationPriority( thirdProgress.selectedPriorityId ) : undefined; const choiceId = this.isThirdCampVisit() ? thirdProgress?.selectedPriorityId ?? null : campaign.campVisitChoiceIds[this.visitId] ?? null; const chosen: VisitChoice | undefined = choiceId ? this.isThirdCampVisit() ? undefined : this.isSecondReliefVisit() ? getSecondBattleReliefChoice(choiceId) : getFirstPursuitScoutVisitChoice(choiceId) : undefined; const reliefProgress = this.isSecondReliefVisit() ? secondBattleReliefProgress(campaign) : undefined; const reliefContext = this.isSecondReliefVisit() ? resolveSecondBattleReliefContext(campaign) : undefined; const activeCharacterTextureKeys = this.currentCharacterTextureKeys(); const activeMovementBounds = this.currentMovementBounds(); const activeCampExit = this.currentCampExit(); return { scene: this.scene.key, locationId: this.visitId, ready: this.ready, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, campaignObjectiveJournal: { snapshot: resolveCampaignObjectiveJournal(campaign), view: this.campaignObjectiveJournal?.getDebugState() ?? null }, background: { key: this.backgroundImage?.texture.key ?? this.currentBackgroundKey(), 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 }, requiredTextures: activeCharacterTextureKeys.map((key) => ({ key, ready: this.textures.exists(key) })), requiredTexturesReady: activeCharacterTextureKeys.every((key) => this.textures.exists(key) ), player: playerPosition ? { ...playerPosition, direction: this.playerDirection, moving: this.playerMoving, textureKey: this.player?.texture.key ?? null, animationKey: this.player?.anims.currentAnim?.key ?? null, animationPlaying: this.player?.anims.isPlaying ?? false, 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, targetId: this.moveTargetId ?? null, lastTargetId: this.lastNavigationTargetId ?? null, waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })), walkBounds: this.boundsSnapshot(activeMovementBounds), collisionRadius: playerCollisionRadius, detourCount: this.navigationDetourCount, autoInteraction: { pending: this.autoInteractionPending, targetId: this.autoInteractionPending ? this.moveTargetId ?? null : null, triggeredCount: this.autoInteractionTriggeredCount, lastTriggeredTargetId: this.lastAutoInteractionTargetId ?? null } }, controls: { movement: ['WASD', '방향키'], interact: ['E', 'Space', 'Enter'], choices: ['1', '2', 'pointer'], pointerMove: true, carryoverGuardMs: inputCarryoverGuardMs, reducedMotion: this.visualMotionReduced }, interaction: { radius: interactionRadius, promptRadius, targetId: target?.id ?? null, targetKind: target?.kind ?? 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 }, actors: Array.from(this.actorViews.values()).map( ({ definition, sprite, initialPosition }) => ({ id: definition.id, name: definition.name, role: definition.role, kind: definition.kind, x: sprite.x, y: sprite.y, initialX: initialPosition.x, initialY: initialPosition.y, moved: Math.abs(sprite.x - initialPosition.x) > 0.1 || Math.abs(sprite.y - initialPosition.y) > 0.1, textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, animationPlaying: sprite.anims.isPlaying, objectiveId: definition.objectiveId ?? null, activityId: definition.activityId ?? null, completed: definition.activityId ? thirdProgress?.completedActivityIds.includes( definition.activityId ) ?? false : definition.kind === 'objective' && (definition.objectiveId ? reliefProgress?.completedObjectiveIds.includes( definition.objectiveId ) ?? false : completed), current: definition.activityId ? definition.activityId === thirdProgress?.selectedPriorityId : definition.objectiveId === reliefProgress?.nextObjectiveId, distance: playerPosition ? Phaser.Math.Distance.Between( playerPosition.x, playerPosition.y, sprite.x, sprite.y ) : null, bounds: this.boundsSnapshot(sprite.getBounds()) }) ), exit: { ...activeCampExit, distance: playerPosition ? Phaser.Math.Distance.Between( playerPosition.x, playerPosition.y, activeCampExit.x, activeCampExit.y ) : null }, 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, portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false, bounds: this.dialoguePanel?.visible ? this.boundsSnapshot( new Phaser.Geom.Rectangle(0, 0, sceneWidth, sceneHeight) ) : null } : { active: false, speaker: null, lineIndex: -1, totalLines: 0, sourceNpcId: null, portraitTextureKey: null, portraitVisible: false, portraitFrameVisible: false, bounds: null }, choice: { open: Boolean(this.choicePanel), mode: this.choicePanelMode, sourceNpcId: this.choiceSourceNpcId ?? null, ready: Boolean( this.choicePanel && this.time.now >= this.choiceReadyAt ), panelBounds: this.choicePanelBounds ? this.boundsSnapshot(this.choicePanelBounds) : null, choices: this.choiceViews.map(({ choice, button }) => ({ id: choice.id, label: choice.label, rewardText: this.choiceRewardText(choice), effectText: this.choiceEffectText(choice), interactive: Boolean(button.input?.enabled), bounds: this.boundsSnapshot(button.getBounds()) })) }, visit: { id: this.visitId, mode: this.isThirdCampVisit() ? 'third-guangzong-sortie-camp' : this.isSecondReliefVisit() ? 'second-battle-relief' : 'first-pursuit-scout', completed, choiceId, choiceLabel: selectedThirdPriority?.label ?? chosen?.label ?? null, itemRewards: chosen ? [...chosen.itemRewards] : [], bondExp: chosen?.bondExp ?? 0, completedObjectiveIds: thirdProgress?.completedActivityIds ?? reliefProgress?.completedObjectiveIds ?? [], completedActivityIds: thirdProgress?.completedActivityIds ?? [], completedActivityCount: thirdProgress?.completedActivityCount ?? 0, activities: thirdProgress?.activities.map((activity) => ({ id: activity.id, label: activity.label, completed: activity.completed, selected: activity.selected, targetActorIds: [...activity.targetActorIds], worldCue: activity.worldCue })) ?? [], selectedPriorityId: thirdProgress?.selectedPriorityId ?? null, selectionConfirmed: thirdProgress?.selectionConfirmed ?? false, selectedPriorityLabel: selectedThirdPriority?.label ?? null, hudActivityTitle: this.thirdActivityTitleText?.text ?? null, hudPriorityLocation: this.thirdPriorityLocationText?.text ?? null, nextObjectiveId: reliefProgress?.nextObjectiveId ?? null, choiceReady: thirdProgress?.ready ?? reliefProgress?.choiceReady ?? !completed }, storyContext: thirdDefinition ? { sourceBattleId: thirdDefinition.sourceBattleId, targetBattleId: thirdDefinition.targetBattleId, resultVariant: thirdDefinition.resultDialogue.variantId, resultSummary: thirdDefinition.resultDialogue.summary, companionDialogueId: thirdDefinition.companionDialogue.id, companionBondId: thirdDefinition.companionDialogue.bondId } : reliefContext ? { sourceBattleId: reliefContext.sourceBattleId, sourceBattleTurn: reliefContext.sourceBattleTurn, villageObjectiveAchieved: reliefContext.villageObjectiveAchieved, quickObjectiveAchieved: reliefContext.quickObjectiveAchieved, arrivalLine: reliefContext.arrivalLine, scoutReview: { ...reliefContext.scoutReview } } : null, worldFeedback: this.isThirdCampVisit() ? thirdDefinition?.activities.map((activity) => { const view = this.thirdWorldFeedback.get(activity.id); return { activityId: activity.id, cue: activity.worldCue, visible: view?.visible ?? false, alpha: view?.alpha ?? 0, bounds: view && view.visible ? this.boundsSnapshot(view.getBounds()) : null }; }) ?? [] : [], campaign: { latestBattleId: campaign.latestBattleId ?? null, completedCampVisits: [...campaign.completedCampVisits], campVisitChoiceIds: { ...campaign.campVisitChoiceIds }, thirdCampPreparationSelection: campaign.thirdCampPreparationSelection ? { ...campaign.thirdCampPreparationSelection } : null, inventory: { ...campaign.inventory } }, blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker) ), navigationPending: this.navigationPending, lastNotice: this.lastNotice }; } debugTeleportTo(targetId: string) { if ( !this.ready || this.navigationPending || this.dialogueState || this.choicePanel || !this.player ) { return false; } const target = this.interactionTargetById(targetId); if (!target) { return false; } const position = this.safeApproachPosition(target.x, target.y); this.setPlayerPosition(position.x, position.y); this.lastNavigationTargetId = target.id; this.refreshInteractionPrompt(); return true; } debugInteractWith(targetId: string) { if (!this.debugTeleportTo(targetId)) { return false; } const target = this.interactionTargetById(targetId); if (!target) { return false; } this.interactWithTarget(target); return true; } debugAdvanceDialogue() { if (!this.dialogueState || this.navigationPending) { return false; } this.advanceDialogue(); return true; } debugChooseScout(choiceId: string) { const choice = getFirstPursuitScoutVisitChoice(choiceId); if ( this.isSecondReliefVisit() || this.isThirdCampVisit() || !choice || this.visitComplete() ) { return false; } this.chooseScout(choice); return true; } debugRecordReliefObjective(objectiveId: string) { if ( !this.isSecondReliefVisit() || this.navigationPending || this.visitComplete() ) { return false; } const result = recordSecondBattleReliefObjective(objectiveId); if (!result.ok) { return false; } this.refreshObjectiveHud(); this.refreshActorMarkers(); this.refreshInteractionPrompt(); return true; } debugChooseVisitChoice(choiceId: string) { if (this.isThirdCampVisit()) { const choice = resolveThirdCampCompanionDialogue( getCampaignState() )?.choices.find((candidate) => candidate.id === choiceId); if (!choice) { return false; } if (!this.choicePanel) { this.openChoicePanel('third-companion'); this.choiceReadyAt = 0; } this.chooseThirdCampCompanion(choice); return thirdCampExplorationProgress().completedActivityIds.includes( 'companion' ); } if (!this.isSecondReliefVisit()) { return this.debugChooseScout(choiceId); } const choice = getSecondBattleReliefChoice(choiceId); if ( !choice || this.visitComplete() || !secondBattleReliefProgress().choiceReady ) { return false; } this.chooseReliefPriority(choice); return this.visitComplete(); } debugRecordThirdCampActivity(activityId: string) { if ( !this.isThirdCampVisit() || !['information', 'equipment', 'companion'].includes(activityId) ) { return false; } this.completeThirdCampActivity( activityId as ThirdCampExplorationActivityId ); return thirdCampExplorationProgress().completedActivityIds.includes( activityId as ThirdCampExplorationActivityId ); } private isSecondReliefVisit() { return this.visitId === secondBattleReliefVisitId; } private isThirdCampVisit() { return this.visitId === thirdCampExplorationVisitId; } private thirdCampDefinition() { return this.isThirdCampVisit() ? resolveThirdCampExplorationDefinition(getCampaignState()) : undefined; } private currentActorDefinitions() { if (this.isThirdCampVisit()) { return (this.thirdCampDefinition()?.actors ?? []).map( (actor): ExplorationActorDefinition => ({ ...actor, dialogue: actor.dialogue.map((line) => ({ ...line })), repeatDialogue: actor.repeatDialogue.map((line) => ({ ...line })), completedDialogue: actor.completedDialogue.map((line) => ({ ...line })) }) ); } return this.isSecondReliefVisit() ? secondReliefActorDefinitions : firstPursuitActorDefinitions; } private currentCharacterTextureKeys() { return [ playerTextureKey, ...this.currentActorDefinitions().map((actor) => actor.textureKey) ]; } private currentPortraitContext() { return this.isThirdCampVisit() ? thirdCampPortraitContext : this.isSecondReliefVisit() ? secondReliefPortraitContext : firstPursuitPortraitContext; } private currentPortraitKeys() { if (!this.isThirdCampVisit()) { return portraitKeys; } const definition = this.thirdCampDefinition(); return [ 'liuBei', ...(definition?.introLines.map((line) => line.portraitKey) ?? []), ...(definition?.actors.flatMap((actor) => [ actor.portraitKey, ...actor.dialogue.map((line) => line.portraitKey), ...actor.repeatDialogue.map((line) => line.portraitKey), ...actor.completedDialogue.map((line) => line.portraitKey) ]) ?? []) ].filter((key): key is string => Boolean(key)); } private currentBackgroundKey() { return this.isThirdCampVisit() ? thirdCampBackgroundKey : this.isSecondReliefVisit() ? secondReliefBackgroundKey : campBackgroundKey; } private currentBackgroundUrl() { return this.isThirdCampVisit() ? thirdCampBackgroundUrl : this.isSecondReliefVisit() ? secondReliefBackgroundUrl : campBackgroundUrl; } private currentMovementBounds() { if (this.isThirdCampVisit()) { const bounds = this.thirdCampDefinition()?.movementBounds; return bounds ? new Phaser.Geom.Rectangle( bounds.x, bounds.y, bounds.width, bounds.height ) : movementBounds; } if (!this.isSecondReliefVisit()) { return movementBounds; } const { x, y, width, height } = secondBattleReliefExplorationDefinition.movementBounds; return new Phaser.Geom.Rectangle(x, y, width, height); } private currentCampExit() { return this.isThirdCampVisit() ? this.thirdCampDefinition()?.exit ?? firstPursuitCampExit : this.isSecondReliefVisit() ? secondBattleReliefExplorationDefinition.exit : firstPursuitCampExit; } private currentChoices(): readonly ChoiceOption[] { return this.isThirdCampVisit() ? resolveThirdCampCompanionDialogue(getCampaignState())?.choices ?? [] : this.isSecondReliefVisit() ? secondBattleReliefExplorationDefinition.choices : firstPursuitScoutVisitDefinition.choices; } private thirdCampGuideMessage() { const progress = thirdCampExplorationProgress(); const selected = progress.selectedPriorityId ? getThirdCampPreparationPriority(progress.selectedPriorityId) : undefined; return progress.completedActivityCount > 0 ? `준비 활동 ${progress.completedActivityCount} / 3 · 출진 보너스 ${selected?.label ?? '미선택'}${progress.ready ? '' : selected ? ' 확인 필요' : ''} · 군영 장부에서 확정` : '준비 활동 0 / 3 · 원하는 곳만 살피고 남쪽 출구에서 언제든 출진할 수 있습니다.'; } private reliefGuideMessage() { const progress = secondBattleReliefProgress(); const objective = progress.nextObjectiveId ? getSecondBattleReliefObjective(progress.nextObjectiveId) : undefined; if (progress.completed) { return '현장 수습이 끝났습니다. 남쪽 표식에서 군영으로 돌아가세요.'; } return objective ? `${objective.label} · ${this.reliefActorName(objective.targetActorId)}에게 다가가세요.` : '간옹에게 수습 기록을 전하고 광종 진군 방침을 정하세요.'; } private reliefActorName(actorId: string) { return secondReliefActorDefinitions.find((actor) => actor.id === actorId) ?.name ?? '현장 인물'; } private resetRuntimeState() { this.backgroundImage = undefined; this.backgroundFallback = false; this.player = undefined; this.playerShadow = undefined; this.playerNameplate = undefined; this.playerDirection = 'north'; this.playerMoving = false; this.actorViews.clear(); this.blockers = []; this.dialogueState = undefined; this.campaignObjectiveJournal = undefined; this.choicePanel = undefined; this.choicePanelBounds = undefined; this.choiceViews = []; this.choicePanelMode = 'visit'; this.choiceSourceNpcId = undefined; this.choiceReadyAt = Number.POSITIVE_INFINITY; this.thirdWorldFeedback.clear(); this.thirdCampFirstEntry = false; this.thirdActivityTitleText = undefined; this.thirdPriorityLocationText = undefined; this.moveTarget = undefined; this.moveWaypoints = []; this.moveTargetId = undefined; this.lastNavigationTargetId = undefined; this.navigationReplanAttempts = 0; this.navigationDetourCount = 0; this.autoInteractionPending = false; this.autoInteractionTriggeredCount = 0; this.lastAutoInteractionTargetId = undefined; this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; this.autoDialogueInputReadyAt = 0; this.explorationInput = undefined; this.stepIndex = 0; this.lastStepAt = 0; this.lastNotice = ''; } private visitAvailable() { const campaign = getCampaignState(); if (this.isThirdCampVisit()) { const definition = resolveThirdCampExplorationDefinition(campaign); const history = definition ? campaign.battleHistory[definition.sourceBattleId] : undefined; return Boolean( definition && campaign.step === 'third-camp' && campaign.latestBattleId === definition.sourceBattleId && (history?.outcome === 'victory' || (campaign.firstBattleReport?.battleId === definition.sourceBattleId && campaign.firstBattleReport.outcome === 'victory')) ); } if (this.isSecondReliefVisit()) { return ( campaign.step === 'second-camp' && campaign.latestBattleId === secondBattleReliefSourceBattleId && campaign.firstBattleReport?.battleId === secondBattleReliefSourceBattleId && campaign.firstBattleReport.outcome === 'victory' && campaign.battleHistory[secondBattleReliefSourceBattleId]?.outcome === 'victory' ); } return ( this.visitId === firstPursuitScoutVisitId && campaign.step === 'first-camp' && campaign.latestBattleId === firstPursuitScoutSourceBattleId && campaign.firstBattleReport?.battleId === firstPursuitScoutSourceBattleId && campaign.firstBattleReport.outcome === 'victory' && campaign.battleHistory[firstPursuitScoutSourceBattleId]?.outcome === 'victory' ); } private drawCamp() { this.cameras.main.setBackgroundColor('#1a241e'); const backgroundKey = this.currentBackgroundKey(); if (this.textures.exists(backgroundKey)) { this.backgroundImage = this.add .image(0, 0, backgroundKey) .setOrigin(0) .setDisplaySize(sceneWidth, sceneHeight) .setDepth(-120); this.registerCampCollisions(); this.drawCampWayfinding(); this.drawCampFireGlow(); if (this.isThirdCampVisit()) { this.drawThirdCampWorldFeedback(); } } else { this.backgroundFallback = true; this.add .rectangle(0, 0, sceneWidth, sceneHeight, 0x26352b, 1) .setOrigin(0) .setDepth(-120); } this.add .rectangle( mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x111720, 0.97 ) .setOrigin(0) .setDepth(1400); this.add .rectangle(mapRight, 0, 3, sceneHeight, 0xd8b15f, 0.72) .setOrigin(0) .setDepth(1401); this.add .rectangle(0, 0, sceneWidth, 92, 0x10161d, 0.97) .setOrigin(0) .setDepth(1400); this.add .rectangle(0, 90, sceneWidth, 2, 0xd8b15f, 0.68) .setOrigin(0) .setDepth(1401); this.add .rectangle(0, 958, mapRight, 122, 0x0e141a, 0.95) .setOrigin(0) .setDepth(1400); this.add .rectangle(0, 956, mapRight, 2, 0xd8b15f, 0.54) .setOrigin(0) .setDepth(1401); } private registerCampCollisions() { const collisionRects = this.isThirdCampVisit() ? [ [42, 108, 1404, 124], [72, 228, 386, 176], [548, 222, 282, 174], [948, 226, 350, 194], [84, 600, 224, 178], [1035, 680, 270, 166], [600, 468, 136, 106] ] : this.isSecondReliefVisit() ? [ [42, 108, 230, 340], [42, 570, 300, 245], [850, 108, 428, 258], [1120, 350, 326, 265], [870, 430, 150, 130], [300, 690, 170, 170] ] : [ [622, 178, 325, 125], [108, 205, 248, 140], [1188, 212, 214, 132], [90, 694, 216, 140], [295, 380, 124, 72], [1018, 655, 182, 142], [330, 820, 76, 54], [413, 832, 62, 43], [1285, 760, 72, 52], [728, 520, 104, 64], [955, 448, 40, 46], [1080, 448, 40, 46], [1205, 448, 40, 46] ]; collisionRects.forEach(([x, y, width, height]) => { this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height)); }); } private drawCampWayfinding() { if (this.isThirdCampVisit()) { [ [360, 318, '동림 선봉 작전판'], [267, 660, '선봉 보급 수레'], [1115, 432, '먼저 귀환한 동료'], [790, 870, '남쪽 · 군영 장부 복귀'] ].forEach(([x, y, label]) => { this.add .text(Number(x), Number(y), String(label), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f4e3b5', fontStyle: 'bold', backgroundColor: '#15120ed4', padding: { left: 10, right: 10, top: 4, bottom: 4 } }) .setOrigin(0.5) .setDepth(45); }); const routeGlow = this.add .ellipse(776, 655, 610, 390, 0xd8b15f, 0.035) .setDepth(34); if (!this.visualMotionReduced) { this.tweens.add({ targets: routeGlow, alpha: { from: 0.02, to: 0.075 }, duration: 1250, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } return; } if (this.isSecondReliefVisit()) { [ [1090, 312, '북쪽 마을'], [380, 315, '강가 나루'], [560, 525, '전령의 말발굽'], [820, 548, '수습 장부'], [790, 800, '남쪽 · 군영 귀환'] ].forEach(([x, y, label]) => { this.add .text(Number(x), Number(y), String(label), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f4e3b5', fontStyle: 'bold', backgroundColor: '#15120ed4', padding: { left: 10, right: 10, top: 4, bottom: 4 } }) .setOrigin(0.5) .setDepth(45); }); const routeGlow = this.add .ellipse(742, 636, 610, 330, 0xe0bd6e, 0.035) .setDepth(34); if (!this.visualMotionReduced) { this.tweens.add({ targets: routeGlow, alpha: { from: 0.02, to: 0.08 }, duration: 1100, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } return; } [ [780, 294, '지휘 막사'], [250, 340, '정찰대 막사'], [1290, 340, '의병 숙영지'], [198, 824, '보급 천막'], [1100, 760, '병기 훈련장'], [784, 116, '북문 · 추격로'] ].forEach(([x, y, label]) => { this.add .text(Number(x), Number(y), String(label), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#f4e3b5', fontStyle: 'bold', backgroundColor: '#15120ed4', padding: { left: 10, right: 10, top: 4, bottom: 4 } }) .setOrigin(0.5) .setDepth(45); }); this.add .text(450, 405, '정찰 작전판', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#edf1dd', fontStyle: 'bold', backgroundColor: '#16221add', padding: { left: 10, right: 10, top: 4, bottom: 4 } }) .setOrigin(0.5) .setDepth(47); const objectiveGlow = this.add .ellipse(510, 492, 150, 86, 0xe0bd6e, 0.08) .setDepth(44); if (!this.visualMotionReduced) { this.tweens.add({ targets: objectiveGlow, alpha: { from: 0.04, to: 0.17 }, scaleX: { from: 0.9, to: 1.08 }, scaleY: { from: 0.9, to: 1.08 }, duration: 820, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } } private drawCampFireGlow() { if (this.isSecondReliefVisit()) { return; } const glow = this.add .ellipse( this.isThirdCampVisit() ? 665 : 780, this.isThirdCampVisit() ? 530 : 540, 230, 150, 0xf2a241, 0.08 ) .setDepth(35); if (!this.visualMotionReduced) { this.tweens.add({ targets: glow, alpha: { from: 0.05, to: 0.14 }, scaleX: { from: 0.95, to: 1.05 }, scaleY: { from: 0.95, to: 1.05 }, duration: 880, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } } private drawThirdCampWorldFeedback() { const definition = this.thirdCampDefinition(); if (!definition) { return; } const routeInk = this.add.graphics(); routeInk.lineStyle(5, 0xe2bc69, 0.94); routeInk.beginPath(); routeInk.moveTo(-78, 20); routeInk.lineTo(-26, -20); routeInk.lineTo(24, 6); routeInk.lineTo(78, -35); routeInk.strokePath(); [-78, -26, 24, 78].forEach((x, index) => { routeInk.fillStyle(index === 3 ? 0xc95742 : 0xf0d995, 0.96); routeInk.fillCircle(x, index % 2 === 0 ? 20 : -20, 8); }); const routeLabel = this.add .text(0, 55, '적 선봉 · 동림 퇴로 표시', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#fff0bd', fontStyle: 'bold', backgroundColor: '#271d13e8', padding: { left: 8, right: 8, top: 4, bottom: 4 } }) .setOrigin(0.5); this.thirdWorldFeedback.set( 'information', this.add .container(330, 390, [routeInk, routeLabel]) .setDepth(48) ); const cartSeal = this.add .rectangle(0, 0, 188, 78, 0x3a2718, 0.9) .setStrokeStyle(3, 0xd4b06a, 0.94); const cartGuard = this.add .text(0, -13, '盾 布 盾', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '23px', color: '#f4ddb0', fontStyle: 'bold' }) .setOrigin(0.5); const cartLabel = this.add .text(0, 24, '선봉 방호구 적재 완료', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: '#dfeccf', fontStyle: 'bold' }) .setOrigin(0.5); this.thirdWorldFeedback.set( 'equipment', this.add .container(230, 672, [cartSeal, cartGuard, cartLabel]) .setDepth(49) ); const companionActor = definition.actors.find( (actor) => actor.activityId === 'companion' ); const rallyRing = this.add .ellipse(0, 24, 154, 66, 0xe0bd6e, 0.08) .setStrokeStyle(3, 0xe4c778, 0.88); const rallyMark = this.add .text(0, -54, '결', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', color: '#fff1bc', fontStyle: 'bold', backgroundColor: '#713b2be8', padding: { left: 10, right: 10, top: 4, bottom: 4 } }) .setOrigin(0.5); const rallyLabel = this.add .text(0, 72, '협공 신호 공유', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#f4ddb0', fontStyle: 'bold', backgroundColor: '#241915df', padding: { left: 8, right: 8, top: 3, bottom: 3 } }) .setOrigin(0.5); this.thirdWorldFeedback.set( 'companion', this.add .container( companionActor?.x ?? 1110, companionActor?.y ?? 520, [rallyRing, rallyMark, rallyLabel] ) .setDepth(95) ); this.refreshThirdWorldFeedback(); } private refreshThirdWorldFeedback() { if (!this.isThirdCampVisit()) { return; } const progress = thirdCampExplorationProgress(); this.thirdWorldFeedback.forEach((container, activityId) => { const completed = progress.completedActivityIds.includes(activityId); container.setVisible(completed).setAlpha(completed ? 1 : 0); }); } private drawHud() { if (this.isThirdCampVisit()) { this.drawThirdCampHud(); return; } if (this.isSecondReliefVisit()) { this.drawSecondReliefHud(); return; } this.add .text(48, 27, '탁현 의용군 막사 · 첫 승리 뒤', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '34px', color: '#f4e3b5', fontStyle: 'bold' }) .setDepth(1500); this.add .text(590, 35, '다음 추격전을 위한 현지 준비', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#b4c0cb' }) .setDepth(1500); this.add .text(1536, 43, '북문 추격로 정찰', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '30px', color: '#f4e3b5', fontStyle: 'bold' }) .setDepth(1500); this.add .text( 1538, 91, '유비를 직접 움직여 간옹과 정찰 방향을 정합니다. 선택은 다음 전투에 이어집니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#9fabb8', lineSpacing: 4, wordWrap: { width: 330, useAdvancedWrap: true } } ) .setDepth(1500); this.objectiveCard = this.drawObjectiveCard( 188, '필수 준비', '간옹 · 정찰 작전판', '두 추격로 중 먼저 확인할 길을 선택합니다.' ).background; this.objectiveStatus = this.add .text(1550, 207, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d8b15f', fontStyle: 'bold' }) .setDepth(1502); this.drawObjectiveCard( 376, '전우와 대화', '관우 · 장비', '두 장수는 제자리에서 다음 추격전을 준비합니다.' ); this.add .text(1550, 395, '◇ 선택 활동', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d8b15f', fontStyle: 'bold' }) .setDepth(1502); this.drawObjectiveCard( 564, '군영 장부로 복귀', '남쪽 출구', '정찰을 미루고도 돌아갈 수 있습니다.' ); this.add .text(1550, 583, '◆ 항상 가능', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#a8d58e', fontStyle: 'bold' }) .setDepth(1502); this.progressText = this.add .text(1538, 772, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#cbd3dc', lineSpacing: 9, wordWrap: { width: 330, useAdvancedWrap: true } }) .setDepth(1502); 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(485, 980, '상호작용', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d8b15f', fontStyle: 'bold' }) .setDepth(1502); this.add .text(485, 1012, '가까이에서 E / Space / Enter', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#e3e7eb' }) .setDepth(1502); this.add .text(1420, 1012, 'ESC · 대화/선택 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#9fabb8' }) .setOrigin(1, 0) .setDepth(1502); this.promptBackground = this.add .rectangle(940, 1018, 610, 58, 0x2a2119, 0.98) .setStrokeStyle(2, 0xd8b15f, 0.92) .setDepth(1510) .setVisible(false); this.promptText = this.add .text(940, 1018, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f6e3af', fontStyle: 'bold' }) .setOrigin(0.5) .setDepth(1511) .setVisible(false); } private drawThirdCampHud() { const definition = this.thirdCampDefinition(); if (!definition) { return; } this.add .text(48, 27, definition.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '34px', color: '#f4e3b5', fontStyle: 'bold' }) .setDepth(1500); this.add .text(650, 35, definition.location, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#b4c0cb' }) .setDepth(1500); this.add .text(1536, 35, definition.heading, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#f4e3b5', fontStyle: 'bold', wordWrap: { width: 340, useAdvancedWrap: true } }) .setDepth(1500); this.add .text( 1538, 82, '모두 선택 활동 · 원하는 순서 · 언제든 군영 장부로 복귀', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#a8d58e', fontStyle: 'bold', wordWrap: { width: 330, useAdvancedWrap: true } } ) .setDepth(1500); const activityCard = this.drawObjectiveCard( 150, '자유 준비 0 / 3', '작전판 · 보급 수레 · 먼저 귀환한 동료', '활동을 마치면 보너스 후보가 열립니다. 현재 출진 보너스는 자동으로 바뀌지 않습니다.' ); this.objectiveCard = activityCard.background; this.thirdActivityTitleText = activityCard.titleText; this.objectiveStatus = this.add .text(1550, 166, '◆ 모두 선택', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#e1bc69', fontStyle: 'bold' }) .setDepth(1502); const priorityCard = this.drawObjectiveCard( 342, '출진 보너스 확정', '아직 정하지 않음', '실제로 적용할 하나는 군영 장부에서 따로 선택하고 확정합니다.' ); this.thirdPriorityLocationText = priorityCard.locationText; this.drawObjectiveCard( 534, '군영 장부로 복귀', '남쪽 출구 · 항상 가능', '부분 진행과 선택은 바로 저장됩니다. 준비를 남겨 두고도 돌아갈 수 있습니다.' ); this.add .text(1550, 550, '↩ 언제든 가능', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#a8d58e', fontStyle: 'bold' }) .setDepth(1502); this.progressText = this.add .text(1538, 716, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#cbd3dc', lineSpacing: 7, wordWrap: { width: 330, useAdvancedWrap: true } }) .setDepth(1502); 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(485, 980, '상호작용', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d8b15f', fontStyle: 'bold' }) .setDepth(1502); this.add .text(485, 1012, '가까이에서 E / Space / Enter', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#e3e7eb' }) .setDepth(1502); this.add .text(1420, 1012, 'ESC · 대화/선택 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#9fabb8' }) .setOrigin(1, 0) .setDepth(1502); this.promptBackground = this.add .rectangle(940, 1018, 610, 58, 0x2a2119, 0.98) .setStrokeStyle(2, 0xd8b15f, 0.92) .setDepth(1510) .setVisible(false); this.promptText = this.add .text(940, 1018, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f6e3af', fontStyle: 'bold' }) .setOrigin(0.5) .setDepth(1511) .setVisible(false); } private drawSecondReliefHud() { const definition = secondBattleReliefExplorationDefinition; this.add .text(48, 27, '탁현 북쪽 마을 · 추격전 뒤', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '34px', color: '#f4e3b5', fontStyle: 'bold' }) .setDepth(1500); this.add .text(620, 35, '전투가 끝난 자리에서 백성과 다음 길을 잇습니다', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#b4c0cb' }) .setDepth(1500); this.add .text(1536, 43, '마을·나루 수습', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '30px', color: '#f4e3b5', fontStyle: 'bold' }) .setDepth(1500); this.add .text(1538, 91, definition.subtitle, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#9fabb8', lineSpacing: 4, wordWrap: { width: 330, useAdvancedWrap: true } }) .setDepth(1500); this.objectiveCard = this.drawObjectiveCard( 188, '순서대로 확인', '관우 → 사공 → 장비 → 간옹', '현장의 증언과 흔적을 하나씩 확인합니다.' ).background; this.objectiveStatus = this.add .text(1550, 207, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d8b15f', fontStyle: 'bold' }) .setDepth(1502); this.drawObjectiveCard( 376, '광종 진군 방침', '마을 보급선 / 전령 추적', '세 현장을 확인한 뒤 간옹과 우선 기준을 정합니다.' ); this.add .text(1550, 395, '◆ 전투 준비에 반영', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d8b15f', fontStyle: 'bold' }) .setDepth(1502); this.drawObjectiveCard( 564, '군영으로 복귀', '남쪽 귀환 표식', '중간 수습 기록도 저장되어 나중에 이어갈 수 있습니다.' ); this.add .text(1550, 583, '◆ 진행 저장', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#a8d58e', fontStyle: 'bold' }) .setDepth(1502); this.progressText = this.add .text(1538, 750, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#cbd3dc', lineSpacing: 7, wordWrap: { width: 330, useAdvancedWrap: true } }) .setDepth(1502); 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(485, 980, '상호작용', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d8b15f', fontStyle: 'bold' }) .setDepth(1502); this.add .text(485, 1012, '가까이에서 E / Space / Enter', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#e3e7eb' }) .setDepth(1502); this.add .text(1420, 1012, 'ESC · 대화/선택 닫기', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#9fabb8' }) .setOrigin(1, 0) .setDepth(1502); this.promptBackground = this.add .rectangle(940, 1018, 610, 58, 0x2a2119, 0.98) .setStrokeStyle(2, 0xd8b15f, 0.92) .setDepth(1510) .setVisible(false); this.promptText = this.add .text(940, 1018, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f6e3af', fontStyle: 'bold' }) .setOrigin(0.5) .setDepth(1511) .setVisible(false); } private drawObjectiveCard( y: number, title: string, location: string, help: string ) { const background = this.add .rectangle(1530, y, 350, 156, 0x1b222d, 0.94) .setOrigin(0) .setStrokeStyle(2, 0x3d4858, 1) .setDepth(1500); const titleText = this.add .text(1550, y + 48, title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: '#e8dfca', fontStyle: 'bold' }) .setDepth(1501); const locationText = this.add .text(1550, y + 82, location, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', color: '#a4afba' }) .setDepth(1501); const helpText = this.add .text(1550, y + 109, help, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#788491', wordWrap: { width: 318, useAdvancedWrap: true } }) .setDepth(1501); return { background, titleText, locationText, helpText }; } private createActors() { const thirdDefinition = this.thirdCampDefinition(); const start = this.isThirdCampVisit() && thirdDefinition ? thirdDefinition.player : this.isSecondReliefVisit() ? secondBattleReliefExplorationDefinition.player : { x: 780, y: 720 }; const startingDirection = this.isThirdCampVisit() && thirdDefinition ? thirdDefinition.player.direction : this.isSecondReliefVisit() ? secondBattleReliefExplorationDefinition.player.direction : 'north'; this.playerDirection = startingDirection; this.playerShadow = this.add .ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45) .setDepth(800); this.player = this.createCharacterSprite( playerTextureKey, start.x, start.y, characterDisplaySize + 6, startingDirection ); this.player.setDepth(100 + start.y); this.playerNameplate = this.add .text(start.x, start.y + 68, '유비', { 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); this.currentActorDefinitions().forEach((definition) => { const shadow = this.add .ellipse( definition.x, definition.y + 20, 64, 24, 0x07100a, 0.43 ) .setDepth(90 + definition.y); const sprite = this.createCharacterSprite( definition.textureKey, definition.x, definition.y, definition.id === 'jian-yong' || definition.id === 'jian-yong-records' || definition.id === 'zou-jing-operations' || definition.id === 'quartermaster-equipment' ? characterDisplaySize + 4 : characterDisplaySize, definition.direction ); sprite.setDepth(100 + definition.y); const nameplate = this.add .text(definition.x, definition.y + 67, 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 = this.add .text( definition.x, definition.y - 77, definition.kind === 'companion' && !this.isThirdCampVisit() ? '…' : '!', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: definition.kind === 'companion' && !this.isThirdCampVisit() ? '23px' : '32px', color: '#2a2013', fontStyle: 'bold', backgroundColor: definition.kind === 'companion' && !this.isThirdCampVisit() ? '#aeb8c4' : '#e5bd68', padding: { left: 10, right: 10, top: 3, bottom: 3 } } ) .setOrigin(0.5) .setDepth(1201); if (!this.visualMotionReduced) { this.tweens.add({ targets: marker, y: marker.y - 8, duration: definition.kind === 'objective' ? 680 : 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } this.actorViews.set(definition.id, { definition, sprite, shadow, nameplate, marker, initialPosition: { x: definition.x, y: definition.y } }); }); const campExit = this.currentCampExit(); const exitMarker = this.add .text(campExit.x, campExit.y - 58, '↩', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '32px', color: '#f0d79b', fontStyle: 'bold', backgroundColor: '#3a3024dd', padding: { left: 10, right: 10, top: 4, bottom: 4 } }) .setOrigin(0.5) .setDepth(1201); this.add .text( campExit.x, campExit.y + 18, this.isSecondReliefVisit() ? '군영으로 복귀' : '군영 장부', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f0dfbd', fontStyle: 'bold', backgroundColor: '#17130fcc', padding: { left: 8, right: 8, top: 3, bottom: 3 } } ) .setOrigin(0.5) .setDepth(1200); if (!this.visualMotionReduced) { this.tweens.add({ targets: exitMarker, alpha: { from: 0.68, to: 1 }, duration: 880, yoyo: true, repeat: -1 }); } } 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); applyExplorationCharacterMotion( sprite, textureKey, 'idle', direction, this.visualMotionReduced ); 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.45) .setOrigin(0) .setInteractive(); const background = this.add .rectangle(x, y, width, height, 0x141a23, 0.985) .setOrigin(0) .setStrokeStyle(3, 0xd8b15f, 0.94); const inner = this.add .rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.84) .setOrigin(0) .setStrokeStyle(1, 0x546174, 0.72); this.dialoguePortraitFrame = this.add .rectangle(x + 130, y + 152, 232, 236, 0x0e131a, 0.92) .setStrokeStyle(2, 0x9e8350, 0.9); this.dialoguePortrait = this.add .image(x + 130, y + 152, '__DEFAULT') .setDisplaySize(220, 220) .setVisible(false); this.dialogueNameText = this.add.text(x + 270, y + 42, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '29px', color: '#f1d691', fontStyle: 'bold' }); this.dialogueBodyText = this.add.text(x + 270, y + 101, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#f0ede5', lineSpacing: 11, wordWrap: { width: 1360, 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, this.dialoguePortraitFrame, this.dialoguePortrait, this.dialogueNameText, this.dialogueBodyText, this.dialogueProgressText ]) .setDepth(3000) .setVisible(false); } private setupInput() { this.explorationInput = new ExplorationInputController(this) .bindKey( Phaser.Input.Keyboard.KeyCodes.ESC, () => this.handleEscape() ) .bindKey( Phaser.Input.Keyboard.KeyCodes.ONE, () => this.chooseChoiceByIndex(0) ) .bindKey( Phaser.Input.Keyboard.KeyCodes.TWO, () => this.chooseChoiceByIndex(1) ) .bindKey( Phaser.Input.Keyboard.KeyCodes.J, () => this.campaignObjectiveJournal?.toggle() ); this.input.keyboard?.on('keydown-ESC', () => { if (this.campaignObjectiveJournal?.isOpen()) { this.campaignObjectiveJournal.close(); } }); this.input.on( Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { if ( !this.ready || this.navigationPending || this.campaignObjectiveJournal?.isOpen() || this.time.now < this.inputReadyAt ) { return; } if (this.choicePanel) { return; } if (this.dialogueState) { if (this.time.now < this.autoDialogueInputReadyAt) { return; } this.advanceDialogue(); return; } const activeMovementBounds = this.currentMovementBounds(); if ( pointer.x >= activeMovementBounds.left && pointer.x <= activeMovementBounds.right && pointer.y >= activeMovementBounds.top && pointer.y <= activeMovementBounds.bottom ) { this.beginPointerMovement(pointer.x, pointer.y); } } ); } private handleEscape() { if ( !this.ready || this.navigationPending || this.time.now < this.inputReadyAt ) { return; } if (this.campaignObjectiveJournal?.isOpen()) { this.campaignObjectiveJournal.close(); return; } if (this.choicePanel) { this.closeChoicePanel(); return; } if (this.dialogueState) { this.cancelDialogue(); return; } this.showPromptMessage( this.isThirdCampVisit() ? '군영 장부로 돌아가려면 남쪽 출구까지 직접 이동하세요. 준비 활동은 모두 선택입니다.' : this.isSecondReliefVisit() ? '군영으로 돌아가려면 남쪽 귀환 표식까지 직접 이동하세요.' : '군영 장부로 돌아가려면 남쪽 출구까지 직접 이동하세요.' ); } private createCampaignObjectiveJournal() { this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, { snapshotProvider: () => resolveCampaignObjectiveJournal(getCampaignState()), canOpen: () => this.ready && !this.navigationPending, buttonX: 1215, buttonY: 21 }); } private updateMovement(time: number, delta: number) { if (!this.player) { return; } const keyboardDirection = this.keyboardMovementDirection(); let movement = keyboardDirection; let pointerDistanceRemaining = Number.POSITIVE_INFINITY; if (movement.lengthSq() > 0) { this.clearNavigationTarget(true); } else if (this.moveTarget) { const activeTarget = this.moveWaypoints[0] ?? this.moveTarget; const targetDelta = activeTarget .clone() .subtract(new Phaser.Math.Vector2(this.player.x, this.player.y)); pointerDistanceRemaining = targetDelta.length(); if (pointerDistanceRemaining <= navigationArrivalRadius) { if (this.moveWaypoints.length > 0) { this.moveWaypoints.shift(); } else { const arrivedTargetId = this.moveTargetId; const shouldAutoInteract = this.autoInteractionPending; this.lastNavigationTargetId = arrivedTargetId; this.clearNavigationTarget(false); this.setPlayerMoving(false); if (arrivedTargetId && shouldAutoInteract) { this.triggerAutoInteraction(arrivedTargetId); } } 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 = Math.min( (playerSpeed * Math.min(delta, 50)) / 1000, pointerDistanceRemaining ); 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) { if (!this.replanBlockedPointerMovement()) { this.clearNavigationTarget(true); } } 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 beginPointerMovement(x: number, y: number) { const snappedTarget = this.pointerMovementTargetAt(x, y); if (!snappedTarget) { this.beginNavigationTo(x, y); return; } const plannedApproaches = this.approachPositions( snappedTarget.x, snappedTarget.y ) .map((destination) => ({ destination, route: this.planNavigationRoute(destination) })) .filter( ( entry ): entry is { destination: Phaser.Math.Vector2; route: Phaser.Math.Vector2[]; } => Boolean(entry.route) ) .sort( (left, right) => this.navigationRouteLength(left.route) - this.navigationRouteLength(right.route) ); const best = plannedApproaches[0]; if (best) { this.applyNavigationRoute(best.route, snappedTarget.id); return; } const fallback = this.safeApproachPosition( snappedTarget.x, snappedTarget.y ); this.beginNavigationTo( fallback.x, fallback.y, snappedTarget.id ); } private beginNavigationTo( x: number, y: number, targetId?: InteractionTarget['id'] ) { const destination = this.nearestWalkablePoint(x, y); if (!destination) { this.clearNavigationTarget(true); return; } const route = this.planNavigationRoute(destination); if (!route) { this.clearNavigationTarget(true); return; } this.applyNavigationRoute(route, targetId); } private applyNavigationRoute( route: Phaser.Math.Vector2[], targetId?: InteractionTarget['id'] ) { const destination = route[route.length - 1]; if (!destination) { this.clearNavigationTarget(true); return; } this.moveTarget = destination.clone(); this.moveWaypoints = route .slice(0, -1) .map((waypoint) => waypoint.clone()); this.moveTargetId = targetId; this.lastNavigationTargetId = undefined; this.navigationReplanAttempts = 0; this.autoInteractionPending = Boolean( targetId && targetId !== 'camp-exit' ); if (route.length > 1) { this.navigationDetourCount += 1; } } private pointerMovementTargetAt(x: number, y: number) { return this.allInteractionTargets() .map((target) => ({ target, distance: Phaser.Math.Distance.Between(x, y, target.x, target.y) })) .filter(({ distance }) => distance <= directPointerSnapRadius) .sort((left, right) => left.distance - right.distance)[0]?.target; } private approachPositions(targetX: number, targetY: number) { return [ new Phaser.Math.Vector2(targetX, targetY + 88), new Phaser.Math.Vector2(targetX - 88, targetY), new Phaser.Math.Vector2(targetX + 88, targetY), new Phaser.Math.Vector2(targetX, targetY - 88) ].filter(({ x, y }) => this.canPlayerOccupy(x, y)); } private nearestWalkablePoint(x: number, y: number) { const margin = playerCollisionRadius + 4; const activeMovementBounds = this.currentMovementBounds(); const clamped = new Phaser.Math.Vector2( Phaser.Math.Clamp( x, activeMovementBounds.left + margin, activeMovementBounds.right - margin ), Phaser.Math.Clamp( y, activeMovementBounds.top + margin, activeMovementBounds.bottom - margin ) ); if (this.canPlayerOccupy(clamped.x, clamped.y)) { return clamped; } for (const radius of [48, 80, 112, 144]) { for (let index = 0; index < 8; index += 1) { const angle = (index * Math.PI) / 4; const candidate = new Phaser.Math.Vector2( Phaser.Math.Clamp( clamped.x + Math.cos(angle) * radius, activeMovementBounds.left + margin, activeMovementBounds.right - margin ), Phaser.Math.Clamp( clamped.y + Math.sin(angle) * radius, activeMovementBounds.top + margin, activeMovementBounds.bottom - margin ) ); if (this.canPlayerOccupy(candidate.x, candidate.y)) { return candidate; } } } return undefined; } private planNavigationRoute(destination: Phaser.Math.Vector2) { if ( !this.player || !this.canPlayerOccupy(destination.x, destination.y) ) { return undefined; } const start = new Phaser.Math.Vector2(this.player.x, this.player.y); if (this.segmentIsNavigable(start, destination)) { return [destination.clone()]; } const candidates = this.navigationDetourCandidates(start, destination); let bestRoute: Phaser.Math.Vector2[] | undefined; let bestLength = Number.POSITIVE_INFINITY; const startVisible = candidates.filter((candidate) => this.segmentIsNavigable(start, candidate) ); const destinationVisible = candidates.filter((candidate) => this.segmentIsNavigable(candidate, destination) ); startVisible.forEach((waypoint) => { if (!this.segmentIsNavigable(waypoint, destination)) { return; } const route = [waypoint, destination]; const length = this.navigationRouteLength(route); if (length < bestLength) { bestLength = length; bestRoute = route; } }); startVisible.forEach((first) => { destinationVisible.forEach((second) => { if ( first.equals(second) || !this.segmentIsNavigable(first, second) ) { return; } const route = [first, second, destination]; const length = this.navigationRouteLength(route); if (length < bestLength) { bestLength = length; bestRoute = route; } }); }); return bestRoute?.map((waypoint) => waypoint.clone()); } private navigationDetourCandidates( start: Phaser.Math.Vector2, destination: Phaser.Math.Vector2 ) { const margin = playerCollisionRadius + 4; const activeMovementBounds = this.currentMovementBounds(); const candidates = [ new Phaser.Math.Vector2(start.x, destination.y), new Phaser.Math.Vector2(destination.x, start.y) ]; this.blockers.forEach((blocker) => { const left = blocker.left - navigationDetourClearance; const right = blocker.right + navigationDetourClearance; const top = blocker.top - navigationDetourClearance; const bottom = blocker.bottom + navigationDetourClearance; candidates.push( new Phaser.Math.Vector2(left, top), new Phaser.Math.Vector2(right, top), new Phaser.Math.Vector2(left, bottom), new Phaser.Math.Vector2(right, bottom) ); }); this.actorViews.forEach(({ sprite }) => { candidates.push( new Phaser.Math.Vector2(sprite.x - 72, sprite.y), new Phaser.Math.Vector2(sprite.x + 72, sprite.y), new Phaser.Math.Vector2(sprite.x, sprite.y - 72), new Phaser.Math.Vector2(sprite.x, sprite.y + 72) ); }); const unique = new Map(); candidates.forEach((candidate) => { candidate.set( Phaser.Math.Clamp( candidate.x, activeMovementBounds.left + margin, activeMovementBounds.right - margin ), Phaser.Math.Clamp( candidate.y, activeMovementBounds.top + margin, activeMovementBounds.bottom - margin ) ); if (!this.canPlayerOccupy(candidate.x, candidate.y)) { return; } unique.set( `${Math.round(candidate.x)}:${Math.round(candidate.y)}`, candidate ); }); return Array.from(unique.values()); } private segmentIsNavigable( from: Phaser.Math.Vector2, to: Phaser.Math.Vector2 ) { const distance = Phaser.Math.Distance.Between( from.x, from.y, to.x, to.y ); const steps = Math.max(1, Math.ceil(distance / navigationProbeStep)); for (let index = 1; index <= steps; index += 1) { const progress = index / steps; const x = Phaser.Math.Linear(from.x, to.x, progress); const y = Phaser.Math.Linear(from.y, to.y, progress); if (!this.canPlayerOccupy(x, y)) { return false; } } return true; } private navigationRouteLength( route: readonly Phaser.Math.Vector2[] ) { if (!this.player) { return Number.POSITIVE_INFINITY; } let previous = new Phaser.Math.Vector2(this.player.x, this.player.y); return route.reduce((total, waypoint) => { const length = Phaser.Math.Distance.Between( previous.x, previous.y, waypoint.x, waypoint.y ); previous = waypoint; return total + length; }, 0); } private replanBlockedPointerMovement() { if (!this.moveTarget || this.navigationReplanAttempts >= 2) { return false; } this.navigationReplanAttempts += 1; const route = this.planNavigationRoute(this.moveTarget); if (!route) { return false; } this.moveWaypoints = route .slice(0, -1) .map((waypoint) => waypoint.clone()); return true; } private clearNavigationTarget(clearLast = false) { this.moveTarget = undefined; this.moveWaypoints = []; this.moveTargetId = undefined; this.navigationReplanAttempts = 0; this.autoInteractionPending = false; if (clearLast) { this.lastNavigationTargetId = undefined; } } private triggerAutoInteraction(targetId: InteractionTarget['id']) { if ( this.navigationPending || this.dialogueState || this.choicePanel || !this.ready ) { return; } const target = this.interactionTargetById(targetId); if (!target || target.kind !== 'actor') { return; } if (this.distanceTo(target.x, target.y) > interactionRadius + 8) { const recoveryRoute = this.approachPositions(target.x, target.y) .map((destination) => this.planNavigationRoute(destination)) .filter( (route): route is Phaser.Math.Vector2[] => Boolean(route) ) .sort( (left, right) => this.navigationRouteLength(left) - this.navigationRouteLength(right) )[0]; if (recoveryRoute) { this.applyNavigationRoute(recoveryRoute, target.id); } return; } this.autoInteractionTriggeredCount += 1; this.lastAutoInteractionTargetId = target.id; this.interactWithTarget(target); if (this.dialogueState) { this.autoDialogueInputReadyAt = this.time.now + inputCarryoverGuardMs; } } private consumeInteractionRequest() { return this.explorationInput?.consumeInteractionRequest() ?? false; } private keyboardMovementDirection() { return ( this.explorationInput?.movementDirection() ?? new Phaser.Math.Vector2() ); } 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) { const activeMovementBounds = this.currentMovementBounds(); if ( x - playerCollisionRadius < activeMovementBounds.left || x + playerCollisionRadius > activeMovementBounds.right || y - playerCollisionRadius < activeMovementBounds.top || y + playerCollisionRadius > activeMovementBounds.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.actorViews.values()).every(({ sprite }) => Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48 ); } private setPlayerMoving(moving: boolean) { if (!this.player) { return; } applyExplorationCharacterMotion( this.player, playerTextureKey, moving ? 'walk' : 'idle', this.playerDirection, this.visualMotionReduced ); this.playerMoving = moving; } private stopPlayerMovement() { this.clearNavigationTarget(); this.setPlayerMoving(false); } private syncPlayerView() { if (!this.player) { return; } this.playerShadow ?.setPosition(this.player.x, this.player.y + 20) .setDepth(90 + this.player.y); this.player.setDepth(100 + this.player.y); this.playerNameplate?.setPosition(this.player.x, this.player.y + 68); } private setPlayerPosition(x: number, y: number) { if (!this.player) { return; } this.player.setPosition(x, y); this.syncPlayerView(); this.stopPlayerMovement(); } private safeApproachPosition(targetX: number, targetY: number) { const activeMovementBounds = this.currentMovementBounds(); return ( this.approachPositions(targetX, targetY)[0] ?? { x: Phaser.Math.Clamp( targetX, activeMovementBounds.left + 40, activeMovementBounds.right - 40 ), y: Phaser.Math.Clamp( targetY + 100, activeMovementBounds.top + 40, activeMovementBounds.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 === 'exit') { this.interactWithExit(); return; } const view = this.actorViews.get(target.id); if (!view) { return; } this.faceCharactersForConversation(view); if (this.isThirdCampVisit()) { this.interactWithThirdCampActor(view); return; } if (this.isSecondReliefVisit()) { this.interactWithReliefActor(view); return; } if (view.definition.kind === 'objective') { this.interactWithJianYong(view); return; } const lines = this.visitComplete() && view.definition.repeatDialogue ? view.definition.repeatDialogue : view.definition.dialogue; this.startDialogue( [...lines], () => this.restoreActorDirection(view), view.definition.id ); } private interactWithThirdCampActor(view: ExplorationActorView) { const activityId = view.definition.activityId; if (!activityId) { this.startDialogue( [...view.definition.dialogue], () => this.restoreActorDirection(view), view.definition.id ); return; } const progress = thirdCampExplorationProgress(); const completed = progress.completedActivityIds.includes(activityId); if (activityId === 'companion' && !completed) { this.startDialogue( [...view.definition.dialogue], () => { this.restoreActorDirection(view); this.openChoicePanel('third-companion', view.definition.id); }, view.definition.id ); return; } const lines = completed ? view.definition.completedDialogue?.length ? view.definition.completedDialogue : view.definition.repeatDialogue ?? view.definition.dialogue : view.definition.dialogue; this.startDialogue( [...lines], () => { this.restoreActorDirection(view); this.completeThirdCampActivity(activityId); }, view.definition.id ); } private completeThirdCampActivity( activityId: ThirdCampExplorationActivityId ) { const before = thirdCampExplorationProgress(); const result = recordThirdCampExplorationActivity(activityId); if (!result.ok) { const message = result.reason === 'choice-required' ? '동료와 대화를 마치고 응답을 정해야 합니다.' : result.reason === 'invalid-activity' ? '이 준비 활동은 기록할 수 없습니다.' : result.reason === 'invalid-campaign' ? '현재 군영에서는 이 준비를 진행할 수 없습니다.' : '준비 기록을 저장하지 못했습니다.'; this.showCompletionToast(message, 'error'); return; } const newlyCompleted = !before.completedActivityIds.includes(activityId); this.inputReadyAt = this.time.now + inputCarryoverGuardMs; this.lastNotice = `${result.activity.shortLabel} · 보너스 후보 해금`; if (newlyCompleted) { soundDirector.playObjectiveAchieved(); } else { soundDirector.playSelect(); } this.refreshObjectiveHud(); this.refreshActorMarkers(); this.refreshThirdWorldFeedback(); this.refreshInteractionPrompt(); this.showCompletionToast( newlyCompleted ? `${result.activity.completionLine} 군영 장부에서 출진 보너스로 선택할 수 있습니다.` : `${result.priority.label} 후보는 이미 해금되어 있습니다. 현재 출진 보너스는 그대로 유지됩니다.`, 'success', newlyCompleted ? 2100 : 1750 ); } private interactWithReliefActor(view: ExplorationActorView) { const objective = view.definition.objectiveId ? getSecondBattleReliefObjective(view.definition.objectiveId) : undefined; if (!objective) { this.startDialogue( [...view.definition.dialogue], () => this.restoreActorDirection(view), view.definition.id ); return; } const campaign = getCampaignState(); const progress = secondBattleReliefProgress(campaign); if ( progress.completed || progress.completedObjectiveIds.includes(objective.id) ) { this.startDialogue( [...(view.definition.repeatDialogue ?? view.definition.dialogue)], () => this.restoreActorDirection(view), view.definition.id ); return; } const missingPrerequisiteId = objective.prerequisiteObjectiveIds.find( (objectiveId) => !progress.completedObjectiveIds.includes(objectiveId) ); if (missingPrerequisiteId) { const missingPrerequisite = getSecondBattleReliefObjective( missingPrerequisiteId ); this.startDialogue( [ { speaker: '현장 기록', text: missingPrerequisite ? `아직 '${missingPrerequisite.label}' 확인이 남았습니다. 앞선 현장부터 살핀 뒤 다시 오십시오.` : '앞선 현장 확인이 남았습니다.' } ], () => this.restoreActorDirection(view), view.definition.id ); return; } if (objective.id === 'set-guangzong-priority') { this.startDialogue( [...view.definition.dialogue], () => { this.restoreActorDirection(view); this.openChoicePanel(); }, view.definition.id ); return; } const dialogueLines = objective.id === 'reassure-northern-village' ? this.reliefOpeningDialogue(view) : [...view.definition.dialogue]; this.startDialogue( dialogueLines, () => { this.restoreActorDirection(view); this.completeReliefObjective(objective.id); }, view.definition.id ); } private reliefOpeningDialogue(view: ExplorationActorView) { const context = resolveSecondBattleReliefContext( getCampaignState() ); if (!context) { return [...view.definition.dialogue]; } return [ ...secondBattleReliefExplorationDefinition.introLines, { speaker: '전투 뒤 기록', text: context.arrivalLine }, { speaker: '간옹', portraitKey: 'jian-yong-campaign', text: context.scoutReview.line }, ...view.definition.dialogue ]; } private completeReliefObjective( objectiveId: SecondBattleReliefObjectiveId ) { const result = recordSecondBattleReliefObjective(objectiveId); if (!result.ok) { const message = result.reason === 'prerequisites-incomplete' ? '앞선 현장부터 확인해야 합니다.' : result.reason === 'already-completed' ? '현장 수습 방침은 이미 기록했습니다.' : '현장 기록을 저장하지 못했습니다.'; this.showCompletionToast(message, 'error'); this.refreshObjectiveHud(); this.refreshActorMarkers(); return; } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; this.lastNotice = result.objective.completionLine; soundDirector.playObjectiveAchieved(); this.refreshObjectiveHud(); this.refreshActorMarkers(); this.refreshInteractionPrompt(); this.showCompletionToast(result.objective.completionLine, 'success', 1900); } private interactWithJianYong(view: ExplorationActorView) { const campaign = getCampaignState(); const choiceId = campaign.campVisitChoiceIds[firstPursuitScoutVisitId]; const choice = choiceId ? getFirstPursuitScoutVisitChoice(choiceId) : undefined; if (this.visitComplete(campaign)) { this.startDialogue( [ { speaker: '간옹', portraitKey: view.definition.portraitKey, text: choice?.response.replace(/^간옹:\s*/, '') ?? '정찰 기록은 이미 출진 장부에 올렸습니다.' }, { speaker: '정찰 기록', text: choice ? `나의 결정 · ${choice.label}` : '북문 추격로 정찰 완료' } ], () => this.restoreActorDirection(view), view.definition.id ); return; } this.startDialogue( [...view.definition.dialogue], () => { this.restoreActorDirection(view); this.openChoicePanel(); }, view.definition.id ); } private faceCharactersForConversation(view: ExplorationActorView) { 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)); applyExplorationCharacterMotion( view.sprite, view.definition.textureKey, 'idle', npcDirection, this.visualMotionReduced ); } private restoreActorDirection(view: ExplorationActorView) { applyExplorationCharacterMotion( view.sprite, view.definition.textureKey, 'idle', view.definition.direction, this.visualMotionReduced ); } private startDialogue( lines: DialogueLine[], onComplete?: () => void, sourceNpcId?: DialogueState['sourceNpcId'] ) { if ( lines.length === 0 || this.navigationPending || this.choicePanel ) { 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.dialogueProgressText?.setText( `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` ); const portraitEntry = this.dialoguePortraitEntry(line); const portraitVisible = Boolean( this.dialoguePortrait && portraitEntry && this.textures.exists(portraitEntry.textureKey) ); const textX = portraitVisible ? 342 : 118; const bodyWidth = portraitVisible ? 1360 : 1640; this.dialogueNameText ?.setX(textX) .setText(line.speaker); this.dialogueBodyText ?.setX(textX) .setWordWrapWidth(bodyWidth, true) .setText(line.text); if (portraitVisible && portraitEntry && this.dialoguePortrait) { this.dialoguePortraitFrame?.setVisible(true); this.dialoguePortrait .setTexture(portraitEntry.textureKey) .setVisible(true); fitDialogueFacePortrait(this.dialoguePortrait, 220); return; } this.dialoguePortraitFrame?.setVisible(false); this.dialoguePortrait?.setVisible(false); } private dialoguePortraitEntry(line: DialogueLine) { return line.portraitKey ? portraitAssetEntryForStoryContext( line.portraitKey, this.currentPortraitContext(), `${this.visitId}-dialogue-${line.speaker}` ) : undefined; } 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 cancelDialogue() { const sourceNpcId = this.dialogueState?.sourceNpcId; this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); if (sourceNpcId && sourceNpcId !== 'camp-exit') { const view = this.actorViews.get(sourceNpcId); if (view) { this.restoreActorDirection(view); } } this.refreshInteractionPrompt(); } private openChoicePanel( mode: ChoicePanelMode = 'visit', sourceNpcId?: ExplorationActorId ) { if ( this.choicePanel || this.navigationPending || (mode === 'visit' && this.visitComplete()) || (mode === 'third-companion' && thirdCampExplorationProgress().completedActivityIds.includes( 'companion' )) ) { return; } this.stopPlayerMovement(); this.promptBackground?.setVisible(false); this.promptText?.setVisible(false); const panelBounds = new Phaser.Geom.Rectangle(110, 600, 1700, 370); const shade = this.add .rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.62) .setOrigin(0) .setInteractive(); const panel = this.add .rectangle( panelBounds.x, panelBounds.y, panelBounds.width, panelBounds.height, 0x151c26, 0.995 ) .setOrigin(0) .setStrokeStyle(3, 0xd8b15f, 0.94); const title = this.add.text( panelBounds.x + 40, panelBounds.y + 38, mode === 'third-companion' ? resolveThirdCampCompanionDialogue(getCampaignState())?.title ?? '동료와 다음 호흡 정하기' : this.isSecondReliefVisit() ? secondBattleReliefExplorationDefinition.title : firstPursuitScoutVisitDefinition.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '31px', color: '#f1d691', fontStyle: 'bold' } ); const hint = this.add.text( panelBounds.x + 40, panelBounds.y + 84, mode === 'third-companion' ? '지난 전투를 함께 되짚고 다음 본영전의 짧은 협공 신호를 정하세요. 숫자 1·2 또는 마우스로 선택할 수 있습니다.' : this.isSecondReliefVisit() ? '광종 구원로에서 먼저 세울 기준을 선택하세요. 결정과 보상은 저장되며 세 번째 전투의 출진 준비로 이어집니다.' : '먼저 확인할 길을 선택하세요. 결정과 보상은 저장되며 두 번째 전투의 배치 정보로 이어집니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#aeb8c4' } ); const objects: Phaser.GameObjects.GameObject[] = [ shade, panel, title, hint ]; this.choiceViews = this.currentChoices().map( (choice, index) => { const x = panelBounds.x + 40 + index * 820; const y = panelBounds.y + 140; const width = 790; const button = this.add .rectangle(x, y, width, 170, 0x1d2936, 0.98) .setOrigin(0) .setStrokeStyle(2, 0xd8b15f, 0.76) .setInteractive({ useHandCursor: true }); const number = this.add.text(x + 28, y + 24, `${index + 1}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#f1d691', fontStyle: 'bold' }); const label = this.add.text(x + 76, y + 24, choice.label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '23px', color: '#f0ede5', fontStyle: 'bold', wordWrap: { width: 670, useAdvancedWrap: true } }); const reward = this.add.text( x + 76, y + 84, this.choiceRewardText(choice), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', color: '#a8d58e', fontStyle: 'bold' } ); const effect = this.add.text( x + 76, y + 120, this.choiceEffectText(choice), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#c2ccd7', wordWrap: { width: 670, useAdvancedWrap: true } } ); button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99) ); button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98) ); button.on('pointerdown', () => this.chooseVisitChoice(choice)); objects.push(button, number, label, reward, effect); return { choice, button }; } ); const cancel = this.add .text( panelBounds.right - 34, panelBounds.bottom - 24, 'ESC · 나중에 결정', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#8f9aa7' } ) .setOrigin(1, 0.5); objects.push(cancel); this.choicePanel = this.add .container(0, 0, objects) .setDepth(3200); this.choicePanelBounds = panelBounds; this.choicePanelMode = mode; this.choiceSourceNpcId = sourceNpcId; this.choiceReadyAt = this.time.now + inputCarryoverGuardMs; } private chooseChoiceByIndex(index: number) { if ( !this.choicePanel || this.navigationPending || this.campaignObjectiveJournal?.isOpen() || this.time.now < this.choiceReadyAt ) { return; } const choice = this.currentChoices()[index]; if (choice) { this.chooseVisitChoice(choice); } } private chooseVisitChoice(choice: ChoiceOption) { if ( !this.choicePanel || this.navigationPending || this.campaignObjectiveJournal?.isOpen() ) { return; } if (this.isThirdCampVisit()) { if ('rewardExp' in choice) { this.chooseThirdCampCompanion(choice); } return; } if (this.isSecondReliefVisit()) { const reliefChoice = getSecondBattleReliefChoice(choice.id); if (reliefChoice) { this.chooseReliefPriority(reliefChoice); } return; } const scoutChoice = getFirstPursuitScoutVisitChoice(choice.id); if (scoutChoice) { this.chooseScout(scoutChoice); } } private chooseScout(choice: FirstPursuitScoutVisitChoice) { if ( this.navigationPending || (this.choicePanel && this.time.now < this.choiceReadyAt) ) { return; } const result = completeFirstPursuitScoutVisit(choice.id); if (!result.ok) { this.closeChoicePanel(); const message = result.reason === 'already-completed' ? '이미 정찰 방향을 기록했습니다.' : result.reason === 'invalid-choice' ? '선택할 수 없는 정찰 방향입니다.' : '정찰 기록을 저장하지 못했습니다.'; this.showCompletionToast(message, 'error'); this.refreshObjectiveHud(); this.refreshActorMarkers(); return; } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; this.closeChoicePanel(); this.lastNotice = `정찰 완료 · ${result.choice.label}`; soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 }); this.refreshObjectiveHud(); this.refreshActorMarkers(); this.startDialogue( [ { speaker: '간옹', portraitKey: 'jian-yong-campaign', text: result.choice.response.replace(/^간옹:\s*/, '') }, { speaker: '정찰 기록', text: `${result.choice.label} · 공명 +${result.choice.bondExp} · ${result.choice.itemRewards.join(' · ')}` } ], () => this.showCompletionToast( '정찰 기록이 출진 준비와 다음 전투에 반영됩니다.' ), 'jian-yong' ); } private chooseReliefPriority(choice: SecondBattleReliefChoice) { if ( this.navigationPending || (this.choicePanel && this.time.now < this.choiceReadyAt) ) { return; } const result = completeSecondBattleReliefExploration(choice.id); if (!result.ok) { this.closeChoicePanel(); const message = result.reason === 'already-completed' ? '현장 수습 방침은 이미 기록했습니다.' : result.reason === 'prerequisites-incomplete' ? '마을과 나루의 현장 확인을 먼저 마쳐야 합니다.' : result.reason === 'invalid-choice' ? '선택할 수 없는 광종 진군 방침입니다.' : '현장 수습 기록을 저장하지 못했습니다.'; this.showCompletionToast(message, 'error'); this.refreshObjectiveHud(); this.refreshActorMarkers(); return; } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; this.closeChoicePanel(); this.lastNotice = `수습 완료 · ${result.choice.label}`; soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 }); this.refreshObjectiveHud(); this.refreshActorMarkers(); this.startDialogue( [ { speaker: '간옹', portraitKey: 'jian-yong-campaign', text: result.choice.response }, { speaker: '현장 수습 기록', text: `${result.choice.label} · 공명 +${result.choice.bondExp} · ${result.choice.itemRewards.join(' · ')}` } ], () => this.showCompletionToast( '수습 방침이 광종 구원로의 출진 준비에 반영됩니다.' ), 'jian-yong-records' ); } private chooseThirdCampCompanion( choice: ThirdCampCompanionDialogueChoice ) { if ( this.navigationPending || this.choicePanelMode !== 'third-companion' || (this.choicePanel && this.time.now < this.choiceReadyAt) ) { return; } const dialogue = resolveThirdCampCompanionDialogue( getCampaignState() ); const alreadyCompleted = Boolean( dialogue && getCampaignState().completedCampDialogues.includes(dialogue.id) ); const sourceNpcId = this.choiceSourceNpcId; const result = completeThirdCampCompanionActivity(choice.id); if (!result.ok) { this.closeChoicePanel(); const message = result.reason === 'invalid-choice' ? '선택할 수 없는 응답입니다.' : result.reason === 'bond-unavailable' ? '현재 동료 공명 기록을 갱신할 수 없습니다.' : result.reason === 'invalid-campaign' ? '현재 군영에서는 이 대화를 진행할 수 없습니다.' : '동료 대화 기록을 저장하지 못했습니다.'; this.showCompletionToast(message, 'error'); this.refreshObjectiveHud(); this.refreshActorMarkers(); return; } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; this.closeChoicePanel(); this.lastNotice = '동료 공명 · 보너스 후보 해금'; if (alreadyCompleted) { soundDirector.playSelect(); } else { soundDirector.playEffect('bond-resonance', { volume: 0.3, stopAfterMs: 1150 }); } this.refreshObjectiveHud(); this.refreshActorMarkers(); this.refreshThirdWorldFeedback(); this.startDialogue( [ { speaker: '출진 약속', text: result.choice.response }, { speaker: '공명 기록', text: alreadyCompleted ? '동료 공명 후보는 이미 해금되어 있습니다. 현재 출진 보너스와 공명 보상은 바뀌지 않습니다.' : `${result.choice.label} · 공명 +${result.rewardExp} · 군영 장부에서 초반 협공 보너스로 선택 가능` } ], () => this.showCompletionToast( result.activity.completionLine, 'success', 2100 ), sourceNpcId ); } private closeChoicePanel() { this.choicePanel?.destroy(); this.choicePanel = undefined; this.choicePanelBounds = undefined; this.choiceViews = []; this.choicePanelMode = 'visit'; this.choiceSourceNpcId = undefined; this.choiceReadyAt = Number.POSITIVE_INFINITY; this.refreshInteractionPrompt(); } private choiceRewardText(choice: ChoiceOption) { if ('rewardExp' in choice) { const dialogue = resolveThirdCampCompanionDialogue( getCampaignState() ); return `공명 +${(dialogue?.rewardExp ?? 0) + choice.rewardExp} · 보상은 최초 1회`; } return `공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}`; } private choiceEffectText(choice: ChoiceOption) { if ('rewardExp' in choice) { return choice.response; } if (this.isSecondReliefVisit()) { const reliefChoice = getSecondBattleReliefChoice(choice.id); if (reliefChoice?.thirdBattleEffect.kind === 'village-supply-line') { return '다음 전투 · 유비 후방 지원 · 휴대 콩 +1'; } if (reliefChoice?.thirdBattleEffect.kind === 'ferry-messenger-intel') { return '다음 전투 · 장비 측면 추천 · 전령 마원의 첫 행동 표시'; } } return choice.id === 'trace-river-ambush' ? '다음 전투 · 나루 앞 숲과 궁병 엄호선을 먼저 확인' : '다음 전투 · 마을 진입로와 피난민 퇴로를 먼저 확인'; } private interactWithExit() { if (!this.visitComplete()) { if (this.isThirdCampVisit()) { const progress = thirdCampExplorationProgress(); this.startDialogue( [ { speaker: '유비', portraitKey: 'liuBei', text: progress.completedActivityCount > 0 ? '살핀 준비는 장부에 남았다. 실제로 적용할 보너스는 군영 장부에서 따로 정하고 출진하자.' : '아직 별도 준비를 확인하지 않았지만, 세 활동은 모두 선택이다. 군영 장부로 돌아가 바로 출진해도 된다.' }, { speaker: '광종 출진 기록', text: `선택 활동 ${progress.completedActivityCount} / 3 · 부분 진행 저장 · 언제든 다시 방문 가능` } ], () => this.returnToCamp(), 'camp-exit' ); return; } if (this.isSecondReliefVisit()) { const progress = secondBattleReliefProgress(); this.startDialogue( [ { speaker: '유비', portraitKey: 'liu-bei-yellow-turban', text: progress.completedObjectiveIds.length > 0 ? '확인한 현장은 장부에 남겼다. 잠시 군영으로 돌아가도 다음에는 이어서 수습할 수 있다.' : '마을과 나루를 아직 살피지 못했다. 이번에는 군영으로 돌아가되 출진 전 다시 와야 한다.' }, { speaker: '현장 기록', text: progress.choiceReady ? '현장 확인 완료 · 광종 진군 방침 결정 대기 · 진행 내용은 저장됩니다.' : `현장 확인 ${Math.min(3, progress.completedObjectiveIds.length)} / 3 · 진행 내용은 저장됩니다.` } ], () => this.returnToCamp(), 'camp-exit' ); return; } this.startDialogue( [ { speaker: '유비', portraitKey: 'liu-bei-yellow-turban', text: '간옹과 정찰 방향을 아직 정하지 않았다. 이번에는 군영 장부로 돌아가되 출진 전 다시 들를 수 있다.' }, { speaker: '군영 기록', text: '정찰은 선택 활동입니다. 진행하지 않아도 군영으로 돌아갈 수 있습니다.' } ], () => this.returnToCamp(), 'camp-exit' ); return; } this.returnToCamp(); } private returnToCamp() { if (this.navigationPending) { return; } this.navigationPending = true; this.stopPlayerMovement(); this.showTransitionOverlay(); this.time.delayedCall(transitionFadeDurationMs, () => { if (!this.sys.isActive()) { return; } void startGameScene(this, 'CampScene').catch((error) => { console.error('Failed to return from camp exploration.', error); this.navigationPending = false; this.transitionOverlay?.destroy(); this.transitionOverlay = undefined; this.showCompletionToast( '군영 장부로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', 'error' ); this.refreshInteractionPrompt(); }); }); } private showTransitionOverlay() { const shade = this.add .rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.82) .setOrigin(0); const title = this.add .text( sceneWidth / 2, sceneHeight / 2 - 32, this.isThirdCampVisit() ? '광종 동림 출진 준비 기록' : this.isSecondReliefVisit() ? '북쪽 마을·나루 수습 기록' : '탁현 의용군 막사 기록', { 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, this.isThirdCampVisit() ? this.visitComplete() ? '완료한 활동을 장부에 반영하고, 출진 보너스는 군영에서 따로 확정합니다.' : '완료한 활동을 저장하고 보너스를 정할 군영 장부로 돌아갑니다.' : this.visitComplete() ? this.isSecondReliefVisit() ? '현장 수습과 광종 진군 방침을 출진 장부에 올립니다.' : '간옹의 정찰 기록을 출진 장부에 올립니다.' : this.isSecondReliefVisit() ? '확인한 현장 기록을 저장하고 군영으로 돌아갑니다.' : '정찰 활동을 남겨 두고 군영 장부로 돌아갑니다.', { 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); if (this.visualMotionReduced) { this.transitionOverlay.setAlpha(1); } else { this.tweens.add({ targets: this.transitionOverlay, alpha: 1, duration: transitionFadeDurationMs, ease: 'Sine.easeOut' }); } } private refreshObjectiveHud() { const campaign = getCampaignState(); const completed = this.visitComplete(campaign); if (this.isThirdCampVisit()) { const progress = thirdCampExplorationProgress(campaign); const selected = progress.selectedPriorityId ? getThirdCampPreparationPriority( progress.selectedPriorityId ) : undefined; this.objectiveStatus ?.setText( `◆ 선택 활동 ${progress.completedActivityCount} / 3 · 언제든 복귀` ) .setColor( progress.completedActivityCount === 3 ? '#a8d58e' : '#e1bc69' ); this.objectiveCard?.setStrokeStyle( 2, progress.completedActivityCount > 0 ? 0x628653 : 0xd8b15f, 0.86 ); this.thirdActivityTitleText?.setText( `자유 준비 ${progress.completedActivityCount} / 3` ); this.thirdPriorityLocationText?.setText( selected ? `${selected.label}${progress.ready ? ' · 확정됨' : ' · 확인 필요'}` : '아직 정하지 않음' ); const activityLines = progress.activities .map( (activity) => `${activity.completed ? '✓' : '!'} ${activity.shortLabel}${ activity.selected ? progress.ready ? ' · 출진 확정' : ' · 확인 필요' : '' }` ) .join('\n'); this.progressText?.setText( `준비 활동 ${progress.completedActivityCount} / 3 · 모두 선택\n` + `출진 보너스 · ${selected?.label ?? '미선택'}${selected && !progress.ready ? ' · 군영 확인 필요' : ''}\n\n` + `${activityLines}\n\n` + '재방문해도 선택은 바뀌지 않습니다. 군영 장부에서 적용할 하나를 확정하세요.' ); this.refreshThirdWorldFeedback(); return; } if (this.isSecondReliefVisit()) { const progress = secondBattleReliefProgress(campaign); const choiceId = campaign.campVisitChoiceIds[secondBattleReliefVisitId]; const choice = choiceId ? getSecondBattleReliefChoice(choiceId) : undefined; const completedFieldCount = Math.min( 3, progress.completedObjectiveIds.length ); const nextObjective = progress.nextObjectiveId ? getSecondBattleReliefObjective(progress.nextObjectiveId) : undefined; this.objectiveStatus ?.setText( completed ? '✓ 수습 기록 완료' : progress.choiceReady ? '◆ 현장 확인 완료 · 방침 결정 대기' : `◆ 현장 확인 ${completedFieldCount} / 3` ) .setColor(completed ? '#a8d58e' : '#e1bc69'); this.objectiveCard?.setStrokeStyle( 2, completed ? 0x628653 : 0xd8b15f, completed ? 0.9 : 0.76 ); this.progressText?.setText( completed ? `현지 수습 완료\n${choice?.label ?? secondBattleReliefExplorationDefinition.title}\n\n선택한 방침은 광종 구원로의 출진 준비와 전투 정보에 이어집니다.` : `${ progress.choiceReady ? '현장 확인 완료 · 방침 결정 대기' : `현장 확인 ${completedFieldCount} / 3` }\n\n${ progress.choiceReady ? '간옹에게 기록을 전하고 광종 진군 방침을 정하세요.' : nextObjective ? `다음 · ${nextObjective.label}\n${nextObjective.detail}` : '간옹에게 기록을 전하고 광종 진군 방침을 정하세요.' }` ); return; } const choiceId = campaign.campVisitChoiceIds[firstPursuitScoutVisitId]; const choice = choiceId ? getFirstPursuitScoutVisitChoice(choiceId) : undefined; this.objectiveStatus ?.setText(completed ? '✓ 정찰 기록 완료' : '◆ 진행 가능') .setColor(completed ? '#a8d58e' : '#e1bc69'); this.objectiveCard?.setStrokeStyle( 2, completed ? 0x628653 : 0xd8b15f, completed ? 0.9 : 0.76 ); this.progressText?.setText( completed ? `현지 준비 완료\n${choice?.label ?? firstPursuitScoutVisitDefinition.title}\n\n확보한 정보는 출진 준비와 두 번째 전투 배치 단계에서 확인할 수 있습니다.` : '현지 준비 0 / 1\n\n정찰 작전판 곁의 간옹에게 다가가 두 갈래 추격로 중 먼저 확인할 길을 정하세요.' ); } private refreshActorMarkers() { const completed = this.visitComplete(); const thirdProgress = this.isThirdCampVisit() ? thirdCampExplorationProgress() : undefined; const reliefProgress = this.isSecondReliefVisit() ? secondBattleReliefProgress() : undefined; this.actorViews.forEach(({ definition, marker }) => { if (this.isThirdCampVisit() && definition.activityId) { const activityComplete = thirdProgress?.completedActivityIds.includes( definition.activityId ) ?? false; const selected = definition.activityId === thirdProgress?.selectedPriorityId; marker .setText(activityComplete ? '✓' : '!') .setColor(activityComplete ? '#e5f2d5' : '#2a2013') .setBackgroundColor( selected ? '#8a6332' : activityComplete ? '#4e7549' : '#e5bd68' ); return; } if (definition.kind !== 'objective') { marker.setText('…'); return; } const objectiveComplete = completed || Boolean( definition.objectiveId && reliefProgress?.completedObjectiveIds.includes( definition.objectiveId ) ); const objectiveCurrent = !completed && Boolean( definition.objectiveId === reliefProgress?.nextObjectiveId ); marker.setText( objectiveComplete ? '✓' : objectiveCurrent || !this.isSecondReliefVisit() ? '!' : '·' ); marker.setColor( objectiveComplete ? '#e5f2d5' : objectiveCurrent || !this.isSecondReliefVisit() ? '#2a2013' : '#d7dde4' ); marker.setBackgroundColor( objectiveComplete ? '#4e7549' : objectiveCurrent || !this.isSecondReliefVisit() ? '#e5bd68' : '#56616e' ); }); } private refreshInteractionPrompt() { if ( !this.ready || this.dialogueState || this.choicePanel || 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 showPromptMessage(message: string) { this.lastNotice = message; this.promptBackground?.setVisible(true); this.promptText?.setText(message).setVisible(true); this.time.delayedCall(850, () => this.refreshInteractionPrompt()); } private showCompletionToast( label: string, kind: 'success' | 'error' | 'guide' = 'success', duration = 1500 ) { this.completionToast?.destroy(); this.lastNotice = label; const success = kind === 'success'; const guide = kind === 'guide'; const background = this.add .rectangle( 748, 132, 610, 72, success ? 0x17251c : guide ? 0x2c281b : 0x38201f, 0.98 ) .setStrokeStyle( 2, success ? 0x8fbd72 : guide ? 0xd8b15f : 0xb36f62, 0.92 ); const text = this.add .text(748, 132, `${success ? '✓' : guide ? '◆' : '!'} ${label}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: success ? '#dff0c9' : guide ? '#f3dda2' : '#f2cbc2', fontStyle: 'bold', wordWrap: { width: 560, useAdvancedWrap: true }, align: 'center' }) .setOrigin(0.5); const toast = this.add .container(0, 0, [background, text]) .setDepth(3300); this.completionToast = toast; const clearToast = () => { toast.destroy(); if (this.completionToast === toast) { this.completionToast = undefined; } }; if (this.visualMotionReduced) { this.time.delayedCall(duration + 380, clearToast); } else { this.tweens.add({ targets: toast, alpha: 0, y: -18, delay: duration, duration: 380, onComplete: clearToast }); } } private nearestInteractionTarget(radius: number) { if (!this.player) { return undefined; } return this.allInteractionTargets() .map((target) => ({ target, distance: this.distanceTo(target.x, target.y) })) .filter( ({ target, distance }) => distance <= (this.isSecondReliefVisit() && target.kind === 'exit' ? Math.min(radius, interactionRadius + 8) : radius) ) .sort((left, right) => left.distance - right.distance)[0]?.target; } private allInteractionTargets(): InteractionTarget[] { const campExit = this.currentCampExit(); const reliefProgress = this.isSecondReliefVisit() ? secondBattleReliefProgress() : undefined; const thirdProgress = this.isThirdCampVisit() ? thirdCampExplorationProgress() : undefined; return [ ...Array.from(this.actorViews.values()).map( ({ definition, sprite }) => ({ kind: 'actor' as const, id: definition.id, name: this.isThirdCampVisit() ? definition.activityId ? `${ thirdProgress?.completedActivityIds.includes( definition.activityId ) ? '다시 확인 · ' : '' }${definition.name} · ${definition.role}` : `${definition.name}와 대화` : this.isSecondReliefVisit() ? definition.objectiveId === reliefProgress?.nextObjectiveId ? `${definition.name} · 다음 현장 확인` : `${definition.name}와 대화` : definition.kind === 'objective' ? `${definition.name}과 정찰로 상의` : `${definition.name}와 대화`, x: sprite.x, y: sprite.y }) ), { kind: 'exit', ...campExit } ]; } private interactionTargetById(targetId: string) { return this.allInteractionTargets().find( (target) => target.id === targetId ); } private visitComplete(campaign = getCampaignState()) { if (this.isThirdCampVisit()) { return thirdCampExplorationProgress(campaign).ready; } return campaign.completedCampVisits.includes( this.visitId ); } 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 releasePresentationTextures() { this.ownedPresentationTextureKeys.forEach((textureKey) => { releaseTextureSource(this, textureKey); }); this.ownedPresentationTextureKeys.clear(); } private boundsSnapshot(bounds: Phaser.Geom.Rectangle) { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } }