This commit is contained in:
Freya Murphy 2023-06-13 21:18:01 -04:00
parent 0a06f163d1
commit edcdd665e1
12 changed files with 1099 additions and 85 deletions

View file

@ -3,9 +3,26 @@
padding: 0; padding: 0;
} }
#container img { :root {
image-rendering: -webkit-optimize-contrast; /* webkit */ font-size: 2rem;
image-rendering: -moz-crisp-edges /* Firefox */ 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 { .rotate90 {
@ -25,3 +42,33 @@
transition: left .1s, top .1s; transition: left .1s, top .1s;
z-index: 2; 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;
}

View file

@ -1,8 +1,21 @@
<!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="stylesheet" href="/css/main.css"/> <link rel="stylesheet" href="/css/main.css"/>
</head> </head>
<body> <body>
<div id="center">
<form id="join" autocomplete="off">
<input type="text" id="room_code" name="room_code" placeholder="Room Code">
<input type="text" id="name" name="name" placeholder="Player Name">
<input type="submit" value="Join!"/>
</form>
<div id="lobby">
<span>Players:</span>
<div id="players"></div>
<input type="button" id="start" value="Start Game"/>
</div>
</div>
<script src="/js/main.js" type="module"></script> <script src="/js/main.js" type="module"></script>
</body> </body>
</html> </html>

111
client/js/game.js Normal file
View file

@ -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++;
}
}
}

56
client/js/gfx/graphics.js Normal file
View file

@ -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
}
}
}
}

View file

@ -1,4 +1,4 @@
const create_style = (map) => { const gen_style = (map, style) => {
const css = ` const css = `
* { * {
--scale: 100; --scale: 100;
@ -7,25 +7,21 @@ const create_style = (map) => {
--scaleY: calc(var(--scale) * 1vh); --scaleY: calc(var(--scale) * 1vh);
} }
body {
background-color: #191919;
width: 100vw;
height: 100vh;
display: flex;
}
#container { #container {
width: calc(var(--scaleY) * var(--aspect)); width: calc(var(--scaleY) * var(--aspect));
height: var(--scaleY); height: var(--scaleY);
margin-top: calc((100vh - var(--scaleY))/2); margin-top: calc((100vh - var(--scaleY))/2);
margin-left: calc(50vw - var(--scaleY)*var(--aspect)/2); margin-left: calc(50vw - var(--scaleY)*var(--aspect)/2);
position: relative; position: relative;
vertical-align: top;
line-height: 0;
} }
#container img { #container img {
display: inline-block; display: inline-block;
width: ${100/map.width}%; width: ${100/map.width}%;
height: ${100/map.height}%; height: ${100/map.height}%;
image-rendering: pixelated;
} }
@media (max-aspect-ratio: ${map.width}/${map.height}) { @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 = { const Direction = {
@ -211,11 +207,10 @@ const gen_walls = (width, height, data) => {
return walls return walls
} }
const gen_map = (map) => { const gen_map = (map, container) => {
let walls = gen_walls(map.width, map.height, map.data)
for (let y = 0; y < map.height; y++) { for (let y = 0; y < map.height; y++) {
for (let x = 0; x < map.width; x++) { 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 { export class Map {
constructor(width, height, data) { constructor(width, height, data) {
let last = document.getElementById("container")
if (last) last.remove()
this.width = width this.width = width
this.height = height this.height = height
this.data = data this.data = data
this.container = document.body.appendChild(document.createElement("div")) this.walls = gen_walls(width, height, data)
this.container.id = "container"
this.style = document.body.appendChild(document.createElement("style"))
create_style(this)
gen_map(this)
} }
destroy() { show() {
this.container.remove() this.hide()
this.style.remove()
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()
}
} }

60
client/js/gfx/sprite.js Normal file
View file

@ -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()
}
}

77
client/js/input.js Normal file
View file

@ -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,
}
}
}

269
client/js/logic.js Normal file
View file

@ -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)
}
}
}

View file

@ -1,25 +1,113 @@
import { Sprite } from './sprite.js' import { Game } from "./game.js";
import { Map } from './map.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 const join = document.getElementById("join")
let height = 5 const lobby = document.getElementById("lobby")
let data = [ lobby.style.display = "none"
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
]
let map = new Map(width, height, data) join.onsubmit = async function(event) {
let tux = new Sprite("/static/tux.png", map) event.preventDefault()
tux.show() const room_code = this.elements.room_code.value.trim()
const callback = () => { const player_name = this.elements.name.value.trim()
tux.add_pos(1, 0) if (room_code == '') {
alert('Please enter a room code')
return
}
setTimeout(callback, 500) if (player_name == '') {
alert('Please enter a player name')
return
}
join.style.display = "none"
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
}
);
}

318
client/js/multiplayer.js Normal file
View file

@ -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<Data, GameInput<Input>>} game
* @param {string} code
* @param {(
* startFrame: number,
* latency: number,
* connection: number,
* update: (input: Input, frame: number) => void,
* ping: () => Promise<number>,
* desyncCheck: () => Promise<boolean>,
* ) => 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;
}
}

View file

@ -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()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 14 KiB