extends MultiMeshInstance3D
## A whole stand of cheering spectators rendered as camera-facing 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 mirrors an existing crowd node (default: the sibling PublikNode) so the
## billboards land exactly where the crowd was authored; if that node is missing it
## falls back to a generated tribune so the scene still populates.

const SHEET_PATH  := "res://Assets/crowd_clap_sheet.png"
const SHADER_PATH := "res://crowd_billboard.gdshader"

## Node whose descendant figures (names starting with `figure_prefix`) donate their
## world positions. Empty = use the generated fallback stand.
@export var source_path: NodePath = ^"../PublikNode"
@export var figure_prefix: String = "mees"
## 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
	_build()


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

	var seats := _seat_positions()
	var center := _arena_center(seats)

	var mm := MultiMesh.new()
	mm.transform_format = MultiMesh.TRANSFORM_3D
	mm.use_custom_data = true
	mm.mesh = quad
	mm.instance_count = seats.size()

	for i in seats.size():
		var s := 1.0 + _rng.randf_range(-0.06, 0.06)          # slight height variety
		mm.set_instance_transform(i, Transform3D(_facing_basis(seats[i], center, s), seats[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


# Orientation for a seat: the sprite's forward (+Z, the quad's face) points horizontally
# inward at the arena centre, so each ring segment faces the action instead of the
# camera. Upright (world +Y), uniformly scaled by `s`.
func _facing_basis(seat: Vector3, center: Vector3, s: float) -> Basis:
	var look := center - seat
	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 right := up.cross(look).normalized()
	var basis := Basis()
	basis.x = right
	basis.y = up
	basis.z = look
	return basis.scaled(Vector3.ONE * s)


# Horizontal centre the crowd faces — the mean of the seat positions (the ring/stand
# centroid), which for an arena crowd is the middle of the pitch.
func _arena_center(seats: PackedVector3Array) -> Vector3:
	if seats.is_empty():
		return global_position
	var sum := Vector3.ZERO
	for p in seats:
		sum += p
	return sum / float(seats.size())


# Global feet-positions for every spectator: harvested from the source crowd if present,
# otherwise the generated fallback tribune.
func _seat_positions() -> PackedVector3Array:
	var out := PackedVector3Array()
	var src := get_node_or_null(source_path)
	if src != null:
		_collect_figures(src, out)
	if out.is_empty():
		_fallback_stand(out)
	return out


func _collect_figures(node: Node, out: PackedVector3Array) -> void:
	if node is Node3D and node != self and (node as Node3D).name.begins_with(figure_prefix):
		out.append((node as Node3D).global_position)
		return   # don't descend into a figure's own rig
	for c in node.get_children():
		_collect_figures(c, out)


func _fallback_stand(out: PackedVector3Array) -> 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)
			out.append(Vector3(cos(ang) * radius, y, sin(ang) * radius))
			placed += 1
