Add Xuzhou rescue sortie flow

This commit is contained in:
2026-06-23 00:45:22 +09:00
parent 84a5606a73
commit 87b200d772
11 changed files with 929 additions and 43 deletions

View File

@@ -48,7 +48,19 @@ const unitTexture: Record<string, string> = {
'pursuit-cavalry-b': 'unit-rebel-cavalry',
'pursuit-guard-a': 'unit-rebel',
'pursuit-guard-b': 'unit-rebel',
'pursuit-leader-han-seok': 'unit-rebel-leader'
'pursuit-leader-han-seok': 'unit-rebel-leader',
'xuzhou-raider-a': 'unit-rebel',
'xuzhou-infantry-a': 'unit-rebel',
'xuzhou-archer-a': 'unit-rebel-archer',
'xuzhou-cavalry-a': 'unit-rebel-cavalry',
'xuzhou-raider-b': 'unit-rebel',
'xuzhou-archer-b': 'unit-rebel-archer',
'xuzhou-cavalry-b': 'unit-rebel-cavalry',
'xuzhou-guard-a': 'unit-rebel',
'xuzhou-cavalry-c': 'unit-rebel-cavalry',
'xuzhou-guard-b': 'unit-rebel',
'xuzhou-archer-c': 'unit-rebel-archer',
'xuzhou-leader-xiahou-dun': 'unit-rebel-leader'
};
const unitTextureByClass: Partial<Record<UnitClassKey, string>> = {
@@ -493,7 +505,19 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'pursuit-cavalry-b': 'aggressive',
'pursuit-guard-a': 'guard',
'pursuit-guard-b': 'guard',
'pursuit-leader-han-seok': 'guard'
'pursuit-leader-han-seok': 'guard',
'xuzhou-raider-a': 'aggressive',
'xuzhou-infantry-a': 'guard',
'xuzhou-archer-a': 'hold',
'xuzhou-cavalry-a': 'aggressive',
'xuzhou-raider-b': 'aggressive',
'xuzhou-archer-b': 'hold',
'xuzhou-cavalry-b': 'aggressive',
'xuzhou-guard-a': 'guard',
'xuzhou-cavalry-c': 'aggressive',
'xuzhou-guard-b': 'guard',
'xuzhou-archer-c': 'hold',
'xuzhou-leader-xiahou-dun': 'guard'
};
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
@@ -697,7 +721,12 @@ export class BattleScene extends Phaser.Scene {
}
private resetBattleData(campaign?: CampaignState) {
battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map((unit) => this.applyCampaignUnitProgress(unit, campaign)));
const selected = new Set(campaign?.selectedSortieUnitIds ?? []);
const hasSelection = selected.size > 0;
const nextUnits = initialBattleUnits
.filter((unit) => unit.faction === 'enemy' || !hasSelection || selected.has(unit.id) || unit.id === 'liu-bei')
.map((unit) => this.applyCampaignUnitProgress(unit, campaign));
battleUnits.splice(0, battleUnits.length, ...nextUnits);
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));
initialBattleUnits = battleUnits.map(cloneUnitData);
initialBattleBonds = battleBonds.map(cloneBattleBond);

View File

