Add campaign battle rewards

This commit is contained in:
2026-07-03 23:21:30 +09:00
parent aafe210977
commit 0c4e358284
5 changed files with 265 additions and 13 deletions

View File

@@ -378,6 +378,16 @@ export type BattleSortieDefinition = {
note: string;
};
export type BattleCampaignRewardDefinition = {
supplies?: string[];
equipment?: string[];
reputation?: string[];
recruits?: string[];
unlockBattleId?: BattleScenarioId;
unlockLabel?: string;
note?: string;
};
export type BattleScenarioDefinition = {
id: BattleScenarioId;
title: string;
@@ -395,6 +405,7 @@ export type BattleScenarioDefinition = {
objectives: BattleObjectiveDefinition[];
defeatConditions: BattleDefeatConditionDefinition[];
itemRewards: string[];
campaignReward?: BattleCampaignRewardDefinition;
victoryPages: StoryPage[];
nextCampScene: string;
};
@@ -448,6 +459,15 @@ export const firstBattleScenario: BattleScenarioDefinition = {
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 1', '탁주 1', '의용군 명성 +1'],
campaignReward: {
supplies: ['콩 1', '탁주 1'],
equipment: ['연습검 1'],
reputation: ['의용군 명성 +1'],
recruits: ['guan-yu', 'zhang-fei'],
unlockBattleId: 'second-battle-yellow-turban-pursuit',
unlockLabel: '황건 잔당 추격 개방',
note: '도원 형제의 의용군이 정식으로 출전 가능한 부대로 정리됩니다.'
},
victoryPages: firstBattleVictoryPages,
nextCampScene: 'CampScene'
};
@@ -521,6 +541,14 @@ export const secondBattleScenario: BattleScenarioDefinition = {
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '상처약 1', '탁주 1', '의용군 명성 +2'],
campaignReward: {
supplies: ['콩 2', '상처약 1', '탁주 1'],
equipment: ['황건 부적 1'],
reputation: ['의용군 명성 +2'],
unlockBattleId: 'third-battle-guangzong-road',
unlockLabel: '광종 구원로 개방',
note: '북쪽 마을 보급과 잔당 추격 기록이 다음 전투 준비에 반영됩니다.'
},
victoryPages: secondBattleVictoryPages,
nextCampScene: 'CampScene'
};
@@ -594,6 +622,14 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '상처약 1', '의용군 명성 +3'],
campaignReward: {
supplies: ['콩 2', '상처약 1'],
equipment: ['단궁 1'],
reputation: ['의용군 명성 +3'],
unlockBattleId: 'fourth-battle-guangzong-camp',
unlockLabel: '광종 본영전 개방',
note: '광종 구원로 확보로 본영 진입 정보가 열립니다.'
},
victoryPages: thirdBattleVictoryPages,
nextCampScene: 'CampScene'
};
@@ -667,6 +703,14 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '상처약 1', '탁주 1', '황건 토벌 공적 +1'],
campaignReward: {
supplies: ['콩 2', '상처약 1', '탁주 1'],
equipment: ['황건도 1'],
reputation: ['황건 토벌 공적 +1'],
unlockBattleId: 'fifth-battle-sishui-vanguard',
unlockLabel: '사수관 전초전 개방',
note: '황건 본영 토벌 공적이 반동탁 연합 합류 명분으로 이어집니다.'
},
victoryPages: fourthBattleVictoryPages,
nextCampScene: 'CampScene'
};
@@ -740,6 +784,14 @@ export const fifthBattleScenario: BattleScenarioDefinition = {
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '상처약 1', '탁주 1', '반동탁 공적 +1'],
campaignReward: {
supplies: ['콩 2', '상처약 1', '탁주 1'],
equipment: ['찰갑 1'],
reputation: ['반동탁 공적 +1'],
unlockBattleId: 'sixth-battle-jieqiao-relief',
unlockLabel: '계교 원군로 개방',
note: '사수관 전초 진지의 전과가 공손찬 진영의 신뢰로 연결됩니다.'
},
victoryPages: fifthBattleVictoryPages,
nextCampScene: 'CampScene'
};

View File

