From 0c4e3582849873353f83f284f536197d7b1c0d4e Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 3 Jul 2026 23:21:30 +0900 Subject: [PATCH] Add campaign battle rewards --- src/game/data/battles.ts | 52 ++++++++++++ src/game/scenes/BattleScene.ts | 135 +++++++++++++++++++++++++++++--- src/game/scenes/CampScene.ts | 31 +++++++- src/game/scenes/TitleScene.ts | 19 +++++ src/game/state/campaignState.ts | 41 +++++++++- 5 files changed, 265 insertions(+), 13 deletions(-) diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index a5342d9..3495848 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -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' }; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 63eb9c3..eb64722 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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 => 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; diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index f997cc8..0a7d7b6 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -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() { diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 4c71f45..d073be6 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -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(); diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 170f151..ea181c7 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -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; }