feat: connect prologue choices to first battle

This commit is contained in:
2026-07-27 03:21:37 +09:00
parent 40dca5321e
commit a726ba129f
10 changed files with 1437 additions and 60 deletions

View File

@@ -1,4 +1,5 @@
import type { PrologueVillageDialogueLine } from './prologueVillage';
import type { SortieOrderId } from './sortieOrders';
export const prologueMilitiaCampId = 'zhuo-volunteer-camp';
@@ -11,6 +12,87 @@ export const prologueMilitiaCampRequiredObjectiveIds = [
export type PrologueMilitiaCampRequiredObjectiveId =
(typeof prologueMilitiaCampRequiredObjectiveIds)[number];
export type PrologueMilitiaCampCommandChoice = {
orderId: SortieOrderId;
label: string;
lead: string;
battlefieldPromise: string;
firstBattleCondition: string;
reaction: PrologueVillageDialogueLine[];
};
export const prologueMilitiaCampCommandChoices: readonly PrologueMilitiaCampCommandChoice[] = [
{
orderId: 'elite',
label: '정예',
lead: '사람을 잃지 않는 승리',
battlefieldPromise: '전열을 지키고 세 형제가 모두 귀환하는 완성도 높은 승리를 노린다.',
firstBattleCondition: '승리 · 세 형제 전원 생존 · 편성 평가 A 이상',
reaction: [
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '옳은 판단입니다. 제가 숲길 전열을 붙들어 의병들이 흩어지지 않게 하겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '좋소! 앞장서되 혼자 깊이 들지는 않겠소. 모두 함께 돌아오는 첫 승리를 만들겠소!'
},
{
speaker: '군령',
text: '정예 군령이 기록되었다. 첫 전투에서 전원 생존과 높은 편성 평가를 달성하면 추가 전공을 얻는다.'
}
]
},
{
orderId: 'mobile',
label: '기동',
lead: '속도를 살린 선봉 돌파',
battlefieldPromise: '장비의 남쪽 압박으로 적의 의도를 먼저 꺾고 제한 턴 안에 승리한다.',
firstBattleCondition: '승리 · 15턴 안에 끝내기 · 장비가 한 번 이상 공격에 기여하기',
reaction: [
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '기다리던 명령이오! 남쪽 길을 단숨에 흔들어 적이 진형을 갖추기 전에 숨통을 끊겠소!'
},
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '속도에 휩쓸려 대열이 갈라지지 않도록 중앙에서 장비의 돌파를 받치겠습니다.'
},
{
speaker: '군령',
text: '기동 군령이 기록되었다. 첫 전투에서 제한 턴과 돌파 기여 조건을 달성하면 추가 전공을 얻는다.'
}
]
},
{
orderId: 'siege',
label: '거점',
lead: '마을 어귀까지 확실히 구원',
battlefieldPromise: '두령만 쫓지 않고 마을 거점과 전열·후원의 역할까지 완수한다.',
firstBattleCondition: '승리 · 마을 어귀 확보 · 관우가 앞을 지키고 유비가 책략·회복으로 돕기',
reaction: [
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '백성을 두고 두령만 쫓을 수는 없습니다. 제가 마을 어귀의 길목부터 확보하겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '좋소! 관우가 전열을 열면 내가 측면을 걷어 내 형님의 후원이 닿게 하겠소!'
},
{
speaker: '군령',
text: '거점 군령이 기록되었다. 첫 전투에서 마을 목표와 전열·후원 기여를 달성하면 추가 전공을 얻는다.'
}
]
}
];
export const prologueMilitiaCampObjectiveDefinitions: ReadonlyArray<{
id: PrologueMilitiaCampRequiredObjectiveId | 'receive-command';
label: string;
@@ -179,26 +261,7 @@ export const prologueMilitiaCampNpcDefinitions: readonly PrologueMilitiaCampNpcD
{
speaker: '추정',
textureKey: 'unit-shu-officer',
text: '정찰과 대열, 보급까지 모두 갖추었군. 황건 두령 한석이 북동쪽 높은 길에서 마을을 노리고 있다. 그대들이 의병 선봉을 맡아 주게.'
},
{
speaker: '관우',
textureKey: 'unit-guan-yu',
text: '맡은 자리를 지키며 형님의 지휘에 따르겠습니다.'
},
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '의병들이 형님 깃발을 보고 한마음으로 움직이게 하겠소!'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '세 사람의 뜻을 한 깃발 아래 모아 첫 싸움을 책임지겠습니다. 의용군 전원에게 북문 집결을 알리시오.'
},
{
speaker: '전황',
text: '세 사람이 나란히 막사를 나섰다. 갓 세운 의용군의 첫 깃발이 북문을 향해 움직이기 시작했다.'
text: '정찰과 대열, 보급까지 갖추었군. 황건 두령 한석이 북동쪽에서 마을을 노다. 그대들이 선봉을 맡되, 첫 승리를 어떤 군령으로 이끌지 직접 정하게.'
}
],
lockedDialogue: [

View File

@@ -96,12 +96,7 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio
{
speaker: '장비',
textureKey: 'unit-zhang-fei',
text: '나는 장비라 하오. 이 고을에서 술과 고기를 팔며 장원을 돌보고 있소. 뜻있는 이를 돕고자 모은 재산이 있으니 함께 의병을 일으시다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '백성을 지키려는 마음이 같으니 더없이 든든하오. 주점에서 사람을 모을 방도를 자세히 의논합시다.'
text: '나는 장비라 하오. 이 고을에서 술과 고기를 팔며 장원을 돌보고 있소. 뜻있는 이를 돕고자 모은 재산이 있으니 함께 의병을 일으키고, 주점에서 사람을 모을 방도도 의논합시다.'
}
],
repeatDialogue: [
@@ -122,14 +117,10 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio
direction: 'west',
objectiveId: 'meet-guan-yu',
dialogue: [
{
speaker: '전황',
text: '손수레를 세운 붉은 얼굴의 나그네가 주점에 들어와, 군문에 자원하러 가는 길이라며 술을 청했다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '기상이 범상치 않으십니다. 존함과 이곳 군문을 찾은 까닭을 여쭈어도 되겠소?'
text: '손수레를 세우고 주점에 들어오시더니 군문에 자원하러 가는 길이라 하셨소. 기상이 범상치 않으 존함과 연유를 여쭈어도 되겠소?'
},
{
speaker: '관우',
@@ -174,17 +165,12 @@ export const prologueVillageNpcDefinitions: readonly PrologueVillageNpcDefinitio
{
speaker: '탁현 모병 관리',
textureKey: 'unit-shu-officer',
text: '성명과 출신, 의병을 일으키는 뜻을 명부에 남겨 주십시오. 인가가 나면 모인 군마와 병기를 막사에서 정식으로 나누겠습니다.'
text: '성명과 출신, 의병을 일으키는 뜻을 명부에 남겨 주십시오. 복숭아 동산에서 한뜻임을 맹세한 뒤 장비 공의 장원 밖 의용군 막사로 오시면 군마와 병기를 나누겠습니다.'
},
{
speaker: '유비',
textureKey: 'unit-liu-bei',
text: '탁현의 유비, 하동의 관우, 이 고을의 장비요. 위로는 나라의 어려움에 보답하고 아래로는 백성을 편안하게 하고자 모였소.'
},
{
speaker: '탁현 모병 관리',
textureKey: 'unit-shu-officer',
text: '세 분의 뜻을 군후 추정께 보고하겠습니다. 결의를 마친 뒤 장비 공의 장원 밖 의용군 막사로 와 주십시오.'
}
],
lockedDialogue: [

View File

@@ -143,6 +143,20 @@ export function sortieOrderDefinition(orderId: CampaignSortieOrderId) {
return sortieOrderDefinitions.find((definition) => definition.id === orderId) ?? sortieOrderDefinitions[0];
}
export function sortieOrderDisplayLabel(battleId: string, orderId: CampaignSortieOrderId) {
if (battleId === 'first-battle-zhuo-commandery' && orderId === 'siege') {
return '거점';
}
return sortieOrderDefinition(orderId).label;
}
export function sortieOrderDisplaySummary(battleId: string, orderId: CampaignSortieOrderId) {
if (battleId === 'first-battle-zhuo-commandery' && orderId === 'siege') {
return '마을 어귀와 전열·후원 역할을 함께 완수합니다.';
}
return sortieOrderDefinition(orderId).summary;
}
export function sortieOrderRewardClaimId(battleId: string, orderId: CampaignSortieOrderId) {
return `${battleId}:${orderId}`;
}

View File

@@ -119,6 +119,8 @@ import {
evaluateSortieOrder,
evaluateSortieOrderProgress,
sortieOrderDefinition,
sortieOrderDisplayLabel,
sortieOrderDisplaySummary,
sortieOrderRewardClaimId,
type CampaignSortieOrderProgressId,
type CampaignSortieOrderProgressSnapshot,
@@ -14207,11 +14209,11 @@ export class BattleScene extends Phaser.Scene {
}
private sortieOrderLabel(orderId: SortieOrderId) {
return sortieOrderDefinition(orderId).label;
return sortieOrderDisplayLabel(battleScenario.id, orderId);
}
private sortieOrderSummary(orderId: SortieOrderId) {
return sortieOrderDefinition(orderId).summary;
return sortieOrderDisplaySummary(battleScenario.id, orderId);
}
private sortieOrderProgressLabel(progressId: string) {
@@ -14919,7 +14921,9 @@ export class BattleScene extends Phaser.Scene {
{
label: '전투 수입',
value: `군자금 +${this.resultDisplayedGold(outcome)}`,
badge: sortieOrderReward ? `${sortieOrderReward.label} 공훈 포함` : `목표 ${achievedCount}/${objectives.length}`,
badge: sortieOrderReward
? `${this.sortieOrderLabel(sortieOrderReward.id)} 공훈 포함`
: `목표 ${achievedCount}/${objectives.length}`,
icon: 'leadership',
accent: palette.gold
},
@@ -17599,6 +17603,7 @@ export class BattleScene extends Phaser.Scene {
const roleLines = this.sortieOpeningLines();
const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세';
this.triggerBattleEvent('opening', title, [
`선택 군령 · ${this.sortieOrderLabel(this.launchSortieOrderId)} · ${this.sortieOrderSummary(this.launchSortieOrderId)}`,
...roleLines,
...(this.launchSortieRecommendation
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]

View File

@@ -7,13 +7,19 @@ import {
type PortraitAssetEntry
} from '../data/portraitAssets';
import {
prologueMilitiaCampCommandChoices,
prologueMilitiaCampNpcDefinitions,
prologueMilitiaCampObjectiveDefinitions,
prologueMilitiaCampRequiredObjectiveIds,
type PrologueMilitiaCampCommandChoice,
type PrologueMilitiaCampNpcDefinition,
type PrologueMilitiaCampRequiredObjectiveId
} from '../data/prologueMilitiaCamp';
import { prologueDeparturePages, type PrologueVillageDialogueLine } from '../data/prologueVillage';
import {
sortieOrderDefinition,
type SortieOrderId
} from '../data/sortieOrders';
import {
ensureExplorationCharacterAnimations,
explorationCharacterAssetInfos,
@@ -30,6 +36,7 @@ import {
hasCompletedCampaignTutorial,
markCampaignStep,
prologueMilitiaCampCampaignTutorialIds,
setCampaignSortieOrderSelection,
type CampaignTutorialId
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
@@ -121,6 +128,13 @@ type ObjectiveRowView = {
location: Phaser.GameObjects.Text;
};
type CommandChoiceView = {
choice: PrologueMilitiaCampCommandChoice;
button: Phaser.GameObjects.Rectangle;
accent: number;
selectLabel: Phaser.GameObjects.Text;
};
const objectiveTutorialIds: Record<PrologueMilitiaCampRequiredObjectiveId, CampaignTutorialId> = {
'review-scout-report': prologueMilitiaCampCampaignTutorialIds.reviewScoutReport,
'ready-vanguard': prologueMilitiaCampCampaignTutorialIds.readyVanguard,
@@ -153,11 +167,19 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private promptBackground?: Phaser.GameObjects.Rectangle;
private promptText?: 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 commandChoicePanel?: Phaser.GameObjects.Container;
private commandChoicePanelBounds?: Phaser.Geom.Rectangle;
private commandChoiceViews: CommandChoiceView[] = [];
private commandChoiceConfirmButton?: Phaser.GameObjects.Rectangle;
private commandChoiceConfirmText?: Phaser.GameObjects.Text;
private pendingCommandOrderId?: SortieOrderId;
private commandChoiceReadyAt = Number.POSITIVE_INFINITY;
private loadingOverlay?: Phaser.GameObjects.Container;
private transitionOverlay?: Phaser.GameObjects.Container;
private completionToast?: Phaser.GameObjects.Container;
@@ -225,6 +247,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.navigationPending = false;
this.moveTarget = undefined;
this.dialogueState = undefined;
this.closeCommandChoice();
this.stopNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
this.releasePresentationTextures();
@@ -260,6 +283,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.interactionQueued = false;
return;
}
if (this.commandChoicePanel) {
this.stopPlayerMovement();
this.interactionQueued = false;
return;
}
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
@@ -325,6 +353,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
controls: {
movement: ['WASD', '방향키'],
interact: ['E', 'Space', 'Enter'],
commandChoice: ['1', '2', '3', 'pointer'],
pointerMove: true,
carryoverGuardMs: inputCarryoverGuardMs
},
@@ -381,6 +410,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
? this.dialoguePortrait.texture.key
: null,
portraitVisible: this.dialoguePortrait?.visible ?? false,
portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false,
bounds: dialogueBounds
}
: {
@@ -391,15 +421,50 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
sourceNpcId: null,
portraitTextureKey: null,
portraitVisible: false,
portraitFrameVisible: false,
bounds: null
},
commandChoice: {
open: Boolean(this.commandChoicePanel),
ready: Boolean(this.commandChoicePanel && this.time.now >= this.commandChoiceReadyAt),
selectedId: campaign.sortieOrderSelection?.battleId === 'first-battle-zhuo-commandery'
? campaign.sortieOrderSelection.orderId
: null,
pendingId: this.pendingCommandOrderId ?? null,
confirmEnabled: Boolean(this.pendingCommandOrderId),
confirmBounds: this.commandChoiceConfirmButton
? this.boundsSnapshot(this.commandChoiceConfirmButton.getBounds())
: null,
panelBounds: this.commandChoicePanelBounds
? this.boundsSnapshot(this.commandChoicePanelBounds)
: null,
cards: this.commandChoiceViews.map(({ choice, button }) => {
const definition = sortieOrderDefinition(choice.orderId);
return {
orderId: choice.orderId,
label: choice.label,
title: `${choice.label} 군령`,
condition: choice.firstBattleCondition,
rewardGold: definition.rewardGold,
rewardItems: [...definition.rewardItems],
pending: this.pendingCommandOrderId === choice.orderId,
bounds: this.boundsSnapshot(button.getBounds())
};
})
},
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
navigationPending: this.navigationPending
};
}
debugTeleportTo(targetId: string) {
if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) {
if (
!this.ready ||
this.navigationPending ||
this.npcMovementPending ||
this.dialogueState ||
this.commandChoicePanel
) {
return false;
}
const view = this.npcViews.get(targetId);
@@ -413,7 +478,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
debugInteractWith(targetId: string) {
if (!this.ready || this.navigationPending || this.npcMovementPending || this.dialogueState) {
if (
!this.ready ||
this.navigationPending ||
this.npcMovementPending ||
this.dialogueState ||
this.commandChoicePanel
) {
return false;
}
const view = this.npcViews.get(targetId);
@@ -438,10 +509,21 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return false;
}
this.debugCompleteRequiredObjectives();
const selection = getCampaignState().sortieOrderSelection;
if (selection?.battleId !== 'first-battle-zhuo-commandery') {
setCampaignSortieOrderSelection('first-battle-zhuo-commandery', 'elite');
}
this.finishCamp();
return true;
}
debugChooseFirstCommand(orderId: SortieOrderId) {
if (!this.previewFirstCommand(orderId, true)) {
return false;
}
return this.confirmFirstCommand(true);
}
private resetRuntimeState() {
this.backgroundImage = undefined;
this.backgroundFallback = false;
@@ -454,8 +536,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.npcMovementPending = false;
this.blockers = [];
this.objectiveRows.clear();
this.dialoguePortraitFrame = undefined;
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.commandChoicePanel = undefined;
this.commandChoicePanelBounds = undefined;
this.commandChoiceViews = [];
this.commandChoiceConfirmButton = undefined;
this.commandChoiceConfirmText = undefined;
this.pendingCommandOrderId = undefined;
this.commandChoiceReadyAt = Number.POSITIVE_INFINITY;
this.moveTarget = undefined;
this.ready = false;
this.navigationPending = false;
@@ -952,7 +1042,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.7);
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
.setStrokeStyle(2, 0x9e8350, 0.9)
.setVisible(false);
this.dialoguePortraitFrame = portraitFrame;
const firstPortraitKey = campDialoguePortraitEntries()
.find(({ textureKey }) => this.textures.exists(textureKey))
?.textureKey ?? '__DEFAULT';
@@ -1010,12 +1102,25 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.interactionQueued = true;
});
});
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE)
.on('down', () => this.chooseFirstCommandByIndex(0));
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO)
.on('down', () => this.chooseFirstCommandByIndex(1));
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE)
.on('down', () => this.chooseFirstCommandByIndex(2));
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
.on('down', () => this.confirmFirstCommand());
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => this.cancelFirstCommandChoice());
}
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
return;
}
if (this.commandChoicePanel) {
return;
}
if (this.dialogueState) {
this.advanceDialogue();
return;
@@ -1210,7 +1315,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return;
}
this.gatherCommandParty(() => {
this.startDialogue(view.definition.dialogue, () => this.finishCamp(), view.definition.id);
this.startDialogue(
view.definition.dialogue,
() => this.openFirstCommandChoice(),
view.definition.id
);
});
return;
}
@@ -1259,12 +1368,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (this.dialoguePortrait) {
const entry = this.dialoguePortraitEntry(line);
if (entry && this.textures.exists(entry.textureKey)) {
this.dialoguePortraitFrame?.setVisible(true);
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
this.dialogueNameText?.setX(350);
this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true);
} else {
this.dialoguePortraitFrame?.setVisible(false);
this.dialoguePortrait.setVisible(false);
this.dialogueNameText?.setX(128);
this.dialogueBodyText?.setX(128).setWordWrapWidth(1604, true);
}
}
}
@@ -1318,6 +1433,350 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.showCompletionToast(`${objective?.shortLabel ?? '점검'} 완료`);
}
private openFirstCommandChoice() {
if (this.commandChoicePanel || this.navigationPending || this.dialogueState) {
return;
}
this.stopPlayerMovement();
this.promptBackground?.setVisible(false);
this.promptText?.setVisible(false);
const panelX = 100;
const panelY = 90;
const panelWidth = 1720;
const panelHeight = 900;
const cardX = 145;
const cardY = 265;
const cardWidth = 510;
const cardHeight = 530;
const cardGap = 35;
const cardAccents = [0xd8b96f, 0x78b8cf, 0xc98163];
const selectedOrderId = getCampaignState().sortieOrderSelection?.battleId ===
'first-battle-zhuo-commandery'
? getCampaignState().sortieOrderSelection?.orderId
: undefined;
this.pendingCommandOrderId = selectedOrderId;
const objects: Phaser.GameObjects.GameObject[] = [];
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.8)
.setOrigin(0)
.setInteractive();
const panel = this.add.rectangle(panelX, panelY, panelWidth, panelHeight, 0x111821, 0.995)
.setOrigin(0)
.setStrokeStyle(3, 0xb89454, 0.94);
const title = this.add.text(sceneWidth / 2, panelY + 54, '첫 출전 군령', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '42px',
color: '#f3dda2',
fontStyle: 'bold'
}).setOrigin(0.5, 0);
const subtitle = this.add.text(
sceneWidth / 2,
panelY + 114,
'같은 전투도 무엇을 지키고 증명할지에 따라 목표와 추가 전공이 달라집니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#c5ccd3'
}
).setOrigin(0.5, 0);
const saveHint = this.add.text(
sceneWidth / 2,
panelY + 154,
'역사의 결과를 바꾸는 선택이 아니라, 유비가 첫 승리를 이끄는 방식입니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#8fa3b8'
}
).setOrigin(0.5, 0);
objects.push(shade, panel, title, subtitle, saveHint);
this.commandChoiceViews = prologueMilitiaCampCommandChoices.map((choice, index) => {
const definition = sortieOrderDefinition(choice.orderId);
const x = cardX + index * (cardWidth + cardGap);
const accent = cardAccents[index] ?? cardAccents[0];
const selected = selectedOrderId === choice.orderId;
const button = this.add.rectangle(
x,
cardY,
cardWidth,
cardHeight,
selected ? 0x263746 : 0x1a2531,
0.995
)
.setOrigin(0)
.setStrokeStyle(selected ? 4 : 2, accent, selected ? 1 : 0.78)
.setInteractive({ useHandCursor: true });
const number = this.add.text(x + 28, cardY + 24, `${index + 1}`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '29px',
color: '#f3dda2',
fontStyle: 'bold'
});
const label = this.add.text(x + 78, cardY + 25, `${choice.label} 군령`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '27px',
color: '#f4f0e6',
fontStyle: 'bold'
});
const lead = this.add.text(x + 28, cardY + 82, choice.lead, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '22px',
color: '#f0cf82',
fontStyle: 'bold',
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
});
const promise = this.add.text(x + 28, cardY + 126, choice.battlefieldPromise, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#d4dce5',
lineSpacing: 7,
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
});
const divider = this.add.rectangle(
x + 28,
cardY + 224,
cardWidth - 56,
1,
accent,
0.48
).setOrigin(0);
const conditionTitle = this.add.text(x + 28, cardY + 252, '달성 조건', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#93a7b9',
fontStyle: 'bold'
});
const condition = this.add.text(x + 28, cardY + 284, choice.firstBattleCondition, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#f0ede5',
lineSpacing: 7,
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
});
const rewardTitle = this.add.text(x + 28, cardY + 382, '첫 달성 전공', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#93a7b9',
fontStyle: 'bold'
});
const reward = this.add.text(
x + 28,
cardY + 416,
`${definition.rewardGold} · ${definition.rewardItems.join(' · ')}`,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: '#a8d58e',
fontStyle: 'bold',
wordWrap: { width: cardWidth - 56, useAdvancedWrap: true }
}
);
const selectLabel = this.add.text(
x + cardWidth / 2,
cardY + cardHeight - 38,
selected ? '현재 선택 · 다시 선택 가능' : '이 군령으로 출전',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: selected ? '#f3dda2' : '#c3ccd5',
fontStyle: 'bold'
}
).setOrigin(0.5);
button.on('pointerover', () => {
button.setFillStyle(0x2a3c4e, 0.995).setStrokeStyle(4, accent, 1);
});
button.on('pointerout', () => {
this.refreshFirstCommandChoice();
});
button.on('pointerdown', () => this.previewFirstCommand(choice.orderId));
objects.push(
button,
number,
label,
lead,
promise,
divider,
conditionTitle,
condition,
rewardTitle,
reward,
selectLabel
);
return { choice, button, accent, selectLabel };
});
const callback = this.add.text(
sceneWidth / 2,
panelY + panelHeight - 142,
'선택한 군령은 첫 전투 목표판에 이어집니다. 실패해도 전투와 기본 보상에는 불이익이 없습니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px',
color: '#d5c59e'
}
).setOrigin(0.5);
const confirmButton = this.add.rectangle(
sceneWidth / 2 - 250,
panelY + panelHeight - 108,
500,
58,
this.pendingCommandOrderId ? 0x806536 : 0x2a3037,
0.99
)
.setOrigin(0)
.setStrokeStyle(2, this.pendingCommandOrderId ? 0xe0bf70 : 0x606b76, 0.9)
.setInteractive({ useHandCursor: true });
const confirmText = this.add.text(
sceneWidth / 2,
panelY + panelHeight - 79,
this.pendingCommandOrderId ? '이 군령으로 출전 확정' : '먼저 군령을 선택하세요',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '21px',
color: this.pendingCommandOrderId ? '#fff0bd' : '#89939e',
fontStyle: 'bold'
}
).setOrigin(0.5);
this.commandChoiceConfirmButton = confirmButton;
this.commandChoiceConfirmText = confirmText;
confirmButton.on('pointerover', () => {
if (this.pendingCommandOrderId) {
confirmButton.setFillStyle(0x9a7940, 1);
}
});
confirmButton.on('pointerout', () => this.refreshFirstCommandChoice());
confirmButton.on('pointerdown', () => this.confirmFirstCommand());
const controls = this.add.text(
sceneWidth / 2,
panelY + panelHeight - 25,
'숫자키 1 · 2 · 3 또는 카드 클릭으로 비교 | Enter 확정 | ESC 막사로 돌아가기',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#98a5b2'
}
).setOrigin(0.5);
objects.push(
callback,
confirmButton,
confirmText,
controls
);
this.commandChoicePanel = this.add.container(0, 0, objects).setDepth(3200);
this.commandChoicePanelBounds = new Phaser.Geom.Rectangle(
panelX,
panelY,
panelWidth,
panelHeight
);
this.commandChoiceReadyAt = this.time.now + 260;
this.refreshFirstCommandChoice();
soundDirector.playSelect();
}
private chooseFirstCommandByIndex(index: number) {
const choice = prologueMilitiaCampCommandChoices[index];
if (choice) {
this.previewFirstCommand(choice.orderId);
}
}
private previewFirstCommand(orderId: SortieOrderId, bypassInputGuard = false) {
if (
!this.commandChoicePanel ||
this.navigationPending ||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
) {
return false;
}
const choice = prologueMilitiaCampCommandChoices.find((entry) => entry.orderId === orderId);
if (!choice) {
return false;
}
this.pendingCommandOrderId = choice.orderId;
this.refreshFirstCommandChoice();
soundDirector.playSelect();
return true;
}
private refreshFirstCommandChoice() {
this.commandChoiceViews.forEach(({ choice, button, accent, selectLabel }) => {
const selected = this.pendingCommandOrderId === choice.orderId;
button
.setFillStyle(selected ? 0x263746 : 0x1a2531, 0.995)
.setStrokeStyle(selected ? 4 : 2, accent, selected ? 1 : 0.78);
selectLabel
.setText(selected ? '선택됨 · 확정 전 변경 가능' : '이 군령을 살펴보기')
.setColor(selected ? '#f3dda2' : '#c3ccd5');
});
const enabled = Boolean(this.pendingCommandOrderId);
this.commandChoiceConfirmButton
?.setFillStyle(enabled ? 0x806536 : 0x2a3037, 0.99)
.setStrokeStyle(2, enabled ? 0xe0bf70 : 0x606b76, 0.9);
this.commandChoiceConfirmText
?.setText(enabled ? '이 군령으로 출전 확정' : '먼저 군령을 선택하세요')
.setColor(enabled ? '#fff0bd' : '#89939e');
}
private confirmFirstCommand(bypassInputGuard = false) {
if (
!this.commandChoicePanel ||
!this.pendingCommandOrderId ||
this.navigationPending ||
(!bypassInputGuard && this.time.now < this.commandChoiceReadyAt)
) {
return false;
}
const choice = prologueMilitiaCampCommandChoices.find(
(entry) => entry.orderId === this.pendingCommandOrderId
);
if (!choice) {
return false;
}
setCampaignSortieOrderSelection('first-battle-zhuo-commandery', choice.orderId);
this.inputReadyAt = this.time.now + 260;
this.closeCommandChoice();
soundDirector.playObjectiveAchieved();
this.startDialogue(
choice.reaction,
() => this.finishCamp(),
`first-command-${choice.orderId}`
);
return true;
}
private cancelFirstCommandChoice() {
if (
!this.commandChoicePanel ||
this.navigationPending ||
this.time.now < this.commandChoiceReadyAt
) {
return false;
}
this.closeCommandChoice();
this.showPromptMessage('추정에게 다시 말을 걸면 첫 출전 군령을 정할 수 있습니다.');
this.refreshInteractionPrompt();
return true;
}
private closeCommandChoice() {
this.commandChoicePanel?.destroy();
this.commandChoicePanel = undefined;
this.commandChoicePanelBounds = undefined;
this.commandChoiceViews = [];
this.commandChoiceConfirmButton = undefined;
this.commandChoiceConfirmText = undefined;
this.pendingCommandOrderId = undefined;
this.commandChoiceReadyAt = Number.POSITIVE_INFINITY;
}
private finishCamp() {
if (this.navigationPending) {
return;
@@ -1329,6 +1788,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
return;
}
if (getCampaignState().sortieOrderSelection?.battleId !== 'first-battle-zhuo-commandery') {
this.openFirstCommandChoice();
return;
}
completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete);
this.refreshObjectiveHud();

