From d749485126cf0b3b8af3b121d1f48fb13e6b191e Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Sun, 23 Aug 2026 13:16:04 +0300 Subject: [PATCH] Map screen version --- CLAUDE.md | 4 + MapScreen.tscn | 10 ++ deploy_web.sh | 24 +++- hud.gd | 30 ++++- level_def.gd | 16 +++ level_def.gd.uid | 1 + main_menu.gd | 1 + map_node.gd | 13 ++ map_node.gd.uid | 1 + map_screen.gd | 280 ++++++++++++++++++++++++++++++++++++++++++++ map_screen.gd.uid | 1 + project.godot | 5 +- run_state.gd | 200 +++++++++++++++++++++++++++++++ run_state.gd.uid | 1 + tests/logic_test.gd | 55 +++++++++ ui_theme.gd | 17 +++ 16 files changed, 650 insertions(+), 9 deletions(-) create mode 100644 MapScreen.tscn create mode 100644 level_def.gd create mode 100644 level_def.gd.uid create mode 100644 map_node.gd create mode 100644 map_node.gd.uid create mode 100644 map_screen.gd create mode 100644 map_screen.gd.uid create mode 100644 run_state.gd create mode 100644 run_state.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index 88addd9..9036adb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,10 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen | `camera_follow.gd` | Smooth camera follow (`Camera3D`) | | `matador.gd` | Matador AI (wander / ragdoll state machine) | | `matador_spawn.gd` | Spawns N matadors at random arena positions | +| `run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights | +| `level_def.gd` | `LevelDef` resource — a level's name / scene / map colour (arena only for now) | +| `map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) | +| `MapScreen.tscn` / `map_screen.gd` | Between-fights map screen; win → pick a node → next level | | `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) | | `Assets/` | Raw 3D assets (`.glb`, `.fbx`) | | `Blender/` | Blender source files, animation scripts, FBX exports | diff --git a/MapScreen.tscn b/MapScreen.tscn new file mode 100644 index 0000000..8c0c388 --- /dev/null +++ b/MapScreen.tscn @@ -0,0 +1,10 @@ +[gd_scene format=3 uid="uid://map_screen_v1"] + +[ext_resource type="Script" path="res://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/deploy_web.sh b/deploy_web.sh index d7d086e..e4295af 100755 --- a/deploy_web.sh +++ b/deploy_web.sh @@ -34,10 +34,28 @@ commit=${GIT_COMMIT} built=${BUILD_DATE} EOF +# Cache-bust: give wasm/pck unique names so browsers never serve a stale build. +# index.js gets a ?v= query string; wasm/pck get renamed because the engine +# fetches them internally using GODOT_CONFIG.executable as the base name. +STAMP="$GIT_COMMIT" +cp "$BUILD_DIR/index.wasm" "$BUILD_DIR/index.${STAMP}.wasm" +cp "$BUILD_DIR/index.wasm.gz" "$BUILD_DIR/index.${STAMP}.wasm.gz" +cp "$BUILD_DIR/index.pck" "$BUILD_DIR/index.${STAMP}.pck" + +sed -i \ + -e "s|src=\"index\.js\"|src=\"index.js?v=${STAMP}\"|" \ + -e "s|\"executable\":\"index\"|\"executable\":\"index.${STAMP}\"|" \ + -e "s|\"index\.pck\"|\"index.${STAMP}.pck\"|g" \ + -e "s|\"index\.wasm\"|\"index.${STAMP}.wasm\"|g" \ + "$BUILD_DIR/index.html" + +# Remove previous build's versioned assets from the server before uploading. +ssh "$SERVER_USER@$SERVER_HOST" "find $SERVER_PATH -maxdepth 1 -name 'index.*.wasm' -o -name 'index.*.wasm.gz' -o -name 'index.*.pck' | xargs -r rm -f" + scp "$BUILD_DIR/index.html" \ "$BUILD_DIR/index.js" \ - "$BUILD_DIR/index.wasm.gz" \ - "$BUILD_DIR/index.pck" \ + "$BUILD_DIR/index.${STAMP}.wasm.gz" \ + "$BUILD_DIR/index.${STAMP}.pck" \ "$BUILD_DIR/index.audio.worklet.js" \ "$BUILD_DIR/index.audio.position.worklet.js" \ "$BUILD_DIR/index.icon.png" \ @@ -46,4 +64,4 @@ scp "$BUILD_DIR/index.html" \ "$BUILD_DIR/version.txt" \ "$SERVER_USER@$SERVER_HOST:$SERVER_PATH/" -echo "deployed ${VERSION} to $SERVER_HOST:$SERVER_PATH" +echo "deployed ${VERSION} (${STAMP}) to $SERVER_HOST:$SERVER_PATH" diff --git a/hud.gd b/hud.gd index c424b7d..2c5d5a9 100644 --- a/hud.gd +++ b/hud.gd @@ -71,6 +71,8 @@ var _result_win: bool = false var _over_root: Control var _over_video: VideoStreamPlayer var _over_label: Label +var _over_continue: Button +var _over_again: Button func _ready() -> void: @@ -214,7 +216,10 @@ func _find_spawner() -> void: func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]: - _restart() + if _result_win: + _continue_to_map() + else: + _restart() elif event.physical_keycode == KEY_TAB and not _game_over: _toggle_controls() elif event.physical_keycode == KEY_ESCAPE: @@ -233,6 +238,14 @@ func _restart() -> void: get_tree().reload_current_scene() +# A cleared arena advances the run: commit the win and open the map to pick the next node. +func _continue_to_map() -> void: + get_tree().paused = false + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + Run.complete_current_level() + get_tree().change_scene_to_file("res://MapScreen.tscn") + + func _toggle_controls() -> void: _controls_visible = not _controls_visible _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" @@ -311,6 +324,9 @@ func _show_game_over(win: bool, _cause: String = "") -> void: await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout _over_label.text = "Bull Won!" if _result_win else "Matadors Won!" + # A win advances the run (on to the map); a loss ends it (retry from the top). + _over_continue.visible = _result_win + _over_again.visible = not _result_win var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO if ResourceLoader.exists(path): @@ -381,9 +397,15 @@ func _build_game_over() -> void: row.offset_bottom = -48.0 _over_root.add_child(row) - var again := _make_button("Go Again (Enter)") - again.pressed.connect(_restart) - row.add_child(again) + # Win → Continue to the run map; loss → Go Again from the top. Shown per-result in + # _show_game_over; both share the Enter shortcut. + _over_continue = _make_button("Continue (Enter)") + _over_continue.pressed.connect(_continue_to_map) + row.add_child(_over_continue) + + _over_again = _make_button("Go Again (Enter)") + _over_again.pressed.connect(_restart) + row.add_child(_over_again) var menu := _make_button("Main Menu") menu.pressed.connect(_return_to_menu) diff --git a/level_def.gd b/level_def.gd new file mode 100644 index 0000000..5531416 --- /dev/null +++ b/level_def.gd @@ -0,0 +1,16 @@ +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. + +@export var id: StringName = &"" +@export var display_name: String = "Level" +## Scene loaded when the player enters a node with this level. Every node points at the +## arena for now. +@export_file("*.tscn") var scene_path: String = "" +## Placeholder pip tint on the map until per-level icon art exists. +@export var color: Color = Color(0.80, 0.62, 0.24) +## A boss level caps a run — its node is drawn larger and clearing it ends the map. +@export var is_boss: bool = false diff --git a/level_def.gd.uid b/level_def.gd.uid new file mode 100644 index 0000000..03345f5 --- /dev/null +++ b/level_def.gd.uid @@ -0,0 +1 @@ +uid://c6sef0tfl26sr diff --git a/main_menu.gd b/main_menu.gd index 8d05d79..f9a7b22 100644 --- a/main_menu.gd +++ b/main_menu.gd @@ -254,6 +254,7 @@ func _build_rebind_rows() -> void: func _on_play_pressed() -> void: _set_buttons_disabled(true) + Run.start_new_run() # fresh map for this run; the arena is the intro fight var tween := create_tween() tween.tween_property(fade_rect, "color:a", 1.0, FADE_TIME) tween.tween_callback(func() -> void: diff --git a/map_node.gd b/map_node.gd new file mode 100644 index 0000000..67e0a32 --- /dev/null +++ b/map_node.gd @@ -0,0 +1,13 @@ +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/map_node.gd.uid b/map_node.gd.uid new file mode 100644 index 0000000..d83f4ad --- /dev/null +++ b/map_node.gd.uid @@ -0,0 +1 @@ +uid://emq8i2bmkekn diff --git a/map_screen.gd b/map_screen.gd new file mode 100644 index 0000000..7cd4d31 --- /dev/null +++ b/map_screen.gd @@ -0,0 +1,280 @@ +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://MainMenu.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/map_screen.gd.uid b/map_screen.gd.uid new file mode 100644 index 0000000..fb32d96 --- /dev/null +++ b/map_screen.gd.uid @@ -0,0 +1 @@ +uid://dt3ffakbwlkmc diff --git a/project.godot b/project.godot index bfab88e..4b194fb 100644 --- a/project.godot +++ b/project.godot @@ -13,7 +13,7 @@ config_version=5 config/name="Bullosseum" config/version="0.2.0" run/main_scene="res://MainMenu.tscn" -config/features=PackedStringArray("4.7", "Forward Plus") +config/features=PackedStringArray("4.7", "GL Compatibility") boot_splash/bg_color=Color(0, 0, 0, 1) boot_splash/image="res://Assets/loading_screen.png" config/icon="uid://dct1d88g015el" @@ -27,6 +27,7 @@ Console="*res://console.gd" Controls="*res://controls_manager.gd" DebugDraw="*res://debug_draw.gd" Settings="*res://settings.gd" +Run="*res://run_state.gd" [display] @@ -120,7 +121,7 @@ pointing/emulate_touch_from_mouse=true [rendering] -rendering_device/driver.windows="d3d12" +rendering_device/driver.windows="opengl3" renderer/rendering_method="gl_compatibility" textures/vram_compression/import_s3tc_bptc=true textures/vram_compression/import_etc2_astc=true diff --git a/run_state.gd b/run_state.gd new file mode 100644 index 0000000..1e9b373 --- /dev/null +++ b/run_state.gd @@ -0,0 +1,200 @@ +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 + +const _ARENA_SCENE := "res://scene.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 + +# Level catalog. Only the arena exists today (a normal and a boss flavour, both loading +# the same scene). Future levels — cave/bear, china shop — slot in here. +var _arena: LevelDef +var _arena_boss: LevelDef + + +func _ready() -> void: + _arena = _make_level(&"arena", "The Arena", _ARENA_SCENE, Color(0.80, 0.62, 0.24), false) + _arena_boss = _make_level( + &"arena_boss", "Grand Arena", _ARENA_SCENE, Color(0.88, 0.30, 0.20), true + ) + + +func _make_level( + id: StringName, level_name: String, scene: String, color: Color, boss: bool +) -> LevelDef: + var l := LevelDef.new() + l.id = id + l.display_name = level_name + l.scene_path = scene + l.color = color + l.is_boss = boss + return l + + +## 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 + 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 + pending_row = node.row + pending_col = node.col + + +## 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) + node.level = _arena_boss if r == _ROWS - 1 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/run_state.gd.uid b/run_state.gd.uid new file mode 100644 index 0000000..fb10d05 --- /dev/null +++ b/run_state.gd.uid @@ -0,0 +1 @@ +uid://u4fartm52bk3 diff --git a/tests/logic_test.gd b/tests/logic_test.gd index 4673020..abeeebd 100644 --- a/tests/logic_test.gd +++ b/tests/logic_test.gd @@ -30,6 +30,7 @@ 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]) @@ -171,6 +172,60 @@ 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: diff --git a/ui_theme.gd b/ui_theme.gd index e930f82..38becbc 100644 --- a/ui_theme.gd +++ b/ui_theme.gd @@ -57,6 +57,23 @@ static func badge(h_margin: float = 8.0, v_margin: float = 2.0) -> StyleBoxFlat: return s +## Round token for the run map — a filled disc with an even border and the shared warm +## shadow. Unlike plaque()'s chiselled tablet the border is uniform, so it reads as a +## marker rather than a plate. A large corner radius clamps to a full circle on any pip +## size. Keeps map pips inside the single styling source instead of a hand-rolled box. +static func disc(fill: Color, border: Color, border_w: int = 3) -> StyleBoxFlat: + var s := StyleBoxFlat.new() + s.bg_color = fill + s.set_corner_radius_all(999) + s.corner_detail = 8 + s.set_border_width_all(border_w) + s.border_color = border + s.shadow_color = SHADOW + s.shadow_size = 6 + s.shadow_offset = Vector2(0.0, 3.0) + return s + + ## Thin gold rule for separating rows inside a plaque — brighter at the centre than ## the surrounding trim so it reads as an inlaid line. static func rule() -> StyleBoxFlat: