singe/util/forgeKit.py
2026-09-14 22:32:53 -05:00

371 lines
16 KiB
Python

#!/usr/bin/env python3
# Makes the Forge tutorial kit -- the pictures, sounds, music, clip, and model the tutorials in
# docs/Forge.adoc tell the reader to use -- into assets/Forge/kit/, which the build packs into
# Forge.game. Everything here is drawn or synthesised by this script, so the kit can be remade
# whenever a tutorial wants something changed:
#
# python3 util/forgeKit.py
#
# Needs Pillow and ffmpeg. The Fox model is copied from testScripts/Models (CC-BY 4.0, see the
# LICENSE the script writes). Names are ASCII and camelCase, as the tutorials print them.
import math
import os
import shutil
import struct
import subprocess
import sys
import wave
from PIL import Image, ImageDraw
KIT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "Forge", "kit")
MODELS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "testScripts", "Models")
RATE = 22050
OUTLINE = (24, 20, 36, 255)
SKIN = (250, 214, 170, 255)
SHIRT = (230, 90, 170, 255)
TROUSERS = (60, 70, 140, 255)
BOOT = (70, 40, 30, 255)
def image(w, h):
return Image.new("RGBA", (w, h), (0, 0, 0, 0))
def save(img, name):
img.save(os.path.join(KIT, name), optimize=True)
print(" " + name)
def outlined(draw, box, fill, shape="rect"):
if shape == "ellipse":
draw.ellipse(box, fill=fill, outline=OUTLINE)
else:
draw.rectangle(box, fill=fill, outline=OUTLINE)
# ---- the hero: 8 frames of 32 x 48, facing right: idle, walk x4, jump, fall, hit ----
def heroFrame(draw, ox, pose):
# Legs first, so the body sits over them.
strides = {"idle": (0, 0), "w1": (-4, 4), "w2": (-2, 2), "w3": (4, -4), "w4": (2, -2), "jump": (-3, 3), "fall": (2, 2), "hit": (-2, -2)}
back, front = strides[pose]
lift = -4 if pose == "jump" else 0
outlined(draw, (ox + 11 + back, 28 + lift, ox + 16 + back, 44 + lift), TROUSERS)
outlined(draw, (ox + 10 + back, 42 + lift, ox + 17 + back, 46 + lift), BOOT)
outlined(draw, (ox + 16 + front, 28 + lift, ox + 21 + front, 44 + lift), TROUSERS)
outlined(draw, (ox + 15 + front, 42 + lift, ox + 22 + front, 46 + lift), BOOT)
# Body and arms.
outlined(draw, (ox + 9, 17 + lift, ox + 22, 30 + lift), SHIRT)
if pose == "jump":
outlined(draw, (ox + 6, 10 + lift, ox + 10, 24 + lift), SHIRT)
outlined(draw, (ox + 21, 10 + lift, ox + 25, 24 + lift), SHIRT)
elif pose == "hit":
outlined(draw, (ox + 4, 18 + lift, ox + 10, 22 + lift), SHIRT)
outlined(draw, (ox + 21, 18 + lift, ox + 27, 22 + lift), SHIRT)
else:
swing = {"w1": 3, "w2": 1, "w3": -3, "w4": -1}.get(pose, 0)
outlined(draw, (ox + 6, 19 + lift, ox + 10, 29 + lift + swing), SHIRT)
outlined(draw, (ox + 21, 19 + lift, ox + 25, 29 + lift - swing), SHIRT)
# Head, with the eye on the right so the art faces right.
outlined(draw, (ox + 8, 3 + lift, ox + 23, 18 + lift), SKIN, "ellipse")
draw.rectangle((ox + 8, 3 + lift, ox + 23, 8 + lift), fill=(90, 50, 30, 255))
draw.rectangle((ox + 18, 9 + lift, ox + 20, 11 + lift), fill=OUTLINE)
if pose == "hit":
draw.line((ox + 17, 9 + lift, ox + 21, 12 + lift), fill=OUTLINE)
draw.line((ox + 21, 9 + lift, ox + 17, 12 + lift), fill=OUTLINE)
def hero():
img = image(256, 48)
draw = ImageDraw.Draw(img)
for at, pose in enumerate(["idle", "w1", "w2", "w3", "w4", "jump", "fall", "hit"]):
heroFrame(draw, at * 32, pose)
save(img, "hero.png")
def coin():
img = image(16, 16)
draw = ImageDraw.Draw(img)
outlined(draw, (1, 1, 14, 14), (250, 200, 40, 255), "ellipse")
draw.ellipse((4, 4, 11, 11), outline=(200, 140, 20, 255))
draw.point((5, 5), fill=(255, 250, 200, 255))
save(img, "coin.png")
def key():
img = image(16, 16)
draw = ImageDraw.Draw(img)
draw.ellipse((1, 4, 8, 11), fill=(250, 200, 40, 255), outline=OUTLINE)
draw.rectangle((8, 7, 14, 8), fill=(250, 200, 40, 255), outline=OUTLINE)
draw.rectangle((12, 8, 12, 11), fill=(250, 200, 40, 255), outline=OUTLINE)
draw.rectangle((14, 8, 14, 10), fill=(250, 200, 40, 255), outline=OUTLINE)
save(img, "key.png")
def crate():
img = image(32, 32)
draw = ImageDraw.Draw(img)
outlined(draw, (0, 0, 31, 31), (170, 120, 60, 255))
draw.rectangle((3, 3, 28, 28), outline=(120, 80, 40, 255))
draw.line((3, 3, 28, 28), fill=(120, 80, 40, 255), width=2)
draw.line((28, 3, 3, 28), fill=(120, 80, 40, 255), width=2)
save(img, "crate.png")
def door():
img = image(32, 48)
draw = ImageDraw.Draw(img)
outlined(draw, (2, 0, 29, 47), (120, 70, 40, 255))
draw.rectangle((6, 4, 25, 43), outline=(80, 45, 25, 255))
draw.line((15, 4, 15, 43), fill=(80, 45, 25, 255))
draw.ellipse((19, 22, 23, 26), fill=(250, 200, 40, 255), outline=OUTLINE)
save(img, "door.png")
def spike():
img = image(32, 16)
draw = ImageDraw.Draw(img)
for at in range(4):
x = at * 8
draw.polygon([(x, 15), (x + 4, 0), (x + 8, 15)], fill=(200, 200, 215, 255), outline=OUTLINE)
save(img, "spike.png")
def tiles():
img = image(128, 32)
draw = ImageDraw.Draw(img)
# 1 grass, 2 dirt, 3 stone, 4 brick.
outlined(draw, (0, 0, 31, 31), (140, 90, 50, 255))
draw.rectangle((1, 1, 30, 8), fill=(90, 180, 70, 255))
for x in range(2, 30, 4):
draw.line((x, 1, x, 4), fill=(60, 140, 50, 255))
outlined(draw, (32, 0, 63, 31), (140, 90, 50, 255))
for x, y in [(38, 8), (50, 20), (44, 26), (56, 6)]:
draw.point((x, y), fill=(110, 70, 40, 255))
outlined(draw, (64, 0, 95, 31), (130, 130, 145, 255))
draw.rectangle((68, 4, 80, 14), outline=(100, 100, 115, 255))
draw.rectangle((78, 16, 92, 28), outline=(100, 100, 115, 255))
outlined(draw, (96, 0, 127, 31), (170, 70, 60, 255))
for row in range(4):
y = row * 8
draw.line((96, y + 7, 127, y + 7), fill=(120, 40, 35, 255))
offset = 8 if row % 2 else 0
for x in range(96 + offset, 128, 16):
draw.line((x, y, x, y + 7), fill=(120, 40, 35, 255))
save(img, "tiles.png")
def enemy():
img = image(128, 32)
draw = ImageDraw.Draw(img)
for at in range(4):
ox = at * 32
flap = [0, 4, 8, 4][at]
draw.polygon([(ox + 2, 16 + flap), (ox + 12, 12), (ox + 12, 20)], fill=(120, 60, 160, 255), outline=OUTLINE)
draw.polygon([(ox + 30, 16 + flap), (ox + 20, 12), (ox + 20, 20)], fill=(120, 60, 160, 255), outline=OUTLINE)
outlined(draw, (ox + 9, 8, ox + 22, 24), (150, 80, 190, 255), "ellipse")
draw.rectangle((ox + 12, 13, ox + 13, 15), fill=(255, 240, 80, 255))
draw.rectangle((ox + 18, 13, ox + 19, 15), fill=(255, 240, 80, 255))
save(img, "enemy.png")
def ship():
img = image(32, 32)
draw = ImageDraw.Draw(img)
draw.polygon([(16, 1), (28, 26), (16, 20), (4, 26)], fill=(200, 210, 230, 255), outline=OUTLINE)
draw.polygon([(16, 8), (20, 18), (12, 18)], fill=(80, 160, 240, 255), outline=OUTLINE)
draw.rectangle((13, 26, 18, 30), fill=(250, 150, 40, 255), outline=OUTLINE)
save(img, "ship.png")
def shot():
img = image(8, 8)
draw = ImageDraw.Draw(img)
draw.ellipse((1, 0, 6, 7), fill=(255, 240, 120, 255), outline=(200, 120, 20, 255))
save(img, "shot.png")
def rock():
img = image(32, 32)
draw = ImageDraw.Draw(img)
draw.polygon([(6, 4), (22, 2), (30, 12), (28, 26), (14, 30), (3, 20)], fill=(140, 130, 120, 255), outline=OUTLINE)
draw.polygon([(10, 10), (18, 8), (20, 16), (12, 18)], fill=(110, 100, 95, 255))
save(img, "rock.png")
def target():
img = image(48, 48)
draw = ImageDraw.Draw(img)
for radius, colour in [(23, (230, 60, 60, 255)), (17, (245, 245, 245, 255)), (11, (230, 60, 60, 255)), (5, (245, 245, 245, 255))]:
draw.ellipse((24 - radius, 24 - radius, 24 + radius, 24 + radius), fill=colour, outline=OUTLINE)
save(img, "target.png")
# ---- painted rooms for the adventure, 720 x 480 ----
def yard():
img = Image.new("RGB", (720, 480), (120, 170, 230))
draw = ImageDraw.Draw(img)
for y in range(0, 200):
draw.line((0, y, 720, y), fill=(100 + y // 4, 150 + y // 5, 230))
# A wall along the back, with a doorway on the right.
draw.rectangle((0, 200, 720, 300), fill=(180, 150, 110))
for row in range(5):
y = 200 + row * 20
draw.line((0, y + 19, 720, y + 19), fill=(140, 110, 80))
offset = 30 if row % 2 else 0
for x in range(offset, 720, 60):
draw.line((x, y, x, y + 19), fill=(140, 110, 80))
draw.rectangle((560, 210, 620, 300), fill=(50, 35, 25))
# The floor, in perspective, and a pillar in front of it.
draw.polygon([(0, 300), (720, 300), (720, 480), (0, 480)], fill=(160, 140, 100))
for at in range(0, 720, 40):
draw.line((at, 300, at * 1.6 - 220, 480), fill=(140, 120, 85))
for y in range(300, 480, 30):
draw.line((0, y, 720, y), fill=(140, 120, 85))
draw.rectangle((300, 120, 340, 420), fill=(200, 200, 210), outline=(90, 90, 100))
draw.rectangle((290, 110, 350, 130), fill=(180, 180, 190), outline=(90, 90, 100))
draw.rectangle((290, 410, 350, 430), fill=(180, 180, 190), outline=(90, 90, 100))
img.save(os.path.join(KIT, "yard.png"), optimize=True)
print(" yard.png")
def hall():
img = Image.new("RGB", (720, 480), (60, 50, 70))
draw = ImageDraw.Draw(img)
draw.rectangle((0, 0, 720, 280), fill=(90, 70, 90))
for x in range(0, 720, 90):
draw.rectangle((x + 20, 60, x + 60, 240), fill=(120, 100, 120), outline=(60, 45, 60))
draw.polygon([(0, 280), (720, 280), (720, 480), (0, 480)], fill=(120, 90, 70))
for y in range(280, 480, 25):
draw.line((0, y, 720, y), fill=(100, 75, 60))
draw.rectangle((80, 120, 140, 280), fill=(40, 30, 35))
draw.rectangle((520, 150, 640, 250), fill=(200, 180, 120), outline=(120, 100, 60))
draw.text((535, 190), "the hall", fill=(60, 45, 30))
img.save(os.path.join(KIT, "hall.png"), optimize=True)
print(" hall.png")
# ---- sounds ----
def tone(seconds, pitch, kind="square", pitchEnd=None, fade=True):
samples = []
count = int(RATE * seconds)
for at in range(count):
t = at / RATE
f = pitch if pitchEnd is None else pitch + (pitchEnd - pitch) * at / count
phase = (t * f) % 1.0
if kind == "square":
v = 1.0 if phase < 0.5 else -1.0
elif kind == "saw":
v = phase * 2 - 1
elif kind == "noise":
v = (hash((at * 7919) % 104729) % 2000) / 1000.0 - 1.0
else:
v = math.sin(phase * 2 * math.pi)
gain = (1.0 - at / count) if fade else 1.0
samples.append(v * gain * 0.4)
return samples
def writeWav(name, samples):
with wave.open(os.path.join(KIT, name), "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(RATE)
out.writeframes(b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767)) for s in samples))
print(" " + name)
def sounds():
writeWav("jump.wav", tone(0.25, 300, "square", 900))
writeWav("coin.wav", tone(0.08, 1200, "square", fade=False) + tone(0.18, 1800, "square"))
writeWav("shot.wav", tone(0.15, 0, "noise"))
writeWav("hit.wav", tone(0.3, 200, "saw", 60))
writeWav("click.wav", tone(0.04, 2000, "square"))
writeWav("door.wav", tone(0.4, 120, "saw", 90))
# An eight-second loop: a bass line under an arpeggio, one bar a second.
notes = [220, 262, 330, 262, 196, 247, 294, 247]
loop = []
for bar in range(8):
root = notes[bar]
for step in range(8):
pitch = root * [1, 1.5, 2, 1.5][step % 4]
loop += [a + b for a, b in zip(tone(0.125, pitch, "square", fade=False), tone(0.125, root / 2, "saw", fade=False))]
writeWav("loopSource.wav", [s * 0.5 for s in loop])
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", os.path.join(KIT, "loopSource.wav"), "-c:a", "libvorbis", "-q:a", "3", os.path.join(KIT, "loop.ogg")], check=True)
os.remove(os.path.join(KIT, "loopSource.wav"))
print(" loop.ogg")
# ---- the clip: twelve seconds, 720 x 480 like the overlay, three moments with a clock along
# the bottom. Drawn at 480 x 320 and scaled, which keeps the shapes' numbers simple.
def clip():
work = os.path.join(KIT, "clipFrames")
os.makedirs(work, exist_ok=True)
fps = 24
total = 12 * fps
for frame in range(total):
t = frame / fps
img = Image.new("RGB", (480, 320), (40, 44, 60))
draw = ImageDraw.Draw(img)
draw.rectangle((0, 0, 480, 200), fill=(70, 74, 96))
draw.rectangle((0, 200, 480, 320), fill=(50, 52, 66))
# A door on the left that opens between 3 and 5 seconds.
opening = max(0.0, min(1.0, (t - 3) / 2))
draw.rectangle((60, 60, 160, 200), fill=(20, 20, 28))
draw.rectangle((60, 60, 60 + int(100 * (1 - opening)), 200), fill=(120, 80, 50), outline=(30, 20, 10))
# A target that rises from the floor between 6 and 8 seconds and sinks after 9.
rise = max(0.0, min(1.0, (t - 6) / 2)) - max(0.0, min(1.0, (t - 9) / 1))
if rise > 0:
cy = 260 - int(120 * rise)
for radius, colour in [(30, (230, 60, 60)), (20, (245, 245, 245)), (10, (230, 60, 60))]:
draw.ellipse((300 - radius, cy - radius, 300 + radius, cy + radius), fill=colour)
# A hand reaching in from the right between 9 and 11 seconds.
reach = max(0.0, min(1.0, (t - 9) / 1)) - max(0.0, min(1.0, (t - 11) / 1))
if reach > 0:
x = 480 - int(160 * reach)
draw.rectangle((x, 120, 480, 160), fill=(250, 214, 170))
draw.ellipse((x - 30, 105, x + 30, 175), fill=(250, 214, 170))
# The clock, so a reader can see the second a moment happens.
draw.rectangle((20, 300, 460, 310), outline=(200, 200, 210))
draw.rectangle((20, 300, 20 + int(440 * t / 12), 310), fill=(255, 207, 74))
draw.text((22, 286), "%4.1f s" % t, fill=(230, 230, 240))
img.resize((720, 480), Image.NEAREST).save(os.path.join(work, "f%04d.png" % frame))
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps), "-i", os.path.join(work, "f%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "28", "-g", "24", os.path.join(KIT, "clip.mkv")], check=True)
shutil.rmtree(work)
print(" clip.mkv")
def model():
shutil.copy(os.path.join(MODELS, "Fox.glb"), os.path.join(KIT, "fox.glb"))
print(" fox.glb")
def notes():
with open(os.path.join(KIT, "LICENSE"), "w") as out:
out.write("The Forge tutorial kit.\n\n"
"Everything here except fox.glb was drawn or synthesised by util/forgeKit.py in the\n"
"Singe source tree and is released under CC0: use it in your own games as you like.\n\n"
"fox.glb is the Fox from the Khronos glTF sample models, CC-BY 4.0: model by PixelMannen,\n"
"rigging and animation by tomkranis, glTF conversion by AsoboStudio and scurest.\n"
"A game that ships it should carry this credit.\n")
with open(os.path.join(KIT, "README.txt"), "w") as out:
out.write("The Forge tutorial kit, addressed from a game as Forge/kit/<name>.\n\n"
"hero.png a sprite sheet, 8 columns of 32 x 48, facing right: idle, walk x4, jump, fall, hit\n"
"enemy.png a sheet, 4 columns of 32 x 32, flapping\n"
"tiles.png a tile sheet, 4 columns of 32 x 32: grass, dirt, stone, brick\n"
"coin.png key.png crate.png door.png spike.png ship.png shot.png rock.png target.png stills\n"
"yard.png hall.png painted rooms, 720 x 480, for the adventures\n"
"jump.wav coin.wav shot.wav hit.wav click.wav door.wav clips\n"
"loop.ogg eight seconds of music that loops\n"
"clip.mkv twelve seconds of 720 x 480 video at 24 frames a second: a door opens at 3 s, a target rises at 6 s, a hand reaches at 9 s\n"
"fox.glb an animated model (see LICENSE)\n")
print(" LICENSE, README.txt")
if __name__ == "__main__":
os.makedirs(KIT, exist_ok=True)
print("Forge kit into " + KIT)
for make in [hero, coin, key, crate, door, spike, tiles, enemy, ship, shot, rock, target, yard, hall, sounds, clip, model, notes]:
make()