View File

@@ -48,6 +48,12 @@ const promptRadius = 160;
const characterDisplaySize = 104;
const inputCarryoverGuardMs = 320;
const narrativeNpcSpeed = 300;
const queuedKeyboardMoveDistance = 150;
const navigationArrivalRadius = 8;
const navigationProbeStep = 24;
const navigationDetourClearance = 64;
const objectivePointerSnapRadius = 172;
const directPointerSnapRadius = 92;
const villageBackgroundKey = 'prologue-village-background';
const villageDialoguePortraitContext = {
id: 'prologue-village-dialogue',
@@ -146,6 +152,20 @@ type NarrativeNpcMove = {
};
};
type QueuedMovementIntent =
| {
kind: 'pointer';
x: number;
y: number;
queuedAt: number;
}
| {
kind: 'keyboard';
x: number;
y: number;
queuedAt: number;
};
const objectiveTutorialIds: Record<PrologueVillageRequiredObjectiveId, CampaignTutorialId> = {
'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei,
'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu,
@@ -178,6 +198,7 @@ export class PrologueVillageScene extends Phaser.Scene {
private oathMarker?: Phaser.GameObjects.Text;
private oathLabel?: 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;
@@ -195,6 +216,17 @@ export class PrologueVillageScene extends Phaser.Scene {
};
private interactKeys: Phaser.Input.Keyboard.Key[] = [];
private moveTarget?: Phaser.Math.Vector2;
private moveWaypoints: Phaser.Math.Vector2[] = [];
private moveTargetNpcId?: string;
private queuedMovementIntent?: QueuedMovementIntent;
private queuedMovementIntentCount = 0;
private appliedQueuedMovementIntentCount = 0;
private lastAppliedQueuedMovementIntent?: {
kind: QueuedMovementIntent['kind'];
appliedAt: number;
};
private navigationDetourCount = 0;
private navigationReplanAttempts = 0;
private npcMoveTweens = new Map<string, Phaser.Tweens.Tween>();
private oathGatherPending = false;
private ready = false;
@@ -252,6 +284,9 @@ export class PrologueVillageScene extends Phaser.Scene {
this.navigationPending = false;
this.oathGatherPending = false;
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.queuedMovementIntent = undefined;
this.dialogueState = undefined;
this.stopAllNpcMovement();
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
@@ -301,6 +336,8 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
this.applyQueuedMovementIntentIfReady();
if (this.consumeInteractionRequest()) {
this.tryInteract();
return;
@@ -349,6 +386,22 @@ export class PrologueVillageScene extends Phaser.Scene {
movement: {
speed: playerSpeed,
target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null,
targetNpcId: this.moveTargetNpcId ?? null,
activeWaypoint: this.moveWaypoints[0]
? { x: this.moveWaypoints[0].x, y: this.moveWaypoints[0].y }
: null,
waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })),
queuedIntent: this.queuedMovementIntent
? { ...this.queuedMovementIntent }
: null,
queuedIntentCount: this.queuedMovementIntentCount,
appliedQueuedIntentCount: this.appliedQueuedMovementIntentCount,
lastAppliedQueuedIntent: this.lastAppliedQueuedMovementIntent
? { ...this.lastAppliedQueuedMovementIntent }
: null,
deferredForNarrativeMovement: this.hasNarrativeNpcMovement(),
detourCount: this.navigationDetourCount,
replanAttempts: this.navigationReplanAttempts,
walkBounds: this.boundsSnapshot(movementBounds),
collisionRadius: playerCollisionRadius
},
@@ -411,6 +464,7 @@ export class PrologueVillageScene extends Phaser.Scene {
? this.dialoguePortrait.texture.key
: null,
portraitVisible: this.dialoguePortrait?.visible ?? false,
portraitFrameVisible: this.dialoguePortraitFrame?.visible ?? false,
bounds: dialogueBounds
}
: {
@@ -421,12 +475,15 @@ export class PrologueVillageScene extends Phaser.Scene {
sourceNpcId: null,
portraitTextureKey: null,
portraitVisible: false,
portraitFrameVisible: false,
bounds: null
},
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
narrativeMovement: {
movingNpcIds: Array.from(this.npcMoveTweens.keys()),
oathGatherPending: this.oathGatherPending
oathGatherPending: this.oathGatherPending,
playerInputDeferred: this.hasNarrativeNpcMovement() &&
this.queuedMovementIntent !== undefined
},
navigationPending: this.navigationPending,
requiredTextures: characterTextureKeys.map((key) => ({
@@ -489,9 +546,18 @@ export class PrologueVillageScene extends Phaser.Scene {
this.npcViews.clear();
this.blockers = [];
this.objectiveRows.clear();
this.dialoguePortraitFrame = undefined;
this.dialoguePortrait = undefined;
this.dialogueState = undefined;
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.queuedMovementIntent = undefined;
this.queuedMovementIntentCount = 0;
this.appliedQueuedMovementIntentCount = 0;
this.lastAppliedQueuedMovementIntent = undefined;
this.navigationDetourCount = 0;
this.navigationReplanAttempts = 0;
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
@@ -1010,7 +1076,9 @@ export class PrologueVillageScene extends Phaser.Scene {
.setOrigin(0)
.setStrokeStyle(1, 0x546174, 0.7);
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
.setStrokeStyle(2, 0x9e8350, 0.9);
.setStrokeStyle(2, 0x9e8350, 0.9)
.setVisible(false);
this.dialoguePortraitFrame = portraitFrame;
const firstPortraitKey = villageDialoguePortraitEntries()
.find(({ textureKey }) => this.textures.exists(textureKey))
?.textureKey ?? '__DEFAULT';
@@ -1058,6 +1126,18 @@ export class PrologueVillageScene extends Phaser.Scene {
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
};
[
{ key: this.cursorKeys.up, x: 0, y: -1 },
{ key: this.cursorKeys.down, x: 0, y: 1 },
{ key: this.cursorKeys.left, x: -1, y: 0 },
{ key: this.cursorKeys.right, x: 1, y: 0 },
{ key: this.moveKeys.up, x: 0, y: -1 },
{ key: this.moveKeys.down, x: 0, y: 1 },
{ key: this.moveKeys.left, x: -1, y: 0 },
{ key: this.moveKeys.right, x: 1, y: 0 }
].forEach(({ key, x, y }) => {
key.on('down', () => this.queueNarrativeKeyboardMovement(x, y));
});
this.interactKeys = [
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE),
@@ -1092,7 +1172,16 @@ export class PrologueVillageScene extends Phaser.Scene {
}
if (pointer.x >= movementBounds.left && pointer.x <= movementBounds.right &&
pointer.y >= movementBounds.top && pointer.y <= movementBounds.bottom) {
this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y);
if (this.hasNarrativeNpcMovement()) {
this.queueMovementIntent({
kind: 'pointer',
x: pointer.x,
y: pointer.y,
queuedAt: this.time.now
});
} else {
this.beginPointerMovement(pointer.x, pointer.y);
}
}
});
}
@@ -1102,13 +1191,34 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
const keyboardDirection = this.keyboardMovementDirection();
if (this.hasNarrativeNpcMovement()) {
if (keyboardDirection.lengthSq() > 0) {
const normalized = keyboardDirection.normalize();
this.queueMovementIntent({
kind: 'keyboard',
x: normalized.x,
y: normalized.y,
queuedAt: time
});
}
this.setPlayerMoving(false);
return;
}
let movement = keyboardDirection;
if (movement.lengthSq() > 0) {
this.moveTarget = undefined;
this.clearNavigationTarget();
} else if (this.moveTarget) {
const targetDelta = this.moveTarget.clone().subtract(new Phaser.Math.Vector2(this.player.x, this.player.y));
if (targetDelta.length() <= 7) {
this.moveTarget = undefined;
const activeTarget = this.moveWaypoints[0] ?? this.moveTarget;
const targetDelta = activeTarget.clone().subtract(
new Phaser.Math.Vector2(this.player.x, this.player.y)
);
if (targetDelta.length() <= navigationArrivalRadius) {
if (this.moveWaypoints.length > 0) {
this.moveWaypoints.shift();
} else {
this.clearNavigationTarget();
}
movement = new Phaser.Math.Vector2();
} else {
movement = targetDelta.normalize();
@@ -1138,7 +1248,9 @@ export class PrologueVillageScene extends Phaser.Scene {
moved = true;
}
if (!moved && this.moveTarget) {
this.moveTarget = undefined;
if (!this.replanBlockedPointerMovement()) {
this.clearNavigationTarget();
}
}
this.syncPlayerView();
@@ -1155,6 +1267,361 @@ export class PrologueVillageScene extends Phaser.Scene {
}
}
private hasNarrativeNpcMovement() {
return this.npcMoveTweens.size > 0;
}
private queueNarrativeKeyboardMovement(x: number, y: number) {
if (
!this.ready ||
this.navigationPending ||
this.oathGatherPending ||
this.dialogueState ||
this.time.now < this.inputReadyAt ||
!this.hasNarrativeNpcMovement()
) {
return;
}
this.queueMovementIntent({
kind: 'keyboard',
x,
y,
queuedAt: this.time.now
});
}
private queueMovementIntent(intent: QueuedMovementIntent) {
const current = this.queuedMovementIntent;
if (
current?.kind === intent.kind &&
Math.abs(current.x - intent.x) < 0.01 &&
Math.abs(current.y - intent.y) < 0.01
) {
return;
}
this.queuedMovementIntent = intent;
this.queuedMovementIntentCount += 1;
}
private applyQueuedMovementIntentIfReady() {
if (
!this.queuedMovementIntent ||
this.hasNarrativeNpcMovement() ||
this.dialogueState ||
this.oathGatherPending ||
this.navigationPending
) {
return;
}
const intent = this.queuedMovementIntent;
this.queuedMovementIntent = undefined;
this.appliedQueuedMovementIntentCount += 1;
this.lastAppliedQueuedMovementIntent = {
kind: intent.kind,
appliedAt: this.time.now
};
if (intent.kind === 'pointer') {
this.beginPointerMovement(intent.x, intent.y);
return;
}
if (!this.player) {
return;
}
const direction = new Phaser.Math.Vector2(intent.x, intent.y);
if (direction.lengthSq() <= 0) {
return;
}
direction.normalize().scale(queuedKeyboardMoveDistance);
this.beginNavigationTo(
this.player.x + direction.x,
this.player.y + direction.y
);
}
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, targetNpcId?: string) {
const destination = this.nearestWalkablePoint(x, y);
if (!destination) {
this.clearNavigationTarget();
return;
}
const route = this.planNavigationRoute(destination);
if (!route) {
this.clearNavigationTarget();
return;
}
this.applyNavigationRoute(route, targetNpcId);
}
private applyNavigationRoute(route: Phaser.Math.Vector2[], targetNpcId?: string) {
const destination = route[route.length - 1];
if (!destination) {
this.clearNavigationTarget();
return;
}
this.moveTarget = destination.clone();
this.moveWaypoints = route.slice(0, -1).map((waypoint) => waypoint.clone());
this.moveTargetNpcId = targetNpcId;
this.navigationReplanAttempts = 0;
if (route.length > 1) {
this.navigationDetourCount += 1;
}
}
private pointerMovementTargetAt(x: number, y: number) {
const targets: InteractionTarget[] = [
...Array.from(this.npcViews.values())
.filter(({ definition }) => !this.npcMoveTweens.has(definition.id))
.map(({ definition, sprite }) => ({
kind: 'npc' as const,
id: definition.id,
name: definition.name,
x: sprite.x,
y: sprite.y
})),
{
kind: 'oath' as const,
id: 'make-oath' as const,
name: '도원결의',
x: oathPosition.x,
y: oathPosition.y
}
];
return targets
.map((target) => ({
target,
distance: Phaser.Math.Distance.Between(x, y, target.x, target.y),
priority: this.interactionTargetPriority(target)
}))
.filter(({ distance, priority }) => (
distance <= (priority === 0 ? objectivePointerSnapRadius : directPointerSnapRadius)
))
.sort((left, right) => (
left.priority - right.priority ||
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 clamped = new Phaser.Math.Vector2(
Phaser.Math.Clamp(x, movementBounds.left + margin, movementBounds.right - margin),
Phaser.Math.Clamp(y, movementBounds.top + margin, movementBounds.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,
movementBounds.left + margin,
movementBounds.right - margin
),
Phaser.Math.Clamp(
clamped.y + Math.sin(angle) * radius,
movementBounds.top + margin,
movementBounds.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 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.npcViews.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,
movementBounds.left + margin,
movementBounds.right - margin
),
Phaser.Math.Clamp(
candidate.y,
movementBounds.top + margin,
movementBounds.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() {
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.navigationReplanAttempts = 0;
}
private consumeInteractionRequest() {
const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key));
const requested = this.interactionQueued || keyPressedThisFrame;
@@ -1221,7 +1688,7 @@ export class PrologueVillageScene extends Phaser.Scene {
}
private stopPlayerMovement() {
this.moveTarget = undefined;
this.clearNavigationTarget();
this.setPlayerMoving(false);
}
@@ -1540,12 +2007,18 @@ export class PrologueVillageScene extends Phaser.Scene {
if (this.dialoguePortrait) {
const entry = this.dialoguePortraitEntry(line);
if (entry && this.textures.exists(entry.textureKey)) {
this.dialoguePortraitFrame?.setVisible(true);
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
this.dialogueNameText?.setX(350);
this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true);
} else {
this.dialoguePortraitFrame?.setVisible(false);
this.dialoguePortrait.setVisible(false);
this.dialogueNameText?.setX(128);
this.dialogueBodyText?.setX(128).setWordWrapWidth(1604, true);
}
}
}
@@ -1800,9 +2273,33 @@ export class PrologueVillageScene extends Phaser.Scene {
}
];
return targets
.map((target) => ({ target, distance: this.distanceTo(target.x, target.y) }))
.map((target) => ({
target,
distance: this.distanceTo(target.x, target.y),
priority: this.interactionTargetPriority(target)
}))
.filter(({ distance }) => distance <= radius)
.sort((left, right) => left.distance - right.distance)[0]?.target;
.sort((left, right) => (
left.priority - right.priority ||
left.distance - right.distance
))[0]?.target;
}
private interactionTargetPriority(target: InteractionTarget) {
if (target.kind === 'oath') {
return this.allRequiredObjectivesComplete() ? 0 : 4;
}
const definition = this.npcViews.get(target.id)?.definition;
if (!definition?.objectiveId) {
return 2;
}
if (!this.isObjectiveComplete(definition.objectiveId)) {
return this.objectiveUnlocked(definition.objectiveId) ? 0 : 3;
}
if (definition.id === 'guan-yu' || definition.id === 'zhang-fei') {
return 4;
}
return 2;
}
private interactionTargetById(targetId: string): InteractionTarget | undefined {

View File

@@ -19,6 +19,7 @@ import {
isSortieOrderId,
sortieOrderCheckIdsByOrder,
sortieOrderDefinition,
sortieOrderDisplayLabel,
sortieOrderIds,
sortieOrderRewardClaimId,
type CampaignSortieOrderCommandSnapshot,
@@ -1142,9 +1143,9 @@ export function campaignVictoryRewardPresentation(report: CampaignVictoryRewardR
? sortieOrderDefinition(report.sortieOrder.orderId)
: undefined;
const sortieOrderBonus = definition
? {
? {
orderId: definition.id,
label: definition.label,
label: sortieOrderDisplayLabel(report.battleId, definition.id),
rewardGold: definition.rewardGold,
rewardItems: [...definition.rewardItems]
}