737 lines
33 KiB
Python
737 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import random
|
|
import time
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
import glfw
|
|
|
|
from engine.ai import AIController
|
|
from engine.aim import snap_to_8way
|
|
from engine.camera import OrthoCamera
|
|
from engine.character import Character
|
|
from engine.character_creator import NPC_SELECTABLE_SPECIES, 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 DevFactionMenu, DevSpawnMenu, DevTurretMenu
|
|
from engine.entity import EntityPosition
|
|
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 (
|
|
backpack_grid_origin,
|
|
backpack_popup_rect,
|
|
character_card_rect,
|
|
draw_ability_bar,
|
|
draw_backpack_popup,
|
|
draw_character_card,
|
|
draw_character_creation,
|
|
draw_controls_menu,
|
|
draw_drag_ghost,
|
|
draw_faction_editor,
|
|
draw_main_menu,
|
|
draw_pause_menu,
|
|
draw_world_creation,
|
|
equipment_row_at,
|
|
inventory_cell_at,
|
|
point_in_rect,
|
|
)
|
|
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.ai import run_ai_tick
|
|
from resources.logic.contagion import apply_flu_contagion_tick
|
|
from resources.logic.falling import apply_fall_damage
|
|
from resources.logic.health_ticks import apply_bleeding_tick, apply_boost_expiry_tick
|
|
from resources.logic.organs import apply_organ_interactions_tick
|
|
from resources.logic.pickup import drop_item, try_pickup
|
|
from resources.logic.steering import advance_all_ship_rotations, at_helm, try_steer_ship
|
|
from resources.logic.swimming import in_water, try_swim_vertical
|
|
from resources.logic.turrets import spawn_turret
|
|
|
|
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.npc_spawn_flow: CharacterCreationFlow | None = None # dev-mode NPC spawner (see Mode.NPC_SPAWN)
|
|
self.faction_edit_menu = DevFactionMenu() # dev-mode faction tag editor (see Mode.FACTION_EDIT)
|
|
self.faction_edit_target: Character | None = None # whose factions faction_edit_menu is currently showing
|
|
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.dragging_item: tuple[str, str] | None = None # (item_id, def_id) picked up off the inventory grid
|
|
self.ability_bar = AbilityBar()
|
|
self.dev_spawn_menu = DevSpawnMenu(registry)
|
|
self.dev_turret_menu = DevTurretMenu(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_npc_spawn_key(key: str) -> None:
|
|
"""Dev-mode-only NPC spawner: the exact same species/name CharacterCreationFlow as "new
|
|
game" (see handle_character_create_key above), just pooled over every species instead of
|
|
only the player-selectable ones (see NPC_SELECTABLE_SPECIES), and confirming spawns the
|
|
built Character into the current world instead of handing off to world creation.
|
|
"""
|
|
flow = state.npc_spawn_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.npc_spawn_flow = None
|
|
enter_playing()
|
|
return
|
|
|
|
if key == "Enter":
|
|
if flow.advance():
|
|
npc = flow.creator.build_character()
|
|
assert npc is not None
|
|
player = state.world.player if state.world is not None else None
|
|
if isinstance(player, Character) and player.position is not None and state.world is not None:
|
|
dx, dy, dz = current_aim_direction(player)
|
|
npc.position = EntityPosition(
|
|
player.position.map_id, player.position.x + dx, player.position.y + dy, player.position.z + dz
|
|
)
|
|
npc.ai = AIController(behavior_id="wander")
|
|
state.world.add_entity(npc)
|
|
state.npc_spawn_flow = None
|
|
enter_playing()
|
|
elif key == "Escape":
|
|
flow.go_back()
|
|
elif key == "Backspace":
|
|
flow.backspace()
|
|
elif len(key) == 1:
|
|
flow.type_char(key)
|
|
|
|
def handle_faction_edit_key(key: str) -> None:
|
|
"""Dev-mode-only faction tag editor - see DevFactionMenu (engine/dev_tools.py) for the
|
|
add/remove/select logic, main.py's dev_edit_player_factions/dev_edit_target_factions
|
|
for how this mode gets entered (and which Character it's pointed at).
|
|
"""
|
|
menu = state.faction_edit_menu
|
|
target = state.faction_edit_target
|
|
assert target is not None
|
|
if key == "ArrowUp":
|
|
menu.cycle_selected(target, -1)
|
|
elif key == "ArrowDown":
|
|
menu.cycle_selected(target, 1)
|
|
elif key == "Enter":
|
|
menu.add_tag(target)
|
|
elif key == "Delete":
|
|
menu.remove_selected(target)
|
|
elif key == "Backspace":
|
|
menu.backspace()
|
|
elif key == "Escape":
|
|
state.faction_edit_target = None
|
|
enter_playing()
|
|
elif len(key) == 1:
|
|
menu.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)
|
|
elif state.mode == Mode.NPC_SPAWN:
|
|
handle_npc_spawn_key(key)
|
|
elif state.mode == Mode.FACTION_EDIT:
|
|
handle_faction_edit_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
|
|
if not state.character_card_visible:
|
|
state.dragging_item = None
|
|
state.character_card.close_backpack_popup()
|
|
return
|
|
if action == "cycle_character_tab":
|
|
if state.character_card_visible:
|
|
state.character_card.cycle_tab()
|
|
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
|
|
if dev_config.enabled and action == "dev_spawn_npc_menu":
|
|
leave_playing()
|
|
state.npc_spawn_flow = CharacterCreationFlow(CharacterCreator(registry, selectable_species=NPC_SELECTABLE_SPECIES))
|
|
state.mode = Mode.NPC_SPAWN
|
|
return
|
|
if dev_config.enabled and action == "dev_cycle_turret":
|
|
state.dev_turret_menu.cycle(1)
|
|
return
|
|
if dev_config.enabled and action == "dev_place_turret":
|
|
player = state.world.player if state.world is not None else None
|
|
entity_def_id = state.dev_turret_menu.selected_entity_def_id()
|
|
if isinstance(player, Character) and player.position is not None and entity_def_id is not None and state.world is not None:
|
|
dx, dy, dz = current_aim_direction(player)
|
|
position = EntityPosition(
|
|
player.position.map_id, player.position.x + dx, player.position.y + dy, player.position.z + dz
|
|
)
|
|
# hostile to the placing player by default - a dev/debug placement is meant to
|
|
# be immediately testable (see it track and fire), not require setting up
|
|
# faction tags first; see resources/logic/turrets.py::is_hostile.
|
|
turret = spawn_turret(registry, entity_def_id, position, hostile_entity_ids=(player.entity_id,))
|
|
state.world.add_entity(turret)
|
|
return
|
|
if dev_config.enabled and action == "dev_edit_player_factions":
|
|
player = state.world.player if state.world is not None else None
|
|
if isinstance(player, Character):
|
|
leave_playing()
|
|
state.faction_edit_target = player
|
|
state.faction_edit_menu = DevFactionMenu()
|
|
state.mode = Mode.FACTION_EDIT
|
|
return
|
|
if dev_config.enabled and action == "dev_edit_target_factions":
|
|
player = state.world.player if state.world is not None else None
|
|
if isinstance(player, Character) and player.position is not None and state.world is not None:
|
|
dx, dy, dz = current_aim_direction(player)
|
|
ahead = (player.position.x + dx, player.position.y + dy, player.position.z + dz)
|
|
target = state.world.entity_at(player.position.map_id, *ahead)
|
|
if isinstance(target, Character):
|
|
leave_playing()
|
|
state.faction_edit_target = target
|
|
state.faction_edit_menu = DevFactionMenu()
|
|
state.mode = Mode.FACTION_EDIT
|
|
return
|
|
input_state.handle_event(event)
|
|
|
|
def handle_character_card_pointer_event(event_type: str, event: dict) -> bool:
|
|
"""Hit-tests a pointer_down/pointer_up against the open character card and (if one is
|
|
open) its backpack popup, driving two gestures: clicking an equipment-tab row toggles
|
|
that container's popup open/closed (see CharacterMenu.toggle_backpack), and dragging a
|
|
cell out of an open popup and releasing outside both the card and the popup drops it on
|
|
the ground (see resources/logic/pickup.py::drop_item). Returns whether the event was
|
|
consumed by the card/popup (so on_event knows not to also feed it to gameplay's
|
|
InputState - e.g. a click landing on the card shouldn't also register as a hand
|
|
activation in the world behind it).
|
|
"""
|
|
player = state.world.player if state.world is not None else None
|
|
if not isinstance(player, Character):
|
|
return False
|
|
menu = state.character_card
|
|
_viewport_w, viewport_h = window.canvas.get_physical_size()
|
|
px, py = event.get("x", 0.0), event.get("y", 0.0)
|
|
open_inventory = menu.resolve_open_inventory(player, registry)
|
|
|
|
if event_type == "pointer_down":
|
|
if open_inventory is not None:
|
|
popup_rect = backpack_popup_rect(open_inventory, viewport_h)
|
|
if point_in_rect(px, py, popup_rect):
|
|
cell = inventory_cell_at(open_inventory, backpack_grid_origin(popup_rect), px, py)
|
|
if cell is not None:
|
|
placed = open_inventory.item_at(*cell)
|
|
if placed is not None:
|
|
state.dragging_item = (placed.item.item_id, placed.item.def_id)
|
|
return True
|
|
|
|
if not point_in_rect(px, py, character_card_rect(viewport_h)):
|
|
return False
|
|
if menu.active_tab == "equipment":
|
|
slot = equipment_row_at(player, viewport_h, px, py)
|
|
if slot is not None:
|
|
menu.toggle_backpack(slot, player, registry)
|
|
return True
|
|
|
|
if event_type == "pointer_up":
|
|
if state.dragging_item is None:
|
|
return False
|
|
item_id, def_id = state.dragging_item
|
|
state.dragging_item = None
|
|
on_card = point_in_rect(px, py, character_card_rect(viewport_h))
|
|
on_popup = open_inventory is not None and point_in_rect(
|
|
px, py, backpack_popup_rect(open_inventory, viewport_h)
|
|
)
|
|
if not on_card and not on_popup:
|
|
drop_item(player, state.world, item_id, def_id, open_inventory, registry)
|
|
return True
|
|
|
|
return False
|
|
|
|
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 state.character_card_visible and event_type in ("pointer_down", "pointer_up"):
|
|
if handle_character_card_pointer_event(event_type, event):
|
|
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_up", "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
|
|
if not state.character_card_visible:
|
|
state.dragging_item = None
|
|
state.character_card.close_backpack_popup()
|
|
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)
|
|
apply_boost_expiry_tick(entity, clock.total_time)
|
|
run_ai_tick(entity, world, registry, clock.total_time, rng)
|
|
apply_flu_contagion_tick(world, ticks, rng)
|
|
|
|
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)
|
|
|
|
fallen = world.consume_fall_distance(player.entity_id)
|
|
if fallen > 0:
|
|
apply_fall_damage(player, registry, fallen, rng)
|
|
|
|
if input_state.consume_pickup():
|
|
try_pickup(player, world, registry)
|
|
|
|
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)
|
|
dragging_id = state.dragging_item[0] if state.dragging_item is not None else None
|
|
draw_backpack_popup(ui_buckets, player, state.character_card, registry, viewport_h, dragging_id)
|
|
if state.dragging_item is not None and input_state.pointer_pos is not None:
|
|
draw_drag_ghost(ui_buckets, state.dragging_item[1], *input_state.pointer_pos)
|
|
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)
|
|
draw_text(ui_buckets, f"turret: {state.dev_turret_menu.selected_entity_def_id()}", viewport_w - 260, 50)
|
|
|
|
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)
|
|
elif state.mode == Mode.NPC_SPAWN:
|
|
assert state.npc_spawn_flow is not None
|
|
draw_character_creation(ui_buckets, state.npc_spawn_flow, viewport_w, viewport_h, title="Spawn NPC")
|
|
elif state.mode == Mode.FACTION_EDIT:
|
|
assert state.faction_edit_target is not None
|
|
draw_faction_editor(ui_buckets, state.faction_edit_menu, state.faction_edit_target, viewport_w, viewport_h)
|
|
renderer.render_frame(context, backdrop, camera, ui_buckets)
|
|
|
|
window.canvas.request_draw(draw_frame)
|
|
window.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|