Files
my-world/tools/test/race_bot.gd
T
aibrashandClaude Sonnet 5.5 c3e0ae40b2 Race 2 «Верхние ярусы», фаза 1: карта, вентиляторы, ускоряющие полосы, правила общие с Race
- maps/race2/: сцена, race2.gd (ворота со световыми столбами, лазеры, вентиляторы, полосы, срыв, дождь, гул), данные и модель из Blender models/race_02
  (раскладка layout.py -> build_race.py -> bake.py; свет пока запечён грубо, небо и музыка — копии Race)
- objects/vent.gd, objects/boost_strip.gd + хуки apply_vent()/apply_boost() в player/player.gd (MAX_SPEED и остальная физика не тронуты)
- race_rules.gd берёт данные у карты (общий код правил для Race и Race 2)
- портал RACE 2 в хабе, пресет экспорта Race2 + export_all.sh, Net.TIMED_MAPS/MAP_TITLES для race2 (RPC не менялись; серверу нужен deploy_server.sh)
- тесты: race_game_test.sh с MAP=race2 (бот проводит игроков телепортами), race2_check.txt
- README: раздел «Карта Race 2», результаты ботов по веткам

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 17:21:07 +05:00

319 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node
# Бот для проверки режима «Гонка» (автозагрузка RaceBot во ВРЕМЕННОЙ копии проекта, см. tools/test/race_game_test.sh).
# Сам «жмёт R» (действие ready через Input.parse_input_event), на отсчёте пытается идти (проверка заморозки),
# в гонке бежит безопасный маршрут race_route.json (Blender-координаты; идея — бот-бегун из Blender models/race_01/tests/runner.gd).
# Аргументы (после --): --name=A --delay=<с задержки старта бега> --expect=<сколько игроков ждать перед «R»>
# --rounds=<сколько кругов пройти> --quit-race-time=<выйти из игры на этой секунде гонки>
# --die-at-gate=<n>: после ворот n умереть (проверка возрождения лицом по трассе)
# --fall-at-gate=<n>: после ворот n упасть в пустоту (проверка срыва ниже ворот)
# --gu1=<план>, --gu2=<план>: сдача в круге 1 / 2. План: «cancel@1,giveup@2» — когда пройдено столько ворот,
# держать G 1,0 с и отпустить (отмена) / держать до сдачи (проверка race_giveup_test.sh)
# --map=race2: карта «Гонка 2». Бежать по её маршруту без физики бегуна нельзя (вентиляторы, лазеры, стены), поэтому бот
# «проводит» игрока через ворота телепортами по ~0,35 м за кадр (меньше TELEPORT_JUMP: правила считают это бегом); проверяются правила, а не проходимость
# (проходимость карты — боты в Blender models/race_02/tests/).
# Факты печатаются строками «[имя] …»; сверяет их tools/test/race_game_test.sh.
var Data = null # данные карты: race_data.gd или (--map=race2) race2_data.gd
var map_id := "race"
var label := "?"
var delay := 0.0
var expect := 2
var rounds := 1
var quit_race_time := -1.0
var die_gate := -1
var fall_gate := -1
var route: Array = []
var wi := 1
var jumped_at := -100
var fr := 0
var rules
var p
var last_phase := -1
var rounds_done := 0
var last_press := -10.0
var t := 0.0
var freeze_start := Vector3.ZERO
var special_done := false
var special_wait := 0
var special_expect := Vector3.ZERO
var stuck_fr := 0
var spectator_logged := false
var music_logged := false
var music_cd_logged := false
var freeze_logged := false
var gu_plan := {1: [], 2: []}
var gu_hold := -1.0
var gu_mode := ""
var gu_done := {}
var gu_after_cancel := false
func _ready() -> void:
for a in OS.get_cmdline_user_args():
if a.begins_with("--map="): map_id = a.substr(6)
if a.begins_with("--name="): label = a.substr(7)
if a.begins_with("--delay="): delay = float(a.substr(8))
if a.begins_with("--expect="): expect = int(a.substr(9))
if a.begins_with("--rounds="): rounds = int(a.substr(9))
if a.begins_with("--quit-race-time="): quit_race_time = float(a.substr(17))
if a.begins_with("--die-at-gate="): die_gate = int(a.substr(14))
if a.begins_with("--fall-at-gate="): fall_gate = int(a.substr(15))
if a.begins_with("--gu1=") or a.begins_with("--gu2="):
for e in a.substr(6).split(","):
var q := e.split("@")
gu_plan[int(a.substr(4, 1))].append({"mode": q[0], "gate": int(q[1])})
if map_id == "race2":
Data = load("res://maps/race2/race2_data.gd")
else:
Data = load("res://maps/race/race_data.gd")
route = JSON.parse_string(FileAccess.get_file_as_string("res://tools/test/race_route.json")).safe
func gp(w: Array) -> Vector3:
return Vector3(w[0], w[2], -w[1])
func key(a: String, down: bool) -> void:
if down: Input.action_press(a)
else: Input.action_release(a)
func ray(from: Vector3, to: Vector3) -> Dictionary:
var q := PhysicsRayQueryParameters3D.create(from, to)
q.exclude = [p.get_rid()]
return p.get_world_3d().direct_space_state.intersect_ray(q)
func say(text: String) -> void:
print("[%s] %s" % [label, text])
func _physics_process(_delta: float) -> void:
var sc := get_tree().current_scene
if sc == null or sc.name != ("Race2" if map_id == "race2" else "Race"):
return
rules = sc.get_node_or_null("Rules")
p = get_tree().get_first_node_in_group("local_player")
if rules == null or p == null:
return
fr += 1
t += _delta
var ph: int = rules.phase
if ph != last_phase:
say("ФАЗА %s, unix=%.3f, место_в_слоте=(%.1f, %.1f, %.1f)" % [rules.Phase.keys()[ph], Time.get_unix_time_from_system(), p.global_position.x, p.global_position.y, p.global_position.z])
if ph == rules.Phase.COUNTDOWN:
freeze_start = p.global_position
if ph == rules.Phase.WAIT and last_phase == rules.Phase.RESULTS:
rounds_done += 1
say("КРУГОВ_ЗАВЕРШЕНО %d" % rounds_done)
if rounds_done >= rounds:
say("ГОТОВО, выхожу")
get_tree().quit()
if ph == rules.Phase.RACE:
wi = 1
special_done = false
music_logged = false
music_cd_logged = false
freeze_logged = false
say("УЧАСТНИК=%s" % rules.participants.has(rules.my_id))
last_phase = ph
match ph:
rules.Phase.WAIT:
_wait()
rules.Phase.COUNTDOWN:
# пытаемся бежать: заморозка не должна пускать
key("move_forward", true)
key("sprint", true)
key("jump", true)
if (Time.get_ticks_msec() + 3000) >= rules.start_tick and not music_cd_logged:
music_cd_logged = true
say("МУЗЫКА на отсчёте: %s" % _music_playing())
if (Time.get_ticks_msec() + 300) >= rules.start_tick and not freeze_logged:
freeze_logged = true
say("ЗАМОРОЗКА: сдвиг за отсчёт %.3f м (по горизонтали)" % Vector2(p.global_position.x - freeze_start.x, p.global_position.z - freeze_start.z).length())
rules.Phase.RACE:
if not music_logged and rules.race_time() > 1.0:
music_logged = true
say("МУЗЫКА после старта: играет=%s" % _music_playing())
_race()
_:
for k in ["move_forward", "sprint", "jump"]:
key(k, false)
func _wait() -> void:
for k in ["move_forward", "sprint", "jump", "giveup"]:
key(k, false)
if rules.waiting_for_race():
if not spectator_logged:
spectator_logged = true
say("ГОНКА_ИДЁТ: жду следующий круг (участник=%s)" % rules.participants.has(rules.my_id))
return
if rules.flags_ready.get(rules.my_id, false):
return
if Net.ghosts.size() < expect - 1 or t - last_press < 1.5:
return
last_press = t
spectator_logged = false
var ev := InputEventAction.new()
ev.action = "ready"
ev.pressed = true
Input.parse_input_event(ev)
say("жму R, видно игроков: %d" % (Net.ghosts.size() + 1))
func _race() -> void:
if not rules.participants.has(rules.my_id):
return # бегаем свободно без зачёта — бот просто стоит
if quit_race_time >= 0.0 and rules.race_time() >= quit_race_time:
say("ВЫХОЖУ из игры в разгар гонки (время %.1f)" % rules.race_time())
get_tree().quit()
return
if rules.gave_up.has(rules.my_id):
for k in ["move_forward", "sprint", "jump", "giveup"]:
key(k, false)
if gu_hold >= 0.0: # только что сдались: фиксируем, где мы и заморожены ли
gu_hold = -1.0
var d := Vector2(p.global_position.x - Data.START_SLOTS[0].x, p.global_position.z - Data.START_SLOTS[0].z).length()
say("СДАЛСЯ_БОТ unix=%.3f на_старте=%s заморожен=%s" % [Time.get_unix_time_from_system(), d < 2.0, p.input_frozen])
return
if rules.finish.has(rules.my_id):
for k in ["move_forward", "sprint", "jump"]:
key(k, false)
return
if rules.race_time() < delay:
return
# --- сдача ---
var cur_round := rounds_done + 1
if gu_after_cancel:
gu_after_cancel = false
say("ПОСЛЕ_ОТМЕНЫ hold=%.2f сдался=%s" % [rules._giveup_hold, rules.gave_up.has(rules.my_id)])
if gu_hold < 0.0 and gu_plan.has(cur_round):
for e in gu_plan[cur_round]:
var gk := "%d%s%d" % [cur_round, e.mode, e.gate]
if not gu_done.has(gk) and rules.passed.get(rules.my_id, 0) == e.gate and not p.is_dead:
gu_done[gk] = true
gu_hold = 0.0
gu_mode = e.mode
say("УДЕРЖИВАЮ G (%s) после ворот %d" % [e.mode, e.gate])
if gu_hold >= 0.0:
for k in ["move_forward", "sprint", "jump"]:
key(k, false)
key("giveup", true)
gu_hold += get_physics_process_delta_time()
if gu_mode == "cancel" and gu_hold >= 1.0:
say("ОТМЕНА_УДЕРЖАНИЯ: отпускаю на %.2f с, шкала=%.2f сдался=%s" % [gu_hold, rules._giveup_hold, rules.gave_up.has(rules.my_id)])
key("giveup", false)
gu_hold = -1.0
gu_after_cancel = true
return
# --- проверки возрождения ---
var g: int = rules.passed.get(rules.my_id, 0)
if not special_done and die_gate >= 0 and g == die_gate and not p.is_dead:
special_done = true
special_wait = 1
say("УМИРАЮ после ворот %d" % g)
p.die()
elif not special_done and fall_gate >= 0 and g == fall_gate and not p.is_dead:
special_done = true
special_wait = 1
say("ПАДАЮ в пустоту после ворот %d" % g)
p.global_position.y -= 45.0
p.velocity = Vector3.ZERO
if special_wait > 0:
if p.is_dead:
special_wait = 2
elif special_wait == 2:
special_wait = 0
var f: Vector3 = -p.global_transform.basis.z
var ex: float = rules.map.gate_yaw(g - 1)
var want := Vector3(-sin(ex), 0, -cos(ex))
say("ВОЗРОЖДЕНИЕ у ворот %d: взгляд (%.2f, %.2f), ожидался (%.2f, %.2f), позиция (%.1f, %.1f, %.1f), ворота (%.1f, %.1f, %.1f) -> %s" % [
g, f.x, f.z, want.x, want.z, p.global_position.x, p.global_position.y, p.global_position.z,
rules.map.gate_pos(g - 1).x, rules.map.gate_pos(g - 1).y, rules.map.gate_pos(g - 1).z,
"ВЕРНО" if Vector2(f.x, f.z).distance_to(Vector2(want.x, want.z)) < 0.05 and p.global_position.distance_to(rules.map.gate_pos(g - 1)) < 1.0 else "ОШИБКА"])
return
_drive()
func _drive_tele() -> void:
var g: int = rules.passed.get(rules.my_id, 0)
if g >= Data.CHECKPOINTS.size() or p.is_dead:
return
var gate: Array = Data.CHECKPOINTS[g]
var yaw := deg_to_rad(gate[2])
var fwd := Vector3(-sin(yaw), 0, -cos(yaw))
var tgt: Vector3 = gate[0] + fwd * 1.5
var step := 0.35 # м за кадр (~21 м/с): круг ~60 с, как у настоящей гонки — иначе зритель C не успеет зайти в разгар круга
# лазерный мост: идём медленно (12 м/с) и на ближних подступах к воротам выбираем полосу, где лазер выключен в момент пролёта
var pos: Vector3 = p.global_position
var lane := -1.0
if pos.x > 105.0 and pos.x < 115.0 and pos.z < -535.0 and pos.z > -615.0:
step = 0.2
for l in Data.LASERS:
if l[0].x < 110.0: # левая половина; правая — в паре
var dz: float = pos.z - l[0].z # > 0 — ворота ещё впереди (идём в -Z)
if dz > -1.0 and dz < 8.0:
var t := Time.get_unix_time_from_system() + maxf(dz, 0.0) / 12.0
var left_on: bool = fposmod(t + l[5], l[3]) < l[4] or fposmod(t + 0.4 + l[5], l[3]) < l[4] or fposmod(t - 0.4 + l[5], l[3]) < l[4]
var right_on: bool = fposmod(t + 1.5 + l[5], l[3]) < l[4] or fposmod(t + 1.9 + l[5], l[3]) < l[4] or fposmod(t + 1.1 + l[5], l[3]) < l[4]
lane = 112.0 if left_on else 108.0
if left_on and right_on:
lane = -2.0 # обе полосы заняты — стоим
if lane == -2.0:
return
if lane > 0.0:
p.global_position.x = lane
pos.x = lane
tgt.x = lane
var d: Vector3 = tgt - pos
if tgt.y - pos.y > 2.0: # ворота выше (крыша башни): сначала поднимаемся вертикально — иначе упрёмся в стену
d = Vector3(0, minf(step, tgt.y - pos.y + 1.5), 0)
elif d.length() > step:
d = d.normalized() * step
p.global_position = pos + d
p.velocity = Vector3.ZERO
p.rotation.y = yaw
func _drive() -> void:
if map_id == "race2":
_drive_tele()
return
var pos: Vector3 = p.global_position
if fr % 120 == 0 and OS.get_cmdline_user_args().has("--bot-log"):
say("бег: wi=%d поз=(%.1f, %.1f, %.1f) v=(%.1f, %.1f) пол=%s" % [wi, pos.x, pos.y, pos.z, p.velocity.x, p.velocity.z, p.is_on_floor()])
var tgt := gp(route[wi])
var to := Vector2(tgt.x - pos.x, tgt.z - pos.z)
var seg_a := gp(route[wi - 1])
var seg := Vector2(tgt.x - seg_a.x, tgt.z - seg_a.z)
var reached := to.length() < 2.5 or (to.dot(seg) < 0.0 and to.length() < 8.0)
if _at_gate(tgt): # точка маршрута на воротах: считается взятой, только когда плоскость ворот уже пересечена
reached = to.dot(seg) < 0.0 and to.length() < 12.0
if reached and wi < route.size() - 1:
wi += 1
tgt = gp(route[wi])
to = Vector2(tgt.x - pos.x, tgt.z - pos.z)
if p.is_dead:
return
key("move_forward", true)
key("sprint", true)
var dir := to.normalized()
p.rotation.y = atan2(-dir.x, -dir.y)
# прыжок: край впереди или препятствие
var jump := false
if p.is_on_floor() and fr - jumped_at > 12:
var ahead := Vector3(dir.x, 0, dir.y) * (0.5 + Vector2(p.velocity.x, p.velocity.z).length() * 0.045)
var probe := pos + ahead
if ray(probe, probe + Vector3(0, -2.6, 0)).is_empty():
jump = true
if not ray(pos + Vector3(0, -0.5, 0), pos + Vector3(0, -0.5, 0) + Vector3(dir.x, 0, dir.y) * 1.4).is_empty():
jump = true
if jump:
jumped_at = fr
key("jump", jump or fr - jumped_at < 22)
func _music_playing() -> bool:
for n in get_tree().current_scene.get_children():
if n is AudioStreamPlayer and n.stream is AudioStreamOggVorbis and n.playing:
return true
return false
func _at_gate(w: Vector3) -> bool:
for g in Data.CHECKPOINTS:
if Vector2(g[0].x - w.x, g[0].z - w.z).length() < 3.0:
return true
return false