This commit is contained in:
2026-09-10 07:47:15 -04:00
parent 1f555ed3e4
commit 1c4d0ecbfe
39 changed files with 1499 additions and 2357 deletions
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
./scavgame.x86_64 &
GODOT_PID=$!
wmctrl -r "scavgame" -b add,hidden
wait "$GODOT_PID"
-1243
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://bj1b5wm8lhd0v"
uid="uid://b35ewh20gulwl"
path="res://.godot/imported/scavgame.144x144.png-b48426c1c0d44c524725caf94371d8cf.ctex"
metadata={
"vram_texture": false
+1 -1
View File
@@ -2,7 +2,7 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://c3ffnf40pfswc"
uid="uid://dxxnbj0y02str"
path="res://.godot/imported/scavgame.180x180.png-98da9fc07cc8cd6f3596b8214cb44abf.ctex"
metadata={
"vram_texture": false
+1 -1
View File
@@ -2,7 +2,7 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://4ele4xirpeuo"
uid="uid://cwtujh1x71cxy"
path="res://.godot/imported/scavgame.512x512.png-fc91a6f75062f7a09d36054b561598b5.ctex"
metadata={
"vram_texture": false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c08c15fn25u5a"
path="res://.godot/imported/scavgame.apple-touch-icon.png-fae91e97aebbd193ef8488b9ce4380d1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://Build/scavgame.apple-touch-icon.png"
dest_files=["res://.godot/imported/scavgame.apple-touch-icon.png-fae91e97aebbd193ef8488b9ce4380d1.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
+1 -1
View File
@@ -110,7 +110,7 @@ body {
<script src="scavgame.js"></script>
<script>
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"scavgame","experimentalVK":false,"fileSizes":{"scavgame.pck":200656,"scavgame.wasm":1601069},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"scavgame.service.worker.js"};
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"scavgame","experimentalVK":false,"fileSizes":{"scavgame.pck":232728,"scavgame.wasm":1601069},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"scavgame.service.worker.js"};
const GODOT_THREADS_ENABLED = true;
const engine = new Engine(GODOT_CONFIG);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dd0gt71ad26cw"
path="res://.godot/imported/scavgame.icon.png-73f49704b23c124153b229d5b0cda394.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://Build/scavgame.icon.png"
dest_files=["res://.godot/imported/scavgame.icon.png-73f49704b23c124153b229d5b0cda394.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
+1 -1
View File
@@ -2,7 +2,7 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://d2j704bew40yv"
uid="uid://cgr0gin458n38"
path="res://.godot/imported/scavgame.png-7a036069753eca4aeee3fbdd0ed99f6b.ctex"
metadata={
"vram_texture": false
+1 -1
View File
@@ -4,7 +4,7 @@
// Incrementing CACHE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
/** @type {string} */
const CACHE_VERSION = '1788537868|6220518696';
const CACHE_VERSION = '1788972329|18914709609';
/** @type {string} */
const CACHE_PREFIX = 'scavgame-sw-cache-';
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
-906
View File
@@ -1,906 +0,0 @@
#!/usr/bin/env python3
import asyncio
import json
import os
import uuid
from pathlib import Path
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from websockets.asyncio.server import serve
from websockets.exceptions import ConnectionClosed
# ============================================================
# Configuration
# ============================================================
HTTP_HOST = "0.0.0.0"
HTTP_PORT = 8080
WS_HOST = "0.0.0.0"
WS_PORT = 8081
GAME_EXECUTABLE = Path(
"/data/1/Projects/Scavenger-2027/Build/scavgame.x86_64"
)
GAME_DIRECTORY = GAME_EXECUTABLE.parent
WEB_DIRECTORY = GAME_DIRECTORY
MAX_SESSIONS = 4
DEBUG_LOGGING = True
sessions: dict[str, "GameSession"] = {}
# ============================================================
# Logging
# ============================================================
def log(*args):
print("[Server]", *args, flush=True)
def debug(*args):
if DEBUG_LOGGING:
print("[Server][DEBUG]", *args, flush=True)
def warning(*args):
print("[Server][WARNING]", *args, flush=True)
def error(*args):
print("[Server][ERROR]", *args, flush=True)
# ============================================================
# Message helpers
# ============================================================
def normalize_sdp_message(message: dict) -> dict | None:
"""
Normalizes these formats:
Browser:
{
"type": "offer",
"offer": {
"type": "offer",
"sdp": "..."
}
}
Godot:
{
"type": "offer",
"sdp": "..."
}
The same applies to answers.
"""
message_type = message.get("type")
if message_type not in {"offer", "answer"}:
return None
nested_key = message_type
nested = message.get(nested_key)
if isinstance(nested, dict):
sdp = nested.get("sdp", "")
if not sdp:
return None
return {
"type": message_type,
"sdp": sdp
}
sdp = message.get("sdp", "")
if not sdp:
return None
return {
"type": message_type,
"sdp": sdp
}
def normalize_ice_message(message: dict) -> dict | None:
"""
Normalizes both:
{ "type": "ice", "candidate": {...} }
and:
{ "type": "candidate", "candidate": {...} }
"""
candidate = message.get("candidate")
if not isinstance(candidate, dict):
return None
if not candidate.get("candidate"):
return None
return {
"type": "ice",
"candidate": candidate
}
# ============================================================
# Game session
# ============================================================
class GameSession:
def __init__(
self,
browser,
requested_session_id: str | None = None
):
self.id = (
requested_session_id
if requested_session_id
else uuid.uuid4().hex
)
self.browser = browser
self.godot = None
self.process = None
self.browser_connected = True
self.godot_connected = False
self.stopping = False
self.send_lock = asyncio.Lock()
# --------------------------------------------------------
# Game process
# --------------------------------------------------------
async def start(self):
if not GAME_EXECUTABLE.exists():
raise FileNotFoundError(
f"Game executable not found: {GAME_EXECUTABLE}"
)
if not os.access(GAME_EXECUTABLE, os.X_OK):
raise PermissionError(
f"Game is not executable: {GAME_EXECUTABLE}"
)
env = os.environ.copy()
env["SCAV_SESSION_ID"] = self.id
# Keep Forward+ rendering.
env["DISPLAY"] = ":1"
# Start minimized and avoid taking focus.
env["SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS"] = "1"
command = [
str(GAME_EXECUTABLE),
"--session-id",
self.id,
"--debug-logging",
"--resolution",
"1x1",
]
debug(
f"[{self.id}] Starting game command:",
command
)
self.process = await asyncio.create_subprocess_exec(
*command,
cwd=str(GAME_DIRECTORY),
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
log(
f"[{self.id}] Started game "
f"PID={self.process.pid}"
)
asyncio.create_task(
self.read_output()
)
async def read_output(self):
process = self.process
if process is None:
return
if process.stdout is None:
return
try:
async for line in process.stdout:
output = line.decode(
errors="replace"
).rstrip()
print(
f"[{self.id}] {output}",
flush=True
)
except asyncio.CancelledError:
raise
except Exception as exc:
error(
f"[{self.id}] Output reader error:",
exc
)
async def stop(self):
if self.stopping:
return
self.stopping = True
process = self.process
self.process = None
if process is None:
return
if process.returncode is not None:
return
log(
f"[{self.id}] Stopping game "
f"PID={process.pid}"
)
try:
process.terminate()
await asyncio.wait_for(
process.wait(),
timeout=3
)
log(
f"[{self.id}] Game exited "
f"code={process.returncode}"
)
except asyncio.TimeoutError:
warning(
f"[{self.id}] Game did not exit; "
f"killing PID={process.pid}"
)
try:
process.kill()
await process.wait()
except ProcessLookupError:
pass
except ProcessLookupError:
pass
# --------------------------------------------------------
# WebSocket sending
# --------------------------------------------------------
async def send_json(
self,
websocket,
message: dict
) -> bool:
if websocket is None:
return False
try:
payload = json.dumps(message)
async with self.send_lock:
await websocket.send(payload)
debug(
f"[{self.id}] Sent:",
message
)
return True
except ConnectionClosed:
return False
except Exception as exc:
error(
f"[{self.id}] Send error:",
exc
)
return False
async def send_to_godot(
self,
message: dict
) -> bool:
if self.godot is None:
warning(
f"[{self.id}] Cannot send to Godot; "
f"Godot is not connected"
)
return False
success = await self.send_json(
self.godot,
message
)
if not success:
self.godot = None
self.godot_connected = False
return success
async def send_to_browser(
self,
message: dict
) -> bool:
if self.browser is None:
warning(
f"[{self.id}] Cannot send to browser; "
f"browser is not connected"
)
return False
success = await self.send_json(
self.browser,
message
)
if not success:
self.browser = None
self.browser_connected = False
return success
# ============================================================
# HTTP server
# ============================================================
class HTTPHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(
*args,
directory=str(WEB_DIRECTORY),
**kwargs
)
def log_message(self, format_string, *args):
print(
"[HTTP] " + format_string % args,
flush=True
)
def start_http_server():
server = ThreadingHTTPServer(
(HTTP_HOST, HTTP_PORT),
HTTPHandler
)
log(
f"HTTP server listening on "
f"http://{HTTP_HOST}:{HTTP_PORT}"
)
try:
server.serve_forever()
finally:
server.server_close()
# ============================================================
# Browser connection
# ============================================================
async def handle_browser(
websocket,
requested_session_id: str | None = None
):
if len(sessions) >= MAX_SESSIONS:
await websocket.send(json.dumps({
"type": "error",
"message": "Server is full"
}))
await websocket.close()
return
session = GameSession(
websocket,
requested_session_id
)
if session.id in sessions:
await websocket.send(json.dumps({
"type": "error",
"message": "Session ID is already in use"
}))
await websocket.close()
return
sessions[session.id] = session
log(
f"[{session.id}] Browser connected"
)
try:
await session.send_to_browser({
"type": "session_created",
"session_id": session.id
})
await session.start()
# Wait for Godot to connect.
for _ in range(100):
if session.godot is not None:
break
await asyncio.sleep(0.1)
if session.godot is None:
warning(
f"[{session.id}] Godot did not connect"
)
await session.send_to_browser({
"type": "error",
"message": "Godot did not connect"
})
return
await session.send_to_godot({
"type": "browser_connected"
})
await session.send_to_browser({
"type": "godot_connected"
})
log(
f"[{session.id}] Browser and Godot paired"
)
async for raw in websocket:
try:
message = json.loads(raw)
except json.JSONDecodeError:
warning(
f"[{session.id}] Invalid browser JSON:",
raw
)
continue
if not isinstance(message, dict):
warning(
f"[{session.id}] Browser message "
f"is not an object"
)
continue
debug(
f"[{session.id}] Browser message:",
message
)
message_type = message.get("type")
# ------------------------------------------------
# WebRTC offer
# ------------------------------------------------
if message_type == "offer":
normalized = normalize_sdp_message(
message
)
if normalized is None:
warning(
f"[{session.id}] Invalid browser offer"
)
continue
await session.send_to_godot(
normalized
)
# ------------------------------------------------
# WebRTC ICE
# ------------------------------------------------
elif message_type in {
"ice",
"candidate"
}:
normalized = normalize_ice_message(
message
)
if normalized is None:
warning(
f"[{session.id}] Invalid browser ICE"
)
continue
await session.send_to_godot(
normalized
)
else:
debug(
f"[{session.id}] Ignoring browser message:",
message_type
)
except ConnectionClosed:
log(
f"[{session.id}] Browser WebSocket closed"
)
except Exception as exc:
error(
f"[{session.id}] Browser error:",
exc
)
finally:
log(
f"[{session.id}] Browser disconnected"
)
session.browser_connected = False
if session.godot is not None:
await session.send_to_godot({
"type": "browser_disconnected"
})
await asyncio.sleep(0.2)
try:
await session.godot.close()
except Exception:
pass
session.godot = None
session.godot_connected = False
await session.stop()
sessions.pop(
session.id,
None
)
log(
f"[{session.id}] Session removed"
)
# ============================================================
# Godot connection
# ============================================================
async def handle_godot(
websocket,
session_id: str
):
session = sessions.get(session_id)
if session is None:
warning(
f"[{session_id}] Godot attempted to connect "
f"to unknown session"
)
await websocket.send(json.dumps({
"type": "error",
"message": "Unknown session"
}))
await websocket.close()
return
if session.godot is not None:
warning(
f"[{session.id}] Replacing existing Godot connection"
)
try:
await session.godot.close()
except Exception:
pass
session.godot = websocket
session.godot_connected = True
log(
f"[{session.id}] Godot connected"
)
try:
await session.send_to_godot({
"type": "registered",
"session_id": session.id
})
if session.browser_connected:
await session.send_to_godot({
"type": "browser_connected"
})
await session.send_to_browser({
"type": "godot_connected"
})
async for raw in websocket:
try:
message = json.loads(raw)
except json.JSONDecodeError:
warning(
f"[{session.id}] Invalid Godot JSON:",
raw
)
continue
if not isinstance(message, dict):
warning(
f"[{session.id}] Godot message "
f"is not an object"
)
continue
debug(
f"[{session.id}] Godot message:",
message
)
message_type = message.get("type")
# ------------------------------------------------
# WebRTC answer
# ------------------------------------------------
if message_type == "answer":
normalized = normalize_sdp_message(
message
)
if normalized is None:
warning(
f"[{session.id}] Invalid Godot answer"
)
continue
await session.send_to_browser({
"type": "answer",
"answer": {
"type": "answer",
"sdp": normalized["sdp"]
}
})
# ------------------------------------------------
# WebRTC ICE
# ------------------------------------------------
elif message_type in {
"ice",
"candidate"
}:
normalized = normalize_ice_message(
message
)
if normalized is None:
warning(
f"[{session.id}] Invalid Godot ICE"
)
continue
await session.send_to_browser(
normalized
)
else:
debug(
f"[{session.id}] Ignoring Godot message:",
message_type
)
except ConnectionClosed:
log(
f"[{session.id}] Godot WebSocket closed"
)
except Exception as exc:
error(
f"[{session.id}] Godot error:",
exc
)
finally:
log(
f"[{session.id}] Godot disconnected"
)
if session.godot is websocket:
session.godot = None
session.godot_connected = False
if session.browser is not None:
await session.send_to_browser({
"type": "godot_disconnected"
})
# ============================================================
# WebSocket routing
# ============================================================
async def websocket_handler(websocket):
try:
raw = await websocket.recv()
message = json.loads(raw)
if not isinstance(message, dict):
await websocket.close()
return
role = message.get("role")
debug(
"Initial WebSocket registration:",
message
)
if role == "browser":
requested_session_id = message.get(
"session_id"
)
await handle_browser(
websocket,
requested_session_id
)
elif role == "godot":
session_id = message.get(
"session_id"
)
if not session_id:
await websocket.send(json.dumps({
"type": "error",
"message": "Missing session_id"
}))
await websocket.close()
return
await handle_godot(
websocket,
session_id
)
else:
await websocket.send(json.dumps({
"type": "error",
"message": "Invalid role"
}))
await websocket.close()
except ConnectionClosed:
pass
except json.JSONDecodeError:
warning(
"Received invalid initial WebSocket JSON"
)
try:
await websocket.close()
except Exception:
pass
except Exception as exc:
error(
"WebSocket handler error:",
exc
)
# ============================================================
# WebSocket server
# ============================================================
async def websocket_server():
log(
f"WebSocket server listening on "
f"ws://{WS_HOST}:{WS_PORT}"
)
async with serve(
websocket_handler,
WS_HOST,
WS_PORT
):
await asyncio.Future()
# ============================================================
# Main
# ============================================================
async def main():
http_task = asyncio.to_thread(
start_http_server
)
websocket_task = asyncio.create_task(
websocket_server()
)
await asyncio.gather(
http_task,
websocket_task
)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print(
"\nServer stopped",
flush=True
)
-1
View File
@@ -1 +0,0 @@
4.6.2.stable