feat: add walkable Guangzong sortie camp
This commit is contained in:
BIN
src/assets/images/exploration/third-guangzong-sortie-camp.webp
Normal file
BIN
src/assets/images/exploration/third-guangzong-sortie-camp.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
@@ -757,24 +757,24 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
|
||||
export const fourthBattleScenario: BattleScenarioDefinition = {
|
||||
id: 'fourth-battle-guangzong-camp',
|
||||
title: '광종 본영전',
|
||||
victoryConditionLabel: '장각 격파',
|
||||
victoryConditionLabel: '장각 본대 지휘 와해',
|
||||
defeatConditionLabel: '유비 퇴각',
|
||||
openingObjectiveLines: [
|
||||
'황건 본영의 중심에는 장각이 있습니다. 장각을 격파하면 광종의 황건 세력은 무너집니다.',
|
||||
'노식 중랑장의 포위망이 광종 본영을 묶었습니다. 의용군은 동쪽 숲 선봉을 맡아 장각 본대의 지휘를 무너뜨려야 합니다.',
|
||||
'요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.',
|
||||
'20턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.'
|
||||
],
|
||||
tacticalGuide: {
|
||||
summary: '광종 본영 깊숙한 곳에서 장각을 직접 격파해 황건 토벌을 마무리하는 전투입니다.',
|
||||
route: '출진 지점 -> 본영 입구 방어선 -> 측면 숲길 정리 -> 장각 본진',
|
||||
focus: '관우는 요새 앞 전열을 고정하고, 장비는 숲길 궁병과 기병을 흔들며, 유비는 뒤에서 무리한 돌입을 막아야 합니다.',
|
||||
roles: ['관우: 본영 입구 전열', '장비: 측면 숲길 압박', '유비: 후방 지휘/마무리 판단'],
|
||||
summary: '관군의 광종 포위망 가운데 동쪽 숲 선봉을 맡아 장각 본대의 지휘를 무너뜨리는 전투입니다.',
|
||||
route: '동쪽 선봉 출진점 -> 본영 입구 방어선 -> 측면 숲길 정리 -> 장각 지휘진',
|
||||
focus: '관우는 동쪽 요새 앞 전열을 고정하고, 장비는 숲길 궁병과 기병을 흔들며, 유비는 포위망에서 홀로 튀어나가지 않도록 진격 속도를 맞춰야 합니다.',
|
||||
roles: ['관우: 동쪽 본영 입구 전열', '장비: 측면 숲길 압박', '유비: 포위망 연계/후방 지휘'],
|
||||
events: {
|
||||
'enemy-posture': {
|
||||
title: '광종 본영 진형',
|
||||
lines: [
|
||||
'장각은 요새와 숲길을 겹쳐 본영 입구를 막고 있습니다.',
|
||||
'중앙을 서두르기 전에 측면 궁병을 줄이면 장각에게 붙는 길이 훨씬 안정됩니다.'
|
||||
'장각 본대는 요새와 숲길을 겹쳐 노식의 포위망 가운데 동쪽 입구를 막고 있습니다.',
|
||||
'중앙을 서두르기 전에 측면 궁병을 줄이면 지휘진에 닿는 길이 훨씬 안정됩니다.'
|
||||
]
|
||||
},
|
||||
'cavalry-approach': {
|
||||
@@ -784,7 +784,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
|
||||
'leader-wavering': {
|
||||
title: '장각 본진 동요',
|
||||
lines: [
|
||||
'장각 주변 수비가 무너지면 황건 본영 전체가 흔들립니다.',
|
||||
'장각의 지휘병과 주변 수비가 무너지면 황건 본대 전체가 흔들립니다.',
|
||||
'유비를 안전하게 두고 관우와 장비를 붙여 끝을 보십시오.'
|
||||
]
|
||||
}
|
||||
@@ -794,7 +794,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
|
||||
sortieLimit: 3,
|
||||
requiredUnits: ['liu-bei'],
|
||||
recommendedUnits: [
|
||||
{ unitId: 'liu-bei', role: 'support', reason: '장각 격파까지 전선이 무너지지 않도록 후방 지휘가 필요합니다.' },
|
||||
{ unitId: 'liu-bei', role: 'support', reason: '장각 본대의 지휘가 무너질 때까지 동쪽 선봉과 관군 포위망을 잇는 후방 지휘가 필요합니다.' },
|
||||
{ unitId: 'guan-yu', role: 'front', reason: '본영 정면의 요새 방어선을 뚫는 핵심 전열입니다.' },
|
||||
{ unitId: 'zhang-fei', role: 'flank', reason: '측면 궁병과 기병을 흔들어 본영 진입 시간을 줄입니다.' }
|
||||
],
|
||||
@@ -821,7 +821,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
|
||||
{
|
||||
id: 'leader',
|
||||
kind: 'defeat-leader',
|
||||
label: '장각 격파',
|
||||
label: '장각 본대 지휘 와해',
|
||||
rewardGold: 420,
|
||||
unitId: 'guangzong-main-leader-zhang-jue'
|
||||
},
|
||||
|
||||
@@ -633,7 +633,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
|
||||
bgm: 'militia-theme',
|
||||
chapter: '광종 본영',
|
||||
background: 'story-yellow-pursuit',
|
||||
text: '전령의 길이 끊기자 황건 본대는 광종 들판에 진을 굳혔다. 노란 깃발이 겹겹이 서고, 장각의 이름이 병사들 사이에 낮게 울렸다.'
|
||||
text: '전령의 길이 끊기자 노식 중랑장의 관군은 광종 포위망을 좁혔다. 황건 본대는 들판에 진을 굳혔고, 의용군에는 동쪽 숲 선봉을 맡으라는 군령이 내려왔다.'
|
||||
},
|
||||
{
|
||||
id: 'fourth-liu-bei-resolve',
|
||||
@@ -642,7 +642,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
|
||||
background: 'story-yellow-pursuit',
|
||||
speaker: '유비',
|
||||
portrait: 'liuBei',
|
||||
text: '장각을 치는 일은 한 사람을 꺾는 일이 아니다. 두려움에 몰린 백성들이 다시 길을 찾게 하는 일이다.'
|
||||
text: '스승님의 큰 포위망을 흐트러뜨리지 않고 우리에게 맡겨진 동쪽 길을 열겠다. 한 사람의 목보다, 황건 본대가 더는 백성을 몰아세우지 못하게 지휘를 끊는 것이 먼저다.'
|
||||
},
|
||||
{
|
||||
id: 'fourth-guan-yu-line',
|
||||
@@ -651,7 +651,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
|
||||
background: 'story-yellow-pursuit',
|
||||
speaker: '관우',
|
||||
portrait: 'guanYu',
|
||||
text: '적은 요새와 숲 사이에 궁병을 숨겼습니다. 전열을 흐트러뜨리지 않고 본영의 깃발까지 밀고 들어가야 합니다.'
|
||||
text: '적은 요새와 숲 사이에 궁병을 숨겼습니다. 동쪽 전열을 흐트러뜨리지 않고 지휘 깃발까지 밀고 들어가면 포위망 전체가 움직일 수 있습니다.'
|
||||
},
|
||||
{
|
||||
id: 'fourth-zhang-fei-roar',
|
||||
@@ -660,7 +660,7 @@ export const fourthBattleIntroPages: StoryPage[] = [
|
||||
background: 'story-yellow-pursuit',
|
||||
speaker: '장비',
|
||||
portrait: 'zhangFei',
|
||||
text: '좋소! 광종의 누런 깃발을 오늘 모두 뽑아 버립시다. 뒤돌아볼 틈 없이 밀고 들어가겠소!'
|
||||
text: '좋소! 형님 깃발이 보이는 거리에서 동쪽 숲길부터 열겠소. 우리가 지휘진을 흔들면 관군의 포위망도 한꺼번에 조여들 것이오!'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -670,7 +670,7 @@ export const fourthBattleVictoryPages: StoryPage[] = [
|
||||
bgm: 'militia-theme',
|
||||
chapter: '황건적 토벌',
|
||||
background: 'story-yellow-pursuit',
|
||||
text: '광종의 들판에서 황건의 본영이 무너졌다. 피난민들은 오래 닫았던 문을 열고, 쓰러진 깃발 너머로 관군과 의용군을 바라보았다.'
|
||||
text: '의용군이 동쪽 지휘선을 끊자 관군의 포위망이 맞물렸고 광종의 황건 본대는 와해되었다. 피난민들은 오래 닫았던 문을 열고, 쓰러진 깃발 너머의 관군과 의용군을 바라보았다.'
|
||||
},
|
||||
{
|
||||
id: 'fourth-victory-liu-bei',
|
||||
@@ -681,12 +681,19 @@ export const fourthBattleVictoryPages: StoryPage[] = [
|
||||
portrait: 'liuBei',
|
||||
text: '난은 잠시 가라앉았지만, 천하가 바로 선 것은 아니다. 오늘의 공이 교만이 되지 않도록 마음을 다잡자.'
|
||||
},
|
||||
{
|
||||
id: 'fourth-victory-lu-zhi-recalled',
|
||||
bgm: 'battle-prep',
|
||||
chapter: '뒤집힌 군령',
|
||||
background: 'story-yellow-pursuit',
|
||||
text: '승전 장부의 먹이 마르기도 전에 조정의 사자가 도착했다. 포위망을 이끌던 노식은 낙양으로 소환되고, 남은 군권에는 동탁의 이름이 올랐다. 세 형제는 전장의 승리만으로 어지러운 조정을 바로잡을 수 없음을 처음 실감했다.'
|
||||
},
|
||||
{
|
||||
id: 'fourth-victory-next-era',
|
||||
bgm: 'battle-prep',
|
||||
chapter: '새로운 혼란',
|
||||
background: 'story-yellow-pursuit',
|
||||
text: '황건의 불길이 잦아들 무렵, 조정 안에서는 또 다른 그림자가 자라났다. 동탁의 폭정에 맞서 제후들이 격문을 돌리기 시작했고, 세 형제에게도 그 소식이 닿았다.'
|
||||
text: '황건의 불길이 잦아든 뒤에도 동탁의 권세는 멈추지 않았다. 마침내 제후들이 폭정에 맞설 격문을 돌리기 시작했고, 광종에서 그 이름을 먼저 들었던 세 형제에게도 소식이 닿았다.'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
583
src/game/data/thirdCampExploration.ts
Normal file
583
src/game/data/thirdCampExploration.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
import type {
|
||||
ExplorationCharacterDirection,
|
||||
ExplorationCharacterTextureKey
|
||||
} from './explorationCharacterAssets';
|
||||
import {
|
||||
resolveThirdBattlePriorityReturnDialogue,
|
||||
thirdBattleReturnSourceBattleId,
|
||||
type ThirdBattlePriorityReturnDialogue,
|
||||
type ThirdBattleReturnCampaignState,
|
||||
type ThirdBattleReturnTargetDialogueId
|
||||
} from './thirdBattleReturnDialogue';
|
||||
|
||||
export const thirdCampExplorationVisitId =
|
||||
'third-guangzong-sortie-camp';
|
||||
export const thirdCampExplorationSourceBattleId =
|
||||
thirdBattleReturnSourceBattleId;
|
||||
export const thirdCampExplorationTargetBattleId =
|
||||
'fourth-battle-guangzong-camp';
|
||||
export const thirdCampExplorationActivityIds = [
|
||||
'information',
|
||||
'equipment',
|
||||
'companion'
|
||||
] as const;
|
||||
|
||||
export type ThirdCampExplorationActivityId =
|
||||
(typeof thirdCampExplorationActivityIds)[number];
|
||||
|
||||
export type ThirdCampExplorationDialogueLine = {
|
||||
readonly speaker: string;
|
||||
readonly text: string;
|
||||
readonly portraitKey?: string;
|
||||
};
|
||||
|
||||
export type ThirdCampCompanionDialogueChoice = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly response: string;
|
||||
readonly rewardExp: number;
|
||||
};
|
||||
|
||||
export type ThirdCampCompanionDialogueDefinition = {
|
||||
readonly id: ThirdBattleReturnTargetDialogueId;
|
||||
readonly title: string;
|
||||
readonly availableAfterBattleIds: readonly [
|
||||
typeof thirdCampExplorationSourceBattleId
|
||||
];
|
||||
readonly unitIds: readonly [string, string];
|
||||
readonly bondId: string;
|
||||
readonly rewardExp: number;
|
||||
readonly lines: readonly string[];
|
||||
readonly choices: readonly ThirdCampCompanionDialogueChoice[];
|
||||
};
|
||||
|
||||
export type ThirdCampExplorationActorId =
|
||||
| 'zou-jing-operations'
|
||||
| 'quartermaster-equipment'
|
||||
| 'companion-guan-yu'
|
||||
| 'companion-zhang-fei';
|
||||
|
||||
export type ThirdCampExplorationActor = {
|
||||
readonly id: ThirdCampExplorationActorId;
|
||||
readonly name: string;
|
||||
readonly role: string;
|
||||
readonly textureKey: ExplorationCharacterTextureKey;
|
||||
readonly portraitKey?: string;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly direction: ExplorationCharacterDirection;
|
||||
readonly kind: 'activity' | 'companion';
|
||||
readonly activityId: ThirdCampExplorationActivityId;
|
||||
readonly dialogue: readonly ThirdCampExplorationDialogueLine[];
|
||||
readonly repeatDialogue: readonly ThirdCampExplorationDialogueLine[];
|
||||
readonly completedDialogue: readonly ThirdCampExplorationDialogueLine[];
|
||||
};
|
||||
|
||||
export type ThirdCampExplorationActivityDefinition = {
|
||||
readonly id: ThirdCampExplorationActivityId;
|
||||
readonly label: string;
|
||||
readonly shortLabel: string;
|
||||
readonly detail: string;
|
||||
readonly targetActorIds: readonly ThirdCampExplorationActorId[];
|
||||
readonly completionLine: string;
|
||||
readonly worldCue: string;
|
||||
};
|
||||
|
||||
export type ThirdCampExplorationDefinition = {
|
||||
readonly id: typeof thirdCampExplorationVisitId;
|
||||
readonly title: string;
|
||||
readonly location: string;
|
||||
readonly sourceBattleId: typeof thirdCampExplorationSourceBattleId;
|
||||
readonly targetBattleId: typeof thirdCampExplorationTargetBattleId;
|
||||
readonly heading: string;
|
||||
readonly subtitle: string;
|
||||
readonly description: string;
|
||||
readonly background: {
|
||||
readonly textureKey: 'third-guangzong-sortie-camp-background';
|
||||
readonly source: 'original-guangzong-forward-camp';
|
||||
};
|
||||
readonly introLines: readonly ThirdCampExplorationDialogueLine[];
|
||||
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 ThirdCampExplorationActor[];
|
||||
readonly activities: readonly ThirdCampExplorationActivityDefinition[];
|
||||
readonly resultDialogue: ThirdBattlePriorityReturnDialogue;
|
||||
readonly companionDialogue: ThirdCampCompanionDialogueDefinition;
|
||||
};
|
||||
|
||||
export const thirdCampCompanionDialogues = [
|
||||
{
|
||||
id: 'liu-guan-after-guangzong-road',
|
||||
title: '광종 길목의 침묵',
|
||||
availableAfterBattleIds: [thirdCampExplorationSourceBattleId],
|
||||
unitIds: ['liu-bei', 'guan-yu'],
|
||||
bondId: 'liu-bei__guan-yu',
|
||||
rewardExp: 16,
|
||||
lines: [
|
||||
'유비: 광종으로 가는 길은 좁고, 적은 지형을 믿고 버티고 있소.',
|
||||
'관우: 험한 길에서는 말보다 마음이 먼저 흔들립니다. 제가 앞에서 병사들의 숨을 고르겠습니다.',
|
||||
'유비: 운장이 앞을 잡아 준다면, 나도 뒤에서 군의 뜻을 잃지 않게 하겠소.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'lead-narrow-road',
|
||||
label: '좁은 길의 선봉을 맡긴다',
|
||||
response: '관우는 험지를 뚫는 임무를 조용히 받아들였다.',
|
||||
rewardExp: 7
|
||||
},
|
||||
{
|
||||
id: 'keep-discipline',
|
||||
label: '군율 유지를 당부한다',
|
||||
response: '관우는 승리보다 흐트러지지 않는 행군이 더 중요하다고 답했다.',
|
||||
rewardExp: 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'liu-zhang-after-guangzong-road',
|
||||
title: '강가의 큰소리',
|
||||
availableAfterBattleIds: [thirdCampExplorationSourceBattleId],
|
||||
unitIds: ['liu-bei', 'zhang-fei'],
|
||||
bondId: 'liu-bei__zhang-fei',
|
||||
rewardExp: 16,
|
||||
lines: [
|
||||
'장비: 강가 길목이 답답해서 창을 휘두를 맛이 덜했습니다.',
|
||||
'유비: 답답한 싸움에서도 익덕이 버텨 주니 병사들이 물러서지 않았소.',
|
||||
'장비: 다음엔 답답한 길도 제가 먼저 웃으며 열어 보이겠습니다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'laugh-through',
|
||||
label: '호방함으로 사기를 올린다',
|
||||
response: '장비의 큰 웃음이 군영의 무거운 공기를 풀었다.',
|
||||
rewardExp: 6
|
||||
},
|
||||
{
|
||||
id: 'guard-bridgehead',
|
||||
label: '교두보 수비를 맡긴다',
|
||||
response: '장비는 한 발도 물러서지 않는 수비 역시 선봉의 일이라며 고개를 끄덕였다.',
|
||||
rewardExp: 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'guan-zhang-after-guangzong-road',
|
||||
title: '험지의 호흡',
|
||||
availableAfterBattleIds: [thirdCampExplorationSourceBattleId],
|
||||
unitIds: ['guan-yu', 'zhang-fei'],
|
||||
bondId: 'guan-yu__zhang-fei',
|
||||
rewardExp: 14,
|
||||
lines: [
|
||||
'관우: 숲과 언덕에서는 한 걸음 늦어도 전열 전체가 늦어진다.',
|
||||
'장비: 그럴 땐 형님이 길을 재고, 제가 길을 넓히면 되지 않겠습니까?',
|
||||
'관우: 좋은 말이다. 칼이 재고 창이 연다면 험지도 길이 된다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'measure-and-break',
|
||||
label: '관우가 재고 장비가 뚫는다',
|
||||
response: '두 사람은 험지 돌파의 역할을 명확히 나누었다.',
|
||||
rewardExp: 7
|
||||
},
|
||||
{
|
||||
id: 'watch-each-other',
|
||||
label: '서로의 후방을 살핀다',
|
||||
response: '성급한 돌파보다 빈틈을 지우는 호흡이 깊어졌다.',
|
||||
rewardExp: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
] as const satisfies readonly ThirdCampCompanionDialogueDefinition[];
|
||||
|
||||
export function isThirdCampExplorationActivityId(
|
||||
value: unknown
|
||||
): value is ThirdCampExplorationActivityId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(thirdCampExplorationActivityIds as readonly string[]).includes(
|
||||
value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function thirdCampExplorationActivityProgressId(
|
||||
activityId: ThirdCampExplorationActivityId
|
||||
) {
|
||||
return `${thirdCampExplorationVisitId}:activity:${activityId}`;
|
||||
}
|
||||
|
||||
export function completedThirdCampExplorationActivityIds(
|
||||
completedCampVisitIds: readonly string[]
|
||||
) {
|
||||
const completed = new Set(completedCampVisitIds);
|
||||
return thirdCampExplorationActivityIds.filter((activityId) =>
|
||||
completed.has(thirdCampExplorationActivityProgressId(activityId))
|
||||
);
|
||||
}
|
||||
|
||||
export function getThirdCampCompanionDialogue(
|
||||
dialogueId: unknown
|
||||
): ThirdCampCompanionDialogueDefinition | undefined {
|
||||
if (typeof dialogueId !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return thirdCampCompanionDialogues.find(
|
||||
(dialogue) => dialogue.id === dialogueId
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveThirdCampCompanionDialogue(
|
||||
campaign?: ThirdBattleReturnCampaignState | null
|
||||
) {
|
||||
const resultDialogue =
|
||||
resolveThirdBattlePriorityReturnDialogue(campaign);
|
||||
return resultDialogue
|
||||
? getThirdCampCompanionDialogue(resultDialogue.targetDialogueId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveThirdCampExplorationDefinition(
|
||||
campaign?: ThirdBattleReturnCampaignState | null
|
||||
): ThirdCampExplorationDefinition | undefined {
|
||||
const resultDialogue =
|
||||
resolveThirdBattlePriorityReturnDialogue(campaign);
|
||||
const companionDialogue = resultDialogue
|
||||
? getThirdCampCompanionDialogue(resultDialogue.targetDialogueId)
|
||||
: undefined;
|
||||
if (!resultDialogue || !companionDialogue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const companionActors = companionActorsFor(resultDialogue);
|
||||
const equipment = equipmentCopyFor(resultDialogue);
|
||||
const actors: ThirdCampExplorationActor[] = [
|
||||
{
|
||||
id: 'zou-jing-operations',
|
||||
name: '추정',
|
||||
role: '동쪽 삼림 선봉 작전',
|
||||
textureKey: 'exploration-zou-jing',
|
||||
portraitKey: 'zouJing',
|
||||
x: 505,
|
||||
y: 405,
|
||||
direction: 'east',
|
||||
kind: 'activity',
|
||||
activityId: 'information',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '추정',
|
||||
portraitKey: 'zouJing',
|
||||
text: '노식 중랑장의 주력은 광종을 넓게 포위한다. 우리는 동쪽 삼림을 지나 본영 바깥 선봉과 퇴로 표식을 먼저 확인한다. 적의 첫 움직임을 읽되 본대와 이어진 길은 비워 두게.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liuBei',
|
||||
text: '본대보다 앞서되 고립되지는 않겠습니다. 적 선봉의 움직임과 우리 병사들이 돌아올 길을 한 작전판에 표시하겠습니다.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '추정',
|
||||
portraitKey: 'zouJing',
|
||||
text: '본영 동쪽 선봉의 이동 표식은 작전판에 남아 있다. 출전 준비에서 다른 방침을 골라도 확인한 정보는 사라지지 않는다.'
|
||||
}
|
||||
],
|
||||
completedDialogue: [
|
||||
{
|
||||
speaker: '추정',
|
||||
portraitKey: 'zouJing',
|
||||
text: '적 선봉 하나의 첫 움직임을 표시했다. 이 정보를 우선하면 배치 때 의도와 표식을 먼저 볼 수 있다.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'quartermaster-equipment',
|
||||
name: '의용군 군수관',
|
||||
role: '동림 선봉 병기·방호구',
|
||||
textureKey: 'exploration-zhuo-quartermaster',
|
||||
portraitKey: 'zhuoQuartermaster',
|
||||
x: 360,
|
||||
y: 745,
|
||||
direction: 'east',
|
||||
kind: 'activity',
|
||||
activityId: 'equipment',
|
||||
dialogue: equipment.dialogue,
|
||||
repeatDialogue: equipment.repeatDialogue,
|
||||
completedDialogue: equipment.completedDialogue
|
||||
},
|
||||
...companionActors
|
||||
];
|
||||
|
||||
return {
|
||||
id: thirdCampExplorationVisitId,
|
||||
title: '광종 동림 출진 야영지',
|
||||
location: '광종 동쪽 삼림 · 노식군 외곽 포위망',
|
||||
sourceBattleId: thirdCampExplorationSourceBattleId,
|
||||
targetBattleId: thirdCampExplorationTargetBattleId,
|
||||
heading: '본영을 향한 세 가지 준비',
|
||||
subtitle:
|
||||
'노식군의 큰 포위망 안에서 의용군은 동쪽 삼림 선봉을 맡습니다. 필요한 준비만 확인하고 언제든 출진할 수 있습니다.',
|
||||
description:
|
||||
'작전판에서 적 선봉의 움직임을 읽고, 보급 수레에서 방호구를 손보거나, 먼저 돌아온 동료와 지난 전투를 되짚을 수 있습니다. 세 활동은 모두 선택 사항이며 어느 순서로 해도 됩니다. 활동을 마칠 때마다 그 준비가 자동으로 선택되고, 마지막으로 확인한 준비 하나만 다음 전투의 초반 효과가 됩니다.',
|
||||
background: {
|
||||
textureKey: 'third-guangzong-sortie-camp-background',
|
||||
source: 'original-guangzong-forward-camp'
|
||||
},
|
||||
introLines: [
|
||||
{
|
||||
speaker: '추정',
|
||||
portraitKey: 'zouJing',
|
||||
text: '노식 중랑장의 본대가 광종 포위망을 좁히는 동안, 그대들의 의용군은 동쪽 삼림으로 나가 적 선봉과 본영 퇴로를 끊는다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liuBei',
|
||||
text: '병사들이 무엇을 준비했는지 직접 살핀 뒤 출전하겠습니다. 서두르되 준비를 의무처럼 붙잡지는 않겠습니다.'
|
||||
}
|
||||
],
|
||||
movementBounds: { x: 42, y: 108, width: 1404, height: 814 },
|
||||
player: {
|
||||
textureKey: 'exploration-liu-bei',
|
||||
x: 930,
|
||||
y: 850,
|
||||
direction: 'north'
|
||||
},
|
||||
exit: {
|
||||
id: 'camp-exit',
|
||||
name: '군영 출진 장부로 돌아가기',
|
||||
x: 790,
|
||||
y: 914
|
||||
},
|
||||
actors,
|
||||
activities: [
|
||||
{
|
||||
id: 'information',
|
||||
label: '추정과 본영 선봉 작전 확인',
|
||||
shortLabel: '본영 정보',
|
||||
detail:
|
||||
'노식군의 포위망과 의용군의 동쪽 삼림 진입로를 겹쳐 적 선봉의 첫 움직임을 표시합니다.',
|
||||
targetActorIds: ['zou-jing-operations'],
|
||||
completionLine:
|
||||
'작전판에 적 선봉의 의도와 동쪽 삼림 퇴로가 함께 표시되었습니다.',
|
||||
worldCue: '작전판에 적 선봉·동림 퇴로 표식'
|
||||
},
|
||||
{
|
||||
id: 'equipment',
|
||||
label: '군수관과 선봉 방호구 정비',
|
||||
shortLabel: '장비 정비',
|
||||
detail: equipment.activityDetail,
|
||||
targetActorIds: ['quartermaster-equipment'],
|
||||
completionLine:
|
||||
'보급 수레에 수선한 방호구와 응급 천이 출전 순서대로 실렸습니다.',
|
||||
worldCue: '보급 수레에 방패·응급 천 적재'
|
||||
},
|
||||
{
|
||||
id: 'companion',
|
||||
label: `${companionDialogue.title} 되짚기`,
|
||||
shortLabel: '동료 공명',
|
||||
detail:
|
||||
'지난 전투 결과에 가장 먼저 반응한 동료와 다음 돌파의 호흡을 정합니다.',
|
||||
targetActorIds: companionActors.map((actor) => actor.id),
|
||||
completionLine:
|
||||
'대화에서 정한 짧은 신호가 다음 본영전의 초반 협공 약속으로 남았습니다.',
|
||||
worldCue: '동료와 협공 신호 공유'
|
||||
}
|
||||
],
|
||||
resultDialogue,
|
||||
companionDialogue
|
||||
};
|
||||
}
|
||||
|
||||
function companionActorsFor(
|
||||
resultDialogue: ThirdBattlePriorityReturnDialogue
|
||||
): ThirdCampExplorationActor[] {
|
||||
const dialogue = resultDialogue.lines.map(resultDialogueLine);
|
||||
const completedDialogue = [
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liuBei',
|
||||
text: '방금 맞춘 호흡은 출전 대열에도 전해 두었소. 다른 준비를 살펴도 이 약속은 남아 있을 것이오.'
|
||||
}
|
||||
];
|
||||
|
||||
if (resultDialogue.variantId === 'fort-recovery') {
|
||||
return [
|
||||
companionActor({
|
||||
id: 'companion-guan-yu',
|
||||
name: '관우',
|
||||
role: '강가 요새 회복책',
|
||||
textureKey: 'exploration-guan-yu',
|
||||
portraitKey: 'guanYu',
|
||||
x: 1075,
|
||||
y: 510,
|
||||
direction: 'west',
|
||||
dialogue,
|
||||
completedDialogue
|
||||
})
|
||||
];
|
||||
}
|
||||
if (resultDialogue.variantId === 'tempo-recovery') {
|
||||
return [
|
||||
companionActor({
|
||||
id: 'companion-zhang-fei',
|
||||
name: '장비',
|
||||
role: '본영 돌파 호흡',
|
||||
textureKey: 'exploration-zhang-fei',
|
||||
portraitKey: 'zhangFei',
|
||||
x: 1115,
|
||||
y: 560,
|
||||
direction: 'west',
|
||||
dialogue,
|
||||
completedDialogue
|
||||
})
|
||||
];
|
||||
}
|
||||
return [
|
||||
companionActor({
|
||||
id: 'companion-guan-yu',
|
||||
name: '관우',
|
||||
role: '동림 길목 판단',
|
||||
textureKey: 'exploration-guan-yu',
|
||||
portraitKey: 'guanYu',
|
||||
x: 1035,
|
||||
y: 500,
|
||||
direction: 'east',
|
||||
dialogue,
|
||||
completedDialogue
|
||||
}),
|
||||
companionActor({
|
||||
id: 'companion-zhang-fei',
|
||||
name: '장비',
|
||||
role: '동림 돌파 선봉',
|
||||
textureKey: 'exploration-zhang-fei',
|
||||
portraitKey: 'zhangFei',
|
||||
x: 1240,
|
||||
y: 535,
|
||||
direction: 'west',
|
||||
dialogue,
|
||||
completedDialogue
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function companionActor(
|
||||
options: Omit<
|
||||
ThirdCampExplorationActor,
|
||||
'kind' | 'activityId' | 'repeatDialogue'
|
||||
>
|
||||
): ThirdCampExplorationActor {
|
||||
return {
|
||||
...options,
|
||||
kind: 'companion',
|
||||
activityId: 'companion',
|
||||
repeatDialogue: options.completedDialogue
|
||||
};
|
||||
}
|
||||
|
||||
function resultDialogueLine(line: string): ThirdCampExplorationDialogueLine {
|
||||
const separator = line.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
return { speaker: '동료', text: line };
|
||||
}
|
||||
const speaker = line.slice(0, separator).trim();
|
||||
return {
|
||||
speaker,
|
||||
text: line.slice(separator + 1).trim(),
|
||||
...(portraitKeyForSpeaker(speaker)
|
||||
? { portraitKey: portraitKeyForSpeaker(speaker) }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function portraitKeyForSpeaker(speaker: string) {
|
||||
const portraitKeys: Readonly<Record<string, string>> = {
|
||||
유비: 'liuBei',
|
||||
관우: 'guanYu',
|
||||
장비: 'zhangFei',
|
||||
추정: 'zouJing',
|
||||
'의용군 군수관': 'zhuoQuartermaster'
|
||||
};
|
||||
return portraitKeys[speaker];
|
||||
}
|
||||
|
||||
function equipmentCopyFor(
|
||||
resultDialogue: ThirdBattlePriorityReturnDialogue
|
||||
) {
|
||||
if (resultDialogue.fortAchieved) {
|
||||
return {
|
||||
activityDetail:
|
||||
'강가 요새에서 회수한 온전한 방패와 마른 결속끈을 동쪽 삼림 선봉의 첫 피격에 맞춰 배분합니다.',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
portraitKey: 'zhuoQuartermaster',
|
||||
text: '강가 요새를 지킨 덕에 마른 결속끈과 온전한 방패를 회수했습니다. 동쪽 숲의 낮은 가지에 걸리지 않게 방패끈부터 줄이겠습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liuBei',
|
||||
text: '첫 충돌을 버틴 뒤 곧바로 대열을 되찾을 수 있게 앞줄부터 나누어 주시오. 무거운 물자는 본대 수레에 남깁시다.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
portraitKey: 'zhuoQuartermaster',
|
||||
text: '요새에서 회수한 방패와 응급 천은 선봉 순서대로 실었습니다. 다른 준비를 골라도 정비한 장비는 그대로 남습니다.'
|
||||
}
|
||||
],
|
||||
completedDialogue: [
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
portraitKey: 'zhuoQuartermaster',
|
||||
text: '첫 피격을 한 번 흘릴 방호구를 앞줄에 배분했습니다.'
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
activityDetail:
|
||||
'강가 요새를 남긴 퇴각에서 젖고 상한 방호구를 골라 수선하고, 동쪽 삼림에서 첫 충돌을 버틸 최소 장비를 마련합니다.',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
portraitKey: 'zhuoQuartermaster',
|
||||
text: '강가 요새를 두고 물러오며 방패끈이 젖고 갑옷 몇 벌이 상했습니다. 멀쩡한 조각을 골라 앞줄 방호구부터 다시 묶겠습니다.'
|
||||
},
|
||||
{
|
||||
speaker: '유비',
|
||||
portraitKey: 'liuBei',
|
||||
text: '새 장비처럼 보이게 하는 것보다 첫 충돌에서 끊어지지 않는 것이 중요하오. 수선한 장비와 응급 천을 같은 수레에 실어 주시오.'
|
||||
}
|
||||
],
|
||||
repeatDialogue: [
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
portraitKey: 'zhuoQuartermaster',
|
||||
text: '젖은 끈은 모두 갈고 상한 방호구는 겹쳐 묶었습니다. 다른 준비를 골라도 수선한 장비는 그대로 남습니다.'
|
||||
}
|
||||
],
|
||||
completedDialogue: [
|
||||
{
|
||||
speaker: '의용군 군수관',
|
||||
portraitKey: 'zhuoQuartermaster',
|
||||
text: '수선한 방호구가 첫 충돌을 한 번 버틸 만큼은 갖춰졌습니다.'
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import {
|
||||
resolveThirdBattlePriorityReturnDialogue,
|
||||
thirdBattleReturnSourceBattleId
|
||||
} from './thirdBattleReturnDialogue';
|
||||
import {
|
||||
completedThirdCampExplorationActivityIds,
|
||||
thirdCampExplorationVisitId,
|
||||
type ThirdCampExplorationActivityId
|
||||
} from './thirdCampExploration';
|
||||
|
||||
export const thirdCampPreparationSourceBattleId =
|
||||
thirdBattleReturnSourceBattleId;
|
||||
@@ -135,6 +140,24 @@ export const thirdCampPreparationPriorities: readonly
|
||||
] as const;
|
||||
|
||||
export type ThirdCampPreparationEvidence =
|
||||
| {
|
||||
kind: 'exploration-activity';
|
||||
battleId: typeof thirdCampPreparationSourceBattleId;
|
||||
visitId: typeof thirdCampExplorationVisitId;
|
||||
activityId: Exclude<
|
||||
ThirdCampExplorationActivityId,
|
||||
'companion'
|
||||
>;
|
||||
}
|
||||
| {
|
||||
kind: 'exploration-activity';
|
||||
battleId: typeof thirdCampPreparationSourceBattleId;
|
||||
visitId: typeof thirdCampExplorationVisitId;
|
||||
activityId: 'companion';
|
||||
dialogueId: string;
|
||||
bondId: string;
|
||||
unitIds: [string, string];
|
||||
}
|
||||
| {
|
||||
kind: 'victory-reward-category';
|
||||
battleId: typeof thirdCampPreparationSourceBattleId;
|
||||
@@ -208,6 +231,7 @@ export type ThirdCampPreparationCampaignState = {
|
||||
>;
|
||||
firstBattleReport?: ThirdCampPreparationBattleRecord | null;
|
||||
completedCampDialogues?: readonly unknown[];
|
||||
completedCampVisits?: readonly unknown[];
|
||||
thirdCampPreparationSelection?: {
|
||||
sourceBattleId?: unknown;
|
||||
targetBattleId?: unknown;
|
||||
@@ -288,15 +312,19 @@ export function resolveThirdCampPreparationAvailability(
|
||||
}));
|
||||
}
|
||||
|
||||
const informationEvidence = acknowledgedRewardEvidence(
|
||||
campaign,
|
||||
'unlocks'
|
||||
);
|
||||
const equipmentEvidence = acknowledgedRewardEvidence(
|
||||
campaign,
|
||||
'equipment'
|
||||
);
|
||||
const companionEvidence = completedCompanionEvidence(campaign);
|
||||
const informationEvidence =
|
||||
completedExplorationEvidence(campaign, 'information') ??
|
||||
selectedLegacyRewardEvidence(campaign, 'information', 'unlocks');
|
||||
const equipmentEvidence =
|
||||
completedExplorationEvidence(campaign, 'equipment') ??
|
||||
selectedLegacyRewardEvidence(
|
||||
campaign,
|
||||
'equipment',
|
||||
'equipment'
|
||||
);
|
||||
const companionEvidence =
|
||||
completedExplorationEvidence(campaign, 'companion') ??
|
||||
completedCompanionEvidence(campaign);
|
||||
const evidenceByPriority: Partial<
|
||||
Record<ThirdCampPreparationPriorityId, ThirdCampPreparationEvidence>
|
||||
> = {
|
||||
@@ -515,6 +543,70 @@ function acknowledgedRewardEvidence(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function selectedLegacyRewardEvidence(
|
||||
campaign: ThirdCampPreparationCampaignState | null | undefined,
|
||||
priorityId: Extract<
|
||||
ThirdCampPreparationPriorityId,
|
||||
'information' | 'equipment'
|
||||
>,
|
||||
category: 'unlocks' | 'equipment'
|
||||
) {
|
||||
const selection = campaign?.thirdCampPreparationSelection;
|
||||
if (
|
||||
selection?.sourceBattleId !==
|
||||
thirdCampPreparationSourceBattleId ||
|
||||
selection.targetBattleId !==
|
||||
thirdCampPreparationTargetBattleId ||
|
||||
selection.priorityId !== priorityId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return acknowledgedRewardEvidence(campaign, category);
|
||||
}
|
||||
|
||||
function completedExplorationEvidence(
|
||||
campaign: ThirdCampPreparationCampaignState | null | undefined,
|
||||
activityId: ThirdCampExplorationActivityId
|
||||
): Extract<
|
||||
ThirdCampPreparationEvidence,
|
||||
{ kind: 'exploration-activity' }
|
||||
> | undefined {
|
||||
const completedActivityIds =
|
||||
completedThirdCampExplorationActivityIds(
|
||||
arrayValue(campaign?.completedCampVisits).filter(
|
||||
(visitId): visitId is string => typeof visitId === 'string'
|
||||
)
|
||||
);
|
||||
if (!completedActivityIds.includes(activityId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (activityId !== 'companion') {
|
||||
return {
|
||||
kind: 'exploration-activity',
|
||||
battleId: thirdCampPreparationSourceBattleId,
|
||||
visitId: thirdCampExplorationVisitId,
|
||||
activityId
|
||||
};
|
||||
}
|
||||
|
||||
const dialogue =
|
||||
resolveThirdBattlePriorityReturnDialogue(campaign);
|
||||
const bond = dialogue
|
||||
? companionBondByDialogueId[dialogue.targetDialogueId]
|
||||
: undefined;
|
||||
return dialogue && bond
|
||||
? {
|
||||
kind: 'exploration-activity',
|
||||
battleId: thirdCampPreparationSourceBattleId,
|
||||
visitId: thirdCampExplorationVisitId,
|
||||
activityId: 'companion',
|
||||
dialogueId: dialogue.targetDialogueId,
|
||||
bondId: bond.bondId,
|
||||
unitIds: [...bond.unitIds]
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function completedCompanionEvidence(
|
||||
campaign?: ThirdCampPreparationCampaignState | null
|
||||
): Extract<
|
||||
@@ -548,21 +640,33 @@ function resolveEffect(
|
||||
): ResolvedThirdCampPreparationEffect | undefined {
|
||||
if (
|
||||
effect.kind === 'marked-vanguard-intel' &&
|
||||
evidence.kind === 'victory-reward-category' &&
|
||||
evidence.category === 'unlocks'
|
||||
(
|
||||
(evidence.kind === 'exploration-activity' &&
|
||||
evidence.activityId === 'information') ||
|
||||
(evidence.kind === 'victory-reward-category' &&
|
||||
evidence.category === 'unlocks')
|
||||
)
|
||||
) {
|
||||
return { ...effect };
|
||||
}
|
||||
if (
|
||||
effect.kind === 'prepared-equipment-guard' &&
|
||||
evidence.kind === 'victory-reward-category' &&
|
||||
evidence.category === 'equipment'
|
||||
(
|
||||
(evidence.kind === 'exploration-activity' &&
|
||||
evidence.activityId === 'equipment') ||
|
||||
(evidence.kind === 'victory-reward-category' &&
|
||||
evidence.category === 'equipment')
|
||||
)
|
||||
) {
|
||||
return { ...effect };
|
||||
}
|
||||
if (
|
||||
effect.kind === 'return-bond-opening' &&
|
||||
evidence.kind === 'priority-return-dialogue'
|
||||
(
|
||||
evidence.kind === 'priority-return-dialogue' ||
|
||||
(evidence.kind === 'exploration-activity' &&
|
||||
evidence.activityId === 'companion')
|
||||
)
|
||||
) {
|
||||
return {
|
||||
...effect,
|
||||
@@ -604,7 +708,11 @@ function clonePriority(
|
||||
function cloneEvidence(
|
||||
evidence: ThirdCampPreparationEvidence
|
||||
): ThirdCampPreparationEvidence {
|
||||
if (evidence.kind === 'priority-return-dialogue') {
|
||||
if (
|
||||
evidence.kind === 'priority-return-dialogue' ||
|
||||
(evidence.kind === 'exploration-activity' &&
|
||||
evidence.activityId === 'companion')
|
||||
) {
|
||||
return {
|
||||
...evidence,
|
||||
unitIds: [...evidence.unitIds]
|
||||
|
||||
@@ -49,6 +49,11 @@ import {
|
||||
thirdBattleReturnCampStep,
|
||||
thirdBattleReturnSourceBattleId
|
||||
} from '../data/thirdBattleReturnDialogue';
|
||||
import {
|
||||
resolveThirdCampExplorationDefinition,
|
||||
thirdCampCompanionDialogues,
|
||||
thirdCampExplorationVisitId
|
||||
} from '../data/thirdCampExploration';
|
||||
import {
|
||||
thirdCampPreparationSourceBattleId,
|
||||
thirdCampPreparationTargetBattleId,
|
||||
@@ -256,9 +261,11 @@ import {
|
||||
} from '../state/cityStayActions';
|
||||
import { secondBattleReliefProgress } from '../state/secondBattleReliefActions';
|
||||
import {
|
||||
clearThirdCampPreparationPriority,
|
||||
setThirdCampPreparationPriority,
|
||||
thirdCampPreparationProgress
|
||||
} from '../state/thirdCampPreparationActions';
|
||||
import { thirdCampExplorationProgress } from '../state/thirdCampExplorationActions';
|
||||
import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys';
|
||||
import { releaseTextureSource } from '../render/loaderLifecycle';
|
||||
import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
@@ -331,7 +338,8 @@ type CampVisitChoice = {
|
||||
|
||||
const directExplorationVisitIds = new Set([
|
||||
firstPursuitScoutVisitId,
|
||||
secondBattleReliefVisitId
|
||||
secondBattleReliefVisitId,
|
||||
thirdCampExplorationVisitId
|
||||
]);
|
||||
|
||||
type CampTabButtonView = {
|
||||
@@ -1327,7 +1335,7 @@ const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefiniti
|
||||
'fourth-battle-guangzong-camp': {
|
||||
maxUnits: 3,
|
||||
recommended: [
|
||||
{ unitId: 'liu-bei', reason: '장각 격파까지 전선을 지휘합니다.' },
|
||||
{ unitId: 'liu-bei', reason: '장각 본대의 지휘를 무너뜨릴 때까지 전선을 이끕니다.' },
|
||||
{ unitId: 'guan-yu', reason: '요새 방어선을 뚫는 전열 핵심입니다.' },
|
||||
{ unitId: 'zhang-fei', reason: '본영 측면을 흔드는 압박 담당입니다.' }
|
||||
],
|
||||
@@ -3124,87 +3132,13 @@ const campDialogues: CampDialogue[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'liu-guan-after-guangzong-road',
|
||||
title: '광종 길목의 침묵',
|
||||
availableAfterBattleIds: [campBattleIds.third],
|
||||
unitIds: ['liu-bei', 'guan-yu'],
|
||||
bondId: 'liu-bei__guan-yu',
|
||||
rewardExp: 16,
|
||||
lines: [
|
||||
'유비: 광종으로 가는 길은 좁고, 적은 지형을 믿고 버티고 있소.',
|
||||
'관우: 험한 길에서는 말보다 마음이 먼저 흔들립니다. 제가 앞에서 병사들의 숨을 고르겠습니다.',
|
||||
'유비: 운장이 앞을 잡아 준다면, 나도 뒤에서 군의 뜻을 잃지 않게 하겠소.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'lead-narrow-road',
|
||||
label: '좁은 길의 선봉을 맡긴다',
|
||||
response: '관우는 험지를 뚫는 임무를 조용히 받아들였다.',
|
||||
rewardExp: 7
|
||||
},
|
||||
{
|
||||
id: 'keep-discipline',
|
||||
label: '군율 유지를 당부한다',
|
||||
response: '관우는 승리보다 흐트러지지 않는 행군이 더 중요하다고 답했다.',
|
||||
rewardExp: 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'liu-zhang-after-guangzong-road',
|
||||
title: '강가의 큰소리',
|
||||
availableAfterBattleIds: [campBattleIds.third],
|
||||
unitIds: ['liu-bei', 'zhang-fei'],
|
||||
bondId: 'liu-bei__zhang-fei',
|
||||
rewardExp: 16,
|
||||
lines: [
|
||||
'장비: 강가 길목이 답답해서 창을 휘두를 맛이 덜했습니다.',
|
||||
'유비: 답답한 싸움에서도 익덕이 버텨 주니 병사들이 물러서지 않았소.',
|
||||
'장비: 다음엔 답답한 길도 제가 먼저 웃으며 열어 보이겠습니다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'laugh-through',
|
||||
label: '호방함으로 사기를 올린다',
|
||||
response: '장비의 큰 웃음이 군영의 무거운 공기를 풀었다.',
|
||||
rewardExp: 6
|
||||
},
|
||||
{
|
||||
id: 'guard-bridgehead',
|
||||
label: '교두보 수비를 맡긴다',
|
||||
response: '장비는 한 발도 물러서지 않는 수비 역시 선봉의 일이라며 고개를 끄덕였다.',
|
||||
rewardExp: 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'guan-zhang-after-guangzong-road',
|
||||
title: '험지의 호흡',
|
||||
availableAfterBattleIds: [campBattleIds.third],
|
||||
unitIds: ['guan-yu', 'zhang-fei'],
|
||||
bondId: 'guan-yu__zhang-fei',
|
||||
rewardExp: 14,
|
||||
lines: [
|
||||
'관우: 숲과 언덕에서는 한 걸음 늦어도 전열 전체가 늦어진다.',
|
||||
'장비: 그럴 땐 형님이 길을 재고, 제가 길을 넓히면 되지 않겠습니까?',
|
||||
'관우: 좋은 말이다. 칼이 재고 창이 연다면 험지도 길이 된다.'
|
||||
],
|
||||
choices: [
|
||||
{
|
||||
id: 'measure-and-break',
|
||||
label: '관우가 재고 장비가 뚫는다',
|
||||
response: '두 사람은 험지 돌파의 역할을 명확히 나누었다.',
|
||||
rewardExp: 7
|
||||
},
|
||||
{
|
||||
id: 'watch-each-other',
|
||||
label: '서로의 후방을 살핀다',
|
||||
response: '성급한 돌파보다 빈틈을 지우는 호흡이 깊어졌다.',
|
||||
rewardExp: 5
|
||||
}
|
||||
]
|
||||
},
|
||||
...thirdCampCompanionDialogues.map((dialogue) => ({
|
||||
...dialogue,
|
||||
availableAfterBattleIds: [...dialogue.availableAfterBattleIds],
|
||||
unitIds: [...dialogue.unitIds] as [string, string],
|
||||
lines: [...dialogue.lines],
|
||||
choices: dialogue.choices.map((choice) => ({ ...choice }))
|
||||
})),
|
||||
{
|
||||
id: 'liu-guan-after-zhang-jue',
|
||||
title: '황건의 깃발이 꺾인 밤',
|
||||
@@ -8508,6 +8442,20 @@ const campVisits: CampVisitDefinition[] = [
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
id: thirdCampExplorationVisitId,
|
||||
title: '광종 동림 출진 야영지',
|
||||
location: '광종 동쪽 삼림 · 노식군 외곽 포위망',
|
||||
availableAfterBattleIds: [thirdCampPreparationSourceBattleId],
|
||||
availableDuringSteps: [thirdBattleReturnCampStep],
|
||||
description:
|
||||
'광종 본영으로 나가기 전, 작전판·보급 수레·먼저 돌아온 동료를 직접 찾아 준비합니다.',
|
||||
lines: [
|
||||
'추정: 노식 중랑장의 본대가 포위망을 좁히는 동안 의용군은 동쪽 삼림 선봉을 맡는다.',
|
||||
'유비: 필요한 준비를 직접 살피되, 준비를 의무처럼 붙잡지는 않겠습니다.'
|
||||
],
|
||||
choices: []
|
||||
},
|
||||
{
|
||||
id: 'jingzhou-scholar-rumors',
|
||||
title: '형주 선비들의 모임',
|
||||
@@ -11489,6 +11437,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private selectedVisitId = campVisits[0].id;
|
||||
private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle;
|
||||
private secondBattleReliefExplorationButton?: Phaser.GameObjects.Rectangle;
|
||||
private thirdCampExplorationButton?: Phaser.GameObjects.Rectangle;
|
||||
private equipmentInventoryPage = 0;
|
||||
private equipmentPanelBackground?: Phaser.GameObjects.Rectangle;
|
||||
private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle;
|
||||
@@ -11550,6 +11499,7 @@ export class CampScene extends Phaser.Scene {
|
||||
private thirdCampPreparationBrowserOpen = false;
|
||||
private thirdCampPreparationToggleButton?: Phaser.GameObjects.Rectangle;
|
||||
private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle;
|
||||
private thirdCampPreparationClearButton?: Phaser.GameObjects.Rectangle;
|
||||
private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = [];
|
||||
private visitedTabs = new Set<CampTab>();
|
||||
private selectedSortieUnitIds: string[] = [];
|
||||
@@ -11636,6 +11586,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.campDialogueBodyText = undefined;
|
||||
this.firstPursuitExplorationButton = undefined;
|
||||
this.secondBattleReliefExplorationButton = undefined;
|
||||
this.thirdCampExplorationButton = undefined;
|
||||
this.dialogueObjects = [];
|
||||
this.sortieObjects = [];
|
||||
this.sortieComparisonPanelView = undefined;
|
||||
@@ -11763,10 +11714,16 @@ export class CampScene extends Phaser.Scene {
|
||||
? 'dialogue'
|
||||
: 'market';
|
||||
}
|
||||
const thirdExplorationProgress =
|
||||
this.campaign.step === thirdBattleReturnCampStep
|
||||
? thirdCampExplorationProgress(this.campaign)
|
||||
: undefined;
|
||||
const pendingDirectExplorationVisit = this.availableCampVisits().find(
|
||||
(visit) =>
|
||||
directExplorationVisitIds.has(visit.id) &&
|
||||
!this.completedCampVisits().includes(visit.id)
|
||||
(visit.id === thirdCampExplorationVisitId
|
||||
? !thirdExplorationProgress?.entered
|
||||
: !this.completedCampVisits().includes(visit.id))
|
||||
);
|
||||
if (!cityStay && pendingDirectExplorationVisit) {
|
||||
this.selectedVisitId = pendingDirectExplorationVisit.id;
|
||||
@@ -13180,7 +13137,34 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
private availableCampVisits() {
|
||||
const battleId = this.currentCampBattleId();
|
||||
return this.filterCampEventsByStep(campVisits.filter((visit) => visit.availableAfterBattleIds.includes(battleId)));
|
||||
return this.filterCampEventsByStep(
|
||||
campVisits
|
||||
.filter((visit) => visit.availableAfterBattleIds.includes(battleId))
|
||||
.map((visit) => this.campVisitWithThirdCampExploration(visit))
|
||||
);
|
||||
}
|
||||
|
||||
private campVisitWithThirdCampExploration(
|
||||
visit: CampVisitDefinition
|
||||
): CampVisitDefinition {
|
||||
if (visit.id !== thirdCampExplorationVisitId) {
|
||||
return visit;
|
||||
}
|
||||
const definition = resolveThirdCampExplorationDefinition(
|
||||
this.campaign
|
||||
);
|
||||
if (!definition) {
|
||||
return visit;
|
||||
}
|
||||
return {
|
||||
...visit,
|
||||
title: definition.title,
|
||||
location: definition.location,
|
||||
description: definition.description,
|
||||
lines: definition.introLines.map(
|
||||
(line) => `${line.speaker}: ${line.text}`
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
private completedAvailableVisits() {
|
||||
@@ -14667,7 +14651,7 @@ export class CampScene extends Phaser.Scene {
|
||||
const commandTitle = thirdCampPreparation
|
||||
? thirdCampPreparation.ready
|
||||
? `출진 우선 · ${thirdCampPreparation.selectedLabel}`
|
||||
: '출진 우선 · 하나를 선택'
|
||||
: '출진 우선 · 보너스 없음'
|
||||
: firstBattleFollowup
|
||||
? `지난 전투 인계 · ${firstBattleFollowup.mentorName}`
|
||||
: '지휘관 판단';
|
||||
@@ -14677,7 +14661,7 @@ export class CampScene extends Phaser.Scene {
|
||||
);
|
||||
const commandSummary = thirdCampPreparation
|
||||
? selectedPreparation?.priority.summary ??
|
||||
'실제로 확인한 정보·장비·동료 활동 가운데 이번 출진에 먼저 반영할 하나를 정하십시오.'
|
||||
'야영지에서 완료한 활동은 보너스 하나로 선택할 수 있습니다. 선택하지 않고 출진해도 됩니다.'
|
||||
: firstBattleFollowup
|
||||
? firstBattleFollowup.achieved
|
||||
? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}`
|
||||
@@ -14700,7 +14684,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.renderThirdCampPreparationToggle(
|
||||
x + width - 86,
|
||||
commandY + 51,
|
||||
thirdCampPreparation.ready ? '준비 변경' : '준비 선택',
|
||||
thirdCampPreparation.ready ? '준비 변경' : '보너스 선택',
|
||||
depth + 2
|
||||
);
|
||||
} else if (firstBattleFollowup) {
|
||||
@@ -14787,13 +14771,13 @@ export class CampScene extends Phaser.Scene {
|
||||
background.setStrokeStyle(1, palette.gold, 0.72);
|
||||
|
||||
this.trackSortie(
|
||||
this.add.text(x + 18, y + 14, '이번 출진의 우선 준비', this.textStyle(19, '#f2e3bf', true))
|
||||
this.add.text(x + 18, y + 14, '이번 출진의 선택 보너스', this.textStyle(19, '#f2e3bf', true))
|
||||
).setDepth(depth + 1);
|
||||
this.trackSortie(
|
||||
this.add.text(
|
||||
x + 18,
|
||||
y + 41,
|
||||
'실제로 확인한 활동 하나를 광종 본영전 첫 3턴에 반영합니다.',
|
||||
'야영지에서 완료한 활동 중 하나만 첫 3턴에 반영합니다. 미선택 출진도 가능합니다.',
|
||||
this.textStyle(10, '#9fb0bf')
|
||||
)
|
||||
).setDepth(depth + 1);
|
||||
@@ -14960,37 +14944,75 @@ export class CampScene extends Phaser.Scene {
|
||||
this.add.text(
|
||||
x + 18,
|
||||
y + height - 25,
|
||||
'권장 선택 · 출진 전에는 언제든 다른 완료 활동으로 바꿀 수 있습니다.',
|
||||
this.textStyle(10, progress.ready ? '#a8ffd0' : '#ffdf7b', true)
|
||||
progress.ready
|
||||
? '선택 사항 · 해제하거나 다른 완료 활동으로 바꿀 수 있습니다.'
|
||||
: '선택 사항 · 보너스 없이 그대로 출진할 수 있습니다.',
|
||||
{
|
||||
...this.textStyle(
|
||||
10,
|
||||
progress.ready ? '#a8ffd0' : '#9fb0bf',
|
||||
true
|
||||
),
|
||||
wordWrap: {
|
||||
width: progress.ready ? width - 158 : width - 36,
|
||||
useAdvancedWrap: true
|
||||
}
|
||||
}
|
||||
)
|
||||
).setDepth(depth + 1);
|
||||
|
||||
if (progress.selectedPriorityId) {
|
||||
const clearX = x + width - 70;
|
||||
const clearY = y + height - 24;
|
||||
const clear = this.trackSortie(
|
||||
this.add.rectangle(clearX, clearY, 112, 26, 0x1a2630, 0.96)
|
||||
);
|
||||
clear.setDepth(depth + 2);
|
||||
clear.setStrokeStyle(1, palette.blue, 0.72);
|
||||
clear.setInteractive({ useHandCursor: true });
|
||||
clear.on('pointerover', () => clear.setFillStyle(0x283947, 1));
|
||||
clear.on('pointerout', () => clear.setFillStyle(0x1a2630, 0.96));
|
||||
clear.on('pointerdown', () => this.clearThirdCampPreparationSelection());
|
||||
this.thirdCampPreparationClearButton = clear;
|
||||
const clearLabel = this.trackSortie(
|
||||
this.add.text(
|
||||
clearX,
|
||||
clearY,
|
||||
'선택 해제',
|
||||
this.textStyle(10, '#d4dce6', true)
|
||||
)
|
||||
);
|
||||
clearLabel.setOrigin(0.5);
|
||||
clearLabel.setDepth(depth + 3);
|
||||
clearLabel.setInteractive({ useHandCursor: true });
|
||||
clearLabel.on('pointerover', () => clear.setFillStyle(0x283947, 1));
|
||||
clearLabel.on('pointerout', () => clear.setFillStyle(0x1a2630, 0.96));
|
||||
clearLabel.on(
|
||||
'pointerdown',
|
||||
() => this.clearThirdCampPreparationSelection()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private thirdCampPreparationUnavailableLabel(
|
||||
reason?: ThirdCampPreparationUnavailableReason
|
||||
) {
|
||||
if (reason === 'report-review-required') {
|
||||
return '승전 보고에서 광종 본영 진입 정보를 먼저 확인해야 합니다.';
|
||||
return '야영지에서 추정과 작전판을 먼저 확인하면 선택할 수 있습니다.';
|
||||
}
|
||||
if (reason === 'equipment-change-required') {
|
||||
return '3차 승리로 받은 장비를 장비 탭에서 먼저 확인해야 합니다.';
|
||||
return '야영지 보급 수레에서 선봉 방호구를 정비하면 선택할 수 있습니다.';
|
||||
}
|
||||
if (reason === 'priority-dialogue-required') {
|
||||
return '전투 결과에 맞춰 표시된 우선 귀환 대화를 먼저 마쳐야 합니다.';
|
||||
return '야영지에서 먼저 돌아온 동료와 결과 대화를 나누면 선택할 수 있습니다.';
|
||||
}
|
||||
return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.';
|
||||
}
|
||||
|
||||
private thirdCampPreparationLockedActionLabel(
|
||||
priorityId: ThirdCampPreparationPriorityId
|
||||
_priorityId: ThirdCampPreparationPriorityId
|
||||
) {
|
||||
if (priorityId === 'equipment') {
|
||||
return '장비 보기';
|
||||
}
|
||||
if (priorityId === 'companion') {
|
||||
return '대화하기';
|
||||
}
|
||||
return '보고 확인';
|
||||
return '야영지로';
|
||||
}
|
||||
|
||||
private selectThirdCampPreparationPriority(
|
||||
@@ -15014,41 +15036,35 @@ export class CampScene extends Phaser.Scene {
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private openThirdCampPreparationActivity(
|
||||
priorityId: ThirdCampPreparationPriorityId
|
||||
) {
|
||||
if (priorityId === 'equipment') {
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
this.hideSortiePrep();
|
||||
this.activeTab = 'equipment';
|
||||
this.render();
|
||||
this.showCampNotice('3차 승전 장비를 확인했습니다. 출진 준비에서 장비 정비를 선택할 수 있습니다.');
|
||||
private clearThirdCampPreparationSelection() {
|
||||
const result = clearThirdCampPreparationPriority();
|
||||
if (!result.ok) {
|
||||
this.showCampNotice(
|
||||
'현재 진행에서는 선택 보너스를 해제할 수 없습니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (priorityId === 'companion') {
|
||||
const priorityDialogue = this.thirdBattlePriorityReturnDialogue();
|
||||
if (!priorityDialogue) {
|
||||
this.showCampNotice('현재 승리 기록에서 우선 귀환 대화를 확인할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
this.hideSortiePrep();
|
||||
this.activeTab = 'dialogue';
|
||||
this.selectedDialogueId = priorityDialogue.targetDialogueId;
|
||||
this.render();
|
||||
this.showCampNotice('표시된 우선 귀환 대화를 마치면 동료 공명을 선택할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.acknowledgePendingVictoryRewardCategories(['unlocks']);
|
||||
this.campaign = getCampaignState();
|
||||
this.campaign = result.campaign;
|
||||
this.thirdCampPreparationBrowserOpen = true;
|
||||
soundDirector.playSelect();
|
||||
this.showCampNotice('승전 보고의 광종 본영 진입 정보를 확인했습니다.');
|
||||
this.showCampNotice(
|
||||
result.changed
|
||||
? '선택 보너스를 해제했습니다. 보너스 없이 출진할 수 있습니다.'
|
||||
: '현재 선택된 출진 보너스가 없습니다.'
|
||||
);
|
||||
this.showSortiePrep();
|
||||
}
|
||||
|
||||
private openThirdCampPreparationActivity(
|
||||
_priorityId: ThirdCampPreparationPriorityId
|
||||
) {
|
||||
soundDirector.playSelect();
|
||||
this.thirdCampPreparationBrowserOpen = false;
|
||||
this.startCampNavigation('CampVisitExplorationScene', {
|
||||
visitId: thirdCampExplorationVisitId
|
||||
});
|
||||
}
|
||||
|
||||
private openFirstBattleFollowupOrderBrowser() {
|
||||
if (!this.firstBattleCampFollowup() || !this.nextSortieScenario()) {
|
||||
return;
|
||||
@@ -20863,10 +20879,10 @@ export class CampScene extends Phaser.Scene {
|
||||
...(thirdCampPreparation
|
||||
? [{
|
||||
label: '출진 우선 준비',
|
||||
complete: thirdCampPreparation.ready,
|
||||
complete: true,
|
||||
detail: thirdCampPreparation.ready
|
||||
? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영`
|
||||
: '정보·장비·동료 중 완료한 활동 하나 선택',
|
||||
: '미선택 · 보너스 없이 출진 가능',
|
||||
priority: 'recommended' as const
|
||||
}]
|
||||
: []),
|
||||
@@ -21242,6 +21258,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.firstBattleFollowupSummaryText = undefined;
|
||||
this.thirdCampPreparationToggleButton = undefined;
|
||||
this.thirdCampPreparationCloseButton = undefined;
|
||||
this.thirdCampPreparationClearButton = undefined;
|
||||
this.thirdCampPreparationCardViews = [];
|
||||
this.sortiePortraitRosterLayout = undefined;
|
||||
this.sortieRosterToggleBounds = [];
|
||||
@@ -24087,10 +24104,19 @@ export class CampScene extends Phaser.Scene {
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.gold, 0.58);
|
||||
const locationLabel = this.currentCityStay()?.city.name ?? this.campSkinSelection?.skin.locationLabel ?? '현지';
|
||||
this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true)));
|
||||
this.track(this.add.text(x + 24, y + 56, '획득한 정보·보급·공명은 출진 준비와 다음 전투로 이어집니다. 방문은 한 번만 완료됩니다.', this.textStyle(14, '#d4dce6')));
|
||||
|
||||
const visits = this.availableCampVisits();
|
||||
const hasThirdCampExploration = visits.some(
|
||||
(visit) => visit.id === thirdCampExplorationVisitId
|
||||
);
|
||||
this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true)));
|
||||
this.track(this.add.text(
|
||||
x + 24,
|
||||
y + 56,
|
||||
hasThirdCampExploration
|
||||
? '야영지는 언제든 다시 들어갈 수 있습니다. 모든 활동은 선택 사항이며 보너스 없이 바로 출진해도 됩니다.'
|
||||
: '획득한 정보·보급·공명은 출진 준비와 다음 전투로 이어집니다. 방문은 한 번만 완료됩니다.',
|
||||
this.textStyle(14, '#d4dce6')
|
||||
));
|
||||
if (!visits.some((visit) => visit.id === this.selectedVisitId)) {
|
||||
this.selectedVisitId = visits[0]?.id ?? campVisits[0].id;
|
||||
}
|
||||
@@ -24153,6 +24179,11 @@ export class CampScene extends Phaser.Scene {
|
||||
})
|
||||
);
|
||||
|
||||
if (visit.id === thirdCampExplorationVisitId) {
|
||||
this.renderThirdCampExplorationVisit(x, y, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
const chosen = this.recordedVisitChoice(visit);
|
||||
if (chosen) {
|
||||
@@ -24255,6 +24286,105 @@ export class CampScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private renderThirdCampExplorationVisit(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const exploration = this.campaign
|
||||
? thirdCampExplorationProgress(this.campaign)
|
||||
: undefined;
|
||||
const preparation = this.campaign
|
||||
? thirdCampPreparationProgress(this.campaign)
|
||||
: undefined;
|
||||
const completedCount = exploration?.completedActivityCount ?? 0;
|
||||
const activityCount = exploration?.activities.length ?? 3;
|
||||
const guide = this.track(
|
||||
this.add.rectangle(
|
||||
x + 18,
|
||||
y + height - 104,
|
||||
width - 36,
|
||||
86,
|
||||
0x151f2a,
|
||||
0.96
|
||||
)
|
||||
);
|
||||
guide.setOrigin(0);
|
||||
guide.setStrokeStyle(
|
||||
1,
|
||||
completedCount > 0 ? palette.green : palette.blue,
|
||||
0.66
|
||||
);
|
||||
this.track(
|
||||
this.add.text(
|
||||
x + 32,
|
||||
y + height - 92,
|
||||
`준비 활동 ${completedCount}/${activityCount} · ${
|
||||
preparation?.selectedLabel
|
||||
? `선택 보너스: ${preparation.selectedLabel}`
|
||||
: '보너스 미선택'
|
||||
}`,
|
||||
this.textStyle(
|
||||
12,
|
||||
preparation?.selectedLabel ? '#a8ffd0' : '#d4dce6',
|
||||
true
|
||||
)
|
||||
)
|
||||
);
|
||||
this.track(
|
||||
this.add.text(
|
||||
x + 32,
|
||||
y + height - 70,
|
||||
'세 활동은 모두 선택 사항 · 군영으로 돌아와 보너스 없이 출진 가능',
|
||||
this.textStyle(10, '#9fb0bf')
|
||||
)
|
||||
);
|
||||
|
||||
const buttonY = y + height - 34;
|
||||
const button = this.track(
|
||||
this.add.rectangle(
|
||||
x + width / 2,
|
||||
buttonY,
|
||||
width - 64,
|
||||
34,
|
||||
0x4a371d,
|
||||
0.98
|
||||
)
|
||||
);
|
||||
button.setStrokeStyle(2, palette.gold, 0.94);
|
||||
button.setInteractive({ useHandCursor: true });
|
||||
const openExploration = () => {
|
||||
soundDirector.playSelect();
|
||||
this.startCampNavigation('CampVisitExplorationScene', {
|
||||
visitId: thirdCampExplorationVisitId
|
||||
});
|
||||
};
|
||||
button.on('pointerover', () => button.setFillStyle(0x654c25, 1));
|
||||
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
|
||||
button.on('pointerdown', openExploration);
|
||||
this.thirdCampExplorationButton = button;
|
||||
|
||||
const ctaLabel = !exploration?.entered
|
||||
? '야영지 탐색 시작'
|
||||
: completedCount < activityCount
|
||||
? '야영지 탐색 계속'
|
||||
: '야영지 다시 둘러보기';
|
||||
const label = this.track(
|
||||
this.add.text(
|
||||
x + width / 2,
|
||||
buttonY,
|
||||
ctaLabel,
|
||||
this.textStyle(14, '#fff2b8', true)
|
||||
)
|
||||
);
|
||||
label.setOrigin(0.5);
|
||||
label.setInteractive({ useHandCursor: true });
|
||||
label.on('pointerover', () => button.setFillStyle(0x654c25, 1));
|
||||
label.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98));
|
||||
label.on('pointerdown', openExploration);
|
||||
}
|
||||
|
||||
private renderVisitPreparationSummary(x: number, y: number, width: number) {
|
||||
const visits = this.availableCampVisits();
|
||||
const completed = this.completedAvailableVisits();
|
||||
@@ -24262,6 +24392,40 @@ export class CampScene extends Phaser.Scene {
|
||||
bg.setOrigin(0);
|
||||
bg.setStrokeStyle(1, palette.blue, 0.42);
|
||||
this.track(this.add.text(x + 14, y + 12, '현지 준비 · 다음 전투 반영', this.textStyle(17, '#f2e3bf', true)));
|
||||
if (
|
||||
visits.some((visit) => visit.id === thirdCampExplorationVisitId) &&
|
||||
this.campaign
|
||||
) {
|
||||
const exploration = thirdCampExplorationProgress(this.campaign);
|
||||
const preparation = thirdCampPreparationProgress(this.campaign);
|
||||
this.track(
|
||||
this.add.text(
|
||||
x + 14,
|
||||
y + 40,
|
||||
`야영지 활동 ${exploration.completedActivityCount}/${exploration.activities.length}`,
|
||||
this.textStyle(
|
||||
13,
|
||||
exploration.completedActivityCount > 0 ? '#a8ffd0' : '#d4dce6',
|
||||
true
|
||||
)
|
||||
)
|
||||
);
|
||||
this.track(
|
||||
this.add.text(
|
||||
x + 14,
|
||||
y + 66,
|
||||
preparation.selectedLabel
|
||||
? `선택 보너스 · ${preparation.selectedLabel}`
|
||||
: '보너스 미선택 · 그대로 출진 가능',
|
||||
this.textStyle(
|
||||
13,
|
||||
preparation.selectedLabel ? '#a8ffd0' : '#9fb0bf',
|
||||
true
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true)));
|
||||
const isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth;
|
||||
const informationLabel = isWolongSearch
|
||||
@@ -26063,6 +26227,7 @@ export class CampScene extends Phaser.Scene {
|
||||
this.campDialogueBodyText = undefined;
|
||||
this.firstPursuitExplorationButton = undefined;
|
||||
this.secondBattleReliefExplorationButton = undefined;
|
||||
this.thirdCampExplorationButton = undefined;
|
||||
this.campRosterLayout = undefined;
|
||||
this.cityPanelBackground = undefined;
|
||||
this.cityExploreButton = undefined;
|
||||
@@ -26234,6 +26399,12 @@ export class CampScene extends Phaser.Scene {
|
||||
: undefined;
|
||||
const secondReliefChoiceId =
|
||||
this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null;
|
||||
const thirdCampExplorationVisit = this.availableCampVisits().find(
|
||||
(visit) => visit.id === thirdCampExplorationVisitId
|
||||
);
|
||||
const thirdCampExploration = this.campaign
|
||||
? thirdCampExplorationProgress(this.campaign)
|
||||
: undefined;
|
||||
const thirdCampPreparation = this.campaign && this.isThirdCampPreparationSlice()
|
||||
? thirdCampPreparationProgress(this.campaign)
|
||||
: undefined;
|
||||
@@ -26484,6 +26655,12 @@ export class CampScene extends Phaser.Scene {
|
||||
closeButtonBounds: this.sortieInteractiveObjectBoundsDebug(
|
||||
this.thirdCampPreparationCloseButton
|
||||
),
|
||||
clearButtonBounds: this.sortieInteractiveObjectBoundsDebug(
|
||||
this.thirdCampPreparationClearButton
|
||||
),
|
||||
clearAvailable: Boolean(
|
||||
thirdCampPreparation.selectedPriorityId
|
||||
),
|
||||
priorities: thirdCampPreparation.availability.map((entry) => {
|
||||
const view = this.thirdCampPreparationCardViews.find(
|
||||
(candidate) => candidate.priorityId === entry.priority.id
|
||||
@@ -26523,6 +26700,8 @@ export class CampScene extends Phaser.Scene {
|
||||
browserOpen: false,
|
||||
toggleButtonBounds: null,
|
||||
closeButtonBounds: null,
|
||||
clearButtonBounds: null,
|
||||
clearAvailable: false,
|
||||
priorities: []
|
||||
},
|
||||
firstPursuitScoutMemory: {
|
||||
@@ -26586,6 +26765,42 @@ export class CampScene extends Phaser.Scene {
|
||||
rewardText: this.visitRewardText(choice)
|
||||
})) ?? []
|
||||
},
|
||||
thirdCampExploration: {
|
||||
visitId: thirdCampExplorationVisitId,
|
||||
available: Boolean(thirdCampExplorationVisit),
|
||||
selected:
|
||||
this.selectedVisitId === thirdCampExplorationVisitId,
|
||||
entered: thirdCampExploration?.entered ?? false,
|
||||
completedActivityIds:
|
||||
thirdCampExploration?.completedActivityIds ?? [],
|
||||
completedActivityCount:
|
||||
thirdCampExploration?.completedActivityCount ?? 0,
|
||||
selectedPriorityId:
|
||||
thirdCampExploration?.selectedPriorityId ?? null,
|
||||
ready: thirdCampExploration?.ready ?? false,
|
||||
mode: !thirdCampExplorationVisit
|
||||
? 'unavailable'
|
||||
: !thirdCampExploration?.entered
|
||||
? 'explore'
|
||||
: (thirdCampExploration.completedActivityCount ?? 0) <
|
||||
(thirdCampExploration.activities.length || 3)
|
||||
? 'continue'
|
||||
: 'revisit',
|
||||
explorationButtonBounds:
|
||||
this.sortieInteractiveObjectBoundsDebug(
|
||||
this.thirdCampExplorationButton
|
||||
),
|
||||
explorationInteractive: Boolean(
|
||||
this.thirdCampExplorationButton?.input?.enabled
|
||||
),
|
||||
activities:
|
||||
thirdCampExploration?.activities.map((activity) => ({
|
||||
id: activity.id,
|
||||
label: activity.label,
|
||||
completed: activity.completed,
|
||||
selected: activity.selected
|
||||
})) ?? []
|
||||
},
|
||||
selectedDialogue: selectedCampDialogue
|
||||
? {
|
||||
id: selectedCampDialogue.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
463
src/game/state/thirdCampExplorationActions.ts
Normal file
463
src/game/state/thirdCampExplorationActions.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import {
|
||||
completedThirdCampExplorationActivityIds,
|
||||
isThirdCampExplorationActivityId,
|
||||
resolveThirdCampCompanionDialogue,
|
||||
resolveThirdCampExplorationDefinition,
|
||||
thirdCampExplorationActivityProgressId,
|
||||
thirdCampExplorationSourceBattleId,
|
||||
thirdCampExplorationVisitId,
|
||||
type ThirdCampCompanionDialogueChoice,
|
||||
type ThirdCampCompanionDialogueDefinition,
|
||||
type ThirdCampExplorationActivityDefinition,
|
||||
type ThirdCampExplorationActivityId
|
||||
} from '../data/thirdCampExploration';
|
||||
import {
|
||||
getThirdCampPreparationPriority,
|
||||
hasThirdCampPreparationSourceVictory,
|
||||
resolveThirdCampPreparationMemory,
|
||||
resolveThirdCampPreparationSelection,
|
||||
thirdCampPreparationSourceBattleId,
|
||||
thirdCampPreparationTargetBattleId,
|
||||
type ThirdCampPreparationMemory,
|
||||
type ThirdCampPreparationPriorityDefinition
|
||||
} from '../data/thirdCampPreparation';
|
||||
import {
|
||||
applyCampBondExp,
|
||||
getCampaignState,
|
||||
setCampaignState,
|
||||
type CampaignState
|
||||
} from './campaignState';
|
||||
|
||||
export type ThirdCampExplorationFailure =
|
||||
| 'invalid-campaign'
|
||||
| 'invalid-activity'
|
||||
| 'choice-required'
|
||||
| 'invalid-choice'
|
||||
| 'bond-unavailable'
|
||||
| 'save-unavailable';
|
||||
|
||||
export type ThirdCampExplorationEntryResult =
|
||||
| {
|
||||
ok: true;
|
||||
campaign: CampaignState;
|
||||
changed: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: Extract<
|
||||
ThirdCampExplorationFailure,
|
||||
'invalid-campaign' | 'save-unavailable'
|
||||
>;
|
||||
};
|
||||
|
||||
export type ThirdCampExplorationActivityResult =
|
||||
| {
|
||||
ok: true;
|
||||
activity: ThirdCampExplorationActivityDefinition;
|
||||
priority: ThirdCampPreparationPriorityDefinition;
|
||||
memory: ThirdCampPreparationMemory;
|
||||
campaign: CampaignState;
|
||||
changed: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: Extract<
|
||||
ThirdCampExplorationFailure,
|
||||
| 'invalid-campaign'
|
||||
| 'invalid-activity'
|
||||
| 'choice-required'
|
||||
| 'save-unavailable'
|
||||
>;
|
||||
};
|
||||
|
||||
export type ThirdCampCompanionActivityResult =
|
||||
| {
|
||||
ok: true;
|
||||
activity: ThirdCampExplorationActivityDefinition;
|
||||
dialogue: ThirdCampCompanionDialogueDefinition;
|
||||
choice: ThirdCampCompanionDialogueChoice;
|
||||
rewardExp: number;
|
||||
memory: ThirdCampPreparationMemory;
|
||||
campaign: CampaignState;
|
||||
changed: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: Extract<
|
||||
ThirdCampExplorationFailure,
|
||||
| 'invalid-campaign'
|
||||
| 'invalid-choice'
|
||||
| 'bond-unavailable'
|
||||
| 'save-unavailable'
|
||||
>;
|
||||
};
|
||||
|
||||
export function thirdCampExplorationProgress(
|
||||
campaign: CampaignState = getCampaignState()
|
||||
) {
|
||||
const definition =
|
||||
resolveThirdCampExplorationDefinition(campaign);
|
||||
const completedActivityIds =
|
||||
completedThirdCampExplorationActivityIds(
|
||||
campaign.completedCampVisits
|
||||
);
|
||||
const completed = new Set(completedActivityIds);
|
||||
const selection = resolveThirdCampPreparationSelection(campaign);
|
||||
|
||||
return {
|
||||
visitId: thirdCampExplorationVisitId,
|
||||
entered: campaign.completedCampVisits.includes(
|
||||
thirdCampExplorationVisitId
|
||||
),
|
||||
completedActivityIds,
|
||||
completedActivityCount: completedActivityIds.length,
|
||||
activities:
|
||||
definition?.activities.map((activity) => ({
|
||||
...activity,
|
||||
targetActorIds: [...activity.targetActorIds],
|
||||
completed: completed.has(activity.id),
|
||||
selected: selection?.priorityId === activity.id
|
||||
})) ?? [],
|
||||
selectedPriorityId: selection?.priorityId ?? null,
|
||||
ready: selection?.selectable === true
|
||||
};
|
||||
}
|
||||
|
||||
export function enterThirdCampExploration():
|
||||
ThirdCampExplorationEntryResult {
|
||||
const snapshot = readCampaignSnapshot();
|
||||
if (!snapshot) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!campaignSupportsThirdCampExploration(snapshot)) {
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
|
||||
const markerIds = [thirdCampExplorationVisitId];
|
||||
const companionDialogue =
|
||||
resolveThirdCampCompanionDialogue(snapshot);
|
||||
if (
|
||||
companionDialogue &&
|
||||
snapshot.completedCampDialogues.includes(companionDialogue.id)
|
||||
) {
|
||||
markerIds.push(
|
||||
thirdCampExplorationActivityProgressId('companion')
|
||||
);
|
||||
}
|
||||
const updated = withVisitMarkers(snapshot, markerIds);
|
||||
if (
|
||||
updated.completedCampVisits.length ===
|
||||
snapshot.completedCampVisits.length
|
||||
) {
|
||||
return { ok: true, campaign: snapshot, changed: false };
|
||||
}
|
||||
|
||||
const persisted = persistCampaign(updated, snapshot);
|
||||
return persisted
|
||||
? { ok: true, campaign: persisted, changed: true }
|
||||
: { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
|
||||
export function recordThirdCampExplorationActivity(
|
||||
priorityId: string
|
||||
): ThirdCampExplorationActivityResult {
|
||||
const snapshot = readCampaignSnapshot();
|
||||
if (!snapshot) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!campaignSupportsThirdCampExploration(snapshot)) {
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
if (!isThirdCampExplorationActivityId(priorityId)) {
|
||||
return { ok: false, reason: 'invalid-activity' };
|
||||
}
|
||||
|
||||
const definition =
|
||||
resolveThirdCampExplorationDefinition(snapshot);
|
||||
const activity = definition?.activities.find(
|
||||
(candidate) => candidate.id === priorityId
|
||||
);
|
||||
const priority = getThirdCampPreparationPriority(priorityId);
|
||||
if (!activity || !priority) {
|
||||
return { ok: false, reason: 'invalid-activity' };
|
||||
}
|
||||
if (
|
||||
priorityId === 'companion' &&
|
||||
!hasCompletedCompanionActivity(snapshot)
|
||||
) {
|
||||
return { ok: false, reason: 'choice-required' };
|
||||
}
|
||||
|
||||
const persisted = persistActivitySelection(
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
export function completeThirdCampCompanionActivity(
|
||||
choiceId: string
|
||||
): ThirdCampCompanionActivityResult {
|
||||
const snapshot = readCampaignSnapshot();
|
||||
if (!snapshot) {
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
if (!campaignSupportsThirdCampExploration(snapshot)) {
|
||||
return { ok: false, reason: 'invalid-campaign' };
|
||||
}
|
||||
|
||||
const definition =
|
||||
resolveThirdCampExplorationDefinition(snapshot);
|
||||
const activity = definition?.activities.find(
|
||||
(candidate) => candidate.id === 'companion'
|
||||
);
|
||||
const dialogue = resolveThirdCampCompanionDialogue(snapshot);
|
||||
const choice = dialogue?.choices.find(
|
||||
(candidate) => candidate.id === choiceId
|
||||
);
|
||||
if (!activity || !dialogue || !choice) {
|
||||
return { ok: false, reason: 'invalid-choice' };
|
||||
}
|
||||
|
||||
const dialogueAlreadyCompleted =
|
||||
snapshot.completedCampDialogues.includes(dialogue.id);
|
||||
if (!dialogueAlreadyCompleted) {
|
||||
const report = applyCompanionBondReward(
|
||||
snapshot,
|
||||
dialogue,
|
||||
choice
|
||||
);
|
||||
if (!report) {
|
||||
return { ok: false, reason: 'bond-unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
const rewardExp = dialogue.rewardExp + choice.rewardExp;
|
||||
const afterDialogue = readCampaignSnapshot();
|
||||
if (!afterDialogue) {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return { ok: false, reason: 'save-unavailable' };
|
||||
}
|
||||
const persisted = persistActivitySelection(
|
||||
afterDialogue,
|
||||
'companion',
|
||||
snapshot
|
||||
);
|
||||
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,
|
||||
activity,
|
||||
dialogue,
|
||||
choice,
|
||||
rewardExp,
|
||||
memory,
|
||||
campaign: persisted,
|
||||
changed:
|
||||
!dialogueAlreadyCompleted ||
|
||||
!snapshot.completedCampVisits.includes(
|
||||
thirdCampExplorationActivityProgressId('companion')
|
||||
) ||
|
||||
snapshot.thirdCampPreparationSelection?.priorityId !==
|
||||
'companion'
|
||||
};
|
||||
}
|
||||
|
||||
function campaignSupportsThirdCampExploration(
|
||||
campaign: CampaignState
|
||||
) {
|
||||
const settlement =
|
||||
campaign.battleHistory[thirdCampExplorationSourceBattleId];
|
||||
const report = campaign.firstBattleReport;
|
||||
return (
|
||||
campaign.step === 'third-camp' &&
|
||||
campaign.latestBattleId ===
|
||||
thirdCampExplorationSourceBattleId &&
|
||||
settlement?.battleId ===
|
||||
thirdCampExplorationSourceBattleId &&
|
||||
settlement.outcome === 'victory' &&
|
||||
report?.battleId === thirdCampExplorationSourceBattleId &&
|
||||
report.outcome === 'victory' &&
|
||||
hasThirdCampPreparationSourceVictory(campaign) &&
|
||||
Boolean(resolveThirdCampExplorationDefinition(campaign))
|
||||
);
|
||||
}
|
||||
|
||||
function hasCompletedCompanionActivity(campaign: CampaignState) {
|
||||
if (
|
||||
campaign.completedCampVisits.includes(
|
||||
thirdCampExplorationActivityProgressId('companion')
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const dialogue = resolveThirdCampCompanionDialogue(campaign);
|
||||
return Boolean(
|
||||
dialogue &&
|
||||
campaign.completedCampDialogues.includes(dialogue.id)
|
||||
);
|
||||
}
|
||||
|
||||
function persistActivitySelection(
|
||||
current: CampaignState,
|
||||
activityId: ThirdCampExplorationActivityId,
|
||||
rollbackSnapshot: CampaignState = current
|
||||
) {
|
||||
const activityMarker =
|
||||
thirdCampExplorationActivityProgressId(activityId);
|
||||
let updated = withVisitMarkers(current, [
|
||||
thirdCampExplorationVisitId,
|
||||
activityMarker
|
||||
]);
|
||||
updated = {
|
||||
...updated,
|
||||
acknowledgedVictoryRewardCategories:
|
||||
acknowledgedRewardCategoryForActivity(updated, activityId),
|
||||
thirdCampPreparationSelection: {
|
||||
sourceBattleId: thirdCampPreparationSourceBattleId,
|
||||
targetBattleId: thirdCampPreparationTargetBattleId,
|
||||
priorityId: activityId
|
||||
}
|
||||
};
|
||||
return persistCampaign(updated, rollbackSnapshot);
|
||||
}
|
||||
|
||||
function acknowledgedRewardCategoryForActivity(
|
||||
campaign: CampaignState,
|
||||
activityId: ThirdCampExplorationActivityId
|
||||
) {
|
||||
const category: 'unlocks' | 'equipment' | undefined =
|
||||
activityId === 'information'
|
||||
? 'unlocks'
|
||||
: activityId === 'equipment'
|
||||
? 'equipment'
|
||||
: undefined;
|
||||
if (!category) {
|
||||
return campaign.acknowledgedVictoryRewardCategories;
|
||||
}
|
||||
const previous =
|
||||
campaign.acknowledgedVictoryRewardCategories[
|
||||
thirdCampExplorationSourceBattleId
|
||||
] ?? [];
|
||||
return {
|
||||
...campaign.acknowledgedVictoryRewardCategories,
|
||||
[thirdCampExplorationSourceBattleId]: Array.from(
|
||||
new Set([...previous, category])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function withVisitMarkers(
|
||||
campaign: CampaignState,
|
||||
markerIds: readonly string[]
|
||||
): CampaignState {
|
||||
const completedCampVisits = Array.from(
|
||||
new Set([...campaign.completedCampVisits, ...markerIds])
|
||||
);
|
||||
return {
|
||||
...campaign,
|
||||
completedCampVisits,
|
||||
...(campaign.firstBattleReport
|
||||
? {
|
||||
firstBattleReport: {
|
||||
...campaign.firstBattleReport,
|
||||
completedCampVisits: Array.from(
|
||||
new Set([
|
||||
...campaign.firstBattleReport.completedCampVisits,
|
||||
...markerIds
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMemory(
|
||||
campaign: CampaignState,
|
||||
activityId: ThirdCampExplorationActivityId
|
||||
) {
|
||||
const memory = resolveThirdCampPreparationMemory({
|
||||
campaign,
|
||||
battleId: thirdCampPreparationTargetBattleId
|
||||
});
|
||||
return memory?.priorityId === activityId ? memory : undefined;
|
||||
}
|
||||
|
||||
function applyCompanionBondReward(
|
||||
snapshot: CampaignState,
|
||||
dialogue: ThirdCampCompanionDialogueDefinition,
|
||||
choice: ThirdCampCompanionDialogueChoice
|
||||
) {
|
||||
try {
|
||||
const rewardExp = dialogue.rewardExp + choice.rewardExp;
|
||||
const report = applyCampBondExp(
|
||||
dialogue.id,
|
||||
dialogue.bondId,
|
||||
rewardExp,
|
||||
choice.id
|
||||
);
|
||||
if (!report) {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
}
|
||||
return report;
|
||||
} catch {
|
||||
restoreCampaignSnapshot(snapshot);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readCampaignSnapshot() {
|
||||
try {
|
||||
return getCampaignState();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function persistCampaign(
|
||||
updated: CampaignState,
|
||||
rollbackSnapshot: CampaignState
|
||||
) {
|
||||
try {
|
||||
return setCampaignState(updated);
|
||||
} catch {
|
||||
restoreCampaignSnapshot(rollbackSnapshot);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCampaignSnapshot(snapshot: CampaignState) {
|
||||
try {
|
||||
setCampaignState(snapshot);
|
||||
} catch {
|
||||
// setCampaignState restores the in-memory snapshot before persistence.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user