Enhance camp unit detail hub

This commit is contained in:
2026-06-22 20:04:12 +09:00
parent f649240759
commit 20b1a794b2

View File

@@ -1,6 +1,7 @@
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 } from '../data/battles';
import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario';
import {
@@ -25,6 +26,12 @@ type CampDialogue = {
lines: string[];
};
type CampTabButtonView = {
tab: CampTab;
bg: Phaser.GameObjects.Rectangle;
text: Phaser.GameObjects.Text;
};
const portraitKeys: Record<PortraitKey, string> = {
liuBei: 'portrait-liu-bei',
guanYu: 'portrait-guan-yu',
@@ -84,12 +91,16 @@ export class CampScene extends Phaser.Scene {
private selectedDialogueId = campDialogues[0].id;
private contentObjects: Phaser.GameObjects.GameObject[] = [];
private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
private tabButtons: CampTabButtonView[] = [];
constructor() {
super('CampScene');
}
create() {
this.contentObjects = [];
this.dialogueObjects = [];
this.tabButtons = [];
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
@@ -187,6 +198,17 @@ export class CampScene extends Phaser.Scene {
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) {
@@ -210,6 +232,7 @@ export class CampScene extends Phaser.Scene {
private render() {
this.clearContent();
this.updateTabButtons();
this.renderUnitColumn();
this.renderReportPanel();
@@ -289,20 +312,47 @@ export class CampScene extends Phaser.Scene {
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);
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 + 24, y + 214, '장비 성장', this.textStyle(20, '#f2e3bf', true)));
equipmentSlots.forEach((slot, index) => {
this.renderEquipmentRow(unit, slot, x + 24, y + 254 + index * 54, 760);
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() {
@@ -412,27 +462,79 @@ export class CampScene extends Phaser.Scene {
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)));
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 renderEquipmentRow(unit: UnitData, slot: EquipmentSlot, x: number, y: number, width: number) {
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 bg = this.track(this.add.rectangle(x, y, width, 42, item.rank === 'treasure' ? 0x1f2430 : 0x151f2a, 0.9));
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, 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);
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) {