Files
heros_web/src/game/data/thirdCampExploration.ts

584 lines
20 KiB
TypeScript

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;
}