@@ -2,7 +2,13 @@ import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { battleMapAssets } from '../data/battleMapAssets';
import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
import { defaultBattleScenario, getBattleScenario, type BattleObjectiveDefinition, type BattleScenarioDefinition } from '../data/battles';
import {
defaultBattleScenario,
getBattleScenario,
type BattleCampaignRewardDefinition,
type BattleObjectiveDefinition,
type BattleScenarioDefinition
} from '../data/battles';
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
import {
ensureUnitAnimations,
@@ -41,6 +47,7 @@ import {
markCampaignStep,
saveCampaignState,
setFirstBattleReport,
type CampaignRewardSnapshot,
type CampaignState,
type CampaignStep
} from '../state/campaignState';
@@ -3179,6 +3186,7 @@ export class BattleScene extends Phaser.Scene {
this.drawDebugSpritePreview();
this.updateTurnText();
this.time.delayedCall(180, () => this.showOpeningBattleEvent());
this.scheduleDebugForcedBattleOutcome();
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
}
@@ -7573,6 +7581,7 @@ export class BattleScene extends Phaser.Scene {
const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length;
const aliveAllies = allies.filter((unit) => unit.hp > 0).length;
const objectives = this.resultObjectives(outcome);
const rewardSnapshot = this.resultCampaignRewards(outcome);
const shade = this.trackResultObject(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68));
shade.setOrigin(0);
@@ -7595,10 +7604,7 @@ export class BattleScene extends Phaser.Scene {
title.setOrigin(0.5, 0);
title.setDepth(depth + 2);
const subtitleText =
outcome === 'victory'
? '탁현 의용군은 첫 싸움에서 황건적을 몰아내고 마을을 지켜냈습니다.'
: '유비가 전장을 이탈했습니다. 진형을 가다듬고 다시 도전해야 합니다.';
const subtitleText = this.resultSubtitle(outcome, rewardSnapshot);
const subtitle = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 72, subtitleText, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
@@ -7617,7 +7623,7 @@ export class BattleScene extends Phaser.Scene {
this.renderResultMetric('전투 보상', outcome === 'victory' ? `군자금 ${this.resultGold(outcome)}` : '없음', left + 44 + (metricWidth + metricGap) * 3, metricTop, metricWidth, depth + 2);
this.renderResultObjectivePanel(objectives, left + 44, top + 178, 456, depth + 2);
this.renderResultRewardPanel(outcome, objectives, left + 526, top + 178, 470, depth + 2);
this.renderResultRewardPanel(outcome, objectives, rewardSnapshot, left + 526, top + 178, 470, depth + 2);
this.renderResultBondStrip(left + 44, top + 318, panelWidth - 88, depth + 2);
const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 354, '장수 성장', {
@@ -7764,6 +7770,7 @@ export class BattleScene extends Phaser.Scene {
private publishFirstBattleReport(outcome: BattleOutcome) {
const objectives = this.resultObjectives(outcome);
const mvp = this.resultMvp();
const rewardSnapshot = this.resultCampaignRewards(outcome);
const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length;
const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length;
@@ -7789,13 +7796,85 @@ export class BattleScene extends Phaser.Scene {
defeats: mvp.stats.defeats
}
: undefined,
itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [],
itemRewards: this.resultItemRewards(outcome),
campaignRewards: rewardSnapshot,
completedCampDialogues: [],
completedCampVisits: [],
createdAt: new Date().toISOString()
});
}
private resultSubtitle(outcome: BattleOutcome, rewards: CampaignRewardSnapshot) {
if (outcome !== 'victory') {
return `${battleScenario.title}에서 패배했습니다. 진형과 보급을 정비한 뒤 다시 도전해야 합니다.`;
}
const unlock = rewards.unlocks[0]?.title;
return unlock
? `${battleScenario.title} 승리. 보상 정산 후 ${unlock}로 이어집니다.`
: `${battleScenario.title} 승리. 획득 보상과 장수 성장을 정산합니다.`;
}
private resultItemRewards(outcome: BattleOutcome) {
if (outcome !== 'victory') {
return [];
}
const reward = battleScenario.campaignReward;
return this.uniqueRewardLabels([
...battleScenario.itemRewards,
...(reward?.supplies ?? []),
...(reward?.equipment ?? []),
...(reward?.reputation ?? [])
]);
}
private resultCampaignRewards(outcome: BattleOutcome): CampaignRewardSnapshot {
if (outcome !== 'victory') {
return {
supplies: [],
equipment: [],
reputation: [],
recruits: [],
unlocks: []
};
}
const reward: BattleCampaignRewardDefinition | undefined = battleScenario.campaignReward;
const unlocks = reward?.unlockBattleId
? [
{
battleId: reward.unlockBattleId,
title: reward.unlockLabel ?? getBattleScenario(reward.unlockBattleId).title
}
]
: [];
return {
supplies: this.uniqueRewardLabels(reward?.supplies ?? this.supplyRewardLabels(battleScenario.itemRewards)),
equipment: this.uniqueRewardLabels(reward?.equipment ?? []),
reputation: this.uniqueRewardLabels(reward?.reputation ?? this.reputationRewardLabels(battleScenario.itemRewards)),
recruits: (reward?.recruits ?? []).map((unitId) => ({
unitId,
name: this.unitName(unitId)
})),
unlocks,
note: reward?.note
};
}
private uniqueRewardLabels(labels: string[]) {
return [...new Set(labels.map((label) => label.trim()).filter(Boolean))];
}
private supplyRewardLabels(labels: string[]) {
return labels.filter((label) => /콩|상처약|탁주/.test(label));
}
private reputationRewardLabels(labels: string[]) {
return labels.filter((label) => !/콩|상처약|탁주/.test(label));
}
private renderResultObjectivePanel(objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) {
const height = 132;
const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
@@ -7834,7 +7913,15 @@ export class BattleScene extends Phaser.Scene {
});
}
private renderResultRewardPanel(outcome: BattleOutcome, objectives: BattleObjectiveResult[], x: number, y: number, width: number, depth: number) {
private renderResultRewardPanel(
outcome: BattleOutcome,
objectives: BattleObjectiveResult[],
rewards: CampaignRewardSnapshot,
x: number,
y: number,
width: number,
depth: number
) {
const height = 132;
const bg = this.trackResultObject(this.add.rectangle(x, y, width, height, 0x101820, 0.9));
bg.setOrigin(0);
@@ -7855,11 +7942,21 @@ export class BattleScene extends Phaser.Scene {
.map((bond) => `${this.unitName(bond.unitIds[0])}·${this.unitName(bond.unitIds[1])} +${bond.battleExp}`)
.join(' ');
const achievedCount = objectives.filter((objective) => objective.achieved).length;
const itemRewards = this.resultItemRewards(outcome);
const itemLine = [
rewards.supplies.length > 0 ? `보급 ${rewards.supplies.join(', ')}` : '',
rewards.equipment.length > 0 ? `장비 ${rewards.equipment.join(', ')}` : '',
rewards.reputation.length > 0 ? `명성 ${rewards.reputation.join(', ')}` : ''
].filter(Boolean).join(' · ');
const progressLine = [
rewards.recruits.length > 0 ? `합류 ${rewards.recruits.map((recruit) => recruit.name).join(', ')}` : '',
rewards.unlocks.length > 0 ? `해금 ${rewards.unlocks.map((unlock) => unlock.title).join(', ')}` : ''
].filter(Boolean).join(' · ');
const lines = [
outcome === 'victory' ? `군자금 ${this.resultGold(outcome)} 목표 보상 ${achievedCount}/${objectives.length}` : '패배: 보상 없음',
mvp ? `MVP ${mvp.unit.name}: 피해 ${mvp.stats.damageDealt}, 격파 ${mvp.stats.defeats}` : 'MVP 없음',
bondSummary ? `공명 성장 ${bondSummary}` : '공명 성장 없음',
outcome === 'victory' ? `전리품: ${battleScenario.itemRewards.join(', ')}` : '재도전하면 목표 보상을 다시 노릴 수 있습니다.'
outcome === 'victory' ? (itemLine || `전리품 ${itemRewards.join(', ') || '없음'}`) : '재도전하면 목표 보상을 다시 노릴 수 있습니다.',
outcome === 'victory' ? (progressLine || rewards.note || '새 해금 없음') : (bondSummary ? `공명 성장 ${bondSummary}` : '공명 성장 없음')
];
lines.forEach((line, index) => {
@@ -15427,6 +15524,24 @@ export class BattleScene extends Phaser.Scene {
.filter((preview): preview is NonNullable<typeof preview> => Boolean(preview));
}
private scheduleDebugForcedBattleOutcome() {
const outcome = this.debugForcedBattleOutcomeParam();
if (!outcome) {
return;
}
this.time.delayedCall(900, () => this.debugForceBattleOutcome(outcome));
}
private debugForcedBattleOutcomeParam(): BattleOutcome | undefined {
if (!this.debugToolsEnabled() || typeof window === 'undefined') {
return undefined;
}
const value = new URLSearchParams(window.location.search).get('debugForceBattleOutcome')?.trim();
return value === 'victory' || value === 'defeat' ? value : undefined;
}
debugForceBattleOutcome(outcome: BattleOutcome = 'victory') {
if (!this.debugToolsEnabled() || this.battleOutcome) {
return;

View File

@@ -12774,11 +12774,38 @@ export class CampScene extends Phaser.Scene {
this.track(
this.add.text(
x + 18,
y + 43,
y + 39,
`MVP ${report.mvp?.name ?? '-'} · 전투 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`,
this.textStyle(15, '#d4dce6')
this.textStyle(14, '#d4dce6')
)
);
this.track(
this.add.text(
x + 18,
y + 62,
this.reportCampaignRewardLine(report),
this.textStyle(12, '#d8b15f', true)
)
);
}
private reportCampaignRewardLine(report: FirstBattleReport) {
const rewards = report.campaignRewards;
if (!rewards) {
return '캠페인 보상: 기존 전리품이 군영 장부에 반영되었습니다.';
}
const recruits = rewards.recruits ?? [];
const equipment = rewards.equipment ?? [];
const reputation = rewards.reputation ?? [];
const unlocks = rewards.unlocks ?? [];
const parts = [
recruits.length > 0 ? `합류 ${recruits.map((recruit) => recruit.name).join(', ')}` : '',
equipment.length > 0 ? `장비 ${equipment.join(', ')}` : '',
reputation.length > 0 ? `명성 ${reputation.join(', ')}` : '',
unlocks.length > 0 ? `다음 ${unlocks.map((unlock) => unlock.title).join(', ')}` : ''
].filter(Boolean);
return parts.length > 0 ? `캠페인 반영: ${parts.join(' · ')}` : '캠페인 보상: 추가 해금 없음';
}
private renderProgressPanel() {

View File

@@ -19,6 +19,14 @@ export class TitleScene extends Phaser.Scene {
this.settingsPanel = undefined;
this.navigating = false;
const debugBattleId = this.debugBattleId();
if (debugBattleId) {
this.time.delayedCall(0, () => {
void this.navigateTo('BattleScene', { battleId: debugBattleId });
});
return;
}
if (this.shouldOpenDebugSortiePrep()) {
this.time.delayedCall(0, () => {
void this.navigateTo('CampScene', {
@@ -55,6 +63,17 @@ export class TitleScene extends Phaser.Scene {
return params.has('debugSortiePrep') && (params.has('debug') || import.meta.env.DEV);
}
private debugBattleId() {
if (typeof window === 'undefined') {
return undefined;
}
const params = new URLSearchParams(window.location.search);
if (!params.has('debugBattle') || (!params.has('debug') && !import.meta.env.DEV)) {
return undefined;
}
return params.get('debugBattle')?.trim() || undefined;
}
private drawBackground(width: number, height: number) {
const background = this.add.image(width / 2, height / 2, 'title-taoyuan');
const texture = this.textures.get('title-taoyuan').getSourceImage();

View File

@@ -21,6 +21,21 @@ export type CampMvpSnapshot = {
defeats: number;
};
export type CampaignRewardSnapshot = {
supplies: string[];
equipment: string[];
reputation: string[];
recruits: {
unitId: string;
name: string;
}[];
unlocks: {
battleId: string;
title: string;
}[];
note?: string;
};
export type FirstBattleReport = {
battleId: string;
battleTitle: string;
@@ -34,6 +49,7 @@ export type FirstBattleReport = {
bonds: CampBondSnapshot[];
mvp?: CampMvpSnapshot;
itemRewards: string[];
campaignRewards?: CampaignRewardSnapshot;
completedCampDialogues: string[];
completedCampVisits: string[];
createdAt: string;
@@ -263,6 +279,7 @@ export type CampaignBattleSettlement = {
outcome: FirstBattleReport['outcome'];
rewardGold: number;
itemRewards: string[];
campaignRewards?: CampaignRewardSnapshot;
objectives: BattleObjectiveSnapshot[];
units: CampaignUnitProgressSnapshot[];
bonds: CampaignBondProgressSnapshot[];
@@ -494,7 +511,10 @@ export function setFirstBattleReport(report: FirstBattleReport) {
state.step = victoryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
const alliedReportUnits = reportClone.units.filter((unit) => unit.faction === 'ally');
const recruitUnitIds = new Set(reportClone.campaignRewards?.recruits.map((recruit) => recruit.unitId) ?? []);
const recruitedUnits = reportClone.units.filter((unit) => unit.faction === 'ally' && recruitUnitIds.has(unit.id));
state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]);
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
? previousSettlement?.reserveTraining ?? []
@@ -753,6 +773,7 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
outcome: report.outcome,
rewardGold: report.rewardGold,
itemRewards: [...report.itemRewards],
campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards),
objectives: report.objectives.map((objective) => ({ ...objective })),
units: report.units
.filter((unit) => unit.faction === 'ally')
@@ -887,9 +908,27 @@ function cloneCampaignState(state: CampaignState): CampaignState {
function cloneReport(report: FirstBattleReport): FirstBattleReport {
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
cloned.completedCampVisits = [...new Set(cloned.completedCampVisits ?? [])];
if (cloned.campaignRewards) {
cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards);
}
return cloned;
}
function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot): CampaignRewardSnapshot | undefined {
if (!rewards) {
return undefined;
}
return {
supplies: [...(rewards.supplies ?? [])],
equipment: [...(rewards.equipment ?? [])],
reputation: [...(rewards.reputation ?? [])],
recruits: (rewards.recruits ?? []).map((recruit) => ({ ...recruit })),
unlocks: (rewards.unlocks ?? []).map((unlock) => ({ ...unlock })),
note: rewards.note
};
}
function cloneUnit(unit: UnitData): UnitData {
return JSON.parse(JSON.stringify(unit)) as UnitData;
}