mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:40:50 +02:00
initial commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build voxel models for the overworld sprites (voxel world mode, Tier 3).
|
||||
|
||||
An AUTHORING tool, run by hand against a machine that has imported its own
|
||||
ROM. It reads the extracted overworld sprite sheets out of the local cache
|
||||
and emits, per sheet, the mod's own assets/voxels/<name>.lua -- a face list
|
||||
the runtime (lib/VoxelModels.lua) turns into a LOVE Mesh.
|
||||
|
||||
The emitted model is geometry only: it names no sheet and bakes no pixel,
|
||||
so what ships is the mod's own carving, not cache content (MK301). The
|
||||
runtime pairs a model with the LIVE sheet the engine already handed it.
|
||||
|
||||
Core idea
|
||||
---------
|
||||
A GB overworld sheet gives THREE orthographic silhouettes of one character:
|
||||
facing down (front), facing up (back), facing left (side; right is the
|
||||
mirror). Three orthogonal views is exactly what visual-hull / space carving
|
||||
needs:
|
||||
|
||||
solid(x, y, z) = (front[y, x] OR back'[y, x]) AND side[y, z]
|
||||
|
||||
where back' is the back view mirrored horizontally so it registers with the
|
||||
front (walk behind someone and left/right swap).
|
||||
|
||||
Note the OR. The front and back constrain the SAME axis -- they are two
|
||||
observations of one [x, y] outline -- so ANDing them erodes the model
|
||||
wherever the two frames disagree, and GB walk art disagrees often (Seel's
|
||||
back frame splays its flippers where the front frame has body). Measured
|
||||
over all 67 sheets, the union is never worse on reprojection IoU and better
|
||||
on 16, and it fails toward "slightly fat" rather than "chunks missing",
|
||||
which is the right failure mode for voxel art: 51 sheets whose two frames
|
||||
agree exactly are bit-identical either way. Only the orthogonal side view
|
||||
actually carves depth, and it still ANDs. `--silhouette intersect` restores
|
||||
the strict three-way hull.
|
||||
|
||||
Carving grid (sprite-pixel aligned):
|
||||
x = front-sprite column y = sprite row, 0 = top
|
||||
z = side-sprite column, 0 = the character's front (nose / hat brim)
|
||||
|
||||
Faces are exported in MODEL space, which is the runtime's world convention
|
||||
(X = map east, Y = up, Z = map south) with the character facing +Z (south,
|
||||
"down") at rest, so the runtime only has to yaw by facing:
|
||||
|
||||
mx = x my = 15 - y mz = 15 - z
|
||||
|
||||
Texturing
|
||||
---------
|
||||
A face does NOT carry a baked color. It carries the sheet pixel (u, v) of
|
||||
the view it faces, so the runtime samples the LIVE sprite sheet image. That
|
||||
is what makes RED++ OBJ-palette recolors (SpriteRenderer's OBP bake) and
|
||||
mod sprite replacements color the voxel model for free, with no rebuild.
|
||||
|
||||
Pipeline
|
||||
--------
|
||||
1. decode PNG (RGBA, GB 4-shade) or raw 2bpp -> shade indices 0..3
|
||||
2. alpha the PNG alpha channel when the source has one (extracted
|
||||
sheets key GB OBJ color 0 to alpha 0, matching the hardware
|
||||
rule that OBJ palette index 0 is always transparent); raw
|
||||
2bpp has no alpha, so it falls back to flood-filling shade 0
|
||||
in from the frame border
|
||||
3. anchor bottom-center align each view's opaque bbox (feet on ground)
|
||||
so views authored with different padding still register
|
||||
4. carve intersect the three extruded silhouettes
|
||||
5. clean keep the largest 6-connected component (kills carve speckle)
|
||||
6. faces emit every exposed voxel face with the sheet pixel it samples
|
||||
7. export <name>.lua (+ optional .vox / .ply for authoring in
|
||||
MagicaVoxel / Blender)
|
||||
8. validate re-project the hull along each axis, report IoU against the
|
||||
source silhouettes -- the hull can only shrink, so IoU < 1
|
||||
flags real loss (misregistered views, over-carving)
|
||||
|
||||
Poses
|
||||
-----
|
||||
6-frame sheets (16x96) carry stand down/up/left in frames 0,1,2 and walk
|
||||
down/up/left in 3,4,5 -> a "stand" and a "walk" model.
|
||||
3-frame sheets (16x48) are stand-only.
|
||||
1-frame sheets (16x16, props: boulder / fossil / clipboard) have no other
|
||||
view at all, so the side silhouette is the mirrored front -- which carves
|
||||
the symmetric solid a prop reads as.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 tools/build_voxels.py # whole sprite dir
|
||||
python3 tools/build_voxels.py --only red,oak
|
||||
python3 tools/build_voxels.py --debug-exports -o /tmp/vox
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
FRAME = 16 # overworld sprites are 16x16
|
||||
SHADES = 4 # GB is 2bpp
|
||||
|
||||
# the author's own imported cache is the INPUT to this build step; only the
|
||||
# carved output below is what the mod ships
|
||||
SPRITE_DIR = Path("assets/generated/sprites")
|
||||
# ...and it lands in the mod's own asset tree, never back in the cache
|
||||
VOXEL_DIR = Path("mods/DRAMATIC_SHAPE/assets/voxels")
|
||||
|
||||
# shade index (0 = lightest) -> RGBA, for the .vox / .ply debug exports only
|
||||
# (the runtime textures from the live sheet instead -- see module docstring)
|
||||
PALETTES = {
|
||||
"gray": [(248, 248, 248, 255), (168, 168, 168, 255),
|
||||
(88, 88, 88, 255), (16, 16, 16, 255)],
|
||||
"gb": [(224, 248, 208, 255), (136, 192, 112, 255),
|
||||
(52, 104, 86, 255), (8, 24, 32, 255)],
|
||||
"sgb": [(255, 239, 206, 255), (222, 148, 74, 255),
|
||||
(173, 41, 33, 255), (49, 24, 82, 255)],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- decoding --
|
||||
|
||||
def decode_2bpp(data: bytes, tiles_wide: int = 2) -> np.ndarray:
|
||||
"""Raw GB 2bpp -> 2D array of shade indices. 16 bytes per 8x8 tile, two
|
||||
bytes per row: byte0 = low bitplane, byte1 = high bitplane."""
|
||||
ntiles = len(data) // 16
|
||||
tiles = []
|
||||
for t in range(ntiles):
|
||||
chunk = data[t * 16:(t + 1) * 16]
|
||||
tile = np.zeros((8, 8), np.uint8)
|
||||
for row in range(8):
|
||||
lo, hi = chunk[row * 2], chunk[row * 2 + 1]
|
||||
for bit in range(8):
|
||||
tile[row, bit] = (((hi >> (7 - bit)) & 1) << 1) | \
|
||||
((lo >> (7 - bit)) & 1)
|
||||
tiles.append(tile)
|
||||
rows = [np.hstack(tiles[i:i + tiles_wide])
|
||||
for i in range(0, ntiles, tiles_wide)]
|
||||
return np.vstack(rows)
|
||||
|
||||
|
||||
def load_sheet(path: Path):
|
||||
"""Load a sheet as (shade indices 0..3, alpha mask or None).
|
||||
|
||||
The extracted PNGs are RGBA with GB OBJ color 0 written as transparent
|
||||
white, so their alpha channel IS the opacity mask -- exact, and what the
|
||||
hardware does. `None` means the source carried no alpha (raw 2bpp, or a
|
||||
flat grayscale PNG) and the caller must derive opacity itself."""
|
||||
if path.suffix.lower() == ".2bpp":
|
||||
return decode_2bpp(path.read_bytes()), None
|
||||
arr = np.array(Image.open(path).convert("RGBA"))
|
||||
lum = arr[..., 0].astype(np.int16) # GB art is gray: R == G == B
|
||||
shade = np.zeros(lum.shape, np.uint8) # bucket; don't trust exact values
|
||||
shade[lum < 213] = 1
|
||||
shade[lum < 128] = 2
|
||||
shade[lum < 43] = 3
|
||||
alpha = arr[..., 3] > 0 if arr[..., 3].min() < 255 else None
|
||||
return shade, alpha
|
||||
|
||||
|
||||
def slice_frames(shade: np.ndarray, alpha):
|
||||
"""Split a sheet into 16x16 frames, row-major over any grid layout.
|
||||
Returns (shade, alpha_or_None, origin_x, origin_y) per frame; the origin
|
||||
is the frame's top-left in sheet pixels, which is what the exported UVs
|
||||
are relative to."""
|
||||
h, w = shade.shape
|
||||
out = []
|
||||
for fy in range(h // FRAME):
|
||||
for fx in range(w // FRAME):
|
||||
sy, sx = fy * FRAME, fx * FRAME
|
||||
sub = shade[sy:sy + FRAME, sx:sx + FRAME]
|
||||
sub_a = alpha[sy:sy + FRAME, sx:sx + FRAME] if alpha is not None \
|
||||
else None
|
||||
out.append((sub, sub_a, sx, sy))
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- alpha --
|
||||
|
||||
def flood_alpha(shade: np.ndarray) -> np.ndarray:
|
||||
"""Fallback opacity for alpha-less sources: shade 0 is transparent ONLY
|
||||
where it flood-connects to the frame border, so an enclosed light region
|
||||
(a face, a hand) stays opaque instead of punching a hole through the
|
||||
model."""
|
||||
h, w = shade.shape
|
||||
bg = np.zeros((h, w), bool)
|
||||
dq = deque()
|
||||
|
||||
def seed(y, x):
|
||||
if shade[y, x] == 0 and not bg[y, x]:
|
||||
bg[y, x] = True
|
||||
dq.append((y, x))
|
||||
|
||||
for x in range(w):
|
||||
seed(0, x)
|
||||
seed(h - 1, x)
|
||||
for y in range(h):
|
||||
seed(y, 0)
|
||||
seed(y, w - 1)
|
||||
while dq:
|
||||
y, x = dq.popleft()
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
ny, nx = y + dy, x + dx
|
||||
if 0 <= ny < h and 0 <= nx < w:
|
||||
seed(ny, nx)
|
||||
return ~bg
|
||||
|
||||
|
||||
def anchor_bottom_center(shade: np.ndarray, alpha: np.ndarray):
|
||||
"""Shift a frame so its opaque bbox sits bottom-anchored and horizontally
|
||||
centered, returning (shade, alpha, dy, dx). Views are authored with
|
||||
slightly different padding; without this the hull loses a pixel shell
|
||||
wherever they disagree. The shifts come back out because the exported UVs
|
||||
must point at the ORIGINAL sheet pixel, not the shifted one."""
|
||||
ys, xs = np.nonzero(alpha)
|
||||
if len(ys) == 0:
|
||||
return shade, alpha, 0, 0
|
||||
dy = (FRAME - 1) - int(ys.max())
|
||||
dx = (FRAME - (int(xs.max()) - int(xs.min()) + 1)) // 2 - int(xs.min())
|
||||
out_s = np.zeros_like(shade)
|
||||
out_a = np.zeros_like(alpha)
|
||||
out_s[ys + dy, xs + dx] = shade[ys, xs]
|
||||
out_a[ys + dy, xs + dx] = True
|
||||
return out_s, out_a, dy, dx
|
||||
|
||||
|
||||
class View:
|
||||
"""One aligned silhouette plus the mapping back to its sheet pixels.
|
||||
|
||||
`mirror` is applied BEFORE alignment: the back view is pre-mirrored so it
|
||||
registers with the front (and a prop's synthetic side view is the
|
||||
mirrored front), which keeps the carve and the UV lookup working in one
|
||||
consistent aligned space with no second flip anywhere downstream."""
|
||||
|
||||
def __init__(self, shade, alpha, ox, oy, mirror=False):
|
||||
if mirror:
|
||||
shade = shade[:, ::-1].copy()
|
||||
alpha = alpha[:, ::-1].copy()
|
||||
self.shade, self.alpha, self.dy, self.dx = \
|
||||
anchor_bottom_center(shade, alpha)
|
||||
self.ox, self.oy, self.mirror = ox, oy, mirror
|
||||
|
||||
def opaque(self, row: int, col: int) -> bool:
|
||||
return bool(self.alpha[row, col])
|
||||
|
||||
def uv(self, row: int, col: int):
|
||||
"""Aligned (row, col) -> (u, v) sheet pixel. Callers only ask for
|
||||
positions this view proved opaque (see `sample`), so the inverse
|
||||
lands inside the frame; the clamp guards a degenerate frame."""
|
||||
y0 = row - self.dy
|
||||
x0 = col - self.dx
|
||||
if self.mirror:
|
||||
x0 = FRAME - 1 - x0
|
||||
y0 = min(max(y0, 0), FRAME - 1)
|
||||
x0 = min(max(x0, 0), FRAME - 1)
|
||||
return self.ox + x0, self.oy + y0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- carve --
|
||||
|
||||
def carve(front: View, side: View, back: View, mode="union") -> np.ndarray:
|
||||
"""Visual hull: solid[x, y, z]. `back` is already registered with the
|
||||
front (pre-mirrored at construction), so no flip here. The front/back
|
||||
pair combines by `mode` (see the module docstring); the orthogonal side
|
||||
view always intersects, since it is the only view carving depth."""
|
||||
fb = front.alpha | back.alpha if mode == "union" else \
|
||||
front.alpha & back.alpha
|
||||
return fb.T[:, :, None] & side.alpha[None, :, :] # [x,y,1] & [1,y,z]
|
||||
|
||||
|
||||
def largest_component(solid: np.ndarray) -> np.ndarray:
|
||||
"""Keep only the largest 6-connected blob (drops carving speckle)."""
|
||||
labels = np.zeros(solid.shape, np.int32)
|
||||
best_id, best_n, cur = 0, 0, 0
|
||||
for idx in zip(*np.nonzero(solid)):
|
||||
if labels[idx]:
|
||||
continue
|
||||
cur += 1
|
||||
n = 0
|
||||
dq = deque([idx])
|
||||
labels[idx] = cur
|
||||
while dq:
|
||||
x, y, z = dq.popleft()
|
||||
n += 1
|
||||
for dx, dy, dz in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
|
||||
(0, -1, 0), (0, 0, 1), (0, 0, -1)):
|
||||
nx, ny, nz = x + dx, y + dy, z + dz
|
||||
if 0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME \
|
||||
and solid[nx, ny, nz] and not labels[nx, ny, nz]:
|
||||
labels[nx, ny, nz] = cur
|
||||
dq.append((nx, ny, nz))
|
||||
if n > best_n:
|
||||
best_id, best_n = cur, n
|
||||
return labels == best_id
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- faces --
|
||||
|
||||
# carve-grid neighbour offset -> (model direction id, which view colors it).
|
||||
# Model dirs: 1 = +X east, 2 = -X west, 3 = +Y up, 4 = -Y down,
|
||||
# 5 = +Z south (toward the camera), 6 = -Z north (away).
|
||||
# A profile face takes the side view -- and so do TOP faces: the camera in
|
||||
# game looks steeply down, so tops dominate what you see, and the axis a
|
||||
# top face varies along on screen is DEPTH (z). The side view is the one
|
||||
# view that resolves depth -- its pixel at (row, z) puts cap-front red and
|
||||
# hair-back black in the right z order. Sampling the front view there (the
|
||||
# obvious first guess, and what shipped first) gives every top voxel of a
|
||||
# row the same front pixel, which painted the entire head cap-color and
|
||||
# turned every character into a red-topped blob.
|
||||
FACE_DIRS = (
|
||||
((1, 0, 0), 1, "side"),
|
||||
((-1, 0, 0), 2, "side"),
|
||||
((0, -1, 0), 3, "side"),
|
||||
((0, 1, 0), 4, "front"),
|
||||
((0, 0, -1), 5, "front"),
|
||||
((0, 0, 1), 6, "back"),
|
||||
)
|
||||
|
||||
|
||||
def source_view(which, x, y, z, front: View, side: View, back: View):
|
||||
"""The view a face takes its pixel from, as (view, row, col) in that
|
||||
view's ALIGNED space, with a fallback chain.
|
||||
|
||||
Under the union rule a voxel can be proved solid by the back view alone,
|
||||
so its front-facing face has no front pixel to take -- sampling one
|
||||
anyway would read the frame's transparent background and punch a hole in
|
||||
the model. Fall back to the opposite same-axis view, then to the side
|
||||
view, which every solid voxel satisfies by construction (the side is the
|
||||
one view that always intersects), so this always terminates on an opaque
|
||||
pixel."""
|
||||
if which == "side":
|
||||
return side, y, z
|
||||
first, second = (front, back) if which == "front" else (back, front)
|
||||
if first.opaque(y, x):
|
||||
return first, y, x
|
||||
if second.opaque(y, x):
|
||||
return second, y, x
|
||||
return side, y, z
|
||||
|
||||
|
||||
def build_faces(solid, front: View, side: View, back: View):
|
||||
"""Every exposed voxel face as (mx, my, mz, dir, u, v) in model space.
|
||||
|
||||
The model's own underside (my == 0) is dropped: it sits flat on the
|
||||
ground plane and can never be seen, and it is ~1 face in 8."""
|
||||
faces = []
|
||||
for x, y, z in zip(*np.nonzero(solid)):
|
||||
x, y, z = int(x), int(y), int(z)
|
||||
mx, my, mz = x, FRAME - 1 - y, FRAME - 1 - z
|
||||
for (dx, dy, dz), d, which in FACE_DIRS:
|
||||
nx, ny, nz = x + dx, y + dy, z + dz
|
||||
if 0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME \
|
||||
and solid[nx, ny, nz]:
|
||||
continue
|
||||
if d == 4 and my == 0:
|
||||
continue
|
||||
v, r, c = source_view(which, x, y, z, front, side, back)
|
||||
u, w = v.uv(r, c)
|
||||
faces.append((mx, my, mz, d, u, w))
|
||||
return faces
|
||||
|
||||
|
||||
def colorize(solid, front: View, side: View, back: View) -> np.ndarray:
|
||||
"""Per-voxel shade index for the .vox / .ply debug exports. Priority
|
||||
front > side > back > top, matching the face order the runtime sees;
|
||||
bottom-only voxels keep the darkest shade (they read as shadow). Shades
|
||||
resolve through `source_view`'s fallback chain for the same reason the
|
||||
exported UVs do -- a back-only voxel has no front pixel."""
|
||||
shade = np.full(solid.shape, 3, np.uint8)
|
||||
|
||||
def free(x, y, z, dx, dy, dz):
|
||||
nx, ny, nz = x + dx, y + dy, z + dz
|
||||
return not (0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME
|
||||
and solid[nx, ny, nz])
|
||||
|
||||
def shade_at(which, x, y, z):
|
||||
v, r, c = source_view(which, x, y, z, front, side, back)
|
||||
return v.shade[r, c]
|
||||
|
||||
for x, y, z in zip(*np.nonzero(solid)):
|
||||
x, y, z = int(x), int(y), int(z)
|
||||
if free(x, y, z, 0, 0, -1):
|
||||
shade[x, y, z] = shade_at("front", x, y, z)
|
||||
elif free(x, y, z, 1, 0, 0) or free(x, y, z, -1, 0, 0):
|
||||
shade[x, y, z] = shade_at("side", x, y, z)
|
||||
elif free(x, y, z, 0, 0, 1):
|
||||
shade[x, y, z] = shade_at("back", x, y, z)
|
||||
elif free(x, y, z, 0, -1, 0):
|
||||
shade[x, y, z] = shade_at("side", x, y, z)
|
||||
return shade
|
||||
|
||||
|
||||
# -------------------------------------------------------------- validation --
|
||||
|
||||
def reprojection_iou(solid, front: View, side: View, back: View) -> dict:
|
||||
"""Project the hull back along each axis against the source silhouettes.
|
||||
Front and back project along the same axis (the back is pre-registered),
|
||||
so they share a projection and differ only in what they are compared to."""
|
||||
proj_fb = solid.any(axis=2).T # [y, x]
|
||||
proj_side = solid.any(axis=0) # [y, z]
|
||||
|
||||
def iou(a, b):
|
||||
u = (a | b).sum()
|
||||
return float((a & b).sum()) / u if u else 1.0
|
||||
|
||||
return {"front": iou(proj_fb, front.alpha),
|
||||
"side": iou(proj_side, side.alpha),
|
||||
"back": iou(proj_fb, back.alpha)}
|
||||
|
||||
|
||||
# ----------------------------------------------------------- debug exports --
|
||||
|
||||
def _chunk(cid: bytes, content: bytes, children: bytes = b"") -> bytes:
|
||||
return cid + struct.pack("<ii", len(content), len(children)) \
|
||||
+ content + children
|
||||
|
||||
|
||||
def write_vox(path: Path, solid, shade, palette):
|
||||
"""MagicaVoxel .vox (z-up). Model axes: vox_x = x, vox_y = depth,
|
||||
vox_z = up."""
|
||||
xs, ys, zs = np.nonzero(solid)
|
||||
xyzi = struct.pack("<i", len(xs))
|
||||
for x, y, z in zip(xs, ys, zs):
|
||||
xyzi += struct.pack("<4B", int(x), int(FRAME - 1 - z),
|
||||
int(FRAME - 1 - y), int(shade[x, y, z]) + 1)
|
||||
pal = bytearray()
|
||||
for i in range(255):
|
||||
pal += bytes(palette[i]) if i < len(palette) else b"\x00\x00\x00\xff"
|
||||
pal += b"\x00\x00\x00\x00"
|
||||
children = _chunk(b"SIZE", struct.pack("<3i", FRAME, FRAME, FRAME)) \
|
||||
+ _chunk(b"XYZI", xyzi) + _chunk(b"RGBA", bytes(pal))
|
||||
path.write_bytes(b"VOX " + struct.pack("<i", 150)
|
||||
+ _chunk(b"MAIN", b"", children))
|
||||
|
||||
|
||||
_PLY_FACES = { # (dx,dy,dz) -> 4 corner offsets (unit cube, CCW from outside)
|
||||
(0, 0, -1): [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
|
||||
(0, 0, 1): [(1, 0, 1), (0, 0, 1), (0, 1, 1), (1, 1, 1)],
|
||||
(-1, 0, 0): [(0, 0, 1), (0, 0, 0), (0, 1, 0), (0, 1, 1)],
|
||||
(1, 0, 0): [(1, 0, 0), (1, 0, 1), (1, 1, 1), (1, 1, 0)],
|
||||
(0, -1, 0): [(0, 0, 1), (1, 0, 1), (1, 0, 0), (0, 0, 0)],
|
||||
(0, 1, 0): [(0, 1, 0), (1, 1, 0), (1, 1, 1), (0, 1, 1)],
|
||||
}
|
||||
|
||||
|
||||
def write_ply(path: Path, solid, shade, palette):
|
||||
"""ASCII PLY, hidden faces culled, vertex colors. y flipped so +up."""
|
||||
verts, faces = [], []
|
||||
for x, y, z in zip(*np.nonzero(solid)):
|
||||
r, g, b, _ = palette[shade[x, y, z]]
|
||||
for (dx, dy, dz), corners in _PLY_FACES.items():
|
||||
nx, ny, nz = x + dx, y + dy, z + dz
|
||||
if 0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME \
|
||||
and solid[nx, ny, nz]:
|
||||
continue
|
||||
base = len(verts)
|
||||
for cx, cy, cz in corners:
|
||||
verts.append((x + cx, FRAME - (y + cy), z + cz, r, g, b))
|
||||
faces.append((base, base + 1, base + 2, base + 3))
|
||||
with open(path, "w") as f:
|
||||
f.write("ply\nformat ascii 1.0\n"
|
||||
f"element vertex {len(verts)}\n"
|
||||
"property float x\nproperty float y\nproperty float z\n"
|
||||
"property uchar red\nproperty uchar green\n"
|
||||
"property uchar blue\n"
|
||||
f"element face {len(faces)}\n"
|
||||
"property list uchar int vertex_indices\nend_header\n")
|
||||
for v in verts:
|
||||
f.write("%g %g %g %d %d %d\n" % v)
|
||||
for a, b, c, d in faces:
|
||||
f.write(f"4 {a} {b} {c} {d}\n")
|
||||
|
||||
|
||||
# ------------------------------------------------------------- lua exports --
|
||||
|
||||
FACES_PER_LINE = 8
|
||||
|
||||
|
||||
def lua_source(name: str, sheet_w: int, sheet_h: int, poses: dict) -> str:
|
||||
out = [
|
||||
"-- Generated by tools/build_voxels.py. DO NOT EDIT.",
|
||||
f"-- Visual-hull voxel model carved from the {name!r} overworld"
|
||||
.replace("'", '"'),
|
||||
"-- sprite sheet. Geometry only -- no sheet path, no baked pixel.",
|
||||
"-- faces: flat runs of 6 ints -- mx, my, mz, dir, u, v.",
|
||||
"-- mx/my/mz voxel corner in model space (X east, Y up, Z south),",
|
||||
"-- 0..15, character faces +Z at rest.",
|
||||
"-- dir 1 +X 2 -X 3 +Y 4 -Y 5 +Z 6 -Z",
|
||||
"-- u/v pixel this face samples in a sheet of sheetW x sheetH.",
|
||||
"-- The runtime pairs the model with the LIVE sheet the",
|
||||
"-- engine handed it, so palette/mod recolors apply.",
|
||||
"return {",
|
||||
f" name = {name!r},".replace("'", '"'),
|
||||
f" sheetW = {sheet_w},",
|
||||
f" sheetH = {sheet_h},",
|
||||
f" size = {FRAME},",
|
||||
" poses = {",
|
||||
]
|
||||
for pose in ("stand", "walk"):
|
||||
p = poses.get(pose)
|
||||
if not p:
|
||||
continue
|
||||
faces = p["faces"]
|
||||
out.append(f" {pose} = {{")
|
||||
out.append(f" frames = {{ {', '.join(str(f) for f in p['frames'])} }},")
|
||||
out.append(f" voxels = {p['voxels']},")
|
||||
out.append(f" count = {len(faces)},")
|
||||
out.append(" faces = {")
|
||||
for i in range(0, len(faces), FACES_PER_LINE):
|
||||
chunk = faces[i:i + FACES_PER_LINE]
|
||||
row = " ".join(
|
||||
",".join(str(n) for n in face) + "," for face in chunk)
|
||||
out.append(" " + row)
|
||||
out.append(" },")
|
||||
out.append(" },")
|
||||
out.append(" },")
|
||||
out.append("}")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pipeline --
|
||||
|
||||
def build_pose(frames, picks, mode="union"):
|
||||
"""Carve one pose. `picks` is (down, up, left) frame indices, or a
|
||||
1-tuple for a prop, whose side view is the mirrored front."""
|
||||
def view(i, mirror=False):
|
||||
shade, alpha, ox, oy = frames[i]
|
||||
if alpha is None:
|
||||
alpha = flood_alpha(shade)
|
||||
return View(shade, alpha, ox, oy, mirror)
|
||||
|
||||
if len(picks) == 1:
|
||||
front = view(picks[0])
|
||||
back = view(picks[0], mirror=True) # a prop reads the same from behind
|
||||
side = view(picks[0], mirror=True)
|
||||
else:
|
||||
front = view(picks[0])
|
||||
back = view(picks[1], mirror=True) # register the back with the front
|
||||
side = view(picks[2])
|
||||
|
||||
solid = largest_component(carve(front, side, back, mode))
|
||||
return solid, front, side, back
|
||||
|
||||
|
||||
def convert(path: Path, outdir: Path, debug: Path = None,
|
||||
palette_name="sgb", min_iou=0.0, mode="union") -> dict:
|
||||
shade, alpha = load_sheet(path)
|
||||
frames = slice_frames(shade, alpha)
|
||||
sheet_h, sheet_w = shade.shape
|
||||
|
||||
if len(frames) >= 6:
|
||||
pose_picks = {"stand": (0, 1, 2), "walk": (3, 4, 5)}
|
||||
elif len(frames) >= 3:
|
||||
pose_picks = {"stand": (0, 1, 2)}
|
||||
else:
|
||||
pose_picks = {"stand": (0,)}
|
||||
|
||||
poses, scores = {}, {}
|
||||
for pose, picks in pose_picks.items():
|
||||
solid, front, side, back = build_pose(frames, picks, mode)
|
||||
poses[pose] = {
|
||||
"frames": picks,
|
||||
"voxels": int(solid.sum()),
|
||||
"faces": build_faces(solid, front, side, back),
|
||||
}
|
||||
scores[pose] = reprojection_iou(solid, front, side, back)
|
||||
if debug is not None:
|
||||
palette = PALETTES[palette_name]
|
||||
sh = colorize(solid, front, side, back)
|
||||
debug.mkdir(parents=True, exist_ok=True)
|
||||
write_vox(debug / f"{path.stem}_{pose}.vox", solid, sh, palette)
|
||||
write_ply(debug / f"{path.stem}_{pose}.ply", solid, sh, palette)
|
||||
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
dest = outdir / f"{path.stem}.lua"
|
||||
dest.write_text(lua_source(path.stem, sheet_w, sheet_h, poses))
|
||||
|
||||
# report the WORST pose, not just stand: `ok` gates on every pose, so a
|
||||
# summary line showing stand's numbers could print "!!" beside three
|
||||
# healthy-looking scores when it is the walk pose that carved badly
|
||||
worst_pose = min(scores, key=lambda p: min(scores[p].values()))
|
||||
worst = min(scores[worst_pose].values())
|
||||
return {"name": path.stem, "poses": poses, "iou": scores,
|
||||
"worst": worst, "worstPose": worst_pose, "ok": worst >= min_iou,
|
||||
"bytes": dest.stat().st_size}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("input", nargs="?", type=Path, default=SPRITE_DIR,
|
||||
help="sprite PNG/.2bpp or directory "
|
||||
f"(default {SPRITE_DIR})")
|
||||
ap.add_argument("-o", "--outdir", type=Path, default=VOXEL_DIR)
|
||||
ap.add_argument("--only", help="comma-separated sprite stems to build")
|
||||
ap.add_argument("--debug-exports", type=Path, default=None,
|
||||
metavar="DIR", help="also write .vox/.ply there")
|
||||
ap.add_argument("--palette", choices=PALETTES, default="sgb",
|
||||
help="debug-export palette only")
|
||||
ap.add_argument("--min-iou", type=float, default=0.0,
|
||||
help="fail the run if any view scores below this")
|
||||
ap.add_argument("--silhouette", choices=("union", "intersect"),
|
||||
default="union",
|
||||
help="how the front/back pair combines (see the module "
|
||||
"docstring); intersect is the strict three-way hull")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.input.is_dir():
|
||||
targets = sorted(args.input.glob("*.png")) + \
|
||||
sorted(args.input.glob("*.2bpp"))
|
||||
else:
|
||||
targets = [args.input]
|
||||
if args.only:
|
||||
keep = {s.strip() for s in args.only.split(",") if s.strip()}
|
||||
targets = [t for t in targets if t.stem in keep]
|
||||
if not targets:
|
||||
print(f"no sprite sheets found under {args.input}")
|
||||
return 1
|
||||
|
||||
failed, total_faces, total_bytes = 0, 0, 0
|
||||
for t in targets:
|
||||
try:
|
||||
r = convert(t, args.outdir, args.debug_exports, args.palette,
|
||||
args.min_iou, args.silhouette)
|
||||
except Exception as e: # noqa: BLE001 - batch robustness
|
||||
print(f"[FAIL] {t.name}: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
nf = sum(len(p["faces"]) for p in r["poses"].values())
|
||||
total_faces += nf
|
||||
total_bytes += r["bytes"]
|
||||
s = r["iou"][r["worstPose"]]
|
||||
flag = "" if r["ok"] else " <-- below min IoU"
|
||||
pose = "" if r["worstPose"] == "stand" else f" [{r['worstPose']}]"
|
||||
print(f"[{'ok' if r['ok'] else '!!'}] {r['name']:<20}"
|
||||
f" {len(r['poses'])} pose(s) {nf:>5} faces "
|
||||
f"IoU f={s['front']:.3f} s={s['side']:.3f} b={s['back']:.3f}"
|
||||
f"{pose}{flag}")
|
||||
failed += not r["ok"]
|
||||
|
||||
print(f"\n{len(targets) - failed}/{len(targets)} models -> {args.outdir}"
|
||||
f" ({total_faces} faces, {total_bytes / 1024:.0f} KiB)")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Composite the reference PNGs that
|
||||
assets/docs/buildings/B##-unnamed-building.md files link to (img/B##.png,
|
||||
img/B##_x{scale}.png, img/B##_atlas.png) but the repo does not ship, since
|
||||
they land in the gitignored .scratchpad/ -- run this to regenerate them
|
||||
locally.
|
||||
|
||||
Parses each doc's raw tile-id grid (the `local rows = {...}` Lua array), its
|
||||
named tileset, and its scale factor straight out of the markdown, then
|
||||
composites the building out of the tileset atlas the same way
|
||||
building_voxels.py's sprite() does: paste each tile's 8x8 block, 16 tiles
|
||||
per row. Emits, per building:
|
||||
- B##.png native resolution
|
||||
- B##_x{N}.png nearest-neighbor upscale, at the scale the doc references
|
||||
- B##_atlas.png a strip of the building's distinct tiles, first-appearance
|
||||
order (matches the doc's legend lettering)
|
||||
|
||||
python mods/DRAMATIC_SHAPE/tools/building_images.py [outdir]
|
||||
|
||||
outdir defaults to .scratchpad/img at the repo root.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import glob
|
||||
from PIL import Image
|
||||
|
||||
MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ROOT = os.path.dirname(os.path.dirname(MOD_ROOT))
|
||||
DOCS = os.path.join(MOD_ROOT, "assets", "docs", "buildings")
|
||||
TILESETS = os.path.join(ROOT, "assets", "generated", "tilesets")
|
||||
PER_ROW = 16
|
||||
|
||||
|
||||
def parse_doc(path):
|
||||
text = open(path, encoding="utf-8").read()
|
||||
bid = re.search(r"^# (B\d+)", text, re.M).group(1)
|
||||
scale = int(re.search(r"img/B\d+_x(\d+)\.png", text).group(1))
|
||||
tileset = re.search(r"assets/generated/tilesets/(\w+)\.png", text).group(1)
|
||||
lua = re.search(r"local rows = \{(.*?)\n\}", text, re.S).group(1)
|
||||
rows = []
|
||||
for line in lua.strip().splitlines():
|
||||
code = line.split("--")[0]
|
||||
nums = re.findall(r"\d+", code)
|
||||
if nums:
|
||||
rows.append([int(n) for n in nums])
|
||||
return bid, scale, tileset, rows
|
||||
|
||||
|
||||
def composite(rows, atlas):
|
||||
h, w = len(rows), len(rows[0])
|
||||
im = Image.new("RGB", (w * 8, h * 8))
|
||||
for r, row in enumerate(rows):
|
||||
for c, t in enumerate(row):
|
||||
ax, ay = (t % PER_ROW) * 8, (t // PER_ROW) * 8
|
||||
im.paste(atlas.crop((ax, ay, ax + 8, ay + 8)), (c * 8, r * 8))
|
||||
return im
|
||||
|
||||
|
||||
def atlas_strip(rows, atlas, cell=32, pad=2, per_row=12):
|
||||
"""Every distinct tile the building uses, in first-appearance (row-major)
|
||||
order -- the same order the doc's legend assigns A, B, C, ... to."""
|
||||
seen, seenset = [], set()
|
||||
for row in rows:
|
||||
for t in row:
|
||||
if t not in seenset:
|
||||
seenset.add(t)
|
||||
seen.append(t)
|
||||
n = len(seen)
|
||||
cols = min(n, per_row)
|
||||
lines = (n + per_row - 1) // per_row
|
||||
W = cols * cell + (cols + 1) * pad
|
||||
H = lines * cell + (lines + 1) * pad
|
||||
img = Image.new("RGB", (W, H), (255, 255, 255))
|
||||
for i, t in enumerate(seen):
|
||||
ax, ay = (t % PER_ROW) * 8, (t // PER_ROW) * 8
|
||||
tile = atlas.crop((ax, ay, ax + 8, ay + 8)).resize((cell, cell), Image.NEAREST)
|
||||
col, line = i % per_row, i // per_row
|
||||
img.paste(tile, (pad + col * (cell + pad), pad + line * (cell + pad)))
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, ".scratchpad", "img")
|
||||
os.makedirs(out, exist_ok=True)
|
||||
|
||||
atlas_cache = {}
|
||||
docs = sorted(
|
||||
glob.glob(os.path.join(DOCS, "B*-unnamed-building.md")),
|
||||
key=lambda p: int(re.search(r"B(\d+)", os.path.basename(p)).group(1)),
|
||||
)
|
||||
for path in docs:
|
||||
bid, scale, tileset, rows = parse_doc(path)
|
||||
if tileset not in atlas_cache:
|
||||
atlas_cache[tileset] = Image.open(
|
||||
os.path.join(TILESETS, tileset + ".png")
|
||||
).convert("RGB")
|
||||
atlas = atlas_cache[tileset]
|
||||
|
||||
native = composite(rows, atlas)
|
||||
native.save(os.path.join(out, f"{bid}.png"))
|
||||
|
||||
big = native.resize((native.width * scale, native.height * scale), Image.NEAREST)
|
||||
big.save(os.path.join(out, f"{bid}_x{scale}.png"))
|
||||
|
||||
atl = atlas_strip(rows, atlas)
|
||||
atl.save(os.path.join(out, f"{bid}_atlas.png"))
|
||||
|
||||
print(f"{bid}: {tileset} {native.size} -> x{scale} {big.size}, atlas {atl.size}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,911 @@
|
||||
"""Reference implementation of the building voxelizer -- the Python half of
|
||||
the dual-implementation parity check demanded by
|
||||
assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md (Stage 5).
|
||||
|
||||
It composites a building out of the tileset atlas exactly the way the map
|
||||
does, extracts palette + silhouette (light-only flood fill), builds the
|
||||
voxel model with the same rules lib/Buildings.lua ships, asserts the
|
||||
geometric intent, and renders isometric previews.
|
||||
|
||||
python mods/DRAMATIC_SHAPE/tools/building_voxels.py [outdir]
|
||||
|
||||
Keep the TEMPLATES table below in sync with the `buildings` section of
|
||||
data/voxel_heights.lua: the two implementations are meant to be
|
||||
independent restatements of the same algorithm, and the voxel/shell
|
||||
counts they print must match.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from collections import deque, Counter
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))))
|
||||
TILESETS = os.path.join(ROOT, "assets/generated/tilesets")
|
||||
PER_ROW = 16
|
||||
|
||||
WHITE, GREY, DARK, BLACK = 0, 1, 2, 3 # by luminance, light first
|
||||
|
||||
# --- the shape profile, mirroring data/voxel_heights.lua's `buildings` ------
|
||||
TEMPLATES = {
|
||||
# B07: Red's house / Blue's house / Bill's / the Copycat's -- 7 placements
|
||||
"gabled_house": dict(
|
||||
tiles=[
|
||||
[5, 6, 7, 7, 7, 7, 8, 9],
|
||||
[21, 22, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 34, 10, 10, 40, 41],
|
||||
[92, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 34, 11, 12, 10, 10, 34, 31],
|
||||
[78, 26, 27, 28, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
|
||||
slab=4, front_eave=4, ledge=(24, 31),
|
||||
),
|
||||
# B31: Oak's lab -- the same architecture with a roof band twice as deep
|
||||
"lab": dict(
|
||||
tiles=[
|
||||
[5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 10, 10, 75, 75, 10, 10, 10, 40, 41],
|
||||
[15, 34, 34, 34, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 11, 12, 10, 10, 10, 10, 10, 31],
|
||||
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B03: the flat-roofed commercial block -- 15 placements, the most of
|
||||
# any voxelized drawing. The lattice is drawn from straight above, so
|
||||
# the measured taper is flat; the eave course is the roof's own south
|
||||
# rim, lab-style. Its sprite is inset from its box: the outer columns
|
||||
# carry no roof.
|
||||
"flat_commercial": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 11, 12, 75, 75, 75, 31],
|
||||
[78, 26, 27, 28, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B05: every Pokemon Center in the game (Celadon, Cerulean,
|
||||
# Cinnabar, Fuchsia, Lavender, Pewter, Saffron, Vermilion, Viridian,
|
||||
# Mt Moon, Rock Tunnel). B03's block with the POKe sign hung beside
|
||||
# the door; the sign is too wide to be a pane, so it stays flush.
|
||||
"pokecenter": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 11, 12, 66, 67, 75, 31],
|
||||
[78, 26, 27, 28, 74, 74, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B06: every Poke Mart (Cerulean, Cinnabar, Fuchsia, Lavender,
|
||||
# Pewter, Saffron, Vermilion, Viridian). The Center's twin, MART on
|
||||
# the sign.
|
||||
"pokemart": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 11, 12, 68, 69, 75, 31],
|
||||
[78, 26, 27, 28, 74, 74, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B02: the plain 4x4 block: one window course over blank brick and
|
||||
# no door. 15 placements, scenery in every city bar the Celadon Mart
|
||||
# roof stair.
|
||||
"flat_block_4x4": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B08: the same block two cells deeper, 6 placements, all scenery.
|
||||
"flat_block_4x6": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B13: the 6x4 scenery block, 4 placements.
|
||||
"flat_block_6x4": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B14: the 6x6 scenery block, 3 placements.
|
||||
"flat_block_6x6": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B27: the 8x4 scenery block, one placement on Route 11.
|
||||
"flat_block_8x4": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B09: the wide storefront: Celadon's Game Corner, the Pokemon
|
||||
# Mansion, Cinnabar Lab, the Safari Zone gate and Fuchsia's meeting
|
||||
# room.
|
||||
"game_corner": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B15: Celadon Mansion and the Route 6 and Route 12 gates.
|
||||
"celadon_mansion": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B18: the Route 2 gate, and the museum's east entrance beside it in
|
||||
# Pewter. The museum hall itself is B24 below -- a sloped roof.
|
||||
"route_2_gate": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 11, 12, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B29: Fuchsia Gym, the block with GYM on the sign.
|
||||
"fuchsia_gym": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 34, 47, 63, 34, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 34, 34, 34, 34, 75, 75, 75, 31],
|
||||
[15, 75, 11, 12, 10, 10, 10, 10, 75, 75, 75, 31],
|
||||
[78, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B28: the Route 5 underground-path gate.
|
||||
"route_5_gate": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B21: the Route 22 league gate, the widest of the family at 12
|
||||
# cells.
|
||||
"route_22_gate": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B25: the Power Plant.
|
||||
"power_plant": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B22: Celadon's department store: six window courses over the MART
|
||||
# sign.
|
||||
"celadon_mart": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 75, 75, 10, 10, 75, 75, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 11, 12, 10, 10, 11, 12, 10, 10, 68, 69, 75, 31],
|
||||
[78, 26, 26, 26, 27, 28, 26, 26, 27, 28, 26, 26, 74, 74, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B20: Silph Co. Twelve cells of plot and ten courses of windows
|
||||
# under the same roof band, so it stands as the tallest thing in
|
||||
# Kanto.
|
||||
"silph_co": dict(
|
||||
tiles=[
|
||||
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
|
||||
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B24: the Pewter museum's hall, and the only building in the family
|
||||
# with a SLOPED roof: the same 2:1 taper the lab and Red's house are
|
||||
# drawn with, over a roof band twice the lab's depth. The drawing
|
||||
# repeats its whole lattice-and-course motif -- rows 8..31 again at
|
||||
# 32..55 -- which is what fixes the cycle at 24 rather than the bare
|
||||
# lattice's 8: the drawing proves the period. The last band (rows
|
||||
# 56..63) is the roof's fascia, wider than the wall it covers, so it
|
||||
# stays in the roof band and lands on the south rim.
|
||||
"museum": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 40, 41],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=64, roof_back=8, roof_front=8, roof_cycle=(8, 31),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B10: the gym. Cinnabar, Pewter, Vermilion and Viridian wear this
|
||||
# drawing, and so does the Fighting Dojo next door to Saffron's.
|
||||
# Oak's lab's roof band exactly, tile for tile, over a facade with
|
||||
# GYM on the sign.
|
||||
"gym": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 10, 34, 47, 63, 34, 10, 10, 40, 41],
|
||||
[15, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 11, 12, 10, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B16: the big-city gym: Celadon, Cerulean and Saffron. Two cells
|
||||
# wider than the standard gym, and it carries the GYM sign twice.
|
||||
"gym_large": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 10, 34, 47, 63, 34, 34, 47, 63, 34, 10, 10, 40, 41],
|
||||
[15, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, 12, 10, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B01: the commonest drawing in the game at 19 placements, and every
|
||||
# one of them scenery: a gabled block with two window courses and no
|
||||
# door. Red's house's roof band, tile for tile, but no awning under
|
||||
# it.
|
||||
"gabled_block_4x3": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 7, 7, 7, 7, 8, 9],
|
||||
[21, 22, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 10, 10, 10, 40, 41],
|
||||
[15, 34, 34, 34, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 31],
|
||||
[78, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B04: the little 4x2 cottage, 12 placements and nearly all of them
|
||||
# somebody's home: Mr Fuji's, the Cubone house, Bill's grandpa's,
|
||||
# the Name Rater's, the Viridian school house, the Route 8
|
||||
# underground path.
|
||||
"gabled_cottage": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 7, 7, 7, 7, 8, 9],
|
||||
[21, 22, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 11, 12, 10, 10, 40, 41],
|
||||
[78, 26, 27, 28, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B17: the wide 6x2 house: Cerulean's badge, trade and trashed
|
||||
# houses.
|
||||
"gabled_house_wide": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 7, 7, 7, 7, 7, 7, 7, 7, 8, 9],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 11, 12, 35, 10, 10, 35, 10, 10, 40, 41],
|
||||
[78, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B11: the 6x2 scenery block, 5 placements, no door.
|
||||
"gabled_block_6x2": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 7, 7, 7, 7, 7, 7, 7, 7, 8, 9],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 34, 35, 10, 10, 35, 10, 10, 40, 41],
|
||||
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B34: the 4x2 scenery block, one placement in Fuchsia.
|
||||
"gabled_block_4x2": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 7, 7, 7, 7, 8, 9],
|
||||
[21, 22, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 34, 10, 10, 40, 41],
|
||||
[78, 26, 26, 26, 26, 26, 26, 79],
|
||||
],
|
||||
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B33: the Route 5 day care.
|
||||
"daycare": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 83, 83, 83, 83, 8, 9],
|
||||
[21, 56, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 10, 10, 10, 10, 40, 41],
|
||||
[15, 34, 34, 34, 34, 34, 34, 31],
|
||||
[15, 10, 10, 10, 11, 12, 10, 31],
|
||||
[78, 26, 26, 26, 27, 28, 26, 79],
|
||||
],
|
||||
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
|
||||
slab=4, front_eave=4, ledge=None,
|
||||
),
|
||||
# B26: the Route 10 scenery block, structurally the museum's twin.
|
||||
# Needs `seal`: its drawing has no black base course, so unsealed
|
||||
# the flood climbs in from the south border and hollows the wall out
|
||||
# (72% surviving in 65 pieces, against 95% in one).
|
||||
"gabled_block_6x6": dict(
|
||||
tiles=[
|
||||
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
|
||||
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
|
||||
[37, 38, 34, 34, 34, 34, 34, 34, 34, 34, 40, 41],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
|
||||
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
|
||||
],
|
||||
roof_rows=64, roof_back=8, roof_front=8, roof_cycle=(8, 31),
|
||||
slab=4, front_eave=4, ledge=None, seal="s",
|
||||
),
|
||||
# B12: the Safari Zone rest houses. A corrugated roof over a plank
|
||||
# facade; the stripe repeats every 5 rows, not the OVERWORLD
|
||||
# lattice's 8.
|
||||
"safari_rest_house": dict(
|
||||
tiles=[
|
||||
[ 8, 9, 9, 9, 9, 9, 9, 12],
|
||||
[24, 25, 25, 25, 25, 25, 25, 28],
|
||||
[40, 41, 42, 43, 1, 1, 41, 44],
|
||||
[56, 41, 58, 59, 41, 41, 41, 60],
|
||||
],
|
||||
roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9),
|
||||
slab=4, front_eave=4, ledge=None, tileset="forest",
|
||||
),
|
||||
# B23: the Victory Road entrance on Route 23: a rock face with two
|
||||
# barred doors. The roof band is the pale cliff top seen from above.
|
||||
"victory_road_gate": dict(
|
||||
tiles=[
|
||||
[37, 38, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 37, 38],
|
||||
[40, 41, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 40, 41],
|
||||
[21, 22, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 15, 15, 15, 15, 15, 15, 21, 22],
|
||||
[ 5, 6, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 15, 15, 15, 15, 15, 15, 5, 6],
|
||||
[ 5, 6, 15, 15, 15, 15, 15, 15, 11, 12, 15, 15, 15, 15, 15, 15, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 11, 12, 15, 15, 15, 15, 5, 6],
|
||||
[21, 22, 14, 14, 14, 14, 14, 14, 27, 28, 14, 14, 14, 14, 14, 14, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 27, 28, 14, 14, 14, 14, 21, 22],
|
||||
],
|
||||
roof_rows=16, roof_back=7, roof_front=8, roof_cycle=(9, 12),
|
||||
slab=4, front_eave=4, ledge=None, tileset="plateau",
|
||||
),
|
||||
}
|
||||
|
||||
# a recess is a window or a doorway: a non-black region the art seals off
|
||||
# behind its own black frame. Anything wider or taller than this is a band
|
||||
# of the facade itself (siding courses, the awning's grey field).
|
||||
RECESS_MAX = 24
|
||||
|
||||
|
||||
# --------------------------------------------------------------- stage 1 --
|
||||
def sprite(tiles, seal="", tileset="overworld"):
|
||||
"""Composite the building and read it the way Structures reads the map:
|
||||
palette index per pixel plus the light-only silhouette flood.
|
||||
|
||||
`seal` names the sides the drawing runs off (a string of n/s/e/w). The
|
||||
flood does not seed there: a drawing trimmed flush to its art, whose
|
||||
base course is brick rather than the black threshold every other
|
||||
building stands on, would otherwise be hollowed out through its own
|
||||
mortar."""
|
||||
atlas = Image.open(os.path.join(TILESETS, tileset + ".png")).convert("RGB")
|
||||
h, w = len(tiles), len(tiles[0])
|
||||
W, H = w * 8, h * 8
|
||||
im = Image.new("RGB", (W, H))
|
||||
for r, row in enumerate(tiles):
|
||||
for c, t in enumerate(row):
|
||||
ax, ay = (t % PER_ROW) * 8, (t // PER_ROW) * 8
|
||||
im.paste(atlas.crop((ax, ay, ax + 8, ay + 8)), (c * 8, r * 8))
|
||||
px = im.load()
|
||||
|
||||
counts = Counter(px[x, y] for y in range(H) for x in range(W))
|
||||
lum = lambda c: 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
|
||||
pal = sorted(counts, key=lambda c: -lum(c)) # white, grey, dark, black
|
||||
assert len(pal) == 4, pal
|
||||
idx = {c: i for i, c in enumerate(pal)}
|
||||
col = [[idx[px[x, y]] for x in range(W)] for y in range(H)]
|
||||
|
||||
# The flood spreads only through LIGHT pixels: the black outline and the
|
||||
# #555 shading together are the boundary. A "not black" threshold lets it
|
||||
# eat the shaded flanks and the silhouette collapses.
|
||||
out = [[False] * W for _ in range(H)]
|
||||
q = deque()
|
||||
|
||||
def seed(x, y):
|
||||
if not out[y][x] and col[y][x] <= GREY:
|
||||
out[y][x] = True
|
||||
q.append((x, y))
|
||||
|
||||
for x in range(W):
|
||||
if "n" not in seal:
|
||||
seed(x, 0)
|
||||
if "s" not in seal:
|
||||
seed(x, H - 1)
|
||||
for y in range(H):
|
||||
if "w" not in seal:
|
||||
seed(0, y)
|
||||
if "e" not in seal:
|
||||
seed(W - 1, y)
|
||||
while q:
|
||||
x, y = q.popleft()
|
||||
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
||||
if 0 <= nx < W and 0 <= ny < H:
|
||||
seed(nx, ny)
|
||||
|
||||
# source texel per pixel, so every voxel can name where its colour came from
|
||||
src = [[((tiles[y // 8][x // 8] % PER_ROW) * 8 + x % 8,
|
||||
(tiles[y // 8][x // 8] // PER_ROW) * 8 + y % 8)
|
||||
for x in range(W)] for y in range(H)]
|
||||
return dict(W=W, H=H, col=col, out=out, src=src, pal=pal)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- stage 2 --
|
||||
def profile(sp, t):
|
||||
"""Everything the band table implies, measured off the mask."""
|
||||
W, H = sp["W"], sp["H"]
|
||||
inside = lambda x, y: 0 <= x < W and 0 <= y < H and not sp["out"][y][x]
|
||||
|
||||
# the taper IS the slope: the topmost drawn row of each column
|
||||
top = []
|
||||
for x in range(W):
|
||||
r = next((y for y in range(H) if inside(x, y)), t["roof_rows"])
|
||||
top.append(min(r, t["roof_rows"]))
|
||||
|
||||
wall_h = H - t["roof_rows"]
|
||||
ytop = wall_h - 1 + t["slab"]
|
||||
|
||||
# Recesses: the panes the art seals behind a black frame. Non-black
|
||||
# pixels of the facade split into components across the black outline;
|
||||
# a component small enough to be a window or a doorway sinks one voxel.
|
||||
wall_y0 = t["roof_rows"]
|
||||
comp = {}
|
||||
recess = set()
|
||||
for sy in range(wall_y0, H):
|
||||
for sx in range(W):
|
||||
if (sx, sy) in comp or not inside(sx, sy) or sp["col"][sy][sx] == BLACK:
|
||||
continue
|
||||
cells, stack = [], [(sx, sy)]
|
||||
comp[(sx, sy)] = True
|
||||
x0 = x1 = sx
|
||||
y0 = y1 = sy
|
||||
while stack:
|
||||
cx, cy = stack.pop()
|
||||
cells.append((cx, cy))
|
||||
x0, x1 = min(x0, cx), max(x1, cx)
|
||||
y0, y1 = min(y0, cy), max(y1, cy)
|
||||
for nx, ny in ((cx + 1, cy), (cx - 1, cy),
|
||||
(cx, cy + 1), (cx, cy - 1)):
|
||||
if (ny >= wall_y0 and (nx, ny) not in comp
|
||||
and inside(nx, ny)
|
||||
and sp["col"][ny][nx] != BLACK):
|
||||
comp[(nx, ny)] = True
|
||||
stack.append((nx, ny))
|
||||
if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX:
|
||||
recess.update(cells)
|
||||
|
||||
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
|
||||
inside=inside, D=H, W=W, H=H)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- stage 3 --
|
||||
def build(sp, pr, t):
|
||||
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
|
||||
the roof solid overwrites what it intersects and the walls are trimmed
|
||||
to the roof's underside."""
|
||||
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||
inside, top, ytop = pr["inside"], pr["top"], pr["ytop"]
|
||||
col, src = sp["col"], sp["src"]
|
||||
slab = t["slab"]
|
||||
|
||||
def T(x):
|
||||
return ytop - top[max(0, min(W - 1, x))]
|
||||
|
||||
def interior(sx, sy):
|
||||
"""Side faces must not be slabs of outline black: walk inward for the
|
||||
first painted colour, the way the drawing's own shading does."""
|
||||
if col[sy][sx] != BLACK:
|
||||
return sx
|
||||
step = 1 if sx < W // 2 else -1
|
||||
for d in range(1, 4):
|
||||
nx = sx + step * d
|
||||
if inside(nx, sy) and col[sy][nx] != BLACK:
|
||||
return nx
|
||||
return sx
|
||||
|
||||
# the roof's drawn span: a sprite inset from its box leaves outer
|
||||
# columns undrawn in the roof band, and they carry no roof at all
|
||||
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
|
||||
x0d, x1d = (roofed[0], roofed[-1]) if roofed else (0, W - 1)
|
||||
|
||||
def trimmed(x, y):
|
||||
"""Under the roof's underside -- a column with no roof over it has
|
||||
no underside, and must not be cut by a profile never drawn."""
|
||||
return top[max(0, min(W - 1, x))] < t["roof_rows"] and y > T(x) - slab
|
||||
|
||||
vox = {}
|
||||
|
||||
def put(x, y, z, sx, sy):
|
||||
vox[(x, y, z)] = (col[sy][sx], src[sy][sx])
|
||||
|
||||
# ---- walls: the facade rows extruded straight back, trimmed under the roof
|
||||
for sy in range(t["roof_rows"], H):
|
||||
y = H - 1 - sy
|
||||
for sx in range(W):
|
||||
if not inside(sx, sy) or trimmed(sx, y):
|
||||
continue
|
||||
ix = interior(sx, sy)
|
||||
for z in range(D):
|
||||
if z == 0 or z == D - 1:
|
||||
put(sx, y, z, sx, sy)
|
||||
else:
|
||||
put(sx, y, z, ix, sy)
|
||||
|
||||
# a base course: the drawing's last row is the ground the house stands
|
||||
# on, so without this the wall floats one voxel over its own plot
|
||||
for x in range(W):
|
||||
for z in range(D):
|
||||
if (x, 0, z) not in vox and (x, 1, z) in vox:
|
||||
vox[(x, 0, z)] = vox[(x, 1, z)]
|
||||
|
||||
# ---- ledge: the awning slab juts two voxels past the walls
|
||||
if t["ledge"]:
|
||||
l0, l1 = t["ledge"]
|
||||
for sy in range(l0, l1 + 1):
|
||||
y = H - 1 - sy
|
||||
for sx in range(W):
|
||||
if inside(sx, sy) and not trimmed(sx, y):
|
||||
for z in (-2, -1, D, D + 1):
|
||||
put(sx, y, z, sx, sy)
|
||||
|
||||
# ---- recesses: the front voxel of every pane sinks, its frame stays proud
|
||||
for sx, sy in pr["recess"]:
|
||||
vox.pop((sx, H - 1 - sy, D - 1), None)
|
||||
|
||||
# ---- roof: flat top over the plateau, stepped diagonal ends
|
||||
z0, z1 = 0, D - 1 + t["front_eave"]
|
||||
back, front = t["roof_back"], t["roof_front"]
|
||||
c0, c1 = t["roof_cycle"]
|
||||
|
||||
def roof_sy(z):
|
||||
df, db = z - z0, z1 - z # from the north / the south edge
|
||||
if df < back:
|
||||
return df # north rim: the drawing's top rows
|
||||
if db < front:
|
||||
return t["roof_rows"] - 1 - db # south rim: fascia and eave course
|
||||
return c0 + (df - c0) % (c1 - c0 + 1)
|
||||
|
||||
shade_px = {}
|
||||
for sy in range(H):
|
||||
for sx in range(W):
|
||||
if inside(sx, sy):
|
||||
shade_px.setdefault(col[sy][sx], (sx, sy))
|
||||
|
||||
for x in roofed:
|
||||
tt = T(x)
|
||||
for z in range(z0, z1 + 1):
|
||||
outer = x == x0d or x == x1d or z == z0 or z == z1
|
||||
# the slope's texture is the drawing's own: clamping into the
|
||||
# column's first drawn row keeps flank battens running down the
|
||||
# slope instead of falling off the silhouette
|
||||
sy = max(roof_sy(z), pr["top"][x])
|
||||
for y in range(tt - slab + 1, tt + 1):
|
||||
if y == tt and not outer:
|
||||
put(x, y, z, x, sy)
|
||||
else:
|
||||
# the rim: the drawing's own eave -- a black outline
|
||||
# over a shaded fascia, closed by the outline again
|
||||
if not outer:
|
||||
shade = DARK
|
||||
elif y == tt or y == tt - slab + 1:
|
||||
shade = BLACK
|
||||
else:
|
||||
shade = DARK
|
||||
px = shade_px.get(shade) or shade_px[BLACK]
|
||||
put(x, y, z, px[0], px[1])
|
||||
return vox
|
||||
|
||||
|
||||
# --------------------------------------------------------------- stage 5 --
|
||||
def verify(vox, sp, pr, t):
|
||||
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||
ytop, top, slab = pr["ytop"], pr["top"], t["slab"]
|
||||
T = lambda x: ytop - top[max(0, min(W - 1, x))]
|
||||
|
||||
for (x, y, z), _ in vox.items():
|
||||
assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}"
|
||||
|
||||
# The roof surface reads over the columns the drawing actually paints:
|
||||
# a sprite inset from its box says nothing about the rest.
|
||||
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
|
||||
assert roofed, "no roof band is drawn"
|
||||
prof = [T(x) for x in roofed]
|
||||
assert prof == prof[::-1], "the roof is not symmetric"
|
||||
plateau = [i for i, v in enumerate(prof) if v == ytop]
|
||||
assert plateau, "no flat top"
|
||||
|
||||
if max(top[x] for x in roofed) > 1:
|
||||
# a slope: eave tip, one step per N columns, flat plateau
|
||||
for i in range(1, plateau[0]):
|
||||
assert 0 <= prof[i] - prof[i - 1] <= 1, "the taper is not monotonic"
|
||||
# the taper rate the drawing sets is the slope: one step per N
|
||||
# columns, the same N the whole way down to the eave tip
|
||||
steps = [i for i in range(1, plateau[0] + 1) if prof[i] != prof[i - 1]]
|
||||
# a rate needs two steps to be a rate. One step is a lip, not a
|
||||
# slope, and there is nothing to hold it to.
|
||||
if len(steps) >= 2:
|
||||
rate = steps[1] - steps[0]
|
||||
assert all(b - a == rate for a, b in zip(steps, steps[1:])), \
|
||||
f"the slope is not a constant {rate}:1"
|
||||
assert prof[0] == ytop - (plateau[0] + rate - 1) // rate, \
|
||||
"the eave tip does not land where the drawn taper ends"
|
||||
else:
|
||||
# flat: one level roof the whole drawn span, give or take the
|
||||
# drawing's own corner rounding
|
||||
assert all(ytop - v <= 1 for v in prof), "the flat roof is not level"
|
||||
|
||||
# every wall column carries roof over it -- and a column the roof never
|
||||
# reaches carries nothing at all, rather than being silently trimmed away
|
||||
over = set(roofed)
|
||||
for x in range(W):
|
||||
for z in range(D):
|
||||
if x not in over:
|
||||
assert not any((x, y, z) in vox for y in range(ytop + 1)), \
|
||||
f"wall stands where no roof reaches at {x},{z}"
|
||||
elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)):
|
||||
assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}"
|
||||
|
||||
shell = [k for k in vox if not all(
|
||||
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
|
||||
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
|
||||
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
|
||||
return shell
|
||||
|
||||
|
||||
def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20):
|
||||
# Mirroring depth swings the camera round to the other side. Mirror
|
||||
# about the model's own depth range, not a fixed one, or a building
|
||||
# deeper or shallower than the first two walks off the canvas.
|
||||
zs = [z for _, _, z in shell]
|
||||
zlo, zhi = min(zs), max(zs)
|
||||
pts = [(x, y, (zlo + zhi - z) if flip else z, vox[(x, y, z)][0])
|
||||
for (x, y, z) in shell]
|
||||
pts.sort(key=lambda p: (p[0] + p[2], p[1]))
|
||||
|
||||
# Fit the projection to the model: Silph Co is four times the height of
|
||||
# Red's house and would otherwise render off the edge.
|
||||
px = lambda x, z: 2 * (x - z)
|
||||
py = lambda x, y, z: (x + z) - 2 * y
|
||||
xs = [px(x, z) for x, _, z, _ in pts] + [px(x + 1, z + 1) for x, _, z, _ in pts]
|
||||
ys = [py(x, y, z) for x, y, z, _ in pts] + [py(x + 1, y + 1, z + 1)
|
||||
for x, y, z, _ in pts]
|
||||
W, H = canvas
|
||||
S = max(1, min((W - 2 * pad) // max(1, max(xs) - min(xs)),
|
||||
(H - 2 * pad) // max(1, max(ys) - min(ys))))
|
||||
ox = pad - min(xs) * S + (W - 2 * pad - (max(xs) - min(xs)) * S) // 2
|
||||
oy = pad - min(ys) * S + (H - 2 * pad - (max(ys) - min(ys)) * S) // 2
|
||||
P = lambda x, y, z: (px(x, z) * S + ox, py(x, y, z) * S + oy)
|
||||
img = Image.new("RGB", canvas, (0xca, 0xdc, 0x9f))
|
||||
dr = ImageDraw.Draw(img)
|
||||
has = {(x, y, z) for x, y, z, _ in pts}
|
||||
sh = lambda c, f: tuple(int(v * f) for v in sp["pal"][c])
|
||||
for x, y, z, c in pts:
|
||||
if (x, y + 1, z) not in has:
|
||||
dr.polygon([P(x, y + 1, z), P(x + 1, y + 1, z),
|
||||
P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=sh(c, 1.0))
|
||||
if (x + 1, y, z) not in has:
|
||||
dr.polygon([P(x + 1, y, z), P(x + 1, y + 1, z),
|
||||
P(x + 1, y + 1, z + 1), P(x + 1, y, z + 1)], fill=sh(c, 0.62))
|
||||
if (x, y, z + 1) not in has:
|
||||
dr.polygon([P(x, y, z + 1), P(x + 1, y, z + 1),
|
||||
P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=sh(c, 0.82))
|
||||
img.save(os.path.join(out, name))
|
||||
|
||||
|
||||
def main():
|
||||
out = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
os.makedirs(out, exist_ok=True)
|
||||
for name, t in TEMPLATES.items():
|
||||
sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld"))
|
||||
pr = profile(sp, t)
|
||||
vox = build(sp, pr, t)
|
||||
shell = verify(vox, sp, pr, t)
|
||||
print(f"{name}: {sp['W']}x{sp['H']} sprite, depth {pr['D']}, "
|
||||
f"height {pr['ytop'] + 1} -> voxels {len(vox)} "
|
||||
f"shell {len(shell)} recessed {len(pr['recess'])}")
|
||||
preview(shell, vox, sp, f"{name}_front.png", out, True)
|
||||
preview(shell, vox, sp, f"{name}_back.png", out, False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user