From 616c9455090e23fa5c3865cdd83e674c70970c75 Mon Sep 17 00:00:00 2001 From: The_miro Date: Thu, 16 Jul 2026 10:32:25 +0200 Subject: [PATCH] Add start menu, procedural world generation, and controller support - Wire up the previously-unused menu/character-creation classes into an actual start menu -> character creation -> world creation -> pause/ controls flow in main.py, instead of dropping straight into a fixed world. Adds a minimal screen-space UI layer (bitmap font + panels) since the engine had no text/menu rendering at all before this. - Fix ship interiors rendering when the player is off-ship, add helm zoom-out, wire the character card and ability bar (both existed as unused pure-state classes with no rendering or input hooked up). - Add a configure-controls menu with live keybind rebinding, and a dev-mode config toggle with an item-spawn cheat. - Full gamepad support for every menu (previously gameplay-only), with a stale-button-state fix across pause transitions. - Procedural world generation: seeded Perlin noise heightmap, terrain types (water/sand/dirt/mud/stone) with speed effects, slopes and occasional caves between elevations, and a swim up/down mechanic in water - selectable as a new world template alongside the existing test map. - Add gravity (fall to the first floor below when there's none underfoot) and Dwarf-Fortress-style depth shading (only the player's own z-level renders at full brightness; lower ground seen through a gap darkens with depth) - both fix a real gap the terrain generator left open (walking off a steep cliff previously just floated in empty air). - Add scripts/export_blank_textures.py and reimport_textures.py for a blank-template/re-import art workflow. 215 new tests across the above; full suite (676 tests) passing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FyY5qQB6XHRoBFNbsgdRQn --- config/controller_bindings.conf | 8 + config/keybindings.conf | 7 + engine/camera.py | 19 ++ engine/config.py | 39 ++- engine/controls_menu.py | 52 +++ engine/defs.py | 1 + engine/dev_config.py | 35 ++ engine/dev_tools.py | 51 +++ engine/game_modes.py | 124 +++++++ engine/gamepad.py | 26 ++ engine/input.py | 34 ++ engine/placeholder_textures.py | 17 + engine/render/menu_draw.py | 186 ++++++++++ engine/render/pipeline.py | 33 +- engine/render/renderer.py | 121 ++++++- engine/render/ui_draw.py | 51 +++ engine/tile.py | 20 ++ engine/ui.py | 59 +++- engine/ui_assets.py | 52 +++ engine/world.py | 37 +- engine/world_creation.py | 77 ++++- engine/worldgen/__init__.py | 0 engine/worldgen/noise.py | 97 ++++++ engine/worldgen/terrain.py | 164 +++++++++ main.py | 504 +++++++++++++++++++++++----- resources/defs/tiles.json | 15 +- resources/logic/swimming.py | 29 ++ scripts/export_blank_textures.py | 99 ++++++ scripts/reimport_textures.py | 52 +++ tests/test_ability_bar.py | 60 +++- tests/test_camera.py | 35 ++ tests/test_controls_menu.py | 68 ++++ tests/test_defs_loading.py | 25 ++ tests/test_depth_shading.py | 57 ++++ tests/test_dev_tools.py | 84 +++++ tests/test_export_blank_textures.py | 62 ++++ tests/test_game_modes.py | 124 +++++++ tests/test_gamepad.py | 33 ++ tests/test_gravity.py | 76 +++++ tests/test_input.py | 54 +++ tests/test_map_embedding.py | 24 ++ tests/test_menu_draw.py | 147 ++++++++ tests/test_noise.py | 49 +++ tests/test_reimport_textures.py | 50 +++ tests/test_swimming.py | 72 ++++ tests/test_terrain_worldgen.py | 205 +++++++++++ tests/test_tile.py | 48 +++ tests/test_ui_assets.py | 24 ++ tests/test_ui_draw.py | 27 ++ tests/test_world_creation.py | 57 +++- 50 files changed, 3239 insertions(+), 151 deletions(-) create mode 100644 engine/controls_menu.py create mode 100644 engine/dev_config.py create mode 100644 engine/dev_tools.py create mode 100644 engine/game_modes.py create mode 100644 engine/render/menu_draw.py create mode 100644 engine/render/ui_draw.py create mode 100644 engine/ui_assets.py create mode 100644 engine/worldgen/__init__.py create mode 100644 engine/worldgen/noise.py create mode 100644 engine/worldgen/terrain.py create mode 100644 resources/logic/swimming.py create mode 100644 scripts/export_blank_textures.py create mode 100644 scripts/reimport_textures.py create mode 100644 tests/test_camera.py create mode 100644 tests/test_controls_menu.py create mode 100644 tests/test_depth_shading.py create mode 100644 tests/test_dev_tools.py create mode 100644 tests/test_export_blank_textures.py create mode 100644 tests/test_game_modes.py create mode 100644 tests/test_gravity.py create mode 100644 tests/test_menu_draw.py create mode 100644 tests/test_noise.py create mode 100644 tests/test_reimport_textures.py create mode 100644 tests/test_swimming.py create mode 100644 tests/test_terrain_worldgen.py create mode 100644 tests/test_tile.py create mode 100644 tests/test_ui_assets.py create mode 100644 tests/test_ui_draw.py diff --git a/config/controller_bindings.conf b/config/controller_bindings.conf index fef5c81..8b25f54 100644 --- a/config/controller_bindings.conf +++ b/config/controller_bindings.conf @@ -4,6 +4,13 @@ ; as pressed past a threshold), gp_back, gp_start, gp_guide, gp_left_thumb, gp_right_thumb, ; gp_dpad_up, gp_dpad_down, gp_dpad_left, gp_dpad_right. The left stick always drives movement ; directly (not rebindable here) - see engine/gamepad.py's AXIS_LEFT_X/AXIS_LEFT_Y. +; +; A few gamepad buttons are fixed in main.py, not configurable here: the dpad/gp_a/gp_b always +; navigate/confirm/cancel every menu (start screen, character creation, controls, pause) the +; same way ArrowUp/Down/Left/Right/Enter/Escape do on a keyboard; gp_start always opens/closes +; the pause menu during play, and gp_back always toggles the character card. Since menus and +; actual play are mutually exclusive, gp_b doing menu-cancel there and activate_selected_ability +; here (see [abilities] below) is the same button serving two contexts, not a real conflict. [actions] leap = gp_a @@ -16,6 +23,7 @@ activate_right_hand_2 = gp_right_trigger activate_left_hand_2 = gp_left_trigger [abilities] +activate_selected_ability = gp_b select_ability_1 = gp_dpad_up select_ability_2 = gp_dpad_right select_ability_3 = gp_dpad_down diff --git a/config/keybindings.conf b/config/keybindings.conf index 001a014..96a1626 100644 --- a/config/keybindings.conf +++ b/config/keybindings.conf @@ -10,7 +10,13 @@ move_right = d, ArrowRight [actions] leap = space +swim_down = Control block = b +pause = Escape +toggle_character_card = c +toggle_dev_mode = F1 +dev_cycle_spawn = F2 +dev_spawn_object = F3 [hands] activate_right_hand = mouse_left @@ -19,6 +25,7 @@ activate_right_hand_2 = shift+mouse_left activate_left_hand_2 = shift+mouse_right [abilities] +activate_selected_ability = e select_ability_1 = 1 select_ability_2 = 2 select_ability_3 = 3 diff --git a/engine/camera.py b/engine/camera.py index c7e6dba..3693b9f 100644 --- a/engine/camera.py +++ b/engine/camera.py @@ -1,5 +1,7 @@ from __future__ import annotations +ZOOM_LERP_RATE = 3.0 # per-second convergence rate for update_zoom's approach to the target + class OrthoCamera: """Top-down orthographic camera; world space is tile units with y increasing downward.""" @@ -10,10 +12,27 @@ class OrthoCamera: self.viewport_w = viewport_w self.viewport_h = viewport_h self.tiles_visible_y = tiles_visible_y + self.target_tiles_visible_y = tiles_visible_y def set_target(self, x: float, y: float) -> None: self.x, self.y = x, y + def set_zoom_target(self, tiles_visible_y: float) -> None: + """Where update_zoom should smoothly carry tiles_visible_y toward - e.g. a wider value + while steering a ship, so the view zooms out to take in more of the ship/surroundings. + """ + self.target_tiles_visible_y = tiles_visible_y + + def update_zoom(self, dt: float) -> None: + """Exponentially approaches target_tiles_visible_y - call once per frame with the + frame's dt. Snaps once the gap is negligible so it settles instead of creeping forever. + """ + diff = self.target_tiles_visible_y - self.tiles_visible_y + if abs(diff) < 1e-3: + self.tiles_visible_y = self.target_tiles_visible_y + return + self.tiles_visible_y += diff * min(1.0, ZOOM_LERP_RATE * dt) + def resize(self, viewport_w: int, viewport_h: int) -> None: self.viewport_w, self.viewport_h = viewport_w, viewport_h diff --git a/engine/config.py b/engine/config.py index 3ce514e..64aeab6 100644 --- a/engine/config.py +++ b/engine/config.py @@ -9,6 +9,7 @@ DEFAULT_KEYBINDINGS: dict[str, list[str]] = { "move_left": ["a", "ArrowLeft"], "move_right": ["d", "ArrowRight"], "leap": ["space"], + "swim_down": ["Control"], "block": ["b"], "activate_right_hand": ["mouse_left"], "activate_left_hand": ["mouse_right"], @@ -24,6 +25,12 @@ DEFAULT_KEYBINDINGS: dict[str, list[str]] = { "select_ability_8": ["8"], "select_ability_9": ["9"], "select_ability_0": ["0"], + "activate_selected_ability": ["e"], + "pause": ["Escape"], + "toggle_character_card": ["c"], + "toggle_dev_mode": ["F1"], + "dev_cycle_spawn": ["F2"], + "dev_spawn_object": ["F3"], } # Human-friendly config names for keys that don't have a printable representation. @@ -42,7 +49,11 @@ class KeyBindings: def __init__(self, bindings: dict[str, list[str]]): self.bindings = bindings self._action_by_key: dict[str, str] = {} - for action, keys in bindings.items(): + self._reindex() + + def _reindex(self) -> None: + self._action_by_key = {} + for action, keys in self.bindings.items(): for key in keys: self._action_by_key[_normalize(key)] = action @@ -64,3 +75,29 @@ class KeyBindings: def keys_for_action(self, action: str) -> list[str]: return self.bindings.get(action, []) + + def rebind(self, action: str, key: str, index: int = 0) -> None: + """Replaces the key at `index` in `action`'s bound-key list (default: its primary key) + with `key` - the "configure controls" menu's rebind action (engine/controls_menu.py). + Steals `key` away from any other action it was already bound to first, so two actions + can never end up racing for the same input. + """ + key = _normalize(key) + for other_action, keys in self.bindings.items(): + if other_action != action and key in keys: + keys.remove(key) + keys = self.bindings.setdefault(action, []) + if index < len(keys): + keys[index] = key + else: + keys.append(key) + self._reindex() + + def save(self, path: Path) -> None: + parser = configparser.ConfigParser() + parser.add_section("actions") + for action, keys in self.bindings.items(): + parser.set("actions", action, ", ".join(keys)) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as handle: + parser.write(handle) diff --git a/engine/controls_menu.py b/engine/controls_menu.py new file mode 100644 index 0000000..1dd1630 --- /dev/null +++ b/engine/controls_menu.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from engine.config import DEFAULT_KEYBINDINGS, KeyBindings + +# Keys a rebind capture should never accept - they're the menu's own navigation/escape keys, +# so binding a game action to one of them would make the controls menu itself unusable. +RESERVED_CAPTURE_KEYS = {"Escape", "ArrowUp", "ArrowDown", "Enter"} + + +@dataclass +class ControlsMenu: + """Pure interaction state for the "configure controls" screen: list every bindable action, + navigate up/down, and rebind the highlighted action's primary key to whatever key is + pressed next. Reads/writes through the same KeyBindings the game itself uses (engine/ + config.py) - a rebind takes effect immediately (live) and can be written back to disk via + save(). No rendering here - same boundary as the rest of engine/ui.py. + """ + + keybindings: KeyBindings + actions: list[str] = field(default_factory=lambda: list(DEFAULT_KEYBINDINGS)) + selected_index: int = 0 + awaiting_key: bool = False + + def move(self, delta: int) -> None: + self.selected_index = (self.selected_index + delta) % len(self.actions) + + def selected_action(self) -> str: + return self.actions[self.selected_index] + + def begin_rebind(self) -> None: + self.awaiting_key = True + + def cancel_rebind(self) -> None: + self.awaiting_key = False + + def capture_key(self, key: str) -> bool: + """Feeds the next raw keypress into the pending rebind, if one is in progress (see + begin_rebind). Returns whether it was actually applied - a reserved menu key is ignored + (capture mode stays open so the player can try again) rather than rebinding onto it. + """ + if not self.awaiting_key: + return False + if key in RESERVED_CAPTURE_KEYS: + return False + self.keybindings.rebind(self.selected_action(), key) + self.awaiting_key = False + return True + + def save(self, path) -> None: + self.keybindings.save(path) diff --git a/engine/defs.py b/engine/defs.py index e2a5c45..602a607 100644 --- a/engine/defs.py +++ b/engine/defs.py @@ -57,6 +57,7 @@ class DefRegistry: deconstruct_item_id=fields.get("deconstruct_item_id"), is_helm=fields.get("is_helm", False), staircase_direction=fields.get("staircase_direction"), + speed_multiplier=fields.get("speed_multiplier", 1.0), ) def _load_items(self, path: Path) -> None: diff --git a/engine/dev_config.py b/engine/dev_config.py new file mode 100644 index 0000000..dbc1904 --- /dev/null +++ b/engine/dev_config.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import configparser +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class DevConfig: + """Whether dev-mode cheats (currently: engine/dev_tools.py's item spawner) are available - + persisted the same way KeyBindings is (engine/config.py), so toggling it in-game via + main.py's toggle_dev_mode action sticks across restarts instead of resetting to off. + """ + + enabled: bool = False + + @classmethod + def load(cls, path: Path) -> "DevConfig": + if not path.exists(): + return cls() + parser = configparser.ConfigParser() + parser.read(path) + enabled = parser.getboolean("dev", "enabled", fallback=False) + return cls(enabled=enabled) + + def save(self, path: Path) -> None: + parser = configparser.ConfigParser() + parser.add_section("dev") + parser.set("dev", "enabled", str(self.enabled).lower()) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as handle: + parser.write(handle) + + def toggle(self) -> None: + self.enabled = not self.enabled diff --git a/engine/dev_tools.py b/engine/dev_tools.py new file mode 100644 index 0000000..dcb9de2 --- /dev/null +++ b/engine/dev_tools.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +from engine.character import Character +from engine.defs import DefRegistry +from engine.item import ItemInstance + + +@dataclass +class DevSpawnMenu: + """Dev-mode-only cheat tool: cycle through every loaded item def and materialize one + straight into the player's inventory (first free-fitting slot). Gated off in normal play by + engine/dev_config.py::DevConfig.enabled - see main.py's dev_spawn_object handling. + + No world floor-item system exists in this engine yet (items only ever live in an Inventory, + or worn/held on a Character), so "spawning" here means "into inventory", not placing a loose + item on the ground - that's a bigger feature this doesn't attempt. + """ + + registry: DefRegistry + item_ids: list[str] = field(default_factory=list) + selected_index: int = 0 + + def __post_init__(self) -> None: + if not self.item_ids: + self.item_ids = sorted(self.registry.item_defs) + + def cycle(self, delta: int) -> None: + if self.item_ids: + self.selected_index = (self.selected_index + delta) % len(self.item_ids) + + def selected_item_id(self) -> str | None: + if not self.item_ids: + return None + return self.item_ids[self.selected_index] + + def spawn_into(self, character: Character) -> bool: + """Returns whether it actually fit - a no-op (False) if the character has no inventory + or the selected item has nowhere left to go. + """ + item_id = self.selected_item_id() + if item_id is None or character.inventory is None: + return False + item_def = self.registry.get_item_def(item_id) + slot = character.inventory.find_first_fit(item_def) + if slot is None: + return False + instance = ItemInstance(f"dev-spawn-{uuid.uuid4().hex[:8]}", item_id) + return character.inventory.place(instance, item_def, *slot) diff --git a/engine/game_modes.py b/engine/game_modes.py new file mode 100644 index 0000000..0cd0b09 --- /dev/null +++ b/engine/game_modes.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from engine.character_creator import CharacterCreator + +MAX_NAME_LENGTH = 20 + + +class Mode(Enum): + """Which screen main.py's frame loop is currently driving - see main.py's on_event/ + draw_frame dispatch. PLAYING is the only mode that touches World/InputState at all; every + other mode is pure menu navigation over the pure-state classes below. + """ + + MAIN_MENU = "main_menu" + CHARACTER_CREATE = "character_create" + WORLD_CREATE = "world_create" + CONTROLS = "controls" + PAUSED = "paused" + PLAYING = "playing" + + +@dataclass +class MainMenu: + """Pure interaction state for the title screen shown at launch instead of dropping straight + into a world. "continue" is only offered when a save already exists (has_existing_save) - + main.py decides that by checking whether WorldRepository.load_world() found anything. + """ + + has_existing_save: bool + selected_index: int = 0 + + def options(self) -> list[str]: + # Only one save slot exists (engine/db.py::WorldRepository holds a single world) - once + # a save exists, "new game" is left off rather than risking a silent overwrite; it only + # appears before any save has ever been made. + if self.has_existing_save: + return ["continue", "controls"] + return ["new_game", "controls"] + + def move(self, delta: int) -> None: + opts = self.options() + self.selected_index = (self.selected_index + delta) % len(opts) + + def selected(self) -> str: + return self.options()[self.selected_index] + + +@dataclass +class PauseMenu: + """Pure interaction state for the in-game Escape menu.""" + + OPTIONS = ("resume", "controls", "save_and_quit_to_menu") + selected_index: int = 0 + + def move(self, delta: int) -> None: + self.selected_index = (self.selected_index + delta) % len(self.OPTIONS) + + def selected(self) -> str: + return self.OPTIONS[self.selected_index] + + +@dataclass +class CharacterCreationFlow: + """Thin step-sequencing wrapper around CharacterCreator (engine/character_creator.py) for + the launch-time "new game" flow: pick a species (left/right arrows), type a name (printable + keys + backspace), then confirm. CharacterCreator itself already validates every choice - + this only tracks which step is active and routes raw menu input to the right one. + + No account/CharacterLibrary here (see engine/character_library.py, engine/start_screen.py) - + this engine only persists one world/player at a time via WorldRepository (engine/db.py), so + "new game" just needs a single freshly-built Character, not a saved roster to pick among. + """ + + creator: CharacterCreator + step: str = "species" + species_index: int = 0 + + def __post_init__(self) -> None: + self._lock_in_species() + + def _lock_in_species(self) -> None: + species = self.species_list() + if species: + self.creator.set_species(species[self.species_index % len(species)]) + + def species_list(self) -> list[str]: + return self.creator.available_species() + + def cycle_species(self, delta: int) -> None: + species = self.species_list() + if not species: + return + self.species_index = (self.species_index + delta) % len(species) + self._lock_in_species() + + def type_char(self, ch: str) -> None: + if self.step == "name" and ch.isprintable() and len(self.creator.name) < MAX_NAME_LENGTH: + self.creator.set_name(self.creator.name + ch) + + def backspace(self) -> None: + if self.step == "name": + self.creator.set_name(self.creator.name[:-1]) + + def advance(self) -> bool: + """Moves to the next step. Returns True once the flow is actually finished (final step + confirmed with a valid name) - main.py builds the Character only then, via + self.creator.build_character(). + """ + if self.step == "species": + self.step = "name" + return False + return bool(self.creator.name.strip()) + + def go_back(self) -> bool: + """Steps back one stage; returns False if already at the first step (caller should + leave character creation entirely, e.g. back to the main menu). + """ + if self.step == "name": + self.step = "species" + return True + return False diff --git a/engine/gamepad.py b/engine/gamepad.py index 23eea8b..54eb181 100644 --- a/engine/gamepad.py +++ b/engine/gamepad.py @@ -45,6 +45,7 @@ DEFAULT_GAMEPAD_BINDINGS: dict[str, list[str]] = { "activate_left_hand": ["gp_x"], "activate_right_hand_2": ["gp_right_trigger"], "activate_left_hand_2": ["gp_left_trigger"], + "activate_selected_ability": ["gp_b"], } @@ -89,6 +90,31 @@ class GamepadState: return cls(buttons=buttons, axes=axes) +@dataclass +class GamepadButtonEdgeTracker: + """Frame-to-frame "was this raw gp_* button just pressed?" detection, independent of any + action binding - used for menu navigation (main.py), which needs raw button edges (dpad/A/B) + rather than InputState's game-action dispatch (see apply_gamepad_state), since a menu isn't + a bindable game action and shouldn't go through GamepadBindings/KeyBindings at all. + """ + + _prev_pressed: set[str] = field(default_factory=set) + + def newly_pressed(self, state: GamepadState) -> set[str]: + pressed = state.digital_buttons() + edges = pressed - self._prev_pressed + self._prev_pressed = pressed + return edges + + def reset(self) -> None: + """Drops the remembered button set - call whenever something other than this tracker + may have changed context out from under it (mirrors InputState.clear_held_state), so a + button held continuously across that boundary is treated as freshly pressed afterward + rather than silently missing its next edge. + """ + self._prev_pressed = set() + + class GamepadBindings: """Action -> list of gp_* button names, loaded from an editable .conf file - same array- per-action format and fallback behavior as engine.config.KeyBindings, just for gamepad diff --git a/engine/input.py b/engine/input.py index 5e93d1a..2cd2b16 100644 --- a/engine/input.py +++ b/engine/input.py @@ -52,8 +52,10 @@ class InputState: self.pending_move: tuple[int, int, int] | None = None self.held_move_actions: set[str] = set() self.pending_leap: tuple[int, int, int] | None = None + self.pending_swim_down = False self.pending_hand_activation: str | None = None self.pending_ability_select: int | None = None + self.pending_activate_ability_bar = False self.is_blocking = False self.facing: tuple[int, int, int] = DEFAULT_FACING self.pointer_pos: tuple[float, float] | None = None @@ -105,12 +107,16 @@ class InputState: self.facing = move elif action == "leap": self.pending_leap = self.facing + elif action == "swim_down": + self.pending_swim_down = True elif action == "block": self.is_blocking = True elif action in HAND_ACTIVATION_ACTIONS: self.pending_hand_activation = HAND_ACTIVATION_ACTIONS[action] elif action in SELECT_ABILITY_ACTIONS: self.pending_ability_select = SELECT_ABILITY_ACTIONS[action] + elif action == "activate_selected_ability": + self.pending_activate_ability_bar = True def _apply_up(self, action: str) -> None: if action in MOVE_ACTION_VECTORS: @@ -165,6 +171,10 @@ class InputState: leap, self.pending_leap = self.pending_leap, None return leap + def consume_swim_down(self) -> bool: + fired, self.pending_swim_down = self.pending_swim_down, False + return fired + def consume_hand_activation(self) -> str | None: hand, self.pending_hand_activation = self.pending_hand_activation, None return hand @@ -172,3 +182,27 @@ class InputState: def consume_ability_select(self) -> int | None: index, self.pending_ability_select = self.pending_ability_select, None return index + + def consume_activate_ability_bar(self) -> bool: + fired, self.pending_activate_ability_bar = self.pending_activate_ability_bar, False + return fired + + def clear_held_state(self) -> None: + """Drops all held-key/blocking state - call whenever leaving PLAYING for a menu, since + key_up events (and gamepad polling - see main.py's poll_gamepad) stop reaching this + class while a menu owns input, so a key/button released mid-menu would otherwise still + read as held once gameplay resumes. + + Also resets the gamepad edge-tracking set: without this, a button held continuously + across the boundary (e.g. still holding block when a pause menu closes) would never see + a fresh "down" edge on resume, since apply_gamepad_state's diff would find it already + "pressed" in both the stale pre-pause snapshot and the current one. + """ + self.held_move_actions.clear() + self.is_blocking = False + self.pending_move = None + self.pending_leap = None + self.pending_swim_down = False + self.pending_hand_activation = None + self.pending_activate_ability_bar = False + self._prev_gamepad_buttons = set() diff --git a/engine/placeholder_textures.py b/engine/placeholder_textures.py index f9ab83e..cb427b3 100644 --- a/engine/placeholder_textures.py +++ b/engine/placeholder_textures.py @@ -9,6 +9,9 @@ from engine.defs import DefRegistry TEXTURE_SIZE = 32 BORDER_SHADE = 0.75 +DEPTH_SHADOW_TEXTURE = "tiles/depth_shadow.png" +DEPTH_SHADOW_ALPHA = 90 # out of 255 per stacked copy - see Renderer._draw_depth_shadow + def _checker_image(color: tuple[int, int, int], size: int = TEXTURE_SIZE) -> Image.Image: img = Image.new("RGBA", (size, size), (*color, 255)) @@ -41,3 +44,17 @@ def generate_placeholder_textures(registry: DefRegistry, textures_dir: Path) -> for part_def in registry.body_part_defs.values(): if part_def.texture: _ensure_texture(textures_dir, part_def.texture, part_def.color) + + +def generate_depth_shadow_texture(textures_dir: Path) -> None: + """A flat black, partially-transparent overlay - Renderer stacks 1+ copies of it on a tile + to darken it proportionally to how far below the camera's reference z-level it sits (see + Renderer._draw_depth_shadow), Dwarf-Fortress-style depth shading. Not keyed to any def + (it's a rendering-only overlay, not game data) but generated the same "only if missing" + way as generate_placeholder_textures. + """ + path = textures_dir / DEPTH_SHADOW_TEXTURE + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (TEXTURE_SIZE, TEXTURE_SIZE), (0, 0, 0, DEPTH_SHADOW_ALPHA)).save(path) diff --git a/engine/render/menu_draw.py b/engine/render/menu_draw.py new file mode 100644 index 0000000..052f6cb --- /dev/null +++ b/engine/render/menu_draw.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from engine.controls_menu import ControlsMenu +from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu +from engine.render.ui_draw import LINE_HEIGHT, draw_panel, draw_text, text_width +from engine.ui import AbilityBar, CharacterMenu +from engine.world_creation import WorldCreationMenu + +MENU_OPTION_LABELS: dict[str, str] = { + "continue": "Continue", + "new_game": "New Game", + "controls": "Configure Controls", + "resume": "Resume", + "save_and_quit_to_menu": "Save & Quit to Menu", +} + +ROW_H = LINE_HEIGHT +PADDING = 16.0 + + +def _draw_option_list(buckets, options: list[str], selected_index: int, x: float, y: float) -> None: + for i, option in enumerate(options): + label = MENU_OPTION_LABELS.get(option, option) + row_y = y + i * ROW_H + if i == selected_index: + draw_panel(buckets, "highlight", x - 6, row_y - 2, text_width("> " + label) + 12, ROW_H) + draw_text(buckets, ("> " if i == selected_index else " ") + label, x, row_y) + + +def draw_main_menu(buckets, menu: MainMenu, viewport_w: float, viewport_h: float) -> None: + options = menu.options() + panel_w, panel_h = 320.0, PADDING * 2 + ROW_H * (len(options) + 1) + x = (viewport_w - panel_w) / 2 + y = (viewport_h - panel_h) / 2 + draw_panel(buckets, "background", x, y, panel_w, panel_h) + draw_text(buckets, "Mi2d RPG Engine", x + PADDING, y + PADDING) + _draw_option_list(buckets, options, menu.selected_index, x + PADDING, y + PADDING + ROW_H * 1.5) + + +def draw_pause_menu(buckets, menu: PauseMenu, viewport_w: float, viewport_h: float) -> None: + options = list(PauseMenu.OPTIONS) + panel_w, panel_h = 320.0, PADDING * 2 + ROW_H * (len(options) + 1) + x = (viewport_w - panel_w) / 2 + y = (viewport_h - panel_h) / 2 + draw_panel(buckets, "background", x, y, panel_w, panel_h) + draw_text(buckets, "Paused", x + PADDING, y + PADDING) + _draw_option_list(buckets, options, menu.selected_index, x + PADDING, y + PADDING + ROW_H * 1.5) + + +def draw_character_creation(buckets, flow: CharacterCreationFlow, viewport_w: float, viewport_h: float) -> None: + panel_w, panel_h = 420.0, 220.0 + x = (viewport_w - panel_w) / 2 + y = (viewport_h - panel_h) / 2 + draw_panel(buckets, "background", x, y, panel_w, panel_h) + draw_text(buckets, "New Character", x + PADDING, y + PADDING) + + if flow.step == "species": + species = flow.species_list() + current = flow.creator.species_id or "-" + draw_text(buckets, f"Species: < {current} >", x + PADDING, y + PADDING + ROW_H * 2) + draw_text(buckets, f"({flow.species_index + 1}/{max(len(species), 1)})", x + PADDING, y + PADDING + ROW_H * 3) + draw_text(buckets, "Left/Right to choose, Enter to continue", x + PADDING, y + panel_h - PADDING - ROW_H) + else: + draw_text(buckets, f"Species: {flow.creator.species_id}", x + PADDING, y + PADDING + ROW_H * 2) + draw_text(buckets, f"Name: {flow.creator.name}_", x + PADDING, y + PADDING + ROW_H * 3) + draw_text(buckets, "Type a name, Enter to confirm, Esc to go back", x + PADDING, y + panel_h - PADDING - ROW_H) + + +def draw_world_creation(buckets, menu: WorldCreationMenu, viewport_w: float, viewport_h: float) -> None: + panel_w, panel_h = 480.0, PADDING * 2 + ROW_H * (len(menu.templates) * 2 + 2) + x = (viewport_w - panel_w) / 2 + y = (viewport_h - panel_h) / 2 + draw_panel(buckets, "background", x, y, panel_w, panel_h) + draw_text(buckets, "Choose a World", x + PADDING, y + PADDING) + + selected = menu.selected_index if menu.selected_index is not None else 0 + for i, template in enumerate(menu.templates): + row_y = y + PADDING + ROW_H * (1.5 + i * 2) + if i == selected: + draw_panel(buckets, "highlight", x + PADDING - 6, row_y - 2, text_width(template.name) + 12, ROW_H) + draw_text(buckets, template.name, x + PADDING, row_y) + draw_text(buckets, template.description[:60], x + PADDING + 10, row_y + ROW_H) + + draw_text(buckets, "Up/Down to choose, Enter to confirm", x + PADDING, y + panel_h - PADDING - ROW_H) + + +def draw_controls_menu(buckets, menu: ControlsMenu, viewport_w: float, viewport_h: float) -> None: + panel_w = 480.0 + panel_h = PADDING * 2 + ROW_H * (len(menu.actions) + 2) + x = (viewport_w - panel_w) / 2 + y = max(20.0, (viewport_h - panel_h) / 2) + draw_panel(buckets, "background", x, y, panel_w, panel_h) + draw_text(buckets, "Configure Controls", x + PADDING, y + PADDING) + + for i, action in enumerate(menu.actions): + row_y = y + PADDING + ROW_H * (i + 1.5) + keys = ", ".join(menu.keybindings.keys_for_action(action)) or "-" + capturing = menu.awaiting_key and i == menu.selected_index + label = f"{action}: {'press a key...' if capturing else keys}" + if i == menu.selected_index: + draw_panel(buckets, "highlight", x + PADDING - 6, row_y - 2, text_width(label) + 12, ROW_H) + draw_text(buckets, label, x + PADDING, row_y) + + footer_y = y + panel_h - PADDING - ROW_H + draw_text(buckets, "Enter to rebind, Esc to go back", x + PADDING, footer_y) + + +CHARACTER_CARD_W = 260.0 +CHARACTER_CARD_H = 200.0 + +ABILITY_BAR_SLOT_W = 30.0 +ABILITY_BAR_SLOT_H = 30.0 +ABILITY_BAR_GAP = 3.0 +ABILITY_BAR_KEY_LABELS = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "0") +ABILITY_BAR_W = len(ABILITY_BAR_KEY_LABELS) * ABILITY_BAR_SLOT_W + (len(ABILITY_BAR_KEY_LABELS) - 1) * ABILITY_BAR_GAP + + +def draw_ability_bar(buckets, bar: AbilityBar, viewport_h: float) -> None: + """Bottom-left action hotbar (numkeys 1-9/0 select a slot, "e"/gp_b activates whichever's + selected - see main.py). engine/ui.py::AbilityBar had never been wired to any rendering, + populated from the player's actual loadout, or hooked up to input consumption before, which + is why it "didn't show" at all. + """ + y = viewport_h - ABILITY_BAR_SLOT_H - PADDING + for i, key_label in enumerate(ABILITY_BAR_KEY_LABELS): + x = PADDING + i * (ABILITY_BAR_SLOT_W + ABILITY_BAR_GAP) + panel_name = "highlight" if i == bar.selected_index else "border" + draw_panel(buckets, panel_name, x, y, ABILITY_BAR_SLOT_W, ABILITY_BAR_SLOT_H) + draw_text(buckets, key_label, x + 2, y + 2, size=10.0, char_advance=7.0) + slot = bar.slots[i] + if slot is not None: + draw_text(buckets, slot.ability_id[:5], x + 1, y + 16, size=8.0, char_advance=6.0) + + +def _medical_lines(character, registry) -> list[str]: + """One line per body part (integrity, or REMOVED) plus an indented sub-line for each of its + ailments and organs - the data's all on Character.resolved_body_parts() (engine/character.py) + already, this just flattens it for the medical tab (see draw_character_card). + """ + lines = [f"Blood: {character.blood_percentage:.0f}%"] + for slot, part in sorted(character.resolved_body_parts().items()): + if part is None: + continue + if part.removed: + lines.append(f"{slot}: REMOVED") + continue + lines.append(f"{slot}: {part.integrity:.0f}%") + for ailment in part.ailments: + lines.append(f" {ailment.name} (sev {ailment.severity:.1f})") + for organ in part.organs: + organ_def = registry.organ_defs.get(organ.organ_def_id) + organ_name = organ_def.name if organ_def is not None else organ.organ_def_id + status = "removed" if organ.removed else f"{organ.integrity:.0f}%" + lines.append(f" {organ_name}: {status}") + return lines + + +def draw_character_card(buckets, character, menu: CharacterMenu, registry, viewport_h: float) -> None: + """Bio/equipment/medical card, next to the ability bar along the bottom edge (see + draw_ability_bar) - engine/ui.py::CharacterMenu had never been wired to any rendering + before, which is why it "didn't show" - see main.py's toggle_character_card. + """ + x = PADDING + ABILITY_BAR_W + PADDING + y = viewport_h - CHARACTER_CARD_H - PADDING + draw_panel(buckets, "background", x, y, CHARACTER_CARD_W, CHARACTER_CARD_H) + + name = character.name or "(unnamed)" + draw_text(buckets, name, x + 10, y + 10) + + tab_y = y + 10 + ROW_H + for i, tab in enumerate(CharacterMenu.TABS): + label = f"[{tab}]" if tab == menu.active_tab else f" {tab} " + draw_text(buckets, label, x + 10 + i * 70, tab_y) + + body_y = tab_y + ROW_H * 1.5 + if menu.active_tab == "bio": + draw_text(buckets, f"Species: {character.species_id or '-'}", x + 10, body_y) + draw_text(buckets, f"Blood: {character.blood_percentage:.0f}%", x + 10, body_y + ROW_H) + for i, (skill_id, value) in enumerate(sorted(character.bio.items())): + draw_text(buckets, f"{skill_id}: {value}", x + 10, body_y + ROW_H * (2 + i)) + elif menu.active_tab == "equipment": + for i, piece in enumerate(character.clothing or character.default_clothing): + draw_text(buckets, f"{piece.slot}: {piece.item_def_id}", x + 10, body_y + ROW_H * i) + else: + for i, line in enumerate(_medical_lines(character, registry)): + draw_text(buckets, line, x + 10, body_y + ROW_H * i) diff --git a/engine/render/pipeline.py b/engine/render/pipeline.py index addc123..65f57de 100644 --- a/engine/render/pipeline.py +++ b/engine/render/pipeline.py @@ -32,7 +32,7 @@ class QuadPipeline: size=CAMERA_UNIFORM_SIZE, usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, ) - camera_bind_group_layout = device.create_bind_group_layout( + self.camera_bind_group_layout = device.create_bind_group_layout( entries=[ { "binding": 0, @@ -41,10 +41,7 @@ class QuadPipeline: }, ] ) - self.camera_bind_group = device.create_bind_group( - layout=camera_bind_group_layout, - entries=[{"binding": 0, "resource": {"buffer": self.camera_buffer, "offset": 0, "size": CAMERA_UNIFORM_SIZE}}], - ) + self.camera_bind_group = self._make_camera_bind_group(self.camera_buffer) self.texture_bind_group_layout = device.create_bind_group_layout( entries=[ @@ -65,7 +62,7 @@ class QuadPipeline: ) pipeline_layout = device.create_pipeline_layout( - bind_group_layouts=[camera_bind_group_layout, self.texture_bind_group_layout] + bind_group_layouts=[self.camera_bind_group_layout, self.texture_bind_group_layout] ) self.pipeline = device.create_render_pipeline( @@ -138,9 +135,29 @@ class QuadPipeline: ], ) - def update_camera(self, scale: tuple[float, float], offset: tuple[float, float]) -> None: + def _make_camera_bind_group(self, buffer): + return self.device.create_bind_group( + layout=self.camera_bind_group_layout, + entries=[{"binding": 0, "resource": {"buffer": buffer, "offset": 0, "size": CAMERA_UNIFORM_SIZE}}], + ) + + def create_camera_binding(self): + """A second, independent (buffer, bind_group) pair sharing this pipeline's camera + layout - e.g. Renderer's screen-space UI camera, which must never share a buffer with + the world camera: both get written once per frame, and a render pass only sees whatever + a uniform buffer holds at submit time, so two live transforms need two buffers. + """ + buffer = self.device.create_buffer( + size=CAMERA_UNIFORM_SIZE, usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST + ) + return buffer, self._make_camera_bind_group(buffer) + + def write_camera(self, buffer, scale: tuple[float, float], offset: tuple[float, float]) -> None: data = np.array([scale[0], scale[1], offset[0], offset[1]], dtype=np.float32) - self.device.queue.write_buffer(self.camera_buffer, 0, data.tobytes()) + self.device.queue.write_buffer(buffer, 0, data.tobytes()) + + def update_camera(self, scale: tuple[float, float], offset: tuple[float, float]) -> None: + self.write_camera(self.camera_buffer, scale, offset) def make_instance_buffer(self, instances: np.ndarray): buffer = self.device.create_buffer( diff --git a/engine/render/renderer.py b/engine/render/renderer.py index 53abe5d..2e094f1 100644 --- a/engine/render/renderer.py +++ b/engine/render/renderer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from collections import defaultdict import numpy as np @@ -9,6 +10,7 @@ from engine.camera import OrthoCamera from engine.character import Character from engine.defs import DefRegistry from engine.map import Map, rotate_point +from engine.placeholder_textures import DEPTH_SHADOW_TEXTURE from engine.render.pipeline import QuadPipeline from engine.render.texture import TextureCache from engine.tile import TILE_LAYERS @@ -16,6 +18,28 @@ from engine.world import World CLEAR_COLOR = {"r": 0.05, "g": 0.05, "b": 0.08, "a": 1.0} CHARACTER_SLOT_ORDER = ("head", "torso", "left_arm", "right_arm", "left_leg", "right_leg") +MAX_DEPTH_SHADOW_LAYERS = 4 # caps how dark a very deep gap gets - see visible_z_and_depth + + +def visible_z_and_depth(m: Map, x: int, y: int, reference_z: int) -> tuple[int | None, int]: + """Which z-level is actually visible looking straight down column (x, y) from + `reference_z` (the camera/player's own level), and how many levels below reference_z it + is - 0 if it's exactly at reference_z, >0 if the view had to look through open air (a gap + with nothing generated there - see Tile.has_any_layer) to find real ground. + + This is what makes height visible top-down, Dwarf-Fortress-style: your own level renders + normally, and looking into a lower area through a gap shows it progressively darker the + deeper it is (see Renderer._draw_depth_shadow). Returns (None, 0) if there's a genuine void + all the way down - nothing generated in this column at or below reference_z at all. + """ + if not m.in_bounds(x, y, reference_z): + return None, 0 + if m.get_tile(x, y, reference_z).has_any_layer(): + return reference_z, 0 + for z in range(math.floor(reference_z) - 1, -1, -1): + if m.get_tile(x, y, z).has_any_layer(): + return z, reference_z - z + return None, 0 class Renderer: @@ -25,6 +49,7 @@ class Renderer: self.texture_cache = texture_cache self.registry = registry self._texture_bind_groups: dict[str, object] = {} + self.ui_camera_buffer, self.ui_camera_bind_group = pipeline.create_camera_binding() def _bind_group_for(self, relative_path: str): if relative_path not in self._texture_bind_groups: @@ -32,14 +57,24 @@ class Renderer: self._texture_bind_groups[relative_path] = self.pipeline.make_texture_bind_group(view) return self._texture_bind_groups[relative_path] - def render_frame(self, context, world: World, camera: OrthoCamera) -> None: + def render_frame( + self, context, world: World, camera: OrthoCamera, ui_instances: dict[str, list[float]] | None = None + ) -> None: scale, offset = camera.scale_offset() self.pipeline.update_camera(scale, offset) buckets: dict[str, list[float]] = defaultdict(list) if world.player is not None and world.player.position is not None: active_map = world.maps[world.player.position.map_id] - self._draw_map(active_map, (0, 0, 0), 0, world, buckets) + reference_z = math.floor(world.player.position.z) + self._draw_map(active_map, (0, 0, 0), 0, world, buckets, reference_z) + + # Screen-space overlay (menus, HUD) - a second camera transform (pixel space, not tile + # space) sharing the same buffer/instance mechanics as world content. See + # engine/render/ui_draw.py for how callers build `ui_instances`. + ui_scale = (2.0 / max(camera.viewport_w, 1), -2.0 / max(camera.viewport_h, 1)) + ui_offset = (-1.0, 1.0) + self.pipeline.write_camera(self.ui_camera_buffer, ui_scale, ui_offset) texture = context.get_current_texture() view = texture.create_view() @@ -66,37 +101,87 @@ class Renderer: pass_encoder.set_vertex_buffer(1, instance_buffer) pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances)) + if ui_instances: + pass_encoder.set_bind_group(0, self.ui_camera_bind_group) + for relative_path, flat_instances in ui_instances.items(): + if not flat_instances: + continue + instances = np.array(flat_instances, dtype=np.float32).reshape(-1, 4) + instance_buffer = self.pipeline.make_instance_buffer(instances) + pass_encoder.set_bind_group(1, self._bind_group_for(relative_path)) + pass_encoder.set_vertex_buffer(1, instance_buffer) + pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances)) + pass_encoder.end() self.device.queue.submit([encoder.finish()]) def _draw_map( - self, m: Map, offset: tuple[int, int, int], rotation: int, world: World, buckets: dict[str, list[float]] + self, + m: Map, + offset: tuple[int, int, int], + rotation: int, + world: World, + buckets: dict[str, list[float]], + reference_z: int = 0, ) -> None: - for z in range(m.depth): - for layer in TILE_LAYERS: + # Only the map the player is actually standing on gets the single-visible-level, + # depth-shaded treatment - visible_embeddings already means this is the only map ever + # reached by this recursion in practice (see its own docstring), but a fallback that + # just draws every z-level stacked keeps this correct if that ever changes. + is_players_map = ( + world.player is not None and world.player.position is not None and world.player.position.map_id == m.map_id + ) + + if is_players_map: + for y in range(m.height): + for x in range(m.width): + z, depth = visible_z_and_depth(m, x, y, reference_z) + if z is None: + continue + self._draw_tile_layers(m, x, y, z, offset, rotation, buckets) + if depth > 0: + self._draw_depth_shadow(m, x, y, offset, rotation, depth, buckets) + else: + for z in range(m.depth): for y in range(m.height): for x in range(m.width): - tile = m.get_tile(x, y, z) - def_id = tile.layer_id(layer) - if def_id is None: - continue - layer_def = self.registry.get_tile_layer_def(layer, def_id) - if layer_def is None or not layer_def.texture: - continue - rx, ry = rotate_point(x, y, m.width, m.height, rotation) - buckets[layer_def.texture].extend( - (float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0) - ) + self._draw_tile_layers(m, x, y, z, offset, rotation, buckets) for entity in world.entities_on_map(m.map_id): if isinstance(entity, Character): self._draw_character(entity, offset, rotation, m.width, m.height, buckets) - for embedding in m.embeddings: + for embedding in world.visible_embeddings(m): arx, ary = rotate_point(embedding.anchor[0], embedding.anchor[1], m.width, m.height, rotation) child_offset = (offset[0] + arx, offset[1] + ary, offset[2] + embedding.anchor[2]) child_rotation = (rotation + embedding.rotation) % 4 - self._draw_map(embedding.child_map, child_offset, child_rotation, world, buckets) + self._draw_map(embedding.child_map, child_offset, child_rotation, world, buckets, reference_z) + + def _draw_tile_layers( + self, m: Map, x: int, y: int, z: int, offset: tuple[int, int, int], rotation: int, buckets: dict[str, list[float]] + ) -> None: + tile = m.get_tile(x, y, z) + for layer in TILE_LAYERS: + def_id = tile.layer_id(layer) + if def_id is None: + continue + layer_def = self.registry.get_tile_layer_def(layer, def_id) + if layer_def is None or not layer_def.texture: + continue + rx, ry = rotate_point(x, y, m.width, m.height, rotation) + buckets[layer_def.texture].extend((float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0)) + + def _draw_depth_shadow( + self, m: Map, x: int, y: int, offset: tuple[int, int, int], rotation: int, depth: int, buckets: dict[str, list[float]] + ) -> None: + """Stacks `depth` (capped) copies of the same translucent-black overlay on one tile - + alpha blending compounds them, so more copies == darker, without any shader/pipeline + changes (see engine/placeholder_textures.py::DEPTH_SHADOW_TEXTURE). + """ + rx, ry = rotate_point(x, y, m.width, m.height, rotation) + position = (float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0) + for _ in range(min(depth, MAX_DEPTH_SHADOW_LAYERS)): + buckets[DEPTH_SHADOW_TEXTURE].extend(position) def _draw_character( self, diff --git a/engine/render/ui_draw.py b/engine/render/ui_draw.py new file mode 100644 index 0000000..9e7e00c --- /dev/null +++ b/engine/render/ui_draw.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +# Screen-space (pixel) UI drawing: pure instance-list builders, no wgpu/rendering dependency, +# so this is testable without a GPU. Renderer feeds the dicts these functions build into a +# second, screen-space camera pass alongside the normal world-space one - see +# engine/render/renderer.py::render_frame's `ui_instances` parameter, and engine/ui_assets.py +# for how the glyph/panel textures referenced here actually get generated on disk. + +GLYPH_SIZE = 16.0 # both the source PNG's pixel dimensions and its default on-screen size +CHAR_ADVANCE = 10.0 # horizontal spacing between glyph left edges when drawing a string +LINE_HEIGHT = 18.0 +PRINTABLE_MIN = 32 +PRINTABLE_MAX = 126 +FALLBACK_CHAR = "?" + + +def glyph_texture_path(ch: str) -> str: + code = ord(ch) + if not (PRINTABLE_MIN <= code <= PRINTABLE_MAX): + ch = FALLBACK_CHAR + return f"ui/font/{ord(ch)}.png" + + +def panel_texture_path(name: str) -> str: + return f"ui/panel/{name}.png" + + +def text_width(text: str, char_advance: float = CHAR_ADVANCE) -> float: + return len(text) * char_advance + + +def draw_text( + buckets: dict[str, list[float]], + text: str, + x: float, + y: float, + size: float = GLYPH_SIZE, + char_advance: float = CHAR_ADVANCE, +) -> float: + """Appends one instance per non-space glyph in `text` at (x, y), advancing left to right. + Returns the total pixel width advanced, so callers can center/right-align follow-up text. + """ + for i, ch in enumerate(text): + if ch == " ": + continue + buckets[glyph_texture_path(ch)].extend((x + i * char_advance, y, size, size)) + return text_width(text, char_advance) + + +def draw_panel(buckets: dict[str, list[float]], name: str, x: float, y: float, w: float, h: float) -> None: + buckets[panel_texture_path(name)].extend((x, y, w, h)) diff --git a/engine/tile.py b/engine/tile.py index c185019..1d5c7ed 100644 --- a/engine/tile.py +++ b/engine/tile.py @@ -16,6 +16,7 @@ class TileLayerDef: deconstruct_item_id: str | None = None # item yielded by a careful (right-click) deconstruction is_helm: bool = False # standing here lets a character steer the embedded map it's part of staircase_direction: str | None = None # "up" or "down" - see Tile.staircase_direction + speed_multiplier: float = 1.0 # e.g. mud/water slow movement - see World.speed_multiplier_at extra: dict = field(default_factory=dict) @@ -37,6 +38,14 @@ class Tile: def get_layer(self, layer: str) -> TileLayerInstance | None: return getattr(self, layer) + def has_any_layer(self) -> bool: + """False for a completely bare/unset tile - e.g. the open air a generated-terrain + column leaves above its own surface when nothing (slope/cave) was placed there. See + Renderer's depth-shading: it looks straight through tiles like this to whatever's + below, the same way World._apply_gravity does for movement. + """ + return any(self.get_layer(layer) is not None for layer in TILE_LAYERS) + def set_layer(self, layer: str, instance: TileLayerInstance | None) -> None: setattr(self, layer, instance) @@ -58,3 +67,14 @@ class Tile: if layer_def is not None and layer_def.staircase_direction is not None: return layer_def.staircase_direction return None + + def speed_multiplier(self, registry) -> float: + """The most restrictive speed_multiplier among this tile's layers (e.g. mud/water + flooring slowing movement) - 1.0 (no slow) if nothing here restricts it. + """ + multiplier = 1.0 + for layer in ("room", "flooring"): + layer_def = registry.get_tile_layer_def(layer, self.layer_id(layer)) + if layer_def is not None: + multiplier = min(multiplier, layer_def.speed_multiplier) + return multiplier diff --git a/engine/ui.py b/engine/ui.py index e065b53..0ff2da7 100644 --- a/engine/ui.py +++ b/engine/ui.py @@ -2,6 +2,14 @@ from __future__ import annotations from dataclasses import dataclass, field +from engine.character import Character +from engine.defs import DefRegistry + +# Fixed scan order populate_ability_bar fills slots in - matches HAND_TO_ARM_SLOT's own order +# in engine/character.py, so a two-armed character's slots land in the same left-to-right +# sense a player would expect (right hand first, then left, then the second pair if present). +ABILITY_BAR_HAND_SLOTS = ("right_hand", "left_hand", "right_hand_2", "left_hand_2") + @dataclass class LayerPickerMenu: @@ -67,20 +75,55 @@ class AbilityBar: return self.slots[self.selected_index] +def populate_ability_bar(bar: AbilityBar, character: Character, registry: DefRegistry) -> None: + """Auto-fills `bar` from whatever `character` can currently actually cast: each wielded + hand holding an ability-granting item (see ABILITY_BAR_HAND_SLOTS), then each installed + ability-granting implant - in that fixed order, one per slot. Meant to be re-run whenever + the loadout might have changed (main.py calls it once per frame during play; cheap enough + not to bother caching), which also means it always leaves stale slots from a dropped/ + unwielded item cleared rather than assigning past the end. + """ + bar.slots = [None] * len(bar.slots) + index = 0 + for hand_slot in ABILITY_BAR_HAND_SLOTS: + if index >= len(bar.slots): + return + held = character.get_held_item(hand_slot) + if held is None: + continue + item_def = registry.get_item_def(held.item_def_id) + if item_def.grants_ability is None: + continue + bar.assign(index, AbilityBarSlot(kind="hand", source_id=hand_slot, ability_id=item_def.grants_ability)) + index += 1 + + for implant_id in character.implants: + if index >= len(bar.slots): + return + item_def = registry.get_item_def(implant_id) + if item_def.grants_ability is None: + continue + bar.assign(index, AbilityBarSlot(kind="implant", source_id=implant_id, ability_id=item_def.grants_ability)) + index += 1 + + @dataclass class CharacterMenu: """Pure interaction state for the bottom-left tabbed character menu: a "bio" tab (skills/ - mental stats/health - all already on the Character model) and an "equipment" tab (worn - clothing + held items). Right-clicking an equipped slot that grants an inventory (e.g. a - worn backpack) opens a popup submenu above the menu showing that slot's contents. + mental stats/health - all already on the Character model), an "equipment" tab (worn + clothing + held items), and a "medical" tab (per-body-part integrity, ailments, and organ + health/removal - see engine/character.py's BodyPart/Organ/Ailment). Right-clicking an + equipped slot that grants an inventory (e.g. a worn backpack) opens a popup submenu above + the menu showing that slot's contents. - No rendering here - same boundary as LayerPickerMenu/AbilityBar above: the actual bio and - equipment data already lives on the Character/DefRegistry models this engine built over the - whole session, so this class only tracks which tab is active and which backpack popup (if - any) is open - drawing the tabs, labels, and popup box is a follow-up UI-layer task. + No rendering here - same boundary as LayerPickerMenu/AbilityBar above: the actual bio, + equipment, and medical data already lives on the Character/DefRegistry models this engine + built over the whole session, so this class only tracks which tab is active and which + backpack popup (if any) is open - drawing the tabs, labels, and popup box is a follow-up + UI-layer task (see engine/render/menu_draw.py::draw_character_card). """ - TABS = ("bio", "equipment") + TABS = ("bio", "equipment", "medical") active_tab: str = "bio" open_backpack_slot: str | None = None # clothing slot whose granted-inventory popup is open diff --git a/engine/ui_assets.py b/engine/ui_assets.py new file mode 100644 index 0000000..83bbfec --- /dev/null +++ b/engine/ui_assets.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +from engine.render.ui_draw import GLYPH_SIZE, PRINTABLE_MAX, PRINTABLE_MIN, glyph_texture_path, panel_texture_path + +FONT_PIXEL_SIZE = 13 + +# Flat RGBA fills for menu chrome - deliberately plain (unlike world tiles/items' checker +# placeholder texture) so text stays legible on top of them. See engine/placeholder_textures.py +# for the equivalent generator for world-space art. +UI_PANEL_COLORS: dict[str, tuple[int, int, int, int]] = { + "background": (12, 14, 20, 235), + "highlight": (70, 110, 200, 235), + "border": (150, 160, 180, 255), + "accent": (200, 90, 40, 235), +} + +_font_cache = None + + +def _font(): + global _font_cache + if _font_cache is None: + _font_cache = ImageFont.load_default(size=FONT_PIXEL_SIZE) + return _font_cache + + +def generate_ui_textures(textures_dir: Path) -> None: + """Writes one glyph PNG per printable ASCII character plus a handful of flat-color panel + fills, if missing. Same on-demand approach as generate_placeholder_textures, just for menu + chrome (start screen, controls menu, character card) instead of world tile/item art. + """ + size = int(GLYPH_SIZE) + for code in range(PRINTABLE_MIN, PRINTABLE_MAX + 1): + ch = chr(code) + path = textures_dir / glyph_texture_path(ch) + if path.exists(): + continue + path.parent.mkdir(parents=True, exist_ok=True) + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + ImageDraw.Draw(img).text((1, 0), ch, font=_font(), fill=(255, 255, 255, 255)) + img.save(path) + + for name, color in UI_PANEL_COLORS.items(): + path = textures_dir / panel_texture_path(name) + if path.exists(): + continue + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGBA", (8, 8), color).save(path) diff --git a/engine/world.py b/engine/world.py index c565996..8af38d2 100644 --- a/engine/world.py +++ b/engine/world.py @@ -48,8 +48,10 @@ class World: self.time_warp_zones: list[TimeWarpZone] = [] def speed_multiplier_at(self, entity_id: str, map_id: str, x: float, y: float, z: float, now: float) -> float: - """Combined slow from every active, non-expired zone that affects this entity here.""" - multiplier = 1.0 + """Combined slow from the terrain underfoot (e.g. mud/water - see Tile.speed_multiplier) + and every active, non-expired time-warp zone that affects this entity here. + """ + multiplier = self.maps[map_id].get_tile(x, y, z).speed_multiplier(self.registry) for zone in self.time_warp_zones: if zone.expires_at > now and zone.affects(entity_id) and zone.contains(map_id, x, y, z): multiplier *= zone.speed_factor @@ -67,6 +69,16 @@ class World: def entities_on_map(self, map_id: str) -> list[Entity]: return [e for e in self.entities.values() if e.position is not None and e.position.map_id == map_id] + def visible_embeddings(self, m: Map) -> list: + """`m`'s embeddings the player is actually aboard right now - i.e. whose child map is + the player's own current map. Rendering only descends into these: an embedded map (e.g. + a ship's interior) is otherwise invisible from outside it, so standing off-ship never + exposes its submap. No-op (empty) with no player positioned anywhere yet. + """ + if self.player is None or self.player.position is None: + return [] + return [e for e in m.embeddings if e.child_map.map_id == self.player.position.map_id] + def entity_at(self, map_id: str, x: int, y: int, z: int) -> Entity | None: """Tile-membership lookup: matches any entity whose (possibly continuous) position falls in tile (x, y, z), i.e. floor(pos.x) == x etc. Floor of an int is itself, so this @@ -89,6 +101,7 @@ class World: moved = self._move_within(entity, cur_map, tx, ty, tz) if moved: self._apply_staircase(entity) + self._apply_gravity(entity) return moved def try_leap(self, entity: Entity, dx: int, dy: int, dz: int, distance: int = LEAP_DISTANCE) -> bool: @@ -147,6 +160,7 @@ class World: moved = self._move_within_continuous(entity, self.maps[entity.position.map_id], nx, ny, nz) if moved: self._apply_staircase(entity) + self._apply_gravity(entity) return moved def _apply_staircase(self, entity: Entity) -> None: @@ -167,6 +181,25 @@ class World: return entity.position = EntityPosition(map_.map_id, x, y, nz) + def _apply_gravity(self, entity: Entity) -> None: + """Drops an entity straight down to the first walkable, floored tile below, if the + tile they're now on has no floor at all - open air, e.g. stepping off a cliff steeper + than any placed slope/cave connector (see engine/worldgen/terrain.py: everywhere else + in this engine's hand-authored maps, every tile gets a flooring layer by default, so + this only ever fires for that generated-terrain gap). A single relocation, not a + physics simulation - same "instant, not animated" spirit as _apply_staircase. + """ + assert entity.position is not None + map_ = self.maps[entity.position.map_id] + x, y, z = entity.position.x, entity.position.y, entity.position.z + if map_.get_tile(x, y, z).flooring is not None: + return + for nz in range(math.floor(z) - 1, -1, -1): + candidate = map_.get_tile(x, y, nz) + if candidate.flooring is not None and candidate.is_walkable(self.registry): + entity.position = EntityPosition(map_.map_id, x, y, nz) + return + def _move_within_continuous(self, entity: Entity, map_: Map, x: float, y: float, z: float) -> bool: tile_x, tile_y, tile_z = math.floor(x), math.floor(y), math.floor(z) if map_.in_bounds(tile_x, tile_y, tile_z): diff --git a/engine/world_creation.py b/engine/world_creation.py index d82937b..3ba1fb2 100644 --- a/engine/world_creation.py +++ b/engine/world_creation.py @@ -1,20 +1,25 @@ from __future__ import annotations +import random from dataclasses import dataclass, field from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map from engine.map_loader import MapLoader from engine.world import World +from engine.worldgen.terrain import generate_terrain_map + +GENERATED_WORLD_SIZE = 32 # width == height, tiles @dataclass(frozen=True) class WorldTemplate: """One selectable starting-world option on the 'create world' screen. - For now every template is just a fixed map file this engine ships with. Planned follow-up - game modes (campaign/survival/creative) will add templates that build a World from - procedural generation instead of a map_file - map_file is left optional so those can slot - in as templates with map_file=None and their own generator, without changing this shape. + Most templates are just a fixed map file this engine ships with; map_file=None marks a + procedurally-generated one instead (see build_world_from_template's dispatch on it, and + engine/worldgen/terrain.py for the actual generator). """ id: str @@ -31,6 +36,13 @@ AVAILABLE_WORLD_TEMPLATES: list[WorldTemplate] = [ "the engine's core mechanics (embedding, steering, staircases, weather).", map_file="harbor_world.json", ), + WorldTemplate( + id="generated", + name="Generated World", + description="A fresh Perlin-noise heightmap: rolling terrain (water, sand, dirt, mud, " + "stone), slopes between elevations, and the occasional cave at a steep cliff.", + map_file=None, + ), ] @@ -49,21 +61,64 @@ class WorldCreationMenu: if 0 <= index < len(self.templates): self.selected_index = index + def move(self, delta: int) -> None: + if not self.templates: + return + current = self.selected_index if self.selected_index is not None else 0 + self.select((current + delta) % len(self.templates)) + def selected_template(self) -> WorldTemplate | None: if self.selected_index is None: return None return self.templates[self.selected_index] -def build_world_from_template(template: WorldTemplate, registry: DefRegistry, map_loader: MapLoader) -> World: - """Builds an empty (player-less) World from a template's map - the "confirm" action once a - WorldCreationMenu selection is made. Spawning/joining a character into it is a separate - step - see engine/start_screen.py::join_world_with_character. +def find_spawn_position(m: Map, registry: DefRegistry, rng: random.Random | None = None) -> EntityPosition | None: + """A random walkable, unroofed, dry (non-water) tile to drop a new character onto - used + for any template, not just generated ones, so a hand-authored map's exact layout doesn't + need to be known in advance. Falls back to any exposed walkable tile (even water) and then + to any walkable tile at all, so this only returns None for a map with nowhere to stand. + """ + rng = rng or random.Random() + candidates_dry = [] + candidates_exposed = [] + candidates_any = [] + for z in range(m.depth): + for y in range(m.height): + for x in range(m.width): + tile = m.get_tile(x, y, z) + if not tile.is_walkable(registry): + continue + candidates_any.append((x, y, z)) + if tile.roof is None: + candidates_exposed.append((x, y, z)) + if tile.layer_id("flooring") != "water": + candidates_dry.append((x, y, z)) + + for candidates in (candidates_dry, candidates_exposed, candidates_any): + if candidates: + return EntityPosition(m.map_id, *rng.choice(candidates)) + return None + + +def build_world_from_template( + template: WorldTemplate, + registry: DefRegistry, + map_loader: MapLoader, + seed: int | None = None, +) -> World: + """Builds an empty (player-less) World from a template - the "confirm" action once a + WorldCreationMenu selection is made. Spawning/joining a character into it is a separate + step - see find_spawn_position above and engine/start_screen.py::join_world_with_character. """ - if template.map_file is None: - raise NotImplementedError(f"world template {template.id!r} has no map_file and no generator yet") world = World(registry=registry) - root_map = map_loader.load(template.map_file) + + if template.map_file is None: + seed = seed if seed is not None else random.randint(0, 2**31 - 1) + root_map = generate_terrain_map(template.id, GENERATED_WORLD_SIZE, GENERATED_WORLD_SIZE, seed) + else: + root_map = map_loader.load(template.map_file) + world.add_map(root_map) for embedding in root_map.embeddings: world.add_map(embedding.child_map) diff --git a/engine/worldgen/__init__.py b/engine/worldgen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/worldgen/noise.py b/engine/worldgen/noise.py new file mode 100644 index 0000000..22f5b8b --- /dev/null +++ b/engine/worldgen/noise.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import random + +import numpy as np + +# The 8 compass-direction gradient vectors classic 2D Perlin noise blends between at each +# integer grid corner - simpler than the usual 12-edge-of-cube set (this is 2D, not 3D) but +# still gives every corner a distinct, non-axis-degenerate direction. +_GRADIENTS = ( + (1, 1), (-1, 1), (1, -1), (-1, -1), + (1, 0), (-1, 0), (0, 1), (0, -1), +) + + +def _build_permutation(seed: int) -> list[int]: + """A 512-long permutation table (256 values, duplicated) so indexing perm[i + 1] never + overflows - the standard trick from Perlin's reference implementation. + """ + rng = random.Random(seed) + table = list(range(256)) + rng.shuffle(table) + return table + table + + +def _fade(t: float) -> float: + """Perlin's improved (quintic) easing curve - smoother second-derivative than a plain + cubic, so the noise has no visible creases at integer grid lines. + """ + return t * t * t * (t * (t * 6 - 15) + 10) + + +def _lerp(a: float, b: float, t: float) -> float: + return a + t * (b - a) + + +def _gradient(hash_value: int, x: float, y: float) -> float: + gx, gy = _GRADIENTS[hash_value % len(_GRADIENTS)] + return gx * x + gy * y + + +def _perlin_at(perm: list[int], x: float, y: float) -> float: + """Single-octave Perlin noise at one continuous (x, y) coordinate, roughly in [-1, 1].""" + xi, yi = int(np.floor(x)) & 255, int(np.floor(y)) & 255 + xf, yf = x - np.floor(x), y - np.floor(y) + u, v = _fade(xf), _fade(yf) + + aa = perm[perm[xi] + yi] + ab = perm[perm[xi] + yi + 1] + ba = perm[perm[xi + 1] + yi] + bb = perm[perm[xi + 1] + yi + 1] + + x1 = _lerp(_gradient(aa, xf, yf), _gradient(ba, xf - 1, yf), u) + x2 = _lerp(_gradient(ab, xf, yf - 1), _gradient(bb, xf - 1, yf - 1), u) + return _lerp(x1, x2, v) + + +def perlin_noise_2d( + width: int, + height: int, + seed: int, + scale: float = 20.0, + octaves: int = 4, + persistence: float = 0.5, + lacunarity: float = 2.0, + x_offset: float = 0.0, + y_offset: float = 0.0, +) -> np.ndarray: + """A (height, width) array of fractal Perlin noise, normalized to [0, 1]. + + `x_offset`/`y_offset` shift which region of the *same* infinite noise field this call + samples - two calls with the same seed whose offsets are `width`/`height` apart tile + together with no visible seam, which is what lets neighboring world-gen chunks (see + engine/worldgen/terrain.py) share one continuous heightmap instead of each rolling an + independent, discontinuous one at their own edges. + """ + perm = _build_permutation(seed) + result = np.zeros((height, width), dtype=np.float64) + + for row in range(height): + for col in range(width): + amplitude = 1.0 + frequency = 1.0 + total = 0.0 + max_amplitude = 0.0 + for _ in range(octaves): + nx = (col + x_offset) / scale * frequency + ny = (row + y_offset) / scale * frequency + total += _perlin_at(perm, nx, ny) * amplitude + max_amplitude += amplitude + amplitude *= persistence + frequency *= lacunarity + result[row, col] = total / max_amplitude if max_amplitude > 0 else 0.0 + + # Perlin noise is theoretically bounded but the practical range for a few octaves is + # comfortably within [-1, 1] - normalize into [0, 1] for a heightmap/moisture-map caller. + return (result + 1.0) / 2.0 diff --git a/engine/worldgen/terrain.py b/engine/worldgen/terrain.py new file mode 100644 index 0000000..0f87b10 --- /dev/null +++ b/engine/worldgen/terrain.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import random + +import numpy as np + +from engine.map import Map +from engine.tile import TileLayerInstance +from engine.worldgen.noise import perlin_noise_2d + +MAX_HEIGHT = 6 # number of distinct surface elevations (z = 0..MAX_HEIGHT-1) +DEPTH = MAX_HEIGHT + 1 # +1 headroom for a slope "descend" connector above the tallest peak + +WATER_MAX_Z = 1 # z <= this is underwater +SAND_Z = 2 # the one shoreline band just above the waterline +STONE_MIN_Z = 5 # z >= this is bare rock/peaks +MUD_MOISTURE_THRESHOLD = 0.55 # moisture noise above this, in the dirt/mud band, becomes mud + +CAVE_MIN_HEIGHT_DIFF = 2 +CAVE_CHANCE = 0.2 + +HEIGHT_NOISE_SCALE = 18.0 +MOISTURE_NOISE_SCALE = 12.0 +MOISTURE_SEED_OFFSET = 10_000 # keeps the moisture field decorrelated from the height field + + +def terrain_for(z: int, moisture: float) -> str: + """Flooring def id for a column of surface height `z`, given its independent moisture + sample - see resources/defs/tiles.json's "flooring" section for the matching defs. + """ + if z <= WATER_MAX_Z: + return "water" + if z == SAND_Z: + return "sand" + if z >= STONE_MIN_Z: + return "stone" + return "mud" if moisture > MUD_MOISTURE_THRESHOLD else "dirt" + + +def generate_heights(width: int, height: int, seed: int, x_offset: float = 0.0, y_offset: float = 0.0) -> np.ndarray: + """Integer surface elevation per (x, y) column, 0..MAX_HEIGHT-1, from Perlin noise.""" + noise = perlin_noise_2d(width, height, seed, scale=HEIGHT_NOISE_SCALE, x_offset=x_offset, y_offset=y_offset) + return (noise * MAX_HEIGHT).astype(int).clip(0, MAX_HEIGHT - 1) + + +def generate_moisture(width: int, height: int, seed: int, x_offset: float = 0.0, y_offset: float = 0.0) -> np.ndarray: + """An independent noise field (different seed + scale) so terrain variety isn't purely a + function of elevation - e.g. two dirt-band columns at the same height can differ (mud vs. + plain dirt) rather than every column at a given height looking identical. + """ + return perlin_noise_2d( + width, height, seed + MOISTURE_SEED_OFFSET, scale=MOISTURE_NOISE_SCALE, x_offset=x_offset, y_offset=y_offset + ) + + +def _set_tile( + m: Map, x: int, y: int, z: int, *, flooring: str | None = None, room: str | None = None, roof: str | None = None +) -> None: + tile = m.get_tile(x, y, z) + if flooring is not None: + tile.flooring = TileLayerInstance(flooring) + if room is not None: + tile.room = TileLayerInstance(room) + if roof is not None: + tile.roof = TileLayerInstance(roof) + + +def _place_slope_connector( + m: Map, low_xy: tuple[int, int], high_xy: tuple[int, int], low_z: int, low_terrain: str, high_terrain: str +) -> None: + """Lets a character walk directly between two columns exactly 1 level apart (req 3): + `high_xy` gets a walkable tile at `low_z` marked to auto-rise (landed on by walking over + from `low_xy`), and `low_xy` gets one at `low_z + 1` marked to auto-descend (landed on by + walking over from `high_xy`) - see Tile.staircase_direction/World._apply_staircase for the + actual auto-shift. Each side keeps its own column's terrain material underfoot. + """ + lx, ly = low_xy + hx, hy = high_xy + if m.in_bounds(hx, hy, low_z): + _set_tile(m, hx, hy, low_z, flooring=high_terrain, room="slope_up") + if m.in_bounds(lx, ly, low_z + 1): + _set_tile(m, lx, ly, low_z + 1, flooring=low_terrain, room="slope_down") + + +def _carve_cave_pocket(m: Map, high_xy: tuple[int, int], low_z: int) -> None: + """Replaces what would otherwise be solid rock at the base of a steep (2+) cliff with a + small walkable, roofed (it's still under the overhanging higher terrain), unwalled cave + pocket (req 4) - reachable by walking straight in from the lower neighbor at `low_z`. + + Doesn't attempt to connect all the way up to the higher column's own surface - a full + multi-level tunnel needs more than one waypoint to be climbable (auto-staircase only + triggers once per horizontal move, onto a *different* tile - see _place_slope_connector's + docstring), which is a bigger corridor-carving feature than "sometimes, a cave forms + here" calls for. This is a "sometimes" flavor pocket, not a guaranteed shortcut. + """ + x, y = high_xy + if m.in_bounds(x, y, low_z): + tile = m.get_tile(x, y, low_z) + tile.flooring = TileLayerInstance("stone") + tile.room = None # clears the solid_rock the underground fill pass set here + tile.roof = TileLayerInstance("cave_ceiling") + + +def build_terrain_map(map_id: str, heights: np.ndarray, moisture: np.ndarray, rng: random.Random) -> Map: + """The deterministic half of terrain generation: turns an already-computed heightmap + + moisture map into an actual Map. Split out from generate_terrain_map so tests can drive it + with hand-built arrays instead of reverse-engineering a seed that produces a given layout. + """ + height, width = heights.shape + m = Map(map_id, width, height, DEPTH) + + for y in range(height): + for x in range(width): + z = int(heights[y, x]) + terrain = terrain_for(z, float(moisture[y, x])) + for below in range(z): + if terrain == "water": + # a shallow water column is swimmable all the way down, not just at its + # surface - see resources/logic/swimming.py's vertical movement (req 6) + _set_tile(m, x, y, below, flooring="water") + else: + _set_tile(m, x, y, below, room="solid_rock") + _set_tile(m, x, y, z, flooring=terrain) # roof left empty - open to the sky (req 2) + + for y in range(height): + for x in range(width): + z = int(heights[y, x]) + terrain = terrain_for(z, float(moisture[y, x])) + for nx, ny in ((x + 1, y), (x, y + 1)): + if not (0 <= nx < width and 0 <= ny < height): + continue + nz = int(heights[ny, nx]) + n_terrain = terrain_for(nz, float(moisture[ny, nx])) + diff = nz - z + + if diff == 1: + _place_slope_connector(m, (x, y), (nx, ny), z, terrain, n_terrain) + elif diff == -1: + _place_slope_connector(m, (nx, ny), (x, y), nz, n_terrain, terrain) + elif abs(diff) >= CAVE_MIN_HEIGHT_DIFF and rng.random() < CAVE_CHANCE: + low_z = z if diff > 0 else nz + high_xy = (nx, ny) if diff > 0 else (x, y) + _carve_cave_pocket(m, high_xy, low_z) + + return m + + +def generate_terrain_map( + map_id: str, + width: int, + height: int, + seed: int, + x_offset: float = 0.0, + y_offset: float = 0.0, +) -> Map: + """Builds a heightmap-based terrain Map from Perlin noise - see build_terrain_map for the + actual tile placement rules (surface material, slopes, cave pockets, underground rock). + `x_offset`/`y_offset` sample a shifted window of the same noise field (see + engine/worldgen/noise.py::perlin_noise_2d), for tiling multiple generated maps seamlessly. + """ + heights = generate_heights(width, height, seed, x_offset, y_offset) + moisture = generate_moisture(width, height, seed, x_offset, y_offset) + rng = random.Random(seed) + return build_terrain_map(map_id, heights, moisture, rng) diff --git a/main.py b/main.py index 9c1be91..7b3c4ee 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import random import time +from collections import defaultdict from pathlib import Path import glfw @@ -9,27 +10,45 @@ import glfw from engine.aim import snap_to_8way from engine.camera import OrthoCamera from engine.character import Character +from engine.character_creator import CharacterCreator from engine.config import KeyBindings +from engine.controls_menu import ControlsMenu from engine.db import WorldRepository from engine.defs import DefRegistry -from engine.entity import EntityPosition -from engine.gamepad import GamepadBindings, GamepadState +from engine.dev_config import DevConfig +from engine.dev_tools import DevSpawnMenu +from engine.gamepad import GamepadBindings, GamepadButtonEdgeTracker, GamepadState +from engine.game_modes import CharacterCreationFlow, MainMenu, Mode, PauseMenu from engine.input import InputState from engine.inventory import Inventory from engine.item import ItemInstance from engine.map_loader import MapLoader -from engine.placeholder_textures import generate_placeholder_textures +from engine.placeholder_textures import generate_depth_shadow_texture, generate_placeholder_textures from engine.render.device import configure_context, request_device +from engine.render.menu_draw import ( + draw_ability_bar, + draw_character_card, + draw_character_creation, + draw_controls_menu, + draw_main_menu, + draw_pause_menu, + draw_world_creation, +) from engine.render.pipeline import QuadPipeline from engine.render.renderer import Renderer from engine.render.texture import TextureCache +from engine.render.ui_draw import draw_text from engine.render.window import Window from engine.timing import GameClock +from engine.ui import AbilityBar, CharacterMenu, populate_ability_bar +from engine.ui_assets import generate_ui_textures from engine.world import World -from resources.logic.actions import activate_hand +from engine.world_creation import WorldCreationMenu, build_world_from_template, find_spawn_position +from resources.logic.actions import activate_ability_bar_slot, activate_hand from resources.logic.health_ticks import apply_bleeding_tick from resources.logic.organs import apply_organ_interactions_tick from resources.logic.steering import advance_all_ship_rotations, at_helm, try_steer_ship +from resources.logic.swimming import in_water, try_swim_vertical ROOT_DIR = Path(__file__).resolve().parent DEFS_DIR = ROOT_DIR / "resources" / "defs" @@ -37,60 +56,99 @@ TEXTURES_DIR = ROOT_DIR / "resources" / "textures" DB_PATH = ROOT_DIR / "data" / "world.sqlite3" KEYBINDINGS_PATH = ROOT_DIR / "config" / "keybindings.conf" CONTROLLER_BINDINGS_PATH = ROOT_DIR / "config" / "controller_bindings.conf" +DEV_CONFIG_PATH = ROOT_DIR / "config" / "dev.conf" BASE_MOVE_SPEED = 4.0 # tiles/second at full (two healthy legs') speed factor +DEFAULT_TILES_VISIBLE_Y = 12.0 +HELM_TILES_VISIBLE_Y = 20.0 # wider view while steering a ship, so more of it is in frame + +# Raw gamepad button -> the same synthetic key strings the menu handlers already accept from +# keyboard input (see handle_menu_key) - lets a controller drive every menu without a second +# set of menu-navigation logic. Only consulted outside Mode.PLAYING; during play, the dpad/A/B +# stay bound to gameplay actions via GamepadBindings/controller_bindings.conf as before. +GAMEPAD_MENU_KEY_MAP: dict[str, str] = { + "gp_dpad_up": "ArrowUp", + "gp_dpad_down": "ArrowDown", + "gp_dpad_left": "ArrowLeft", + "gp_dpad_right": "ArrowRight", + "gp_a": "Enter", + "gp_b": "Escape", +} -def spawn_player(registry: DefRegistry) -> Character: +def equip_starting_loadout(character: Character, registry: DefRegistry) -> None: + """A fresh character's starter gear - the same loadout spawn_player used to hand out + directly, now applied to whichever Character actually becomes the player (built via + engine/character_creator.py::CharacterCreator at "new game" time rather than a fixed + template), so newly created characters aren't dropped into the world with empty hands. + """ + character.wield_item("right_hand", "staff_oak", registry) entity_def = registry.get_entity_def("player") - assert entity_def.species_id is not None - - player = Character( - entity_id="player-1", - name=entity_def.name, - def_id=entity_def.id, - position=EntityPosition("demo_world", 4, 4, 0), - species_id=entity_def.species_id, - gender="nonbinary", - default_body_parts=registry.new_default_body_parts(entity_def.species_id), - default_clothing=registry.new_default_clothing(entity_def.id), - ) - player.wield_item("right_hand", "staff_oak", registry) - - if entity_def.inventory_size is not None: - player.inventory = Inventory(*entity_def.inventory_size) - player.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0) - player.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0) - - return player + if entity_def.inventory_size is not None and character.inventory is None: + character.inventory = Inventory(*entity_def.inventory_size) + character.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0) + character.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0) -def build_fresh_world(registry: DefRegistry, map_loader: MapLoader) -> World: - world = World(registry=registry) - demo_world = map_loader.load("demo_world.json") - world.add_map(demo_world) - for embedding in demo_world.embeddings: - world.add_map(embedding.child_map) - - player = spawn_player(registry) +def spawn_new_character_into_world(world: World, registry: DefRegistry, player: Character, rng: random.Random) -> bool: + """Drops a freshly-created character into `world` at a random walkable, dry, unroofed + spot (see engine/world_creation.py::find_spawn_position) and equips their starter gear. + Returns whether it succeeded - False (world untouched) only if the map has nowhere to + stand at all, which shouldn't happen for any of AVAILABLE_WORLD_TEMPLATES. + """ + root_map = next((m for m in world.maps.values() if m.parent_embedding is None), None) + if root_map is None: + return False + spawn = find_spawn_position(root_map, registry, rng) + if spawn is None: + return False + player.position = spawn + equip_starting_loadout(player, registry) world.add_entity(player) world.player = player - return world + return True + + +class AppState: + """Everything main()'s frame loop mutates from one frame to the next: which screen is + active (see engine/game_modes.py::Mode) and the live instance of whichever pure-state menu + class that screen is currently driving. Not itself reusable outside this module - it's just + the integration point tying together the several previously-unwired menu/flow classes. + """ + + def __init__(self, registry: DefRegistry, world: World | None): + self.mode = Mode.MAIN_MENU + self.world = world + self.main_menu = MainMenu(has_existing_save=world is not None) + self.char_flow: CharacterCreationFlow | None = None + self.pending_character: Character | None = None # built, awaiting a world to spawn into + self.world_creation_menu: WorldCreationMenu | None = None + self.controls_menu: ControlsMenu | None = None + self.pause_menu: PauseMenu | None = None + self.return_mode: Mode = Mode.MAIN_MENU # where Escape from the controls menu goes back to + self.character_card = CharacterMenu() + self.character_card_visible = False + self.ability_bar = AbilityBar() + self.dev_spawn_menu = DevSpawnMenu(registry) def main() -> None: registry = DefRegistry.load(DEFS_DIR) generate_placeholder_textures(registry, TEXTURES_DIR) + generate_depth_shadow_texture(TEXTURES_DIR) + generate_ui_textures(TEXTURES_DIR) DB_PATH.parent.mkdir(parents=True, exist_ok=True) repo = WorldRepository.open(DB_PATH) map_loader = MapLoader(DEFS_DIR / "maps") + empty_world = World(registry=registry) # menu backdrop before any world has ever been made - world = repo.load_world(registry, map_loader) - if world is None: - world = build_fresh_world(registry, map_loader) - print("No save found; started a fresh world.") - else: - print(f"Loaded save: player at {world.player.position}.") + keybindings = KeyBindings.load(KEYBINDINGS_PATH) + gamepad_bindings = GamepadBindings.load(CONTROLLER_BINDINGS_PATH) + dev_config = DevConfig.load(DEV_CONFIG_PATH) + input_state = InputState(keybindings) + menu_gamepad = GamepadButtonEdgeTracker() + clock = GameClock(tick_interval=1.0) + rng = random.Random() window = Window() adapter, device = request_device(window.canvas) @@ -99,18 +157,204 @@ def main() -> None: pipeline = QuadPipeline(device, texture_format) texture_cache = TextureCache(device, TEXTURES_DIR) renderer = Renderer(device, pipeline, texture_cache, registry) - camera = OrthoCamera(window.canvas.get_physical_size()[0], window.canvas.get_physical_size()[1]) - keybindings = KeyBindings.load(KEYBINDINGS_PATH) - gamepad_bindings = GamepadBindings.load(CONTROLLER_BINDINGS_PATH) - input_state = InputState(keybindings) - clock = GameClock(tick_interval=1.0) + camera = OrthoCamera(*window.canvas.get_physical_size(), tiles_visible_y=DEFAULT_TILES_VISIBLE_Y) last_frame_time = time.perf_counter() - rng = random.Random() + + saved_world = repo.load_world(registry, map_loader) + if saved_world is not None: + print(f"Found a save: player at {saved_world.player.position}.") + state = AppState(registry, saved_world) + + def enter_playing() -> None: + input_state.clear_held_state() + state.mode = Mode.PLAYING + + def leave_playing() -> None: + input_state.clear_held_state() + + def trigger_pause() -> None: + leave_playing() + state.pause_menu = PauseMenu() + state.mode = Mode.PAUSED + + def resolve_action(key: str, modifiers) -> str | None: + if key and "Shift" in modifiers: + action = keybindings.action_for_key(f"shift+{key}") + if action is not None: + return action + return keybindings.action_for_key(key) + + def handle_main_menu_key(key: str) -> None: + menu = state.main_menu + if key == "ArrowUp": + menu.move(-1) + elif key == "ArrowDown": + menu.move(1) + elif key == "Enter": + choice = menu.selected() + if choice == "continue": + enter_playing() + elif choice == "new_game": + state.char_flow = CharacterCreationFlow(CharacterCreator(registry)) + state.mode = Mode.CHARACTER_CREATE + elif choice == "controls": + state.return_mode = Mode.MAIN_MENU + state.controls_menu = ControlsMenu(keybindings) + state.mode = Mode.CONTROLS + + def handle_character_create_key(key: str) -> None: + flow = state.char_flow + assert flow is not None + if flow.step == "species": + if key == "ArrowLeft": + flow.cycle_species(-1) + elif key == "ArrowRight": + flow.cycle_species(1) + elif key == "Enter": + flow.advance() + elif key == "Escape": + state.char_flow = None + state.mode = Mode.MAIN_MENU + return + + if key == "Enter": + if flow.advance(): + character = flow.creator.build_character() + assert character is not None + state.pending_character = character + state.char_flow = None + state.world_creation_menu = WorldCreationMenu() + state.world_creation_menu.select(0) + state.mode = Mode.WORLD_CREATE + elif key == "Escape": + flow.go_back() + elif key == "Backspace": + flow.backspace() + elif len(key) == 1: + flow.type_char(key) + + def handle_world_create_key(key: str) -> None: + menu = state.world_creation_menu + assert menu is not None + if key == "ArrowUp": + menu.move(-1) + elif key == "ArrowDown": + menu.move(1) + elif key == "Escape": + state.world_creation_menu = None + state.pending_character = None + state.mode = Mode.MAIN_MENU + elif key == "Enter": + template = menu.selected_template() + character = state.pending_character + if template is not None and character is not None: + world = build_world_from_template(template, registry, map_loader) + if spawn_new_character_into_world(world, registry, character, rng): + state.world = world + state.main_menu = MainMenu(has_existing_save=True) + state.pending_character = None + state.world_creation_menu = None + enter_playing() + + def handle_controls_key(key: str) -> None: + menu = state.controls_menu + assert menu is not None + if menu.awaiting_key: + if key == "Escape": + menu.cancel_rebind() + else: + menu.capture_key(key) + return + if key == "ArrowUp": + menu.move(-1) + elif key == "ArrowDown": + menu.move(1) + elif key == "Enter": + menu.begin_rebind() + elif key == "Escape": + menu.save(KEYBINDINGS_PATH) + state.controls_menu = None + state.mode = state.return_mode + + def handle_pause_key(key: str) -> None: + menu = state.pause_menu + assert menu is not None + if key == "ArrowUp": + menu.move(-1) + elif key == "ArrowDown": + menu.move(1) + elif key == "Escape": + state.pause_menu = None + enter_playing() + elif key == "Enter": + choice = menu.selected() + if choice == "resume": + state.pause_menu = None + enter_playing() + elif choice == "controls": + state.return_mode = Mode.PAUSED + state.controls_menu = ControlsMenu(keybindings) + state.mode = Mode.CONTROLS + elif choice == "save_and_quit_to_menu": + if state.world is not None: + repo.save_world(state.world) + print("Saved world.") + state.world = None + state.pause_menu = None + state.main_menu = MainMenu(has_existing_save=True) + state.mode = Mode.MAIN_MENU + + def handle_menu_key(key: str) -> None: + if state.mode == Mode.MAIN_MENU: + handle_main_menu_key(key) + elif state.mode == Mode.CHARACTER_CREATE: + handle_character_create_key(key) + elif state.mode == Mode.WORLD_CREATE: + handle_world_create_key(key) + elif state.mode == Mode.CONTROLS: + handle_controls_key(key) + elif state.mode == Mode.PAUSED: + handle_pause_key(key) + + def handle_playing_key_down(event: dict) -> None: + key = event.get("key", "") + action = resolve_action(key, event.get("modifiers", ())) + if action == "pause": + trigger_pause() + return + if action == "toggle_character_card": + state.character_card_visible = not state.character_card_visible + return + if action == "toggle_dev_mode": + dev_config.toggle() + dev_config.save(DEV_CONFIG_PATH) + return + if dev_config.enabled and action == "dev_cycle_spawn": + state.dev_spawn_menu.cycle(1) + return + if dev_config.enabled and action == "dev_spawn_object": + player = state.world.player if state.world is not None else None + if isinstance(player, Character): + state.dev_spawn_menu.spawn_into(player) + return + input_state.handle_event(event) def on_event(event: dict) -> None: - if event.get("event_type") == "close": - repo.save_world(world) - print("Saved world on close.") + event_type = event.get("event_type") + + if event_type == "close": + if state.world is not None: + repo.save_world(state.world) + print("Saved world on close.") + return + + if state.mode != Mode.PLAYING: + if event_type == "key_down": + handle_menu_key(event.get("key", "")) + return + + if event_type == "key_down": + handle_playing_key_down(event) else: input_state.handle_event(event) @@ -124,14 +368,16 @@ def main() -> None: dx, dy = snap_to_8way(wx - (player.position.x + 0.5), wy - (player.position.y + 0.5)) return dx, dy, 0 - def poll_gamepad() -> None: - # A gamepad is polled (not event-driven like key_down/key_up), so this just feeds - # whatever's plugged into joystick slot 1 into the same action dispatch as keyboard/ - # mouse each frame - see InputState.apply_gamepad_state for the edge detection. + def read_gamepad_state() -> GamepadState | None: + # A gamepad is polled (not event-driven like key_down/key_up), so this reads whatever's + # plugged into joystick slot 1 fresh each frame - see InputState.apply_gamepad_state for + # gameplay's own edge detection, and GAMEPAD_MENU_KEY_MAP/menu_gamepad above for menu + # navigation's separate one (both need to run off the same one snapshot per frame). if glfw.joystick_present(glfw.JOYSTICK_1) and glfw.joystick_is_gamepad(glfw.JOYSTICK_1): raw = glfw.get_gamepad_state(glfw.JOYSTICK_1) if raw is not None: - input_state.apply_gamepad_state(GamepadState.from_glfw(raw), gamepad_bindings) + return GamepadState.from_glfw(raw) + return None def draw_frame() -> None: nonlocal last_frame_time @@ -139,49 +385,127 @@ def main() -> None: dt = wall_now - last_frame_time last_frame_time = wall_now - poll_gamepad() + viewport_w, viewport_h = window.canvas.get_physical_size() + camera.resize(viewport_w, viewport_h) + ui_buckets: dict[str, list[float]] = defaultdict(list) - ticks = clock.advance(dt) - world.expire_time_warp_zones(clock.total_time) - advance_all_ship_rotations(world, clock.total_time) - if ticks: - for entity in world.entities.values(): - if isinstance(entity, Character): - apply_bleeding_tick(entity, registry, ticks) - apply_organ_interactions_tick(entity, registry, ticks) + gamepad_state = read_gamepad_state() + # Always fed, regardless of mode, so the edge set reflects real button transitions - if + # this only ran while a menu was open, a button already held when a menu opens would + # look "just pressed" the moment play resumes and the tracker starts watching again. + gamepad_menu_edges = menu_gamepad.newly_pressed(gamepad_state) if gamepad_state is not None else set() - player = world.player - if isinstance(player, Character) and player.position is not None: - player.is_blocking = input_state.is_blocking + # Mode transitions from gamepad input are applied before branching on state.mode below, + # same as keyboard's on_event (which always runs between frames) - otherwise a gp_start + # pause would still fall through and run/render one more frame of gameplay first. + if state.mode == Mode.PLAYING: + if "gp_start" in gamepad_menu_edges: + trigger_pause() + elif "gp_back" in gamepad_menu_edges: + state.character_card_visible = not state.character_card_visible + else: + for button in gamepad_menu_edges: + key = GAMEPAD_MENU_KEY_MAP.get(button) + if key is not None: + handle_menu_key(key) - move_dx, move_dy, _ = input_state.current_move_vector() - if move_dx or move_dy: - if at_helm(player, world) is not None: - # steering a ship stays a discrete, one-press-per-step affair - the ship's - # own grid structure doesn't need continuous sub-tile positioning - move = input_state.consume_move() - if move is not None: - try_steer_ship(player, world, *move) - else: - speed = player.effective_speed(BASE_MOVE_SPEED, registry) - slow = world.speed_multiplier_at( - player.entity_id, player.position.map_id, player.position.x, player.position.y, - player.position.z, clock.total_time, + if state.mode == Mode.PLAYING and state.world is not None: + if gamepad_state is not None: + input_state.apply_gamepad_state(gamepad_state, gamepad_bindings) + world = state.world + + ticks = clock.advance(dt) + world.expire_time_warp_zones(clock.total_time) + advance_all_ship_rotations(world, clock.total_time) + if ticks: + for entity in world.entities.values(): + if isinstance(entity, Character): + apply_bleeding_tick(entity, registry, ticks) + apply_organ_interactions_tick(entity, registry, ticks) + + player = world.player + if isinstance(player, Character) and player.position is not None: + player.is_blocking = input_state.is_blocking + + helming = at_helm(player, world) is not None + camera.set_zoom_target(HELM_TILES_VISIBLE_Y if helming else DEFAULT_TILES_VISIBLE_Y) + + move_dx, move_dy, _ = input_state.current_move_vector() + if move_dx or move_dy: + if helming: + # steering a ship stays a discrete, one-press-per-step affair - the + # ship's own grid structure doesn't need continuous sub-tile positioning + move = input_state.consume_move() + if move is not None: + try_steer_ship(player, world, *move) + else: + speed = player.effective_speed(BASE_MOVE_SPEED, registry) + slow = world.speed_multiplier_at( + player.entity_id, player.position.map_id, player.position.x, player.position.y, + player.position.z, clock.total_time, + ) + if speed > 0: + world.try_move_continuous(player, move_dx, move_dy, 0, distance=speed * slow * dt) + + swimming = in_water(player, world) + leap = input_state.consume_leap() + if leap is not None: + if swimming: + try_swim_vertical(player, world, 1) # space surfaces while submerged + else: + world.try_leap(player, *leap) + if input_state.consume_swim_down(): + try_swim_vertical(player, world, -1) + + hand = input_state.consume_hand_activation() + if hand is not None: + activate_hand( + player, world, registry, hand, current_aim_direction(player), rng=rng, now=clock.total_time ) - if speed > 0: - world.try_move_continuous(player, move_dx, move_dy, 0, distance=speed * slow * dt) - leap = input_state.consume_leap() - if leap is not None: - world.try_leap(player, *leap) + populate_ability_bar(state.ability_bar, player, registry) + ability_index = input_state.consume_ability_select() + if ability_index is not None: + state.ability_bar.select(ability_index) + if input_state.consume_activate_ability_bar(): + selected = state.ability_bar.selected_slot() + if selected is not None: + activate_ability_bar_slot( + player, world, registry, selected, current_aim_direction(player), + rng=rng, now=clock.total_time, + ) - hand = input_state.consume_hand_activation() - if hand is not None: - activate_hand(player, world, registry, hand, current_aim_direction(player), rng=rng, now=clock.total_time) + camera.set_target(player.position.x + 0.5, player.position.y + 0.5) - if isinstance(player, Character) and player.position is not None: - camera.set_target(player.position.x + 0.5, player.position.y + 0.5) - renderer.render_frame(context, world, camera) + camera.update_zoom(dt) + + if isinstance(player, Character): + draw_ability_bar(ui_buckets, state.ability_bar, viewport_h) + if state.character_card_visible: + draw_character_card(ui_buckets, player, state.character_card, registry, viewport_h) + if dev_config.enabled: + draw_text(ui_buckets, "DEV MODE", viewport_w - 130, 10) + draw_text(ui_buckets, f"spawn: {state.dev_spawn_menu.selected_item_id()}", viewport_w - 260, 30) + + renderer.render_frame(context, world, camera, ui_buckets) + return + + backdrop = state.world if state.world is not None else empty_world + if state.mode == Mode.MAIN_MENU: + draw_main_menu(ui_buckets, state.main_menu, viewport_w, viewport_h) + elif state.mode == Mode.CHARACTER_CREATE: + assert state.char_flow is not None + draw_character_creation(ui_buckets, state.char_flow, viewport_w, viewport_h) + elif state.mode == Mode.WORLD_CREATE: + assert state.world_creation_menu is not None + draw_world_creation(ui_buckets, state.world_creation_menu, viewport_w, viewport_h) + elif state.mode == Mode.CONTROLS: + assert state.controls_menu is not None + draw_controls_menu(ui_buckets, state.controls_menu, viewport_w, viewport_h) + elif state.mode == Mode.PAUSED: + assert state.pause_menu is not None + draw_pause_menu(ui_buckets, state.pause_menu, viewport_w, viewport_h) + renderer.render_frame(context, backdrop, camera, ui_buckets) window.canvas.request_draw(draw_frame) window.run() diff --git a/resources/defs/tiles.json b/resources/defs/tiles.json index 9559048..1e48a9f 100644 --- a/resources/defs/tiles.json +++ b/resources/defs/tiles.json @@ -4,7 +4,12 @@ }, "flooring": { "grass": { "color": [86, 156, 70], "walkable": true }, - "wood_deck": { "color": [166, 124, 82], "walkable": true } + "wood_deck": { "color": [166, 124, 82], "walkable": true }, + "dirt": { "color": [111, 79, 51], "walkable": true, "texture": "tiles/flooring_dirt.png" }, + "stone": { "color": [130, 130, 132], "walkable": true, "texture": "tiles/flooring_stone.png" }, + "mud": { "color": [77, 63, 44], "walkable": true, "texture": "tiles/flooring_mud.png", "speed_multiplier": 0.6 }, + "sand": { "color": [220, 200, 150], "walkable": true, "texture": "tiles/flooring_sand.png" }, + "water": { "color": [60, 110, 200], "walkable": true, "texture": "tiles/flooring_water.png", "speed_multiplier": 0.7 } }, "room": { "wood_wall": { "color": [110, 78, 48], "walkable": false, "blocks_los": true, "deconstruct_item_id": "wood_planks" }, @@ -12,9 +17,13 @@ "ship_helm": { "color": [200, 170, 60], "walkable": true, "is_helm": true }, "stairs_up": { "color": [160, 140, 100], "walkable": true, "staircase_direction": "up" }, "stairs_down": { "color": [100, 90, 80], "walkable": true, "staircase_direction": "down" }, - "ladder": { "color": [130, 110, 70], "walkable": true, "staircase_direction": "up" } + "ladder": { "color": [130, 110, 70], "walkable": true, "staircase_direction": "up" }, + "solid_rock": { "color": [80, 78, 76], "walkable": false, "blocks_los": true, "texture": "tiles/room_solid_rock.png" }, + "slope_up": { "color": [140, 118, 90], "walkable": true, "staircase_direction": "up", "texture": "tiles/room_slope_up.png" }, + "slope_down": { "color": [120, 100, 76], "walkable": true, "staircase_direction": "down", "texture": "tiles/room_slope_down.png" } }, "roof": { - "shingles": { "color": [140, 60, 50], "walkable": true } + "shingles": { "color": [140, 60, 50], "walkable": true }, + "cave_ceiling": { "color": [55, 52, 50], "walkable": true, "texture": "tiles/roof_cave_ceiling.png" } } } diff --git a/resources/logic/swimming.py b/resources/logic/swimming.py new file mode 100644 index 0000000..ff859fa --- /dev/null +++ b/resources/logic/swimming.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from engine.character import Character +from engine.world import World + + +def in_water(character: Character, world: World) -> bool: + """True if the character is currently standing in a water tile (resources/defs/tiles.json's + "water" flooring def) - gates the free vertical swim movement (req 6) the same way + resources/logic/steering.py::at_helm gates ship-steering movement in main.py. + """ + if character.position is None: + return False + map_ = world.maps[character.position.map_id] + tile = map_.get_tile(character.position.x, character.position.y, character.position.z) + return tile.layer_id("flooring") == "water" + + +def try_swim_vertical(character: Character, world: World, dz: int) -> bool: + """Moves one z-level up/down while submerged - unlike land staircases this needs no placed + connector tile, since every water column is swimmable end to end (see + engine/worldgen/terrain.py's underground fill: a water column stays water all the way + down, rather than turning to solid rock like dry land does). No-op if not currently in + water, or if the target z isn't itself walkable (e.g. surfacing above the waterline into a + solid tile, or diving below the seabed). + """ + if not in_water(character, world): + return False + return world.try_move(character, 0, 0, dz) diff --git a/scripts/export_blank_textures.py b/scripts/export_blank_textures.py new file mode 100644 index 0000000..ae62f28 --- /dev/null +++ b/scripts/export_blank_textures.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Generates a blank, correctly-sized, labeled PNG for every game-art texture slot that hasn't +been staged for an artist yet - see reimport_textures.py for bringing finished art back in. + +Only covers actual hand-drawn game art (tile layers, items, body parts) - not the +auto-generated UI font/panel textures (engine/ui_assets.py), which regenerate from code and +aren't meant to be hand-painted. + +Existing files in the output directory are left untouched, so re-running this after an artist +has started replacing some of them won't blank out their work - it only fills in the gaps, the +same "only write if missing" idiom engine/placeholder_textures.py already uses. + +Usage: + python scripts/export_blank_textures.py [--out art_wip] +""" +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Iterator + +from PIL import Image, ImageDraw, ImageFont + +from engine.defs import DefRegistry +from engine.placeholder_textures import TEXTURE_SIZE + +ROOT_DIR = Path(__file__).resolve().parent.parent +DEFS_DIR = ROOT_DIR / "resources" / "defs" +DEFAULT_OUT_DIR = ROOT_DIR / "art_wip" +LABEL_FONT_SIZE = 7 +LABEL_COLOR = (130, 130, 130, 220) + +_label_font = None + + +def _font(): + global _label_font + if _label_font is None: + _label_font = ImageFont.load_default(size=LABEL_FONT_SIZE) + return _label_font + + +def _blank_labeled_image(label: str, size: int) -> Image.Image: + """A fully transparent canvas with just an identifying label drawn on it - a tile artist + filling the whole square in opaque paint will naturally cover it; an icon artist working on + a transparent background will still see it as a guide (and can paint over/around it). + """ + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + ImageDraw.Draw(img).text((1, 1), label, font=_font(), fill=LABEL_COLOR) + return img + + +def _texture_slots(registry: DefRegistry) -> Iterator[tuple[str, str, int]]: + """Yields (relative_path, label, size) for every def with a texture path - the same set + engine/placeholder_textures.py::generate_placeholder_textures covers. + """ + for layer_def in registry.tile_layer_defs.values(): + if layer_def.texture: + yield layer_def.texture, layer_def.id, TEXTURE_SIZE + for item_def in registry.item_defs.values(): + if item_def.texture: + yield item_def.texture, item_def.id, TEXTURE_SIZE + for part_def in registry.body_part_defs.values(): + if part_def.texture: + yield part_def.texture, part_def.id, TEXTURE_SIZE + + +def export_blank_textures(registry: DefRegistry, out_dir: Path) -> tuple[int, int]: + """Writes a blank labeled PNG for every texture slot not already present in out_dir. + Returns (written_count, skipped_count). + """ + written = skipped = 0 + for relative_path, label, size in _texture_slots(registry): + path = out_dir / relative_path + if path.exists(): + skipped += 1 + continue + path.parent.mkdir(parents=True, exist_ok=True) + _blank_labeled_image(label, size).save(path) + written += 1 + return written, skipped + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--out", type=Path, default=DEFAULT_OUT_DIR, help=f"staging directory for artists (default: {DEFAULT_OUT_DIR})" + ) + args = parser.parse_args() + + registry = DefRegistry.load(DEFS_DIR) + written, skipped = export_blank_textures(registry, args.out) + print(f"Wrote {written} blank texture template(s) to {args.out}") + if skipped: + print(f"Skipped {skipped} file(s) already staged there (not overwritten).") + + +if __name__ == "__main__": + main() diff --git a/scripts/reimport_textures.py b/scripts/reimport_textures.py new file mode 100644 index 0000000..d452c8f --- /dev/null +++ b/scripts/reimport_textures.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Copies finished art from the artist staging directory (see export_blank_textures.py) into +resources/textures/, where the game actually loads textures from at runtime - overwriting +whatever placeholder/checker art (engine/placeholder_textures.py) or earlier import currently +sits at each path. + +Usage: + python scripts/reimport_textures.py [--src art_wip] +""" +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parent.parent +DEFAULT_SRC_DIR = ROOT_DIR / "art_wip" +TEXTURES_DIR = ROOT_DIR / "resources" / "textures" + + +def reimport_textures(src_dir: Path, textures_dir: Path) -> int: + """Copies every file under src_dir to the same relative path under textures_dir, + overwriting anything already there. Returns how many files were copied. + """ + count = 0 + for path in sorted(src_dir.rglob("*")): + if not path.is_file(): + continue + relative_path = path.relative_to(src_dir) + dest = textures_dir / relative_path + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, dest) + count += 1 + return count + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--src", type=Path, default=DEFAULT_SRC_DIR, help=f"artist staging directory (default: {DEFAULT_SRC_DIR})" + ) + args = parser.parse_args() + + if not args.src.exists(): + raise SystemExit(f"No staging directory at {args.src} - run export_blank_textures.py first.") + + count = reimport_textures(args.src, TEXTURES_DIR) + print(f"Imported {count} texture(s) into {TEXTURES_DIR}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_ability_bar.py b/tests/test_ability_bar.py index 05220c9..bd53bbc 100644 --- a/tests/test_ability_bar.py +++ b/tests/test_ability_bar.py @@ -7,7 +7,7 @@ from engine.defs import DefRegistry from engine.entity import EntityPosition from engine.map import Map from engine.tile import Tile, TileLayerInstance -from engine.ui import AbilityBar, AbilityBarSlot +from engine.ui import AbilityBar, AbilityBarSlot, populate_ability_bar from engine.world import World from resources.logic.actions import activate_ability_bar_slot @@ -111,3 +111,61 @@ def test_activate_ability_bar_slot_unknown_kind_returns_none(registry): slot = AbilityBarSlot(kind="bogus", source_id="right_hand", ability_id="time_warp_sphere") assert activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=3.0) is None + + +# --- populate_ability_bar ------------------------------------------------------------------- + + +def test_populate_fills_a_slot_for_a_wielded_ability_granting_item(registry): + bar = AbilityBar() + character = make_human(registry) + character.wield_item("right_hand", "chronowand", registry) + + populate_ability_bar(bar, character, registry) + + assert bar.slots[0] == AbilityBarSlot(kind="hand", source_id="right_hand", ability_id="time_warp_sphere") + assert bar.slots[1] is None + + +def test_populate_skips_a_wielded_item_with_no_granted_ability(registry): + bar = AbilityBar() + character = make_human(registry) + character.wield_item("right_hand", "staff_oak", registry) + + populate_ability_bar(bar, character, registry) + + assert all(slot is None for slot in bar.slots) + + +def test_populate_fills_a_slot_for_an_installed_implant(registry): + bar = AbilityBar() + character = make_human(registry) + character.install_implant("chronoimplant", registry) + + populate_ability_bar(bar, character, registry) + + assert bar.slots[0] == AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere") + + +def test_populate_orders_hands_before_implants(registry): + bar = AbilityBar() + character = make_human(registry) + character.install_implant("chronoimplant", registry) + character.wield_item("right_hand", "storm_rod", registry) + + populate_ability_bar(bar, character, registry) + + assert bar.slots[0] == AbilityBarSlot(kind="hand", source_id="right_hand", ability_id="launch_lightning") + assert bar.slots[1] == AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere") + + +def test_populate_clears_stale_slots_from_a_previous_call(registry): + bar = AbilityBar() + character = make_human(registry) + character.wield_item("right_hand", "chronowand", registry) + populate_ability_bar(bar, character, registry) + assert bar.slots[0] is not None + + character.unwield("right_hand") + populate_ability_bar(bar, character, registry) + assert bar.slots[0] is None diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..f183206 --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,35 @@ +from engine.camera import OrthoCamera + + +def test_zoom_defaults_target_to_initial_tiles_visible_y(): + camera = OrthoCamera(800, 600, tiles_visible_y=12.0) + assert camera.target_tiles_visible_y == 12.0 + + +def test_set_zoom_target_does_not_snap_immediately(): + camera = OrthoCamera(800, 600, tiles_visible_y=12.0) + camera.set_zoom_target(20.0) + assert camera.tiles_visible_y == 12.0 + assert camera.target_tiles_visible_y == 20.0 + + +def test_update_zoom_moves_toward_the_target_over_time(): + camera = OrthoCamera(800, 600, tiles_visible_y=12.0) + camera.set_zoom_target(20.0) + camera.update_zoom(dt=0.1) + assert 12.0 < camera.tiles_visible_y < 20.0 + + +def test_update_zoom_snaps_once_close_enough(): + camera = OrthoCamera(800, 600, tiles_visible_y=12.0) + camera.set_zoom_target(20.0) + for _ in range(100): + camera.update_zoom(dt=0.1) + assert camera.tiles_visible_y == 20.0 + + +def test_update_zoom_can_zoom_back_in(): + camera = OrthoCamera(800, 600, tiles_visible_y=20.0) + camera.set_zoom_target(12.0) + camera.update_zoom(dt=0.1) + assert 12.0 < camera.tiles_visible_y < 20.0 diff --git a/tests/test_controls_menu.py b/tests/test_controls_menu.py new file mode 100644 index 0000000..2604dda --- /dev/null +++ b/tests/test_controls_menu.py @@ -0,0 +1,68 @@ +from pathlib import Path + +from engine.config import KeyBindings +from engine.controls_menu import ControlsMenu + + +def make_menu(): + bindings = KeyBindings.load(Path("/nonexistent")) + return ControlsMenu(bindings) + + +def test_move_wraps_around_the_action_list(): + menu = make_menu() + menu.move(-1) + assert menu.selected_action() == menu.actions[-1] + + +def test_capture_key_is_ignored_until_rebind_begins(): + menu = make_menu() + action = menu.selected_action() + original = list(menu.keybindings.keys_for_action(action)) + applied = menu.capture_key("z") + assert applied is False + assert menu.keybindings.keys_for_action(action) == original + + +def test_begin_rebind_then_capture_key_rebinds_the_primary_key(): + menu = make_menu() + menu.selected_index = menu.actions.index("leap") + menu.begin_rebind() + applied = menu.capture_key("j") + assert applied is True + assert menu.awaiting_key is False + assert menu.keybindings.action_for_key("j") == "leap" + assert menu.keybindings.action_for_key(" ") is None # old primary key freed + + +def test_capture_key_rejects_reserved_menu_keys(): + menu = make_menu() + menu.selected_index = menu.actions.index("leap") + menu.begin_rebind() + applied = menu.capture_key("Escape") + assert applied is False + assert menu.awaiting_key is True # still waiting - player can try again + + +def test_cancel_rebind_stops_capturing(): + menu = make_menu() + menu.begin_rebind() + menu.cancel_rebind() + assert menu.capture_key("z") is False + + +def test_rebind_steals_the_key_from_whichever_action_had_it(): + bindings = KeyBindings.load(Path("/nonexistent")) + bindings.rebind("leap", "w") + assert bindings.action_for_key("w") == "leap" + assert "w" not in bindings.keys_for_action("move_up") + + +def test_save_and_reload_round_trips_a_rebind(tmp_path): + bindings = KeyBindings.load(Path("/nonexistent")) + bindings.rebind("leap", "j") + path = tmp_path / "keybindings.conf" + bindings.save(path) + + reloaded = KeyBindings.load(path) + assert reloaded.action_for_key("j") == "leap" diff --git a/tests/test_defs_loading.py b/tests/test_defs_loading.py index 30bc22a..bddc705 100644 --- a/tests/test_defs_loading.py +++ b/tests/test_defs_loading.py @@ -24,6 +24,31 @@ def test_tile_layer_defs_load_from_json(registry): assert wall.blocks_los is True +def test_terrain_flooring_defs_load_with_speed_multipliers(registry): + mud = registry.get_tile_layer_def("flooring", "mud") + assert mud.walkable is True + assert mud.speed_multiplier == 0.6 + + water = registry.get_tile_layer_def("flooring", "water") + assert water.walkable is True + assert water.speed_multiplier == 0.7 + + for def_id in ("dirt", "stone", "sand"): + terrain = registry.get_tile_layer_def("flooring", def_id) + assert terrain.walkable is True + assert terrain.speed_multiplier == 1.0 + + +def test_solid_rock_and_slope_room_defs_load(registry): + rock = registry.get_tile_layer_def("room", "solid_rock") + assert rock.walkable is False + + up = registry.get_tile_layer_def("room", "slope_up") + assert up.staircase_direction == "up" + down = registry.get_tile_layer_def("room", "slope_down") + assert down.staircase_direction == "down" + + def test_item_defs_load_with_correct_footprints(registry): staff = registry.get_item_def("staff_oak") assert (staff.width, staff.height) == (1, 5) diff --git a/tests/test_depth_shading.py b/tests/test_depth_shading.py new file mode 100644 index 0000000..c27cfe7 --- /dev/null +++ b/tests/test_depth_shading.py @@ -0,0 +1,57 @@ +from engine.map import Map +from engine.placeholder_textures import DEPTH_SHADOW_TEXTURE, generate_depth_shadow_texture +from engine.render.renderer import visible_z_and_depth +from engine.tile import Tile, TileLayerInstance + + +def make_column_map(): + """A 1x1 map, 4 z-levels tall: floored at z=0 and z=3 only, bare (no layers) at z=1, 2 - + the "open air gap above a lower surface" case generation leaves at steep cliffs. + """ + m = Map("shaft", 1, 1, 4) + m.set_tile(0, 0, 0, Tile(flooring=TileLayerInstance("stone"))) + m.set_tile(0, 0, 3, Tile(flooring=TileLayerInstance("dirt"))) + return m + + +def test_reference_level_with_content_is_visible_at_zero_depth(): + m = make_column_map() + z, depth = visible_z_and_depth(m, 0, 0, reference_z=3) + assert (z, depth) == (3, 0) + + +def test_gap_below_reference_looks_down_to_the_first_real_tile(): + m = make_column_map() + z, depth = visible_z_and_depth(m, 0, 0, reference_z=2) + assert (z, depth) == (0, 2) + + +def test_gap_one_level_down_reports_depth_one(): + m = make_column_map() + z, depth = visible_z_and_depth(m, 0, 0, reference_z=1) + assert (z, depth) == (0, 1) + + +def test_out_of_bounds_reference_returns_none(): + m = make_column_map() + z, depth = visible_z_and_depth(m, 0, 0, reference_z=99) + assert (z, depth) == (None, 0) + + +def test_true_void_all_the_way_down_returns_none(): + m = Map("empty", 1, 1, 2) # nothing set anywhere + z, depth = visible_z_and_depth(m, 0, 0, reference_z=1) + assert (z, depth) == (None, 0) + + +def test_generate_depth_shadow_texture_writes_a_file(tmp_path): + generate_depth_shadow_texture(tmp_path) + assert (tmp_path / DEPTH_SHADOW_TEXTURE).exists() + + +def test_generate_depth_shadow_texture_does_not_overwrite(tmp_path): + generate_depth_shadow_texture(tmp_path) + path = tmp_path / DEPTH_SHADOW_TEXTURE + path.write_bytes(b"custom") + generate_depth_shadow_texture(tmp_path) + assert path.read_bytes() == b"custom" diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py new file mode 100644 index 0000000..2440954 --- /dev/null +++ b/tests/test_dev_tools.py @@ -0,0 +1,84 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.dev_config import DevConfig +from engine.dev_tools import DevSpawnMenu +from engine.inventory import Inventory + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +# --- DevSpawnMenu ------------------------------------------------------------------------------- + + +def test_menu_defaults_to_every_loaded_item_def_sorted(registry): + menu = DevSpawnMenu(registry) + assert menu.item_ids == sorted(registry.item_defs) + assert menu.selected_item_id() == menu.item_ids[0] + + +def test_cycle_wraps_around(registry): + menu = DevSpawnMenu(registry) + menu.cycle(-1) + assert menu.selected_item_id() == menu.item_ids[-1] + + +def test_spawn_into_places_the_item_when_inventory_has_room(registry): + menu = DevSpawnMenu(registry) + character = make_human(registry) + character.inventory = Inventory(4, 4) + assert menu.spawn_into(character) is True + assert len(character.inventory.items()) == 1 + + +def test_spawn_into_fails_without_an_inventory(registry): + menu = DevSpawnMenu(registry) + character = make_human(registry) + assert menu.spawn_into(character) is False + + +def test_spawn_into_fails_when_inventory_is_full(registry): + menu = DevSpawnMenu(registry) + character = make_human(registry) + character.inventory = Inventory(1, 1) + item_def = registry.get_item_def(menu.selected_item_id()) + if item_def.width > 1 or item_def.height > 1: + pytest.skip("first item def doesn't fit a 1x1 inventory to begin with") + assert menu.spawn_into(character) is True + assert menu.spawn_into(character) is False + + +# --- DevConfig ------------------------------------------------------------------------------------ + + +def test_dev_config_defaults_to_disabled_when_file_missing(tmp_path): + config = DevConfig.load(tmp_path / "does_not_exist.conf") + assert config.enabled is False + + +def test_dev_config_toggle_flips_the_flag(): + config = DevConfig() + config.toggle() + assert config.enabled is True + config.toggle() + assert config.enabled is False + + +def test_dev_config_save_and_reload_round_trips(tmp_path): + path = tmp_path / "dev.conf" + config = DevConfig(enabled=True) + config.save(path) + reloaded = DevConfig.load(path) + assert reloaded.enabled is True diff --git a/tests/test_export_blank_textures.py b/tests/test_export_blank_textures.py new file mode 100644 index 0000000..d0e10c5 --- /dev/null +++ b/tests/test_export_blank_textures.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from engine.defs import DefRegistry +from engine.placeholder_textures import TEXTURE_SIZE +from scripts.export_blank_textures import export_blank_textures + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def test_writes_a_correctly_sized_blank_file_for_a_known_item(registry, tmp_path): + export_blank_textures(registry, tmp_path) + staff_def = registry.item_defs["staff_oak"] + path = tmp_path / staff_def.texture + assert path.exists() + with Image.open(path) as img: + assert img.size == (TEXTURE_SIZE, TEXTURE_SIZE) + assert img.mode == "RGBA" + + +def test_written_image_is_mostly_transparent(registry, tmp_path): + export_blank_textures(registry, tmp_path) + staff_def = registry.item_defs["staff_oak"] + with Image.open(tmp_path / staff_def.texture) as img: + alphas = img.tobytes()[3::4] # every 4th byte of an RGBA buffer is alpha + # a small label is drawn, but the vast majority of a 32x32 canvas stays untouched + assert alphas.count(0) > len(alphas) * 0.5 + + +def test_covers_items_and_body_parts_too(registry, tmp_path): + export_blank_textures(registry, tmp_path) + item_def = next(d for d in registry.item_defs.values() if d.texture) + part_def = next(d for d in registry.body_part_defs.values() if d.texture) + assert (tmp_path / item_def.texture).exists() + assert (tmp_path / part_def.texture).exists() + + +def test_reports_written_and_skipped_counts(registry, tmp_path): + written_first, skipped_first = export_blank_textures(registry, tmp_path) + assert written_first > 0 + assert skipped_first == 0 + + written_second, skipped_second = export_blank_textures(registry, tmp_path) + assert written_second == 0 + assert skipped_second == written_first + + +def test_does_not_overwrite_a_file_an_artist_already_replaced(registry, tmp_path): + export_blank_textures(registry, tmp_path) + staff_def = registry.item_defs["staff_oak"] + path = tmp_path / staff_def.texture + path.write_bytes(b"real-art-not-a-real-png") + + export_blank_textures(registry, tmp_path) + assert path.read_bytes() == b"real-art-not-a-real-png" diff --git a/tests/test_game_modes.py b/tests/test_game_modes.py new file mode 100644 index 0000000..564d57d --- /dev/null +++ b/tests/test_game_modes.py @@ -0,0 +1,124 @@ +from pathlib import Path + +import pytest + +from engine.character_creator import CharacterCreator +from engine.defs import DefRegistry +from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +# --- MainMenu ----------------------------------------------------------------------------------- + + +def test_main_menu_without_a_save_has_no_continue_option(): + menu = MainMenu(has_existing_save=False) + assert "continue" not in menu.options() + assert menu.selected() == "new_game" + + +def test_main_menu_with_a_save_offers_continue_first(): + menu = MainMenu(has_existing_save=True) + assert menu.options()[0] == "continue" + assert menu.selected() == "continue" + + +def test_main_menu_move_wraps_around(): + menu = MainMenu(has_existing_save=False) + assert menu.options() == ["new_game", "controls"] + menu.move(-1) + assert menu.selected() == "controls" + menu.move(1) + assert menu.selected() == "new_game" + + +# --- PauseMenu ------------------------------------------------------------------------------------ + + +def test_pause_menu_defaults_to_resume(): + assert PauseMenu().selected() == "resume" + + +def test_pause_menu_cycles_through_all_options(): + menu = PauseMenu() + seen = {menu.selected()} + for _ in range(len(PauseMenu.OPTIONS)): + menu.move(1) + seen.add(menu.selected()) + assert seen == set(PauseMenu.OPTIONS) + + +# --- CharacterCreationFlow ------------------------------------------------------------------------- + + +def test_flow_locks_in_a_default_species_on_creation(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + assert flow.creator.species_id == flow.species_list()[0] + + +def test_cycle_species_updates_the_creator(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + first = flow.creator.species_id + flow.cycle_species(1) + assert flow.creator.species_id != first + assert flow.creator.species_id == flow.species_list()[1 % len(flow.species_list())] + + +def test_advance_from_species_moves_to_name_step_without_finishing(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + assert flow.advance() is False + assert flow.step == "name" + + +def test_typing_only_applies_during_the_name_step(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + flow.type_char("x") # still on species step + assert flow.creator.name == "" + flow.advance() + flow.type_char("V") + flow.type_char("e") + flow.type_char("x") + assert flow.creator.name == "Vex" + + +def test_backspace_removes_the_last_character(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + flow.advance() + flow.type_char("V") + flow.type_char("x") + flow.backspace() + assert flow.creator.name == "V" + + +def test_advance_on_name_step_requires_a_non_empty_name(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + flow.advance() + assert flow.advance() is False + flow.type_char("V") + assert flow.advance() is True + + +def test_typing_is_capped_at_max_name_length(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + flow.advance() + for ch in "x" * 30: + flow.type_char(ch) + assert len(flow.creator.name) == 20 + + +def test_go_back_from_name_returns_to_species(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + flow.advance() + assert flow.go_back() is True + assert flow.step == "species" + + +def test_go_back_from_species_reports_theres_nowhere_further_back(registry): + flow = CharacterCreationFlow(CharacterCreator(registry)) + assert flow.go_back() is False diff --git a/tests/test_gamepad.py b/tests/test_gamepad.py index f50cfe3..aa6d181 100644 --- a/tests/test_gamepad.py +++ b/tests/test_gamepad.py @@ -4,6 +4,7 @@ from engine.gamepad import ( AXIS_RIGHT_TRIGGER, DEFAULT_GAMEPAD_BINDINGS, GamepadBindings, + GamepadButtonEdgeTracker, GamepadState, apply_deadzone, ) @@ -97,3 +98,35 @@ def test_load_reads_the_real_shipped_controller_bindings_conf(): bindings = GamepadBindings.load(path) assert bindings.action_for_button("gp_a") == "leap" assert bindings.action_for_button("gp_dpad_up") == "select_ability_1" + + +# --- GamepadButtonEdgeTracker -------------------------------------------------------------------- + + +def test_newly_pressed_reports_a_button_pressed_for_the_first_time(): + tracker = GamepadButtonEdgeTracker() + edges = tracker.newly_pressed(GamepadState(buttons={"gp_a": True})) + assert edges == {"gp_a"} + + +def test_newly_pressed_does_not_repeat_while_still_held(): + tracker = GamepadButtonEdgeTracker() + tracker.newly_pressed(GamepadState(buttons={"gp_a": True})) + edges = tracker.newly_pressed(GamepadState(buttons={"gp_a": True})) + assert edges == set() + + +def test_newly_pressed_fires_again_after_a_release_and_repress(): + tracker = GamepadButtonEdgeTracker() + tracker.newly_pressed(GamepadState(buttons={"gp_a": True})) + tracker.newly_pressed(GamepadState(buttons={"gp_a": False})) + edges = tracker.newly_pressed(GamepadState(buttons={"gp_a": True})) + assert edges == {"gp_a"} + + +def test_reset_forgets_a_currently_held_button_so_it_re_fires(): + tracker = GamepadButtonEdgeTracker() + tracker.newly_pressed(GamepadState(buttons={"gp_a": True})) + tracker.reset() + edges = tracker.newly_pressed(GamepadState(buttons={"gp_a": True})) + assert edges == {"gp_a"} diff --git a/tests/test_gravity.py b/tests/test_gravity.py new file mode 100644 index 0000000..f6e3d50 --- /dev/null +++ b/tests/test_gravity.py @@ -0,0 +1,76 @@ +from pathlib import Path + +import pytest + +from engine.defs import DefRegistry +from engine.entity import Entity, EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_cliff_world(registry): + """A 2x1 map: x=0 is a 3-level-tall column (floored at z=0,1,2), x=1 has floor only at + z=0 - everything above that at x=1 is bare/unset (no flooring), simulating the "open air + above a lower column" gap engine/worldgen/terrain.py leaves when no slope/cave connects a + steep drop. + """ + m = Map("cliff", 2, 1, 3) + m.set_tile(0, 0, 0, Tile(flooring=TileLayerInstance("stone"))) + m.set_tile(0, 0, 1, Tile(flooring=TileLayerInstance("stone"))) + m.set_tile(0, 0, 2, Tile(flooring=TileLayerInstance("stone"))) + m.set_tile(1, 0, 0, Tile(flooring=TileLayerInstance("dirt"))) + # (1, 0, 1) and (1, 0, 2) left as bare Tile() - no flooring + world = World(registry=registry) + world.add_map(m) + return world + + +def test_stepping_off_a_cliff_falls_straight_to_the_floor_below(registry): + world = make_cliff_world(registry) + walker = Entity(position=EntityPosition("cliff", 0, 0, 2)) + assert world.try_move(walker, 1, 0, 0) is True + assert (walker.position.x, walker.position.y, walker.position.z) == (1, 0, 0) + + +def test_falling_skips_multiple_floorless_levels_to_the_first_real_floor(registry): + world = make_cliff_world(registry) + walker = Entity(position=EntityPosition("cliff", 0, 0, 1)) + assert world.try_move(walker, 1, 0, 0) is True + assert walker.position.z == 0 + + +def test_moving_between_two_floored_tiles_does_not_trigger_gravity(registry): + m = Map("level", 2, 1, 1) + m.set_tile(0, 0, 0, Tile(flooring=TileLayerInstance("dirt"))) + m.set_tile(1, 0, 0, Tile(flooring=TileLayerInstance("dirt"))) + world = World(registry=registry) + world.add_map(m) + walker = Entity(position=EntityPosition("level", 0, 0, 0)) + assert world.try_move(walker, 1, 0, 0) is True + assert walker.position.z == 0 + + +def test_gravity_also_applies_after_continuous_movement(registry): + world = make_cliff_world(registry) + walker = Entity(position=EntityPosition("cliff", 0.5, 0, 2)) + assert world.try_move_continuous(walker, 1, 0, 0, distance=1.0) is True + assert walker.position.z == 0 + + +def test_gravity_does_not_fire_on_a_single_level_map(registry): + m = Map("flat", 2, 1, 1) + m.set_tile(0, 0, 0, Tile(flooring=TileLayerInstance("dirt"))) + m.set_tile(1, 0, 0, Tile()) # bare, but nothing below z=0 to fall to + world = World(registry=registry) + world.add_map(m) + walker = Entity(position=EntityPosition("flat", 0, 0, 0)) + assert world.try_move(walker, 1, 0, 0) is True + assert (walker.position.x, walker.position.z) == (1, 0) # stays put, no lower floor exists diff --git a/tests/test_input.py b/tests/test_input.py index 6aa1239..67306a2 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -39,6 +39,60 @@ def test_held_key_contributes_to_current_move_vector(): assert input_state.current_move_vector() == (1.0, 0.0, 0.0) +def test_clear_held_state_drops_held_movement_and_blocking(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "d"}) + input_state.handle_event({"event_type": "key_down", "key": "b"}) + input_state.clear_held_state() + assert input_state.current_move_vector() == (0.0, 0.0, 0.0) + assert input_state.is_blocking is False + + +def test_clear_held_state_drops_pending_one_shot_actions(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "d"}) + input_state.handle_event({"event_type": "key_down", "key": " "}) + input_state.clear_held_state() + assert input_state.consume_move() is None + assert input_state.consume_leap() is None + assert input_state.consume_hand_activation() is None + + +def test_swim_down_key_sets_a_one_shot_flag(): + input_state = InputState() + assert input_state.consume_swim_down() is False + input_state.handle_event({"event_type": "key_down", "key": "Control"}) + assert input_state.consume_swim_down() is True + assert input_state.consume_swim_down() is False # consumed + + +def test_activate_selected_ability_key_sets_a_one_shot_flag(): + input_state = InputState() + assert input_state.consume_activate_ability_bar() is False + input_state.handle_event({"event_type": "key_down", "key": "e"}) + assert input_state.consume_activate_ability_bar() is True + assert input_state.consume_activate_ability_bar() is False # consumed + + +def test_clear_held_state_lets_a_continuously_held_gamepad_button_refire(): + from engine.gamepad import DEFAULT_GAMEPAD_BINDINGS, GamepadBindings, GamepadState + + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + held = GamepadState(buttons={"gp_left_bumper": True}) + + input_state.apply_gamepad_state(held, bindings) + assert input_state.is_blocking is True + + # simulate a pause: block gets reset even though the button is still physically held + input_state.clear_held_state() + assert input_state.is_blocking is False + + # on resume, the still-held button must re-fire its down edge, not be treated as unchanged + input_state.apply_gamepad_state(held, bindings) + assert input_state.is_blocking is True + + def test_releasing_a_held_key_removes_its_contribution(): input_state = InputState() input_state.handle_event({"event_type": "key_down", "key": "d"}) diff --git a/tests/test_map_embedding.py b/tests/test_map_embedding.py index aedc583..652a279 100644 --- a/tests/test_map_embedding.py +++ b/tests/test_map_embedding.py @@ -140,6 +140,30 @@ def test_moving_and_rotating_ship_does_not_touch_aboard_entities_local_position( assert (sailor.position.x, sailor.position.y, sailor.position.z) == original_local +def test_visible_embeddings_hides_the_ship_when_the_player_is_off_it(): + parent, child = make_world() + world = World() + world.maps = {"world": parent, "ship": child} + world.player = Entity(position=EntityPosition("world", 0, 0, 0)) + assert world.visible_embeddings(parent) == [] + + +def test_visible_embeddings_shows_the_ship_when_the_player_is_aboard(): + parent, child = make_world() + embedding = child.parent_embedding + world = World() + world.maps = {"world": parent, "ship": child} + world.player = Entity(position=EntityPosition("ship", 1, 1, 0)) + assert world.visible_embeddings(parent) == [embedding] + + +def test_visible_embeddings_empty_before_a_player_exists(): + parent, _child = make_world() + world = World() + world.maps = {"world": parent} + assert world.visible_embeddings(parent) == [] + + def test_disembarking_resolves_against_current_anchor_after_ship_moves(): parent, child = make_world() embedding = child.parent_embedding diff --git a/tests/test_menu_draw.py b/tests/test_menu_draw.py new file mode 100644 index 0000000..9ef1c40 --- /dev/null +++ b/tests/test_menu_draw.py @@ -0,0 +1,147 @@ +from collections import defaultdict +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.character_creator import CharacterCreator +from engine.controls_menu import ControlsMenu +from engine.defs import DefRegistry +from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu +from engine.render import menu_draw +from engine.ui import AbilityBar, AbilityBarSlot, CharacterMenu +from engine.world_creation import WorldCreationMenu + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def new_buckets(): + return defaultdict(list) + + +def test_draw_main_menu_emits_a_background_panel_and_option_text(): + buckets = new_buckets() + menu_draw.draw_main_menu(buckets, MainMenu(has_existing_save=True), 800, 600) + assert buckets["ui/panel/background.png"] + assert any(k.startswith("ui/font/") for k in buckets) + + +def test_draw_pause_menu_emits_content(): + buckets = new_buckets() + menu_draw.draw_pause_menu(buckets, PauseMenu(), 800, 600) + assert buckets["ui/panel/background.png"] + + +def test_draw_character_creation_species_step(registry): + buckets = new_buckets() + flow = CharacterCreationFlow(CharacterCreator(registry)) + menu_draw.draw_character_creation(buckets, flow, 800, 600) + assert buckets["ui/panel/background.png"] + + +def test_draw_character_creation_name_step(registry): + buckets = new_buckets() + flow = CharacterCreationFlow(CharacterCreator(registry)) + flow.advance() + flow.type_char("V") + menu_draw.draw_character_creation(buckets, flow, 800, 600) + assert buckets["ui/panel/background.png"] + + +def test_draw_world_creation_lists_templates_and_highlights_selection(): + buckets = new_buckets() + menu = WorldCreationMenu() + menu.select(0) + menu_draw.draw_world_creation(buckets, menu, 800, 600) + assert buckets["ui/panel/background.png"] + assert buckets["ui/panel/highlight.png"] + assert any(k.startswith("ui/font/") for k in buckets) + + +def test_draw_world_creation_with_nothing_selected_defaults_to_first_row(): + buckets = new_buckets() + menu_draw.draw_world_creation(buckets, WorldCreationMenu(), 800, 600) + assert buckets["ui/panel/highlight.png"] + + +def test_draw_controls_menu_highlights_selected_row(): + buckets = new_buckets() + from engine.config import KeyBindings + + menu = ControlsMenu(KeyBindings.load(Path("/nonexistent"))) + menu_draw.draw_controls_menu(buckets, menu, 800, 600) + assert buckets["ui/panel/highlight.png"] + + +def test_draw_ability_bar_highlights_the_selected_slot(): + buckets = new_buckets() + bar = AbilityBar() + bar.assign(2, AbilityBarSlot(kind="hand", source_id="right_hand", ability_id="time_warp_sphere")) + bar.select(2) + menu_draw.draw_ability_bar(buckets, bar, 600) + assert buckets["ui/panel/highlight.png"] + assert buckets["ui/panel/border.png"] + assert any(k.startswith("ui/font/") for k in buckets) + + +def test_draw_ability_bar_with_nothing_selected_has_no_highlight(): + buckets = new_buckets() + menu_draw.draw_ability_bar(buckets, AbilityBar(), 600) + assert "ui/panel/highlight.png" not in buckets + assert buckets["ui/panel/border.png"] + + +def test_draw_character_card_bio_tab(registry): + buckets = new_buckets() + character = Character( + name="Vex", species_id="human", default_body_parts=registry.new_default_body_parts("human") + ) + menu_draw.draw_character_card(buckets, character, CharacterMenu(), registry, 600) + assert buckets["ui/panel/background.png"] + assert any(k.startswith("ui/font/") for k in buckets) + + +def test_draw_character_card_equipment_tab(registry): + buckets = new_buckets() + character = Character( + name="Vex", species_id="human", default_body_parts=registry.new_default_body_parts("human") + ) + card_menu = CharacterMenu() + card_menu.select_tab("equipment") + menu_draw.draw_character_card(buckets, character, card_menu, registry, 600) + assert buckets["ui/panel/background.png"] + + +def test_draw_character_card_medical_tab_lists_body_parts_and_organs(registry): + buckets = new_buckets() + character = Character( + name="Vex", species_id="human", default_body_parts=registry.new_default_body_parts("human") + ) + card_menu = CharacterMenu() + card_menu.select_tab("medical") + menu_draw.draw_character_card(buckets, character, card_menu, registry, 600) + assert buckets["ui/panel/background.png"] + assert any(k.startswith("ui/font/") for k in buckets) + + +def test_draw_character_card_medical_tab_shows_removed_parts_and_ailments(registry): + from engine.character import Ailment + + buckets = new_buckets() + character = Character( + name="Vex", species_id="human", default_body_parts=registry.new_default_body_parts("human") + ) + torso = character.get_or_create_body_part_override("torso") + torso.ailments.append(Ailment(id="infection", name="Infection", severity=0.5)) + leg = character.get_or_create_body_part_override("left_leg") + leg.removed = True + + card_menu = CharacterMenu() + card_menu.select_tab("medical") + menu_draw.draw_character_card(buckets, character, card_menu, registry, 600) + assert buckets["ui/panel/background.png"] diff --git a/tests/test_noise.py b/tests/test_noise.py new file mode 100644 index 0000000..99f23d4 --- /dev/null +++ b/tests/test_noise.py @@ -0,0 +1,49 @@ +import numpy as np + +from engine.worldgen.noise import perlin_noise_2d + + +def test_same_seed_is_deterministic(): + a = perlin_noise_2d(8, 8, seed=42) + b = perlin_noise_2d(8, 8, seed=42) + assert np.array_equal(a, b) + + +def test_different_seeds_produce_different_noise(): + a = perlin_noise_2d(8, 8, seed=1) + b = perlin_noise_2d(8, 8, seed=2) + assert not np.array_equal(a, b) + + +def test_output_shape_matches_width_and_height(): + result = perlin_noise_2d(5, 9, seed=7) + assert result.shape == (9, 5) + + +def test_output_is_roughly_normalized_to_zero_one(): + result = perlin_noise_2d(32, 32, seed=3, octaves=4) + assert result.min() >= 0.0 + assert result.max() <= 1.0 + # not degenerate/constant + assert result.max() - result.min() > 0.05 + + +def test_offset_sampling_tiles_seamlessly_with_a_direct_full_field_sample(): + # Sampling two adjacent 4-wide windows via x_offset must match sampling the same 8-wide + # region directly in one call - this is what lets neighboring chunks share one continuous + # noise field with no seam at the boundary. + seed = 99 + whole = perlin_noise_2d(8, 4, seed=seed) + left = perlin_noise_2d(4, 4, seed=seed, x_offset=0) + right = perlin_noise_2d(4, 4, seed=seed, x_offset=4) + assert np.allclose(whole[:, :4], left) + assert np.allclose(whole[:, 4:], right) + + +def test_y_offset_also_tiles_seamlessly(): + seed = 7 + whole = perlin_noise_2d(4, 8, seed=seed) + top = perlin_noise_2d(4, 4, seed=seed, y_offset=0) + bottom = perlin_noise_2d(4, 4, seed=seed, y_offset=4) + assert np.allclose(whole[:4, :], top) + assert np.allclose(whole[4:, :], bottom) diff --git a/tests/test_reimport_textures.py b/tests/test_reimport_textures.py new file mode 100644 index 0000000..e78735e --- /dev/null +++ b/tests/test_reimport_textures.py @@ -0,0 +1,50 @@ +from scripts.reimport_textures import reimport_textures + + +def test_copies_files_preserving_relative_paths(tmp_path): + src = tmp_path / "art_wip" + dest = tmp_path / "resources_textures" + (src / "tiles").mkdir(parents=True) + (src / "tiles" / "grass.png").write_bytes(b"fake-png-bytes") + + count = reimport_textures(src, dest) + + assert count == 1 + assert (dest / "tiles" / "grass.png").read_bytes() == b"fake-png-bytes" + + +def test_overwrites_an_existing_destination_file(tmp_path): + src = tmp_path / "art_wip" + dest = tmp_path / "resources_textures" + (src / "items").mkdir(parents=True) + (src / "items" / "staff_oak.png").write_bytes(b"new-art") + (dest / "items").mkdir(parents=True) + (dest / "items" / "staff_oak.png").write_bytes(b"old-placeholder") + + reimport_textures(src, dest) + + assert (dest / "items" / "staff_oak.png").read_bytes() == b"new-art" + + +def test_copies_multiple_nested_files(tmp_path): + src = tmp_path / "art_wip" + dest = tmp_path / "resources_textures" + (src / "a" / "b").mkdir(parents=True) + (src / "a" / "b" / "one.png").write_bytes(b"1") + (src / "a" / "two.png").write_bytes(b"2") + + count = reimport_textures(src, dest) + + assert count == 2 + assert (dest / "a" / "b" / "one.png").read_bytes() == b"1" + assert (dest / "a" / "two.png").read_bytes() == b"2" + + +def test_ignores_directories_themselves(tmp_path): + src = tmp_path / "art_wip" + dest = tmp_path / "resources_textures" + (src / "empty_dir").mkdir(parents=True) + + count = reimport_textures(src, dest) + + assert count == 0 diff --git a/tests/test_swimming.py b/tests/test_swimming.py new file mode 100644 index 0000000..e90bd94 --- /dev/null +++ b/tests/test_swimming.py @@ -0,0 +1,72 @@ +from pathlib import Path + +import pytest + +from engine.defs import DefRegistry +from engine.entity import Entity, EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.swimming import in_water, try_swim_vertical + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_world(registry): + m = Map("lake", 2, 1, 3) + m.set_tile(0, 0, 0, Tile(flooring=TileLayerInstance("water"))) + m.set_tile(0, 0, 1, Tile(flooring=TileLayerInstance("water"))) + m.set_tile(0, 0, 2, Tile(room=TileLayerInstance("solid_rock"))) # a lid above the water + m.set_tile(1, 0, 0, Tile(flooring=TileLayerInstance("dirt"))) + world = World(registry=registry) + world.add_map(m) + return world + + +def test_in_water_true_when_standing_on_water(registry): + world = make_world(registry) + swimmer = Entity(position=EntityPosition("lake", 0, 0, 0)) + assert in_water(swimmer, world) is True + + +def test_in_water_false_on_dry_land(registry): + world = make_world(registry) + walker = Entity(position=EntityPosition("lake", 1, 0, 0)) + assert in_water(walker, world) is False + + +def test_in_water_false_with_no_position(registry): + world = make_world(registry) + assert in_water(Entity(), world) is False + + +def test_swim_up_moves_between_stacked_water_tiles(registry): + world = make_world(registry) + swimmer = Entity(position=EntityPosition("lake", 0, 0, 0)) + assert try_swim_vertical(swimmer, world, 1) is True + assert swimmer.position.z == 1 + + +def test_swim_down_moves_between_stacked_water_tiles(registry): + world = make_world(registry) + swimmer = Entity(position=EntityPosition("lake", 0, 0, 1)) + assert try_swim_vertical(swimmer, world, -1) is True + assert swimmer.position.z == 0 + + +def test_swim_is_blocked_by_a_solid_ceiling(registry): + world = make_world(registry) + swimmer = Entity(position=EntityPosition("lake", 0, 0, 1)) + assert try_swim_vertical(swimmer, world, 1) is False + assert swimmer.position.z == 1 + + +def test_swim_vertical_is_a_no_op_on_dry_land(registry): + world = make_world(registry) + walker = Entity(position=EntityPosition("lake", 1, 0, 0)) + assert try_swim_vertical(walker, world, 1) is False diff --git a/tests/test_terrain_worldgen.py b/tests/test_terrain_worldgen.py new file mode 100644 index 0000000..789cd5d --- /dev/null +++ b/tests/test_terrain_worldgen.py @@ -0,0 +1,205 @@ +from pathlib import Path + +import numpy as np +import pytest + +from engine.defs import DefRegistry +from engine.entity import Entity, EntityPosition +from engine.worldgen.terrain import ( + CAVE_MIN_HEIGHT_DIFF, + MAX_HEIGHT, + SAND_Z, + STONE_MIN_Z, + WATER_MAX_Z, + build_terrain_map, + generate_terrain_map, + terrain_for, +) +from engine.world import World + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +class _FixedRng: + """A random.Random look-alike that always returns a fixed value from random().""" + + def __init__(self, value: float): + self.value = value + + def random(self) -> float: + return self.value + + +# --- terrain_for classification ----------------------------------------------------------------- + + +def test_low_elevations_are_water(): + for z in range(WATER_MAX_Z + 1): + assert terrain_for(z, moisture=0.5) == "water" + + +def test_shoreline_band_is_sand(): + assert terrain_for(SAND_Z, moisture=0.9) == "sand" + assert terrain_for(SAND_Z, moisture=0.1) == "sand" + + +def test_high_elevations_are_stone(): + for z in range(STONE_MIN_Z, MAX_HEIGHT): + assert terrain_for(z, moisture=0.1) == "stone" + + +def test_mid_elevations_are_dirt_or_mud_by_moisture(): + mid_z = (SAND_Z + STONE_MIN_Z) // 2 + assert terrain_for(mid_z, moisture=0.9) == "mud" + assert terrain_for(mid_z, moisture=0.1) == "dirt" + + +# --- build_terrain_map: surface + underground fill ---------------------------------------------- + + +def test_surface_tile_has_the_classified_terrain_and_an_empty_roof(registry): + heights = np.array([[3]]) + moisture = np.array([[0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + tile = m.get_tile(0, 0, 3) + assert tile.layer_id("flooring") == "dirt" + assert tile.roof is None + assert tile.is_walkable(registry) is True + + +def test_underground_is_solid_unwalkable_rock(registry): + heights = np.array([[4]]) + moisture = np.array([[0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + for z in range(4): + tile = m.get_tile(0, 0, z) + assert tile.layer_id("room") == "solid_rock" + assert tile.is_walkable(registry) is False + + +def test_a_flat_map_has_no_underground_fill_at_the_minimum_height(): + heights = np.array([[0, 0]]) + moisture = np.array([[0.1, 0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + # nothing below z=0 to fill - just confirm this doesn't error and the surface is water + assert m.get_tile(0, 0, 0).layer_id("flooring") == "water" + + +def test_a_water_column_is_swimmable_all_the_way_down_not_solid_rock(registry): + heights = np.array([[1]]) # a height-1 water column has one tile beneath its surface + moisture = np.array([[0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + assert m.get_tile(0, 0, 0).layer_id("flooring") == "water" + assert m.get_tile(0, 0, 0).is_walkable(registry) is True + assert m.get_tile(0, 0, 1).layer_id("flooring") == "water" + + +# --- slope connectors: req 3, actually walkable via World.try_move ------------------------------- + + +def test_one_level_boundary_gets_a_walkable_slope_connector_both_ways(registry): + heights = np.array([[2, 3]]) + moisture = np.array([[0.1, 0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + + world = World(registry=registry) + world.maps["t"] = m + + walker = Entity(position=EntityPosition("t", 0, 0, 2)) + assert world.try_move(walker, 1, 0, 0) is True + assert (walker.position.x, walker.position.y, walker.position.z) == (1, 0, 3) + + assert world.try_move(walker, -1, 0, 0) is True + assert (walker.position.x, walker.position.y, walker.position.z) == (0, 0, 2) + + +def test_slope_connector_tiles_are_not_walled(registry): + heights = np.array([[2, 3]]) + moisture = np.array([[0.1, 0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + assert m.get_tile(1, 0, 2).is_walkable(registry) is True # ascend connector + assert m.get_tile(0, 0, 3).is_walkable(registry) is True # descend connector + + +def test_no_connector_between_columns_at_the_same_height(): + heights = np.array([[3, 3]]) + moisture = np.array([[0.1, 0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + # neither column got an extra tile above/below its own surface + assert m.get_tile(0, 0, 4).flooring is None + assert m.get_tile(1, 0, 2).flooring is None + + +# --- cave pockets: req 4 ------------------------------------------------------------------------- + + +def test_steep_boundary_carves_a_cave_pocket_when_the_roll_succeeds(registry): + heights = np.array([[1, 3]]) # diff == 2, qualifies + moisture = np.array([[0.1, 0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(0.0)) # random() always < CAVE_CHANCE + + pocket = m.get_tile(1, 0, 1) # higher column (x=1), at the lower column's height + assert pocket.room is None # unwalled + assert pocket.roof is not None # roofed + assert pocket.is_walkable(registry) is True + + +def test_steep_boundary_stays_solid_rock_when_the_roll_fails(registry): + heights = np.array([[1, 3]]) + moisture = np.array([[0.1, 0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) # random() never < CAVE_CHANCE + + pocket = m.get_tile(1, 0, 1) + assert pocket.layer_id("room") == "solid_rock" + assert pocket.is_walkable(registry) is False + + +def test_cave_min_height_diff_constant_is_2(): + assert CAVE_MIN_HEIGHT_DIFF == 2 + + +def test_walking_off_a_steep_uncave_cliff_falls_instead_of_floating(registry): + # diff == 3, no slope connector applies (only diff==1 gets one) and the roll is forced to + # fail (no cave carved) - previously this left bare, "walkable" empty air above the lower + # column (x=0's cells above its own height-1 surface are never touched by generation); + # gravity (engine/world.py::World._apply_gravity) should catch it and drop the walker down + # to the real ground instead of leaving them floating. + heights = np.array([[1, 4]]) + moisture = np.array([[0.1, 0.1]]) + m = build_terrain_map("t", heights, moisture, rng=_FixedRng(1.0)) + + world = World(registry=registry) + world.maps["t"] = m + walker = Entity(position=EntityPosition("t", 1, 0, 4)) # start on the high column's surface + assert world.try_move(walker, -1, 0, 0) is True # step toward the low column + assert (walker.position.x, walker.position.y, walker.position.z) == (0, 0, 1) # falls to its real surface + + +# --- generate_terrain_map: full pipeline smoke test ---------------------------------------------- + + +def test_generate_terrain_map_produces_a_fully_resolvable_map(registry): + m = generate_terrain_map("generated_test", width=12, height=12, seed=1234) + assert m.width == 12 + assert m.height == 12 + assert m.depth == MAX_HEIGHT + 1 + # every tile's layers must resolve against the real registry without raising, and every + # column's own surface must be walkable + for y in range(m.height): + for x in range(m.width): + for z in range(m.depth): + m.get_tile(x, y, z).is_walkable(registry) # no KeyError/crash + + +def test_generate_terrain_map_is_deterministic_for_the_same_seed(): + a = generate_terrain_map("a", 10, 10, seed=55) + b = generate_terrain_map("b", 10, 10, seed=55) + for y in range(10): + for x in range(10): + for z in range(a.depth): + assert a.get_tile(x, y, z).layer_id("flooring") == b.get_tile(x, y, z).layer_id("flooring") diff --git a/tests/test_tile.py b/tests/test_tile.py new file mode 100644 index 0000000..bd19111 --- /dev/null +++ b/tests/test_tile.py @@ -0,0 +1,48 @@ +from engine.tile import Tile, TileLayerDef, TileLayerInstance + + +class FakeRegistry: + def __init__(self, defs: dict[tuple[str, str], TileLayerDef]): + self._defs = defs + + def get_tile_layer_def(self, layer, def_id): + if def_id is None: + return None + return self._defs.get((layer, def_id)) + + +def test_speed_multiplier_defaults_to_1_with_no_layers_set(): + tile = Tile() + assert tile.speed_multiplier(FakeRegistry({})) == 1.0 + + +def test_speed_multiplier_uses_flooring_defs_value(): + registry = FakeRegistry({("flooring", "mud"): TileLayerDef(id="mud", layer="flooring", speed_multiplier=0.6)}) + tile = Tile(flooring=TileLayerInstance("mud")) + assert tile.speed_multiplier(registry) == 0.6 + + +def test_speed_multiplier_takes_the_most_restrictive_of_room_and_flooring(): + registry = FakeRegistry({ + ("flooring", "mud"): TileLayerDef(id="mud", layer="flooring", speed_multiplier=0.6), + ("room", "webbing"): TileLayerDef(id="webbing", layer="room", speed_multiplier=0.3), + }) + tile = Tile(flooring=TileLayerInstance("mud"), room=TileLayerInstance("webbing")) + assert tile.speed_multiplier(registry) == 0.3 + + +def test_speed_multiplier_unaffected_by_an_unrelated_def_with_no_slow(): + registry = FakeRegistry({("flooring", "grass"): TileLayerDef(id="grass", layer="flooring")}) + tile = Tile(flooring=TileLayerInstance("grass")) + assert tile.speed_multiplier(registry) == 1.0 + + +def test_has_any_layer_false_for_a_bare_tile(): + assert Tile().has_any_layer() is False + + +def test_has_any_layer_true_if_any_single_layer_is_set(): + assert Tile(flooring=TileLayerInstance("dirt")).has_any_layer() is True + assert Tile(room=TileLayerInstance("solid_rock")).has_any_layer() is True + assert Tile(roof=TileLayerInstance("cave_ceiling")).has_any_layer() is True + assert Tile(subfloor=TileLayerInstance("dirt")).has_any_layer() is True diff --git a/tests/test_ui_assets.py b/tests/test_ui_assets.py new file mode 100644 index 0000000..fc178b2 --- /dev/null +++ b/tests/test_ui_assets.py @@ -0,0 +1,24 @@ +from engine.render.ui_draw import glyph_texture_path, panel_texture_path +from engine.ui_assets import UI_PANEL_COLORS, generate_ui_textures + + +def test_generate_ui_textures_writes_a_glyph_and_panel_file(tmp_path): + generate_ui_textures(tmp_path) + assert (tmp_path / glyph_texture_path("A")).exists() + assert (tmp_path / panel_texture_path("background")).exists() + + +def test_generate_ui_textures_writes_every_declared_panel_color(tmp_path): + generate_ui_textures(tmp_path) + for name in UI_PANEL_COLORS: + assert (tmp_path / panel_texture_path(name)).exists() + + +def test_generate_ui_textures_does_not_overwrite_existing_files(tmp_path): + generate_ui_textures(tmp_path) + path = tmp_path / glyph_texture_path("A") + original_bytes = path.read_bytes() + path.write_bytes(b"not-a-real-png") + generate_ui_textures(tmp_path) + assert path.read_bytes() == b"not-a-real-png" + assert path.read_bytes() != original_bytes diff --git a/tests/test_ui_draw.py b/tests/test_ui_draw.py new file mode 100644 index 0000000..ac2d971 --- /dev/null +++ b/tests/test_ui_draw.py @@ -0,0 +1,27 @@ +from collections import defaultdict + +from engine.render.ui_draw import draw_panel, draw_text, glyph_texture_path, panel_texture_path, text_width + + +def test_glyph_texture_path_uses_char_code(): + assert glyph_texture_path("A") == f"ui/font/{ord('A')}.png" + + +def test_glyph_texture_path_falls_back_for_non_printable_chars(): + assert glyph_texture_path("\n") == glyph_texture_path("?") + + +def test_draw_text_skips_spaces_but_still_advances(): + buckets: dict[str, list[float]] = defaultdict(list) + width = draw_text(buckets, "a b", x=0, y=0, char_advance=10) + a_path, b_path = glyph_texture_path("a"), glyph_texture_path("b") + assert buckets[a_path] == [0, 0, 16.0, 16.0] + assert buckets[b_path] == [20, 0, 16.0, 16.0] # index 2 * advance 10 + assert " " not in "".join(buckets.keys()) + assert width == text_width("a b", 10) + + +def test_draw_panel_emits_one_instance(): + buckets: dict[str, list[float]] = defaultdict(list) + draw_panel(buckets, "background", 5, 6, 100, 40) + assert buckets[panel_texture_path("background")] == [5, 6, 100, 40] diff --git a/tests/test_world_creation.py b/tests/test_world_creation.py index f420e2d..180f389 100644 --- a/tests/test_world_creation.py +++ b/tests/test_world_creation.py @@ -4,7 +4,13 @@ import pytest from engine.defs import DefRegistry from engine.map_loader import MapLoader -from engine.world_creation import AVAILABLE_WORLD_TEMPLATES, WorldCreationMenu, WorldTemplate, build_world_from_template +from engine.world_creation import ( + AVAILABLE_WORLD_TEMPLATES, + GENERATED_WORLD_SIZE, + WorldCreationMenu, + build_world_from_template, + find_spawn_position, +) DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" @@ -28,10 +34,8 @@ def test_menu_starts_with_the_available_templates(): assert menu.selected_index is None -def test_currently_only_one_template_is_available(): - # campaign/survival/creative (procedurally generated) modes are planned follow-ups, not yet - # implemented - this pins down the "just the test map for now" scope explicitly - assert [t.id for t in AVAILABLE_WORLD_TEMPLATES] == ["test_map"] +def test_available_templates_are_test_map_and_generated(): + assert [t.id for t in AVAILABLE_WORLD_TEMPLATES] == ["test_map", "generated"] def test_select_sets_selected_index(): @@ -68,7 +72,42 @@ def test_build_world_from_template_loads_the_map_and_its_embeddings(registry, ma assert world.entities == {} # world creation alone spawns no one -def test_build_world_from_template_raises_for_a_generator_only_template(registry, map_loader): - generator_template = WorldTemplate(id="campaign", name="Campaign", map_file=None) - with pytest.raises(NotImplementedError): - build_world_from_template(generator_template, registry, map_loader) +def test_build_world_from_template_generates_a_procedural_map_for_the_generated_template(registry, map_loader): + generated_template = next(t for t in AVAILABLE_WORLD_TEMPLATES if t.id == "generated") + world = build_world_from_template(generated_template, registry, map_loader, seed=42) + assert "generated" in world.maps + generated_map = world.maps["generated"] + assert (generated_map.width, generated_map.height) == (GENERATED_WORLD_SIZE, GENERATED_WORLD_SIZE) + assert world.entities == {} + + +def test_build_world_from_template_generation_is_deterministic_for_the_same_seed(registry, map_loader): + generated_template = next(t for t in AVAILABLE_WORLD_TEMPLATES if t.id == "generated") + world_a = build_world_from_template(generated_template, registry, map_loader, seed=7) + world_b = build_world_from_template(generated_template, registry, map_loader, seed=7) + map_a, map_b = world_a.maps["generated"], world_b.maps["generated"] + for y in range(map_a.height): + for x in range(map_a.width): + assert map_a.get_tile(x, y, 0).layer_id("flooring") == map_b.get_tile(x, y, 0).layer_id("flooring") + + +# --- find_spawn_position ------------------------------------------------------------------------- + + +def test_find_spawn_position_lands_on_a_walkable_dry_tile(registry, map_loader): + template = AVAILABLE_WORLD_TEMPLATES[0] + world = build_world_from_template(template, registry, map_loader) + position = find_spawn_position(world.maps["harbor_world"], registry) + assert position is not None + tile = world.maps["harbor_world"].get_tile(position.x, position.y, position.z) + assert tile.is_walkable(registry) is True + assert tile.roof is None + assert tile.layer_id("flooring") != "water" + + +def test_find_spawn_position_works_on_a_generated_map(registry, map_loader): + generated_template = next(t for t in AVAILABLE_WORLD_TEMPLATES if t.id == "generated") + world = build_world_from_template(generated_template, registry, map_loader, seed=42) + position = find_spawn_position(world.maps["generated"], registry) + assert position is not None + assert position.map_id == "generated"