Ники, общий рейтинг (топ-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
+8 -2
View File
@@ -28,6 +28,7 @@ var pitch := 0.0
var flags := 0
var velocity := Vector3.ZERO # Сглаженная скорость по сетевым пакетам, м/с
var label: Label3D
var anim: AnimationPlayer
var skeleton: Skeleton3D
var head_bone := -1
@@ -56,8 +57,9 @@ func setup(peer_id: int, pos: Vector3) -> void:
anim.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL # кадры двигаем сами (см. _process)
_paint_glow(model)
var label := Label3D.new()
label.text = "Игрок %d" % (id % 1000)
label = Label3D.new()
label.text = Net.nick_of(id) # обычный текст, не разметка: чужой ник не меняет оформление
Net.nick_changed.connect(_on_nick_changed)
label.modulate = color
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.no_depth_test = true
@@ -65,6 +67,10 @@ func setup(peer_id: int, pos: Vector3) -> void:
label.pixel_size = 0.005
add_child(label)
func _on_nick_changed(changed_id: int) -> void:
if changed_id == id and label != null:
label.text = Net.nick_of(id)
# Материал «Glow» модели заменяем своим — с цветом игрока (общий материал модели остался бы одним на всех)
func _paint_glow(model: Node) -> void:
var mat := StandardMaterial3D.new()
+355
View File
@@ -24,9 +24,25 @@ const RACE_START = 1 # b: unix-время старта (секунды, час
const RACE_CP = 2 # a: индекс пройденных ворот, b: время гонки, с
const RACE_FINISH = 3 # b: время финиша, с
const RACE_STATE = 4 # «маяк» раз в секунду: a — биты состояния (см. race_rules.gd), b — время финиша или -1
const RACE_GIVEUP = 5 # игрок сдался (b: время гонки на момент сдачи); в рейтинг ничего не идёт
# Рейтинг лучших времён. Список карт «на время» и минимально допустимое время (страховка от мусора, не античит;
# подкручивается здесь — см. README «Ники и рейтинг»). Сервер принимает только эти id; клиент по ним же решает, где рисовать доски.
const TIMED_MAPS := {"race": {"min": 25.0}, "parkour": {"min": 40.0}, "metro": {"min": 25.0}}
const MAP_TITLES := {"race": "Гонка", "parkour": "Паркур", "metro": "Метро", "hub": "Хаб", "world": "Песочница", "backrooms": "Backrooms"}
const MAX_RECORD_TIME := 3600.0
const BOARD_TOP := 10 # сколько строк уходит клиенту
const BOARD_KEEP := 100 # сколько записей на карту хранит сервер
const SAVE_EVERY := 2.0 # не чаще раза в столько секунд сервер пишет файл
const RECORDS_PER_MIN := 6 # лимит submit_record на игрока
const REQUESTS_PER_10S := 12 # лимит request_leaderboard на игрока
const NICK_MAX := 16
signal peer_respawned(pos: Vector3) # Чужой игрок возродился в точке pos
signal race_event(id: int, kind: int, a: int, b: float) # Событие гонки от игрока id (кроме нас самих)
signal nick_changed(id: int) # Ник игрока id (или наш, id == своему) стал известен/изменился
signal leaderboard_updated(map_id: String) # Пришла таблица рейтинга
signal record_result(map_id: String, time: float, rank: int, personal: bool, map_record: bool)
var is_server := false
var ghosts := {} # peer_id -> Ghost
@@ -44,17 +60,35 @@ var rock_owner := 0 # peer_id владельца (0 — ещё не на
var dead_peers := {} # peer_id -> true, пока игрока поймали
var hooked_player: Node = null
# Профиль игрока (клиент): user://profile.cfg. client_id — случайный 128-битный hex, опознаёт игрока в рейтинге, не показывается
var my_client_id := ""
var my_nick := "" # "" — не задан (показываем «Игрок N»)
var nick_asked := false # уже спрашивали ник (ввёл или пропустил) — больше в хабе не спрашиваем
var nicks := {} # peer_id -> ник игроков комнаты (без нас самих)
var input_blocked := false # открыта панель ника: игровой ввод не работает (отдельно от player.input_frozen гонки)
var boards := {} # map_id -> {"top": Array, "my_rank": int, "my_time": float, "tick": msec}
var last_record := {} # map_id -> {"time", "rank", "personal", "map_record"}: ответ на последний submit_record
# Сервер
var peer_maps := {} # peer_id -> карта ("" пока клиент не сообщил)
var rock_owners := {} # карта -> peer_id владельца камня
var rock_seen := {} # карта -> время (сек), когда владелец последний раз присылал камень
var peer_client := {} # peer_id -> client_id
var client_nick := {} # client_id -> ник (нормализованный)
var lb := {} # карта -> {client_id: {"nick": String, "time": float, "ts": int}}
var lb_dirty := false
var lb_last_save := 0.0
var record_log := {} # peer_id -> [msec, ...] свои submit_record за последнюю минуту
var request_log := {} # peer_id -> [msec, ...] request_leaderboard за последние 10 с
func _ready() -> void:
# Сервер — это headless-запуск; --client делает клиентом и headless (боты для тестов, tools/test)
is_server = DisplayServer.get_name() == "headless" and not OS.get_cmdline_user_args().has("--client")
if is_server:
_load_leaderboard()
_start_server()
else:
_load_profile()
_connect_client()
# ---------- Сервер ----------
@@ -79,6 +113,9 @@ func _server_peer_left(id: int) -> void:
print("- игрок ", id)
var map: String = peer_maps.get(id, "")
peer_maps.erase(id)
peer_client.erase(id)
record_log.erase(id)
request_log.erase(id)
_leave_room(id, map)
# Игроки комнаты map, кроме except
@@ -137,6 +174,8 @@ func submit_map(map: String) -> void:
if old != map:
_leave_room(id, old)
recv_map_ack.rpc_id(id, map)
if old != map:
_share_nicks(id, map)
if rock_owners.get(map, 0) == 0:
_set_rock_owner(map, id)
else:
@@ -182,8 +221,320 @@ func submit_race(kind: int, a: int, b: float) -> void:
for other in _room(peer_maps.get(sender, ""), sender):
recv_race.rpc_id(other, sender, kind, a, b)
# ---------- Сервер: ники ----------
# Нормализация ника (клиент и сервер используют одну функцию): переводы строк и табуляция -> пробел, остальные
# управляющие и невидимые символы выбрасываются, пробелы по краям обрезаются, не более NICK_MAX символов; пусто -> «Игрок N»
static func normalize_nick(raw: String, id: int) -> String:
var out := ""
for i in raw.length():
var c := raw.unicode_at(i)
if c == 9 or c == 10 or c == 13:
out += " "
elif c < 32 or c == 127 or (c >= 0x80 and c <= 0x9F) or (c >= 0x200B and c <= 0x200F) \
or (c >= 0x202A and c <= 0x202E) or (c >= 0x2066 and c <= 0x2069) or c == 0xFEFF or c == 0x2028 or c == 0x2029:
continue
else:
out += String.chr(c)
out = out.strip_edges()
if out.length() > NICK_MAX:
out = out.substr(0, NICK_MAX).strip_edges()
if out == "":
return default_nick(id)
return out
static func default_nick(id: int) -> String:
return "Игрок %d" % (id % 1000)
func _valid_client_id(cid: String) -> bool:
if cid.length() < 8 or cid.length() > 64:
return false
for i in cid.length():
var c := cid.unicode_at(i)
if not ((c >= 48 and c <= 57) or (c >= 97 and c <= 122) or (c >= 65 and c <= 90)):
return false
return true
# Игрок id пришёл в комнату map: ему — ники присутствующих, остальным — его ник
func _share_nicks(id: int, map: String) -> void:
for other in _room(map, id):
var n := _nick_of_peer(other)
if n != "":
recv_nick.rpc_id(id, other, n)
var mine := _nick_of_peer(id)
if mine != "":
for other in _room(map, id):
recv_nick.rpc_id(other, id, mine)
func _nick_of_peer(id: int) -> String:
var cid: String = peer_client.get(id, "")
return client_nick.get(cid, "") if cid != "" else ""
# Клиент -> сервер: профиль (после подключения и при смене ника). client_id опознаёт игрока в рейтинге.
@rpc("any_peer", "reliable")
func submit_profile(client_id: String, nick: String) -> void:
if not is_server:
return
var id := multiplayer.get_remote_sender_id()
if not _valid_client_id(client_id):
return
var norm := normalize_nick(nick, id)
peer_client[id] = client_id
client_nick[client_id] = norm
for map in lb:
if lb[map].has(client_id) and lb[map][client_id].nick != norm:
lb[map][client_id].nick = norm
lb_dirty = true
for other in _room(peer_maps.get(id, ""), id):
recv_nick.rpc_id(other, id, norm)
# ---------- Сервер: рейтинг ----------
func _lb_path() -> String:
return "user://leaderboard.json"
func _load_leaderboard() -> void:
lb = {}
for m in TIMED_MAPS:
lb[m] = {}
var path := _lb_path()
if not FileAccess.file_exists(path):
return
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
if not (parsed is Dictionary) or not (parsed.get("maps") is Dictionary):
push_warning("leaderboard.json испорчен, начинаю с пустого (файл сохранён как .bad)")
DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".bad")
return
var total := 0
for m in TIMED_MAPS:
var entries = parsed.maps.get(m)
if not (entries is Dictionary):
continue
for cid in entries:
var e = entries[cid]
if not (cid is String) or not _valid_client_id(cid) or not (e is Dictionary):
continue
var t = e.get("time")
if not (t is float or t is int) or not is_finite(t) or t <= 0.0 or t > MAX_RECORD_TIME:
continue
var nick = e.get("nick", "")
lb[m][cid] = {"nick": normalize_nick(nick if nick is String else "", 0), "time": float(t), "ts": int(e.get("ts", 0)) if (e.get("ts") is float or e.get("ts") is int) else 0}
_trim_board(m)
total += lb[m].size()
print("Рейтинг загружен: %d записей" % total)
func _trim_board(m: String) -> void:
var board: Dictionary = lb[m]
if board.size() <= BOARD_KEEP:
return
var ids := _sorted_ids(m)
for i in range(BOARD_KEEP, ids.size()):
board.erase(ids[i])
# client_id карты по возрастанию времени (при равенстве — кто раньше поставил)
func _sorted_ids(m: String) -> Array:
var board: Dictionary = lb[m]
var ids := board.keys()
ids.sort_custom(func(a, b) -> bool:
var ea: Dictionary = board[a]
var eb: Dictionary = board[b]
if ea.time != eb.time:
return ea.time < eb.time
if ea.ts != eb.ts:
return ea.ts < eb.ts
return a < b)
return ids
# Атомарная запись: во временный файл, затем переименование
func _save_leaderboard() -> void:
lb_dirty = false
lb_last_save = Time.get_ticks_msec() / 1000.0
var path := _lb_path()
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_warning("Не удалось записать рейтинг: %s" % error_string(FileAccess.get_open_error()))
lb_dirty = true
return
f.store_string(JSON.stringify({"version": 1, "maps": lb}))
f.close()
var err := DirAccess.rename_absolute(ProjectSettings.globalize_path(tmp), ProjectSettings.globalize_path(path))
if err != OK:
push_warning("Не удалось переименовать рейтинг: %s" % error_string(err))
lb_dirty = true
func _server_flush_leaderboard() -> void:
if lb_dirty and Time.get_ticks_msec() / 1000.0 - lb_last_save >= SAVE_EVERY:
_save_leaderboard()
func _notification(what: int) -> void:
if is_server and what == NOTIFICATION_WM_CLOSE_REQUEST and lb_dirty:
_save_leaderboard()
# Лимит частоты: не более limit событий за window_ms на игрока (лишнее молча отбрасывается)
func _rate_ok(log: Dictionary, id: int, limit: int, window_ms: int) -> bool:
var now := Time.get_ticks_msec()
var arr: Array = log.get(id, [])
while not arr.is_empty() and now - arr[0] > window_ms:
arr.pop_front()
log[id] = arr
if arr.size() >= limit:
return false
arr.append(now)
return true
func _board_top(m: String) -> Array:
var out := []
var ids := _sorted_ids(m)
for i in mini(BOARD_TOP, ids.size()):
var e: Dictionary = lb[m][ids[i]]
out.append([e.nick, e.time, e.ts])
return out
func _send_board(peer: int, m: String) -> void:
var cid: String = peer_client.get(peer, "")
var rank := 0
var my_time := 0.0
if cid != "" and lb[m].has(cid):
my_time = lb[m][cid].time
rank = _sorted_ids(m).find(cid) + 1
recv_leaderboard.rpc_id(peer, m, _board_top(m), rank, my_time)
# Клиент -> сервер: мой результат на карте на время. Берётся лучшее время игрока; ответ — recv_record_result + recv_leaderboard.
@rpc("any_peer", "reliable")
func submit_record(map_id: String, time: float) -> void:
if not is_server:
return
var id := multiplayer.get_remote_sender_id()
if not _rate_ok(record_log, id, RECORDS_PER_MIN, 60000):
return
if not TIMED_MAPS.has(map_id) or peer_maps.get(id, "") != map_id:
return
if not is_finite(time) or time < float(TIMED_MAPS[map_id].min) or time > MAX_RECORD_TIME:
return
var cid: String = peer_client.get(id, "")
if cid == "":
return
var nick: String = client_nick.get(cid, default_nick(id))
var board: Dictionary = lb[map_id]
var old = board.get(cid)
var personal: bool = old == null or time < old.time
if personal:
board[cid] = {"nick": nick, "time": time, "ts": int(Time.get_unix_time_from_system())}
_trim_board(map_id)
lb_dirty = true
elif old.nick != nick:
old.nick = nick
lb_dirty = true
var rank := _sorted_ids(map_id).find(cid) + 1
print("[рекорд] %s %s %.3f (место %d%s)" % [map_id, nick, time, rank, ", личный" if personal else ""])
recv_record_result.rpc_id(id, map_id, time, rank, personal, personal and rank == 1)
_send_board(id, map_id)
# Клиент -> сервер: пришли топ-10 карты и моё место
@rpc("any_peer", "reliable")
func request_leaderboard(map_id: String) -> void:
if not is_server:
return
var id := multiplayer.get_remote_sender_id()
if not _rate_ok(request_log, id, REQUESTS_PER_10S, 10000):
return
if not TIMED_MAPS.has(map_id):
return
_send_board(id, map_id)
# ---------- Клиент ----------
func _profile_path() -> String:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--profile="): # для тестов: не трогаем настоящий профиль
return arg.substr("--profile=".length())
return "user://profile.cfg"
func _load_profile() -> void:
var cf := ConfigFile.new()
cf.load(_profile_path())
my_client_id = str(cf.get_value("profile", "client_id", ""))
if not _valid_client_id(my_client_id):
my_client_id = Crypto.new().generate_random_bytes(16).hex_encode()
cf.set_value("profile", "client_id", my_client_id)
cf.save(_profile_path())
var saved := str(cf.get_value("profile", "nick", ""))
my_nick = normalize_nick(saved, 0) if saved.strip_edges() != "" else ""
nick_asked = bool(cf.get_value("profile", "asked", my_nick != ""))
func _save_profile() -> void:
var cf := ConfigFile.new()
cf.load(_profile_path())
cf.set_value("profile", "client_id", my_client_id)
cf.set_value("profile", "nick", my_nick)
cf.set_value("profile", "asked", nick_asked)
cf.save(_profile_path())
# Задать свой ник (пустая строка — «Игрок N»). Сохраняется, уходит серверу и комнате.
func set_nick(raw: String) -> void:
var n := normalize_nick(raw, 0) if raw.strip_edges() != "" else ""
my_nick = n
nick_asked = true
_save_profile()
_send_profile()
nick_changed.emit(own_id())
# Пропустили ввод ника: больше не спрашиваем
func skip_nick() -> void:
nick_asked = true
_save_profile()
func _send_profile() -> void:
if connected:
submit_profile.rpc_id(1, my_client_id, my_nick)
func own_id() -> int:
return multiplayer.get_unique_id() if connected else 1
# Единая точка: ник игрока id (наш — my_nick), запасной вариант «Игрок N»
func nick_of(id: int) -> String:
if id == own_id():
return my_nick if my_nick != "" else default_nick(id)
return nicks.get(id, default_nick(id))
# Время рекорда «мм:сс.мс»
static func fmt_time(t: float) -> String:
t = maxf(t, 0.0)
var ms := int(round(t * 1000.0))
return "%02d:%02d.%03d" % [ms / 60000, (ms / 1000) % 60, ms % 1000]
func submit_my_record(map_id: String, time: float) -> void:
last_record.erase(map_id)
if connected:
submit_record.rpc_id(1, map_id, time)
# Запросить таблицу карты (не чаще, чем раз в max_age_ms)
func request_board(map_id: String, max_age_ms := 0) -> void:
if not connected or not TIMED_MAPS.has(map_id):
return
var b = boards.get(map_id)
if b != null and Time.get_ticks_msec() - b.tick < max_age_ms:
return
request_leaderboard.rpc_id(1, map_id)
@rpc("authority", "reliable")
func recv_nick(id: int, nick: String) -> void:
if not map_ready:
return
nicks[id] = normalize_nick(nick, id)
nick_changed.emit(id)
@rpc("authority", "reliable")
func recv_leaderboard(map_id: String, top: Array, my_rank: int, my_time: float) -> void:
boards[map_id] = {"top": top, "my_rank": my_rank, "my_time": my_time, "tick": Time.get_ticks_msec()}
leaderboard_updated.emit(map_id)
@rpc("authority", "reliable")
func recv_record_result(map_id: String, time: float, rank: int, personal: bool, map_record: bool) -> void:
last_record[map_id] = {"time": time, "rank": rank, "personal": personal, "map_record": map_record}
record_result.emit(map_id, time, rank, personal, map_record)
func _server_url() -> String:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--server-url="):
@@ -209,6 +560,7 @@ func _connect_client() -> void:
func _on_connected() -> void:
connected = true
_send_profile() # раньше submit_map: при входе в комнату сервер уже знает наш ник
submit_map.rpc_id(1, current_map)
func _on_lost() -> void:
@@ -230,6 +582,7 @@ func _reset_room_state() -> void:
rock_owner = 0
chaser_states.clear()
hooked_player = null
nicks.clear()
map_ready = false
# Мы перешли на другую карту (вызывает сама карта при загрузке, идемпотентно)
@@ -308,6 +661,7 @@ func recv_left(id: int) -> void:
ghost.queue_free()
ghosts.erase(id)
dead_peers.erase(id)
nicks.erase(id)
# Сообщаем остальным о смерти и возрождении своего игрока
func _hook_player(player: Node) -> void:
@@ -324,6 +678,7 @@ func _send_life(dead: bool, player: Node) -> void:
func _process(delta: float) -> void:
if is_server:
_server_check_rock_owner()
_server_flush_leaderboard()
return
if not connected or not map_ready:
return