Upgrade tactical unit sprites to premium detail
@@ -5,7 +5,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageEnhance
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
PREVIEW_PATH = ROOT / "tmp" / "readable-tactical-unit-preview.png"
|
||||
|
||||
FRAME_SIZE = 313
|
||||
RENDER_SCALE = 3
|
||||
DIRECTIONS = ("south", "east", "north", "west")
|
||||
IDLE_FRAMES = 8
|
||||
WALK_FRAMES = 8
|
||||
@@ -31,7 +32,8 @@ ACTION_COLUMN_OFFSETS = {
|
||||
for index, action in enumerate(ACTION_ORDER)
|
||||
}
|
||||
ACTION_FRAMES_PER_DIRECTION = sum(ACTION_FRAME_COUNTS.values())
|
||||
PROCESSING_VERSION = "readable-tactical-v2"
|
||||
PROCESSING_VERSION = "readable-tactical-v3-premium"
|
||||
SHEET_PALETTE_COLORS = 128
|
||||
|
||||
|
||||
Color = tuple[int, int, int]
|
||||
@@ -90,6 +92,51 @@ class Motion:
|
||||
hurt_shift: float = 0
|
||||
|
||||
|
||||
class ScaledDraw:
|
||||
def __init__(self, draw: ImageDraw.ImageDraw, scale: int):
|
||||
self.draw = draw
|
||||
self.scale = scale
|
||||
|
||||
def _value(self, value: float | int) -> float:
|
||||
return value * self.scale
|
||||
|
||||
def _coords(self, xy):
|
||||
if isinstance(xy, list):
|
||||
return [(self._value(x), self._value(y)) for x, y in xy]
|
||||
if isinstance(xy, tuple) and xy and isinstance(xy[0], (tuple, list)):
|
||||
return [(self._value(x), self._value(y)) for x, y in xy]
|
||||
return tuple(self._value(value) for value in xy)
|
||||
|
||||
def _width(self, width: int | None) -> int | None:
|
||||
if width is None:
|
||||
return None
|
||||
return max(1, round(width * self.scale))
|
||||
|
||||
def line(self, xy, fill=None, width=1, **kwargs) -> None:
|
||||
self.draw.line(self._coords(xy), fill=fill, width=self._width(width), **kwargs)
|
||||
|
||||
def polygon(self, xy, fill=None, outline=None) -> None:
|
||||
self.draw.polygon(self._coords(xy), fill=fill, outline=outline)
|
||||
|
||||
def rectangle(self, xy, fill=None, outline=None, width=1) -> None:
|
||||
self.draw.rectangle(self._coords(xy), fill=fill, outline=outline, width=self._width(width))
|
||||
|
||||
def rounded_rectangle(self, xy, radius=0, fill=None, outline=None, width=1) -> None:
|
||||
self.draw.rounded_rectangle(
|
||||
self._coords(xy),
|
||||
radius=max(1, round(radius * self.scale)),
|
||||
fill=fill,
|
||||
outline=outline,
|
||||
width=self._width(width),
|
||||
)
|
||||
|
||||
def ellipse(self, xy, fill=None, outline=None, width=1) -> None:
|
||||
self.draw.ellipse(self._coords(xy), fill=fill, outline=outline, width=self._width(width))
|
||||
|
||||
def arc(self, xy, start, end, fill=None, width=1) -> None:
|
||||
self.draw.arc(self._coords(xy), start, end, fill=fill, width=self._width(width))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Redraw tactical unit sprite sheets as bold, readable, original map sprites."
|
||||
@@ -163,7 +210,7 @@ def unit_spec(stem: str) -> UnitSpec:
|
||||
bulk += 0.14
|
||||
if role in ("archer", "strategist", "shaman"):
|
||||
bulk -= 0.06
|
||||
scale = 1.06 + min(rank - 1, 2) * 0.04
|
||||
scale = 1.12 + min(rank - 1, 2) * 0.045
|
||||
hair = (44, 31, 23)
|
||||
skin = SKIN
|
||||
if faction == "nanman":
|
||||
@@ -325,8 +372,8 @@ def draw_action_sheet(spec: UnitSpec) -> Image.Image:
|
||||
|
||||
|
||||
def render_frame(spec: UnitSpec, direction: str, action: str, frame_index: int, frame_count: int) -> Image.Image:
|
||||
image = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
image = Image.new("RGBA", (FRAME_SIZE * RENDER_SCALE, FRAME_SIZE * RENDER_SCALE), (0, 0, 0, 0))
|
||||
draw = ScaledDraw(ImageDraw.Draw(image), RENDER_SCALE)
|
||||
motion = motion_for(action, frame_index, frame_count)
|
||||
cx = 156 + motion.dx
|
||||
bottom = 282 + motion.dy
|
||||
@@ -356,7 +403,8 @@ def render_frame(spec: UnitSpec, direction: str, action: str, frame_index: int,
|
||||
elif action == "celebrate":
|
||||
draw_celebrate_mark(draw, cx, bottom, frame_index, frame_count, spec)
|
||||
|
||||
return flatten_alpha(image)
|
||||
frame = image.resize((FRAME_SIZE, FRAME_SIZE), Image.Resampling.LANCZOS)
|
||||
return polish_frame(frame, spec)
|
||||
|
||||
|
||||
def motion_for(action: str, frame_index: int, frame_count: int) -> Motion:
|
||||
@@ -461,6 +509,7 @@ def draw_human(draw: ImageDraw.ImageDraw, spec: UnitSpec, direction: str, cx: fl
|
||||
INK,
|
||||
5,
|
||||
)
|
||||
draw_torso_detail(draw, cx, torso_top, torso_bottom, shoulder, waist, scale, spec)
|
||||
draw_poly(
|
||||
draw,
|
||||
[
|
||||
@@ -771,6 +820,61 @@ def draw_saddle(draw: ImageDraw.ImageDraw, cx: float, y: float, scale: float, sp
|
||||
draw.line((cx - side * 14 * scale, y + 13 * scale, cx - side * 22 * scale, y + 43 * scale), fill=LEATHER, width=max(2, round(2 * scale)))
|
||||
|
||||
|
||||
def draw_torso_detail(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: float,
|
||||
torso_top: float,
|
||||
torso_bottom: float,
|
||||
shoulder: float,
|
||||
waist: float,
|
||||
scale: float,
|
||||
spec: UnitSpec,
|
||||
) -> None:
|
||||
trim = spec.palette.accent if spec.rank > 1 or spec.role in ("lord", "officer") else darken(spec.palette.armor, 0.12)
|
||||
if spec.role in ("strategist", "shaman"):
|
||||
for offset, tone in ((-0.34, darken(spec.palette.light, 0.2)), (0.34, lighten(spec.palette.light, 0.08))):
|
||||
draw.line(
|
||||
(
|
||||
cx + shoulder * offset,
|
||||
torso_top + 18 * scale,
|
||||
cx + waist * offset * 0.65,
|
||||
torso_bottom - 6 * scale,
|
||||
),
|
||||
fill=tone,
|
||||
width=max(2, round(3 * scale)),
|
||||
)
|
||||
draw.line(
|
||||
(cx - shoulder * 0.62, torso_top + 14 * scale, cx + shoulder * 0.62, torso_top + 14 * scale),
|
||||
fill=trim,
|
||||
width=max(3, round(4 * scale)),
|
||||
)
|
||||
return
|
||||
|
||||
band_count = 4 if spec.rank > 1 else 3
|
||||
for index in range(band_count):
|
||||
t = (index + 1) / (band_count + 1)
|
||||
y = torso_top + (torso_bottom - torso_top) * t
|
||||
half = shoulder * (1 - t) + waist * t
|
||||
fill = trim if index % 2 == 0 else darken(spec.palette.armor, 0.2)
|
||||
draw.line((cx - half * 0.66, y, cx + half * 0.66, y), fill=INK, width=max(3, round(4 * scale)))
|
||||
draw.line((cx - half * 0.6, y, cx + half * 0.6, y), fill=fill, width=max(2, round(2 * scale)))
|
||||
if spec.rank > 1:
|
||||
for side in (-0.42, 0.42):
|
||||
dot_x = cx + half * side
|
||||
draw_ellipse(draw, around((dot_x, y - 1 * scale), 3.2 * scale), lighten(trim, 0.18), INK, 1)
|
||||
|
||||
draw.line(
|
||||
(cx - shoulder * 0.48, torso_top + 20 * scale, cx - waist * 0.28, torso_bottom - 2 * scale),
|
||||
fill=darken(spec.palette.armor, 0.28),
|
||||
width=max(2, round(3 * scale)),
|
||||
)
|
||||
draw.line(
|
||||
(cx + shoulder * 0.48, torso_top + 22 * scale, cx + waist * 0.25, torso_bottom - 3 * scale),
|
||||
fill=lighten(spec.palette.armor, 0.2),
|
||||
width=max(2, round(3 * scale)),
|
||||
)
|
||||
|
||||
|
||||
def draw_attack_effect(draw: ImageDraw.ImageDraw, spec: UnitSpec, direction: str, cx: float, bottom: float, frame_index: int, frame_count: int) -> None:
|
||||
t = frame_index / max(1, frame_count - 1)
|
||||
if t < 0.28 or t > 0.82:
|
||||
@@ -848,6 +952,67 @@ def around(point: tuple[float, float], radius: float) -> tuple[float, float, flo
|
||||
return (point[0] - radius, point[1] - radius, point[0] + radius, point[1] + radius)
|
||||
|
||||
|
||||
_POLISH_MASKS: tuple[Image.Image, Image.Image, Image.Image] | None = None
|
||||
|
||||
|
||||
def polish_masks() -> tuple[Image.Image, Image.Image, Image.Image]:
|
||||
global _POLISH_MASKS
|
||||
if _POLISH_MASKS is not None:
|
||||
return _POLISH_MASKS
|
||||
|
||||
top_data: list[int] = []
|
||||
bottom_data: list[int] = []
|
||||
grain_data: list[int] = []
|
||||
span = max(1, FRAME_SIZE - 1)
|
||||
for y in range(FRAME_SIZE):
|
||||
yn = y / span
|
||||
for x in range(FRAME_SIZE):
|
||||
xn = x / span
|
||||
top_data.append(round((1 - yn) * 150 + (1 - xn) * 58))
|
||||
bottom_data.append(round(yn * 168 + xn * 52))
|
||||
hatch = ((x * 23 + y * 37 + (x // 7) * 11 + (y // 5) * 17) % 64) - 32
|
||||
grain_data.append(max(0, min(255, 112 + hatch)))
|
||||
|
||||
top = Image.new("L", (FRAME_SIZE, FRAME_SIZE))
|
||||
top.putdata(top_data)
|
||||
bottom = Image.new("L", (FRAME_SIZE, FRAME_SIZE))
|
||||
bottom.putdata(bottom_data)
|
||||
grain = Image.new("L", (FRAME_SIZE, FRAME_SIZE))
|
||||
grain.putdata(grain_data)
|
||||
_POLISH_MASKS = (top, bottom, grain)
|
||||
return _POLISH_MASKS
|
||||
|
||||
|
||||
def polish_frame(image: Image.Image, spec: UnitSpec) -> Image.Image:
|
||||
image = flatten_alpha(image)
|
||||
alpha = image.getchannel("A")
|
||||
if not alpha.getbbox():
|
||||
return image
|
||||
|
||||
top, bottom, grain = polish_masks()
|
||||
light_alpha = ImageChops.multiply(alpha, top).point(lambda value: min(48, value // 5))
|
||||
shade_alpha = ImageChops.multiply(alpha, bottom).point(lambda value: min(58, value // 4))
|
||||
grain_alpha = ImageChops.multiply(alpha, grain).point(lambda value: min(16, value // 20))
|
||||
|
||||
highlight = Image.new("RGBA", image.size, (255, 237, 184, 0))
|
||||
highlight.putalpha(light_alpha)
|
||||
image = Image.alpha_composite(image, highlight)
|
||||
|
||||
shadow = Image.new("RGBA", image.size, (8, 9, 11, 0))
|
||||
shadow.putalpha(shade_alpha)
|
||||
image = Image.alpha_composite(image, shadow)
|
||||
|
||||
grain_overlay = Image.new("RGBA", image.size, lighten(spec.palette.light, 0.2) + (0,))
|
||||
grain_overlay.putalpha(grain_alpha)
|
||||
image = Image.alpha_composite(image, grain_overlay)
|
||||
|
||||
alpha = image.getchannel("A")
|
||||
rgb = ImageEnhance.Contrast(image.convert("RGB")).enhance(1.1)
|
||||
rgb = ImageEnhance.Color(rgb).enhance(1.06)
|
||||
polished = Image.merge("RGBA", (*rgb.split(), alpha))
|
||||
return flatten_alpha(polished)
|
||||
|
||||
|
||||
def flatten_alpha(image: Image.Image) -> Image.Image:
|
||||
image = image.convert("RGBA")
|
||||
alpha = image.getchannel("A").point(lambda value: 0 if value < 8 else 255)
|
||||
@@ -858,11 +1023,18 @@ def flatten_alpha(image: Image.Image) -> Image.Image:
|
||||
|
||||
def save_sprite_sheet(image: Image.Image, path: Path) -> None:
|
||||
image = flatten_alpha(image)
|
||||
image = quantize_sheet(image)
|
||||
png_info = PngInfo()
|
||||
png_info.add_text("heros_unit_sprite", PROCESSING_VERSION)
|
||||
image.save(path, optimize=True, pnginfo=png_info)
|
||||
|
||||
|
||||
def quantize_sheet(image: Image.Image) -> Image.Image:
|
||||
image = flatten_alpha(image)
|
||||
quantized = image.quantize(colors=SHEET_PALETTE_COLORS, method=Image.Quantize.FASTOCTREE).convert("RGBA")
|
||||
return flatten_alpha(quantized)
|
||||
|
||||
|
||||
def write_preview(rendered: dict[str, tuple[Image.Image, Image.Image]], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stems = list(rendered.keys())
|
||||
|
||||
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 543 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 571 KiB |
|
Before Width: | Height: | Size: 393 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 643 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 675 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 533 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 575 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 640 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 689 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 615 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 542 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 542 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 786 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 612 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 517 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 595 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 543 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 1.4 MiB |