3183 lines
130 KiB
TypeScript
3183 lines
130 KiB
TypeScript
import Phaser from 'phaser';
|
|
import { soundDirector } from '../audio/SoundDirector';
|
|
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
|
|
import { getUnitClass, terrainRules, type TerrainType } from '../data/battleRules';
|
|
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
|
|
import { getSortieFlow } from '../data/campaignFlow';
|
|
import {
|
|
caoBreakRecruitBonds,
|
|
caoBreakRecruitUnits,
|
|
firstBattleBonds,
|
|
firstBattleUnits,
|
|
xuzhouRecruitBonds,
|
|
xuzhouRecruitUnits,
|
|
type PortraitKey,
|
|
type UnitData
|
|
} from '../data/scenario';
|
|
import {
|
|
applyCampBondExp,
|
|
ensureCampaignRosterUnits,
|
|
getCampaignState,
|
|
getFirstBattleReport,
|
|
markCampaignStep,
|
|
saveCampaignState,
|
|
type CampaignState,
|
|
type FirstBattleReport
|
|
} from '../state/campaignState';
|
|
import { palette } from '../ui/palette';
|
|
|
|
type CampTab = 'status' | 'dialogue' | 'supplies';
|
|
|
|
type CampDialogue = {
|
|
id: string;
|
|
title: string;
|
|
availableAfterBattleIds: string[];
|
|
unitIds: [string, string];
|
|
bondId: string;
|
|
rewardExp: number;
|
|
lines: string[];
|
|
choices: CampDialogueChoice[];
|
|
};
|
|
|
|
type CampDialogueChoice = {
|
|
id: string;
|
|
label: string;
|
|
response: string;
|
|
rewardExp: number;
|
|
};
|
|
|
|
type CampTabButtonView = {
|
|
tab: CampTab;
|
|
bg: Phaser.GameObjects.Rectangle;
|
|
text: Phaser.GameObjects.Text;
|
|
};
|
|
|
|
type CampSupplyDefinition = {
|
|
label: string;
|
|
title: string;
|
|
description: string;
|
|
healHp: number;
|
|
bondExp: number;
|
|
};
|
|
|
|
type MerchantItemDefinition = {
|
|
label: string;
|
|
title: string;
|
|
description: string;
|
|
price: number;
|
|
};
|
|
|
|
type SortieChecklistItem = {
|
|
label: string;
|
|
complete: boolean;
|
|
detail: string;
|
|
};
|
|
|
|
type SortieUnitTacticalSummary = {
|
|
statLine: string;
|
|
equipmentLine: string;
|
|
bondLine: string;
|
|
terrainLine: string;
|
|
};
|
|
|
|
type SortieRecommendation = {
|
|
unitId: string;
|
|
reason: string;
|
|
};
|
|
|
|
type SortieRuleDefinition = {
|
|
maxUnits: number;
|
|
recommended: SortieRecommendation[];
|
|
note: string;
|
|
};
|
|
|
|
type SortieFormationRole = 'front' | 'flank' | 'support' | 'reserve';
|
|
|
|
type SortieFormationSlot = {
|
|
role: SortieFormationRole;
|
|
label: string;
|
|
description: string;
|
|
unitNames: string[];
|
|
};
|
|
|
|
type SortiePlanSummary = {
|
|
selectedCount: number;
|
|
maxCount: number;
|
|
terrainScore: number;
|
|
terrainGrade: string;
|
|
activeBondCount: number;
|
|
recruitedCount: number;
|
|
selectedRecruitedCount: number;
|
|
reserveCount: number;
|
|
recommendedSelectedCount: number;
|
|
recommendedTotal: number;
|
|
deploymentLine: string;
|
|
recommendationLine: string;
|
|
recruitedLine: string;
|
|
reserveLine: string;
|
|
objectiveLine: string;
|
|
formationSlots: SortieFormationSlot[];
|
|
warnings: string[];
|
|
};
|
|
|
|
const portraitKeys: Record<PortraitKey, string> = {
|
|
liuBei: 'portrait-liu-bei',
|
|
guanYu: 'portrait-guan-yu',
|
|
zhangFei: 'portrait-zhang-fei'
|
|
};
|
|
|
|
const portraitByUnitId: Record<string, PortraitKey> = {
|
|
'liu-bei': 'liuBei',
|
|
'guan-yu': 'guanYu',
|
|
'zhang-fei': 'zhangFei'
|
|
};
|
|
|
|
const campSupplies: CampSupplyDefinition[] = [
|
|
{
|
|
label: '콩',
|
|
title: '콩',
|
|
description: '선택 장수의 병력을 12 회복합니다.',
|
|
healHp: 12,
|
|
bondExp: 0
|
|
},
|
|
{
|
|
label: '탁주',
|
|
title: '탁주',
|
|
description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.',
|
|
healHp: 8,
|
|
bondExp: 2
|
|
},
|
|
{
|
|
label: '상처약',
|
|
title: '상처약',
|
|
description: '선택 장수의 병력을 22 회복합니다.',
|
|
healHp: 22,
|
|
bondExp: 0
|
|
}
|
|
];
|
|
|
|
const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply]));
|
|
|
|
const merchantItems: MerchantItemDefinition[] = [
|
|
{
|
|
label: '콩',
|
|
title: '콩',
|
|
description: '값싸고 가벼운 회복 도구입니다.',
|
|
price: 40
|
|
},
|
|
{
|
|
label: '탁주',
|
|
title: '탁주',
|
|
description: '병력 회복과 작은 공명 상승에 유용합니다.',
|
|
price: 70
|
|
},
|
|
{
|
|
label: '상처약',
|
|
title: '상처약',
|
|
description: '큰 부상을 빠르게 회복합니다.',
|
|
price: 110
|
|
}
|
|
];
|
|
|
|
const campBattleIds = {
|
|
first: defaultBattleScenario.id,
|
|
second: 'second-battle-yellow-turban-pursuit',
|
|
third: 'third-battle-guangzong-road',
|
|
fourth: 'fourth-battle-guangzong-camp',
|
|
fifth: 'fifth-battle-sishui-vanguard',
|
|
sixth: 'sixth-battle-jieqiao-relief',
|
|
seventh: 'seventh-battle-xuzhou-rescue',
|
|
eighth: 'eighth-battle-xiaopei-supply-road',
|
|
ninth: 'ninth-battle-xuzhou-gate-night-raid',
|
|
tenth: 'tenth-battle-xuzhou-breakout',
|
|
eleventh: 'eleventh-battle-xudu-refuge-road',
|
|
twelfth: 'twelfth-battle-xiapi-outer-siege',
|
|
thirteenth: 'thirteenth-battle-xiapi-final',
|
|
fourteenth: 'fourteenth-battle-cao-break'
|
|
} as const;
|
|
|
|
const requiredSortieUnitIds = new Set(['liu-bei']);
|
|
const foundingSortieUnitIds = new Set(['liu-bei', 'guan-yu', 'zhang-fei']);
|
|
const maxSortieUnits = 6;
|
|
const sortieFormationSlotDefinitions: Omit<SortieFormationSlot, 'unitNames'>[] = [
|
|
{ role: 'front', label: '전열', description: '길목을 막고 반격을 받아냄' },
|
|
{ role: 'flank', label: '돌파', description: '측면 기동과 추격 담당' },
|
|
{ role: 'support', label: '후원', description: '원거리, 책략, 보급 지원' },
|
|
{ role: 'reserve', label: '예비', description: '빈틈 보강과 공명 연결' }
|
|
];
|
|
|
|
const defaultSortieRule: SortieRuleDefinition = {
|
|
maxUnits: maxSortieUnits,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '패배 조건인 주장이며 공명 중심입니다.' }
|
|
],
|
|
note: '유비 생존을 중심에 두고 전열, 돌파, 후원을 고르게 배치하십시오.'
|
|
};
|
|
|
|
const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefinition>> = {
|
|
'second-battle-yellow-turban-pursuit': {
|
|
maxUnits: 3,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '마을 안정과 지휘를 맡습니다.' },
|
|
{ unitId: 'guan-yu', reason: '숲길 전열을 안정적으로 밀 수 있습니다.' },
|
|
{ unitId: 'zhang-fei', reason: '잔당 추격과 막타 돌파에 강합니다.' }
|
|
],
|
|
note: '첫 추격전은 세 형제의 공명과 기본 진형을 익히는 전장입니다.'
|
|
},
|
|
'third-battle-guangzong-road': {
|
|
maxUnits: 3,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '강가 길목에서 부대를 묶어 줍니다.' },
|
|
{ unitId: 'guan-yu', reason: '요새와 숲의 방어선을 버팁니다.' },
|
|
{ unitId: 'zhang-fei', reason: '좁은 길의 적 전령을 빠르게 압박합니다.' }
|
|
],
|
|
note: '강과 요새가 많은 길목이라 전열 유지와 빠른 추격이 모두 필요합니다.'
|
|
},
|
|
'fourth-battle-guangzong-camp': {
|
|
maxUnits: 3,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '장각 격파까지 전선을 지휘합니다.' },
|
|
{ unitId: 'guan-yu', reason: '요새 방어선을 뚫는 전열 핵심입니다.' },
|
|
{ unitId: 'zhang-fei', reason: '본영 측면을 흔드는 압박 담당입니다.' }
|
|
],
|
|
note: '황건 본영전은 세 형제 전원 출전으로 공명 피해와 전선 안정성을 확보하십시오.'
|
|
},
|
|
'fifth-battle-sishui-vanguard': {
|
|
maxUnits: 3,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '반동탁 연합에서 이름을 세울 주장입니다.' },
|
|
{ unitId: 'guan-yu', reason: '전초 요새 주변을 안정적으로 장악합니다.' },
|
|
{ unitId: 'zhang-fei', reason: '호진의 전열을 빠르게 흔듭니다.' }
|
|
],
|
|
note: '사수관 전초전은 중앙 길을 서두르되 숲길 척후를 놓치지 않는 편성이 좋습니다.'
|
|
},
|
|
'sixth-battle-jieqiao-relief': {
|
|
maxUnits: 3,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '공손찬 원군로의 신뢰를 지켜야 합니다.' },
|
|
{ unitId: 'guan-yu', reason: '강가와 진영 지형에서 방어 적성이 좋습니다.' },
|
|
{ unitId: 'zhang-fei', reason: '원소군 별동대 추격에 적합합니다.' }
|
|
],
|
|
note: '계교 원군로는 빠른 압박보다 길목 안정이 중요합니다.'
|
|
},
|
|
'seventh-battle-xuzhou-rescue': {
|
|
maxUnits: 4,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '서주 원군 요청의 중심 인물입니다.' },
|
|
{ unitId: 'guan-yu', reason: '하후돈 선봉을 받아낼 전열 핵심입니다.' },
|
|
{ unitId: 'zhang-fei', reason: '조조군 기병을 압박할 돌파 역할입니다.' }
|
|
],
|
|
note: '서주 구원전부터 출전 여유가 생깁니다. 전열을 유지하면서 한 명을 상황에 맞게 더 넣으십시오.'
|
|
},
|
|
'eighth-battle-xiaopei-supply-road': {
|
|
maxUnits: 5,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '서주 방위선의 사기를 붙잡습니다.' },
|
|
{ unitId: 'jian-yong', reason: '보급로 전투에서 책략 보조가 빛납니다.' },
|
|
{ unitId: 'mi-zhu', reason: '소패 보급과 회복 운용에 어울립니다.' }
|
|
],
|
|
note: '새로 합류한 서주 인재를 시험하기 좋은 전장입니다.'
|
|
},
|
|
'ninth-battle-xuzhou-gate-night-raid': {
|
|
maxUnits: 5,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '성문을 되찾는 정치적 명분의 중심입니다.' },
|
|
{ unitId: 'guan-yu', reason: '야습 방어와 성문 전열 유지에 강합니다.' },
|
|
{ unitId: 'mi-zhu', reason: '성내 보급 안정에 도움을 줍니다.' }
|
|
],
|
|
note: '성문 야습은 전열 장악과 보급 유지가 함께 필요합니다.'
|
|
},
|
|
'tenth-battle-xuzhou-breakout': {
|
|
maxUnits: 5,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '탈출전의 패배 조건입니다.' },
|
|
{ unitId: 'guan-yu', reason: '추격대를 막아 퇴로를 엽니다.' },
|
|
{ unitId: 'zhang-fei', reason: '실책을 만회할 돌파와 반격 담당입니다.' },
|
|
{ unitId: 'mi-zhu', reason: '피난민과 보급을 보존합니다.' }
|
|
],
|
|
note: '서주 탈출전은 주력과 보급 담당을 함께 데려가 병력 손실을 줄이는 편성이 좋습니다.'
|
|
},
|
|
'eleventh-battle-xudu-refuge-road': {
|
|
maxUnits: 5,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '조조의 시험을 직접 받아야 합니다.' },
|
|
{ unitId: 'guan-yu', reason: '기령의 주력과 맞설 전열 핵심입니다.' },
|
|
{ unitId: 'jian-yong', reason: '감시 속 군율과 책략 보조를 맡습니다.' },
|
|
{ unitId: 'mi-zhu', reason: '허도 입성 전 보급 안정에 유리합니다.' }
|
|
],
|
|
note: '허도 입성로는 조조군의 감시를 받는 시험입니다. 군율과 보급을 보여줄 무장을 챙기십시오.'
|
|
},
|
|
'twelfth-battle-xiapi-outer-siege': {
|
|
maxUnits: 5,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '조조 휘하 작전에서도 유비군의 명분을 지켜야 합니다.' },
|
|
{ unitId: 'zhang-fei', reason: '여포군 기병 돌파를 막고 둑길을 압박합니다.' },
|
|
{ unitId: 'jian-yong', reason: '조조군 감시 속 명분과 보고 문서를 조율합니다.' },
|
|
{ unitId: 'mi-zhu', reason: '강가 포위전의 보급과 회복 운용에 유리합니다.' }
|
|
],
|
|
note: '하비 외곽전은 강줄기와 둑길이 전장을 나눕니다. 돌파와 보급, 조조군 보고까지 함께 고려하십시오.'
|
|
},
|
|
'thirteenth-battle-xiapi-final': {
|
|
maxUnits: 5,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '여포의 운명을 두고 조조 앞에서 말해야 할 주장입니다.' },
|
|
{ unitId: 'guan-yu', reason: '성문 요새의 전열을 안정적으로 밀어낼 수 있습니다.' },
|
|
{ unitId: 'zhang-fei', reason: '여포군 기병의 돌파를 받아치고 반격하는 역할입니다.' },
|
|
{ unitId: 'jian-yong', reason: '여포 처분과 조조의 시선을 둘러싼 명분 조율에 필요합니다.' }
|
|
],
|
|
note: '하비 결전은 전열과 돌파가 강해야 하지만, 전후 조조와의 긴장을 생각하면 책사 한 명을 챙기는 편성이 좋습니다.'
|
|
},
|
|
'fourteenth-battle-cao-break': {
|
|
maxUnits: 6,
|
|
recommended: [
|
|
{ unitId: 'liu-bei', reason: '조조 휘하를 벗어나는 명분과 생존 조건의 중심입니다.' },
|
|
{ unitId: 'guan-yu', reason: '관문 수비병을 정면에서 밀어낼 전열 핵심입니다.' },
|
|
{ unitId: 'zhang-fei', reason: '길목으로 돌진하는 조조군 기병을 받아치는 역할입니다.' },
|
|
{ unitId: 'sun-qian', reason: '문서와 민심을 정리해 서주 관문 확보 목표에 힘을 보탭니다.' },
|
|
{ unitId: 'mi-zhu', reason: '보급과 회복 도구 운용으로 장기전을 안정시킵니다.' }
|
|
],
|
|
note: '차주의 관문은 길과 강이 겹쳐 전열, 기동, 책사, 보급이 모두 필요합니다. 새로 합류한 손건도 출전 후보로 살펴보십시오.'
|
|
}
|
|
};
|
|
|
|
const campDialogues: CampDialogue[] = [
|
|
{
|
|
id: 'liu-guan-after-first-battle',
|
|
title: '의리를 다지는 밤',
|
|
availableAfterBattleIds: [campBattleIds.first],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 14,
|
|
lines: [
|
|
'유비: 운장, 오늘 그대가 앞을 막아 주지 않았다면 백성들을 지키지 못했을 것이오.',
|
|
'관우: 형님의 뜻이 분명했기에 칼을 들 수 있었습니다. 다음 싸움에서도 길을 열겠습니다.',
|
|
'유비: 우리의 맹세가 전장에서 더 단단해지는구려.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'trust-vanguard',
|
|
label: '앞길을 맡긴다',
|
|
response: '관우는 말없이 고개를 숙였다. 다음 전장에서도 형님의 길을 열겠다는 결의가 깊어졌다.',
|
|
rewardExp: 6
|
|
},
|
|
{
|
|
id: 'protect-people',
|
|
label: '백성 보호를 당부한다',
|
|
response: '관우는 칼끝보다 백성의 숨결을 먼저 살피겠다고 답했다. 두 사람의 뜻이 한층 가까워졌다.',
|
|
rewardExp: 4
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-first-battle',
|
|
title: '거친 용기를 붙잡다',
|
|
availableAfterBattleIds: [campBattleIds.first],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 14,
|
|
lines: [
|
|
'장비: 형님, 다음엔 제가 먼저 뛰쳐나가 두령 놈을 단번에 꺾겠습니다!',
|
|
'유비: 익덕의 용맹은 든든하나, 백성이 뒤에 있음을 잊지 말아야 하오.',
|
|
'장비: 하하! 형님 말이라면 참아 보겠습니다. 대신 싸울 때는 크게 치겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'temper-courage',
|
|
label: '용맹을 다독인다',
|
|
response: '장비는 답답하다는 듯 웃었지만, 형님의 말에 맞춰 창을 거두는 법도 익히겠다고 했다.',
|
|
rewardExp: 5
|
|
},
|
|
{
|
|
id: 'lead-charge',
|
|
label: '선봉을 약속한다',
|
|
response: '장비의 눈빛이 밝아졌다. 믿고 맡긴다는 말은 그에게 무엇보다 큰 격려였다.',
|
|
rewardExp: 7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'guan-zhang-after-first-battle',
|
|
title: '칼과 창의 약속',
|
|
availableAfterBattleIds: [campBattleIds.first],
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
bondId: 'guan-yu__zhang-fei',
|
|
rewardExp: 12,
|
|
lines: [
|
|
'관우: 익덕, 적이 흩어질 때 너무 깊이 들어가면 형님이 걱정하신다.',
|
|
'장비: 운장 형님이 옆을 받쳐 준다면 걱정할 일이 없지 않습니까?',
|
|
'관우: 좋다. 다음 전장에서는 서로의 빈틈을 먼저 살피자.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'cover-flanks',
|
|
label: '서로의 측면을 맡긴다',
|
|
response: '칼과 창이 같은 호흡으로 움직일 길을 찾았다. 두 맹장의 공명이 단단해졌다.',
|
|
rewardExp: 6
|
|
},
|
|
{
|
|
id: 'compete-boldly',
|
|
label: '누가 먼저 베는지 겨룬다',
|
|
response: '장비가 크게 웃고 관우도 미소를 감추지 못했다. 경쟁심마저 전장의 힘이 되었다.',
|
|
rewardExp: 4
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-pursuit',
|
|
title: '추격 뒤의 절제',
|
|
availableAfterBattleIds: [campBattleIds.second],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 15,
|
|
lines: [
|
|
'유비: 잔당을 쫓는 싸움일수록 칼끝이 흐려지기 쉽소. 오늘 운장의 판단이 군을 지켰소.',
|
|
'관우: 형님이 백성의 길을 먼저 살피라 하셨기에 무리한 추격을 멈출 수 있었습니다.',
|
|
'유비: 이기되 어지럽히지 않는 것, 그것이 우리가 지켜야 할 싸움이오.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'steady-line',
|
|
label: '전열의 절제를 칭찬한다',
|
|
response: '관우는 전공보다 전열의 안정이 먼저라는 말을 마음에 새겼다.',
|
|
rewardExp: 6
|
|
},
|
|
{
|
|
id: 'entrust-village',
|
|
label: '마을 수비를 맡긴다',
|
|
response: '관우는 다음 싸움에서도 백성이 있는 곳을 먼저 지키겠다고 답했다.',
|
|
rewardExp: 5
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-pursuit',
|
|
title: '성급함과 선봉',
|
|
availableAfterBattleIds: [campBattleIds.second],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 15,
|
|
lines: [
|
|
'장비: 형님, 추격전에서는 한 발만 더 빨랐다면 놈들을 몽땅 묶어 둘 수 있었습니다.',
|
|
'유비: 익덕의 빠른 창은 큰 힘이오. 다만 혼자 먼저 닿으면 뒤따르는 병사들이 흔들리오.',
|
|
'장비: 좋소. 다음엔 앞서 달리되, 형님 깃발이 보이는 거리 안에서 치겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'controlled-charge',
|
|
label: '통제된 돌격을 약속받는다',
|
|
response: '장비는 선봉의 힘과 군율이 함께 가야 한다는 말을 받아들였다.',
|
|
rewardExp: 6
|
|
},
|
|
{
|
|
id: 'praise-speed',
|
|
label: '빠른 결단을 칭찬한다',
|
|
response: '믿음을 받은 장비는 다음 전장에서 더 날카로운 선봉이 되겠다고 웃었다.',
|
|
rewardExp: 5
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'guan-zhang-after-pursuit',
|
|
title: '길목을 나누다',
|
|
availableAfterBattleIds: [campBattleIds.second],
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
bondId: 'guan-yu__zhang-fei',
|
|
rewardExp: 13,
|
|
lines: [
|
|
'관우: 익덕, 네가 길목을 막아 준 덕에 궁병들이 숨을 돌렸다.',
|
|
'장비: 운장 형님이 옆을 눌러 주니 제가 마음 놓고 앞을 칠 수 있었지요.',
|
|
'관우: 다음에도 누가 먼저 나서든, 남은 한 사람이 뒤를 끊어 주면 된다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'split-roads',
|
|
label: '길목 분담을 정한다',
|
|
response: '두 사람은 좁은 길에서 서로의 창끝과 칼끝이 부딪히지 않을 간격을 익혔다.',
|
|
rewardExp: 6
|
|
},
|
|
{
|
|
id: 'bold-competition',
|
|
label: '전공을 겨루게 둔다',
|
|
response: '가벼운 경쟁심이 두 맹장의 호흡을 더 뜨겁게 만들었다.',
|
|
rewardExp: 4
|
|
}
|
|
]
|
|
},
|
|
{
|
|
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
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-zhang-jue',
|
|
title: '황건의 깃발이 꺾인 밤',
|
|
availableAfterBattleIds: [campBattleIds.fourth],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'유비: 장각이 쓰러졌으나 천하가 바로 맑아진 것은 아니오.',
|
|
'관우: 큰 난이 끝난 뒤에도 작은 탐욕은 남습니다. 형님의 뜻이 흔들리지 않게 제가 곁에 있겠습니다.',
|
|
'유비: 운장과 익덕이 있으니, 더 큰 혼란에도 나아갈 용기가 생기오.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'prepare-larger-chaos',
|
|
label: '더 큰 혼란을 준비한다',
|
|
response: '관우는 반동탁의 소문을 들으며 다시 칼을 정비했다.',
|
|
rewardExp: 7
|
|
},
|
|
{
|
|
id: 'honor-fallen-people',
|
|
label: '희생된 백성을 기린다',
|
|
response: '두 사람은 전공보다 백성의 상처를 먼저 기억하기로 했다.',
|
|
rewardExp: 6
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-zhang-jue',
|
|
title: '끝난 싸움, 남은 분노',
|
|
availableAfterBattleIds: [campBattleIds.fourth],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'장비: 장각을 꺾었는데도 속이 시원하지만은 않습니다. 아직 백성을 괴롭히는 놈들이 많습니다.',
|
|
'유비: 그 분노가 백성을 향하지 않고 악한 자에게만 향하도록 붙잡아야 하오.',
|
|
'장비: 형님이 길을 가리키면, 제 창은 그 길 밖으로 나가지 않겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'temper-anger',
|
|
label: '분노를 다스리게 한다',
|
|
response: '장비는 분노를 창끝에만 싣고 마음에는 남기지 않겠다고 했다.',
|
|
rewardExp: 7
|
|
},
|
|
{
|
|
id: 'promise-next-vanguard',
|
|
label: '다음 선봉을 약속한다',
|
|
response: '장비는 반동탁의 길에서도 가장 먼저 적진을 흔들겠다고 웃었다.',
|
|
rewardExp: 6
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'guan-zhang-after-zhang-jue',
|
|
title: '맹장들의 전공',
|
|
availableAfterBattleIds: [campBattleIds.fourth],
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
bondId: 'guan-yu__zhang-fei',
|
|
rewardExp: 16,
|
|
lines: [
|
|
'장비: 운장 형님, 오늘은 제가 더 크게 적진을 흔든 것 같습니다.',
|
|
'관우: 전공을 다투는 마음이 백성을 구하는 데 쓰인다면 나쁠 것이 없다.',
|
|
'장비: 그럼 다음 전장에서도 누가 더 크게 이름을 떨치는지 봅시다!'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'clean-rivalry',
|
|
label: '깨끗한 경쟁을 인정한다',
|
|
response: '두 맹장은 웃으며 전공 경쟁을 다음 전장의 활력으로 삼았다.',
|
|
rewardExp: 6
|
|
},
|
|
{
|
|
id: 'brothers-before-glory',
|
|
label: '명성보다 형제를 앞세운다',
|
|
response: '관우와 장비는 전공보다 서로의 생존을 먼저 지키기로 했다.',
|
|
rewardExp: 7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-sishui',
|
|
title: '공손찬의 깃발 아래',
|
|
availableAfterBattleIds: [campBattleIds.fifth],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'유비: 공손찬의 진영은 크고, 제후들의 생각은 저마다 다르오. 우리 뜻을 잃지 않는 것이 더 어려워졌소.',
|
|
'관우: 큰 깃발 아래 설수록 작은 의리를 더 단단히 붙잡아야 합니다.',
|
|
'유비: 운장의 말이 옳소. 남의 군영에 있어도 우리가 지킬 길은 변하지 않소.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'hold-oath-in-coalition',
|
|
label: '연합 속에서도 맹세를 지킨다',
|
|
response: '관우는 도원에서 맺은 뜻을 낯선 군영에서도 흐리지 않겠다고 답했다.',
|
|
rewardExp: 7
|
|
},
|
|
{
|
|
id: 'observe-warlords',
|
|
label: '제후들의 속내를 살핀다',
|
|
response: '관우는 싸움만이 아니라 사람의 뜻을 살피는 눈도 필요하다고 받아들였다.',
|
|
rewardExp: 6
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-sishui',
|
|
title: '낯선 군영의 술잔',
|
|
availableAfterBattleIds: [campBattleIds.fifth],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'장비: 형님, 공손찬 군영엔 말도 많고 술도 많고, 괜히 시비 걸 놈들도 많습니다.',
|
|
'유비: 익덕의 기개는 좋으나, 지금은 싸워야 할 적과 참아야 할 말을 구분해야 하오.',
|
|
'장비: 하하, 형님이 그렇게 말하면 술잔은 들되 주먹은 참아 보겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'restrain-in-camp',
|
|
label: '군영 안의 절제를 당부한다',
|
|
response: '장비는 시비보다 형님의 체면을 먼저 지키겠다고 약속했다.',
|
|
rewardExp: 7
|
|
},
|
|
{
|
|
id: 'trust-his-honesty',
|
|
label: '솔직함을 믿어 준다',
|
|
response: '믿음을 받은 장비는 낯선 제후들 사이에서도 형님의 편에 서겠다고 웃었다.',
|
|
rewardExp: 6
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'guan-zhang-after-sishui',
|
|
title: '연합군의 두 맹장',
|
|
availableAfterBattleIds: [campBattleIds.fifth],
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
bondId: 'guan-yu__zhang-fei',
|
|
rewardExp: 16,
|
|
lines: [
|
|
'관우: 이곳에는 이름난 장수도 많고, 허세뿐인 자도 많다.',
|
|
'장비: 그럼 우리가 전장에서 보여 주면 되지요. 말보다 칼과 창이 빠릅니다.',
|
|
'관우: 다만 형님의 이름이 먼저 서야 한다. 우리의 무공은 그 뜻을 받치는 것이니.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'raise-liu-bei-name',
|
|
label: '유비의 이름을 세운다',
|
|
response: '두 사람은 자신의 전공보다 유비의 명성을 먼저 드러내기로 뜻을 모았다.',
|
|
rewardExp: 7
|
|
},
|
|
{
|
|
id: 'test-famous-generals',
|
|
label: '이름난 장수들을 의식한다',
|
|
response: '장비의 승부욕과 관우의 침착함이 묘한 균형을 이루었다.',
|
|
rewardExp: 5
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-jieqiao',
|
|
title: '서주로 향하는 뜻',
|
|
availableAfterBattleIds: [campBattleIds.sixth],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'유비: 공손찬 장군의 은혜는 크나, 서주의 백성이 위태롭다는 소식을 들으니 마음이 편치 않소.',
|
|
'관우: 의탁은 몸을 잠시 맡기는 일이고, 의리는 마음이 향하는 곳을 따르는 일입니다.',
|
|
'유비: 운장의 말이 나를 깨우는구려. 도겸 공의 청을 외면하지 않겠소.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'ask-permission',
|
|
label: '공손찬에게 허락을 구한다',
|
|
response: '관우는 예를 갖추어 떠나는 것이 유비의 이름을 더 높인다고 답했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'put-people-first',
|
|
label: '백성을 먼저 생각한다',
|
|
response: '두 사람은 새 주인의 눈치보다 고통받는 백성을 먼저 보겠다고 뜻을 모았다.',
|
|
rewardExp: 7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-jieqiao',
|
|
title: '떠나는 술잔',
|
|
availableAfterBattleIds: [campBattleIds.sixth],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'장비: 형님, 북방 군영도 나쁘진 않았지만 서주 백성이 부른다면 가야지요.',
|
|
'유비: 익덕은 떠나는 길에서도 성급히 다투지 말아야 하오. 우리는 도움을 주러 가는 것이오.',
|
|
'장비: 알겠습니다. 술잔은 비우고, 창은 백성을 괴롭히는 자에게만 겨누겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'steady-departure',
|
|
label: '차분한 출발을 당부한다',
|
|
response: '장비는 떠나는 순간의 소란도 형님의 이름에 흠이 될 수 있음을 받아들였다.',
|
|
rewardExp: 7
|
|
},
|
|
{
|
|
id: 'promise-xuzhou-vanguard',
|
|
label: '서주 선봉을 맡긴다',
|
|
response: '장비는 서주 길에서도 가장 앞에서 백성을 지키겠다며 창을 움켜쥐었다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'guan-zhang-after-jieqiao',
|
|
title: '새 길의 두 기둥',
|
|
availableAfterBattleIds: [campBattleIds.sixth],
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
bondId: 'guan-yu__zhang-fei',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'관우: 익덕, 서주로 가면 우리 힘만으로는 감당하기 어려운 일도 많을 것이다.',
|
|
'장비: 그러니 더더욱 형님 곁을 단단히 지켜야지요. 운장 형님이 오른쪽이면 저는 왼쪽입니다.',
|
|
'관우: 좋다. 길이 바뀌어도 형님을 받치는 두 기둥은 흔들리지 않는다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'two-pillars',
|
|
label: '좌우의 역할을 정한다',
|
|
response: '두 사람은 유비의 좌우에서 전선과 군심을 함께 지키기로 했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'guard-the-oath',
|
|
label: '도원 맹세를 되새긴다',
|
|
response: '낯선 서주 길 앞에서도 도원의 맹세가 세 사람의 중심이 되었다.',
|
|
rewardExp: 6
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-xuzhou',
|
|
title: '서주의 무게',
|
|
availableAfterBattleIds: [campBattleIds.seventh],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'유비: 도겸 공은 서주를 맡아 달라 하나, 내 그릇이 백성을 감당할 수 있을지 두렵소.',
|
|
'관우: 형님께서 두려워하시는 까닭은 땅이 아니라 백성을 먼저 보기 때문입니다. 그래서 맡을 수 있는 일입니다.',
|
|
'유비: 운장의 말처럼 두려움이 의를 잊지 않게 하는 끈이라면, 그 끈을 놓지 않겠소.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'accept-responsibility',
|
|
label: '책임을 피하지 않는다',
|
|
response: '관우는 유비가 명분보다 책임을 먼저 말하는 것을 보고 깊이 고개를 끄덕였다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'listen-to-xuzhou',
|
|
label: '서주 사람들의 뜻을 듣는다',
|
|
response: '두 사람은 성급히 자리를 받기보다 백성의 마음을 먼저 살피기로 했다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-xuzhou',
|
|
title: '성급함을 다스리다',
|
|
availableAfterBattleIds: [campBattleIds.seventh],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'장비: 형님, 서주가 우리를 부른다면 시원하게 맡으면 되지 않습니까?',
|
|
'유비: 익덕, 기뻐할 일만은 아니오. 성 하나를 맡는다는 것은 그 성의 굶주림과 두려움도 함께 맡는 일이오.',
|
|
'장비: 그렇다면 제가 먼저 군량과 길목을 지키겠습니다. 백성이 굶는 일부터 막아야겠지요.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'guard-supplies',
|
|
label: '군량 길목을 맡긴다',
|
|
response: '장비는 싸움보다 지키는 일이 더 어렵다는 말을 마음에 새겼다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'calm-the-temper',
|
|
label: '분노보다 절제를 당부한다',
|
|
response: '유비의 당부에 장비는 주먹을 풀고, 백성을 놀라게 하지 않겠다고 답했다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'guan-zhang-after-xuzhou',
|
|
title: '새 군영의 좌우',
|
|
availableAfterBattleIds: [campBattleIds.seventh],
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
bondId: 'guan-yu__zhang-fei',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'관우: 서주는 넓고 사람의 마음은 아직 흩어져 있다. 우리가 먼저 흔들리면 형님의 짐이 더 무거워질 것이다.',
|
|
'장비: 걱정 마시오. 운장 형님이 군율을 세우면, 나는 군심을 붙잡겠습니다.',
|
|
'관우: 좋다. 이제 우리는 전장에서뿐 아니라 군영에서도 형님의 좌우가 되어야 한다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'divide-camp-duties',
|
|
label: '군율과 군심을 나눈다',
|
|
response: '관우와 장비는 서로의 강점을 인정하며 새 군영을 안정시키기로 했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'train-new-recruits',
|
|
label: '새 병사를 함께 훈련한다',
|
|
response: '두 장수의 훈련 방식은 달랐지만, 병사들은 그 사이에서 빠르게 자리를 잡았다.',
|
|
rewardExp: 7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-jian-yong-after-xuzhou',
|
|
title: '오랜 벗의 조언',
|
|
availableAfterBattleIds: [campBattleIds.seventh],
|
|
unitIds: ['liu-bei', 'jian-yong'],
|
|
bondId: 'liu-bei__jian-yong',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'간옹: 현덕, 서주를 맡는 일은 칼을 드는 일보다 더 무겁네. 백성은 말보다 먼저 밥과 길을 보네.',
|
|
'유비: 헌화, 내가 그 무게를 감당할 수 있을까 두렵소.',
|
|
'간옹: 두려움을 아는 사람이면 적어도 함부로 빼앗지는 않겠지. 그러니 먼저 사람을 살피게.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'listen-before-rule',
|
|
label: '먼저 백성의 말을 듣는다',
|
|
response: '간옹은 유비가 급히 명분을 세우기보다 서주의 목소리를 먼저 듣겠다고 하자 고개를 끄덕였다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'appoint-practical-aide',
|
|
label: '실무를 맡아 달라 한다',
|
|
response: '간옹은 투덜대면서도 군영 장부와 사람들의 청원을 살피겠다고 나섰다.',
|
|
rewardExp: 7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-mi-zhu-after-xuzhou',
|
|
title: '서주의 군량',
|
|
availableAfterBattleIds: [campBattleIds.seventh],
|
|
unitIds: ['liu-bei', 'mi-zhu'],
|
|
bondId: 'liu-bei__mi-zhu',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'미축: 유 장군, 서주의 창고는 아직 온전하지만 오래 버티려면 상인과 호족의 마음을 함께 묶어야 합니다.',
|
|
'유비: 백성에게 더 짐을 지우고 싶지는 않소. 다른 길이 있겠소?',
|
|
'미축: 사사로운 이익을 줄이고 공적인 신뢰를 세우면 길은 열립니다. 제가 그 일을 맡겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'protect-grain',
|
|
label: '군량을 백성과 나눈다',
|
|
response: '미축은 군량을 무기로 삼지 않겠다는 유비의 뜻을 확인하고 서주의 상단을 설득하기로 했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'secure-supply-route',
|
|
label: '보급로 정비를 부탁한다',
|
|
response: '미축은 곧장 장부를 펼쳐 길목과 창고를 다시 묶는 계획을 세웠다.',
|
|
rewardExp: 7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-mi-zhu-after-xiaopei',
|
|
title: '지켜낸 보급로',
|
|
availableAfterBattleIds: [campBattleIds.eighth],
|
|
unitIds: ['liu-bei', 'mi-zhu'],
|
|
bondId: 'liu-bei__mi-zhu',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'미축: 소패 보급로는 지켰지만, 서주는 아직 안정되었다고 보기 어렵습니다. 창고를 지켜도 사람의 마음이 흩어지면 다시 흔들립니다.',
|
|
'유비: 군량은 칼보다 조용하지만, 백성을 살리는 힘이 있소. 그 일을 계속 맡아 주시오.',
|
|
'미축: 맡겨 주신다면 상단과 호족을 설득해 서주의 숨통을 조금 더 넓히겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'trust-supply-network',
|
|
label: '보급망을 믿고 맡긴다',
|
|
response: '미축은 유비가 실무를 믿고 맡기자 서주의 창고와 상단을 더 단단히 묶겠다고 답했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'share-grain-openly',
|
|
label: '군량 배분을 투명하게 한다',
|
|
response: '유비가 백성 앞에서 군량 배분을 숨기지 않겠다고 하자 미축의 표정이 한결 밝아졌다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-jian-yong-after-xiaopei',
|
|
title: '여포를 맞을 것인가',
|
|
availableAfterBattleIds: [campBattleIds.eighth],
|
|
unitIds: ['liu-bei', 'jian-yong'],
|
|
bondId: 'liu-bei__jian-yong',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'간옹: 여포를 받아들이면 의롭다는 말은 들을 걸세. 하지만 사람들은 그 뒤에 칼집이 몇 개인지도 세어 볼 거야.',
|
|
'유비: 곤궁한 이를 외면할 수는 없소. 그러나 서주 백성의 근심도 가볍게 여길 수 없소.',
|
|
'간옹: 그래서 선택에는 늘 값을 치르는 법이지. 적어도 그 값을 알고 받아들이게.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'accept-with-guard',
|
|
label: '경계를 세우고 맞는다',
|
|
response: '간옹은 유비가 의와 경계를 함께 세우겠다고 하자, 그나마 현실적인 대답이라고 웃었다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'ask-people-first',
|
|
label: '백성의 불안을 먼저 살핀다',
|
|
response: '간옹은 서주 사람들의 두려움을 먼저 살피겠다는 말에 고개를 끄덕였다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'guan-zhang-after-xiaopei',
|
|
title: '성문을 지키는 두 장수',
|
|
availableAfterBattleIds: [campBattleIds.eighth],
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
bondId: 'guan-yu__zhang-fei',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'관우: 여포가 온다면 군율이 먼저 흔들릴 것이다. 성문과 술자리를 모두 조심해야 한다.',
|
|
'장비: 왜 나를 보며 말하시오? 하지만 알겠소. 이번에는 먼저 소리치기보다 성문부터 붙잡겠소.',
|
|
'관우: 형님의 뜻을 지키려면 우리부터 흔들리지 않아야 한다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'guard-gates',
|
|
label: '성문 경계를 강화한다',
|
|
response: '관우와 장비는 성문 근무와 순찰을 나누어 맡기로 했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'restrain-temper',
|
|
label: '장비의 성급함을 다잡는다',
|
|
response: '장비는 툴툴대면서도 이번만큼은 술보다 군율을 먼저 보겠다고 약속했다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-xuzhou-gate',
|
|
title: '성문 뒤의 후회',
|
|
availableAfterBattleIds: [campBattleIds.ninth],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'장비: 형님, 성문은 지켰지만 조표 같은 자들이 움직인 것 자체가 제 불찰입니다.',
|
|
'유비: 익덕, 성급함은 네 강점이자 약점이다. 하지만 후회로 멈추면 다음 성문도 지키지 못한다.',
|
|
'장비: 다음에는 술도, 분노도, 먼저 앞세우지 않겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'trust-reform',
|
|
label: '다시 맡기되 군율을 세운다',
|
|
response: '장비는 꾸짖음보다 믿음을 받은 것에 더 무겁게 고개를 숙였다.',
|
|
rewardExp: 10
|
|
},
|
|
{
|
|
id: 'share-command',
|
|
label: '관우와 함께 성문을 맡긴다',
|
|
response: '장비는 운장 형님과 함께라면 이번 실수를 씻어 보이겠다고 답했다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-jian-yong-after-xuzhou-gate',
|
|
title: '의와 현실 사이',
|
|
availableAfterBattleIds: [campBattleIds.ninth],
|
|
unitIds: ['liu-bei', 'jian-yong'],
|
|
bondId: 'liu-bei__jian-yong',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'간옹: 현덕, 여포를 들인 일은 이제 말 한마디로 되돌릴 수 없네. 사람들은 이미 네 의와 그의 칼을 함께 보고 있어.',
|
|
'유비: 의로 시작한 일이 백성을 위태롭게 한다면, 나는 무엇을 지켜야 하오?',
|
|
'간옹: 그래서 더더욱 눈을 뜨고 있어야지. 의도 잠들면 남의 칼집이 된다네.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'watch-lu-bu',
|
|
label: '여포의 움직임을 감시한다',
|
|
response: '간옹은 마침내 현실적인 지시가 나왔다며 사람을 붙이겠다고 했다.',
|
|
rewardExp: 10
|
|
},
|
|
{
|
|
id: 'protect-civilians-first',
|
|
label: '백성 피난로를 먼저 정한다',
|
|
response: '간옹은 서주를 잃더라도 사람을 잃지 않겠다는 말에 한숨 섞인 미소를 지었다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-mi-after-xuzhou-gate',
|
|
title: '성문과 창고',
|
|
availableAfterBattleIds: [campBattleIds.ninth],
|
|
unitIds: ['liu-bei', 'mi-zhu'],
|
|
bondId: 'liu-bei__mi-zhu',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'미축: 성문이 흔들리면 창고도 오래 버티지 못합니다. 군량을 지키는 길과 성문을 지키는 길은 다르지 않습니다.',
|
|
'유비: 그렇다면 창고의 문서와 수레를 성문 수비와 함께 움직입시다. 길이 끊겨도 백성에게 갈 몫은 남겨 두어야 하오.',
|
|
'미축: 그 뜻이면 충분합니다. 다음 혼란에 대비하겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'move-grain-carts',
|
|
label: '군량 수레를 재배치한다',
|
|
response: '미축은 군량 수레를 성문 안쪽 길목으로 옮겨 비상시에 대비했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'guard-ledgers',
|
|
label: '창고 장부를 보호한다',
|
|
response: '관우는 장부를 지키는 일도 전열을 지키는 일이라며 병사를 배치했다.',
|
|
rewardExp: 7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-xuzhou-loss',
|
|
title: '잃은 성 앞에서',
|
|
availableAfterBattleIds: [campBattleIds.tenth],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 24,
|
|
lines: [
|
|
'장비: 형님, 서주를 잃은 죄는 모두 제게 있습니다. 제 목을 베어 군율을 세우십시오.',
|
|
'유비: 익덕의 목을 베면 서주가 돌아오느냐. 살아서 다시 지키는 것이 네 벌이다.',
|
|
'장비: 다시는 술과 분노가 창끝보다 먼저 나가지 않게 하겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'forgive-and-command',
|
|
label: '용서하되 군율을 맡긴다',
|
|
response: '장비는 꾸지람보다 무거운 신뢰를 받고, 다음 전장에서는 먼저 자신을 다스리겠다고 맹세했다.',
|
|
rewardExp: 11
|
|
},
|
|
{
|
|
id: 'make-amends-through-action',
|
|
label: '공으로 갚게 한다',
|
|
response: '장비는 서주를 잃은 분노를 다음 싸움의 방패로 삼겠다고 답했다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-xuzhou-loss',
|
|
title: '흩어지지 않는 전열',
|
|
availableAfterBattleIds: [campBattleIds.tenth],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'관우: 성은 잃었으나 형님의 깃발 아래 모인 마음은 아직 남았습니다.',
|
|
'유비: 운장, 내가 의를 앞세워 사람을 들였고 그 의가 백성을 흔들었다.',
|
|
'관우: 의는 버릴 것이 아니라 더 단단히 세울 것입니다. 이번 패배가 그 기둥이 되게 하십시오.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'hold-the-banner',
|
|
label: '깃발을 다시 세운다',
|
|
response: '관우는 흩어진 병사들이 볼 수 있도록 유비군의 깃발을 다시 높이 세웠다.',
|
|
rewardExp: 10
|
|
},
|
|
{
|
|
id: 'protect-the-people',
|
|
label: '피난민을 먼저 모은다',
|
|
response: '관우는 패전의 행군에서도 백성을 버리지 않는 것이 형님의 길이라며 고개를 끄덕였다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-jian-yong-after-xuzhou-loss',
|
|
title: '허도로 가는 말',
|
|
availableAfterBattleIds: [campBattleIds.tenth],
|
|
unitIds: ['liu-bei', 'jian-yong'],
|
|
bondId: 'liu-bei__jian-yong',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'간옹: 현덕, 조조에게 몸을 의탁하는 일은 달갑지 않겠지. 하지만 지금은 그 그늘을 지나야 다음 길이 열린다네.',
|
|
'유비: 조조의 그늘이 너무 짙어 내 뜻까지 가리면 어찌하오?',
|
|
'간옹: 그때를 대비해 사람과 말을 잃지 말아야지. 뜻도 살아 있는 사람에게 붙는 법이네.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'send-envoys',
|
|
label: '먼저 사자를 보낸다',
|
|
response: '간옹은 허도로 향할 말을 고르고, 유비군이 굴복이 아닌 임시 의탁임을 전할 문장을 다듬었다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'hide-strength',
|
|
label: '병력 규모를 감춘다',
|
|
response: '간옹은 조조가 보는 숫자와 실제로 보존할 숫자를 나누어 생각해야 한다고 조언했다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-cao-cao-refuge',
|
|
title: '남의 깃발 아래',
|
|
availableAfterBattleIds: [campBattleIds.eleventh],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'관우: 조조의 깃발 아래 서 있으니 병사들의 눈빛이 조심스러워졌습니다.',
|
|
'유비: 운장, 깃발은 잠시 빌릴 수 있어도 뜻은 빌릴 수 없다. 우리가 지켜야 할 선을 잊지 맙시다.',
|
|
'관우: 형님의 뜻이 흔들리지 않는다면, 남의 진영에서도 우리의 칼은 길을 잃지 않을 것입니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'keep-oath-visible',
|
|
label: '도원의 뜻을 분명히 한다',
|
|
response: '관우는 병사들에게 조조의 명을 따르되 유비군의 군율을 잃지 말라고 전했다.',
|
|
rewardExp: 10
|
|
},
|
|
{
|
|
id: 'watch-cao-cao-camp',
|
|
label: '조조 진영의 움직임을 살핀다',
|
|
response: '관우는 지나친 경계가 오히려 의심을 부른다며 조용한 감시를 맡았다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-jian-yong-after-cao-cao-refuge',
|
|
title: '웃음 뒤의 문장',
|
|
availableAfterBattleIds: [campBattleIds.eleventh],
|
|
unitIds: ['liu-bei', 'jian-yong'],
|
|
bondId: 'liu-bei__jian-yong',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'간옹: 조조는 웃으며 맞았지만, 그 웃음은 사람을 묶는 줄이기도 하네.',
|
|
'유비: 그 줄을 끊으면 목이 조이고, 그대로 두면 발이 묶일 것이오.',
|
|
'간옹: 그래서 말과 문서를 조심해야지. 지금은 충심을 보이되, 떠날 길의 이름을 잊지 않는 때라네.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'write-carefully',
|
|
label: '문서를 조심스럽게 정리한다',
|
|
response: '간옹은 조조에게 보일 문서와 유비군 내부 문서를 따로 정리했다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'hear-rumors',
|
|
label: '허도 소문을 모은다',
|
|
response: '간옹은 말 많은 술자리와 시장길에서 조조 진영의 분위기를 살피기로 했다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-mi-after-cao-cao-refuge',
|
|
title: '빌린 창고',
|
|
availableAfterBattleIds: [campBattleIds.eleventh],
|
|
unitIds: ['liu-bei', 'mi-zhu'],
|
|
bondId: 'liu-bei__mi-zhu',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'미축: 조조군의 창고를 빌리더라도 우리 병사의 식량 장부는 따로 남겨 두겠습니다.',
|
|
'유비: 군량 하나에도 마음이 묶이는 법이오. 빌린 것은 빌린 것이라 분명히 해야 하오.',
|
|
'미축: 그렇다면 보급은 받되, 유비군이 스스로 버틸 양도 함께 모아 두겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'separate-ledgers',
|
|
label: '장부를 분리한다',
|
|
response: '미축은 조조군 보급과 유비군 예비 군량을 따로 기록해 훗날의 발목을 줄였다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'save-refugee-grain',
|
|
label: '피난민 군량을 남긴다',
|
|
response: '미축은 병사 몫을 줄이지 않으면서도 피난민에게 갈 곡식을 별도로 숨겨 두었다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-zhang-after-xiapi-outer',
|
|
title: '창끝을 누르는 법',
|
|
availableAfterBattleIds: [campBattleIds.twelfth],
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
bondId: 'liu-bei__zhang-fei',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'장비: 형님, 여포 놈의 깃발이 눈앞에 있는데도 창을 참는 일이 쉽지 않았습니다.',
|
|
'유비: 참았기에 포위망이 무너지지 않았다. 익덕의 창은 분노보다 늦게 나갈 때 더 무겁소.',
|
|
'장비: 다음에는 여포의 말발굽보다 먼저 제 마음을 묶어 두겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'praise-restraint',
|
|
label: '참은 공을 칭찬한다',
|
|
response: '장비는 호통보다 뜻밖의 칭찬에 더 깊이 고개를 숙였고, 다음 싸움에서도 전열을 지키겠다고 다짐했다.',
|
|
rewardExp: 10
|
|
},
|
|
{
|
|
id: 'assign-dike-front',
|
|
label: '둑길 전열을 맡긴다',
|
|
response: '장비는 자신이 앞에서 버티면 여포군 기병도 쉽게 뚫지 못할 것이라며 창대를 움켜쥐었다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-jian-yong-after-xiapi-outer',
|
|
title: '공적을 쓰는 붓',
|
|
availableAfterBattleIds: [campBattleIds.twelfth],
|
|
unitIds: ['liu-bei', 'jian-yong'],
|
|
bondId: 'liu-bei__jian-yong',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'간옹: 오늘 공적은 조조의 장부에도 오르겠지만, 그 장부가 우리 목줄이 되지 않게 써야 하네.',
|
|
'유비: 공을 세워도 빚이 되고, 물러서도 의심이 되는 형국이오.',
|
|
'간옹: 그래서 말의 끝을 남겨야지. 우리는 조조를 위해서만 싸운 것이 아니라 서주 백성을 위해 싸웠다고.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'record-people-first',
|
|
label: '백성 보호를 먼저 기록한다',
|
|
response: '간옹은 전공 보고의 첫 줄에 피난민과 보급로 보호를 적어 유비군의 명분을 분명히 했다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'keep-private-notes',
|
|
label: '별도 기록을 남긴다',
|
|
response: '간옹은 조조에게 보일 보고와 훗날 유비군이 기억할 기록을 따로 남겼다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-mi-after-xiapi-outer',
|
|
title: '물가의 보급선',
|
|
availableAfterBattleIds: [campBattleIds.twelfth],
|
|
unitIds: ['liu-bei', 'mi-zhu'],
|
|
bondId: 'liu-bei__mi-zhu',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'미축: 강가 포위전은 싸움보다 군량이 먼저 흔들립니다. 조조군 배급만 믿으면 우리 병사 마음도 함께 묶입니다.',
|
|
'유비: 독자 보급이 곧 독자적인 마음이오. 작은 양이라도 우리가 스스로 마련할 길을 남깁시다.',
|
|
'미축: 그렇다면 하비 외곽에서 거둔 전리품은 공용과 예비로 나누어 두겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'split-siege-supplies',
|
|
label: '보급을 둘로 나눈다',
|
|
response: '미축은 조조군 공용 보급과 유비군 예비 보급을 나누어, 훗날 떠날 수 있는 여지를 남겼다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'secure-river-cart',
|
|
label: '강가 수레를 확보한다',
|
|
response: '미축은 강가에서 쓸 수레와 배를 따로 챙겨 다음 포위전의 군량 흐름을 안정시켰다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-guan-after-lu-bu-fall',
|
|
title: '무용과 믿음',
|
|
availableAfterBattleIds: [campBattleIds.thirteenth],
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
bondId: 'liu-bei__guan-yu',
|
|
rewardExp: 24,
|
|
lines: [
|
|
'관우: 여포의 무용은 천하가 알았으나, 끝내 누구도 오래 믿게 하지 못했습니다.',
|
|
'유비: 힘만으로 길을 열 수는 있어도 사람 마음을 머물게 하지는 못하오.',
|
|
'관우: 그렇다면 형님의 길은 더 무거워질 것입니다. 저 또한 그 길을 지키겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'honor-trust-over-force',
|
|
label: '믿음의 무게를 말한다',
|
|
response: '관우는 여포의 말로를 힘의 패배가 아니라 믿음의 패배로 새기며 결의를 다졌다.',
|
|
rewardExp: 10
|
|
},
|
|
{
|
|
id: 'prepare-for-cao-watch',
|
|
label: '조조의 시선을 경계한다',
|
|
response: '관우는 칼을 거두는 때도 전투라 여기며 조조의 경계를 살피겠다고 답했다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-jian-yong-after-lu-bu-fall',
|
|
title: '말 한마디의 무게',
|
|
availableAfterBattleIds: [campBattleIds.thirteenth],
|
|
unitIds: ['liu-bei', 'jian-yong'],
|
|
bondId: 'liu-bei__jian-yong',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'간옹: 현덕, 여포가 사라졌으니 조조는 이제 우리를 더 오래 바라볼 걸세.',
|
|
'유비: 오늘 한 말이 내일의 의심이 될 수 있겠군.',
|
|
'간옹: 그러니 말은 짧게, 뜻은 길게 남기세. 떠날 명분은 오늘부터 쌓아야 하네.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'keep-words-measured',
|
|
label: '말을 아끼게 한다',
|
|
response: '간옹은 조조 앞에서 드러낼 말과 속으로 남길 뜻을 분리해 두었다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'collect-leaving-cause',
|
|
label: '떠날 명분을 모은다',
|
|
response: '간옹은 조조 휘하에서 겪는 작은 어긋남도 훗날의 기록으로 남기기 시작했다.',
|
|
rewardExp: 10
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-mi-after-lu-bu-fall',
|
|
title: '남겨 둘 군량',
|
|
availableAfterBattleIds: [campBattleIds.thirteenth],
|
|
unitIds: ['liu-bei', 'mi-zhu'],
|
|
bondId: 'liu-bei__mi-zhu',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'미축: 하비의 보급품은 조조군 장부로 들어가겠지만, 우리 병사들이 떠날 때 쓸 몫도 필요합니다.',
|
|
'유비: 떠날 길을 생각하는 것이 불충으로 보일 수도 있소.',
|
|
'미축: 뜻을 지키려면 길도 있어야 합니다. 군량은 의리를 움직이게 하는 바퀴입니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'set-aside-march-grain',
|
|
label: '행군 군량을 남긴다',
|
|
response: '미축은 장부 밖으로 새지 않는 선에서 유비군의 독자 행군 물자를 확보했다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'protect-xu-refugees',
|
|
label: '서주 피난민을 챙긴다',
|
|
response: '미축은 하비 뒤편의 피난민에게 갈 식량을 남겨 유비군의 명분을 조용히 지켰다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-sun-after-cao-break',
|
|
title: '명분의 문서',
|
|
availableAfterBattleIds: [campBattleIds.fourteenth],
|
|
unitIds: ['liu-bei', 'sun-qian'],
|
|
bondId: 'liu-bei__sun-qian',
|
|
rewardExp: 22,
|
|
lines: [
|
|
'손건: 주공, 차주의 관문은 열렸으나 조조의 눈은 더 날카로워질 것입니다. 이제부터는 말 한마디와 문서 한 장이 모두 칼날입니다.',
|
|
'유비: 힘으로만 길을 뚫는다면 백성의 마음을 잃겠지. 그대의 글이 우리 군의 숨길을 열어 주었소.',
|
|
'손건: 그렇다면 다음 길목에서는 싸움보다 먼저 명분을 세우겠습니다. 원소에게 가는 길도 그냥 피난이 되어서는 안 됩니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'write-for-people',
|
|
label: '백성을 앞세운 격문을 쓴다',
|
|
response: '손건은 유비군이 조조를 떠나는 이유를 백성의 안위와 서주의 책임으로 정리해 각 진영에 보낼 문안을 준비했다.',
|
|
rewardExp: 10
|
|
},
|
|
{
|
|
id: 'write-for-alliance',
|
|
label: '원소에게 보낼 명분을 다듬는다',
|
|
response: '손건은 북상하는 길의 위험과 원소 진영에서 필요한 예법을 정리하며 유비가 다음 의탁을 준비하도록 도왔다.',
|
|
rewardExp: 9
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'jian-sun-after-cao-break',
|
|
title: '말과 글의 길',
|
|
availableAfterBattleIds: [campBattleIds.fourteenth],
|
|
unitIds: ['jian-yong', 'sun-qian'],
|
|
bondId: 'jian-yong__sun-qian',
|
|
rewardExp: 20,
|
|
lines: [
|
|
'간옹: 손 선생, 글은 곧지만 세상은 구부러져 있소. 너무 반듯하게 쓰면 칼보다 먼저 부러질 때도 있지.',
|
|
'손건: 그렇기에 간옹 님의 말이 필요합니다. 글이 문을 두드리면, 말은 그 문을 열게 만들 수 있으니까요.',
|
|
'간옹: 좋소. 그럼 나는 웃으며 시간을 벌 테니, 선생은 그 틈에 빠져나갈 길을 문서로 만들구려.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'soften-message',
|
|
label: '문장을 부드럽게 고친다',
|
|
response: '간옹은 손건의 문서에 숨 쉴 틈을 더했고, 손건은 그 말투 속에서도 뜻이 흐려지지 않게 문장을 다시 세웠다.',
|
|
rewardExp: 9
|
|
},
|
|
{
|
|
id: 'prepare-messenger-route',
|
|
label: '사자 동선을 짠다',
|
|
response: '두 사람은 관문과 역참을 피해 갈 사자 동선을 나누어 적의 감시망을 늦게 흔들 계획을 세웠다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'liu-mi-after-cao-break',
|
|
title: '떠나는 군량',
|
|
availableAfterBattleIds: [campBattleIds.fourteenth],
|
|
unitIds: ['liu-bei', 'mi-zhu'],
|
|
bondId: 'liu-bei__mi-zhu',
|
|
rewardExp: 18,
|
|
lines: [
|
|
'미축: 주공, 북쪽 길은 멀고 조조군의 추격은 빠릅니다. 군량을 아끼면 병사가 지치고, 풀어 쓰면 다음 진영에 닿기 전 바닥날 수 있습니다.',
|
|
'유비: 백성에게서 억지로 거둘 수는 없소. 우리가 떠나는 길 또한 의로워야 하오.',
|
|
'미축: 그래서 서주의 상단을 나누어 보내겠습니다. 병사에게는 당장의 밥을, 백성에게는 우리가 돌아올 이유를 남기겠습니다.'
|
|
],
|
|
choices: [
|
|
{
|
|
id: 'protect-refugee-supplies',
|
|
label: '피난민 몫을 남긴다',
|
|
response: '미축은 유비군의 짐을 줄이는 대신 피난민에게 남길 곡식을 따로 묶어, 떠나는 길의 민심을 보존했다.',
|
|
rewardExp: 8
|
|
},
|
|
{
|
|
id: 'stock-northern-road',
|
|
label: '북상 보급을 숨겨 둔다',
|
|
response: '미축은 상단의 표식을 숨긴 짐수레를 북쪽 길에 먼저 보내, 추격을 피해 움직일 여지를 만들었다.',
|
|
rewardExp: 8
|
|
}
|
|
]
|
|
}
|
|
];
|
|
|
|
export class CampScene extends Phaser.Scene {
|
|
private campaign?: CampaignState;
|
|
private report?: FirstBattleReport;
|
|
private selectedUnitId = 'liu-bei';
|
|
private activeTab: CampTab = 'status';
|
|
private selectedDialogueId = campDialogues[0].id;
|
|
private contentObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private tabButtons: CampTabButtonView[] = [];
|
|
private visitedTabs = new Set<CampTab>();
|
|
private selectedSortieUnitIds: string[] = [];
|
|
|
|
constructor() {
|
|
super('CampScene');
|
|
}
|
|
|
|
create() {
|
|
this.contentObjects = [];
|
|
this.dialogueObjects = [];
|
|
this.sortieObjects = [];
|
|
this.tabButtons = [];
|
|
this.visitedTabs = new Set(['status']);
|
|
this.campaign = getCampaignState();
|
|
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
|
|
this.ensureCurrentCampRecruitment();
|
|
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
|
|
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
|
|
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
|
|
soundDirector.playMusic('militia-theme');
|
|
|
|
const { width, height } = this.scale;
|
|
this.add.image(width / 2, height / 2, 'story-militia').setDisplaySize(width * 1.08, height * 1.08).setAlpha(0.62);
|
|
this.add.rectangle(0, 0, width, height, 0x06090d, 0.54).setOrigin(0);
|
|
this.add.rectangle(0, 0, width, 84, 0x06090d, 0.52).setOrigin(0);
|
|
|
|
this.add.text(42, 28, this.currentCampTitle(), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '30px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700',
|
|
stroke: '#05070a',
|
|
strokeThickness: 4
|
|
});
|
|
this.add.text(42, 64, this.reportSummary(), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '14px',
|
|
color: '#d4dce6'
|
|
});
|
|
|
|
this.renderStaticButtons();
|
|
this.render();
|
|
}
|
|
|
|
private createFallbackReport(): FirstBattleReport {
|
|
const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally');
|
|
return {
|
|
battleId: defaultBattleScenario.id,
|
|
battleTitle: defaultBattleScenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 1,
|
|
rewardGold: 300,
|
|
defeatedEnemies: 0,
|
|
totalEnemies: firstBattleUnits.filter((unit) => unit.faction === 'enemy').length,
|
|
objectives: [],
|
|
units: allies.map((unit) => JSON.parse(JSON.stringify(unit)) as UnitData),
|
|
bonds: firstBattleBonds.map((bond) => ({ ...bond, battleExp: 0 })),
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
private ensureCurrentCampRecruitment() {
|
|
const battleId = this.currentCampBattleId();
|
|
if (!this.campaign) {
|
|
return;
|
|
}
|
|
|
|
if (battleId === campBattleIds.thirteenth || battleId === campBattleIds.fourteenth) {
|
|
this.campaign = ensureCampaignRosterUnits(
|
|
[...xuzhouRecruitUnits, ...caoBreakRecruitUnits],
|
|
[...xuzhouRecruitBonds, ...caoBreakRecruitBonds]
|
|
);
|
|
this.report = this.campaign.firstBattleReport ?? this.report;
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(battleId !== campBattleIds.seventh &&
|
|
battleId !== campBattleIds.eighth &&
|
|
battleId !== campBattleIds.ninth &&
|
|
battleId !== campBattleIds.tenth &&
|
|
battleId !== campBattleIds.eleventh &&
|
|
battleId !== campBattleIds.twelfth)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.campaign = ensureCampaignRosterUnits(xuzhouRecruitUnits, xuzhouRecruitBonds);
|
|
this.report = this.campaign.firstBattleReport ?? this.report;
|
|
}
|
|
|
|
private reportSummary() {
|
|
if (!this.report) {
|
|
return '';
|
|
}
|
|
|
|
const achieved = this.report.objectives.filter((objective) => objective.achieved).length;
|
|
const gold = this.campaign?.gold ?? this.report.rewardGold;
|
|
return `${this.report.battleTitle} ${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`;
|
|
}
|
|
|
|
private currentCampTitle() {
|
|
const battleId = this.currentCampBattleId();
|
|
if (battleId === campBattleIds.fourteenth) {
|
|
return '서주 재기 후 군영';
|
|
}
|
|
if (battleId === campBattleIds.thirteenth) {
|
|
return '하비 결전 후 군영';
|
|
}
|
|
if (battleId === campBattleIds.twelfth) {
|
|
return '하비 포위 군영';
|
|
}
|
|
if (battleId === campBattleIds.eleventh) {
|
|
return '허도 의탁 군영';
|
|
}
|
|
if (battleId === campBattleIds.tenth) {
|
|
return '서주 상실 후 군영';
|
|
}
|
|
if (battleId === campBattleIds.ninth) {
|
|
return '서주 성문 군영';
|
|
}
|
|
if (battleId === campBattleIds.eighth) {
|
|
return '소패 방위 군영';
|
|
}
|
|
if (battleId === campBattleIds.seventh) {
|
|
return '서주 인수 준비 군영';
|
|
}
|
|
if (battleId === campBattleIds.fifth) {
|
|
return '공손찬 진영 군영';
|
|
}
|
|
if (battleId === campBattleIds.sixth) {
|
|
return '서주 원군 준비 군영';
|
|
}
|
|
if (battleId === campBattleIds.fourth) {
|
|
return '광종 토벌 후 군영';
|
|
}
|
|
if (battleId === campBattleIds.third) {
|
|
return '광종 구원로 군영';
|
|
}
|
|
if (battleId === campBattleIds.second) {
|
|
return '황건 잔당 추격 군영';
|
|
}
|
|
return '탁현 의용군 군영';
|
|
}
|
|
|
|
private currentCampBattleId() {
|
|
return this.campaign?.latestBattleId ?? this.report?.battleId ?? defaultBattleScenario.id;
|
|
}
|
|
|
|
private availableCampDialogues() {
|
|
const battleId = this.currentCampBattleId();
|
|
const dialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId));
|
|
return dialogues.length > 0 ? dialogues : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id));
|
|
}
|
|
|
|
private completedAvailableDialogues() {
|
|
const completed = this.completedCampDialogues();
|
|
return this.availableCampDialogues().filter((dialogue) => completed.includes(dialogue.id));
|
|
}
|
|
|
|
private renderStaticButtons() {
|
|
this.addTabButton('장수', 'status', 742, 38);
|
|
this.addTabButton('대화', 'dialogue', 830, 38);
|
|
this.addTabButton('정비', 'supplies', 918, 38);
|
|
this.addCommandButton('저장', 1002, 38, 76, () => {
|
|
soundDirector.playSelect();
|
|
this.campaign = saveCampaignState(getCampaignState());
|
|
this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`);
|
|
this.render();
|
|
});
|
|
this.addCommandButton('다음 이야기', 1120, 38, 142, () => {
|
|
soundDirector.playSelect();
|
|
this.showSortiePrep();
|
|
});
|
|
}
|
|
|
|
private addTabButton(label: string, tab: CampTab, x: number, y: number) {
|
|
const bg = this.add.rectangle(x, y, 76, 34, 0x182431, 0.94);
|
|
bg.setStrokeStyle(1, palette.blue, 0.62);
|
|
bg.setInteractive({ useHandCursor: true });
|
|
bg.on('pointerdown', () => {
|
|
soundDirector.playSelect();
|
|
this.activeTab = tab;
|
|
this.render();
|
|
});
|
|
|
|
const text = this.add.text(x, y, label, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
});
|
|
text.setOrigin(0.5);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', () => {
|
|
soundDirector.playSelect();
|
|
this.activeTab = tab;
|
|
this.render();
|
|
});
|
|
this.tabButtons.push({ tab, bg, text });
|
|
this.updateTabButtons();
|
|
}
|
|
|
|
private updateTabButtons() {
|
|
this.tabButtons.forEach(({ tab, bg, text }) => {
|
|
const active = this.activeTab === tab;
|
|
bg.setFillStyle(active ? 0x25384a : 0x182431, active ? 0.98 : 0.94);
|
|
bg.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.86 : 0.62);
|
|
text.setColor(active ? '#fff2b8' : '#f2e3bf');
|
|
});
|
|
}
|
|
|
|
private addCommandButton(label: string, x: number, y: number, width: number, action: () => void) {
|
|
const bg = this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96);
|
|
bg.setStrokeStyle(1, palette.gold, 0.8);
|
|
bg.setInteractive({ useHandCursor: true });
|
|
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
|
|
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
|
|
bg.on('pointerdown', action);
|
|
|
|
const text = this.add.text(x, y, label, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: '#f2e3bf',
|
|
fontStyle: '700'
|
|
});
|
|
text.setOrigin(0.5);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', action);
|
|
}
|
|
|
|
private render() {
|
|
this.clearContent();
|
|
this.visitedTabs.add(this.activeTab);
|
|
this.updateTabButtons();
|
|
this.renderUnitColumn();
|
|
this.renderReportPanel();
|
|
|
|
if (this.activeTab === 'dialogue') {
|
|
this.renderDialoguePanel();
|
|
return;
|
|
}
|
|
|
|
if (this.activeTab === 'supplies') {
|
|
this.renderSuppliesPanel();
|
|
return;
|
|
}
|
|
|
|
this.renderStatusPanel();
|
|
}
|
|
|
|
private showSortiePrep() {
|
|
this.hideSortiePrep();
|
|
this.campaign = getCampaignState();
|
|
this.report = this.campaign.firstBattleReport ?? this.report;
|
|
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
|
|
|
|
const depth = 40;
|
|
const width = 1188;
|
|
const height = 676;
|
|
const x = Math.floor((this.scale.width - width) / 2);
|
|
const y = Math.floor((this.scale.height - height) / 2);
|
|
|
|
const shade = this.trackSortie(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68));
|
|
shade.setOrigin(0);
|
|
shade.setDepth(depth);
|
|
shade.setInteractive();
|
|
|
|
const panel = this.trackSortie(this.add.rectangle(x, y, width, height, 0x101820, 0.98));
|
|
panel.setOrigin(0);
|
|
panel.setDepth(depth + 1);
|
|
panel.setStrokeStyle(3, palette.gold, 0.92);
|
|
|
|
const title = this.trackSortie(this.add.text(x + 34, y + 28, '출진 준비', this.textStyle(30, '#f2e3bf', true)));
|
|
title.setDepth(depth + 2);
|
|
const subtitle = this.trackSortie(
|
|
this.add.text(x + 34, y + 70, '대화, 보급, 상인 정비를 마친 뒤 함께 출전할 무장을 선택합니다.', this.textStyle(15, '#d4dce6'))
|
|
);
|
|
subtitle.setDepth(depth + 2);
|
|
|
|
this.renderSortieBriefing(x + 34, y + 104, 520, 168, depth + 2);
|
|
this.renderSortieChecklist(x + 576, y + 104, 578, 168, depth + 2);
|
|
this.renderSortieUnitSummary(x + 34, y + 292, 728, 286, depth + 2);
|
|
this.renderSortieFormationPlan(x + 782, y + 292, 372, 286, depth + 2);
|
|
this.renderSortieRewardHint(x + 34, y + 596, 808, 52, depth + 2);
|
|
|
|
this.addSortieButton('군영으로', x + width - 298, y + height - 42, 124, () => {
|
|
soundDirector.playSelect();
|
|
this.hideSortiePrep();
|
|
}, depth + 3);
|
|
this.addSortieButton('출진', x + width - 152, y + height - 42, 124, () => {
|
|
soundDirector.playSelect();
|
|
this.startVictoryStory();
|
|
}, depth + 3);
|
|
}
|
|
|
|
private renderSortieBriefing(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.blue, 0.48);
|
|
|
|
const briefing = this.nextSortieBriefing();
|
|
this.trackSortie(this.add.text(x + 18, y + 14, briefing.eyebrow, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 18, y + 48, briefing.title, this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
|
|
this.trackSortie(
|
|
this.add.text(x + 18, y + 84, briefing.description, {
|
|
...this.textStyle(14, '#d4dce6'),
|
|
wordWrap: { width: width - 36, useAdvancedWrap: true },
|
|
lineSpacing: 4
|
|
})
|
|
).setDepth(depth + 1);
|
|
this.trackSortie(
|
|
this.add.text(x + 18, y + height - 30, `편성 주안점: ${this.nextSortieRule().note}`, {
|
|
...this.textStyle(12, '#d8b15f', true),
|
|
wordWrap: { width: width - 36, useAdvancedWrap: true }
|
|
})
|
|
).setDepth(depth + 1);
|
|
}
|
|
|
|
private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.gold, 0.5);
|
|
this.trackSortie(this.add.text(x + 18, y + 14, '준비 체크', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
|
|
|
|
this.sortieChecklist().forEach((item, index) => {
|
|
const rowY = y + 42 + index * 27;
|
|
const color = item.complete ? '#a8ffd0' : '#ffdf7b';
|
|
const status = item.complete ? '완료' : '주의';
|
|
this.trackSortie(this.add.text(x + 18, rowY, status, this.textStyle(13, color, true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 70, rowY, item.label, this.textStyle(14, '#d4dce6', true))).setDepth(depth + 1);
|
|
const detail = this.trackSortie(this.add.text(x + 190, rowY, item.detail, this.textStyle(12, item.complete ? '#9fb0bf' : '#d8b15f')));
|
|
detail.setDepth(depth + 1);
|
|
});
|
|
}
|
|
|
|
private renderSortieUnitSummary(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.blue, 0.48);
|
|
const allies = this.sortieAllies();
|
|
const selectedCount = this.selectedSortieUnitIds.length;
|
|
const maxUnits = this.sortieMaxUnits();
|
|
const rowGap = allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48;
|
|
const rowHeight = rowGap - 6;
|
|
this.trackSortie(
|
|
this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${maxUnits}`, this.textStyle(18, '#f2e3bf', true))
|
|
).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1);
|
|
|
|
allies.forEach((unit, index) => {
|
|
const rowY = y + 50 + index * rowGap;
|
|
const selected = this.isSortieSelected(unit.id);
|
|
const required = requiredSortieUnitIds.has(unit.id);
|
|
const recruited = !foundingSortieUnitIds.has(unit.id);
|
|
const recommendation = this.sortieRecommendation(unit.id);
|
|
const summary = this.sortieUnitTacticalSummary(unit);
|
|
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
|
|
row.setOrigin(0);
|
|
row.setDepth(depth + 1);
|
|
row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38);
|
|
row.setInteractive({ useHandCursor: !required });
|
|
row.on('pointerdown', () => this.toggleSortieUnit(unit.id));
|
|
|
|
const marker = selected ? '출전' : '대기';
|
|
const markerLabel = required ? '필수' : recommendation ? '추천' : recruited ? '합류' : marker;
|
|
this.trackSortie(this.add.text(x + 30, rowY, markerLabel, this.textStyle(12, required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2);
|
|
this.trackSortie(this.add.text(x + 84, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
|
|
this.trackSortie(this.add.text(x + 214, rowY, this.compactText(`${unit.className} · ${getUnitClass(unit.classKey).role}`, 19), this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
|
|
this.trackSortie(this.add.text(x + 424, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
|
|
this.drawSortieBar(x + 514, rowY + 6, 74, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
|
|
this.trackSortie(this.add.text(x + 610, rowY, this.compactText(summary.terrainLine, 13), this.textStyle(11, selected ? '#d8b15f' : '#77818c', true))).setDepth(depth + 2);
|
|
|
|
this.renderSortieEquipmentIcons(unit, x + 84, rowY + 19, selected, depth + 2);
|
|
this.trackSortie(this.add.text(x + 162, rowY + 18, this.compactText(summary.equipmentLine, 34), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
|
|
this.trackSortie(this.add.text(x + 424, rowY + 18, this.compactText(summary.statLine, 23), this.textStyle(11, selected ? '#c8d2dd' : '#68727e'))).setDepth(depth + 2);
|
|
this.trackSortie(this.add.text(x + 610, rowY + 18, this.compactText(summary.bondLine, 15), this.textStyle(11, selected ? '#a8ffd0' : '#68727e', selected))).setDepth(depth + 2);
|
|
});
|
|
}
|
|
|
|
private renderSortieFormationPlan(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.gold, 0.5);
|
|
|
|
const plan = this.sortiePlanSummary();
|
|
this.trackSortie(this.add.text(x + 18, y + 14, '전술 편성', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
|
|
this.trackSortie(
|
|
this.add.text(x + width - 18, y + 17, `${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade}`, this.textStyle(12, '#d8b15f', true))
|
|
).setOrigin(1, 0).setDepth(depth + 1);
|
|
|
|
this.renderSortieDeploymentMap(x + 18, y + 46, width - 36, 92, depth + 1);
|
|
|
|
const summaryLines = [
|
|
plan.deploymentLine,
|
|
`${plan.recommendationLine} · 공명 ${plan.activeBondCount}개`,
|
|
plan.recruitedLine,
|
|
plan.objectiveLine
|
|
];
|
|
summaryLines.forEach((line, index) => {
|
|
this.trackSortie(this.add.text(x + 18, y + 146 + index * 16, this.compactText(line, 32), this.textStyle(12, index === 0 ? '#f2e3bf' : '#d4dce6', index === 0))).setDepth(depth + 1);
|
|
});
|
|
|
|
plan.formationSlots.forEach((slot, index) => {
|
|
const column = index % 2;
|
|
const row = Math.floor(index / 2);
|
|
const rowX = x + 18 + column * 176;
|
|
const rowY = y + 218 + row * 28;
|
|
const names = slot.unitNames.length > 0 ? slot.unitNames.join(', ') : '미배치';
|
|
const color = slot.unitNames.length > 0 ? '#a8ffd0' : '#9fb0bf';
|
|
this.trackSortie(this.add.text(rowX, rowY, slot.label, this.textStyle(12, color, true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(rowX + 42, rowY, this.compactText(names, 13), this.textStyle(11, '#c8d2dd'))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(rowX + 42, rowY + 13, this.compactText(slot.description, 14), this.textStyle(10, '#87919c'))).setDepth(depth + 1);
|
|
});
|
|
|
|
const warning = plan.warnings[0] ?? plan.reserveLine;
|
|
this.trackSortie(this.add.text(x + 18, y + height - 28, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1);
|
|
}
|
|
|
|
private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) {
|
|
equipmentSlots.forEach((slot, index) => {
|
|
const state = unit.equipment[slot];
|
|
const item = getItem(state.itemId);
|
|
const iconFrame = this.trackSortie(this.add.rectangle(x + index * 23, y, 18, 18, selected && item.rank === 'treasure' ? 0x2c2630 : 0x0a1017, 0.92));
|
|
iconFrame.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : 0x53606c, selected ? 0.62 : 0.34);
|
|
iconFrame.setDepth(depth);
|
|
const icon = this.trackSortie(this.add.image(x + index * 23, y, `item-${item.id}`));
|
|
icon.setDisplaySize(14, 14);
|
|
icon.setAlpha(selected ? 1 : 0.52);
|
|
icon.setDepth(depth + 1);
|
|
});
|
|
}
|
|
|
|
private sortieUnitTacticalSummary(unit: UnitData): SortieUnitTacticalSummary {
|
|
return {
|
|
statLine: `무 ${unit.stats.might} 지 ${unit.stats.intelligence} 통 ${unit.stats.leadership} 민 ${unit.stats.agility} 운 ${unit.stats.luck}`,
|
|
equipmentLine: this.sortieEquipmentLine(unit),
|
|
bondLine: this.sortieBondLine(unit),
|
|
terrainLine: this.sortieTerrainLine(unit)
|
|
};
|
|
}
|
|
|
|
private sortieEquipmentLine(unit: UnitData) {
|
|
return equipmentSlots
|
|
.map((slot) => {
|
|
const state = unit.equipment[slot];
|
|
const item = getItem(state.itemId);
|
|
return `${item.name} Lv${state.level}`;
|
|
})
|
|
.join(' · ');
|
|
}
|
|
|
|
private sortieBondLine(unit: UnitData) {
|
|
const selected = new Set(this.selectedSortieUnitIds);
|
|
const activeBonds = this.currentBonds()
|
|
.filter((bond) => bond.unitIds.includes(unit.id))
|
|
.map((bond) => {
|
|
const partnerId = bond.unitIds.find((unitId) => unitId !== unit.id) ?? bond.unitIds[0];
|
|
return {
|
|
bond,
|
|
partnerId,
|
|
partnerName: this.unitName(partnerId),
|
|
partnerSelected: selected.has(partnerId)
|
|
};
|
|
})
|
|
.sort((a, b) => Number(b.partnerSelected) - Number(a.partnerSelected) || b.bond.level - a.bond.level || b.bond.exp - a.bond.exp);
|
|
|
|
const deployedBond = activeBonds.find((entry) => entry.partnerSelected);
|
|
if (deployedBond) {
|
|
return `공명 ${deployedBond.partnerName} Lv${deployedBond.bond.level}`;
|
|
}
|
|
|
|
const strongestBond = activeBonds[0];
|
|
if (strongestBond) {
|
|
return `대기 공명 ${strongestBond.partnerName} Lv${strongestBond.bond.level}`;
|
|
}
|
|
|
|
return '공명 관계 없음';
|
|
}
|
|
|
|
private sortieTerrainLine(unit: UnitData) {
|
|
const scenario = this.nextSortieScenario();
|
|
if (!scenario) {
|
|
return `${getUnitClass(unit.classKey).role}`;
|
|
}
|
|
|
|
const terrainCounts = this.passableTerrainCounts(scenario);
|
|
const unitClass = getUnitClass(unit.classKey);
|
|
const entries = Object.entries(terrainCounts) as [TerrainType, number][];
|
|
const total = entries.reduce((sum, [, count]) => sum + count, 0);
|
|
if (total <= 0) {
|
|
return `${unitClass.role}`;
|
|
}
|
|
|
|
const weightedScore =
|
|
entries.reduce((sum, [terrain, count]) => sum + (unitClass.terrainRatings[terrain] ?? 0) * count, 0) / total;
|
|
const grade = weightedScore >= 110 ? '최적' : weightedScore >= 102 ? '유리' : weightedScore >= 95 ? '보통' : '주의';
|
|
const bestTerrains = entries
|
|
.sort((a, b) => (unitClass.terrainRatings[b[0]] ?? 0) - (unitClass.terrainRatings[a[0]] ?? 0) || b[1] - a[1])
|
|
.slice(0, 2)
|
|
.map(([terrain]) => terrainRules[terrain].label)
|
|
.join('/');
|
|
|
|
return `지형 ${grade}${bestTerrains ? ` · ${bestTerrains}` : ''}`;
|
|
}
|
|
|
|
private passableTerrainCounts(scenario: BattleScenarioDefinition) {
|
|
const counts = {} as Record<TerrainType, number>;
|
|
scenario.map.terrain.forEach((row) => {
|
|
row.forEach((terrain) => {
|
|
if (terrainRules[terrain].passable === false) {
|
|
return;
|
|
}
|
|
counts[terrain] = (counts[terrain] ?? 0) + 1;
|
|
});
|
|
});
|
|
return counts;
|
|
}
|
|
|
|
private nextSortieScenario() {
|
|
const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId;
|
|
return nextBattleId ? getBattleScenario(nextBattleId) : undefined;
|
|
}
|
|
|
|
private nextSortieRule(scenario = this.nextSortieScenario()) {
|
|
if (!scenario) {
|
|
return defaultSortieRule;
|
|
}
|
|
return sortieRulesByBattleId[scenario.id] ?? defaultSortieRule;
|
|
}
|
|
|
|
private sortieMaxUnits(scenario = this.nextSortieScenario()) {
|
|
return Math.min(this.nextSortieRule(scenario).maxUnits, this.sortieAllies().length);
|
|
}
|
|
|
|
private sortieRecommendation(unitId: string, scenario = this.nextSortieScenario()) {
|
|
return this.nextSortieRule(scenario).recommended.find((entry) => entry.unitId === unitId);
|
|
}
|
|
|
|
private renderSortieDeploymentMap(x: number, y: number, width: number, height: number, depth: number) {
|
|
const scenario = this.nextSortieScenario();
|
|
const frame = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.9));
|
|
frame.setOrigin(0);
|
|
frame.setDepth(depth);
|
|
frame.setStrokeStyle(1, palette.blue, 0.48);
|
|
|
|
if (!scenario) {
|
|
this.trackSortie(this.add.text(x + 14, y + 34, '다음 전투 배치 정보가 없습니다.', this.textStyle(12, '#9fb0bf'))).setDepth(depth + 1);
|
|
return;
|
|
}
|
|
|
|
const mapWidth = scenario.map.width;
|
|
const mapHeight = scenario.map.height;
|
|
const cell = Math.min((width - 16) / mapWidth, (height - 16) / mapHeight);
|
|
const originX = x + Math.floor((width - mapWidth * cell) / 2);
|
|
const originY = y + Math.floor((height - mapHeight * cell) / 2);
|
|
const graphics = this.trackSortie(this.add.graphics());
|
|
graphics.setDepth(depth + 1);
|
|
|
|
scenario.map.terrain.forEach((row, rowIndex) => {
|
|
row.forEach((terrain, columnIndex) => {
|
|
const rule = terrainRules[terrain];
|
|
graphics.fillStyle(rule.color, terrain === 'river' || terrain === 'cliff' ? 0.82 : 0.58);
|
|
graphics.fillRect(originX + columnIndex * cell, originY + rowIndex * cell, Math.ceil(cell), Math.ceil(cell));
|
|
});
|
|
});
|
|
|
|
const selected = new Set(this.selectedSortieUnitIds);
|
|
scenario.units.forEach((unit) => {
|
|
if (unit.faction !== 'enemy' && !selected.has(unit.id)) {
|
|
return;
|
|
}
|
|
const dotX = originX + (unit.x + 0.5) * cell;
|
|
const dotY = originY + (unit.y + 0.5) * cell;
|
|
const color = unit.faction === 'enemy' ? 0xd35f4a : unit.id === 'liu-bei' ? palette.gold : palette.green;
|
|
const alpha = unit.faction === 'enemy' ? 0.72 : 0.96;
|
|
const dot = this.trackSortie(this.add.circle(dotX, dotY, unit.faction === 'enemy' ? 2.2 : 3.7, color, alpha));
|
|
dot.setDepth(depth + 2);
|
|
if (unit.faction !== 'enemy') {
|
|
dot.setStrokeStyle(1, 0x05070a, 0.8);
|
|
}
|
|
});
|
|
|
|
this.trackSortie(this.add.text(x + 10, y + height - 16, '녹색: 출전 아군 · 붉은색: 적 배치', this.textStyle(10, '#9fb0bf'))).setDepth(depth + 2);
|
|
}
|
|
|
|
private sortiePlanSummary(): SortiePlanSummary {
|
|
const allAllies = this.sortieAllies();
|
|
const selectedUnits = this.selectedSortieUnits();
|
|
const selectedIds = new Set(selectedUnits.map((unit) => unit.id));
|
|
const scenario = this.nextSortieScenario();
|
|
const rule = this.nextSortieRule(scenario);
|
|
const availableIds = new Set(allAllies.map((unit) => unit.id));
|
|
const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId));
|
|
const missingRecommended = recommended.filter((entry) => !selectedIds.has(entry.unitId));
|
|
const recruitedUnits = allAllies.filter((unit) => !foundingSortieUnitIds.has(unit.id));
|
|
const selectedRecruitedCount = recruitedUnits.filter((unit) => selectedIds.has(unit.id)).length;
|
|
const reserveUnits = allAllies.filter((unit) => !selectedIds.has(unit.id));
|
|
const reserveNames = reserveUnits.map((unit) => unit.name).join(', ');
|
|
const terrainScores = selectedUnits.map((unit) => this.sortieTerrainScore(unit, scenario));
|
|
const terrainScore = terrainScores.length
|
|
? Math.round(terrainScores.reduce((sum, score) => sum + score, 0) / terrainScores.length)
|
|
: 0;
|
|
const terrainGrade = this.sortieTerrainGrade(terrainScore);
|
|
const activeBondCount = this.currentBonds().filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length;
|
|
const recommendedSelectedCount = recommended.filter((entry) => selectedIds.has(entry.unitId)).length;
|
|
const roleCounts = selectedUnits.reduce(
|
|
(counts, unit) => {
|
|
counts[this.sortieFormationRole(unit)] += 1;
|
|
return counts;
|
|
},
|
|
{ front: 0, flank: 0, support: 0, reserve: 0 } as Record<SortieFormationRole, number>
|
|
);
|
|
const formationSlots = sortieFormationSlotDefinitions.map((slot) => ({
|
|
...slot,
|
|
unitNames: selectedUnits.filter((unit) => this.sortieFormationRole(unit) === slot.role).map((unit) => unit.name)
|
|
}));
|
|
const warnings: string[] = [];
|
|
const maxCount = this.sortieMaxUnits(scenario);
|
|
|
|
if (selectedUnits.length < maxCount) {
|
|
warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`);
|
|
}
|
|
if (missingRecommended.length > 0) {
|
|
warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')}이 대기 중입니다.`);
|
|
}
|
|
if (roleCounts.front <= 0) {
|
|
warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.');
|
|
}
|
|
if (roleCounts.flank <= 0) {
|
|
warnings.push('돌파 병과가 없어 추격과 측면 압박이 느릴 수 있습니다.');
|
|
}
|
|
if (roleCounts.support <= 0) {
|
|
warnings.push('후원 병과가 없어 회복, 보급, 원거리 견제가 약합니다.');
|
|
}
|
|
if (activeBondCount <= 0 && selectedUnits.length > 1) {
|
|
warnings.push('함께 출전하는 공명 조합이 없습니다.');
|
|
}
|
|
if (terrainScore > 0 && terrainScore < 98) {
|
|
warnings.push('선택 무장의 평균 지형 적성이 낮습니다.');
|
|
}
|
|
|
|
return {
|
|
selectedCount: selectedUnits.length,
|
|
maxCount,
|
|
terrainScore,
|
|
terrainGrade,
|
|
activeBondCount,
|
|
recruitedCount: recruitedUnits.length,
|
|
selectedRecruitedCount,
|
|
reserveCount: reserveUnits.length,
|
|
recommendedSelectedCount,
|
|
recommendedTotal: recommended.length,
|
|
deploymentLine: `전열 ${roleCounts.front} · 돌파 ${roleCounts.flank} · 후원 ${roleCounts.support} · 예비 ${roleCounts.reserve}`,
|
|
recommendationLine: recommended.length > 0 ? `추천 ${recommendedSelectedCount}/${recommended.length}` : '추천 없음',
|
|
recruitedLine:
|
|
recruitedUnits.length > 0
|
|
? `합류 무장 ${selectedRecruitedCount}/${recruitedUnits.length} · 대기 ${reserveUnits.length}`
|
|
: '합류 무장 없음',
|
|
reserveLine: reserveUnits.length > 0 ? `대기 무장: ${reserveNames}` : '전원 출전 중입니다.',
|
|
objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음',
|
|
formationSlots,
|
|
warnings
|
|
};
|
|
}
|
|
|
|
private sortieFormationRole(unit: UnitData): SortieFormationRole {
|
|
if (unit.classKey === 'cavalry') {
|
|
return 'flank';
|
|
}
|
|
if (unit.classKey === 'archer' || unit.classKey === 'strategist' || unit.classKey === 'quartermaster') {
|
|
return 'support';
|
|
}
|
|
if (unit.classKey === 'lord' || unit.classKey === 'infantry' || unit.classKey === 'spearman') {
|
|
return 'front';
|
|
}
|
|
return 'reserve';
|
|
}
|
|
|
|
private sortieTerrainScore(unit: UnitData, scenario: BattleScenarioDefinition | undefined) {
|
|
if (!scenario) {
|
|
return 100;
|
|
}
|
|
const terrainCounts = this.passableTerrainCounts(scenario);
|
|
const entries = Object.entries(terrainCounts) as [TerrainType, number][];
|
|
const total = entries.reduce((sum, [, count]) => sum + count, 0);
|
|
if (total <= 0) {
|
|
return 100;
|
|
}
|
|
const unitClass = getUnitClass(unit.classKey);
|
|
return Math.round(entries.reduce((sum, [terrain, count]) => sum + (unitClass.terrainRatings[terrain] ?? 0) * count, 0) / total);
|
|
}
|
|
|
|
private sortieTerrainGrade(score: number) {
|
|
if (score >= 110) {
|
|
return '최적';
|
|
}
|
|
if (score >= 102) {
|
|
return '유리';
|
|
}
|
|
if (score >= 95) {
|
|
return '보통';
|
|
}
|
|
return score > 0 ? '주의' : '미정';
|
|
}
|
|
|
|
private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.gold, 0.4);
|
|
const availableDialogues = this.availableCampDialogues();
|
|
const completedDialogues = this.completedAvailableDialogues().length;
|
|
const inventory = this.inventoryLabels().join(', ');
|
|
const selectedNames = this.selectedSortieUnits().map((unit) => unit.name).join(', ');
|
|
const reward = getSortieFlow(this.campaign?.latestBattleId).rewardHint;
|
|
this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
|
|
}
|
|
|
|
private nextSortieBriefing() {
|
|
const flow = getSortieFlow(this.campaign?.latestBattleId);
|
|
return {
|
|
eyebrow: flow.eyebrow,
|
|
title: flow.title,
|
|
description: flow.description
|
|
};
|
|
}
|
|
|
|
private sortieChecklist(): SortieChecklistItem[] {
|
|
const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
|
|
const liuBei = units.find((unit) => unit.id === 'liu-bei');
|
|
const injured = units.filter((unit) => unit.hp < unit.maxHp);
|
|
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
|
|
const availableDialogues = this.availableCampDialogues();
|
|
const completedDialogues = this.completedAvailableDialogues().length;
|
|
const selected = this.selectedSortieUnits();
|
|
return [
|
|
{
|
|
label: '유비 생존',
|
|
complete: Boolean(liuBei && liuBei.hp > 0),
|
|
detail: liuBei ? `병력 ${liuBei.hp}/${liuBei.maxHp}` : '유비 없음'
|
|
},
|
|
{
|
|
label: '부상 장수 확인',
|
|
complete: injured.length === 0,
|
|
detail: injured.length === 0 ? '전원 완전한 병력' : `${injured.map((unit) => unit.name).join(', ')} 회복 권장`
|
|
},
|
|
{
|
|
label: '출전 구성',
|
|
complete: selected.some((unit) => unit.id === 'liu-bei') && selected.length > 0,
|
|
detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : '출전 무장 선택 필요'
|
|
},
|
|
{
|
|
label: '소모품 보유',
|
|
complete: supplyCount > 0,
|
|
detail: supplyCount > 0 ? `보유 ${supplyCount}` : '정비 탭에서 보급 확인'
|
|
},
|
|
{
|
|
label: '공명 대화',
|
|
complete: availableDialogues.length === 0 || completedDialogues >= availableDialogues.length,
|
|
detail: `${completedDialogues}/${availableDialogues.length} 완료`
|
|
},
|
|
{
|
|
label: '장비 상태',
|
|
complete: this.visitedTabs.has('status'),
|
|
detail: this.visitedTabs.has('status') ? '장수 탭 확인됨' : '장수 탭 확인 권장'
|
|
}
|
|
];
|
|
}
|
|
|
|
private addSortieButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96));
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, label === '출진' ? palette.gold : palette.blue, 0.78);
|
|
bg.setInteractive({ useHandCursor: true });
|
|
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98));
|
|
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96));
|
|
bg.on('pointerdown', action);
|
|
|
|
const text = this.trackSortie(this.add.text(x, y, label, this.textStyle(15, '#f2e3bf', true)));
|
|
text.setOrigin(0.5);
|
|
text.setDepth(depth + 1);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', action);
|
|
}
|
|
|
|
private startVictoryStory() {
|
|
const flow = getSortieFlow(this.campaign?.latestBattleId);
|
|
if (!this.ensureSortieSelectionSaved()) {
|
|
this.showCampNotice('출전할 무장을 선택하세요. 유비는 반드시 출전해야 합니다.');
|
|
return;
|
|
}
|
|
|
|
if (!flow.nextBattleId || !flow.campaignStep || flow.pages.length === 0) {
|
|
this.hideSortiePrep();
|
|
if (flow.pages.length > 0) {
|
|
this.scene.start('StoryScene', {
|
|
pages: flow.pages,
|
|
nextScene: 'CampScene'
|
|
});
|
|
return;
|
|
}
|
|
this.showCampNotice(flow.unavailableNotice ?? '다음 장은 아직 준비 중입니다. 군영에서 성장 상태를 정비하세요.');
|
|
return;
|
|
}
|
|
|
|
markCampaignStep(flow.campaignStep);
|
|
this.scene.start('StoryScene', {
|
|
pages: flow.pages,
|
|
nextScene: 'BattleScene',
|
|
nextSceneData: { battleId: flow.nextBattleId }
|
|
});
|
|
}
|
|
|
|
private hideSortiePrep() {
|
|
this.sortieObjects.forEach((object) => object.destroy());
|
|
this.sortieObjects = [];
|
|
}
|
|
|
|
private trackSortie<T extends Phaser.GameObjects.GameObject>(object: T) {
|
|
this.sortieObjects.push(object);
|
|
return object;
|
|
}
|
|
|
|
private drawSortieBar(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) {
|
|
const track = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
|
|
track.setOrigin(0);
|
|
track.setDepth(depth);
|
|
const fill = this.trackSortie(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96));
|
|
fill.setOrigin(0);
|
|
fill.setDepth(depth + 1);
|
|
}
|
|
|
|
private renderUnitColumn() {
|
|
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
|
|
const availableHeight = 462;
|
|
const rowGap = allies.length > 1 ? Math.min(150, Math.floor(availableHeight / allies.length)) : 150;
|
|
const cardHeight = Math.min(126, Math.max(70, rowGap - 10));
|
|
const compact = cardHeight < 104;
|
|
allies.forEach((unit, index) => {
|
|
const x = 42;
|
|
const y = 120 + index * rowGap;
|
|
const active = this.selectedUnitId === unit.id;
|
|
const bg = this.track(this.add.rectangle(x, y, 318, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46);
|
|
bg.setInteractive({ useHandCursor: true });
|
|
bg.on('pointerdown', () => {
|
|
soundDirector.playSelect();
|
|
this.selectedUnitId = unit.id;
|
|
this.render();
|
|
});
|
|
|
|
const portraitKey = portraitByUnitId[unit.id];
|
|
const portraitCenterY = y + cardHeight / 2;
|
|
const portraitSize = compact ? 62 : 92;
|
|
if (portraitKey) {
|
|
const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey]));
|
|
portrait.setDisplaySize(portraitSize, portraitSize);
|
|
} else {
|
|
this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 31 : 44, unit);
|
|
}
|
|
|
|
const textX = compact ? x + 102 : x + 116;
|
|
this.track(this.add.text(textX, y + (compact ? 13 : 20), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 17 : 21, '#f2e3bf', true)));
|
|
this.track(this.add.text(textX, y + (compact ? 38 : 51), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 12 : 14, '#d4dce6')));
|
|
this.track(this.add.text(textX, y + (compact ? 58 : 78), `경험 ${unit.exp}/100`, this.textStyle(compact ? 12 : 14, '#d8b15f', true)));
|
|
this.drawBar(textX, y + cardHeight - 18, compact ? 160 : 176, 8, unit.exp / 100, palette.gold);
|
|
});
|
|
}
|
|
|
|
private renderUnitFallbackPortrait(x: number, y: number, radius: number, unit: UnitData) {
|
|
const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96));
|
|
bg.setStrokeStyle(2, palette.gold, 0.58);
|
|
const initial = this.track(this.add.text(x, y - radius * 0.34, unit.name.slice(0, 1), this.textStyle(Math.max(17, Math.floor(radius * 0.86)), '#f2e3bf', true)));
|
|
initial.setOrigin(0.5);
|
|
const label = this.track(this.add.text(x, y + radius * 0.34, unit.className, this.textStyle(Math.max(9, Math.floor(radius * 0.3)), '#d4dce6', true)));
|
|
label.setOrigin(0.5);
|
|
}
|
|
|
|
private renderReportPanel() {
|
|
const report = this.report;
|
|
if (!report) {
|
|
return;
|
|
}
|
|
|
|
const x = 42;
|
|
const y = 590;
|
|
const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.48);
|
|
this.track(this.add.text(x + 18, y + 14, '전투 보고', this.textStyle(18, '#f2e3bf', true)));
|
|
this.track(
|
|
this.add.text(
|
|
x + 18,
|
|
y + 43,
|
|
`MVP ${report.mvp?.name ?? '-'} · 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`,
|
|
this.textStyle(15, '#d4dce6')
|
|
)
|
|
);
|
|
}
|
|
|
|
private renderStatusPanel() {
|
|
const unit = this.selectedUnit();
|
|
if (!unit) {
|
|
return;
|
|
}
|
|
|
|
const x = 394;
|
|
const y = 120;
|
|
const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.blue, 0.56);
|
|
|
|
const unitClass = getUnitClass(unit.classKey);
|
|
const portraitKey = portraitByUnitId[unit.id];
|
|
const portraitFrame = this.track(this.add.rectangle(x + 84, y + 90, 122, 122, 0x0b1219, 0.92));
|
|
portraitFrame.setStrokeStyle(2, palette.gold, 0.62);
|
|
if (portraitKey) {
|
|
const portrait = this.track(this.add.image(x + 84, y + 90, portraitKeys[portraitKey]));
|
|
portrait.setDisplaySize(112, 112);
|
|
} else {
|
|
this.renderUnitFallbackPortrait(x + 84, y + 90, 48, unit);
|
|
}
|
|
|
|
this.track(this.add.text(x + 166, y + 22, `${unit.name} Lv ${unit.level}`, this.textStyle(27, '#f2e3bf', true)));
|
|
this.track(this.add.text(x + 166, y + 60, `${unit.className} · ${unitClass.family} · ${unitClass.role}`, this.textStyle(15, '#d8b15f', true)));
|
|
this.track(
|
|
this.add.text(x + 166, y + 91, unitClass.description, {
|
|
...this.textStyle(14, '#d4dce6'),
|
|
wordWrap: { width: 324, useAdvancedWrap: true },
|
|
lineSpacing: 2
|
|
})
|
|
);
|
|
this.renderDetailGauge('병력', `${unit.hp}/${unit.maxHp}`, unit.hp / unit.maxHp, x + 166, y + 142, 300, palette.green);
|
|
this.renderDetailGauge('경험', `${unit.exp}/100`, unit.exp / 100, x + 166, y + 168, 300, palette.gold);
|
|
|
|
const statX = x + 524;
|
|
const stats = [
|
|
['공격', unit.attack],
|
|
['이동', unit.move],
|
|
['무력', unit.stats.might],
|
|
['지력', unit.stats.intelligence],
|
|
['통솔', unit.stats.leadership],
|
|
['민첩', unit.stats.agility],
|
|
['운', unit.stats.luck]
|
|
] as const;
|
|
stats.forEach(([label, value], index) => {
|
|
this.renderStatCard(label, `${value}`, statX + (index % 2) * 128, y + 22 + Math.floor(index / 2) * 42, 116);
|
|
});
|
|
|
|
this.track(this.add.text(x + 24, y + 210, '장비', this.textStyle(20, '#f2e3bf', true)));
|
|
equipmentSlots.forEach((slot, index) => {
|
|
this.renderEquipmentCard(unit, slot, x + 24 + index * 260, y + 244, 244, 102);
|
|
});
|
|
|
|
this.renderSelectedUnitBondPanel(unit, x + 24, y + 362, 780);
|
|
}
|
|
|
|
private renderDialoguePanel() {
|
|
const x = 394;
|
|
const y = 120;
|
|
const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.58);
|
|
this.track(this.add.text(x + 24, y + 22, '장수 대화', this.textStyle(24, '#f2e3bf', true)));
|
|
this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
|
|
|
|
const dialogues = this.availableCampDialogues();
|
|
const compactDialogueList = dialogues.length > 3;
|
|
const dialogueRowGap = compactDialogueList ? 44 : 64;
|
|
const dialogueRowHeight = compactDialogueList ? 36 : 48;
|
|
if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) {
|
|
this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id;
|
|
}
|
|
|
|
dialogues.forEach((dialogue, index) => {
|
|
const completed = this.completedCampDialogues().includes(dialogue.id);
|
|
const rowY = y + 96 + index * dialogueRowGap;
|
|
const selected = this.selectedDialogueId === dialogue.id;
|
|
const row = this.track(this.add.rectangle(x + 24, rowY, 320, dialogueRowHeight, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
|
|
row.setOrigin(0);
|
|
row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
|
|
row.setInteractive({ useHandCursor: true });
|
|
row.on('pointerdown', () => {
|
|
soundDirector.playSelect();
|
|
this.selectedDialogueId = dialogue.id;
|
|
this.render();
|
|
});
|
|
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 5 : 8), dialogue.title, this.textStyle(compactDialogueList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
|
|
const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
|
|
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 22 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 10 : 12, '#9fb0bf')));
|
|
});
|
|
|
|
this.renderSelectedDialogue(x + 372, y + 96, 416, 300);
|
|
this.renderBondList(x + 24, y + 308, 320);
|
|
}
|
|
|
|
private renderSelectedDialogue(x: number, y: number, width: number, height: number) {
|
|
const dialogues = this.availableCampDialogues();
|
|
const dialogue = dialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? dialogues[0];
|
|
if (!dialogue) {
|
|
const empty = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
empty.setOrigin(0);
|
|
empty.setStrokeStyle(1, palette.blue, 0.42);
|
|
this.track(this.add.text(x + 18, y + 16, '대화 없음', this.textStyle(20, '#f2e3bf', true)));
|
|
this.track(this.add.text(x + 18, y + 54, '현재 군영에서 가능한 장수 대화가 없습니다.', this.textStyle(14, '#9fb0bf')));
|
|
return;
|
|
}
|
|
const completed = this.completedCampDialogues().includes(dialogue.id);
|
|
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
|
|
this.track(this.add.text(x + 18, y + 16, dialogue.title, this.textStyle(20, '#f2e3bf', true)));
|
|
this.track(
|
|
this.add.text(x + 18, y + 52, dialogue.lines.join('\n\n'), {
|
|
...this.textStyle(15, '#d4dce6'),
|
|
wordWrap: { width: width - 36, useAdvancedWrap: true },
|
|
lineSpacing: 5
|
|
})
|
|
);
|
|
|
|
if (completed) {
|
|
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
|
|
done.setStrokeStyle(1, palette.green, 0.76);
|
|
const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료', this.textStyle(15, '#a8ffd0', true)));
|
|
text.setOrigin(0.5);
|
|
return;
|
|
}
|
|
|
|
dialogue.choices.forEach((choice, index) => {
|
|
const choiceY = y + height - 80 + index * 42;
|
|
const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
|
|
button.setStrokeStyle(1, palette.gold, 0.76);
|
|
button.setInteractive({ useHandCursor: true });
|
|
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
|
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
|
button.on('pointerdown', () => this.completeDialogue(dialogue, choice));
|
|
|
|
const label = `${choice.label} 공명 +${dialogue.rewardExp + choice.rewardExp}`;
|
|
const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(14, '#f2e3bf', true)));
|
|
text.setOrigin(0.5);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', () => this.completeDialogue(dialogue, choice));
|
|
});
|
|
}
|
|
|
|
private renderBondList(x: number, y: number, width: number) {
|
|
const bonds = this.currentBonds();
|
|
const compact = bonds.length > 3;
|
|
const rowGap = compact ? 25 : 36;
|
|
this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true)));
|
|
bonds.forEach((bond, index) => {
|
|
const rowY = y + (compact ? 28 : 32) + index * rowGap;
|
|
const first = this.unitName(bond.unitIds[0]);
|
|
const second = this.unitName(bond.unitIds[1]);
|
|
this.track(this.add.text(x, rowY, `${first}·${second} Lv ${bond.level}`, this.textStyle(compact ? 11 : 13, '#d4dce6')));
|
|
this.drawBar(x + 136, rowY + 6, width - 136, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue);
|
|
});
|
|
}
|
|
|
|
private renderSuppliesPanel() {
|
|
const x = 394;
|
|
const y = 120;
|
|
const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.blue, 0.56);
|
|
this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true)));
|
|
this.track(this.add.text(x + 24, y + 58, '소모품은 왼쪽에서 선택한 장수에게 사용됩니다.', this.textStyle(14, '#d4dce6')));
|
|
|
|
const unit = this.selectedUnit();
|
|
if (unit) {
|
|
this.renderSupplyTargetCard(unit, x + 24, y + 94, 342, 86);
|
|
}
|
|
|
|
campSupplies.forEach((supply, index) => {
|
|
this.renderSupplyUseRow(supply, x + 390, y + 94 + index * 74, 390);
|
|
});
|
|
|
|
this.renderMerchantPanel(x + 24, y + 202, 342);
|
|
|
|
this.track(this.add.text(x + 390, y + 330, '전리품과 명성', this.textStyle(19, '#f2e3bf', true)));
|
|
const trophies = this.nonConsumableInventoryLabels();
|
|
if (trophies.length === 0) {
|
|
this.track(this.add.text(x + 390, y + 364, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf')));
|
|
} else {
|
|
trophies.forEach((reward, index) => {
|
|
this.renderSupplyBox(reward, x + 390 + index * 128, y + 360);
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderMerchantPanel(x: number, y: number, width: number) {
|
|
const campaign = this.campaign ?? getCampaignState();
|
|
const bg = this.track(this.add.rectangle(x, y, width, 210, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.48);
|
|
this.track(this.add.text(x + 16, y + 10, `군영 상인 · 군자금 ${campaign.gold}`, this.textStyle(17, '#f2e3bf', true)));
|
|
|
|
merchantItems.forEach((item, index) => {
|
|
this.renderMerchantItem(item, x + 16, y + 44 + index * 52, width - 32);
|
|
});
|
|
}
|
|
|
|
private renderMerchantItem(item: MerchantItemDefinition, x: number, y: number, width: number) {
|
|
const campaign = this.campaign ?? getCampaignState();
|
|
const canBuy = campaign.gold >= item.price;
|
|
const bg = this.track(this.add.rectangle(x, y, width, 40, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.58 : 0.36);
|
|
this.track(this.add.text(x + 10, y + 6, item.title, this.textStyle(13, canBuy ? '#f2e3bf' : '#7f8994', true)));
|
|
this.track(this.add.text(x + 10, y + 23, `${item.price}금`, this.textStyle(11, canBuy ? '#d8b15f' : '#7f8994', true)));
|
|
|
|
const button = this.track(this.add.rectangle(x + width - 42, y + 20, 60, 26, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.96 : 0.72));
|
|
button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.72 : 0.4);
|
|
const text = this.track(this.add.text(x + width - 42, y + 20, '구매', this.textStyle(12, canBuy ? '#f2e3bf' : '#7f8994', true)));
|
|
text.setOrigin(0.5);
|
|
|
|
if (canBuy) {
|
|
const action = () => this.buyMerchantItem(item);
|
|
button.setInteractive({ useHandCursor: true });
|
|
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
|
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
|
button.on('pointerdown', action);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', action);
|
|
}
|
|
}
|
|
|
|
private buyMerchantItem(item: MerchantItemDefinition) {
|
|
const campaign = this.campaign ?? getCampaignState();
|
|
if (campaign.gold < item.price) {
|
|
this.showCampNotice('군자금이 부족합니다.');
|
|
return;
|
|
}
|
|
|
|
campaign.gold -= item.price;
|
|
campaign.inventory[item.label] = (campaign.inventory[item.label] ?? 0) + 1;
|
|
this.campaign = saveCampaignState(campaign);
|
|
this.report = this.campaign.firstBattleReport ?? this.report;
|
|
soundDirector.playSelect();
|
|
this.showCampNotice(`${item.title} 구입 · 군자금 -${item.price}`);
|
|
this.render();
|
|
}
|
|
|
|
private renderSupplyBox(label: string, x: number, y: number) {
|
|
const bg = this.track(this.add.rectangle(x, y, 138, 50, 0x151f2a, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.52);
|
|
this.track(this.add.text(x + 14, y + 15, label, this.textStyle(16, '#f2e3bf', true)));
|
|
}
|
|
|
|
private renderSupplyTargetCard(unit: UnitData, x: number, y: number, width: number, height: number) {
|
|
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.48);
|
|
this.track(this.add.text(x + 16, y + 12, '사용 대상', this.textStyle(15, '#f2e3bf', true)));
|
|
this.track(this.add.text(x + 16, y + 38, `${unit.name} ${unit.className} Lv ${unit.level}`, this.textStyle(16, '#d4dce6', true)));
|
|
this.track(this.add.text(x + 16, y + 62, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#9fb0bf', true)));
|
|
this.drawBar(x + 118, y + 68, width - 138, 8, unit.hp / unit.maxHp, palette.green);
|
|
}
|
|
|
|
private renderSupplyUseRow(supply: CampSupplyDefinition, x: number, y: number, width: number) {
|
|
const unit = this.selectedUnit();
|
|
const amount = this.inventoryAmount(supply.label);
|
|
const enabled = Boolean(unit && this.canUseSupply(supply, unit));
|
|
const bg = this.track(this.add.rectangle(x, y, width, 62, enabled ? 0x151f2a : 0x111820, enabled ? 0.92 : 0.76));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.62 : 0.38);
|
|
|
|
this.track(this.add.text(x + 14, y + 10, `${supply.title} x${amount}`, this.textStyle(16, amount > 0 ? '#f2e3bf' : '#7f8994', true)));
|
|
this.track(
|
|
this.add.text(x + 14, y + 34, supply.description, {
|
|
...this.textStyle(12, enabled ? '#c8d2dd' : '#77818c'),
|
|
wordWrap: { width: width - 128, useAdvancedWrap: true }
|
|
})
|
|
);
|
|
|
|
const button = this.track(this.add.rectangle(x + width - 54, y + 31, 82, 30, enabled ? 0x1a2630 : 0x121922, enabled ? 0.96 : 0.72));
|
|
button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.76 : 0.42);
|
|
const buttonText = this.track(this.add.text(x + width - 54, y + 31, enabled ? '사용' : '불가', this.textStyle(13, enabled ? '#f2e3bf' : '#7f8994', true)));
|
|
buttonText.setOrigin(0.5);
|
|
|
|
if (enabled) {
|
|
const action = () => this.useSupply(supply);
|
|
button.setInteractive({ useHandCursor: true });
|
|
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
|
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
|
button.on('pointerdown', action);
|
|
buttonText.setInteractive({ useHandCursor: true });
|
|
buttonText.on('pointerdown', action);
|
|
}
|
|
}
|
|
|
|
private canUseSupply(supply: CampSupplyDefinition, unit: UnitData) {
|
|
if (this.inventoryAmount(supply.label) <= 0) {
|
|
return false;
|
|
}
|
|
if (supply.healHp > 0 && unit.hp < unit.maxHp) {
|
|
return true;
|
|
}
|
|
return supply.bondExp > 0 && this.currentBonds().some((bond) => bond.unitIds.includes(unit.id));
|
|
}
|
|
|
|
private useSupply(supply: CampSupplyDefinition) {
|
|
const campaign = this.campaign ?? getCampaignState();
|
|
const target = campaign.roster.find((unit) => unit.id === this.selectedUnitId);
|
|
if (!target) {
|
|
this.showCampNotice('사용할 장수를 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
if ((campaign.inventory[supply.label] ?? 0) <= 0) {
|
|
this.showCampNotice(`${supply.title}이 없습니다.`);
|
|
return;
|
|
}
|
|
|
|
const previousHp = target.hp;
|
|
target.hp = Math.min(target.maxHp, target.hp + supply.healHp);
|
|
const hpGain = target.hp - previousHp;
|
|
const bondGain = this.applySupplyBondExp(campaign, target.id, supply.bondExp);
|
|
|
|
if (hpGain <= 0 && bondGain <= 0) {
|
|
this.showCampNotice(`${target.name}에게 지금은 사용할 필요가 없습니다.`);
|
|
return;
|
|
}
|
|
|
|
campaign.inventory[supply.label] -= 1;
|
|
if (campaign.inventory[supply.label] <= 0) {
|
|
delete campaign.inventory[supply.label];
|
|
}
|
|
this.syncReportUnitHp(campaign, target.id, target.hp);
|
|
|
|
this.campaign = saveCampaignState(campaign);
|
|
this.report = this.campaign.firstBattleReport ?? this.report;
|
|
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 });
|
|
|
|
const hpText = hpGain > 0 ? `병력 +${hpGain}` : '';
|
|
const bondText = bondGain > 0 ? `공명 +${supply.bondExp}` : '';
|
|
this.showCampNotice(`${target.name} ${supply.title} 사용 · ${[hpText, bondText].filter(Boolean).join(' / ')}`);
|
|
this.render();
|
|
}
|
|
|
|
private applySupplyBondExp(campaign: CampaignState, unitId: string, amount: number) {
|
|
if (amount <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
let affected = 0;
|
|
campaign.bonds
|
|
.filter((bond) => bond.unitIds.includes(unitId))
|
|
.forEach((bond) => {
|
|
this.advanceCampBond(bond, amount);
|
|
affected += 1;
|
|
const reportBond = campaign.firstBattleReport?.bonds.find((candidate) => candidate.id === bond.id);
|
|
if (reportBond) {
|
|
reportBond.level = bond.level;
|
|
reportBond.exp = bond.exp;
|
|
reportBond.battleExp = bond.battleExp;
|
|
}
|
|
});
|
|
|
|
return affected * amount;
|
|
}
|
|
|
|
private advanceCampBond(bond: CampaignState['bonds'][number], amount: number) {
|
|
bond.battleExp += amount;
|
|
bond.exp += amount;
|
|
while (bond.exp >= 100) {
|
|
bond.exp -= 100;
|
|
bond.level = Math.min(100, bond.level + 1);
|
|
}
|
|
}
|
|
|
|
private syncReportUnitHp(campaign: CampaignState, unitId: string, hp: number) {
|
|
const reportUnit = campaign.firstBattleReport?.units.find((unit) => unit.id === unitId);
|
|
if (reportUnit) {
|
|
reportUnit.hp = hp;
|
|
}
|
|
}
|
|
|
|
private renderDetailGauge(label: string, value: string, ratio: number, x: number, y: number, width: number, color: number) {
|
|
this.track(this.add.text(x, y - 2, label, this.textStyle(13, '#9fb0bf', true)));
|
|
this.drawBar(x + 54, y + 6, width - 134, 8, ratio, color);
|
|
const valueText = this.track(this.add.text(x + width, y - 3, value, this.textStyle(13, '#f2e3bf', true)));
|
|
valueText.setOrigin(1, 0);
|
|
}
|
|
|
|
private renderStatCard(label: string, value: string, x: number, y: number, width: number) {
|
|
const bg = this.track(this.add.rectangle(x, y, width, 34, 0x151f2a, 0.82));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, 0x53606c, 0.38);
|
|
this.track(this.add.text(x + 10, y + 8, label, this.textStyle(12, '#9fb0bf', true)));
|
|
const valueText = this.track(this.add.text(x + width - 10, y + 5, value, this.textStyle(17, '#f2e3bf', true)));
|
|
valueText.setOrigin(1, 0);
|
|
}
|
|
|
|
private renderEquipmentCard(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number, height: number) {
|
|
const state = unit.equipment[slot];
|
|
const item = getItem(state.itemId);
|
|
const next = equipmentExpToNext(state.level);
|
|
const isTreasure = item.rank === 'treasure';
|
|
const bg = this.track(this.add.rectangle(x, y, width, height, isTreasure ? 0x1f2430 : 0x151f2a, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, isTreasure ? palette.gold : palette.blue, isTreasure ? 0.68 : 0.42);
|
|
|
|
const iconFrame = this.track(this.add.rectangle(x + 28, y + 29, 42, 42, 0x0a1017, 0.92));
|
|
iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.72 : 0.48);
|
|
const icon = this.track(this.add.image(x + 28, y + 29, `item-${item.id}`));
|
|
icon.setDisplaySize(30, 30);
|
|
|
|
this.track(this.add.text(x + 58, y + 12, `${equipmentSlotLabels[slot]} · ${item.name}`, this.textStyle(14, isTreasure ? '#f4dfad' : '#d4dce6', true)));
|
|
this.track(this.add.text(x + 58, y + 34, `${isTreasure ? '보물' : '일반'} Lv ${state.level}`, this.textStyle(12, isTreasure ? '#d8b15f' : '#9fb0bf', true)));
|
|
const valueText = this.track(this.add.text(x + width - 12, y + 34, `${state.exp}/${next}`, this.textStyle(12, '#f0e4c8', true)));
|
|
valueText.setOrigin(1, 0);
|
|
this.drawBar(x + 58, y + 57, width - 74, 7, state.exp / next, isTreasure ? palette.gold : palette.blue);
|
|
|
|
this.track(
|
|
this.add.text(x + 14, y + 72, `${this.itemBonusText(item)} · ${item.effects[0] ?? item.description}`, {
|
|
...this.textStyle(11, '#c8d2dd'),
|
|
wordWrap: { width: width - 28, useAdvancedWrap: true }
|
|
})
|
|
);
|
|
}
|
|
|
|
private renderSelectedUnitBondPanel(unit: UnitData, x: number, y: number, width: number) {
|
|
const bonds = this.currentBonds().filter((bond) => bond.unitIds.includes(unit.id));
|
|
const bg = this.track(this.add.rectangle(x, y, width, 62, 0x0d141c, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.42);
|
|
this.track(this.add.text(x + 14, y + 9, '공명 관계', this.textStyle(16, '#f2e3bf', true)));
|
|
|
|
if (bonds.length === 0) {
|
|
this.track(this.add.text(x + 120, y + 22, '아직 연결된 공명 관계가 없습니다.', this.textStyle(13, '#9fb0bf')));
|
|
return;
|
|
}
|
|
|
|
bonds.forEach((bond, index) => {
|
|
const cardX = x + 116 + index * 322;
|
|
const partnerId = bond.unitIds.find((unitId) => unitId !== unit.id) ?? bond.unitIds[0];
|
|
const partnerName = this.unitName(partnerId);
|
|
this.track(this.add.text(cardX, y + 10, `${partnerName} · ${bond.title}`, this.textStyle(13, '#d4dce6', true)));
|
|
this.track(this.add.text(cardX, y + 32, `Lv ${bond.level} ${bond.exp}/100 전투 +${bond.battleExp}`, this.textStyle(12, bond.battleExp > 0 ? '#d8b15f' : '#9fb0bf', true)));
|
|
this.drawBar(cardX + 176, y + 38, 120, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue);
|
|
});
|
|
}
|
|
|
|
private itemBonusText(item: ReturnType<typeof getItem>) {
|
|
const bonuses = [
|
|
item.attackBonus ? `공격 +${item.attackBonus}` : '',
|
|
item.defenseBonus ? `방어 +${item.defenseBonus}` : '',
|
|
item.strategyBonus ? `책략 +${item.strategyBonus}` : ''
|
|
].filter(Boolean);
|
|
return bonuses.join(' / ') || '특수 보정 없음';
|
|
}
|
|
|
|
private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) {
|
|
const rewardExp = dialogue.rewardExp + choice.rewardExp;
|
|
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
|
|
if (updated) {
|
|
this.report = updated;
|
|
this.campaign = getCampaignState();
|
|
} else if (this.report && !this.report.completedCampDialogues.includes(dialogue.id)) {
|
|
this.report.completedCampDialogues.push(dialogue.id);
|
|
const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId);
|
|
if (bond) {
|
|
bond.battleExp += rewardExp;
|
|
bond.exp += rewardExp;
|
|
}
|
|
}
|
|
|
|
soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 });
|
|
this.showDialogueReward(dialogue, choice, rewardExp);
|
|
this.render();
|
|
}
|
|
|
|
private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) {
|
|
this.showCampNotice(`${choice.label} · 공명 +${rewardExp}`);
|
|
this.time.delayedCall(120, () => this.showCampNotice(choice.response));
|
|
}
|
|
|
|
private showCampNotice(message: string) {
|
|
this.dialogueObjects.forEach((object) => object.destroy());
|
|
this.dialogueObjects = [];
|
|
const box = this.add.rectangle(this.scale.width / 2, 104, 720, 58, 0x101820, 0.96);
|
|
box.setStrokeStyle(2, palette.gold, 0.84);
|
|
box.setDepth(30);
|
|
const text = this.add.text(this.scale.width / 2, 104, message, {
|
|
...this.textStyle(16, '#f2e3bf', true),
|
|
align: 'center',
|
|
wordWrap: { width: 670, useAdvancedWrap: true }
|
|
});
|
|
text.setOrigin(0.5);
|
|
text.setDepth(31);
|
|
this.dialogueObjects.push(box, text);
|
|
this.tweens.add({
|
|
targets: this.dialogueObjects,
|
|
alpha: 0,
|
|
delay: 1300,
|
|
duration: 320,
|
|
onComplete: () => {
|
|
this.dialogueObjects.forEach((object) => object.destroy());
|
|
this.dialogueObjects = [];
|
|
}
|
|
});
|
|
}
|
|
|
|
private selectedUnit() {
|
|
return this.currentUnits().find((unit) => unit.id === this.selectedUnitId);
|
|
}
|
|
|
|
private unitName(unitId: string) {
|
|
return this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId;
|
|
}
|
|
|
|
private currentUnits() {
|
|
if (this.campaign?.roster.length) {
|
|
return this.campaign.roster;
|
|
}
|
|
return this.report?.units ?? [];
|
|
}
|
|
|
|
private currentBonds() {
|
|
if (this.campaign?.bonds.length) {
|
|
return this.campaign.bonds;
|
|
}
|
|
return this.report?.bonds ?? [];
|
|
}
|
|
|
|
private sortieAllies() {
|
|
return this.currentUnits().filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
|
}
|
|
|
|
private selectedSortieUnits() {
|
|
const selected = new Set(this.selectedSortieUnitIds);
|
|
return this.sortieAllies().filter((unit) => selected.has(unit.id));
|
|
}
|
|
|
|
private normalizedSortieUnitIds(candidateIds?: string[]) {
|
|
const allies = this.sortieAllies();
|
|
const allyIds = new Set(allies.map((unit) => unit.id));
|
|
const maxUnits = this.sortieMaxUnits();
|
|
const useDefaultSelection = !candidateIds || candidateIds.length === 0;
|
|
const candidateSet = new Set((candidateIds ?? []).filter((id) => allyIds.has(id)));
|
|
requiredSortieUnitIds.forEach((id) => {
|
|
if (allyIds.has(id)) {
|
|
candidateSet.add(id);
|
|
}
|
|
});
|
|
|
|
if (useDefaultSelection) {
|
|
allies.slice(0, maxUnits).forEach((unit) => candidateSet.add(unit.id));
|
|
}
|
|
|
|
const required = allies.filter((unit) => requiredSortieUnitIds.has(unit.id)).map((unit) => unit.id);
|
|
const optional = allies
|
|
.filter((unit) => !requiredSortieUnitIds.has(unit.id) && candidateSet.has(unit.id))
|
|
.slice(0, Math.max(0, maxUnits - required.length))
|
|
.map((unit) => unit.id);
|
|
|
|
return [...new Set([...required, ...optional])].slice(0, maxUnits);
|
|
}
|
|
|
|
private isSortieSelected(unitId: string) {
|
|
return this.selectedSortieUnitIds.includes(unitId);
|
|
}
|
|
|
|
private toggleSortieUnit(unitId: string) {
|
|
const unit = this.sortieAllies().find((candidate) => candidate.id === unitId);
|
|
if (!unit) {
|
|
return;
|
|
}
|
|
if (requiredSortieUnitIds.has(unitId)) {
|
|
this.showCampNotice(`${unit.name}는 반드시 출전해야 합니다.`);
|
|
return;
|
|
}
|
|
|
|
const selected = new Set(this.selectedSortieUnitIds);
|
|
if (selected.has(unitId)) {
|
|
selected.delete(unitId);
|
|
} else if (selected.size >= this.sortieMaxUnits()) {
|
|
this.showCampNotice(`이번 전투는 최대 ${this.sortieMaxUnits()}명까지 출전할 수 있습니다.`);
|
|
return;
|
|
} else {
|
|
selected.add(unitId);
|
|
}
|
|
|
|
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(Array.from(selected));
|
|
this.persistSortieSelection();
|
|
soundDirector.playSelect();
|
|
this.showSortiePrep();
|
|
}
|
|
|
|
private ensureSortieSelectionSaved() {
|
|
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.selectedSortieUnitIds);
|
|
if (!this.selectedSortieUnitIds.some((id) => requiredSortieUnitIds.has(id)) || this.selectedSortieUnitIds.length === 0) {
|
|
return false;
|
|
}
|
|
this.persistSortieSelection();
|
|
return true;
|
|
}
|
|
|
|
private persistSortieSelection() {
|
|
const campaign = this.campaign ?? getCampaignState();
|
|
campaign.selectedSortieUnitIds = [...this.selectedSortieUnitIds];
|
|
this.campaign = saveCampaignState(campaign);
|
|
}
|
|
|
|
private completedCampDialogues() {
|
|
if (this.campaign) {
|
|
return this.campaign.completedCampDialogues;
|
|
}
|
|
return this.report?.completedCampDialogues ?? [];
|
|
}
|
|
|
|
private inventoryLabels() {
|
|
const inventory = this.campaign?.inventory ?? {};
|
|
const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);
|
|
if (entries.length > 0) {
|
|
return entries.map(([label, amount]) => `${label} ${amount}`);
|
|
}
|
|
return this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1'];
|
|
}
|
|
|
|
private nonConsumableInventoryLabels() {
|
|
const entries = Object.entries(this.campaign?.inventory ?? {}).filter(([label, amount]) => {
|
|
return amount > 0 && !campSupplyByLabel.has(label);
|
|
});
|
|
if (entries.length > 0) {
|
|
return entries.map(([label, amount]) => `${label} ${amount}`);
|
|
}
|
|
return this.inventoryLabels().filter((label) => {
|
|
const normalized = label.replace(/\s+\d+$/, '').trim();
|
|
return !campSupplyByLabel.has(normalized);
|
|
});
|
|
}
|
|
|
|
private inventoryAmount(label: string) {
|
|
return this.campaign?.inventory[label] ?? 0;
|
|
}
|
|
|
|
private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) {
|
|
const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
|
|
track.setOrigin(0);
|
|
const fill = this.track(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96));
|
|
fill.setOrigin(0);
|
|
}
|
|
|
|
private textStyle(fontSize: number, color: string, bold = false): Phaser.Types.GameObjects.Text.TextStyle {
|
|
return {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: `${fontSize}px`,
|
|
color,
|
|
fontStyle: bold ? '700' : '400'
|
|
};
|
|
}
|
|
|
|
private track<T extends Phaser.GameObjects.GameObject>(object: T) {
|
|
this.contentObjects.push(object);
|
|
return object;
|
|
}
|
|
|
|
private compactText(value: string, maxLength: number) {
|
|
if (value.length <= maxLength) {
|
|
return value;
|
|
}
|
|
return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
}
|
|
|
|
private clearContent() {
|
|
this.contentObjects.forEach((object) => object.destroy());
|
|
this.contentObjects = [];
|
|
}
|
|
|
|
getDebugState() {
|
|
return {
|
|
scene: this.scene.key,
|
|
activeTab: this.activeTab,
|
|
sortieVisible: this.sortieObjects.length > 0,
|
|
selectedUnitId: this.selectedUnitId,
|
|
selectedDialogueId: this.selectedDialogueId,
|
|
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
|
|
sortieRoster: this.sortieAllies().map((unit) => {
|
|
const summary = this.sortieUnitTacticalSummary(unit);
|
|
return {
|
|
id: unit.id,
|
|
name: unit.name,
|
|
selected: this.isSortieSelected(unit.id),
|
|
recruited: !foundingSortieUnitIds.has(unit.id),
|
|
required: requiredSortieUnitIds.has(unit.id),
|
|
className: unit.className,
|
|
classRole: getUnitClass(unit.classKey).role,
|
|
formationRole: this.sortieFormationRole(unit),
|
|
recommended: Boolean(this.sortieRecommendation(unit.id)),
|
|
recommendationReason: this.sortieRecommendation(unit.id)?.reason ?? null,
|
|
statLine: summary.statLine,
|
|
equipmentLine: summary.equipmentLine,
|
|
bondLine: summary.bondLine,
|
|
terrainLine: summary.terrainLine
|
|
};
|
|
}),
|
|
sortiePlan: this.sortiePlanSummary(),
|
|
campBattleId: this.currentCampBattleId(),
|
|
campTitle: this.currentCampTitle(),
|
|
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
|
|
completedAvailableDialogues: this.completedAvailableDialogues().map((dialogue) => dialogue.id),
|
|
campaign: this.campaign
|
|
? {
|
|
step: this.campaign.step,
|
|
gold: this.campaign.gold,
|
|
inventory: this.campaign.inventory,
|
|
selectedSortieUnitIds: this.campaign.selectedSortieUnitIds,
|
|
roster: this.campaign.roster.map((unit) => ({
|
|
id: unit.id,
|
|
name: unit.name,
|
|
hp: unit.hp,
|
|
maxHp: unit.maxHp
|
|
})),
|
|
completedCampDialogues: this.campaign.completedCampDialogues
|
|
}
|
|
: null,
|
|
report: this.report
|
|
? {
|
|
rewardGold: this.report.rewardGold,
|
|
objectives: this.report.objectives,
|
|
completedCampDialogues: this.report.completedCampDialogues,
|
|
bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp }))
|
|
}
|
|
: null
|
|
};
|
|
}
|
|
}
|