diff --git a/Net/ghost.gd b/Net/ghost.gd new file mode 100644 index 0000000..6808ddd --- /dev/null +++ b/Net/ghost.gd @@ -0,0 +1,66 @@ +class_name Ghost +extends Node3D +# Чужой игрок: капсула, которая плавно догоняет последнее полученное состояние. + +const SMOOTHING = 14.0 + +var target_pos := Vector3.ZERO +var target_yaw := 0.0 +var flags := 0 +var body: MeshInstance3D +var visor: MeshInstance3D + +func setup(id: int, pos: Vector3) -> void: + name = "Ghost_%d" % id + position = pos + target_pos = pos + var color := Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0) + + var mat := StandardMaterial3D.new() + mat.albedo_color = color + mat.emission_enabled = true + mat.emission = color + mat.emission_energy_multiplier = 0.6 + + var capsule := CapsuleMesh.new() + capsule.radius = 0.34 + capsule.height = 2.3 + body = MeshInstance3D.new() + body.mesh = capsule + body.material_override = mat + add_child(body) + + # «Визор» показывает, куда смотрит игрок (-Z — вперёд) + var box := BoxMesh.new() + box.size = Vector3(0.5, 0.15, 0.3) + visor = MeshInstance3D.new() + visor.mesh = box + visor.position = Vector3(0, 0.9, -0.25) + add_child(visor) + + var label := Label3D.new() + label.text = "Игрок %d" % (id % 1000) + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + label.no_depth_test = true + label.position = Vector3(0, 1.6, 0) + label.pixel_size = 0.005 + add_child(label) + +func set_target(pos: Vector3, yaw: float, pitch: float, new_flags: int) -> void: + target_pos = pos + target_yaw = yaw + flags = new_flags + visor.rotation.x = pitch + +func _process(delta: float) -> void: + var t := 1.0 - exp(-SMOOTHING * delta) + position = position.lerp(target_pos, t) + rotation.y = lerp_angle(rotation.y, target_yaw, t) + + var sliding := (flags & Net.FLAG_SLIDING) != 0 + var squash := 0.55 if sliding else 1.0 + body.scale = body.scale.lerp(Vector3(1, squash, 1), t) + var roll := 0.0 + if (flags & Net.FLAG_WALLRUN) != 0: + roll = 0.35 + body.rotation.z = lerp(body.rotation.z, roll, t) diff --git a/Net/ghost.gd.uid b/Net/ghost.gd.uid new file mode 100644 index 0000000..449e14b --- /dev/null +++ b/Net/ghost.gd.uid @@ -0,0 +1 @@ +uid://c8h6333310d0m diff --git a/Net/net.gd b/Net/net.gd new file mode 100644 index 0000000..05c16fd --- /dev/null +++ b/Net/net.gd @@ -0,0 +1,122 @@ +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 FLAG_SLIDING = 1 +const FLAG_WALLRUN = 2 +const FLAG_DASH = 4 +const FLAG_MANTLE = 8 + +var is_server := false +var ghosts := {} # peer_id -> Ghost +var send_timer := 0.0 +var connected := false + +func _ready() -> void: + is_server = DisplayServer.get_name() == "headless" + 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(func(id): print("+ игрок ", id)) + multiplayer.peer_disconnected.connect(_server_peer_left) + print("Сервер запущен на порту ", PORT) + +func _server_peer_left(id: int) -> void: + print("- игрок ", id) + for other in multiplayer.get_peers(): + recv_left.rpc_id(other, id) + +# Клиент -> сервер: своё состояние. Сервер пересылает остальным. +@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 multiplayer.get_peers(): + if other != sender: + recv_state.rpc_id(other, sender, pos, yaw, pitch, flags) + +# ---------- Клиент ---------- + +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 + +func _on_lost() -> void: + connected = false + for g in ghosts.values(): + g.queue_free() + ghosts.clear() + multiplayer.multiplayer_peer = null + _schedule_retry() + +func _schedule_retry() -> void: + get_tree().create_timer(RETRY_DELAY).timeout.connect(_connect_client) + +@rpc("authority", "unreliable_ordered") +func recv_state(id: int, pos: Vector3, yaw: float, pitch: float, flags: int) -> void: + 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_left(id: int) -> void: + var ghost = ghosts.get(id) + if ghost: + ghost.queue_free() + ghosts.erase(id) + +func _process(delta: float) -> void: + if is_server or not connected: + 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 + submit_state.rpc_id(1, player.global_position, player.rotation.y, player.head.rotation.x, player.get_net_flags()) diff --git a/Net/net.gd.uid b/Net/net.gd.uid new file mode 100644 index 0000000..38087eb --- /dev/null +++ b/Net/net.gd.uid @@ -0,0 +1 @@ +uid://dtwbyyjncecj2 diff --git a/Net/server.tscn b/Net/server.tscn new file mode 100644 index 0000000..105eb7d --- /dev/null +++ b/Net/server.tscn @@ -0,0 +1,3 @@ +[gd_scene format=3] + +[node name="Server" type="Node"] diff --git a/Player/character_body_3d.gd b/Player/character_body_3d.gd index 077847f..f575663 100644 --- a/Player/character_body_3d.gd +++ b/Player/character_body_3d.gd @@ -173,6 +173,7 @@ var default_collision_y: float = 0.0 @onready var head: Camera3D = $Camera3D func _ready() -> void: + add_to_group("local_player") Input.mouse_mode = Input.MOUSE_MODE_CAPTURED head_base_pos = head.position base_fov = head.fov @@ -223,6 +224,15 @@ func _unhandled_input(event: InputEvent) -> void: elif event.keycode == KEY_Q: _try_dash() # Рывок +# Битовые флаги состояния для сетевой синхронизации (см. Net.FLAG_*) +func get_net_flags() -> int: + var flags := 0 + if is_sliding: flags |= Net.FLAG_SLIDING + if is_wallrunning: flags |= Net.FLAG_WALLRUN + if is_dashing: flags |= Net.FLAG_DASH + if is_mantling: flags |= Net.FLAG_MANTLE + return flags + func _get_input_dir() -> Vector2: var input_dir := Vector2.ZERO if Input.is_key_pressed(KEY_D): diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..827389b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,17 @@ +# Мультиплеер: деплой + +## Сервер игры +Выделенный сервер — тот же проект, запущенный headless (порт 9080, plain WebSocket): + + godot --headless --path /путь/к/my-world res://Net/server.tscn + +Пример systemd-юнита: `deploy/my-world.service`. + +## nginx (TLS + статика) +См. `deploy/nginx.conf`. Страница по https подключается к `wss://<домен>/ws`, +nginx проксирует `/ws` на `127.0.0.1:9080`. + +## Клиент +Web-экспорт (пресет «Web») кладётся в корень сайта. Адрес сервера берётся из +`window.location`, на десктопе — `ws://127.0.0.1:9080` или +`godot -- --server-url=wss://example.com/ws`. diff --git a/deploy/my-world.service b/deploy/my-world.service new file mode 100644 index 0000000..15def89 --- /dev/null +++ b/deploy/my-world.service @@ -0,0 +1,11 @@ +[Unit] +Description=My World game server +After=network.target + +[Service] +ExecStart=/usr/bin/godot --headless --path /opt/my-world res://Net/server.tscn +Restart=always +User=nobody + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..d3e5617 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 443 ssl; + server_name example.com; + # ssl_certificate / ssl_certificate_key — например, от certbot + + root /var/www/my-world; # сюда экспорт Web (index.html, .wasm, .pck) + index index.html; + + location /ws { + proxy_pass http://127.0.0.1:9080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 1h; + } +} diff --git a/project.godot b/project.godot index 6265b80..85bf9ff 100644 --- a/project.godot +++ b/project.godot @@ -15,6 +15,10 @@ run/main_scene="uid://6vc0m7mc73f1" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://icon.svg" +[autoload] + +Net="*res://Net/net.gd" + [display] window/stretch/mode="canvas_items"