Files
my-world/maps/race/race_rules.gd
T
aibrashandClaude Sonnet 5.5 9130202a51 Карта 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
2026-10-28 17:28:05 +05:00

616 lines
23 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
# Правила режима «Гонка»: ожидание -> отсчёт -> гонка -> итоги -> снова ожидание, 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