371 lines
16 KiB
Python
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()
|