Add Yi Province relief road chapter

This commit is contained in:
2026-06-23 08:55:39 +09:00
parent 2402e1c38f
commit 17cbaab55d
11 changed files with 1251 additions and 49 deletions

View File

@@ -21,6 +21,8 @@ import {
wulingRecruitUnits,
xuzhouRecruitBonds,
xuzhouRecruitUnits,
yizhouRecruitBonds,
yizhouRecruitUnits,
zhugeRecruitBonds,
zhugeRecruitUnits,
type PortraitKey,
@@ -307,9 +309,10 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [
'twenty-third-battle-jingzhou-south-entry',
'twenty-fourth-battle-guiyang-persuasion',
'twenty-fifth-battle-wuling-mountain-road',
'twenty-sixth-battle-changsha-veteran'
'twenty-sixth-battle-changsha-veteran',
'twenty-seventh-battle-yizhou-relief-road'
],
nextHints: ['형주 방위', '익주 진입', '장수 대거 합류']
nextHints: ['형주 방위', '익주 진입', '방통 합류']
},
{
id: 'shu-han',
@@ -347,7 +350,8 @@ const campBattleIds = {
twentyThird: 'twenty-third-battle-jingzhou-south-entry',
twentyFourth: 'twenty-fourth-battle-guiyang-persuasion',
twentyFifth: 'twenty-fifth-battle-wuling-mountain-road',
twentySixth: 'twenty-sixth-battle-changsha-veteran'
twentySixth: 'twenty-sixth-battle-changsha-veteran',
twentySeventh: 'twenty-seventh-battle-yizhou-relief-road'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
@@ -634,6 +638,18 @@ const sortieRulesByBattleId: Partial<Record<BattleScenarioId, SortieRuleDefiniti
{ unitId: 'zhao-yun', reason: '장사 성문 주변 기병과 척후를 빠르게 정리하기 좋습니다.' }
],
note: '장사 노장 대면전은 열한 명 중 여섯 명만 고르는 전장입니다. 황충과 위연은 승리 후 합류하므로, 이번에는 예우와 돌파의 균형을 먼저 준비하십시오.'
},
'twenty-seventh-battle-yizhou-relief-road': {
maxUnits: 6,
recommended: [
{ unitId: 'liu-bei', reason: '익주 원군 요청의 명분이자 패배 조건의 중심입니다.' },
{ unitId: 'zhuge-liang', reason: '원군이라는 명분과 관문 돌파 계책을 함께 잡는 핵심 책사입니다.' },
{ unitId: 'huang-zhong', reason: '산길과 성루의 궁병을 안정적으로 견제할 노련한 원거리 장수입니다.' },
{ unitId: 'wei-yan', reason: '좁은 관문 전열을 빠르게 흔들 돌파 역할입니다.' },
{ unitId: 'zhao-yun', reason: '긴 원군로에서 척후와 기병을 끊는 기동 핵심입니다.' },
{ unitId: 'mi-zhu', reason: '큰 로스터 전투에서 회복 도구와 보급 안정성이 빛납니다.' }
],
note: '익주 원군로는 열세 명 중 여섯 명만 고르는 전장입니다. 황충·위연을 시험할지, 기존 책사와 보급을 두껍게 둘지 직접 선택하십시오.'
}
};
@@ -2824,6 +2840,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 9
}
]
},
{
id: 'liu-pang-after-yizhou',
title: '봉추가 보는 인의',
availableAfterBattleIds: [campBattleIds.twentySeventh],
unitIds: ['liu-bei', 'pang-tong'],
bondId: 'liu-bei__pang-tong',
rewardExp: 30,
lines: [
'방통: 원군로를 열면서도 마을 창고를 지킨 일은 예상보다 인상적이었습니다.',
'유비: 큰 뜻이 백성을 짓밟고 지나가면, 그 뜻은 이미 비어 있는 것이오.',
'방통: 그렇다면 그 빈틈 없는 뜻에 제가 조금 날카로운 길을 보태 보겠습니다.'
],
choices: [
{
id: 'ask-pang-tong-yizhou-map',
label: '익주 지세를 묻는다',
response: '방통은 산문과 관문의 이름을 빠르게 짚으며, 유비군이 서두를 곳과 멈출 곳을 나누었다.',
rewardExp: 12
},
{
id: 'trust-pang-tong-boldness',
label: '과감한 계책을 맡긴다',
response: '유비가 방통의 과감함을 인정하자, 봉추의 눈빛은 한층 밝아졌다.',
rewardExp: 11
}
]
},
{
id: 'zhuge-pang-after-yizhou',
title: '와룡과 봉추',
availableAfterBattleIds: [campBattleIds.twentySeventh],
unitIds: ['zhuge-liang', 'pang-tong'],
bondId: 'zhuge-liang__pang-tong',
rewardExp: 30,
lines: [
'제갈량: 익주는 산이 많아 큰 판만으로는 늦을 때가 있습니다.',
'방통: 그래서 때로는 바른 길보다 빠른 틈을 먼저 보아야 하지요.',
'제갈량: 큰 판과 빠른 틈이 함께 있으면, 군은 길을 잃지 않을 것입니다.'
],
choices: [
{
id: 'divide-dragon-phoenix-plans',
label: '큰 판과 기책을 나눈다',
response: '제갈량은 전체 진군로를 맡고, 방통은 관문과 협곡을 흔드는 기책을 맡기로 했다.',
rewardExp: 12
},
{
id: 'compare-yizhou-people',
label: '익주 사람을 논한다',
response: '두 책사는 지도보다 먼저 사람의 마음을 살피기로 했고, 익주 공략의 색이 조금 더 깊어졌다.',
rewardExp: 10
}
]
},
{
id: 'huang-pang-after-yizhou',
title: '노궁과 기책',
availableAfterBattleIds: [campBattleIds.twentySeventh],
unitIds: ['huang-zhong', 'pang-tong'],
bondId: 'huang-zhong__pang-tong',
rewardExp: 26,
lines: [
'방통: 노장군의 활은 멀리서도 전열의 숨을 끊을 수 있더군요.',
'황충: 책사의 계책은 화살보다 먼저 날아간다 들었습니다. 어디를 겨눌지 알려 주시오.',
'방통: 다음 관문에서는 활이 닿는 곳부터 적의 마음을 흔들어 보겠습니다.'
],
choices: [
{
id: 'mark-bow-kill-zone',
label: '활의 사거리를 표시한다',
response: '방통은 산길의 사각을 표시했고, 황충은 그 표시를 따라 활이 닿을 거리를 익혔다.',
rewardExp: 10
},
{
id: 'prepare-feigned-retreat',
label: '유인책을 준비한다',
response: '황충은 물러서는 척하며 적을 끌어낼 거리와, 방통의 매복 신호를 함께 맞췄다.',
rewardExp: 9
}
]
}
];
@@ -3513,6 +3610,66 @@ const campVisits: CampVisitDefinition[] = [
itemRewards: ['군영 편성 기록 1']
}
]
},
{
id: 'yizhou-entry-council',
title: '익주 원군로 회의',
location: '익주 길목 군막',
availableAfterBattleIds: [campBattleIds.twentySeventh],
bondId: 'liu-bei__pang-tong',
description: '방통이 합류한 뒤, 유비와 새 책사가 익주로 들어가는 명분과 속도를 처음 조율합니다.',
lines: [
'방통: 익주는 문이 많은 땅입니다. 모든 문을 두드리면 늦고, 모든 문을 부수면 민심을 잃습니다.',
'유비: 그러니 열어야 할 문과 기다려야 할 문을 구분해야 하오.',
'군막의 촛불 아래 익주의 관문 이름과 마을 이름이 하나씩 적히기 시작했습니다.'
],
choices: [
{
id: 'choose-yizhou-cause-first',
label: '명분을 먼저 세운다',
response: '방통은 유비의 뜻에 맞춰 원군 요청 문서를 앞세우고, 군의 이동 속도는 조금 늦추되 마을의 신뢰를 얻도록 조정했다.',
bondExp: 24,
itemRewards: ['익주 원군 문서 1']
},
{
id: 'choose-yizhou-speed-first',
label: '관문 속도를 높인다',
response: '방통은 빠르게 흔들 수 있는 관문과 우회로를 짚었다. 유비는 백성이 다치지 않는 선을 분명히 못박았다.',
bondExp: 22,
gold: 240,
itemRewards: ['산문 정찰 기록 1']
}
]
},
{
id: 'fu-pass-road-survey',
title: '부수관 길 정찰',
location: '익주 산길',
availableAfterBattleIds: [campBattleIds.twentySeventh],
bondId: 'zhuge-liang__pang-tong',
description: '제갈량과 방통이 다음 관문으로 이어지는 산길을 살피며, 출전 후보와 병과 조합을 논합니다.',
lines: [
'제갈량: 이 길은 한 번 막히면 보급이 끊깁니다. 전열보다 후원이 무너질 수 있습니다.',
'방통: 그렇다면 적이 막기 전에 우리가 먼저 막힌 척해야겠군요. 적을 끌어내면 관문은 비게 됩니다.',
'두 책사는 산의 그림자와 강의 굽이를 지도 위에 겹쳐 보았습니다.'
],
choices: [
{
id: 'prepare-fu-pass-supply',
label: '보급선을 먼저 살핀다',
response: '제갈량은 보급선을, 방통은 유인 지점을 표시했다. 다음 전투의 출전 선택이 한층 중요해졌다.',
bondExp: 22,
itemRewards: ['부수관 보급표 1']
},
{
id: 'prepare-feint-on-pass',
label: '유인책을 세운다',
response: '방통의 기책과 제갈량의 큰 판이 합쳐져, 적을 관문 밖으로 끌어내는 초안이 마련되었다.',
bondExp: 24,
gold: 260,
itemRewards: ['관문 유인 초안 1']
}
]
}
];
@@ -3598,6 +3755,35 @@ export class CampScene extends Phaser.Scene {
return;
}
if (battleId === campBattleIds.twentySeventh) {
this.campaign = ensureCampaignRosterUnits(
[
...xuzhouRecruitUnits,
...caoBreakRecruitUnits,
...liuBiaoRecruitUnits,
...zhugeRecruitUnits,
...jingzhouRecruitUnits,
...guiyangRecruitUnits,
...wulingRecruitUnits,
...changshaRecruitUnits,
...yizhouRecruitUnits
],
[
...xuzhouRecruitBonds,
...caoBreakRecruitBonds,
...liuBiaoRecruitBonds,
...zhugeRecruitBonds,
...jingzhouRecruitBonds,
...guiyangRecruitBonds,
...wulingRecruitBonds,
...changshaRecruitBonds,
...yizhouRecruitBonds
]
);
this.report = this.campaign.firstBattleReport ?? this.report;
return;
}
if (battleId === campBattleIds.twentySixth) {
this.campaign = ensureCampaignRosterUnits(
[
@@ -3746,6 +3932,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.twentySeventh) {
return '익주 원군로 후 군영';
}
if (battleId === campBattleIds.twentySixth) {
return '장사 노장 대면 후 군영';
}
@@ -4047,15 +4236,16 @@ export class CampScene extends Phaser.Scene {
const allies = this.sortieAllies();
const selectedCount = this.selectedSortieUnitIds.length;
const maxUnits = this.sortieMaxUnits();
const rowGap = allies.length > 7 ? 30 : allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48;
const rowHeight = allies.length > 7 ? 24 : rowGap - 6;
const denseRoster = allies.length > 12;
const rowGap = denseRoster ? 17 : allies.length > 7 ? 30 : allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48;
const rowHeight = denseRoster ? 15 : allies.length > 7 ? 24 : 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 rowY = y + (denseRoster ? 48 : 50) + index * rowGap;
const selected = this.isSortieSelected(unit.id);
const required = requiredSortieUnitIds.has(unit.id);
const recruited = !foundingSortieUnitIds.has(unit.id);
@@ -4070,17 +4260,21 @@ export class CampScene extends Phaser.Scene {
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.trackSortie(this.add.text(x + 30, rowY, markerLabel, this.textStyle(denseRoster ? 10 : 12, required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + (denseRoster ? 78 : 84), rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(denseRoster ? 12 : 14, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + (denseRoster ? 194 : 214), rowY, this.compactText(`${unit.className} · ${getUnitClass(unit.classKey).role}`, denseRoster ? 17 : 19), this.textStyle(denseRoster ? 10 : 12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 424, rowY, denseRoster ? this.compactText(summary.statLine, 23) : `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(denseRoster ? 10 : 12, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
if (!denseRoster) {
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(denseRoster ? summary.bondLine : summary.terrainLine, denseRoster ? 15 : 13), this.textStyle(11, selected ? (denseRoster ? '#a8ffd0' : '#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);
if (!denseRoster) {
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);
}
});
}