diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 3b8225a..eb9c6ba 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -139,6 +139,28 @@ try { await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); + await page.mouse.click(738, 642); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('CampScene'); + }); + await page.screenshot({ path: 'dist/verification-camp.png', fullPage: true }); + + const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!campState || campState.scene !== 'CampScene' || campState.report?.rewardGold <= 0) { + throw new Error(`Expected CampScene report after victory: ${JSON.stringify(campState)}`); + } + + await page.mouse.click(830, 38); + await page.waitForTimeout(120); + await page.mouse.click(974, 482); + await page.waitForTimeout(260); + + const campStateAfterDialogue = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!campStateAfterDialogue.report?.completedCampDialogues?.length) { + throw new Error(`Expected camp dialogue to award bond exp: ${JSON.stringify(campStateAfterDialogue)}`); + } + await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle()); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 4982560..8b03a38 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -4,7 +4,6 @@ import { firstBattleBonds, firstBattleMap, firstBattleUnits, - firstBattleVictoryPages, type BattleBond, type UnitData, type UnitStats @@ -17,6 +16,7 @@ import { getItem, type EquipmentSlot } from '../data/battleItems'; +import { setFirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; const unitTexture: Record = { @@ -1732,6 +1732,7 @@ export class BattleScene extends Phaser.Scene { volume: outcome === 'victory' ? 0.38 : 0.28, stopAfterMs: 1200 }); + this.publishFirstBattleReport(outcome); this.showBattleResult(outcome); } @@ -1807,9 +1808,9 @@ export class BattleScene extends Phaser.Scene { }); if (outcome === 'victory') { - this.addResultButton('이야기 계속', left + panelWidth - 422, top + 612, 132, () => { + this.addResultButton('군영으로', left + panelWidth - 422, top + 612, 132, () => { soundDirector.playSelect(); - this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + this.scene.start('CampScene'); }, depth + 3); } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { @@ -1885,6 +1886,38 @@ export class BattleScene extends Phaser.Scene { .sort((a, b) => b.score - a.score || b.stats.defeats - a.stats.defeats || b.stats.damageDealt - a.stats.damageDealt)[0]; } + private publishFirstBattleReport(outcome: BattleOutcome) { + const objectives = this.resultObjectives(outcome); + const mvp = this.resultMvp(); + const defeatedEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; + const totalEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy').length; + + setFirstBattleReport({ + outcome, + turnNumber: this.turnNumber, + rewardGold: this.resultGold(outcome), + defeatedEnemies, + totalEnemies, + objectives: objectives.map((objective) => ({ ...objective })), + units: firstBattleUnits.map(cloneUnitData), + bonds: Array.from(this.bondStates.values()).map((bond) => ({ + ...bond, + unitIds: [...bond.unitIds] as [string, string] + })), + mvp: mvp + ? { + unitId: mvp.unit.id, + name: mvp.unit.name, + damageDealt: mvp.stats.damageDealt, + defeats: mvp.stats.defeats + } + : undefined, + itemRewards: outcome === 'victory' ? ['콩 1', '탁주 1', '의용군 명성 +1'] : [], + completedCampDialogues: [], + createdAt: new Date().toISOString() + }); + } + 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)); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts new file mode 100644 index 0000000..df18184 --- /dev/null +++ b/src/game/scenes/CampScene.ts @@ -0,0 +1,507 @@ +import Phaser from 'phaser'; +import { soundDirector } from '../audio/SoundDirector'; +import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; +import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario'; +import { applyCampBondExp, getFirstBattleReport, type CampBondSnapshot, type FirstBattleReport } from '../state/campaignState'; +import { palette } from '../ui/palette'; + +type CampTab = 'status' | 'dialogue' | 'supplies'; + +type CampDialogue = { + id: string; + title: string; + unitIds: [string, string]; + bondId: string; + rewardExp: number; + lines: string[]; +}; + +const portraitKeys: Record = { + liuBei: 'portrait-liu-bei', + guanYu: 'portrait-guan-yu', + zhangFei: 'portrait-zhang-fei' +}; + +const portraitByUnitId: Record = { + 'liu-bei': 'liuBei', + 'guan-yu': 'guanYu', + 'zhang-fei': 'zhangFei' +}; + +const campDialogues: CampDialogue[] = [ + { + id: 'liu-guan-after-first-battle', + title: '의리를 다지는 밤', + unitIds: ['liu-bei', 'guan-yu'], + bondId: 'liu-bei__guan-yu', + rewardExp: 14, + lines: [ + '유비: 운장, 오늘 그대가 앞을 막아 주지 않았다면 백성들을 지키지 못했을 것이오.', + '관우: 형님의 뜻이 분명했기에 칼을 들 수 있었습니다. 다음 싸움에서도 길을 열겠습니다.', + '유비: 우리의 맹세가 전장에서 더 단단해지는구려.' + ] + }, + { + id: 'liu-zhang-after-first-battle', + title: '거친 용기를 붙잡다', + unitIds: ['liu-bei', 'zhang-fei'], + bondId: 'liu-bei__zhang-fei', + rewardExp: 14, + lines: [ + '장비: 형님, 다음엔 제가 먼저 뛰쳐나가 두령 놈을 단번에 꺾겠습니다!', + '유비: 익덕의 용맹은 든든하나, 백성이 뒤에 있음을 잊지 말아야 하오.', + '장비: 하하! 형님 말이라면 참아 보겠습니다. 대신 싸울 때는 크게 치겠습니다.' + ] + }, + { + id: 'guan-zhang-after-first-battle', + title: '칼과 창의 약속', + unitIds: ['guan-yu', 'zhang-fei'], + bondId: 'guan-yu__zhang-fei', + rewardExp: 12, + lines: [ + '관우: 익덕, 적이 흩어질 때 너무 깊이 들어가면 형님이 걱정하신다.', + '장비: 운장 형님이 옆을 받쳐 준다면 걱정할 일이 없지 않습니까?', + '관우: 좋다. 다음 전장에서는 서로의 빈틈을 먼저 살피자.' + ] + } +]; + +export class CampScene extends Phaser.Scene { + private report?: FirstBattleReport; + private selectedUnitId = 'liu-bei'; + private activeTab: CampTab = 'status'; + private selectedDialogueId = campDialogues[0].id; + private contentObjects: Phaser.GameObjects.GameObject[] = []; + private dialogueObjects: Phaser.GameObjects.GameObject[] = []; + + constructor() { + super('CampScene'); + } + + create() { + this.report = getFirstBattleReport() ?? this.createFallbackReport(); + this.selectedUnitId = this.report.units.find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; + soundDirector.playMusic('militia-theme'); + + const { width, height } = this.scale; + this.add.image(width / 2, height / 2, 'story-militia').setDisplaySize(width * 1.08, height * 1.08).setAlpha(0.62); + this.add.rectangle(0, 0, width, height, 0x06090d, 0.54).setOrigin(0); + this.add.rectangle(0, 0, width, 84, 0x06090d, 0.52).setOrigin(0); + + this.add.text(42, 28, '탁현 의용군 군영', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '30px', + color: '#f2e3bf', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 4 + }); + this.add.text(42, 64, this.reportSummary(), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '14px', + color: '#d4dce6' + }); + + this.renderStaticButtons(); + this.render(); + } + + private createFallbackReport(): FirstBattleReport { + const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally'); + return { + outcome: 'victory', + turnNumber: 1, + rewardGold: 300, + defeatedEnemies: 0, + totalEnemies: firstBattleUnits.filter((unit) => unit.faction === 'enemy').length, + objectives: [], + units: allies.map((unit) => JSON.parse(JSON.stringify(unit)) as UnitData), + bonds: firstBattleBonds.map((bond) => ({ ...bond, battleExp: 0 })), + itemRewards: [], + completedCampDialogues: [], + createdAt: new Date().toISOString() + }; + } + + private reportSummary() { + if (!this.report) { + return ''; + } + + const achieved = this.report.objectives.filter((objective) => objective.achieved).length; + return `첫 전투 ${this.report.turnNumber}턴 승리 · 군자금 ${this.report.rewardGold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`; + } + + private renderStaticButtons() { + this.addTabButton('장수', 'status', 742, 38); + this.addTabButton('대화', 'dialogue', 830, 38); + this.addTabButton('정비', 'supplies', 918, 38); + this.addCommandButton('다음 이야기', 1080, 38, 150, () => { + soundDirector.playSelect(); + this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + }); + } + + private addTabButton(label: string, tab: CampTab, x: number, y: number) { + const bg = this.add.rectangle(x, y, 76, 34, 0x182431, 0.94); + bg.setStrokeStyle(1, palette.blue, 0.62); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => { + soundDirector.playSelect(); + this.activeTab = tab; + this.render(); + }); + + const text = this.add.text(x, y, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f2e3bf', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', () => { + soundDirector.playSelect(); + this.activeTab = tab; + this.render(); + }); + } + + private addCommandButton(label: string, x: number, y: number, width: number, action: () => void) { + const bg = this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96); + bg.setStrokeStyle(1, palette.gold, 0.8); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); + bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96)); + bg.on('pointerdown', action); + + const text = this.add.text(x, y, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f2e3bf', + fontStyle: '700' + }); + text.setOrigin(0.5); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', action); + } + + private render() { + this.clearContent(); + this.renderUnitColumn(); + this.renderReportPanel(); + + if (this.activeTab === 'dialogue') { + this.renderDialoguePanel(); + return; + } + + if (this.activeTab === 'supplies') { + this.renderSuppliesPanel(); + return; + } + + this.renderStatusPanel(); + } + + private renderUnitColumn() { + const allies = this.report?.units.filter((unit) => unit.faction === 'ally') ?? []; + allies.forEach((unit, index) => { + const x = 42; + const y = 120 + index * 150; + 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)); + bg.setOrigin(0); + bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => { + soundDirector.playSelect(); + this.selectedUnitId = unit.id; + this.render(); + }); + + const portraitKey = portraitByUnitId[unit.id]; + if (portraitKey) { + const portrait = this.track(this.add.image(x + 58, y + 62, portraitKeys[portraitKey])); + portrait.setDisplaySize(92, 92); + } + + 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); + }); + } + + private renderReportPanel() { + const report = this.report; + if (!report) { + return; + } + + const x = 42; + const y = 590; + const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.48); + this.track(this.add.text(x + 18, y + 14, '전투 보고', this.textStyle(18, '#f2e3bf', true))); + this.track( + this.add.text( + x + 18, + y + 43, + `MVP ${report.mvp?.name ?? '-'} · 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`, + this.textStyle(15, '#d4dce6') + ) + ); + } + + private renderStatusPanel() { + const unit = this.selectedUnit(); + if (!unit) { + return; + } + + const x = 394; + const y = 120; + const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.blue, 0.56); + + this.track(this.add.text(x + 24, y + 22, `${unit.name} 상태`, this.textStyle(24, '#f2e3bf', true))); + this.renderStatLine('병력', `${unit.hp} / ${unit.maxHp}`, x + 24, y + 70, 360); + this.renderStatLine('공격', `${unit.attack}`, x + 24, y + 110, 360); + this.renderStatLine('이동', `${unit.move}`, x + 24, y + 150, 360); + this.renderStatLine('무력', `${unit.stats.might}`, x + 430, y + 70, 350); + this.renderStatLine('지력', `${unit.stats.intelligence}`, x + 430, y + 110, 350); + this.renderStatLine('통솔', `${unit.stats.leadership}`, x + 430, y + 150, 350); + this.renderStatLine('민첩', `${unit.stats.agility}`, x + 430, y + 190, 350); + this.renderStatLine('운', `${unit.stats.luck}`, x + 430, y + 230, 350); + + this.track(this.add.text(x + 24, y + 214, '장비 성장', this.textStyle(20, '#f2e3bf', true))); + equipmentSlots.forEach((slot, index) => { + this.renderEquipmentRow(unit, slot, x + 24, y + 254 + index * 54, 760); + }); + } + + private renderDialoguePanel() { + const x = 394; + const y = 120; + const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.58); + this.track(this.add.text(x + 24, y + 22, '장수 대화', this.textStyle(24, '#f2e3bf', true))); + this.track(this.add.text(x + 24, y + 56, '대화를 보면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6'))); + + campDialogues.forEach((dialogue, index) => { + const completed = this.report?.completedCampDialogues.includes(dialogue.id) ?? false; + const rowY = y + 96 + index * 64; + 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)); + row.setOrigin(0); + row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46); + row.setInteractive({ useHandCursor: true }); + row.on('pointerdown', () => { + soundDirector.playSelect(); + 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 + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}`, this.textStyle(12, '#9fb0bf'))); + }); + + this.renderSelectedDialogue(x + 372, y + 96, 416, 300); + this.renderBondList(x + 24, y + 308, 320); + } + + private renderSelectedDialogue(x: number, y: number, width: number, height: number) { + const dialogue = campDialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? campDialogues[0]; + const completed = this.report?.completedCampDialogues.includes(dialogue.id) ?? false; + const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5); + this.track(this.add.text(x + 18, y + 16, dialogue.title, this.textStyle(20, '#f2e3bf', true))); + this.track( + this.add.text(x + 18, y + 52, dialogue.lines.join('\n\n'), { + ...this.textStyle(15, '#d4dce6'), + wordWrap: { width: width - 36, useAdvancedWrap: true }, + lineSpacing: 5 + }) + ); + + const buttonLabel = completed ? '대화 완료' : `대화 보기 공명 +${dialogue.rewardExp}`; + const button = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, completed ? 0x17231d : 0x1a2630, 0.96)); + button.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.76); + if (!completed) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerdown', () => this.completeDialogue(dialogue)); + } + const text = this.track(this.add.text(x + width / 2, y + height - 34, buttonLabel, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true))); + text.setOrigin(0.5); + if (!completed) { + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', () => this.completeDialogue(dialogue)); + } + } + + private renderBondList(x: number, y: number, width: number) { + this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true))); + this.report?.bonds.forEach((bond, index) => { + const rowY = y + 32 + index * 36; + 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.drawBar(x + 136, rowY + 6, width - 136, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue); + }); + } + + private renderSuppliesPanel() { + const x = 394; + const y = 120; + const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.blue, 0.56); + this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true))); + this.track(this.add.text(x + 24, y + 58, '획득한 보상과 장비 성장 상태를 확인합니다. 장비 교체와 상점은 다음 확장에서 붙일 수 있습니다.', this.textStyle(14, '#d4dce6'))); + + const rewards = this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1']; + rewards.forEach((reward, index) => { + this.renderSupplyBox(reward, x + 24 + index * 160, y + 104); + }); + + this.track(this.add.text(x + 24, y + 190, '부대 장비 요약', this.textStyle(20, '#f2e3bf', true))); + this.report?.units + .filter((unit) => unit.faction === 'ally') + .forEach((unit, index) => { + const rowY = y + 232 + index * 58; + this.track(this.add.text(x + 24, rowY, unit.name, this.textStyle(17, '#f2e3bf', true))); + equipmentSlots.forEach((slot, slotIndex) => { + const state = unit.equipment[slot]; + const item = getItem(state.itemId); + this.track(this.add.text(x + 110 + slotIndex * 218, rowY, `${equipmentSlotLabels[slot]} ${item.name}`, this.textStyle(13, '#d4dce6'))); + this.drawBar(x + 110 + slotIndex * 218, rowY + 24, 176, 6, state.exp / equipmentExpToNext(state.level), item.rank === 'treasure' ? palette.gold : palette.blue); + }); + }); + } + + private renderSupplyBox(label: string, x: number, y: number) { + const bg = this.track(this.add.rectangle(x, y, 138, 50, 0x151f2a, 0.9)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.52); + this.track(this.add.text(x + 14, y + 15, label, this.textStyle(16, '#f2e3bf', true))); + } + + private renderStatLine(label: string, value: string, x: number, y: number, width: number) { + const bg = this.track(this.add.rectangle(x, y, width, 30, 0x151f2a, 0.72)); + bg.setOrigin(0); + bg.setStrokeStyle(1, 0x53606c, 0.36); + this.track(this.add.text(x + 12, y + 7, label, this.textStyle(14, '#9fb0bf'))); + const valueText = this.track(this.add.text(x + width - 12, y + 6, value, this.textStyle(15, '#f2e3bf', true))); + valueText.setOrigin(1, 0); + } + + private renderEquipmentRow(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number) { + const state = unit.equipment[slot]; + const item = getItem(state.itemId); + const next = equipmentExpToNext(state.level); + const bg = this.track(this.add.rectangle(x, y, width, 42, item.rank === 'treasure' ? 0x1f2430 : 0x151f2a, 0.9)); + bg.setOrigin(0); + bg.setStrokeStyle(1, item.rank === 'treasure' ? palette.gold : palette.blue, item.rank === 'treasure' ? 0.62 : 0.38); + const icon = this.track(this.add.image(x + 22, y + 21, `item-${item.id}`)); + icon.setDisplaySize(28, 28); + this.track(this.add.text(x + 48, y + 7, `${equipmentSlotLabels[slot]} ${item.name} Lv ${state.level}`, this.textStyle(15, item.rank === 'treasure' ? '#f4dfad' : '#d4dce6', true))); + this.track(this.add.text(x + width - 110, y + 7, `${state.exp}/${next}`, this.textStyle(13, '#9fb0bf'))); + this.drawBar(x + 48, y + 30, width - 92, 6, state.exp / next, item.rank === 'treasure' ? palette.gold : palette.blue); + } + + private completeDialogue(dialogue: CampDialogue) { + const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp); + if (updated) { + this.report = updated; + } else if (this.report && !this.report.completedCampDialogues.includes(dialogue.id)) { + this.report.completedCampDialogues.push(dialogue.id); + const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId); + if (bond) { + bond.battleExp += dialogue.rewardExp; + bond.exp += dialogue.rewardExp; + } + } + + soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 }); + this.showDialogueReward(dialogue); + this.render(); + } + + private showDialogueReward(dialogue: CampDialogue) { + this.dialogueObjects.forEach((object) => object.destroy()); + this.dialogueObjects = []; + const box = this.add.rectangle(this.scale.width / 2, 104, 440, 52, 0x101820, 0.96); + box.setStrokeStyle(2, palette.gold, 0.84); + box.setDepth(30); + const text = this.add.text(this.scale.width / 2, 104, `${dialogue.title} 완료 · 공명 +${dialogue.rewardExp}`, this.textStyle(17, '#f2e3bf', true)); + text.setOrigin(0.5); + text.setDepth(31); + this.dialogueObjects.push(box, text); + this.tweens.add({ + targets: this.dialogueObjects, + alpha: 0, + delay: 1300, + duration: 320, + onComplete: () => { + this.dialogueObjects.forEach((object) => object.destroy()); + this.dialogueObjects = []; + } + }); + } + + private selectedUnit() { + return this.report?.units.find((unit) => unit.id === this.selectedUnitId); + } + + private unitName(unitId: string) { + return this.report?.units.find((unit) => unit.id === unitId)?.name ?? unitId; + } + + private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) { + const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86)); + track.setOrigin(0); + const fill = this.track(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96)); + fill.setOrigin(0); + } + + private textStyle(fontSize: number, color: string, bold = false): Phaser.Types.GameObjects.Text.TextStyle { + return { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: `${fontSize}px`, + color, + fontStyle: bold ? '700' : '400' + }; + } + + private track(object: T) { + this.contentObjects.push(object); + return object; + } + + private clearContent() { + this.contentObjects.forEach((object) => object.destroy()); + this.contentObjects = []; + } + + getDebugState() { + return { + scene: this.scene.key, + activeTab: this.activeTab, + selectedUnitId: this.selectedUnitId, + selectedDialogueId: this.selectedDialogueId, + report: this.report + ? { + rewardGold: this.report.rewardGold, + objectives: this.report.objectives, + completedCampDialogues: this.report.completedCampDialogues, + bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp })) + } + : null + }; + } +} diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts new file mode 100644 index 0000000..194e3d7 --- /dev/null +++ b/src/game/state/campaignState.ts @@ -0,0 +1,73 @@ +import type { BattleBond, UnitData } from '../data/scenario'; + +export type BattleObjectiveSnapshot = { + id: string; + label: string; + achieved: boolean; + detail: string; + rewardGold: number; +}; + +export type CampBondSnapshot = BattleBond & { + battleExp: number; +}; + +export type CampMvpSnapshot = { + unitId: string; + name: string; + damageDealt: number; + defeats: number; +}; + +export type FirstBattleReport = { + outcome: 'victory' | 'defeat'; + turnNumber: number; + rewardGold: number; + defeatedEnemies: number; + totalEnemies: number; + objectives: BattleObjectiveSnapshot[]; + units: UnitData[]; + bonds: CampBondSnapshot[]; + mvp?: CampMvpSnapshot; + itemRewards: string[]; + completedCampDialogues: string[]; + createdAt: string; +}; + +let firstBattleReport: FirstBattleReport | undefined; + +export function setFirstBattleReport(report: FirstBattleReport) { + firstBattleReport = cloneReport(report); +} + +export function getFirstBattleReport() { + return firstBattleReport ? cloneReport(firstBattleReport) : undefined; +} + +export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) { + if (!firstBattleReport) { + return undefined; + } + + const bond = firstBattleReport.bonds.find((candidate) => candidate.id === bondId); + if (!bond) { + return undefined; + } + + if (!firstBattleReport.completedCampDialogues.includes(dialogueId)) { + firstBattleReport.completedCampDialogues.push(dialogueId); + } + + bond.battleExp += amount; + bond.exp += amount; + while (bond.exp >= 100) { + bond.exp -= 100; + bond.level = Math.min(100, bond.level + 1); + } + + return cloneReport(firstBattleReport); +} + +function cloneReport(report: FirstBattleReport): FirstBattleReport { + return JSON.parse(JSON.stringify(report)) as FirstBattleReport; +} diff --git a/src/main.ts b/src/main.ts index 72c7a3d..edc42fc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { effectTracks, musicTracks } from './game/audio/audioAssets'; import { soundDirector } from './game/audio/SoundDirector'; import { BattleScene } from './game/scenes/BattleScene'; import { BootScene } from './game/scenes/BootScene'; +import { CampScene } from './game/scenes/CampScene'; import { StoryScene } from './game/scenes/StoryScene'; import { TitleScene } from './game/scenes/TitleScene'; import './styles/global.css'; @@ -20,11 +21,17 @@ type DebugBattleScene = Phaser.Scene & { debugForceBattleOutcome?: (outcome: 'victory' | 'defeat') => void; }; +type DebugCampScene = Phaser.Scene & { + getDebugState?: () => unknown; +}; + type HerosDebugApi = { game: Phaser.Game; activeScenes: () => string[]; battle: () => unknown; + camp: () => unknown; goToBattle: () => void; + goToCamp: () => void; forceBattleOutcome: (outcome: 'victory' | 'defeat') => void; scene: (key: string) => Phaser.Scene | undefined; toggleBattleOverlay: () => void; @@ -40,7 +47,7 @@ const config: Phaser.Types.Core.GameConfig = { mode: Phaser.Scale.RESIZE, autoCenter: Phaser.Scale.CENTER_BOTH }, - scene: [BootScene, TitleScene, StoryScene, BattleScene] + scene: [BootScene, TitleScene, StoryScene, BattleScene, CampScene] }; soundDirector.registerMusicTracks(musicTracks); @@ -57,14 +64,19 @@ if (import.meta.env.DEV) { function createDebugApi(game: Phaser.Game): HerosDebugApi { const scene = (key: string) => game.scene.getScene(key); const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined; + const campScene = () => scene('CampScene') as DebugCampScene | undefined; return { game, activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key), battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' }, + camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' }, goToBattle: () => { game.scene.start('BattleScene'); }, + goToCamp: () => { + game.scene.start('CampScene'); + }, forceBattleOutcome: (outcome) => { battleScene()?.debugForceBattleOutcome?.(outcome); },