56593c58cf
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>
143 lines
5.2 KiB
GDScript
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
|