Reorganize the codebase into role-based folders (snake_case)

Move the 40-odd root-level scripts and scenes into a tree that groups by role,
per the approved roadmap (WS2/WS3): actors/{player,matador,bear}, systems/{combat,
fx,input,debug,render,crowd,config}, ui/, props/ (was tscn_s/). Rename the shell
scene.tscn -> levels/game_shell.tscn and every PascalCase scene to snake_case
(Player.tscn -> player.tscn, MainMenu.tscn -> main_menu.tscn, etc.) so the
case-insensitive web/macOS filesystems can't alias two files together.

Done as a scripted move: each file travels with its .uid; every quoted res:// path
across scenes, scripts, .tres, and project.godot's 9 autoloads is rewritten; the
.godot uid/class cache is rebuilt from scratch. Scenes still resolve by uid, so the
rewrite is belt-and-suspenders. Lint globs (run_tests.sh + both CI workflows) now
walk the new dirs; an orphan animation_tree.gd.uid and 7 pre-existing trailing-
whitespace lints (barrel/torch, never covered by the old root-only glob) are cleaned.

Full suite green on both sides. Asset consolidation (Assets/Animations/fonts ->
art/) is deferred — moving imported .glb/.png/.ttf is safer done in the editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
This commit is contained in:
2026-09-06 00:04:41 +03:00
parent b839252fe0
commit 7b6a141767
141 changed files with 199 additions and 158 deletions
+6
View File
@@ -0,0 +1,6 @@
extends AnimationPlayer
func _ready() -> void:
speed_scale = randf_range(0.5, 1.5)
play("ArmatureAction")
+1
View File
@@ -0,0 +1 @@
uid://b6gdjsr6fcwc2
+35
View File
@@ -0,0 +1,35 @@
shader_type spatial;
// Flat crowd sprite. Each instance is oriented at build time to face the arena centre
// (see crowd_billboards.gd) — NOT the camera — so spectators on different segments of
// the ring face inward toward the action, like a real stand. Unshaded (the sheet was
// baked with lighting), alpha-tested (no transparency sorting across hundreds of
// quads), nearest-filtered for a crisp PS1 look that matches the ps1_filter effect.
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_opaque;
uniform sampler2D sheet : source_color, filter_nearest;
uniform int hframes = 8;
uniform float fps = 10.0;
uniform float alpha_clip = 0.4;
varying flat float v_frame;
varying flat vec3 v_tint;
void vertex() {
// Per-instance clap phase (0..1) and tint are packed into the MultiMesh custom data.
// The instance transform already carries the inward facing, so no billboard maths.
float phase = INSTANCE_CUSTOM.x;
v_tint = INSTANCE_CUSTOM.yzw;
v_frame = floor(mod(TIME * fps + phase * float(hframes), float(hframes)));
}
void fragment() {
// Sheet is two rows: top = front, bottom = back. Show the back when the camera is
// behind the spectator (they're facing into the arena, away from us).
float u = (UV.x + v_frame) / float(hframes);
float v = UV.y * 0.5 + (FRONT_FACING ? 0.0 : 0.5);
vec4 c = texture(sheet, vec2(u, v));
if (c.a < alpha_clip) {
discard;
}
ALBEDO = c.rgb * v_tint;
}
@@ -0,0 +1 @@
uid://hkdivctavovb
+119
View File
@@ -0,0 +1,119 @@
extends MultiMeshInstance3D
## A whole stand of cheering spectators rendered as flat billboards in a single draw
## call — the performant replacement for dozens of skinned crowd skeletons. The sprite
## sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd.
##
## Placement comes from CrowdMarker nodes the designer drops into the level (collected
## via the &"crowd_marker" group): each marker gives one spectator its world position and
## a chosen facing (one of six 60°-spaced headings). If no markers are present the scene
## falls back to a generated tribune so it still populates.
const SHEET_PATH := "res://Assets/crowd_clap_sheet.png"
const SHADER_PATH := "res://systems/crowd/crowd_billboard.gdshader"
## World height of a spectator billboard, in metres. Sheet aspect sets the width.
@export var figure_height: float = 2.0
@export var frames: int = 8
@export var clap_fps: float = 10.0
## Subtle per-spectator brightness spread so the crowd doesn't read as clones.
@export_range(0.0, 0.5) var tint_variation: float = 0.18
# Fallback tribune (used only when source_path resolves to nothing) — concentric arcs
# of seats ringing the arena.
@export_group("Fallback stand")
@export var fallback_count: int = 120
@export var fallback_inner_radius: float = 26.0
@export var fallback_rows: int = 6
@export var fallback_row_rise: float = 1.6
@export var fallback_row_step: float = 2.2
@export var fallback_seat_step: float = 2.4
var _rng := RandomNumberGenerator.new()
func _ready() -> void:
_rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs
# Deferred so every CrowdMarker has run _ready and joined the group before we harvest.
_build.call_deferred()
func _build() -> void:
var sheet := load(SHEET_PATH) as Texture2D
if sheet == null:
push_warning("crowd_billboards: missing sheet %s — run tools/bake_crowd_sheet.gd" % SHEET_PATH)
return
# Sheet holds `frames` columns and 2 rows (front / back), so a cell is half-height.
var aspect := (float(sheet.get_width()) / float(frames)) / (float(sheet.get_height()) / 2.0)
var quad := QuadMesh.new()
quad.size = Vector2(figure_height * aspect, figure_height)
quad.center_offset = Vector3(0.0, figure_height * 0.5, 0.0) # pivot at the feet
var mat := ShaderMaterial.new()
mat.shader = load(SHADER_PATH) as Shader
mat.set_shader_parameter("sheet", sheet)
mat.set_shader_parameter("hframes", frames)
mat.set_shader_parameter("fps", clap_fps)
material_override = mat
# Each seat is a (global feet-position, unit forward) pair.
var positions := PackedVector3Array()
var facings: Array[Vector3] = []
_collect_seats(positions, facings)
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_custom_data = true
mm.mesh = quad
mm.instance_count = positions.size()
for i in positions.size():
var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety
mm.set_instance_transform(i, Transform3D(_upright_basis(facings[i], s), positions[i]))
var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation)
mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98))
multimesh = mm
# Upright basis whose forward (+Z, the quad's face) points along `look`, uniformly
# scaled by `s`. World +Y stays up so spectators never tilt.
func _upright_basis(look: Vector3, s: float) -> Basis:
look.y = 0.0
look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, 1.0)
var up := Vector3.UP
var basis := Basis()
basis.x = up.cross(look).normalized()
basis.y = up
basis.z = look
return basis.scaled(Vector3.ONE * s)
# Seats from designer-placed CrowdMarker nodes: each contributes its world position and
# its chosen 60°-spaced facing. With no markers present, fall back to a generated ring
# that faces inward so the scene still shows a crowd.
func _collect_seats(positions: PackedVector3Array, facings: Array[Vector3]) -> void:
for node in get_tree().get_nodes_in_group(&"crowd_marker"):
var marker := node as Node3D
if marker == null or not marker.has_method(&"facing_dir"):
continue
positions.append(marker.global_position)
facings.append(marker.facing_dir())
if positions.is_empty():
_fallback_stand(positions, facings)
func _fallback_stand(positions: PackedVector3Array, facings: Array[Vector3]) -> void:
var placed := 0
for row in fallback_rows:
var radius := fallback_inner_radius + float(row) * fallback_row_step
var y := float(row) * fallback_row_rise
var circumference := TAU * radius
var seats_in_row := int(circumference / fallback_seat_step)
for s in seats_in_row:
if placed >= fallback_count:
return
var ang := TAU * float(s) / float(seats_in_row)
var pos := Vector3(cos(ang) * radius, y, sin(ang) * radius)
positions.append(pos)
facings.append(-pos) # face the ring centre (origin)
placed += 1
+1
View File
@@ -0,0 +1 @@
uid://cj0ytlkuavmf4
+89
View File
@@ -0,0 +1,89 @@
@tool
extends Node3D
class_name CrowdMarker
## A designer-placed spot for one billboard spectator. Drop these into a level in the
## editor; crowd_billboards.gd harvests every marker (via the &"crowd_marker" group) into
## its MultiMesh at runtime, using each marker's global position and chosen facing.
##
## The marker draws a lightweight preview (a slab standing in for the sprite + a forward
## arrow) so its position and orientation are visible in the viewport. That preview is
## editor-only and never serialised into the scene, so it costs nothing at runtime.
## The six selectable facings, one every 60° about the Y axis. DEG_0 faces the quad's
## front (+Z) along world +Z; increasing values rotate clockwise looking down.
enum Orientation {
DEG_0,
DEG_60,
DEG_120,
DEG_180,
DEG_240,
DEG_300,
}
const ORIENTATION_STEPS := 6
## Metres — matches crowd_billboards.figure_height so the editor preview reads true to scale.
const PREVIEW_HEIGHT := 2.0
@export var orientation := Orientation.DEG_0:
set(value):
orientation = value
_apply_orientation()
var _preview: Node3D
func _ready() -> void:
add_to_group(&"crowd_marker")
if Engine.is_editor_hint():
_build_preview()
_apply_orientation()
# Facing about Y for the current orientation: 0, 60, 120 … degrees as radians.
func facing_yaw() -> float:
return float(orientation) * (TAU / float(ORIENTATION_STEPS))
# Unit forward the spectator's front (+Z) points along, on the horizontal plane.
func facing_dir() -> Vector3:
var a := facing_yaw()
return Vector3(sin(a), 0.0, cos(a))
func _apply_orientation() -> void:
if _preview != null:
_preview.rotation = Vector3(0.0, facing_yaw(), 0.0)
# Editor-only preview. Children are added without an owner so they are excluded from the
# saved .tscn (Godot only serialises nodes owned by the scene root).
func _build_preview() -> void:
_preview = Node3D.new()
add_child(_preview)
var aspect := 0.5
var slab := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(PREVIEW_HEIGHT * aspect, PREVIEW_HEIGHT)
quad.center_offset = Vector3(0.0, PREVIEW_HEIGHT * 0.5, 0.0) # stand on the marker
slab.mesh = quad
slab.material_override = _preview_material(Color(0.3, 0.7, 1.0, 0.35))
_preview.add_child(slab)
var arrow := MeshInstance3D.new()
var shaft := BoxMesh.new()
shaft.size = Vector3(0.08, 0.08, PREVIEW_HEIGHT * 0.5)
arrow.mesh = shaft
# Push the shaft out along +Z (the facing) and up to chest height.
arrow.position = Vector3(0.0, PREVIEW_HEIGHT * 0.5, PREVIEW_HEIGHT * 0.25)
arrow.material_override = _preview_material(Color(1.0, 0.85, 0.2, 0.9))
_preview.add_child(arrow)
func _preview_material(col: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = col
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
return mat
+1
View File
@@ -0,0 +1 @@
uid://70j3ks1emhkd
+12
View File
@@ -0,0 +1,12 @@
extends Node3D
func _ready() -> void:
# Mark the crowd so debug overlays (show_animation_name / show_bones) skip it —
# 72 ambient clapping spectators would otherwise flood the view with tags.
add_to_group(&"public")
for child in get_children():
var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim:
anim.speed_scale = randf_range(0.5, 1.5)
anim.play(&"ArmatureAction")
+1
View File
@@ -0,0 +1 @@
uid://bs682ey3p1ivj