station/tools/gen_placeholder_assets.py

412 lines
18 KiB
Python

#!/usr/bin/env python3
"""Generates placeholder art and the starter Tiled map.
Run from the repository root: `python3 tools/gen_placeholder_assets.py`.
Requires Pillow. Replace the generated PNGs with real art at any time; the map
(`assets/maps/station.tmx`) can be opened and edited in Tiled.
"""
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "assets"
T = 32
def canvas():
return Image.new("RGBA", (T, T), (0, 0, 0, 0))
def save(img, rel):
path = ASSETS / rel
path.parent.mkdir(parents=True, exist_ok=True)
img.save(path)
# --- Tileset -----------------------------------------------------------------
# (name, color, solid, pattern)
TILES = [
("floor", (120, 124, 130), False, "grid"),
("dark_floor", (80, 84, 92), False, "grid"),
("wall", (60, 66, 80), True, "brick"),
("grass", (70, 140, 60), False, "speckle"),
("water", (40, 90, 170), True, "wave"),
("plating", (150, 150, 140), False, "rivets"),
("dirt", (120, 90, 60), False, "speckle"),
("window", (120, 180, 210), True, "glass"),
]
COLS = 4
def draw_tile(d, ox, oy, color, pattern):
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], fill=color)
dark = tuple(max(0, c - 30) for c in color)
light = tuple(min(255, c + 30) for c in color)
if pattern == "grid":
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=dark)
elif pattern == "brick":
for y in range(0, T, 8):
d.line([ox, oy + y, ox + T - 1, oy + y], fill=dark)
off = 0 if (y // 8) % 2 == 0 else 8
for x in range(off, T, 16):
d.line([ox + x, oy + y, ox + x, oy + y + 7], fill=dark)
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=(30, 32, 40))
elif pattern == "speckle":
for i in range(24):
x = (i * 37) % T
y = (i * 53 + 7) % T
d.point([ox + x, oy + y], fill=light if i % 2 else dark)
elif pattern == "wave":
for y in range(4, T - 4, 8):
for x in range(0, T - 8, 8):
d.arc([ox + x, oy + y, ox + x + 8, oy + y + 4], 180, 360, fill=light)
elif pattern == "rivets":
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=dark)
for x, y in [(3, 3), (T - 4, 3), (3, T - 4), (T - 4, T - 4)]:
d.point([ox + x, oy + y], fill=dark)
elif pattern == "glass":
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=(60, 66, 80), width=3)
d.line([ox + 8, oy + 8, ox + 16, oy + 16], fill=(230, 240, 255))
def gen_tileset():
rows = (len(TILES) + COLS - 1) // COLS
img = Image.new("RGBA", (COLS * T, rows * T), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
for i, (_, color, _, pattern) in enumerate(TILES):
draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern)
save(img, "tilesets/station.png")
for i, (name, _, _, _) in enumerate(TILES):
if name in {"floor", "dark_floor", "plating", "window"}:
x, y = (i % COLS) * T, (i // COLS) * T
save(img.crop((x, y, x + T, y + T)), f"sprites/buildings/{name}.png")
tiles_xml = []
for i, (name, _, solid, _) in enumerate(TILES):
props = f'<property name="name" value="{name}"/>'
if solid:
props += '<property name="solid" type="bool" value="true"/>'
if name in {"floor", "dark_floor", "plating", "wall", "window"}:
props += f'<property name="proto" value="{name}"/>'
layer = "floor" if name in {"floor", "dark_floor", "plating"} else "structure"
props += f'<property name="state.layer" value="&quot;{layer}&quot;"/>'
props += f'<property name="state.health" type="int" value="{300 if name == "wall" else 100}"/>'
props += f'<property name="state.solid" type="bool" value="{str(solid).lower()}"/>'
props += '<property name="state.variant" value="null"/>'
props += '<property name="state.vars" value="{}"/>'
tiles_xml.append(f' <tile id="{i}">\n <properties>{props}</properties>\n </tile>')
tsx = f"""<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="{T}" tileheight="{T}" tilecount="{len(TILES)}" columns="{COLS}">
<image source="station.png" width="{COLS * T}" height="{rows * T}"/>
{chr(10).join(tiles_xml)}
</tileset>
"""
(ASSETS / "tilesets/station.tsx").write_text(tsx)
# --- Sprites -----------------------------------------------------------------
def sprite(rel, *shapes):
img = canvas()
d = ImageDraw.Draw(img)
for shape in shapes:
shape(d)
save(img, rel)
def blob(color):
return lambda d: d.ellipse([7, 9, 25, 25], fill=color, outline=(20, 20, 20))
def plate(color):
return lambda d: (d.rectangle([6, 10, 26, 22], fill=color, outline=(30, 30, 30)),
d.line([8, 12, 24, 12], fill=(230, 230, 230)))
def gen_sprites():
# Items
sprite("sprites/items/iron_ore.png", blob((140, 100, 90)))
sprite("sprites/items/iron_plate.png", plate((170, 175, 185)))
sprite("sprites/items/log.png",
lambda d: d.rectangle([5, 12, 27, 21], fill=(120, 80, 40), outline=(60, 40, 20)),
lambda d: d.ellipse([22, 12, 28, 21], fill=(200, 160, 100)))
sprite("sprites/items/plank.png", plate((190, 140, 80)))
sprite("sprites/items/apple.png",
lambda d: d.ellipse([9, 10, 23, 24], fill=(200, 30, 30), outline=(90, 10, 10)),
lambda d: d.line([16, 6, 16, 11], fill=(80, 50, 20), width=2))
sprite("sprites/items/bread.png",
lambda d: d.rounded_rectangle([6, 11, 26, 23], 5, fill=(210, 160, 90), outline=(120, 80, 30)))
sprite("sprites/items/sandwich.png",
lambda d: d.polygon([(6, 22), (26, 22), (16, 9)], fill=(230, 200, 140), outline=(120, 90, 40)),
lambda d: d.line([9, 19, 23, 19], fill=(60, 170, 60), width=2))
sprite("sprites/items/screwdriver.png",
lambda d: d.line([8, 24, 18, 14], fill=(220, 180, 40), width=4),
lambda d: d.line([18, 14, 25, 7], fill=(190, 190, 200), width=2))
sprite("sprites/items/wrench.png",
lambda d: d.line([9, 23, 21, 11], fill=(170, 170, 180), width=3),
lambda d: d.ellipse([18, 6, 26, 14], outline=(170, 170, 180), width=3))
sprite("sprites/items/medkit.png",
lambda d: d.rectangle([7, 9, 25, 24], fill=(240, 240, 240), outline=(80, 80, 80)),
lambda d: d.rectangle([14, 11, 18, 22], fill=(210, 30, 30)),
lambda d: d.rectangle([10, 15, 22, 18], fill=(210, 30, 30)))
sprite("sprites/items/pickaxe.png",
lambda d: d.line([9, 26, 20, 11], fill=(120, 80, 40), width=3),
lambda d: d.arc([8, 4, 30, 20], 200, 340, fill=(160, 160, 170), width=3))
sprite("sprites/items/axe.png",
lambda d: d.line([9, 26, 20, 8], fill=(120, 80, 40), width=3),
lambda d: d.polygon([(18, 6), (26, 8), (24, 16), (18, 12)], fill=(170, 170, 180)))
# Buildings
def wall(d):
d.rectangle([0, 0, T - 1, T - 1], fill=(95, 100, 115), outline=(40, 44, 52), width=2)
d.line([0, 16, T, 16], fill=(70, 74, 86))
def door(open_):
def f(d):
d.rectangle([0, 0, T - 1, T - 1], fill=(70, 74, 86))
if open_:
d.rectangle([2, 2, 6, T - 3], fill=(150, 160, 70))
d.rectangle([T - 7, 2, T - 3, T - 3], fill=(150, 160, 70))
else:
d.rectangle([2, 2, T - 3, T - 3], fill=(150, 160, 70), outline=(60, 60, 30))
d.line([16, 2, 16, T - 3], fill=(60, 60, 30), width=2)
return f
sprite("sprites/buildings/wall.png", wall)
sprite("sprites/buildings/door_closed.png", door(False))
sprite("sprites/buildings/door_open.png", door(True))
sprite("sprites/buildings/workbench.png",
lambda d: d.rectangle([2, 8, 29, 18], fill=(150, 100, 50), outline=(70, 45, 20)),
lambda d: d.rectangle([4, 18, 7, 28], fill=(110, 70, 30)),
lambda d: d.rectangle([24, 18, 27, 28], fill=(110, 70, 30)),
lambda d: d.line([10, 6, 18, 6], fill=(170, 170, 180), width=2))
furnace_body = lambda d: d.rectangle([3, 3, 28, 29], fill=(90, 85, 85), outline=(40, 35, 35), width=2)
sprite("sprites/buildings/furnace.png", furnace_body,
lambda d: d.rectangle([9, 15, 22, 25], fill=(60, 30, 20), outline=(40, 35, 35)))
sprite("sprites/buildings/furnace_lit.png", furnace_body,
lambda d: d.rectangle([9, 15, 22, 25], fill=(255, 230, 80), outline=(255, 120, 0)),
lambda d: d.polygon([(11, 15), (16, 7), (20, 15)], fill=(255, 160, 40)))
assembler_body = lambda d: d.rectangle([2, 4, 29, 29], fill=(70, 110, 120), outline=(30, 50, 60), width=2)
sprite("sprites/buildings/assembler.png", assembler_body,
lambda d: d.rectangle([8, 10, 23, 20], fill=(30, 40, 45)),
lambda d: d.rectangle([12, 23, 19, 26], fill=(40, 60, 60)))
sprite("sprites/buildings/assembler_active.png", assembler_body,
lambda d: d.rectangle([8, 10, 23, 20], fill=(120, 230, 170)),
lambda d: d.rectangle([12, 23, 19, 26], fill=(90, 255, 120)))
sprite("sprites/buildings/table.png",
lambda d: d.rectangle([2, 8, 29, 16], fill=(170, 170, 180), outline=(80, 80, 90)),
lambda d: d.rectangle([4, 16, 6, 28], fill=(120, 120, 130)),
lambda d: d.rectangle([25, 16, 27, 28], fill=(120, 120, 130)))
sprite("sprites/buildings/vending.png",
lambda d: d.rectangle([5, 1, 26, 30], fill=(40, 90, 160), outline=(20, 40, 80), width=2),
lambda d: d.rectangle([9, 5, 22, 18], fill=(170, 220, 255)),
lambda d: d.rectangle([9, 22, 22, 26], fill=(20, 20, 30)))
sprite("sprites/buildings/tree.png",
lambda d: d.rectangle([14, 20, 18, 30], fill=(100, 70, 40)),
lambda d: d.ellipse([4, 2, 28, 24], fill=(40, 120, 50), outline=(20, 70, 30)))
sprite("sprites/buildings/ore_rock.png",
lambda d: d.polygon([(4, 28), (8, 10), (18, 4), (28, 14), (27, 28)], fill=(110, 105, 100), outline=(50, 50, 50)),
lambda d: d.ellipse([10, 14, 15, 19], fill=(180, 110, 90)),
lambda d: d.ellipse([18, 18, 23, 23], fill=(180, 110, 90)))
def stairs(down):
def f(d):
d.rectangle([0, 0, T - 1, T - 1], fill=(60, 55, 50), outline=(30, 28, 25), width=2)
for i in range(4):
# Steps shrink and darken towards the far end of the flight.
inset = 3 + i * 3 if down else 12 - i * 3
shade = 150 - i * 25 if down else 75 + i * 25
y = 3 + i * 7
d.rectangle([inset, y, T - 1 - inset, y + 5], fill=(shade, shade - 10, shade - 25))
tip, base = (27, 20) if down else (5, 12)
d.polygon([(13, base), (19, base), (16, tip)], fill=(230, 200, 80))
return f
sprite("sprites/buildings/stairs_down.png", stairs(True))
sprite("sprites/buildings/stairs_up.png", stairs(False))
# Characters
sprite("sprites/characters/player.png",
lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)),
lambda d: d.rectangle([9, 14, 23, 25], fill=(60, 80, 160), outline=(30, 40, 80)),
lambda d: d.rectangle([10, 25, 14, 30], fill=(40, 40, 50)),
lambda d: d.rectangle([18, 25, 22, 30], fill=(40, 40, 50)))
# --- UI ----------------------------------------------------------------------
def gen_ui():
# Assembler window background; slot frames match `ui` in assets/data/machines.rsn.
w, h, s = 260, 150, 40
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([0, 0, w - 1, h - 1], radius=10, fill=(38, 52, 60), outline=(90, 140, 150), width=2)
for i in range(0, w, 12):
d.line([i, 2, i, h - 3], fill=(44, 60, 68))
inputs = [(16, 14), (16, 96), (70, 55)]
output = (200, 55)
# Conveyor lines from the inputs to the output.
for x, y in inputs:
d.line([x + s // 2, y + s // 2, 110, 75], fill=(110, 160, 170), width=3)
d.line([110, 75, output[0], 75], fill=(110, 160, 170), width=3)
for x, y in inputs + [output]:
d.rounded_rectangle([x - 4, y - 4, x + s + 3, y + s + 3], radius=6, fill=(24, 32, 38), outline=(140, 200, 210), width=2)
d.polygon([(186, 66), (196, 75), (186, 84)], fill=(140, 200, 210))
save(img, "ui/machines/assembler.png")
# --- Map ---------------------------------------------------------------------
W, H = 40, 28
FLOOR, DARK, WALL, GRASS, WATER, PLATING, DIRT, WINDOW = range(len(TILES))
def gen_map():
ground = [[GRASS] * W for _ in range(H)]
walls = [[None] * W for _ in range(H)]
def room(x0, y0, x1, y1, floor):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
ground[y][x] = floor
if x in (x0, x1) or y in (y0, y1):
walls[y][x] = WALL
for x in range(W):
walls[0][x] = walls[H - 1][x] = WALL
for y in range(H):
walls[y][0] = walls[y][W - 1] = WALL
room(3, 3, 18, 14, FLOOR) # main hall
room(18, 3, 27, 14, PLATING) # workshop
room(3, 14, 12, 21, DARK) # kitchen
for x in (6, 7, 14):
walls[3][x] = WINDOW
# Door openings; the doors themselves are buildings on the object layer.
for x, y in [(18, 8), (8, 14), (10, 14), (10, 3)]:
walls[y][x] = None
walls[14][10] = WALL
for y in range(1, 3):
ground[y][10] = DIRT
for y in range(15, 27):
if walls[y][10] is None and ground[y][10] == GRASS:
ground[y][10] = DIRT
for y in range(18, 24):
for x in range(28, 34):
ground[y][x] = WATER
# Outside path from the north door around to the east.
for x in range(10, 38):
ground[1][x] = DIRT
for y in range(1, 26):
ground[y][37] = DIRT
objects, triggers = MapObjects(), MapObjects()
obj = objects.point
obj("spawn", 10, 8, "arrivals")
obj("building", 18, 8, proto="door")
obj("building", 8, 14, proto="door")
obj("building", 10, 3, proto="door")
obj("building", 20, 5, proto="workbench")
obj("building", 24, 5, proto="furnace")
obj("building", 22, 5, proto="assembler")
obj("building", 5, 5, proto="vending_machine", stock=5)
obj("building", 5, 17, proto="table")
obj("building", 6, 17, proto="table")
obj("item", 6, 18, proto="bread", count=2)
obj("item", 21, 10, proto="screwdriver")
obj("item", 22, 10, proto="wrench")
obj("item", 23, 10, proto="pickaxe")
obj("item", 24, 10, proto="axe")
obj("item", 12, 10, proto="iron_ore", count=6)
obj("item", 14, 6, proto="medkit")
for x, y in [(4, 24), (6, 25), (15, 22), (22, 20), (33, 5), (34, 9), (31, 14)]:
obj("building", x, y, proto="tree")
for x, y in [(30, 4), (33, 11), (35, 15)]:
obj("building", x, y, proto="ore_rock")
obj("item", 25, 22, proto="apple", count=3)
obj("item", 14, 25, proto="log", count=4)
obj("building", 16, 12, proto="stairs_down")
triggers.teleport(16, 12, "stairs_down", "cellar.tmx", "stairs_up")
write_map("station.tmx", W, H, ground, walls, objects, triggers)
def gen_cellar():
w, h = 14, 10
ground = [[DARK] * w for _ in range(h)]
walls = [[WALL if x in (0, w - 1) or y in (0, h - 1) else None for x in range(w)] for y in range(h)]
objects, triggers = MapObjects(), MapObjects()
objects.point("building", 2, 2, proto="stairs_up")
objects.point("building", 11, 2, proto="furnace")
objects.point("building", 11, 7, proto="table")
objects.point("item", 10, 7, proto="iron_ore", count=4)
objects.point("item", 6, 6, proto="log", count=2)
triggers.teleport(2, 2, "stairs_up", "station.tmx", "stairs_down")
write_map("cellar.tmx", w, h, ground, walls, objects, triggers)
class MapObjects:
"""Objects of one object layer; ids are unique per map via the shared counter."""
next_id = 1
def __init__(self):
self.xml = []
def _add(self, cls, name, geometry, props, body=""):
oid = MapObjects.next_id
MapObjects.next_id += 1
prop_xml = ""
if props:
items = []
for k, (typ, v) in props.items():
typ = f' type="{typ}"' if typ else ""
items.append(f' <property name="{k}"{typ} value="{v}"/>')
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>"
self.xml.append(f' <object id="{oid}" name="{name}" class="{cls}" {geometry}>{prop_xml}{body}\n </object>')
def point(self, cls, x, y, name="", **props):
props = {k: ("int" if isinstance(v, int) else None, v) for k, v in props.items()}
self._add(cls, name, f'x="{x * T + T // 2}" y="{y * T + T // 2}"', props, "\n <point/>")
def teleport(self, x, y, name, map_file, target):
props = {"map": ("file", map_file), "target": (None, target)}
self._add("teleport", name, f'x="{x * T}" y="{y * T}" width="{T}" height="{T}"', props)
def write_map(file, w, h, ground, walls, objects, triggers):
def csv(layer):
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
tmx = f"""<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{w}" height="{h}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="5" nextobjectid="{MapObjects.next_id}">
<properties><property name="default_tile" type="int" value="6"/></properties>
<tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="{w}" height="{h}">
<data encoding="csv">
{csv(ground)}
</data>
</layer>
<layer id="2" name="Walls" width="{w}" height="{h}">
<data encoding="csv">
{csv(walls)}
</data>
</layer>
<objectgroup id="3" name="Objects">
{chr(10).join(objects.xml)}
</objectgroup>
<objectgroup id="4" name="Triggers">
{chr(10).join(triggers.xml)}
</objectgroup>
</map>
"""
(ASSETS / "maps").mkdir(parents=True, exist_ok=True)
(ASSETS / "maps" / file).write_text(tmx)
MapObjects.next_id = 1
if __name__ == "__main__":
gen_tileset()
gen_sprites()
gen_ui()
gen_map()
gen_cellar()
print("placeholder assets written to", ASSETS)