Files
KateLegend2_proj/scripts/gen_pixel_art_assets.py
2026-06-07 22:10:03 +08:00

1130 lines
42 KiB
Python

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