ajhahn.de
← the-way-out
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]