@@ -3,6 +3,7 @@ import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg';
import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg';
import seventhBattleMapUrl from '../../assets/images/battle/seventh-battle-map.svg';
import sixthBattleMapUrl from '../../assets/images/battle/sixth-battle-map.svg';
import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg';
import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png';
@@ -70,6 +71,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-fourth', fourthBattleMapUrl);
this.load.image('battle-map-fifth', fifthBattleMapUrl);
this.load.image('battle-map-sixth', sixthBattleMapUrl);
this.load.image('battle-map-seventh', seventhBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

@@ -128,9 +128,13 @@ const campBattleIds = {
third: 'third-battle-guangzong-road',
fourth: 'fourth-battle-guangzong-camp',
fifth: 'fifth-battle-sishui-vanguard',
sixth: 'sixth-battle-jieqiao-relief'
sixth: 'sixth-battle-jieqiao-relief',
seventh: 'seventh-battle-xuzhou-rescue'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
const maxSortieUnits = 6;
const campDialogues: CampDialogue[] = [
{
id: 'liu-guan-after-first-battle',
@@ -617,6 +621,87 @@ const campDialogues: CampDialogue[] = [
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
}
]
}
];
@@ -631,6 +716,7 @@ export class CampScene extends Phaser.Scene {
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
private tabButtons: CampTabButtonView[] = [];
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
constructor() {
super('CampScene');
@@ -645,6 +731,7 @@ export class CampScene extends Phaser.Scene {
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
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');
@@ -702,6 +789,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.seventh) {
return '서주 인수 준비 군영';
}
if (battleId === campBattleIds.fifth) {
return '공손찬 진영 군영';
}
@@ -830,6 +920,7 @@ export class CampScene extends Phaser.Scene {
this.hideSortiePrep();
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? this.report;
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
const depth = 40;
const width = 900;
@@ -850,7 +941,7 @@ export class CampScene extends Phaser.Scene {
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'))
this.add.text(x + 34, y + 70, '대화, 보급, 상인 정비를 마친 뒤 함께 출전할 무장을 선택합니다.', this.textStyle(15, '#d4dce6'))
);
subtitle.setDepth(depth + 2);
@@ -910,25 +1001,39 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.48);
this.trackSortie(this.add.text(x + 18, y + 14, '출진 부대', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
const allies = this.sortieAllies();
const selectedCount = this.selectedSortieUnitIds.length;
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);
this.currentUnits()
.filter((unit) => unit.faction === 'ally')
.forEach((unit, index) => {
const rowY = y + 52 + index * 30;
this.trackSortie(this.add.text(x + 18, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 132, rowY, unit.className, this.textStyle(13, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 236, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#d4dce6', true))).setDepth(depth + 1);
this.drawSortieBar(x + 338, rowY + 6, 128, 7, unit.hp / unit.maxHp, palette.green, depth + 1);
allies.forEach((unit, index) => {
const rowY = y + 48 + index * 30;
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));
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 equipment = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
return `${equipmentSlotLabels[slot]} Lv${state.level}`;
})
.join(' ');
this.trackSortie(this.add.text(x + 496, rowY, equipment, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
});
const marker = selected ? '출전' : '대기';
this.trackSortie(this.add.text(x + 30, rowY, required ? '필수' : marker, this.textStyle(12, required ? '#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 + 198, rowY, unit.className, this.textStyle(13, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 310, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(x + 414, rowY + 6, 102, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
const equipment = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
return `${equipmentSlotLabels[slot]} Lv${state.level}`;
})
.join(' ');
this.trackSortie(this.add.text(x + 542, rowY, equipment, this.textStyle(12, selected ? '#d4dce6' : '#77818c'))).setDepth(depth + 2);
});
}
private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) {
@@ -939,9 +1044,10 @@ export class CampScene extends Phaser.Scene {
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} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).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() {
@@ -960,6 +1066,7 @@ export class CampScene extends Phaser.Scene {
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: '유비 생존',
@@ -971,6 +1078,11 @@ export class CampScene extends Phaser.Scene {
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,
@@ -1007,6 +1119,10 @@ export class CampScene extends Phaser.Scene {
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();
@@ -1618,6 +1734,84 @@ export class CampScene extends Phaser.Scene {
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 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, maxSortieUnits).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, maxSortieUnits - required.length))
.map((unit) => unit.id);
return [...new Set([...required, ...optional])].slice(0, maxSortieUnits);
}
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 >= maxSortieUnits) {
this.showCampNotice(`이번 전투는 최대 ${maxSortieUnits}명까지 출전할 수 있습니다.`);
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;
@@ -1684,6 +1878,7 @@ export class CampScene extends Phaser.Scene {
sortieVisible: this.sortieObjects.length > 0,
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
@@ -1693,6 +1888,7 @@ export class CampScene extends Phaser.Scene {
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,

View File

@@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario';
import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, sixthBattleScenario, thirdBattleScenario } from '../data/battles';
import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, seventhBattleScenario, sixthBattleScenario, thirdBattleScenario } from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -305,7 +305,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'third-camp' ||
campaign.step === 'fourth-camp' ||
campaign.step === 'fifth-camp' ||
campaign.step === 'sixth-camp'
campaign.step === 'sixth-camp' ||
campaign.step === 'seventh-camp'
) {
this.scene.start('CampScene');
return;
@@ -341,6 +342,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
if (campaign.step === 'seventh-battle') {
this.scene.start('BattleScene', { battleId: seventhBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;