- 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
351 lines
14 KiB
GDScript
351 lines
14 KiB
GDScript
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
|
|
|
|
signal peer_respawned(pos: Vector3) # Чужой игрок возродился в точке pos
|
|
signal race_event(id: int, kind: int, a: int, b: float) # Событие гонки от игрока id (кроме нас самих)
|
|
|
|
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
|
|
|
|
# Сервер
|
|
var peer_maps := {} # peer_id -> карта ("" пока клиент не сообщил)
|
|
var rock_owners := {} # карта -> peer_id владельца камня
|
|
var rock_seen := {} # карта -> время (сек), когда владелец последний раз присылал камень
|
|
|
|
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:
|
|
_start_server()
|
|
else:
|
|
_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)
|
|
_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 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)
|
|
|
|
# ---------- Клиент ----------
|
|
|
|
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
|
|
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
|
|
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)
|
|
|
|
# Сообщаем остальным о смерти и возрождении своего игрока
|
|
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()
|
|
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)
|