feat: add second-victory relief exploration

This commit is contained in:
2026-07-27 10:53:20 +09:00
parent e9fcd27611
commit f07a45c62b
24 changed files with 4583 additions and 282 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

View File

@@ -59,6 +59,7 @@ import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.m
import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3';
import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3';
import riverNightAmbienceUrl from '../../assets/audio/ambience/river-night-ambience.mp3';
import riversideVillageDayAmbienceUrl from '../../assets/audio/ambience/riverside-village-day-ambience.mp3';
export const musicTracks = {
'title-theme': titleThemeUrl,
@@ -77,6 +78,7 @@ export const ambienceTracks = {
'battle-fire': battleFireAmbienceUrl,
'campfire-ambience': campfireAmbienceUrl,
'river-night-ambience': riverNightAmbienceUrl,
'riverside-village-day-ambience': riversideVillageDayAmbienceUrl,
'mountain-wind-ambience': mountainWindAmbienceUrl,
'nanzhong-night-ambience': nanzhongNightAmbienceUrl
} as const;
@@ -93,6 +95,7 @@ export const ambienceTrackGainCompensation = {
'battle-fire': 0.72,
'campfire-ambience': 1,
'river-night-ambience': 1,
'riverside-village-day-ambience': 1,
'mountain-wind-ambience': 0.88,
'nanzhong-night-ambience': 1
} as const satisfies Record<keyof typeof ambienceTracks, number>;
@@ -120,6 +123,7 @@ export const ambienceTrackIntegratedLoudnessLufs = {
'battle-fire': -26.11,
'campfire-ambience': -24.58,
'river-night-ambience': -25.2,
'riverside-village-day-ambience': -24.03,
'mountain-wind-ambience': -24.9,
'nanzhong-night-ambience': -24.43
} as const satisfies Record<keyof typeof ambienceTracks, number>;

View File

@@ -0,0 +1,436 @@
import type { BattleScenarioId } from './battles';
import type {
ExplorationCharacterDirection,
ExplorationCharacterTextureKey
} from './explorationCharacterAssets';
export const secondBattleReliefVisitId =
'second-pursuit-aftermath-relief';
export const secondBattleReliefSourceBattleId =
'second-battle-yellow-turban-pursuit' satisfies BattleScenarioId;
export const secondBattleReliefTargetBattleId =
'third-battle-guangzong-road' satisfies BattleScenarioId;
export const secondBattleReliefChoiceIds = [
'restore-northern-village',
'trace-ferry-messenger'
] as const;
export type SecondBattleReliefChoiceId =
(typeof secondBattleReliefChoiceIds)[number];
export const secondBattleReliefObjectiveIds = [
'reassure-northern-village',
'question-ferry-witness',
'inspect-messenger-trail',
'set-guangzong-priority'
] as const;
export type SecondBattleReliefObjectiveId =
(typeof secondBattleReliefObjectiveIds)[number];
export const secondBattleReliefPrerequisiteObjectiveIds =
secondBattleReliefObjectiveIds.slice(0, -1) as readonly SecondBattleReliefObjectiveId[];
export const secondBattleReliefSourceObjectiveIds = {
village: 'village',
quick: 'quick'
} as const;
export type SecondBattleReliefActorId =
| 'guan-yu-village'
| 'north-ferry-boatman'
| 'zhang-fei-ferry'
| 'jian-yong-records';
export type SecondBattleReliefDialogueLine = {
readonly speaker: string;
readonly text: string;
readonly portraitKey?: string;
};
export type SecondBattleReliefActor = {
readonly id: SecondBattleReliefActorId;
readonly name: string;
readonly role: string;
readonly textureKey: ExplorationCharacterTextureKey;
readonly portraitKey?: string;
readonly x: number;
readonly y: number;
readonly direction: ExplorationCharacterDirection;
readonly dialogue: readonly SecondBattleReliefDialogueLine[];
readonly repeatDialogue: readonly SecondBattleReliefDialogueLine[];
};
export type SecondBattleReliefObjective = {
readonly id: SecondBattleReliefObjectiveId;
readonly label: string;
readonly detail: string;
readonly targetActorId: SecondBattleReliefActorId;
readonly prerequisiteObjectiveIds: readonly SecondBattleReliefObjectiveId[];
readonly completionLine: string;
};
type SecondBattleReliefEffectBase = {
readonly id:
| 'northern-village-supply-line'
| 'guangzong-messenger-route';
readonly targetBattleId: typeof secondBattleReliefTargetBattleId;
readonly label: string;
readonly summary: string;
readonly openingLines: readonly [string, string];
};
export type SecondBattleReliefThirdBattleEffect =
| (SecondBattleReliefEffectBase & {
readonly kind: 'village-supply-line';
readonly focusObjectiveId: 'liu-bei';
readonly recommendedUnitId: 'liu-bei';
readonly recommendedRole: 'support';
readonly carriedUsableId: 'bean';
readonly carriedUsableCount: 1;
})
| (SecondBattleReliefEffectBase & {
readonly kind: 'ferry-messenger-intel';
readonly focusObjectiveId: 'leader';
readonly recommendedUnitId: 'zhang-fei';
readonly recommendedRole: 'flank';
readonly trackedEnemyUnitId: 'guangzong-leader-ma-yuan';
readonly revealTrackedRoute: true;
});
export type SecondBattleReliefChoice = {
readonly id: SecondBattleReliefChoiceId;
readonly label: string;
readonly response: string;
readonly bondId: 'liu-bei__guan-yu' | 'liu-bei__jian-yong';
readonly bondExp: number;
readonly itemRewards: readonly [string];
readonly thirdBattleEffect: SecondBattleReliefThirdBattleEffect;
};
export type SecondBattleReliefExplorationDefinition = {
readonly id: typeof secondBattleReliefVisitId;
readonly title: string;
readonly location: string;
readonly sourceBattleId: typeof secondBattleReliefSourceBattleId;
readonly targetBattleId: typeof secondBattleReliefTargetBattleId;
readonly heading: string;
readonly subtitle: string;
readonly description: string;
readonly introLines: readonly SecondBattleReliefDialogueLine[];
readonly movementBounds: {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
};
readonly player: {
readonly textureKey: 'exploration-liu-bei';
readonly x: number;
readonly y: number;
readonly direction: ExplorationCharacterDirection;
};
readonly exit: {
readonly id: 'camp-exit';
readonly name: string;
readonly x: number;
readonly y: number;
};
readonly actors: readonly SecondBattleReliefActor[];
readonly objectives: readonly SecondBattleReliefObjective[];
readonly choices: readonly SecondBattleReliefChoice[];
};
export const secondBattleReliefExplorationDefinition = {
id: secondBattleReliefVisitId,
title: '북쪽 마을과 나루 수습',
location: '탁현 북쪽 마을 · 강가 나루',
sourceBattleId: secondBattleReliefSourceBattleId,
targetBattleId: secondBattleReliefTargetBattleId,
heading: '황건 잔당 추격전 뒤',
subtitle: '마을의 상처를 살피고 나루에 남은 광종 전령의 흔적을 확인합니다.',
description:
'전투에서 확보한 북쪽 마을은 아직 수습이 끝나지 않았습니다. 주민의 안전과 나루의 증언을 직접 확인한 뒤 광종 구원로에서 무엇을 먼저 지킬지 정합니다.',
introLines: [
{
speaker: '유비',
portraitKey: 'liu-bei-yellow-turban',
text: '싸움은 끝났으나 백성이 집으로 돌아가고 나루가 다시 열려야 비로소 이 길을 지켰다 할 수 있소.'
},
{
speaker: '간옹',
portraitKey: 'jian-yong-campaign',
text: '마을의 피해와 사공의 증언을 함께 적겠습니다. 그 기록이 광종으로 향할 다음 군령이 될 것입니다.'
}
],
movementBounds: { x: 42, y: 108, width: 1404, height: 814 },
player: {
textureKey: 'exploration-liu-bei',
x: 950,
y: 840,
direction: 'north'
},
exit: {
id: 'camp-exit',
name: '수습 현장을 떠나 군영으로 돌아가기',
x: 790,
y: 914
},
actors: [
{
id: 'guan-yu-village',
name: '관우',
role: '북쪽 마을 부상자 수습',
textureKey: 'exploration-guan-yu',
portraitKey: 'guan-yu-yellow-turban',
x: 1050,
y: 455,
direction: 'west',
dialogue: [
{
speaker: '관우',
portraitKey: 'guan-yu-yellow-turban',
text: '불은 껐지만 빼앗겼던 곡식과 다친 이들이 남았습니다. 전열을 거두기 전에 마을이 오늘 밤을 버틸 수 있게 해야 합니다.'
},
{
speaker: '유비',
portraitKey: 'liu-bei-yellow-turban',
text: '군량을 셈하기 전에 백성의 솥부터 살핍시다. 여기서 얻은 승리가 다시 원망이 되어서는 안 되오.'
}
],
repeatDialogue: [
{
speaker: '관우',
portraitKey: 'guan-yu-yellow-turban',
text: '마을 사람들의 귀환로는 열어 두었습니다. 이제 나루에서 놓친 흔적을 확인하십시오.'
}
]
},
{
id: 'north-ferry-boatman',
name: '나루 사공',
role: '전령 이동 목격자',
textureKey: 'exploration-zhuo-villager',
portraitKey: 'zhuo-villager-yellow-turban',
x: 360,
y: 470,
direction: 'east',
dialogue: [
{
speaker: '나루 사공',
text: '두령이 쓰러지기 전, 누런 깃발을 감춘 기병 하나가 말을 배에 싣고 건넜습니다. 광종 쪽 물길을 잘 아는 자였습니다.'
},
{
speaker: '유비',
portraitKey: 'liu-bei-yellow-turban',
text: '사람을 더 쫓게 하지는 않겠소. 어느 배를 썼고 어디에 말발굽이 남았는지만 알려 주시오.'
}
],
repeatDialogue: [
{
speaker: '나루 사공',
text: '동쪽 말뚝에 묶였던 작은 배였습니다. 장비 장군이 그 주변의 자국을 살피고 있습니다.'
}
]
},
{
id: 'zhang-fei-ferry',
name: '장비',
role: '나루와 말발굽 흔적 확인',
textureKey: 'exploration-zhang-fei',
portraitKey: 'zhang-fei-yellow-turban',
x: 540,
y: 675,
direction: 'east',
dialogue: [
{
speaker: '장비',
portraitKey: 'zhang-fei-yellow-turban',
text: '사공 말이 맞소. 배 밑의 진흙과 말발굽이 광종 길로 이어집니다. 지금 뒤쫓으면 흔적을 더 잡을 수 있소.'
},
{
speaker: '유비',
portraitKey: 'liu-bei-yellow-turban',
text: '흔적은 간옹에게 남기고 병사부터 모으시오. 빠른 추격과 마을 수습 중 무엇을 앞세울지는 함께 정하겠소.'
}
],
repeatDialogue: [
{
speaker: '장비',
portraitKey: 'zhang-fei-yellow-turban',
text: '나루의 흔적은 표시해 두었소. 간옹에게 가면 바로 다음 명을 정할 수 있소.'
}
]
},
{
id: 'jian-yong-records',
name: '간옹',
role: '수습 기록과 광종 진군안',
textureKey: 'exploration-jian-yong',
portraitKey: 'jian-yong-campaign',
x: 790,
y: 690,
direction: 'north',
dialogue: [
{
speaker: '간옹',
portraitKey: 'jian-yong-campaign',
text: '마을 보급선을 세우면 광종 길의 후방이 단단해집니다. 나루 흔적을 좇으면 전령 마원의 퇴로를 먼저 읽을 수 있습니다.'
},
{
speaker: '유비',
portraitKey: 'liu-bei-yellow-turban',
text: '둘 다 버릴 수는 없으나, 이번 출정에서 먼저 세울 기준은 하나여야 하오. 기록에 남겨 군 전체가 따르게 합시다.'
}
],
repeatDialogue: [
{
speaker: '간옹',
portraitKey: 'jian-yong-campaign',
text: '정한 방침은 광종 구원로 출진 장부에 옮겨 두었습니다.'
}
]
}
],
objectives: [
{
id: 'reassure-northern-village',
label: '북쪽 마을의 부상자와 귀환로 확인',
detail: '관우에게 다가가 전투 뒤에도 남은 마을의 피해를 확인합니다.',
targetActorId: 'guan-yu-village',
prerequisiteObjectiveIds: [],
completionLine: '북쪽 마을 주민이 돌아올 길과 오늘 밤의 식량을 확인했습니다.'
},
{
id: 'question-ferry-witness',
label: '나루 사공에게 전령의 이동 경로 질문',
detail: '마을 수습을 확인한 뒤 사공에게 빠져나간 기병과 배의 흔적을 묻습니다.',
targetActorId: 'north-ferry-boatman',
prerequisiteObjectiveIds: ['reassure-northern-village'],
completionLine: '광종 방향으로 빠져나간 황건 전령과 사용한 배를 확인했습니다.'
},
{
id: 'inspect-messenger-trail',
label: '장비와 나루의 말발굽 흔적 확인',
detail: '사공의 증언을 토대로 장비가 표시한 물가의 흔적을 직접 확인합니다.',
targetActorId: 'zhang-fei-ferry',
prerequisiteObjectiveIds: [
'reassure-northern-village',
'question-ferry-witness'
],
completionLine: '배 밑 진흙과 말발굽이 광종 구원로로 이어진다는 사실을 확인했습니다.'
},
{
id: 'set-guangzong-priority',
label: '간옹과 광종 구원로의 우선 방침 결정',
detail: '모은 기록을 간옹에게 전하고 마을 보급과 전령 추적 중 다음 출정의 우선 기준을 고릅니다.',
targetActorId: 'jian-yong-records',
prerequisiteObjectiveIds: [
'reassure-northern-village',
'question-ferry-witness',
'inspect-messenger-trail'
],
completionLine: '현장 수습과 광종 출정 방침을 하나의 장부로 남겼습니다.'
}
],
choices: [
{
id: 'restore-northern-village',
label: '북쪽 마을 보급선을 먼저 세운다',
response:
'관우가 마을의 귀환로를 지키고 간옹은 남은 곡식과 부상자 수를 장부에 적었습니다. 광종으로 향하는 군은 이 마을을 안전한 후방 보급점으로 삼습니다.',
bondId: 'liu-bei__guan-yu',
bondExp: 10,
itemRewards: ['콩 1'],
thirdBattleEffect: {
id: 'northern-village-supply-line',
kind: 'village-supply-line',
targetBattleId: secondBattleReliefTargetBattleId,
label: '북쪽 마을 보급선',
summary: '유비의 후방 지휘와 회복 여지를 우선하는 광종 구원로 준비',
focusObjectiveId: 'liu-bei',
recommendedUnitId: 'liu-bei',
recommendedRole: 'support',
carriedUsableId: 'bean',
carriedUsableCount: 1,
openingLines: [
'현장 수습 · 북쪽 마을 보급선이 광종 구원로의 후방과 이어집니다.',
'유비를 후방 지원 위치에 두고, 마을에서 챙긴 콩을 출전 장비에 배정해 전열의 회복 여지를 남기십시오.'
]
}
},
{
id: 'trace-ferry-messenger',
label: '나루의 전령 흔적을 먼저 좇는다',
response:
'장비가 말발굽을 표시하고 간옹은 사공의 증언을 지도에 옮겼습니다. 광종 구원로에서는 전령 마원의 퇴로가 가장 먼저 확인해야 할 길이 됩니다.',
bondId: 'liu-bei__jian-yong',
bondExp: 10,
itemRewards: ['상처약 1'],
thirdBattleEffect: {
id: 'guangzong-messenger-route',
kind: 'ferry-messenger-intel',
targetBattleId: secondBattleReliefTargetBattleId,
label: '나루 전령로',
summary: '전령 마원의 광종 방면 퇴로와 첫 이동을 우선 확인하는 준비',
focusObjectiveId: 'leader',
recommendedUnitId: 'zhang-fei',
recommendedRole: 'flank',
trackedEnemyUnitId: 'guangzong-leader-ma-yuan',
revealTrackedRoute: true,
openingLines: [
'현장 수습 · 나루의 말발굽과 배 흔적이 전령 마원의 광종 퇴로와 이어집니다.',
'장비를 좁은 길의 측면에 두고, 전령이 요새 뒤로 빠지기 전에 표시된 퇴로를 먼저 압박하십시오.'
]
}
}
]
} as const satisfies SecondBattleReliefExplorationDefinition;
export function getSecondBattleReliefChoice(
choiceId: string
): SecondBattleReliefChoice | undefined {
return secondBattleReliefExplorationDefinition.choices.find(
(choice) => choice.id === choiceId
);
}
export function getSecondBattleReliefObjective(
objectiveId: string
): SecondBattleReliefObjective | undefined {
return secondBattleReliefExplorationDefinition.objectives.find(
(objective) => objective.id === objectiveId
);
}
export function isSecondBattleReliefChoiceId(
value: unknown
): value is SecondBattleReliefChoiceId {
return (
typeof value === 'string' &&
(secondBattleReliefChoiceIds as readonly string[]).includes(value)
);
}
export function completedSecondBattleReliefPrerequisites(
completedObjectiveIds: readonly string[]
) {
const completed = new Set(completedObjectiveIds);
return secondBattleReliefPrerequisiteObjectiveIds.every((objectiveId) =>
completed.has(objectiveId)
);
}
export function secondBattleReliefObjectiveProgressId(
objectiveId: SecondBattleReliefObjectiveId
) {
return `${secondBattleReliefVisitId}:objective:${objectiveId}`;
}
export function completedSecondBattleReliefObjectiveIds(
completedCampVisitIds: readonly string[]
) {
const completed = new Set(completedCampVisitIds);
return secondBattleReliefObjectiveIds.filter((objectiveId) =>
completed.has(secondBattleReliefObjectiveProgressId(objectiveId))
);
}

View File

@@ -0,0 +1,246 @@
import type { BattleScenarioId } from './battles';
import {
firstPursuitScoutChoiceIds,
firstPursuitScoutVisitId,
isFirstPursuitScoutChoiceId,
type FirstPursuitScoutChoiceId
} from './firstPursuitScoutMemory';
import {
getSecondBattleReliefChoice,
isSecondBattleReliefChoiceId,
secondBattleReliefSourceBattleId,
secondBattleReliefSourceObjectiveIds,
secondBattleReliefTargetBattleId,
secondBattleReliefVisitId,
type SecondBattleReliefChoiceId,
type SecondBattleReliefThirdBattleEffect
} from './secondBattleReliefExploration';
import type {
CampaignBattleSettlement,
CampaignState
} from '../state/campaignState';
export type SecondBattleReliefCampaignState = Pick<
CampaignState,
'battleHistory' | 'completedCampVisits' | 'campVisitChoiceIds'
>;
export type SecondBattleReliefScoutReview = {
choiceId?: FirstPursuitScoutChoiceId;
focus: 'river' | 'village' | 'unrecorded';
outcome: 'confirmed' | 'missed' | 'unrecorded';
line: string;
};
export type SecondBattleReliefContext = {
sourceBattleId: typeof secondBattleReliefSourceBattleId;
sourceBattleTurn: number;
villageObjectiveAchieved: boolean;
quickObjectiveAchieved: boolean;
scoutReview: SecondBattleReliefScoutReview;
arrivalLine: string;
};
export type ResolvedSecondBattleReliefEffect =
SecondBattleReliefThirdBattleEffect & {
readiness: 'reinforced' | 'recovered';
sourceObjectiveAchieved: boolean;
};
export type SecondBattleReliefMemory = {
sourceBattleId: typeof secondBattleReliefSourceBattleId;
targetBattleId: typeof secondBattleReliefTargetBattleId;
visitId: typeof secondBattleReliefVisitId;
choiceId: SecondBattleReliefChoiceId;
choiceLabel: string;
sourceBattleTurn: number;
villageObjectiveAchieved: boolean;
quickObjectiveAchieved: boolean;
scoutReview: SecondBattleReliefScoutReview;
effect: ResolvedSecondBattleReliefEffect;
campSummaryLine: string;
openingLines: [string, string, string];
};
export function resolveSecondBattleReliefContext(
campaign: SecondBattleReliefCampaignState
): SecondBattleReliefContext | undefined {
const settlement =
campaign.battleHistory[secondBattleReliefSourceBattleId];
if (!validSecondBattleVictory(settlement)) {
return undefined;
}
const villageObjectiveAchieved = objectiveAchieved(
settlement,
secondBattleReliefSourceObjectiveIds.village
);
const quickObjectiveAchieved = objectiveAchieved(
settlement,
secondBattleReliefSourceObjectiveIds.quick
);
const scoutReview = resolveFirstPursuitScoutReview(
campaign,
villageObjectiveAchieved,
quickObjectiveAchieved
);
return {
sourceBattleId: secondBattleReliefSourceBattleId,
sourceBattleTurn: settlement.turnNumber,
villageObjectiveAchieved,
quickObjectiveAchieved,
scoutReview,
arrivalLine: villageObjectiveAchieved
? '전투에서 지킨 북쪽 마을은 주민이 돌아오기 시작했지만, 부상자와 빼앗긴 곡식은 아직 현장에 남아 있습니다.'
: '두령은 쓰러졌지만 북쪽 마을 어귀를 온전히 지키지 못했습니다. 떠나기 전에 주민의 귀환로와 남은 위협부터 수습해야 합니다.'
};
}
export function resolveSecondBattleReliefMemory(options: {
campaign: SecondBattleReliefCampaignState;
battleId: BattleScenarioId;
}): SecondBattleReliefMemory | undefined {
if (options.battleId !== secondBattleReliefTargetBattleId) {
return undefined;
}
const context = resolveSecondBattleReliefContext(options.campaign);
if (
!context ||
!options.campaign.completedCampVisits.includes(
secondBattleReliefVisitId
)
) {
return undefined;
}
const choiceId =
options.campaign.campVisitChoiceIds[secondBattleReliefVisitId];
if (!isSecondBattleReliefChoiceId(choiceId)) {
return undefined;
}
const choice = getSecondBattleReliefChoice(choiceId);
if (!choice) {
return undefined;
}
const sourceObjectiveAchieved =
choice.thirdBattleEffect.kind === 'village-supply-line'
? context.villageObjectiveAchieved
: context.quickObjectiveAchieved;
const readiness = sourceObjectiveAchieved
? 'reinforced'
: 'recovered';
const effect = cloneEffect(choice.thirdBattleEffect, {
readiness,
sourceObjectiveAchieved
});
const readinessLabel = readiness === 'reinforced'
? '전투 성과를 온전히 이어받음'
: '현장 수습으로 놓친 준비를 보완함';
const campSummaryLine =
`현장 수습 · ${effect.label} · ${readinessLabel}`;
return {
sourceBattleId: secondBattleReliefSourceBattleId,
targetBattleId: secondBattleReliefTargetBattleId,
visitId: secondBattleReliefVisitId,
choiceId,
choiceLabel: choice.label,
sourceBattleTurn: context.sourceBattleTurn,
villageObjectiveAchieved: context.villageObjectiveAchieved,
quickObjectiveAchieved: context.quickObjectiveAchieved,
scoutReview: { ...context.scoutReview },
effect,
campSummaryLine,
openingLines: [
campSummaryLine,
...effect.openingLines
]
};
}
function validSecondBattleVictory(
settlement?: CampaignBattleSettlement
): settlement is CampaignBattleSettlement {
return Boolean(
settlement &&
settlement.battleId === secondBattleReliefSourceBattleId &&
settlement.outcome === 'victory'
);
}
function objectiveAchieved(
settlement: CampaignBattleSettlement,
objectiveId: string
) {
return settlement.objectives.some(
(objective) =>
objective.id === objectiveId &&
objective.achieved === true &&
objective.status !== 'failed'
);
}
function resolveFirstPursuitScoutReview(
campaign: SecondBattleReliefCampaignState,
villageObjectiveAchieved: boolean,
quickObjectiveAchieved: boolean
): SecondBattleReliefScoutReview {
const choiceId =
campaign.completedCampVisits.includes(firstPursuitScoutVisitId)
? campaign.campVisitChoiceIds[firstPursuitScoutVisitId]
: undefined;
if (!isFirstPursuitScoutChoiceId(choiceId)) {
return {
focus: 'unrecorded',
outcome: 'unrecorded',
line: '이전 정찰 방침은 장부에 남아 있지 않습니다. 이번 현장 확인을 광종 진군의 첫 기록으로 삼습니다.'
};
}
if (choiceId === firstPursuitScoutChoiceIds[0]) {
return quickObjectiveAchieved
? {
choiceId,
focus: 'river',
outcome: 'confirmed',
line: '간옹이 먼저 표시한 강가 매복로를 빠르게 끊어 나루의 전령 흔적이 선명하게 남았습니다.'
}
: {
choiceId,
focus: 'river',
outcome: 'missed',
line: '강가 매복로는 찾았지만 결착이 늦어 전령의 흔적이 흐려졌습니다. 사공의 증언으로 빠진 길을 보완해야 합니다.'
};
}
return villageObjectiveAchieved
? {
choiceId,
focus: 'village',
outcome: 'confirmed',
line: '간옹이 먼저 표시한 마을 구호로를 지켜 주민의 귀환과 후방 보급을 바로 시작할 수 있습니다.'
}
: {
choiceId,
focus: 'village',
outcome: 'missed',
line: '마을 구호로를 먼저 표시했지만 전투에서 어귀를 온전히 지키지 못했습니다. 떠나기 전에 그 약속을 직접 수습해야 합니다.'
};
}
function cloneEffect(
effect: SecondBattleReliefThirdBattleEffect,
resolution: Pick<
ResolvedSecondBattleReliefEffect,
'readiness' | 'sourceObjectiveAchieved'
>
): ResolvedSecondBattleReliefEffect {
return {
...effect,
...resolution,
openingLines: [...effect.openingLines]
} as ResolvedSecondBattleReliefEffect;
}

View File

@@ -0,0 +1,145 @@
import Phaser from 'phaser';
export type ExplorationMovementDirection = Readonly<{
x: number;
y: number;
}>;
export type ExplorationInputControllerOptions = {
captureNavigationKeys?: boolean;
};
const interactionKeyCodes = [
Phaser.Input.Keyboard.KeyCodes.E,
Phaser.Input.Keyboard.KeyCodes.SPACE,
Phaser.Input.Keyboard.KeyCodes.ENTER
] as const;
const capturedNavigationKeyCodes = [
Phaser.Input.Keyboard.KeyCodes.UP,
Phaser.Input.Keyboard.KeyCodes.DOWN,
Phaser.Input.Keyboard.KeyCodes.LEFT,
Phaser.Input.Keyboard.KeyCodes.RIGHT,
Phaser.Input.Keyboard.KeyCodes.SPACE
] as const;
export class ExplorationInputController {
private readonly keyboard?: Phaser.Input.Keyboard.KeyboardPlugin;
private readonly cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;
private readonly moveKeys?: {
up: Phaser.Input.Keyboard.Key;
down: Phaser.Input.Keyboard.Key;
left: Phaser.Input.Keyboard.Key;
right: Phaser.Input.Keyboard.Key;
};
private readonly interactKeys: Phaser.Input.Keyboard.Key[] = [];
private readonly keyDownBindings: Array<{
key: Phaser.Input.Keyboard.Key;
callback: () => void;
}> = [];
private interactionQueued = false;
constructor(
scene: Phaser.Scene,
{ captureNavigationKeys = true }: ExplorationInputControllerOptions = {}
) {
const keyboard = scene.input.keyboard ?? undefined;
this.keyboard = keyboard;
if (!keyboard) {
return;
}
this.cursorKeys = keyboard.createCursorKeys();
this.moveKeys = {
up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
};
interactionKeyCodes.forEach((keyCode) => {
const key = keyboard.addKey(keyCode);
this.onKeyDown(key, () => {
this.interactionQueued = true;
});
this.interactKeys.push(key);
});
if (captureNavigationKeys) {
keyboard.addCapture([...capturedNavigationKeyCodes]);
}
}
bindKey(keyCode: number, onDown: () => void) {
const key = this.keyboard?.addKey(keyCode);
if (key) {
this.onKeyDown(key, onDown);
}
return this;
}
onMovementKeyDown(
callback: (direction: ExplorationMovementDirection) => void
) {
const bindings = [
{ key: this.cursorKeys?.up, direction: { x: 0, y: -1 } },
{ key: this.cursorKeys?.down, direction: { x: 0, y: 1 } },
{ key: this.cursorKeys?.left, direction: { x: -1, y: 0 } },
{ key: this.cursorKeys?.right, direction: { x: 1, y: 0 } },
{ key: this.moveKeys?.up, direction: { x: 0, y: -1 } },
{ key: this.moveKeys?.down, direction: { x: 0, y: 1 } },
{ key: this.moveKeys?.left, direction: { x: -1, y: 0 } },
{ key: this.moveKeys?.right, direction: { x: 1, y: 0 } }
] as const;
bindings.forEach(({ key, direction }) => {
if (key) {
this.onKeyDown(key, () => callback(direction));
}
});
return this;
}
destroy() {
this.keyDownBindings.forEach(({ key, callback }) => {
key.off('down', callback);
});
this.keyDownBindings.length = 0;
this.interactionQueued = false;
}
discardInteractionRequest() {
this.interactionQueued = false;
}
consumeInteractionRequest() {
const keyPressedThisFrame = this.interactKeys.some((key) =>
Phaser.Input.Keyboard.JustDown(key)
);
const requested = this.interactionQueued || keyPressedThisFrame;
this.interactionQueued = false;
return requested;
}
movementDirection() {
const vector = new Phaser.Math.Vector2();
if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) {
vector.x -= 1;
}
if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) {
vector.x += 1;
}
if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) {
vector.y -= 1;
}
if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) {
vector.y += 1;
}
return vector;
}
private onKeyDown(
key: Phaser.Input.Keyboard.Key,
callback: () => void
) {
key.on('down', callback);
this.keyDownBindings.push({ key, callback });
}
}

