#!/usr/bin/env python3 """ gen_pixel_art_assets.py ──────────────────────── Procedural pixel-art generator for Chapter 1 assets. Purpose: Replace the 1x1 solid-color placeholder PNGs produced by `scripts/gen_placeholder_assets.js` with *real* (though still stylized) pixel-art PNGs that match the spec in `assets/resources/ASSETS.md`. Visual quality is intentionally simple; the goal is to let the game boot with something *visible* on screen while waiting for the final art. Coverage (all PNGs in ASSETS.md): 1. Protagonist : kage_red / kage_green / kage_yellow (176x32, 11 frames) 2. Enemies : qing_ren / chi_ren / hei_ren / yao_fang 3. Bosses : shuang_huan_fang / butterfly 4. Scenes (3 x 4 layers): forest / castle_wall / demon_castle x far/mid/near/fx 5. Story illustrations : ch1_page1..3 (480x270) 6. FX textures : leaf_particle / jump_dust / parry_spark Usage: python3 scripts/gen_pixel_art_assets.py """ from __future__ import annotations import math import random import sys from pathlib import Path try: from PIL import Image, ImageDraw, ImageFont, ImageFilter except ImportError: print("Pillow is required. Install with: pip3 install Pillow", file=sys.stderr) sys.exit(1) PROJECT = Path(__file__).resolve().parent.parent RES = PROJECT / "assets" / "resources" TEX = RES / "textures" # ───────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────── def new_rgba(w, h): return Image.new("RGBA", (w, h), (0, 0, 0, 0)) def save_png(img: Image.Image, rel_path: str): out = RES / rel_path out.parent.mkdir(parents=True, exist_ok=True) img.save(out, "PNG", optimize=True) size_kb = out.stat().st_size / 1024 print(f" + {rel_path} ({img.width}x{img.height}, {size_kb:.1f} KB)") def _patch_meta_files(): """Ensure every PNG .meta has type='sprite-frame' so Cocos Creator generates a spriteFrame sub-asset for each image. This is idempotent and safe to run every time the script executes.""" import json count = 0 for meta_path in RES.rglob("*.png.meta"): with open(meta_path, "r") as f: meta = json.load(f) ud = meta.setdefault("userData", {}) if ud.get("type") != "sprite-frame": ud["type"] = "sprite-frame" with open(meta_path, "w") as f: json.dump(meta, f, indent=2, ensure_ascii=False) f.write("\n") count += 1 print(f" ~ {meta_path.relative_to(RES)} (type → sprite-frame)") if count == 0: print(" (all meta files already correct)") else: print(f" Patched {count} meta file(s).") def plot(img: Image.Image, x: int, y: int, rgba): """Safe single-pixel plot (ignores out-of-range).""" if 0 <= x < img.width and 0 <= y < img.height: img.putpixel((x, y), rgba) def rect(img, x, y, w, h, rgba): for j in range(h): for i in range(w): plot(img, x + i, y + j, rgba) # ───────────────────────────────────────────────────────────────────── # 1. Protagonist sprite sheet (176x32, 11 frames) # ───────────────────────────────────────────────────────────────────── # Shared outline/skin/hair palette indices; only clothing changes per variant. BASE_PAL = { "bg": (0, 0, 0, 0), "outline": (20, 15, 15, 255), "skin": (228, 188, 152, 255), "skin_sh": (180, 140, 110, 255), "hair": (30, 20, 20, 255), "eye": (250, 250, 250, 255), "shadow": (25, 20, 25, 255), } VARIANT_PAL = { "kage_red": { "cloth_dark": (110, 20, 18, 255), "cloth_mid": (170, 40, 34, 255), "cloth_lit": (220, 80, 60, 255), "scarf_dark": (60, 8, 8, 255), "scarf_mid": (90, 12, 12, 255), "accent": (230, 200, 120, 255), }, "kage_green": { "cloth_dark": (20, 80, 35, 255), "cloth_mid": (40, 130, 55, 255), "cloth_lit": (80, 180, 90, 255), "scarf_dark": (15, 50, 25, 255), "scarf_mid": (25, 70, 35, 255), "accent": (220, 210, 140, 255), }, "kage_yellow": { "cloth_dark": (140, 110, 25, 255), "cloth_mid": (200, 170, 40, 255), "cloth_lit": (245, 220, 90, 255), "scarf_dark": (90, 70, 15, 255), "scarf_mid": (120, 95, 20, 255), "accent": (230, 240, 200, 255), }, } def draw_kage_frame(img, ox, variant_name, pose): """Draw one 16x32 frame at offset ox into a sprite sheet image.""" p = dict(BASE_PAL) p.update(VARIANT_PAL[variant_name]) O = p["outline"] SK = p["skin"] SS = p["skin_sh"] HR = p["hair"] EY = p["eye"] CD = p["cloth_dark"] CM = p["cloth_mid"] CL = p["cloth_lit"] SD = p["scarf_dark"] SM = p["scarf_mid"] AC = p["accent"] SH = p["shadow"] # Y-offset for jump (whole body lifts) yoff = 0 if pose == "jump_up": yoff = -3 elif pose == "jump_down": yoff = -1 def px(x, y, c): plot(img, ox + x, y + yoff, c) # ── Head / hood band (rows 2..9) ───────────────────────────── # Hood outline for x in range(5, 11): px(x, 2, O) for y in range(3, 5): px(4, y, O); px(11, y, O) # Hood fill for y in range(3, 5): for x in range(5, 11): px(x, y, CD) # Forehead band for x in range(4, 12): px(x, 5, CM) # Mask area (face) for y in range(6, 9): px(4, y, O); px(11, y, O) for y in range(6, 9): for x in range(5, 11): px(x, y, SK) # Eyes (row 7) — vary slightly per pose for life eye_left, eye_right = 6, 9 if pose in ("run2", "run4"): eye_left, eye_right = 7, 10 px(eye_left, 7, EY) px(eye_right, 7, EY) px(eye_left, 7, O) # pupil dot over white? keep outline dark # Instead: pupil = outline, sclera = eye around it (simplified: just dark) px(eye_left, 7, O) px(eye_right, 7, O) # Nose / shadow px(8, 8, SS) # Chin outline for x in range(5, 11): px(x, 9, O) # ── Scarf (rows 10..13) ────────────────────────────────────── for x in range(3, 13): px(x, 10, O) for x in range(4, 12): px(x, 11, SM) for x in range(4, 12): px(x, 12, SD) # Trailing ends differ per pose if pose in ("run1", "run3", "attack2", "attack3"): px(2, 11, SD); px(2, 12, SD); px(1, 12, SD) if pose in ("attack1",): px(13, 11, SD); px(13, 12, SD) # ── Torso (rows 13..20) ────────────────────────────────────── for y in range(13, 20): px(4, y, O); px(11, y, O) for y in range(13, 20): for x in range(5, 11): px(x, y, CM) # Chest highlight for y in range(14, 18): px(6, y, CL) px(7, y, CL) # Belt for x in range(4, 12): px(x, 19, CD) px(7, 19, AC) px(8, 19, AC) # ── Arms (rows 12..19) ─────────────────────────────────────── if pose == "idle1" or pose == "idle2": # Both arms at sides for y in range(13, 19): px(3, y, O) px(12, y, O) for y in range(13, 18): px(3, y, CM) if y % 2 == 0 else px(3, y, CD) px(12, y, CM) if y % 2 == 0 else px(12, y, CD) elif pose.startswith("run"): # One arm forward, one back — swaps per frame fwd_left = pose in ("run1", "run3") if fwd_left: # Left arm forward (screen-right since facing right) for y in range(13, 18): px(13, y, O) for y in range(14, 17): px(13, y, CM) # Right arm back for y in range(13, 19): px(2, y, O) px(2, y + 0, CD) if y < 18 else None else: for y in range(13, 18): px(2, y, O) for y in range(14, 17): px(2, y, CM) for y in range(13, 19): px(13, y, O) elif pose.startswith("jump"): # Arms up for y in range(11, 15): px(2, y, O); px(13, y, O) for y in range(12, 14): px(2, y, CM); px(13, y, CM) elif pose.startswith("attack"): # Sword arm extended forward (right side) for y in range(13, 17): px(12, y, O); px(13, y, O) for y in range(14, 16): px(12, y, SK); px(13, y, SK) # Sword blade if pose == "attack1": # Wind-up: blade raised diagonally up-right for i in range(3): px(14 + i, 13 - i, AC) px(14 + i, 12 - i, O) elif pose == "attack2": # Full horizontal slash for i in range(4): px(13 + i, 14, O) if 13 + i < 16 else None px(13 + i, 15, AC) if 13 + i < 16 else None else: # attack3 follow-through down for i in range(3): px(13 + i, 15 + i, O) if (13 + i < 16 and 15 + i < 32) else None px(13 + i, 14 + i, AC) if (13 + i < 16 and 14 + i < 32) else None # Off-hand at hip for y in range(14, 18): px(3, y, O) for y in range(15, 17): px(3, y, CM) # ── Legs (rows 20..29) ─────────────────────────────────────── def draw_leg(x_center, y_top, y_bot, bent=False): for y in range(y_top, y_bot): px(x_center, y, O) px(x_center + 1, y, O) for y in range(y_top, y_bot - 1): px(x_center, y, CD) px(x_center + 1, y, CD) # Foot / sandal for dx in range(-1, 3): px(x_center + dx, y_bot - 1, SH) if pose in ("idle1", "idle2"): # Two legs straight draw_leg(5, 20, 30) draw_leg(9, 20, 30) elif pose == "run1": # Left leg forward (raised) draw_leg(4, 20, 27) draw_leg(10, 22, 30) elif pose == "run2": # Passing pose draw_leg(6, 20, 29) draw_leg(9, 20, 29) elif pose == "run3": # Right leg forward draw_leg(10, 20, 27) draw_leg(5, 22, 30) elif pose == "run4": # Passing pose (mirror of run2) draw_leg(5, 20, 29) draw_leg(10, 20, 29) elif pose == "jump_up": # Legs tucked up draw_leg(5, 21, 27) draw_leg(9, 21, 27) elif pose == "jump_down": # Legs extended draw_leg(4, 20, 30) draw_leg(10, 20, 30) elif pose.startswith("attack"): # Attack stance: wide, one forward draw_leg(4, 20, 30) draw_leg(10, 21, 30) def gen_kage_sheet(variant_name): """Generate a 176x32 sprite sheet for one kage variant.""" poses = [ "idle1", "idle2", # 0..1 "run1", "run2", "run3", "run4", # 2..5 "jump_up", "jump_down", # 6..7 "attack1", "attack2", "attack3", # 8..10 ] assert len(poses) == 11 img = new_rgba(16 * 11, 32) for i, pose in enumerate(poses): draw_kage_frame(img, i * 16, variant_name, pose) return img # ───────────────────────────────────────────────────────────────────── # 2. Enemies # ───────────────────────────────────────────────────────────────────── def gen_small_enemy_sheet(primary, secondary, frame_count, size=(16, 16)): """ Minimal stylized enemy sprite: body (rounded square) + eyes + waist band; pose variations shift eyes/body. """ w, h = size img = new_rgba(w * frame_count, h) OUT = (15, 15, 20, 255) SK = (230, 200, 170, 255) EY = (250, 250, 250, 255) for f in range(frame_count): ox = f * w # Body outline (rounded rectangle) for y in range(3, h - 1): plot(img, ox + 2, y, OUT) plot(img, ox + w - 3, y, OUT) for x in range(3, w - 3): plot(img, ox + x, 2, OUT) plot(img, ox + x, h - 1, OUT) # Fill for y in range(3, h - 1): for x in range(3, w - 3): plot(img, ox + x, y, primary) # Head / mask for x in range(4, w - 4): plot(img, ox + x, 3, primary) plot(img, ox + x, 4, SK) # Eyes (shift right on even frames for run animation) eye_shift = 1 if f % 2 == 0 else 0 plot(img, ox + 5 + eye_shift, 4, OUT) plot(img, ox + w - 6 + eye_shift, 4, OUT) # Waist band for x in range(3, w - 3): plot(img, ox + x, 9, secondary) plot(img, ox + x, 10, secondary) # Legs — slight offset per frame for motion leg_off = (f % 2) * 1 plot(img, ox + 5, h - 1, OUT) plot(img, ox + 6, h - 1, OUT) plot(img, ox + w - 7, h - 1 - leg_off, OUT) plot(img, ox + w - 6, h - 1 - leg_off, OUT) # Accessory (weapon/shuriken hint) on throw/swing frames if f >= frame_count - 2: plot(img, ox + w - 2, 6, secondary) plot(img, ox + w - 1, 6, secondary) return img def gen_qing_ren(): # 16x16, 7 frames: idle 1 / run 2 / throw 2 / swing 2 return gen_small_enemy_sheet( primary=(70, 130, 180, 255), secondary=(30, 70, 120, 255), frame_count=7, ) def gen_chi_ren(): # 16x16, 7 frames: idle 1 / run 2 / throw 2 / jump 2 return gen_small_enemy_sheet( primary=(190, 60, 60, 255), secondary=(120, 20, 20, 255), frame_count=7, ) def gen_hei_ren(): # 20x24, 6 frames: idle 2 / run 2 / swing 2 w, h = 20, 24 frames = 6 img = new_rgba(w * frames, h) OUT = (10, 10, 12, 255) BODY = (35, 35, 45, 255) BODY_HI = (60, 60, 75, 255) SK = (220, 190, 160, 255) GOLD = (220, 180, 60, 255) for f in range(frames): ox = f * w # Head for y in range(2, 8): plot(img, ox + 6, y, OUT); plot(img, ox + w - 7, y, OUT) for x in range(6, w - 6): plot(img, ox + x, 1, OUT); plot(img, ox + x, 8, OUT) for y in range(2, 8): for x in range(7, w - 7): plot(img, ox + x, y, BODY) # Mask (face strip) for x in range(7, w - 7): plot(img, ox + x, 5, SK) # Eyes plot(img, ox + 8, 5, OUT); plot(img, ox + w - 9, 5, OUT) # Shoulders / torso for y in range(9, 18): plot(img, ox + 4, y, OUT); plot(img, ox + w - 5, y, OUT) for y in range(9, 18): for x in range(5, w - 5): plot(img, ox + x, y, BODY) # Body highlight for y in range(10, 16): plot(img, ox + 6, y, BODY_HI) plot(img, ox + 7, y, BODY_HI) # Belt w/ gold buckle (魔笛 hint) for x in range(5, w - 5): plot(img, ox + x, 17, OUT) plot(img, ox + w // 2, 17, GOLD) plot(img, ox + w // 2 - 1, 17, GOLD) # Legs (motion) leg_off = (f % 2) * 2 if 2 <= f <= 3 else 0 for y in range(18, h - 1): plot(img, ox + 7, y, OUT) plot(img, ox + 8, y, BODY) plot(img, ox + w - 9, y - leg_off, BODY) plot(img, ox + w - 8, y - leg_off, OUT) # Arm swing on last two frames if f >= 4: for i in range(4): plot(img, ox + w - 5 + i, 10 - i, OUT) plot(img, ox + w - 5 + i, 11 - i, BODY_HI) return img def gen_yao_fang(): # 18x20, 4 frames: idle 1 / cast 3 w, h = 18, 20 frames = 4 img = new_rgba(w * frames, h) OUT = (15, 10, 20, 255) ROBE = (120, 50, 140, 255) ROBE_LIT = (170, 90, 190, 255) SK = (220, 190, 160, 255) FIRE_A = (255, 180, 60, 255) FIRE_B = (255, 100, 40, 255) for f in range(frames): ox = f * w # Wide robe (triangle-ish) for y in range(4, h - 1): width_at_y = 2 + int((y - 4) * 0.6) x0 = w // 2 - width_at_y x1 = w // 2 + width_at_y plot(img, ox + x0, y, OUT) plot(img, ox + x1 - 1, y, OUT) for x in range(x0 + 1, x1 - 1): plot(img, ox + x, y, ROBE) # Robe highlight strip for y in range(8, h - 2): plot(img, ox + w // 2 - 1, y, ROBE_LIT) plot(img, ox + w // 2, y, ROBE_LIT) # Head (small skull under cowl) for y in range(1, 5): plot(img, ox + w // 2 - 2, y, OUT) plot(img, ox + w // 2 + 1, y, OUT) for x in range(w // 2 - 1, w // 2 + 1): plot(img, ox + x, 0, OUT) plot(img, ox + x, 4, OUT) plot(img, ox + x, 2, SK) plot(img, ox + x, 3, SK) # Eyes plot(img, ox + w // 2 - 1, 3, OUT) plot(img, ox + w // 2, 3, OUT) # Fireball charging on cast frames (f=1..3) if f >= 1: cx = ox + w - 3 cy = 10 radius = f # grows 1,2,3 for dy in range(-radius, radius + 1): for dx in range(-radius, radius + 1): if dx * dx + dy * dy <= radius * radius: col = FIRE_A if (dx + dy) % 2 == 0 else FIRE_B plot(img, cx + dx, cy + dy, col) return img # ───────────────────────────────────────────────────────────────────── # 3. Bosses # ───────────────────────────────────────────────────────────────────── def gen_shuang_huan_fang(): """ Per ASSETS.md: 32x32 (single body) OR 96x32 (双身 clone row). We output 96x32 total = 3 frames x 32 wide (single body + 2 clones). Actually spec says "idle 2 / clone 4 / fireball 3" — 9 frames. We ship 9x32 = 288x32. """ fw, fh = 32, 32 frames = 9 img = new_rgba(fw * frames, fh) OUT = (15, 5, 25, 255) ROBE = (100, 30, 130, 255) ROBE_D = (60, 15, 80, 255) ROBE_L = (160, 70, 190, 255) GOLD = (230, 200, 90, 255) SK = (210, 180, 150, 255) FIRE_A = (255, 160, 50, 255) FIRE_B = (255, 80, 30, 255) CLONE = (100, 30, 130, 140) # semi-transparent for clone frames for f in range(frames): ox = f * fw # Robe silhouette for y in range(6, fh - 2): width_at_y = 3 + int((y - 6) * 0.55) x0 = fw // 2 - width_at_y x1 = fw // 2 + width_at_y for x in range(x0, x1): plot(img, ox + x, y, ROBE) plot(img, ox + x0 - 1, y, OUT) plot(img, ox + x1, y, OUT) # Robe shadow / highlight for y in range(10, fh - 3): plot(img, ox + fw // 2 - 2, y, ROBE_D) plot(img, ox + fw // 2 + 1, y, ROBE_D) plot(img, ox + fw // 2 - 1, y, ROBE_L) plot(img, ox + fw // 2, y, ROBE_L) # Head hx, hy = fw // 2, 4 for dy in range(-3, 4): for dx in range(-3, 4): if dx * dx + dy * dy <= 9: plot(img, ox + hx + dx, hy + dy, SK) for dy in range(-3, 4): for dx in range(-3, 4): if 9 - 2 <= dx * dx + dy * dy <= 9: plot(img, ox + hx + dx, hy + dy, OUT) # Eyes (glowing on fireball frames) eye_col = GOLD if f >= 6 else OUT plot(img, ox + hx - 1, hy, eye_col) plot(img, ox + hx + 1, hy, eye_col) # Crown / gold band for x in range(hx - 3, hx + 4): plot(img, ox + x, hy - 3, GOLD) # Idle bob on frame 1 # Clone frames (2..5): ghost duplicates drift outward if 2 <= f <= 5: offset = (f - 1) * 3 for y in range(10, fh - 2): for x in range(fw // 2 - 4, fw // 2 + 4): plot(img, ox + x - offset, y, CLONE) plot(img, ox + x + offset, y, CLONE) # Fireball charge frames (6..8) if f >= 6: cr = f - 5 # 1..3 cx, cy = ox + fw // 2, fh - 8 for dy in range(-cr - 1, cr + 2): for dx in range(-cr - 1, cr + 2): d2 = dx * dx + dy * dy if d2 <= (cr + 1) * (cr + 1): col = FIRE_A if (dx + dy) % 2 == 0 else FIRE_B plot(img, cx + dx, cy + dy, col) return img def gen_butterfly(): # 16x16, 4 frames: fly (wing up/mid/down/mid) w, h = 16, 16 frames = 4 img = new_rgba(w * frames, h) OUT = (20, 10, 30, 255) W1 = (255, 220, 100, 255) W2 = (255, 160, 60, 255) BODY = (40, 20, 50, 255) for f in range(frames): ox = f * w # Body (center vertical line) for y in range(4, 12): plot(img, ox + w // 2, y, BODY) plot(img, ox + w // 2 - 1, y, BODY) # Wings — vertical stretch by frame spread = [5, 6, 7, 6][f] for dy in range(-spread // 2, spread // 2 + 1): for dx in range(-spread, spread + 1): # Elliptical wings if (dx * dx) * 4 + (dy * dy) * 9 <= spread * spread * 4: cx = ox + w // 2 + (dx + (2 if dx > 0 else -2)) cy = h // 2 + dy col = W1 if abs(dx) < spread - 1 else W2 plot(img, cx, cy, col) # Wing outlines for dx in range(-spread - 1, spread + 2): plot(img, ox + w // 2 + dx + (2 if dx > 0 else -2), h // 2 - spread // 2 - 1, OUT) plot(img, ox + w // 2 + dx + (2 if dx > 0 else -2), h // 2 + spread // 2 + 1, OUT) # Antennae plot(img, ox + w // 2 - 1, 3, OUT) plot(img, ox + w // 2 + 1, 3, OUT) return img # ───────────────────────────────────────────────────────────────────── # 4. Scenes (parallax layers) # ───────────────────────────────────────────────────────────────────── SCENE_W, SCENE_H = 480, 270 def gen_scene_forest(): rng = random.Random(1001) # Far: gradient sky far = new_rgba(SCENE_W, SCENE_H) for y in range(SCENE_H): t = y / SCENE_H r = int(180 - t * 60) g = int(210 - t * 40) b = int(220 - t * 30) for x in range(SCENE_W): far.putpixel((x, y), (r, g, b, 255)) # Distant mountain silhouette for x in range(SCENE_W): height = int(40 + 30 * math.sin(x * 0.015) + 15 * math.sin(x * 0.04)) for y in range(SCENE_H - 100 - height, SCENE_H - 100): far.putpixel((x, y), (70, 100, 90, 255)) # Mid: distant trees mid = new_rgba(SCENE_W, SCENE_H) for _ in range(40): tx = rng.randint(0, SCENE_W - 1) th = rng.randint(60, 120) tw = rng.randint(20, 40) top_y = SCENE_H - 120 - th # Trunk for y in range(top_y + th - 20, SCENE_H - 80): for x in range(tx - 2, tx + 3): if 0 <= x < SCENE_W and 0 <= y < SCENE_H: mid.putpixel((x, y), (70, 50, 30, 255)) # Foliage (stacked triangles) for layer in range(3): lw = tw - layer * 4 lh = 20 ly = top_y + layer * 12 for j in range(lh): wj = int(lw * (1 - j / lh)) for i in range(-wj, wj + 1): x = tx + i y = ly + j if 0 <= x < SCENE_W and 0 <= y < SCENE_H: mid.putpixel((x, y), (50 + layer * 15, 120 + layer * 15, 60, 255)) # Near: ground + close trees silhouettes near = new_rgba(SCENE_W, SCENE_H) # Ground for y in range(SCENE_H - 80, SCENE_H): shade = 40 + (SCENE_H - y) // 3 for x in range(SCENE_W): # Grass highlights g = (30, 90, 50, 255) if ((x + y) % 7) != 0 else (60, 140, 70, 255) near.putpixel((x, y), g) # Grass blades for _ in range(400): x = rng.randint(0, SCENE_W - 1) y = rng.randint(SCENE_H - 80, SCENE_H - 1) h = rng.randint(2, 6) for j in range(h): if 0 <= y - j < SCENE_H: near.putpixel((x, y - j), (20, 80, 40, 255)) # A couple big tree silhouettes in foreground for tx in [60, 300, 420]: tw = 10 top_y = SCENE_H - 200 for y in range(top_y, SCENE_H - 80): for x in range(tx - tw, tx + tw): if 0 <= x < SCENE_W: near.putpixel((x, y), (30, 20, 15, 255)) # Canopy for r in range(30): for ang in range(0, 360, 5): rad = math.radians(ang) xx = tx + int(r * math.cos(rad) * 1.2) yy = top_y - 10 + int(r * math.sin(rad)) if 0 <= xx < SCENE_W and 0 <= yy < SCENE_H and r > 20: near.putpixel((xx, yy), (25, 70, 40, 255)) # FX: subtle light rays / falling leaves fx = new_rgba(SCENE_W, SCENE_H) for _ in range(150): x = rng.randint(0, SCENE_W - 1) y = rng.randint(0, SCENE_H - 1) fx.putpixel((x, y), (200, 230, 150, 90)) # Diagonal light rays for i in range(0, SCENE_W, 40): for t in range(100): x = i + t y = t if 0 <= x < SCENE_W and 0 <= y < SCENE_H: fx.putpixel((x, y), (255, 245, 200, 40)) return far, mid, near, fx def gen_scene_castle_wall(): rng = random.Random(2002) # Far: dusk sky far = new_rgba(SCENE_W, SCENE_H) for y in range(SCENE_H): t = y / SCENE_H r = int(60 + t * 40) g = int(50 + t * 30) b = int(90 + t * 20) for x in range(SCENE_W): far.putpixel((x, y), (r, g, b, 255)) # Far towers for tx in [80, 220, 360]: for y in range(SCENE_H - 180, SCENE_H - 80): for x in range(tx - 15, tx + 15): far.putpixel((x, y), (50, 45, 65, 255)) # Crenellations for cx in range(tx - 15, tx + 15, 6): for dy in range(5): for dx in range(3): if 0 <= cx + dx < SCENE_W: far.putpixel((cx + dx, SCENE_H - 180 + dy), (70, 65, 85, 255)) # Mid: wall stones mid = new_rgba(SCENE_W, SCENE_H) for y in range(SCENE_H - 150, SCENE_H - 50): for x in range(SCENE_W): stone = ((x // 20 + y // 12) % 2) base = 95 if stone else 110 noise = rng.randint(-10, 10) mid.putpixel((x, y), (base + noise, base + noise, base + 10 + noise, 255)) # Mortar lines for y in range(SCENE_H - 150, SCENE_H - 50, 12): for x in range(SCENE_W): mid.putpixel((x, y), (60, 55, 70, 255)) for x in range(0, SCENE_W, 20): for y in range(SCENE_H - 150, SCENE_H - 50): mid.putpixel((x, y), (60, 55, 70, 255)) # Near: ground + torches near = new_rgba(SCENE_W, SCENE_H) for y in range(SCENE_H - 50, SCENE_H): for x in range(SCENE_W): near.putpixel((x, y), (45, 40, 55, 255)) # Torches for tx in [100, 280, 420]: # Sconce for y in range(SCENE_H - 110, SCENE_H - 80): for x in range(tx - 2, tx + 2): near.putpixel((x, y), (30, 20, 15, 255)) # Flame for y in range(SCENE_H - 130, SCENE_H - 110): for x in range(tx - 5, tx + 5): d = abs(x - tx) + abs(y - (SCENE_H - 120)) if d < 6: col = (255, 180, 60, 255) if d < 3 else (255, 100, 30, 255) near.putpixel((x, y), col) # FX: fog / glow fx = new_rgba(SCENE_W, SCENE_H) for y in range(SCENE_H - 80, SCENE_H): alpha = int((y - (SCENE_H - 80)) / 80 * 80) for x in range(SCENE_W): fx.putpixel((x, y), (180, 170, 190, alpha)) return far, mid, near, fx def gen_scene_demon_castle(): rng = random.Random(3003) # Far: blood moon sky far = new_rgba(SCENE_W, SCENE_H) for y in range(SCENE_H): t = y / SCENE_H r = int(40 + t * 20) g = int(10 + t * 10) b = int(30 + t * 15) for x in range(SCENE_W): far.putpixel((x, y), (r, g, b, 255)) # Moon mx, my = 380, 70 for dy in range(-30, 31): for dx in range(-30, 31): d = dx * dx + dy * dy if d <= 900: col = (220, 80, 80, 255) if d <= 700 else (180, 50, 50, 255) far.putpixel((mx + dx, my + dy), col) # Mid: demon castle silhouette w/ spires mid = new_rgba(SCENE_W, SCENE_H) for x in range(SCENE_W): base = SCENE_H - 100 # Main body if 100 < x < 380: top = base - 80 for y in range(top, SCENE_H - 60): mid.putpixel((x, y), (25, 10, 35, 255)) # Spires for sx in [130, 200, 280, 350]: height = rng.randint(100, 140) for y in range(SCENE_H - 60 - height, SCENE_H - 60): for x in range(sx - 8, sx + 8): mid.putpixel((x, y), (30, 15, 40, 255)) # Spike top for j in range(20): for x in range(sx - (8 - j // 3), sx + (8 - j // 3)): mid.putpixel((x, SCENE_H - 60 - height - j), (30, 15, 40, 255)) # Near: floor + candles near = new_rgba(SCENE_W, SCENE_H) for y in range(SCENE_H - 60, SCENE_H): for x in range(SCENE_W): near.putpixel((x, y), (60, 20, 70, 255)) # Floor tiles pattern for x in range(0, SCENE_W, 32): for y in range(SCENE_H - 60, SCENE_H): near.putpixel((x, y), (30, 10, 40, 255)) # Candles for tx in [80, 200, 340, 440]: for y in range(SCENE_H - 100, SCENE_H - 60): near.putpixel((tx, y), (220, 200, 160, 255)) # Flame for dy in range(-6, 0): for dx in range(-2, 3): if abs(dx) + abs(dy) < 5: near.putpixel((tx + dx, SCENE_H - 100 + dy), (255, 200, 80, 255)) # FX: purple fog + sparks fx = new_rgba(SCENE_W, SCENE_H) for _ in range(200): x = rng.randint(0, SCENE_W - 1) y = rng.randint(0, SCENE_H - 1) fx.putpixel((x, y), (200, 120, 220, 80)) for y in range(SCENE_H - 60, SCENE_H): alpha = int((y - (SCENE_H - 60)) / 60 * 100) for x in range(0, SCENE_W, 2): fx.putpixel((x, y), (160, 80, 180, alpha)) return far, mid, near, fx # ───────────────────────────────────────────────────────────────────── # 5. Story illustrations (480x270) # ───────────────────────────────────────────────────────────────────── def gen_story_page(title_text, bg_top, bg_bot, fg_color, draw_ninja=True, draw_princess=False, draw_path=False, seed=0): rng = random.Random(seed) img = new_rgba(480, 270) d = ImageDraw.Draw(img) # Gradient background for y in range(270): t = y / 270 r = int(bg_top[0] * (1 - t) + bg_bot[0] * t) g = int(bg_top[1] * (1 - t) + bg_bot[1] * t) b = int(bg_top[2] * (1 - t) + bg_bot[2] * t) d.line([(0, y), (480, y)], fill=(r, g, b, 255)) # Distant silhouettes for x in range(480): h = int(50 + 25 * math.sin(x * 0.02)) d.line([(x, 270 - 60 - h), (x, 270 - 60)], fill=(40, 30, 50, 255)) # Ground d.rectangle([(0, 210), (480, 270)], fill=(20, 15, 25, 255)) # Draw ninja (larger version of kage) if draw_ninja: # Scale factor: 5x -> 80x160 from 16x32 # Position center-left cx, cy = 160, 180 # Body d.rectangle([(cx - 20, cy - 50), (cx + 20, cy + 30)], fill=fg_color) # Head d.ellipse([(cx - 22, cy - 90), (cx + 22, cy - 40)], fill=fg_color) # Mask d.rectangle([(cx - 18, cy - 72), (cx + 18, cy - 58)], fill=(228, 188, 152, 255)) # Eyes d.rectangle([(cx - 10, cy - 68), (cx - 6, cy - 64)], fill=(20, 15, 15, 255)) d.rectangle([(cx + 6, cy - 68), (cx + 10, cy - 64)], fill=(20, 15, 15, 255)) # Scarf tail flowing for i in range(40): d.line([(cx + 20 + i, cy - 40 + i // 3), (cx + 22 + i, cy - 38 + i // 3)], fill=(fg_color[0] - 40, fg_color[1] - 20, fg_color[2] - 20, 255)) # Legs d.rectangle([(cx - 15, cy + 30), (cx - 5, cy + 70)], fill=(40, 25, 25, 255)) d.rectangle([(cx + 5, cy + 30), (cx + 15, cy + 70)], fill=(40, 25, 25, 255)) # Sword on back d.rectangle([(cx + 20, cy - 60), (cx + 40, cy - 55)], fill=(200, 200, 210, 255)) d.rectangle([(cx + 15, cy - 55), (cx + 25, cy - 40)], fill=(100, 60, 30, 255)) # Draw princess (pink dress) if draw_princess: px, py = 340, 180 # Body / dress (triangle) points = [(px - 30, py + 30), (px + 30, py + 30), (px, py - 30)] d.polygon(points, fill=(230, 130, 170, 255)) # Head d.ellipse([(px - 15, py - 60), (px + 15, py - 30)], fill=(255, 220, 190, 255)) # Hair d.ellipse([(px - 18, py - 65), (px + 18, py - 45)], fill=(80, 50, 30, 255)) # Eyes (sad) d.rectangle([(px - 8, py - 48), (px - 5, py - 45)], fill=(20, 15, 15, 255)) d.rectangle([(px + 5, py - 48), (px + 8, py - 45)], fill=(20, 15, 15, 255)) # Tear d.rectangle([(px - 7, py - 42), (px - 6, py - 38)], fill=(100, 180, 230, 255)) # Crown for i in range(3): d.polygon([(px - 10 + i * 10, py - 60), (px - 7 + i * 10, py - 68), (px - 4 + i * 10, py - 60)], fill=(230, 200, 80, 255)) # Captor shadow behind (blue enemy) d.rectangle([(px - 5, py - 80), (px + 40, py - 20)], fill=(40, 80, 120, 180)) # Draw winding path to horizon if draw_path: for y in range(210, 270): t = (y - 210) / 60 width = int(100 * t) cx = 240 + int(30 * math.sin(t * 3)) d.line([(cx - width, y), (cx + width, y)], fill=(160, 130, 90, 255)) # Footprints for i in range(5): fy = 220 + i * 10 fx = 230 - i * 5 d.ellipse([(fx, fy), (fx + 8, fy + 5)], fill=(80, 60, 40, 255)) # Title bar at top d.rectangle([(0, 0), (480, 28)], fill=(0, 0, 0, 180)) try: font = ImageFont.load_default() d.text((10, 7), title_text, fill=(240, 230, 200, 255), font=font) except Exception: d.text((10, 7), title_text, fill=(240, 230, 200, 255)) # Vignette vignette = new_rgba(480, 270) vd = ImageDraw.Draw(vignette) for r in range(20): alpha = int((20 - r) * 8) vd.rectangle([(r, r), (480 - r, 270 - r)], outline=(0, 0, 0, alpha)) img = Image.alpha_composite(img, vignette) return img # ───────────────────────────────────────────────────────────────────── # 6. FX particle textures # ───────────────────────────────────────────────────────────────────── def gen_leaf_particle(): img = new_rgba(16, 16) # Simple leaf shape with stem leaf_main = (110, 180, 70, 255) leaf_lit = (160, 220, 100, 255) stem = (80, 50, 30, 255) vein = (70, 130, 50, 255) for y in range(16): for x in range(16): dx = x - 8 dy = y - 8 # Elliptical leaf rotated 30 degrees ang = math.radians(30) rx = dx * math.cos(ang) - dy * math.sin(ang) ry = dx * math.sin(ang) + dy * math.cos(ang) if (rx * rx) / 25 + (ry * ry) / 9 <= 1: if (rx * rx) / 16 + (ry * ry) / 4 <= 1: img.putpixel((x, y), leaf_lit) else: img.putpixel((x, y), leaf_main) # Stem for i in range(5): img.putpixel((11 + i // 2, 5 - i // 2), stem) # Vein for i in range(7): img.putpixel((8 - i // 2, 8 + i // 2), vein) return img def gen_jump_dust(): img = new_rgba(16, 16) dust_a = (210, 210, 200, 255) dust_b = (180, 175, 160, 180) # Puff cloud for (cx, cy, r) in [(8, 10, 4), (4, 12, 3), (12, 12, 3), (6, 8, 2), (11, 8, 2)]: for dy in range(-r, r + 1): for dx in range(-r, r + 1): if dx * dx + dy * dy <= r * r: if (dx + dy) % 2 == 0: img.putpixel((cx + dx, cy + dy), dust_a) else: img.putpixel((cx + dx, cy + dy), dust_b) return img def gen_parry_spark(): img = new_rgba(16, 16) hot = (255, 245, 180, 255) warm = (255, 200, 80, 255) mid = (255, 120, 40, 255) # 4-point star for i in range(8): img.putpixel((8, 8 - i), warm if i > 2 else hot) img.putpixel((8, 8 + i), warm if i > 2 else hot) img.putpixel((8 - i, 8), warm if i > 2 else hot) img.putpixel((8 + i, 8), warm if i > 2 else hot) # Diagonals for i in range(5): img.putpixel((8 + i, 8 + i), mid) img.putpixel((8 - i, 8 - i), mid) img.putpixel((8 + i, 8 - i), mid) img.putpixel((8 - i, 8 + i), mid) # Center img.putpixel((8, 8), hot) img.putpixel((7, 8), hot) img.putpixel((9, 8), hot) img.putpixel((8, 7), hot) img.putpixel((8, 9), hot) return img # ───────────────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────────────── def main(): print("==== Procedural pixel-art asset generation ====\n") print("[1/6] Protagonist sprites ...") for variant in ("kage_red", "kage_green", "kage_yellow"): save_png(gen_kage_sheet(variant), f"textures/characters/{variant}.png") print("\n[2/6] Enemies ...") save_png(gen_qing_ren(), "textures/enemies/qing_ren.png") save_png(gen_chi_ren(), "textures/enemies/chi_ren.png") save_png(gen_hei_ren(), "textures/enemies/hei_ren.png") save_png(gen_yao_fang(), "textures/enemies/yao_fang.png") print("\n[3/6] Bosses ...") save_png(gen_shuang_huan_fang(), "textures/bosses/shuang_huan_fang.png") save_png(gen_butterfly(), "textures/bosses/butterfly.png") print("\n[4/6] Scene parallax layers ...") for theme_name, gen_func in ( ("forest", gen_scene_forest), ("castle_wall", gen_scene_castle_wall), ("demon_castle", gen_scene_demon_castle), ): far, mid, near, fx = gen_func() save_png(far, f"textures/scenes/{theme_name}/far.png") save_png(mid, f"textures/scenes/{theme_name}/mid.png") save_png(near, f"textures/scenes/{theme_name}/near.png") save_png(fx, f"textures/scenes/{theme_name}/fx.png") print("\n[5/6] Story illustrations ...") save_png( gen_story_page( "Chapter 1 - Page 1: The Ninja", bg_top=(70, 100, 160), bg_bot=(30, 40, 80), fg_color=(170, 40, 34, 255), draw_ninja=True, seed=1, ), "textures/story/ch1_page1_ninja.png", ) save_png( gen_story_page( "Chapter 1 - Page 2: The Princess Taken", bg_top=(140, 60, 100), bg_bot=(50, 20, 50), fg_color=(110, 20, 18, 255), draw_ninja=False, draw_princess=True, seed=2, ), "textures/story/ch1_page2_princess.png", ) save_png( gen_story_page( "Chapter 1 - Page 3: Departure", bg_top=(200, 150, 100), bg_bot=(80, 60, 100), fg_color=(170, 40, 34, 255), draw_ninja=True, draw_path=True, seed=3, ), "textures/story/ch1_page3_depart.png", ) print("\n[6/6] FX particle textures ...") save_png(gen_leaf_particle(), "textures/fx/leaf_particle.png") save_png(gen_jump_dust(), "textures/fx/jump_dust.png") save_png(gen_parry_spark(), "textures/fx/parry_spark.png") print("\n[7/6] Patching .meta files to type=sprite-frame ...") _patch_meta_files() print("\n==== Done. ====") print("Audio files (wav / mp3) are left to `scripts/gen_placeholder_assets.js`.") if __name__ == "__main__": main()