Карта 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
+2 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Экспорт веб-клиента и пакетов карт. По умолчанию — в ../grid-world (репозиторий сайта).
# tools/export_all.sh [папка_вывода]
# Пакеты карт (backrooms.pck, world.pck, parkour.pck) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
# Пакеты карт (backrooms.pck, world.pck, parkour.pck, race.pck) нужно выкладывать вместе с index.* — клиент качает их при входе в портал.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT="${1:-../grid-world}"
@@ -15,4 +15,5 @@ godot --headless --export-release Web "$OUT/index.html"
godot --headless --export-pack Backrooms "$OUT/backrooms.pck"
godot --headless --export-pack World "$OUT/world.pck"
godot --headless --export-pack Parkour "$OUT/parkour.pck"
godot --headless --export-pack Race "$OUT/race.pck"
ls -la "$OUT"/*.pck "$OUT"/index.wasm | awk '{printf "%7.2f МБ %s\n", $5/1e6, $9}'
+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
+1
View File
@@ -0,0 +1 @@
uid://w0j6xo7y5o6n
+18
View File
@@ -0,0 +1,18 @@
# Проверка карты Race «по существу» после прохода через портал RACE с настоящими пакетами (по образцу parkour_check.txt).
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...")
# Пакеты: tools/export_all.sh /tmp/site (НЕ в grid-world), затем (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &) и
# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:6.75,1,-7.5
# (портал RACE четвёртый в ряду: x = (3 - 1.5) * 4.5 = 6.75). Сервер в этом тесте не нужен.
# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true.
var sc := get_tree().current_scene
if sc != null and sc.name == "Race" and int(t) % 3 == 0:
var shaded := 0
for mi in sc.find_children("*", "MeshInstance3D", true, false):
if mi.get_surface_override_material_count() > 0 and mi.get_surface_override_material(0) is ShaderMaterial:
shaded += 1
var env := sc.find_children("*", "WorldEnvironment", false, false)
var sky_ok: bool = env.size() > 0 and env[0].environment.sky != null and env[0].environment.sky.sky_material is PanoramaSkyMaterial and env[0].environment.sky.sky_material.panorama != null
print(" ПРОВЕРКА Race: скрипт=%s правила=%s шейдерных_мешей=%d небо_панорама=%s ворот=%d лазеров=%d музыка_в_файле=%s" % [
sc.get_script() != null, sc.get_node_or_null("Rules") != null, shaded, sky_ok, sc._gates.size(), sc._lasers.size(),
ResourceLoader.exists("res://maps/race/audio/music.ogg")])
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Сверка логов race_game_test.sh: A, B (два круга), C (зашёл в разгар круга 1, участвует в круге 2 и выходит)."""
import re, sys, os
W = sys.argv[1]
logs = {n: open(os.path.join(W, n + ".log"), errors="replace").read().splitlines() for n in "ABC"}
ok = True
def check(cond, text):
global ok
print((" ДА " if cond else " НЕТ ") + text)
if not cond:
ok = False
def rounds_of(lines):
"""Таблицы итогов по кругам: список [ [(место, id, время, ворот)...], ... ]"""
res, cur = [], []
for l in lines:
m = re.search(r"итог: место (\d+) игрок (-?\d+) время (-?[\d.]+) ворот (\d+)", l)
if m:
if cur and int(m.group(1)) == 1:
res.append(cur); cur = []
cur.append((int(m.group(1)), int(m.group(2)), float(m.group(3)), int(m.group(4))))
elif cur and "фаза -> WAIT" in l:
res.append(cur); cur = []
if cur: res.append(cur)
return res
ra, rb = rounds_of(logs["A"]), rounds_of(logs["B"])
print("Итоговые таблицы A:", ra)
print("Итоговые таблицы B:", rb)
check(len(ra) == 2 and len(rb) == 2, "оба клиента показали итоги двух кругов (%d и %d)" % (len(ra), len(rb)))
for i in range(min(len(ra), len(rb))):
check(ra[i] == rb[i], "круг %d: таблица мест одинакова на A и B" % (i + 1))
if ra[i]:
times = [r[2] for r in ra[i]]
check(len(ra[i]) == 2 and all(t > 0 for t in times) and len(set(times)) == len(times),
"круг %d: два финиша с разным временем %s" % (i + 1, times))
check([r[0] for r in ra[i]] == [1, 2] and ra[i][0][2] < ra[i][1][2], "круг %d: места 1 и 2 по возрастанию времени" % (i + 1))
if len(ra) == 2:
check(len(ra[1]) == 2, "круг 2: C вышел в разгар и убран из итогов (в таблице %d строк)" % len(ra[1]))
# один START на круг
starts = sum(1 for n in "ABC" for l in logs[n] if "даю старт" in l)
check(starts == 2, "команду START дал один клиент на круг (всего %d за 2 круга)" % starts)
for n in "AB":
senders = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs[n]))
print(" старт на %s: %s" % (n, senders))
sa = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs["A"]))
sb = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs["B"]))
sc = re.findall(r"старт от (\d+): слот (\d+) из (\d+)", "\n".join(logs["C"]))
check(len(sa) == 2 and len(sb) == 2 and [s[0] for s in sa] == [s[0] for s in sb], "A и B приняли одних и тех же отправителей START в обоих кругах")
if len(sa) == 2 and len(sb) == 2:
check(sa[0][2] == "2" and sa[1][2] == "3", "в круге 1 участвовали 2, в круге 2 — 3 игрока (A: %s)" % [s[2] for s in sa])
check(sorted([sa[1][1], sb[1][1], sc[0][1] if sc else "?"]) == ["0", "1", "2"] and sa[0][1] != sb[0][1], "слоты в кругах различны и подряд")
# синхронность старта по unix-времени (фаза RACE)
def race_unix(lines):
return [float(m.group(1)) for l in lines for m in [re.search(r"ФАЗА RACE, unix=([\d.]+)", l)] if m]
ua, ub = race_unix(logs["A"]), race_unix(logs["B"])
print(" unix начала гонки A:", ua, " B:", ub)
check(len(ua) == 2 and len(ub) == 2 and all(abs(x - y) < 0.35 for x, y in zip(ua, ub)), "начало гонки на A и B совпадает в пределах 0,35 с: %s" % [round(x - y, 3) for x, y in zip(ua, ub)])
# заморозка
fr = [float(m.group(1)) for n in "ABC" for l in logs[n] for m in [re.search(r"ЗАМОРОЗКА: сдвиг за отсчёт ([\d.]+)", l)] if m]
check(len(fr) >= 5 and max(fr) < 0.01, "заморозка на отсчёте: смещения %s" % fr)
# музыка
mus_after = [l for n in "AB" for l in logs[n] if "МУЗЫКА после старта" in l]
mus_cd = [l for n in "AB" for l in logs[n] if "МУЗЫКА на отсчёте" in l]
check(len(mus_after) >= 4 and all("играет=true" in l for l in mus_after), "музыка играет после старта (%d проверок)" % len(mus_after))
check(len(mus_cd) >= 4 and all("false" in l for l in mus_cd), "на отсчёте музыки нет (%d проверок)" % len(mus_cd))
# возрождение
resp = [l for n in "AB" for l in logs[n] if "ВОЗРОЖДЕНИЕ" in l]
for l in resp: print(" ", l)
check(len(resp) >= 4 and all("-> ВЕРНО" in l for l in resp), "возрождение у ворот лицом по трассе (%d проверок, ошибок нет)" % len(resp))
# C: зритель, потом участник
c = "\n".join(logs["C"])
check("ГОНКА_ИДЁТ" in c and "участник=false" in c, "C зашёл в разгар круга 1: «гонка идёт», без зачёта")
check("УЧАСТНИК=true" in c, "C участвует в круге 2")
check("ВЫХОЖУ из игры" in c, "C вышел в разгар круга 2")
for n in "ABC":
errs = [l for l in logs[n] if "SCRIPT ERROR" in l or (l.startswith("ERROR") and "still in use at exit" not in l) or "Parse Error" in l]
check(not errs, "в логе %s нет ошибок скриптов %s" % (n, errs[:2]))
check("ГОТОВО" in "\n".join(logs["A"]) and "ГОТОВО" in "\n".join(logs["B"]), "A и B дошли до конца теста")
print("\nПРОВЕРКА ПРОЙДЕНА" if ok else "\nПРОВЕРКА ПРОВАЛЕНА")
sys.exit(0 if ok else 1)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Игровая проверка режима «Гонка» на локальном сервере: 2 клиента-бота (+ третий, который заходит в разгар круга 1,
# наблюдает, участвует в круге 2 и выходит посреди него). Окно не открывается.
# tools/test/race_game_test.sh [рабочая_папка=/tmp/race_game]
# Печатает факты и итог «ПРОВЕРКА ПРОЙДЕНА / ПРОВАЛЕНА». Порт сервера 9080 должен быть свободен.
# Настоящий рекорд (user://race_best.cfg) не трогается: рекорд ботов идёт в файлы в рабочей папке.
set -uo pipefail
SRC="$(cd "$(dirname "$0")/../.." && pwd)"
W="${1:-/tmp/race_game}"
rm -rf "$W"; mkdir -p "$W"
"$SRC/tools/test/make_test_server.sh" "$W/server" >/dev/null
"$SRC/tools/test/make_test_project.sh" "$W/project" >/dev/null
sed -i 's#^Bot="\*res://tools/test/bot.gd"#Bot="*res://tools/test/bot.gd"\nRaceBot="*res://tools/test/race_bot.gd"#' "$W/project/project.godot"
sed -i 's#^config/name="world2"#config/name="world2-racetest"#' "$W/project/project.godot"
(cd "$W/project" && godot --headless --import --path . >/dev/null 2>&1)
godot --headless --path "$W/server" > "$W/server.log" 2>&1 &
SERVER=$!
sleep 2
run() { # имя, остальные аргументы бота
local n="$1"; shift
timeout 420 godot --headless --path "$W/project" res://maps/race/race.tscn -- --client --race-log --race-best="$W/best_$n.cfg" --name="$n" "$@" > "$W/$n.log" 2>&1 &
}
run A --expect=2 --rounds=2 --die-at-gate=3
PA=$!
run B --expect=2 --rounds=2 --delay=2.0 --fall-at-gate=2
PB=$!
sleep 40 # круг 1 в разгаре (отсчёт 6 с + бег)
run C --expect=2 --rounds=1 --quit-race-time=12
PC=$!
sleep 5; ps -eo pid,args | grep '[g]odot' | cut -c1-200 > "$W/ps_after_C.txt"
wait $PA $PB $PC
kill $SERVER 2>/dev/null
echo "логи: $W/{A,B,C,server}.log"
python3 "$SRC/tools/test/race_game_check.py" "$W"
+1
View File
@@ -0,0 +1 @@
{"safe": [[0, 28.0, 0], [0, 40, 0], [6, 50, 0], [6, 58, 0], [0, 64, 0], [0, 84, 0], [0, 96, 1.5], [0, 106, 3.0], [0, 120, 4.5], [0, 134, 4.5], [0, 150, 4.5], [0, 172, 4.5], [0, 190, 4.5], [20, 195, 4.5], [60, 195, 4.5], [99, 195, 5.0], [150, 195, 3.5], [138, 216, -0.7999999999999998], [116, 254, -0.7999999999999998], [114, 270, -0.7999999999999998], [143, 276, -0.7999999999999998], [143, 296, -0.7999999999999998], [160, 304, -0.7999999999999998], [162, 331, -2.8], [162, 347, -4.9], [162, 358, -6.9], [163.5, 366, -6.9], [163.5, 373.5, -6.9], [151.5, 381, -6.9], [163.5, 389, -6.9], [151.5, 397, -6.9], [163.5, 405, -6.9], [163.5, 416, -6.9], [161, 428, -6.9], [161, 440.0, -6.9], [161, 462.0, -6.9]], "finish": 440.0}
+58
View File
@@ -0,0 +1,58 @@
extends Node
# Облёт карты Race для скриншотов (окно мелькнёт; кадры пишет Godot через --write-movie).
# --mode=gates (по умолчанию): телепорт игрока к воротам и видам трассы, смотрит вдоль трассы
# --mode=hud: «жмёт R», ждёт отсчёт, пробегает ворота (телепортом перед воротами + шаг вперёд), финиш, итоги
# Запуск:
# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/r.png --quit-after 60 res://tools/test/race_tour.tscn -- --client
# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/h.png --quit-after 260 res://tools/test/race_tour.tscn -- --client --mode=hud --race-best=/tmp/shots/best.cfg
const Data = preload("res://maps/race/race_data.gd")
var level
var p
var frame := 0
var mode := "gates"
var gate := 0
# (позиция центра капсулы, рыскание°, наклон°) — виды вдоль трассы
var spots := [
[Vector3(0, 1.25, -20), 0, 4], [Vector3(0, 1.25, -70), 0, 4], [Vector3(0, 5.7, -100), 0, 4], [Vector3(40, 5.7, -195), -90, 4],
[Vector3(120, 5.2, -195), -90, 4], [Vector3(163, 0.4, -290), 0, 4], [Vector3(162, -5.7, -350), 0, 4], [Vector3(161, -5.7, -420), 0, 4],
[Vector3(161, -5.7, -470), 0, 4], [Vector3(161, -6.7, -495), 180, 4],
]
func _ready() -> void:
for a in OS.get_cmdline_user_args():
if a.begins_with("--mode="):
mode = a.substr(7)
level = (load("res://maps/race/race.tscn") as PackedScene).instantiate()
add_child(level)
p = level.get_node("Player")
if mode == "gates":
p.set_physics_process(false)
func _process(_d: float) -> void:
frame += 1
if mode == "gates":
var i := int(frame / 5)
if i < spots.size() and frame % 5 == 1:
var s = spots[i]
p.global_position = s[0]
p.rotation.y = deg_to_rad(s[1])
p.head.rotation.x = deg_to_rad(s[2])
return
var rules = level.get_node("Rules")
if frame == 8:
rules.toggle_ready()
# после старта: каждые 25 кадров ставим игрока за 4 м до очередных ворот лицом вперёд и бежим
if rules.phase == 2 and not rules.finish.has(rules.my_id) and frame % 25 == 0:
var g: int = rules.passed.get(rules.my_id, 0)
if g < Data.CHECKPOINTS.size():
var gp: Vector3 = Data.CHECKPOINTS[g][0]
var yaw := deg_to_rad(Data.CHECKPOINTS[g][2])
if g < 6:
p.global_position = gp - Vector3(-sin(yaw), 0, -cos(yaw)) * 6.0
p.rotation.y = yaw
p.velocity = Vector3.ZERO
Input.action_press("move_forward")
Input.action_press("sprint")
else: # дальше просто засчитываем ворота (облёт проверяет только HUD; проход трассы — в race_game_test)
Input.action_release("move_forward")
Input.action_release("sprint")
p.global_position = gp
rules._pass_gate(g)
+1
View File
@@ -0,0 +1 @@
uid://bb65tfdst1g06
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/race_tour.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")