"""In-game level editor. A pygame-native palette → canvas editor that writes ``.txt`` files in the same tokenised format the runtime loads. Output goes to ``~/.the-way-out/custom_levels/.txt`` and is picked up by :mod:`level_catalog` automatically, so a fresh level appears in the level menu the next time it's opened. Layout (anchored to the screen size, scales with it): * **Canvas** on the left: scrolling grid, the level being authored. * **Palette** on the right: a narrow strip of tile thumbnails grouped by category (terrain, special, hazard, enemy, prop). Click to select. A preview panel below the grid shows the selected tile at a large size; its < > buttons (or the mouse wheel) cycle the variant. * **Toolbar** at the bottom: file name (click to rename), grid size, Save / Test / Clear buttons. Controls (also displayed in the toolbar/hint): * LMB / RMB: place selected tile / clear to floor * Shift + LMB/RMB drag: box-fill / box-erase a rectangle * Q (or Pick): eyedropper — next canvas click adopts that cell * Border: re-wall the outer ring * Mouse wheel: cycle variant on the selected tile * WASD / arrows: pan camera * Esc: back to main menu * F5 (or Test): save + launch the level * Ctrl+S (or Save): save only The editor is intentionally one self-contained file. It reuses ``tiles.REGISTRY`` for what tiles exist and ``tileset.sprite`` / ``interactables`` images for the canvas previews — so a new tile added to the registry shows up automatically in the palette. """ import json import re from pathlib import Path import pygame import level_catalog import theme import tileset from interactables import Gate, KeyItem, Lever, PressurePlate, Spikes from settings import TILE_SIZE from static_objects import TileTextures from tiles import PALETTE_CATEGORIES, REGISTRY, chars_for # Used both as a directory and as the legal-filename charset. SAFE_NAME = re.compile(r"[^a-zA-Z0-9_\-]+") MAX_NAME = 32 def sanitize(name): """Squash any non-portable characters out of a filename stem, collapse runs of underscores, trim, and clamp length so the user can paste anything weird and still get something writable.""" cleaned = SAFE_NAME.sub("_", name).strip("_") cleaned = re.sub(r"_+", "_", cleaned)[:MAX_NAME] return cleaned or "untitled" class LevelEditor: """The editor surface + state. One instance lives for the whole session; ``new_level`` / ``open_level`` reset the grid.""" # Palette layout. Cells are square thumbnails; the category headers # sit between them. CELL / CELL_GAP are sized so six columns fill # the narrow palette exactly: 6*CELL + 5*CELL_GAP == PALETTE_W - 2*PAD. # PALETTE_W is the expanded panel content width; PALETTE_GRIP_W is # the always-visible collapsed rail. Total panel width when the # drawer is expanded = PALETTE_GRIP_W + PALETTE_W. PALETTE_W = 330 PALETTE_GRIP_W = 44 PALETTE_PAD = 18 CELL = 44 CELL_GAP = 6 # Hover-drawer animation seconds (linear). 0.14 ≈ 8 frames at 60 fps. PALETTE_ANIM_TIME = 0.14 # Selected-tile preview panel: a large sprite plus the < > buttons # that step its variant. PREVIEW_SIZE = 64 VARIANT_BTN = 30 # Toolbar TOOLBAR_H = 110 # Load-picker modal: one list row's height. PICKER_ROW_H = 56 # Default grid for a fresh level (with auto-walled border) DEFAULT_COLS = 30 DEFAULT_ROWS = 20 # --- lifecycle ----------------------------------------------------- def __init__(self, width, height): self.width = width self.height = height # Fonts self.font = theme.font(36) self.label_font = theme.font(22) self.small_font = theme.font(18) self.hint_font = theme.font(20) self.head_font = theme.font(26) # Geometry — canvas fills everything left of the collapsed # palette rail and above the toolbar. The rail is the # always-visible portion of the drawer; the rest of the panel # slides in as an overlay over the canvas on hover, so the # canvas extent never reflows. palette_rect / _grip_rect are # placeholders here and get their real coordinates from # _layout_palette() once self._palette_anim is initialised. self.canvas_rect = pygame.Rect( 0, 0, width - self.PALETTE_GRIP_W, height - self.TOOLBAR_H) self.toolbar_rect = pygame.Rect( 0, height - self.TOOLBAR_H, width, self.TOOLBAR_H) self.palette_rect = pygame.Rect(0, 0, 0, 0) self._grip_rect = pygame.Rect(0, 0, 0, 0) # State that resets per-level self.grid = [] self.cols = 0 self.rows = 0 self.name = "my_level" self.cam_x = 0.0 self.cam_y = 0.0 self.message = "" self.message_timer = 0.0 # Palette / selection self.selected_char = 'W' self.selected_variant = 1 self._palette_rects = [] # list of (pygame.Rect, char) self._variant_btn_rects = {} # 'prev'/'next' -> pygame.Rect self._tile_thumb_cache = {} # (char, variant, size) -> Surface # Hover-drawer state: 0.0 = collapsed rail, 1.0 = fully expanded. # _layout_palette() projects this onto _grip_rect / palette_rect # so the existing _build_palette_layout() math (B25) keeps # working unchanged regardless of where the drawer sits. self._palette_anim = 0.0 self._palette_label_surf = None # cached vertical letter stack self._palette_label_col = None # last colour the cache was built for # Toolbar / buttons self._toolbar_rects = {} # name -> pygame.Rect self._name_rect = pygame.Rect(0, 0, 0, 0) self.editing_name = False # Drag-painting: while LMB or RMB is held, every frame where # the mouse is on a new cell paints/erases. self._mouse_buttons = [False, False, False] # left, middle, right self._last_painted_cell = None # 'paint' (default) or 'pick' (eyedropper, toggled with Q). self.tool = 'paint' # Shift+drag box fill/erase: anchor cell while the drag is live. self._box_start = None self._box_erase = False # ``request_test`` flips True when the user hits F5/Test; main # reads & clears it to switch into the game state. self.request_test = False self.test_level_id = None # Load picker (modal overlay listing saved custom maps). Closed # by default; opened from the toolbar's Load button. self.picker_open = False self.picker_entries = [] self.picker_scroll = 0 # Theme picker (modal overlay listing the floor/wall presets in # tileset.THEMES). ``theme`` is the current map's theme id; it is # written to the map's .json sidecar on save and restored # from that sidecar on open. new_level / open_level (re)set it. self.theme = tileset.DEFAULT_THEME self.theme_picker_open = False self.new_level() self._layout_palette() self._build_toolbar_layout() def new_level(self, cols=None, rows=None, name=None): """Reset the grid to the requested size (or default) with a wall border and a single 'P' near the top-left so the level is legal-ish out of the box.""" if cols is not None: self.cols = cols else: self.cols = self.DEFAULT_COLS if rows is not None: self.rows = rows else: self.rows = self.DEFAULT_ROWS if name is not None: self.name = sanitize(name) self.grid = [['.' for _ in range(self.cols)] for _ in range(self.rows)] self._wall_border() # Helpful defaults so a smashed save still loads. if self.rows > 2 and self.cols > 2: self.grid[1][1] = 'P' self.grid[self.rows - 2][self.cols - 2] = 'X' self.theme = tileset.DEFAULT_THEME self.cam_x = 0.0 self.cam_y = 0.0 def open_level(self, entry): """Load an existing level file into the editor. Used to tweak a built-in level or continue work on a custom one.""" try: with open(entry.file) as f: lines = [ln.rstrip('\n') for ln in f if ln.strip()] except (FileNotFoundError, OSError) as e: self._flash(f"Could not open {entry.file}: {e}") return grid = [] for line in lines: # Reuse the runtime parser so dense and tokenised rows # round-trip identically. if ' ' in line: grid.append(line.split()) else: grid.append(list(line)) if not grid: self._flash("Empty level — starting blank instead") self.new_level() return cols = max(len(r) for r in grid) for row in grid: row.extend('W' * (cols - len(row))) self.rows = len(grid) self.cols = cols self.grid = grid # Default name = file stem; user can rename before saving. self.name = sanitize(Path(entry.file).stem) # Restore the map's theme from its sidecar (built-ins / un-themed # maps have none and resolve to the default). self.theme = level_catalog.read_custom_theme(entry.file) self.cam_x = 0.0 self.cam_y = 0.0 def reset_pointer_state(self): """Drop any in-flight drag / box / held-button state. The editor instance lives for the whole session, so a Shift-drag box that was interrupted mid-gesture (Esc to menu, Test, window focus loss) would otherwise keep ``_box_start`` / a stuck ``_mouse_buttons`` entry set and commit a spurious box-fill on the next visit. ``main`` calls this whenever the editor (re)gains or loses control. Snapping the drawer closed at the same time means the user always lands on the collapsed rail when they come back, matching the "default smaller, hover to expand" intent of B27.""" self._box_start = None self._box_erase = False self._mouse_buttons = [False, False, False] self._last_painted_cell = None self.picker_open = False self.theme_picker_open = False self._palette_anim = 0.0 self._layout_palette() # --- palette geometry (drawer) ------------------------------------ def _layout_palette(self): """Place ``_grip_rect`` and ``palette_rect`` from ``_palette_anim``. ``canvas_rect`` is permanent — set once in ``__init__`` — and never reflows. The drawer slides in from the right: at anim=0 ``palette_rect`` sits off-screen to the right of the grip, so no palette tile click can land while collapsed; at anim=1 the palette is flush with the screen's right edge, exactly where the fixed 330 px panel used to be.""" panel_x = round( (self.width - self.PALETTE_GRIP_W) - self.PALETTE_W * self._palette_anim) h = self.height - self.TOOLBAR_H self._grip_rect = pygame.Rect( panel_x, 0, self.PALETTE_GRIP_W, h) self.palette_rect = pygame.Rect( panel_x + self.PALETTE_GRIP_W, 0, self.PALETTE_W, h) self._build_palette_layout() # --- frame --------------------------------------------------------- def update(self, dt): # Decay the toast message. if self.message_timer > 0: self.message_timer = max(0.0, self.message_timer - dt) if self.message_timer == 0: self.message = "" # Drawer hover-state animation. The drawer expands while the # cursor is over the rail or the panel body and contracts when # it leaves. Gated on no-buttons-held and no-active-box-drag # so a paint stroke aimed at the right edge of the canvas # doesn't open the drawer mid-gesture (which would then block # painting via the _screen_to_cell overlay guard). mx, my = pygame.mouse.get_pos() hot = ( mx >= self._grip_rect.left and my < self.height - self.TOOLBAR_H and not any(self._mouse_buttons) and self._box_start is None and not self.editing_name ) target = 1.0 if hot else 0.0 if self._palette_anim != target: step = dt / self.PALETTE_ANIM_TIME if self._palette_anim < target: self._palette_anim = min( target, self._palette_anim + step) else: self._palette_anim = max( target, self._palette_anim - step) new_x = round( (self.width - self.PALETTE_GRIP_W) - self.PALETTE_W * self._palette_anim) if new_x != self._grip_rect.left: self._layout_palette() # Camera pan on held keys. Read pressed-state every frame so it # feels continuous (event-driven would tick once per repeat). pan_speed = TILE_SIZE * 12 * dt # ~12 tiles/sec keys = pygame.key.get_pressed() if (not self.editing_name and not self.picker_open and not self.theme_picker_open): if keys[pygame.K_a] or keys[pygame.K_LEFT]: self.cam_x -= pan_speed if keys[pygame.K_d] or keys[pygame.K_RIGHT]: self.cam_x += pan_speed if keys[pygame.K_w] or keys[pygame.K_UP]: self.cam_y -= pan_speed if keys[pygame.K_s] or keys[pygame.K_DOWN]: self.cam_y += pan_speed self._clamp_camera() # Drag-paint: if a button is still held, keep applying while # the cursor crosses cells. Suppressed during a Shift box-drag # and while the eyedropper is active (those are click-only). if ((self._mouse_buttons[0] or self._mouse_buttons[2]) and self._box_start is None and self.tool == 'paint'): mx, my = pygame.mouse.get_pos() if self.canvas_rect.collidepoint(mx, my): cell = self._screen_to_cell(mx, my) if cell is not None and cell != self._last_painted_cell: self._paint_cell(cell, erase=self._mouse_buttons[2]) def _clamp_camera(self): max_x = max(0, self.cols * TILE_SIZE - self.canvas_rect.width) max_y = max(0, self.rows * TILE_SIZE - self.canvas_rect.height) self.cam_x = max(0, min(self.cam_x, max_x)) self.cam_y = max(0, min(self.cam_y, max_y)) # --- input --------------------------------------------------------- def handle_input(self, event): """Return ``'back'`` to leave the editor, ``'test'`` to enter game state with the currently-saved level, or ``None``.""" if self.picker_open: return self._handle_picker_input(event) if self.theme_picker_open: return self._handle_theme_picker_input(event) if event.type == pygame.KEYDOWN: if self.editing_name: if event.key == pygame.K_RETURN: self.editing_name = False self.name = sanitize(self.name) or "untitled" elif event.key == pygame.K_ESCAPE: # Cancel the rename. Re-sanitize so backspacing the # name down to empty can't leave self.name == "". self.editing_name = False self.name = sanitize(self.name) elif event.key == pygame.K_BACKSPACE: self.name = self.name[:-1] else: ch = event.unicode if ch and (ch.isalnum() or ch in "_-") and len(self.name) < MAX_NAME: self.name += ch return None if event.key == pygame.K_ESCAPE: return 'back' if event.key == pygame.K_F5: return self._do_test() if event.key == pygame.K_s and (pygame.key.get_mods() & pygame.KMOD_CTRL): self._do_save() return None if event.key == pygame.K_q and not ( pygame.key.get_mods() & pygame.KMOD_META): self.tool = 'pick' if self.tool == 'paint' else 'paint' return None elif event.type == pygame.MOUSEBUTTONDOWN: mx, my = event.pos shift = pygame.key.get_mods() & pygame.KMOD_SHIFT if event.button == 1: self._mouse_buttons[0] = True if shift and self.canvas_rect.collidepoint(mx, my): cell = self._screen_to_cell(mx, my) if cell is not None: self._box_start = cell self._box_erase = False return None result = self._click_left(mx, my) if result is not None: return result elif event.button == 3: self._mouse_buttons[2] = True if shift and self.canvas_rect.collidepoint(mx, my): cell = self._screen_to_cell(mx, my) if cell is not None: self._box_start = cell self._box_erase = True return None if self.canvas_rect.collidepoint(mx, my): cell = self._screen_to_cell(mx, my) if cell is not None: self._paint_cell(cell, erase=True) elif event.button == 4: # wheel up self._cycle_variant(1) elif event.button == 5: # wheel down self._cycle_variant(-1) elif event.type == pygame.MOUSEBUTTONUP: if event.button == 1: self._mouse_buttons[0] = False self._last_painted_cell = None if self._box_start is not None and not self._box_erase: end = self._screen_to_cell(*event.pos) \ or self._box_start self._commit_box(end, erase=False) elif event.button == 3: self._mouse_buttons[2] = False self._last_painted_cell = None if self._box_start is not None and self._box_erase: end = self._screen_to_cell(*event.pos) \ or self._box_start self._commit_box(end, erase=True) elif event.type == pygame.MOUSEWHEEL: # Some platforms emit MOUSEWHEEL instead of buttons 4/5. if event.y: self._cycle_variant(1 if event.y > 0 else -1) return None def _click_left(self, mx, my): # Drawer rail: pop open even if the hover signal is flaky # (touchpad scroll wheels, OS-level cursor warps). The rail is # the only mouse target while the drawer is collapsed, so a # click here is unambiguous intent to expand. if self._grip_rect.collidepoint(mx, my): self._palette_anim = 1.0 self._layout_palette() return # Palette if self.palette_rect.collidepoint(mx, my): for rect, ch in self._palette_rects: if rect.collidepoint(mx, my): self.selected_char = ch # Reset variant when switching tiles so it can't go # out of range silently. self.selected_variant = 1 return # < > buttons under the preview panel step the variant the # same way the wheel does (_cycle_variant no-ops when the # selected tile has only one variant). if self._variant_btn_rects['prev'].collidepoint(mx, my): self._cycle_variant(-1) elif self._variant_btn_rects['next'].collidepoint(mx, my): self._cycle_variant(1) return # Toolbar if self.toolbar_rect.collidepoint(mx, my): if self._name_rect.collidepoint(mx, my): self.editing_name = True return for name, rect in self._toolbar_rects.items(): if rect.collidepoint(mx, my): if name == 'save': self._do_save() elif name == 'test': # Propagate 'test' out so handle_input can # return it to main — without this the # toolbar Test button silently saves but # never launches a test session (F5 worked # because it returned _do_test() directly). return self._do_test() elif name == 'load': self._open_picker() elif name == 'theme': self._open_theme_picker() elif name == 'clear': self.new_level(self.cols, self.rows, self.name) self._flash("Cleared") elif name == 'border': self._wall_border() self._flash("Border walled") elif name == 'pick': self.tool = ('pick' if self.tool == 'paint' else 'paint') elif name == 'grow_w': self._resize(self.cols + 2, self.rows) elif name == 'shrink_w': self._resize(max(6, self.cols - 2), self.rows) elif name == 'grow_h': self._resize(self.cols, self.rows + 2) elif name == 'shrink_h': self._resize(self.cols, max(6, self.rows - 2)) return return # Canvas if self.canvas_rect.collidepoint(mx, my): cell = self._screen_to_cell(mx, my) if cell is None: return if self.tool == 'pick': self._pick_cell(cell) else: self._paint_cell(cell, erase=False) def _cycle_variant(self, delta): spec = REGISTRY.get(self.selected_char) if spec is None or spec.variant_count <= 1: return self.selected_variant = ( (self.selected_variant - 1 + delta) % spec.variant_count) + 1 # --- grid editing -------------------------------------------------- def _screen_to_cell(self, sx, sy): if not self.canvas_rect.collidepoint(sx, sy): return None # canvas_rect now extends under the expanded palette drawer # (B27), so reject any point sitting under the live overlay — # the user is targeting the palette, not a cell. if self._palette_anim > 0 and sx >= self._grip_rect.left: return None # int() to match _cell_to_screen and the canvas draw loop # (both use int(self.cam_*)); a float cam here would shift the # hit-test off the drawn grid by the sub-pixel camera fraction. wx = sx - self.canvas_rect.left + int(self.cam_x) wy = sy - self.canvas_rect.top + int(self.cam_y) c = int(wx // TILE_SIZE) r = int(wy // TILE_SIZE) if 0 <= r < self.rows and 0 <= c < self.cols: return (r, c) return None def _cell_to_screen(self, r, c): return (self.canvas_rect.left + c * TILE_SIZE - int(self.cam_x), self.canvas_rect.top + r * TILE_SIZE - int(self.cam_y)) def _paint_cell(self, cell, erase=False): r, c = cell if erase: self.grid[r][c] = '.' else: spec = REGISTRY.get(self.selected_char) # Singleton enforcement: P and X must be unique. if spec is not None and spec.category == 'special': self._clear_char(spec.char) self.grid[r][c] = self._selected_token() self._last_painted_cell = cell def _clear_char(self, ch): """Remove every existing occurrence of ``ch`` from the grid. Used for singleton tiles (P, X) so painting another puts the spawn where the user pointed instead of leaving two.""" for r in range(self.rows): for c in range(self.cols): if self.grid[r][c] and self.grid[r][c][0] == ch: self.grid[r][c] = '.' def _wall_border(self): """Set the whole outer ring to wall. Shared by ``new_level`` and the toolbar Border button.""" for r in range(self.rows): self.grid[r][0] = 'W' self.grid[r][self.cols - 1] = 'W' for c in range(self.cols): self.grid[0][c] = 'W' self.grid[self.rows - 1][c] = 'W' def _selected_token(self): """The token string the current selection writes — letter plus a variant suffix only when it has a non-default variant (keeps saved files readable).""" spec = REGISTRY.get(self.selected_char) if (spec is not None and spec.variant_count > 1 and self.selected_variant > 1): return f"{spec.char}{self.selected_variant}" return self.selected_char def _pick_cell(self, cell): """Eyedropper: load the token under the cursor into the selection, then drop back to paint so the next click draws.""" r, c = cell tok = self.grid[r][c] or '.' ch = tok[0] if ch not in REGISTRY: ch = '.' self.selected_char = ch spec = REGISTRY.get(ch) v = _token_variant(tok) self.selected_variant = ( v if spec is not None and 1 <= v <= spec.variant_count else 1) self.tool = 'paint' def _commit_box(self, end_cell, erase): """Fill (or erase) the rectangle spanned by the Shift-drag. Singleton specials (P/X) can't sensibly tile a box, so they fall back to a single placement at the release cell.""" (r0, c0), (r1, c1) = self._box_start, end_cell self._box_start = None spec = REGISTRY.get(self.selected_char) if (not erase and spec is not None and spec.category == 'special'): self._paint_cell(end_cell, erase=False) return token = '.' if erase else self._selected_token() for r in range(min(r0, r1), max(r0, r1) + 1): for c in range(min(c0, c1), max(c0, c1) + 1): self.grid[r][c] = token def _resize(self, new_cols, new_rows): """Grow / shrink the grid, keeping existing content in place. New cells default to floor; the new outer border becomes wall only if it's the very edge.""" new_grid = [['.' for _ in range(new_cols)] for _ in range(new_rows)] for r in range(min(self.rows, new_rows)): for c in range(min(self.cols, new_cols)): new_grid[r][c] = self.grid[r][c] # Re-wall border row/cols at the new edges. for r in range(new_rows): if new_grid[r][0] == '.': new_grid[r][0] = 'W' if new_grid[r][new_cols - 1] == '.': new_grid[r][new_cols - 1] = 'W' for c in range(new_cols): if new_grid[0][c] == '.': new_grid[0][c] = 'W' if new_grid[new_rows - 1][c] == '.': new_grid[new_rows - 1][c] = 'W' self.rows = new_rows self.cols = new_cols self.grid = new_grid self._clamp_camera() # --- save / load / test -------------------------------------------- def _validate(self): """Return a list of warnings (empty if everything is fine). Only blocks 'no player start' — anything else is a soft warn so the user can experiment freely.""" warnings = [] flat = [tok for row in self.grid for tok in row] chars = [t[0] if t else '.' for t in flat] if 'P' not in chars: warnings.append("No 'P' player start — level won't be playable") if 'X' not in chars: warnings.append("No 'X' exit — player can't escape") triggers = sum(c in ('L', 'Y') for c in chars) # G *cells* are counted, but multiple adjacent G are one panel # at runtime. Cheap approximation: count G runs as panels. gate_runs = 0 prev = None for c in chars: if c == 'G' and prev != 'G': gate_runs += 1 prev = c if triggers and gate_runs and triggers != gate_runs: warnings.append( f"{triggers} trigger(s) but ~{gate_runs} gate panel(s) — " "pairings may be off") return warnings def _do_save(self): # Last line of defence: every entry path *should* keep self.name # safe, but sanitize at the write boundary too so an empty name # can never produce a ".txt" dot-file / "custom_" id. self.name = sanitize(self.name) warnings = self._validate() level_catalog.ensure_custom_dir() path = level_catalog.CUSTOM_DIR / f"{self.name}.txt" try: with open(path, 'w') as f: for row in self.grid: f.write(' '.join(row) + '\n') except OSError as e: self._flash(f"Save failed: {e}") return False # Theme sidecar: .json beside the .txt. Non-fatal — the map # itself is already written; a sidecar failure just means the map # reopens with the default theme. sidecar = level_catalog.CUSTOM_DIR / f"{self.name}.json" try: with open(sidecar, 'w') as f: json.dump({"theme": self.theme}, f) except OSError: pass if warnings: self._flash(f"Saved with warnings: {warnings[0]}") else: self._flash(f"Saved → {path.name}") return True def _do_test(self): if not self._do_save(): return None # Custom-level id matches the convention in level_catalog. self.test_level_id = f"custom_{self.name}" self.request_test = True return 'test' def _flash(self, msg, secs=2.6): self.message = msg self.message_timer = secs # --- load picker (modal) ------------------------------------------ def _open_picker(self): """Open the modal Load picker over the canvas. Lists the player's saved custom maps only (built-in levels are deliberately excluded — the picker exists to *reopen what you saved*). ``_scan_custom`` is the same scan the level menu uses, so a map saved this session shows up without a restart.""" self.picker_entries = level_catalog._scan_custom() self.picker_scroll = 0 self.picker_open = True def _picker_panel(self): """Centred rect for the modal Load-picker panel.""" w = min(560, self.width - 120) h = min(620, self.height - 160) rect = pygame.Rect(0, 0, w, h) rect.center = (self.width // 2, self.height // 2) return rect def _picker_list_rect(self): """Scrolling-list area inside the panel (below the title, above the footer hint). Rows are clipped to this rect.""" panel = self._picker_panel() return pygame.Rect(panel.left, panel.top + 84, panel.width, panel.height - 84 - 52) def _picker_visible_count(self): return max(1, self._picker_list_rect().height // self.PICKER_ROW_H) def _picker_rows(self): """``(rect, entry)`` for every list row, in catalog order. Rows are positioned absolutely against ``picker_scroll``; rows outside the list area simply fall outside ``_picker_list_rect`` and the caller clips them. Shared by draw and input so hit-test and render never disagree.""" lr = self._picker_list_rect() rows = [] for i, entry in enumerate(self.picker_entries): y = lr.top + (i - self.picker_scroll) * self.PICKER_ROW_H rect = pygame.Rect(lr.left + 24, y, lr.width - 48, self.PICKER_ROW_H) rows.append((rect, entry)) return rows def _scroll_picker(self, delta): max_scroll = max(0, len(self.picker_entries) - self._picker_visible_count()) self.picker_scroll = max( 0, min(self.picker_scroll + delta, max_scroll)) def _handle_picker_input(self, event): """Drive the modal Load picker. Always returns ``None`` — the picker never leaves the editor or starts a test.""" if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.picker_open = False return None if event.type == pygame.MOUSEWHEEL: if event.y: self._scroll_picker(-1 if event.y > 0 else 1) return None if event.type == pygame.MOUSEBUTTONDOWN: if event.button == 4: # wheel up self._scroll_picker(-1) return None if event.button == 5: # wheel down self._scroll_picker(1) return None if event.button != 1: return None if not self._picker_panel().collidepoint(event.pos): # Click off the panel dismisses the picker. self.picker_open = False return None list_rect = self._picker_list_rect() if list_rect.collidepoint(event.pos): for rect, entry in self._picker_rows(): if rect.collidepoint(event.pos): self.open_level(entry) self._flash(f"Loaded {entry.title}") self.picker_open = False break return None def _draw_picker(self, screen): """Modal overlay: a centred panel listing saved custom maps.""" dim = pygame.Surface(screen.get_size(), pygame.SRCALPHA) dim.fill((*theme.BG, 210)) screen.blit(dim, (0, 0)) panel = self._picker_panel() pygame.draw.rect(screen, theme.shade(theme.BG, 6), panel, border_radius=10) pygame.draw.rect(screen, theme.LINE_C, panel, 2, border_radius=10) title = self.font.render("LOAD MAP", True, theme.TITLE_C) screen.blit(title, title.get_rect( midtop=(panel.centerx, panel.top + 22))) list_rect = self._picker_list_rect() if not self.picker_entries: empty = self.label_font.render( "No custom maps yet — save one first", True, theme.MUTED) screen.blit(empty, empty.get_rect(center=list_rect.center)) else: prev_clip = screen.get_clip() screen.set_clip(list_rect) mp = pygame.mouse.get_pos() for rect, entry in self._picker_rows(): if (rect.bottom < list_rect.top or rect.top > list_rect.bottom): continue hov = (rect.collidepoint(mp) and list_rect.collidepoint(mp)) col = theme.ACCENT if hov else theme.INK name = self.head_font.render(entry.title, True, col) screen.blit(name, name.get_rect( midleft=(rect.left + 16, rect.centery))) screen.set_clip(prev_clip) hint = self.hint_font.render( "Click a map to load · Esc cancel", True, theme.MUTED) screen.blit(hint, hint.get_rect( midbottom=(panel.centerx, panel.bottom - 16))) # --- theme picker (modal) ----------------------------------------- def _open_theme_picker(self): """Open the modal theme picker over the canvas. Lists the floor/wall presets in ``tileset.THEMES``; the picked theme is saved into the map's sidecar by ``_do_save``.""" self.theme_picker_open = True def _theme_label(self): """Toolbar-button caption — the current map theme's display name, or a plain ``"Theme"`` if the id is somehow unknown.""" for tid, name, _f, _w in tileset.THEMES: if tid == self.theme: return f"Theme: {name}" return "Theme" def _theme_picker_panel(self): """Centred rect for the modal theme-picker panel, sized to the fixed number of presets (no scrolling needed).""" rows = len(tileset.THEMES) w = min(480, self.width - 120) h = min(84 + rows * self.PICKER_ROW_H + 52, self.height - 120) rect = pygame.Rect(0, 0, w, h) rect.center = (self.width // 2, self.height // 2) return rect def _theme_picker_list_rect(self): """Row area inside the panel (below the title, above the hint).""" panel = self._theme_picker_panel() return pygame.Rect(panel.left, panel.top + 84, panel.width, panel.height - 84 - 52) def _theme_picker_rows(self): """``(rect, preset)`` for every theme row, in ``tileset.THEMES`` order. Shared by draw and input so they never disagree.""" lr = self._theme_picker_list_rect() rows = [] for i, preset in enumerate(tileset.THEMES): y = lr.top + i * self.PICKER_ROW_H rect = pygame.Rect(lr.left + 24, y, lr.width - 48, self.PICKER_ROW_H) rows.append((rect, preset)) return rows def _handle_theme_picker_input(self, event): """Drive the modal theme picker. Always returns ``None`` — the picker never leaves the editor or starts a test.""" if event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.theme_picker_open = False return None if event.type == pygame.MOUSEBUTTONDOWN: if event.button != 1: return None if not self._theme_picker_panel().collidepoint(event.pos): # Click off the panel dismisses the picker. self.theme_picker_open = False return None for rect, (tid, name, _f, _w) in self._theme_picker_rows(): if rect.collidepoint(event.pos): self.theme = tid self._flash(f"Theme: {name}") self.theme_picker_open = False break return None def _draw_theme_picker(self, screen): """Modal overlay: a centred panel listing the floor/wall themes, each with two tile swatches. The current theme row is marked.""" dim = pygame.Surface(screen.get_size(), pygame.SRCALPHA) dim.fill((*theme.BG, 210)) screen.blit(dim, (0, 0)) panel = self._theme_picker_panel() pygame.draw.rect(screen, theme.shade(theme.BG, 6), panel, border_radius=10) pygame.draw.rect(screen, theme.LINE_C, panel, 2, border_radius=10) title = self.font.render("MAP THEME", True, theme.TITLE_C) screen.blit(title, title.get_rect( midtop=(panel.centerx, panel.top + 22))) mp = pygame.mouse.get_pos() sw = 38 # tile-swatch size for rect, (tid, name, floor, wall) in self._theme_picker_rows(): selected = (tid == self.theme) hov = rect.collidepoint(mp) col = theme.ACCENT if (hov or selected) else theme.INK # Floor + wall swatches so the player sees the actual look. for i, tname in enumerate((floor, wall)): sr = pygame.Rect(rect.left + 16 + i * (sw + 8), rect.centery - sw // 2, sw, sw) img = tileset.tile(tname) if img is not None: screen.blit(pygame.transform.scale(img, (sw, sw)), sr) else: pygame.draw.rect(screen, theme.shade(theme.BG, 14), sr) pygame.draw.rect(screen, theme.LINE_C, sr, 1) label = self.head_font.render(name, True, col) screen.blit(label, label.get_rect(midleft=( rect.left + 16 + 2 * (sw + 8) + 12, rect.centery))) if selected: mark = self.label_font.render("●", True, theme.ACCENT) screen.blit(mark, mark.get_rect( midright=(rect.right - 16, rect.centery))) hint = self.hint_font.render( "Click a theme · Esc cancel", True, theme.MUTED) screen.blit(hint, hint.get_rect( midbottom=(panel.centerx, panel.bottom - 16))) # --- draw --------------------------------------------------------- def draw(self, screen): # Backdrop — canvas/palette/toolbar are shades derived from the # shared BG so the split stays one family, not three tuples. # The palette's own backdrop is drawn inside _draw_palette, # *after* _draw_canvas, so the expanded drawer cleanly overlays # the canvas instead of being painted over by it. screen.fill(theme.BG) pygame.draw.rect(screen, theme.shade(theme.BG, -6), self.canvas_rect) pygame.draw.rect(screen, theme.shade(theme.BG, -2), self.toolbar_rect) self._draw_canvas(screen) self._draw_palette(screen) self._draw_toolbar(screen) self._draw_message(screen) if self.picker_open: self._draw_picker(screen) if self.theme_picker_open: self._draw_theme_picker(screen) # ----- canvas ----------------------------------------------------- def _draw_canvas(self, screen): # Clip to the canvas area so tiles drawn past the panel boundary # don't bleed into the palette. prev_clip = screen.get_clip() screen.set_clip(self.canvas_rect) # Tiles. Only the visible window costs anything — we skip every # row/col outside the camera. cw = self.canvas_rect.width ch = self.canvas_rect.height first_col = max(0, int(self.cam_x) // TILE_SIZE) first_row = max(0, int(self.cam_y) // TILE_SIZE) last_col = min(self.cols - 1, (int(self.cam_x) + cw) // TILE_SIZE) last_row = min(self.rows - 1, (int(self.cam_y) + ch) // TILE_SIZE) floor_img = tileset.tile(tileset.FLOOR_TILE) wall_img = tileset.tile(tileset.WALL_TILE) floor_tex = TileTextures.get('floor') wall_tex = TileTextures.get('wall') for r in range(first_row, last_row + 1): for c in range(first_col, last_col + 1): sx, sy = self._cell_to_screen(r, c) # Floor underneath everything. screen.blit(floor_img or floor_tex, (sx, sy)) token = self.grid[r][c] if not token or token == '.': continue ch_ = token[0] if ch_ == 'W': screen.blit(wall_img or wall_tex, (sx, sy)) else: img = self._thumbnail(ch_, _token_variant(token), large=True) if img is not None: screen.blit(img, (sx, sy)) # Grid lines (subtle) — a touch above the canvas shade. grid_col = theme.shade(theme.BG, 22) for c in range(first_col, last_col + 2): x = self.canvas_rect.left + c * TILE_SIZE - int(self.cam_x) pygame.draw.line(screen, grid_col, (x, self.canvas_rect.top), (x, self.canvas_rect.bottom), 1) for r in range(first_row, last_row + 2): y = self.canvas_rect.top + r * TILE_SIZE - int(self.cam_y) pygame.draw.line(screen, grid_col, (self.canvas_rect.left, y), (self.canvas_rect.right, y), 1) # Cursor preview — eyedropper shows just a cyan box (no ghost, # since picking doesn't place the current selection). mx, my = pygame.mouse.get_pos() cell = self._screen_to_cell(mx, my) if cell is not None and not self.editing_name: r, c = cell sx, sy = self._cell_to_screen(r, c) if self.tool == 'pick': pygame.draw.rect(screen, theme.ACCENT, (sx, sy, TILE_SIZE, TILE_SIZE), 3) else: preview = self._thumbnail(self.selected_char, self.selected_variant, large=True) if preview is not None: ghost = preview.copy() ghost.set_alpha(160) screen.blit(ghost, (sx, sy)) pygame.draw.rect(screen, theme.ACCENT, (sx, sy, TILE_SIZE, TILE_SIZE), 2) # Box fill/erase marquee while a Shift-drag is live. if self._box_start is not None: r0, c0 = self._box_start r1, c1 = self._screen_to_cell(mx, my) or self._box_start x0, y0 = self._cell_to_screen(min(r0, r1), min(c0, c1)) w = (abs(c1 - c0) + 1) * TILE_SIZE h = (abs(r1 - r0) + 1) * TILE_SIZE tint = theme.FAIL if self._box_erase else theme.ACCENT ov = pygame.Surface((w, h), pygame.SRCALPHA) ov.fill((*tint, 45)) screen.blit(ov, (x0, y0)) pygame.draw.rect(screen, tint, (x0, y0, w, h), 2) screen.set_clip(prev_clip) # ----- palette ---------------------------------------------------- def _build_palette_layout(self): """Compute click rects for every palette tile + remember the category headers' y-positions (used for drawing).""" self._palette_rects = [] self._palette_headers = [] # (y, label) x0 = self.palette_rect.left + self.PALETTE_PAD y = self.palette_rect.top + 90 # leave room for "PALETTE" title max_x = self.palette_rect.right - self.PALETTE_PAD cell = self.CELL gap = self.CELL_GAP for category in PALETTE_CATEGORIES: chars = chars_for(category) if not chars: continue self._palette_headers.append((y, category.upper())) y += 32 x = x0 for ch in chars: if x + cell > max_x: x = x0 y += cell + gap rect = pygame.Rect(x, y, cell, cell) self._palette_rects.append((rect, ch)) x += cell + gap y += cell + gap + 8 # Selected-tile preview panel sits below the last category. self._palette_info_y = y + 6 # Panel geometry: a PREVIEW_SIZE sprite centred in the panel, # flanked by the < > variant buttons. _draw_palette mirrors # these offsets for the cosmetic parts (name, separator, text). iy = self._palette_info_y ix = self.palette_rect.left + self.PALETTE_PAD pw = self.palette_rect.width - 2 * self.PALETTE_PAD sprite_top = iy + 56 self._preview_sprite_pos = ( ix + (pw - self.PREVIEW_SIZE) // 2, sprite_top) btn = self.VARIANT_BTN by = sprite_top + (self.PREVIEW_SIZE - btn) // 2 self._variant_btn_rects = { 'prev': pygame.Rect(ix + 8, by, btn, btn), 'next': pygame.Rect(ix + pw - 8 - btn, by, btn, btn), } def _draw_palette(self, screen): # Rail (always visible) — the hover affordance + the only piece # of the drawer shown while it's collapsed. pygame.draw.rect( screen, theme.shade(theme.BG, 4), self._grip_rect) self._draw_palette_grip(screen) if self._palette_anim <= 0: return # Panel body backdrop — drawn after the canvas so the expanded # drawer cleanly overlays. The 10-shade matches the previous # fixed-panel tone from B25. pygame.draw.rect( screen, theme.shade(theme.BG, 10), self.palette_rect) # Title title = self.font.render("PALETTE", True, theme.TITLE_C) screen.blit(title, title.get_rect( midtop=(self.palette_rect.centerx, self.palette_rect.top + 24))) # Headers for y, label in self._palette_headers: h = self.head_font.render(label, True, theme.MUTED) screen.blit(h, (self.palette_rect.left + self.PALETTE_PAD, y)) pygame.draw.line( screen, theme.LINE_C, (self.palette_rect.left + self.PALETTE_PAD + 120, y + 12), (self.palette_rect.right - self.PALETTE_PAD, y + 12), 1) # Tile thumbnails mp = pygame.mouse.get_pos() for rect, ch in self._palette_rects: is_sel = (ch == self.selected_char) is_hov = rect.collidepoint(mp) # Backplate bp_color = (theme.shade(theme.BG, 28) if is_sel else theme.shade(theme.BG, 16) if is_hov else theme.shade(theme.BG, 6)) pygame.draw.rect(screen, bp_color, rect, border_radius=6) # Tile preview — the thumb for char's variant 1 img = self._thumbnail(ch, 1, large=False) if img is not None: screen.blit(img, img.get_rect(center=rect.center)) # Letter overlay (top-left) letter = self.small_font.render(ch, True, theme.INK) screen.blit(letter, (rect.left + 4, rect.top + 2)) # Border border = (theme.ACCENT if is_sel else theme.MUTED if is_hov else theme.LINE_C) pygame.draw.rect(screen, border, rect, 2, border_radius=6) # Selected-tile preview panel — name, a thin separator, a large # sprite flanked by the < > variant buttons, the variant counter, # then a short description. Same flat visual language as the # CharacterMenu stat block. The buttons step the variant like the # wheel; they grey out for single-variant tiles. spec = REGISTRY.get(self.selected_char) if spec is None: return ix = self.palette_rect.left + self.PALETTE_PAD iy = self._palette_info_y w = self.palette_rect.width - 2 * self.PALETTE_PAD card = pygame.Rect(ix, iy, w, 150) name = self.font.render(spec.label, True, theme.TITLE_C) screen.blit(name, (card.left + 16, card.top + 10)) pygame.draw.line(screen, theme.LINE_C, (card.left + 16, card.top + 48), (card.right - 16, card.top + 48), 2) # Large sprite preview of the current selection + variant, on a # faint backplate so a dark tile still reads against the panel. sx, sy = self._preview_sprite_pos plate = pygame.Rect(sx, sy, self.PREVIEW_SIZE, self.PREVIEW_SIZE) pygame.draw.rect(screen, theme.shade(theme.BG, 6), plate, border_radius=6) preview = self._thumbnail(self.selected_char, self.selected_variant, large=False, size=self.PREVIEW_SIZE) if preview is not None: screen.blit(preview, (sx, sy)) # < > variant buttons — interactive only for multi-variant tiles. has_variants = spec.variant_count > 1 for key, glyph in (('prev', "<"), ('next', ">")): rect = self._variant_btn_rects[key] hov = has_variants and rect.collidepoint(mp) col = (theme.ACCENT if hov else theme.INK if has_variants else theme.LINE_C) pygame.draw.rect(screen, col, rect, 2, border_radius=6) g = self.head_font.render(glyph, True, col) screen.blit(g, g.get_rect(center=rect.center)) # Variant counter, centred under the sprite. vtext = (f"variant {self.selected_variant} / {spec.variant_count}" if has_variants else "single variant") v = self.small_font.render(vtext, True, theme.MUTED) screen.blit(v, v.get_rect( midtop=(card.centerx, sy + self.PREVIEW_SIZE + 8))) # Short wrapped description below the panel. desc_lines = self._wrap(spec.description, card.width - 30, self.small_font) desc_y = sy + self.PREVIEW_SIZE + 32 for i, line in enumerate(desc_lines[:2]): s = self.small_font.render(line, True, theme.MUTED) screen.blit(s, (card.left + 16, desc_y + i * 18)) def _draw_palette_grip(self, screen): """Draw the always-visible rail: a 32×32 thumbnail of the currently-selected tile, a vertical 'PALETTE' letter stack, and — while the drawer is expanded — an ACCENT stripe on the rail's left edge as a hover affordance.""" mp = pygame.mouse.get_pos() hot = self._grip_rect.collidepoint(mp) # 32×32 selected-tile thumb at the top of the rail so the user # always sees what's currently selected even when the drawer is # closed. Cached via the regular _thumbnail path; size keys # share with the existing palette grid cache only when 32 # happens to match CELL-12, which it does (44-12 == 32). thumb = self._thumbnail( self.selected_char, self.selected_variant, large=False, size=32) if thumb is not None: screen.blit(thumb, thumb.get_rect( midtop=(self._grip_rect.centerx, self._grip_rect.top + 12))) # Vertical letter stack — one small_font letter per row, # centred horizontally. ACCENT tint while hovered, MUTED at # rest. The stack surface is cached and only rebuilt when the # colour flips, so the per-frame cost is one blit. col = theme.ACCENT if hot else theme.MUTED if (self._palette_label_surf is None or self._palette_label_col != col): self._palette_label_col = col letters = [theme.text_surface(self.small_font, ch, col) for ch in "PALETTE"] line_h = self.small_font.get_height() + 2 stack = pygame.Surface( (self._grip_rect.width, line_h * len(letters)), pygame.SRCALPHA) for i, s in enumerate(letters): stack.blit(s, s.get_rect( midtop=(stack.get_width() // 2, i * line_h))) self._palette_label_surf = stack label = self._palette_label_surf screen.blit(label, label.get_rect( midtop=(self._grip_rect.centerx, self._grip_rect.top + 56))) # Accent stripe on the rail's left edge while the drawer is # open — mirrors the toolbar button underline language. if self._palette_anim > 0: pygame.draw.line( screen, theme.ACCENT, (self._grip_rect.left, self._grip_rect.top + 4), (self._grip_rect.left, self._grip_rect.bottom - 4), 2) def _wrap(self, text, max_w, font): words = text.split() lines, cur = [], [] for w in words: cur.append(w) if font.size(' '.join(cur))[0] > max_w: cur.pop() if cur: lines.append(' '.join(cur)) cur = [w] if cur: lines.append(' '.join(cur)) return lines # ----- toolbar ---------------------------------------------------- def _build_toolbar_layout(self): # Button rects are positioned right-anchored; the filename and # size info take the left side. h = self.toolbar_rect.height y = self.toolbar_rect.top # Filename area (top-left) self._name_rect = pygame.Rect( 24, y + 16, 460, 44) # Buttons stacked horizontally, anchored to the right. Flat # text-only buttons share the menu/list language — no coloured # chips. Destructive intent ('clear') is signalled by FAIL on # hover; the eyedropper ('pick') latches to ACCENT while armed. spec = [ ('pick', "Pick (Q)", 160, 'tool'), ('border', "Border", 140, 'tool'), ('clear', "Clear", 130, 'danger'), ('test', "Test (F5)", 180, 'tool'), ('load', "Load", 130, 'tool'), ('theme', "Theme", 210, 'tool'), ('save', "Save (Ctrl+S)", 220, 'tool'), ] right = self.toolbar_rect.right - 20 self._toolbar_rects = {} for name, _label, width, _kind in reversed(spec): rect = pygame.Rect(right - width, y + 18, width, h - 36) self._toolbar_rects[name] = rect right -= width + 12 self._toolbar_button_meta = {n: (lbl, k) for n, lbl, _w, k in spec} # Size +/- arrows (smaller, in the middle of the toolbar) mid_x = self._name_rect.right + 30 my = y + 22 for i, key in enumerate(['shrink_w', 'grow_w']): self._toolbar_rects[key] = pygame.Rect( mid_x + i * 38, my, 32, 32) for i, key in enumerate(['shrink_h', 'grow_h']): self._toolbar_rects[key] = pygame.Rect( mid_x + 90 + i * 38, my, 32, 32) self._toolbar_button_meta.update({ 'shrink_w': ("-W", 'tool'), 'grow_w': ("+W", 'tool'), 'shrink_h': ("-H", 'tool'), 'grow_h': ("+H", 'tool'), }) def _draw_toolbar(self, screen): # Filename "input" col = (theme.shade(theme.BG, 18) if self.editing_name else theme.shade(theme.BG, 8)) pygame.draw.rect(screen, col, self._name_rect, border_radius=6) pygame.draw.rect(screen, theme.LINE_C, self._name_rect, 2, border_radius=6) label = self.small_font.render( "FILE", True, theme.MUTED) screen.blit(label, (self._name_rect.left + 12, self._name_rect.top - 18)) cursor = "_" if (self.editing_name and (pygame.time.get_ticks() // 400) % 2 == 0) else "" name_surf = self.label_font.render( f"{self.name}{cursor}.txt", True, theme.INK) screen.blit(name_surf, name_surf.get_rect( midleft=(self._name_rect.left + 16, self._name_rect.centery))) # Grid size readout size = self.label_font.render( f"{self.cols} × {self.rows}", True, theme.MUTED) screen.blit(size, (self._name_rect.right + 200, self.toolbar_rect.top + 28)) # Buttons — flat text + thin underline. Hover or active state # lights the text/underline ACCENT; destructive 'clear' goes # FAIL on hover only. mp = pygame.mouse.get_pos() for name, rect in self._toolbar_rects.items(): text, kind = self._toolbar_button_meta[name] # The theme button shows the current map theme's name. if name == 'theme': text = self._theme_label() active = (name == 'pick' and self.tool == 'pick') hov = rect.collidepoint(mp) if active: text_col = theme.ACCENT elif hov: text_col = theme.FAIL if kind == 'danger' else theme.ACCENT else: text_col = theme.INK t = self.label_font.render(text, True, text_col) screen.blit(t, t.get_rect(center=rect.center)) if hov or active: pygame.draw.line(screen, text_col, (rect.left + 12, rect.bottom - 6), (rect.right - 12, rect.bottom - 6), 2) # Bottom-line hint d = theme.HINT_DOT hint = self.hint_font.render( f"LMB place {d} RMB clear {d} Shift+drag box {d} Q pick {d} " f"Wheel variant {d} WASD pan {d} Esc back {d} F5 test", True, theme.MUTED) screen.blit(hint, hint.get_rect( midbottom=(self.toolbar_rect.centerx, self.toolbar_rect.bottom - 6))) def _draw_message(self, screen): if not self.message: return s = self.label_font.render(self.message, True, theme.INK) pad = s.get_rect().inflate(28, 14) pad.center = (self.canvas_rect.centerx, self.toolbar_rect.top - 40) bg = pygame.Surface(pad.size, pygame.SRCALPHA) bg.fill((*theme.BG, 220)) screen.blit(bg, pad) pygame.draw.rect(screen, theme.LINE_C, pad, 2, border_radius=8) screen.blit(s, s.get_rect(center=pad.center)) # --- thumbnail rendering ------------------------------------------ def _thumbnail(self, char, variant, large, size=None): """Cached preview surface for one tile. ``large=True`` returns a TILE_SIZE x TILE_SIZE image suitable for the canvas; ``large=False`` returns a thumbnail-sized image for the palette grid. An explicit ``size`` overrides both — the selected-tile preview panel uses it for a larger sprite.""" if size is None: size = TILE_SIZE if large else self.CELL - 12 key = (char, variant, size) if key in self._tile_thumb_cache: return self._tile_thumb_cache[key] surf = self._build_thumbnail(char, variant, size) self._tile_thumb_cache[key] = surf return surf def _build_thumbnail(self, char, variant, size): spec = REGISTRY.get(char) if spec is None: return None # Prop tiles draw straight from the tileset. if spec.tileset_category is not None: base = tileset.sprite(spec.tileset_category, variant) return _scale_surface(base, size) # Procedural / built-in tiles — fall through by character. surf = pygame.Surface((TILE_SIZE, TILE_SIZE), pygame.SRCALPHA) if char == 'W': wall = (tileset.tile(tileset.WALL_TILE) or TileTextures.get('wall')) surf.blit(wall, (0, 0)) elif char == '.': floor = (tileset.tile(tileset.FLOOR_TILE) or TileTextures.get('floor')) surf.blit(floor, (0, 0)) elif char == 'P': _draw_marker(surf, theme.SUCCESS, "P") elif char == 'X': _draw_exit_marker(surf) elif spec.category == 'enemy': _draw_marker(surf, theme.FAIL, char) elif char == 'S': Spikes._build_images() surf.blit(Spikes._imgs['up'], (0, 0)) elif char == 'L': Lever._build_images() surf.blit(Lever._imgs[False], (0, 0)) elif char == 'Y': PressurePlate._build_images() surf.blit(PressurePlate._imgs[False], (0, 0)) elif char == 'G': Gate._build_images() surf.blit(Gate._imgs[False], (0, 0)) elif char == 'K': KeyItem._build_image() surf.blit(KeyItem._img, (0, 0)) else: # Last-resort marker for an unknown tile char — keep a loud # magenta so it visibly screams "missing art" in the editor. _draw_marker(surf, (180, 60, 180), char) return _scale_surface(surf, size) # --- module helpers -------------------------------------------------------- def _token_variant(token): """Same logic as ``levels._cell_variant`` — duplicated here to keep editor.py importable without dragging the level runtime along.""" digits = token[1:] return int(digits) if digits.isdigit() else 1 def _scale_surface(surf, size): if size == TILE_SIZE: return surf return pygame.transform.smoothscale(surf, (size, size)) def _draw_marker(surf, color, letter): """Generic 'this tile has no art' marker — circle + bold letter.""" cx = cy = TILE_SIZE // 2 pygame.draw.circle(surf, color, (cx, cy), TILE_SIZE // 2 - 6) pygame.draw.circle(surf, theme.BG, (cx, cy), TILE_SIZE // 2 - 6, 3) f = theme.font(32) s = f.render(letter, True, theme.BG) surf.blit(s, s.get_rect(center=(cx, cy))) def _draw_exit_marker(surf): """Door-shaped silhouette so the exit reads as something to head toward, not a generic marker.""" rect = pygame.Rect(8, 6, TILE_SIZE - 16, TILE_SIZE - 12) pygame.draw.rect(surf, theme.shade(theme.BG, +6), rect, border_radius=4) pygame.draw.rect(surf, theme.SUCCESS, rect, 3, border_radius=4) inner = rect.inflate(-14, -10) pygame.draw.rect(surf, theme.shade(theme.SUCCESS, -30), inner)