Reorganize into role-based folders and consolidate art

Move every script and scene into role-based directories (actors/, systems/, ui/,
levels/, props/) and all raw art under art/ (grouped by role, snake_case,
Extract-Textures GLBs), dropping Blender version-suffix noise. Filenames are
snake_case (PascalCase reserved for class_name) to avoid case-insensitive
filesystem aliasing on the web/macOS targets. Routes the bull's hits through the
typed DamageEvent pipeline and wires the bull's abilities to the Fx library.
Updates CLAUDE.md's folder-layout overview to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
This commit is contained in:
2026-09-08 14:56:22 +03:00
parent c0a022fed4
commit 09d4a60f43
244 changed files with 415 additions and 19337 deletions
+4 -4
View File
@@ -1,10 +1,10 @@
[gd_scene format=3 uid="uid://co4tgvdpdte4v"]
[ext_resource type="PackedScene" uid="uid://dio67r8ambe7h" path="res://Assets/arena_placeholder_v01.glb" id="2_nxogm"]
[ext_resource type="PackedScene" uid="uid://cl54dt38yj2x" path="res://Assets/Arena.glb" id="3_fy5k1"]
[ext_resource type="PackedScene" uid="uid://dio67r8ambe7h" path="res://art/levels/arena_placeholder.glb" id="2_nxogm"]
[ext_resource type="PackedScene" uid="uid://cl54dt38yj2x" path="res://art/levels/arena.glb" id="3_fy5k1"]
[ext_resource type="Script" uid="uid://dou5ar6tkmng1" path="res://levels/matador_spawn.gd" id="3_spawn"]
[ext_resource type="Script" uid="uid://70j3ks1emhkd" path="res://crowd_marker.gd" id="9_agtuu"]
[ext_resource type="Script" uid="uid://cj0ytlkuavmf4" path="res://crowd_billboards.gd" id="11_crowd"]
[ext_resource type="Script" uid="uid://70j3ks1emhkd" path="res://systems/crowd/crowd_marker.gd" id="9_agtuu"]
[ext_resource type="Script" uid="uid://cj0ytlkuavmf4" path="res://systems/crowd/crowd_billboards.gd" id="11_crowd"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ulcgi"]
+2 -2
View File
@@ -1,8 +1,8 @@
[gd_scene format=3 uid="uid://dfxigg3cr6nl5"]
[ext_resource type="PackedScene" uid="uid://bm6rmt3xprhjs" path="res://Assets/bear_level_arena.glb" id="1_beararena"]
[ext_resource type="PackedScene" uid="uid://bm6rmt3xprhjs" path="res://art/levels/bear_arena.glb" id="1_beararena"]
[ext_resource type="Script" uid="uid://bbtlwihtlc1wp" path="res://levels/bear_spawn.gd" id="2_spawn"]
[ext_resource type="PackedScene" uid="uid://o8ocjfprxa7u" path="res://Bear.tscn" id="3_bear"]
[ext_resource type="PackedScene" uid="uid://o8ocjfprxa7u" path="res://actors/bear/bear.tscn" id="3_bear"]
[ext_resource type="Script" uid="uid://dfk7dcvki7rqu" path="res://levels/bear_hud.gd" id="4_bearhud"]
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
+1 -1
View File
@@ -4,7 +4,7 @@ extends Node3D
## spawners, lighting, colliders) into LevelRoot. Switching levels frees the whole
## previous module first, so no old mesh or collider can survive into the next fight.
const PhysicsLayers = preload("res://physics_layers.gd")
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
@onready var _level_root: Node3D = $LevelRoot
@onready var _camera: IsoCamera = $Player/Camera3D
+116
View File
@@ -0,0 +1,116 @@
[gd_scene format=3 uid="uid://b7afbx36ltb3f"]
[ext_resource type="PackedScene" uid="uid://crtm0xtiql3r8" path="res://actors/player/player.tscn" id="1_ulcgi"]
[ext_resource type="Shader" uid="uid://d3xyyy5bh0ma" path="res://systems/render/ps1_filter.gdshader" id="4_ps1shader"]
[ext_resource type="Script" uid="uid://butd6b3tdwxrv" path="res://ui/hud.gd" id="10_hud"]
[ext_resource type="Script" uid="uid://ce5n4qu88mhyx" path="res://systems/render/ps1_filter.gd" id="12_ps1ctl"]
[ext_resource type="Script" uid="uid://ptsfs3wq5bje" path="res://levels/game_shell.gd" id="13_shell"]
[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_73fnb"]
dof_blur_far_enabled = true
dof_blur_far_distance = 8192.0
dof_blur_far_transition = 8192.0
dof_blur_near_enabled = true
dof_blur_near_distance = 8192.0
dof_blur_near_transition = 8192.0
dof_blur_amount = 1.0
[sub_resource type="ShaderMaterial" id="ShaderMaterial_ps1"]
shader = ExtResource("4_ps1shader")
shader_parameter/pixel_scale = 4.0
shader_parameter/color_depth = 5.0
shader_parameter/dither_strength = 0.8
shader_parameter/scanline_strength = 0.2
shader_parameter/roll_speed = 3.0
shader_parameter/roll_strength = 1.0
[node name="scene" type="Node3D" unique_id=118294681]
script = ExtResource("13_shell")
[node name="Player" parent="." unique_id=332720469 instance=ExtResource("1_ulcgi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.2416196, 5.1711025, -4.841382)
[node name="Camera3D" parent="Player" index="1" unique_id=1120161087]
attributes = SubResource("CameraAttributesPractical_73fnb")
fov = 50.0
near = 2.636
far = 0.01
[node name="Skeleton3D" parent="Player/bull/Armature" parent_id_path=PackedInt32Array(332720469, 1386495166, 1942410469) index="0" unique_id=550138996]
bones/0/position = Vector3(0, 0, 0.004288793)
bones/0/rotation = Quaternion(0.71960354, 0, 0, 0.6943852)
bones/0/scale = Vector3(1.3083478, 0.6738599, 1.3077359)
bones/1/position = Vector3(0.003526625, -0.0043296535, 0.004082799)
bones/1/rotation = Quaternion(-0.00038647576, 0.06909673, 0.9868542, -0.14609681)
bones/1/scale = Vector3(1.0000021, 1.0000001, 1.0000006)
bones/2/position = Vector3(-5.853537e-11, 0.002669535, -1.489375e-10)
bones/2/rotation = Quaternion(-0.06929122, 3.2415448e-07, -0.11267061, 0.99121344)
bones/2/scale = Vector3(1, 1.0000001, 0.9999999)
bones/4/position = Vector3(0.004535823, -0.0021376952, -0.0042386237)
bones/4/rotation = Quaternion(-0.24948508, 0.19590344, 0.9416978, -0.11217982)
bones/4/scale = Vector3(0.99999994, 0.99999994, 1.0000002)
bones/5/position = Vector3(1.264555e-10, 0.0028599252, -2.4303243e-10)
bones/5/rotation = Quaternion(-0.58943665, -0.36607245, -0.2256448, 0.683842)
bones/6/position = Vector3(-7.092306e-11, 0.003274492, 4.6413304e-10)
bones/6/rotation = Quaternion(0.52910197, 0.15997829, 0.12024502, 0.82462066)
bones/6/scale = Vector3(1, 1.0000001, 1.0000001)
bones/8/position = Vector3(2.259811e-19, 0.0010177285, -0.008495503)
bones/8/rotation = Quaternion(-0.70710677, 0, 0, 0.70710677)
bones/8/scale = Vector3(1, 1, 1)
bones/9/position = Vector3(4.814825e-35, 0.0017711426, 0)
bones/9/scale = Vector3(1, 1, 1)
bones/10/position = Vector3(4.814825e-35, 0.0018082206, 0)
bones/10/scale = Vector3(1, 0.99999994, 1)
bones/11/position = Vector3(0, 0.0019911951, 0)
bones/11/rotation = Quaternion(0, 0, 0, 1)
bones/11/scale = Vector3(1, 1, 1)
bones/12/position = Vector3(0, 0.002023485, 0)
bones/12/rotation = Quaternion(0, 0, 0, 1)
bones/12/scale = Vector3(1, 1, 1)
bones/13/position = Vector3(0, 0.0016682985, 0)
bones/13/rotation = Quaternion(0, 0, 0, 1)
bones/13/scale = Vector3(1, 1, 1)
bones/14/position = Vector3(0, 0.0014960878, 0)
bones/14/rotation = Quaternion(0, 0, 0, 1)
bones/14/scale = Vector3(1, 1, 1)
bones/15/position = Vector3(0, 0.0010996431, 0)
bones/15/scale = Vector3(1, 0.99999994, 1)
bones/17/position = Vector3(0, 0.000937381, 0.0075526168)
bones/17/rotation = Quaternion(-5.3385083e-08, 0.70710677, 0.70710677, -5.3385083e-08)
bones/17/scale = Vector3(1, 1, 1)
bones/19/position = Vector3(-0.003526625, -0.0043296535, 0.004082799)
bones/19/rotation = Quaternion(0.00038647576, 0.06909673, 0.9868542, 0.14609681)
bones/19/scale = Vector3(1.0000021, 1.0000001, 1.0000006)
bones/20/position = Vector3(5.853537e-11, 0.002669535, -1.489375e-10)
bones/20/rotation = Quaternion(-0.06929122, -3.2415448e-07, 0.11267061, 0.99121344)
bones/20/scale = Vector3(1, 1.0000001, 0.9999999)
bones/22/position = Vector3(-0.004535823, -0.0021376952, -0.0042386237)
bones/22/rotation = Quaternion(0.24948508, 0.19590344, 0.9416978, 0.11217982)
bones/22/scale = Vector3(0.99999994, 0.99999994, 1.0000002)
bones/23/position = Vector3(-1.264555e-10, 0.0028599252, -2.4303243e-10)
bones/23/rotation = Quaternion(-0.58943665, 0.36607245, 0.2256448, 0.683842)
bones/23/scale = Vector3(1, 1, 1)
bones/24/position = Vector3(7.092306e-11, 0.003274492, 4.6413304e-10)
bones/24/rotation = Quaternion(0.52910197, -0.15997829, -0.12024502, 0.82462066)
bones/24/scale = Vector3(1, 1.0000001, 1.0000001)
[node name="HUD" type="CanvasLayer" parent="." unique_id=475750379]
script = ExtResource("10_hud")
[node name="CanvasLayer" type="CanvasLayer" parent="." unique_id=998765394]
layer = 10
[node name="PS1Filter" type="ColorRect" parent="CanvasLayer" unique_id=1660776144]
material = SubResource("ShaderMaterial_ps1")
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("12_ps1ctl")
metadata/_edit_use_anchors_ = true
[node name="LevelRoot" type="Node3D" parent="." unique_id=5023050]
[editable path="Player"]
[editable path="Player/bull"]
+3 -8
View File
@@ -1,9 +1,8 @@
class_name LevelDef
extends Resource
## Definition of one playable level on the run map — the arena today, later a cave with
## a bear, a china shop to smash, and so on. Every map node references a LevelDef, and
## the map/flow code never hard-codes a scene: adding a level is a new LevelDef (plus its
## scene) handed out by the map generator in run_state.gd.
## Definition of one playable level — the arena, the bear's den, the lobby hub, and later
## a china shop to smash. The flow code never hard-codes a scene: a level is a LevelDef
## (plus its scene) built in run_state.gd and served through the lobby.
@export var id: StringName = &""
@export var display_name: String = "Level"
@@ -15,14 +14,10 @@ extends Resource
## colliders and any level-specific subscripts. Swapped wholesale between levels so no
## previous level's geometry (or its colliders) can leak into the next.
@export var level_scene: PackedScene = null
## Placeholder pip tint on the map until per-level icon art exists.
@export var color: Color = Color(0.80, 0.62, 0.24)
## Camera framing multiplier when this level loads — 1.0 is the shell camera's default
## shot; >1 dollies the camera back for a wider view (the lobby pulls out so the whole
## hub reads). Applied by game_shell; independent of the player's scroll-wheel zoom.
@export var camera_zoom: float = 1.0
## A boss level caps a run — its node is drawn larger and clearing it ends the map.
@export var is_boss: bool = false
## The enemy this level pits the bull against. Null → the usual matador wave (the
## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g.
## the bear), and the fight is won when it falls. The arena scene is shared either way;
+4 -4
View File
@@ -1,9 +1,9 @@
[gd_scene format=3 uid="uid://32nyipcwrab5"]
[ext_resource type="PackedScene" uid="uid://bbfvwolhomilp" path="res://tscn_s/torch.tscn" id="1_w7uyf"]
[ext_resource type="PackedScene" uid="uid://bd56iuttyiypv" path="res://tscn_s/BarrelSimple.tscn" id="2_3o0md"]
[ext_resource type="PackedScene" uid="uid://dctmlh4hnuux1" path="res://Assets/Lobby_underground.glb" id="2_w7uyf"]
[ext_resource type="Script" uid="uid://cwbf7jfkr8j10" path="res://Assets/lobby_underground.gd" id="4_73452"]
[ext_resource type="PackedScene" uid="uid://bbfvwolhomilp" path="res://props/torch.tscn" id="1_w7uyf"]
[ext_resource type="PackedScene" uid="uid://bd56iuttyiypv" path="res://props/barrel_simple.tscn" id="2_3o0md"]
[ext_resource type="PackedScene" uid="uid://dctmlh4hnuux1" path="res://art/levels/lobby.glb" id="2_w7uyf"]
[ext_resource type="Script" uid="uid://cwbf7jfkr8j10" path="res://levels/lobby_underground.gd" id="4_73452"]
[ext_resource type="Script" uid="uid://dn77mjyxllgl6" path="res://levels/to_the_bear_level.gd" id="5_6j2sa"]
[ext_resource type="Script" uid="uid://cgwliuslwfmbk" path="res://levels/to_the_arena_level.gd" id="6_jmi3x"]
+15
View File
@@ -0,0 +1,15 @@
extends Node3D
## Lobby geometry — watches the run state and raises the bear-gate grid once the arena has
## been cleared. The DP flag stays as a designer override for testing the animation.
var bear_gate_open := false
func _process(_delta: float) -> void:
if bear_gate_open:
return
if Run.bear_gate_open() or DP.b("gate_unlocked_bear"):
bear_gate_open = true
$AnimationPlayer.play("Gate_opening")
if DP.b("gamepad_vibration"):
Input.start_joy_vibration(0, 0.5, .5, 7)
+1
View File
@@ -0,0 +1 @@
uid://cwbf7jfkr8j10
+7 -7
View File
@@ -3,7 +3,7 @@ extends Node3D
signal matador_killed
signal all_defeated
const MATADOR := preload("res://Matador.tscn")
const MATADOR := preload("res://actors/matador/matador.tscn")
var _alive: int = 0
var _wave: int = 0
@@ -34,25 +34,25 @@ func _spawn() -> void:
# Matadors are assigned to a random subset of spawn points each wave and enter staggered.
func _start_next_wave() -> void:
_wave += 1
# 8 waves total.
if _wave > DP.f("mat_wave_size"):
return
# Wave 1 = 1 matador, Wave 2 = 2, ... Wave 8 = 8.
var count := _wave
# Shuffle the spawn points so the spawn locations are random.
var points: Array[Node3D] = _spawn_points.duplicate()
points.shuffle()
for i in range(count):
# Reuse spawn points when we have more matadors than points.
var sp: Node3D = points[i % points.size()]
var m := _spawn_at(sp.global_position)
m.call_deferred(&"enter_spawn_roll", Vector3.ZERO)
if i < count - 1:
await get_tree().create_timer(
DP.f("mat_spawn_stagger")
+29 -189
View File
@@ -1,37 +1,22 @@
extends Node
## Persistent run progression across scene changes — the Slay-the-Spire-style map and
## where the player sits on it. The arena scene is reloaded for every fight, so this
## state must outlive scene changes: genuinely global run state earns an autoload (`Run`).
## Persistent run progression across scene changes — which hub/level the player is in.
## The level scene is reloaded for every fight, so this state must outlive scene changes:
## genuinely global run state earns an autoload (`Run`).
##
## Flow: menu Play → start_new_run() → arena (intro fight) → win → MapScreen shows the
## first frontier → pick a node → its level scene → win → back to MapScreen advanced one
## row, and on until a boss node ends the map. Every node is the arena for now; adding a
## level later is a new LevelDef in the catalog and pointing the generator at it.
## Flow: menu Play → start_new_run() → lobby hub → enter a gate → that level → win →
## return_to_lobby() (clearing a level opens its follow-on gates, e.g. clearing the arena
## unlocks the bear). Adding a level is a new LevelDef in the catalog plus a lobby gate.
signal run_started
# Every level loads the same reusable shell (player, camera, HUD, PS1 filter); the shell
# then instances the level's own `level_scene` module into its LevelRoot.
const _SHELL_SCENE := "res://scene.tscn"
const _SHELL_SCENE := "res://levels/game_shell.tscn"
const _ARENA_LEVEL := preload("res://levels/arena_level.tscn")
const _BEAR_LEVEL := preload("res://levels/bear_level.tscn")
const _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn")
# Map shape. Small + placeholder for now; the final row is a single boss node.
const _ROWS := 5
const _MIN_PER_ROW := 2
const _MAX_PER_ROW := 4
## map[row] is an Array[MapNode]; empty until a run starts.
var map: Array = []
## Node the player currently occupies. (-1, -1) = the start gate below the first row —
## i.e. straight after the intro fight, before any map node has been chosen.
var player_row: int = -1
var player_col: int = -1
## Node picked on the map whose level is being played now; committed to on the next win.
var pending_row: int = -1
var pending_col: int = -1
## Debug override — when set, active_level() returns this regardless of map state.
## Debug override — when set, active_level() returns this regardless of run state.
## Set via the console `level` command; cleared on start_new_run().
var debug_level_override: LevelDef = null
## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead
@@ -48,12 +33,15 @@ var cleared: Dictionary = {}
## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP.
var bonus_hp: int = 0
const BONUS_HP_CAP: int = 2
## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run
## when the shell reloads for each fight.
var _run_active: bool = false
# Level catalog. Every level shares the shell scene; what changes is the level module
# (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss
# flavours) and the bear (a lone boss enemy that can turn up as a normal node) live here.
# Future levels — china shop — slot in the same way: a new module + a LevelDef entry.
const _BEAR_SCENE := preload("res://Bear.tscn")
# flavours) and the bear (a lone boss enemy) live here. Future levels — china shop — slot
# in the same way: a new module + a LevelDef entry.
const _BEAR_SCENE := preload("res://actors/bear/bear.tscn")
var _arena: LevelDef
var _arena_boss: LevelDef
@@ -62,33 +50,27 @@ var _lobby: LevelDef
func _ready() -> void:
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL, Color(0.80, 0.62, 0.24), false)
_arena_boss = _make_level(
&"arena_boss", "Grand Arena", _ARENA_LEVEL, Color(0.88, 0.30, 0.20), true
)
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL, Color(0.55, 0.38, 0.22), false)
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL)
_arena_boss = _make_level(&"arena_boss", "Grand Arena", _ARENA_LEVEL)
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL)
_bear.enemy_scene = _BEAR_SCENE
# The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose.
_lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL, Color(0.62, 0.66, 0.72), false)
_lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL)
# It's a roomy hub with nothing to fight — pull the camera back so more of it reads.
_lobby.camera_zoom = 1.6
func _make_level(
id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool
) -> LevelDef:
func _make_level(id: StringName, level_name: String, level_scene: PackedScene) -> LevelDef:
var l := LevelDef.new()
l.id = id
l.display_name = level_name
l.scene_path = _SHELL_SCENE
l.level_scene = level_scene
l.color = color
l.is_boss = boss
return l
## The level being fought right now: debug override wins, then the pending/current map
## node, falling back to the plain arena for the intro fight or a direct scene launch.
## The level being served right now: debug override wins, then the lobby hub or the level
## picked at a gate, falling back to the plain arena for a direct scene launch.
func active_level() -> LevelDef:
if debug_level_override != null:
return debug_level_override
@@ -96,10 +78,7 @@ func active_level() -> LevelDef:
return _lobby
if lobby_choice != null:
return lobby_choice
var n := node_at(pending_row, pending_col)
if n == null:
n = current_node()
return n.level if n != null and n.level != null else _arena
return _arena
## Return a level by id, or null if unknown. Used by the debug console `level` command.
@@ -117,80 +96,27 @@ func level_ids() -> Array[StringName]:
return [&"arena", &"arena_boss", &"bear", &"lobby"]
## Begin a fresh run: build a new map and place the player at the start gate. Called from
## the main-menu Play button, not from the arena scene (which reloads for every fight).
## Begin a fresh run: reset progression and open in the lobby hub. Called from the main-menu
## Play button, not from a level scene (which reloads for every fight).
func start_new_run() -> void:
_generate_map()
player_row = -1
player_col = -1
pending_row = -1
pending_col = -1
debug_level_override = null
# The run opens in the lobby hub; its gates lead into the fights.
enter_lobby = true
lobby_choice = null
cleared = {}
bonus_hp = 0
_run_active = true
run_started.emit()
## Build a run if none exists — a safety net for entering the map or arena directly (e.g.
## running scene.tscn straight from the editor) without coming through the menu.
## Build a run if none is active — a safety net for entering a level directly (e.g. running
## game_shell.tscn straight from the editor) without coming through the menu. Guarded so the
## shell reloading for each fight doesn't wipe the run in progress.
func ensure_run() -> void:
if map.is_empty():
if not _run_active:
start_new_run()
func has_run() -> bool:
return not map.is_empty()
func node_at(row: int, col: int) -> MapNode:
if row < 0 or row >= map.size():
return null
var r: Array = map[row]
if col < 0 or col >= r.size():
return null
return r[col]
func current_node() -> MapNode:
return node_at(player_row, player_col)
## Nodes the player may pick next: the whole first row from the start gate, otherwise the
## nodes the current node links forward to.
func reachable() -> Array:
if map.is_empty():
return []
if player_row < 0:
return (map[0] as Array).duplicate()
var here := current_node()
if here == null or player_row + 1 >= map.size():
return []
var out: Array = []
for c: int in here.links:
var n := node_at(player_row + 1, c)
if n != null:
out.append(n)
return out
## True once the player has cleared a boss node — the map is finished.
func run_complete() -> bool:
var here := current_node()
return here != null and here.level != null and here.level.is_boss
## Mark a node as the one being played (its level scene is about to load).
func select_node(node: MapNode) -> void:
if node == null:
return
enter_lobby = false
pending_row = node.row
pending_col = node.col
## Send the player to the lobby hub instead of a fight — active_level() serves the lobby
## until a gate is entered.
func go_to_lobby() -> void:
@@ -228,89 +154,3 @@ func add_bonus_hp(amount: int) -> int:
var before := bonus_hp
bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP)
return bonus_hp - before
## Commit the level just won: move the player onto the pending node (or leave them at the
## start gate after the intro fight, which has no pending node).
func complete_current_level() -> void:
if pending_row >= 0:
player_row = pending_row
player_col = pending_col
var n := current_node()
if n != null:
n.cleared = true
pending_row = -1
pending_col = -1
# ── Map generation ────────────────────────────────────────────────────────────
# A small branching lattice: each row gets a handful of nodes, each node links to the
# nearest node(s) in the next row, and every next-row node is guaranteed a parent so the
# path is always connected (no orphan the player can never reach). The final row is a
# single boss node the whole map funnels toward.
func _generate_map() -> void:
map.clear()
for r: int in _ROWS:
var count := 1 if r == _ROWS - 1 else randi_range(_MIN_PER_ROW, _MAX_PER_ROW)
var y := 1.0 - float(r + 1) / float(_ROWS + 1) # row 0 near the bottom, boss on top
var row_nodes: Array = []
for c: int in count:
var node := MapNode.new()
node.row = r
node.col = c
node.pos = Vector2(_column_x(c, count), y)
# Boss row is always the Grand Arena; normal nodes are a mix of matador
# arenas and the occasional Bear's Den for variety.
if r == _ROWS - 1:
node.level = _arena_boss
else:
node.level = _bear if randf() < 0.35 else _arena
row_nodes.append(node)
map.append(row_nodes)
_link_rows()
func _column_x(col: int, count: int) -> float:
if count <= 1:
return 0.5
return lerpf(0.18, 0.82, float(col) / float(count - 1))
func _link_rows() -> void:
for r: int in _ROWS - 1:
var cur: Array = map[r]
var nxt: Array = map[r + 1]
var incoming := PackedInt32Array()
incoming.resize(nxt.size()) # zero-filled
for from_node: MapNode in cur:
var nearest := _nearest_index(from_node, nxt)
_add_link(from_node, nearest, incoming)
# Occasionally fan out to an adjacent next-row node so paths branch.
if nxt.size() > 1 and randf() < 0.45:
var step := 1 if randf() < 0.5 else -1
_add_link(from_node, clampi(nearest + step, 0, nxt.size() - 1), incoming)
# Guarantee every next-row node has at least one parent (no orphans).
for c: int in nxt.size():
if incoming[c] == 0:
_add_link(cur[_nearest_index(nxt[c], cur)], c, incoming)
func _add_link(from_node: MapNode, to_col: int, incoming: PackedInt32Array) -> void:
if to_col in from_node.links:
return
from_node.links.append(to_col)
incoming[to_col] += 1
# Index of the node in `others` whose column position is closest to `node` on the x axis —
# used both to link a node forward and to adopt an orphan back to its nearest parent.
func _nearest_index(node: MapNode, others: Array) -> int:
var best := 0
var best_d := INF
for i: int in others.size():
var d: float = absf((others[i] as MapNode).pos.x - node.pos.x)
if d < best_d:
best_d = d
best = i
return best