259 lines
7.7 KiB
GDScript
259 lines
7.7 KiB
GDScript
extends SceneTree
|
|
## Headless difficulty simulator.
|
|
##
|
|
## Drives a kinematic "bot bull" against ONE real matador (the actual Matador.tscn
|
|
## + matador.gd AI) across a few behaviour profiles, many trials each, and reports
|
|
## how often the matador wins — i.e. gores the bull — versus how often the bull
|
|
## wins by ramming the matador. It exists so matador difficulty can be tuned from
|
|
## measurements instead of guesswork.
|
|
##
|
|
## godot --headless --script tests/difficulty_sim.gd
|
|
##
|
|
## Read the CHARGER row: that models a player who just charges in. If the matador
|
|
## almost never wins there, blind charging is unpunished and the game is too easy.
|
|
|
|
const CHARGER_TRIALS := 16
|
|
const KITER_TRIALS := 16
|
|
const PASSIVE_TRIALS := 8
|
|
const TRIAL_TIMEOUT := 5.0
|
|
const SPAWN_SETTLE := 0.4
|
|
|
|
const CHARGE_SPEED := 42.0 # a fast, committed horn-charge
|
|
const KITE_DIST := 12.0
|
|
const KITE_SPEED := 17.0 # a real bull easily out-paces the matador; only throws threaten
|
|
const START_DIST := 12.0
|
|
|
|
var _scene: Node
|
|
var _dp: Node
|
|
var _mat_scene: PackedScene
|
|
var _player: CharacterBody3D
|
|
var _matador: Node3D
|
|
var _bull_dead := false
|
|
var _mat_dead := false
|
|
var _dt := 1.0 / 60.0
|
|
|
|
# Charger bull state
|
|
var _charge_dir: Vector3 = Vector3.ZERO
|
|
var _charge_timer: float = 0.0
|
|
var _recover_timer: float = 0.0
|
|
# Kiter juke state
|
|
var _juke_dir: float = 1.0
|
|
var _juke_timer: float = 0.0
|
|
var _juke_range: float = KITE_DIST
|
|
|
|
|
|
func _init() -> void:
|
|
_run.call_deferred()
|
|
|
|
|
|
func _run() -> void:
|
|
_dt = 1.0 / float(Engine.physics_ticks_per_second)
|
|
_dp = root.get_node_or_null("/root/DP")
|
|
_mat_scene = load("res://Matador.tscn") as PackedScene
|
|
|
|
_scene = (load("res://scene.tscn") as PackedScene).instantiate()
|
|
root.add_child(_scene)
|
|
current_scene = _scene
|
|
await create_timer(0.5).timeout
|
|
|
|
var players := get_nodes_in_group(&"player")
|
|
if players.is_empty():
|
|
push_error("no player in scene"); quit(1); return
|
|
_player = players[0]
|
|
_player.set_physics_process(false) # we drive the bull kinematically
|
|
_player.died.connect(func(_cause: String) -> void: _bull_dead = true)
|
|
|
|
_strip_match_machinery() # no HUD pause / spawner interference
|
|
|
|
print("=".repeat(64))
|
|
print("DIFFICULTY SIMULATION (physics %d Hz)" % Engine.physics_ticks_per_second)
|
|
print("=".repeat(64))
|
|
|
|
var charger := await _run_profile("CHARGER (fast straight charges)", CHARGER_TRIALS, &"charger")
|
|
var kiter := await _run_profile("KITER (circles at ~11 m)", KITER_TRIALS, &"kiter")
|
|
var passive := await _run_profile("PASSIVE (holds at attack range)", PASSIVE_TRIALS, &"passive")
|
|
|
|
print("\n" + "=".repeat(64))
|
|
print("SUMMARY — matador win %% (bull gored)")
|
|
print("=".repeat(64))
|
|
_print_row("CHARGER", charger)
|
|
_print_row("KITER ", kiter)
|
|
_print_row("PASSIVE", passive)
|
|
print("=".repeat(64))
|
|
quit(0)
|
|
|
|
|
|
var _state_hist: Dictionary = {}
|
|
var _throws: int = 0
|
|
var _diag_printed: bool = false
|
|
|
|
|
|
func _run_profile(label: String, trials: int, mode: StringName) -> Dictionary:
|
|
var wins := 0
|
|
var losses := 0
|
|
var timeouts := 0
|
|
var total_t := 0.0
|
|
_state_hist = {}
|
|
_throws = 0
|
|
for _i in trials:
|
|
var res := await _run_trial(mode)
|
|
total_t += res[1] as float
|
|
match res[0] as StringName:
|
|
&"mat": wins += 1
|
|
&"bull": losses += 1
|
|
_: timeouts += 1
|
|
print("\n-- %s --" % label)
|
|
print(" matador wins: %d/%d bull wins: %d timeouts: %d avg %.2fs" % [
|
|
wins, trials, losses, timeouts, total_t / maxf(float(trials), 1.0)])
|
|
print(" swords thrown: %d states: %s" % [_throws, _fmt_hist()])
|
|
return {"win": wins, "loss": losses, "timeout": timeouts, "n": trials}
|
|
|
|
|
|
func _fmt_hist() -> String:
|
|
var parts: Array[String] = []
|
|
var total := 0
|
|
for k: String in _state_hist:
|
|
total += _state_hist[k] as int
|
|
for k: String in _state_hist:
|
|
parts.append("%s %.0f%%" % [k, 100.0 * float(_state_hist[k]) / maxf(float(total), 1.0)])
|
|
return ", ".join(parts)
|
|
|
|
|
|
func _count_thrown_swords() -> int:
|
|
var n := 0
|
|
for c: Node in _scene.get_children():
|
|
if c is RigidBody3D:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
func _run_trial(mode: StringName) -> Array:
|
|
if is_instance_valid(_matador):
|
|
_matador.queue_free()
|
|
await physics_frame
|
|
|
|
_matador = _mat_scene.instantiate()
|
|
_scene.add_child(_matador)
|
|
_matador.global_position = Vector3(0.0, 1.0, 0.0)
|
|
_matador.killed.connect(func() -> void: _mat_dead = true)
|
|
|
|
var ang := randf() * TAU
|
|
_player.global_position = Vector3(cos(ang), 0.0, sin(ang)) * START_DIST + Vector3(0.0, 1.0, 0.0)
|
|
_player.velocity = Vector3.ZERO
|
|
_player._dead = false
|
|
_bull_dead = false
|
|
_mat_dead = false
|
|
_charge_dir = Vector3.ZERO
|
|
_charge_timer = 0.0
|
|
_recover_timer = 0.0
|
|
_juke_dir = 1.0 if randf() < 0.5 else -1.0
|
|
_juke_timer = 0.0
|
|
_juke_range = KITE_DIST
|
|
|
|
# Let the matador's _ready (ragdoll rig, sword) settle before the duel counts.
|
|
var settle := SPAWN_SETTLE
|
|
while settle > 0.0:
|
|
await physics_frame
|
|
settle -= _dt
|
|
_bull_dead = false
|
|
_mat_dead = false
|
|
_player._dead = false
|
|
|
|
if not _diag_printed:
|
|
_diag_printed = true
|
|
print(" [diag] matador has_sword=%s in_hand=%s throw_range=[%.0f,%.0f]" % [
|
|
is_instance_valid(_matador._sword_node), _matador._sword_in_hand,
|
|
_dp.f("mat_throw_min_dist"), _dp.f("mat_throw_range")])
|
|
|
|
var t := 0.0
|
|
var max_swords := 0
|
|
while t < TRIAL_TIMEOUT:
|
|
_drive(mode)
|
|
await physics_frame
|
|
t += _dt
|
|
var s: String = _matador.ai_state_name()
|
|
_state_hist[s] = int(_state_hist.get(s, 0)) + 1
|
|
max_swords = maxi(max_swords, _count_thrown_swords())
|
|
if _bull_dead: # the bull was gored → matador win
|
|
_throws += max_swords
|
|
return [&"mat", t]
|
|
if _mat_dead: # the matador was rammed → bull win
|
|
_throws += max_swords
|
|
return [&"bull", t]
|
|
_throws += max_swords
|
|
return [&"timeout", t]
|
|
|
|
|
|
func _drive(mode: StringName) -> void:
|
|
var to_mat := _matador.global_position - _player.global_position
|
|
to_mat.y = 0.0
|
|
var dist := to_mat.length()
|
|
var aim := to_mat.normalized() if dist > 0.05 else Vector3.FORWARD
|
|
var vel := Vector3.ZERO
|
|
|
|
match mode:
|
|
&"charger":
|
|
if _recover_timer > 0.0:
|
|
_recover_timer -= _dt
|
|
vel = -aim * 14.0
|
|
else:
|
|
if _charge_timer <= 0.0:
|
|
_charge_dir = aim # commit a straight line (dodgeable)
|
|
_charge_timer = 0.6
|
|
_charge_timer -= _dt
|
|
vel = _charge_dir * CHARGE_SPEED
|
|
if dist < 0.8 or _charge_timer <= 0.0:
|
|
_recover_timer = 0.35
|
|
_charge_timer = 0.0
|
|
&"kiter":
|
|
# Juke: flip strafe direction and shift the hold distance at random so the
|
|
# matador can't perfectly lead the throw — models an evasive player.
|
|
_juke_timer -= _dt
|
|
if _juke_timer <= 0.0:
|
|
_juke_timer = randf_range(0.4, 0.9)
|
|
_juke_dir = -_juke_dir if randf() < 0.6 else _juke_dir
|
|
_juke_range = randf_range(8.0, 13.0)
|
|
var inward := aim * clampf(dist - _juke_range, -1.0, 1.0)
|
|
var tangent := Vector3(-aim.z, 0.0, aim.x) * _juke_dir
|
|
vel = (tangent + inward).normalized() * KITE_SPEED
|
|
&"passive":
|
|
# Sit just inside the matador's attack range so it commits to a strike.
|
|
if dist > 6.5:
|
|
vel = aim * 6.0
|
|
elif dist < 5.0:
|
|
vel = -aim * 4.0
|
|
|
|
_player.velocity = Vector3(vel.x, 0.0, vel.z)
|
|
_player.move_and_slide()
|
|
var p := _player.global_position
|
|
p.y = 1.0
|
|
_player.global_position = p
|
|
|
|
|
|
func _strip_match_machinery() -> void:
|
|
for m: Node in get_nodes_in_group(&"matador"):
|
|
m.queue_free()
|
|
for s: Node in get_nodes_in_group(&"matador_spawn"):
|
|
s.queue_free()
|
|
var hud := _find_hud(_scene)
|
|
if hud != null:
|
|
hud.queue_free()
|
|
|
|
|
|
func _find_hud(n: Node) -> Node:
|
|
if n is CanvasLayer and n.has_method(&"_show_game_over"):
|
|
return n
|
|
for c: Node in n.get_children():
|
|
var r := _find_hud(c)
|
|
if r != null:
|
|
return r
|
|
return null
|
|
|
|
|
|
func _print_row(label: String, r: Dictionary) -> void:
|
|
var n: int = r["n"]
|
|
var win: int = r["win"]
|
|
var pct := 100.0 * float(win) / maxf(float(n), 1.0)
|
|
print(" %s %5.1f%% (%d win / %d loss / %d timeout)" % [
|
|
label, pct, win, r["loss"], r["timeout"]])
|