Add Xu Province recruit sortie flow

This commit is contained in:
2026-06-23 01:07:17 +09:00
parent 87b200d772
commit 6b863882aa
7 changed files with 330 additions and 33 deletions

View File

@@ -4,9 +4,10 @@ import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type
import { getUnitClass } from '../data/battleRules';
import { defaultBattleScenario } from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
import { firstBattleBonds, firstBattleUnits, type PortraitKey, type UnitData } from '../data/scenario';
import { firstBattleBonds, firstBattleUnits, xuzhouRecruitBonds, xuzhouRecruitUnits, type PortraitKey, type UnitData } from '../data/scenario';
import {
applyCampBondExp,
ensureCampaignRosterUnits,
getCampaignState,
getFirstBattleReport,
markCampaignStep,
@@ -702,6 +703,60 @@ const campDialogues: CampDialogue[] = [
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
}
]
}
];
@@ -730,6 +785,7 @@ export class CampScene extends Phaser.Scene {
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;
@@ -777,6 +833,15 @@ export class CampScene extends Phaser.Scene {
};
}
private ensureCurrentCampRecruitment() {
if (this.currentCampBattleId() !== campBattleIds.seventh || !this.campaign) {
return;
}
this.campaign = ensureCampaignRosterUnits(xuzhouRecruitUnits, xuzhouRecruitBonds);
this.report = this.campaign.firstBattleReport ?? this.report;
}
private reportSummary() {
if (!this.report) {
return '';
@@ -947,8 +1012,8 @@ export class CampScene extends Phaser.Scene {
this.renderSortieBriefing(x + 34, y + 112, 396, 152, depth + 2);
this.renderSortieChecklist(x + 462, y + 112, 404, 170, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 832, 148, depth + 2);
this.renderSortieRewardHint(x + 34, y + 462, 548, 52, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 832, 176, depth + 2);
this.renderSortieRewardHint(x + 34, y + 480, 548, 52, depth + 2);
this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => {
soundDirector.playSelect();
@@ -1003,16 +1068,18 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.blue, 0.48);
const allies = this.sortieAllies();
const selectedCount = this.selectedSortieUnitIds.length;
const rowGap = allies.length > 4 ? 24 : 30;
const rowHeight = allies.length > 4 ? 22 : 26;
this.trackSortie(
this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${Math.min(maxSortieUnits, allies.length)}`, 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 + 48 + index * 30;
const rowY = y + 48 + index * rowGap;
const selected = this.isSortieSelected(unit.id);
const required = requiredSortieUnitIds.has(unit.id);
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, 26, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
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);
@@ -1166,11 +1233,15 @@ export class CampScene extends Phaser.Scene {
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 * 150;
const y = 120 + index * rowGap;
const active = this.selectedUnitId === unit.id;
const bg = this.track(this.add.rectangle(x, y, 318, 126, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86));
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 });
@@ -1181,18 +1252,32 @@ export class CampScene extends Phaser.Scene {
});
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, y + 62, portraitKeys[portraitKey]));
portrait.setDisplaySize(92, 92);
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);
}
this.track(this.add.text(x + 116, y + 20, `${unit.name} Lv ${unit.level}`, this.textStyle(21, '#f2e3bf', true)));
this.track(this.add.text(x + 116, y + 51, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(14, '#d4dce6')));
this.track(this.add.text(x + 116, y + 78, `경험 ${unit.exp}/100`, this.textStyle(14, '#d8b15f', true)));
this.drawBar(x + 116, y + 104, 176, 8, unit.exp / 100, palette.gold);
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) {
@@ -1234,6 +1319,8 @@ export class CampScene extends Phaser.Scene {
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)));
@@ -1280,15 +1367,18 @@ export class CampScene extends Phaser.Scene {
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 * 64;
const rowY = y + 96 + index * dialogueRowGap;
const selected = this.selectedDialogueId === dialogue.id;
const row = this.track(this.add.rectangle(x + 24, rowY, 320, 48, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
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 });
@@ -1297,9 +1387,9 @@ export class CampScene extends Phaser.Scene {
this.selectedDialogueId = dialogue.id;
this.render();
});
this.track(this.add.text(x + 38, rowY + 8, dialogue.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
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 + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(12, '#9fb0bf')));
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);
@@ -1356,12 +1446,15 @@ export class CampScene extends Phaser.Scene {
}
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)));
this.currentBonds().forEach((bond, index) => {
const rowY = y + 32 + index * 36;
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(13, '#d4dce6')));
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);
});
}