Python 163 lines
"""Tileset asset loader.
Every floor/wall tile and every furniture/decoration object the level
text files can place comes from ``assets/tileset/`` through here. The
art is tiny pixel-art (16x16 tiles, sub-tile props); this module scales
each piece up to one ``TILE_SIZE`` cell, caches the result, and hands
back a ready-to-blit surface. One surface per (category, variant) is
reused for every placement, mirroring the cheap-allocation approach in
``static_objects.TileTextures``.
Built lazily on first use, so it is safe to import before the pygame
display exists (the actual loads happen during ``load_level``).
"""
import os
import pygame
from settings import TILE_SIZE
TS = TILE_SIZE
_BASE = os.path.join("assets", "tileset")
# --- baked floor / wall layer -------------------------------------------
# Which Tile_XX.png the level's baked map surface uses. These are the
# only two values to tweak to restyle the dungeon floor/walls. To see
# the choices, open assets/tileset/tiles/ (or the full sheet at
# assets/tileset/Tileset.png / Palette.png) and put the file's number
# here. If a name fails to load the level falls back to the old
# procedural stone look automatically, so a bad value is harmless.
FLOOR_TILE = "Tile_42"
WALL_TILE = "Tile_03"
# --- map themes ---------------------------------------------------------
# Named floor/wall presets the editor's theme picker offers per custom
# map. Each is (id, display name, floor Tile_XX, wall Tile_XX). The id is
# what gets written to the map's sidecar JSON; ``"keep"`` reuses the
# global FLOOR_TILE/WALL_TILE above so an un-themed map looks unchanged.
# A bad tile name is harmless — ``tile`` returns None and the level
# falls back to the procedural stone look.
DEFAULT_THEME = "keep"
THEMES = [
("keep", "Keep", FLOOR_TILE, WALL_TILE),
("foundry", "Foundry", "Tile_53", "Tile_34"),
("cellar", "Cellar", "Tile_30", "Tile_15"),
("archive", "Archive", "Tile_44", "Tile_05"),
("frost", "Frost", "Tile_48", "Tile_07"),
]
def theme_tiles(theme_id):
"""``(floor_tile, wall_tile)`` for a theme id, falling back to
``DEFAULT_THEME`` for an unknown or missing id."""
for tid, _name, floor, wall in THEMES:
if tid == theme_id:
return floor, wall
for tid, _name, floor, wall in THEMES:
if tid == DEFAULT_THEME:
return floor, wall
return FLOOR_TILE, WALL_TILE
# --- placeable objects --------------------------------------------------
# map letter -> (folder under assets/tileset, filename pattern with
# "{n}" for the variant, variant count, solid?). ``solid`` props join
# the obstacle group (you can't walk through them); the rest are pure
# decoration drawn under the player. The letter -> category mapping
# lives in levels.py (PROP_CHARS); keep the two in sync with LEGEND.md.
CATEGORIES = {
"torch": ("static_objects/torches", "{n}.png", 8, False),
"chair": ("static_objects/chairs", "{n}.png", 14, False),
"table": ("static_objects/tables", "{n}.png", 8, True),
"shelf": ("static_objects/bookshelf", "{n}.png", 12, True),
"decor": ("static_objects/bookshelf_decor", "{n}.png", 40, False),
"box": ("static_objects/boxes", "{n}.png", 16, True),
"rubble": ("static_objects/blockage", "{n}.png", 8, True),
"misc": ("static_objects/other", "{n}.png", 44, False),
"door": ("static_objects/doors", "{n}.png", 4, True),
"trapdoor": ("static_objects/trapdoors", "{n}.png", 6, False),
"chest": ("interactables", "Chest{n}_S.png", 2, True),
"fire": ("interactables", "Fire1.png", 1, False),
}
_cache = {}
def _placeholder():
"""Magenta block for a missing/unknown asset — loud on purpose."""
if "__ph__" not in _cache:
s = pygame.Surface((TS, TS), pygame.SRCALPHA)
s.fill((180, 40, 160))
pygame.draw.rect(s, (0, 0, 0), s.get_rect(), 2)
_cache["__ph__"] = s
return _cache["__ph__"]
def _fit(surf):
"""Scale a small pixel-art sprite up to sit in one tile, keeping
aspect ratio (nearest-neighbour, anchored bottom-centre so the
object visually rests on the floor)."""
w, h = surf.get_size()
scale = TS / max(w, h)
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
img = pygame.transform.scale(surf, (nw, nh))
cell = pygame.Surface((TS, TS), pygame.SRCALPHA)
cell.blit(img, ((TS - nw) // 2, TS - nh))
return cell
def tile(name):
"""A floor/wall tile scaled to exactly one cell, or None if the
named PNG is missing (caller then uses the procedural fallback)."""
key = ("tile", name)
if key not in _cache:
path = os.path.join(_BASE, "tiles", name + ".png")
try:
s = pygame.image.load(path).convert_alpha()
_cache[key] = pygame.transform.scale(s, (TS, TS))
except (pygame.error, FileNotFoundError):
_cache[key] = None
return _cache[key]
def is_solid(category):
cat = CATEGORIES.get(category)
return bool(cat and cat[3])
def variant_count(category):
cat = CATEGORIES.get(category)
return cat[2] if cat else 0
def sprite(category, variant=1):
"""One tile-sized surface for ``category``/``variant`` (1-based).
Out-of-range or non-numeric variants clamp to 1. Unknown category
or a missing file yields the magenta placeholder."""
cat = CATEGORIES.get(category)
if cat is None:
return _placeholder()
sub, pattern, count, _solid = cat
n = variant if isinstance(variant, int) and 1 <= variant <= count else 1
key = (category, n)
if key in _cache:
return _cache[key]
path = os.path.join(_BASE, sub, pattern.format(n=n))
try:
raw = pygame.image.load(path).convert_alpha()
if category == "fire":
# Fire1.png is a horizontal animation strip; the first
# square frame is enough for a static torch-fire decoration.
h = raw.get_height()
raw = raw.subsurface((0, 0, h, h)).copy()
img = _fit(raw)
except (pygame.error, FileNotFoundError, ValueError):
img = _placeholder()
_cache[key] = img
return img