#!/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()