Mi2dRPGamEng/main.py

516 lines
22 KiB
Python

from __future__ import annotations
import random
import time
from collections import defaultdict
from pathlib import Path
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.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_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 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"
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 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")
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 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 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
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)
context, texture_format = configure_context(window.canvas, adapter, device)
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(), tiles_visible_y=DEFAULT_TILES_VISIBLE_Y)
last_frame_time = time.perf_counter()
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:
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)
window.canvas.add_event_handler(on_event, "key_down", "key_up", "pointer_down", "pointer_move", "close")
def current_aim_direction(player: Character) -> tuple[int, int, int]:
assert player.position is not None
if input_state.pointer_pos is None:
return input_state.facing
wx, wy = camera.screen_to_world(*input_state.pointer_pos)
dx, dy = snap_to_8way(wx - (player.position.x + 0.5), wy - (player.position.y + 0.5))
return dx, dy, 0
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:
return GamepadState.from_glfw(raw)
return None
def draw_frame() -> None:
nonlocal last_frame_time
wall_now = time.perf_counter()
dt = wall_now - last_frame_time
last_frame_time = wall_now
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)
advance_all_ship_rotations(world, clock.total_time)
if ticks:
for entity in world.entities.values():
if isinstance(entity, Character):
apply_bleeding_tick(entity, registry, ticks)
apply_organ_interactions_tick(entity, registry, ticks)
player = world.player
if isinstance(player, Character) and player.position is not None:
player.is_blocking = input_state.is_blocking
helming = at_helm(player, world) is not None
camera.set_zoom_target(HELM_TILES_VISIBLE_Y if helming else DEFAULT_TILES_VISIBLE_Y)
move_dx, move_dy, _ = input_state.current_move_vector()
if move_dx or move_dy:
if helming:
# steering a ship stays a discrete, one-press-per-step affair - the
# ship's own grid structure doesn't need continuous sub-tile positioning
move = input_state.consume_move()
if move is not None:
try_steer_ship(player, world, *move)
else:
speed = player.effective_speed(BASE_MOVE_SPEED, registry)
slow = world.speed_multiplier_at(
player.entity_id, player.position.map_id, player.position.x, player.position.y,
player.position.z, clock.total_time,
)
if speed > 0:
world.try_move_continuous(player, move_dx, move_dy, 0, distance=speed * slow * dt)
swimming = in_water(player, world)
leap = input_state.consume_leap()
if leap is not None:
if swimming:
try_swim_vertical(player, world, 1) # space surfaces while submerged
else:
world.try_leap(player, *leap)
if input_state.consume_swim_down():
try_swim_vertical(player, world, -1)
hand = input_state.consume_hand_activation()
if hand is not None:
activate_hand(
player, world, registry, hand, current_aim_direction(player), rng=rng, now=clock.total_time
)
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,
)
camera.set_target(player.position.x + 0.5, player.position.y + 0.5)
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()
if __name__ == "__main__":
main()