import Player from "/common/player.js"; import { WORLD_SIZE } from "/common/world.js"; import Stateful from "./silver.min.js"; import Prop from "/common/prop.js"; const canvas = document.getElementById("game"); const ctx = canvas.getContext("2d"); const chatbox = document.getElementById("chatbox"); const composeBox = document.getElementById("compose-msg"); const composeBtn = document.getElementById("compose-btn"); const playerSprite = new Image(); playerSprite.src = "/img/player.png"; const TICK_RATE = 30; canvas.height = WORLD_SIZE; canvas.width = WORLD_SIZE; var players = {}; var props = {}; var client_id; var delta = 0.0; var lastUpdate = 0.0; var frames = 0; var ticks = 0; var serverTick = 0; var serverPing = 0; var reconciliations = 0; var lastServerState = 0; var ws; const BUFFER_SIZE = TICK_RATE * 5; // 5 seconds of buffer var stateBuffer = new Array(BUFFER_SIZE); var inputBuffer = new Array(BUFFER_SIZE); const fakePingInput = document.getElementById("fakeping"); var fakePing = new Stateful(localStorage.getItem("fakeping") || 0); fakePingInput.value = fakePing.get(); fakePing.onUpdate(val => { localStorage.setItem("fakeping", val); }); fakePingInput.addEventListener("change", () => { fakePing.set(fakePingInput.value); }); const interpolationToggle = document.getElementById("interpolation"); var interpolationEnabled = new Stateful(localStorage.getItem("interpolation") || true); interpolationToggle.checked = interpolationEnabled.get(); interpolationEnabled.onUpdate(val => { localStorage.setItem("interpolation", val); }); interpolationToggle.addEventListener("change", () => { interpolationEnabled.set(interpolationToggle.checked); }); const authorityPositionToggle = document.getElementById("show-authority"); var showAuthorityPositions = new Stateful(localStorage.getItem("show-authority") || false); authorityPositionToggle.checked = showAuthorityPositions.get(); showAuthorityPositions.onUpdate(val => { localStorage.setItem("show-authority", val); }); authorityPositionToggle.addEventListener("change", () => { showAuthorityPositions.set(authorityPositionToggle.checked); }); var input = { move_up: 0.0, move_down: 0.0, move_left: 0.0, move_right: 0.0, }; function start() { const secure = location.protocol === "https:"; ws = new WebSocket((secure ? "wss://" : "ws://") + location.host); ws.addEventListener("open", () => { canvas.classList.remove("offline"); console.log("Websocket connection established!"); const name = prompt("What's your name?"); canvas.focus(); ws.send(JSON.stringify({ type: "join", name: name, })); }); ws.addEventListener("message", packet => { var data = JSON.parse(packet.data); switch (data.type) { case "welcome": client_id = data.id; Object.keys(data.players).forEach(id => { players[id] = new Player( data.players[id].name, data.players[id].x, data.players[id].y, data.players[id].col); }); Object.keys(data.props).forEach(id => { const prop = new Prop( data.props[id].name, data.props[id].x, data.props[id].y, data.props[id].col, data.props[id].sprite); prop.spriteImage = new Image(); prop.spriteImage.src = prop.sprite; props[id] = prop; }); console.log("client ID is " + client_id); break; case "join": console.log(data.name + " joined the game."); const p = document.createElement("p"); p.className = "chat-message"; p.innerText = data.name + " joined the game."; chatbox.appendChild(p); chatbox.scrollTop = chatbox.scrollHeight; players[data.id] = new Player(data.name, data.x, data.y, data.col); break; case "update": processServerTick(data); break; case "chat": { const player = players[data.player]; const _name = document.createElement("span"); _name.innerText = player.name; const _msg = document.createElement("span"); _msg.innerText = data.msg; const p = document.createElement("p"); p.className = "chat-message"; p.innerHTML = `<${_name.innerText}> ${_msg.innerText}`; chatbox.appendChild(p); chatbox.scrollTop = chatbox.scrollHeight; } case "leave": { const player = players[data.id]; if (!player) break; console.log(player.name + " left the game."); const p = document.createElement("p"); p.className = "chat-message"; p.innerText = player.name + " left the game."; chatbox.appendChild(p); chatbox.scrollTop = chatbox.scrollHeight; delete players[data.id]; break; } case "kick": { console.log("Kicked from the server: " + data.reason); const p = document.createElement("p"); p.className = "chat-message"; p.innerText = "Kicked from the server: " + data.reason; chatbox.appendChild(p); chatbox.scrollTop = chatbox.scrollHeight; break; } default: console.warn("Unknown message received from the server."); console.warn(msg); } }); ws.addEventListener("error", error => { canvas.classList.add("offline"); console.error(error); const p = document.createElement("p"); p.classList.add("chat-message"); p.classList.add("error"); p.innerText = "Connection error. Please refresh!"; chatbox.appendChild(p); chatbox.scrollTop = chatbox.scrollHeight; ws = undefined; }); ws.addEventListener("close", () => { canvas.classList.add("offline"); console.log("Websocket connection closed."); const p = document.createElement("p"); p.classList.add("chat-message"); p.classList.add("error"); p.innerText = "Connection error. Please refresh!"; chatbox.appendChild(p); chatbox.scrollTop = chatbox.scrollHeight; ws = undefined; }); } function processServerTick(data) { lastServerState = data; serverTick = data.tick; var localState = stateBuffer[data.tick % BUFFER_SIZE] if (localState) serverPing = new Date() - localState.time; // update players Object.keys(data.players).forEach(id => { const player = players[id]; const serverPlayerState = data.players[id]; if (!localState) { player.x = serverPlayerState.x; player.y = serverPlayerState.y; player.in_x = serverPlayerState.in_x; player.in_y = serverPlayerState.in_y; return; } const localPlayerState = localState.players[id]; // calculate position difference from authority var dist = Math.sqrt( Math.pow(localPlayerState.x - serverPlayerState.x, 2) + Math.pow(localPlayerState.y - serverPlayerState.y, 2)); if (dist > 10.0) { return; reconciliations++; console.warn("Player#" + id + ": Reconciling " + dist + " units of error."); console.warn("\tclient said (" + localPlayerState.x.toPrecision(4) + "," + localPlayerState.y.toPrecision(4) + ")"); console.warn("\tserver said (" + serverPlayerState.x.toPrecision(4) + "," + serverPlayerState.y.toPrecision(4) + ")"); // resync to authority and roll-forward localState.players[id] = serverPlayerState; for (var tick = serverTick + 1; tick < ticks; tick++) { const state = stateBuffer[(tick - 1) % BUFFER_SIZE].players[id]; const input = id == client_id ? inputBuffer[tick % BUFFER_SIZE] : { x: state.in_x, y: state.in_y, }; player.x = state.x; player.y = state.y; player.in_x = input.x; player.in_y = input.y; player.update(1000 / TICK_RATE / 1000); stateBuffer[tick % BUFFER_SIZE].player = { x: player.x, y: player.y, } } var lastState = stateBuffer[ticks % BUFFER_SIZE]; if (lastState) { player.x = lastState.players[id].x; player.y = lastState.players[id].y; } } }); // update props Object.keys(data.props).forEach(id => { const serverPropState = data.props[id]; const prop = props[id]; prop.x = serverPropState.x; prop.y = serverPropState.y; prop.xv = serverPropState.xv; prop.yv = serverPropState.yv; return; // TODO: reimplement this once player reconciliation is stable if (!localState) { prop.x = serverPropState.x; prop.y = serverPropState.y; prop.xv = serverPropState.xv; prop.yv = serverPropState.yv; return; } const localPropState = localState.props[id]; // calculate position difference from authority var dist = Math.sqrt( Math.pow(localPropState.x - serverPropState.x, 2) + Math.pow(localPropState.y - serverPropState.y, 2)); if (dist > 10.0) { reconciliations++; console.warn("Prop#" + id + ": Reconciling " + dist + " units of error."); // resync to authority and roll-forward localState.props[id] = serverPropState; for (var tick = serverTick; tick < ticks; tick++) { const state = stateBuffer[(tick - 1) % BUFFER_SIZE]; const propState = state.props[id]; prop.x = propState.x; prop.y = propState.y; prop.xv = propState.xv; prop.yv = propState.yv; prop.update(1000 / TICK_RATE / 1000, Object.values(state.players)); stateBuffer[tick % BUFFER_SIZE].props[id] = { x: prop.x, y: prop.y, xv: prop.xv, yv: prop.yv, } } var lastState = stateBuffer[ticks % BUFFER_SIZE]; if (lastState) { prop.x = lastState.props[id].x; prop.y = lastState.props[id].y; prop.xv = lastState.props[id].xv; prop.yv = lastState.props[id].yv; } } }); } canvas.addEventListener("keypress", event => { switch (event.key.toLowerCase()) { case 'i': interpolationEnabled.update(val => !val); interpolationToggle.checked = interpolationEnabled.get(); break; case 'p': showAuthorityPositions.update(val => !val); authorityPositionToggle.checked = showAuthorityPositions.get(); break; case 'enter': composeBox.focus(); break; } }); canvas.addEventListener("keydown", event => { switch (event.key.toLowerCase()) { case 'w': input.move_up = 1.0; break; case 'a': input.move_left = 1.0; break; case 's': input.move_down = 1.0; break; case 'd': input.move_right = 1.0; break; default: break; } }); canvas.addEventListener("keyup", event => { switch (event.key.toLowerCase()) { case 'w': input.move_up = 0.0; break; case 'a': input.move_left = 0.0; break; case 's': input.move_down = 0.0; break; case 'd': input.move_right = 0.0; break; default: break; } }); canvas.addEventListener("focusout", () => { input.move_up = 0.0; input.move_left = 0.0; input.move_down = 0.0; input.move_right = 0.0; }); composeBtn.addEventListener("click", () => { sendChat(composeBox.value); composeBox.value = ""; }); composeBox.addEventListener("keypress", event => { if (event.key != "Enter") return; sendChat(composeBox.value); composeBox.value = ""; canvas.focus(); }); function sendChat(msg) { if (msg === "") return; setTimeout(() => { if (!ws) return; ws.send(JSON.stringify({ type: "chat", msg: msg, })); }, fakePing.get()); } function update(delta) { const clientPlayer = players[client_id]; if (!clientPlayer) return; clientPlayer.in_x = input.move_right - input.move_left; clientPlayer.in_y = input.move_down - input.move_up; inputBuffer[ticks % BUFFER_SIZE] = { x: clientPlayer.in_x, y: clientPlayer.in_y, }; var playerState = {}; Object.keys(players).forEach(id => { const player = players[id]; player.update(delta); playerState[id] = { x: player.x, y: player.y, in_x: player.in_x, in_y: player.in_y, }; }); var propState = {}; Object.keys(props).forEach(id => { const prop = props[id]; prop.update(delta, Object.values(players)); propState[id] = { x: prop.x, y: prop.y, xv: prop.xv, yv: prop.yv, }; }); stateBuffer[ticks % BUFFER_SIZE] = { tick: ticks, time: new Date(), players: playerState, props: propState, }; setTimeout(ticks => { if (!ws) return; ws.send(JSON.stringify({ type: "update", tick: ticks, x: input.move_right - input.move_left, y: input.move_down - input.move_up, })); }, fakePing.get() / 2, ticks); ticks++; } function draw() { delta = performance.now() - lastUpdate; if (performance.now() - lastUpdate >= 1000 / TICK_RATE) { lastUpdate = performance.now(); update(delta / 1000); } ctx.clearRect(0, 0, WORLD_SIZE, WORLD_SIZE); ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE); // DEBUG: show authoritative position for client players and props var authorityDistance = 0.0; if (lastServerState && showAuthorityPositions.get()) { const player = players[client_id]; const lastPlayerState = lastServerState.players[client_id]; authorityDistance = Math.sqrt( Math.pow(players[client_id].x - lastPlayerState.x, 2) + Math.pow(players[client_id].y - lastPlayerState.y, 2)); ctx.strokeStyle = player.colour; ctx.beginPath(); ctx.rect( lastPlayerState.x - Player.SIZE / 2, lastPlayerState.y - Player.SIZE / 2, Player.SIZE, Player.SIZE); ctx.stroke(); Object.keys(props).forEach(id => { const prop = props[id]; const lastPropState = lastServerState.props[id]; ctx.strokeStyle = prop.colour; ctx.beginPath(); ctx.rect( lastPropState.x - Prop.SIZE / 2, lastPropState.y - Prop.SIZE / 2, Prop.SIZE, Prop.SIZE); ctx.stroke(); }); } drawPlayers(); drawProps(); var debug = { "ping": serverPing + "ms", "fake ping": + fakePing.get() + "ms", "ticks behind": ticks - serverTick, "reconciliations": reconciliations, } if (showAuthorityPositions.get()) { debug["authority distance"] = authorityDistance; } ctx.fillStyle = "#101010"; ctx.font = "16px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; var debug_y = WORLD_SIZE - 8 - (Object.keys(debug).length - 1) * 16; Object.keys(debug).forEach((key, i) => { ctx.fillText(key + ": " + debug[key], 8, debug_y + 16 * i); }); frames++; requestAnimationFrame(draw); } function drawPlayers() { Object.keys(players).forEach((id, index) => { const player = players[id]; if (interpolationEnabled.get()) { player.draw_x = player.draw_x + 0.1 * (player.x - player.draw_x); player.draw_y = player.draw_y + 0.1 * (player.y - player.draw_y); } else { player.draw_x = player.x; player.draw_y = player.y; } ctx.drawImage( playerSprite, player.draw_x - Player.SIZE / 2, player.draw_y - Player.SIZE / 2, Player.SIZE, Player.SIZE ); ctx.fillStyle = player.colour; ctx.font = "16px monospace"; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillText(player.name, player.draw_x, player.draw_y - Player.SIZE / 2 - 16); ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText(`${player.name} (${id})`, 8, 28 + index * 16); }); ctx.fillStyle = "#101010"; ctx.font = "20px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText("Players:", 8, 8); } function drawProps() { Object.keys(props).forEach(id => { const prop = props[id]; if (interpolationEnabled.get()) { prop.draw_x = prop.draw_x + 0.1 * (prop.x - prop.draw_x); prop.draw_y = prop.draw_y + 0.1 * (prop.y - prop.draw_y); } else { prop.draw_x = prop.x; prop.draw_y = prop.y; } ctx.drawImage( prop.spriteImage, prop.draw_x - Prop.SIZE / 2, prop.draw_y - Prop.SIZE / 2, Prop.SIZE, Prop.SIZE ); ctx.fillStyle = prop.colour; ctx.font = "16px monospace"; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillText(prop.name, prop.draw_x, prop.draw_y - Prop.SIZE / 2 - 16); }); } start(); requestAnimationFrame(draw);