#!/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'' if solid: props += '' if name in {"floor", "dark_floor", "plating", "wall", "window"}: props += f'' layer = "floor" if name in {"floor", "dark_floor", "plating"} else "structure" props += f'' props += f'' props += f'' props += '' props += '' tiles_xml.append(f' \n {props}\n ') tsx = f""" {chr(10).join(tiles_xml)} """ (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' ') prop_xml = "\n \n" + "\n".join(items) + "\n " self.xml.append(f' {prop_xml}{body}\n ') 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 ") 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""" {csv(ground)} {csv(walls)} {chr(10).join(objects.xml)} {chr(10).join(triggers.xml)} """ (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)