Files
Bullosseum/matador_spawn.gd
T
richard 56593c58cf Release 0.2.0: device-aware controls, spear, settings, shaders
Show touch controls only on touch platforms and keyboard hints only on
desktop, via a shared Controls.use_touch_ui() gate (is_touchscreen_available
is unreliable with emulate_touch_from_mouse). Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 19:39:37 +03:00

143 lines
5.2 KiB
GDScript

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