Ники, общий рейтинг (топ-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
+220
View File
@@ -0,0 +1,220 @@
extends Node
# Автолоад «Overlay»: общие панели поверх любой карты — ввод ника (клавиша N, первый запуск в хабе)
# и таблица лидеров (Tab). Пока открыта панель ника, Net.input_blocked = true: игрок (player.gd) стоит и не
# обрабатывает игровые клавиши. Это отдельный флаг, НЕ player.input_frozen (его ведёт гонка на отсчёте).
const TEXT := Color(0.92, 0.88, 0.8)
const DIM := Color(0.62, 0.6, 0.56)
const MINE := Color(0.96, 0.76, 0.44)
var nick_open := false
var lb_open := false
var _first_run := false
var _nick_layer: CanvasLayer
var _nick_title: Label
var _edit: LineEdit
var _lb_layer: CanvasLayer
var _lb_title: Label
var _lb_tabs: HBoxContainer
var _lb_holder: Control
var _lb_hint: Label
var _lb_view: LbView
var _lb_maps: Array = []
var _lb_index := 0
var _lb_for_map := ""
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
_build_nick_panel()
_build_lb_panel()
# ---------- ввод ника ----------
func _build_nick_panel() -> void:
_nick_layer = CanvasLayer.new()
_nick_layer.layer = 20
_nick_layer.visible = false
add_child(_nick_layer)
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nick_layer.add_child(root)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", LbView.panel_style(26))
root.add_child(panel)
panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
panel.grow_horizontal = Control.GROW_DIRECTION_BOTH
panel.grow_vertical = Control.GROW_DIRECTION_BOTH
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 14)
panel.add_child(box)
_nick_title = LbView.make_label("Введите ник", 28, TEXT)
_nick_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_nick_title)
_edit = LineEdit.new()
_edit.max_length = Net.NICK_MAX
_edit.custom_minimum_size = Vector2(320, 40)
_edit.add_theme_font_size_override("font_size", 22)
_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER
_edit.placeholder_text = "до %d символов" % Net.NICK_MAX
_edit.context_menu_enabled = false
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.015, 0.018, 0.02, 0.95)
sb.border_color = Color(0.45, 0.30, 0.16, 0.9)
sb.set_border_width_all(1)
sb.set_content_margin_all(8)
_edit.add_theme_stylebox_override("normal", sb)
_edit.add_theme_stylebox_override("focus", sb)
_edit.add_theme_color_override("font_color", TEXT)
box.add_child(_edit)
_edit.focus_next = _edit.get_path() # Tab не уводит фокус
_edit.focus_previous = _edit.get_path()
_edit.text_submitted.connect(_on_nick_submitted)
_edit.gui_input.connect(_on_edit_gui_input)
var hint := LbView.make_label("Enter — подтвердить · Esc — пропустить", 16, DIM)
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(hint)
func open_nick(first_run := false) -> void:
if nick_open:
return
nick_open = true
_first_run = first_run
Net.input_blocked = true
if lb_open:
close_lb()
_nick_title.text = "Введите ник" if (first_run or Net.my_nick == "") else "Смена ника"
_edit.text = Net.my_nick
_edit.caret_column = _edit.text.length()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_nick_layer.visible = true
_edit.grab_focus.call_deferred() # открывающая клавиша уже обработана и в поле не попадает
func _close_nick(apply: bool) -> void:
if not nick_open:
return
if apply:
Net.set_nick(_edit.text)
elif _first_run:
Net.skip_nick()
nick_open = false
Net.input_blocked = false
_nick_layer.visible = false
_edit.release_focus()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _on_nick_submitted(_text: String) -> void:
_close_nick(true)
func _on_edit_gui_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
_edit.accept_event()
_close_nick(false)
# ---------- таблица лидеров ----------
func _build_lb_panel() -> void:
_lb_layer = CanvasLayer.new()
_lb_layer.layer = 14
_lb_layer.visible = false
add_child(_lb_layer)
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_lb_layer.add_child(root)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", LbView.panel_style(18))
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(panel)
# справа по центру: сверху остаётся таймер гонки, в центре — отсчёт, слева — место и ворота
panel.anchor_left = 1.0
panel.anchor_right = 1.0
panel.anchor_top = 0.5
panel.anchor_bottom = 0.5
panel.offset_right = -48.0
panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
panel.grow_vertical = Control.GROW_DIRECTION_BOTH
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 8)
panel.add_child(box)
_lb_title = LbView.make_label("", 24, TEXT)
box.add_child(_lb_title)
_lb_tabs = HBoxContainer.new()
_lb_tabs.add_theme_constant_override("separation", 14)
box.add_child(_lb_tabs)
var hold := VBoxContainer.new()
hold.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(hold)
_lb_holder = hold
_lb_hint = LbView.make_label("", 14, DIM)
box.add_child(_lb_hint)
func _lb_map_list() -> Array:
if Net.TIMED_MAPS.has(Net.current_map):
return [Net.current_map]
return Net.TIMED_MAPS.keys() # хаб и карты без рейтинга: вкладки по всем картам
func open_lb() -> void:
if lb_open or nick_open:
return
lb_open = true
_lb_for_map = Net.current_map
_lb_maps = _lb_map_list()
_lb_index = 0
_lb_view = LbView.create(_lb_maps[0], 18)
_lb_holder.add_child(_lb_view)
_lb_layer.visible = true
_lb_refresh()
func close_lb() -> void:
if not lb_open:
return
lb_open = false
_lb_layer.visible = false
if _lb_view != null:
_lb_holder.remove_child(_lb_view)
_lb_view.queue_free()
_lb_view = null
func _lb_refresh() -> void:
var m: String = _lb_maps[_lb_index]
_lb_title.text = "ЛУЧШИЕ ВРЕМЕНА · " + Net.MAP_TITLES.get(m, m).to_upper() if _lb_maps.size() == 1 else "ЛУЧШИЕ ВРЕМЕНА"
for c in _lb_tabs.get_children():
c.queue_free()
_lb_tabs.visible = _lb_maps.size() > 1
for i in _lb_maps.size():
_lb_tabs.add_child(LbView.make_label(Net.MAP_TITLES.get(_lb_maps[i], _lb_maps[i]), 18, MINE if i == _lb_index else DIM))
_lb_hint.text = ("A / D — карта · " if _lb_maps.size() > 1 else "") + "Tab / Esc — закрыть"
_lb_view.set_map(m)
Net.request_board(m)
func _process(_delta: float) -> void:
if lb_open and _lb_for_map != Net.current_map:
close_lb() # перешли на другую карту через портал
func _unhandled_input(event: InputEvent) -> void:
if nick_open:
if event.is_action_pressed("ui_cancel"):
_close_nick(false)
return
if event.is_echo():
return
if event.is_action_pressed("nick"):
open_nick(false)
elif event.is_action_pressed("leaderboard"):
if lb_open:
close_lb()
else:
open_lb()
elif lb_open and event.is_action_pressed("ui_cancel"):
close_lb()
elif lb_open and _lb_maps.size() > 1:
var d := 0
if event.is_action_pressed("move_left"):
d = -1
elif event.is_action_pressed("move_right"):
d = 1
if d != 0:
_lb_index = posmod(_lb_index + d, _lb_maps.size())
_lb_refresh()