Remove the dead Slay-the-Spire map system (superseded by the lobby)
The lobby hub replaced the between-fights map, leaving the whole map subsystem unused. Remove it: - Delete MapScreen.tscn + map_screen.gd (the dead screen) and map_node.gd (the MapNode class). - Strip map generation + navigation from run_state.gd (~90 lines: _generate_map/_link_rows/reachable/select_node/node_at/current_node/ run_complete/complete_current_level, plus map/player_row/col/pending_row/col). ensure_run() now guards on a _run_active flag instead of the map array, so the shell reloading for each fight still can't wipe the run in progress. - Simplify active_level() to the lobby flow (override -> lobby -> gate -> arena). - Drop the now-unread LevelDef.color / is_boss fields and the _make_level args. - Remove the map test from logic_test.gd. Live flow unchanged (lobby gates via choose_level, wins via return_to_lobby). Full headless suite green; menu + shell boot into the lobby clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FBbGXbzg3SD6ZBSadPPF5H
This commit is contained in:
@@ -52,10 +52,8 @@ art/ all raw art, grouped by role (snake_case):
|
|||||||
| `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
|
| `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
|
||||||
| `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) |
|
| `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) |
|
||||||
| `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher |
|
| `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher |
|
||||||
| `levels/run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights |
|
| `levels/run_state.gd` | Run progression autoload (`Run`) — the lobby hub and which level is being fought, across scene reloads |
|
||||||
| `levels/level_def.gd` | `LevelDef` resource — a level's name / map colour / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
|
| `levels/level_def.gd` | `LevelDef` resource — a level's name / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
|
||||||
| `levels/map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) |
|
|
||||||
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
|
|
||||||
| `systems/crowd/crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
|
| `systems/crowd/crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
|
||||||
| `systems/crowd/crowd_billboards.gd` / `systems/crowd/crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
|
| `systems/crowd/crowd_billboards.gd` / `systems/crowd/crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
|
||||||
| `systems/debug/debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `systems/input/touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `systems/render/rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag |
|
| `systems/debug/debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `systems/input/touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `systems/render/rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag |
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
[gd_scene format=3 uid="uid://map_screen_v1"]
|
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://levels/map_screen.gd" id="1_map"]
|
|
||||||
|
|
||||||
[node name="MapScreen" type="Control"]
|
|
||||||
layout_mode = 3
|
|
||||||
anchors_preset = 15
|
|
||||||
anchor_right = 1.0
|
|
||||||
anchor_bottom = 1.0
|
|
||||||
script = ExtResource("1_map")
|
|
||||||
+3
-8
@@ -1,9 +1,8 @@
|
|||||||
class_name LevelDef
|
class_name LevelDef
|
||||||
extends Resource
|
extends Resource
|
||||||
## Definition of one playable level on the run map — the arena today, later a cave with
|
## Definition of one playable level — the arena, the bear's den, the lobby hub, and later
|
||||||
## a bear, a china shop to smash, and so on. Every map node references a LevelDef, and
|
## a china shop to smash. The flow code never hard-codes a scene: a level is a LevelDef
|
||||||
## the map/flow code never hard-codes a scene: adding a level is a new LevelDef (plus its
|
## (plus its scene) built in run_state.gd and served through the lobby.
|
||||||
## scene) handed out by the map generator in run_state.gd.
|
|
||||||
|
|
||||||
@export var id: StringName = &""
|
@export var id: StringName = &""
|
||||||
@export var display_name: String = "Level"
|
@export var display_name: String = "Level"
|
||||||
@@ -15,14 +14,10 @@ extends Resource
|
|||||||
## colliders and any level-specific subscripts. Swapped wholesale between levels so no
|
## colliders and any level-specific subscripts. Swapped wholesale between levels so no
|
||||||
## previous level's geometry (or its colliders) can leak into the next.
|
## previous level's geometry (or its colliders) can leak into the next.
|
||||||
@export var level_scene: PackedScene = null
|
@export var level_scene: PackedScene = null
|
||||||
## Placeholder pip tint on the map until per-level icon art exists.
|
|
||||||
@export var color: Color = Color(0.80, 0.62, 0.24)
|
|
||||||
## Camera framing multiplier when this level loads — 1.0 is the shell camera's default
|
## Camera framing multiplier when this level loads — 1.0 is the shell camera's default
|
||||||
## shot; >1 dollies the camera back for a wider view (the lobby pulls out so the whole
|
## shot; >1 dollies the camera back for a wider view (the lobby pulls out so the whole
|
||||||
## hub reads). Applied by game_shell; independent of the player's scroll-wheel zoom.
|
## hub reads). Applied by game_shell; independent of the player's scroll-wheel zoom.
|
||||||
@export var camera_zoom: float = 1.0
|
@export var camera_zoom: float = 1.0
|
||||||
## A boss level caps a run — its node is drawn larger and clearing it ends the map.
|
|
||||||
@export var is_boss: bool = false
|
|
||||||
## The enemy this level pits the bull against. Null → the usual matador wave (the
|
## The enemy this level pits the bull against. Null → the usual matador wave (the
|
||||||
## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g.
|
## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g.
|
||||||
## the bear), and the fight is won when it falls. The arena scene is shared either way;
|
## the bear), and the fight is won when it falls. The arena scene is shared either way;
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
class_name MapNode
|
|
||||||
extends RefCounted
|
|
||||||
## One node on the run map. Regenerated every run and never persisted, so RefCounted
|
|
||||||
## rather than Resource. `pos` is normalized (0-1) so the map screen can lay the node out
|
|
||||||
## at any resolution; `links` are column indices in the *next* row this node connects to
|
|
||||||
## (the Slay-the-Spire branching path).
|
|
||||||
|
|
||||||
var row: int = 0
|
|
||||||
var col: int = 0
|
|
||||||
var pos: Vector2 = Vector2.ZERO
|
|
||||||
var level: LevelDef = null
|
|
||||||
var links: PackedInt32Array = PackedInt32Array()
|
|
||||||
var cleared: bool = false
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://emq8i2bmkekn
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
extends Control
|
|
||||||
## Slay-the-Spire-style run map shown between fights. Placeholder art — coloured pips
|
|
||||||
## joined by paths — but fully interactive: the player picks one of the reachable nodes
|
|
||||||
## and is sent into that level. Every node is the arena for now (see run_state.gd); the
|
|
||||||
## screen is level-agnostic, so richer levels just need a LevelDef with its own colour.
|
|
||||||
|
|
||||||
const _GOLD := Color(1.00, 0.78, 0.22)
|
|
||||||
const _CREAM := Color(0.93, 0.88, 0.72)
|
|
||||||
const _EDGE_DIM := Color(0.45, 0.36, 0.18, 0.55)
|
|
||||||
const _EDGE_ACTIVE := Color(1.00, 0.82, 0.32, 0.95)
|
|
||||||
|
|
||||||
const _NODE_SIZE := 58.0
|
|
||||||
const _BOSS_SIZE := 88.0
|
|
||||||
|
|
||||||
const _MENU_SCENE := "res://ui/main_menu.tscn"
|
|
||||||
|
|
||||||
var _map_area: Control
|
|
||||||
var _completion: PanelContainer
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
||||||
get_tree().paused = false
|
|
||||||
Run.ensure_run()
|
|
||||||
_build_ui()
|
|
||||||
_relayout.call_deferred() # defer so _map_area has its real size first
|
|
||||||
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
|
||||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
|
|
||||||
var bg := ColorRect.new()
|
|
||||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
bg.color = Color(0.09, 0.06, 0.03) # opaque leather, no scene showing through
|
|
||||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
add_child(bg)
|
|
||||||
|
|
||||||
var title := Label.new()
|
|
||||||
title.text = "Choose Your Path"
|
|
||||||
title.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
|
||||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
||||||
title.offset_top = 22.0
|
|
||||||
title.add_theme_font_override("font", UiFonts.title())
|
|
||||||
title.add_theme_font_size_override("font_size", 46)
|
|
||||||
title.add_theme_color_override("font_color", _GOLD)
|
|
||||||
title.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
|
||||||
title.add_theme_constant_override("outline_size", 6)
|
|
||||||
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
add_child(title)
|
|
||||||
|
|
||||||
_map_area = Control.new()
|
|
||||||
_map_area.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
_map_area.offset_top = 108.0
|
|
||||||
_map_area.offset_bottom = -92.0
|
|
||||||
_map_area.offset_left = 48.0
|
|
||||||
_map_area.offset_right = -48.0
|
|
||||||
_map_area.clip_contents = false
|
|
||||||
add_child(_map_area)
|
|
||||||
_map_area.resized.connect(_relayout)
|
|
||||||
|
|
||||||
var menu_btn := _make_button("Main Menu")
|
|
||||||
menu_btn.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
|
||||||
menu_btn.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
|
||||||
menu_btn.offset_left = 32.0
|
|
||||||
menu_btn.offset_bottom = -26.0
|
|
||||||
menu_btn.pressed.connect(_to_menu)
|
|
||||||
add_child(menu_btn)
|
|
||||||
|
|
||||||
_build_completion()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Layout ──────────────────────────────────────────────────────────────────
|
|
||||||
# Rebuilt from the run state on first show and whenever the map area resizes. Edges are
|
|
||||||
# added first so the node pips draw on top of the paths.
|
|
||||||
|
|
||||||
func _relayout() -> void:
|
|
||||||
if _map_area == null:
|
|
||||||
return
|
|
||||||
for child in _map_area.get_children():
|
|
||||||
child.queue_free()
|
|
||||||
var size := _map_area.size
|
|
||||||
if size.x <= 0.0 or size.y <= 0.0:
|
|
||||||
return
|
|
||||||
|
|
||||||
var reachable := Run.reachable()
|
|
||||||
var reach_set := {}
|
|
||||||
for n: MapNode in reachable:
|
|
||||||
reach_set[n] = true
|
|
||||||
|
|
||||||
var here := Run.current_node()
|
|
||||||
if Run.player_row < 0:
|
|
||||||
var start_pos := Vector2(size.x * 0.5, size.y - 8.0)
|
|
||||||
_add_start_pip(start_pos)
|
|
||||||
for n: MapNode in reachable:
|
|
||||||
_add_edge(start_pos, _local(n, size), true)
|
|
||||||
for row: Array in Run.map:
|
|
||||||
for from_node: MapNode in row:
|
|
||||||
for to_col: int in from_node.links:
|
|
||||||
var to_node := Run.node_at(from_node.row + 1, to_col)
|
|
||||||
if to_node == null:
|
|
||||||
continue
|
|
||||||
var active := from_node == here and reach_set.has(to_node)
|
|
||||||
_add_edge(_local(from_node, size), _local(to_node, size), active)
|
|
||||||
|
|
||||||
for row: Array in Run.map:
|
|
||||||
for node: MapNode in row:
|
|
||||||
_add_node(node, size, reach_set.has(node))
|
|
||||||
|
|
||||||
_completion.visible = Run.run_complete() or reachable.is_empty()
|
|
||||||
|
|
||||||
|
|
||||||
func _local(node: MapNode, size: Vector2) -> Vector2:
|
|
||||||
return Vector2(node.pos.x * size.x, node.pos.y * size.y)
|
|
||||||
|
|
||||||
|
|
||||||
func _add_edge(a: Vector2, b: Vector2, active: bool) -> void:
|
|
||||||
var line := Line2D.new()
|
|
||||||
line.add_point(a)
|
|
||||||
line.add_point(b)
|
|
||||||
line.width = 4.0 if active else 2.5
|
|
||||||
line.default_color = _EDGE_ACTIVE if active else _EDGE_DIM
|
|
||||||
line.antialiased = true
|
|
||||||
_map_area.add_child(line)
|
|
||||||
|
|
||||||
|
|
||||||
func _add_start_pip(center: Vector2) -> void:
|
|
||||||
var pip := PanelContainer.new()
|
|
||||||
var sz := 22.0
|
|
||||||
pip.custom_minimum_size = Vector2(sz, sz)
|
|
||||||
pip.size = Vector2(sz, sz)
|
|
||||||
pip.position = center - Vector2(sz, sz) * 0.5
|
|
||||||
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
pip.add_theme_stylebox_override("panel", UiTheme.disc(_GOLD, _CREAM, 2))
|
|
||||||
_map_area.add_child(pip)
|
|
||||||
|
|
||||||
|
|
||||||
func _add_node(node: MapNode, size: Vector2, is_reachable: bool) -> void:
|
|
||||||
var is_current := node.row == Run.player_row and node.col == Run.player_col
|
|
||||||
var sz := _BOSS_SIZE if node.level.is_boss else _NODE_SIZE
|
|
||||||
var center := _local(node, size)
|
|
||||||
|
|
||||||
var btn := Button.new()
|
|
||||||
btn.custom_minimum_size = Vector2(sz, sz)
|
|
||||||
btn.size = Vector2(sz, sz)
|
|
||||||
btn.position = center - Vector2(sz, sz) * 0.5
|
|
||||||
btn.focus_mode = Control.FOCUS_NONE
|
|
||||||
btn.disabled = not is_reachable
|
|
||||||
btn.mouse_filter = Control.MOUSE_FILTER_STOP if is_reachable else Control.MOUSE_FILTER_IGNORE
|
|
||||||
|
|
||||||
var fill := node.level.color
|
|
||||||
var border := UiTheme.BORDER
|
|
||||||
if is_current:
|
|
||||||
border = _CREAM
|
|
||||||
elif is_reachable:
|
|
||||||
border = _GOLD
|
|
||||||
elif node.cleared:
|
|
||||||
fill = fill.darkened(0.35)
|
|
||||||
border = Color(_GOLD.r, _GOLD.g, _GOLD.b, 0.5)
|
|
||||||
else: # locked — a future row not yet reachable
|
|
||||||
fill = fill.darkened(0.6)
|
|
||||||
border = Color(0.40, 0.34, 0.20, 0.6)
|
|
||||||
|
|
||||||
var border_w := 4 if (is_reachable or is_current) else 2
|
|
||||||
var normal := UiTheme.disc(fill, border, border_w)
|
|
||||||
var hover := UiTheme.disc(fill.lightened(0.18), _CREAM, border_w)
|
|
||||||
btn.add_theme_stylebox_override("normal", normal)
|
|
||||||
btn.add_theme_stylebox_override("hover", hover)
|
|
||||||
btn.add_theme_stylebox_override("pressed", hover)
|
|
||||||
btn.add_theme_stylebox_override("disabled", normal)
|
|
||||||
_map_area.add_child(btn)
|
|
||||||
|
|
||||||
if is_reachable:
|
|
||||||
btn.pressed.connect(_enter_node.bind(node))
|
|
||||||
_pulse(btn)
|
|
||||||
|
|
||||||
if is_reachable or is_current or node.level.is_boss:
|
|
||||||
_add_caption(node.level.display_name, center + Vector2(0.0, sz * 0.5 + 6.0), is_reachable)
|
|
||||||
if is_current:
|
|
||||||
_add_caption("You are here", center - Vector2(0.0, sz * 0.5 + 18.0), false)
|
|
||||||
|
|
||||||
|
|
||||||
func _add_caption(text: String, center_top: Vector2, bright: bool) -> void:
|
|
||||||
var lbl := Label.new()
|
|
||||||
lbl.text = text
|
|
||||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
||||||
lbl.size = Vector2(200.0, 0.0)
|
|
||||||
lbl.position = center_top - Vector2(100.0, 0.0)
|
|
||||||
lbl.add_theme_font_override("font", UiFonts.body())
|
|
||||||
lbl.add_theme_font_size_override("font_size", 15)
|
|
||||||
lbl.add_theme_color_override("font_color", _CREAM if bright else UiTheme.MUTED)
|
|
||||||
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
|
||||||
lbl.add_theme_constant_override("outline_size", 4)
|
|
||||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
_map_area.add_child(lbl)
|
|
||||||
|
|
||||||
|
|
||||||
# Slow glow on the reachable pips so the eye lands on where the player can go next.
|
|
||||||
func _pulse(node: CanvasItem) -> void:
|
|
||||||
var tw := create_tween().set_loops()
|
|
||||||
tw.tween_property(node, "modulate:a", 0.6, 0.75).set_trans(Tween.TRANS_SINE)
|
|
||||||
tw.tween_property(node, "modulate:a", 1.0, 0.75).set_trans(Tween.TRANS_SINE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Actions ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _enter_node(node: MapNode) -> void:
|
|
||||||
Run.select_node(node)
|
|
||||||
get_tree().paused = false
|
|
||||||
get_tree().change_scene_to_file(node.level.scene_path)
|
|
||||||
|
|
||||||
|
|
||||||
func _to_menu() -> void:
|
|
||||||
get_tree().paused = false
|
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
||||||
get_tree().change_scene_to_file(_MENU_SCENE)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Run-complete overlay ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _build_completion() -> void:
|
|
||||||
_completion = PanelContainer.new()
|
|
||||||
_completion.set_anchors_preset(Control.PRESET_CENTER)
|
|
||||||
_completion.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
|
||||||
_completion.grow_vertical = Control.GROW_DIRECTION_BOTH
|
|
||||||
_completion.add_theme_stylebox_override(
|
|
||||||
"panel", UiTheme.plaque(UiTheme.LEATHER, _GOLD, 4, 32.0, 24.0)
|
|
||||||
)
|
|
||||||
_completion.visible = false
|
|
||||||
add_child(_completion)
|
|
||||||
|
|
||||||
var col := VBoxContainer.new()
|
|
||||||
col.add_theme_constant_override("separation", 16)
|
|
||||||
col.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
||||||
_completion.add_child(col)
|
|
||||||
|
|
||||||
var heading := Label.new()
|
|
||||||
heading.text = "Run Complete!"
|
|
||||||
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
||||||
heading.add_theme_font_override("font", UiFonts.title())
|
|
||||||
heading.add_theme_font_size_override("font_size", 40)
|
|
||||||
heading.add_theme_color_override("font_color", _GOLD)
|
|
||||||
heading.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
|
||||||
heading.add_theme_constant_override("outline_size", 5)
|
|
||||||
col.add_child(heading)
|
|
||||||
|
|
||||||
var row := HBoxContainer.new()
|
|
||||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
||||||
row.add_theme_constant_override("separation", 16)
|
|
||||||
col.add_child(row)
|
|
||||||
|
|
||||||
var again := _make_button("New Run")
|
|
||||||
again.pressed.connect(_new_run)
|
|
||||||
row.add_child(again)
|
|
||||||
|
|
||||||
var menu := _make_button("Main Menu")
|
|
||||||
menu.pressed.connect(_to_menu)
|
|
||||||
row.add_child(menu)
|
|
||||||
|
|
||||||
|
|
||||||
func _new_run() -> void:
|
|
||||||
Run.start_new_run()
|
|
||||||
_relayout()
|
|
||||||
|
|
||||||
|
|
||||||
# Shared thematic button, matching the HUD / menu plaques.
|
|
||||||
func _make_button(text: String) -> Button:
|
|
||||||
var btn := Button.new()
|
|
||||||
btn.text = text
|
|
||||||
btn.focus_mode = Control.FOCUS_NONE
|
|
||||||
btn.add_theme_stylebox_override(
|
|
||||||
"normal", UiTheme.plaque(Color(0.10, 0.07, 0.03, 0.90), _GOLD, 3, 18.0, 9.0)
|
|
||||||
)
|
|
||||||
var hover := UiTheme.plaque(Color(0.16, 0.10, 0.04, 0.96), _CREAM, 3, 18.0, 9.0)
|
|
||||||
btn.add_theme_stylebox_override("hover", hover)
|
|
||||||
btn.add_theme_stylebox_override("pressed", hover)
|
|
||||||
btn.add_theme_font_override("font", UiFonts.body())
|
|
||||||
btn.add_theme_color_override("font_color", _GOLD)
|
|
||||||
btn.add_theme_color_override("font_hover_color", _CREAM)
|
|
||||||
btn.add_theme_font_size_override("font_size", 18)
|
|
||||||
return btn
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dt3ffakbwlkmc
|
|
||||||
+27
-187
@@ -1,12 +1,11 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Persistent run progression across scene changes — the Slay-the-Spire-style map and
|
## Persistent run progression across scene changes — which hub/level the player is in.
|
||||||
## where the player sits on it. The arena scene is reloaded for every fight, so this
|
## The level scene is reloaded for every fight, so this state must outlive scene changes:
|
||||||
## state must outlive scene changes: genuinely global run state earns an autoload (`Run`).
|
## genuinely global run state earns an autoload (`Run`).
|
||||||
##
|
##
|
||||||
## Flow: menu Play → start_new_run() → arena (intro fight) → win → MapScreen shows the
|
## Flow: menu Play → start_new_run() → lobby hub → enter a gate → that level → win →
|
||||||
## first frontier → pick a node → its level scene → win → back to MapScreen advanced one
|
## return_to_lobby() (clearing a level opens its follow-on gates, e.g. clearing the arena
|
||||||
## row, and on until a boss node ends the map. Every node is the arena for now; adding a
|
## unlocks the bear). Adding a level is a new LevelDef in the catalog plus a lobby gate.
|
||||||
## level later is a new LevelDef in the catalog and pointing the generator at it.
|
|
||||||
|
|
||||||
signal run_started
|
signal run_started
|
||||||
|
|
||||||
@@ -17,21 +16,7 @@ const _ARENA_LEVEL := preload("res://levels/arena_level.tscn")
|
|||||||
const _BEAR_LEVEL := preload("res://levels/bear_level.tscn")
|
const _BEAR_LEVEL := preload("res://levels/bear_level.tscn")
|
||||||
const _LOBBY_LEVEL := preload("res://levels/lobby_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.
|
## Debug override — when set, active_level() returns this regardless of run state.
|
||||||
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().
|
## Set via the console `level` command; cleared on start_new_run().
|
||||||
var debug_level_override: LevelDef = null
|
var debug_level_override: LevelDef = null
|
||||||
## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead
|
## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead
|
||||||
@@ -48,11 +33,14 @@ var cleared: Dictionary = {}
|
|||||||
## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP.
|
## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP.
|
||||||
var bonus_hp: int = 0
|
var bonus_hp: int = 0
|
||||||
const BONUS_HP_CAP: int = 2
|
const BONUS_HP_CAP: int = 2
|
||||||
|
## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run
|
||||||
|
## when the shell reloads for each fight.
|
||||||
|
var _run_active: bool = false
|
||||||
|
|
||||||
# Level catalog. Every level shares the shell scene; what changes is the level module
|
# 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
|
# (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.
|
# flavours) and the bear (a lone boss enemy) live here. Future levels — china shop — slot
|
||||||
# Future levels — china shop — slot in the same way: a new module + a LevelDef entry.
|
# in the same way: a new module + a LevelDef entry.
|
||||||
const _BEAR_SCENE := preload("res://actors/bear/bear.tscn")
|
const _BEAR_SCENE := preload("res://actors/bear/bear.tscn")
|
||||||
|
|
||||||
var _arena: LevelDef
|
var _arena: LevelDef
|
||||||
@@ -62,33 +50,27 @@ var _lobby: LevelDef
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL, Color(0.80, 0.62, 0.24), false)
|
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL)
|
||||||
_arena_boss = _make_level(
|
_arena_boss = _make_level(&"arena_boss", "Grand Arena", _ARENA_LEVEL)
|
||||||
&"arena_boss", "Grand Arena", _ARENA_LEVEL, Color(0.88, 0.30, 0.20), true
|
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL)
|
||||||
)
|
|
||||||
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL, Color(0.55, 0.38, 0.22), false)
|
|
||||||
_bear.enemy_scene = _BEAR_SCENE
|
_bear.enemy_scene = _BEAR_SCENE
|
||||||
# The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose.
|
# 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)
|
_lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL)
|
||||||
# It's a roomy hub with nothing to fight — pull the camera back so more of it reads.
|
# It's a roomy hub with nothing to fight — pull the camera back so more of it reads.
|
||||||
_lobby.camera_zoom = 1.6
|
_lobby.camera_zoom = 1.6
|
||||||
|
|
||||||
|
|
||||||
func _make_level(
|
func _make_level(id: StringName, level_name: String, level_scene: PackedScene) -> LevelDef:
|
||||||
id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool
|
|
||||||
) -> LevelDef:
|
|
||||||
var l := LevelDef.new()
|
var l := LevelDef.new()
|
||||||
l.id = id
|
l.id = id
|
||||||
l.display_name = level_name
|
l.display_name = level_name
|
||||||
l.scene_path = _SHELL_SCENE
|
l.scene_path = _SHELL_SCENE
|
||||||
l.level_scene = level_scene
|
l.level_scene = level_scene
|
||||||
l.color = color
|
|
||||||
l.is_boss = boss
|
|
||||||
return l
|
return l
|
||||||
|
|
||||||
|
|
||||||
## The level being fought right now: debug override wins, then the pending/current map
|
## The level being served right now: debug override wins, then the lobby hub or the level
|
||||||
## node, falling back to the plain arena for the intro fight or a direct scene launch.
|
## picked at a gate, falling back to the plain arena for a direct scene launch.
|
||||||
func active_level() -> LevelDef:
|
func active_level() -> LevelDef:
|
||||||
if debug_level_override != null:
|
if debug_level_override != null:
|
||||||
return debug_level_override
|
return debug_level_override
|
||||||
@@ -96,10 +78,7 @@ func active_level() -> LevelDef:
|
|||||||
return _lobby
|
return _lobby
|
||||||
if lobby_choice != null:
|
if lobby_choice != null:
|
||||||
return lobby_choice
|
return lobby_choice
|
||||||
var n := node_at(pending_row, pending_col)
|
return _arena
|
||||||
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.
|
## Return a level by id, or null if unknown. Used by the debug console `level` command.
|
||||||
@@ -117,80 +96,27 @@ func level_ids() -> Array[StringName]:
|
|||||||
return [&"arena", &"arena_boss", &"bear", &"lobby"]
|
return [&"arena", &"arena_boss", &"bear", &"lobby"]
|
||||||
|
|
||||||
|
|
||||||
## Begin a fresh run: build a new map and place the player at the start gate. Called from
|
## Begin a fresh run: reset progression and open in the lobby hub. Called from the main-menu
|
||||||
## the main-menu Play button, not from the arena scene (which reloads for every fight).
|
## Play button, not from a level scene (which reloads for every fight).
|
||||||
func start_new_run() -> void:
|
func start_new_run() -> void:
|
||||||
_generate_map()
|
|
||||||
player_row = -1
|
|
||||||
player_col = -1
|
|
||||||
pending_row = -1
|
|
||||||
pending_col = -1
|
|
||||||
debug_level_override = null
|
debug_level_override = null
|
||||||
# The run opens in the lobby hub; its gates lead into the fights.
|
# The run opens in the lobby hub; its gates lead into the fights.
|
||||||
enter_lobby = true
|
enter_lobby = true
|
||||||
lobby_choice = null
|
lobby_choice = null
|
||||||
cleared = {}
|
cleared = {}
|
||||||
bonus_hp = 0
|
bonus_hp = 0
|
||||||
|
_run_active = true
|
||||||
run_started.emit()
|
run_started.emit()
|
||||||
|
|
||||||
|
|
||||||
## Build a run if none exists — a safety net for entering the map or arena directly (e.g.
|
## Build a run if none is active — a safety net for entering a level directly (e.g. running
|
||||||
## running scene.tscn straight from the editor) without coming through the menu.
|
## game_shell.tscn straight from the editor) without coming through the menu. Guarded so the
|
||||||
|
## shell reloading for each fight doesn't wipe the run in progress.
|
||||||
func ensure_run() -> void:
|
func ensure_run() -> void:
|
||||||
if map.is_empty():
|
if not _run_active:
|
||||||
start_new_run()
|
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
|
## Send the player to the lobby hub instead of a fight — active_level() serves the lobby
|
||||||
## until a gate is entered.
|
## until a gate is entered.
|
||||||
func go_to_lobby() -> void:
|
func go_to_lobby() -> void:
|
||||||
@@ -228,89 +154,3 @@ func add_bonus_hp(amount: int) -> int:
|
|||||||
var before := bonus_hp
|
var before := bonus_hp
|
||||||
bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP)
|
bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP)
|
||||||
return bonus_hp - before
|
return bonus_hp - before
|
||||||
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ func _run() -> void:
|
|||||||
test_kick_spread()
|
test_kick_spread()
|
||||||
test_gait_phase_crossing()
|
test_gait_phase_crossing()
|
||||||
test_tail_verlet_constraint()
|
test_tail_verlet_constraint()
|
||||||
test_run_map()
|
|
||||||
|
|
||||||
print("=" .repeat(60))
|
print("=" .repeat(60))
|
||||||
print("Results: %d passed, %d failed" % [_passed, _failed])
|
print("Results: %d passed, %d failed" % [_passed, _failed])
|
||||||
@@ -172,60 +171,6 @@ func test_tail_verlet_constraint() -> void:
|
|||||||
_assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable")
|
_assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable")
|
||||||
|
|
||||||
|
|
||||||
# ── Run map (Slay-the-Spire progression) ──────────────────────────────────────
|
|
||||||
|
|
||||||
func test_run_map() -> void:
|
|
||||||
print("\n-- test_run_map --")
|
|
||||||
var run: Node = root.get_node_or_null("/root/Run")
|
|
||||||
if run == null:
|
|
||||||
_assert_true(false, "Run autoload should exist")
|
|
||||||
return
|
|
||||||
|
|
||||||
run.start_new_run()
|
|
||||||
_assert_true(run.has_run(), "start_new_run builds a map")
|
|
||||||
_assert_true(run.map.size() >= 2, "map has multiple rows")
|
|
||||||
|
|
||||||
# Final row is a single boss node the map funnels toward.
|
|
||||||
var last: Array = run.map[run.map.size() - 1]
|
|
||||||
_assert_eq(last.size(), 1, "final row is a single boss node")
|
|
||||||
_assert_true(last[0].level.is_boss, "final node is a boss level")
|
|
||||||
|
|
||||||
# Connectivity: every node links forward, and every next-row node has a parent.
|
|
||||||
for r: int in run.map.size() - 1:
|
|
||||||
var nxt: Array = run.map[r + 1]
|
|
||||||
var reached := {}
|
|
||||||
for from_node in run.map[r]:
|
|
||||||
_assert_true(from_node.links.size() >= 1, "row %d node links forward" % r)
|
|
||||||
for c: int in from_node.links:
|
|
||||||
_assert_true(c >= 0 and c < nxt.size(), "link column in range")
|
|
||||||
reached[c] = true
|
|
||||||
for c: int in nxt.size():
|
|
||||||
_assert_true(reached.has(c), "row %d col %d has a parent" % [r + 1, c])
|
|
||||||
|
|
||||||
# From the start gate the whole first row is the frontier.
|
|
||||||
_assert_eq(run.reachable().size(), run.map[0].size(), "start frontier is row 0")
|
|
||||||
|
|
||||||
# Advancing follows the chosen node's links.
|
|
||||||
var first = run.map[0][0]
|
|
||||||
run.select_node(first)
|
|
||||||
run.complete_current_level()
|
|
||||||
_assert_eq(run.player_row, 0, "player advanced to row 0")
|
|
||||||
_assert_true(first.cleared, "cleared node is flagged")
|
|
||||||
_assert_eq(run.reachable().size(), first.links.size(),
|
|
||||||
"frontier follows the chosen node's links")
|
|
||||||
|
|
||||||
# Walking to a boss node latches run completion.
|
|
||||||
var guard := 0
|
|
||||||
while not run.run_complete() and guard < 64:
|
|
||||||
var opts: Array = run.reachable()
|
|
||||||
if opts.is_empty():
|
|
||||||
break
|
|
||||||
run.select_node(opts[0])
|
|
||||||
run.complete_current_level()
|
|
||||||
guard += 1
|
|
||||||
_assert_true(run.run_complete(), "reaching a boss node completes the run")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Shared helpers ────────────────────────────────────────────────────────────
|
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void:
|
func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void:
|
||||||
|
|||||||
Reference in New Issue
Block a user