Files
Scavenger-2027/Build/index.html
T
2026-09-08 10:03:23 -04:00

1244 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Remote Godot Game</title>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: black;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
object-fit: contain;
background: black;
cursor: default;
}
canvas.locked {
cursor: none;
}
#status {
position: fixed;
top: 12px;
left: 12px;
z-index: 10;
padding: 8px 12px;
color: white;
background: rgba(0, 0, 0, 0.65);
font-family: monospace;
pointer-events: none;
}
</style>
</head>
<body>
<div id="status">Connecting...</div>
<!-- Must match the Godot streamed frame resolution. -->
<canvas id="game" width="1366" height="768"></canvas>
<script>
"use strict";
// ----------------------------------------------------
// Configuration
// ----------------------------------------------------
const STREAM_WIDTH = 1366;
const STREAM_HEIGHT = 768;
const DEBUG_LOGGING = true;
function getSignalingUrl() {
const hostname = window.location.hostname;
const isLocalConnection =
window.location.protocol === "file:" ||
hostname === "" ||
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.endsWith(".local") ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
hostname.startsWith("172.16.") ||
hostname.startsWith("172.17.") ||
hostname.startsWith("172.18.") ||
hostname.startsWith("172.19.") ||
hostname.startsWith("172.20.") ||
hostname.startsWith("172.21.") ||
hostname.startsWith("172.22.") ||
hostname.startsWith("172.23.") ||
hostname.startsWith("172.24.") ||
hostname.startsWith("172.25.") ||
hostname.startsWith("172.26.") ||
hostname.startsWith("172.27.") ||
hostname.startsWith("172.28.") ||
hostname.startsWith("172.29.") ||
hostname.startsWith("172.30.") ||
hostname.startsWith("172.31.");
if (isLocalConnection) {
return "ws://localhost:8081/browser";
}
return "wss://wstsa.astronand.dev/browser";
}
const SIGNALING_URL = getSignalingUrl();
// ----------------------------------------------------
// Logging
// ----------------------------------------------------
function log(...args) {
if (DEBUG_LOGGING) {
console.log("[StreamServer]", ...args);
}
}
function warn(...args) {
if (DEBUG_LOGGING) {
console.warn("[StreamServer]", ...args);
}
}
function errorLog(...args) {
console.error("[StreamServer]", ...args);
}
// ----------------------------------------------------
// Canvas
// ----------------------------------------------------
const canvas = document.getElementById("game");
const statusElement = document.getElementById("status");
const context = canvas.getContext("2d", {
alpha: false,
desynchronized: true
});
const urlParams = new URLSearchParams(
window.location.search
);
let sessionId = urlParams.get("session_id") || "";
// ----------------------------------------------------
// Connection state
// ----------------------------------------------------
let signalingSocket = null;
let peerConnection = null;
let dataChannel = null;
let godotConnected = false;
let offerCreated = false;
let mouseLocked = false;
let serverMouseLocked = false;
let frameInProgress = false;
let currentImage = null;
const pressedKeys = new Set();
// ----------------------------------------------------
// Status
// ----------------------------------------------------
function setStatus(text) {
log("Status:", text);
statusElement.textContent = text;
}
// ----------------------------------------------------
// Signaling
// ----------------------------------------------------
function sendSignalingMessage(message) {
if (
!signalingSocket ||
signalingSocket.readyState !== WebSocket.OPEN
) {
warn(
"Signaling socket is not open:",
message
);
return false;
}
try {
signalingSocket.send(
JSON.stringify(message)
);
log("Signaling message sent:", message);
return true;
} catch (error) {
errorLog(
"Could not send signaling message:",
error
);
return false;
}
}
// ----------------------------------------------------
// Data channel
// ----------------------------------------------------
function sendEvent(event) {
if (
!dataChannel ||
dataChannel.readyState !== "open"
) {
warn(
"Data channel is not open:",
event
);
return false;
}
try {
dataChannel.send(
JSON.stringify(event)
);
log("Input event sent:", event);
return true;
} catch (error) {
errorLog(
"Could not send input event:",
error
);
return false;
}
}
// ----------------------------------------------------
// Mouse locking
// ----------------------------------------------------
async function lockMouse() {
if (!serverMouseLocked) {
log(
"Godot has not enabled mouse locking"
);
return;
}
if (!godotConnected) {
warn("Godot is not connected");
return;
}
if (
!dataChannel ||
dataChannel.readyState !== "open"
) {
warn("Input data channel is not open");
return;
}
try {
await canvas.requestPointerLock();
} catch (error) {
errorLog(
"Could not request pointer lock:",
error
);
}
}
function unlockMouse() {
if (
document.pointerLockElement === canvas
) {
document.exitPointerLock();
}
}
function updateMouseLockState() {
const locked =
document.pointerLockElement === canvas;
mouseLocked = locked;
canvas.classList.toggle(
"locked",
mouseLocked
);
if (mouseLocked) {
setStatus(
"Mouse locked — press Escape to unlock"
);
} else if (godotConnected) {
setStatus(
"Connected — click to lock mouse"
);
}
}
// ----------------------------------------------------
// Keyboard conversion
// ----------------------------------------------------
function browserKeyToGodot(code) {
const keys = {
KeyA: 65,
KeyB: 66,
KeyC: 67,
KeyD: 68,
KeyE: 69,
KeyF: 70,
KeyG: 71,
KeyH: 72,
KeyI: 73,
KeyJ: 74,
KeyK: 75,
KeyL: 76,
KeyM: 77,
KeyN: 78,
KeyO: 79,
KeyP: 80,
KeyQ: 81,
KeyR: 82,
KeyS: 83,
KeyT: 84,
KeyU: 85,
KeyV: 86,
KeyW: 87,
KeyX: 88,
KeyY: 89,
KeyZ: 90,
Digit0: 48,
Digit1: 49,
Digit2: 50,
Digit3: 51,
Digit4: 52,
Digit5: 53,
Digit6: 54,
Digit7: 55,
Digit8: 56,
Digit9: 57,
Numpad0: 96,
Numpad1: 97,
Numpad2: 98,
Numpad3: 99,
Numpad4: 100,
Numpad5: 101,
Numpad6: 102,
Numpad7: 103,
Numpad8: 104,
Numpad9: 105,
Space: 32,
Enter: 4194309,
Tab: 4194308,
Backspace: 4194308,
Delete: 4194312,
Insert: 4194311,
Home: 4194313,
End: 4194315,
PageUp: 4194314,
PageDown: 4194316,
ShiftLeft: 4194325,
ShiftRight: 4194325,
ControlLeft: 4194326,
ControlRight: 4194326,
AltLeft: 4194327,
AltRight: 4194327,
MetaLeft: 4194328,
MetaRight: 4194328,
ArrowUp: 4194320,
ArrowDown: 4194322,
ArrowLeft: 4194319,
ArrowRight: 4194321,
Escape: 4194305,
F1: 4194332,
F2: 4194333,
F3: 4194334,
F4: 4194335,
F5: 4194336,
F6: 4194337,
F7: 4194338,
F8: 4194339,
F9: 4194340,
F10: 4194341,
F11: 4194342,
F12: 4194343,
Minus: 45,
Equal: 61,
BracketLeft: 91,
BracketRight: 93,
Backslash: 92,
Semicolon: 59,
Quote: 39,
Comma: 44,
Period: 46,
Slash: 47,
Backquote: 96
};
return keys[code] ?? 0;
}
function getCharacter(event) {
if (
typeof event.key === "string" &&
event.key.length === 1
) {
return event.key;
}
return "";
}
function sendKeyEvent(event, pressed) {
const keycode = browserKeyToGodot(
event.code
);
if (keycode === 0) {
warn(
"Unknown browser key:",
event.code
);
return;
}
sendEvent({
type: pressed
? "key_down"
: "key_up",
char: getCharacter(event),
keycode: keycode
});
}
// ----------------------------------------------------
// Mouse input
// ----------------------------------------------------
function mouseButtonName(button) {
switch (button) {
case 0:
return "left";
case 1:
return "middle";
case 2:
return "right";
case 3:
return "wheel_up";
case 4:
return "wheel_down";
default:
return "left";
}
}
function getCanvasPosition(event) {
const rect =
canvas.getBoundingClientRect();
const scaleX =
canvas.width / rect.width;
const scaleY =
canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
};
}
function sendMouseClick(event, pressed) {
const position =
getCanvasPosition(event);
sendEvent({
type: "mouse_click",
button: mouseButtonName(event.button),
x: position.x,
y: position.y,
pressed: pressed
});
}
function sendMouseMove(event) {
const position =
getCanvasPosition(event);
sendEvent({
type: "mouse_move",
x: event.movementX,
y: event.movementY,
position_x: position.x,
position_y: position.y,
button_mask: event.buttons
});
}
// ----------------------------------------------------
// Input setup
// ----------------------------------------------------
function setupInput() {
log("Setting up input listeners");
// Normal mouse movement is always sent.
// This allows Godot Control nodes to receive
// hover and motion events.
canvas.addEventListener(
"mousemove",
event => {
sendMouseMove(event);
}
);
// Normal mouse press.
canvas.addEventListener(
"mousedown",
event => {
log("Mouse down:", event.button);
sendMouseClick(event, true);
}
);
// Normal mouse release.
canvas.addEventListener(
"mouseup",
event => {
log("Mouse up:", event.button);
sendMouseClick(event, false);
}
);
// Mouse wheel.
canvas.addEventListener(
"wheel",
event => {
event.preventDefault();
const position =
getCanvasPosition(event);
const button =
event.deltaY < 0
? "wheel_up"
: "wheel_down";
sendEvent({
type: "mouse_click",
button: button,
x: position.x,
y: position.y,
pressed: true
});
sendEvent({
type: "mouse_click",
button: button,
x: position.x,
y: position.y,
pressed: false
});
},
{
passive: false
}
);
// Request browser pointer lock when Godot
// has enabled mouse locking.
canvas.addEventListener(
"click",
async () => {
if (!serverMouseLocked) {
return;
}
if (!mouseLocked) {
await lockMouse();
}
}
);
// Relative movement while pointer locked.
document.addEventListener(
"mousemove",
event => {
if (!mouseLocked) {
return;
}
sendEvent({
type: "mouse_move",
x: event.movementX,
y: event.movementY,
// The virtual cursor is centered
// while pointer locked.
position_x: canvas.width / 2,
position_y: canvas.height / 2,
button_mask: event.buttons
});
}
);
document.addEventListener(
"pointerlockchange",
() => {
log("Pointer lock changed");
updateMouseLockState();
}
);
document.addEventListener(
"pointerlockerror",
() => {
errorLog("Pointer lock error");
mouseLocked = false;
canvas.classList.remove("locked");
setStatus(
"Could not lock mouse"
);
}
);
// Keyboard input.
document.addEventListener(
"keydown",
event => {
log("Key down:", event.code);
if (event.code === "Escape") {
unlockMouse();
return;
}
if (pressedKeys.has(event.code)) {
return;
}
pressedKeys.add(event.code);
sendKeyEvent(event, true);
}
);
document.addEventListener(
"keyup",
event => {
log("Key up:", event.code);
if (event.code === "Escape") {
return;
}
pressedKeys.delete(event.code);
sendKeyEvent(event, false);
}
);
// Release keys and buttons if the browser loses focus.
window.addEventListener(
"blur",
() => {
log(
"Window lost focus; releasing input"
);
for (const code of pressedKeys) {
const keycode =
browserKeyToGodot(code);
if (keycode !== 0) {
sendEvent({
type: "key_up",
char: "",
keycode: keycode
});
}
}
pressedKeys.clear();
for (
const button of [
"left",
"middle",
"right"
]
) {
sendEvent({
type: "mouse_click",
button: button,
x: 0,
y: 0,
pressed: false
});
}
}
);
window.addEventListener(
"contextmenu",
event => {
event.preventDefault();
}
);
}
// ----------------------------------------------------
// WebRTC
// ----------------------------------------------------
function setupPeerConnection() {
if (peerConnection) {
return;
}
log("Creating RTCPeerConnection");
peerConnection = new RTCPeerConnection({
iceServers: [
{
urls: [
"stun:stun.l.google.com:19302"
]
}
// Add your real TURN credentials here
// if direct WebRTC connections fail:
//
// {
// urls: "turn:turn.astronand.dev:3478",
// username: "YOUR_USERNAME",
// credential: "YOUR_PASSWORD"
// }
]
});
peerConnection.onicecandidate = event => {
if (!event.candidate) {
log("ICE gathering completed");
return;
}
sendSignalingMessage({
type: "ice",
candidate: event.candidate
});
};
peerConnection.onicegatheringstatechange = () => {
log(
"ICE gathering state:",
peerConnection.iceGatheringState
);
};
peerConnection.oniceconnectionstatechange = () => {
log(
"ICE connection state:",
peerConnection.iceConnectionState
);
};
peerConnection.onconnectionstatechange = () => {
const state =
peerConnection.connectionState;
log("WebRTC state:", state);
if (state === "connected") {
godotConnected = true;
setStatus(
"Connected — click to lock mouse"
);
}
if (
state === "failed" ||
state === "disconnected" ||
state === "closed"
) {
godotConnected = false;
serverMouseLocked = false;
unlockMouse();
setStatus(
"WebRTC connection lost"
);
}
};
dataChannel =
peerConnection.createDataChannel(
"input",
{
ordered: true
}
);
dataChannel.binaryType = "arraybuffer";
dataChannel.onopen = () => {
log("Input data channel opened");
godotConnected = true;
setStatus(
"Connected — click to lock mouse"
);
};
dataChannel.onclose = () => {
warn("Input data channel closed");
godotConnected = false;
serverMouseLocked = false;
unlockMouse();
setStatus(
"Data channel closed"
);
};
dataChannel.onerror = error => {
errorLog(
"Data channel error:",
error
);
};
dataChannel.onmessage = event => {
// Godot sends control messages as JSON text.
if (typeof event.data === "string") {
try {
const message =
JSON.parse(event.data);
log(
"Server message:",
message
);
if (
message.type ===
"mouse_locked"
) {
serverMouseLocked =
message.locked === true;
if (!serverMouseLocked) {
unlockMouse();
}
return;
}
} catch (error) {
warn(
"Invalid server message:",
error
);
}
return;
}
receiveFrame(event.data);
};
}
async function createOffer() {
if (offerCreated) {
return;
}
if (!peerConnection) {
setupPeerConnection();
}
offerCreated = true;
try {
const offer =
await peerConnection.createOffer();
await peerConnection.setLocalDescription(
offer
);
sendSignalingMessage({
type: "offer",
offer: peerConnection.localDescription
});
setStatus(
"Connecting WebRTC..."
);
} catch (error) {
offerCreated = false;
errorLog(
"Could not create offer:",
error
);
setStatus(
"WebRTC offer failed"
);
}
}
async function handleAnswer(answer) {
if (!peerConnection) {
return;
}
try {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(answer)
);
} catch (error) {
errorLog(
"Could not set answer:",
error
);
}
}
async function handleOffer(offer) {
if (!offer) {
return;
}
setupPeerConnection();
try {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(offer)
);
const answer =
await peerConnection.createAnswer();
await peerConnection.setLocalDescription(
answer
);
sendSignalingMessage({
type: "answer",
answer: peerConnection.localDescription
});
} catch (error) {
errorLog(
"Could not handle offer:",
error
);
}
}
async function handleIceCandidate(candidate) {
if (!candidate || !peerConnection) {
return;
}
try {
await peerConnection.addIceCandidate(
candidate
);
} catch (error) {
errorLog(
"Could not add ICE candidate:",
error
);
}
}
// ----------------------------------------------------
// Frame receiving
// ----------------------------------------------------
async function receiveFrame(data) {
if (frameInProgress) {
return;
}
frameInProgress = true;
try {
const blob = new Blob(
[data],
{
type: "image/jpeg"
}
);
const bitmap =
await createImageBitmap(blob);
if (currentImage) {
currentImage.close();
}
currentImage = bitmap;
context.drawImage(
bitmap,
0,
0,
STREAM_WIDTH,
STREAM_HEIGHT
);
} catch (error) {
errorLog(
"Frame decode error:",
error
);
} finally {
frameInProgress = false;
}
}
// ----------------------------------------------------
// Signaling connection
// ----------------------------------------------------
function setupSignaling() {
log(
"Connecting to:",
SIGNALING_URL
);
signalingSocket =
new WebSocket(SIGNALING_URL);
signalingSocket.onopen = () => {
log("Signaling WebSocket opened");
setStatus(
"Waiting for Godot..."
);
const registration = {
type: "register",
role: "browser"
};
if (sessionId) {
registration.session_id =
sessionId;
}
sendSignalingMessage(
registration
);
};
signalingSocket.onmessage = async event => {
let message;
try {
message = JSON.parse(
event.data
);
} catch (error) {
errorLog(
"Invalid signaling message:",
event.data
);
return;
}
log(
"Signaling message received:",
message
);
switch (message.type) {
case "session_created":
if (
!sessionId &&
message.session_id
) {
sessionId =
message.session_id;
}
break;
case "registered":
log(
"Browser registered"
);
break;
case "godot_connected":
godotConnected = true;
setStatus(
"Creating WebRTC connection..."
);
await createOffer();
break;
case "offer":
await handleOffer(
message.offer ||
message.sdp
);
break;
case "answer":
await handleAnswer(
message.answer ||
message.sdp
);
break;
case "ice":
case "candidate":
await handleIceCandidate(
message.candidate
);
break;
case "browser_disconnected":
case "godot_disconnected":
godotConnected = false;
serverMouseLocked = false;
unlockMouse();
setStatus(
"Godot disconnected"
);
break;
case "error":
errorLog(
"Signaling error:",
message.message
);
setStatus(
"Error: " +
message.message
);
break;
default:
warn(
"Unknown signaling message:",
message
);
break;
}
};
signalingSocket.onclose = event => {
warn(
"Signaling connection closed:",
event.code,
event.reason
);
godotConnected = false;
serverMouseLocked = false;
unlockMouse();
setStatus(
"Signaling disconnected"
);
};
signalingSocket.onerror = error => {
errorLog(
"Signaling error:",
error
);
setStatus(
"Signaling error"
);
};
}
// ----------------------------------------------------
// Startup
// ----------------------------------------------------
log(
"Remote Godot client starting"
);
log(
"Canvas resolution:",
canvas.width,
"x",
canvas.height
);
setupInput();
setupSignaling();
</script>
</body>
</html>