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:
+29
-189
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user