Align battle map art and bond effects

This commit is contained in:
2026-06-22 15:58:20 +09:00
parent 305484a7c3
commit b7f683eb9f
4 changed files with 420 additions and 14 deletions

View File

@@ -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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -24,15 +24,15 @@ export type UnitClassDefinition = {
};
export const terrainRules: Record<TerrainType, TerrainRule> = {
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<UnitClassKey, UnitClassDefinition> = {

View File

@@ -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}`
};
}