movement
This commit is contained in:
parent
0a06f163d1
commit
edcdd665e1
12 changed files with 1099 additions and 85 deletions
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/main.css"/>
|
||||
</head>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
111
client/js/game.js
Normal file
111
client/js/game.js
Normal 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
56
client/js/gfx/graphics.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
const create_style = (map) => {
|
||||
const gen_style = (map, style) => {
|
||||
const css = `
|
||||
* {
|
||||
--scale: 100;
|
||||
|
@ -7,25 +7,21 @@ const create_style = (map) => {
|
|||
--scaleY: calc(var(--scale) * 1vh);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #191919;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#container {
|
||||
width: calc(var(--scaleY) * var(--aspect));
|
||||
height: var(--scaleY);
|
||||
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()
|
||||
}
|
||||
}
|
60
client/js/gfx/sprite.js
Normal file
60
client/js/gfx/sprite.js
Normal 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
77
client/js/input.js
Normal 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
269
client/js/logic.js
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
tux.show()
|
||||
const callback = () => {
|
||||
const room_code = this.elements.room_code.value.trim()
|
||||
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
318
client/js/multiplayer.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 |
Loading…
Reference in a new issue