diff --git a/scripts/build-battle-ui-icons.py b/scripts/build-battle-ui-icons.py new file mode 100644 index 0000000..2d4d9d3 --- /dev/null +++ b/scripts/build-battle-ui-icons.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import math +from pathlib import Path +from typing import Callable + +from PIL import Image, ImageDraw, ImageFilter + + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "src" / "assets" / "images" / "ui" / "battle-ui-icons.png" +FRAME = 48 +SCALE = 4 + + +def main() -> None: + OUT.parent.mkdir(parents=True, exist_ok=True) + icons: list[tuple[str, Callable[[ImageDraw.ImageDraw], None]]] = [ + ("hp", draw_hp), + ("might", draw_might), + ("intelligence", draw_intelligence), + ("leadership", draw_leadership), + ("attack", draw_attack), + ("defense", draw_defense), + ("move", draw_move), + ("mastery", draw_mastery), + ("sword", draw_sword), + ("spear", draw_spear), + ("axe", draw_axe), + ("bow", draw_bow), + ("strategy", draw_strategy), + ("horse", draw_horse), + ("armor", draw_armor), + ("accessory", draw_accessory), + ("hit", draw_hit), + ("critical", draw_critical), + ("terrain", draw_terrain), + ("counter", draw_counter), + ("advantage", draw_advantage), + ("success", draw_success), + ] + cols = 5 + rows = math.ceil(len(icons) / cols) + atlas = Image.new("RGBA", (cols * FRAME, rows * FRAME), (0, 0, 0, 0)) + for index, (_name, draw_icon) in enumerate(icons): + tile = make_icon(draw_icon) + atlas.alpha_composite(tile, ((index % cols) * FRAME, (index // cols) * FRAME)) + atlas.save(OUT, optimize=True) + print(f"Wrote {OUT}") + + +def make_icon(draw_icon: Callable[[ImageDraw.ImageDraw], None]) -> Image.Image: + image = Image.new("RGBA", (FRAME * SCALE, FRAME * SCALE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image, "RGBA") + draw_icon(draw) + image = image.filter(ImageFilter.UnsharpMask(radius=1.2, percent=90, threshold=3)) + return image.resize((FRAME, FRAME), Image.Resampling.LANCZOS) + + +def c(value: tuple[int, int, int, int] | tuple[int, int, int]) -> tuple[int, int, int, int]: + if len(value) == 3: + return (value[0], value[1], value[2], 255) + return value + + +def line(draw: ImageDraw.ImageDraw, points: list[tuple[int, int]], fill: tuple[int, int, int, int], width: int) -> None: + scaled = [(x * SCALE, y * SCALE) for x, y in points] + draw.line(scaled, fill=fill, width=width * SCALE, joint="curve") + + +def poly(draw: ImageDraw.ImageDraw, points: list[tuple[int, int]], fill, outline=None) -> None: + scaled = [(x * SCALE, y * SCALE) for x, y in points] + draw.polygon(scaled, fill=c(fill), outline=c(outline) if outline else None) + + +def ellipse(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], fill, outline=None, width=1) -> None: + scaled = tuple(v * SCALE for v in box) + draw.ellipse(scaled, fill=c(fill), outline=c(outline) if outline else None, width=width * SCALE) + + +def rect(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], fill, outline=None, width=1) -> None: + scaled = tuple(v * SCALE for v in box) + draw.rounded_rectangle(scaled, radius=4 * SCALE, fill=c(fill), outline=c(outline) if outline else None, width=width * SCALE) + + +def icon_shadow(draw: ImageDraw.ImageDraw) -> None: + ellipse(draw, (7, 35, 41, 42), (0, 0, 0, 80)) + + +def draw_hp(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(24, 39), (10, 25), (10, 15), (16, 10), (24, 15), (32, 10), (38, 15), (38, 25)], (210, 64, 65), (72, 21, 28)) + poly(draw, [(24, 35), (13, 24), (13, 16), (17, 13), (24, 18), (31, 13), (35, 16), (35, 24)], (255, 130, 111)) + line(draw, [(18, 21), (30, 21)], (255, 235, 188, 240), 2) + line(draw, [(24, 15), (24, 28)], (255, 235, 188, 240), 2) + + +def draw_might(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + line(draw, [(13, 35), (31, 17)], (112, 78, 48), 4) + poly(draw, [(33, 10), (39, 15), (27, 25), (23, 21)], (228, 230, 219), (72, 78, 80)) + line(draw, [(35, 36), (17, 18)], (112, 78, 48), 4) + poly(draw, [(15, 11), (10, 18), (22, 23), (25, 18)], (217, 221, 211), (65, 70, 73)) + ellipse(draw, (19, 19, 29, 29), (190, 60, 52), (74, 31, 29)) + line(draw, [(18, 30), (24, 36), (30, 30)], (239, 188, 83), 2) + + +def draw_intelligence(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(13, 13), (25, 9), (38, 13), (38, 34), (25, 38), (13, 34)], (74, 103, 116), (30, 41, 50)) + poly(draw, [(16, 16), (25, 13), (35, 16), (35, 31), (25, 34), (16, 31)], (212, 204, 158)) + line(draw, [(20, 18), (31, 16)], (89, 75, 52, 210), 1) + line(draw, [(20, 23), (31, 21)], (89, 75, 52, 210), 1) + line(draw, [(20, 28), (31, 26)], (89, 75, 52, 210), 1) + ellipse(draw, (9, 26, 17, 36), (145, 95, 64), (67, 37, 31)) + line(draw, [(14, 30), (10, 37)], (231, 206, 150, 230), 2) + + +def draw_leadership(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + line(draw, [(15, 39), (15, 9)], (118, 92, 58), 3) + poly(draw, [(17, 11), (38, 15), (31, 23), (38, 31), (17, 27)], (185, 55, 50), (77, 27, 30)) + poly(draw, [(17, 13), (34, 16), (29, 22), (34, 28), (17, 25)], (229, 157, 73)) + ellipse(draw, (11, 35, 20, 43), (208, 176, 88), (73, 55, 31)) + + +def draw_attack(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + line(draw, [(13, 34), (33, 14)], (108, 80, 49), 4) + poly(draw, [(35, 9), (39, 13), (25, 28), (21, 24)], (225, 226, 217), (70, 76, 78)) + poly(draw, [(25, 25), (33, 32), (30, 36), (21, 28)], (216, 151, 73), (79, 46, 28)) + line(draw, [(14, 34), (21, 41)], (244, 199, 104), 3) + + +def draw_defense(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(24, 7), (38, 13), (35, 31), (24, 41), (13, 31), (10, 13)], (77, 109, 132), (29, 38, 50)) + poly(draw, [(24, 11), (34, 15), (32, 29), (24, 36), (16, 29), (14, 15)], (113, 154, 183)) + line(draw, [(24, 12), (24, 36)], (225, 206, 138, 230), 2) + line(draw, [(15, 22), (33, 22)], (225, 206, 138, 220), 2) + + +def draw_move(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(15, 29), (24, 31), (36, 35), (35, 40), (22, 40), (12, 35)], (72, 98, 96), (25, 38, 38)) + poly(draw, [(19, 10), (28, 10), (27, 28), (18, 29)], (148, 108, 72), (65, 43, 30)) + poly(draw, [(20, 27), (31, 30), (36, 34), (23, 34), (15, 31)], (213, 167, 94)) + line(draw, [(32, 14), (39, 10)], (220, 204, 144, 170), 2) + line(draw, [(34, 22), (42, 21)], (220, 204, 144, 150), 2) + + +def draw_mastery(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + points = [] + for i in range(10): + radius = 15 if i % 2 == 0 else 6 + angle = -math.pi / 2 + i * math.pi / 5 + points.append((24 + math.cos(angle) * radius, 22 + math.sin(angle) * radius)) + poly(draw, [(round(x), round(y)) for x, y in points], (237, 185, 72), (88, 58, 24)) + rect(draw, (12, 33, 36, 39), (61, 73, 69), (30, 38, 37)) + rect(draw, (14, 35, 27, 37), (129, 206, 120), None) + + +def draw_sword(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(32, 6), (38, 11), (21, 31), (17, 27)], (226, 229, 220), (73, 78, 80)) + poly(draw, [(17, 28), (23, 34), (20, 38), (13, 31)], (201, 135, 67), (72, 44, 31)) + line(draw, [(12, 37), (17, 32)], (235, 205, 112), 3) + line(draw, [(18, 25), (25, 32)], (75, 43, 33), 2) + + +def draw_spear(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + line(draw, [(14, 39), (31, 12)], (117, 82, 45), 3) + poly(draw, [(33, 6), (39, 18), (29, 17)], (226, 229, 220), (71, 77, 78)) + poly(draw, [(29, 18), (35, 20), (30, 24)], (187, 61, 51), (75, 31, 30)) + + +def draw_axe(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + line(draw, [(17, 39), (30, 10)], (112, 78, 48), 4) + poly(draw, [(27, 8), (42, 13), (39, 25), (28, 21), (24, 15)], (217, 221, 211), (65, 70, 73)) + poly(draw, [(24, 15), (17, 19), (24, 23), (29, 20)], (183, 130, 64), (75, 45, 29)) + + +def draw_bow(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + line(draw, [(29, 8), (34, 14), (35, 25), (30, 37)], (184, 121, 62), 4) + line(draw, [(29, 8), (18, 22), (30, 37)], (217, 199, 139, 220), 1) + line(draw, [(15, 24), (35, 24)], (220, 225, 214), 2) + poly(draw, [(36, 24), (30, 20), (30, 28)], (219, 83, 66), (75, 29, 26)) + + +def draw_strategy(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(11, 30), (18, 13), (30, 10), (38, 22), (34, 36), (22, 39)], (223, 221, 202), (78, 77, 65)) + line(draw, [(18, 16), (34, 23)], (107, 121, 129, 180), 1) + line(draw, [(16, 22), (33, 28)], (107, 121, 129, 180), 1) + line(draw, [(14, 29), (30, 34)], (107, 121, 129, 180), 1) + line(draw, [(25, 17), (17, 40)], (124, 84, 49), 2) + + +def draw_horse(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(11, 25), (18, 15), (31, 15), (39, 23), (35, 31), (22, 31)], (150, 114, 80), (60, 43, 33)) + poly(draw, [(30, 15), (40, 9), (42, 17), (36, 21)], (150, 114, 80), (60, 43, 33)) + ellipse(draw, (38, 12, 41, 15), (20, 18, 14)) + line(draw, [(17, 30), (14, 40)], (90, 62, 42), 3) + line(draw, [(29, 30), (33, 40)], (90, 62, 42), 3) + line(draw, [(13, 20), (8, 16)], (92, 57, 35), 3) + + +def draw_armor(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(17, 9), (31, 9), (38, 18), (34, 39), (14, 39), (10, 18)], (79, 98, 116), (28, 38, 48)) + poly(draw, [(20, 12), (28, 12), (34, 20), (31, 35), (17, 35), (14, 20)], (121, 143, 158)) + line(draw, [(24, 13), (24, 36)], (216, 178, 94, 190), 2) + line(draw, [(16, 23), (32, 23)], (43, 55, 66, 200), 1) + line(draw, [(15, 29), (33, 29)], (43, 55, 66, 200), 1) + + +def draw_accessory(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + ellipse(draw, (13, 12, 35, 34), (42, 82, 90), (218, 187, 96), 2) + ellipse(draw, (19, 18, 29, 28), (84, 174, 166), (236, 223, 155), 1) + line(draw, [(24, 34), (24, 40)], (218, 187, 96), 2) + ellipse(draw, (21, 38, 27, 44), (204, 65, 59), (80, 29, 29)) + + +def draw_hit(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + ellipse(draw, (9, 9, 39, 39), (32, 50, 58), (94, 149, 164), 2) + ellipse(draw, (16, 16, 32, 32), (83, 134, 140), (211, 218, 172), 2) + ellipse(draw, (21, 21, 27, 27), (241, 208, 96), (73, 56, 28), 1) + line(draw, [(31, 17), (40, 8)], (225, 229, 219), 2) + + +def draw_critical(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + points = [] + for i in range(14): + radius = 18 if i % 2 == 0 else 7 + angle = -math.pi / 2 + i * math.pi / 7 + points.append((24 + math.cos(angle) * radius, 24 + math.sin(angle) * radius)) + poly(draw, [(round(x), round(y)) for x, y in points], (230, 78, 61), (83, 32, 27)) + line(draw, [(17, 16), (31, 32)], (255, 229, 139), 3) + line(draw, [(31, 16), (17, 32)], (255, 229, 139), 3) + + +def draw_terrain(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + poly(draw, [(7, 35), (17, 20), (24, 32), (32, 14), (42, 35)], (81, 117, 69), (33, 54, 34)) + poly(draw, [(7, 35), (18, 28), (30, 33), (42, 35), (42, 40), (7, 40)], (122, 102, 63), (56, 45, 31)) + line(draw, [(17, 23), (19, 27)], (215, 206, 158), 1) + line(draw, [(32, 17), (34, 22)], (215, 206, 158), 1) + + +def draw_counter(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + line(draw, [(13, 30), (30, 13)], (220, 224, 214), 3) + line(draw, [(35, 18), (18, 35)], (220, 224, 214), 3) + poly(draw, [(31, 9), (39, 12), (33, 18)], (216, 151, 73), (75, 41, 28)) + poly(draw, [(17, 39), (9, 36), (15, 30)], (216, 151, 73), (75, 41, 28)) + + +def draw_advantage(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + ellipse(draw, (9, 9, 39, 39), (32, 50, 58), (94, 149, 164), 2) + poly(draw, [(15, 17), (28, 17), (28, 12), (38, 22), (28, 32), (28, 27), (15, 27)], (229, 157, 73), (76, 48, 30)) + poly(draw, [(33, 31), (20, 31), (20, 36), (10, 26), (20, 16), (20, 21), (33, 21)], (113, 154, 183), (29, 38, 50)) + ellipse(draw, (20, 20, 28, 28), (237, 185, 72), (79, 52, 24)) + + +def draw_success(draw: ImageDraw.ImageDraw) -> None: + icon_shadow(draw) + rect(draw, (11, 11, 37, 35), (76, 103, 91), (29, 43, 39), 2) + poly(draw, [(14, 14), (34, 14), (31, 23), (34, 32), (14, 32), (17, 23)], (184, 145, 83), (77, 55, 31)) + line(draw, [(17, 24), (22, 29), (32, 17)], (225, 245, 202), 3) + ellipse(draw, (18, 36, 30, 43), (180, 63, 58), (73, 27, 27)) + + +if __name__ == "__main__": + main() diff --git a/src/assets/images/ui/battle-ui-icons.png b/src/assets/images/ui/battle-ui-icons.png new file mode 100644 index 0000000..c353f0a Binary files /dev/null and b/src/assets/images/ui/battle-ui-icons.png differ diff --git a/src/game/data/battleUiIcons.ts b/src/game/data/battleUiIcons.ts new file mode 100644 index 0000000..cc6945d --- /dev/null +++ b/src/game/data/battleUiIcons.ts @@ -0,0 +1,57 @@ +import Phaser from 'phaser'; + +import battleUiIconsUrl from '../../assets/images/ui/battle-ui-icons.png'; + +export const battleUiIconTextureKey = 'battle-ui-icons'; +export const battleUiIconFrameSize = 48; + +export const battleUiIconFrames = { + hp: 0, + might: 1, + intelligence: 2, + leadership: 3, + attack: 4, + defense: 5, + move: 6, + mastery: 7, + sword: 8, + spear: 9, + axe: 10, + bow: 11, + strategy: 12, + horse: 13, + armor: 14, + accessory: 15, + hit: 16, + critical: 17, + terrain: 18, + counter: 19, + advantage: 20, + success: 21 +} as const; + +export type BattleUiIconKey = keyof typeof battleUiIconFrames; + +export const battleUiIconManifest = { + source: 'src/assets/images/ui/battle-ui-icons.png', + textureKey: battleUiIconTextureKey, + frameWidth: battleUiIconFrameSize, + frameHeight: battleUiIconFrameSize, + columns: 5, + rows: 5, + frames: battleUiIconFrames +} as const; + +export function loadBattleUiIcons(scene: Phaser.Scene, onReady: () => void) { + if (scene.textures.exists(battleUiIconTextureKey)) { + onReady(); + return; + } + + scene.load.once('complete', onReady); + scene.load.spritesheet(battleUiIconTextureKey, battleUiIconsUrl, { + frameWidth: battleUiIconFrameSize, + frameHeight: battleUiIconFrameSize + }); + scene.load.start(); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index d2b4c89..5cf3e99 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -11,12 +11,19 @@ import { unitTextureVariantKeys as unitAssetTextureVariantKeys, type UnitDirection } from '../data/unitAssets'; +import { + battleUiIconFrames, + battleUiIconTextureKey, + loadBattleUiIcons, + type BattleUiIconKey +} from '../data/battleUiIcons'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, - type EquipmentSlot + type EquipmentSlot, + type ItemDefinition } from '../data/battleItems'; import { campaignSaveSlotCount, @@ -1194,6 +1201,7 @@ type EquipmentGrowthResult = { type LevelUpStatGain = { label: string; amount: number; + icon: BattleUiIconKey; }; type CharacterGrowthResult = { @@ -1285,6 +1293,7 @@ type CombatPreview = { criticalRate: number; counterAvailable: boolean; terrainLabel: string; + matchupLabel: string; }; type CombatResult = CombatPreview & { @@ -2992,7 +3001,7 @@ export class BattleScene extends Phaser.Scene { } private ensureScenarioAssets(onReady: () => void) { - this.ensureScenarioMapTexture(() => this.ensureScenarioUnitTextures(onReady)); + this.ensureScenarioMapTexture(() => loadBattleUiIcons(this, () => this.ensureScenarioUnitTextures(onReady))); } private ensureScenarioMapTexture(onReady: () => void) { @@ -4453,15 +4462,8 @@ export class BattleScene extends Phaser.Scene { } private renderAttackPreview(preview: CombatPreview) { - const counter = preview.counterAvailable ? ' / 반격 가능' : ''; - const bond = preview.bondDamageBonus > 0 && preview.bondLabel ? `\n공명 ${preview.bondLabel} 피해 +${preview.bondDamageBonus}%` : ''; - const equipment = preview.equipmentEffectLabels.length > 0 - ? `\n보물 ${preview.equipmentEffectLabels.join(', ')} (${this.equipmentPreviewBonusText(preview)})` - : ''; - this.renderUnitDetail( - preview.defender, - `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}${bond}${equipment}` - ); + this.renderUnitDetail(preview.defender); + this.renderCombatPreviewCard(preview); } private renderSupportPreview(user: UnitData, target: UnitData, usable: BattleUsable) { @@ -4487,6 +4489,101 @@ export class BattleScene extends Phaser.Scene { return parts.join(' / ') || '보정 없음'; } + private renderCombatPreviewCard(preview: CombatPreview) { + const { panelX, panelWidth } = this.layout; + const left = panelX + 24; + const width = panelWidth - 48; + const height = 124; + const y = Math.min(this.sideContentTop() + 430, this.sideContentBottom(10) - height); + + const bg = this.trackSideObject(this.add.rectangle(left, y, width, height, 0x121a22, 0.96)); + bg.setOrigin(0); + bg.setStrokeStyle(2, palette.gold, 0.72); + + this.trackSideIcon(left + 22, y + 25, this.actionIcon(preview), 32); + this.trackSideObject(this.add.text(left + 44, y + 11, `${preview.attacker.name} ${commandLabels[preview.action]} 예측`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#f4dfad', + fontStyle: '700' + })); + + const attackerWeapon = getItem(preview.attacker.equipment.weapon.itemId); + const defenderArmor = getItem(preview.defender.equipment.armor.itemId); + this.renderPreviewEquipmentChip(left + 44, y + 38, 126, this.itemIcon(attackerWeapon, 'weapon'), attackerWeapon.name); + this.renderPreviewEquipmentChip(left + 178, y + 38, 126, this.itemIcon(defenderArmor, 'armor'), defenderArmor.name); + + const metricTop = y + 70; + const metricWidth = (width - 24) / 3; + this.renderPreviewMetric(left + 8, metricTop, metricWidth, 'attack', '피해', `${preview.damage}`); + this.renderPreviewMetric(left + 8 + metricWidth, metricTop, metricWidth, preview.action === 'strategy' ? 'success' : 'hit', preview.action === 'strategy' ? '성공' : '명중', `${preview.hitRate}%`); + this.renderPreviewMetric(left + 8 + metricWidth * 2, metricTop, metricWidth, 'critical', '치명', `${preview.criticalRate}%`); + + const badgeY = y + 101; + const maxBadgeRight = left + width - 10; + let badgeX = left + 10; + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'advantage', preview.matchupLabel, maxBadgeRight); + if (preview.action === 'strategy') { + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'success', `${preview.hitRate}% 성공`, maxBadgeRight); + } + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'terrain', preview.terrainLabel, maxBadgeRight); + if (preview.counterAvailable) { + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'counter', '반격 가능', maxBadgeRight); + } + if (preview.bondDamageBonus > 0 && preview.bondLabel) { + badgeX = this.renderPreviewBadge(badgeX, badgeY, 'leadership', `공명 +${preview.bondDamageBonus}%`, maxBadgeRight); + } + if (preview.equipmentEffectLabels.length > 0) { + this.renderPreviewBadge(badgeX, badgeY, 'mastery', '장비 보정', maxBadgeRight); + } + } + + private renderPreviewEquipmentChip(x: number, y: number, width: number, icon: BattleUiIconKey, label: string) { + const chip = this.trackSideObject(this.add.rectangle(x, y, width, 22, 0x0b1118, 0.86)); + chip.setOrigin(0); + chip.setStrokeStyle(1, 0x53606c, 0.42); + this.trackSideIcon(x + 12, y + 11, icon, 18); + this.trackSideObject(this.add.text(x + 25, y + 4, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#d4dce6', + wordWrap: { width: width - 32, useAdvancedWrap: true } + })); + } + + private renderPreviewMetric(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string) { + this.trackSideIcon(x + 13, y + 11, icon, 18); + this.trackSideObject(this.add.text(x + 26, y + 2, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#9fb0bf' + })); + const valueText = this.trackSideObject(this.add.text(x + width - 8, y + 1, value, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#f2e3bf', + fontStyle: '700' + })); + valueText.setOrigin(1, 0); + } + + private renderPreviewBadge(x: number, y: number, icon: BattleUiIconKey, label: string, maxRight?: number) { + const width = Phaser.Math.Clamp(label.length * 8 + 34, 58, 120); + if (maxRight && x + width > maxRight) { + return x; + } + const badge = this.trackSideObject(this.add.rectangle(x, y, width, 20, 0x0b1118, 0.88)); + badge.setOrigin(0); + badge.setStrokeStyle(1, 0x53606c, 0.44); + this.trackSideIcon(x + 11, y + 10, icon, 15); + this.trackSideObject(this.add.text(x + 22, y + 4, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: '#d4dce6' + })); + return x + width + 6; + } + private finishUnitAction(unit: UnitData, message: string) { this.actedUnitIds.add(unit.id); this.phase = 'idle'; @@ -5104,7 +5201,7 @@ export class BattleScene extends Phaser.Scene { exp: unit.exp, next: 100, leveled: unit.level > previousLevel, - statGains: this.characterLevelUpStatGains(unit.level - previousLevel), + statGains: this.characterLevelUpStatGains(unit, unit.level - previousLevel), flashArea: { x, y, width, height: 68, depth: depth + 3 } }); } @@ -5397,7 +5494,7 @@ export class BattleScene extends Phaser.Scene { color: '#f4dfad', fontStyle: '700' }); - const statText = this.add.text(-width / 2 + 110, 26, stats ?? '숙련 효과 강화', { + const statText = this.add.text(-width / 2 + 110, 22, stats ? '능력 상승' : '숙련 효과 강화', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: stats ? '#a8ffd0' : '#d8b15f', @@ -5406,6 +5503,9 @@ export class BattleScene extends Phaser.Scene { strokeThickness: 3 }); container.add([bg, burst, title, levelText, statText]); + if (animation.statGains) { + this.addStatGainChipsToContainer(container, animation.statGains, -width / 2 + 110, 46, width - 124); + } if (animation.unit) { const sprite = this.add.sprite(-width / 2 + 56, 10, this.unitActionTexture(animation.unit), this.unitActionFrameIndex('south', this.celebrationPose(animation.unit))); @@ -5428,6 +5528,40 @@ export class BattleScene extends Phaser.Scene { await this.delay(260); } + private addStatGainChipsToContainer( + container: Phaser.GameObjects.Container, + gains: LevelUpStatGain[], + x: number, + y: number, + maxWidth: number + ) { + const visibleGains = gains.filter((gain) => gain.amount > 0).slice(0, 5); + let cursorX = x; + visibleGains.forEach((gain) => { + const chipWidth = Phaser.Math.Clamp(gain.label.length * 12 + 42, 64, 92); + if (cursorX + chipWidth > x + maxWidth) { + return; + } + const bg = this.add.rectangle(cursorX, y, chipWidth, 24, 0x0b1118, 0.9); + bg.setOrigin(0); + bg.setStrokeStyle(1, 0x53606c, 0.5); + const icon = this.createBattleUiIcon(cursorX + 13, y + 12, gain.icon, 18); + const text = this.add.text(cursorX + 26, y + 5, `+${gain.amount}`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#a8ffd0', + fontStyle: '700' + }); + const label = this.add.text(cursorX + 48, y + 5, gain.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#d4dce6' + }); + container.add([bg, icon, text, label]); + cursorX += chipWidth + 6; + }); + } + private createResultLevelUpBurst(x: number, y: number, depth: number) { const ring = this.trackResultObject(this.add.circle(x, y + 2, 33)); ring.setStrokeStyle(3, 0xffdf7b, 0.84); @@ -5617,7 +5751,8 @@ export class BattleScene extends Phaser.Scene { hitRate, criticalRate, counterAvailable: this.canCounterAttack(defender, attacker, action), - terrainLabel: terrainRule.label + terrainLabel: terrainRule.label, + matchupLabel: this.combatMatchupLabel(attacker, defender, action) }; } @@ -5886,6 +6021,43 @@ export class BattleScene extends Phaser.Scene { return this.equipmentDefenseBonus(unit) + Math.floor(unit.stats.leadership / 18) + Math.floor(terrainDefenseBonus / 3); } + private combatMatchupLabel(attacker: UnitData, defender: UnitData, action: DamageCommand) { + if (action === 'strategy') { + const gap = attacker.stats.intelligence - defender.stats.intelligence; + return gap >= 10 ? '책략 우위' : gap <= -10 ? '책략 불리' : '책략 균형'; + } + + if (action === 'item') { + const gap = attacker.stats.luck - defender.stats.luck; + return gap >= 12 ? '도구 우위' : gap <= -12 ? '도구 불리' : '도구 균형'; + } + + if (this.hasClassAdvantage(attacker, defender)) { + return '상성 우위'; + } + if (this.hasClassAdvantage(defender, attacker)) { + return '상성 불리'; + } + return '상성 보통'; + } + + private hasClassAdvantage(attacker: UnitData, defender: UnitData) { + const distance = this.tileDistance(attacker, defender); + if (attacker.classKey === 'spearman' && defender.classKey === 'cavalry') { + return true; + } + if (attacker.classKey === 'cavalry' && (defender.classKey === 'archer' || defender.classKey === 'strategist' || defender.classKey === 'quartermaster')) { + return true; + } + if (attacker.classKey === 'archer' && distance > 1 && (defender.classKey === 'infantry' || defender.classKey === 'spearman' || defender.classKey === 'yellowTurban')) { + return true; + } + if ((attacker.classKey === 'strategist' || attacker.classKey === 'quartermaster') && defender.stats.intelligence + 12 <= attacker.stats.intelligence) { + return true; + } + return false; + } + private attackerEquipmentExpGain(unit: UnitData, action: DamageCommand) { if (action === 'attack') { return this.weaponExpGain(unit); @@ -7163,6 +7335,7 @@ export class BattleScene extends Phaser.Scene { })); title.setOrigin(0.5, 0); title.setDepth(depth + 8); + this.renderCombatActionRibbon(result, left + panelWidth / 2 - 220, top + 54, 440, depth + 9); const attackerDirection = 'east'; const defenderDirection = 'west'; @@ -7249,6 +7422,68 @@ export class BattleScene extends Phaser.Scene { this.hideCombatCutIn(); } + private renderCombatActionRibbon(result: CombatResult, x: number, y: number, width: number, depth: number) { + const bg = this.trackCombatObject(this.add.rectangle(x, y, width, 58, 0x0b1118, 0.88)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.58); + + this.trackCombatIcon(x + 22, y + 19, this.actionIcon(result), 28, depth + 1); + const attackerWeapon = getItem(result.attacker.equipment.weapon.itemId); + const defenderArmor = getItem(result.defender.equipment.armor.itemId); + this.trackCombatIcon(x + 132, y + 19, this.itemIcon(attackerWeapon, 'weapon'), 22, depth + 1); + this.trackCombatIcon(x + 248, y + 19, this.itemIcon(defenderArmor, 'armor'), 22, depth + 1); + + const actionLabel = this.trackCombatObject(this.add.text(x + 42, y + 8, result.usable?.name ?? commandLabels[result.action], { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '15px', + color: '#f4dfad', + fontStyle: '700', + fixedWidth: 74 + })); + actionLabel.setDepth(depth + 1); + + const weaponLabel = this.trackCombatObject(this.add.text(x + 148, y + 9, attackerWeapon.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#d4dce6', + fixedWidth: 82 + })); + weaponLabel.setDepth(depth + 1); + + const armorLabel = this.trackCombatObject(this.add.text(x + 264, y + 9, defenderArmor.name, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: '#d4dce6', + fixedWidth: 74 + })); + armorLabel.setDepth(depth + 1); + + const outcomeIcon = result.action === 'strategy' ? 'success' : result.critical ? 'critical' : 'hit'; + const outcomeLabel = result.action === 'strategy' ? (result.hit ? '성공' : '실패') : result.hit ? (result.critical ? '치명' : '명중') : '회피'; + this.renderCombatRibbonBadge(x + 42, y + 33, 'advantage', result.matchupLabel, depth + 1, 74); + this.renderCombatRibbonBadge(x + 122, y + 33, outcomeIcon, outcomeLabel, depth + 1, 58); + if (result.counter) { + this.renderCombatRibbonBadge(x + 186, y + 33, 'counter', '반격', depth + 1, 54); + } + } + + private renderCombatRibbonBadge(x: number, y: number, icon: BattleUiIconKey, label: string, depth: number, width = 52) { + const badge = this.trackCombatObject(this.add.rectangle(x, y, width, 20, 0x101820, 0.92)); + badge.setOrigin(0); + badge.setDepth(depth); + badge.setStrokeStyle(1, 0x53606c, 0.48); + this.trackCombatIcon(x + 11, y + 10, icon, 15, depth + 1); + const text = this.trackCombatObject(this.add.text(x + 22, y + 4, label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: '#f2e3bf', + fontStyle: '700', + fixedWidth: width - 26 + })); + text.setDepth(depth + 1); + } + private applyCombatSpriteDisplaySize(sprite: Phaser.GameObjects.Sprite, unit: UnitData, compact = false) { const size = this.combatSpriteDisplaySize(unit, compact); sprite.setDisplaySize(size, size); @@ -7386,17 +7621,18 @@ export class BattleScene extends Phaser.Scene { })); nameText.setDepth(85); - const hpLabel = this.trackCombatObject(this.add.text(x + 78, y + 45, '병력', { + this.trackCombatIcon(x + 88, y + 54, 'hp', 18, 85); + const hpLabel = this.trackCombatObject(this.add.text(x + 104, y + 45, '병력', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#9fb0bf', fontStyle: '700' })); hpLabel.setDepth(85); - const hpTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 9, 0x0a0f14, 0.9)); + const hpTrack = this.trackCombatObject(this.add.rectangle(x + 148, y + 54, width - 232, 9, 0x0a0f14, 0.9)); hpTrack.setOrigin(0, 0.5); hpTrack.setDepth(85); - const hpFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 54, width - 210, 7, 0x59d18c, 0.96)); + const hpFill = this.trackCombatObject(this.add.rectangle(x + 148, y + 54, width - 232, 7, 0x59d18c, 0.96)); hpFill.setOrigin(0, 0.5); hpFill.setDepth(86); hpFill.setScale(unit.hp / unit.maxHp, 1); @@ -7409,17 +7645,18 @@ export class BattleScene extends Phaser.Scene { hpText.setOrigin(1, 0); hpText.setDepth(85); - const expLabel = this.trackCombatObject(this.add.text(x + 78, y + 75, '경험', { + this.trackCombatIcon(x + 88, y + 84, 'mastery', 18, 85); + const expLabel = this.trackCombatObject(this.add.text(x + 104, y + 75, '경험', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#9fb0bf', fontStyle: '700' })); expLabel.setDepth(85); - const expTrack = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 9, 0x0a0f14, 0.9)); + const expTrack = this.trackCombatObject(this.add.rectangle(x + 148, y + 84, width - 232, 9, 0x0a0f14, 0.9)); expTrack.setOrigin(0, 0.5); expTrack.setDepth(85); - const expFill = this.trackCombatObject(this.add.rectangle(x + 126, y + 84, width - 210, 7, 0xd8b15f, 0.96)); + const expFill = this.trackCombatObject(this.add.rectangle(x + 148, y + 84, width - 232, 7, 0xd8b15f, 0.96)); expFill.setOrigin(0, 0.5); expFill.setDepth(86); expFill.setScale((growth?.previousExp ?? unit.exp) / 100, 1); @@ -7666,7 +7903,7 @@ export class BattleScene extends Phaser.Scene { private async playCombatLevelUpSpotlight(entry: GrowthGaugeEntry, celebrant: UnitData | undefined, x: number, y: number, width: number, depth: number) { const panelWidth = Math.min(540, width - 34); - const panelHeight = 104; + const panelHeight = 116; const container = this.trackCombatObject(this.add.container(x, y)); container.setDepth(depth); container.setAlpha(0); @@ -7691,7 +7928,7 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700' }); levelText.setOrigin(celebrant ? 0 : 0.5, 0); - const statText = this.add.text(celebrant ? -panelWidth / 2 + 112 : 0, 25, this.statGainLine(entry.statGains) ?? `${entry.label} 효과 강화`, { + const statText = this.add.text(celebrant ? -panelWidth / 2 + 112 : 0, 25, entry.statGains ? '능력 상승' : `${entry.label} 효과 강화`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#a8ffd0', @@ -7701,6 +7938,9 @@ export class BattleScene extends Phaser.Scene { }); statText.setOrigin(celebrant ? 0 : 0.5, 0); container.add([bg, glow, title, levelText, statText]); + if (entry.statGains) { + this.addStatGainChipsToContainer(container, entry.statGains, celebrant ? -panelWidth / 2 + 112 : -panelWidth / 2 + 40, 51, panelWidth - 140); + } if (celebrant) { const sprite = this.add.sprite(-panelWidth / 2 + 56, 6, this.unitActionTexture(celebrant), this.unitActionFrameIndex('south', this.celebrationPose(celebrant))); @@ -7725,7 +7965,8 @@ export class BattleScene extends Phaser.Scene { } private renderGrowthGaugeRow(entry: GrowthGaugeEntry, x: number, y: number, width: number, depth: number): GrowthGaugeView { - const label = this.trackCombatObject(this.add.text(x, y - 3, `${entry.owner} ${entry.label}`, { + this.trackCombatIcon(x + 8, y + 8, this.growthEntryIcon(entry), 16, depth); + const label = this.trackCombatObject(this.add.text(x + 22, y - 3, `${entry.owner} ${entry.label}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: '#d4dce6', @@ -7804,6 +8045,22 @@ export class BattleScene extends Phaser.Scene { return stats ? `Lv ${entry.level} 달성! ${stats}` : `Lv ${entry.level} 달성!`; } + private growthEntryIcon(entry: GrowthGaugeEntry): BattleUiIconKey { + if (entry.kind === 'character') { + return entry.statGains?.[0]?.icon ?? 'hp'; + } + if (entry.kind === 'bond') { + return 'leadership'; + } + if (entry.slot === 'armor') { + return 'armor'; + } + if (entry.slot === 'accessory') { + return 'accessory'; + } + return 'sword'; + } + private showGrowthEventText(text: Phaser.GameObjects.Text, label: string) { text.setText(label); text.setAlpha(1); @@ -8874,13 +9131,11 @@ export class BattleScene extends Phaser.Scene { while (unit.level < maxCharacterLevel && exp >= 100) { exp -= 100; unit.level += 1; - unit.maxHp += 2; - unit.hp = Math.min(unit.maxHp, unit.hp + 2); - unit.attack += 1; leveled = true; levelUps += 1; } + const statGains = this.applyCharacterLevelUpStats(unit, levelUps); unit.exp = unit.level >= maxCharacterLevel ? Math.min(exp, 100) : exp; return { @@ -8892,21 +9147,90 @@ export class BattleScene extends Phaser.Scene { next: 100, leveled, levelUps, - statGains: this.characterLevelUpStatGains(levelUps) + statGains }; } - private characterLevelUpStatGains(levelUps: number): LevelUpStatGain[] { + private applyCharacterLevelUpStats(unit: UnitData, levelUps: number) { + const gains = this.characterLevelUpStatGains(unit, levelUps); + gains.forEach((gain) => { + switch (gain.icon) { + case 'hp': + unit.maxHp += gain.amount; + unit.hp = Math.min(unit.maxHp, unit.hp + gain.amount); + return; + case 'attack': + unit.attack += gain.amount; + return; + case 'might': + unit.stats.might = Math.min(255, unit.stats.might + gain.amount); + return; + case 'intelligence': + unit.stats.intelligence = Math.min(255, unit.stats.intelligence + gain.amount); + return; + case 'leadership': + unit.stats.leadership = Math.min(255, unit.stats.leadership + gain.amount); + return; + case 'move': + unit.stats.agility = Math.min(255, unit.stats.agility + gain.amount); + return; + default: + return; + } + }); + return gains; + } + + private characterLevelUpStatGains(unit: UnitData, levelUps: number): LevelUpStatGain[] { if (levelUps <= 0) { return []; } + const profile = this.levelUpStatProfile(unit.classKey); return [ - { label: '병력', amount: levelUps * 2 }, - { label: '공격', amount: levelUps } + { label: '병력', amount: levelUps * 2, icon: 'hp' }, + { label: '공격', amount: levelUps, icon: 'attack' }, + ...profile.map((icon) => ({ + label: this.levelUpStatLabel(icon), + amount: levelUps, + icon + })) ]; } + private levelUpStatProfile(classKey: UnitClassKey): BattleUiIconKey[] { + switch (classKey) { + case 'strategist': + case 'quartermaster': + return ['intelligence', 'leadership']; + case 'archer': + return ['might', 'intelligence']; + case 'cavalry': + return ['might', 'leadership', 'move']; + case 'lord': + case 'rebelLeader': + case 'yellowTurban': + return ['might', 'intelligence', 'leadership']; + default: + return ['might', 'leadership']; + } + } + + private levelUpStatLabel(icon: BattleUiIconKey) { + switch (icon) { + case 'might': + return '무력'; + case 'intelligence': + return '지력'; + case 'leadership': + return '통솔'; + case 'move': + return '민첩'; + default: + return '능력'; + } + } + private formatEquipmentGrowth(result: EquipmentGrowthResult) { const levelUp = result.leveled ? ` / Lv ${result.level} 달성` : ''; return `${equipmentSlotLabels[result.slot]} ${result.itemName} 경험치 +${result.amount} (${result.exp}/${result.next})${levelUp}`; @@ -9782,7 +10106,8 @@ export class BattleScene extends Phaser.Scene { } private sideContentBottom(padding = 16) { - return (this.miniMapLayout?.y ?? this.layout.panelY + this.layout.panelHeight - 140) - padding; + const panelBottom = this.layout.panelY + this.layout.panelHeight - padding; + return this.miniMapVisible && this.miniMapLayout ? this.miniMapLayout.y - padding : panelBottom; } private renderBondPanel(message?: string) { @@ -10230,13 +10555,16 @@ export class BattleScene extends Phaser.Scene { header.setOrigin(0); header.setStrokeStyle(1, factionColor, 0.76); - this.trackSideObject(this.add.text(left + 14, top + 9, `${rosterLabels[unit.faction]} / ${unitClass.family}`, { + const classIcon = this.trackSideIcon(left + 23, top + 31, this.unitMoveIcon(unit), 30); + classIcon.setAlpha(acted ? 0.55 : 0.94); + const headerTextX = left + 48; + this.trackSideObject(this.add.text(headerTextX, top + 9, `${rosterLabels[unit.faction]} / ${unitClass.family}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: unit.faction === 'ally' ? '#9fd0ff' : '#ffb2a4', fontStyle: '700' })); - this.trackSideObject(this.add.text(left + 14, top + 29, `${unit.name} ${unitClass.name}`, { + this.trackSideObject(this.add.text(headerTextX, top + 29, `${unit.name} ${unitClass.name}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: acted ? '#bdbdbd' : '#f2e3bf', @@ -10266,7 +10594,8 @@ export class BattleScene extends Phaser.Scene { backText.setOrigin(0.5); } - this.trackSideObject(this.add.text(left, top + 78, '병력', { + this.trackSideIcon(left + 10, top + 88, 'hp', 20); + this.trackSideObject(this.add.text(left + 26, top + 78, '병력', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: '#d4dce6', @@ -10279,43 +10608,46 @@ export class BattleScene extends Phaser.Scene { fontStyle: '700' })); hpValue.setOrigin(1, 0); - this.drawGauge(left + 76, top + 84, width - 160, 10, unit.hp / unit.maxHp, 0x59d18c); + this.drawGauge(left + 92, top + 84, width - 176, 10, unit.hp / unit.maxHp, 0x59d18c); const attackBonus = this.equipmentAttackBonus(unit); const summaryGap = 6; const summaryBoxWidth = (width - summaryGap * 2) / 3; - this.renderCompactValueBox(left, top + 112, summaryBoxWidth, '공격', `${unit.attack + attackBonus}`); - this.renderCompactValueBox(left + summaryBoxWidth + summaryGap, top + 112, summaryBoxWidth, '방어+', `${this.equipmentDefenseBonus(unit)}`); + this.renderCompactValueBox(left, top + 112, summaryBoxWidth, 'attack', '공격', `${unit.attack + attackBonus}`); + this.renderCompactValueBox(left + summaryBoxWidth + summaryGap, top + 112, summaryBoxWidth, 'defense', '방어+', `${this.equipmentDefenseBonus(unit)}`); this.renderCompactValueBox( left + (summaryBoxWidth + summaryGap) * 2, top + 112, summaryBoxWidth, + this.unitMoveIcon(unit), '방향', `${this.directionSymbol(direction)} ${this.unitDirectionLabel(direction)}` ); const statTop = top + 158; statLabels.forEach((stat, index) => { - this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 28, width); + this.renderStatRow(stat.key, stat.label, unit.stats[stat.key], left, statTop + index * 28, width); }); const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} / ${terrainRule.label} ${terrainRating}% / 이동 ${unit.move}${acted ? ' / 행동완료' : ''}`; - this.trackSideObject(this.add.text(left, top + 306, positionText, { + this.trackSideIcon(left + 9, top + 315, acted ? 'counter' : this.unitMoveIcon(unit), 18); + this.trackSideObject(this.add.text(left + 24, top + 306, positionText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '14px', color: acted ? '#bdbdbd' : '#9fb0bf', - wordWrap: { width, useAdvancedWrap: true } + wordWrap: { width: width - 24, useAdvancedWrap: true } })); this.renderEquipmentSummary(unit, left, top + 330, width); if (message) { - this.renderPanelMessage(message, left, top + 444, width, 66); + this.renderPanelMessage(message, left, top + 460, width, 58); } this.showFacingIndicator(unit); } private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number) { - this.trackSideObject(this.add.text(x, y, '장비 / 숙련도', { + this.trackSideIcon(x + 9, y + 10, 'mastery', 18); + this.trackSideObject(this.add.text(x + 24, y, '장비 / 숙련도', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f2e3bf', @@ -10323,7 +10655,7 @@ export class BattleScene extends Phaser.Scene { })); equipmentSlots.forEach((slot, index) => { - this.renderEquipmentRow(unit, slot, x, y + 24 + index * 28, width); + this.renderEquipmentRow(unit, slot, x, y + 24 + index * 31, width); }); } @@ -10332,23 +10664,22 @@ export class BattleScene extends Phaser.Scene { const item = getItem(state.itemId); const next = equipmentExpToNext(state.level); const isTreasure = item.rank === 'treasure'; - const bg = this.trackSideObject(this.add.rectangle(x, y, width, 26, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.94 : 0.86)); + const bg = this.trackSideObject(this.add.rectangle(x, y, width, 29, isTreasure ? 0x1f2430 : 0x101820, isTreasure ? 0.94 : 0.86)); bg.setOrigin(0); bg.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.58 : 0.42); - const iconFrame = this.trackSideObject(this.add.rectangle(x + 14, y + 13, 22, 22, 0x0a0f14, 0.82)); + const iconFrame = this.trackSideObject(this.add.rectangle(x + 15, y + 14.5, 24, 24, 0x0a0f14, 0.82)); iconFrame.setStrokeStyle(1, isTreasure ? palette.gold : 0x53606c, isTreasure ? 0.72 : 0.48); - const icon = this.trackSideObject(this.add.image(x + 14, y + 13, this.itemIconKey(item.id))); - icon.setDisplaySize(22, 22); + this.trackSideIcon(x + 15, y + 14.5, this.itemIcon(item, slot), 22); - this.trackSideObject(this.add.text(x + 31, y + 6, equipmentSlotLabels[slot], { + this.trackSideObject(this.add.text(x + 33, y + 4, equipmentSlotLabels[slot], { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf', fontStyle: '700' })); - this.trackSideObject(this.add.text(x + 78, y + 4, item.name, { + this.trackSideObject(this.add.text(x + 78, y + 3, item.name, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', color: isTreasure ? '#f4dfad' : '#d4dce6', @@ -10362,6 +10693,7 @@ export class BattleScene extends Phaser.Scene { color: '#9fb0bf' })); + this.trackSideIcon(x + width - 69, y + 14, 'mastery', 15); const expText = this.trackSideObject(this.add.text(x + width - 54, y + 6, `${state.exp}/${next}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', @@ -10377,12 +10709,76 @@ export class BattleScene extends Phaser.Scene { })); levelText.setOrigin(1, 0); - this.drawGauge(x + 78, y + 20, width - 178, 4, state.exp / next, isTreasure ? 0xd8b15f : 0x58aee0); + this.drawGauge(x + 78, y + 22, width - 178, 4, state.exp / next, isTreasure ? 0xd8b15f : 0x58aee0); } - private itemIconKey(itemId: string) { - const key = `item-${itemId}`; - return this.textures.exists(key) ? key : 'item-training-sword'; + private createBattleUiIcon(x: number, y: number, icon: BattleUiIconKey, size = 22) { + const image = this.add.image(x, y, battleUiIconTextureKey, battleUiIconFrames[icon]); + image.setDisplaySize(size, size); + return image; + } + + private trackSideIcon(x: number, y: number, icon: BattleUiIconKey, size = 22) { + return this.trackSideObject(this.createBattleUiIcon(x, y, icon, size)); + } + + private trackCombatIcon(x: number, y: number, icon: BattleUiIconKey, size = 22, depth = 85) { + const image = this.trackCombatObject(this.createBattleUiIcon(x, y, icon, size)); + image.setDepth(depth); + return image; + } + + private itemIcon(item: ItemDefinition, slot: EquipmentSlot = item.slot): BattleUiIconKey { + if (slot === 'armor') { + return 'armor'; + } + if (slot === 'accessory') { + return item.id.includes('quiver') ? 'bow' : 'accessory'; + } + if (item.id.includes('bow') || item.id.includes('quiver')) { + return 'bow'; + } + if (item.id.includes('spear') || item.id.includes('glaive') || item.id.includes('halberd') || item.id.includes('piercer')) { + return 'spear'; + } + if (item.id.includes('axe')) { + return 'axe'; + } + if (item.id.includes('fan') || item.id.includes('manual') || item.id.includes('scroll') || (item.strategyBonus ?? 0) > (item.attackBonus ?? 0)) { + return 'strategy'; + } + return 'sword'; + } + + private unitMoveIcon(unit: UnitData): BattleUiIconKey { + return unit.classKey === 'cavalry' ? 'horse' : 'move'; + } + + private statIcon(key: keyof UnitStats): BattleUiIconKey { + switch (key) { + case 'might': + return 'might'; + case 'intelligence': + return 'intelligence'; + case 'leadership': + return 'leadership'; + case 'agility': + return 'move'; + case 'luck': + return 'mastery'; + default: + return 'might'; + } + } + + private actionIcon(preview: CombatPreview): BattleUiIconKey { + if (preview.action === 'attack') { + return this.itemIcon(getItem(preview.attacker.equipment.weapon.itemId), 'weapon'); + } + if (preview.action === 'strategy') { + return 'strategy'; + } + return preview.usable ? this.itemIcon(getItem(preview.attacker.equipment.accessory.itemId), 'accessory') : 'accessory'; } private itemBonusText(item: ReturnType) { @@ -10418,11 +10814,12 @@ export class BattleScene extends Phaser.Scene { }, 0); } - private renderCompactValueBox(x: number, y: number, width: number, label: string, value: string) { + private renderCompactValueBox(x: number, y: number, width: number, icon: BattleUiIconKey, label: string, value: string) { const bg = this.trackSideObject(this.add.rectangle(x, y, width, 38, 0x16212d, 0.92)); bg.setOrigin(0); bg.setStrokeStyle(1, 0x53606c, 0.56); - this.trackSideObject(this.add.text(x + 8, y + 6, label, { + this.trackSideIcon(x + 13, y + 17, icon, 18); + this.trackSideObject(this.add.text(x + 25, y + 6, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', color: '#9fb0bf' @@ -10436,15 +10833,16 @@ export class BattleScene extends Phaser.Scene { valueText.setOrigin(1, 0.5); } - private renderStatRow(label: string, value: number, x: number, y: number, width: number) { - this.trackSideObject(this.add.text(x, y, label, { + private renderStatRow(key: keyof UnitStats, label: string, value: number, x: number, y: number, width: number) { + this.trackSideIcon(x + 9, y + 10, this.statIcon(key), 18); + this.trackSideObject(this.add.text(x + 24, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#d4dce6', fontStyle: '700' })); - this.drawGauge(x + 76, y + 8, width - 130, 9, value / 100, value >= 80 ? 0xd8b15f : 0x58aee0); + this.drawGauge(x + 92, y + 8, width - 146, 9, value / 100, value >= 80 ? 0xd8b15f : 0x58aee0); const valueText = this.trackSideObject(this.add.text(x + width, y - 1, `${value}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',