View File

@@ -128,6 +128,10 @@ import {
resolveFirstPursuitScoutMemory,
type FirstPursuitScoutMemory
} from '../data/firstPursuitScoutMemory';
import {
resolveSecondBattleReliefMemory,
type SecondBattleReliefMemory
} from '../data/secondBattleReliefMemory';
import {
evaluateSortieOrder,
evaluateSortieOrderProgress,
@@ -3898,6 +3902,7 @@ export class BattleScene extends Phaser.Scene {
private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot;
private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState };
private firstPursuitScoutMemory?: FirstPursuitScoutMemory;
private secondBattleReliefMemory?: SecondBattleReliefMemory;
private prologueVolunteerReassured = false;
private coreResonancePursuitAttempts = 0;
private coreResonancePursuitSuccesses = 0;
@@ -4010,6 +4015,10 @@ export class BattleScene extends Phaser.Scene {
campaign,
battleId: battleScenario.id
});
this.secondBattleReliefMemory = resolveSecondBattleReliefMemory({
campaign,
battleId: battleScenario.id
});
this.prologueVolunteerReassured = campaign.completedTutorialIds.includes(
prologueMilitiaCampCampaignTutorialIds.reassureVolunteer
);
@@ -8594,6 +8603,11 @@ export class BattleScene extends Phaser.Scene {
if (this.firstPursuitScoutMemory) {
return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`;
}
if (this.secondBattleReliefMemory) {
return this.secondBattleReliefMemory.effect.kind === 'village-supply-line'
? `${this.secondBattleReliefMemory.campSummaryLine}. 유비를 후방에 두면 마을에서 챙긴 콩 1개를 전투 중 사용할 수 있습니다.`
: `${this.secondBattleReliefMemory.campSummaryLine}. 표시된 전령 마원의 첫 움직임을 보고 장비의 측면 출발 위치를 조정하세요.`;
}
return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.';
}
@@ -8626,6 +8640,15 @@ export class BattleScene extends Phaser.Scene {
})();
return `${this.firstPursuitScoutMemory.campSummaryLine}\n${strategySummary}`;
}
if (this.secondBattleReliefMemory) {
const strategySummary = this.launchSortieRecommendation
? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`
: (() => {
const order = sortieOrderDefinition(this.launchSortieOrderId);
return `첫 군령 · ${order.label} · ${order.summary}`;
})();
return `${this.secondBattleReliefMemory.campSummaryLine}\n${strategySummary}`;
}
if (this.launchSortieRecommendation) {
return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`;
}
@@ -9677,7 +9700,11 @@ export class BattleScene extends Phaser.Scene {
this.firstBattlePreparation.eligible &&
this.firstBattlePreparation.scouting
) ||
this.firstPursuitScoutMemory?.deploymentIntentPreview === true
this.firstPursuitScoutMemory?.deploymentIntentPreview === true ||
(
this.secondBattleReliefMemory?.effect.kind === 'ferry-messenger-intel' &&
this.secondBattleReliefMemory.effect.revealTrackedRoute === true
)
) &&
!this.battleOutcome
) {
@@ -9690,6 +9717,31 @@ export class BattleScene extends Phaser.Scene {
});
}
private trackedDeploymentIntentEnemyId() {
if (
this.phase !== 'deployment' ||
(
this.firstBattlePreparation.eligible &&
this.firstBattlePreparation.scouting
) ||
this.firstPursuitScoutMemory?.deploymentIntentPreview === true ||
this.secondBattleReliefMemory?.effect.kind !==
'ferry-messenger-intel' ||
this.secondBattleReliefMemory.effect.revealTrackedRoute !== true
) {
return undefined;
}
return this.secondBattleReliefMemory.effect.trackedEnemyUnitId;
}
private enemyIntentForecastVisibleForUnit(unitId: string) {
if (!this.enemyIntentForecastEnabled()) {
return false;
}
const trackedEnemyId = this.trackedDeploymentIntentEnemyId();
return !trackedEnemyId || trackedEnemyId === unitId;
}
private enemyIntentCounterplayEnabled() {
return isEnemyIntentCounterplayAvailable({
phase: this.phase,
@@ -9765,7 +9817,10 @@ export class BattleScene extends Phaser.Scene {
return;
}
const plans = this.currentEnemyActionPlans();
const trackedEnemyId = this.trackedDeploymentIntentEnemyId();
const plans = this.currentEnemyActionPlans().filter(
(plan) => !trackedEnemyId || plan.enemy.id === trackedEnemyId
);
plans.forEach((plan) => this.enemyIntentForecasts.set(plan.enemy.id, plan));
plans
@@ -17295,6 +17350,17 @@ export class BattleScene extends Phaser.Scene {
guanYuStocks.set('salve', Math.min(2, (guanYuStocks.get('salve') ?? 0) + 1));
}
}
if (this.secondBattleReliefMemory?.effect.kind === 'village-supply-line') {
const { carriedUsableCount, carriedUsableId, recommendedUnitId } =
this.secondBattleReliefMemory.effect;
const carrierStocks = stocksByUnit.get(recommendedUnitId);
if (carrierStocks) {
carrierStocks.set(
carriedUsableId,
(carrierStocks.get(carriedUsableId) ?? 0) + carriedUsableCount
);
}
}
return stocksByUnit;
}
@@ -17867,6 +17933,7 @@ export class BattleScene extends Phaser.Scene {
? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`]
: []),
...(this.firstPursuitScoutMemory?.openingLines ?? []),
...(this.secondBattleReliefMemory?.openingLines ?? []),
...cityInformationLines,
enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined),
...(battleScenario.id === 'first-battle-zhuo-commandery'
@@ -18182,9 +18249,11 @@ export class BattleScene extends Phaser.Scene {
const { title, lines } = event;
const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines());
const firstPursuitScoutLines = new Set(this.firstPursuitScoutMemory?.openingLines ?? []);
const secondBattleReliefLines = new Set(this.secondBattleReliefMemory?.openingLines ?? []);
const doctrineLines = lines.filter((line) => (
completedCityInformationLines.has(line) ||
firstPursuitScoutLines.has(line) ||
secondBattleReliefLines.has(line) ||
line.startsWith('도원 공명') ||
line.startsWith('핵심 공명') ||
line.startsWith('공명 ') ||
@@ -19420,6 +19489,10 @@ export class BattleScene extends Phaser.Scene {
campaign: getCampaignState(),
battleId: battleScenario.id
});
this.secondBattleReliefMemory = resolveSecondBattleReliefMemory({
campaign: getCampaignState(),
battleId: battleScenario.id
});
this.refreshLaunchCamaraderieMemoryBonus(getCampaignState());
if (this.launchSortieResonanceBondId) {
this.restoreCoreResonancePursuitStats(state.coreResonanceStats);
@@ -27777,7 +27850,10 @@ export class BattleScene extends Phaser.Scene {
const terrainRule = getTerrainRule(terrain);
const terrainValue = this.terrainEffectText(terrain, unit, 'card');
if (unit.faction === 'enemy' && this.enemyIntentForecastEnabled()) {
if (
unit.faction === 'enemy' &&
this.enemyIntentForecastVisibleForUnit(unit.id)
) {
const plan = this.enemyIntentForecasts.get(unit.id) ?? this.buildEnemyActionPlan(unit);
effects.push({
icon: this.enemyActionPlanIcon(plan),
@@ -29020,6 +29096,70 @@ export class BattleScene extends Phaser.Scene {
};
}
private secondBattleReliefMemoryDebugState() {
const memory = this.secondBattleReliefMemory;
if (!memory) {
return {
active: false,
targetBattleId: 'third-battle-guangzong-road',
choiceId: null,
effectKind: null,
openingLines: []
};
}
const effect = memory.effect;
const villageSupply = effect.kind === 'village-supply-line'
? {
active: true,
carrierUnitId: effect.recommendedUnitId,
recommendedRole: effect.recommendedRole,
itemId: effect.carriedUsableId,
carriedCount: effect.carriedUsableCount,
battleStock: this.itemStock(effect.recommendedUnitId, effect.carriedUsableId)
}
: {
active: false
};
const ferryIntel = effect.kind === 'ferry-messenger-intel'
? {
active: true,
recommendedUnitId: effect.recommendedUnitId,
recommendedRole: effect.recommendedRole,
trackedEnemyUnitId: effect.trackedEnemyUnitId,
revealTrackedRoute: effect.revealTrackedRoute,
forecastVisibleDuringDeployment:
this.phase === 'deployment' &&
this.enemyIntentForecastEnabled() &&
this.enemyIntentForecasts.has(effect.trackedEnemyUnitId),
trackedIntent: this.enemyIntentForecasts.has(effect.trackedEnemyUnitId)
? this.enemyActionPlanDebugValue(this.enemyIntentForecasts.get(effect.trackedEnemyUnitId)!)
: null
}
: {
active: false
};
return {
active: true,
sourceBattleId: memory.sourceBattleId,
targetBattleId: memory.targetBattleId,
visitId: memory.visitId,
choiceId: memory.choiceId,
choiceLabel: memory.choiceLabel,
campSummaryLine: memory.campSummaryLine,
effectId: effect.id,
effectKind: effect.kind,
effectLabel: effect.label,
readiness: effect.readiness,
sourceObjectiveAchieved: effect.sourceObjectiveAchieved,
focusObjectiveId: effect.focusObjectiveId,
villageSupply,
ferryIntel,
openingLines: [...memory.openingLines]
};
}
private firstBattleVolunteerPromiseDebugState() {
const line = volunteerPromiseLineForBattle(
battleScenario.id,
@@ -29170,6 +29310,7 @@ export class BattleScene extends Phaser.Scene {
battleHud: this.battleHudDebugState(),
firstBattlePreparation: this.firstBattlePreparationDebugState(),
firstPursuitScoutMemory: this.firstPursuitScoutMemoryDebugState(),
secondBattleReliefMemory: this.secondBattleReliefMemoryDebugState(),
firstBattleNarrativeMemory: {
volunteerPromise: this.firstBattleVolunteerPromiseDebugState()
},

View File

@@ -39,6 +39,11 @@ import {
resolveFirstPursuitScoutMemory
} from '../data/firstPursuitScoutMemory';
import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit';
import {
secondBattleReliefExplorationDefinition,
secondBattleReliefSourceBattleId,
secondBattleReliefVisitId
} from '../data/secondBattleReliefExploration';
import {
findCityStayAfterBattle,
type CityEquipmentOffer,
@@ -231,6 +236,7 @@ import {
collectCityStayInformation,
purchaseCityStayEquipment
} from '../state/cityStayActions';
import { secondBattleReliefProgress } from '../state/secondBattleReliefActions';
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { palette } from '../ui/palette';
@@ -300,6 +306,11 @@ type CampVisitChoice = {
itemRewards?: string[];
};
const directExplorationVisitIds = new Set([
firstPursuitScoutVisitId,
secondBattleReliefVisitId
]);
type CampTabButtonView = {
tab: CampTab;
bg: Phaser.GameObjects.Rectangle;
@@ -8410,6 +8421,26 @@ const campVisits: CampVisitDefinition[] = [
itemRewards: [...choice.itemRewards]
}))
},
{
id: secondBattleReliefExplorationDefinition.id,
title: secondBattleReliefExplorationDefinition.title,
location: secondBattleReliefExplorationDefinition.location,
availableAfterBattleIds: [secondBattleReliefSourceBattleId],
availableDuringSteps: ['second-camp'],
description: secondBattleReliefExplorationDefinition.description,
lines: secondBattleReliefExplorationDefinition.introLines.map(
(line) => `${line.speaker}: ${line.text}`
),
choices: secondBattleReliefExplorationDefinition.choices.map(
(choice) => ({
id: choice.id,
label: choice.label,
response: choice.response,
bondExp: choice.bondExp,
itemRewards: [...choice.itemRewards]
})
)
},
{
id: 'jingzhou-scholar-rumors',
title: '형주 선비들의 모임',
@@ -11389,6 +11420,7 @@ export class CampScene extends Phaser.Scene {
private campDialogueBodyText?: Phaser.GameObjects.Text;
private selectedVisitId = campVisits[0].id;
private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle;
private secondBattleReliefExplorationButton?: Phaser.GameObjects.Rectangle;
private equipmentInventoryPage = 0;
private equipmentPanelBackground?: Phaser.GameObjects.Rectangle;
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
@@ -11514,6 +11546,7 @@ export class CampScene extends Phaser.Scene {
this.campDialogueChoiceButtons = {};
this.campDialogueBodyText = undefined;
this.firstPursuitExplorationButton = undefined;
this.secondBattleReliefExplorationButton = undefined;
this.dialogueObjects = [];
this.sortieObjects = [];
this.sortieComparisonPanelView = undefined;
@@ -11625,12 +11658,13 @@ export class CampScene extends Phaser.Scene {
} else if (this.activeTab === 'city') {
this.activeTab = 'status';
}
const pendingFirstPursuitScoutVisit = this.availableCampVisits().some(
const pendingDirectExplorationVisit = this.availableCampVisits().find(
(visit) =>
visit.id === firstPursuitScoutVisitId &&
directExplorationVisitIds.has(visit.id) &&
!this.completedCampVisits().includes(visit.id)
);
if (!cityStay && pendingFirstPursuitScoutVisit && this.activeTab === 'status') {
if (!cityStay && pendingDirectExplorationVisit) {
this.selectedVisitId = pendingDirectExplorationVisit.id;
this.activeTab = 'visit';
}
soundDirector.playSoundscape({
@@ -23361,20 +23395,32 @@ export class CampScene extends Phaser.Scene {
return;
}
if (visit.id === firstPursuitScoutVisitId) {
if (directExplorationVisitIds.has(visit.id)) {
const secondRelief = visit.id === secondBattleReliefVisitId;
const reliefProgress = secondRelief && this.campaign
? secondBattleReliefProgress(this.campaign)
: undefined;
const guide = this.track(this.add.rectangle(x + 18, y + height - 102, width - 36, 84, 0x151f2a, 0.96));
guide.setOrigin(0);
guide.setStrokeStyle(1, palette.blue, 0.62);
this.track(this.add.text(
x + 32,
y + height - 90,
'유비를 움직여 간옹에게 직접 말을 거세요.',
secondRelief
? reliefProgress?.choiceReady
? '현장 확인 완료 · 간옹과 광종 진군 방침을 정하세요.'
: reliefProgress && reliefProgress.completedObjectiveIds.length > 0
? `유비를 움직여 현장 수습을 이어가세요 · ${Math.min(3, reliefProgress.completedObjectiveIds.length)}/3 기록됨`
: '유비를 움직여 북쪽 마을과 나루를 직접 살피세요.'
: '유비를 움직여 간옹에게 직접 말을 거세요.',
this.textStyle(13, '#d4dce6', true)
));
this.track(this.add.text(
x + 32,
y + height - 68,
'WASD·방향키 이동 · E/Space 대화',
secondRelief
? '관우 → 사공 → 장비 → 간옹 · 진행 자동 저장'
: 'WASD·방향키 이동 · E/Space 대화',
this.textStyle(11, '#9fb0bf')
));
@@ -23389,12 +23435,22 @@ export class CampScene extends Phaser.Scene {
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
button.on('pointerdown', openExploration);
this.firstPursuitExplorationButton = button;
if (secondRelief) {
this.secondBattleReliefExplorationButton = button;
} else {
this.firstPursuitExplorationButton = button;
}
const label = this.track(this.add.text(
x + width / 2,
buttonY,
'정찰막으로 이동',
secondRelief
? reliefProgress?.choiceReady
? '광종 진군 방침 결정'
: reliefProgress && reliefProgress.completedObjectiveIds.length > 0
? '현장 수습 이어가기'
: '북쪽 마을·나루로 이동'
: '정찰막으로 이동',
this.textStyle(14, '#fff2b8', true)
));
label.setOrigin(0.5);
@@ -24942,6 +24998,7 @@ export class CampScene extends Phaser.Scene {
this.campDialogueChoiceButtons = {};
this.campDialogueBodyText = undefined;
this.firstPursuitExplorationButton = undefined;
this.secondBattleReliefExplorationButton = undefined;
this.campRosterLayout = undefined;
this.cityPanelBackground = undefined;
this.cityExploreButton = undefined;
@@ -25102,6 +25159,14 @@ export class CampScene extends Phaser.Scene {
(visit) => visit.id === firstPursuitScoutVisitId
);
const firstPursuitScoutMemory = this.firstPursuitScoutMemoryForNextSortie();
const secondBattleReliefVisit = this.availableCampVisits().find(
(visit) => visit.id === secondBattleReliefVisitId
);
const secondReliefProgress = this.campaign
? secondBattleReliefProgress(this.campaign)
: undefined;
const secondReliefChoiceId =
this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null;
const availableCampDialogues = this.availableCampDialogues();
const selectedCampDialogue = availableCampDialogues.find(
(dialogue) => dialogue.id === this.selectedDialogueId
@@ -25289,6 +25354,39 @@ export class CampScene extends Phaser.Scene {
openingLines: firstPursuitScoutMemory ? [...firstPursuitScoutMemory.openingLines] : [],
briefingSummaryBounds: this.sortieTextBoundsDebug(this.sortieBriefingScoutSummaryText)
},
secondBattleReliefExploration: {
visitId: secondBattleReliefVisitId,
sourceBattleId: secondBattleReliefSourceBattleId,
available: Boolean(secondBattleReliefVisit),
selected: this.selectedVisitId === secondBattleReliefVisitId,
completed: Boolean(secondReliefProgress?.completed),
mode: secondReliefProgress?.completed
? 'completed'
: secondBattleReliefVisit
? secondReliefProgress &&
secondReliefProgress.completedObjectiveIds.length > 0
? 'resume-walkable-relief'
: 'walkable-relief'
: 'unavailable',
explorationButtonBounds: this.sortieInteractiveObjectBoundsDebug(
this.secondBattleReliefExplorationButton
),
explorationInteractive: Boolean(
this.secondBattleReliefExplorationButton?.input?.enabled
),
completedObjectiveIds:
secondReliefProgress?.completedObjectiveIds ?? [],
nextObjectiveId: secondReliefProgress?.nextObjectiveId ?? null,
choiceReady: secondReliefProgress?.choiceReady ?? false,
choiceId: secondReliefChoiceId,
choices:
secondBattleReliefVisit?.choices.map((choice) => ({
id: choice.id,
label: choice.label,
response: choice.response,
rewardText: this.visitRewardText(choice)
})) ?? []
},
selectedDialogue: selectedCampDialogue
? {
id: selectedCampDialogue.id,

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ import {
releaseUnitBaseSheetTextures,
type UnitDirection
} from '../data/unitAssets';
import { ExplorationInputController } from '../input/ExplorationInputController';
import {
chooseCityStayResonance,
collectCityStayInformation,
@@ -137,19 +138,11 @@ export class CityStayScene extends Phaser.Scene {
private loadingOverlay?: Phaser.GameObjects.Container;
private transitionOverlay?: Phaser.GameObjects.Container;
private completionToast?: Phaser.GameObjects.Container;
private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;
private moveKeys?: {
up: Phaser.Input.Keyboard.Key;
down: Phaser.Input.Keyboard.Key;
left: Phaser.Input.Keyboard.Key;
right: Phaser.Input.Keyboard.Key;
};
private interactKeys: Phaser.Input.Keyboard.Key[] = [];
private explorationInput?: ExplorationInputController;
private moveTarget?: Phaser.Math.Vector2;
private ready = false;
private navigationPending = false;
private inputReadyAt = Number.POSITIVE_INFINITY;
private interactionQueued = false;
private stepIndex = 0;
private lastStepAt = 0;
private lastNotice = '';
@@ -191,6 +184,8 @@ export class CityStayScene extends Phaser.Scene {
this.ready = false;
this.navigationPending = false;
this.moveTarget = undefined;
this.explorationInput?.destroy();
this.explorationInput = undefined;
this.dialogueState = undefined;
releaseUnitBaseSheetTextures(this);
});
@@ -215,13 +210,13 @@ export class CityStayScene extends Phaser.Scene {
update(time: number, delta: number) {
if (!this.ready || this.navigationPending || time < this.inputReadyAt) {
this.interactionQueued = false;
this.explorationInput?.discardInteractionRequest();
return;
}
if (this.shopPanel || this.choicePanel) {
this.stopPlayerMovement();
this.interactionQueued = false;
this.explorationInput?.discardInteractionRequest();
return;
}
@@ -438,7 +433,7 @@ export class CityStayScene extends Phaser.Scene {
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
this.interactionQueued = false;
this.explorationInput = undefined;
this.stepIndex = 0;
this.lastStepAt = 0;
this.lastNotice = '';
@@ -934,36 +929,19 @@ export class CityStayScene extends Phaser.Scene {
}
private setupInput() {
const keyboard = this.input.keyboard;
if (keyboard) {
this.cursorKeys = keyboard.createCursorKeys();
this.moveKeys = {
up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
};
this.interactKeys = [
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE),
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
];
this.interactKeys.forEach((key) => {
key.on('down', () => {
this.interactionQueued = true;
});
});
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC).on('down', () => this.handleEscape());
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE).on('down', () => this.chooseDialogueByIndex(0));
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO).on('down', () => this.chooseDialogueByIndex(1));
keyboard.addCapture([
Phaser.Input.Keyboard.KeyCodes.UP,
Phaser.Input.Keyboard.KeyCodes.DOWN,
Phaser.Input.Keyboard.KeyCodes.LEFT,
Phaser.Input.Keyboard.KeyCodes.RIGHT,
Phaser.Input.Keyboard.KeyCodes.SPACE
]);
}
this.explorationInput = new ExplorationInputController(this)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.ESC,
() => this.handleEscape()
)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.ONE,
() => this.chooseDialogueByIndex(0)
)
.bindKey(
Phaser.Input.Keyboard.KeyCodes.TWO,
() => this.chooseDialogueByIndex(1)
);
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
@@ -1065,27 +1043,14 @@ export class CityStayScene extends Phaser.Scene {
}
private consumeInteractionRequest() {
const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key));
const requested = this.interactionQueued || keyPressedThisFrame;
this.interactionQueued = false;
return requested;
return this.explorationInput?.consumeInteractionRequest() ?? false;
}
private keyboardMovementDirection() {
const vector = new Phaser.Math.Vector2();
if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) {
vector.x -= 1;
}
if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) {
vector.x += 1;
}
if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) {
vector.y -= 1;
}
if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) {
vector.y += 1;
}
return vector;
return (
this.explorationInput?.movementDirection() ??
new Phaser.Math.Vector2()
);
}
private directionForVector(vector: Phaser.Math.Vector2): UnitDirection {

View File

@@ -0,0 +1,258 @@
import {
completedSecondBattleReliefObjectiveIds,
getSecondBattleReliefChoice,
getSecondBattleReliefObjective,
secondBattleReliefObjectiveIds,
secondBattleReliefObjectiveProgressId,
secondBattleReliefPrerequisiteObjectiveIds,
secondBattleReliefSourceBattleId,
secondBattleReliefTargetBattleId,
secondBattleReliefVisitId,
type SecondBattleReliefChoice,
type SecondBattleReliefObjective,
type SecondBattleReliefObjectiveId
} from '../data/secondBattleReliefExploration';
import {
resolveSecondBattleReliefMemory,
type SecondBattleReliefMemory
} from '../data/secondBattleReliefMemory';
import {
applyCampVisitReward,
getCampaignState,
setCampaignState,
type CampaignState
} from './campaignState';
type SecondBattleReliefFailure =
| 'invalid-campaign'
| 'invalid-objective'
| 'prerequisites-incomplete'
| 'choice-required'
| 'invalid-choice'
| 'already-completed'
| 'save-unavailable';
export type SecondBattleReliefObjectiveActionResult =
| {
ok: true;
objective: SecondBattleReliefObjective;
completedObjectiveIds: SecondBattleReliefObjectiveId[];
campaign: CampaignState;
}
| {
ok: false;
reason: SecondBattleReliefFailure;
};
export type SecondBattleReliefCompletionResult =
| {
ok: true;
choice: SecondBattleReliefChoice;
memory: SecondBattleReliefMemory;
campaign: CampaignState;
}
| {
ok: false;
reason: SecondBattleReliefFailure;
};
export function secondBattleReliefProgress(
campaign: CampaignState = getCampaignState()
) {
const completedObjectiveIds =
completedSecondBattleReliefObjectiveIds(
campaign.completedCampVisits
);
const completed = new Set(completedObjectiveIds);
const visitCompleted = campaign.completedCampVisits.includes(
secondBattleReliefVisitId
);
const nextObjective = visitCompleted
? undefined
: secondBattleReliefObjectiveIds
.map((objectiveId) => getSecondBattleReliefObjective(objectiveId))
.find((objective) =>
objective &&
!completed.has(objective.id) &&
objective.prerequisiteObjectiveIds.every((objectiveId) =>
completed.has(objectiveId)
)
);
return {
visitId: secondBattleReliefVisitId,
completed: visitCompleted,
completedObjectiveIds,
nextObjectiveId: nextObjective?.id ?? null,
choiceReady:
!visitCompleted &&
secondBattleReliefPrerequisiteObjectiveIds.every((objectiveId) =>
completed.has(objectiveId)
)
};
}
export function recordSecondBattleReliefObjective(
objectiveId: string
): SecondBattleReliefObjectiveActionResult {
let campaign: CampaignState;
try {
campaign = getCampaignState();
} catch {
return { ok: false, reason: 'save-unavailable' };
}
if (!campaignSupportsSecondBattleRelief(campaign)) {
return { ok: false, reason: 'invalid-campaign' };
}
if (
campaign.completedCampVisits.includes(secondBattleReliefVisitId)
) {
return { ok: false, reason: 'already-completed' };
}
const objective = getSecondBattleReliefObjective(objectiveId);
if (!objective) {
return { ok: false, reason: 'invalid-objective' };
}
if (objective.id === 'set-guangzong-priority') {
return { ok: false, reason: 'choice-required' };
}
const progress = secondBattleReliefProgress(campaign);
if (progress.completedObjectiveIds.includes(objective.id)) {
return {
ok: true,
objective,
completedObjectiveIds: progress.completedObjectiveIds,
campaign
};
}
if (
!objective.prerequisiteObjectiveIds.every((prerequisiteId) =>
progress.completedObjectiveIds.includes(prerequisiteId)
)
) {
return { ok: false, reason: 'prerequisites-incomplete' };
}
const updated = persistReliefProgress(
campaign,
secondBattleReliefObjectiveProgressId(objective.id),
{}
);
if (!updated) {
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
objective,
completedObjectiveIds:
completedSecondBattleReliefObjectiveIds(
updated.completedCampVisits
),
campaign: updated
};
}
export function completeSecondBattleReliefExploration(
choiceId: string
): SecondBattleReliefCompletionResult {
let campaign: CampaignState;
try {
campaign = getCampaignState();
} catch {
return { ok: false, reason: 'save-unavailable' };
}
if (!campaignSupportsSecondBattleRelief(campaign)) {
return { ok: false, reason: 'invalid-campaign' };
}
if (
campaign.completedCampVisits.includes(secondBattleReliefVisitId)
) {
return { ok: false, reason: 'already-completed' };
}
const choice = getSecondBattleReliefChoice(choiceId);
if (!choice) {
return { ok: false, reason: 'invalid-choice' };
}
const progress = secondBattleReliefProgress(campaign);
if (!progress.choiceReady) {
return { ok: false, reason: 'prerequisites-incomplete' };
}
const updated = persistReliefProgress(
campaign,
secondBattleReliefVisitId,
{
bondId: choice.bondId,
bondExp: choice.bondExp,
itemRewards: [...choice.itemRewards]
},
choice.id
);
if (!updated) {
return { ok: false, reason: 'save-unavailable' };
}
const memory = resolveSecondBattleReliefMemory({
campaign: updated,
battleId: secondBattleReliefTargetBattleId
});
if (!memory) {
restoreCampaignSnapshot(campaign);
return { ok: false, reason: 'save-unavailable' };
}
return {
ok: true,
choice,
memory,
campaign: updated
};
}
function campaignSupportsSecondBattleRelief(campaign: CampaignState) {
const settlement =
campaign.battleHistory[secondBattleReliefSourceBattleId];
const report = campaign.firstBattleReport;
return (
campaign.step === 'second-camp' &&
campaign.latestBattleId === secondBattleReliefSourceBattleId &&
settlement?.battleId === secondBattleReliefSourceBattleId &&
settlement.outcome === 'victory' &&
report?.battleId === secondBattleReliefSourceBattleId &&
report.outcome === 'victory'
);
}
function persistReliefProgress(
snapshot: CampaignState,
visitId: string,
reward: {
bondId?: string;
bondExp?: number;
itemRewards?: string[];
},
choiceId?: string
) {
try {
return applyCampVisitReward(
visitId,
reward,
choiceId
);
} catch {
restoreCampaignSnapshot(snapshot);
return undefined;
}
}
function restoreCampaignSnapshot(snapshot: CampaignState) {
try {
setCampaignState(snapshot);
} catch {
// setCampaignState restores the in-memory snapshot before persistence.
}
}