diff --git a/CLAUDE.md b/CLAUDE.md index 106cfe2..afe93d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_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 | -| `levels/run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights | -| `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/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 | +| `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 / `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) | | `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/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 | diff --git a/levels/MapScreen.tscn b/levels/MapScreen.tscn deleted file mode 100644 index 6c29183..0000000 --- a/levels/MapScreen.tscn +++ /dev/null @@ -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") diff --git a/levels/level_def.gd b/levels/level_def.gd index 95276a8..87ea380 100644 --- a/levels/level_def.gd +++ b/levels/level_def.gd @@ -1,9 +1,8 @@ class_name LevelDef extends Resource -## Definition of one playable level on the run map — the arena today, later a cave with -## a bear, a china shop to smash, and so on. Every map node references a LevelDef, and -## the map/flow code never hard-codes a scene: adding a level is a new LevelDef (plus its -## scene) handed out by the map generator in run_state.gd. +## Definition of one playable level — the arena, the bear's den, the lobby hub, and later +## a china shop to smash. The flow code never hard-codes a scene: a level is a LevelDef +## (plus its scene) built in run_state.gd and served through the lobby. @export var id: StringName = &"" @export var display_name: String = "Level" @@ -15,14 +14,10 @@ extends Resource ## colliders and any level-specific subscripts. Swapped wholesale between levels so no ## previous level's geometry (or its colliders) can leak into the next. @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 ## 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. @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 ## 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; diff --git a/levels/map_node.gd b/levels/map_node.gd deleted file mode 100644 index 67e0a32..0000000 --- a/levels/map_node.gd +++ /dev/null @@ -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 diff --git a/levels/map_node.gd.uid b/levels/map_node.gd.uid deleted file mode 100644 index d83f4ad..0000000 --- a/levels/map_node.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://emq8i2bmkekn diff --git a/levels/map_screen.gd b/levels/map_screen.gd deleted file mode 100644 index 847696b..0000000 --- a/levels/map_screen.gd +++ /dev/null @@ -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 diff --git a/levels/map_screen.gd.uid b/levels/map_screen.gd.uid deleted file mode 100644 index fb32d96..0000000 --- a/levels/map_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dt3ffakbwlkmc diff --git a/levels/run_state.gd b/levels/run_state.gd index fab6b16..f5fe743 100644 --- a/levels/run_state.gd +++ b/levels/run_state.gd @@ -1,12 +1,11 @@ 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`). +## Persistent run progression across scene changes — which hub/level the player is in. +## The level 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. +## Flow: menu Play → start_new_run() → lobby hub → enter a gate → that level → win → +## return_to_lobby() (clearing a level opens its follow-on gates, e.g. clearing the arena +## unlocks the bear). Adding a level is a new LevelDef in the catalog plus a lobby gate. 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 _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. +## Debug override — when set, active_level() returns this regardless of run state. ## Set via the console `level` command; cleared on start_new_run(). var debug_level_override: LevelDef = null ## 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. var bonus_hp: int = 0 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 # (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. +# flavours) and the bear (a lone boss enemy) live here. Future levels — china shop — slot +# in the same way: a new module + a LevelDef entry. const _BEAR_SCENE := preload("res://actors/bear/bear.tscn") var _arena: LevelDef @@ -62,33 +50,27 @@ 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) + _arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL) + _arena_boss = _make_level(&"arena_boss", "Grand Arena", _ARENA_LEVEL) + _bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL) _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) + _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. _lobby.camera_zoom = 1.6 -func _make_level( - id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool -) -> LevelDef: +func _make_level(id: StringName, level_name: String, level_scene: PackedScene) -> 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. +## The level being served right now: debug override wins, then the lobby hub or the level +## picked at a gate, falling back to the plain arena for a direct scene launch. func active_level() -> LevelDef: if debug_level_override != null: return debug_level_override @@ -96,10 +78,7 @@ func active_level() -> LevelDef: return _lobby if lobby_choice != null: return lobby_choice - 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 _arena ## 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"] -## 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). +## Begin a fresh run: reset progression and open in the lobby hub. Called from the main-menu +## Play button, not from a level 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 # The run opens in the lobby hub; its gates lead into the fights. enter_lobby = true lobby_choice = null cleared = {} bonus_hp = 0 + _run_active = true 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. +## Build a run if none is active — a safety net for entering a level directly (e.g. running +## 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: - if map.is_empty(): + if not _run_active: 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 gate is entered. func go_to_lobby() -> void: @@ -228,89 +154,3 @@ func add_bonus_hp(amount: int) -> int: var before := bonus_hp bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP) 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 diff --git a/tests/logic_test.gd b/tests/logic_test.gd index c7ac15f..0bdd17c 100644 --- a/tests/logic_test.gd +++ b/tests/logic_test.gd @@ -30,7 +30,6 @@ func _run() -> void: test_kick_spread() test_gait_phase_crossing() test_tail_verlet_constraint() - test_run_map() print("=" .repeat(60)) 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") -# ── 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 ──────────────────────────────────────────────────────────── func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void: