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