1130 lines
42 KiB
Python
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()
|