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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyY5qQB6XHRoBFNbsgdRQn
main
Amir Alexander Abdelbaki 2026-07-16 10:32:25 +02:00
parent 05f66b8e00
commit 616c945509
50 changed files with 3239 additions and 151 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

52
engine/controls_menu.py Normal file
View File

@ -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)

View File

@ -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:

35
engine/dev_config.py Normal file
View File

@ -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

51
engine/dev_tools.py Normal file
View File

@ -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)

124
engine/game_modes.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

186
engine/render/menu_draw.py Normal file
View File

@ -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)

View File

@ -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(

View File

@ -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,17 +101,67 @@ 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):
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 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, 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
@ -84,19 +169,19 @@ class Renderer:
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)
)
buckets[layer_def.texture].extend((float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0))
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:
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)
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,

51
engine/render/ui_draw.py Normal file
View File

@ -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))

View File

@ -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

View File

@ -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

52
engine/ui_assets.py Normal file
View File

@ -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)

View File

@ -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):

View File

@ -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)
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)

View File

97
engine/worldgen/noise.py Normal file
View File

@ -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

164
engine/worldgen/terrain.py Normal file
View File

@ -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)

444
main.py
View File

@ -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)
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,7 +385,34 @@ 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)
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()
# 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)
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)
@ -154,11 +427,14 @@ def main() -> None:
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 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
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)
@ -171,17 +447,65 @@ def main() -> None:
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)
activate_hand(
player, world, registry, hand, current_aim_direction(player), rng=rng, now=clock.total_time
)
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,
)
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()

View File

@ -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" }
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

35
tests/test_camera.py Normal file
View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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"

84
tests/test_dev_tools.py Normal file
View File

@ -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

View File

@ -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"

124
tests/test_game_modes.py Normal file
View File

@ -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

View File

@ -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"}

76
tests/test_gravity.py Normal file
View File

@ -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

View File

@ -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"})

View File

@ -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

147
tests/test_menu_draw.py Normal file
View File

@ -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"]

49
tests/test_noise.py Normal file
View File

@ -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)

View File

@ -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

72
tests/test_swimming.py Normal file
View File

@ -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

View File

@ -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")

48
tests/test_tile.py Normal file
View File

@ -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

24
tests/test_ui_assets.py Normal file
View File

@ -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

27
tests/test_ui_draw.py Normal file
View File

@ -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]

View File

@ -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"