315 lines
13 KiB
Python
315 lines
13 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")
|
|
|
|
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"/>'
|
|
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)))
|
|
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)))
|
|
|
|
# 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)))
|
|
|
|
|
|
# --- 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
|
|
|
|
def csv(layer):
|
|
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
|
|
|
|
objects = []
|
|
next_id = [1]
|
|
|
|
def obj(cls, x, y, name="", **props):
|
|
oid = next_id[0]
|
|
next_id[0] += 1
|
|
prop_xml = ""
|
|
if props:
|
|
items = []
|
|
for k, v in props.items():
|
|
typ = ' type="int"' if isinstance(v, int) else ""
|
|
items.append(f' <property name="{k}"{typ} value="{v}"/>')
|
|
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>\n "
|
|
objects.append(
|
|
f' <object id="{oid}" name="{name}" class="{cls}" x="{x * T + T // 2}" y="{y * T + T // 2}">{prop_xml}<point/>\n </object>'
|
|
)
|
|
|
|
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", 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)
|
|
|
|
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="4" nextobjectid="{next_id[0]}">
|
|
<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)}
|
|
</objectgroup>
|
|
</map>
|
|
"""
|
|
(ASSETS / "maps").mkdir(parents=True, exist_ok=True)
|
|
(ASSETS / "maps/station.tmx").write_text(tmx)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
gen_tileset()
|
|
gen_sprites()
|
|
gen_map()
|
|
print("placeholder assets written to", ASSETS)
|