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