Python 152 lines
"""The level tile vocabulary — one source of truth.
Every character that can appear in a level's ``.txt`` has a
:class:`TileSpec` here. ``levels.py`` reads it for prop dispatch at load
time, and ``editor.py`` reads the same registry to build its palette.
So adding a new tile is one entry here (and at most one branch in
``load_level``), never a triple-edit across files.
Category vocabulary used by the editor's palette grouping:
* ``terrain`` — wall, floor: structural cells
* ``special`` — player spawn, exit: singletons the level needs exactly
one of
* ``hazard`` — spikes, levers, plates, gates, key: escape-room
interactables
* ``enemy`` — boss (and any future enemies)
* ``prop`` — tileset furniture/decor (with variants)
"""
from dataclasses import dataclass
import tileset
from units import ENEMY_INFO
@dataclass(frozen=True)
class TileSpec:
"""Metadata for one map character.
``tileset_category`` is non-``None`` when the tile draws from the
art tileset — ``solid`` and ``variant_count`` then mirror
``tileset.CATEGORIES``. Otherwise the runtime renders the tile
procedurally (walls, spikes, levers, ...).
"""
char: str
label: str
category: str
description: str
solid: bool = False
variant_count: int = 1
tileset_category: str | None = None
# Prop letters → tileset category. The only duplication left between
# this module and ``tileset.CATEGORIES``; everything else (variant count,
# solid flag) is read from tileset at registry build time.
_PROP_MAPPING = {
'T': ('torch', "Torch"),
'C': ('chair', "Chair"),
'A': ('table', "Table"),
'E': ('shelf', "Bookshelf"),
'D': ('decor', "Bookshelf decor"),
'O': ('box', "Box / crate"),
'R': ('rubble', "Rubble"),
'M': ('misc', "Misc clutter"),
'Z': ('door', "Door"),
'J': ('trapdoor', "Trapdoor"),
'H': ('chest', "Chest"),
'F': ('fire', "Fire"),
}
def _build_registry():
reg = {}
# --- terrain --------------------------------------------------------
reg['W'] = TileSpec(
'W', "Wall", 'terrain', "Solid wall, blocks movement",
solid=True)
reg['.'] = TileSpec(
'.', "Floor", 'terrain', "Walkable cell (default)")
# --- special (singletons) -------------------------------------------
reg['P'] = TileSpec(
'P', "Player start", 'special',
"Where the chosen character spawns. Use exactly one.")
reg['X'] = TileSpec(
'X', "Exit", 'special',
"The way out — opens after boss/key conditions are met.")
# --- hazards / puzzles ----------------------------------------------
reg['S'] = TileSpec(
'S', "Spikes", 'hazard',
"Timed trap (safe → warning → deadly cycle).")
# L/Y/G carry an optional pair id in their trailing digit. The
# variant_count is what the editor wheel cycles: 1 = pair by
# reading order (writes a bare token), 2..9 = explicitly pair the
# trigger with the gate of the same number.
reg['L'] = TileSpec(
'L', "Lever", 'hazard',
"Pull with E. Wheel: 1 = pair by order, 2-9 = pair with the "
"gate of that number.",
variant_count=9)
reg['Y'] = TileSpec(
'Y', "Pressure plate", 'hazard',
"Stand on ~0.25 s. Wheel: 1 = pair by order, 2-9 = pair with "
"the gate of that number.",
variant_count=9)
reg['G'] = TileSpec(
'G', "Gate", 'hazard',
"Solid until its trigger fires; adjacent G = one panel. Wheel: "
"1 = pair by order, 2-9 = pair id.",
solid=True, variant_count=9)
reg['K'] = TileSpec(
'K', "Key", 'hazard',
"Walk over to pick up; required before the exit opens.")
# --- enemies --------------------------------------------------------
reg['B'] = TileSpec(
'B', "Boss (random general)", 'enemy',
"Spawns lazily the first time the player enters its arena. "
"Identity is picked deterministically from the level id.")
# Generic enemies are derived from units.ENEMY_INFO so adding one
# there makes it appear in the editor palette automatically.
for ch, _cls, label in ENEMY_INFO:
reg[ch] = TileSpec(
ch, label, 'enemy',
"Roaming enemy — chases the player and deals contact "
"damage. Spawns at once; does not block the exit.")
# --- tileset props --------------------------------------------------
for ch, (cat, label) in _PROP_MAPPING.items():
meta = tileset.CATEGORIES.get(cat)
if meta is None:
continue
_folder, _pattern, count, solid = meta
reg[ch] = TileSpec(
ch, label, 'prop',
f"{label} ({count} variant{'s' if count > 1 else ''}).",
solid=solid, variant_count=count, tileset_category=cat)
return reg
REGISTRY = _build_registry()
# Palette category order used by the editor. REGISTRY insertion order
# is preserved within each category (CPython dicts are ordered).
PALETTE_CATEGORIES = ('terrain', 'special', 'hazard', 'enemy', 'prop')
# Back-compat shim for ``levels.py``: char -> tileset category, for the
# prop branch of the level-loading switch. Derived so editing REGISTRY
# is the only place to add a prop letter.
PROP_CHARS = {ch: spec.tileset_category
for ch, spec in REGISTRY.items()
if spec.tileset_category is not None}
def chars_for(category):
"""All characters in one palette category, in REGISTRY order."""
return [ch for ch, spec in REGISTRY.items() if spec.category == category]