ajhahn.de
← the-way-out
Python 155 lines
"""Single source of truth for the level list.

Built-in levels come from ``assets/levels/manifest.json``. Custom levels
written by the in-game editor are discovered as ``*.txt`` files in
``~/.the-way-out/custom_levels/`` and appended at the end of the list.

Each level has a stable string id (``"level_1"`` for built-ins,
``"custom_<name>"`` for user-built). The id is what :mod:`save` records
as completed and what :meth:`LevelManager.load_level` takes — so adding
a built-in level or saving one in the editor needs no code changes
anywhere else.
"""

import json
import os
from dataclasses import dataclass
from pathlib import Path

import tileset
from settings import SAVE_DIR

CUSTOM_DIR = SAVE_DIR / "custom_levels"
MANIFEST_PATH = Path("assets/levels/manifest.json")


@dataclass(frozen=True)
class LevelEntry:
    """One playable level — built-in or user-built.

    ``id``     stable handle used by save.py and the level menu.
    ``file``   path (working-dir relative for built-ins, absolute for
               custom) to the level's .txt.
    ``title``  short header (e.g. "LEVEL 1").
    ``tagline`` one-liner under the title.
    ``custom`` True for editor-written levels; the level menu uses this
               to mark them visually so a player can tell them apart.
    ``music``  optional background-track name under
               ``assets/audio/music/`` (no extension); None = silent.
    ``floor_tile`` / ``wall_tile`` optional per-level tileset PNG names
               (under ``assets/tileset/tiles/``); None = the global
               ``tileset.FLOOR_TILE`` / ``WALL_TILE`` default.
    """
    id: str
    file: str
    title: str
    tagline: str
    custom: bool
    music: str | None = None
    floor_tile: str | None = None
    wall_tile: str | None = None


def _load_manifest():
    """Built-in levels from manifest.json. Empty list on any IO error so
    a missing/corrupt manifest never crashes the menu."""
    try:
        with open(MANIFEST_PATH) as f:
            data = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return []
    # Valid JSON that isn't the expected shape (top level not an object,
    # "levels" not a list, an entry not a dict) must also degrade to an
    # empty list, not raise — the docstring promises the menu never
    # crashes on a corrupt manifest, and AttributeError/TypeError from
    # the wrong shape aren't caught above.
    if not isinstance(data, dict):
        return []
    levels = data.get("levels", [])
    if not isinstance(levels, list):
        return []
    out = []
    for raw in levels:
        try:
            out.append(LevelEntry(
                id=raw["id"],
                file=os.path.join("assets/levels", raw["file"]),
                title=raw.get("title", raw["id"]),
                tagline=raw.get("tagline", ""),
                custom=False,
                music=raw.get("music"),
                floor_tile=raw.get("floor_tile"),
                wall_tile=raw.get("wall_tile")))
        except (KeyError, TypeError):
            continue
    return out


def read_custom_theme(txt_path):
    """Theme id from a custom map's ``<name>.json`` sidecar.

    Returns ``tileset.DEFAULT_THEME`` when there is no sidecar, it can't
    be read, or it isn't the expected shape — defensive in the same
    spirit as :func:`_load_manifest`, so a stray/corrupt sidecar never
    breaks the level list."""
    sidecar = Path(txt_path).with_suffix(".json")
    try:
        with open(sidecar) as f:
            data = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return tileset.DEFAULT_THEME
    if not isinstance(data, dict):
        return tileset.DEFAULT_THEME
    theme_id = data.get("theme", tileset.DEFAULT_THEME)
    return theme_id if isinstance(theme_id, str) else tileset.DEFAULT_THEME


def _scan_custom():
    """Levels saved by the editor under ``~/.the-way-out/custom_levels/``.

    The file name (without extension) becomes the human-readable title
    and is also part of the id, so renaming a file = a "new" level for
    the save system. A map's visual theme comes from its ``<name>.json``
    sidecar (see :func:`read_custom_theme`); an un-themed map resolves to
    the default tiles and looks unchanged."""
    if not CUSTOM_DIR.exists():
        return []
    out = []
    for path in sorted(CUSTOM_DIR.glob("*.txt")):
        name = path.stem
        floor_tile, wall_tile = tileset.theme_tiles(read_custom_theme(path))
        out.append(LevelEntry(
            id=f"custom_{name}",
            file=str(path),
            title=name.replace("_", " ").title(),
            tagline="Custom",
            custom=True,
            music="default",
            floor_tile=floor_tile,
            wall_tile=wall_tile))
    return out


def load_catalog():
    """Return every playable level in display order:
    built-ins (manifest order) followed by custom levels (alphabetical)."""
    return _load_manifest() + _scan_custom()


def find(level_id):
    """Look up a level by id. Returns ``None`` if it's not in the
    catalog (e.g. user deleted a custom file)."""
    for entry in load_catalog():
        if entry.id == level_id:
            return entry
    return None


def ensure_custom_dir():
    """Make sure the custom-level directory exists; safe to call any
    time."""
    try:
        CUSTOM_DIR.mkdir(parents=True, exist_ok=True)
    except OSError:
        pass