Ники, общий рейтинг (топ-10 по картам), таблица Tab, сдача в гонке, таймер Parkour

- Ники: профиль user://profile.cfg (client_id + ник), панель при первом запуске и по N;
  Net.nick_of(id); ники над призраками, в HUD/итогах гонки и таблицах
- Рейтинг на сервере: лучшее время каждого игрока по картам race/parkour/metro, топ-10,
  user://leaderboard.json (атомарная запись, лимиты, проверка карты/комнаты/времени)
- Таблица лидеров по Tab, доски топ-3 над порталами в хабе, итоги гонки с общим рейтингом
- Гонка: сдача (G, удержание 1,5 с), RACE_GIVEUP; Parkour: таймер, финиш на вершине, R — перезапуск
- Новые RPC (submit_profile, recv_nick, submit_record, recv_record_result, request_leaderboard,
  recv_leaderboard): клиент и сервер выкладываются вместе
- Тесты: nicks_lb_test, race_giveup_test, parkour_run_test, nick_ui_test, ui_tour; 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-29 12:09:33 +05:00
co-authored by Claude Sonnet 5.5
parent d38152ea97
commit 666bebfb60
31 changed files with 2014 additions and 44 deletions
+127 -10
View File
@@ -5,7 +5,9 @@ extends Node
# - старт даёт единственный отправитель (наименьший id в комнате) и все считают время от одного момента;
# - ворота засчитывает сам игрок и сообщает всем (CP/FINISH), порядок мест вычисляется одинаково у всех;
# - раз в секунду каждый шлёт «маяк» (RACE_STATE) — по нему догоняют потерявшие событие и пришедшие позже.
# Биты маяка (a): 0..7 — пройдено ворот; 8 — готов; 9..10 — фаза отправителя (Phase).
# Биты маяка (a): 0..7 — пройдено ворот; 8 — готов; 9..10 — фаза отправителя (Phase); 11 — сдался.
# Сдача (клавиша G, удержание): игрока возвращает на стартовую площадку, время не пишется ни в итоги, ни в рейтинг;
# сдавшиеся считаются завершившими для конца круга. Рейтинг: финиш -> Net.submit_my_record(rating_id, время).
const Data = preload("res://maps/race/race_data.gd")
@@ -18,6 +20,7 @@ const EXTRA_ROW := 3.0 # запасные места: ряды поз
const AFTER_FIRST_FINISH := 45.0 # гонка кончается через столько секунд после первого финиша
const RESULTS_TIME := 8.0
const BEACON_PERIOD := 1.0
const GIVEUP_HOLD := 1.5 # сколько секунд держать G, чтобы сдаться
const GATE_HEIGHT := 5.0 # диапазон высот, в котором пересечение плоскости ворот засчитывается
const GATE_MARGIN := 1.0 # запас по ширине сверх полуширины ворот
const TELEPORT_JUMP := 4.0 # смещение за кадр больше этого — телепорт (возрождение), не пересечение ворот
@@ -39,6 +42,8 @@ var results_end_tick := 0
var splits: Array = [] # свои сплиты круга
var best_time := -1.0
var best_splits: Array = []
var gave_up := {} # id -> true: сдался в этом круге
var rating_id := "race" # id карты в рейтинге сервера (Metro: map.rating_id = "metro")
var debug := false # --race-log: факты в консоль (тесты)
var _beacon_timer := 0.0
@@ -48,6 +53,7 @@ var _go_until := 0
var _split_until := 0
var _new_record := false
var _music_on := false
var _giveup_hold := 0.0
# HUD
var _timer_label: Label
@@ -58,10 +64,14 @@ var _center_label: Label
var _split_label: Label
var _hint_label: Label
var _results_panel: PanelContainer
var _giveup_label: Label
var _giveup_bar: ProgressBar
func setup(m: Node3D) -> void:
map = m
player = m.player
if m.get("rating_id") != null:
rating_id = m.rating_id
func _ready() -> void:
debug = OS.get_cmdline_user_args().has("--race-log")
@@ -107,6 +117,7 @@ func _reset_all() -> void:
flags_ready.clear()
passed.clear()
finish.clear()
gave_up.clear()
peer_phase.clear()
known.clear()
known[my_id] = true
@@ -121,12 +132,13 @@ func _forget(id: int) -> void:
flags_ready.erase(id)
passed.erase(id)
finish.erase(id)
gave_up.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)
return Net.nick_of(id) # как над призраком (net/ghost.gd)
func _color_of(id: int) -> Color:
return Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0) # как у призрака
@@ -149,6 +161,8 @@ func _beacon_bits() -> int:
if flags_ready.get(my_id, false):
bits |= 1 << 8
bits |= int(phase) << 9
if gave_up.has(my_id):
bits |= 1 << 11
return bits
func _send_beacon() -> void:
@@ -177,6 +191,10 @@ func _on_race_event(id: int, kind: int, a: int, b: float) -> void:
if participants.has(id):
finish[id] = b
passed[id] = Data.CHECKPOINTS.size()
Net.RACE_GIVEUP:
if participants.has(id) and not finish.has(id):
gave_up[id] = true
_log("игрок %d сдался" % id)
Net.RACE_STATE:
_on_beacon(id, a, b)
@@ -189,6 +207,8 @@ func _on_beacon(id: int, a: int, b: float) -> void:
passed[id] = maxi(passed.get(id, 0), a & 255)
if b >= 0.0 and not finish.has(id):
finish[id] = b
if ((a >> 11) & 1) == 1 and not finish.has(id):
gave_up[id] = true
# Идёт чужой круг, в который мы не попали (зашли позже): бегаем свободно и ждём следующего
func waiting_for_race() -> bool:
@@ -202,6 +222,8 @@ func waiting_for_race() -> bool:
# ---------- ожидание: готовность ----------
func _unhandled_input(event: InputEvent) -> void:
if Net.input_blocked:
return
if event.is_action_pressed("ready") and not event.is_echo():
toggle_ready()
@@ -248,6 +270,7 @@ func _on_start(sender: int, unix_start: float) -> void:
participants = _room_ids()
passed.clear()
finish.clear()
gave_up.clear()
splits.clear()
_new_record = false
for id in participants:
@@ -271,7 +294,7 @@ func race_time() -> float:
# ---------- гонка ----------
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:
if phase != Phase.RACE or not participants.has(my_id) or finish.has(my_id) or gave_up.has(my_id) or player.is_dead:
_have_prev = false
return
var cur: Vector3 = player.global_position
@@ -316,6 +339,7 @@ func _pass_gate(idx: int) -> void:
if idx == n - 1:
finish[my_id] = t
_send(Net.RACE_FINISH, 0, t)
Net.submit_my_record(rating_id, t) # источник истины для рейтинга — сервер
_new_record = best_time < 0.0 or t < best_time
if _new_record:
best_time = t
@@ -348,6 +372,10 @@ func ranking() -> Array:
return finish[a] < finish[b] or (finish[a] == finish[b] and a < b)
if fa != fb:
return fa
var ga := gave_up.has(a)
var gb := gave_up.has(b)
if ga != gb:
return gb # сдавшиеся — ниже остальных
var pa: int = passed.get(a, 0)
var pb: int = passed.get(b, 0)
if pa != pb:
@@ -371,27 +399,29 @@ func _check_race_end() -> void:
if finish.has(id):
if first < 0.0 or finish[id] < first:
first = finish[id]
else:
elif not gave_up.has(id):
all_done = false
if all_done or (first >= 0.0 and race_time() >= first + AFTER_FIRST_FINISH):
_end_race()
func _end_race() -> void:
Net.request_board(rating_id) # к показу итогов — свежий рейтинг (другие могли финишировать позже нас)
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.append({"id": id, "place": i + 1, "time": finish.get(id, -1.0), "gates": passed.get(id, 0), "gaveup": gave_up.has(id)})
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])
_log("итог: место %d игрок %d время %.3f ворот %d сдался=%s" % [r.place, r.id, r.time, r.gates, r.gaveup])
func _back_to_wait() -> void:
participants.clear()
passed.clear()
finish.clear()
gave_up.clear()
flags_ready.clear()
_results_panel.visible = false
player.input_frozen = false
@@ -400,8 +430,32 @@ func _back_to_wait() -> void:
_have_prev = false
_set_phase(Phase.WAIT)
# Сдача: удерживаем G, пока не наполнится полоска; отпустил раньше — отмена
func _update_giveup(delta: float) -> void:
var can := phase == Phase.RACE and participants.has(my_id) and not finish.has(my_id) and not gave_up.has(my_id) and not Net.input_blocked
if can and Input.is_action_pressed("giveup"):
_giveup_hold += delta
if _giveup_hold >= GIVEUP_HOLD:
_giveup_hold = 0.0
_give_up()
else:
_giveup_hold = 0.0
func _give_up() -> void:
gave_up[my_id] = true
_send(Net.RACE_GIVEUP, 0, race_time())
player.place(Transform3D(Basis.IDENTITY, Data.START_SLOTS[0])) # назад на стартовую площадку, дальше бегаем свободно
map.set_gate_progress(0)
_have_prev = false
_split_label.text = "ВЫ СДАЛИСЬ"
_split_label.add_theme_color_override("font_color", DIM)
_split_until = Time.get_ticks_msec() + 3500
_send_beacon()
_log("СДАЛСЯ (время гонки %.2f)" % race_time())
func _process(delta: float) -> void:
_refresh_room()
_update_giveup(delta)
_beacon_timer += delta
if _beacon_timer >= BEACON_PERIOD:
_send_beacon()
@@ -515,6 +569,37 @@ func _build_hud() -> void:
_center_label.offset_left = 0.0
_center_label.offset_right = 0.0
# индикатор сдачи: внизу по центру, полоска наполняется за GIVEUP_HOLD секунд
_giveup_label = _label(18, DIM, root)
_giveup_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_giveup_label.anchor_left = 0.0
_giveup_label.anchor_right = 1.0
_giveup_label.anchor_top = 1.0
_giveup_label.anchor_bottom = 1.0
_giveup_label.offset_top = -112.0
_giveup_label.offset_bottom = -84.0
_giveup_bar = ProgressBar.new()
_giveup_bar.show_percentage = false
_giveup_bar.min_value = 0.0
_giveup_bar.max_value = GIVEUP_HOLD
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.03, 0.03, 0.035, 0.85)
var fg := StyleBoxFlat.new()
fg.bg_color = Color(0.78, 0.30, 0.20)
_giveup_bar.add_theme_stylebox_override("background", bg)
_giveup_bar.add_theme_stylebox_override("fill", fg)
_giveup_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(_giveup_bar)
_giveup_bar.anchor_left = 0.5
_giveup_bar.anchor_right = 0.5
_giveup_bar.anchor_top = 1.0
_giveup_bar.anchor_bottom = 1.0
_giveup_bar.offset_left = -110.0
_giveup_bar.offset_right = 110.0
_giveup_bar.offset_top = -80.0
_giveup_bar.offset_bottom = -72.0
_giveup_bar.visible = false
_results_panel = PanelContainer.new()
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.04, 0.045, 0.05, 0.84)
@@ -532,9 +617,12 @@ func _build_hud() -> void:
func _show_results() -> void:
for c in _results_panel.get_children():
c.queue_free()
var cols := HBoxContainer.new() # слева итоги круга, справа общий рейтинг сервера
cols.add_theme_constant_override("separation", 44)
_results_panel.add_child(cols)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 10)
_results_panel.add_child(box)
cols.add_child(box)
var title := _label(36, TEXT, box)
title.text = "ИТОГИ"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
@@ -551,11 +639,21 @@ func _show_results() -> void:
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 r.gaveup:
tm.text = "сдался"
else:
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
var side := VBoxContainer.new()
side.add_theme_constant_override("separation", 8)
cols.add_child(side)
var st := _label(26, TEXT, side)
st.text = "ОБЩИЙ РЕЙТИНГ"
st.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
side.add_child(LbView.create(rating_id, 18, true))
_results_panel.visible = true
func _update_hud() -> void:
@@ -569,13 +667,18 @@ func _update_hud() -> void:
_split_label.visible = now < _split_until and racing
if racing:
var n: int = Data.CHECKPOINTS.size()
if finish.has(my_id):
if gave_up.has(my_id):
_timer_label.text = "СДАЛСЯ"
_timer_label.add_theme_color_override("font_color", DIM)
elif 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()]
_place_label.text = "МЕСТО %d/%d" % [_my_place(), participants.size() - gave_up.size()]
_place_label.visible = not gave_up.has(my_id)
_gates_label.visible = not gave_up.has(my_id)
_gates_label.text = "ВОРОТА %d/%d" % [passed.get(my_id, 0), n]
# центр: отсчёт, «СТАРТ!»
@@ -594,6 +697,20 @@ func _update_hud() -> void:
_center_label.add_theme_font_size_override("font_size", 110)
_center_label.text = "СТАРТ!"
# сдача: подсказка первые секунды гонки, при удержании G — «СДАЮСЬ…» и полоска
var can_give := phase == Phase.RACE and participants.has(my_id) and not finish.has(my_id) and not gave_up.has(my_id)
_giveup_bar.visible = can_give and _giveup_hold > 0.0
_giveup_bar.value = _giveup_hold
_giveup_label.visible = can_give and (_giveup_hold > 0.0 or race_time() < 12.0)
if _giveup_hold > 0.0:
_giveup_label.text = "СДАЮСЬ…"
_giveup_label.add_theme_font_size_override("font_size", 24)
_giveup_label.add_theme_color_override("font_color", TEXT)
else:
_giveup_label.text = "G (удерживать) — сдаться"
_giveup_label.add_theme_font_size_override("font_size", 16)
_giveup_label.add_theme_color_override("font_color", DIM)
# подсказка внизу
_hint_label.visible = true
if phase == Phase.WAIT: