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 @@ + +
+
+ + + +
+
+ Players: +
+ +
+
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