From 20b1a794b2aa65d33c655293b0b8b8bf459ce804 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 20:04:12 +0900 Subject: [PATCH] Enhance camp unit detail hub --- src/game/scenes/CampScene.ts | 154 +++++++++++++++++++++++++++++------ 1 file changed, 128 insertions(+), 26 deletions(-) diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index d6b938f..97208b0 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -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 = { 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) { + const bonuses = [ + item.attackBonus ? `공격 +${item.attackBonus}` : '', + item.defenseBonus ? `방어 +${item.defenseBonus}` : '', + item.strategyBonus ? `책략 +${item.strategyBonus}` : '' + ].filter(Boolean); + return bonuses.join(' / ') || '특수 보정 없음'; } private completeDialogue(dialogue: CampDialogue) {