You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
362 lines
12 KiB
362 lines
12 KiB
#!/usr/bin/env python3 |
|
"""Generate detailed 64×64 pixel art weapon sprites for mice game. |
|
|
|
Logical grid: 16×16, each logical pixel = 4×4 real pixels → 64×64 output. |
|
|
|
Sprites generated: |
|
BMP_BOMB0 … BMP_BOMB4 — bomb fuse animation (0=long fuse, 4=spark) |
|
BMP_GAS — toxic gas cloud (symmetric) |
|
BMP_GAS_LEFT/RIGHT/UP/DOWN — gas half-sprites (directional clip) |
|
BMP_EXPLOSION — central starburst |
|
BMP_EXPLOSION_LEFT/RIGHT/UP/DOWN |
|
BMP_NUCLEAR — mushroom cloud |
|
BMP_POISON — poison vial with skull |
|
|
|
Usage (from project root): |
|
python tools/generate_weapon_sprites.py |
|
""" |
|
|
|
from pathlib import Path |
|
from PIL import Image, ImageDraw |
|
import math |
|
|
|
REPO = Path(__file__).resolve().parent.parent |
|
OUT = REPO / "assets" / "Rat" |
|
|
|
SZ = 64 # canvas size (real pixels) |
|
LP = 4 # logical pixel size in real pixels |
|
LG = SZ // LP # 16 — logical grid dimension |
|
|
|
# ─── Palette ─────────────────────────────────────────────────────────────────── |
|
T = (0, 0, 0, 0) |
|
|
|
# Bomb body |
|
BK = (10, 10, 20, 255) # outline |
|
BD = (35, 35, 60, 255) # dark body |
|
BM = (58, 58, 90, 255) # mid body |
|
BH = (88, 92, 130, 255) # highlight |
|
BG = (150, 155, 205, 255) # gloss spot |
|
|
|
# Fuse rope |
|
FD = (70, 40, 10, 255) # dark strand |
|
FL = (120, 80, 28, 255) # light strand |
|
|
|
# Sparks |
|
SK = (255, 225, 30, 255) # yellow |
|
SO = (255, 130, 0, 255) # orange |
|
EW = (255, 255, 210, 255) # spark white core |
|
|
|
# Gas / toxic cloud |
|
GK = (15, 80, 8, 255) # dark-green outline |
|
GD = (30, 140, 18, 255) # dark green body |
|
GM = (55, 195, 40, 255) # mid green |
|
GL = (120, 235, 75, 255) # light green |
|
GH = (205, 255, 155, 255) # gloss highlight |
|
|
|
# Explosion |
|
EK = (140, 10, 0, 255) # dark red core |
|
EM = (230, 70, 0, 255) # orange rays |
|
EL = (255, 200, 0, 255) # yellow outer |
|
|
|
# Nuclear cloud |
|
NC = (120, 120, 120, 255) # cloud dark grey |
|
NL = (185, 185, 185, 255) # cloud mid grey |
|
NH = (245, 245, 245, 255) # cloud light / highlight |
|
NK = (155, 30, 0, 255) # stem dark red |
|
NM = (235, 110, 15, 255) # stem orange |
|
NY = (255, 215, 25, 255) # inner glow yellow |
|
|
|
# Poison vial |
|
PD = (70, 0, 115, 255) # dark purple stopper |
|
PM = (125, 20, 170, 255) # mid purple cork |
|
PW = (235, 235, 235, 255) # white glass / label |
|
PBK = (5, 5, 10, 255) # skull black |
|
PG = (25, 165, 20, 255) # green liquid |
|
PGH = (85, 230, 55, 255) # green highlight |
|
|
|
|
|
# ─── Drawing helpers ──────────────────────────────────────────────────────────── |
|
|
|
def _img(): |
|
return Image.new("RGBA", (SZ, SZ), (0, 0, 0, 0)) |
|
|
|
|
|
def _put(img, lx, ly, color): |
|
"""Paint one logical pixel (LP×LP block).""" |
|
if 0 <= lx < LG and 0 <= ly < LG: |
|
drw = ImageDraw.Draw(img) |
|
x0, y0 = lx * LP, ly * LP |
|
drw.rectangle([x0, y0, x0 + LP - 1, y0 + LP - 1], fill=color) |
|
|
|
|
|
def _circle(img, cx, cy, layers): |
|
""" |
|
Paint concentric circles. cx/cy in logical float coords. |
|
layers = [(outer_radius, color), ...] tested in order; first hit wins. |
|
""" |
|
drw = ImageDraw.Draw(img) |
|
for ly in range(LG): |
|
for lx in range(LG): |
|
d = math.sqrt((lx - cx) ** 2 + (ly - cy) ** 2) |
|
for r, color in layers: |
|
if d <= r: |
|
x0, y0 = lx * LP, ly * LP |
|
drw.rectangle([x0, y0, x0 + LP - 1, y0 + LP - 1], fill=color) |
|
break |
|
|
|
|
|
def _grid(img, rows, palette): |
|
""" |
|
Paint from a character-grid string list. |
|
rows: list of 16 strings, each with 16 non-space chars. |
|
'.' = skip (transparent). palette maps char → RGBA. |
|
""" |
|
for row_idx, row_str in enumerate(rows): |
|
chars = [c for c in row_str if c != ' '] |
|
for col_idx, ch in enumerate(chars): |
|
if ch == '.' or col_idx >= LG or row_idx >= LG: |
|
continue |
|
color = palette.get(ch) |
|
if color: |
|
_put(img, col_idx, row_idx, color) |
|
|
|
|
|
# ─── BOMB ────────────────────────────────────────────────────────────────────── |
|
# Fuse rope path (logical coords), body-attachment → spark tip |
|
_FUSE = [(9, 6), (9, 5), (10, 4), (11, 3), (11, 2), (12, 1)] |
|
|
|
|
|
def make_bomb(frame: int) -> Image.Image: |
|
"""frame 0 = long fuse / tiny spark; frame 4 = no fuse / huge spark.""" |
|
img = _img() |
|
|
|
# Body: nested circles centered at logical (7.5, 10.0) |
|
cx, cy = 7.5, 10.0 |
|
_circle(img, cx, cy, [ |
|
(5.4, BK), # outline ring |
|
(4.9, BD), # dark body edge |
|
(4.0, BM), # mid body fill |
|
]) |
|
# Highlight blob (upper-left of body) |
|
_circle(img, 5.5, 7.8, [(2.3, BH), (1.2, BG)]) |
|
|
|
# Fuse rope — show only the remaining segments |
|
segs = max(1, len(_FUSE) - frame) |
|
for i in range(segs): |
|
fx, fy = _FUSE[i] |
|
_put(img, fx, fy, FL if i % 2 == 0 else FD) |
|
|
|
# Spark at the fuse tip — grows with frame |
|
ti = min(segs - 1, len(_FUSE) - 1) |
|
tx, ty = _FUSE[ti] |
|
if frame == 0: |
|
_put(img, tx, ty, SK) |
|
elif frame == 1: |
|
_put(img, tx, ty, EW) |
|
_put(img, tx, ty - 1, SK) |
|
elif frame == 2: |
|
_put(img, tx, ty, EW) |
|
_put(img, tx + 1, ty, SK) |
|
_put(img, tx, ty - 1, SK) |
|
elif frame == 3: |
|
for dx, dy, c in [(-1, 0, SK), (1, 0, SK), (0, -1, SK), (0, 1, SO), (0, 0, EW)]: |
|
_put(img, tx + dx, ty + dy, c) |
|
else: # frame 4 — about to detonate |
|
for dx, dy, c in [ |
|
(-2, 0, SO), (-1, 0, SK), (-1, -1, SK), (-1, 1, SO), |
|
(0, -2, SK), (0, -1, EW), (0, 0, EW), (0, 1, SK), |
|
(1, 0, SK), (1, -1, SK), (2, 0, SO), (0, -3, SO), |
|
]: |
|
_put(img, tx + dx, ty + dy, c) |
|
|
|
return img |
|
|
|
|
|
# ─── GAS ─────────────────────────────────────────────────────────────────────── |
|
_GAS_ROWS = [ |
|
'. . . . . . . . . . . . . . . .', |
|
'. . . . . K K K K . . . . . . .', |
|
'. . . K K D D D D K K . . . . .', |
|
'. . K D D M M M M D D K . . . .', |
|
'. K D M M M L L M M D D K . . .', |
|
'. K D M L G L L G L M D K . . .', |
|
'K D M M L L L L L L M D D K . .', |
|
'K D M L L L L L L L L M D K . .', |
|
'K D M M L L L L L L M M D K . .', |
|
'K D D M M L L L L M M D D K . .', |
|
'. K D D M M M M M M D D K . . .', |
|
'. . K D D D M M D D D K . . . .', |
|
'. . . K K D D D D K K . . . . .', |
|
'. . . . . K K K K . . . . . . .', |
|
'. . . . . . . . . . . . . . . .', |
|
'. . . . . . . . . . . . . . . .', |
|
] |
|
_GAS_PAL = {'K': GK, 'D': GD, 'M': GM, 'L': GL, 'G': GH} |
|
|
|
|
|
def make_gas(direction=None) -> Image.Image: |
|
img = _img() |
|
_grid(img, _GAS_ROWS, _GAS_PAL) |
|
# Directional clip: erase the half the gas does NOT flow toward |
|
drw = ImageDraw.Draw(img) |
|
clips = { |
|
'LEFT': (32, 0, 63, 63), |
|
'RIGHT': (0, 0, 31, 63), |
|
'UP': (0, 32, 63, 63), |
|
'DOWN': (0, 0, 63, 31), |
|
} |
|
if direction in clips: |
|
drw.rectangle(list(clips[direction]), fill=(0, 0, 0, 0)) |
|
return img |
|
|
|
|
|
# ─── EXPLOSION ───────────────────────────────────────────────────────────────── |
|
|
|
def make_explosion(direction=None) -> Image.Image: |
|
img = _img() |
|
cx, cy = 7.5, 7.5 |
|
|
|
# 8-way diagonal rays (thin) |
|
for deg in range(0, 360, 45): |
|
rad = math.radians(deg) |
|
for step in range(1, 110): |
|
d = step * 0.07 |
|
if d > 7.8: |
|
break |
|
lx = round(cx + d * math.cos(rad)) |
|
ly = round(cy + d * math.sin(rad)) |
|
if 0 <= lx < LG and 0 <= ly < LG: |
|
c = EM if d > 6 else (EL if d > 3.5 else EW) |
|
_put(img, lx, ly, c) |
|
|
|
# 4-way cardinal rays (thicker — 3 pixels wide) |
|
for deg in [0, 90, 180, 270]: |
|
rad = math.radians(deg) |
|
perp = math.radians(deg + 90) |
|
for step in range(1, 120): |
|
d = step * 0.07 |
|
if d > 7.8: |
|
break |
|
for spread in (-0.35, 0.0, 0.35): |
|
lx = round(cx + d * math.cos(rad) + spread * math.cos(perp)) |
|
ly = round(cy + d * math.sin(rad) + spread * math.sin(perp)) |
|
if 0 <= lx < LG and 0 <= ly < LG: |
|
c = EM if d > 5.5 else (EL if d > 3.0 else EW) |
|
_put(img, lx, ly, c) |
|
|
|
# Hot core |
|
_circle(img, cx, cy, [(2.5, EK), (1.8, EM), (1.0, EL), (0.5, EW)]) |
|
|
|
# Directional clip |
|
drw = ImageDraw.Draw(img) |
|
clips = { |
|
'LEFT': (32, 0, 63, 63), |
|
'RIGHT': (0, 0, 31, 63), |
|
'UP': (0, 32, 63, 63), |
|
'DOWN': (0, 0, 63, 31), |
|
} |
|
if direction in clips: |
|
drw.rectangle(list(clips[direction]), fill=(0, 0, 0, 0)) |
|
return img |
|
|
|
|
|
# ─── NUCLEAR (mushroom cloud) ─────────────────────────────────────────────────── |
|
_NUCLEAR_ROWS = [ |
|
'. . . . . . . . . . . . . . . .', |
|
'. . . . N L L L L L L N . . . .', |
|
'. . . N L H H H H H H L N . . .', |
|
'. . N L H H N N H H N H L N . .', |
|
'. . N L H N N N H N N H L N . .', |
|
'. . N L N N N H H N N N L N . .', |
|
'. . N L N N N H H N N N L N . .', |
|
'. . . N L H H H H H H L N . . .', |
|
'. . . . N L L L L L L N . . . .', |
|
'. . . . . . M Y Y M . . . . . .', |
|
'. . . . . . M Y Y M . . . . . .', |
|
'. . . . . Z M Y Y M Z . . . . .', |
|
'. . . . Z Z M Y Y M Z Z . . . .', |
|
'. . . Z Z Z M Y Y M Z Z Z . . .', |
|
'. . . Z Z Z Z Z Z Z Z Z Z . . .', |
|
'. . . . . . . . . . . . . . . .', |
|
] |
|
_NUCLEAR_PAL = {'N': NC, 'L': NL, 'H': NH, 'M': NM, 'Y': NY, 'Z': NK} |
|
|
|
|
|
def make_nuclear() -> Image.Image: |
|
img = _img() |
|
_grid(img, _NUCLEAR_ROWS, _NUCLEAR_PAL) |
|
return img |
|
|
|
|
|
# ─── POISON VIAL ─────────────────────────────────────────────────────────────── |
|
_POISON_ROWS = [ |
|
'. . . . . . . . . . . . . . . .', |
|
'. . . . . . . D D D . . . . . .', |
|
'. . . . . . D M M M D . . . . .', |
|
'. . . . . . W W W W W W . . . .', |
|
'. . . . . W B B B B B B W . . .', |
|
'. . . . . W B W . . B B W . . .', |
|
'. . . . . W B . W W . B W . . .', |
|
'. . . . . W B G G G B B W . . .', |
|
'. . . . . W B G H G B B W . . .', |
|
'. . . . . W B G G G B B W . . .', |
|
'. . . . . W B B B B B B W . . .', |
|
'. . . . . W B B B B B B W . . .', |
|
'. . . . . . W W W W W W . . . .', |
|
'. . . . . . . . . . . . . . . .', |
|
'. . . . . . . . . . . . . . . .', |
|
'. . . . . . . . . . . . . . . .', |
|
] |
|
_POISON_PAL = { |
|
'D': PD, # dark purple stopper |
|
'M': PM, # mid purple cork |
|
'W': PW, # white glass |
|
'B': PBK, # skull / label black |
|
'G': PG, # green liquid |
|
'H': PGH, # green highlight |
|
} |
|
|
|
|
|
def make_poison() -> Image.Image: |
|
img = _img() |
|
_grid(img, _POISON_ROWS, _POISON_PAL) |
|
return img |
|
|
|
|
|
# ─── Main ─────────────────────────────────────────────────────────────────────── |
|
|
|
def main(): |
|
OUT.mkdir(parents=True, exist_ok=True) |
|
|
|
# Bombs (5 animation frames) |
|
for frame in range(5): |
|
p = OUT / f"BMP_BOMB{frame}.png" |
|
make_bomb(frame).save(p) |
|
print(f" wrote {p.name}") |
|
|
|
# Gas |
|
for direction in [None, 'LEFT', 'RIGHT', 'UP', 'DOWN']: |
|
suffix = f"_{direction}" if direction else "" |
|
p = OUT / f"BMP_GAS{suffix}.png" |
|
make_gas(direction).save(p) |
|
print(f" wrote {p.name}") |
|
|
|
# Explosion |
|
for direction in [None, 'LEFT', 'RIGHT', 'UP', 'DOWN']: |
|
suffix = f"_{direction}" if direction else "" |
|
p = OUT / f"BMP_EXPLOSION{suffix}.png" |
|
make_explosion(direction).save(p) |
|
print(f" wrote {p.name}") |
|
|
|
# Nuclear & Poison |
|
make_nuclear().save(OUT / "BMP_NUCLEAR.png") |
|
print(" wrote BMP_NUCLEAR.png") |
|
make_poison().save(OUT / "BMP_POISON.png") |
|
print(" wrote BMP_POISON.png") |
|
|
|
total = 5 + 5 + 5 + 2 |
|
print(f"\nDone — {total} sprites saved to {OUT}") |
|
|
|
|
|
if __name__ == "__main__": |
|
main()
|
|
|