nodes/game_manager.py

Part of Dungeon Explorer.

  1"""Game manager: global state, scene transitions, and save/load orchestration.
  2
  3Designed as a singleton: one instance, created by the game root and added as a
  4child of it, so every system reads the same game state.
  5"""
  6
  7from simvx.core import Node, Property, Signal
  8
  9
 10class GameManager(Node):
 11    """Global game state manager: a single instance held by the game root.
 12
 13    State previously stored on raw instance attributes (``activated_waypoints``,
 14    ``difficulty``, ``ng_plus_cycle``, dungeon seeds, …) is now declared as
 15    ``Property(persist=True)`` so the engine's :class:`SaveManager` walks and
 16    snapshots it automatically: no bespoke ``to_dict``/``from_dict`` plumbing.
 17    """
 18
 19    __save_version__ = 1
 20
 21    dungeon_level = Property(1, range=(1, 100), hint="Current dungeon depth", persist=True, save_version=1)
 22    max_dungeon_reached = Property(0, range=(0, 100), hint="Deepest level reached", persist=True, save_version=1)
 23    save_point_level = Property(0, range=(0, 100), hint="Last save point level", persist=True, save_version=1)
 24    in_town = Property(True, hint="Player is in town", persist=True, save_version=1)
 25    game_over = Property(False)
 26    boss_defeated = Property(False, persist=True, save_version=1)
 27
 28    # Persisted run state: pickled directly by SaveManager. Dict-valued
 29    # properties use ``default_factory`` so each instance gets its own container.
 30    activated_waypoints = Property((), hint="Activated waypoint floors (tuple of int)", persist=True, save_version=1)
 31    difficulty = Property("normal", enum=["easy", "normal", "hard", "nightmare"], persist=True, save_version=1)
 32    ng_plus_cycle = Property(0, range=(0, 99), hint="New Game+ cycle count", persist=True, save_version=1)
 33    _dungeon_seeds = Property(default_factory=dict, hint="Per-floor RNG seed map", persist=True, save_version=1)
 34    game_state = Property(default_factory=dict, hint="Misc serialised game-state bag", persist=True, save_version=1)
 35
 36    # Signals
 37    scene_changed = Signal()
 38    dungeon_entered = Signal()
 39    town_entered = Signal()
 40    game_saved = Signal()
 41    game_loaded = Signal()
 42
 43    # Difficulty multipliers: (enemy_hp, enemy_dmg, xp_mult, loot_mult)
 44    DIFFICULTY_MODES = {
 45        "easy": (1.5, 1.4, 1.1, 1.1),
 46        "normal": (2.5, 2.2, 1.0, 1.0),
 47        "hard": (4.0, 3.5, 0.85, 0.8),
 48        "nightmare": (6.0, 5.0, 0.7, 0.6),
 49    }
 50
 51    def __init__(self, **kwargs):
 52        super().__init__(name="GameManager", **kwargs)
 53        self._dungeon_seed: int | None = None
 54        # Seed the misc state bag with the keys the game reads before its first save.
 55        self.game_state = {
 56            "gold": 0,
 57            "dungeon_level": 1,
 58            "save_point_level": 0,
 59            "boss_defeated": False,
 60        }
 61
 62    @property
 63    def difficulty_multipliers(self) -> tuple[float, float, float, float]:
 64        """Return (hp_mult, dmg_mult, xp_mult, loot_mult) for current difficulty."""
 65        base = self.DIFFICULTY_MODES.get(self.difficulty, (1.0, 1.0, 1.0, 1.0))
 66        # NG+ stacks 2x per cycle on top of difficulty
 67        ng_scale = 1.0 + self.ng_plus_cycle * 1.0
 68        return (base[0] * ng_scale, base[1] * ng_scale, base[2], base[3])
 69
 70    def get_dungeon_seed(self, level: int) -> int:
 71        """Get a persistent seed for a dungeon level. Generates one if none exists."""
 72        seeds = dict(self._dungeon_seeds)
 73        if level not in seeds:
 74            import random
 75
 76            seeds[level] = random.randint(0, 2**31)
 77            self._dungeon_seeds = seeds
 78        return seeds[level]
 79
 80    def enter_dungeon(self, level: int = 1, seed: int | None = None) -> None:
 81        """Transition to dungeon at given level."""
 82        self.dungeon_level = level
 83        self.in_town = False
 84        self._dungeon_seed = seed
 85        if level > self.max_dungeon_reached:
 86            self.max_dungeon_reached = level
 87        self.dungeon_entered(level)
 88
 89    def enter_town(self) -> None:
 90        """Transition to town hub."""
 91        self.in_town = True
 92        self.town_entered()
 93
 94    def next_dungeon_level(self) -> None:
 95        """Advance to the next dungeon level."""
 96        self.enter_dungeon(self.dungeon_level + 1)
 97
 98    def set_save_point(self) -> None:
 99        """Record a save point at the current dungeon level."""
100        self.save_point_level = self.dungeon_level
101
102    def activate_waypoint(self, floor: int) -> None:
103        """Activate a waypoint at the given floor for fast-travel."""
104        floors = list(self.activated_waypoints)
105        if floor not in floors:
106            floors.append(floor)
107            self.activated_waypoints = tuple(floors)
108
109    def reroll_dungeon(self) -> None:
110        """Re-roll the dungeon (well mechanic). Clears all stored seeds."""
111        self.save_point_level = 0
112        self._dungeon_seed = None
113        self._dungeon_seeds = {}
114
115    def start_new_game_plus(self) -> None:
116        """Start a New Game+ cycle: keep items/skills, reset dungeon, scale enemies."""
117        self.ng_plus_cycle += 1
118        self.dungeon_level = 1
119        self.max_dungeon_reached = 0
120        self.save_point_level = 0
121        self.boss_defeated = False
122        self.activated_waypoints = ()
123        self.in_town = True
124
125    def reset_run_state(self) -> None:
126        """Reset to a fresh-game baseline. Replaces the old ``load_state_snapshot({...})``
127        idiom for kicking off a new game.
128        """
129        self.dungeon_level = 1
130        self.max_dungeon_reached = 0
131        self.save_point_level = 0
132        self.in_town = False
133        self.boss_defeated = False
134        self.game_state = {}
135        self.activated_waypoints = ()