feat: connect prologue choices to first battle
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user