commit c35c18bbbcaefef7cf1328ef2b20b1cb3ad5e881 Author: ari melody Date: Fri Aug 30 03:56:03 2024 +0100 first commit! 🎉 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..89c3529 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +.git/ +.gitignore +nodemon.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2165a5b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:alpine + +WORKDIR /app + +COPY . . + +RUN npm ci + +ENTRYPOINT [ "npm", "run", "start" ] + diff --git a/client/css/main.css b/client/css/main.css new file mode 100644 index 0000000..ce1a63d --- /dev/null +++ b/client/css/main.css @@ -0,0 +1,69 @@ +body { + margin: 0; + padding: 1em; + + font-family: "Inter", "sans-serif"; + + text-align: center; +} + +#game { + border: 2px solid #ddd; +} + +#game:focus { + outline: none; +} + +#game.offline { + opacity: .5; + cursor: not-allowed; +} + +#chatbox { + width: min(calc(100% - 4px), calc(796px - 1em)); + height: 8em; + margin: 1em auto 0 auto; + padding: .5em; + display: flex; + flex-direction: column; + text-align: left; + background: #f8f8f8; + border: 2px solid #e0e0e0; + border-bottom: none; + overflow-y: scroll; +} + +#chatbox .chat-message { + margin: 0; +} + +.chat-message.error { + color: #e42020; +} + +#compose { + width: min(100%, 800px); + margin: 0 auto; + display: flex; +} + +#compose input[type="text"] { + padding: .2em .5em; + background: #fff; + border: 2px solid #e0e0e0; + overflow-y: scroll; + flex-grow: 1; + border-right: none; +} + +#compose button { + padding: .2em 1em; + border: 2px solid #2b8adf; + background: #51acfd; +} + +footer { + color: #808080; + font-style: italic; +} diff --git a/client/img/ball.png b/client/img/ball.png new file mode 100644 index 0000000..b97c47e Binary files /dev/null and b/client/img/ball.png differ diff --git a/client/img/player.png b/client/img/player.png new file mode 100644 index 0000000..2026744 Binary files /dev/null and b/client/img/player.png differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..9d771f0 --- /dev/null +++ b/client/index.html @@ -0,0 +1,39 @@ + + + + + + multiplayer test + + + +

multiplayer test

