1096 lines
44 KiB
TypeScript
1096 lines
44 KiB
TypeScript
import Phaser from 'phaser';
|
|
import { soundDirector } from '../audio/SoundDirector';
|
|
import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems';
|
|
import { getUnitClass } from '../data/battleRules';
|
|
import { defaultBattleScenario, secondBattleScenario } from '../data/battles';
|
|
import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario';
|
|
import {
|
|
applyCampBondExp,
|
|
getCampaignState,
|
|
getFirstBattleReport,
|
|
markCampaignStep,
|
|
saveCampaignState,
|
|
type CampaignState,
|
|
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[];
|
|
};
|
|
|
|
type CampTabButtonView = {
|
|
tab: CampTab;
|
|
bg: Phaser.GameObjects.Rectangle;
|
|
text: Phaser.GameObjects.Text;
|
|
};
|
|
|
|
type CampSupplyDefinition = {
|
|
label: string;
|
|
title: string;
|
|
description: string;
|
|
healHp: number;
|
|
bondExp: number;
|
|
};
|
|
|
|
type SortieChecklistItem = {
|
|
label: string;
|
|
complete: boolean;
|
|
detail: 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 campSupplies: CampSupplyDefinition[] = [
|
|
{
|
|
label: '콩',
|
|
title: '콩',
|
|
description: '선택 장수의 병력을 12 회복합니다.',
|
|
healHp: 12,
|
|
bondExp: 0
|
|
},
|
|
{
|
|
label: '탁주',
|
|
title: '탁주',
|
|
description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.',
|
|
healHp: 8,
|
|
bondExp: 2
|
|
}
|
|
];
|
|
|
|
const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply]));
|
|
|
|
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 campaign?: CampaignState;
|
|
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[] = [];
|
|
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
|
|
private tabButtons: CampTabButtonView[] = [];
|
|
private visitedTabs = new Set<CampTab>();
|
|
|
|
constructor() {
|
|
super('CampScene');
|
|
}
|
|
|
|
create() {
|
|
this.contentObjects = [];
|
|
this.dialogueObjects = [];
|
|
this.sortieObjects = [];
|
|
this.tabButtons = [];
|
|
this.visitedTabs = new Set(['status']);
|
|
this.campaign = getCampaignState();
|
|
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
|
|
this.selectedUnitId = this.currentUnits().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 {
|
|
battleId: defaultBattleScenario.id,
|
|
battleTitle: defaultBattleScenario.title,
|
|
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;
|
|
const gold = this.campaign?.gold ?? this.report.rewardGold;
|
|
return `첫 전투 ${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${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('저장', 1002, 38, 76, () => {
|
|
soundDirector.playSelect();
|
|
this.campaign = saveCampaignState(getCampaignState());
|
|
this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`);
|
|
this.render();
|
|
});
|
|
this.addCommandButton('다음 이야기', 1120, 38, 142, () => {
|
|
soundDirector.playSelect();
|
|
this.showSortiePrep();
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
this.tabButtons.push({ tab, bg, text });
|
|
this.updateTabButtons();
|
|
}
|
|
|
|
private updateTabButtons() {
|
|
this.tabButtons.forEach(({ tab, bg, text }) => {
|
|
const active = this.activeTab === tab;
|
|
bg.setFillStyle(active ? 0x25384a : 0x182431, active ? 0.98 : 0.94);
|
|
bg.setStrokeStyle(1, active ? palette.gold : palette.blue, active ? 0.86 : 0.62);
|
|
text.setColor(active ? '#fff2b8' : '#f2e3bf');
|
|
});
|
|
}
|
|
|
|
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.visitedTabs.add(this.activeTab);
|
|
this.updateTabButtons();
|
|
this.renderUnitColumn();
|
|
this.renderReportPanel();
|
|
|
|
if (this.activeTab === 'dialogue') {
|
|
this.renderDialoguePanel();
|
|
return;
|
|
}
|
|
|
|
if (this.activeTab === 'supplies') {
|
|
this.renderSuppliesPanel();
|
|
return;
|
|
}
|
|
|
|
this.renderStatusPanel();
|
|
}
|
|
|
|
private showSortiePrep() {
|
|
this.hideSortiePrep();
|
|
this.campaign = getCampaignState();
|
|
this.report = this.campaign.firstBattleReport ?? this.report;
|
|
|
|
const depth = 40;
|
|
const width = 900;
|
|
const height = 560;
|
|
const x = Math.floor((this.scale.width - width) / 2);
|
|
const y = Math.floor((this.scale.height - height) / 2);
|
|
|
|
const shade = this.trackSortie(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68));
|
|
shade.setOrigin(0);
|
|
shade.setDepth(depth);
|
|
shade.setInteractive();
|
|
|
|
const panel = this.trackSortie(this.add.rectangle(x, y, width, height, 0x101820, 0.98));
|
|
panel.setOrigin(0);
|
|
panel.setDepth(depth + 1);
|
|
panel.setStrokeStyle(3, palette.gold, 0.92);
|
|
|
|
const title = this.trackSortie(this.add.text(x + 34, y + 28, '출진 준비', this.textStyle(30, '#f2e3bf', true)));
|
|
title.setDepth(depth + 2);
|
|
const subtitle = this.trackSortie(
|
|
this.add.text(x + 34, y + 70, '다음 전장으로 향하기 전 부대 상태와 준비 항목을 확인합니다.', this.textStyle(15, '#d4dce6'))
|
|
);
|
|
subtitle.setDepth(depth + 2);
|
|
|
|
this.renderSortieBriefing(x + 34, y + 112, 396, 152, depth + 2);
|
|
this.renderSortieChecklist(x + 462, y + 112, 404, 170, depth + 2);
|
|
this.renderSortieUnitSummary(x + 34, y + 292, 832, 148, depth + 2);
|
|
this.renderSortieRewardHint(x + 34, y + 462, 548, 52, depth + 2);
|
|
|
|
this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => {
|
|
soundDirector.playSelect();
|
|
this.hideSortiePrep();
|
|
}, depth + 3);
|
|
this.addSortieButton('출진', x + width - 152, y + height - 44, 124, () => {
|
|
soundDirector.playSelect();
|
|
this.startVictoryStory();
|
|
}, depth + 3);
|
|
}
|
|
|
|
private renderSortieBriefing(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.blue, 0.48);
|
|
|
|
const briefing = this.nextSortieBriefing();
|
|
this.trackSortie(this.add.text(x + 18, y + 14, briefing.eyebrow, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 18, y + 48, briefing.title, this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1);
|
|
this.trackSortie(
|
|
this.add.text(x + 18, y + 84, briefing.description, {
|
|
...this.textStyle(14, '#d4dce6'),
|
|
wordWrap: { width: width - 36, useAdvancedWrap: true },
|
|
lineSpacing: 4
|
|
})
|
|
).setDepth(depth + 1);
|
|
}
|
|
|
|
private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.gold, 0.5);
|
|
this.trackSortie(this.add.text(x + 18, y + 14, '준비 체크', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
|
|
|
|
this.sortieChecklist().forEach((item, index) => {
|
|
const rowY = y + 42 + index * 27;
|
|
const color = item.complete ? '#a8ffd0' : '#ffdf7b';
|
|
const status = item.complete ? '완료' : '주의';
|
|
this.trackSortie(this.add.text(x + 18, rowY, status, this.textStyle(13, color, true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 70, rowY, item.label, this.textStyle(14, '#d4dce6', true))).setDepth(depth + 1);
|
|
const detail = this.trackSortie(this.add.text(x + 190, rowY, item.detail, this.textStyle(12, item.complete ? '#9fb0bf' : '#d8b15f')));
|
|
detail.setDepth(depth + 1);
|
|
});
|
|
}
|
|
|
|
private renderSortieUnitSummary(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.blue, 0.48);
|
|
this.trackSortie(this.add.text(x + 18, y + 14, '출진 부대', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
|
|
|
|
this.currentUnits()
|
|
.filter((unit) => unit.faction === 'ally')
|
|
.forEach((unit, index) => {
|
|
const rowY = y + 52 + index * 30;
|
|
this.trackSortie(this.add.text(x + 18, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, '#f2e3bf', true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 132, rowY, unit.className, this.textStyle(13, '#9fb0bf', true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 236, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#d4dce6', true))).setDepth(depth + 1);
|
|
this.drawSortieBar(x + 338, rowY + 6, 128, 7, unit.hp / unit.maxHp, palette.green, depth + 1);
|
|
|
|
const equipment = equipmentSlots
|
|
.map((slot) => {
|
|
const state = unit.equipment[slot];
|
|
return `${equipmentSlotLabels[slot]} Lv${state.level}`;
|
|
})
|
|
.join(' ');
|
|
this.trackSortie(this.add.text(x + 496, rowY, equipment, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
|
|
});
|
|
}
|
|
|
|
private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, palette.gold, 0.4);
|
|
const completedDialogues = this.completedCampDialogues().length;
|
|
const inventory = this.inventoryLabels().join(', ');
|
|
const reward = this.campaign?.latestBattleId === secondBattleScenario.id ? '예상 보상: 다음 장 개방' : '예상 보상: 군자금, 소모품, 의용군 명성';
|
|
this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
|
|
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
|
|
}
|
|
|
|
private nextSortieBriefing() {
|
|
if (this.campaign?.latestBattleId === secondBattleScenario.id) {
|
|
return {
|
|
eyebrow: '다음 이야기',
|
|
title: '넓어지는 전장',
|
|
description: '황건 잔당 추격을 마친 의용군은 탁현을 넘어 더 큰 난세의 흐름을 마주합니다. 다음 장으로 향하기 전 전투 결과를 정리합니다.'
|
|
};
|
|
}
|
|
|
|
return {
|
|
eyebrow: '다음 전장',
|
|
title: secondBattleScenario.title,
|
|
description: '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.'
|
|
};
|
|
}
|
|
|
|
private sortieChecklist(): SortieChecklistItem[] {
|
|
const units = this.currentUnits().filter((unit) => unit.faction === 'ally');
|
|
const liuBei = units.find((unit) => unit.id === 'liu-bei');
|
|
const injured = units.filter((unit) => unit.hp < unit.maxHp);
|
|
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
|
|
const completedDialogues = this.completedCampDialogues().length;
|
|
return [
|
|
{
|
|
label: '유비 생존',
|
|
complete: Boolean(liuBei && liuBei.hp > 0),
|
|
detail: liuBei ? `병력 ${liuBei.hp}/${liuBei.maxHp}` : '유비 없음'
|
|
},
|
|
{
|
|
label: '부상 장수 확인',
|
|
complete: injured.length === 0,
|
|
detail: injured.length === 0 ? '전원 완전한 병력' : `${injured.map((unit) => unit.name).join(', ')} 회복 권장`
|
|
},
|
|
{
|
|
label: '소모품 보유',
|
|
complete: supplyCount > 0,
|
|
detail: supplyCount > 0 ? `보유 ${supplyCount}` : '정비 탭에서 보급 확인'
|
|
},
|
|
{
|
|
label: '공명 대화',
|
|
complete: completedDialogues >= campDialogues.length,
|
|
detail: `${completedDialogues}/${campDialogues.length} 완료`
|
|
},
|
|
{
|
|
label: '장비 상태',
|
|
complete: this.visitedTabs.has('status'),
|
|
detail: this.visitedTabs.has('status') ? '장수 탭 확인됨' : '장수 탭 확인 권장'
|
|
}
|
|
];
|
|
}
|
|
|
|
private addSortieButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) {
|
|
const bg = this.trackSortie(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96));
|
|
bg.setDepth(depth);
|
|
bg.setStrokeStyle(1, label === '출진' ? palette.gold : palette.blue, 0.78);
|
|
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.trackSortie(this.add.text(x, y, label, this.textStyle(15, '#f2e3bf', true)));
|
|
text.setOrigin(0.5);
|
|
text.setDepth(depth + 1);
|
|
text.setInteractive({ useHandCursor: true });
|
|
text.on('pointerdown', action);
|
|
}
|
|
|
|
private startVictoryStory() {
|
|
if (this.campaign?.latestBattleId === secondBattleScenario.id) {
|
|
this.scene.start('StoryScene', { pages: secondBattleScenario.victoryPages, nextScene: 'TitleScene' });
|
|
return;
|
|
}
|
|
|
|
markCampaignStep('second-battle');
|
|
this.scene.start('StoryScene', {
|
|
pages: secondBattleIntroPages,
|
|
nextScene: 'BattleScene',
|
|
nextSceneData: { battleId: secondBattleScenario.id }
|
|
});
|
|
}
|
|
|
|
private hideSortiePrep() {
|
|
this.sortieObjects.forEach((object) => object.destroy());
|
|
this.sortieObjects = [];
|
|
}
|
|
|
|
private trackSortie<T extends Phaser.GameObjects.GameObject>(object: T) {
|
|
this.sortieObjects.push(object);
|
|
return object;
|
|
}
|
|
|
|
private drawSortieBar(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) {
|
|
const track = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.86));
|
|
track.setOrigin(0);
|
|
track.setDepth(depth);
|
|
const fill = this.trackSortie(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96));
|
|
fill.setOrigin(0);
|
|
fill.setDepth(depth + 1);
|
|
}
|
|
|
|
private renderUnitColumn() {
|
|
const allies = this.currentUnits().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);
|
|
|
|
const unitClass = getUnitClass(unit.classKey);
|
|
const portraitKey = portraitByUnitId[unit.id];
|
|
const portraitFrame = this.track(this.add.rectangle(x + 84, y + 90, 122, 122, 0x0b1219, 0.92));
|
|
portraitFrame.setStrokeStyle(2, palette.gold, 0.62);
|
|
if (portraitKey) {
|
|
const portrait = this.track(this.add.image(x + 84, y + 90, portraitKeys[portraitKey]));
|
|
portrait.setDisplaySize(112, 112);
|
|
}
|
|
|
|
this.track(this.add.text(x + 166, y + 22, `${unit.name} Lv ${unit.level}`, this.textStyle(27, '#f2e3bf', true)));
|
|
this.track(this.add.text(x + 166, y + 60, `${unit.className} · ${unitClass.family} · ${unitClass.role}`, this.textStyle(15, '#d8b15f', true)));
|
|
this.track(
|
|
this.add.text(x + 166, y + 91, unitClass.description, {
|
|
...this.textStyle(14, '#d4dce6'),
|
|
wordWrap: { width: 324, useAdvancedWrap: true },
|
|
lineSpacing: 2
|
|
})
|
|
);
|
|
this.renderDetailGauge('병력', `${unit.hp}/${unit.maxHp}`, unit.hp / unit.maxHp, x + 166, y + 142, 300, palette.green);
|
|
this.renderDetailGauge('경험', `${unit.exp}/100`, unit.exp / 100, x + 166, y + 168, 300, palette.gold);
|
|
|
|
const statX = x + 524;
|
|
const stats = [
|
|
['공격', unit.attack],
|
|
['이동', unit.move],
|
|
['무력', unit.stats.might],
|
|
['지력', unit.stats.intelligence],
|
|
['통솔', unit.stats.leadership],
|
|
['민첩', unit.stats.agility],
|
|
['운', unit.stats.luck]
|
|
] as const;
|
|
stats.forEach(([label, value], index) => {
|
|
this.renderStatCard(label, `${value}`, statX + (index % 2) * 128, y + 22 + Math.floor(index / 2) * 42, 116);
|
|
});
|
|
|
|
this.track(this.add.text(x + 24, y + 210, '장비', this.textStyle(20, '#f2e3bf', true)));
|
|
equipmentSlots.forEach((slot, index) => {
|
|
this.renderEquipmentCard(unit, slot, x + 24 + index * 260, y + 244, 244, 102);
|
|
});
|
|
|
|
this.renderSelectedUnitBondPanel(unit, x + 24, y + 362, 780);
|
|
}
|
|
|
|
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.completedCampDialogues().includes(dialogue.id);
|
|
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.completedCampDialogues().includes(dialogue.id);
|
|
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.currentBonds().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 unit = this.selectedUnit();
|
|
if (unit) {
|
|
this.renderSupplyTargetCard(unit, x + 24, y + 94, 342, 86);
|
|
}
|
|
|
|
campSupplies.forEach((supply, index) => {
|
|
this.renderSupplyUseRow(supply, x + 390, y + 94 + index * 74, 390);
|
|
});
|
|
|
|
this.track(this.add.text(x + 24, y + 202, '전리품과 명성', this.textStyle(19, '#f2e3bf', true)));
|
|
const trophies = this.nonConsumableInventoryLabels();
|
|
if (trophies.length === 0) {
|
|
this.track(this.add.text(x + 24, y + 236, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf')));
|
|
} else {
|
|
trophies.forEach((reward, index) => {
|
|
this.renderSupplyBox(reward, x + 24 + index * 154, y + 232);
|
|
});
|
|
}
|
|
|
|
this.track(this.add.text(x + 24, y + 306, '부대 장비 요약', this.textStyle(19, '#f2e3bf', true)));
|
|
this.currentUnits()
|
|
.filter((unit) => unit.faction === 'ally')
|
|
.forEach((unit, index) => {
|
|
const rowY = y + 342 + index * 30;
|
|
this.track(this.add.text(x + 24, rowY, unit.name, this.textStyle(14, '#f2e3bf', true)));
|
|
equipmentSlots.forEach((slot, slotIndex) => {
|
|
const state = unit.equipment[slot];
|
|
const item = getItem(state.itemId);
|
|
const slotX = x + 104 + slotIndex * 220;
|
|
this.track(this.add.text(slotX, rowY - 2, `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`, this.textStyle(11, '#d4dce6')));
|
|
this.drawBar(slotX, rowY + 17, 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 renderSupplyTargetCard(unit: UnitData, x: number, y: number, width: number, height: number) {
|
|
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.48);
|
|
this.track(this.add.text(x + 16, y + 12, '사용 대상', this.textStyle(15, '#f2e3bf', true)));
|
|
this.track(this.add.text(x + 16, y + 38, `${unit.name} ${unit.className} Lv ${unit.level}`, this.textStyle(16, '#d4dce6', true)));
|
|
this.track(this.add.text(x + 16, y + 62, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#9fb0bf', true)));
|
|
this.drawBar(x + 118, y + 68, width - 138, 8, unit.hp / unit.maxHp, palette.green);
|
|
}
|
|
|
|
private renderSupplyUseRow(supply: CampSupplyDefinition, x: number, y: number, width: number) {
|
|
const unit = this.selectedUnit();
|
|
const amount = this.inventoryAmount(supply.label);
|
|
const enabled = Boolean(unit && this.canUseSupply(supply, unit));
|
|
const bg = this.track(this.add.rectangle(x, y, width, 62, enabled ? 0x151f2a : 0x111820, enabled ? 0.92 : 0.76));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.62 : 0.38);
|
|
|
|
this.track(this.add.text(x + 14, y + 10, `${supply.title} x${amount}`, this.textStyle(16, amount > 0 ? '#f2e3bf' : '#7f8994', true)));
|
|
this.track(
|
|
this.add.text(x + 14, y + 34, supply.description, {
|
|
...this.textStyle(12, enabled ? '#c8d2dd' : '#77818c'),
|
|
wordWrap: { width: width - 128, useAdvancedWrap: true }
|
|
})
|
|
);
|
|
|
|
const button = this.track(this.add.rectangle(x + width - 54, y + 31, 82, 30, enabled ? 0x1a2630 : 0x121922, enabled ? 0.96 : 0.72));
|
|
button.setStrokeStyle(1, enabled ? palette.gold : 0x53606c, enabled ? 0.76 : 0.42);
|
|
const buttonText = this.track(this.add.text(x + width - 54, y + 31, enabled ? '사용' : '불가', this.textStyle(13, enabled ? '#f2e3bf' : '#7f8994', true)));
|
|
buttonText.setOrigin(0.5);
|
|
|
|
if (enabled) {
|
|
const action = () => this.useSupply(supply);
|
|
button.setInteractive({ useHandCursor: true });
|
|
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
|
|
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
|
|
button.on('pointerdown', action);
|
|
buttonText.setInteractive({ useHandCursor: true });
|
|
buttonText.on('pointerdown', action);
|
|
}
|
|
}
|
|
|
|
private canUseSupply(supply: CampSupplyDefinition, unit: UnitData) {
|
|
if (this.inventoryAmount(supply.label) <= 0) {
|
|
return false;
|
|
}
|
|
if (supply.healHp > 0 && unit.hp < unit.maxHp) {
|
|
return true;
|
|
}
|
|
return supply.bondExp > 0 && this.currentBonds().some((bond) => bond.unitIds.includes(unit.id));
|
|
}
|
|
|
|
private useSupply(supply: CampSupplyDefinition) {
|
|
const campaign = this.campaign ?? getCampaignState();
|
|
const target = campaign.roster.find((unit) => unit.id === this.selectedUnitId);
|
|
if (!target) {
|
|
this.showCampNotice('사용할 장수를 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
if ((campaign.inventory[supply.label] ?? 0) <= 0) {
|
|
this.showCampNotice(`${supply.title}이 없습니다.`);
|
|
return;
|
|
}
|
|
|
|
const previousHp = target.hp;
|
|
target.hp = Math.min(target.maxHp, target.hp + supply.healHp);
|
|
const hpGain = target.hp - previousHp;
|
|
const bondGain = this.applySupplyBondExp(campaign, target.id, supply.bondExp);
|
|
|
|
if (hpGain <= 0 && bondGain <= 0) {
|
|
this.showCampNotice(`${target.name}에게 지금은 사용할 필요가 없습니다.`);
|
|
return;
|
|
}
|
|
|
|
campaign.inventory[supply.label] -= 1;
|
|
if (campaign.inventory[supply.label] <= 0) {
|
|
delete campaign.inventory[supply.label];
|
|
}
|
|
this.syncReportUnitHp(campaign, target.id, target.hp);
|
|
|
|
this.campaign = saveCampaignState(campaign);
|
|
this.report = this.campaign.firstBattleReport ?? this.report;
|
|
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 620 });
|
|
|
|
const hpText = hpGain > 0 ? `병력 +${hpGain}` : '';
|
|
const bondText = bondGain > 0 ? `공명 +${supply.bondExp}` : '';
|
|
this.showCampNotice(`${target.name} ${supply.title} 사용 · ${[hpText, bondText].filter(Boolean).join(' / ')}`);
|
|
this.render();
|
|
}
|
|
|
|
private applySupplyBondExp(campaign: CampaignState, unitId: string, amount: number) {
|
|
if (amount <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
let affected = 0;
|
|
campaign.bonds
|
|
.filter((bond) => bond.unitIds.includes(unitId))
|
|
.forEach((bond) => {
|
|
this.advanceCampBond(bond, amount);
|
|
affected += 1;
|
|
const reportBond = campaign.firstBattleReport?.bonds.find((candidate) => candidate.id === bond.id);
|
|
if (reportBond) {
|
|
reportBond.level = bond.level;
|
|
reportBond.exp = bond.exp;
|
|
reportBond.battleExp = bond.battleExp;
|
|
}
|
|
});
|
|
|
|
return affected * amount;
|
|
}
|
|
|
|
private advanceCampBond(bond: CampaignState['bonds'][number], amount: number) {
|
|
bond.battleExp += amount;
|
|
bond.exp += amount;
|
|
while (bond.exp >= 100) {
|
|
bond.exp -= 100;
|
|
bond.level = Math.min(100, bond.level + 1);
|
|
}
|
|
}
|
|
|
|
private syncReportUnitHp(campaign: CampaignState, unitId: string, hp: number) {
|
|
const reportUnit = campaign.firstBattleReport?.units.find((unit) => unit.id === unitId);
|
|
if (reportUnit) {
|
|
reportUnit.hp = hp;
|
|
}
|
|
}
|
|
|
|
private renderDetailGauge(label: string, value: string, ratio: number, x: number, y: number, width: number, color: number) {
|
|
this.track(this.add.text(x, y - 2, label, this.textStyle(13, '#9fb0bf', true)));
|
|
this.drawBar(x + 54, y + 6, width - 134, 8, ratio, color);
|
|
const valueText = this.track(this.add.text(x + width, y - 3, value, this.textStyle(13, '#f2e3bf', true)));
|
|
valueText.setOrigin(1, 0);
|
|
}
|
|
|
|
private renderStatCard(label: string, value: string, x: number, y: number, width: number) {
|
|
const bg = this.track(this.add.rectangle(x, y, width, 34, 0x151f2a, 0.82));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, 0x53606c, 0.38);
|
|
this.track(this.add.text(x + 10, y + 8, label, this.textStyle(12, '#9fb0bf', true)));
|
|
const valueText = this.track(this.add.text(x + width - 10, y + 5, value, this.textStyle(17, '#f2e3bf', true)));
|
|
valueText.setOrigin(1, 0);
|
|
}
|
|
|
|
private renderEquipmentCard(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number, height: number) {
|
|
const state = unit.equipment[slot];
|
|
const item = getItem(state.itemId);
|
|
const next = equipmentExpToNext(state.level);
|
|
const isTreasure = item.rank === 'treasure';
|
|
const bg = this.track(this.add.rectangle(x, y, width, height, isTreasure ? 0x1f2430 : 0x151f2a, 0.92));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, isTreasure ? palette.gold : palette.blue, isTreasure ? 0.68 : 0.42);
|
|
|
|
const iconFrame = this.track(this.add.rectangle(x + 28, y + 29, 42, 42, 0x0a1017, 0.92));
|
|
iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.72 : 0.48);
|
|
const icon = this.track(this.add.image(x + 28, y + 29, `item-${item.id}`));
|
|
icon.setDisplaySize(30, 30);
|
|
|
|
this.track(this.add.text(x + 58, y + 12, `${equipmentSlotLabels[slot]} · ${item.name}`, this.textStyle(14, isTreasure ? '#f4dfad' : '#d4dce6', true)));
|
|
this.track(this.add.text(x + 58, y + 34, `${isTreasure ? '보물' : '일반'} Lv ${state.level}`, this.textStyle(12, isTreasure ? '#d8b15f' : '#9fb0bf', true)));
|
|
const valueText = this.track(this.add.text(x + width - 12, y + 34, `${state.exp}/${next}`, this.textStyle(12, '#f0e4c8', true)));
|
|
valueText.setOrigin(1, 0);
|
|
this.drawBar(x + 58, y + 57, width - 74, 7, state.exp / next, isTreasure ? palette.gold : palette.blue);
|
|
|
|
this.track(
|
|
this.add.text(x + 14, y + 72, `${this.itemBonusText(item)} · ${item.effects[0] ?? item.description}`, {
|
|
...this.textStyle(11, '#c8d2dd'),
|
|
wordWrap: { width: width - 28, useAdvancedWrap: true }
|
|
})
|
|
);
|
|
}
|
|
|
|
private renderSelectedUnitBondPanel(unit: UnitData, x: number, y: number, width: number) {
|
|
const bonds = this.currentBonds().filter((bond) => bond.unitIds.includes(unit.id));
|
|
const bg = this.track(this.add.rectangle(x, y, width, 62, 0x0d141c, 0.9));
|
|
bg.setOrigin(0);
|
|
bg.setStrokeStyle(1, palette.gold, 0.42);
|
|
this.track(this.add.text(x + 14, y + 9, '공명 관계', this.textStyle(16, '#f2e3bf', true)));
|
|
|
|
if (bonds.length === 0) {
|
|
this.track(this.add.text(x + 120, y + 22, '아직 연결된 공명 관계가 없습니다.', this.textStyle(13, '#9fb0bf')));
|
|
return;
|
|
}
|
|
|
|
bonds.forEach((bond, index) => {
|
|
const cardX = x + 116 + index * 322;
|
|
const partnerId = bond.unitIds.find((unitId) => unitId !== unit.id) ?? bond.unitIds[0];
|
|
const partnerName = this.unitName(partnerId);
|
|
this.track(this.add.text(cardX, y + 10, `${partnerName} · ${bond.title}`, this.textStyle(13, '#d4dce6', true)));
|
|
this.track(this.add.text(cardX, y + 32, `Lv ${bond.level} ${bond.exp}/100 전투 +${bond.battleExp}`, this.textStyle(12, bond.battleExp > 0 ? '#d8b15f' : '#9fb0bf', true)));
|
|
this.drawBar(cardX + 176, y + 38, 120, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue);
|
|
});
|
|
}
|
|
|
|
private itemBonusText(item: ReturnType<typeof getItem>) {
|
|
const bonuses = [
|
|
item.attackBonus ? `공격 +${item.attackBonus}` : '',
|
|
item.defenseBonus ? `방어 +${item.defenseBonus}` : '',
|
|
item.strategyBonus ? `책략 +${item.strategyBonus}` : ''
|
|
].filter(Boolean);
|
|
return bonuses.join(' / ') || '특수 보정 없음';
|
|
}
|
|
|
|
private completeDialogue(dialogue: CampDialogue) {
|
|
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp);
|
|
if (updated) {
|
|
this.report = updated;
|
|
this.campaign = getCampaignState();
|
|
} 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.showCampNotice(`${dialogue.title} 완료 · 공명 +${dialogue.rewardExp}`);
|
|
}
|
|
|
|
private showCampNotice(message: string) {
|
|
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, message, 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.currentUnits().find((unit) => unit.id === this.selectedUnitId);
|
|
}
|
|
|
|
private unitName(unitId: string) {
|
|
return this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId;
|
|
}
|
|
|
|
private currentUnits() {
|
|
if (this.campaign?.roster.length) {
|
|
return this.campaign.roster;
|
|
}
|
|
return this.report?.units ?? [];
|
|
}
|
|
|
|
private currentBonds() {
|
|
if (this.campaign?.bonds.length) {
|
|
return this.campaign.bonds;
|
|
}
|
|
return this.report?.bonds ?? [];
|
|
}
|
|
|
|
private completedCampDialogues() {
|
|
if (this.campaign) {
|
|
return this.campaign.completedCampDialogues;
|
|
}
|
|
return this.report?.completedCampDialogues ?? [];
|
|
}
|
|
|
|
private inventoryLabels() {
|
|
const inventory = this.campaign?.inventory ?? {};
|
|
const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);
|
|
if (entries.length > 0) {
|
|
return entries.map(([label, amount]) => `${label} ${amount}`);
|
|
}
|
|
return this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1'];
|
|
}
|
|
|
|
private nonConsumableInventoryLabels() {
|
|
const entries = Object.entries(this.campaign?.inventory ?? {}).filter(([label, amount]) => {
|
|
return amount > 0 && !campSupplyByLabel.has(label);
|
|
});
|
|
if (entries.length > 0) {
|
|
return entries.map(([label, amount]) => `${label} ${amount}`);
|
|
}
|
|
return this.inventoryLabels().filter((label) => {
|
|
const normalized = label.replace(/\s+\d+$/, '').trim();
|
|
return !campSupplyByLabel.has(normalized);
|
|
});
|
|
}
|
|
|
|
private inventoryAmount(label: string) {
|
|
return this.campaign?.inventory[label] ?? 0;
|
|
}
|
|
|
|
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,
|
|
sortieVisible: this.sortieObjects.length > 0,
|
|
selectedUnitId: this.selectedUnitId,
|
|
selectedDialogueId: this.selectedDialogueId,
|
|
campaign: this.campaign
|
|
? {
|
|
step: this.campaign.step,
|
|
gold: this.campaign.gold,
|
|
inventory: this.campaign.inventory,
|
|
roster: this.campaign.roster.map((unit) => ({
|
|
id: unit.id,
|
|
name: unit.name,
|
|
hp: unit.hp,
|
|
maxHp: unit.maxHp
|
|
})),
|
|
completedCampDialogues: this.campaign.completedCampDialogues
|
|
}
|
|
: null,
|
|
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
|
|
};
|
|
}
|
|
}
|