Map screen version

This commit is contained in:
2026-08-23 13:16:04 +03:00
parent 45427b7070
commit d749485126
16 changed files with 650 additions and 9 deletions
+4
View File
@@ -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`) | | `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
| `matador.gd` | Matador AI (wander / ragdoll state machine) | | `matador.gd` | Matador AI (wander / ragdoll state machine) |
| `matador_spawn.gd` | Spawns N matadors at random arena positions | | `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`) | | `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) |
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) | | `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
| `Blender/` | Blender source files, animation scripts, FBX exports | | `Blender/` | Blender source files, animation scripts, FBX exports |
+10
View File
@@ -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")
+21 -3
View File
@@ -34,10 +34,28 @@ commit=${GIT_COMMIT}
built=${BUILD_DATE} built=${BUILD_DATE}
EOF 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" \ scp "$BUILD_DIR/index.html" \
"$BUILD_DIR/index.js" \ "$BUILD_DIR/index.js" \
"$BUILD_DIR/index.wasm.gz" \ "$BUILD_DIR/index.${STAMP}.wasm.gz" \
"$BUILD_DIR/index.pck" \ "$BUILD_DIR/index.${STAMP}.pck" \
"$BUILD_DIR/index.audio.worklet.js" \ "$BUILD_DIR/index.audio.worklet.js" \
"$BUILD_DIR/index.audio.position.worklet.js" \ "$BUILD_DIR/index.audio.position.worklet.js" \
"$BUILD_DIR/index.icon.png" \ "$BUILD_DIR/index.icon.png" \
@@ -46,4 +64,4 @@ scp "$BUILD_DIR/index.html" \
"$BUILD_DIR/version.txt" \ "$BUILD_DIR/version.txt" \
"$SERVER_USER@$SERVER_HOST:$SERVER_PATH/" "$SERVER_USER@$SERVER_HOST:$SERVER_PATH/"
echo "deployed ${VERSION} to $SERVER_HOST:$SERVER_PATH" echo "deployed ${VERSION} (${STAMP}) to $SERVER_HOST:$SERVER_PATH"
+26 -4
View File
@@ -71,6 +71,8 @@ var _result_win: bool = false
var _over_root: Control var _over_root: Control
var _over_video: VideoStreamPlayer var _over_video: VideoStreamPlayer
var _over_label: Label var _over_label: Label
var _over_continue: Button
var _over_again: Button
func _ready() -> void: func _ready() -> void:
@@ -214,7 +216,10 @@ func _find_spawner() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo: if event is InputEventKey and event.pressed and not event.echo:
if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]: 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: elif event.physical_keycode == KEY_TAB and not _game_over:
_toggle_controls() _toggle_controls()
elif event.physical_keycode == KEY_ESCAPE: elif event.physical_keycode == KEY_ESCAPE:
@@ -233,6 +238,14 @@ func _restart() -> void:
get_tree().reload_current_scene() 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: func _toggle_controls() -> void:
_controls_visible = not _controls_visible _controls_visible = not _controls_visible
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" _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 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!" _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 var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO
if ResourceLoader.exists(path): if ResourceLoader.exists(path):
@@ -381,9 +397,15 @@ func _build_game_over() -> void:
row.offset_bottom = -48.0 row.offset_bottom = -48.0
_over_root.add_child(row) _over_root.add_child(row)
var again := _make_button("Go Again (Enter)") # Win → Continue to the run map; loss → Go Again from the top. Shown per-result in
again.pressed.connect(_restart) # _show_game_over; both share the Enter shortcut.
row.add_child(again) _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") var menu := _make_button("Main Menu")
menu.pressed.connect(_return_to_menu) menu.pressed.connect(_return_to_menu)
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c6sef0tfl26sr
+1
View File
@@ -254,6 +254,7 @@ func _build_rebind_rows() -> void:
func _on_play_pressed() -> void: func _on_play_pressed() -> void:
_set_buttons_disabled(true) _set_buttons_disabled(true)
Run.start_new_run() # fresh map for this run; the arena is the intro fight
var tween := create_tween() var tween := create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, FADE_TIME) tween.tween_property(fade_rect, "color:a", 1.0, FADE_TIME)
tween.tween_callback(func() -> void: tween.tween_callback(func() -> void:
+13
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://emq8i2bmkekn
+280
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dt3ffakbwlkmc
+3 -2
View File
@@ -13,7 +13,7 @@ config_version=5
config/name="Bullosseum" config/name="Bullosseum"
config/version="0.2.0" config/version="0.2.0"
run/main_scene="res://MainMenu.tscn" 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/bg_color=Color(0, 0, 0, 1)
boot_splash/image="res://Assets/loading_screen.png" boot_splash/image="res://Assets/loading_screen.png"
config/icon="uid://dct1d88g015el" config/icon="uid://dct1d88g015el"
@@ -27,6 +27,7 @@ Console="*res://console.gd"
Controls="*res://controls_manager.gd" Controls="*res://controls_manager.gd"
DebugDraw="*res://debug_draw.gd" DebugDraw="*res://debug_draw.gd"
Settings="*res://settings.gd" Settings="*res://settings.gd"
Run="*res://run_state.gd"
[display] [display]
@@ -120,7 +121,7 @@ pointing/emulate_touch_from_mouse=true
[rendering] [rendering]
rendering_device/driver.windows="d3d12" rendering_device/driver.windows="opengl3"
renderer/rendering_method="gl_compatibility" renderer/rendering_method="gl_compatibility"
textures/vram_compression/import_s3tc_bptc=true textures/vram_compression/import_s3tc_bptc=true
textures/vram_compression/import_etc2_astc=true textures/vram_compression/import_etc2_astc=true
+200
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://u4fartm52bk3
+55
View File
@@ -30,6 +30,7 @@ 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])
@@ -171,6 +172,60 @@ 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:
+17
View File
@@ -57,6 +57,23 @@ static func badge(h_margin: float = 8.0, v_margin: float = 2.0) -> StyleBoxFlat:
return s 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 ## Thin gold rule for separating rows inside a plaque — brighter at the centre than
## the surrounding trim so it reads as an inlaid line. ## the surrounding trim so it reads as an inlaid line.
static func rule() -> StyleBoxFlat: static func rule() -> StyleBoxFlat: