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 last_update = 0.0; var frames = 0; var ticks = 0; var server_tick = 0; var server_ping = 0; var ws; var predictions = {}; const interpolationToggle = document.getElementById("interpolation"); var enable_interpolation = new Stateful(localStorage.getItem("interpolation") || true); interpolationToggle.checked = enable_interpolation.get(); enable_interpolation.onUpdate(val => { localStorage.setItem("interpolation", val); }); interpolationToggle.addEventListener("change", () => { enable_interpolation.set(interpolationToggle.checked); }); const fakePingInput = document.getElementById("fakeping"); var fake_ping = new Stateful(localStorage.getItem("fakeping") || 0); fakePingInput.value = fake_ping.get(); fake_ping.onUpdate(val => { localStorage.setItem("fakeping", val); }); fakePingInput.addEventListener("change", () => { fake_ping.set(fakePingInput.value); }); 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 => { setTimeout(() => { 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": { server_tick = data.tick; var prediction = predictions[data.tick]; if (prediction) { Object.keys(predictions).forEach(tick => { if (tick < data.tick) delete predictions[tick]; }); server_ping = new Date() - prediction.time; } Object.keys(data.players).forEach(id => { const player = players[id]; const update = data.players[id]; if (id == client_id) { if (!prediction) return; const predicted = prediction.player; // clear all predictions prior to this tick if (predicted != update) { var diff_x = predicted.x - update.x; var diff_y = predicted.y - update.y; // apply difference to all predictions Object.values(predictions).forEach(p => { p.x -= diff_x; p.y -= diff_y; }); // update client state to reflect player.x -= diff_x; player.y -= diff_y; } delete predictions[data.tick]; } else { player.x = update.x; player.y = update.y; } }); Object.keys(data.props).forEach(id => { const prop = props[id]; const update = data.props[id]; if (!prediction) { prop.x = update.x; prop.y = update.y; prop.xv = update.xv; prop.yv = update.yv; return; } const predicted = prediction.props[id]; if (predicted != update) { var diff_x = predicted.x - update.x; var diff_y = predicted.y - update.y; var diff_xv = predicted.xv - update.xv; var diff_yv = predicted.yv - update.yv; // apply difference to all predictions Object.values(predictions).forEach(p => { p.x -= diff_x; p.y -= diff_y; p.xv -= diff_xv; p.yv -= diff_yv; }); // update client state to reflect prop.x -= diff_x; prop.y -= diff_y; prop.xv -= diff_xv; prop.yv -= diff_yv; } }); 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); } }, fake_ping.get() / 2); }); 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; }); } canvas.addEventListener("keypress", event => { switch (event.key.toLowerCase()) { case 'i': enable_interpolation.update(val => !val); interpolationToggle.checked = enable_interpolation.get(); break; case 'p': console.log(predictions); 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, })); }, fake_ping.get() / 2); } function update(delta) { const prediction = { time: new Date(), player: {}, props: {}, }; 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; clientPlayer.update(delta); // insert prediction for the next server tick prediction.player = { x: clientPlayer.x, y: clientPlayer.y, }; var t = ticks; setTimeout(() => { if (!ws) return; ws.send(JSON.stringify({ type: "update", tick: t, x: input.move_right - input.move_left, y: input.move_down - input.move_up, })); }, fake_ping.get() / 2); Object.keys(props).forEach(id => { const prop = props[id]; prop.update(delta, Object.values(players)); prediction.props[id] = { x: prop.x, y: prop.y, xv: prop.xv, yv: prop.yv, }; }); predictions[ticks] = prediction; ticks++; } function draw() { delta = performance.now() - last_update; if (performance.now() - last_update >= 1000 / TICK_RATE) { last_update = performance.now(); update(delta / 1000); } ctx.clearRect(0, 0, WORLD_SIZE, WORLD_SIZE); ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE); drawPlayers(); drawProps(); // DEBUG: draw last known authoritative state if (Object.values(predictions).length > 10) { const server_state = Object.values(predictions)[0]; ctx.fillStyle = "#208020"; ctx.beginPath(); ctx.rect(server_state.player.x - Player.SIZE / 2, server_state.player.y - Player.SIZE / 2, Player.SIZE, Player.SIZE); ctx.stroke(); } var debug = "ping: " + server_ping + "ms\n" + "fake ping: " + fake_ping.get() + "ms\n" + "buffer length: " + Object.keys(predictions).length + "\n" + "delta: " + delta + "\n" + "ticks behind: " + (ticks - server_tick); ctx.fillStyle = "#101010"; ctx.font = "16px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; var debug_lines = debug.split('\n'); var debug_y = WORLD_SIZE - 8 - (debug_lines.length - 1) * 16; for (var i = 0; i < debug_lines.length; i++) { ctx.fillText(debug_lines[i], 8, debug_y + 16 * i); } frames++; requestAnimationFrame(draw); } function drawPlayers() { Object.keys(players).forEach((id, index) => { const player = players[id]; if (enable_interpolation.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 (enable_interpolation.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);