Map screen version
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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,6 +216,9 @@ 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]:
|
||||||
|
if _result_win:
|
||||||
|
_continue_to_map()
|
||||||
|
else:
|
||||||
_restart()
|
_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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c6sef0tfl26sr
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://emq8i2bmkekn
|
||||||
+280
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dt3ffakbwlkmc
|
||||||
+3
-2
@@ -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
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://u4fartm52bk3
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user