extends Node ## Persistent run progression across scene changes — the Slay-the-Spire-style map and ## where the player sits on it. The arena scene is reloaded for every fight, so this ## state must outlive scene changes: genuinely global run state earns an autoload (`Run`). ## ## Flow: menu Play → start_new_run() → arena (intro fight) → win → MapScreen shows the ## first frontier → pick a node → its level scene → win → back to MapScreen advanced one ## row, and on until a boss node ends the map. Every node is the arena for now; adding a ## level later is a new LevelDef in the catalog and pointing the generator at it. signal run_started # Every level loads the same reusable shell (player, camera, HUD, PS1 filter); the shell # then instances the level's own `level_scene` module into its LevelRoot. const _SHELL_SCENE := "res://scene.tscn" const _ARENA_LEVEL := preload("res://levels/arena_level.tscn") const _BEAR_LEVEL := preload("res://levels/bear_level.tscn") const _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn") # Map shape. Small + placeholder for now; the final row is a single boss node. const _ROWS := 5 const _MIN_PER_ROW := 2 const _MAX_PER_ROW := 4 ## map[row] is an Array[MapNode]; empty until a run starts. var map: Array = [] ## Node the player currently occupies. (-1, -1) = the start gate below the first row — ## i.e. straight after the intro fight, before any map node has been chosen. var player_row: int = -1 var player_col: int = -1 ## Node picked on the map whose level is being played now; committed to on the next win. var pending_row: int = -1 var pending_col: int = -1 ## Debug override — when set, active_level() returns this regardless of map state. ## Set via the console `level` command; cleared on start_new_run(). var debug_level_override: LevelDef = null ## The lobby is an interstitial hub between fights, not a map node: the player is sent ## here after clearing a level (go_to_lobby()) to move around freely before choosing the ## next node. While set, active_level() serves the lobby; cleared once a node is picked ## (select_node) or a fresh run begins. var enter_lobby: bool = false # Level catalog. Every level shares the shell scene; what changes is the level module # (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss # flavours) and the bear (a lone boss enemy that can turn up as a normal node) live here. # Future levels — china shop — slot in the same way: a new module + a LevelDef entry. const _BEAR_SCENE := preload("res://Bear.tscn") var _arena: LevelDef var _arena_boss: LevelDef var _bear: LevelDef var _lobby: LevelDef func _ready() -> void: _arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL, Color(0.80, 0.62, 0.24), false) _arena_boss = _make_level( &"arena_boss", "Grand Arena", _ARENA_LEVEL, Color(0.88, 0.30, 0.20), true ) _bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL, Color(0.55, 0.38, 0.22), false) _bear.enemy_scene = _BEAR_SCENE # The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose. _lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL, Color(0.62, 0.66, 0.72), false) # It's a roomy hub with nothing to fight — pull the camera back so more of it reads. _lobby.camera_zoom = 1.6 func _make_level( id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool ) -> LevelDef: var l := LevelDef.new() l.id = id l.display_name = level_name l.scene_path = _SHELL_SCENE l.level_scene = level_scene l.color = color l.is_boss = boss return l ## The level being fought right now: debug override wins, then the pending/current map ## node, falling back to the plain arena for the intro fight or a direct scene launch. func active_level() -> LevelDef: if debug_level_override != null: return debug_level_override if enter_lobby: return _lobby var n := node_at(pending_row, pending_col) if n == null: n = current_node() return n.level if n != null and n.level != null else _arena ## Return a level by id, or null if unknown. Used by the debug console `level` command. func get_level(id: StringName) -> LevelDef: match id: &"arena": return _arena &"arena_boss": return _arena_boss &"bear": return _bear &"lobby": return _lobby return null ## All known level ids — shown by `level` in the console. func level_ids() -> Array[StringName]: return [&"arena", &"arena_boss", &"bear", &"lobby"] ## Begin a fresh run: build a new map and place the player at the start gate. Called from ## the main-menu Play button, not from the arena scene (which reloads for every fight). func start_new_run() -> void: _generate_map() player_row = -1 player_col = -1 pending_row = -1 pending_col = -1 debug_level_override = null enter_lobby = false run_started.emit() ## Build a run if none exists — a safety net for entering the map or arena directly (e.g. ## running scene.tscn straight from the editor) without coming through the menu. func ensure_run() -> void: if map.is_empty(): start_new_run() func has_run() -> bool: return not map.is_empty() func node_at(row: int, col: int) -> MapNode: if row < 0 or row >= map.size(): return null var r: Array = map[row] if col < 0 or col >= r.size(): return null return r[col] func current_node() -> MapNode: return node_at(player_row, player_col) ## Nodes the player may pick next: the whole first row from the start gate, otherwise the ## nodes the current node links forward to. func reachable() -> Array: if map.is_empty(): return [] if player_row < 0: return (map[0] as Array).duplicate() var here := current_node() if here == null or player_row + 1 >= map.size(): return [] var out: Array = [] for c: int in here.links: var n := node_at(player_row + 1, c) if n != null: out.append(n) return out ## True once the player has cleared a boss node — the map is finished. func run_complete() -> bool: var here := current_node() return here != null and here.level != null and here.level.is_boss ## Mark a node as the one being played (its level scene is about to load). func select_node(node: MapNode) -> void: if node == null: return enter_lobby = false pending_row = node.row pending_col = node.col ## Send the player to the lobby hub instead of a fight — active_level() serves the lobby ## until a node is picked. Called after clearing a level so the player lands in the lobby ## rather than jumping straight to the next fight. func go_to_lobby() -> void: enter_lobby = true ## Commit the level just won: move the player onto the pending node (or leave them at the ## start gate after the intro fight, which has no pending node). func complete_current_level() -> void: if pending_row >= 0: player_row = pending_row player_col = pending_col var n := current_node() if n != null: n.cleared = true pending_row = -1 pending_col = -1 # ── Map generation ──────────────────────────────────────────────────────────── # A small branching lattice: each row gets a handful of nodes, each node links to the # nearest node(s) in the next row, and every next-row node is guaranteed a parent so the # path is always connected (no orphan the player can never reach). The final row is a # single boss node the whole map funnels toward. func _generate_map() -> void: map.clear() for r: int in _ROWS: var count := 1 if r == _ROWS - 1 else randi_range(_MIN_PER_ROW, _MAX_PER_ROW) var y := 1.0 - float(r + 1) / float(_ROWS + 1) # row 0 near the bottom, boss on top var row_nodes: Array = [] for c: int in count: var node := MapNode.new() node.row = r node.col = c node.pos = Vector2(_column_x(c, count), y) # Boss row is always the Grand Arena; normal nodes are a mix of matador # arenas and the occasional Bear's Den for variety. if r == _ROWS - 1: node.level = _arena_boss else: node.level = _bear if randf() < 0.35 else _arena row_nodes.append(node) map.append(row_nodes) _link_rows() func _column_x(col: int, count: int) -> float: if count <= 1: return 0.5 return lerpf(0.18, 0.82, float(col) / float(count - 1)) func _link_rows() -> void: for r: int in _ROWS - 1: var cur: Array = map[r] var nxt: Array = map[r + 1] var incoming := PackedInt32Array() incoming.resize(nxt.size()) # zero-filled for from_node: MapNode in cur: var nearest := _nearest_index(from_node, nxt) _add_link(from_node, nearest, incoming) # Occasionally fan out to an adjacent next-row node so paths branch. if nxt.size() > 1 and randf() < 0.45: var step := 1 if randf() < 0.5 else -1 _add_link(from_node, clampi(nearest + step, 0, nxt.size() - 1), incoming) # Guarantee every next-row node has at least one parent (no orphans). for c: int in nxt.size(): if incoming[c] == 0: _add_link(cur[_nearest_index(nxt[c], cur)], c, incoming) func _add_link(from_node: MapNode, to_col: int, incoming: PackedInt32Array) -> void: if to_col in from_node.links: return from_node.links.append(to_col) incoming[to_col] += 1 # Index of the node in `others` whose column position is closest to `node` on the x axis — # used both to link a node forward and to adopt an orphan back to its nearest parent. func _nearest_index(node: MapNode, others: Array) -> int: var best := 0 var best_d := INF for i: int in others.size(): var d: float = absf((others[i] as MapNode).pos.x - node.pos.x) if d < best_d: best_d = d best = i return best