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_01FyY5qQB6XHRoBFNbsgdRQnmain
parent
05f66b8e00
commit
616c945509
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
59
engine/ui.py
59
engine/ui.py
|
|
@ -2,6 +2,14 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from engine.character import Character
|
||||
from engine.defs import DefRegistry
|
||||
|
||||
# Fixed scan order populate_ability_bar fills slots in - matches HAND_TO_ARM_SLOT's own order
|
||||
# in engine/character.py, so a two-armed character's slots land in the same left-to-right
|
||||
# sense a player would expect (right hand first, then left, then the second pair if present).
|
||||
ABILITY_BAR_HAND_SLOTS = ("right_hand", "left_hand", "right_hand_2", "left_hand_2")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayerPickerMenu:
|
||||
|
|
@ -67,20 +75,55 @@ class AbilityBar:
|
|||
return self.slots[self.selected_index]
|
||||
|
||||
|
||||
def populate_ability_bar(bar: AbilityBar, character: Character, registry: DefRegistry) -> None:
|
||||
"""Auto-fills `bar` from whatever `character` can currently actually cast: each wielded
|
||||
hand holding an ability-granting item (see ABILITY_BAR_HAND_SLOTS), then each installed
|
||||
ability-granting implant - in that fixed order, one per slot. Meant to be re-run whenever
|
||||
the loadout might have changed (main.py calls it once per frame during play; cheap enough
|
||||
not to bother caching), which also means it always leaves stale slots from a dropped/
|
||||
unwielded item cleared rather than assigning past the end.
|
||||
"""
|
||||
bar.slots = [None] * len(bar.slots)
|
||||
index = 0
|
||||
for hand_slot in ABILITY_BAR_HAND_SLOTS:
|
||||
if index >= len(bar.slots):
|
||||
return
|
||||
held = character.get_held_item(hand_slot)
|
||||
if held is None:
|
||||
continue
|
||||
item_def = registry.get_item_def(held.item_def_id)
|
||||
if item_def.grants_ability is None:
|
||||
continue
|
||||
bar.assign(index, AbilityBarSlot(kind="hand", source_id=hand_slot, ability_id=item_def.grants_ability))
|
||||
index += 1
|
||||
|
||||
for implant_id in character.implants:
|
||||
if index >= len(bar.slots):
|
||||
return
|
||||
item_def = registry.get_item_def(implant_id)
|
||||
if item_def.grants_ability is None:
|
||||
continue
|
||||
bar.assign(index, AbilityBarSlot(kind="implant", source_id=implant_id, ability_id=item_def.grants_ability))
|
||||
index += 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class CharacterMenu:
|
||||
"""Pure interaction state for the bottom-left tabbed character menu: a "bio" tab (skills/
|
||||
mental stats/health - all already on the Character model) and an "equipment" tab (worn
|
||||
clothing + held items). Right-clicking an equipped slot that grants an inventory (e.g. a
|
||||
worn backpack) opens a popup submenu above the menu showing that slot's contents.
|
||||
mental stats/health - all already on the Character model), an "equipment" tab (worn
|
||||
clothing + held items), and a "medical" tab (per-body-part integrity, ailments, and organ
|
||||
health/removal - see engine/character.py's BodyPart/Organ/Ailment). Right-clicking an
|
||||
equipped slot that grants an inventory (e.g. a worn backpack) opens a popup submenu above
|
||||
the menu showing that slot's contents.
|
||||
|
||||
No rendering here - same boundary as LayerPickerMenu/AbilityBar above: the actual bio and
|
||||
equipment data already lives on the Character/DefRegistry models this engine built over the
|
||||
whole session, so this class only tracks which tab is active and which backpack popup (if
|
||||
any) is open - drawing the tabs, labels, and popup box is a follow-up UI-layer task.
|
||||
No rendering here - same boundary as LayerPickerMenu/AbilityBar above: the actual bio,
|
||||
equipment, and medical data already lives on the Character/DefRegistry models this engine
|
||||
built over the whole session, so this class only tracks which tab is active and which
|
||||
backpack popup (if any) is open - drawing the tabs, labels, and popup box is a follow-up
|
||||
UI-layer task (see engine/render/menu_draw.py::draw_character_card).
|
||||
"""
|
||||
|
||||
TABS = ("bio", "equipment")
|
||||
TABS = ("bio", "equipment", "medical")
|
||||
|
||||
active_tab: str = "bio"
|
||||
open_backpack_slot: str | None = None # clothing slot whose granted-inventory popup is open
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
444
main.py
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import random
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import glfw
|
||||
|
|
@ -9,27 +10,45 @@ import glfw
|
|||
from engine.aim import snap_to_8way
|
||||
from engine.camera import OrthoCamera
|
||||
from engine.character import Character
|
||||
from engine.character_creator import CharacterCreator
|
||||
from engine.config import KeyBindings
|
||||
from engine.controls_menu import ControlsMenu
|
||||
from engine.db import WorldRepository
|
||||
from engine.defs import DefRegistry
|
||||
from engine.entity import EntityPosition
|
||||
from engine.gamepad import GamepadBindings, GamepadState
|
||||
from engine.dev_config import DevConfig
|
||||
from engine.dev_tools import DevSpawnMenu
|
||||
from engine.gamepad import GamepadBindings, GamepadButtonEdgeTracker, GamepadState
|
||||
from engine.game_modes import CharacterCreationFlow, MainMenu, Mode, PauseMenu
|
||||
from engine.input import InputState
|
||||
from engine.inventory import Inventory
|
||||
from engine.item import ItemInstance
|
||||
from engine.map_loader import MapLoader
|
||||
from engine.placeholder_textures import generate_placeholder_textures
|
||||
from engine.placeholder_textures import generate_depth_shadow_texture, generate_placeholder_textures
|
||||
from engine.render.device import configure_context, request_device
|
||||
from engine.render.menu_draw import (
|
||||
draw_ability_bar,
|
||||
draw_character_card,
|
||||
draw_character_creation,
|
||||
draw_controls_menu,
|
||||
draw_main_menu,
|
||||
draw_pause_menu,
|
||||
draw_world_creation,
|
||||
)
|
||||
from engine.render.pipeline import QuadPipeline
|
||||
from engine.render.renderer import Renderer
|
||||
from engine.render.texture import TextureCache
|
||||
from engine.render.ui_draw import draw_text
|
||||
from engine.render.window import Window
|
||||
from engine.timing import GameClock
|
||||
from engine.ui import AbilityBar, CharacterMenu, populate_ability_bar
|
||||
from engine.ui_assets import generate_ui_textures
|
||||
from engine.world import World
|
||||
from resources.logic.actions import activate_hand
|
||||
from engine.world_creation import WorldCreationMenu, build_world_from_template, find_spawn_position
|
||||
from resources.logic.actions import activate_ability_bar_slot, activate_hand
|
||||
from resources.logic.health_ticks import apply_bleeding_tick
|
||||
from resources.logic.organs import apply_organ_interactions_tick
|
||||
from resources.logic.steering import advance_all_ship_rotations, at_helm, try_steer_ship
|
||||
from resources.logic.swimming import in_water, try_swim_vertical
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
DEFS_DIR = ROOT_DIR / "resources" / "defs"
|
||||
|
|
@ -37,60 +56,99 @@ TEXTURES_DIR = ROOT_DIR / "resources" / "textures"
|
|||
DB_PATH = ROOT_DIR / "data" / "world.sqlite3"
|
||||
KEYBINDINGS_PATH = ROOT_DIR / "config" / "keybindings.conf"
|
||||
CONTROLLER_BINDINGS_PATH = ROOT_DIR / "config" / "controller_bindings.conf"
|
||||
DEV_CONFIG_PATH = ROOT_DIR / "config" / "dev.conf"
|
||||
BASE_MOVE_SPEED = 4.0 # tiles/second at full (two healthy legs') speed factor
|
||||
DEFAULT_TILES_VISIBLE_Y = 12.0
|
||||
HELM_TILES_VISIBLE_Y = 20.0 # wider view while steering a ship, so more of it is in frame
|
||||
|
||||
# Raw gamepad button -> the same synthetic key strings the menu handlers already accept from
|
||||
# keyboard input (see handle_menu_key) - lets a controller drive every menu without a second
|
||||
# set of menu-navigation logic. Only consulted outside Mode.PLAYING; during play, the dpad/A/B
|
||||
# stay bound to gameplay actions via GamepadBindings/controller_bindings.conf as before.
|
||||
GAMEPAD_MENU_KEY_MAP: dict[str, str] = {
|
||||
"gp_dpad_up": "ArrowUp",
|
||||
"gp_dpad_down": "ArrowDown",
|
||||
"gp_dpad_left": "ArrowLeft",
|
||||
"gp_dpad_right": "ArrowRight",
|
||||
"gp_a": "Enter",
|
||||
"gp_b": "Escape",
|
||||
}
|
||||
|
||||
|
||||
def spawn_player(registry: DefRegistry) -> Character:
|
||||
def equip_starting_loadout(character: Character, registry: DefRegistry) -> None:
|
||||
"""A fresh character's starter gear - the same loadout spawn_player used to hand out
|
||||
directly, now applied to whichever Character actually becomes the player (built via
|
||||
engine/character_creator.py::CharacterCreator at "new game" time rather than a fixed
|
||||
template), so newly created characters aren't dropped into the world with empty hands.
|
||||
"""
|
||||
character.wield_item("right_hand", "staff_oak", registry)
|
||||
entity_def = registry.get_entity_def("player")
|
||||
assert entity_def.species_id is not None
|
||||
|
||||
player = Character(
|
||||
entity_id="player-1",
|
||||
name=entity_def.name,
|
||||
def_id=entity_def.id,
|
||||
position=EntityPosition("demo_world", 4, 4, 0),
|
||||
species_id=entity_def.species_id,
|
||||
gender="nonbinary",
|
||||
default_body_parts=registry.new_default_body_parts(entity_def.species_id),
|
||||
default_clothing=registry.new_default_clothing(entity_def.id),
|
||||
)
|
||||
player.wield_item("right_hand", "staff_oak", registry)
|
||||
|
||||
if entity_def.inventory_size is not None:
|
||||
player.inventory = Inventory(*entity_def.inventory_size)
|
||||
player.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0)
|
||||
player.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0)
|
||||
|
||||
return player
|
||||
if entity_def.inventory_size is not None and character.inventory is None:
|
||||
character.inventory = Inventory(*entity_def.inventory_size)
|
||||
character.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0)
|
||||
character.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0)
|
||||
|
||||
|
||||
def build_fresh_world(registry: DefRegistry, map_loader: MapLoader) -> World:
|
||||
world = World(registry=registry)
|
||||
demo_world = map_loader.load("demo_world.json")
|
||||
world.add_map(demo_world)
|
||||
for embedding in demo_world.embeddings:
|
||||
world.add_map(embedding.child_map)
|
||||
|
||||
player = spawn_player(registry)
|
||||
def spawn_new_character_into_world(world: World, registry: DefRegistry, player: Character, rng: random.Random) -> bool:
|
||||
"""Drops a freshly-created character into `world` at a random walkable, dry, unroofed
|
||||
spot (see engine/world_creation.py::find_spawn_position) and equips their starter gear.
|
||||
Returns whether it succeeded - False (world untouched) only if the map has nowhere to
|
||||
stand at all, which shouldn't happen for any of AVAILABLE_WORLD_TEMPLATES.
|
||||
"""
|
||||
root_map = next((m for m in world.maps.values() if m.parent_embedding is None), None)
|
||||
if root_map is None:
|
||||
return False
|
||||
spawn = find_spawn_position(root_map, registry, rng)
|
||||
if spawn is None:
|
||||
return False
|
||||
player.position = spawn
|
||||
equip_starting_loadout(player, registry)
|
||||
world.add_entity(player)
|
||||
world.player = player
|
||||
return world
|
||||
return True
|
||||
|
||||
|
||||
class AppState:
|
||||
"""Everything main()'s frame loop mutates from one frame to the next: which screen is
|
||||
active (see engine/game_modes.py::Mode) and the live instance of whichever pure-state menu
|
||||
class that screen is currently driving. Not itself reusable outside this module - it's just
|
||||
the integration point tying together the several previously-unwired menu/flow classes.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: DefRegistry, world: World | None):
|
||||
self.mode = Mode.MAIN_MENU
|
||||
self.world = world
|
||||
self.main_menu = MainMenu(has_existing_save=world is not None)
|
||||
self.char_flow: CharacterCreationFlow | None = None
|
||||
self.pending_character: Character | None = None # built, awaiting a world to spawn into
|
||||
self.world_creation_menu: WorldCreationMenu | None = None
|
||||
self.controls_menu: ControlsMenu | None = None
|
||||
self.pause_menu: PauseMenu | None = None
|
||||
self.return_mode: Mode = Mode.MAIN_MENU # where Escape from the controls menu goes back to
|
||||
self.character_card = CharacterMenu()
|
||||
self.character_card_visible = False
|
||||
self.ability_bar = AbilityBar()
|
||||
self.dev_spawn_menu = DevSpawnMenu(registry)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
registry = DefRegistry.load(DEFS_DIR)
|
||||
generate_placeholder_textures(registry, TEXTURES_DIR)
|
||||
generate_depth_shadow_texture(TEXTURES_DIR)
|
||||
generate_ui_textures(TEXTURES_DIR)
|
||||
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
repo = WorldRepository.open(DB_PATH)
|
||||
map_loader = MapLoader(DEFS_DIR / "maps")
|
||||
empty_world = World(registry=registry) # menu backdrop before any world has ever been made
|
||||
|
||||
world = repo.load_world(registry, map_loader)
|
||||
if world is None:
|
||||
world = build_fresh_world(registry, map_loader)
|
||||
print("No save found; started a fresh world.")
|
||||
else:
|
||||
print(f"Loaded save: player at {world.player.position}.")
|
||||
keybindings = KeyBindings.load(KEYBINDINGS_PATH)
|
||||
gamepad_bindings = GamepadBindings.load(CONTROLLER_BINDINGS_PATH)
|
||||
dev_config = DevConfig.load(DEV_CONFIG_PATH)
|
||||
input_state = InputState(keybindings)
|
||||
menu_gamepad = GamepadButtonEdgeTracker()
|
||||
clock = GameClock(tick_interval=1.0)
|
||||
rng = random.Random()
|
||||
|
||||
window = Window()
|
||||
adapter, device = request_device(window.canvas)
|
||||
|
|
@ -99,18 +157,204 @@ def main() -> None:
|
|||
pipeline = QuadPipeline(device, texture_format)
|
||||
texture_cache = TextureCache(device, TEXTURES_DIR)
|
||||
renderer = Renderer(device, pipeline, texture_cache, registry)
|
||||
camera = OrthoCamera(window.canvas.get_physical_size()[0], window.canvas.get_physical_size()[1])
|
||||
keybindings = KeyBindings.load(KEYBINDINGS_PATH)
|
||||
gamepad_bindings = GamepadBindings.load(CONTROLLER_BINDINGS_PATH)
|
||||
input_state = InputState(keybindings)
|
||||
clock = GameClock(tick_interval=1.0)
|
||||
camera = OrthoCamera(*window.canvas.get_physical_size(), tiles_visible_y=DEFAULT_TILES_VISIBLE_Y)
|
||||
last_frame_time = time.perf_counter()
|
||||
rng = random.Random()
|
||||
|
||||
saved_world = repo.load_world(registry, map_loader)
|
||||
if saved_world is not None:
|
||||
print(f"Found a save: player at {saved_world.player.position}.")
|
||||
state = AppState(registry, saved_world)
|
||||
|
||||
def enter_playing() -> None:
|
||||
input_state.clear_held_state()
|
||||
state.mode = Mode.PLAYING
|
||||
|
||||
def leave_playing() -> None:
|
||||
input_state.clear_held_state()
|
||||
|
||||
def trigger_pause() -> None:
|
||||
leave_playing()
|
||||
state.pause_menu = PauseMenu()
|
||||
state.mode = Mode.PAUSED
|
||||
|
||||
def resolve_action(key: str, modifiers) -> str | None:
|
||||
if key and "Shift" in modifiers:
|
||||
action = keybindings.action_for_key(f"shift+{key}")
|
||||
if action is not None:
|
||||
return action
|
||||
return keybindings.action_for_key(key)
|
||||
|
||||
def handle_main_menu_key(key: str) -> None:
|
||||
menu = state.main_menu
|
||||
if key == "ArrowUp":
|
||||
menu.move(-1)
|
||||
elif key == "ArrowDown":
|
||||
menu.move(1)
|
||||
elif key == "Enter":
|
||||
choice = menu.selected()
|
||||
if choice == "continue":
|
||||
enter_playing()
|
||||
elif choice == "new_game":
|
||||
state.char_flow = CharacterCreationFlow(CharacterCreator(registry))
|
||||
state.mode = Mode.CHARACTER_CREATE
|
||||
elif choice == "controls":
|
||||
state.return_mode = Mode.MAIN_MENU
|
||||
state.controls_menu = ControlsMenu(keybindings)
|
||||
state.mode = Mode.CONTROLS
|
||||
|
||||
def handle_character_create_key(key: str) -> None:
|
||||
flow = state.char_flow
|
||||
assert flow is not None
|
||||
if flow.step == "species":
|
||||
if key == "ArrowLeft":
|
||||
flow.cycle_species(-1)
|
||||
elif key == "ArrowRight":
|
||||
flow.cycle_species(1)
|
||||
elif key == "Enter":
|
||||
flow.advance()
|
||||
elif key == "Escape":
|
||||
state.char_flow = None
|
||||
state.mode = Mode.MAIN_MENU
|
||||
return
|
||||
|
||||
if key == "Enter":
|
||||
if flow.advance():
|
||||
character = flow.creator.build_character()
|
||||
assert character is not None
|
||||
state.pending_character = character
|
||||
state.char_flow = None
|
||||
state.world_creation_menu = WorldCreationMenu()
|
||||
state.world_creation_menu.select(0)
|
||||
state.mode = Mode.WORLD_CREATE
|
||||
elif key == "Escape":
|
||||
flow.go_back()
|
||||
elif key == "Backspace":
|
||||
flow.backspace()
|
||||
elif len(key) == 1:
|
||||
flow.type_char(key)
|
||||
|
||||
def handle_world_create_key(key: str) -> None:
|
||||
menu = state.world_creation_menu
|
||||
assert menu is not None
|
||||
if key == "ArrowUp":
|
||||
menu.move(-1)
|
||||
elif key == "ArrowDown":
|
||||
menu.move(1)
|
||||
elif key == "Escape":
|
||||
state.world_creation_menu = None
|
||||
state.pending_character = None
|
||||
state.mode = Mode.MAIN_MENU
|
||||
elif key == "Enter":
|
||||
template = menu.selected_template()
|
||||
character = state.pending_character
|
||||
if template is not None and character is not None:
|
||||
world = build_world_from_template(template, registry, map_loader)
|
||||
if spawn_new_character_into_world(world, registry, character, rng):
|
||||
state.world = world
|
||||
state.main_menu = MainMenu(has_existing_save=True)
|
||||
state.pending_character = None
|
||||
state.world_creation_menu = None
|
||||
enter_playing()
|
||||
|
||||
def handle_controls_key(key: str) -> None:
|
||||
menu = state.controls_menu
|
||||
assert menu is not None
|
||||
if menu.awaiting_key:
|
||||
if key == "Escape":
|
||||
menu.cancel_rebind()
|
||||
else:
|
||||
menu.capture_key(key)
|
||||
return
|
||||
if key == "ArrowUp":
|
||||
menu.move(-1)
|
||||
elif key == "ArrowDown":
|
||||
menu.move(1)
|
||||
elif key == "Enter":
|
||||
menu.begin_rebind()
|
||||
elif key == "Escape":
|
||||
menu.save(KEYBINDINGS_PATH)
|
||||
state.controls_menu = None
|
||||
state.mode = state.return_mode
|
||||
|
||||
def handle_pause_key(key: str) -> None:
|
||||
menu = state.pause_menu
|
||||
assert menu is not None
|
||||
if key == "ArrowUp":
|
||||
menu.move(-1)
|
||||
elif key == "ArrowDown":
|
||||
menu.move(1)
|
||||
elif key == "Escape":
|
||||
state.pause_menu = None
|
||||
enter_playing()
|
||||
elif key == "Enter":
|
||||
choice = menu.selected()
|
||||
if choice == "resume":
|
||||
state.pause_menu = None
|
||||
enter_playing()
|
||||
elif choice == "controls":
|
||||
state.return_mode = Mode.PAUSED
|
||||
state.controls_menu = ControlsMenu(keybindings)
|
||||
state.mode = Mode.CONTROLS
|
||||
elif choice == "save_and_quit_to_menu":
|
||||
if state.world is not None:
|
||||
repo.save_world(state.world)
|
||||
print("Saved world.")
|
||||
state.world = None
|
||||
state.pause_menu = None
|
||||
state.main_menu = MainMenu(has_existing_save=True)
|
||||
state.mode = Mode.MAIN_MENU
|
||||
|
||||
def handle_menu_key(key: str) -> None:
|
||||
if state.mode == Mode.MAIN_MENU:
|
||||
handle_main_menu_key(key)
|
||||
elif state.mode == Mode.CHARACTER_CREATE:
|
||||
handle_character_create_key(key)
|
||||
elif state.mode == Mode.WORLD_CREATE:
|
||||
handle_world_create_key(key)
|
||||
elif state.mode == Mode.CONTROLS:
|
||||
handle_controls_key(key)
|
||||
elif state.mode == Mode.PAUSED:
|
||||
handle_pause_key(key)
|
||||
|
||||
def handle_playing_key_down(event: dict) -> None:
|
||||
key = event.get("key", "")
|
||||
action = resolve_action(key, event.get("modifiers", ()))
|
||||
if action == "pause":
|
||||
trigger_pause()
|
||||
return
|
||||
if action == "toggle_character_card":
|
||||
state.character_card_visible = not state.character_card_visible
|
||||
return
|
||||
if action == "toggle_dev_mode":
|
||||
dev_config.toggle()
|
||||
dev_config.save(DEV_CONFIG_PATH)
|
||||
return
|
||||
if dev_config.enabled and action == "dev_cycle_spawn":
|
||||
state.dev_spawn_menu.cycle(1)
|
||||
return
|
||||
if dev_config.enabled and action == "dev_spawn_object":
|
||||
player = state.world.player if state.world is not None else None
|
||||
if isinstance(player, Character):
|
||||
state.dev_spawn_menu.spawn_into(player)
|
||||
return
|
||||
input_state.handle_event(event)
|
||||
|
||||
def on_event(event: dict) -> None:
|
||||
if event.get("event_type") == "close":
|
||||
repo.save_world(world)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue