Карта Race: гонка на ногах по киберпанк-антиутопии, 8 игроков

- maps/race: трасса 633 м из Blender (три развилки: срез по контейнерам, каньон со стенами
  против нижней дороги, труба с лазерами против коридора), запечённый свет, небо, дождь, гул;
  фоновая песня (Ogg Vorbis, ~0,6 МБ) включается на «СТАРТ!»
- Правила (race_rules.gd): R — готов; когда готовы все — отсчёт 6 с, заморозка в слотах,
  ворота строго по порядку, сплиты, место, итоги, личный рекорд (user://race_best.cfg)
- Сеть: Net.submit_race / recv_race / race_event (RACE_READY, START, CP, FINISH, STATE).
  ВНИМАНИЕ: набор RPC изменён — клиент и сервер выкладываются вместе, старый клиент
  с новым сервером не работает (rpc node checksum failed)
- player.gd: input_frozen и place() для режимов карт; project.godot: действие ready (R)
- Портал RACE в хабе, пресет экспорта Race (include_filter для ресурсов, грузимых строкой)
- tools/test: двухклиентный тест гонки на локальном сервере, бот, облёт, проверка пакета
- README: правила гонки, события, проверки, заметки

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
This commit is contained in:
2026-10-28 17:28:05 +05:00
co-authored by Claude Sonnet 5.5
parent 2a6397da79
commit 9130202a51
31 changed files with 1775 additions and 4 deletions
+224
View File
@@ -0,0 +1,224 @@
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 упасть в пустоту (проверка срыва ниже ворот)
# Факты печатаются строками «[имя] …»; сверяет их tools/test/race_game_test.sh.
const Data = preload("res://maps/race/race_data.gd")
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
func _ready() -> void:
for a in OS.get_cmdline_user_args():
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))
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 != "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"]:
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.finish.has(rules.my_id):
for k in ["move_forward", "sprint", "jump"]:
key(k, false)
return
if rules.race_time() < delay:
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() -> void:
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