feat: make third-camp preparation choices explicit

This commit is contained in:
2026-07-28 01:29:20 +09:00
parent 1a81ce5689
commit a41672bd0e
11 changed files with 1608 additions and 143 deletions

View File

@@ -333,7 +333,7 @@ export function resolveThirdCampExplorationDefinition(
subtitle:
'노식군의 큰 포위망 안에서 의용군은 동쪽 삼림 선봉을 맡습니다. 필요한 준비만 확인하고 언제든 출진할 수 있습니다.',
description:
'작전판에서 적 선봉의 움직임을 읽고, 보급 수레에서 방호구를 손보거나, 먼저 돌아온 동료와 지난 전투를 되짚을 수 있습니다. 세 활동은 모두 선택 사항이며 어느 순서로 해도 됩니다. 활동을 마칠 때마다 그 준비가 자동으로 선택되고, 마지막으로 확인한 준비 하나만 다음 전투의 초반 효과가 됩니다.',
'작전판에서 적 선봉의 움직임을 읽고, 보급 수레에서 방호구를 손보거나, 먼저 돌아온 동료와 지난 전투를 되짚을 수 있습니다. 세 활동은 모두 선택 사항이며 어느 순서로 해도 됩니다. 활동을 마치면 출진 보너스 후보가 열리며, 실제로 적용할 하나는 군영 장부에서 따로 확정합니다.',
background: {
textureKey: 'third-guangzong-sortie-camp-background',
source: 'original-guangzong-forward-camp'

View File

@@ -96,18 +96,18 @@ export const thirdCampPreparationPriorities: readonly
},
{
id: 'equipment',
label: '장비 정비',
label: '선봉 방호',
activityLabel: '승전 장비 확인',
summary:
'3차 승전 장비를 확인하면 첫 3턴 아군의 첫 피격 피해를 한 번 8% 줄입니다.',
'수선한 방호구로 첫 3턴 아군의 첫 피격 피해를 한 번 8% 줄입니다.',
deploymentLine:
'장비 정비 · 아군 첫 피격 1회 방호 준비',
'선봉 방호 · 아군 첫 피격 1회 방호 준비',
activeLine:
'장비 정비 · 첫 3턴 동안 아군 첫 피격 1회 피해 -8%',
'선봉 방호 · 첫 3턴 동안 아군 첫 피격 1회 피해 -8%',
appliedLine:
'장비 정비 · 아군 첫 피격 방호 적용',
'선봉 방호 · 아군 첫 피격 방호 적용',
unusedLine:
'장비 정비 · 방호가 발동하기 전에 준비 시간이 끝남',
'선봉 방호 · 방호가 발동하기 전에 준비 시간이 끝남',
effect: {
kind: 'prepared-equipment-guard',
activeThroughTurn: thirdCampPreparationActiveThroughTurn,
@@ -216,13 +216,21 @@ export type ThirdCampPreparationBattlePresentation = {
| 'active'
| 'expired'
| 'applied'
| 'unused';
| 'unused'
| 'incompatible';
text: string;
activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn;
turnNumber: number | null;
usageCount: number;
};
export type ThirdCampPreparationCompatibility = {
compatible: boolean;
requiredUnitIds: string[];
missingUnitIds: string[];
disabledReason: 'required-units-missing' | null;
};
export type ThirdCampPreparationCampaignState = {
step?: unknown;
latestBattleId?: unknown;
@@ -236,6 +244,7 @@ export type ThirdCampPreparationCampaignState = {
sourceBattleId?: unknown;
targetBattleId?: unknown;
priorityId?: unknown;
confirmed?: unknown;
} | null;
acknowledgedVictoryRewardCategories?: Readonly<
Record<string, readonly unknown[] | undefined>
@@ -381,6 +390,7 @@ export function resolveThirdCampPreparationSelection(
priorityId,
label: availability.priority.label,
selectable: availability.selectable,
confirmed: selection.confirmed === true,
...(availability.evidence
? { evidence: cloneEvidence(availability.evidence) }
: {}),
@@ -391,17 +401,72 @@ export function resolveThirdCampPreparationSelection(
: undefined;
}
export function shouldReviewThirdCampPreparationBeforeRetry(
campaign?: ThirdCampPreparationCampaignState | null
) {
const selection =
resolveThirdCampPreparationSelection(campaign);
if (
campaign?.step !== 'fourth-battle' ||
!selection?.selectable ||
selection.confirmed
) {
return false;
}
const targetSettlement =
campaign.battleHistory?.[
thirdCampPreparationTargetBattleId
];
return (
targetSettlement?.outcome === 'defeat' ||
(campaign.firstBattleReport?.battleId ===
thirdCampPreparationTargetBattleId &&
campaign.firstBattleReport.outcome === 'defeat')
);
}
export function resolveThirdCampPreparationMemory(options: {
campaign?: ThirdCampPreparationCampaignState | null;
battleId?: string;
}): ThirdCampPreparationMemory | undefined {
return resolveThirdCampPreparationMemoryCandidate(options);
}
export function resolveThirdCampPreparationBattleResumeMemory(
options: {
campaign?: ThirdCampPreparationCampaignState | null;
battleId?: string;
savedPriorityId?: unknown;
}
): ThirdCampPreparationMemory | undefined {
if (!isThirdCampPreparationPriorityId(options.savedPriorityId)) {
return undefined;
}
return resolveThirdCampPreparationMemoryCandidate(
options,
options.savedPriorityId
);
}
function resolveThirdCampPreparationMemoryCandidate(
options: {
campaign?: ThirdCampPreparationCampaignState | null;
battleId?: string;
},
resumedPriorityId?: ThirdCampPreparationPriorityId
): ThirdCampPreparationMemory | undefined {
if (options.battleId !== thirdCampPreparationTargetBattleId) {
return undefined;
}
const selection = resolveThirdCampPreparationSelection(
options.campaign
);
if (!selection?.selectable || !selection.evidence) {
if (
!selection?.selectable ||
!selection.evidence ||
(!selection.confirmed &&
resumedPriorityId !== selection.priorityId)
) {
return undefined;
}
const priority = getThirdCampPreparationPriority(
@@ -436,6 +501,7 @@ export function resolveThirdCampPreparationBattlePresentation(options: {
stage: 'deployment' | 'turn' | 'result';
turnNumber?: number;
usageCount?: number;
compatibility?: ThirdCampPreparationCompatibility;
}): ThirdCampPreparationBattlePresentation | undefined {
const priority = getThirdCampPreparationPriority(
options.memory.priorityId
@@ -451,6 +517,15 @@ export function resolveThirdCampPreparationBattlePresentation(options: {
const usageCount = nonNegativeInteger(options.usageCount);
if (options.stage === 'deployment') {
if (options.compatibility?.compatible === false) {
return presentationBase(
options.memory,
'incompatible',
`${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`,
null,
usageCount
);
}
return presentationBase(
options.memory,
'deployment',
@@ -464,19 +539,42 @@ export function resolveThirdCampPreparationBattlePresentation(options: {
if (!turnNumber) {
return undefined;
}
const active =
turnNumber <= thirdCampPreparationActiveThroughTurn;
if (turnNumber > thirdCampPreparationActiveThroughTurn) {
return presentationBase(
options.memory,
'expired',
`${options.memory.label} · 준비 효과 종료`,
turnNumber,
usageCount
);
}
if (options.compatibility?.compatible === false) {
return presentationBase(
options.memory,
'incompatible',
`${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`,
turnNumber,
usageCount
);
}
return presentationBase(
options.memory,
active ? 'active' : 'expired',
active
? `${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}`
: `${options.memory.label} · 준비 효과 종료`,
'active',
`${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}`,
turnNumber,
usageCount
);
}
if (options.compatibility?.compatible === false) {
return presentationBase(
options.memory,
'incompatible',
`${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`,
null,
usageCount
);
}
const applied = usageCount > 0;
return presentationBase(
options.memory,
@@ -489,6 +587,27 @@ export function resolveThirdCampPreparationBattlePresentation(options: {
);
}
export function evaluateThirdCampPreparationCompatibility(
memory: ThirdCampPreparationMemory | undefined,
selectedUnitIds: readonly string[]
): ThirdCampPreparationCompatibility {
const requiredUnitIds =
memory?.effect.kind === 'return-bond-opening'
? [...memory.effect.unitIds]
: [];
const selected = new Set(selectedUnitIds);
const missingUnitIds = requiredUnitIds.filter(
(unitId) => !selected.has(unitId)
);
return {
compatible: missingUnitIds.length === 0,
requiredUnitIds,
missingUnitIds,
disabledReason:
missingUnitIds.length > 0 ? 'required-units-missing' : null
};
}
function selectThirdBattleVictoryRecord(
campaign?: ThirdCampPreparationCampaignState | null
): ThirdBattleRecordSelection | undefined {

View File

@@ -154,13 +154,16 @@ import {
type SecondBattleReliefMemory
} from '../data/secondBattleReliefMemory';
import {
evaluateThirdCampPreparationCompatibility,
resolveThirdCampPreparationBattlePresentation,
resolveThirdCampPreparationBattleResumeMemory,
resolveThirdCampPreparationMemory,
thirdCampPreparationActiveThroughTurn,
thirdCampPreparationSelectionRecordId,
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationBattlePresentation,
type ThirdCampPreparationCompatibility,
type ThirdCampPreparationMemory
} from '../data/thirdCampPreparation';
import {
@@ -6551,8 +6554,11 @@ export class BattleScene extends Phaser.Scene {
const applied =
this.thirdCampPreparationRuntime.usageCount > 0;
const active = presentation.status === 'active';
const incompatible = presentation.status === 'incompatible';
const tone = applied
? palette.green
: incompatible
? palette.red
: active
? palette.gold
: 0x647485;
@@ -6581,6 +6587,8 @@ export class BattleScene extends Phaser.Scene {
fontSize: this.battleUiFontSize(11),
color: applied
? '#a8ffd0'
: incompatible
? '#ffb6a6'
: active
? '#fff2b8'
: '#9fb0bf',
@@ -6789,6 +6797,9 @@ export class BattleScene extends Phaser.Scene {
if (!presentation) {
return undefined;
}
if (presentation.status === 'incompatible') {
return `준비 ${presentation.label} · 미편성 · 미발동`;
}
const applied =
this.thirdCampPreparationRuntime.usageCount > 0;
const active = presentation.status === 'active';
@@ -9329,6 +9340,11 @@ export class BattleScene extends Phaser.Scene {
: `${this.cityEquipmentPreparationDisplayLine()} 준비 무장을 출전시키지 않아 이번 전투에는 반영되지 않습니다.`;
}
if (this.thirdCampPreparationMemory) {
const compatibility =
this.thirdCampPreparationSortieCompatibility();
if (!compatibility.compatible) {
return `${this.thirdCampPreparationMemory.label}은 필요한 공명조가 편성되지 않아 이번 전투에 반영되지 않습니다.`;
}
return `${this.thirdCampPreparationMemory.deploymentLine}. 효과는 3턴이 끝나면 종료됩니다.`;
}
if (this.secondBattleReliefMemory) {
@@ -9404,7 +9420,12 @@ export class BattleScene extends Phaser.Scene {
const order = sortieOrderDefinition(this.launchSortieOrderId);
return `선택 군령 · ${order.label} · ${order.summary}`;
})();
return `${this.thirdCampPreparationMemory.deploymentLine}\n${strategySummary}`;
const compatibility =
this.thirdCampPreparationSortieCompatibility();
const preparationLine = compatibility.compatible
? this.thirdCampPreparationMemory.deploymentLine
: `${this.thirdCampPreparationMemory.label} · 공명조 미편성으로 이번 전투 미발동`;
return `${preparationLine}\n${strategySummary}`;
}
if (this.launchSortieRecommendation) {
return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`;
@@ -19668,9 +19689,18 @@ export class BattleScene extends Phaser.Scene {
};
}
private thirdCampPreparationSortieCompatibility():
ThirdCampPreparationCompatibility {
return evaluateThirdCampPreparationCompatibility(
this.thirdCampPreparationMemory,
this.launchSortieUnitIds
);
}
private thirdCampPreparationActive() {
return Boolean(
this.thirdCampPreparationMemory &&
this.thirdCampPreparationSortieCompatibility().compatible &&
!this.battleOutcome &&
this.turnNumber <= thirdCampPreparationActiveThroughTurn
);
@@ -19720,7 +19750,7 @@ export class BattleScene extends Phaser.Scene {
damageReduction:
memory.effect.incomingDamageReductionPercent,
labels: [
`장비 정비 · 첫 피격 -${memory.effect.incomingDamageReductionPercent}%`
`선봉 방호 · 첫 피격 -${memory.effect.incomingDamageReductionPercent}%`
]
};
}
@@ -19818,7 +19848,9 @@ export class BattleScene extends Phaser.Scene {
memory,
stage,
...(stage === 'turn' ? { turnNumber: this.turnNumber } : {}),
usageCount: this.thirdCampPreparationRuntime.usageCount
usageCount: this.thirdCampPreparationRuntime.usageCount,
compatibility:
this.thirdCampPreparationSortieCompatibility()
});
}
@@ -19900,7 +19932,10 @@ export class BattleScene extends Phaser.Scene {
...(this.thirdCampPreparationMemory
? [
`군영 선택 · ${this.thirdCampPreparationMemory.label}`,
this.thirdCampPreparationMemory.activeLine
this.thirdCampPreparationSortieCompatibility()
.compatible
? this.thirdCampPreparationMemory.activeLine
: `${this.thirdCampPreparationMemory.label} · 필요한 공명조 미편성으로 이번 전투 미발동`
]
: []),
...cityInformationLines,
@@ -21513,11 +21548,18 @@ export class BattleScene extends Phaser.Scene {
campaign: getCampaignState(),
battleId: battleScenario.id
});
this.thirdCampPreparationMemory = resolveThirdCampPreparationMemory({
campaign: getCampaignState(),
battleId: battleScenario.id
});
const campaign = getCampaignState();
this.thirdCampPreparationMemory =
resolveThirdCampPreparationMemory({
campaign,
battleId: battleScenario.id
}) ??
resolveThirdCampPreparationBattleResumeMemory({
campaign,
battleId: battleScenario.id,
savedPriorityId:
state.thirdCampPreparation?.priorityId
});
this.xuzhouStayBattlePayoff =
resolveXuzhouStayBattlePayoff(
campaign,
@@ -31785,6 +31827,7 @@ export class BattleScene extends Phaser.Scene {
activeThroughTurn:
thirdCampPreparationActiveThroughTurn,
evidence: null,
compatibility: null,
trackedEnemyUnitId: null,
bondId: null,
unitIds: [],
@@ -31837,6 +31880,8 @@ export class BattleScene extends Phaser.Scene {
this.thirdCampPreparationPresentation('result');
const saveSnapshot =
this.thirdCampPreparationSaveState();
const compatibility =
this.thirdCampPreparationSortieCompatibility();
const mechanicsProbe = (() => {
if (memory.effect.kind === 'marked-vanguard-intel') {
const effect = memory.effect;
@@ -31964,6 +32009,12 @@ export class BattleScene extends Phaser.Scene {
unitIds: [...memory.evidence.unitIds]
}
: { ...memory.evidence },
compatibility: {
compatible: compatibility.compatible,
requiredUnitIds: [...compatibility.requiredUnitIds],
missingUnitIds: [...compatibility.missingUnitIds],
disabledReason: compatibility.disabledReason
},
trackedEnemyUnitId,
bondId,
unitIds,

View File

@@ -55,8 +55,11 @@ import {
thirdCampExplorationVisitId
} from '../data/thirdCampExploration';
import {
evaluateThirdCampPreparationCompatibility,
resolveThirdCampPreparationMemory,
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationEvidence,
type ThirdCampPreparationPriorityId,
type ThirdCampPreparationUnavailableReason
} from '../data/thirdCampPreparation';
@@ -11501,6 +11504,8 @@ export class CampScene extends Phaser.Scene {
private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle;
private thirdCampPreparationClearButton?: Phaser.GameObjects.Rectangle;
private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = [];
private thirdCampPreparationKeyboardIndex = 0;
private thirdCampIncompatibleSortieConfirmSignature?: string;
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
private sortieFormationAssignments: SortieFormationAssignments = {};
@@ -11554,6 +11559,8 @@ export class CampScene extends Phaser.Scene {
init(data?: CampSceneData) {
this.ownedCampTextureKeys.clear();
this.thirdCampPreparationBrowserOpen = false;
this.thirdCampPreparationKeyboardIndex = 0;
this.thirdCampIncompatibleSortieConfirmSignature = undefined;
if (data?.completedAftermathBattleId) {
completeCampaignAftermath(data.completedAftermathBattleId);
}
@@ -11740,7 +11747,11 @@ export class CampScene extends Phaser.Scene {
});
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => {
if (!this.handleThirdCampPreparationKey(event)) {
this.handleSaveSlotKey(event);
}
});
loadBattleUiIcons(this, () => this.ensureCampAssets(() => this.drawCampScene()));
}
@@ -14147,6 +14158,98 @@ export class CampScene extends Phaser.Scene {
this.startVictoryStory();
}
private handleThirdCampPreparationKey(event: KeyboardEvent) {
if (
!this.thirdCampPreparationBrowserOpen ||
this.sortiePrepStep !== 'briefing' ||
this.sortieObjects.length === 0 ||
this.navigationPending ||
this.victoryRewardObjects.length > 0 ||
this.equipmentSwapConfirmObjects.length > 0 ||
this.saveSlotObjects.length > 0 ||
this.saveSlotConfirmObjects.length > 0 ||
this.reportFormationReviewVisible ||
this.reportFormationHistoryVisible
) {
return false;
}
if (event.repeat) {
return true;
}
const progress = this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
if (!progress) {
return false;
}
const priorities = progress.availability;
const moveFocus = (offset: number) => {
this.thirdCampPreparationKeyboardIndex =
(this.thirdCampPreparationKeyboardIndex +
offset +
priorities.length) %
priorities.length;
soundDirector.playSelect();
this.showSortiePrep();
};
const chooseFocused = () => {
const entry =
priorities[this.thirdCampPreparationKeyboardIndex];
if (!entry) {
return;
}
if (!entry.selectable) {
this.openThirdCampPreparationActivity(
entry.priority.id
);
return;
}
this.selectThirdCampPreparationPriority(entry.priority.id);
};
if (
event.key === 'ArrowDown' ||
event.key === 'ArrowRight' ||
(event.key === 'Tab' && !event.shiftKey)
) {
event.preventDefault();
moveFocus(1);
return true;
}
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowLeft' ||
(event.key === 'Tab' && event.shiftKey)
) {
event.preventDefault();
moveFocus(-1);
return true;
}
if (
event.key === 'Enter' ||
event.key === ' ' ||
event.code === 'Space'
) {
event.preventDefault();
chooseFocused();
return true;
}
if (/^[1-3]$/.test(event.key)) {
event.preventDefault();
this.thirdCampPreparationKeyboardIndex =
Number(event.key) - 1;
chooseFocused();
return true;
}
if (event.key === '0') {
event.preventDefault();
this.clearThirdCampPreparationSelection();
return true;
}
return false;
}
private addTabButton(label: string, tab: CampTab, x: number, y: number, width = 76, fontSize = 16) {
const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 34, 0x182431, 0.94));
bg.setStrokeStyle(1, palette.blue, 0.62);
@@ -14471,8 +14574,10 @@ export class CampScene extends Phaser.Scene {
private isThirdCampPreparationSlice(flow = this.currentSortieFlow()) {
return (
flow.afterBattleId === thirdCampPreparationSourceBattleId &&
flow.nextBattleId === thirdCampPreparationTargetBattleId
flow.nextBattleId === thirdCampPreparationTargetBattleId &&
(flow.afterBattleId === thirdCampPreparationSourceBattleId ||
this.retrySortieBattleId ===
thirdCampPreparationTargetBattleId)
);
}
@@ -14481,6 +14586,9 @@ export class CampScene extends Phaser.Scene {
}
private setSortiePrepStep(step: SortiePrepStep) {
if (step !== 'briefing') {
this.thirdCampPreparationBrowserOpen = false;
}
if (this.sortiePrepStep === step) {
return;
}
@@ -14651,7 +14759,9 @@ export class CampScene extends Phaser.Scene {
const commandTitle = thirdCampPreparation
? thirdCampPreparation.ready
? `출진 우선 · ${thirdCampPreparation.selectedLabel}`
: '출진 우선 · 보너스 없음'
: thirdCampPreparation.selectedLabel
? `출진 우선 · ${thirdCampPreparation.selectedLabel} 확인 필요`
: '출진 우선 · 보너스 없음'
: firstBattleFollowup
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
: '지휘관 판단';
@@ -14659,9 +14769,22 @@ export class CampScene extends Phaser.Scene {
const selectedPreparation = thirdCampPreparation?.availability.find(
(entry) => entry.priority.id === thirdCampPreparation.selectedPriorityId
);
const preparationCompatibility =
this.thirdCampPreparationSortieState();
const preparationCompatibilityWarning =
preparationCompatibility?.compatibility.compatible === false
? `발동 불가 · ${preparationCompatibility.compatibility.missingUnitIds
.map((unitId) => this.unitName(unitId))
.join('·')} .`
: '';
const commandSummary = thirdCampPreparation
? selectedPreparation?.priority.summary ??
'야영지에서 완료한 활동은 보너스 하나로 선택할 수 있습니다. 선택하지 않고 출진해도 됩니다.'
? [
selectedPreparation?.priority.summary ??
'야영지에서 완료한 활동은 보너스 하나로 선택할 수 있습니다. 선택하지 않고 출진해도 됩니다.',
preparationCompatibilityWarning
]
.filter(Boolean)
.join(' ')
: firstBattleFollowup
? firstBattleFollowup.achieved
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
@@ -14684,7 +14807,11 @@ export class CampScene extends Phaser.Scene {
this.renderThirdCampPreparationToggle(
x + width - 86,
commandY + 51,
thirdCampPreparation.ready ? '준비 변경' : '보너스 선택',
thirdCampPreparation.ready
? '준비 변경'
: thirdCampPreparation.selectedPriorityId
? '선택 확인'
: '보너스 선택',
depth + 2
);
} else if (firstBattleFollowup) {
@@ -14733,6 +14860,15 @@ export class CampScene extends Phaser.Scene {
button.setStrokeStyle(2, palette.gold, 0.92);
button.setInteractive({ useHandCursor: true });
const open = () => {
const progress = this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
const selectedIndex = progress?.availability.findIndex(
(entry) =>
entry.priority.id === progress.selectedPriorityId
) ?? -1;
this.thirdCampPreparationKeyboardIndex =
selectedIndex >= 0 ? selectedIndex : 0;
this.thirdCampPreparationBrowserOpen = true;
soundDirector.playSelect();
this.showSortiePrep();
@@ -14777,8 +14913,8 @@ export class CampScene extends Phaser.Scene {
this.add.text(
x + 18,
y + 41,
'야영지에서 완료한 활동 중 하나만 첫 3턴에 반영합니다. 미선택 출진도 가능합니다.',
this.textStyle(10, '#9fb0bf')
'완료 후보 1개 확정 · ↑↓/Tab 이동 · Enter/Space 확정 · 1~3 바로 선택 · 0 해제',
this.textStyle(9, '#9fb0bf')
)
).setDepth(depth + 1);
@@ -14830,20 +14966,35 @@ export class CampScene extends Phaser.Scene {
progress.availability.forEach((entry, index) => {
const priorityId = entry.priority.id;
const selected = progress.selectedPriorityId === priorityId;
const confirmed = selected && progress.confirmed;
const focused =
this.thirdCampPreparationKeyboardIndex === index;
const requirementLine =
this.thirdCampPreparationRequirementLine(entry.evidence);
const cardY = y + 68 + index * 106;
const cardHeight = 98;
const fill = selected
const fill = confirmed
? 0x183126
: selected
? 0x352a17
: entry.selectable
? 0x17232e
: 0x111820;
const tone = selected ? palette.green : toneByPriority[priorityId];
const tone = confirmed
? palette.green
: selected
? palette.gold
: toneByPriority[priorityId];
const card = this.trackSortie(
this.add.rectangle(x + 16, cardY, width - 32, cardHeight, fill, entry.selectable ? 0.98 : 0.78)
);
card.setOrigin(0);
card.setDepth(depth + 1);
card.setStrokeStyle(selected ? 2 : 1, tone, selected ? 0.96 : entry.selectable ? 0.68 : 0.28);
card.setStrokeStyle(
focused ? 3 : selected ? 2 : 1,
focused ? 0xf4e3b5 : tone,
focused ? 1 : selected ? 0.96 : entry.selectable ? 0.68 : 0.28
);
const seal = this.trackSortie(
this.add.circle(x + 43, cardY + 31, 17, tone, entry.selectable ? 0.9 : 0.38)
@@ -14857,7 +15008,7 @@ export class CampScene extends Phaser.Scene {
this.add.text(
x + 68,
cardY + 10,
`${entry.priority.label} · ${entry.priority.activityLabel}`,
`${focused ? '▶ ' : ''}${entry.priority.label} · ${entry.priority.activityLabel}`,
this.textStyle(13, entry.selectable ? '#ffdf7b' : '#77818c', true)
)
).setDepth(depth + 2);
@@ -14866,7 +15017,7 @@ export class CampScene extends Phaser.Scene {
x + 68,
cardY + 33,
entry.selectable
? entry.priority.summary
? `${requirementLine} · ${entry.priority.summary}`
: this.thirdCampPreparationUnavailableLabel(entry.unavailableReason),
{
...this.textStyle(10, entry.selectable ? '#d4dce6' : '#7f8994'),
@@ -14885,7 +15036,11 @@ export class CampScene extends Phaser.Scene {
actionY,
82,
32,
selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630,
confirmed
? 0x28533c
: entry.selectable
? 0x4a371d
: 0x1a2630,
entry.selectable ? 0.98 : 0.8
)
);
@@ -14896,10 +15051,12 @@ export class CampScene extends Phaser.Scene {
this.add.text(
actionX,
actionY,
selected
? '선택됨'
confirmed
? '확정됨'
: selected
? '확정'
: entry.selectable
? '선택'
? '선택·확정'
: this.thirdCampPreparationLockedActionLabel(priorityId),
this.textStyle(10, entry.selectable ? '#fff2b8' : '#9fb0bf', true)
)
@@ -14924,7 +15081,11 @@ export class CampScene extends Phaser.Scene {
);
action.on('pointerout', () =>
action.setFillStyle(
selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630,
confirmed
? 0x28533c
: entry.selectable
? 0x4a371d
: 0x1a2630,
entry.selectable ? 0.98 : 0.8
)
);
@@ -14945,8 +15106,10 @@ export class CampScene extends Phaser.Scene {
x + 18,
y + height - 25,
progress.ready
? '선택 사항 · 해제하거나 다른 완료 활동으로 바꿀 수 있습니다.'
: '선택 사항 · 보너스 없이 그대로 출진할 수 있습니다.',
? '확정 완료 · 해제하거나 다른 완료 활동으로 바꿀 수 있습니다.'
: progress.selectedPriorityId
? '이전 자동 선택 · 적용하려면 위 카드에서 한 번 확정하세요.'
: '선택 사항 · 보너스 없이 그대로 출진할 수 있습니다.',
{
...this.textStyle(
10,
@@ -14997,6 +15160,9 @@ export class CampScene extends Phaser.Scene {
private thirdCampPreparationUnavailableLabel(
reason?: ThirdCampPreparationUnavailableReason
) {
if (this.campaign?.step !== thirdBattleReturnCampStep) {
return '첫 체류에서 완료하지 않은 활동입니다. 재도전 중에는 새 활동을 진행할 수 없습니다.';
}
if (reason === 'report-review-required') {
return '야영지에서 추정과 작전판을 먼저 확인하면 선택할 수 있습니다.';
}
@@ -15009,10 +15175,57 @@ export class CampScene extends Phaser.Scene {
return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.';
}
private thirdCampPreparationSortieState() {
if (!this.isThirdCampPreparationSlice()) {
return undefined;
}
const campaign = this.campaign ?? getCampaignState();
const memory = resolveThirdCampPreparationMemory({
campaign,
battleId: thirdCampPreparationTargetBattleId
});
if (!memory) {
return undefined;
}
return {
memory,
compatibility: evaluateThirdCampPreparationCompatibility(
memory,
this.selectedSortieUnitIds
)
};
}
private thirdCampPreparationRequirementLine(
evidence?: ThirdCampPreparationEvidence
) {
const requiredUnitIds =
evidence && 'unitIds' in evidence ? evidence.unitIds : [];
if (requiredUnitIds.length === 0) {
return '발동 조건 · 편성 무관';
}
const selected = new Set(this.selectedSortieUnitIds);
const selectedCount = requiredUnitIds.filter((unitId) =>
selected.has(unitId)
).length;
return `발동 조건 · ${requiredUnitIds
.map((unitId) => this.unitName(unitId))
.join('·')} ${selectedCount}/${requiredUnitIds.length}`;
}
private thirdCampIncompatibleSortieSignature(
priorityId: ThirdCampPreparationPriorityId,
selectedUnitIds: readonly string[]
) {
return `${priorityId}|${[...selectedUnitIds].sort().join(',')}`;
}
private thirdCampPreparationLockedActionLabel(
_priorityId: ThirdCampPreparationPriorityId
) {
return '야영지로';
return this.campaign?.step === thirdBattleReturnCampStep
? '야영지로'
: '초회 한정';
}
private selectThirdCampPreparationPriority(
@@ -15029,9 +15242,10 @@ export class CampScene extends Phaser.Scene {
this.campaign = result.campaign;
this.thirdCampPreparationBrowserOpen = true;
this.thirdCampIncompatibleSortieConfirmSignature = undefined;
soundDirector.playSelect();
this.showCampNotice(
`${result.priority.label} 선택 · ${result.memory.activeLine}`
`${result.priority.label} 확정 · ${result.memory.activeLine}`
);
this.showSortiePrep();
}
@@ -15046,6 +15260,7 @@ export class CampScene extends Phaser.Scene {
}
this.campaign = result.campaign;
this.thirdCampPreparationBrowserOpen = true;
this.thirdCampIncompatibleSortieConfirmSignature = undefined;
soundDirector.playSelect();
this.showCampNotice(
result.changed
@@ -15058,6 +15273,12 @@ export class CampScene extends Phaser.Scene {
private openThirdCampPreparationActivity(
_priorityId: ThirdCampPreparationPriorityId
) {
if (this.campaign?.step !== thirdBattleReturnCampStep) {
this.showCampNotice(
'재도전에서는 완료한 야영지 후보만 바꿀 수 있습니다. 새 활동은 광종 야영지에서만 진행할 수 있습니다.'
);
return;
}
soundDirector.playSelect();
this.thirdCampPreparationBrowserOpen = false;
this.startCampNavigation('CampVisitExplorationScene', {
@@ -16850,8 +17071,16 @@ export class CampScene extends Phaser.Scene {
checklist: SortieChecklistItem[]
) {
const summary = this.sortieChecklistSummary(checklist);
const items = this.firstSortieFinalCheckItems(checklist);
const finalChecksComplete = items.every((item) => item.complete);
const requiredItems = this.firstSortieFinalCheckItems(checklist);
const preparationItem = checklist.find(
(item) => item.label === '출진 우선 준비'
);
const items = preparationItem
? [...requiredItems, preparationItem]
: requiredItems;
const finalChecksComplete = requiredItems.every(
(item) => item.complete
);
const heading = finalChecksComplete ? '출진 준비 완료' : summary.requiredComplete ? '권장 점검 남음' : '필수 확인 필요';
const headingColor = finalChecksComplete ? '#a8ffd0' : summary.requiredComplete ? '#ffdf7b' : '#ff9d7d';
const headingTone = finalChecksComplete ? palette.green : summary.requiredComplete ? palette.gold : palette.red;
@@ -16862,7 +17091,7 @@ export class CampScene extends Phaser.Scene {
this.trackSortie(
this.add.text(x + 18, y + 16, heading, this.textStyle(20, headingColor, true))
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 45, `${items.filter((item) => item.complete).length}/${items.length} 핵심 조건`, this.textStyle(12, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 18, y + 45, `${requiredItems.filter((item) => item.complete).length}/${requiredItems.length} 핵심 조건${preparationItem ? ' · 야영지 효과 표시' : ''}`, this.textStyle(12, '#9fb0bf', true))).setDepth(depth + 1);
const cardGap = Math.min(67, Math.floor((height - 160) / Math.max(1, items.length)));
const cardHeight = Math.max(42, cardGap - 8);
@@ -16936,6 +17165,27 @@ export class CampScene extends Phaser.Scene {
const pursuit = this.sortiePursuitPairs();
const selectedOrder = this.currentSortieOrderDefinition();
const cityEquipment = this.cityEquipmentSortieStatus();
const thirdCampPreparation = this.isThirdCampPreparationSlice() &&
this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
const thirdCampSortie = this.thirdCampPreparationSortieState();
const thirdCampLine = thirdCampPreparation
? thirdCampPreparation.ready
? thirdCampSortie?.compatibility.compatible === false
? `야영지 ${thirdCampPreparation.selectedLabel} · 미발동 · ${thirdCampSortie.compatibility.missingUnitIds
.map((unitId) => this.unitName(unitId))
.join('·')} `
: `야영지 ${thirdCampPreparation.selectedLabel} · 첫 3턴 · ${
thirdCampSortie?.memory.effect.kind ===
'return-bond-opening'
? '공명조 편성 충족'
: '편성 무관'
}`
: thirdCampPreparation.selectedPriorityId
? `야영지 ${thirdCampPreparation.selectedLabel} · 확정 전 · 현재 미적용`
: '야영지 보너스 없음 · 선택 없이 출진 가능'
: undefined;
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78));
bg.setOrigin(0);
bg.setDepth(depth);
@@ -16951,7 +17201,7 @@ export class CampScene extends Phaser.Scene {
)
).setDepth(depth + 1);
this.renderSortieBattleUiIcon('focus', x + 22, y + 44, 22, depth + 1, 0.74);
const secondaryLine = cityEquipment?.line ??
const secondaryLine = cityEquipment?.line ?? thirdCampLine ??
`${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · 추격 후보 ${pursuit.activePairs.length}조 · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}`;
const secondaryText = this.trackSortie(
this.add.text(
@@ -16962,6 +17212,12 @@ export class CampScene extends Phaser.Scene {
12,
cityEquipment
? cityEquipment.active ? '#a8ffd0' : '#ffdf7b'
: thirdCampLine
? thirdCampSortie?.compatibility.compatible === false
? '#ff9d7d'
: thirdCampPreparation?.ready
? '#a8ffd0'
: '#ffdf7b'
: reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf',
true
)
@@ -16970,6 +17226,15 @@ export class CampScene extends Phaser.Scene {
if (cityEquipment) {
this.sortieCityEquipmentSummaryText = secondaryText;
}
if (thirdCampLine) {
secondaryText.setInteractive({ useHandCursor: true });
secondaryText.on('pointerdown', () => {
this.sortiePrepStep = 'briefing';
this.thirdCampPreparationBrowserOpen = true;
soundDirector.playSelect();
this.showSortiePrep();
});
}
}
private cityEquipmentSortieStatus() {
@@ -20869,6 +21134,8 @@ export class CampScene extends Phaser.Scene {
const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign
? thirdCampPreparationProgress(this.campaign)
: undefined;
const thirdCampPreparationSortie =
this.thirdCampPreparationSortieState();
return [
{
label: requiredLabel,
@@ -20879,10 +21146,24 @@ export class CampScene extends Phaser.Scene {
...(thirdCampPreparation
? [{
label: '출진 우선 준비',
complete: true,
complete:
thirdCampPreparationSortie?.compatibility.compatible ??
true,
detail: thirdCampPreparation.ready
? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영`
: '미선택 · 보너스 없이 출진 가능',
? thirdCampPreparationSortie?.compatibility.compatible ===
false
? `${thirdCampPreparation.selectedLabel} · ${thirdCampPreparationSortie.compatibility.missingUnitIds
.map((unitId) => this.unitName(unitId))
.join('·')} · `
: `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영 · ${
thirdCampPreparationSortie?.memory.effect.kind ===
'return-bond-opening'
? '공명조 편성 충족'
: '편성 무관'
}`
: thirdCampPreparation.selectedPriorityId
? `${thirdCampPreparation.selectedLabel} · 이전 자동 선택 확인 필요 · 미적용`
: '미선택 · 보너스 없이 출진 가능',
priority: 'recommended' as const
}]
: []),
@@ -21034,6 +21315,33 @@ export class CampScene extends Phaser.Scene {
return;
}
const thirdCampSortie =
this.thirdCampPreparationSortieState();
if (
thirdCampSortie &&
!thirdCampSortie.compatibility.compatible
) {
const signature =
this.thirdCampIncompatibleSortieSignature(
thirdCampSortie.memory.priorityId,
this.selectedSortieUnitIds
);
if (
this.thirdCampIncompatibleSortieConfirmSignature !==
signature
) {
this.thirdCampIncompatibleSortieConfirmSignature =
signature;
this.showCampNotice(
`${thirdCampSortie.memory.label} 미발동 경고 · ${thirdCampSortie.compatibility.missingUnitIds
.map((unitId) => this.unitName(unitId))
.join('·')} . .`
);
this.showSortiePrep();
return;
}
}
if (this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep) {
this.hideSortiePrep();
this.startCampNavigation('EndingScene');
@@ -21271,6 +21579,7 @@ export class CampScene extends Phaser.Scene {
this.sortieCoreResonanceSelectorOpen = false;
this.sortieCoreResonancePage = 0;
this.thirdCampPreparationBrowserOpen = false;
this.thirdCampIncompatibleSortieConfirmSignature = undefined;
}
this.sortieComparisonPanelView = undefined;
this.sortiePursuitPanelView = undefined;
@@ -26646,8 +26955,31 @@ export class CampScene extends Phaser.Scene {
targetBattleId: thirdCampPreparation.targetBattleId,
selectionRecordId: thirdCampPreparation.selectionRecordId,
ready: thirdCampPreparation.ready,
confirmed: thirdCampPreparation.confirmed,
selectedPriorityId: thirdCampPreparation.selectedPriorityId,
selectedLabel: thirdCampPreparation.selectedLabel,
keyboardFocusIndex:
this.thirdCampPreparationKeyboardIndex,
keyboardFocusPriorityId:
thirdCampPreparation.availability[
this.thirdCampPreparationKeyboardIndex
]?.priority.id ?? null,
compatibility: (() => {
const sortie = this.thirdCampPreparationSortieState();
return sortie
? {
compatible: sortie.compatibility.compatible,
requiredUnitIds: [
...sortie.compatibility.requiredUnitIds
],
missingUnitIds: [
...sortie.compatibility.missingUnitIds
],
disabledReason:
sortie.compatibility.disabledReason
}
: null;
})(),
browserOpen: this.thirdCampPreparationBrowserOpen,
toggleButtonBounds: this.sortieInteractiveObjectBoundsDebug(
this.thirdCampPreparationToggleButton
@@ -26695,8 +27027,12 @@ export class CampScene extends Phaser.Scene {
targetBattleId: thirdCampPreparationTargetBattleId,
selectionRecordId: null,
ready: false,
confirmed: false,
selectedPriorityId: null,
selectedLabel: null,
keyboardFocusIndex: 0,
keyboardFocusPriorityId: null,
compatibility: null,
browserOpen: false,
toggleButtonBounds: null,
closeButtonBounds: null,
@@ -26777,6 +27113,8 @@ export class CampScene extends Phaser.Scene {
thirdCampExploration?.completedActivityCount ?? 0,
selectedPriorityId:
thirdCampExploration?.selectedPriorityId ?? null,
selectionConfirmed:
thirdCampExploration?.selectionConfirmed ?? false,
ready: thirdCampExploration?.ready ?? false,
mode: !thirdCampExplorationVisit
? 'unavailable'

View File

@@ -749,6 +749,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
})) ?? [],
selectedPriorityId:
thirdProgress?.selectedPriorityId ?? null,
selectionConfirmed:
thirdProgress?.selectionConfirmed ?? false,
selectedPriorityLabel:
selectedThirdPriority?.label ?? null,
hudActivityTitle:
@@ -933,8 +935,9 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.completeThirdCampActivity(
activityId as ThirdCampExplorationActivityId
);
return thirdCampExplorationProgress().selectedPriorityId ===
activityId;
return thirdCampExplorationProgress().completedActivityIds.includes(
activityId as ThirdCampExplorationActivityId
);
}
private isSecondReliefVisit() {
@@ -1061,7 +1064,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
? getThirdCampPreparationPriority(progress.selectedPriorityId)
: undefined;
return progress.completedActivityCount > 0
? `선택 활동 ${progress.completedActivityCount} / 3 · 현재 우선 ${selected?.label ?? '미정'} · 언제든 출진 가능`
? `준비 활동 ${progress.completedActivityCount} / 3 · 출진 보너스 ${selected?.label ?? '미선택'}${progress.ready ? '' : selected ? ' 확인 필요' : ''} · 군영 장부에서 확정`
: '준비 활동 0 / 3 · 원하는 곳만 살피고 남쪽 출구에서 언제든 출진할 수 있습니다.';
}
@@ -1721,7 +1724,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
150,
'자유 준비 0 / 3',
'작전판 · 보급 수레 · 먼저 귀환한 동료',
'한 곳만 확인해도 방침이 정해지며, 마지막 활동이 현재 우선 기준이 됩니다.'
'활동을 마치면 보너스 후보가 열립니다. 현재 출진 보너스는 자동으로 바뀌지 않습니다.'
);
this.objectiveCard = activityCard.background;
this.thirdActivityTitleText = activityCard.titleText;
@@ -1736,9 +1739,9 @@ export class CampVisitExplorationScene extends Phaser.Scene {
const priorityCard = this.drawObjectiveCard(
342,
'현재 출진 우선',
'출진 보너스 확정',
'아직 정하지 않음',
'완료한 활동을 다시 확인하면 보상 중복 없이 우선 기준만 바뀝니다.'
'실제로 적용할 하나는 군영 장부에서 따로 선택하고 확정합니다.'
);
this.thirdPriorityLocationText = priorityCard.locationText;
@@ -2913,7 +2916,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
const newlyCompleted =
!before.completedActivityIds.includes(activityId);
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.lastNotice = `${result.activity.shortLabel} · 현재 출진 우선`;
this.lastNotice = `${result.activity.shortLabel} · 보너스 후보 해금`;
if (newlyCompleted) {
soundDirector.playObjectiveAchieved();
} else {
@@ -2925,8 +2928,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.refreshInteractionPrompt();
this.showCompletionToast(
newlyCompleted
? result.activity.completionLine
: `${result.priority.label}을 현재 출진 우선으로 다시 선택했습니다. 보상은 중복되지 않습니다.`,
? `${result.activity.completionLine} 군영 장부에서 출진 보너스로 선택할 수 있습니다.`
: `${result.priority.label} 후보는 이미 해금되어 있습니다. 현재 출진 보너스는 그대로 유지됩니다.`,
'success',
newlyCompleted ? 2100 : 1750
);
@@ -3572,7 +3575,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
this.closeChoicePanel();
this.lastNotice = '동료 공명 · 현재 출진 우선';
this.lastNotice = '동료 공명 · 보너스 후보 해금';
if (alreadyCompleted) {
soundDirector.playSelect();
} else {
@@ -3593,8 +3596,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
{
speaker: '공명 기록',
text: alreadyCompleted
? `${result.memory.label}을 현재 출진 우선으로 다시 선택했습니다. 공명 보상은 중복되지 않습니다.`
: `${result.choice.label} · 공명 +${result.rewardExp} · 다음 본영전 초반 협공 신호에 반영`
? '동료 공명 후보는 이미 해금되어 있습니다. 현재 출진 보너스와 공명 보상은 바뀌지 않습니다.'
: `${result.choice.label} · 공명 +${result.rewardExp} · 군영 장부에서 초반 협공 보너스로 선택 가능`
}
],
() =>
@@ -3657,7 +3660,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
portraitKey: 'liuBei',
text:
progress.completedActivityCount > 0
? '살핀 준비와 현재 우선 기준은 장부에 남았다. 나머지는 선택이니 군영으로 돌아가 출진 여부를 정하자.'
? '살핀 준비는 장부에 남았다. 실제로 적용할 보너스는 군영 장부에서 따로 정하고 출진하자.'
: '아직 별도 준비를 확인하지 않았지만, 세 활동은 모두 선택이다. 군영 장부로 돌아가 바로 출진해도 된다.'
},
{
@@ -3766,8 +3769,8 @@ export class CampVisitExplorationScene extends Phaser.Scene {
sceneHeight / 2 + 44,
this.isThirdCampVisit()
? this.visitComplete()
? '선택한 출진 우선과 완료한 활동을 장부에 반영합니다.'
: '선택 활동을 남겨 두고 군영 장부로 돌아갑니다.'
? '완료한 활동을 장부에 반영하고, 출진 보너스는 군영에서 따로 확정합니다.'
: '완료한 활동을 저장하고 보너스를 정할 군영 장부로 돌아갑니다.'
: this.visitComplete()
? this.isSecondReliefVisit()
? '현장 수습과 광종 진군 방침을 출진 장부에 올립니다.'
@@ -3826,21 +3829,27 @@ export class CampVisitExplorationScene extends Phaser.Scene {
`자유 준비 ${progress.completedActivityCount} / 3`
);
this.thirdPriorityLocationText?.setText(
selected?.label ?? '아직 정하지 않음'
selected
? `${selected.label}${progress.ready ? ' · 확정됨' : ' · 확인 필요'}`
: '아직 정하지 않음'
);
const activityLines = progress.activities
.map(
(activity) =>
`${activity.completed ? '✓' : '!'} ${activity.shortLabel}${
activity.selected ? ' · 현재 우선' : ''
activity.selected
? progress.ready
? ' · 출진 확정'
: ' · 확인 필요'
: ''
}`
)
.join('\n');
this.progressText?.setText(
`선택 활동 ${progress.completedActivityCount} / 3 · 모두 선택\n` +
`현재 우선 · ${selected?.label ?? '아직 정하지 않음'}\n\n` +
`준비 활동 ${progress.completedActivityCount} / 3 · 모두 선택\n` +
`출진 보너스 · ${selected?.label ?? '미선택'}${selected && !progress.ready ? ' · 군영 확인 필요' : ''}\n\n` +
`${activityLines}\n\n` +
'완료한 곳을 다시 확인하면 보상 중복 없이 우선 기준만 바뀝니다.'
'재방문해도 선택은 바뀌지 않습니다. 군영 장부에서 적용할 하나를 확정하세요.'
);
this.refreshThirdWorldFeedback();
return;

View File

@@ -25,6 +25,10 @@ import {
prologueDeparturePages,
prologueOpeningPages
} from '../data/prologueVillage';
import {
shouldReviewThirdCampPreparationBeforeRetry,
thirdCampPreparationTargetBattleId
} from '../data/thirdCampPreparation';
import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting';
import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage';
import {
@@ -1321,6 +1325,16 @@ export class TitleScene extends Phaser.Scene {
});
return;
}
if (
shouldReviewThirdCampPreparationBeforeRetry(campaign)
) {
await this.navigateTo('CampScene', {
openSortiePrep: true,
retryBattleId: thirdCampPreparationTargetBattleId,
skipIntroStory: true
});
return;
}
if (campaign.pendingAftermathBattleId) {
const { battleScenarios } = await import('../data/battles');
const aftermathScenario = battleScenarios[campaign.pendingAftermathBattleId];

View File

@@ -111,6 +111,7 @@ export type ThirdCampPreparationSelectionSnapshot = {
sourceBattleId: typeof thirdCampPreparationSourceBattleId;
targetBattleId: typeof thirdCampPreparationTargetBattleId;
priorityId: ThirdCampPreparationPriorityId;
confirmed: boolean;
};
const campaignVictoryRewardCategoryIdSet = new Set<string>(campaignVictoryRewardCategoryIds);
@@ -1190,6 +1191,13 @@ export function setFirstBattleReport(report: FirstBattleReport) {
if (state.sortieRecommendationSelection?.battleId === battleId) {
delete state.sortieRecommendationSelection;
}
if (
reportClone.outcome === 'victory' &&
battleId === thirdCampPreparationTargetBattleId &&
state.thirdCampPreparationSelection?.targetBattleId === battleId
) {
delete state.thirdCampPreparationSelection;
}
state.latestBattleId = battleId;
delete state.activeCityStayId;
state.pendingAftermathBattleId = battleId as BattleScenarioId;
@@ -2035,6 +2043,16 @@ function normalizeThirdCampPreparationSelection(
value: unknown,
campaign: CampaignState
): ThirdCampPreparationSelectionSnapshot | undefined {
const targetSettlement =
campaign.battleHistory[thirdCampPreparationTargetBattleId];
if (
targetSettlement?.outcome === 'victory' ||
(campaign.firstBattleReport?.battleId ===
thirdCampPreparationTargetBattleId &&
campaign.firstBattleReport.outcome === 'victory')
) {
return undefined;
}
if (
!isPlainObject(value) ||
value.sourceBattleId !== thirdCampPreparationSourceBattleId ||
@@ -2053,7 +2071,8 @@ function normalizeThirdCampPreparationSelection(
? {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
priorityId: value.priorityId
priorityId: value.priorityId,
confirmed: value.confirmed === true
}
: undefined;
}

View File

@@ -14,11 +14,7 @@ import {
import {
getThirdCampPreparationPriority,
hasThirdCampPreparationSourceVictory,
resolveThirdCampPreparationMemory,
resolveThirdCampPreparationSelection,
thirdCampPreparationSourceBattleId,
thirdCampPreparationTargetBattleId,
type ThirdCampPreparationMemory,
type ThirdCampPreparationPriorityDefinition
} from '../data/thirdCampPreparation';
import {
@@ -55,7 +51,6 @@ export type ThirdCampExplorationActivityResult =
ok: true;
activity: ThirdCampExplorationActivityDefinition;
priority: ThirdCampPreparationPriorityDefinition;
memory: ThirdCampPreparationMemory;
campaign: CampaignState;
changed: boolean;
}
@@ -77,7 +72,6 @@ export type ThirdCampCompanionActivityResult =
dialogue: ThirdCampCompanionDialogueDefinition;
choice: ThirdCampCompanionDialogueChoice;
rewardExp: number;
memory: ThirdCampPreparationMemory;
campaign: CampaignState;
changed: boolean;
}
@@ -119,7 +113,10 @@ export function thirdCampExplorationProgress(
selected: selection?.priorityId === activity.id
})) ?? [],
selectedPriorityId: selection?.priorityId ?? null,
ready: selection?.selectable === true
selectionConfirmed: selection?.confirmed === true,
ready:
selection?.selectable === true &&
selection.confirmed === true
};
}
@@ -188,30 +185,23 @@ export function recordThirdCampExplorationActivity(
return { ok: false, reason: 'choice-required' };
}
const persisted = persistActivitySelection(
const persisted = persistActivityCompletion(
snapshot,
priorityId
);
if (!persisted) {
return { ok: false, reason: 'save-unavailable' };
}
const memory = selectedMemory(persisted, priorityId);
if (!memory) {
restoreCampaignSnapshot(snapshot);
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
activity,
priority,
memory,
campaign: persisted,
changed:
!snapshot.completedCampVisits.includes(
thirdCampExplorationActivityProgressId(priorityId)
) ||
snapshot.thirdCampPreparationSelection?.priorityId !== priorityId
)
};
}
@@ -258,7 +248,7 @@ export function completeThirdCampCompanionActivity(
restoreCampaignSnapshot(snapshot);
return { ok: false, reason: 'save-unavailable' };
}
const persisted = persistActivitySelection(
const persisted = persistActivityCompletion(
afterDialogue,
'companion',
snapshot
@@ -266,11 +256,6 @@ export function completeThirdCampCompanionActivity(
if (!persisted) {
return { ok: false, reason: 'save-unavailable' };
}
const memory = selectedMemory(persisted, 'companion');
if (!memory) {
restoreCampaignSnapshot(snapshot);
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
@@ -278,15 +263,12 @@ export function completeThirdCampCompanionActivity(
dialogue,
choice,
rewardExp,
memory,
campaign: persisted,
changed:
!dialogueAlreadyCompleted ||
!snapshot.completedCampVisits.includes(
thirdCampExplorationActivityProgressId('companion')
) ||
snapshot.thirdCampPreparationSelection?.priorityId !==
'companion'
)
};
}
@@ -325,7 +307,7 @@ function hasCompletedCompanionActivity(campaign: CampaignState) {
);
}
function persistActivitySelection(
function persistActivityCompletion(
current: CampaignState,
activityId: ThirdCampExplorationActivityId,
rollbackSnapshot: CampaignState = current
@@ -339,12 +321,7 @@ function persistActivitySelection(
updated = {
...updated,
acknowledgedVictoryRewardCategories:
acknowledgedRewardCategoryForActivity(updated, activityId),
thirdCampPreparationSelection: {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
priorityId: activityId
}
acknowledgedRewardCategoryForActivity(updated, activityId)
};
return persistCampaign(updated, rollbackSnapshot);
}
@@ -400,17 +377,6 @@ function withVisitMarkers(
};
}
function selectedMemory(
campaign: CampaignState,
activityId: ThirdCampExplorationActivityId
) {
const memory = resolveThirdCampPreparationMemory({
campaign,
battleId: thirdCampPreparationTargetBattleId
});
return memory?.priorityId === activityId ? memory : undefined;
}
function applyCompanionBondReward(
snapshot: CampaignState,
dialogue: ThirdCampCompanionDialogueDefinition,

View File

@@ -63,7 +63,10 @@ export function thirdCampPreparationProgress(
availability,
selectedPriorityId: selection?.priorityId ?? null,
selectedLabel: selection?.label ?? null,
ready: selection?.selectable === true
confirmed: selection?.confirmed === true,
ready:
selection?.selectable === true &&
selection.confirmed === true
};
}
@@ -94,7 +97,9 @@ export function setThirdCampPreparationPriority(
const previousPriorityId =
snapshot.thirdCampPreparationSelection?.priorityId;
if (previousPriorityId === priorityId) {
const previouslyConfirmed =
snapshot.thirdCampPreparationSelection?.confirmed === true;
if (previousPriorityId === priorityId && previouslyConfirmed) {
const memory = resolveThirdCampPreparationMemory({
campaign: snapshot,
battleId: thirdCampPreparationTargetBattleId
@@ -115,7 +120,8 @@ export function setThirdCampPreparationPriority(
thirdCampPreparationSelection: {
sourceBattleId: thirdCampPreparationSourceBattleId,
targetBattleId: thirdCampPreparationTargetBattleId,
priorityId
priorityId,
confirmed: true
}
};
const persisted = persistSelection(updated, snapshot);
@@ -173,9 +179,26 @@ export function clearThirdCampPreparationPriority():
function campaignSupportsThirdCampPreparation(
campaign: CampaignState
) {
return (
const isInitialPreparation =
campaign.step === 'third-camp' &&
campaign.latestBattleId === thirdCampPreparationSourceBattleId &&
campaign.latestBattleId ===
thirdCampPreparationSourceBattleId;
const targetSettlement =
campaign.battleHistory[
thirdCampPreparationTargetBattleId
];
const isTargetBattleRetry =
campaign.step === 'fourth-battle' &&
(
targetSettlement?.outcome === 'defeat' ||
(
campaign.firstBattleReport?.battleId ===
thirdCampPreparationTargetBattleId &&
campaign.firstBattleReport.outcome === 'defeat'
)
);
return (
(isInitialPreparation || isTargetBattleRetry) &&
hasThirdCampPreparationSourceVictory(campaign)
);
}