extends Node3D signal matador_killed signal all_defeated const MATADOR := preload("res://Matador.tscn") var _spawn_count: int = 1 var _alive: int = 0 var _kills: int = 0 var _resolved: bool = false # Flat (x,z) positions of the most recent batch spawned. A fresh wave keeps at least # _SPAWN_MIN_SEP away from these so it never lands on top of where the last one did. const _SPAWN_MIN_SEP: float = 3.5 var _last_spawn_positions: Array[Vector3] = [] func _ready() -> void: add_to_group(&"matador_spawn") _spawn_count = maxi(1, int(DP.f("mat_spawn_count"))) # Deferred so it's safe from _ready and matches the previous spawn timing. _spawn.call_deferred() func _spawn() -> void: # Bull and matadors start at opposite ends of a random diameter, so they always # begin as far apart as the arena allows (edge-to-edge across the centre). var arena_r := DP.f("arena_spawn_radius") var bull_angle := randf() * TAU _place_bull(bull_angle, arena_r) var placed: Array[Vector3] = _spread_positions(_spawn_count, bull_angle + PI, arena_r) for pos: Vector3 in placed: _spawn_at(pos) _last_spawn_positions = placed func _place_bull(angle: float, arena_r: float) -> void: var bull := get_tree().get_first_node_in_group(&"player") as Node3D if not bull: return bull.global_position = Vector3( cos(angle) * arena_r, bull.global_position.y, sin(angle) * arena_r ) # Positions for the matadors, spread across the far side (centred on `center_angle`) # so every matador stays near the point diametrically opposite the bull. Each slot gets # its own angular band and a staggered radius, then rejection-samples inside that band so # no two matadors land closer than _SPAWN_MIN_SEP — otherwise they pile up on top of # each other on spawn. The arc widens with the crowd so a big wave doesn't get cramped. func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]: var positions: Array[Vector3] = [] if count <= 1: positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r)) return positions var arc := clampf(TAU * 0.33 + float(count) * 0.25, TAU * 0.33, TAU * 0.92) var slot := arc / float(count) var inner := maxf(2.5, arena_r * 0.5) for i: int in count: # Give each matador its own angular slot for an even fan, but sample the radius # freely across the whole far-side band so the rejection test can push crowded # neighbours onto a different ring instead of piling them at one distance. var base_angle := center_angle - arc * 0.5 + slot * (float(i) + 0.5) var pos := Vector3(cos(base_angle) * arena_r, 1.0, sin(base_angle) * arena_r) for attempt: int in 16: var angle := base_angle + randf_range(-0.5, 0.5) * slot var radius := randf_range(inner, arena_r) pos = Vector3(cos(angle) * radius, 1.0, sin(angle) * radius) if _clear_of(pos, positions, _SPAWN_MIN_SEP): break positions.append(pos) return positions func _spawn_at(pos: Vector3, generation: int = 0) -> void: var m: Node3D = MATADOR.instantiate() m.position = pos m.set_meta(&"generation", generation) get_parent().add_child(m) m.killed.connect(_on_matador_killed.bind(m)) _alive += 1 # A slain matador is replaced by mat_split_factor fresh ones on the spot (a hydra), # so the horde doubles each generation, up to mat_split_generations deep. The bull # wins only when a whole generation dies out without splitting — i.e. once the spawns # stop and the last body drops. func _on_matador_killed(dead: Node3D) -> void: matador_killed.emit() _alive -= 1 _kills += 1 _split(dead) var win_kills := int(DP.f("mat_win_kills")) var reached_quota := win_kills > 0 and _kills >= win_kills if (reached_quota or _alive <= 0) and not _resolved: _resolved = true all_defeated.emit() func _split(dead: Node3D) -> void: var count := int(DP.f("mat_split_factor")) var generation := int(dead.get_meta(&"generation", 0)) if count < 2 or generation >= int(DP.f("mat_split_generations")): return var arena_r := DP.f("arena_spawn_radius") var origin := dead.global_position origin.y = 0.0 var wave: Array[Vector3] = [] for i: int in count: var pos := _pick_spawn(origin, arena_r, wave) wave.append(pos) _spawn_at(pos, generation + 1) _last_spawn_positions = wave # Rejection-sample a spawn point ringed around `origin`, pushing the ring outward on # each failed attempt until it clears both the previous wave (_last_spawn_positions) # and the spots already taken this wave — so a fresh wave never lands where the last # one did. Falls back to the last candidate if the arena is too cramped to satisfy it. func _pick_spawn(origin: Vector3, arena_r: float, taken: Array[Vector3]) -> Vector3: var pos := origin for attempt: int in 12: var angle := randf() * TAU var r := randf_range(2.0, 3.5) + float(attempt) * 0.4 pos = origin + Vector3(cos(angle) * r, 0.0, sin(angle) * r) var flat := Vector2(pos.x, pos.z) if flat.length() > arena_r: flat = flat.normalized() * arena_r pos = Vector3(flat.x, 0.0, flat.y) if _clear_of(pos, _last_spawn_positions, _SPAWN_MIN_SEP) \ and _clear_of(pos, taken, _SPAWN_MIN_SEP): break pos.y = 1.0 return pos func _clear_of(pos: Vector3, others: Array[Vector3], min_sep: float) -> bool: for o: Vector3 in others: if Vector2(pos.x - o.x, pos.z - o.z).length() < min_sep: return false return true