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

4299 lines
133 KiB
TypeScript

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<ExplorationActorId, ExplorationActorView>();
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<string>();
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<string, Phaser.Math.Vector2>();
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
};
}
}