Add post-battle camp scene
This commit is contained in:
@@ -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<string, string> = {
|
||||
@@ -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));
|
||||
|
||||
507
src/game/scenes/CampScene.ts
Normal file
507
src/game/scenes/CampScene.ts
Normal file
@@ -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<PortraitKey, string> = {
|
||||
liuBei: 'portrait-liu-bei',
|
||||
guanYu: 'portrait-guan-yu',
|
||||
zhangFei: 'portrait-zhang-fei'
|
||||
};
|
||||
|
||||
const portraitByUnitId: Record<string, PortraitKey> = {
|
||||
'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<T extends Phaser.GameObjects.GameObject>(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
|
||||
};
|
||||
}
|
||||
}
|
||||
73
src/game/state/campaignState.ts
Normal file
73
src/game/state/campaignState.ts
Normal file
@@ -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;
|
||||
}
|
||||
14
src/main.ts
14
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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user