From ef030b88b779ce71c0cc41a5aeede15723d9096a Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 3 Jul 2026 04:39:51 +0900 Subject: [PATCH] Improve usable action menu readability --- src/game/scenes/BattleScene.ts | 282 +++++++++++++++++++++++---------- 1 file changed, 199 insertions(+), 83 deletions(-) diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 0337aa6..f1b08ad 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1365,6 +1365,16 @@ type SupportPreview = { duration: number; }; +type UsableMenuForecast = { + available: boolean; + status: string; + targetCount: number; + valueLabel: string; + value: string; + detail: string; + secondary: string; +}; + type LockedTargetPreview = { kind: 'damage' | 'support'; userId: string; @@ -4713,7 +4723,7 @@ export class BattleScene extends Phaser.Scene { return this.supportTargetUnavailableReason(user, usable); } - private usableMenuForecast(user: UnitData, usable: BattleUsable) { + private usableMenuForecast(user: UnitData, usable: BattleUsable): UsableMenuForecast { const targetCount = this.usableTargetCount(user, usable); const hasStock = usable.command !== 'item' || this.itemStock(user.id, usable.id) > 0; const available = targetCount > 0 && hasStock; @@ -4725,59 +4735,141 @@ export class BattleScene extends Phaser.Scene { return { available, status, + targetCount, valueLabel: '피해', value: `${preview.damage}`, - detail: `${preview.hitRate}% ${usable.command === 'strategy' ? '성공' : '명중'} · 치명 ${preview.criticalRate}%` + detail: `${target.name}에게 ${preview.damage} 피해 예상`, + secondary: `${preview.hitRate}% ${usable.command === 'strategy' ? '성공' : '명중'} · 치명 ${preview.criticalRate}%` }; } return { available, status, + targetCount, valueLabel: '위력', value: `${usable.power}`, - detail: this.usableUnavailableReason(user, usable) + detail: this.usableUnavailableReason(user, usable), + secondary: usable.description }; } if (usable.effect === 'heal') { const target = this.supportTargets(user, usable)[0]; const amount = target ? this.supportHealAmount(user, target, usable) : usable.power; + const projectedHp = target ? Math.min(target.maxHp, target.hp + amount) : undefined; return { available, status, + targetCount, valueLabel: '회복', value: `+${amount}`, - detail: target ? `${target.name} 기준 HP 회복` : this.usableUnavailableReason(user, usable) + detail: target ? `${target.name} HP ${target.hp} → ${projectedHp}` : this.usableUnavailableReason(user, usable), + secondary: target ? `기본 ${usable.power} + 능력/장비 보정` : usable.description }; } return { available, status, + targetCount, valueLabel: '강화', value: `${usable.duration ?? 1}턴`, - detail: `공격 +${usable.attackBonus ?? 0} · 명중 +${usable.hitBonus ?? 0} · 치명 +${usable.criticalBonus ?? 0}` + detail: `공격 +${usable.attackBonus ?? 0} · 명중 +${usable.hitBonus ?? 0} · 치명 +${usable.criticalBonus ?? 0}`, + secondary: available ? `${this.usableTargetLabel(usable)} ${targetCount}명에게 ${usable.duration ?? 1}턴 적용` : this.usableUnavailableReason(user, usable) }; } - private renderUsableMenuPill(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, tone: number) { - const pill = this.add.rectangle(x, y, width, 30, 0x0b1118, 0.92); - pill.setOrigin(0); - pill.setDepth(16); - pill.setStrokeStyle(1, tone, 0.54); - this.commandMenuObjects.push(pill); + private usableRangeText(usable: BattleUsable) { + return usable.range === 0 ? '자신' : `${usable.range}칸`; + } - const iconImage = this.createBattleUiIcon(x + 16, y + 15, icon, 24); + private usableBuffText(usable: BattleUsable) { + if (usable.effect !== 'focus') { + return this.usablePowerLabel(usable); + } + return `공+${usable.attackBonus ?? 0} 명+${usable.hitBonus ?? 0} 치+${usable.criticalBonus ?? 0}`; + } + + private renderUsableMenuInfoChip( + x: number, + y: number, + width: number, + icon: BattleUiIconKey, + label: string, + value: string, + tone: number, + available: boolean + ) { + const chip = this.add.rectangle(x, y, width, 34, 0x0b1118, available ? 0.94 : 0.86); + chip.setOrigin(0); + chip.setDepth(16); + chip.setStrokeStyle(1, tone, available ? 0.58 : 0.36); + this.commandMenuObjects.push(chip); + + const iconImage = this.createBattleUiIcon(x + 17, y + 17, icon, 25); + iconImage.setAlpha(available ? 0.98 : 0.72); iconImage.setDepth(17); this.commandMenuObjects.push(iconImage); - const text = this.add.text(x + 35, y + 6, label, { + const labelText = this.add.text(x + 34, y + 4, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', - color: '#d4dce6', + fontSize: '9px', + color: '#9fb0bf', fontStyle: '700', fixedWidth: width - 40 }); - text.setDepth(17); - this.commandMenuObjects.push(text); + labelText.setDepth(17); + this.commandMenuObjects.push(labelText); + + const valueText = this.add.text(x + 34, y + 18, this.truncateUiText(value, 8), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: available ? '#f2e3bf' : '#c8ced6', + fontStyle: '700', + fixedWidth: width - 40 + }); + valueText.setDepth(17); + this.commandMenuObjects.push(valueText); + } + + private renderUsableMenuValueCard( + x: number, + y: number, + width: number, + height: number, + usable: BattleUsable, + forecast: UsableMenuForecast, + tone: number + ) { + const valueBox = this.add.rectangle(x, y, width, height, 0x0b1118, forecast.available ? 0.96 : 0.9); + valueBox.setOrigin(0); + valueBox.setDepth(16); + valueBox.setStrokeStyle(1, forecast.available ? tone : 0x6c7480, forecast.available ? 0.76 : 0.46); + this.commandMenuObjects.push(valueBox); + + const valueIcon = this.createBattleUiIcon(x + 24, y + height / 2, this.usablePowerIcon(usable), 36); + valueIcon.setAlpha(forecast.available ? 1 : 0.72); + valueIcon.setDepth(17); + this.commandMenuObjects.push(valueIcon); + + const valueLabel = this.add.text(x + 50, y + 10, forecast.valueLabel, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#9fb0bf', + fontStyle: '700', + fixedWidth: width - 58 + }); + valueLabel.setDepth(17); + this.commandMenuObjects.push(valueLabel); + + const valueHasKorean = /[가-힣]/.test(forecast.value); + const valueText = this.add.text(x + width - 10, y + height - 16, forecast.value, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: valueHasKorean || forecast.value.length > 4 ? '18px' : '23px', + color: forecast.available ? '#f2e3bf' : '#c8ced6', + fontStyle: '700' + }); + valueText.setOrigin(1, 0.5); + valueText.setDepth(17); + this.commandMenuObjects.push(valueText); } private hideCommandMenu() { @@ -4799,9 +4891,11 @@ export class BattleScene extends Phaser.Scene { this.hideCommandMenu(); - const menuWidth = 432; - const titleHeight = 52; - const rowHeight = 112; + const forecasts = usables.map((usable) => ({ usable, forecast: this.usableMenuForecast(unit, usable) })); + const readyCount = forecasts.filter(({ forecast }) => forecast.available).length; + const menuWidth = 486; + const titleHeight = 58; + const rowHeight = 130; const padding = 10; const menuHeight = padding * 2 + titleHeight + usables.length * rowHeight; const { left, top } = this.commandMenuPosition(pointerX, pointerY, menuWidth, menuHeight); @@ -4823,7 +4917,7 @@ export class BattleScene extends Phaser.Scene { title.setDepth(15); this.commandMenuObjects.push(title); - const subtitle = this.add.text(left + 16, top + padding + 29, `목록 ${usables.length} · HP ${unit.hp}/${unit.maxHp} · ${getUnitClass(unit.classKey).name}`, { + const subtitle = this.add.text(left + 16, top + padding + 31, `HP ${unit.hp}/${unit.maxHp} · ${getUnitClass(unit.classKey).name} · ${command === 'strategy' ? '책략 선택' : '도구 선택'}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#aeb7c2', @@ -4832,28 +4926,47 @@ export class BattleScene extends Phaser.Scene { subtitle.setDepth(15); this.commandMenuObjects.push(subtitle); - usables.forEach((usable, index) => { + const countBadge = this.add.rectangle(left + menuWidth - 92, top + padding + 12, 70, 28, 0x0b1118, 0.94); + countBadge.setOrigin(0); + countBadge.setDepth(16); + countBadge.setStrokeStyle(1, readyCount > 0 ? accentColor : 0xb64a45, readyCount > 0 ? 0.64 : 0.76); + this.commandMenuObjects.push(countBadge); + + const countText = this.add.text(left + menuWidth - 57, top + padding + 19, `${readyCount}/${usables.length}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: readyCount > 0 ? '#f2e3bf' : '#ffcf9a', + fontStyle: '700' + }); + countText.setOrigin(0.5, 0); + countText.setDepth(17); + this.commandMenuObjects.push(countText); + + forecasts.forEach(({ usable, forecast }, index) => { const rowTop = top + padding + titleHeight + index * rowHeight; const accent = this.usableAccentColor(usable); - const forecast = this.usableMenuForecast(unit, usable); const rowLeft = left + padding; const rowWidth = menuWidth - padding * 2; - const contentLeft = left + 92; - const sideBoxLeft = left + menuWidth - 112; + const rowRight = rowLeft + rowWidth; + const contentLeft = rowLeft + 90; + const sideBoxWidth = 112; + const sideBoxLeft = rowRight - sideBoxWidth - 10; + const contentWidth = sideBoxLeft - contentLeft - 12; const baseFill = forecast.available ? 0x1a2630 : 0x151c23; - const bg = this.add.rectangle(rowLeft, rowTop, rowWidth, rowHeight - 8, baseFill, 0.95); + const bg = this.add.rectangle(rowLeft, rowTop, rowWidth, rowHeight - 8, baseFill, forecast.available ? 0.97 : 0.92); bg.setOrigin(0); bg.setDepth(15); bg.setStrokeStyle(1, forecast.available ? accent : 0x6c7480, forecast.available ? 0.72 : 0.52); bg.setInteractive({ useHandCursor: true }); this.commandMenuObjects.push(bg); - const iconFrame = this.add.rectangle(left + 52, rowTop + 52, 66, 66, 0x0a0f14, 0.9); + const iconFrame = this.add.rectangle(rowLeft + 43, rowTop + 61, 76, 76, 0x0a0f14, forecast.available ? 0.92 : 0.86); iconFrame.setStrokeStyle(1, accent, forecast.available ? 0.72 : 0.42); iconFrame.setDepth(16); this.commandMenuObjects.push(iconFrame); - const icon = this.createBattleUiIcon(left + 52, rowTop + 52, this.usableIcon(usable), 58); + const icon = this.createBattleUiIcon(rowLeft + 43, rowTop + 61, this.usableIcon(usable), 68); + icon.setAlpha(forecast.available ? 1 : 0.72); icon.setDepth(17); this.commandMenuObjects.push(icon); @@ -4863,85 +4976,84 @@ export class BattleScene extends Phaser.Scene { fontSize: '21px', color: forecast.available ? '#f2e3bf' : '#c8ced6', fontStyle: '700', - fixedWidth: sideBoxLeft - contentLeft - 10 + fixedWidth: contentWidth }); name.setDepth(16); this.commandMenuObjects.push(name); - const stockText = this.add.text(left + menuWidth - 20, rowTop + 11, stock, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '12px', - color: command === 'item' ? '#f4dfad' : '#9fb0bf', - fontStyle: '700' - }); - stockText.setOrigin(1, 0); - stockText.setDepth(16); - this.commandMenuObjects.push(stockText); - const statusTone = forecast.available ? accent : 0xb64a45; - const statusBg = this.add.rectangle(sideBoxLeft, rowTop + 36, 92, 28, 0x0b1118, 0.94); + const statusBg = this.add.rectangle(rowRight - 98, rowTop + 9, 88, 28, 0x0b1118, 0.95); statusBg.setOrigin(0); statusBg.setDepth(16); - statusBg.setStrokeStyle(1, statusTone, forecast.available ? 0.62 : 0.76); + statusBg.setStrokeStyle(1, statusTone, forecast.available ? 0.66 : 0.78); this.commandMenuObjects.push(statusBg); - const statusText = this.add.text(sideBoxLeft + 46, rowTop + 42, forecast.status, { + const statusText = this.add.text(rowRight - 54, rowTop + 16, forecast.status, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: forecast.available ? '#d4dce6' : '#ffcf9a', fontStyle: '700', - fixedWidth: 88, + fixedWidth: 84, align: 'center' }); statusText.setOrigin(0.5, 0); statusText.setDepth(17); this.commandMenuObjects.push(statusText); - const summary = this.add.text( - contentLeft, - rowTop + 39, - forecast.detail, - { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '13px', - color: forecast.available ? '#aeb7c2' : '#ffcf9a', - fixedWidth: sideBoxLeft - contentLeft - 12 - } - ); + const metaText = this.add.text(contentLeft, rowTop + 35, `${this.usableChannelLabel(usable)} · ${this.usableEffectLabel(usable)} · ${this.usableTargetLabel(usable)} ${this.usableRangeText(usable)} · ${stock}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#9fb0bf', + fontStyle: '700', + fixedWidth: contentWidth + }); + metaText.setDepth(16); + this.commandMenuObjects.push(metaText); + + const summary = this.add.text(contentLeft, rowTop + 54, this.truncateUiText(forecast.detail, 32), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: forecast.available ? '#d4dce6' : '#ffcf9a', + fontStyle: '700', + fixedWidth: contentWidth + }); summary.setDepth(16); this.commandMenuObjects.push(summary); - this.renderUsableMenuPill(contentLeft, rowTop + 70, 96, this.usableIcon(usable), this.usableEffectLabel(usable), accent); - this.renderUsableMenuPill(contentLeft + 104, rowTop + 70, 116, this.usableTargetIcon(usable), `${this.usableTargetLabel(usable)} ${usable.range}칸`, accent); - - const valueBox = this.add.rectangle(sideBoxLeft, rowTop + 68, 92, 34, 0x0b1118, 0.94); - valueBox.setOrigin(0); - valueBox.setDepth(16); - valueBox.setStrokeStyle(1, accent, forecast.available ? 0.72 : 0.44); - this.commandMenuObjects.push(valueBox); - - const valueIcon = this.createBattleUiIcon(sideBoxLeft + 16, rowTop + 85, this.usablePowerIcon(usable), 24); - valueIcon.setDepth(17); - this.commandMenuObjects.push(valueIcon); - - const valueText = this.add.text(sideBoxLeft + 86, rowTop + 83, forecast.value, { + const secondary = this.add.text(contentLeft, rowTop + 72, this.truncateUiText(forecast.secondary, 36), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '14px', - color: forecast.available ? '#f2e3bf' : '#c8ced6', - fontStyle: '700' + fontSize: '11px', + color: forecast.available ? '#aeb7c2' : '#d8b199', + fixedWidth: contentWidth }); - valueText.setOrigin(1, 0); - valueText.setDepth(17); - this.commandMenuObjects.push(valueText); + secondary.setDepth(16); + this.commandMenuObjects.push(secondary); - const valueLabel = this.add.text(sideBoxLeft + 34, rowTop + 73, forecast.valueLabel, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '10px', - color: '#9fb0bf', - fontStyle: '700' - }); - valueLabel.setDepth(17); - this.commandMenuObjects.push(valueLabel); + this.renderUsableMenuValueCard(sideBoxLeft, rowTop + 45, sideBoxWidth, 66, usable, forecast, accent); + + const chipGap = 6; + const chipWidth = (contentWidth - chipGap * 2) / 3; + this.renderUsableMenuInfoChip(contentLeft, rowTop + 88, chipWidth, this.usableIcon(usable), '효과', this.usableEffectLabel(usable), accent, forecast.available); + this.renderUsableMenuInfoChip( + contentLeft + chipWidth + chipGap, + rowTop + 88, + chipWidth, + this.usableTargetIcon(usable), + '범위', + `${this.usableTargetLabel(usable)} ${this.usableRangeText(usable)}`, + accent, + forecast.available + ); + this.renderUsableMenuInfoChip( + contentLeft + (chipWidth + chipGap) * 2, + rowTop + 88, + chipWidth, + usable.effect === 'focus' ? 'leadership' : usable.command === 'item' ? 'accessory' : 'success', + usable.effect === 'focus' ? '강화' : usable.command === 'item' ? '수량' : '가능', + usable.effect === 'focus' ? this.usableBuffText(usable) : usable.command === 'item' ? `${this.itemStock(unit.id, usable.id)}개` : `${forecast.targetCount}명`, + accent, + forecast.available + ); const choose = (choicePointer: Phaser.Input.Pointer) => { if (choicePointer.rightButtonDown()) { @@ -4950,6 +5062,10 @@ export class BattleScene extends Phaser.Scene { } if (choicePointer.leftButtonDown()) { this.suppressNextLeftClick = true; + if (!forecast.available) { + showDetail(); + return; + } this.beginUsableTargeting(unit, usable); } }; @@ -4957,7 +5073,7 @@ export class BattleScene extends Phaser.Scene { bg.setFillStyle(forecast.available ? 0x283947 : 0x27303a, 0.98); this.renderUnitDetail(unit, this.usableDetailText(unit, usable)); }; - const clearHover = () => bg.setFillStyle(baseFill, 0.95); + const clearHover = () => bg.setFillStyle(baseFill, forecast.available ? 0.97 : 0.92); bg.on('pointerover', () => { showDetail(); });