+ + +
+ + + + +
+
+
+ + +
+ + + diff --git a/client/js/main.js b/client/js/main.js new file mode 100644 index 0000000..de9d254 --- /dev/null +++ b/client/js/main.js @@ -0,0 +1,432 @@ +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; + Object.keys(data.players).forEach(id => { + const player = players[id]; + const update = data.players[id]; + // this should never be true, but just in case + if (!player) return; + if (id == client_id) { + // clear all predictions prior to this tick + Object.keys(predictions).forEach(tick => { + if (tick < data.tick) delete predictions[tick]; + }); + var prediction = predictions[data.tick]; + if (!prediction) return; + server_ping = new Date() - prediction.time; + if (Math.abs(prediction.x - update.x) > 1) + players[client_id].x = update.x; + if (Math.abs(prediction.y - update.y) > 1) + players[client_id].y = update.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]; + + prop.x = update.x; + prop.y = update.y; + }); + 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 '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) { + setTimeout(() => { + if (!ws) return; + ws.send(JSON.stringify({ + type: "chat", + msg: msg, + })); + }, fake_ping.get() / 2); +} + +function update(delta) { + const clientPlayer = players[client_id]; + if (clientPlayer) { + 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 + predictions[ticks] = { + time: new Date(), + 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); + } + + 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.x - Player.SIZE / 2, + server_state.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); + diff --git a/client/js/silver.min.js b/client/js/silver.min.js new file mode 100644 index 0000000..47d4cbb --- /dev/null +++ b/client/js/silver.min.js @@ -0,0 +1 @@ +export default class Stateful{#e;#t=[];constructor(e){this.#e=e}get(){return this.#e}set(e){let t=this.#e;this.#e=e;for(let s in this.#t)this.#t[s](e,t)}update(e){this.set(e(this.#e))}onUpdate(e){return this.#t.push(e),e}removeListener(e){this.#t=this.#t.filter((t=>t!==e))}} diff --git a/common/log.js b/common/log.js new file mode 100644 index 0000000..32f9d18 --- /dev/null +++ b/common/log.js @@ -0,0 +1,13 @@ +export default class Log { + static info(message) { + console.log('[' + new Date().toISOString() + '] ' + message); + } + + static warn(message) { + console.warn('[' + new Date().toISOString() + '] WARN: ' + message); + } + + static error(message) { + console.error('[' + new Date().toISOString() + '] FATAL: ' + message); + } +} diff --git a/common/player.js b/common/player.js new file mode 100644 index 0000000..d624fb0 --- /dev/null +++ b/common/player.js @@ -0,0 +1,20 @@ +export default class Player { + static SPEED = 400.0; + static SIZE = 50.0; + + constructor(name, x, y, colour) { + this.x = x; + this.y = y; + this.in_x = 0.0; + this.in_y = 0.0; + this.draw_x = x; + this.draw_y = y; + this.name = name; + this.colour = colour; + } + + update(delta) { + if (this.in_x != 0) this.x += this.in_x * Player.SPEED * delta; + if (this.in_y != 0) this.y += this.in_y * Player.SPEED * delta; + } +} diff --git a/common/prop.js b/common/prop.js new file mode 100644 index 0000000..4045d2c --- /dev/null +++ b/common/prop.js @@ -0,0 +1,48 @@ +import Player from "./player.js"; +import { WORLD_SIZE } from "./world.js"; + +export default class Prop { + static SIZE = 50.0; + + constructor(name, x, y, colour, sprite) { + this.x = x; + this.y = y; + this.xv = 0.0; + this.yv = 0.0; + this.draw_x = x; + this.draw_y = y; + this.name = name; + this.colour = colour; + this.sprite = sprite; + } + + update(delta, players) { + players.forEach(player => { + if (this.x - Prop.SIZE / 2 < player.x + Player.SIZE / 2 && + this.x + Prop.SIZE / 2 > player.x - Player.SIZE / 2 && + this.y - Prop.SIZE / 2 < player.y + Player.SIZE / 2 && + this.y + Prop.SIZE / 2 > player.y - Player.SIZE / 2) { + this.xv += player.in_x * Player.SPEED * delta * 20.0; + this.yv += player.in_y * Player.SPEED * delta * 20.0; + } + }); + + if (this.xv != 0) this.x += this.xv * delta; + if (this.yv != 0) this.y += this.yv * delta; + + this.xv *= 0.95; + this.yv *= 0.95; + + // bounce off walls + if (this.x + Prop.SIZE / 2 > WORLD_SIZE || + this.x - Prop.SIZE / 2 < 0.0) { + this.x = Math.min(Math.max(this.x, 0.0), WORLD_SIZE); + this.xv *= -1; + } + if (this.y + Prop.SIZE / 2 > WORLD_SIZE || + this.y - Prop.SIZE / 2 < 0.0) { + this.y = Math.min(Math.max(this.y, 0.0), WORLD_SIZE); + this.yv *= -1; + } + } +} diff --git a/common/world.js b/common/world.js new file mode 100644 index 0000000..0293fb2 --- /dev/null +++ b/common/world.js @@ -0,0 +1 @@ +export const WORLD_SIZE = 500; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b389d78 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + web: + build: . + image: docker.arimelody.me/multiplayer-test:latest + ports: + - "3000:3000" diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..3376d1e --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "ext": "js", + "ignore": [ + "client" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..52ee977 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "client-server-test", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "client-server-test", + "version": "1.0.0", + "license": "GPLv3", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea910c2 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "client-server-test", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node ./server/main.js", + "dev": "nodemon ./server/main.js" + }, + "author": "ari melody ", + "license": "GPLv3", + "description": "", + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/server/main.js b/server/main.js new file mode 100644 index 0000000..3f98b47 --- /dev/null +++ b/server/main.js @@ -0,0 +1,112 @@ +import http from "http"; +import path from "path"; +import fs, { openSync } from "fs"; +import Log from "../common/log.js"; +import { init as initWS } from "./ws.js"; + +var PORT = 3000; +var mime_types = { + 'html': 'text/html', + 'css': 'text/css', + 'js': 'application/javascript', + 'png': 'image/png', + 'jpg': 'image/jpg', + 'gif': 'image/gif', +}; + +process.argv.forEach((arg, index) => { + if (index < 2) return; + if (!arg.startsWith('-')) return; + switch (arg.substring(1)) { + case "port": + if (process.argv.length < index + 2) { + Log.error("FATAL: -port was supplied with no arguments."); + exit(1); + } + var val = process.argv[index + 1]; + if (val.startsWith('-')) { + Log.error("FATAL: -port was supplied with no arguments."); + exit(1); + } + var port = Number(val); + if (isNaN(port)) { + Log.error("FATAL: -port was supplied with invalid arguments."); + exit(1); + } + PORT = port; + break; + } +}); + +const server = http.createServer(async (req, res) => { + var code = await new Promise((resolve) => { + if (req.method !== "GET") { + res.writeHead(501, { + "Content-Type": "text/plain", + "Server": "ari's awesome server" + }); + res.end("Not Implemented"); + return resolve(501); + } + + // find the file + var filepath = path.join("client", req.url); + if (req.url.startsWith("/common/")) { + filepath = path.join("common", req.url.slice(8)); + } + if (filepath.endsWith('/') || filepath.endsWith('\\')) { + filepath = path.join(filepath, "index.html"); + } + if (!fs.existsSync(filepath)) { + res.writeHead(404, { + "Content-Type": "text/plain", + "Server": "ari's awesome server" + }); + res.end("Not Found"); + return resolve(404); + } + + try { + var ext = path.extname(filepath).slice(1); + var mime_type = mime_types[ext] || "application/octet-stream"; + + const stream = fs.createReadStream(filepath); + res.writeHead(200, { + "Content-Type": mime_type, + "Server": "ari's awesome server" + }); + stream.pipe(res); + return resolve(200); + stream.on("open", () => { + res.writeHead(200, { + "Content-Type": mime_type, + "Server": "ari's awesome server" + }); + stream.pipe(res); + res.end(); + return resolve(200); + }); + stream.on("error", error => { + throw error; + }); + } catch (error) { + Log.error(error); + res.writeHead(500, { + "Content-Type": "text/plain", + "Server": "ari's awesome server" + }); + res.end("Internal Server Error"); + return resolve(500); + } + }); + + Log.info(`${code} - ${req.method} ${req.url}`); +}); + +initWS(server); + +server.on("listening", () => { + Log.info("server listening on port " + PORT); +}); + +server.listen(PORT, "0.0.0.0") diff --git a/server/ws.js b/server/ws.js new file mode 100644 index 0000000..a387592 --- /dev/null +++ b/server/ws.js @@ -0,0 +1,231 @@ +import { WebSocketServer } from "ws"; +import Log from "../common/log.js"; +import { WORLD_SIZE } from "../common/world.js"; +import Player from "../common/player.js"; +import Prop from "../common/prop.js"; + +const TICK_RATE = 30; + +var clients = []; +var props = { + 1: new Prop("the silly", + WORLD_SIZE / 2, WORLD_SIZE / 2, + "#ff00ff", "/img/ball.png") +}; +var last_update = 0.0; +var ticks = 0; +var wss; + +export function init(http_server) { + wss = new WebSocketServer({ server: http_server }); + + wss.on("connection", (socket) => { + clients.push(socket); + + socket.on("error", error => { + Log.warn("Websocket connection closed due to error: " + error); + if (socket.player) { + broadcast({ + type: "leave", + id: socket.id, + }); + } + clients = clients.filter(s => s != socket); + }); + + socket.on("close", () => { + if (socket.player) { + Log.info(socket.player.name + " left the game."); + broadcast({ + type: "leave", + id: socket.id, + }); + } + clients = clients.filter(s => s != socket); + }); + + socket.on("message", msg => { + try { + const data = JSON.parse(msg); + if (!data.type) + throw new Error("Type not specified"); + + switch (data.type) { + case "join": + if (!data.name) + throw new Error("Name cannot be null"); + + var player_name = data.name.slice(0, 32); + player_name = player_name.replaceAll('<', '<'); + player_name = player_name.replaceAll('>', '>'); + player_name = player_name.trim(); + + socket.id = generateID(); + socket.player = new Player( + data.name.slice(0, 32), + WORLD_SIZE / 2, + WORLD_SIZE / 2, + randomColour() + ); + + Log.info(socket.player.name + " joined the game."); + + var lobby_players = {}; + clients.forEach(client => { + if (!client.player) return; + lobby_players[client.id] = { + name: client.player.name, + x: client.player.x, + y: client.player.y, + col: client.player.colour, + }; + }); + + var lobby_props = {}; + Object.keys(props).forEach(id => { + const prop = props[id]; + lobby_props[id] = { + name: prop.name, + x: prop.x, + y: prop.y, + col: prop.colour, + sprite: prop.sprite, + } + }); + + socket.send(JSON.stringify({ + type: "welcome", + id: socket.id, + tick: ticks, + players: lobby_players, + props: lobby_props, + })); + + clients.forEach(s => { + if (s.id == socket.id) return; + // send player joined event + s.send(JSON.stringify({ + type: "join", + id: socket.id, + name: socket.player.name, + x: socket.player.x, + y: socket.player.y, + col: socket.player.colour, + })); + }); + break; + case "update": + if (!socket.player) + throw new Error("Player does not exist"); + if (data.x === undefined || data.y === undefined) + throw new Error("Movement vector not provided"); + if (data.tick === undefined) + throw new Error("User tick not provided"); + + socket.player.tick = data.tick; + socket.player.in_x = Math.min(1.0, data.x); + socket.player.in_y = Math.min(1.0, data.y); + break; + case "chat": + if (data.msg === undefined) + throw new Error("Attempted chat with no message"); + Log.info('<' + socket.player.name + '> ' + data.msg) + data.msg = data.msg.replaceAll("<", "<") + data.msg = data.msg.replaceAll(">", ">") + data.msg = data.msg.replaceAll("\n", ""); + data.msg = data.msg.trim(); + if (data.msg == "") return; + clients.forEach(client => { + client.send(JSON.stringify({ + type: "chat", + player: socket.id, + msg: data.msg, + })); + }); + break; + default: + throw new Error("Invalid message type"); + } + } catch (error) { + if (socket.player) { + Log.warn("Received invalid packet from " + socket.player.id + ": " + error); + socket.send(JSON.stringify({ + type: "kick", + reason: "Received invalid packet", + })); + } else { + Log.warn("Received invalid packet: " + error); + } + socket.close(); + } + }); + }); + + update(); +} + +function update() { + var delta = (performance.now() - last_update) / 1000; + + // update players + var frame_players = {}; + clients.forEach(client => { + if (!client.player) return; + client.player.update(delta); + client.player.x = Math.max(Math.min(client.player.x, WORLD_SIZE), 0); + client.player.y = Math.max(Math.min(client.player.y, WORLD_SIZE), 0); + frame_players[client.id] = { + x: client.player.x, + y: client.player.y, + }; + }); + + // god help me this code is awful + // really leaning on this just being a tech demo here + var prop_players = []; + clients.forEach(client => { + if (client.player) prop_players.push(client.player); + }); + var frame_props = {}; + Object.keys(props).forEach(id => { + const prop = props[id]; + prop.update(delta, prop_players); + frame_props[id] = { + x: prop.x, + y: prop.y, + } + }); + + // send update to players + clients.forEach(client => { + if (!client.player) return; + client.send(JSON.stringify({ + type: "update", + tick: client.player.tick, + players: frame_players, + props: frame_props, + })); + }) + + last_update = performance.now(); + ticks++; + setTimeout(update, 1000 / TICK_RATE); +} + +function broadcast(data) { + clients.forEach(socket => { + socket.send(JSON.stringify(data)); + }); +} + +function generateID() { + // five random digits followed by five digits from the end of unix timestamp + return (10000 + Math.floor(Math.random() * 90000)).toString() + (new Date() % 100000).toString(); +} + +function randomColour() { + var res = "#"; + for (var i = 0; i < 6; i++) + res += "0123456789abcdef"[Math.floor(Math.random() * 16)]; + return res; +}