singe/util/learnKit.py

174 lines
5.7 KiB
Python

# Draws and synthesises the art kit the beginner's course uses, into docs/learn/art.
#
# A tutorial cannot say "find a picture of a spaceship": the pictures have to be there, named the
# same on every machine, or half the readers stop at lesson nine. Everything here is drawn from
# shapes rather than painted, so it is ours, it is tiny, and it can be regenerated. The engine
# already gives every reader a video, two models, a font, and a click (the Singe folder it unpacks
# on the first run), so this kit is only the things a 2D game needs: a ship, a rock, a shot, a
# star, one walk cycle, and two noises; and one small database, the stages lesson twenty-nine's
# game reads.
#
# Usage: python3 util/learnKit.py
import math
import os
import sqlite3
import struct
import wave
from PIL import Image, ImageDraw
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(REPO, "docs", "learn", "art")
RATE = 22050
def blank(width, height):
return Image.new("RGBA", (width, height), (0, 0, 0, 0))
def save(image, name):
path = os.path.join(OUT, name)
image.save(path)
print("%s %dx%d" % (name, image.width, image.height))
# A ship pointing up: a hull, a nose, and two fins, so it reads at 32 pixels.
def ship():
image = blank(32, 24)
draw = ImageDraw.Draw(image)
draw.polygon([(16, 0), (23, 17), (9, 17)], fill=(120, 200, 255, 255))
draw.polygon([(16, 3), (20, 16), (12, 16)], fill=(230, 245, 255, 255))
draw.polygon([(9, 12), (2, 22), (9, 20)], fill=(70, 130, 200, 255))
draw.polygon([(23, 12), (30, 22), (23, 20)], fill=(70, 130, 200, 255))
draw.rectangle([13, 17, 18, 21], fill=(255, 170, 60, 255))
draw.rectangle([14, 19, 17, 23], fill=(255, 230, 120, 255))
return image
# A rock: an uneven lump with a couple of craters, so a rotation would not be missed.
def rock():
image = blank(24, 24)
draw = ImageDraw.Draw(image)
points = [(12, 1), (19, 4), (23, 11), (20, 19), (13, 23), (5, 21), (1, 13), (3, 5)]
draw.polygon(points, fill=(150, 140, 130, 255))
draw.polygon([(12, 3), (17, 6), (19, 11), (16, 17), (11, 20), (6, 18), (4, 12), (6, 6)],
fill=(185, 175, 165, 255))
draw.ellipse([8, 7, 12, 11], fill=(140, 130, 120, 255))
draw.ellipse([14, 13, 17, 16], fill=(140, 130, 120, 255))
return image
def shot():
image = blank(4, 10)
draw = ImageDraw.Draw(image)
draw.rectangle([0, 2, 3, 9], fill=(255, 210, 90, 255))
draw.rectangle([1, 0, 2, 9], fill=(255, 255, 220, 255))
return image
def star():
image = blank(8, 8)
draw = ImageDraw.Draw(image)
draw.line([4, 0, 4, 7], fill=(255, 240, 160, 255))
draw.line([0, 4, 7, 4], fill=(255, 240, 160, 255))
draw.point([(3, 3), (5, 3), (3, 5), (5, 5)], fill=(255, 210, 90, 255))
return image
# Four frames of a walk, side by side in one picture: the sheet the frames lesson steps through.
# The legs swing, the arms swing the other way, and frames two and four differ so the cycle reads.
def walk():
image = blank(24 * 4, 32)
draw = ImageDraw.Draw(image)
swing = [(-5, 5), (0, 0), (5, -5), (0, 0)]
for frame in range(4):
x = frame * 24 + 12
lead = swing[frame][0]
back = swing[frame][1]
bob = 1 if frame % 2 == 0 else 0
draw.ellipse([x - 4, 3 + bob, x + 4, 11 + bob], fill=(245, 205, 160, 255))
draw.rectangle([x - 4, 12 + bob, x + 4, 22 + bob], fill=(80, 130, 200, 255))
draw.line([x - 4, 13 + bob, x - 4 - abs(back), 19 + bob], fill=(245, 205, 160, 255), width=2)
draw.line([x + 4, 13 + bob, x + 4 + abs(lead), 19 + bob], fill=(245, 205, 160, 255), width=2)
draw.line([x - 1, 22 + bob, x - 1 + back, 30], fill=(60, 60, 90, 255), width=3)
draw.line([x + 1, 22 + bob, x + 1 + lead, 30], fill=(40, 40, 70, 255), width=3)
return image
# A noise, written straight out as a 16 bit mono WAV: no library, and small enough to read.
def tone(name, seconds, shape):
frames = int(RATE * seconds)
data = bytearray()
for i in range(frames):
t = i / RATE
value = shape(t, t / seconds)
value = max(-1.0, min(1.0, value))
data += struct.pack("<h", int(value * 32000))
path = os.path.join(OUT, name)
with wave.open(path, "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(RATE)
out.writeframes(bytes(data))
print("%s %.2f seconds" % (name, seconds))
# A shot: a tone sliding down fast, which is the oldest arcade sound there is.
def shootShape(t, done):
pitch = 900.0 - 600.0 * done
return math.sin(2.0 * math.pi * pitch * t) * (1.0 - done) ** 2
# A bang: noise shaped by a falling envelope, with a low thump under it.
def boomShape(t, done):
seed = int(t * RATE)
noise = (((seed * 1103515245 + 12345) >> 16) % 2000 - 1000) / 1000.0
return (noise * 0.8 + math.sin(2.0 * math.pi * 70.0 * t) * 0.5) * (1.0 - done) ** 3
# The stages of lesson twenty-nine's reaction test, as the database a game would ship: a name and
# the shortest and longest wait before "NOW!", in milliseconds. Written fresh each time, so the
# file is the same on every machine.
STAGES = [
(1, "Easy", 1000, 3000),
(2, "Patient", 3000, 6000),
(3, "Twitchy", 300, 1200),
(4, "Cruel", 200, 8000),
]
def stages():
path = os.path.join(OUT, "stages.db")
if os.path.exists(path):
os.remove(path)
db = sqlite3.connect(path)
db.execute("CREATE TABLE stages (id INTEGER PRIMARY KEY, name TEXT NOT NULL, waitMin INTEGER NOT NULL, waitMax INTEGER NOT NULL)")
db.executemany("INSERT INTO stages VALUES (?, ?, ?, ?)", STAGES)
db.commit()
db.execute("VACUUM")
db.close()
print("stages.db %d stages" % len(STAGES))
def main():
os.makedirs(OUT, exist_ok=True)
save(ship(), "spaceship.png")
save(rock(), "asteroid.png")
save(shot(), "bullet.png")
save(star(), "star.png")
save(walk(), "walk.png")
tone("shoot.wav", 0.18, shootShape)
tone("boom.wav", 0.45, boomShape)
stages()
if __name__ == "__main__":
main()