4299 lines
133 KiB
TypeScript
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
|
|
};
|
|
}
|
|
}
|