first commit! 🎉
This commit is contained in:
commit
c35c18bbbc
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
nodemon.json
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
node_modules/
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
FROM node:alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
ENTRYPOINT [ "npm", "run", "start" ]
|
||||||
|
|
69
client/css/main.css
Normal file
69
client/css/main.css
Normal file
|
@ -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;
|
||||||
|
}
|
BIN
client/img/ball.png
Normal file
BIN
client/img/ball.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
BIN
client/img/player.png
Normal file
BIN
client/img/player.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
39
client/index.html
Normal file
39
client/index.html
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>multiplayer test</title>
|
||||||
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>multiplayer test</h1>
|
||||||
|
<canvas id="game" class="offline" tabindex="1"></canvas>
|
||||||
|
<script type="module" src="/js/main.js"></script>
|
||||||
|
<div id="controls">
|
||||||
|
<label for="fakeping">Fake Latency (ms): </label>
|
||||||
|
<input type="number" id="fakeping" value="0" min="0" max="1000" title="This feature is broken!" disabled>
|
||||||
|
<label for="interpolation">Interpolation: </label>
|
||||||
|
<input type="checkbox" id="interpolation">
|
||||||
|
</div>
|
||||||
|
<div id="chatbox"></div>
|
||||||
|
<div id="compose">
|
||||||
|
<input type="text" id="compose-msg" value="" placeholder="Chat with players...">
|
||||||
|
<button type="submit" id="compose-btn">Send</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
This is a test game I'm using to learn client/server interactions.
|
||||||
|
Expect a very minimal experience with little polish!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Mobile devices are sadly not supported, but not off the table!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you enjoy this little project, or have any neat ideas,
|
||||||
|
feel free to send them my way at
|
||||||
|
<a href="mailto:ari@arimelody.me">ari@arimelody.me</a>.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</html>
|
432
client/js/main.js
Normal file
432
client/js/main.js
Normal file
|
@ -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 = `<<span style="color:${player.colour}">${_name.innerText}</span>> ${_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);
|
||||||
|
|
1
client/js/silver.min.js
vendored
Normal file
1
client/js/silver.min.js
vendored
Normal file
|
@ -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))}}
|
13
common/log.js
Normal file
13
common/log.js
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
20
common/player.js
Normal file
20
common/player.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
48
common/prop.js
Normal file
48
common/prop.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
common/world.js
Normal file
1
common/world.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const WORLD_SIZE = 500;
|
6
docker-compose.yml
Normal file
6
docker-compose.yml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
image: docker.arimelody.me/multiplayer-test:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
6
nodemon.json
Normal file
6
nodemon.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"ext": "js",
|
||||||
|
"ignore": [
|
||||||
|
"client"
|
||||||
|
]
|
||||||
|
}
|
37
package-lock.json
generated
Normal file
37
package-lock.json
generated
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
package.json
Normal file
16
package.json
Normal file
|
@ -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 <ari@arimelody.me>",
|
||||||
|
"license": "GPLv3",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
112
server/main.js
Normal file
112
server/main.js
Normal file
|
@ -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")
|
231
server/ws.js
Normal file
231
server/ws.js
Normal file
|
@ -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;
|
||||||
|
}
|
Loading…
Reference in a new issue