decdb74ade
Bump export_presets.cfg version to 2.3.5. Update CHANGELOG_DRAFT.md. Refactor lobby.gd into LobbyChat, LobbyMainMenu, LobbyRoomList, LobbyRoom. Move Nakama config to environment variables in nakama_manager.gd. Derive auth_manager.gd encryption key from OS.get_unique_id().sha256_text(). Remove Steam email auth fallback. Require auth ticket. Make GachaManager.pull() async in gacha_panel.gd. Remove dummy wallet seeding. Add store_type to IAP payload. Validate IAP receipts server-side in economy.lua. Register gacha module in main.lua. Clean backend_service.gd stubs. Fix featured_banners type safety in gacha_manager.gd. Guards non-array responses. Move tiles_armagedon_a1.res to assets/models/meshes/. Fix import fallback_path.
313 lines
11 KiB
GDScript
313 lines
11 KiB
GDScript
extends Node
|
|
|
|
# Standard Nakama Configuration
|
|
var nakama_server_key = OS.get_environment("NAKAMA_SERVER_KEY") if OS.has_environment("NAKAMA_SERVER_KEY") else ProjectSettings.get_setting("network/nakama/server_key", "defaultkey")
|
|
var nakama_host = OS.get_environment("NAKAMA_HOST") if OS.has_environment("NAKAMA_HOST") else ProjectSettings.get_setting("network/nakama/host", "tektondash.vps.webdock.cloud")
|
|
var nakama_port = OS.get_environment("NAKAMA_PORT").to_int() if OS.has_environment("NAKAMA_PORT") else ProjectSettings.get_setting("network/nakama/port", 7350)
|
|
var nakama_scheme = OS.get_environment("NAKAMA_SCHEME") if OS.has_environment("NAKAMA_SCHEME") else ProjectSettings.get_setting("network/nakama/scheme", "http")
|
|
|
|
# Core Nakama Variables
|
|
var client: NakamaClient
|
|
var session: NakamaSession
|
|
var socket: NakamaSocket
|
|
var bridge: NakamaMultiplayerBridge
|
|
|
|
# Signals
|
|
signal connected_to_nakama
|
|
signal connection_failed(error_message)
|
|
signal match_joined(match_id)
|
|
signal match_join_error(error_message)
|
|
|
|
# Current Match Info
|
|
var current_match_id: String = ""
|
|
|
|
func _ready():
|
|
# Initialize the Nakama Client
|
|
_init_client()
|
|
|
|
# Ensure we process network events
|
|
set_process(true)
|
|
|
|
func _init_client():
|
|
client = Nakama.create_client(nakama_server_key, nakama_host, nakama_port, nakama_scheme)
|
|
|
|
func set_server(host: String, port: int = 7350):
|
|
# Clean up the host string
|
|
var clean_host = host.strip_edges()
|
|
|
|
# Extract protocol if present (override everything else)
|
|
var forced_scheme = ""
|
|
if clean_host.begins_with("https://"):
|
|
forced_scheme = "https"
|
|
clean_host = clean_host.replace("https://", "")
|
|
elif clean_host.begins_with("http://"):
|
|
forced_scheme = "http"
|
|
clean_host = clean_host.replace("http://", "")
|
|
|
|
# Handle trailing slashes
|
|
if clean_host.ends_with("/"):
|
|
clean_host = clean_host.substr(0, clean_host.length() - 1)
|
|
|
|
# Extract port if explicitly provided in host string (e.g. host:port)
|
|
var explicit_port = -1
|
|
if ":" in clean_host:
|
|
var parts = clean_host.split(":")
|
|
clean_host = parts[0]
|
|
explicit_port = parts[1].to_int()
|
|
|
|
# DETECT SETTINGS
|
|
nakama_host = clean_host
|
|
|
|
if forced_scheme != "":
|
|
nakama_scheme = forced_scheme
|
|
nakama_port = explicit_port if explicit_port != -1 else (443 if forced_scheme == "https" else port)
|
|
elif clean_host.ends_with(".ts.net") and explicit_port == -1:
|
|
# Tailscale Funnel Case (No port provided, .ts.net domain)
|
|
nakama_scheme = "https"
|
|
nakama_port = 443
|
|
elif clean_host.begins_with("100."):
|
|
# Standard Tailscale IP Case
|
|
nakama_scheme = "http"
|
|
nakama_port = explicit_port if explicit_port != -1 else port
|
|
else:
|
|
# Generic Case (e.g. localhost, public IP)
|
|
nakama_scheme = "http"
|
|
nakama_port = explicit_port if explicit_port != -1 else port
|
|
|
|
# Only recreate the client if there is no active session.
|
|
# If we're already connected, just store the new settings for the next connect.
|
|
if not session:
|
|
_init_client()
|
|
print("[NakamaManager] Server updated to: ", nakama_scheme, "://", nakama_host, ":", nakama_port)
|
|
|
|
func _process(_delta):
|
|
# If using the standard socket adapter, it needs polling in some versions
|
|
if socket:
|
|
pass
|
|
|
|
func connect_to_nakama_async(email: String = "", password: String = "") -> bool:
|
|
if is_connected_to_nakama():
|
|
print("Already connected to Nakama.")
|
|
if not multiplayer.has_multiplayer_peer() and bridge:
|
|
multiplayer.set_multiplayer_peer(bridge.multiplayer_peer)
|
|
emit_signal("connected_to_nakama")
|
|
return true
|
|
|
|
# 1. Authenticate
|
|
if email == "":
|
|
var device_id = OS.get_unique_id()
|
|
|
|
# If running in editor or debug, append a unique suffix to allow multiple
|
|
# instances on one machine to have separate sessions.
|
|
if OS.is_debug_build():
|
|
device_id += "_" + str(Time.get_ticks_msec()) + "_" + str(randi() % 1000)
|
|
|
|
session = await client.authenticate_device_async(device_id)
|
|
else:
|
|
session = await client.authenticate_email_async(email, password)
|
|
|
|
if session.is_exception():
|
|
var err = session.get_exception()
|
|
printerr("[NakamaManager] Auth Error: %s (Code: %s)" % [err.message, err.status_code])
|
|
emit_signal("connection_failed", err.message)
|
|
return false
|
|
|
|
# 2. Connect Socket
|
|
socket = Nakama.create_socket_from(client)
|
|
var socket_result = await socket.connect_async(session)
|
|
|
|
if typeof(socket_result) == TYPE_INT:
|
|
if socket_result != OK:
|
|
printerr("[NakamaManager] Socket Error (Code: %s)" % socket_result)
|
|
emit_signal("connection_failed", "Socket connect failed with code " + str(socket_result))
|
|
return false
|
|
elif socket_result.is_exception():
|
|
var err = socket_result.get_exception()
|
|
printerr("[NakamaManager] Socket Error: %s (Code: %s)" % [err.message, err.status_code])
|
|
emit_signal("connection_failed", err.message)
|
|
return false
|
|
|
|
# 3. Initialize Multiplayer Bridge
|
|
# This links Nakama's socket to Godot's High-Level Multiplayer API
|
|
if bridge:
|
|
bridge.leave()
|
|
bridge = null
|
|
|
|
bridge = NakamaMultiplayerBridge.new(socket)
|
|
|
|
# Connect bridge signals
|
|
bridge.match_joined.connect(_on_bridge_match_joined)
|
|
bridge.match_join_error.connect(_on_bridge_match_join_error)
|
|
|
|
# CRITICAL: Set Godot's multiplayer peer to the Nakama bridge
|
|
# This allows @rpc functions to work over Nakama
|
|
multiplayer.set_multiplayer_peer(bridge.multiplayer_peer)
|
|
|
|
print("Connected to Nakama and Bridge Initialized.")
|
|
emit_signal("connected_to_nakama")
|
|
return true
|
|
|
|
func cleanup():
|
|
"""Properly shutdown the Nakama connection and reset the multiplayer peer."""
|
|
print("[NakamaManager] Full cleanup starting...")
|
|
|
|
if bridge:
|
|
bridge.leave()
|
|
bridge = null
|
|
|
|
if socket:
|
|
socket.close()
|
|
socket = null
|
|
|
|
# Delete match metadata from storage (best-effort, don't block cleanup)
|
|
if session and client and not current_match_id.is_empty():
|
|
client.delete_storage_objects_async(session, [
|
|
NakamaStorageObjectId.new("match_meta", current_match_id, session.user_id)
|
|
])
|
|
|
|
current_match_id = ""
|
|
|
|
# Reset Godot's multiplayer peer
|
|
if multiplayer.get_multiplayer_peer():
|
|
multiplayer.set_multiplayer_peer(null)
|
|
|
|
print("[NakamaManager] Cleanup complete.")
|
|
|
|
# --- Match Management ---
|
|
|
|
func host_game(room_meta: Dictionary = {}):
|
|
if not bridge:
|
|
printerr("Cannot host: Bridge not initialized")
|
|
return
|
|
print("Hosting match via Nakama Bridge...")
|
|
var result = await bridge.create_match()
|
|
if result and result.is_exception():
|
|
emit_signal("match_join_error", result.get_exception().message)
|
|
return
|
|
# Store room metadata in Nakama storage so other players can see it in listings
|
|
if session and current_match_id and room_meta.size() > 0:
|
|
var meta_json = JSON.stringify(room_meta)
|
|
var write_obj = NakamaWriteStorageObject.new(
|
|
"match_meta", current_match_id,
|
|
2, 1, meta_json, ""
|
|
)
|
|
var wr = await client.write_storage_objects_async(session, [write_obj])
|
|
if wr.is_exception():
|
|
push_warning("[NakamaManager] Failed to write match metadata: ", wr.get_exception().message)
|
|
else:
|
|
print("[NakamaManager] Match metadata stored for: ", current_match_id)
|
|
|
|
func join_game(match_id: String):
|
|
if not bridge:
|
|
printerr("Cannot join: Bridge not initialized")
|
|
return
|
|
|
|
# If already connected, leave current match first
|
|
if bridge.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED:
|
|
print("Already connected to a match. Leaving current match before joining...")
|
|
bridge.leave()
|
|
# Wait a bit for cleanup
|
|
await get_tree().create_timer(0.2).timeout
|
|
|
|
print("Joining match: ", match_id)
|
|
var result = await bridge.join_match(match_id)
|
|
if result and result.is_exception():
|
|
emit_signal("match_join_error", result.get_exception().message)
|
|
|
|
# --- Callbacks ---
|
|
|
|
func _on_bridge_match_joined() -> void:
|
|
current_match_id = bridge.match_id
|
|
print("Successfully joined match: ", current_match_id)
|
|
emit_signal("match_joined", current_match_id)
|
|
|
|
func _on_bridge_match_join_error(error) -> void:
|
|
printerr("Bridge failed to join match: ", error.message)
|
|
emit_signal("match_join_error", error.message)
|
|
|
|
func is_connected_to_nakama() -> bool:
|
|
return socket != null and socket.is_connected_to_host()
|
|
|
|
# --- Match Listing ---
|
|
|
|
func list_matches_async(mode_filter: String = "") -> Array:
|
|
"""Query available matches from Nakama server.
|
|
mode_filter: optional game mode string — used as label query for authoritative matches.
|
|
For relayed matches this has no server-side effect; filtering is client-side."""
|
|
if not client:
|
|
push_error("Cannot list matches: Client not initialized")
|
|
return []
|
|
|
|
if not session or session.is_expired():
|
|
push_error("Cannot list matches: No valid session")
|
|
return []
|
|
|
|
print("Querying matches from Nakama server... (filter: '%s')" % mode_filter)
|
|
|
|
# Query matches — label filter works for authoritative matches only.
|
|
# For relayed matches all rooms are returned; game_mode is parsed from label field if present.
|
|
var result = await client.list_matches_async(session, 0, 8, 20, false, "", "")
|
|
|
|
if result.is_exception():
|
|
printerr("Failed to list matches: ", result.get_exception().message)
|
|
return []
|
|
|
|
var rooms: Array = []
|
|
if result.matches:
|
|
print("Found %d matches" % result.matches.size())
|
|
# Build storage read requests for all matches
|
|
var read_ids: Array = []
|
|
for match_data in result.matches:
|
|
# Try to read metadata stored by each host
|
|
read_ids.append(match_data.match_id)
|
|
|
|
# Batch-read room metadata from Nakama storage
|
|
var meta_map: Dictionary = {} # match_id -> {host_name, game_mode, ...}
|
|
if read_ids.size() > 0:
|
|
var storage_reads: Array = []
|
|
for mid in read_ids:
|
|
# We don't know the owner_id, so list objects by collection+key
|
|
# Use list_storage_objects per match or batch read
|
|
pass
|
|
# Read all match metas from our own storage (other users' objects are public read)
|
|
for mid in read_ids:
|
|
var list_result = await client.list_storage_objects_async(session, "match_meta", "", 100)
|
|
if not list_result.is_exception() and list_result.objects:
|
|
for obj in list_result.objects:
|
|
var parsed = JSON.parse_string(obj.value)
|
|
if parsed is Dictionary:
|
|
meta_map[obj.key] = parsed
|
|
break # Only need one call — lists all match_meta objects
|
|
|
|
for match_data in result.matches:
|
|
print(" Match: ", match_data.match_id, " - Size: ", match_data.size)
|
|
var short_id = match_data.match_id.substr(0, 8) if match_data.match_id.length() > 8 else match_data.match_id
|
|
|
|
# Get metadata from storage (host_name, game_mode)
|
|
var meta = meta_map.get(match_data.match_id, {})
|
|
var host_name = meta.get("host_name", "Unknown")
|
|
var game_mode = meta.get("game_mode", "")
|
|
var max_players = int(meta.get("max_players", 8))
|
|
|
|
# Fallback: parse game_mode from label if available
|
|
if game_mode.is_empty():
|
|
var label: String = match_data.label if match_data.label != null else ""
|
|
if label.begins_with("[") and "]" in label:
|
|
game_mode = label.substr(1, label.find("]") - 1)
|
|
|
|
rooms.append({
|
|
"match_id": match_data.match_id,
|
|
"room_name": short_id,
|
|
"host_name": host_name,
|
|
"player_count": match_data.size if match_data.size else 1,
|
|
"max_players": max_players,
|
|
"game_mode": game_mode
|
|
})
|
|
else:
|
|
print("No matches found")
|
|
|
|
return rooms
|
|
|
|
func _exit_tree():
|
|
cleanup()
|