Initial commit — procedural blob-schema reference asset lib v0.1.0
Companion implementation to the design discussion at
sporel-meta/docs/design/2026-05-28-autotile-blob-styles-design.md §9.
Ships the S-V2E2-RM-Blob 14-slot schema in four canonical style
profiles, generated procedurally so the asset lib can be rebuilt
from scratch on any platform with Python 3 + Pillow:
Styles (14 base slots each, sub-cell-grid layout):
rectilinear — crisp 90-degree corners (dungeon walls, retro)
diagonal_cut — 45-degree chamfered convex corners (iso-feel)
organic — rounded quarter-arc convex corners (water/moss)
concave — concave-bite convex corners (frost/web)
Material variants (single-material atlases, one tinted set each):
blob_rect_stone — dark blue-gray (#282C38)
blob_rect_grass — mid green (#4A7838)
blob_rect_wood — light brown (#8B6B3F)
Multi-material testbench atlas:
blob_testbench — 3 materials x 14 slots = 42 tiles in one
atlas, for the vagrant-skeleton showcase
Per-style collision.json (axis-aligned rect-list, row+column merged
to minimum form: slot 13 collapses to a single 64x64 rect, slot 4
to a single 32x64 vertical bar, cross variants max 3 rects).
Phase-1: same rectilinear collision shared across all 4 styles,
max 5 px deviation at corners; per-style polygons deferred to
atlas-baker E2.
Reference sheets at docs/reference_sheet_<style>.png show all 14
slots per style in a 7x2 grid with slot index + bitmask + 3x3
neighbour mini-diagram. Color showcase at docs/color_showcase.png
demonstrates that one atlas serves N visual materials via runtime
tint (5 sample colours).
Atlas packer (scripts/atlas_pack.py) is a pure-Python substitute
for sporel-tool-atlas-baker. It produces the Sporel-conformant
4-file atlas set (tiles.atlas.json + tiles.atlas.lock.json +
tiles.diffuse.atlas.png + 1x1 placeholder tiles.height.atlas.png)
with 2 px edge-replicated padding around every tile to prevent
bilinear sampler bleed (sister-tile colours leaking at UV
boundaries — the artefact that caused the green-flicker bug
caught during 0.5.0c integration with vagrant).
Generation pipeline (scripts/bake.sh):
generate.py -> 56 base sprites + 4 collision.json
reference_sheet.py -> 4 reference_sheet PNGs
color_showcase.py -> color_showcase.png
bake_testbench.py -> 42-tile mixed-material atlas
bake_singles.py -> 3 x 14-tile single-material atlases
All output deterministic from the seed scripts (no random state).
.gitignore excludes the baked atlases (assets/atlases/) — those
are reproducible from sources via the bake scripts.
License: CC0 (procedural output, no third-party authorship claim).
Safe for any public-facing build.
Used in production by sporel-module-vagrant-skeleton via the
asset_aliases blob_testbench + blob_rect_stone — see the matching
commit there for the consumer-side integration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
154
scripts/atlas_pack.py
Normal file
154
scripts/atlas_pack.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""atlas_pack.py - minimal pure-Python substitute for sporel-tool-atlas-baker.
|
||||
|
||||
Packs a directory of <name>_diffuse.png sources into the Sporel runtime
|
||||
atlas format expected by lib-core.maps:
|
||||
|
||||
<out_dir>/
|
||||
tiles.atlas.json ← metadata (atlas_id, atlas_size_px, tile_size_px, tiles[])
|
||||
tiles.atlas.lock.json ← name → id binding + next_id
|
||||
tiles.diffuse.atlas.png ← packed RGBA atlas
|
||||
tiles.height.atlas.png ← 1x1 transparent placeholder (engine reads via pcall)
|
||||
|
||||
Tile entries get sequential integer IDs starting at 1, alphabetical by
|
||||
source filename for stable ordering. Each tile may carry walkable +
|
||||
optional `name_to_walkable` overrides via the API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _pad_edge_replicate(sprite: Image.Image, p: int) -> Image.Image:
|
||||
"""Wrap a sprite with `p` pixels of edge-replicated padding on all
|
||||
sides. The 4 edges + 4 corners are filled by stretching a 1-pixel
|
||||
strip outward (NEAREST resampling). This prevents bilinear sampling
|
||||
near tile boundaries from leaking into neighbouring atlas tiles."""
|
||||
if p <= 0:
|
||||
return sprite
|
||||
w, h = sprite.size
|
||||
pw, ph = w + 2 * p, h + 2 * p
|
||||
new = Image.new(sprite.mode, (pw, ph), (0, 0, 0, 0))
|
||||
new.paste(sprite, (p, p))
|
||||
# 4 edges.
|
||||
top = sprite.crop((0, 0, w, 1)).resize((w, p), Image.Resampling.NEAREST)
|
||||
new.paste(top, (p, 0))
|
||||
bot = sprite.crop((0, h - 1, w, h)).resize((w, p), Image.Resampling.NEAREST)
|
||||
new.paste(bot, (p, h + p))
|
||||
left = sprite.crop((0, 0, 1, h)).resize((p, h), Image.Resampling.NEAREST)
|
||||
new.paste(left, (0, p))
|
||||
right = sprite.crop((w - 1, 0, w, h)).resize((p, h), Image.Resampling.NEAREST)
|
||||
new.paste(right, (w + p, p))
|
||||
# 4 corners.
|
||||
for (sx, sy, dx, dy) in [
|
||||
(0, 0, 0, 0),
|
||||
(w - 1, 0, w + p, 0),
|
||||
(0, h - 1, 0, h + p),
|
||||
(w - 1, h - 1, w + p, h + p),
|
||||
]:
|
||||
corner = sprite.crop((sx, sy, sx + 1, sy + 1)).resize((p, p), Image.Resampling.NEAREST)
|
||||
new.paste(corner, (dx, dy))
|
||||
return new
|
||||
|
||||
|
||||
def pack(
|
||||
source_dir: str,
|
||||
out_dir: str,
|
||||
atlas_id: str,
|
||||
tile_size_px: int,
|
||||
walkable_fn: Optional[Callable[[str], bool]] = None,
|
||||
cols: Optional[int] = None,
|
||||
pad_px: int = 2,
|
||||
) -> None:
|
||||
"""Pack all <name>_diffuse.png in source_dir into a single atlas.
|
||||
|
||||
walkable_fn(tile_name) -> bool decides per-tile walkability; defaults
|
||||
to False if not supplied (safe for wall-like tiles, override for
|
||||
floors). cols controls grid width; default = ceil(sqrt(N)).
|
||||
|
||||
pad_px: edge-replicated padding around each tile in the packed
|
||||
atlas. Prevents bilinear-filtering bleed between neighbouring atlas
|
||||
tiles when sprites are rendered near their UV boundary. UV coords
|
||||
in the metadata still point to the inner non-padded region.
|
||||
"""
|
||||
sources = sorted(
|
||||
f for f in os.listdir(source_dir) if f.endswith("_diffuse.png")
|
||||
)
|
||||
if not sources:
|
||||
raise RuntimeError(f"no _diffuse.png files in {source_dir}")
|
||||
|
||||
n = len(sources)
|
||||
if cols is None:
|
||||
import math
|
||||
cols = math.ceil(math.sqrt(n))
|
||||
rows = (n + cols - 1) // cols
|
||||
stride = tile_size_px + 2 * pad_px # per-tile cell stride including padding
|
||||
atlas_w = cols * stride
|
||||
atlas_h = rows * stride
|
||||
|
||||
atlas = Image.new("RGBA", (atlas_w, atlas_h), (0, 0, 0, 0))
|
||||
|
||||
tiles_meta = []
|
||||
bindings = {}
|
||||
|
||||
for idx, fname in enumerate(sources):
|
||||
name = fname[: -len("_diffuse.png")]
|
||||
col = idx % cols
|
||||
row = idx // cols
|
||||
cell_x = col * stride
|
||||
cell_y = row * stride
|
||||
|
||||
sprite = Image.open(os.path.join(source_dir, fname)).convert("RGBA")
|
||||
if sprite.size != (tile_size_px, tile_size_px):
|
||||
sprite = sprite.resize((tile_size_px, tile_size_px), resample=Image.Resampling.NEAREST)
|
||||
padded = _pad_edge_replicate(sprite, pad_px)
|
||||
atlas.paste(padded, (cell_x, cell_y))
|
||||
|
||||
# UV points to the inner non-padded region.
|
||||
uv_x = cell_x + pad_px
|
||||
uv_y = cell_y + pad_px
|
||||
tile_id = idx + 1
|
||||
bindings[name] = tile_id
|
||||
walkable = walkable_fn(name) if walkable_fn else False
|
||||
tiles_meta.append({
|
||||
"id": tile_id,
|
||||
"name": name,
|
||||
"uv": [uv_x, uv_y, tile_size_px, tile_size_px],
|
||||
"walkable": walkable,
|
||||
})
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
atlas.save(os.path.join(out_dir, "tiles.diffuse.atlas.png"))
|
||||
|
||||
# Height placeholder: 1x1 transparent. Engine reads via pcall and tolerates absence,
|
||||
# but shipping a real PNG avoids a confusing "asset load failed" warning in logs.
|
||||
Image.new("RGBA", (1, 1), (0, 0, 0, 0)).save(
|
||||
os.path.join(out_dir, "tiles.height.atlas.png")
|
||||
)
|
||||
|
||||
atlas_json = {
|
||||
"atlas_id": atlas_id,
|
||||
"atlas_version": 1,
|
||||
"atlas_size_px": [atlas_w, atlas_h],
|
||||
"tile_size_px": tile_size_px,
|
||||
"tiles": tiles_meta,
|
||||
}
|
||||
with open(os.path.join(out_dir, "tiles.atlas.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(atlas_json, f, indent=2)
|
||||
|
||||
lock_json = {
|
||||
"atlas_id": atlas_id,
|
||||
"bindings": bindings,
|
||||
"deleted": [],
|
||||
"next_id": n + 1,
|
||||
}
|
||||
with open(os.path.join(out_dir, "tiles.atlas.lock.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(lock_json, f, indent=2)
|
||||
|
||||
print(
|
||||
f" packed {n} tiles ({cols}x{rows} grid, pad={pad_px}px, "
|
||||
f"{atlas_w}x{atlas_h} px) -> {out_dir}"
|
||||
)
|
||||
25
scripts/bake.sh
Normal file
25
scripts/bake.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# bake.sh - regenerate procedural source sprites + reference sheets,
|
||||
# then pack each of the 4 style atlases via sporel-tool-atlas-baker.
|
||||
#
|
||||
# Idempotent: re-running overwrites all generated outputs.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LIB_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BAKER=~/Projects/Sporel/sporel-tool-atlas-baker/bin/atlas-baker.js
|
||||
|
||||
# 1) Regenerate sources for all 4 styles (diffuse PNGs + collision.json)
|
||||
# + the per-style reference sheets + the color showcase.
|
||||
python "$SCRIPT_DIR/generate.py"
|
||||
python "$SCRIPT_DIR/reference_sheet.py"
|
||||
python "$SCRIPT_DIR/color_showcase.py"
|
||||
|
||||
# 2) Pack each style into its own runtime atlas.
|
||||
for style in rectilinear diagonal_cut organic concave; do
|
||||
atlas_id="blob_${style}"
|
||||
node "$BAKER" \
|
||||
--in "$LIB_DIR/assets/_sources/${atlas_id}" \
|
||||
--out "$LIB_DIR/assets/atlases/${atlas_id}" \
|
||||
--atlas-id "${atlas_id}" \
|
||||
--tile-size auto
|
||||
done
|
||||
77
scripts/bake_singles.py
Normal file
77
scripts/bake_singles.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bake_singles.py - bake single-material atlases for vertex-painted
|
||||
autotile layers. Each material is one tinted 14-slot atlas, separate
|
||||
from the mixed-material blob_testbench atlas.
|
||||
|
||||
Atlases produced (under assets/atlases/blob_rect_<material>/):
|
||||
- blob_rect_stone (dark gray, walls/floors)
|
||||
- blob_rect_grass (mid green, organic)
|
||||
- blob_rect_wood (light brown, paths)
|
||||
|
||||
Filenames inside each atlas use the canonical slot_NN_<name> naming so
|
||||
the runtime can build a slot->tile_id index by parsing tile names.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from generate import SLOTS, render_slot, TILE_PX # noqa: E402
|
||||
from atlas_pack import pack # noqa: E402
|
||||
|
||||
MATERIALS = [
|
||||
("stone", (40, 44, 56)),
|
||||
("grass", (74, 120, 56)),
|
||||
("wood", (139, 107, 63)),
|
||||
]
|
||||
|
||||
|
||||
def tint(img: Image.Image, rgb: tuple[int, int, int]) -> Image.Image:
|
||||
"""Replace material RGB while preserving alpha. Caller is responsible
|
||||
for ensuring the input is the neutral source sprite."""
|
||||
out = img.copy()
|
||||
px = out.load()
|
||||
r, g, b = rgb
|
||||
for y in range(out.height):
|
||||
for x in range(out.width):
|
||||
_, _, _, a = px[x, y]
|
||||
if a > 0:
|
||||
px[x, y] = (r, g, b, a)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
lib_dir = os.path.normpath(os.path.join(script_dir, ".."))
|
||||
for material, rgb in MATERIALS:
|
||||
atlas_id = f"blob_rect_{material}"
|
||||
src_dir = os.path.join(lib_dir, "assets", "_sources", atlas_id)
|
||||
out_dir = os.path.join(lib_dir, "assets", "atlases", atlas_id)
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
|
||||
# Emit 14 tinted source PNGs with canonical slot_NN naming.
|
||||
for slot, slot_name, pattern in SLOTS:
|
||||
img = tint(render_slot(pattern, "rectilinear"), rgb)
|
||||
fname = f"slot_{slot:02d}_{slot_name}_diffuse.png"
|
||||
img.save(os.path.join(src_dir, fname))
|
||||
|
||||
# Walkability: slot_00 (isolated) is non-walkable for stone (decorative
|
||||
# pillar); all others material-dependent. Stone = all non-walkable
|
||||
# (walls). Grass/wood = walkable except isolated.
|
||||
def walk_fn(name, _mat=material):
|
||||
if _mat == "stone":
|
||||
return False
|
||||
return "_isolated" not in name
|
||||
|
||||
pack(
|
||||
source_dir=src_dir,
|
||||
out_dir=out_dir,
|
||||
atlas_id=atlas_id,
|
||||
tile_size_px=TILE_PX,
|
||||
walkable_fn=walk_fn,
|
||||
cols=14, # one row
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
scripts/bake_testbench.py
Normal file
83
scripts/bake_testbench.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bake_testbench.py - generate the multi-material testbench atlas.
|
||||
|
||||
The engine doesn't yet implement runtime material-tint (the production
|
||||
architecture from the design paper §11), so for the vagrant-skeleton
|
||||
testbench we pre-bake the rectilinear blob set in N material colours
|
||||
into one combined atlas. 3 materials × 14 slots = 42 tiles.
|
||||
|
||||
Outputs:
|
||||
assets/_sources/blob_testbench/<material>_<slot>_diffuse.png (42 files)
|
||||
assets/atlases/blob_testbench/{tiles.atlas.json, tiles.diffuse.atlas.png,
|
||||
tiles.height.atlas.png, tiles.atlas.lock.json}
|
||||
"""
|
||||
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from generate import SLOTS, render_slot, TILE_PX # noqa: E402
|
||||
from atlas_pack import pack # noqa: E402
|
||||
|
||||
# (material_name, FG tint rgb) — same palette as color_showcase.py.
|
||||
TESTBENCH_MATERIALS = [
|
||||
("stone", (40, 44, 56)),
|
||||
("grass", (74, 120, 56)),
|
||||
("wood", (139, 107, 63)),
|
||||
]
|
||||
|
||||
ATLAS_ID = "blob_testbench"
|
||||
|
||||
|
||||
def tinted(bitmask: int, rgb: tuple[int, int, int]) -> Image.Image:
|
||||
img = render_slot(bitmask, "rectilinear").copy()
|
||||
px = img.load()
|
||||
r, g, b = rgb
|
||||
for y in range(img.height):
|
||||
for x in range(img.width):
|
||||
_, _, _, a = px[x, y]
|
||||
if a > 0:
|
||||
px[x, y] = (r, g, b, a)
|
||||
return img
|
||||
|
||||
|
||||
def is_walkable(name: str) -> bool:
|
||||
"""Stone is non-walkable (walls). Grass + wood are walkable (floor/path).
|
||||
The 'isolated' slot for any material is non-walkable (decorative pillar)."""
|
||||
if name.startswith("stone_"):
|
||||
return False
|
||||
if "_isolated" in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
lib_dir = os.path.normpath(os.path.join(script_dir, ".."))
|
||||
src_dir = os.path.join(lib_dir, "assets", "_sources", ATLAS_ID)
|
||||
out_dir = os.path.join(lib_dir, "assets", "atlases", ATLAS_ID)
|
||||
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
|
||||
# 1) Write the 42 tinted source PNGs.
|
||||
print(f"Writing {len(TESTBENCH_MATERIALS) * len(SLOTS)} tinted sprites -> {src_dir}")
|
||||
for material, rgb in TESTBENCH_MATERIALS:
|
||||
for slot, slot_name, pattern in SLOTS:
|
||||
img = tinted(pattern, rgb)
|
||||
fname = f"{material}_slot_{slot:02d}_{slot_name}_diffuse.png"
|
||||
img.save(os.path.join(src_dir, fname))
|
||||
|
||||
# 2) Pack into the runtime atlas. Use 14 cols x 3 rows so each row is
|
||||
# one material's slot strip (visually navigable when inspecting the
|
||||
# baked PNG directly).
|
||||
pack(
|
||||
source_dir=src_dir,
|
||||
out_dir=out_dir,
|
||||
atlas_id=ATLAS_ID,
|
||||
tile_size_px=TILE_PX,
|
||||
walkable_fn=is_walkable,
|
||||
cols=14,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
133
scripts/color_showcase.py
Normal file
133
scripts/color_showcase.py
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""color_showcase.py - demonstrate runtime-tint architecture by showing
|
||||
the same procedural blob set rendered in N material colours.
|
||||
|
||||
The shipped atlas is one neutral stone-gray. Materials don't fork the
|
||||
atlas — they declare a colour tint and the engine applies it at render
|
||||
time. This script shows what that looks like across a sample of common
|
||||
material colours.
|
||||
|
||||
Output: docs/color_showcase.png
|
||||
"""
|
||||
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from generate import SLOTS, render_slot, TILE_PX # noqa: E402
|
||||
|
||||
# Sample material tints. Each is the "fully filled material" colour;
|
||||
# the engine would multiply the atlas's grayscale alpha against this.
|
||||
PALETTE = [
|
||||
("stone", (40, 44, 56)),
|
||||
("grass", (74, 120, 56)),
|
||||
("earth", (92, 61, 36)),
|
||||
("wood", (139, 107, 63)),
|
||||
("ice", (123, 168, 200)),
|
||||
]
|
||||
|
||||
STYLE = "rectilinear" # showcase uses rectilinear; pattern is identical across styles.
|
||||
|
||||
CELL_W = 80 # 64 sprite + margins
|
||||
CELL_H = 80
|
||||
SPRITE_SCALE = 1
|
||||
LABEL_COL_W = 88
|
||||
HEADER_H = 56
|
||||
|
||||
BG_RGBA = (240, 240, 244, 255)
|
||||
GRID_RGBA = (180, 180, 188, 255)
|
||||
LABEL_RGBA = (24, 24, 28, 255)
|
||||
HEADER_RGBA = (20, 20, 32, 255)
|
||||
|
||||
|
||||
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
||||
for candidate in ("DejaVuSans-Bold.ttf", "arial.ttf", "Arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def tinted_sprite(bitmask: int, rgb: tuple[int, int, int]) -> Image.Image:
|
||||
"""Render the slot in the base style, then replace material colour
|
||||
(preserving alpha)."""
|
||||
sprite = render_slot(bitmask, STYLE).copy()
|
||||
pixels = sprite.load()
|
||||
w, h = sprite.size
|
||||
r, g, b = rgb
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
_, _, _, a = pixels[x, y]
|
||||
if a > 0:
|
||||
pixels[x, y] = (r, g, b, a)
|
||||
return sprite
|
||||
|
||||
|
||||
def main() -> None:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
out_path = os.path.normpath(
|
||||
os.path.join(script_dir, "..", "docs", "color_showcase.png")
|
||||
)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
|
||||
cols = len(SLOTS)
|
||||
rows = len(PALETTE)
|
||||
sheet_w = LABEL_COL_W + cols * CELL_W
|
||||
sheet_h = HEADER_H + rows * CELL_H
|
||||
|
||||
sheet = Image.new("RGBA", (sheet_w, sheet_h), BG_RGBA)
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font_header = load_font(20)
|
||||
font_label = load_font(15)
|
||||
font_small = load_font(11)
|
||||
|
||||
# Header text.
|
||||
draw.text(
|
||||
(12, 14),
|
||||
f"Color showcase - same {STYLE} atlas, {rows} material tints applied at render time",
|
||||
fill=HEADER_RGBA,
|
||||
font=font_header,
|
||||
)
|
||||
|
||||
# Column headers: slot indices.
|
||||
for ci, (slot, name, _) in enumerate(SLOTS):
|
||||
cx0 = LABEL_COL_W + ci * CELL_W
|
||||
draw.text((cx0 + 6, HEADER_H - 18), f"slot {slot:02d}", fill=LABEL_RGBA, font=font_small)
|
||||
|
||||
# Rows.
|
||||
for ri, (mat_name, rgb) in enumerate(PALETTE):
|
||||
ry0 = HEADER_H + ri * CELL_H
|
||||
# Row label cell.
|
||||
draw.rectangle(
|
||||
[0, ry0, LABEL_COL_W - 1, ry0 + CELL_H - 1],
|
||||
fill=(*rgb, 255),
|
||||
outline=GRID_RGBA,
|
||||
)
|
||||
# Pick readable text colour for swatch label.
|
||||
brightness = sum(rgb) / 3
|
||||
text_col = (240, 240, 240, 255) if brightness < 128 else (24, 24, 24, 255)
|
||||
draw.text((8, ry0 + CELL_H // 2 - 10), mat_name, fill=text_col, font=font_label)
|
||||
draw.text(
|
||||
(8, ry0 + CELL_H // 2 + 6),
|
||||
f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}",
|
||||
fill=text_col,
|
||||
font=font_small,
|
||||
)
|
||||
|
||||
for ci, (slot, name, pattern) in enumerate(SLOTS):
|
||||
cx0 = LABEL_COL_W + ci * CELL_W
|
||||
draw.rectangle(
|
||||
[cx0, ry0, cx0 + CELL_W - 1, ry0 + CELL_H - 1],
|
||||
outline=GRID_RGBA,
|
||||
)
|
||||
sprite = tinted_sprite(pattern, rgb)
|
||||
sx = cx0 + (CELL_W - TILE_PX) // 2
|
||||
sy = ry0 + (CELL_H - TILE_PX) // 2
|
||||
sheet.paste(sprite, (sx, sy), sprite)
|
||||
|
||||
sheet.save(out_path)
|
||||
print(f"Wrote {out_path} ({sheet_w}x{sheet_h})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
340
scripts/generate.py
Normal file
340
scripts/generate.py
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""generate.py - procedural sprite generator for the blob-geom prototype lib.
|
||||
|
||||
Emits four parallel 14-slot sprite sets, one per style, covering the
|
||||
S-V2E2-RM-Blob schema at the Sporel default tile-size (64 px).
|
||||
|
||||
Styles share the same filled-sub-cell mask per slot; they differ only in
|
||||
how external convex corners are rendered:
|
||||
|
||||
- rectilinear : leave the 90 deg corner sharp (block letters)
|
||||
- diagonal_cut : chamfer with a 45 deg straight cut (octagonal feel)
|
||||
- organic : round the corner with a quarter-disk arc (rounded-rect feel)
|
||||
- concave : bite a quarter-disk inward at the corner (frost / web feel)
|
||||
|
||||
Output layout (relative to lib root):
|
||||
assets/_sources/blob_<style>/slot_NN_<name>_diffuse.png
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
TILE_PX = 64
|
||||
SUB_PX = TILE_PX // 4 # 16 - sub-cell edge length
|
||||
CORNER_DEPTH = 5 # px - how aggressively each style modifies the corner
|
||||
|
||||
# Slot definitions: (index, name, canonical bitmask).
|
||||
# Bit layout, clockwise from north, LSB-first:
|
||||
# bit 0 = N, 1 = NE, 2 = E, 3 = SE, 4 = S, 5 = SW, 6 = W, 7 = NW
|
||||
SLOTS = [
|
||||
(0, "isolated", 0b00000000),
|
||||
(1, "end", 0b00000001),
|
||||
(2, "corner_open", 0b00000101),
|
||||
(3, "corner_full", 0b00000111),
|
||||
(4, "straight", 0b00010001),
|
||||
(5, "tee_open", 0b00010101),
|
||||
(6, "tee_half", 0b00010111),
|
||||
(7, "tee_full", 0b00011111),
|
||||
(8, "cross_open", 0b01010101),
|
||||
(9, "cross_q1", 0b01010111),
|
||||
(10, "cross_q2adj", 0b01011111),
|
||||
(11, "cross_q2opp", 0b01110111),
|
||||
(12, "cross_q3", 0b01111111),
|
||||
(13, "solid", 0b11111111),
|
||||
]
|
||||
|
||||
STYLES = ("rectilinear", "diagonal_cut", "organic", "concave")
|
||||
|
||||
# Foreground colour for the material region.
|
||||
FG_RGBA = (40, 44, 56, 255)
|
||||
TRANSPARENT = (0, 0, 0, 0)
|
||||
|
||||
|
||||
def has_bit(bits: int, pos: int) -> int:
|
||||
return (bits >> pos) & 1
|
||||
|
||||
|
||||
def compute_mask(bitmask: int) -> list[list[bool]]:
|
||||
"""Return a 4x4 boolean grid indicating which sub-cells are filled
|
||||
for the given neighbour bitmask, under blob-gating."""
|
||||
N, NE, E, SE, S, SW, W, NW = (has_bit(bitmask, i) for i in range(8))
|
||||
|
||||
mask = [[False] * 4 for _ in range(4)]
|
||||
|
||||
# Center 2x2 always filled: this tile has material.
|
||||
for sy in (1, 2):
|
||||
for sx in (1, 2):
|
||||
mask[sy][sx] = True
|
||||
|
||||
# Cardinal edges fill 2 sub-cells along that edge.
|
||||
if N:
|
||||
mask[0][1] = mask[0][2] = True
|
||||
if E:
|
||||
mask[1][3] = mask[2][3] = True
|
||||
if S:
|
||||
mask[3][1] = mask[3][2] = True
|
||||
if W:
|
||||
mask[1][0] = mask[2][0] = True
|
||||
|
||||
# Diagonal corners under blob-gating.
|
||||
if NE and N and E: mask[0][3] = True
|
||||
if SE and S and E: mask[3][3] = True
|
||||
if SW and S and W: mask[3][0] = True
|
||||
if NW and N and W: mask[0][0] = True
|
||||
|
||||
return mask
|
||||
|
||||
|
||||
def _is_filled(mask: list[list[bool]], bitmask: int, cx: int, cy: int) -> bool:
|
||||
"""Is the sub-cell at (cx, cy) filled with material?
|
||||
|
||||
For positions inside this tile (0..3) we consult `mask` directly.
|
||||
For positions outside the tile we consult the neighbour-bitmask: if
|
||||
the corresponding neighbour tile has material at all, the abutting
|
||||
edge is assumed filled seamlessly. This is the standard symmetric
|
||||
blob-tile assumption and is required for slot 13 (fully-surrounded)
|
||||
to render as a flat square, and for slot 4 (straight bar) to
|
||||
continue cleanly into its north/south neighbours.
|
||||
"""
|
||||
if 0 <= cx < 4 and 0 <= cy < 4:
|
||||
return mask[cy][cx]
|
||||
N = has_bit(bitmask, 0)
|
||||
NE = has_bit(bitmask, 1)
|
||||
E = has_bit(bitmask, 2)
|
||||
SE = has_bit(bitmask, 3)
|
||||
S = has_bit(bitmask, 4)
|
||||
SW = has_bit(bitmask, 5)
|
||||
W = has_bit(bitmask, 6)
|
||||
NW = has_bit(bitmask, 7)
|
||||
# Diagonal neighbours: respect blob-gating (only material if both
|
||||
# adjacent cardinals are also set).
|
||||
if cy < 0 and cx < 0: return bool(NW and N and W)
|
||||
if cy < 0 and cx >= 4: return bool(NE and N and E)
|
||||
if cy >= 4 and cx < 0: return bool(SW and S and W)
|
||||
if cy >= 4 and cx >= 4: return bool(SE and S and E)
|
||||
if cy < 0: return bool(N)
|
||||
if cy >= 4: return bool(S)
|
||||
if cx < 0: return bool(W)
|
||||
if cx >= 4: return bool(E)
|
||||
return False
|
||||
|
||||
|
||||
def _apply_corner(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
corner_x: int,
|
||||
corner_y: int,
|
||||
inx: int, # +1 or -1: x direction "into the cell" from corner
|
||||
iny: int, # +1 or -1: y direction "into the cell" from corner
|
||||
style: str,
|
||||
depth: int,
|
||||
) -> None:
|
||||
"""Modify the rendered image at a convex external corner of the
|
||||
material region, per the chosen style."""
|
||||
if style == "rectilinear":
|
||||
return
|
||||
|
||||
if style == "diagonal_cut":
|
||||
# Erase a right triangle whose right-angle is at (corner_x, corner_y).
|
||||
pts = [
|
||||
(corner_x, corner_y),
|
||||
(corner_x + inx * depth, corner_y),
|
||||
(corner_x, corner_y + iny * depth),
|
||||
]
|
||||
draw.polygon(pts, fill=TRANSPARENT)
|
||||
return
|
||||
|
||||
if style == "organic":
|
||||
# Rounded-rectangle corner. Erase the depth x depth square at the
|
||||
# corner, then paint back a quarter-disk centered on the inside
|
||||
# corner of that square so the boundary becomes a smooth arc.
|
||||
x0 = min(corner_x, corner_x + inx * depth)
|
||||
y0 = min(corner_y, corner_y + iny * depth)
|
||||
x1 = max(corner_x, corner_x + inx * depth)
|
||||
y1 = max(corner_y, corner_y + iny * depth)
|
||||
draw.rectangle([x0, y0, x1 - 1, y1 - 1], fill=TRANSPARENT)
|
||||
cx_c = corner_x + inx * depth
|
||||
cy_c = corner_y + iny * depth
|
||||
bbox = [cx_c - depth, cy_c - depth, cx_c + depth, cy_c + depth]
|
||||
# Pieslice angles run clockwise from 3-o'clock (east).
|
||||
# Pick the quadrant whose interior points TOWARD the original corner.
|
||||
if inx > 0 and iny > 0:
|
||||
start, end = 180, 270
|
||||
elif inx < 0 and iny > 0:
|
||||
start, end = 270, 360
|
||||
elif inx < 0 and iny < 0:
|
||||
start, end = 0, 90
|
||||
else: # inx > 0 and iny < 0
|
||||
start, end = 90, 180
|
||||
draw.pieslice(bbox, start, end, fill=FG_RGBA)
|
||||
return
|
||||
|
||||
if style == "concave":
|
||||
# Bite a quarter-disk INTO the material, centered on the corner
|
||||
# itself. Boundary at the corner becomes a concave arc.
|
||||
bbox = [
|
||||
corner_x - depth,
|
||||
corner_y - depth,
|
||||
corner_x + depth,
|
||||
corner_y + depth,
|
||||
]
|
||||
# Quadrant pointing INTO the cell (into the material).
|
||||
if inx > 0 and iny > 0:
|
||||
start, end = 0, 90
|
||||
elif inx < 0 and iny > 0:
|
||||
start, end = 90, 180
|
||||
elif inx < 0 and iny < 0:
|
||||
start, end = 180, 270
|
||||
else: # inx > 0 and iny < 0
|
||||
start, end = 270, 360
|
||||
draw.pieslice(bbox, start, end, fill=TRANSPARENT)
|
||||
return
|
||||
|
||||
raise ValueError(f"unknown style: {style!r}")
|
||||
|
||||
|
||||
def render_slot(bitmask: int, style: str = "rectilinear") -> Image.Image:
|
||||
"""Render one slot into a fresh TILE_PX x TILE_PX RGBA image."""
|
||||
img = Image.new("RGBA", (TILE_PX, TILE_PX), TRANSPARENT)
|
||||
draw = ImageDraw.Draw(img)
|
||||
mask = compute_mask(bitmask)
|
||||
|
||||
# Base pass: every filled sub-cell as a solid square.
|
||||
for cy in range(4):
|
||||
for cx in range(4):
|
||||
if not mask[cy][cx]:
|
||||
continue
|
||||
x0, y0 = cx * SUB_PX, cy * SUB_PX
|
||||
draw.rectangle([x0, y0, x0 + SUB_PX - 1, y0 + SUB_PX - 1], fill=FG_RGBA)
|
||||
|
||||
if style == "rectilinear":
|
||||
return img
|
||||
|
||||
# Corner pass: detect external convex corners of the filled region
|
||||
# and modify each per style.
|
||||
for cy in range(4):
|
||||
for cx in range(4):
|
||||
if not mask[cy][cx]:
|
||||
continue
|
||||
for dx, dy in ((0, 0), (1, 0), (0, 1), (1, 1)):
|
||||
# outx/outy: direction pointing OUT of the cell at this corner.
|
||||
outx = 2 * dx - 1
|
||||
outy = 2 * dy - 1
|
||||
# Three neighbours touching this corner from outside the cell.
|
||||
diag_filled = _is_filled(mask, bitmask, cx + outx, cy + outy)
|
||||
cardx_filled = _is_filled(mask, bitmask, cx + outx, cy)
|
||||
cardy_filled = _is_filled(mask, bitmask, cx, cy + outy)
|
||||
if diag_filled or cardx_filled or cardy_filled:
|
||||
continue # not a convex external corner
|
||||
corner_x = (cx + dx) * SUB_PX
|
||||
corner_y = (cy + dy) * SUB_PX
|
||||
_apply_corner(draw, corner_x, corner_y, -outx, -outy, style, CORNER_DEPTH)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def collision_rects(bitmask: int) -> list[list[int]]:
|
||||
"""Compute axis-aligned collision rectangles for a slot's filled
|
||||
region. Adjacent filled sub-cells on the same row are merged into
|
||||
a single rectangle for compactness. Slot 13 thus collapses to a
|
||||
single 64x64 rect; slot 0 to a single 32x32 rect.
|
||||
|
||||
Output: list of [x, y, w, h] in tile-local pixels. Geometry matches
|
||||
the rectilinear visual exactly; for diagonal_cut/organic/concave
|
||||
visuals the boundary deviates by at most CORNER_DEPTH px at convex
|
||||
external corners (acceptable for typical gameplay collision; modders
|
||||
needing pixel-perfect physics ship per-style overrides).
|
||||
"""
|
||||
mask = compute_mask(bitmask)
|
||||
# Pass 1: row-wise run-length merge.
|
||||
rects: list[list[int]] = []
|
||||
for cy in range(4):
|
||||
run_start: int | None = None
|
||||
for cx in range(4):
|
||||
if mask[cy][cx]:
|
||||
if run_start is None:
|
||||
run_start = cx
|
||||
else:
|
||||
if run_start is not None:
|
||||
rects.append(
|
||||
[run_start * SUB_PX, cy * SUB_PX, (cx - run_start) * SUB_PX, SUB_PX]
|
||||
)
|
||||
run_start = None
|
||||
if run_start is not None:
|
||||
rects.append(
|
||||
[run_start * SUB_PX, cy * SUB_PX, (4 - run_start) * SUB_PX, SUB_PX]
|
||||
)
|
||||
# Pass 2: stack vertically-adjacent rectangles with matching x and w.
|
||||
merged: list[list[int]] = []
|
||||
for r in rects:
|
||||
x, y, w, h = r
|
||||
absorbed = False
|
||||
for m in merged:
|
||||
mx, my, mw, mh = m
|
||||
if mx == x and mw == w and my + mh == y:
|
||||
m[3] = mh + h
|
||||
absorbed = True
|
||||
break
|
||||
if not absorbed:
|
||||
merged.append(list(r))
|
||||
return merged
|
||||
|
||||
|
||||
def build_collision_doc() -> dict:
|
||||
"""Produce the collision.json content shared across all styles."""
|
||||
return {
|
||||
"schema": "blob-14",
|
||||
"tile_size_px": TILE_PX,
|
||||
"sub_cell_px": SUB_PX,
|
||||
"shape_kind": "rect_list",
|
||||
"_note": (
|
||||
"Collision matches the rectilinear visual exactly. For "
|
||||
"diagonal_cut/organic/concave styles, the visible boundary "
|
||||
f"deviates by up to {CORNER_DEPTH} px at convex external "
|
||||
"corners — acceptable for typical gameplay collision. "
|
||||
"Modders needing pixel-perfect physics ship per-style "
|
||||
"polygon overrides."
|
||||
),
|
||||
"slots": [
|
||||
{
|
||||
"slot": slot,
|
||||
"name": name,
|
||||
"bitmask": f"0b{pattern:08b}",
|
||||
"rects": collision_rects(pattern),
|
||||
}
|
||||
for slot, name, pattern in SLOTS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_style(style: str, out_root: str) -> str:
|
||||
"""Render and write all 14 slots for one style, plus the shared
|
||||
collision.json. Returns the output directory."""
|
||||
out_dir = os.path.join(out_root, f"blob_{style}")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for slot, name, pattern in SLOTS:
|
||||
img = render_slot(pattern, style)
|
||||
fname = f"slot_{slot:02d}_{name}_diffuse.png"
|
||||
img.save(os.path.join(out_dir, fname))
|
||||
collision_doc = build_collision_doc()
|
||||
collision_doc["style"] = style
|
||||
with open(os.path.join(out_dir, "collision.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(collision_doc, f, indent=2)
|
||||
return out_dir
|
||||
|
||||
|
||||
def main(styles: Iterable[str] = STYLES) -> None:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
out_root = os.path.normpath(
|
||||
os.path.join(script_dir, "..", "assets", "_sources")
|
||||
)
|
||||
os.makedirs(out_root, exist_ok=True)
|
||||
for style in styles:
|
||||
out_dir = write_style(style, out_root)
|
||||
print(f" wrote {len(SLOTS)} sprites -> {out_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
scripts/reference_sheet.py
Normal file
147
scripts/reference_sheet.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""reference_sheet.py - assemble all 14 generated slot sprites into one
|
||||
labelled overview PNG per style. Used as artist-facing documentation
|
||||
embedded in the lib README.
|
||||
|
||||
Output: docs/reference_sheet_<style>.png (one file per style)
|
||||
"""
|
||||
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from generate import SLOTS, STYLES, render_slot, TILE_PX # noqa: E402
|
||||
|
||||
# Per-cell layout (px).
|
||||
CELL_W = 192
|
||||
CELL_H = 216
|
||||
PAD = 12
|
||||
SPRITE_SCALE = 2 # 64 -> 128 px
|
||||
MINI_PX = 36 # 3x3 mini-diagram cell size
|
||||
|
||||
# Grid layout: 7 wide x 2 tall = 14 cells exactly.
|
||||
COLS = 7
|
||||
ROWS = 2
|
||||
HEADER_H = 48
|
||||
SHEET_W = COLS * CELL_W
|
||||
SHEET_H = HEADER_H + ROWS * CELL_H
|
||||
|
||||
BG_RGBA = (240, 240, 244, 255)
|
||||
GRID_RGBA = (180, 180, 188, 255)
|
||||
LABEL_RGBA = (24, 24, 28, 255)
|
||||
HEADER_RGBA = (20, 20, 32, 255)
|
||||
MINI_FILLED = (40, 44, 56, 255)
|
||||
MINI_EMPTY = (210, 210, 218, 255)
|
||||
MINI_CENTER = (88, 88, 96, 255)
|
||||
|
||||
|
||||
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
||||
for candidate in ("DejaVuSans-Bold.ttf", "arial.ttf", "Arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_mini(draw: ImageDraw.ImageDraw, x: int, y: int, bitmask: int) -> None:
|
||||
"""Draw a 3x3 neighbour mini-diagram at (x, y)."""
|
||||
cell = MINI_PX // 3
|
||||
layout = [
|
||||
(0, 0, 7), # NW
|
||||
(1, 0, 0), # N
|
||||
(2, 0, 1), # NE
|
||||
(0, 1, 6), # W
|
||||
(2, 1, 2), # E
|
||||
(0, 2, 5), # SW
|
||||
(1, 2, 4), # S
|
||||
(2, 2, 3), # SE
|
||||
]
|
||||
draw.rectangle([x, y, x + MINI_PX, y + MINI_PX], fill=MINI_EMPTY)
|
||||
for cx, cy, bit in layout:
|
||||
if (bitmask >> bit) & 1:
|
||||
x0 = x + cx * cell
|
||||
y0 = y + cy * cell
|
||||
draw.rectangle([x0, y0, x0 + cell - 1, y0 + cell - 1], fill=MINI_FILLED)
|
||||
cx0 = x + cell
|
||||
cy0 = y + cell
|
||||
draw.rectangle([cx0, cy0, cx0 + cell - 1, cy0 + cell - 1], fill=MINI_CENTER)
|
||||
draw.rectangle([x, y, x + MINI_PX, y + MINI_PX], outline=LABEL_RGBA)
|
||||
|
||||
|
||||
def render_sheet(style: str, out_path: str) -> None:
|
||||
sheet = Image.new("RGBA", (SHEET_W, SHEET_H), BG_RGBA)
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font_big = load_font(28)
|
||||
font_mid = load_font(16)
|
||||
font_small = load_font(13)
|
||||
font_header = load_font(22)
|
||||
|
||||
# Header.
|
||||
draw.text(
|
||||
(PAD, PAD),
|
||||
f"S-V2E2-RM-Blob, 14 slots, style: {style}",
|
||||
fill=HEADER_RGBA,
|
||||
font=font_header,
|
||||
)
|
||||
|
||||
for i, (slot, name, pattern) in enumerate(SLOTS):
|
||||
col = i % COLS
|
||||
row = i // COLS
|
||||
cx0 = col * CELL_W
|
||||
cy0 = HEADER_H + row * CELL_H
|
||||
|
||||
draw.rectangle([cx0, cy0, cx0 + CELL_W - 1, cy0 + CELL_H - 1], outline=GRID_RGBA)
|
||||
draw.text((cx0 + PAD, cy0 + PAD - 4), f"{slot:02d}", fill=LABEL_RGBA, font=font_big)
|
||||
draw.text((cx0 + PAD + 56, cy0 + PAD + 2), name, fill=LABEL_RGBA, font=font_mid)
|
||||
draw.text(
|
||||
(cx0 + PAD + 56, cy0 + PAD + 22),
|
||||
f"0b{pattern:08b}",
|
||||
fill=LABEL_RGBA,
|
||||
font=font_small,
|
||||
)
|
||||
|
||||
sprite = render_slot(pattern, style)
|
||||
sprite_big = sprite.resize(
|
||||
(TILE_PX * SPRITE_SCALE, TILE_PX * SPRITE_SCALE),
|
||||
resample=Image.Resampling.NEAREST,
|
||||
)
|
||||
sx = cx0 + (CELL_W - sprite_big.width) // 2
|
||||
sy = cy0 + PAD + 50
|
||||
cb = 8
|
||||
for ix in range(sprite_big.width // cb):
|
||||
for iy in range(sprite_big.height // cb):
|
||||
if (ix + iy) & 1:
|
||||
draw.rectangle(
|
||||
[
|
||||
sx + ix * cb,
|
||||
sy + iy * cb,
|
||||
sx + (ix + 1) * cb - 1,
|
||||
sy + (iy + 1) * cb - 1,
|
||||
],
|
||||
fill=(225, 225, 232, 255),
|
||||
)
|
||||
sheet.paste(sprite_big, (sx, sy), sprite_big)
|
||||
draw.rectangle(
|
||||
[sx, sy, sx + sprite_big.width, sy + sprite_big.height],
|
||||
outline=GRID_RGBA,
|
||||
)
|
||||
|
||||
mx = cx0 + CELL_W - MINI_PX - PAD
|
||||
my = cy0 + CELL_H - MINI_PX - PAD
|
||||
draw_mini(draw, mx, my, pattern)
|
||||
|
||||
sheet.save(out_path)
|
||||
print(f" wrote {out_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
docs_dir = os.path.normpath(os.path.join(script_dir, "..", "docs"))
|
||||
os.makedirs(docs_dir, exist_ok=True)
|
||||
for style in STYLES:
|
||||
out_path = os.path.join(docs_dir, f"reference_sheet_{style}.png")
|
||||
render_sheet(style, out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user