Карта 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
+615
View File
@@ -0,0 +1,615 @@
extends Node
# Правила режима «Гонка»: ожидание -> отсчёт -> гонка -> итоги -> снова ожидание, HUD и обмен событиями.
# Сервер про гонку ничего не знает, он только пересылает события (Net.RACE_*, submit_race/recv_race) внутри комнаты карты.
# Каждый клиент ведёт собственную копию состояния и приходит к одному результату, потому что:
# - старт даёт единственный отправитель (наименьший id в комнате) и все считают время от одного момента;
# - ворота засчитывает сам игрок и сообщает всем (CP/FINISH), порядок мест вычисляется одинаково у всех;
# - раз в секунду каждый шлёт «маяк» (RACE_STATE) — по нему догоняют потерявшие событие и пришедшие позже.
# Биты маяка (a): 0..7 — пройдено ворот; 8 — готов; 9..10 — фаза отправителя (Phase).
const Data = preload("res://maps/race/race_data.gd")
enum Phase { WAIT, COUNTDOWN, RACE, RESULTS }
const COUNTDOWN_TIME := 6.0 # от START до старта: секунда на «приготовьтесь» и 5…1
const CLOCK_TOLERANCE := 0.4 # на сколько unix-время старта может разойтись с ожидаемым, прежде чем считаем часы чужими
const MAX_SLOTS := 8
const EXTRA_ROW := 3.0 # запасные места: ряды позади стартовых слотов (на столько метров назад)
const AFTER_FIRST_FINISH := 45.0 # гонка кончается через столько секунд после первого финиша
const RESULTS_TIME := 8.0
const BEACON_PERIOD := 1.0
const GATE_HEIGHT := 5.0 # диапазон высот, в котором пересечение плоскости ворот засчитывается
const GATE_MARGIN := 1.0 # запас по ширине сверх полуширины ворот
const TELEPORT_JUMP := 4.0 # смещение за кадр больше этого — телепорт (возрождение), не пересечение ворот
var map: Node3D # race.gd: gate_pos/gate_yaw/gate_half/set_gate_progress
var player: Node3D
var phase := Phase.WAIT
var my_id := 1
var flags_ready := {} # id -> готов (только в ожидании)
var passed := {} # id -> пройдено ворот (участники круга)
var finish := {} # id -> время финиша, с
var peer_phase := {} # id -> фаза по последнему маяку
var known := {} # id -> true: этого игрока видели в комнате
var participants: Array = [] # отсортированные id участников текущего круга
var start_tick := 0 # Time.get_ticks_msec() момента старта
var start_sender := 0
var results: Array = [] # итоги круга: {id, place, time, gates}
var results_end_tick := 0
var splits: Array = [] # свои сплиты круга
var best_time := -1.0
var best_splits: Array = []
var debug := false # --race-log: факты в консоль (тесты)
var _beacon_timer := 0.0
var _prev_pos := Vector3.ZERO
var _have_prev := false
var _go_until := 0
var _split_until := 0
var _new_record := false
var _music_on := false
# HUD
var _timer_label: Label
var _best_label: Label
var _place_label: Label
var _gates_label: Label
var _center_label: Label
var _split_label: Label
var _hint_label: Label
var _results_panel: PanelContainer
func setup(m: Node3D) -> void:
map = m
player = m.player
func _ready() -> void:
debug = OS.get_cmdline_user_args().has("--race-log")
my_id = _current_id()
known[my_id] = true
_load_best()
_build_hud()
Net.race_event.connect(_on_race_event)
map.set_gate_progress(0)
_log("карта готова, id=%d, ворот=%d" % [my_id, Data.CHECKPOINTS.size()])
func _log(text: String) -> void:
if debug:
print("[RACE %d] %s" % [my_id, text])
# ---------- участники ----------
func _current_id() -> int:
return multiplayer.get_unique_id() if Net.connected else 1
# Все игроки комнаты, включая нас (чужие — по призракам Net.ghosts)
func _room_ids() -> Array:
var ids: Array = [my_id]
for id in Net.ghosts.keys():
if is_instance_valid(Net.ghosts[id]):
ids.append(id)
ids.sort()
return ids
func _refresh_room() -> void:
var now := _current_id()
if now != my_id: # подключились к серверу: у нас другой id — начинаем с чистого листа
my_id = now
_reset_all()
var ids := _room_ids()
for id in ids:
known[id] = true
for id in known.keys():
if not ids.has(id):
_forget(id)
func _reset_all() -> void:
flags_ready.clear()
passed.clear()
finish.clear()
peer_phase.clear()
known.clear()
known[my_id] = true
participants.clear()
_set_phase(Phase.WAIT)
player.input_frozen = false
map.set_gate_progress(0)
# Игрок вышел: убираем из расчётов
func _forget(id: int) -> void:
known.erase(id)
flags_ready.erase(id)
passed.erase(id)
finish.erase(id)
peer_phase.erase(id)
participants.erase(id)
_log("игрок %d вышел" % id)
func _name_of(id: int) -> String:
return "Игрок %d" % (id % 1000) # как у призрака (net/ghost.gd)
func _color_of(id: int) -> Color:
return Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0) # как у призрака
func _pos_of(id: int) -> Vector3:
if id == my_id:
return player.global_position
var g = Net.ghosts.get(id)
if g != null and is_instance_valid(g):
return g.position
return Vector3(1e6, 1e6, 1e6)
# ---------- сеть ----------
func _send(kind: int, a: int, b: float) -> void:
Net.send_race(kind, a, b)
func _beacon_bits() -> int:
var bits := mini(passed.get(my_id, 0), 255)
if flags_ready.get(my_id, false):
bits |= 1 << 8
bits |= int(phase) << 9
return bits
func _send_beacon() -> void:
_beacon_timer = 0.0
_send(Net.RACE_STATE, _beacon_bits(), finish.get(my_id, -1.0))
func _set_phase(p: Phase) -> void:
if p != phase:
phase = p
_log("фаза -> %s" % Phase.keys()[p])
_send_beacon()
func _on_race_event(id: int, kind: int, a: int, b: float) -> void:
if id == my_id:
return
match kind:
Net.RACE_READY:
if phase == Phase.WAIT:
flags_ready[id] = a != 0
Net.RACE_START:
_on_start(id, b)
Net.RACE_CP:
if participants.has(id):
passed[id] = maxi(passed.get(id, 0), a + 1)
Net.RACE_FINISH:
if participants.has(id):
finish[id] = b
passed[id] = Data.CHECKPOINTS.size()
Net.RACE_STATE:
_on_beacon(id, a, b)
func _on_beacon(id: int, a: int, b: float) -> void:
var ph := (a >> 9) & 3
peer_phase[id] = ph
if phase == Phase.WAIT and ph == Phase.WAIT:
flags_ready[id] = ((a >> 8) & 1) == 1
if participants.has(id) and (phase == Phase.RACE or phase == Phase.RESULTS) and (ph == Phase.RACE or ph == Phase.RESULTS):
passed[id] = maxi(passed.get(id, 0), a & 255)
if b >= 0.0 and not finish.has(id):
finish[id] = b
# Идёт чужой круг, в который мы не попали (зашли позже): бегаем свободно и ждём следующего
func waiting_for_race() -> bool:
if phase != Phase.WAIT:
return false
for id in peer_phase:
if known.has(id) and (peer_phase[id] == Phase.COUNTDOWN or peer_phase[id] == Phase.RACE):
return true
return false
# ---------- ожидание: готовность ----------
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ready") and not event.is_echo():
toggle_ready()
func toggle_ready() -> void:
if phase != Phase.WAIT or waiting_for_race():
return
flags_ready[my_id] = not flags_ready.get(my_id, false)
_log("готов=%s" % flags_ready[my_id])
_send(Net.RACE_READY, 1 if flags_ready[my_id] else 0, 0.0)
# Все ли в комнате готовы; старт даёт наименьший id (если двое стали «последними готовыми», старт всё равно один)
func _check_all_ready() -> void:
if phase != Phase.WAIT or waiting_for_race() or not flags_ready.get(my_id, false):
return
var ids := _room_ids()
if ids[0] != my_id:
return
for id in ids:
if not flags_ready.get(id, false):
return
var t := Time.get_unix_time_from_system() + COUNTDOWN_TIME
_log("все готовы (%d), даю старт" % ids.size())
_send(Net.RACE_START, 0, t)
_on_start(my_id, t)
# ---------- отсчёт и старт ----------
func _slot_pos(i: int) -> Vector3:
var base: Vector3 = Data.START_SLOTS[i % MAX_SLOTS]
return base + Vector3(0, 0, EXTRA_ROW * (i / MAX_SLOTS)) # лишние — рядами позади
func _on_start(sender: int, unix_start: float) -> void:
if phase == Phase.RACE or (phase == Phase.COUNTDOWN and sender >= start_sender):
return # второй START (двое решили одновременно): остаётся от наименьшего id
start_sender = sender
var remaining := COUNTDOWN_TIME
if sender != my_id:
remaining = unix_start - Time.get_unix_time_from_system()
if absf(remaining - COUNTDOWN_TIME) > CLOCK_TOLERANCE: # часы отправителя расходятся с нашими: считаем от получения
_log("часы расходятся на %.2f с, считаю от получения" % (remaining - COUNTDOWN_TIME))
remaining = COUNTDOWN_TIME - 0.1
start_tick = Time.get_ticks_msec() + int(remaining * 1000.0)
flags_ready.clear()
participants = _room_ids()
passed.clear()
finish.clear()
splits.clear()
_new_record = false
for id in participants:
passed[id] = 0
var slot := participants.find(my_id)
player.place(Transform3D(Basis.IDENTITY, _slot_pos(slot)))
player.input_frozen = true
_have_prev = false
map.set_gate_progress(0)
_set_phase(Phase.COUNTDOWN)
_log("старт от %d: слот %d из %d, через %.2f с, участники %s" % [sender, slot, participants.size(), remaining, participants])
func _begin_race() -> void:
player.input_frozen = false
_go_until = Time.get_ticks_msec() + 1200
_set_phase(Phase.RACE)
func race_time() -> float:
return (Time.get_ticks_msec() - start_tick) / 1000.0
# ---------- гонка ----------
func _physics_process(_delta: float) -> void:
if phase != Phase.RACE or not participants.has(my_id) or finish.has(my_id) or player.is_dead:
_have_prev = false
return
var cur: Vector3 = player.global_position
if _have_prev:
var idx: int = passed.get(my_id, 0)
if (cur - _prev_pos).length() < TELEPORT_JUMP and _crossed(idx, _prev_pos, cur):
_pass_gate(idx)
_prev_pos = cur
_have_prev = true
# Отрезок prev->cur пересёк плоскость ворот i по ходу трассы в пределах ворот?
func _crossed(i: int, prev: Vector3, cur: Vector3) -> bool:
var p: Vector3 = map.gate_pos(i)
var yaw: float = map.gate_yaw(i)
var fwd := Vector3(-sin(yaw), 0, -cos(yaw)) # rotation.y = yaw поворачивает -Z именно сюда
var side := Vector3(cos(yaw), 0, -sin(yaw))
var d0 := (prev - p).dot(fwd)
var d1 := (cur - p).dot(fwd)
if not (d0 < 0.0 and d1 >= 0.0):
return false
var q := prev.lerp(cur, d0 / (d0 - d1))
if absf((q - p).dot(side)) > float(map.gate_half(i)) + GATE_MARGIN:
return false
var dy := q.y - p.y
return dy > -3.0 and dy < GATE_HEIGHT + 4.0
func _pass_gate(idx: int) -> void:
var t := race_time()
var n: int = Data.CHECKPOINTS.size()
passed[my_id] = idx + 1
splits.append(t)
map.set_gate_progress(idx + 1)
# возрождаемся на последних пройденных воротах, лицом по трассе
player.spawn_transform = Transform3D(Basis(Vector3.UP, map.gate_yaw(idx)), map.gate_pos(idx))
var diff := ""
if best_splits.size() > idx:
var d: float = t - best_splits[idx]
diff = " %s%.2f" % ["+" if d >= 0.0 else "-", absf(d)]
_split_label.add_theme_color_override("font_color", Color(0.9, 0.45, 0.35) if d >= 0.0 else Color(0.5, 0.85, 0.4))
else:
_split_label.add_theme_color_override("font_color", Color(0.92, 0.88, 0.8))
if idx == n - 1:
finish[my_id] = t
_send(Net.RACE_FINISH, 0, t)
_new_record = best_time < 0.0 or t < best_time
if _new_record:
best_time = t
best_splits = splits.duplicate()
_save_best()
_split_label.text = "ФИНИШ %s%s%s" % [_fmt(t), diff, " НОВЫЙ РЕКОРД" if _new_record else ""]
_log("ФИНИШ %.3f (место %d)" % [t, _my_place()])
else:
_split_label.text = "ВОРОТА %d %s%s" % [idx + 1, _fmt(t), diff]
_send(Net.RACE_CP, idx, t)
_log("ворота %d: %.3f" % [idx + 1, t])
_split_until = Time.get_ticks_msec() + 3500
_send_beacon()
# ---------- места ----------
func _dist_next(id: int) -> float:
var g: int = passed.get(id, 0)
if g >= Data.CHECKPOINTS.size():
return 0.0
return _pos_of(id).distance_to(map.gate_pos(g))
# Финишировавшие по времени, затем остальные: больше ворот, ближе к следующим
func ranking() -> Array:
var ids: Array = participants.duplicate()
ids.sort_custom(func(a: int, b: int) -> bool:
var fa := finish.has(a)
var fb := finish.has(b)
if fa and fb:
return finish[a] < finish[b] or (finish[a] == finish[b] and a < b)
if fa != fb:
return fa
var pa: int = passed.get(a, 0)
var pb: int = passed.get(b, 0)
if pa != pb:
return pa > pb
var da := _dist_next(a)
var db := _dist_next(b)
if not is_equal_approx(da, db):
return da < db
return a < b)
return ids
func _my_place() -> int:
return ranking().find(my_id) + 1
func _check_race_end() -> void:
if participants.is_empty():
return
var first := -1.0 # время первого финиша
var all_done := true
for id in participants:
if finish.has(id):
if first < 0.0 or finish[id] < first:
first = finish[id]
else:
all_done = false
if all_done or (first >= 0.0 and race_time() >= first + AFTER_FIRST_FINISH):
_end_race()
func _end_race() -> void:
results.clear()
var rank := ranking()
for i in rank.size():
var id: int = rank[i]
results.append({"id": id, "place": i + 1, "time": finish.get(id, -1.0), "gates": passed.get(id, 0)})
results_end_tick = Time.get_ticks_msec() + int(RESULTS_TIME * 1000.0)
_set_phase(Phase.RESULTS)
_show_results()
for r in results:
_log("итог: место %d игрок %d время %.3f ворот %d" % [r.place, r.id, r.time, r.gates])
func _back_to_wait() -> void:
participants.clear()
passed.clear()
finish.clear()
flags_ready.clear()
_results_panel.visible = false
player.input_frozen = false
player.spawn_transform = Transform3D(Basis.IDENTITY, Data.START_SLOTS[0])
map.set_gate_progress(0)
_have_prev = false
_set_phase(Phase.WAIT)
func _process(delta: float) -> void:
_refresh_room()
_beacon_timer += delta
if _beacon_timer >= BEACON_PERIOD:
_send_beacon()
var now := Time.get_ticks_msec()
match phase:
Phase.WAIT:
_check_all_ready()
Phase.COUNTDOWN:
if now >= start_tick:
_begin_race()
Phase.RACE:
_check_race_end()
Phase.RESULTS:
if now >= results_end_tick:
_back_to_wait()
_update_hud()
# музыка: от «СТАРТ!» до возврата в ожидание; зашедший в разгар чужого круга тоже слышит её
var want := phase == Phase.RACE or phase == Phase.RESULTS
if phase == Phase.WAIT:
for id in peer_phase:
if known.has(id) and peer_phase[id] == Phase.RACE:
want = true
if want != _music_on:
_music_on = want
map.set_music(want)
# ---------- личный рекорд ----------
func _best_path() -> String:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--race-best="): # для тестов: не трогаем настоящий рекорд
return arg.substr("--race-best=".length())
return "user://race_best.cfg"
func _load_best() -> void:
var cf := ConfigFile.new()
if cf.load(_best_path()) == OK:
best_time = cf.get_value("race", "best", -1.0)
best_splits = cf.get_value("race", "splits", [])
func _save_best() -> void:
var cf := ConfigFile.new()
cf.set_value("race", "best", best_time)
cf.set_value("race", "splits", best_splits)
cf.save(_best_path())
# ---------- HUD ----------
const TEXT := Color(0.92, 0.88, 0.8)
const DIM := Color(0.66, 0.64, 0.6)
func _fmt(t: float) -> String:
t = maxf(t, 0.0)
return "%d:%05.2f" % [int(t / 60.0), fmod(t, 60.0)]
func _label(size: int, color: Color, parent: Control) -> Label:
var l := Label.new()
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", color)
l.add_theme_constant_override("outline_size", maxi(size / 5, 4))
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(l)
return l
func _build_hud() -> void:
var layer := CanvasLayer.new()
layer.layer = 12
add_child(layer)
var root := Control.new()
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(root)
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_timer_label = _label(40, TEXT, root)
_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_best_label = _label(20, DIM, root)
_best_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_place_label = _label(30, TEXT, root)
_gates_label = _label(22, DIM, root)
_center_label = _label(160, TEXT, root)
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_split_label = _label(28, TEXT, root)
_split_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hint_label = _label(24, TEXT, root)
_hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timer_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP, Control.PRESET_MODE_MINSIZE, 0)
for l in [_timer_label, _best_label, _split_label, _hint_label]:
l.anchor_left = 0.0
l.anchor_right = 1.0
l.offset_left = 0.0
l.offset_right = 0.0
_timer_label.anchor_top = 0.0
_timer_label.offset_top = 14.0
_best_label.anchor_top = 0.0
_best_label.offset_top = 64.0
_split_label.anchor_top = 0.0
_split_label.offset_top = 100.0
_hint_label.anchor_top = 1.0
_hint_label.anchor_bottom = 1.0
_hint_label.offset_top = -80.0
_hint_label.offset_bottom = -40.0
_place_label.position = Vector2(64, 18)
_gates_label.position = Vector2(66, 58)
_center_label.anchor_left = 0.0
_center_label.anchor_right = 1.0
_center_label.anchor_top = 0.2
_center_label.anchor_bottom = 0.5
_center_label.offset_left = 0.0
_center_label.offset_right = 0.0
_results_panel = PanelContainer.new()
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.04, 0.045, 0.05, 0.84)
sb.border_color = Color(0.45, 0.30, 0.16, 0.9)
sb.set_border_width_all(2)
sb.set_content_margin_all(26)
_results_panel.add_theme_stylebox_override("panel", sb)
_results_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_results_panel.visible = false
root.add_child(_results_panel)
_results_panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
_results_panel.grow_horizontal = Control.GROW_DIRECTION_BOTH
_results_panel.grow_vertical = Control.GROW_DIRECTION_BOTH
func _show_results() -> void:
for c in _results_panel.get_children():
c.queue_free()
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 10)
_results_panel.add_child(box)
var title := _label(36, TEXT, box)
title.text = "ИТОГИ"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 34)
grid.add_theme_constant_override("v_separation", 6)
box.add_child(grid)
for r in results:
var c := _color_of(r.id)
var mine: bool = r.id == my_id
var place := _label(26, c if mine else TEXT, grid)
place.text = "%d" % r.place
var nm := _label(26, c, grid)
nm.text = _name_of(r.id) + (" (вы)" if mine else "")
var tm := _label(26, TEXT if r.time >= 0.0 else DIM, grid)
tm.text = _fmt(r.time) if r.time >= 0.0 else "не финишировал (%d/%d)" % [r.gates, Data.CHECKPOINTS.size()]
if best_time >= 0.0:
var rec := _label(20, DIM, box)
rec.text = "Личный рекорд: %s%s" % [_fmt(best_time), " (новый!)" if _new_record else ""]
rec.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_results_panel.visible = true
func _update_hud() -> void:
var now := Time.get_ticks_msec()
var racing := participants.has(my_id) and (phase == Phase.RACE or phase == Phase.RESULTS)
_timer_label.visible = racing
_place_label.visible = racing
_gates_label.visible = racing
_best_label.visible = racing or phase == Phase.COUNTDOWN
_best_label.text = "РЕКОРД %s" % (_fmt(best_time) if best_time >= 0.0 else "—")
_split_label.visible = now < _split_until and racing
if racing:
var n: int = Data.CHECKPOINTS.size()
if finish.has(my_id):
_timer_label.text = _fmt(finish[my_id])
_timer_label.add_theme_color_override("font_color", Color(0.5, 0.85, 0.4))
else:
_timer_label.text = _fmt(race_time())
_timer_label.add_theme_color_override("font_color", TEXT)
_place_label.text = "МЕСТО %d/%d" % [_my_place(), participants.size()]
_gates_label.text = "ВОРОТА %d/%d" % [passed.get(my_id, 0), n]
# центр: отсчёт, «СТАРТ!»
_center_label.visible = false
if phase == Phase.COUNTDOWN:
var rem := (start_tick - now) / 1000.0
_center_label.visible = true
if rem > COUNTDOWN_TIME - 1.0:
_center_label.add_theme_font_size_override("font_size", 56)
_center_label.text = "ПРИГОТОВЬТЕСЬ"
else:
_center_label.add_theme_font_size_override("font_size", 170)
_center_label.text = "%d" % maxi(ceili(rem), 1)
elif phase == Phase.RACE and now < _go_until:
_center_label.visible = true
_center_label.add_theme_font_size_override("font_size", 110)
_center_label.text = "СТАРТ!"
# подсказка внизу
_hint_label.visible = true
if phase == Phase.WAIT:
if waiting_for_race():
_hint_label.text = "ГОНКА ИДЁТ — дождитесь следующего круга"
else:
var ids := _room_ids()
var n_ready := 0
for id in ids:
if flags_ready.get(id, false):
n_ready += 1
if flags_ready.get(my_id, false):
_hint_label.text = "ГОТОВ (%d/%d) · R — отменить" % [n_ready, ids.size()]
else:
_hint_label.text = "R — готов (%d/%d)" % [n_ready, ids.size()]
elif phase == Phase.RACE and not participants.has(my_id):
_hint_label.text = "ГОНКА ИДЁТ"
else:
_hint_label.visible = false