754 lines
27 KiB
HTML
754 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<!-- Google tag (gtag.js) -->
|
|
<script async src="https://www.googletagmanager.com/gtag/js?id=AW-18299227566"></script>
|
|
<script>
|
|
window.dataLayer = window.dataLayer || [];
|
|
function gtag(){dataLayer.push(arguments);}
|
|
gtag('js', new Date());
|
|
|
|
gtag('config', 'AW-18299227566');
|
|
</script>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>CSS Stream</title>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.2/adapter.min.js"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
background: #000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
font-family: monospace;
|
|
color: #fff;
|
|
}
|
|
#status {
|
|
position: fixed;
|
|
top: 10px;
|
|
left: 10px;
|
|
background: rgba(0,0,0,0.7);
|
|
padding: 6px 12px;
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
z-index: 10;
|
|
}
|
|
#hint {
|
|
position: fixed;
|
|
bottom: 10px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(0,0,0,0.7);
|
|
padding: 6px 12px;
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
z-index: 10;
|
|
}
|
|
#idle-warning {
|
|
display: none;
|
|
position: fixed;
|
|
top: 10px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(180,60,0,0.85);
|
|
padding: 6px 14px;
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
z-index: 20;
|
|
}
|
|
#idle-warning.visible { display: block; }
|
|
|
|
#brightness-control {
|
|
position: fixed;
|
|
bottom: 40px;
|
|
left: 10px;
|
|
background: rgba(0,0,0,0.7);
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #fff;
|
|
font-family: monospace;
|
|
}
|
|
#brightness-control input[type=range] {
|
|
width: 80px;
|
|
accent-color: #4fc;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#sensitivity-control {
|
|
position: fixed;
|
|
bottom: 10px;
|
|
left: 10px;
|
|
background: rgba(0,0,0,0.7);
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #fff;
|
|
font-family: monospace;
|
|
}
|
|
#sensitivity-control input[type=range] {
|
|
width: 80px;
|
|
accent-color: #4fc;
|
|
cursor: pointer;
|
|
}
|
|
#sound-toggle {
|
|
position: fixed;
|
|
top: 10px;
|
|
right: 10px;
|
|
background: rgba(0,0,0,0.7);
|
|
color: #fff;
|
|
border: 1px solid #444;
|
|
padding: 6px 12px;
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
font-family: monospace;
|
|
cursor: pointer;
|
|
z-index: 10;
|
|
}
|
|
#sound-toggle:hover { background: rgba(40,40,40,0.85); }
|
|
#sound-toggle.unmuted { color: #4fc; border-color: #4fc; }
|
|
|
|
#restart-game-btn {
|
|
position: fixed;
|
|
top: 36px;
|
|
left: 10px;
|
|
background: rgba(120,20,20,0.75);
|
|
color: #fff;
|
|
border: 1px solid #a44;
|
|
padding: 6px 12px;
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
font-family: monospace;
|
|
cursor: pointer;
|
|
z-index: 10;
|
|
}
|
|
#restart-game-btn:hover { background: rgba(160,30,30,0.9); }
|
|
#gameCanvas {
|
|
width: 1280px;
|
|
height: 720px;
|
|
max-width: 100vw;
|
|
max-height: 100vh;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
#gameCanvas.watching { cursor: default; }
|
|
#gameCanvas.controlling { cursor: none; }
|
|
|
|
#queue-panel {
|
|
position: fixed;
|
|
top: 68px;
|
|
left: 10px;
|
|
background: rgba(0,0,0,0.75);
|
|
padding: 10px 14px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
z-index: 10;
|
|
min-width: 180px;
|
|
}
|
|
#queue-panel h3 { font-size: 12px; color: #ffcc00; margin-bottom: 6px; }
|
|
#queue-panel .controller-row { color: #4fc; margin-bottom: 4px; }
|
|
#queue-panel .queue-row { color: #aaa; margin-bottom: 2px; }
|
|
#queue-panel .you { color: #fff; font-weight: bold; }
|
|
|
|
#loading-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.85);
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
#loading-overlay.visible { display: flex; }
|
|
#loading-overlay p { color: #fff; font-size: 14px; margin-bottom: 16px; }
|
|
.spinner {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 4px solid #333;
|
|
border-top-color: #4fc;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
#faq {
|
|
position: fixed;
|
|
bottom: 40px;
|
|
right: 10px;
|
|
background: rgba(0,0,0,0.7);
|
|
padding: 12px 16px;
|
|
border-radius: 4px;
|
|
max-width: 300px;
|
|
font-size: 12px;
|
|
z-index: 10;
|
|
}
|
|
#faq h2 { font-size: 13px; margin-bottom: 8px; color: #fff; }
|
|
#faq h3 { font-size: 12px; color: #ffcc00; margin-bottom: 4px; }
|
|
#faq p { color: #ccc; line-height: 1.4; margin-bottom: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="status">Connecting...</div>
|
|
<button id="sound-toggle">🔇 Unmute</button>
|
|
<button id="restart-game-btn" style="display:none;">🔄 Restart Game</button>
|
|
<div id="brightness-control">
|
|
☀️ <input type="range" id="brightness-slider" min="50" max="200" value="100" step="5"> <span id="brightness-value">100%</span>
|
|
</div>
|
|
<div id="sensitivity-control">
|
|
🖱️ <input type="range" id="sensitivity-slider" min="1" max="6" value="3" step="0.5"> <span id="sensitivity-value">1.0x</span>
|
|
</div>
|
|
|
|
<div id="idle-warning">Idle — control will be released soon. Move your mouse to stay in control.</div>
|
|
|
|
<div id="queue-panel">
|
|
<h3>Controller</h3>
|
|
<div id="queue-list">—</div>
|
|
</div>
|
|
|
|
<div id="loading-overlay">
|
|
<p>Switching player — please wait...</p>
|
|
<div class="spinner"></div>
|
|
</div>
|
|
|
|
<video id="gameCanvas" class="watching" autoplay muted playsinline></video>
|
|
|
|
<div id="hint">Click to take control</div>
|
|
|
|
<div id="faq">
|
|
<h2>FAQ</h2>
|
|
<div class="faq-item">
|
|
<h3>Experience problems with controlling the client?</h3>
|
|
<p>The best thing to always try is reloading your webpage.</p>
|
|
</div>
|
|
<div class="faq-item">
|
|
<h3>What is this?</h3>
|
|
<p>This is the unloze webclient. You can play Zombie escape, Minigame and Zombie Riot with it. Visit our website at: https://unloze.com</p>
|
|
</div>
|
|
<div class="faq-item">
|
|
<h3>What game is this?</h3>
|
|
<p>The game is Counter Strike Source. you can buy it on steam over at: https://store.steampowered.com/app/240/CounterStrike_Source/</p>
|
|
<p>However be aware that the Ingame server browser is not usable as it is filled with fake redirect servers.</p>
|
|
</div>
|
|
<div class="faq-item">
|
|
<h3>How can i join the servers?</h3>
|
|
<p>The zombie escape IP: 51.195.188.106:27015.</p>
|
|
<p>The zombie escape2 IP: 51.195.188.106:27035.</p>
|
|
<p>The zombie riot IP: 51.195.188.106:27016.</p>
|
|
<p>The minigame IP: 51.195.188.106:27017.</p>
|
|
</div>
|
|
<div class="faq-item">
|
|
<h3>Want to chat with us?</h3>
|
|
<p>We have our own self hosted stoat instance: https://unloze.com/stoat/</p>
|
|
</div>
|
|
<div class="faq-item">
|
|
<h3>Opening the in-game menu</h3>
|
|
<p>Press ESC to release mouse control, then press ESC again to open the menu.</p>
|
|
</div>
|
|
<div class="faq-item">
|
|
<h3>Keybinds</h3>
|
|
<p>v: crouch key currently</p>
|
|
<p>k: microphone key. Browser will ask for microphone permission on first use. but does not work currently.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
// ── Name generation from IP ───────────────────────────────────────────
|
|
function hashString(str) {
|
|
let hash = 5381;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
hash = hash & 0x7fffffff;
|
|
}
|
|
return hash;
|
|
}
|
|
|
|
let visitorName = null;
|
|
|
|
async function initVisitorName() {
|
|
await new Promise(resolve => {
|
|
const handler = e => {
|
|
if (e.data instanceof Blob) return;
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'your_ip') {
|
|
visitorName = msg.ip; // server already sent the name
|
|
sigWs.removeEventListener('message', handler);
|
|
resolve();
|
|
}
|
|
} catch {}
|
|
};
|
|
sigWs.addEventListener('message', handler);
|
|
sigWs.send(JSON.stringify({ type: 'get_ip' }));
|
|
});
|
|
}
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────
|
|
const SIG_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
|
|
|
|
const statusEl = document.getElementById('status');
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const hintEl = document.getElementById('hint');
|
|
const queueList = document.getElementById('queue-list');
|
|
const loadingEl = document.getElementById('loading-overlay');
|
|
const idleWarnEl = document.getElementById('idle-warning');
|
|
const soundToggleEl = document.getElementById('sound-toggle');
|
|
const restartGameBtn = document.getElementById('restart-game-btn');
|
|
const brightnessSlider = document.getElementById('brightness-slider');
|
|
const brightnessValue = document.getElementById('brightness-value');
|
|
const sensitivitySlider = document.getElementById('sensitivity-slider');
|
|
const sensitivityValue = document.getElementById('sensitivity-value');
|
|
let mouseSensitivity = 1;
|
|
|
|
let sigWs = null;
|
|
let pc = null;
|
|
let sessionId = null;
|
|
let pointerLocked = false;
|
|
let isControlling = false;
|
|
let isInQueue = false;
|
|
let isWaitingForRestart = false;
|
|
let waitingRetryTimer = null;
|
|
let idleWarnTimer = null;
|
|
|
|
function setStatus(msg) { statusEl.textContent = msg; }
|
|
|
|
function setLoading(visible) {
|
|
loadingEl.classList.toggle('visible', visible);
|
|
}
|
|
|
|
// Mirrors the server's IDLE_TIMEOUT (60s); warns 15s before release so
|
|
// losing control is never a silent surprise.
|
|
const IDLE_TIMEOUT_MS = 60000;
|
|
const IDLE_WARN_MS = 15000;
|
|
function resetIdleWarning() {
|
|
idleWarnEl.classList.remove('visible');
|
|
if (idleWarnTimer) clearTimeout(idleWarnTimer);
|
|
if (!isControlling) return;
|
|
idleWarnTimer = setTimeout(() => {
|
|
idleWarnEl.classList.add('visible');
|
|
}, IDLE_TIMEOUT_MS - IDLE_WARN_MS);
|
|
}
|
|
|
|
function pingActive() {
|
|
resetIdleWarning();
|
|
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
|
|
sigWs.send(JSON.stringify({ type: 'controller_active' }));
|
|
}
|
|
}
|
|
|
|
function setMuted(muted) {
|
|
canvas.muted = muted;
|
|
soundToggleEl.textContent = muted ? '🔇 Unmute' : '🔊 Mute';
|
|
soundToggleEl.classList.toggle('unmuted', !muted);
|
|
}
|
|
|
|
soundToggleEl.addEventListener('click', () => {
|
|
setMuted(!canvas.muted);
|
|
});
|
|
|
|
restartGameBtn.addEventListener('click', () => {
|
|
if (!isControlling) return;
|
|
const confirmed = confirm(
|
|
'Restart the game? This kills and relaunches Counter-Strike Source. ' +
|
|
'Use this only if the game appears stuck/frozen. Takes a few seconds.'
|
|
);
|
|
if (!confirmed) return;
|
|
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
|
|
sigWs.send(JSON.stringify({ type: 'restart_css' }));
|
|
}
|
|
});
|
|
|
|
brightnessSlider.addEventListener('input', () => {
|
|
const val = brightnessSlider.value;
|
|
canvas.style.filter = `brightness(${val}%)`;
|
|
brightnessValue.textContent = `${val}%`;
|
|
});
|
|
|
|
sensitivitySlider.addEventListener('input', () => {
|
|
mouseSensitivity = parseFloat(sensitivitySlider.value);
|
|
sensitivityValue.textContent = `${mouseSensitivity.toFixed(1)}x`;
|
|
});
|
|
|
|
function updateQueuePanel(controller, queue, restarting) {
|
|
let html = '';
|
|
if (restarting) {
|
|
html = '<div class="controller-row">⟳ Restarting...</div>';
|
|
} else if (controller) {
|
|
const isMe = visitorName && controller === visitorName;
|
|
html += `<div class="controller-row${isMe ? ' you' : ''}">🎮 ${controller}${isMe ? ' (you)' : ''}</div>`;
|
|
} else {
|
|
html += '<div class="controller-row">— nobody —</div>';
|
|
}
|
|
if (queue.length > 0) {
|
|
html += '<div style="margin-top:6px;color:#ffcc00;font-size:11px">Queue:</div>';
|
|
queue.forEach((name, i) => {
|
|
const isMe = visitorName && name === visitorName;
|
|
html += `<div class="queue-row${isMe ? ' you' : ''}">${i + 1}. ${name}${isMe ? ' (you)' : ''}</div>`;
|
|
});
|
|
}
|
|
queueList.innerHTML = html;
|
|
}
|
|
|
|
// ── Microphone / PTT ──────────────────────────────────────────────────
|
|
let micStream = null;
|
|
let audioContext = null;
|
|
let micProcessor = null;
|
|
let micActive = false;
|
|
|
|
async function initMic() {
|
|
if (micStream) return true;
|
|
try {
|
|
micStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
|
video: false,
|
|
});
|
|
return true;
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
async function startMic() {
|
|
if (micActive) return;
|
|
const ok = await initMic();
|
|
if (!ok) return;
|
|
audioContext = new AudioContext({ sampleRate: 48000 });
|
|
const source = audioContext.createMediaStreamSource(micStream);
|
|
micProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
|
micProcessor.onaudioprocess = e => {
|
|
if (!micActive) return;
|
|
const float32 = e.inputBuffer.getChannelData(0);
|
|
const int16 = new Int16Array(float32.length);
|
|
for (let i = 0; i < float32.length; i++) {
|
|
int16[i] = Math.max(-32768, Math.min(32767, float32[i] * 32768));
|
|
}
|
|
if (sigWs && sigWs.readyState === WebSocket.OPEN) sigWs.send(int16.buffer);
|
|
};
|
|
source.connect(micProcessor);
|
|
// Connect to a dummy destination to keep onaudioprocess firing
|
|
// without routing audio to speakers
|
|
micProcessor.connect(audioContext.createMediaStreamDestination());
|
|
micActive = true;
|
|
}
|
|
|
|
function stopMic() {
|
|
if (!micActive) return;
|
|
micActive = false;
|
|
if (micProcessor) { micProcessor.disconnect(); micProcessor = null; }
|
|
if (audioContext) { audioContext.close(); audioContext = null; }
|
|
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
|
|
sigWs.send(JSON.stringify({ type: 'mic_stop' }));
|
|
}
|
|
}
|
|
|
|
// ── Control ───────────────────────────────────────────────────────────
|
|
function requestControl() {
|
|
if (!visitorName || !sigWs || sigWs.readyState !== WebSocket.OPEN) return;
|
|
if (isControlling || isInQueue || isWaitingForRestart) return;
|
|
sigWs.send(JSON.stringify({ type: 'request_control', visitorName }));
|
|
isInQueue = true;
|
|
hintEl.textContent = 'Waiting in queue...';
|
|
}
|
|
|
|
function enterControl() {
|
|
isControlling = true;
|
|
isInQueue = false;
|
|
isWaitingForRestart = false;
|
|
canvas.classList.replace('watching', 'controlling');
|
|
setMuted(false);
|
|
canvas.requestPointerLock();
|
|
hintEl.textContent = 'Mouse captured — ESC to release';
|
|
resetIdleWarning();
|
|
restartGameBtn.style.display = 'block';
|
|
}
|
|
|
|
function exitControl() {
|
|
isControlling = false;
|
|
isInQueue = false;
|
|
isWaitingForRestart = false;
|
|
canvas.classList.replace('controlling', 'watching');
|
|
hintEl.textContent = 'Click to take control';
|
|
stopMic();
|
|
if (idleWarnTimer) clearTimeout(idleWarnTimer);
|
|
idleWarnEl.classList.remove('visible');
|
|
restartGameBtn.style.display = 'none';
|
|
if (document.pointerLockElement === canvas) document.exitPointerLock();
|
|
}
|
|
|
|
// ── WebRTC ────────────────────────────────────────────────────────────
|
|
async function setupPeerConnection() {
|
|
pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }],
|
|
bundlePolicy: 'max-bundle',
|
|
});
|
|
|
|
const remoteStream = new MediaStream();
|
|
canvas.srcObject = remoteStream;
|
|
|
|
pc.ontrack = event => {
|
|
remoteStream.addTrack(event.track);
|
|
if (event.track.kind === 'video') setStatus('Streaming');
|
|
};
|
|
|
|
await pc.setLocalDescription(await pc.createOffer({
|
|
offerToReceiveVideo: true,
|
|
offerToReceiveAudio: true,
|
|
}));
|
|
|
|
sigWs.send(JSON.stringify({ type: 'browser_connect', sdp: pc.localDescription.sdp }));
|
|
setStatus('Waiting for session...');
|
|
}
|
|
|
|
// ── Signaling ─────────────────────────────────────────────────────────
|
|
function connectSignaling() {
|
|
setStatus('Connecting...');
|
|
sigWs = new WebSocket(SIG_URL);
|
|
|
|
sigWs.onopen = async () => {
|
|
setStatus('Connected — setting up stream...');
|
|
await initVisitorName();
|
|
await setupPeerConnection();
|
|
};
|
|
|
|
sigWs.onmessage = async e => {
|
|
if (e.data instanceof Blob) return;
|
|
const msg = JSON.parse(e.data);
|
|
|
|
if (msg.type === 'session_created') {
|
|
if (waitingRetryTimer) { clearInterval(waitingRetryTimer); waitingRetryTimer = null; }
|
|
sessionId = msg.sessionId;
|
|
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdpAnswer));
|
|
await waitForIce();
|
|
setStatus('ICE connected — pulling tracks...');
|
|
sigWs.send(JSON.stringify({ type: 'pull_track', sessionId }));
|
|
}
|
|
else if (msg.type === 'track_ready') {
|
|
const result = msg.trackResult;
|
|
if (result.requiresImmediateRenegotiation) {
|
|
await pc.setRemoteDescription(new RTCSessionDescription(result.sessionDescription));
|
|
await pc.setLocalDescription(await pc.createAnswer());
|
|
sigWs.send(JSON.stringify({ type: 'renegotiate', sessionId, sdp: pc.localDescription.sdp }));
|
|
}
|
|
}
|
|
else if (msg.type === 'queue_state') {
|
|
updateQueuePanel(msg.controller, msg.queue, msg.restarting);
|
|
isWaitingForRestart = msg.restarting;
|
|
setLoading(msg.restarting && (isControlling || isInQueue));
|
|
}
|
|
else if (msg.type === 'control_granted') {
|
|
setLoading(false);
|
|
enterControl();
|
|
}
|
|
else if (msg.type === 'control_released') {
|
|
exitControl();
|
|
hintEl.textContent = msg.reason === 'idle'
|
|
? 'Control released (idle) — click to take control'
|
|
: 'Click to take control';
|
|
}
|
|
else if (msg.type === 'waiting') {
|
|
setStatus('Waiting for game instance...');
|
|
// Previously this just set the status text and did nothing further —
|
|
// if gst_agent had crashed (e.g. ICE failure) and hadn't finished
|
|
// restarting/re-registering yet at the exact moment this browser's
|
|
// browser_connect request landed, the browser was stuck showing
|
|
// this message forever with no way to recover short of a manual
|
|
// page refresh, even after gst_agent came back online moments later.
|
|
// Now retry browser_connect periodically until session_created
|
|
// arrives (cleared there) or the socket closes.
|
|
if (!waitingRetryTimer) {
|
|
waitingRetryTimer = setInterval(() => {
|
|
if (sigWs && sigWs.readyState === WebSocket.OPEN && pc && pc.localDescription) {
|
|
sigWs.send(JSON.stringify({ type: 'browser_connect', sdp: pc.localDescription.sdp }));
|
|
}
|
|
}, 3000);
|
|
}
|
|
}
|
|
else if (msg.type === 'error') {
|
|
setStatus('Error: ' + msg.message);
|
|
}
|
|
};
|
|
|
|
sigWs.onerror = () => setStatus('Connection error');
|
|
sigWs.onclose = () => {
|
|
stopMic();
|
|
exitControl();
|
|
if (waitingRetryTimer) { clearInterval(waitingRetryTimer); waitingRetryTimer = null; }
|
|
setStatus('Disconnected');
|
|
};
|
|
}
|
|
|
|
function waitForIce() {
|
|
return new Promise((resolve, reject) => {
|
|
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
|
|
resolve(); return;
|
|
}
|
|
const timeout = setTimeout(() => reject(new Error('ICE timeout')), 15000);
|
|
pc.addEventListener('iceconnectionstatechange', () => {
|
|
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
|
|
clearTimeout(timeout); resolve();
|
|
}
|
|
if (pc.iceConnectionState === 'failed') {
|
|
clearTimeout(timeout); reject(new Error('ICE failed'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Input ─────────────────────────────────────────────────────────────
|
|
function sendInput(event) {
|
|
if (!sigWs || sigWs.readyState !== WebSocket.OPEN || !sessionId) return;
|
|
sigWs.send(JSON.stringify({ type: 'input', sessionId, event }));
|
|
}
|
|
|
|
canvas.addEventListener('click', () => {
|
|
if (isControlling) {
|
|
setMuted(false);
|
|
canvas.requestPointerLock();
|
|
} else {
|
|
requestControl();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('pointerlockchange', () => {
|
|
pointerLocked = document.pointerLockElement === canvas;
|
|
if (!pointerLocked && isControlling) {
|
|
hintEl.textContent = 'Mouse released — click to recapture or wait to release control';
|
|
} else if (pointerLocked) {
|
|
hintEl.textContent = 'Mouse captured — ESC to release';
|
|
pingActive();
|
|
}
|
|
});
|
|
|
|
const heldKeys = new Set();
|
|
|
|
window.addEventListener('blur', () => {
|
|
heldKeys.forEach(key => sendInput({ type: 'key_up', key }));
|
|
heldKeys.clear();
|
|
stopMic();
|
|
});
|
|
|
|
setInterval(() => {
|
|
if (pointerLocked && isControlling && sigWs && sigWs.readyState === WebSocket.OPEN) {
|
|
pingActive();
|
|
}
|
|
}, 3000);
|
|
|
|
let lastMouseMove = 0;
|
|
document.addEventListener('mousemove', e => {
|
|
if (!pointerLocked || !isControlling) return;
|
|
const now = performance.now();
|
|
if (now - lastMouseMove < 16) return;
|
|
lastMouseMove = now;
|
|
sendInput({ type: 'mouse_move', dx: e.movementX * mouseSensitivity, dy: e.movementY * mouseSensitivity });
|
|
pingActive();
|
|
});
|
|
|
|
canvas.addEventListener('contextmenu', e => {
|
|
e.preventDefault();
|
|
});
|
|
|
|
canvas.addEventListener('mousedown', e => {
|
|
if (!pointerLocked || !isControlling) return;
|
|
sendInput({ type: 'mouse_down', button: e.button + 1 });
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', e => {
|
|
if (!pointerLocked || !isControlling) return;
|
|
sendInput({ type: 'mouse_up', button: e.button + 1 });
|
|
});
|
|
|
|
let lastMouseWheel = 0;
|
|
canvas.addEventListener('wheel', e => {
|
|
if (!pointerLocked || !isControlling) return;
|
|
e.preventDefault();
|
|
const now = performance.now();
|
|
if (now - lastMouseWheel < 16) return;
|
|
lastMouseWheel = now;
|
|
sendInput({ type: 'mouse_wheel', direction: e.deltaY < 0 ? 'up' : 'down' });
|
|
}, { passive: false });
|
|
|
|
const KEY_MAP = {
|
|
'KeyW': 'w', 'KeyA': 'a', 'KeyS': 's', 'KeyD': 'd',
|
|
'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',
|
|
'Space': 'space', 'ShiftLeft': 'shift', 'ShiftRight': 'shift',
|
|
'ControlLeft': 'ctrl', 'ControlRight': 'ctrl',
|
|
'AltLeft': 'alt', 'AltRight': 'alt',
|
|
'Enter': 'Return', 'Escape': 'Escape', 'Tab': 'Tab',
|
|
'Backspace': 'BackSpace', 'Delete': 'Delete',
|
|
'KeyR': 'r', 'KeyF': 'f', 'KeyG': 'g', 'KeyE': 'e', 'KeyQ': 'q',
|
|
'KeyZ': 'z', 'KeyX': 'x', 'KeyC': 'c', 'KeyV': 'v', 'KeyB': 'b',
|
|
'KeyH': 'h', 'KeyI': 'i', 'KeyJ': 'j', 'KeyK': 'k', 'KeyL': 'l',
|
|
'KeyM': 'm', 'KeyN': 'n', 'KeyO': 'o', 'KeyP': 'p',
|
|
'KeyT': 't', 'KeyU': 'u', 'KeyY': 'y',
|
|
'Digit1': '1', 'Digit2': '2', 'Digit3': '3', 'Digit4': '4', 'Digit5': '5',
|
|
'Digit6': '6', 'Digit7': '7', 'Digit8': '8', 'Digit9': '9', 'Digit0': '0',
|
|
'Minus': 'minus', 'Equal': 'equal', 'Slash': 'slash',
|
|
'Period': 'period', 'Comma': 'comma', 'Backquote': 'grave',
|
|
'BracketLeft': 'bracketleft', 'BracketRight': 'bracketright',
|
|
'Backslash': 'backslash', 'CapsLock': 'Caps_Lock',
|
|
'F1': 'F1', 'F2': 'F2', 'F3': 'F3', 'F4': 'F4',
|
|
'F5': 'F5', 'F6': 'F6', 'F7': 'F7', 'F8': 'F8',
|
|
'F9': 'F9', 'F10': 'F10', 'F11': 'F11', 'F12': 'F12',
|
|
};
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'F4' && e.altKey) { e.preventDefault(); return; }
|
|
if (e.key === 'w' && e.ctrlKey) { e.preventDefault(); return; }
|
|
if (e.key === 'q' && e.ctrlKey) { e.preventDefault(); return; }
|
|
if (e.key === 'v' && e.ctrlKey) { e.preventDefault(); return; }
|
|
if (e.key === 'V' && e.ctrlKey) { e.preventDefault(); return; }
|
|
if (e.key === ';') { e.preventDefault(); return; }
|
|
if (e.key === '"') { e.preventDefault(); return; }
|
|
if (e.key === 'F11') { e.preventDefault(); return; }
|
|
if (e.key === 'Escape') {
|
|
// If pointer is locked, Escape releases pointer lock — don't also
|
|
// forward it to the game or it opens the in-game escape menu.
|
|
// Only forward Escape when pointer is already unlocked (user is
|
|
// navigating the in-game menu with mouse released).
|
|
if (isControlling && !pointerLocked) {
|
|
sendInput({ type: 'key_down', key: 'Escape' });
|
|
sendInput({ type: 'key_up', key: 'Escape' });
|
|
}
|
|
return;
|
|
}
|
|
if (!pointerLocked || !isControlling) return;
|
|
e.preventDefault();
|
|
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
|
if (e.code === 'KeyK' && !e.repeat) startMic();
|
|
heldKeys.add(key);
|
|
sendInput({ type: 'key_down', key });
|
|
});
|
|
|
|
document.addEventListener('keyup', e => {
|
|
if (e.key === 'Escape') {
|
|
if (isControlling) sendInput({ type: 'key_up', key: 'Escape' });
|
|
return;
|
|
}
|
|
if (!pointerLocked || !isControlling) return;
|
|
e.preventDefault();
|
|
const key = KEY_MAP[e.code] || e.code.toLowerCase();
|
|
if (e.code === 'KeyK') stopMic();
|
|
heldKeys.delete(key);
|
|
sendInput({ type: 'key_up', key });
|
|
});
|
|
|
|
connectSignaling();
|
|
</script>
|
|
</body>
|
|
<footer style="background:#000; color:#ccc; font-family:sans-serif; font-size:13px; text-align:center; padding:12px 20px; line-height:1.5;">
|
|
<p>
|
|
Unloze is an independent, community-run project and is not affiliated with, endorsed by, or sponsored by Valve Corporation.
|
|
Counter-Strike, Counter-Strike: Source, and related logos are trademarks of Valve Corporation.
|
|
</p>
|
|
</footer>
|
|
</html>
|