Мультиплеер: выделенный WebSocket-сервер, призраки других игроков
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8h6333310d0m
|
||||||
+122
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dtwbyyjncecj2
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[node name="Server" type="Node"]
|
||||||
@@ -173,6 +173,7 @@ var default_collision_y: float = 0.0
|
|||||||
@onready var head: Camera3D = $Camera3D
|
@onready var head: Camera3D = $Camera3D
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
add_to_group("local_player")
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
head_base_pos = head.position
|
head_base_pos = head.position
|
||||||
base_fov = head.fov
|
base_fov = head.fov
|
||||||
@@ -223,6 +224,15 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
elif event.keycode == KEY_Q:
|
elif event.keycode == KEY_Q:
|
||||||
_try_dash() # Рывок
|
_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:
|
func _get_input_dir() -> Vector2:
|
||||||
var input_dir := Vector2.ZERO
|
var input_dir := Vector2.ZERO
|
||||||
if Input.is_key_pressed(KEY_D):
|
if Input.is_key_pressed(KEY_D):
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ run/main_scene="uid://6vc0m7mc73f1"
|
|||||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
|
[autoload]
|
||||||
|
|
||||||
|
Net="*res://Net/net.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
|
|||||||
Reference in New Issue
Block a user