extends Node # Автолоад "Net". Один и тот же скрипт работает и на клиенте, и на выделенном сервере # (RPC требуют одинаковый путь узла и скрипт). Сервер — это headless-запуск Godot. # # Игроки делятся на «комнаты» по картам: видят друг друга и общий камень-преследователь # только те, кто сейчас на одной карте. Сервер ничего не знает об уровнях — он лишь пересылает. const PORT = 9080 const DEFAULT_URL = "ws://127.0.0.1:9080" const SEND_RATE = 20.0 const RETRY_DELAY = 3.0 const ROCK_TIMEOUT = 3.0 # Владелец камня молчит дольше (свернул вкладку?) — камень берёт другой const FLAG_SLIDING = 1 const FLAG_WALLRUN = 2 const FLAG_DASH = 4 const FLAG_MANTLE = 8 const FLAG_WALL_RIGHT = 16 # Бег по стене: стена справа (иначе слева) const FLAG_AIR = 32 # В воздухе (не на полу, не на стене, не на уступе) # Виды событий гонки (см. maps/race/race_rules.gd): сервер их не разбирает, только пересылает const RACE_READY = 0 # a: 1 — готов, 0 — нет 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}, "race2": {"min": 44.0}, "parkour": {"min": 40.0}} const MAP_TITLES := {"race": "Гонка", "race2": "Гонка 2", "parkour": "Паркур", "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 var send_timer := 0.0 var connected := false # Клиент: на какой мы карте. map_ready — сервер подтвердил, что мы в её комнате. var current_map := "world" var map_ready := false # Преследователи (камень, Верити…): их все считает один клиент (владелец), остальные повторяют var chasers := {} # id -> Node3D; преследователи этой сцены, регистрируют себя сами var chaser_states := {} # id -> {"pos": Vector3, "yaw": float} — что прислал владелец 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() # ---------- Сервер ---------- func _start_server() -> void: var peer := WebSocketMultiplayerPeer.new() var err := peer.create_server(PORT) if err != OK: push_error("Не удалось открыть порт %d: %s" % [PORT, error_string(err)]) get_tree().quit(1) return multiplayer.multiplayer_peer = peer multiplayer.peer_connected.connect(_server_peer_joined) multiplayer.peer_disconnected.connect(_server_peer_left) print("Сервер запущен на порту ", PORT) func _server_peer_joined(id: int) -> void: print("+ игрок ", id) peer_maps[id] = "" 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 func _room(map: String, except := 0) -> Array: var out := [] if map == "": return out for p in multiplayer.get_peers(): if p != except and peer_maps.get(p, "") == map: out.append(p) return out # Игрок id покинул комнату map (вышел или сменил карту) func _leave_room(id: int, map: String) -> void: if map == "": return for other in _room(map, id): recv_left.rpc_id(other, id) if rock_owners.get(map, 0) == id: _set_rock_owner(map, _next_rock_owner(map, id)) # Новый владелец камня — наименьший peer_id в комнате, кроме except (0 — никого) func _next_rock_owner(map: String, except: int) -> int: var next := 0 for p in _room(map, except): if next == 0 or p < next: next = p return next func _set_rock_owner(map: String, id: int) -> void: rock_owners[map] = id rock_seen[map] = Time.get_ticks_msec() / 1000.0 if id != 0: print("[%s] камень считает игрок %d" % [map, id]) for p in _room(map): recv_rock_owner.rpc_id(p, id) # Владелец перестал присылать камень (вкладка в фоне, лаги) — передаём другому func _server_check_rock_owner() -> void: var now := Time.get_ticks_msec() / 1000.0 for map in rock_owners.keys(): var owner: int = rock_owners[map] if owner != 0 and now - rock_seen.get(map, now) > ROCK_TIMEOUT: var next := _next_rock_owner(map, owner) if next != 0: _set_rock_owner(map, next) # Клиент -> сервер: я теперь на этой карте. Подтверждение приходит в recv_map_ack. @rpc("any_peer", "reliable") func submit_map(map: String) -> void: if not is_server: return var id := multiplayer.get_remote_sender_id() var old: String = peer_maps.get(id, "") peer_maps[id] = map 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: recv_rock_owner.rpc_id(id, rock_owners[map]) # Клиент -> сервер: своё состояние. Сервер пересылает остальным на той же карте. @rpc("any_peer", "unreliable_ordered") func submit_state(pos: Vector3, yaw: float, pitch: float, flags: int) -> void: if not is_server: return var sender := multiplayer.get_remote_sender_id() for other in _room(peer_maps.get(sender, ""), sender): recv_state.rpc_id(other, sender, pos, yaw, pitch, flags) # Владелец -> сервер: позиции всех преследователей. Сервер пересылает остальным на той же карте. @rpc("any_peer", "unreliable_ordered") func submit_chasers(ids: PackedStringArray, positions: PackedVector3Array, yaws: PackedFloat32Array) -> void: if not is_server: return var sender := multiplayer.get_remote_sender_id() var map: String = peer_maps.get(sender, "") if map == "" or rock_owners.get(map, 0) != sender: return rock_seen[map] = Time.get_ticks_msec() / 1000.0 for other in _room(map, sender): recv_chasers.rpc_id(other, ids, positions, yaws) # Клиент -> сервер: его поймали (dead) или он возродился в точке pos. @rpc("any_peer", "reliable") func submit_life(dead: bool, pos: Vector3) -> void: if not is_server: return var sender := multiplayer.get_remote_sender_id() for other in _room(peer_maps.get(sender, ""), sender): recv_life.rpc_id(other, sender, dead, pos) # Клиент -> сервер: событие гонки (RACE_*). Сервер пересылает остальным на той же карте. @rpc("any_peer", "reliable") func submit_race(kind: int, a: int, b: float) -> void: if not is_server: return var sender := multiplayer.get_remote_sender_id() 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 _save_leaderboard() # новый рекорд пишем сразу: рекорды редки, а перезапуск службы (SIGTERM) иначе терял бы последние ≤ SAVE_EVERY секунд 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="): return arg.substr("--server-url=".length()) if OS.has_feature("web"): var proto = JavaScriptBridge.eval("window.location.protocol") var host = JavaScriptBridge.eval("window.location.host") return ("wss://" if proto == "https:" else "ws://") + str(host) + "/ws" return DEFAULT_URL func _connect_client() -> void: var peer := WebSocketMultiplayerPeer.new() var err := peer.create_client(_server_url()) if err != OK: push_warning("Не удалось подключиться: %s" % error_string(err)) _schedule_retry() return multiplayer.multiplayer_peer = peer if not multiplayer.connected_to_server.is_connected(_on_connected): multiplayer.connected_to_server.connect(_on_connected) multiplayer.connection_failed.connect(_on_lost) multiplayer.server_disconnected.connect(_on_lost) func _on_connected() -> void: connected = true _send_profile() # раньше submit_map: при входе в комнату сервер уже знает наш ник submit_map.rpc_id(1, current_map) func _on_lost() -> void: connected = false _reset_room_state() multiplayer.multiplayer_peer = null _schedule_retry() func _schedule_retry() -> void: get_tree().create_timer(RETRY_DELAY).timeout.connect(_connect_client) # Забываем всё, что знали про комнату: чужих игроков, владельца и позицию камня func _reset_room_state() -> void: for g in ghosts.values(): if is_instance_valid(g): g.queue_free() ghosts.clear() dead_peers.clear() rock_owner = 0 chaser_states.clear() hooked_player = null nicks.clear() map_ready = false # Мы перешли на другую карту (вызывает сама карта при загрузке, идемпотентно) func enter_map(map: String) -> void: if map == current_map: return current_map = map _reset_room_state() if connected: submit_map.rpc_id(1, map) # Портал: сообщаем серверу о смене карты и загружаем сцену func go_to_map(map: String, scene_path: String) -> void: enter_map(map) get_tree().change_scene_to_file.call_deferred(scene_path) # Камень считаем сами: если не в сети или мы владелец. Пока сервер не назначил владельца — ждём. func rock_is_local() -> bool: if not connected: return true return rock_owner == multiplayer.get_unique_id() @rpc("authority", "reliable") func recv_map_ack(map: String) -> void: if map == current_map: map_ready = true @rpc("authority", "reliable") func recv_rock_owner(id: int) -> void: if map_ready: rock_owner = id @rpc("authority", "unreliable_ordered") func recv_chasers(ids: PackedStringArray, positions: PackedVector3Array, yaws: PackedFloat32Array) -> void: if not map_ready: return for i in ids.size(): chaser_states[ids[i]] = {"pos": positions[i], "yaw": yaws[i]} @rpc("authority", "reliable") func recv_life(id: int, dead: bool, pos: Vector3) -> void: if not map_ready: return if dead: dead_peers[id] = true else: dead_peers.erase(id) peer_respawned.emit(pos) @rpc("authority", "unreliable_ordered") func recv_state(id: int, pos: Vector3, yaw: float, pitch: float, flags: int) -> void: if not map_ready or get_tree().current_scene == null: return # Идёт смена сцены — следующий пакет придёт через 50 мс var ghost: Ghost = ghosts.get(id) if ghost == null: ghost = Ghost.new() ghost.setup(id, pos) ghosts[id] = ghost get_tree().current_scene.add_child(ghost) ghost.set_target(pos, yaw, pitch, flags) @rpc("authority", "reliable") func recv_race(id: int, kind: int, a: int, b: float) -> void: if map_ready: race_event.emit(id, kind, a, b) # Отправить своё событие гонки остальным в комнате (без сети — молча ничего) func send_race(kind: int, a: int, b: float) -> void: if connected and map_ready: submit_race.rpc_id(1, kind, a, b) @rpc("authority", "reliable") func recv_left(id: int) -> void: var ghost = ghosts.get(id) if ghost: ghost.queue_free() ghosts.erase(id) dead_peers.erase(id) nicks.erase(id) # Сообщаем остальным о смерти и возрождении своего игрока func _hook_player(player: Node) -> void: if hooked_player == player: return hooked_player = player player.died.connect(func(): _send_life(true, player)) player.respawned.connect(func(): _send_life(false, player)) func _send_life(dead: bool, player: Node) -> void: if connected and map_ready: submit_life.rpc_id(1, dead, player.global_position) func _process(delta: float) -> void: if is_server: _server_check_rock_owner() _server_flush_leaderboard() return if not connected or not map_ready: return send_timer += delta if send_timer < 1.0 / SEND_RATE: return send_timer = 0.0 var player := get_tree().get_first_node_in_group("local_player") if player == null: return _hook_player(player) submit_state.rpc_id(1, player.global_position, player.rotation.y, player.head.rotation.x, player.get_net_flags()) if not chasers.is_empty() and rock_is_local(): var ids := PackedStringArray() var positions := PackedVector3Array() var yaws := PackedFloat32Array() for id in chasers: var c: Node3D = chasers[id] if is_instance_valid(c): ids.append(id) positions.append(c.global_position) yaws.append(c.rotation.y) if not ids.is_empty(): submit_chasers.rpc_id(1, ids, positions, yaws)