diff --git a/scripts/generate-battle-map.py b/scripts/generate-battle-map.py new file mode 100644 index 0000000..dbf2dd4 --- /dev/null +++ b/scripts/generate-battle-map.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import ast +import random +import re +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter + + +ROOT = Path(__file__).resolve().parents[1] +SCENARIO_PATH = ROOT / "src" / "game" / "data" / "scenario.ts" +OUTPUT_PATH = ROOT / "src" / "assets" / "images" / "battle" / "first-battle-map.png" +TILE = 128 +SEED = 314159 + + +def load_first_battle_map() -> tuple[int, int, list[list[str]]]: + source = SCENARIO_PATH.read_text(encoding="utf-8") + width_match = re.search(r"firstBattleMap:\s*BattleMap\s*=\s*\{\s*width:\s*(\d+)", source) + height_match = re.search(r"firstBattleMap:[\s\S]*?height:\s*(\d+)", source) + terrain_match = re.search(r"firstBattleMap:[\s\S]*?terrain:\s*(\[[\s\S]*?\n\s*\])\s*\n\s*\};", source) + if not width_match or not height_match or not terrain_match: + raise RuntimeError("Could not parse firstBattleMap from scenario.ts") + + width = int(width_match.group(1)) + height = int(height_match.group(1)) + terrain = ast.literal_eval(terrain_match.group(1)) + if len(terrain) != height or any(len(row) != width for row in terrain): + raise RuntimeError("Parsed terrain dimensions do not match firstBattleMap width/height") + return width, height, terrain + + +def clamp(value: int) -> int: + return max(0, min(255, value)) + + +def jitter(color: tuple[int, int, int], amount: int, rng: random.Random) -> tuple[int, int, int]: + return tuple(clamp(channel + rng.randint(-amount, amount)) for channel in color) + + +def tile_box(x: int, y: int, inset: int = 0) -> tuple[int, int, int, int]: + return (x * TILE + inset, y * TILE + inset, (x + 1) * TILE - inset, (y + 1) * TILE - inset) + + +def tile_center(x: int, y: int) -> tuple[int, int]: + return (x * TILE + TILE // 2, y * TILE + TILE // 2) + + +def neighbor_is(terrain: list[list[str]], x: int, y: int, kinds: set[str]) -> bool: + return 0 <= y < len(terrain) and 0 <= x < len(terrain[0]) and terrain[y][x] in kinds + + +def draw_base(width: int, height: int, terrain: list[list[str]], rng: random.Random) -> Image.Image: + image = Image.new("RGB", (width * TILE, height * TILE), (94, 109, 62)) + draw = ImageDraw.Draw(image) + palettes = { + "plain": [(92, 112, 61), (117, 119, 68), (112, 96, 54), (78, 105, 58)], + "road": [(157, 128, 82), (177, 147, 92), (132, 107, 72)], + "forest": [(42, 80, 43), (56, 95, 43), (72, 102, 50), (33, 67, 40)], + "hill": [(105, 100, 70), (128, 116, 79), (83, 91, 62)], + "village": [(118, 98, 62), (100, 113, 64), (151, 126, 79)], + "fort": [(94, 79, 63), (118, 98, 74), (84, 76, 63)], + "camp": [(105, 95, 55), (123, 112, 66), (85, 109, 70)], + "river": [(50, 93, 101), (41, 80, 92), (74, 118, 121)], + "cliff": [(57, 54, 47), (76, 70, 56), (43, 43, 38)], + } + + ground_noise = Image.new("RGBA", image.size, (0, 0, 0, 0)) + noise_draw = ImageDraw.Draw(ground_noise) + for _ in range(width * height * 12): + px = rng.randrange(width * TILE) + py = rng.randrange(height * TILE) + radius = rng.randint(2, 9) + color = jitter(rng.choice(palettes["plain"]), 16, rng) + noise_draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=(*color, rng.randint(18, 42))) + ground_noise = ground_noise.filter(ImageFilter.GaussianBlur(1.2)) + image = Image.alpha_composite(image.convert("RGBA"), ground_noise).convert("RGB") + draw = ImageDraw.Draw(image) + + tint = Image.new("RGBA", image.size, (0, 0, 0, 0)) + tint_draw = ImageDraw.Draw(tint) + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + base = rng.choice(palettes[kind]) + alpha = { + "plain": 18, + "road": 34, + "forest": 68, + "hill": 54, + "village": 40, + "fort": 52, + "camp": 40, + "river": 88, + "cliff": 82, + }[kind] + tint_draw.rectangle(tile_box(x, y), fill=(*jitter(base, 8, rng), alpha)) + for _ in range(20): + px = x * TILE + rng.randrange(TILE) + py = y * TILE + rng.randrange(TILE) + radius = rng.randint(1, 3) + color = jitter(rng.choice(palettes[kind]), 14, rng) + draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=color) + + tint = tint.filter(ImageFilter.GaussianBlur(18)) + image = Image.alpha_composite(image.convert("RGBA"), tint).convert("RGB") + return image + + +def draw_grass_detail(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind not in {"plain", "camp", "village"}: + continue + for _ in range(18): + px = x * TILE + rng.randint(4, TILE - 4) + py = y * TILE + rng.randint(4, TILE - 4) + length = rng.randint(5, 13) + color = rng.choice([(70, 116, 55), (126, 116, 54), (62, 94, 47), (148, 130, 68)]) + draw.line((px, py, px + rng.randint(-3, 3), py - length), fill=color, width=1) + + +def draw_roads(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: + road_kinds = {"road", "village", "fort", "camp"} + shadow = (96, 78, 54) + road = (181, 145, 92) + highlight = (210, 178, 119) + + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind not in road_kinds: + continue + cx, cy = tile_center(x, y) + connections = [] + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + if neighbor_is(terrain, x + dx, y + dy, road_kinds): + connections.append((dx, dy)) + if not connections: + connections = [(0, 0)] + + for dx, dy in connections: + end_x = cx + dx * TILE // 2 + end_y = cy + dy * TILE // 2 + wobble = rng.randint(-7, 7) + if dx: + points = [(cx, cy + wobble), ((cx + end_x) // 2, cy - wobble), (end_x, end_y + wobble)] + elif dy: + points = [(cx + wobble, cy), (cx - wobble, (cy + end_y) // 2), (end_x + wobble, end_y)] + else: + points = [(cx - 28, cy), (cx, cy + 14), (cx + 28, cy - 8)] + draw.line(points, fill=shadow, width=42, joint="curve") + draw.line(points, fill=road, width=35, joint="curve") + draw.line(points, fill=highlight, width=6, joint="curve") + + for _ in range(20): + px = x * TILE + rng.randint(10, TILE - 10) + py = y * TILE + rng.randint(10, TILE - 10) + draw.ellipse((px - 2, py - 1, px + 2, py + 1), fill=jitter((122, 96, 62), 10, rng)) + + +def draw_rivers(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind != "river": + continue + left, top, right, bottom = tile_box(x, y) + cx, cy = tile_center(x, y) + north = neighbor_is(terrain, x, y - 1, {"river"}) + south = neighbor_is(terrain, x, y + 1, {"river"}) + east = neighbor_is(terrain, x + 1, y, {"river"}) + west = neighbor_is(terrain, x - 1, y, {"river"}) + + water = [(left + 10, top + 10), (right - 8, top + 2), (right - 12, bottom - 8), (left + 4, bottom - 2)] + if north: + water[0] = (left + 8, top - 8) + water[1] = (right - 4, top - 8) + if south: + water[2] = (right - 10, bottom + 8) + water[3] = (left + 8, bottom + 8) + draw.polygon(water, fill=(43, 94, 107)) + draw.polygon([(left + 3, top + 2), (right - 3, top + 0), (right - 8, top + 18), (left + 8, top + 20)], fill=(69, 117, 119)) + for _ in range(10): + sx = rng.randint(left + 12, right - 12) + sy = rng.randint(top + 15, bottom - 15) + draw.arc((sx - 18, sy - 5, sx + 18, sy + 9), 190, 340, fill=(135, 177, 175), width=1) + for side, connected in ((left, west), (right, east)): + if not connected: + draw.line((side, top + 6, side, bottom - 6), fill=(83, 87, 62), width=6) + if not north: + draw.line((left + 5, top, right - 5, top), fill=(91, 91, 60), width=6) + if not south: + draw.line((left + 4, bottom, right - 6, bottom), fill=(80, 83, 57), width=7) + for _ in range(5): + rx = rng.randint(left + 8, right - 8) + ry = rng.randint(top + 8, bottom - 8) + draw.ellipse((rx - 5, ry - 3, rx + 6, ry + 4), fill=(85, 92, 80)) + if not (north or south or east or west): + draw.ellipse((cx - 24, cy - 20, cx + 24, cy + 20), fill=(44, 92, 103)) + + +def draw_forests(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind != "forest": + continue + left, top, right, bottom = tile_box(x, y) + for _ in range(13): + px = rng.randint(left + 12, right - 12) + py = rng.randint(top + 14, bottom - 12) + trunk = jitter((78, 54, 34), 8, rng) + draw.rectangle((px - 3, py + 7, px + 3, py + 23), fill=trunk) + for radius, color in ((22, (36, 74, 39)), (17, (54, 96, 42)), (11, (81, 120, 53))): + ox = rng.randint(-4, 4) + oy = rng.randint(-5, 4) + draw.ellipse((px - radius + ox, py - radius + oy, px + radius + ox, py + radius + oy), fill=jitter(color, 8, rng)) + draw.ellipse((px - 14, py - 18, px + 4, py - 2), fill=(103, 132, 56)) + for _ in range(10): + px = rng.randint(left, right) + py = rng.randint(top, bottom) + draw.ellipse((px - 3, py - 3, px + 3, py + 3), fill=(24, 54, 31)) + + +def draw_hills_and_cliffs(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind not in {"hill", "cliff"}: + continue + left, top, right, bottom = tile_box(x, y) + if kind == "hill": + for _ in range(5): + cx = rng.randint(left + 22, right - 22) + cy = rng.randint(top + 22, bottom - 22) + rx = rng.randint(20, 38) + ry = rng.randint(10, 24) + draw.ellipse((cx - rx, cy - ry, cx + rx, cy + ry), fill=jitter((126, 113, 80), 12, rng)) + draw.arc((cx - rx, cy - ry, cx + rx, cy + ry), 180, 350, fill=(176, 160, 112), width=2) + draw.arc((cx - rx, cy - ry, cx + rx, cy + ry), 20, 160, fill=(72, 69, 52), width=2) + continue + + draw.rectangle((left, top, right, bottom), fill=jitter((55, 52, 45), 5, rng)) + for _ in range(12): + ax = rng.randint(left - 8, right - 12) + ay = rng.randint(top + 2, bottom - 8) + points = [ + (ax, ay + rng.randint(12, 26)), + (ax + rng.randint(14, 31), ay), + (ax + rng.randint(32, 58), ay + rng.randint(18, 40)), + (ax + rng.randint(4, 18), ay + rng.randint(42, 68)), + ] + draw.polygon(points, fill=jitter((75, 70, 58), 15, rng)) + draw.line(points + [points[0]], fill=(37, 37, 34), width=2) + draw.line((left + 8, top + 5, right - 8, bottom - 8), fill=(116, 105, 77), width=3) + + +def draw_structures(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind not in {"village", "camp", "fort"}: + continue + left, top, right, bottom = tile_box(x, y) + cx, cy = tile_center(x, y) + if kind == "village": + draw_hut(draw, cx - 22, cy - 14, rng) + draw_hut(draw, cx + 22, cy + 12, rng) + draw.line((left + 12, bottom - 22, right - 12, bottom - 22), fill=(91, 66, 39), width=5) + draw.rectangle((cx - 7, cy + 22, cx + 8, cy + 45), fill=(208, 165, 65)) + continue + if kind == "camp": + for ox, oy, color in ((-24, 5, (179, 129, 50)), (22, 0, (209, 174, 84))): + draw.polygon([(cx + ox, cy + oy - 32), (cx + ox - 28, cy + oy + 22), (cx + ox + 28, cy + oy + 22)], fill=color) + draw.line((cx + ox, cy + oy - 32, cx + ox, cy + oy + 22), fill=(88, 58, 32), width=3) + draw.line((cx + ox - 28, cy + oy + 22, cx + ox + 28, cy + oy + 22), fill=(92, 61, 35), width=4) + draw.line((left + 12, top + 20, right - 12, top + 10), fill=(105, 81, 45), width=4) + draw_banner(draw, right - 28, top + 28, (216, 171, 57)) + continue + draw.rectangle((left + 12, top + 22, right - 12, bottom - 18), outline=(73, 55, 42), width=8) + draw.rectangle((left + 20, top + 30, right - 20, bottom - 26), outline=(119, 88, 60), width=5) + draw.rectangle((cx - 18, bottom - 50, cx + 18, bottom - 18), fill=(63, 48, 39)) + for tx, ty in ((left + 16, top + 20), (right - 16, top + 20), (left + 16, bottom - 18), (right - 16, bottom - 18)): + draw.rectangle((tx - 10, ty - 10, tx + 10, ty + 10), fill=(93, 70, 52)) + draw_banner(draw, cx + 26, top + 28, (213, 171, 54)) + + +def draw_hut(draw: ImageDraw.ImageDraw, x: int, y: int, rng: random.Random) -> None: + draw.rectangle((x - 22, y - 2, x + 22, y + 26), fill=jitter((106, 74, 45), 8, rng)) + draw.polygon([(x - 31, y), (x, y - 28), (x + 31, y)], fill=jitter((151, 106, 48), 8, rng)) + draw.line((x - 30, y, x + 30, y), fill=(84, 56, 34), width=4) + draw.rectangle((x - 7, y + 8, x + 6, y + 26), fill=(55, 39, 31)) + + +def draw_banner(draw: ImageDraw.ImageDraw, x: int, y: int, color: tuple[int, int, int]) -> None: + draw.line((x, y - 22, x, y + 30), fill=(68, 48, 31), width=3) + draw.polygon([(x, y - 18), (x + 23, y - 12), (x, y + 2)], fill=color) + draw.line((x, y - 18, x + 23, y - 12, x, y + 2), fill=(96, 66, 28), width=1) + + +def draw_shadows_and_light(image: Image.Image, rng: random.Random) -> Image.Image: + width, height = image.size + shade = Image.new("RGBA", image.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(shade) + for _ in range(150): + x = rng.randint(-80, width) + y = rng.randint(-60, height) + rx = rng.randint(45, 150) + ry = rng.randint(20, 90) + draw.ellipse((x - rx, y - ry, x + rx, y + ry), fill=(0, 0, 0, rng.randint(5, 16))) + shade = shade.filter(ImageFilter.GaussianBlur(18)) + result = Image.alpha_composite(image.convert("RGBA"), shade) + + glow = Image.new("RGBA", image.size, (0, 0, 0, 0)) + gdraw = ImageDraw.Draw(glow) + gdraw.ellipse((-width * 0.22, -height * 0.22, width * 0.82, height * 0.62), fill=(255, 213, 135, 28)) + glow = glow.filter(ImageFilter.GaussianBlur(80)) + result = Image.alpha_composite(result, glow) + + vignette = Image.new("RGBA", image.size, (0, 0, 0, 0)) + vdraw = ImageDraw.Draw(vignette) + for i in range(80): + alpha = int((i / 79) ** 2 * 75) + vdraw.rectangle((i * width / 160, i * height / 160, width - i * width / 160, height - i * height / 160), outline=(0, 0, 0, alpha), width=4) + result = Image.alpha_composite(result, vignette) + return result.convert("RGB") + + +def add_tile_seams(image: Image.Image, width: int, height: int) -> Image.Image: + overlay = Image.new("RGBA", image.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + for x in range(1, width): + px = x * TILE + draw.line((px, 0, px, height * TILE), fill=(14, 19, 14, 5), width=1) + for y in range(1, height): + py = y * TILE + draw.line((0, py, width * TILE, py), fill=(14, 19, 14, 5), width=1) + return Image.alpha_composite(image.convert("RGBA"), overlay).convert("RGB") + + +def main() -> None: + rng = random.Random(SEED) + width, height, terrain = load_first_battle_map() + image = draw_base(width, height, terrain, rng) + draw = ImageDraw.Draw(image) + draw_roads(draw, terrain, rng) + draw_rivers(draw, terrain, rng) + draw_hills_and_cliffs(draw, terrain, rng) + draw_forests(draw, terrain, rng) + draw_grass_detail(draw, terrain, rng) + draw_structures(draw, terrain, rng) + image = draw_shadows_and_light(image, rng) + image = add_tile_seams(image, width, height) + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + image.save(OUTPUT_PATH, optimize=True) + print(f"Generated {OUTPUT_PATH} ({image.width}x{image.height})") + + +if __name__ == "__main__": + main() diff --git a/src/assets/images/battle/first-battle-map.png b/src/assets/images/battle/first-battle-map.png index 5198dde..d667298 100644 Binary files a/src/assets/images/battle/first-battle-map.png and b/src/assets/images/battle/first-battle-map.png differ diff --git a/src/game/data/battleRules.ts b/src/game/data/battleRules.ts index dd56057..648d107 100644 --- a/src/game/data/battleRules.ts +++ b/src/game/data/battleRules.ts @@ -24,15 +24,15 @@ export type UnitClassDefinition = { }; export const terrainRules: Record = { - plain: { label: '평지', color: 0x6d8056, alpha: 0.04, moveCost: 1, defenseBonus: 0 }, - road: { label: '길', color: 0xb08b55, alpha: 0.1, moveCost: 1, defenseBonus: 0 }, - forest: { label: '숲', color: 0x355f42, alpha: 0.17, moveCost: 2, defenseBonus: 10 }, - hill: { label: '언덕', color: 0x82724e, alpha: 0.15, moveCost: 2, defenseBonus: 8 }, - village: { label: '마을', color: 0x8d6f4b, alpha: 0.2, moveCost: 1, defenseBonus: 12, recoveryHp: 8, moraleBonus: 3 }, - fort: { label: '요새', color: 0x735c46, alpha: 0.22, moveCost: 1, defenseBonus: 18, recoveryHp: 10, moraleBonus: 4 }, - camp: { label: '진영', color: 0x4f6a73, alpha: 0.17, moveCost: 1, defenseBonus: 15, recoveryHp: 6, moraleBonus: 4 }, - river: { label: '강', color: 0x2c6687, alpha: 0.24, moveCost: 99, defenseBonus: -4, passable: false }, - cliff: { label: '절벽', color: 0x39342e, alpha: 0.28, moveCost: 99, defenseBonus: 20, passable: false } + plain: { label: '평지', color: 0x6d8056, alpha: 0.01, moveCost: 1, defenseBonus: 0 }, + road: { label: '길', color: 0xb08b55, alpha: 0.015, moveCost: 1, defenseBonus: 0 }, + forest: { label: '숲', color: 0x355f42, alpha: 0.025, moveCost: 2, defenseBonus: 10 }, + hill: { label: '언덕', color: 0x82724e, alpha: 0.025, moveCost: 2, defenseBonus: 8 }, + village: { label: '마을', color: 0x8d6f4b, alpha: 0.03, moveCost: 1, defenseBonus: 12, recoveryHp: 8, moraleBonus: 3 }, + fort: { label: '요새', color: 0x735c46, alpha: 0.035, moveCost: 1, defenseBonus: 18, recoveryHp: 10, moraleBonus: 4 }, + camp: { label: '진영', color: 0x4f6a73, alpha: 0.03, moveCost: 1, defenseBonus: 15, recoveryHp: 6, moraleBonus: 4 }, + river: { label: '강', color: 0x2c6687, alpha: 0.035, moveCost: 99, defenseBonus: -4, passable: false }, + cliff: { label: '절벽', color: 0x39342e, alpha: 0.045, moveCost: 99, defenseBonus: 20, passable: false } }; export const unitClasses: Record = { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 948f61b..791c03f 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -188,6 +188,8 @@ type CombatPreview = { attacker: UnitData; defender: UnitData; damage: number; + bondDamageBonus: number; + bondLabel?: string; hitRate: number; criticalRate: number; counterAvailable: boolean; @@ -1595,9 +1597,10 @@ 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}%` : ''; this.renderUnitDetail( preview.defender, - `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}` + `${preview.attacker.name} ${commandLabels[preview.action]} 예측\n피해 ${preview.damage} / 명중 ${preview.hitRate}% / 치명 ${preview.criticalRate}%\n지형 ${preview.terrainLabel}${counter}${bond}` ); } @@ -2018,8 +2021,8 @@ export class BattleScene extends Phaser.Scene { const terrainRule = getTerrainRule(terrain); const attackPower = this.actionPower(attacker, action, usable); const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); - const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id).damageBonus : 0; - const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus / 100)), 3, defender.maxHp); + const bondBonus = attacker.faction === 'ally' ? this.strongestBondBonus(attacker.id) : { damageBonus: 0, chainRate: 0 }; + const damage = Phaser.Math.Clamp(Math.round((attackPower - defensePower) * (1 + bondBonus.damageBonus / 100)), 3, defender.maxHp); const defenderTerrainRating = getUnitClass(defender.classKey).terrainRatings[terrain] ?? 100; const terrainHitPenalty = Math.floor(terrainRule.defenseBonus / 2) + Math.max(0, Math.floor((defenderTerrainRating - 100) / 4)); const hitRate = Phaser.Math.Clamp( @@ -2049,6 +2052,8 @@ export class BattleScene extends Phaser.Scene { attacker, defender, damage, + bondDamageBonus: bondBonus.damageBonus, + bondLabel: bondBonus.label, hitRate, criticalRate, counterAvailable: this.canCounterAttack(defender, attacker, action), @@ -2223,9 +2228,10 @@ export class BattleScene extends Phaser.Scene { private formatCombatResult(result: CombatResult) { const outcome = this.formatCombatOutcome(result); const defeated = result.defeated ? `\n${result.defender.name} 퇴각!` : `\n${result.defender.name} HP ${result.defender.hp}/${result.defender.maxHp}`; + const bondBonus = result.bondDamageBonus > 0 && result.bondLabel ? `\n공명 효과 ${result.bondLabel}: 피해 +${result.bondDamageBonus}%` : ''; const bond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; const counter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; - return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`; + return `${result.attacker.name} ${result.usable?.name ?? commandLabels[result.action]}\n${outcome}${defeated}${bondBonus}\n장수 경험치 +${result.characterGrowth.amount}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${bond}${counter}`; } private formatCombatOutcome(result: CombatResult) { @@ -2902,6 +2908,10 @@ export class BattleScene extends Phaser.Scene { attackerStatus.expFill.setScale(result.characterGrowth.previousExp / result.characterGrowth.next, 1); attackerStatus.expText.setText(`${result.characterGrowth.previousExp} / ${result.characterGrowth.next}`); + if (result.bondDamageBonus > 0 && result.bondLabel) { + await this.showBondCombatEffect(result, left + panelWidth / 2, top + 106, attackerX, groundY - 70, depth + 15); + } + await this.delay(180); await this.playCombatActionMotion(result, attackerSprite, defenderSprite, attackerX, defenderX, groundY, depth + 10, defenderDirection); if (result.hit) { @@ -3490,6 +3500,44 @@ export class BattleScene extends Phaser.Scene { return container; } + private showBondCombatEffect(result: CombatResult, textX: number, textY: number, auraX: number, auraY: number, depth: number) { + if (!result.bondLabel || result.bondDamageBonus <= 0) { + return Promise.resolve(); + } + + soundDirector.playEffect('strategy-cast', { volume: 0.22, rate: 1.08, stopAfterMs: 720 }); + + const aura = this.trackCombatObject(this.add.circle(auraX, auraY, 42)); + aura.setStrokeStyle(4, 0xffdf7b, 0.88); + aura.setDepth(depth); + + const inner = this.trackCombatObject(this.add.circle(auraX, auraY, 24, 0xffdf7b, 0.18)); + inner.setDepth(depth - 1); + + const spark = this.trackCombatObject(this.add.star(auraX, auraY, 6, 8, 24, 0xfff0b8, 0.86)); + spark.setDepth(depth + 1); + + const text = this.trackCombatObject(this.add.text(textX, textY, `공명 효과 ${result.bondLabel}\n피해 +${result.bondDamageBonus}%`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#fff2b8', + fontStyle: '700', + align: 'center', + stroke: '#2b1606', + strokeThickness: 4, + lineSpacing: 2 + })); + text.setOrigin(0.5); + text.setDepth(depth + 2); + + this.tweens.add({ targets: aura, scale: 1.7, alpha: 0, duration: 560, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: inner, scale: 1.35, alpha: 0.04, duration: 560, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: spark, angle: 180, scale: 1.22, duration: 560, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: text, y: textY - 8, scale: 1.05, duration: 180, yoyo: true, ease: 'Sine.easeOut' }); + + return this.delay(580); + } + private showCombatImpact(result: CombatResult, x: number, y: number, depth: number) { soundDirector.playEffect('combat-impact', { volume: result.critical ? 0.62 : result.action === 'strategy' ? 0.3 : 0.48, stopAfterMs: 650 }); @@ -4006,10 +4054,12 @@ export class BattleScene extends Phaser.Scene { if (!strongest) { return { damageBonus: 0, chainRate: 0 }; } + const [first, second] = strongest.unitIds.map((id) => this.unitName(id)); return { damageBonus: Math.floor(strongest.level / 20) * 3, - chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0 + chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0, + label: `${strongest.title}: ${first}·${second}` }; }