diff --git a/client/css/main.css b/client/css/main.css
index fbe272c..bfb7426 100644
--- a/client/css/main.css
+++ b/client/css/main.css
@@ -3,9 +3,26 @@
padding: 0;
}
-#container img {
- image-rendering: -webkit-optimize-contrast; /* webkit */
- image-rendering: -moz-crisp-edges /* Firefox */
+:root {
+ font-size: 2rem;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
+ color: #fff;
+}
+
+body {
+ background-color: #191919;
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+}
+
+#center {
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
}
.rotate90 {
@@ -25,3 +42,33 @@
transition: left .1s, top .1s;
z-index: 2;
}
+
+#join, #lobby {
+ display: flex;
+ flex-direction: column;
+}
+
+#lobby span {
+ margin-bottom: .5rem;
+}
+
+#lobby #start {
+ margin-top: 1rem;
+}
+
+#lobby #players {
+ display: flex;
+ flex-direction: column;
+}
+
+input {
+ background-color: transparent;
+ outline: none;
+ box-shadow: none;
+ color: #fff;
+ font-size: 1rem;
+ border: solid 2px #fff;
+ padding: .25rem;
+ margin-bottom: .215rem;
+}
+
diff --git a/client/index.html b/client/index.html
index 9ba6b4c..0740f5f 100644
--- a/client/index.html
+++ b/client/index.html
@@ -1,8 +1,21 @@
+
+
diff --git a/client/js/game.js b/client/js/game.js
new file mode 100644
index 0000000..3d8f6a7
--- /dev/null
+++ b/client/js/game.js
@@ -0,0 +1,111 @@
+/**
+ * @author tint
+ * @template Data, Input
+ */
+export class Game {
+ /**
+ * @param {number} history How many frames of history to keep in memory
+ * @param {(data: Data, input: Input, frame: number) => Data} advance The function to apply game logic. For rollback to work properly, this must be a pure function, and can't mutate inputs.
+ */
+ constructor(history, advance) {
+ this.historysize = history;
+ this.advance = advance;
+
+ /** @type {{data: Data, input: Input}[]} */
+ this.history = [];
+ this.historyStart = 0;
+
+ // the game may have inputs from the "future"
+ // (local input delay to make sure inputs play at the same time on all machines)
+ // so the "present" isn't always the latest frame
+ // the game loop should set this every frame
+ this.currentFrame = 0;
+ }
+
+ startHistory(frame, data) {
+ this.historyStart = frame;
+ this.history = [{ data }];
+ this.currentFrame = frame;
+ }
+
+ getHistory(frame) {
+ return this.history[frame - this.historyStart];
+ }
+
+ getFrame() {
+ return this.historyStart + this.history.length - 1;
+ }
+
+ getCurrentData() {
+ const entry = this.history[this.history.length - 1];
+ return entry && entry.data;
+ }
+
+ /**
+ * Sets the input at a specific frame. If that frame is in history,
+ * the game will be rewound, the input applied, and then fast-forwarded to the current head.
+ * If the frame is ahead of the current latest frame, the game will be run until that frame.
+ * @param {number} frame The time to apply the input at
+ * @param {Input} input The input
+ */
+ setInput(frame, input) {
+ this.editFrame(frame, index => {
+ let past = this.history[index - 1];
+ if(index === 0) {
+ past = { data: undefined };
+ }
+ this.history[index] = {
+ input,
+ data: this.advance(past ? past.data : undefined, input, frame),
+ };
+ });
+ }
+
+ setData(frame, data) {
+ this.editFrame(frame, index => {
+ this.history[index] = {
+ data,
+ input: this.history[index] && this.history[index].input,
+ }
+ });
+ }
+
+ /**
+ * @param {number} frame
+ * @param {(index: number) => void} edit
+ */
+ editFrame(frame, edit) {
+ const head = this.historyStart + this.history.length;
+ if(frame < head) {
+ if(frame < this.historyStart) {
+ throw new Error("Tried to edit a past frame not in history:", frame);
+ }
+
+ edit(frame - this.historyStart);
+ // fast forward back to the present with the new data
+ for(let i = frame + 1; i < head; i++) {
+ const past = this.history[i - this.historyStart - 1];
+ this.history[i - this.historyStart].data = this.advance(
+ past ? past.data : undefined,
+ this.history[i - this.historyStart].input,
+ i
+ );
+ }
+ } else {
+ // fast forward the inbetween frames with no input
+ for(let i = head; i < frame; i++) {
+ const entry = this.history[i - this.historyStart - 1];
+ this.history[i - this.historyStart] = {
+ input: undefined,
+ data: this.advance(entry ? entry.data : undefined, undefined, i),
+ };
+ }
+ edit(frame - this.historyStart);
+ }
+
+ while(this.history.length > this.historysize) {
+ this.history.shift();
+ this.historyStart++;
+ }
+ }
+}
diff --git a/client/js/gfx/graphics.js b/client/js/gfx/graphics.js
new file mode 100644
index 0000000..e73927e
--- /dev/null
+++ b/client/js/gfx/graphics.js
@@ -0,0 +1,56 @@
+import { Sprite } from './sprite.js'
+import { Rotation } from '../logic.js'
+
+
+export const startGraphicsUpdater = () => {
+
+ let sprites = []
+
+ /**
+ * @type {(data: import("../logic.js").GameState) => void}
+ */
+ return (data) => {
+
+ if (!data.started) return
+
+ let players = Object.keys(data.players).filter(k => data.players[k] !== undefined)
+
+ if (sprites.length !== players.length) {
+
+ for (const sprite of sprites) {
+ if (sprite !== undefined) {
+ sprite.destroy()
+ }
+ }
+
+ sprites = Array(players)
+ sprites.fill(undefined)
+
+ for (let id of players) {
+ let sprite = new Sprite("/static/tux.png", data.map)
+ sprite.show()
+ sprite.resize(1.5,1.5)
+ sprites[id] = sprite
+ }
+ }
+
+ for (let id of players) {
+ let pos = data.players[id].pos
+ sprites[id].move(pos[0], pos[1])
+ switch (data.players[id].move_rot) {
+ case Rotation.NORTH:
+ sprites[id].rotate(270)
+ break
+ case Rotation.EAST:
+ sprites[id].rotate(0)
+ break
+ case Rotation.SOUTH:
+ sprites[id].rotate(90)
+ break
+ case Rotation.WEST:
+ sprites[id].rotate(180)
+ break
+ }
+ }
+ }
+}
diff --git a/client/js/map.js b/client/js/gfx/map.js
similarity index 84%
rename from client/js/map.js
rename to client/js/gfx/map.js
index e293ad2..0ba7f9c 100644
--- a/client/js/map.js
+++ b/client/js/gfx/map.js
@@ -1,18 +1,11 @@
-const create_style = (map) => {
+const gen_style = (map, style) => {
const css = `
* {
--scale: 100;
--aspect: ${map.width/map.height};
--scaleX: calc(var(--scale) * 1vw);
--scaleY: calc(var(--scale) * 1vh);
- }
-
- body {
- background-color: #191919;
- width: 100vw;
- height: 100vh;
- display: flex;
- }
+ }
#container {
width: calc(var(--scaleY) * var(--aspect));
@@ -20,12 +13,15 @@ const create_style = (map) => {
margin-top: calc((100vh - var(--scaleY))/2);
margin-left: calc(50vw - var(--scaleY)*var(--aspect)/2);
position: relative;
+ vertical-align: top;
+ line-height: 0;
}
#container img {
display: inline-block;
width: ${100/map.width}%;
height: ${100/map.height}%;
+ image-rendering: pixelated;
}
@media (max-aspect-ratio: ${map.width}/${map.height}) {
@@ -37,7 +33,7 @@ const create_style = (map) => {
}
}`;
- map.style.innerHTML = css
+ style.innerHTML = css
}
const Direction = {
@@ -211,11 +207,10 @@ const gen_walls = (width, height, data) => {
return walls
}
-const gen_map = (map) => {
- let walls = gen_walls(map.width, map.height, map.data)
+const gen_map = (map, container) => {
for (let y = 0; y < map.height; y++) {
for (let x = 0; x < map.width; x++) {
- place_tile(map.container, walls[y * map.width + x])
+ place_tile(container, map.walls[y * map.width + x])
}
}
@@ -224,20 +219,43 @@ const gen_map = (map) => {
export class Map {
constructor(width, height, data) {
+
+ let last = document.getElementById("container")
+ if (last) last.remove()
+
this.width = width
this.height = height
this.data = data
- this.container = document.body.appendChild(document.createElement("div"))
- this.container.id = "container"
- this.style = document.body.appendChild(document.createElement("style"))
+ this.walls = gen_walls(width, height, data)
- create_style(this)
- gen_map(this)
}
- destroy() {
- this.container.remove()
- this.style.remove()
+ show() {
+ this.hide()
+
+ let container = document.getElementById("container")
+ if (!container) {
+ container = document.createElement("div")
+ container.id = "container"
+ document.body.appendChild(container)
+ }
+
+ gen_map(this, container)
+
+ let style = document.getElementById("style")
+ if (!style) {
+ style = document.createElement("style")
+ style.id = "style"
+ document.body.appendChild(style)
+ }
+
+ gen_style(this, style)
}
+ hide() {
+ let container = document.getElementById("container")
+ if (container) container.remove()
+ let style = document.getElementById("style")
+ if (style) style.remove()
+ }
}
diff --git a/client/js/gfx/sprite.js b/client/js/gfx/sprite.js
new file mode 100644
index 0000000..1ed8136
--- /dev/null
+++ b/client/js/gfx/sprite.js
@@ -0,0 +1,60 @@
+export class Sprite {
+
+ constructor(image_src, map) {
+ this.element = document.createElement("img")
+ this.element.src = image_src
+ this.element.className = "sprite"
+ document.getElementById("container").appendChild(this.element)
+
+ this.map = map
+ this.x = 0
+ this.y = 0
+ this.w = 1
+ this.h = 1
+ this.d = 0
+ this.hide()
+ }
+
+ #update_pos() {
+ let width = 100 / this.map.width * this.w
+ let height = 100 / this.map.height * this.h
+ let left = 100 / this.map.width * (this.x + (1 - this.w) / 2)
+ let top = 100 / this.map.height * (this.y + (1 - this.h) / 2)
+
+ this.element.style.width = `${width}%`
+ this.element.style.height = `${height}%`
+ this.element.style.left = `${left}%`
+ this.element.style.top = `${top}%`
+ this.element.style.transform = `rotate(${this.d}deg)`
+ }
+
+ move(x, y) {
+ this.x = x
+ this.y = y
+ this.#update_pos()
+ }
+
+ resize(w, h) {
+ this.w = w
+ this.h = h
+ this.#update_pos()
+ }
+
+ rotate(d) {
+ this.d = d
+ this.#update_pos()
+ }
+
+ hide() {
+ this.element.style.display = "none"
+ }
+
+ show() {
+ this.element.style.display = "initial"
+ }
+
+ destroy() {
+ this.element.remove()
+ }
+
+}
diff --git a/client/js/input.js b/client/js/input.js
new file mode 100644
index 0000000..421ce2e
--- /dev/null
+++ b/client/js/input.js
@@ -0,0 +1,77 @@
+import { Key } from "./logic.js";
+
+const debug_style = document.body.appendChild(document.createElement("style"))
+var debug_enabled = false
+
+export function startInputListener() {
+ let dir = 0;
+ let start = false;
+
+ // document.getElementById("start").onclick = e => {
+ // e.preventDefault();
+ // start = true;
+ // }
+
+ let keymap = {
+ "KeyW": Key.UP,
+ "KeyA": Key.LEFT,
+ "KeyS": Key.DOWN,
+ "KeyD": Key.RIGHT,
+ };
+
+ document.getElementById("start").onclick = function() {
+ start = true
+ }
+
+ window.addEventListener("keydown", ev => {
+ if(ev.repeat) {
+ return;
+ }
+ if(!(ev.code in keymap)) {
+ if (ev.code === "KeyB") {
+ debug_enabled = !debug_enabled
+ if (debug_enabled) {
+ debug_style.innerHTML = "* {box-shadow: 0 0 1px red inset;}"
+ } else {
+ debug_style.innerHTML = ""
+ }
+ }
+ return;
+ }
+ dir = keymap[ev.code];
+ });
+
+ window.addEventListener("keyup", ev => {
+ if (ev.repeat) {
+ return;
+ }
+ if (!(ev.code in keymap)) {
+ return
+ }
+ if (dir == keymap[ev.code]) {
+ dir = Key.NOTHING
+ }
+ })
+
+ let last = {
+ dir: 0,
+ }
+
+ return function() {
+
+ if(dir === last.dir && !start) {
+ return;
+ }
+
+ last = {
+ dir,
+ };
+
+ let s = start;
+ start = false;
+ return {
+ dir,
+ start: s,
+ }
+ }
+}
diff --git a/client/js/logic.js b/client/js/logic.js
new file mode 100644
index 0000000..e9a0b05
--- /dev/null
+++ b/client/js/logic.js
@@ -0,0 +1,269 @@
+import { Map } from "./gfx/map.js";
+
+// enum
+export const Key = {
+ NOTHING: undefined,
+ UP: 1,
+ DOWN: 2,
+ LEFT: 3,
+ RIGHT: 4,
+}
+
+// enum
+export const Rotation = {
+ NOTHING: undefined,
+ NORTH: 1,
+ EAST: 2,
+ SOUTH: 3,
+ WEST: 4
+}
+
+/**
+ * @typedef {[number, number]} Vec2
+ *
+ * @typedef {{[key: number]: Key} InputMap
+ *
+ * @typedef {{pos: Vec2, move_rot: Rotation, input_rot: Rotation, name?: string}} Player
+ * @typedef {{start: boolean, key: Key, name?: string}} PlayerInput
+ * @typedef {{players: {[key: number]: PlayerInput}, added?: number[], removed?: number[] }} Input
+ *
+ * @typedef {{[key: number]: Player}} Players
+ *
+ * @typedef {{width: number, height: number, data: number[]}} Map
+ *
+ * @typedef {{
+ * started: boolean,
+ * input: InputMap,
+ * players: Players,
+ * map: Map
+ * }} GameState
+ */
+
+/** @type {GameState} */
+export const initState = {
+ started: false,
+ input: {},
+ players: [],
+ map: {}
+}
+
+export function advance(
+ pastData = initState,
+ input = { players: {} },
+ frame
+) {
+ let data = processInput(pastData, input, frame);
+
+ return data;
+}
+
+/**
+ * @param {GameState} pastData
+ * @param {Input} input
+ * @param {number} frame
+ */
+function processInput(pastData, input) {
+
+ /** @type {GameState} */
+ let data = structuredClone(pastData)
+
+ let startPressed = false;
+
+ for(const added of input.added || []) {
+ if (data.started || Object.keys(data.players).length >= 4) continue;
+ console.log("added", added);
+ data.input[added] ||= {
+ pos: [1, 1],
+ input_rot: Rotation.EAST,
+ mov_rot: Rotation.EAST
+ };
+ if(!(added in data.players)) {
+ data.players[added] = structuredClone(data.input[added])
+ }
+ }
+
+ for(const id in input.players) {
+ if(!input.players[id]) {
+ continue;
+ }
+
+ if(id in data.players && input.players[id].name !== undefined) {
+ let name = input.players[id].name;
+ name = name.substring(0, 16);
+
+ data.players[id] = {
+ ...data.players[id],
+ name,
+ };
+
+ }
+
+ startPressed ||= input.players[id].start;
+ data.input[id] = input.players[id].dir
+ }
+
+ const player_display = document.getElementById("players")
+ for (const id in data.players) {
+ if (data.players[id] === null) continue
+ let name = data.players[id].name
+ if (name === undefined) continue
+
+ let element_id = 'span' + id
+
+ let element = player_display.children[element_id]
+ if (element === null || element === undefined) {
+ let span = document.createElement("span")
+ span.innerHTML = `[${id}] ${name}`
+ span.id = element_id
+ player_display.appendChild(span)
+ }
+ }
+
+ if (startPressed && !data.started) {
+ init_map(data)
+ data.started ||= startPressed;
+ }
+
+ if (data.started) {
+ update_players(data)
+ }
+
+
+ for(const removed of input.removed || []) {
+ console.log("removed", removed);
+ delete data.input[removed];
+ delete data.players[removed];
+
+ let element_id = 'span' + removed
+ let element = document.getElementById(element_id)
+ if (element !== null && element !== undefined) element.remove()
+ }
+
+
+ return data
+}
+
+const init_map = (data) => {
+
+ document.getElementById("lobby").style.display = "none"
+
+ let width = 13
+ let height = 5
+ let m_data = [
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+ 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1,
+ 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1,
+ 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1,
+ 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1
+ ]
+
+ data.map = new Map(width, height, m_data)
+ data.map.show()
+}
+
+const MOVE_SPEED = .1
+
+const round_pos = (pos) => {
+ return [Math.round(pos[0]), Math.round(pos[1])]
+}
+
+const is_stable_pos = (pos) => {
+ let rpos = round_pos(pos)
+ return Math.abs(rpos[0] - pos[0]) < MOVE_SPEED && Math.abs(rpos[1] - pos[1]) < MOVE_SPEED
+}
+
+const get_tile = (map, pos, ox, oy) => {
+ let x = Math.round(pos[0] + ox)
+ let y = Math.round(pos[1] + oy)
+ if (x < 0 || x >= map.width || y < 0 || y >= map.height) return 1
+ return map.data[y * map.width + x]
+}
+
+const get_tile_with_rot = (map, pos, rot) => {
+ let collider = 1
+ switch(rot) {
+ case Rotation.NORTH:
+ collider = get_tile(map, pos, 0, -.51)
+ break
+ case Rotation.SOUTH:
+ collider = get_tile(map, pos, 0, .51)
+ break
+ case Rotation.WEST:
+ collider = get_tile(map, pos, -.51, 0)
+ break
+ case Rotation.EAST:
+ collider = get_tile(map, pos, .51, 0)
+ break
+ }
+ return collider
+}
+
+const get_rot = (dir) => {
+ switch (dir) {
+ case Key.UP: return Rotation.NORTH
+ case Key.DOWN: return Rotation.SOUTH
+ case Key.LEFT: return Rotation.WEST
+ case Key.RIGHT: return Rotation.EAST
+ case Key.NOTHING: return Rotation.NOTHING
+ }
+}
+
+const increment_pos = (pos, rot, speed) => {
+ switch (rot) {
+ case Rotation.NORTH:
+ pos[1] -= speed
+ break
+ case Rotation.SOUTH:
+ pos[1] += speed
+ break
+ case Rotation.WEST:
+ pos[0] -= speed
+ break
+ case Rotation.EAST:
+ pos[0] += speed
+ break
+ }
+}
+
+/**
+ * @param {GameState} data
+ */
+const update_players = (data) => {
+
+ for(const id in data.input) { // move players
+ if(!(id in data.players)) {
+ console.log("what. player undefined???", id);
+ continue;
+ }
+
+ let input_key = data.input[id]
+ let input_dir = get_rot(input_key)
+ let move_dir = data.players[id].move_rot
+ let current_pos = data.players[id].pos
+
+ if (get_tile_with_rot(data.map, current_pos, input_dir) == 1) {
+ input_dir = Rotation.NOTHING
+ }
+
+ let turning = input_dir != Key.NOTHING && input_dir != move_dir
+
+ data.players[id].input_rot = input_dir
+
+ if (turning && is_stable_pos(current_pos)) {
+ current_pos = round_pos(current_pos)
+ data.players[id].move_rot = input_dir
+ move_dir = input_dir
+ }
+
+ let move_pos = structuredClone(current_pos)
+ increment_pos(move_pos, move_dir, MOVE_SPEED)
+
+ if (get_tile_with_rot(data.map, current_pos, move_dir) != 1) {
+ data.players[id].pos = move_pos
+ } else {
+ data.players[id].pos = round_pos(current_pos)
+ }
+
+ }
+
+}
diff --git a/client/js/main.js b/client/js/main.js
index 7004fb5..81262d3 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -1,25 +1,113 @@
-import { Sprite } from './sprite.js'
-import { Map } from './map.js'
+import { Game } from "./game.js";
+import { startInputListener } from "./input.js";
+import { multiplayer } from "./multiplayer.js";
+import { advance, initState } from "./logic.js";
+import { startGraphicsUpdater } from "./gfx/graphics.js";
-let width = 13
-let height = 5
-let data = [
- 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
- 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1,
- 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1,
- 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1,
- 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1
-]
+const join = document.getElementById("join")
+const lobby = document.getElementById("lobby")
+lobby.style.display = "none"
-let map = new Map(width, height, data)
-let tux = new Sprite("/static/tux.png", map)
+join.onsubmit = async function(event) {
+ event.preventDefault()
+
+ const room_code = this.elements.room_code.value.trim()
+ const player_name = this.elements.name.value.trim()
-tux.show()
-const callback = () => {
+ if (room_code == '') {
+ alert('Please enter a room code')
+ return
+ }
- tux.add_pos(1, 0)
+ if (player_name == '') {
+ alert('Please enter a player name')
+ return
+ }
+
+ join.style.display = "none"
- setTimeout(callback, 500)
+ startGame(room_code, player_name)
}
-callback()
+function startGame(code, name) {
+
+ const game = window.game = new Game(3000, advance);
+ const fps = 60;
+ let delay = 3;
+
+ // set up the game up
+ // const ui = document.getElementById("ui");
+ // ui.style.display = "block";
+
+ multiplayer(
+ game,
+ code,
+ (startFrame, latency, player, update, ping, desyncCheck) => {
+ // document.getElementById("desynccheck").onclick = function(e) {
+ // e.preventDefault();
+ // this.textContent = "check for desyncs: checking...";
+ // desyncCheck(game.currentFrame - 5)
+ // .then(res => {
+ // this.textContent = "check for desyncs: " + (res ? "desync" : "no desync");
+ // });
+ // }
+ console.log("started game at frame", startFrame);
+ window.desyncCheck = () => desyncCheck(game.currentFrame - 5);
+
+ lobby.style.display = ""
+
+ let startTs = performance.now() - latency;
+ let lastFrame = startFrame;
+ update({
+ name,
+ }, startFrame + 1);
+
+ const getInput = startInputListener();
+ const updateGraphics = startGraphicsUpdater();
+
+ const start_data = game.getHistory(startFrame)
+ if (start_data.data.started) {
+ alert('Room has already started')
+ return false
+ }
+
+ let players = Object.values(start_data.data.players).filter(p => { return p.name !== undefined })
+ if (players.length >= 4) {
+ alert('Room is full')
+ return false
+ }
+
+ // main game loop
+ let lastTs = performance.now();
+ function f(ts) {
+ const frame = Math.floor((ts - startTs) / 1000 * fps) + startFrame;
+ if(frame !== lastFrame) { // update input once per frame, regardless of the display refresh rate
+ lastFrame = frame;
+
+ // gather input
+ const input = getInput();
+
+ // apply input
+ update(input, frame + delay);
+ }
+
+ // set up graphics
+ game.currentFrame = frame;
+ const data = game.getHistory(frame);
+ updateGraphics(data ? data.data : initState);
+ lastTs = ts;
+
+ requestAnimationFrame(f);
+ }
+
+ requestAnimationFrame(f);
+ if(startFrame === -1) {
+ update({
+ name,
+ }, 0);
+ }
+
+ return true
+ }
+ );
+}
diff --git a/client/js/multiplayer.js b/client/js/multiplayer.js
new file mode 100644
index 0000000..eec1c7d
--- /dev/null
+++ b/client/js/multiplayer.js
@@ -0,0 +1,318 @@
+/**
+ * @author tint
+ */
+
+/**
+ * @template Input
+ * @typedef {{
+ * added?: number[],
+ * removed?: number[],
+ * players: {
+ * [conn: number]: Input
+ * },
+ * }} GameInput
+ */
+
+/**
+ * @template Data, Input
+ * @param {import("./game.js").Game>} game
+ * @param {string} code
+ * @param {(
+ * startFrame: number,
+ * latency: number,
+ * connection: number,
+ * update: (input: Input, frame: number) => void,
+ * ping: () => Promise,
+ * desyncCheck: () => Promise,
+ * ) => void} onStart
+ * Called when the game is in a ready state with the current frame
+ * (or -1 if you're starting the room), an estimate of how many milliseconds have elapsed since that frame was sent,
+ * and your connection ID
+ */
+export function multiplayer(game, code, onStart) {
+ const url = new URL("api/join/" + encodeURIComponent(code), window.location);
+ url.protocol = url.protocol.replace("http", "ws");
+
+ const socket = new WebSocket(url);
+
+ let requestStateTime;
+ let hasState = false;
+ let connectionId;
+ let cachedInputs = [];
+ let connections = [];
+
+ let pingPromise;
+
+ function send(obj) {
+ socket.send(JSON.stringify(obj));
+ }
+
+ function applyInput(input) {
+ let prev = game.getHistory(input.frame);
+ let newInput = prev && prev.input ? {...prev.input} : { players: {} };
+
+ if(input.type === "input") {
+ if(input.connection === undefined) { // local input
+ if(input.data) {
+ // send it to the server
+ send(input);
+
+ // then apply it
+ newInput.players[connectionId] = input.data;
+ }
+ } else {
+ newInput.players[input.connection] = input.data;
+ }
+ } else if(input.type === "connections") {
+ if(input.added !== null) {
+ newInput.added = (newInput.added || []).concat([input.added]);
+ }
+ if(input.removed !== null) {
+ if(newInput.added) {
+ newInput.added = newInput.added.filter(n => n !== input.removed);
+ }
+ newInput.removed = (newInput.removed || []).concat([input.removed]);
+ }
+ }
+
+ game.setInput(input.frame, newInput);
+ }
+
+ function flushCachedInputs(latency = 0) {
+ for(const input of cachedInputs) {
+ // only care about inputs after the new state
+ if(input.frame <= game.historyStart) {
+ continue;
+ }
+
+ applyInput(input);
+ }
+ cachedInputs = [];
+ return onStart(game.getFrame(), latency, connectionId, update, ping, desyncCheck);
+ }
+
+ function update(input, frame) {
+ if(input === undefined) { // used to update the game locally
+ if(hasState) {
+ applyInput({
+ frame,
+ });
+ }
+ return;
+ }
+ const data = {
+ type: "input",
+ data: input,
+ frame: frame,
+ };
+
+ if(!hasState) {
+ cachedInputs.push(data);
+ } else {
+ applyInput(data);
+ }
+ }
+
+ async function ping() {
+ send({
+ type: "ping",
+ frame: Math.max(0, game.currentFrame),
+ });
+ const frame = await new Promise(r => pingPromise = r);
+ return game.currentFrame - frame;
+ }
+
+ async function desyncCheck(frame) {
+ const history = game.getHistory(frame);
+ if(!history) {
+ console.error("tried to check for desyncs on a frame not in memory", frame);
+ return true;
+ }
+ const localstate = history.data;
+ const proms = connections
+ .filter(n => n !== connectionId)
+ .map(connection => {
+ send({
+ type: "requeststate",
+ frame,
+ connection,
+ });
+ return new Promise(r => {
+ stateRequests[frame + "," + connection] = state => {
+ r({
+ state,
+ connection,
+ });
+ }
+ });
+ });
+
+ if(!proms.length) {
+ return false; // this is the only connection, no check necessary
+ }
+ const states = await Promise.all(proms);
+ if(!states.every(({ state }) => objeq(localstate, state))) {
+ console.error("desync! remote states:", states, "local state:", localstate);
+ return true;
+ }
+ return false;
+ }
+
+ let stateRequests = {};
+
+ socket.onmessage = message => {
+ const data = JSON.parse(message.data.toString());
+
+ switch(data.type) {
+ case "error":
+ console.error(data);
+ break;
+ case "framerequest":
+ send({
+ type: "frame",
+ frame: Math.max(game.currentFrame, 1),
+ });
+ break;
+ case "state":
+ if(data.frame + "," + data.connection in stateRequests) {
+ stateRequests[data.frame + "," + data.connection](data.state);
+ }
+ if(!hasState) {
+ game.startHistory(data.frame, data.state);
+ hasState = true;
+
+ // this state is from the past
+ // i want to find out exactly how far in the past
+ // the sequence of requests looks like:
+ // client -[staterequest]-> server -[staterequest]-> client2
+ // client2 -[state]-> server -[state]-> client
+ // and the time i'm concerned with is the second half,
+ // how long it takes the state to come from client2
+ let delta = 0;
+ if(requestStateTime !== undefined) {
+ delta = performance.now() - requestStateTime;
+ }
+ if (!flushCachedInputs(delta / 2)) {
+ socket.close()
+ document.getElementById("lobby").style.display = "none"
+ document.getElementById("join").style.display = ""
+ return
+ }
+ }
+ break;
+ case "requeststate":
+ // wait until there's some state to send
+ const startTime = performance.now();
+ function check() {
+ if(performance.now() - startTime > 5000) {
+ return; // give up after 5s
+ }
+ const state = game.getHistory(data.frame);
+ if(!state) {
+ return;
+ }
+
+ send({
+ type: "state",
+ frame: data.frame,
+ state: state.data,
+ });
+ clearInterval(interval);
+ }
+ const interval = setInterval(check, 100);
+ check();
+ break;
+ case "connections":
+ connections = data.connections;
+ if(connectionId === undefined) {
+ console.log("setting connection id", data.id);
+ connectionId = data.id;
+ if(data.connections.length === 1) { // no need to request state
+ hasState = true;
+ applyInput(data);
+ flushCachedInputs(); // just in case, also it calls onStart
+ break;
+ }
+
+ // grab the state from another client
+ console.log("requesting state");
+ // measure the time it takes for state to be delivered
+ requestStateTime = performance.now();
+ send({
+ type: "requeststate",
+ frame: data.frame,
+ });
+ }
+
+ if(!hasState) {
+ cachedInputs.push(data);
+ } else {
+ applyInput(data);
+ }
+
+ break;
+ case "input":
+ if(!hasState) {
+ cachedInputs.push(data);
+ } else {
+ applyInput(data);
+ }
+ break;
+ case "pong":
+ if(pingPromise) {
+ pingPromise(data.frame);
+ pingPromise = undefined;
+ }
+ break;
+ default:
+ console.warn("unknown server message", data);
+ break;
+ }
+ }
+}
+
+// compare two plain objects (things that can be JSON.stringified)
+function objeq(a, b) {
+ if(typeof(a) !== typeof(b)) {
+ return false;
+ }
+ // array diff
+ if(Array.isArray(a) && Array.isArray(b)) {
+ if(a.length !== b.length) {
+ return false;
+ }
+ for(let i = 0; i < a.length; i++) {
+ if(!objeq(a[i], b[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+ switch(typeof(a)) {
+ // primitives can be compared directly
+ case "number":
+ case "boolean":
+ case "string":
+ case "undefined": return a === b;
+
+ case "object":
+ // typeof(null) = "object" but null can be compared directly
+ if(a === null || b === null) {
+ return a === b;
+ }
+ // object diff
+ for(let k in a) {
+ if(!(k in b) || !objeq(a[k], b[k])) {
+ return false;
+ }
+ }
+ for(let k in b) {
+ if(!(k in a)) {
+ return false;
+ }
+ }
+ return true;
+ default: // incomparable things
+ return false;
+ }
+}
diff --git a/client/js/sprite.js b/client/js/sprite.js
deleted file mode 100644
index ec6ecbd..0000000
--- a/client/js/sprite.js
+++ /dev/null
@@ -1,43 +0,0 @@
-export class Sprite {
-
- constructor(image_src, map) {
- this.element = document.createElement("img")
- this.element.src = image_src
- this.element.className = "sprite"
- this.map = map
- this.map.container.appendChild(this.element)
- this.x = 0
- this.y = 0
- this.hide()
- }
-
- #update_pos() {
- this.element.style.left = `${100/this.map.width*this.x}%`,
- this.element.style.top = `${100/this.map.height*this.y}%`
- }
-
- set_pos(x, y) {
- this.x = x
- this.y = y
- this.#update_pos()
- }
-
- add_pos(x, y) {
- this.x += x
- this.y += y
- this.#update_pos()
- }
-
- hide() {
- this.element.style.display = "none"
- }
-
- show() {
- this.element.style.display = "initial"
- }
-
- destory() {
- this.element.remove()
- }
-
-}
diff --git a/client/static/tux.png b/client/static/tux.png
index b78a72b..a595145 100644
Binary files a/client/static/tux.png and b/client/static/tux.png differ