diff options
author | Tyler Murphy <tylerm@tylerm.dev> | 2023-06-16 20:38:55 -0400 |
---|---|---|
committer | Tyler Murphy <tylerm@tylerm.dev> | 2023-06-16 20:38:55 -0400 |
commit | 44334fc3852eb832280a335f72e6416c93a9f19f (patch) | |
tree | 4a97b6064a97c4ad58c07d89050ad8a11e7a4f70 /client/src | |
parent | better map bg renderer (diff) | |
download | tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.tar.gz tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.tar.bz2 tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.zip |
ts
Diffstat (limited to 'client/src')
-rw-r--r-- | client/src/logic/items.ts | 41 | ||||
-rw-r--r-- | client/src/logic/logic.ts | 80 | ||||
-rw-r--r-- | client/src/logic/movement.ts | 142 | ||||
-rw-r--r-- | client/src/logic/players.ts | 79 | ||||
-rw-r--r-- | client/src/logic/ui.ts | 32 | ||||
-rw-r--r-- | client/src/main.ts | 75 | ||||
-rw-r--r-- | client/src/map.ts | 151 | ||||
-rw-r--r-- | client/src/net/game.ts | 186 | ||||
-rw-r--r-- | client/src/net/input.ts | 70 | ||||
-rw-r--r-- | client/src/net/multiplayer.ts | 303 | ||||
-rw-r--r-- | client/src/renderer.ts | 320 | ||||
-rw-r--r-- | client/src/types.ts | 131 |
12 files changed, 1610 insertions, 0 deletions
diff --git a/client/src/logic/items.ts b/client/src/logic/items.ts new file mode 100644 index 0000000..5f8a38e --- /dev/null +++ b/client/src/logic/items.ts @@ -0,0 +1,41 @@ +import { getMap, getItemKey } from "../map.js" +import { GameState, Map, Player } from "../types.js" + +const ceilHalf = (n: number): number => { + return Math.ceil(n*2)/2 +} + +const floorHalf = (n: number): number => { + return Math.floor(n*2)/2 +} + +const eatItems = (data: GameState, map: Map, player: Player) => { + + let pos = player.pos + + for (let x = ceilHalf(pos.x-.5); x <= floorHalf(pos.x+.5); x += .5) { + for (let y = ceilHalf(pos.y-.5); y <= floorHalf(pos.y+.5); y += .5) { + let item_key = getItemKey(x, y, map.width) + delete data.items[item_key] + } + } +} + +export const updateItems = (data: GameState) => { + + let map = getMap(data.mapId) + if (!map) return + + for(const id in data.input) { + + const player = data.players[id] + + if(!player) { + continue; + } + + eatItems(data, map, player) + + } + +} diff --git a/client/src/logic/logic.ts b/client/src/logic/logic.ts new file mode 100644 index 0000000..1cca2b7 --- /dev/null +++ b/client/src/logic/logic.ts @@ -0,0 +1,80 @@ +import { genItems, loadMap, getMap } from "../map.js"; +import { updatePlayers } from "./players.js" +import { updateUI } from "./ui.js" +import { updateMovement } from "./movement.js" +import { updateItems } from "./items.js" +import { GameState, Input } from "../types.js"; + + +export const InitialState: GameState = { + started: false, + input: {}, + players: [], + items: {}, + mapId: undefined +} + +export const onLogic = ( + pastData: GameState = InitialState, + input: Input = { players: {} }, + _frame: number +) => { + + let data = structuredClone(pastData) + + let startPressed = updatePlayers(data, input); + + if (data.started) { + updateMovement(data) + updateItems(data) + } else { + updateUI(data) + } + + if (startPressed && !data.started) { + initMap(data) + data.started = true; + } + + return data + +} + +const initMap = (data: GameState) => { + + document.getElementById("lobby").style.display = "none" + + data.mapId = 0 + + if (getMap(0)) return + + let width = 21 + let height = 21 + let m_data = [ + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1, + 1,0,1,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,1,0,1, + 1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1, + 1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1, + 1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1, + 1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,0,1, + 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, + 1,1,1,0,1,0,1,0,1,1,1,1,1,0,1,0,1,0,1,1,1, + 1,0,0,0,0,0,1,0,1,2,2,2,1,0,1,0,0,0,0,0,1, + 1,0,1,1,1,0,1,0,1,2,2,2,1,0,1,0,1,1,1,0,1, + 1,0,0,0,0,0,1,0,1,2,2,2,1,0,1,0,0,0,0,0,1, + 1,1,1,0,1,0,1,0,1,1,2,1,1,0,1,0,1,0,1,1,1, + 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, + 1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,0,1, + 1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1, + 1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1, + 1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1, + 1,0,1,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,1,0,1, + 1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 + ] + + loadMap(width, height, m_data) // cursed temp thing + data.items = genItems(getMap(0)) +} + diff --git a/client/src/logic/movement.ts b/client/src/logic/movement.ts new file mode 100644 index 0000000..40cfc3e --- /dev/null +++ b/client/src/logic/movement.ts @@ -0,0 +1,142 @@ +import { getMap } from "../map.js" +import { Vec2, Map, Rotation, Key, Player, GameState } from "../types.js" + +const MOVE_SPEED = .1 + +const roundPos = (pos: Vec2): Vec2 => { + return {x: Math.round(pos.x), y: Math.round(pos.y)} +} + +const isStablePos = (pos: Vec2): boolean => { + let rpos = roundPos(pos) + return Math.abs(rpos.x - pos.x) < .05 && Math.abs(rpos.y - pos.y) < .05 +} + +const getTile = ( + map: Map, + pos: Vec2, + ox: number, + oy: number +): number => { + let x = Math.round(pos.x + ox) + let y = Math.round(pos.y + oy) + if (x < 0 || x >= map.width || y < 0 || y >= map.height) return 1 + return map.data[y * map.width + x] +} + +const getTileFrontWithRot = ( + map: Map, + pos: Vec2, + rot: Rotation +): number => { + let collider = 1 + switch(rot) { + case Rotation.NORTH: + collider = getTile(map, pos, 0, -.51) + break + case Rotation.SOUTH: + collider = getTile(map, pos, 0, .51) + break + case Rotation.WEST: + collider = getTile(map, pos, -.51, 0) + break + case Rotation.EAST: + collider = getTile(map, pos, .51, 0) + break + } + return collider +} + +const getRot = (key: Key): Rotation => { + switch (key) { + 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 incrementPos = ( + pos: Vec2, + rot: Rotation, + speed: number +): void => { + switch (rot) { + case Rotation.NORTH: + pos.y -= speed + break + case Rotation.SOUTH: + pos.y += speed + break + case Rotation.WEST: + pos.x -= speed + break + case Rotation.EAST: + pos.y += speed + break + } +} + +let i = 0 + +const updateMovementForPlayer = ( + map: Map, + player: Player, + inputKey: Key +) => { + + let inputRot = getRot(inputKey) + let moveRot = player.moveRotation + let currentPosition = player.pos + + let turningFrontTile = getTileFrontWithRot(map, currentPosition, inputRot) + if (turningFrontTile == 1 || turningFrontTile == 2) { + inputRot = Rotation.NOTHING + } + + let turning = inputRot != Rotation.NOTHING && inputRot != moveRot + + player.inputRotation = inputRot + + if (turning && isStablePos(currentPosition)) { + currentPosition = roundPos(currentPosition) + player.moveRotation = inputRot + moveRot = inputRot + } + + let movePos = structuredClone(currentPosition) + incrementPos(movePos, moveRot, MOVE_SPEED) + + let frontTile = getTileFrontWithRot(map, currentPosition, moveRot) + if (frontTile != 1 && frontTile != 2) { + player.pos = movePos + player.moving = true + } else { + player.pos = roundPos(currentPosition) + player.moving = false + } + + +} + +export const updateMovement = (data: GameState) => { + + let map = getMap(data.mapId) + if (!map) return + + for (const id in data.players) { + + const player = data.players[id] + + if(!player) { + continue + } + + let inputKey = data.input[id] + + updateMovementForPlayer(map, player, inputKey) + + } + +} diff --git a/client/src/logic/players.ts b/client/src/logic/players.ts new file mode 100644 index 0000000..ebe469f --- /dev/null +++ b/client/src/logic/players.ts @@ -0,0 +1,79 @@ +import { GameState, Input, Key, Rotation } from "../types.js" + +const canPlayerJoin = (data: GameState) => { + + // lobby has already started + if (data.started) { + return false + } + + // lobby full + if (Object.keys(data.players).length >= 4) { + return false + } + + return true + +} + +export const updatePlayers = (data: GameState, input: Input) => { + + let startPressed = false; + + for(const added of input.added || []) { + + if (!canPlayerJoin(data)) { + continue + } + + console.log("added", added); + + data.input[added] = Key.NOTHING + + data.players[added] ||= { + pos: {x: 1, y: 1}, + inputRotation: Rotation.EAST, + moveRotation: Rotation.EAST, + moving: false, + }; + + } + + 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; + if (input.players[id].key) + data.input[id] = input.players[id].key + + } + + 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 startPressed + +} + diff --git a/client/src/logic/ui.ts b/client/src/logic/ui.ts new file mode 100644 index 0000000..5706843 --- /dev/null +++ b/client/src/logic/ui.ts @@ -0,0 +1,32 @@ +import { GameState } from "../types.js" + +export const updateUI = (data: GameState) => { + + const player_display = document.getElementById("players") + + for (const id in data.players) { + + const player = data.players[id] + + if (!player) { + continue + } + + let name = player.name + + if (!name) { + continue + } + + let element_id = 'span' + id + let element = player_display.children[element_id] + + if (!element) { + let span = document.createElement("span") + span.textContent = `[${id}] ${name}` + span.id = element_id + player_display.appendChild(span) + } + } + +} diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..a6cc3ba --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,75 @@ +import { Game } from "./net/game.js"; +import { InitialState, onLogic } from "./logic/logic.js"; +import { startGraphicsUpdater } from "./renderer.js"; +import { GameKeyMap, Frame, Key } from "./types.js"; + +const join = document.getElementById("join") +const lobby = document.getElementById("lobby") +lobby.style.display = "none" + +join.onsubmit = async function(event) { + event.preventDefault() + + const room_code = this.elements.room_code.value.trim() + const player_name = this.elements.name.value.trim() + + if (room_code == '') { + alert('Please enter a room code') + return + } + + if (player_name == '') { + alert('Please enter a player name') + return + } + + join.style.display = "none" + + startGame(room_code, player_name) +} + +const updateGraphics = startGraphicsUpdater() + +const onLoad = (startData: Frame) => { + + if (startData.data.started) { + alert('Room has already started') + return false + } + + let players = Object.values(startData.data.players).filter(p => { return p !== null && p.name !== undefined }) + if (players.length >= 4) { + alert('Room is full') + return false + } + + lobby.style.display = "" + + return true +} + +const onFrame = (data: Frame, frame: number) => { + + updateGraphics(data ? data.data : InitialState, frame); + +} + + +const startGame = (code: string, name: string) => { + + const game = new Game(3000) + + game.start( + code, + GameKeyMap, + onLoad, + onFrame, + onLogic, + { + start: false, + key: Key.NOTHING, + name + } + ) + +} diff --git a/client/src/map.ts b/client/src/map.ts new file mode 100644 index 0000000..e6fab9d --- /dev/null +++ b/client/src/map.ts @@ -0,0 +1,151 @@ +import { Wall, ItemType, Map, Maps, Items } from "./types.js" + +export const getItemKey = ( + x: number, + y: number, + w: number +): number => { + let nx = Math.round(x * 2) + let ny = Math.round(y * 2) + let key = ny * w * 2 + nx + return key +} + +const getPoint = ( + width: number, + height: number, + data: number[], + x: number, + y: number +): number => { + if (x < 0 || x >= width || y < 0 || y >= height) { + return 0 + } else { + return data[y * width + x] + } +} + +const genWalls = ( + width: number, + height: number, + data: number[] +): number[] => { + + let walls = Array(width * height) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + + let north = getPoint(width, height, data, x, y-1) == 1 + let south = getPoint(width, height, data, x, y+1) == 1 + let east = getPoint(width, height, data, x+1, y) == 1 + let west = getPoint(width, height, data, x-1, y) == 1 + let current = getPoint(width, height, data, x, y) == 1 + + let point = Wall.EMPTY + + if (!current) { + walls[y * width + x] = point + continue + } + + if (north && south && east && west) { + point = Wall.CROSS + } else if (east && west && north) { + point = Wall.TEE_NORTH + } else if (east && west && south) { + point = Wall.TEE_SOUTH + } else if (north && south && east) { + point = Wall.TEE_EAST + } else if (north && south && west) { + point = Wall.TEE_WEST + } else if (east && west) { + point = Wall.WALL_HZ + } else if (north && south) { + point = Wall.WALL_VT + } else if (west && south) { + point = Wall.TURN_Q1 + } else if (south && east) { + point = Wall.TURN_Q2 + } else if (east && north) { + point = Wall.TURN_Q3 + } else if (north && west) { + point = Wall.TURN_Q4 + } else if (north) { + point = Wall.WALL_END_NORTH + } else if (east) { + point = Wall.WALL_END_EAST + } else if (south) { + point = Wall.WALL_END_SOUTH + } else if (west) { + point = Wall.WALL_END_WEST + } else { + point = Wall.DOT + } + + walls[y * width + x] = point + + } + } + + return walls +} + +export const genItems = (map: Map): Items => { + + let width = map.width + let height = map.height + let data = map.data + + let items: Items = {} + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let tile = getPoint(width, height, data, x, y) + if (tile != 0) continue + + let item_key = getItemKey(x, y, width) + items[item_key] = {type: ItemType.DOT, pos: {x, y}} + + let tile_south = getPoint(width, height, data, x, y + 1) + if (tile_south == 0) { + item_key = getItemKey(x, y + .5, width) + items[item_key] = {type: ItemType.DOT, pos: {x, y: y + .5}} + } + + let tile_east = getPoint(width, height, data, x + 1, y) + if (tile_east == 0) { + item_key = getItemKey(x + .5, y, width) + items[item_key] = {type: ItemType.DOT, pos: {x: x + .5, y}} + } + } + } + + return items +} + +let mapData: Maps = {} +let id: number = 0 + +export const loadMap = ( + width: number, + height: number, + data: number[] +): number => { + + let mapId = id++ + + mapData[mapId] = { + data: structuredClone(data), + walls: genWalls(width, height, data), + width, + height, + id: mapId + } + + return mapId +} + +export const getMap = (mapId: number): Map | undefined => { + if (mapId == undefined) return undefined + return mapData[mapId] +} diff --git a/client/src/net/game.ts b/client/src/net/game.ts new file mode 100644 index 0000000..c8e5991 --- /dev/null +++ b/client/src/net/game.ts @@ -0,0 +1,186 @@ +import { Frame, GameState, Input, Key, KeyMap, PlayerInput } from "../types.js"; +import { startInputListener } from "./input.js"; +import { multiplayer } from "./multiplayer.js"; + +/** + * @author tint + * @template Data, Input + */ +export class Game { + + historysize: number + history: Frame[] + historyStart: number + currentFrame: number + advance: (pastData: GameState, input: Input, frame: number) => GameState + + constructor(history: number) { + this.historysize = history; + + 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: number, data: GameState) { + this.historyStart = frame; + this.history = [{ data, input: { players: {} }}]; + this.currentFrame = frame; + } + + getHistory(frame: number): Frame { + return this.history[frame - this.historyStart]; + } + + getFrame(): number { + return this.historyStart + this.history.length - 1; + } + + getCurrentData(): GameState { + 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. + */ + setInput(frame: number, input: Input) { + console.log('input', frame, input) + this.editFrame(frame, (index: number): void => { + let past = this.history[index - 1]; + if(index === 0) { + past = { data: undefined, input: undefined }; + } + this.history[index] = { + input, + data: this.advance(past ? past.data : undefined, input, frame), + }; + }); + } + + setData(frame: number, data: GameState) { + console.log('data', frame, data) + this.editFrame(frame, (index: number): void => { + this.history[index] = { + data, + input: this.history[index] && this.history[index].input, + } + }); + } + + editFrame(frame: number, edit: (index: number) => void) { + 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++; + } + } + + start ( + code: string, + keymap: KeyMap, + onLoad: (startFrame: Frame) => boolean, + onFrame: (data: Frame, frame: number) => void, + onLogic: (pastData: GameState, input: Input, frame: number) => GameState, + data: PlayerInput = { start: false, key: Key.NOTHING } + ): void { + + const fps = 60; + let delay = 3; + + this.advance = onLogic + + const onStart = ( + startFrame: number, + latency: number, + _connection: number, + update: (input: PlayerInput, frame: number) => void, + _ping: () => Promise<number>, + _desyncCheck: (frame: number) => Promise<boolean>, + ) => { + console.log("started game at frame", startFrame); + // window.desyncCheck = () => desyncCheck(this.currentFrame - 5); + + let startTs = performance.now() - latency; + let lastFrame = startFrame; + update(data, startFrame + 1); + + let getInput = startInputListener(keymap) + + const startData = this.getHistory(startFrame) + + if (!onLoad(startData)) return false + + let lastTs = performance.now(); + + let loop = (ts: number) => { + + 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: PlayerInput = getInput(); + + // apply input + update(input, frame + delay); + } + + this.currentFrame = frame + const data = this.getHistory(frame) + + onFrame(data, frame) + + lastTs = ts + + requestAnimationFrame(loop) + } + + requestAnimationFrame(loop) + + if(startFrame === -1) { + update(data, 0); + } + + return true + } + + multiplayer(this, code, onStart) + + } +} diff --git a/client/src/net/input.ts b/client/src/net/input.ts new file mode 100644 index 0000000..75be3e6 --- /dev/null +++ b/client/src/net/input.ts @@ -0,0 +1,70 @@ +import { Key, KeyMap, PlayerInput } from "../types.js" + +let pressed = {} + +const updateRecent = (keymap: KeyMap) => { + let max = -1 + let key = undefined + for (let code in pressed) { + let weight = pressed[code] + if (weight < max) continue + max = weight + key = keymap[code] + } + + return key +} + +export const startInputListener = (keymap: KeyMap): () => PlayerInput => { + let key: Key = Key.NOTHING; + let start = false; + + document.getElementById("start").onclick = function() { + start = true + } + + window.addEventListener("keydown", ev => { + if(ev.repeat) { + return; + } + if(!(ev.code in keymap)) { + return; + } + pressed[ev.code] = Object.keys(pressed).length + key = updateRecent(keymap) + }); + + window.addEventListener("keyup", ev => { + if (ev.repeat) { + return; + } + if (!(ev.code in keymap)) { + return + } + delete pressed[ev.code] + key = updateRecent(keymap) + }) + + let last = { + key: Key.NOTHING, + } + + return (): PlayerInput => { + + if(key === last.key && !start) { + return; + } + + last = { + key, + }; + + let s = start; + start = false; + + return { + key, + start: s, + } + } +} diff --git a/client/src/net/multiplayer.ts b/client/src/net/multiplayer.ts new file mode 100644 index 0000000..e9f3057 --- /dev/null +++ b/client/src/net/multiplayer.ts @@ -0,0 +1,303 @@ +/** + * @author tint + */ + +import { GameState, Message, PlayerInput } from "../types.js"; +import { Game } from "./game"; + +export function multiplayer( + game: Game, + code: string, + onStart: ( + startFrame: number, + latency: number, + connection: number, + update: (input: PlayerInput, frame: number) => void, + ping: () => Promise<number>, + desyncCheck: (frame: number) => Promise<boolean>, + ) => boolean +) { + const url = new URL("api/join/" + encodeURIComponent(code), window.location.toString()); + url.protocol = url.protocol.replace("http", "ws"); + + const socket = new WebSocket(url); + + let requestStateTime: number; + let hasState = false; + let connectionId: number; + let cachedInputs = []; + let connections = []; + + let pingPromise: (type: Promise<number>) => void; + + function send(obj: any) { + socket.send(JSON.stringify(obj)); + } + + function applyInput(input: Message) { + 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): boolean { + 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: PlayerInput, frame: number) { + if(input === undefined) { // used to update the game locally + if(hasState) { + applyInput({}) + } + 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: number = await new Promise(r => pingPromise = r); + return game.currentFrame - frame; + } + + async function desyncCheck(frame: number): Promise<boolean> { + 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: GameState) => { + 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(); + const 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: any, b: any) { + 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/src/renderer.ts b/client/src/renderer.ts new file mode 100644 index 0000000..c7bbbc2 --- /dev/null +++ b/client/src/renderer.ts @@ -0,0 +1,320 @@ +import { getMap } from "./map.js"; +import { Items, Players, Rotation, ItemType, Map, Wall, GameState } from "./types.js"; + +const ATLAS_TILE_WIDTH = 32 + +const update_style = (width: number, height: number) => { + + let style = document.getElementById("style") + + const css = ` + * { + --scale: 100; + --aspect: ${width/height}; + --scaleX: calc(var(--scale) * 1vw); + --scaleY: calc(var(--scale) * 1vh); + } + + #canvas { + 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; + } + + @media (max-aspect-ratio: ${width}/${height}) { + #canvas { + width: var(--scaleX); + height: calc(var(--scaleX) / var(--aspect)); + margin-left: calc((100vw - var(--scaleX))/2); + margin-top: calc(50vh - var(--scaleX)/var(--aspect)/2); + } + }`; + + style.innerHTML = css +} + +const draw_sprite = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + atlas: CanvasImageSource, + atlas_index: [number, number], + atlas_tile_width: number, + rotation: Rotation +) => { + ctx.save() + ctx.translate( + (x + 0.5) * ATLAS_TILE_WIDTH, + (y + 0.5) * ATLAS_TILE_WIDTH + ) + ctx.rotate(rotation * Math.PI / 180) + ctx.drawImage( + atlas, + atlas_index[0] * atlas_tile_width, + atlas_index[1] * atlas_tile_width, + atlas_tile_width, + atlas_tile_width, + -width * ATLAS_TILE_WIDTH / 2, + -width * ATLAS_TILE_WIDTH / 2, + width * ATLAS_TILE_WIDTH, + width * ATLAS_TILE_WIDTH + ) + ctx.restore() +} + +const draw_players = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + players: Players, + frame: number +) => { + + let atlas_frames: [number, number][] = [ + [0, 2], + [1, 2], + [2, 2], + [0, 3], + [1, 3], + [0, 3], + [2, 2], + [1, 2], + ] + + for (let id in players) { + + let player = players[id] + if (!player) continue + + let atlas_index = atlas_frames[0] + if (player.moving) { + atlas_index = atlas_frames[Math.floor(frame / 2) % atlas_frames.length] + } + + let rotation: number + switch (player.moveRotation) { + case Rotation.NORTH: + rotation = 270 + break + case Rotation.SOUTH: + rotation = 90 + break + case Rotation.WEST: + rotation = 180 + break + case Rotation.EAST: + default: + rotation = 0 + break + } + + draw_sprite ( + ctx, + player.pos.x, + player.pos.y, + 1, + atlas, + atlas_index, + ATLAS_TILE_WIDTH, + rotation + ) + } +} + +const draw_items = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + items: Items +) => { + + for (let item_key in items) { + + let item = items[item_key] + if (!item) continue + + let width: number, atlas_index: [number, number] + switch (item.type) { + case ItemType.DOT: + width = .2, + atlas_index = [2, 3] + break + default: + continue + } + + draw_sprite ( + ctx, + item.pos.x, + item.pos.y, + width, + atlas, + atlas_index, + ATLAS_TILE_WIDTH, + 0 + ) + + } + +} + +const draw_map_canvas = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + map: Map +) => { + + for (let y = 0; y < map.height; y++) { + for (let x = 0; x < map.width; x++) { + + let wall_type = map.walls[y * map.width + x] + + let atlas_index: [number, number], rotation: number; + switch(wall_type) { + case Wall.EMPTY: + continue + case Wall.WALL_HZ: + atlas_index = [1, 1] + rotation = 0 + break + case Wall.WALL_VT: + atlas_index = [1, 1] + rotation = 90 + break + case Wall.TURN_Q1: + atlas_index = [2, 0] + rotation = 0 + break + case Wall.TURN_Q2: + atlas_index = [2, 0] + rotation = 270 + break + case Wall.TURN_Q3: + atlas_index = [2, 0] + rotation = 180 + break + case Wall.TURN_Q4: + atlas_index = [2, 0] + rotation = 90 + break + case Wall.TEE_NORTH: + atlas_index = [1, 0] + rotation = 180 + break + case Wall.TEE_EAST: + atlas_index = [1, 0] + rotation = 270 + break + case Wall.TEE_SOUTH: + atlas_index = [1, 0] + rotation = 0 + break + case Wall.TEE_WEST: + atlas_index = [1, 0] + rotation = 90 + break + case Wall.CROSS: + atlas_index = [0, 0] + rotation = 0 + break + case Wall.DOT: + atlas_index = [2, 1] + rotation = 0 + break + case Wall.WALL_END_NORTH: + atlas_index = [0, 1] + rotation = 0 + break; + case Wall.WALL_END_EAST: + atlas_index = [0, 1] + rotation = 90 + break; + case Wall.WALL_END_SOUTH: + atlas_index = [0, 1] + rotation = 180 + break; + case Wall.WALL_END_WEST: + atlas_index = [0, 1] + rotation = 270 + break; + } + + draw_sprite ( + ctx, + x, + y, + 1, + atlas, + atlas_index, + ATLAS_TILE_WIDTH, + rotation + ) + } + } + +} + +let map_canvas = document.createElement("canvas") +const draw_map = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + map: Map, + last: number | undefined +) => { + + if (map.id !== last) { + map_canvas.width = map.width * ATLAS_TILE_WIDTH + map_canvas.height = map.height * ATLAS_TILE_WIDTH + + let map_ctx = map_canvas.getContext("2d") + draw_map_canvas(map_ctx, atlas, map) + } + + ctx.drawImage ( + map_canvas, + 0, + 0 + ) + +} + +let last_map_drawn: number | undefined +export const startGraphicsUpdater = () => { + + let canvas = document.getElementById("canvas") as HTMLCanvasElement + let atlas = document.getElementById("atlas") as HTMLImageElement + + /** + * @param {import("./logic").GameState} data + */ + return ( + data: GameState, + frame: number + ) => { + + let map = getMap(data.mapId) + + if (!map) return + + if (map.id !== last_map_drawn) { + canvas.style.display = "" + canvas.width = map.width * ATLAS_TILE_WIDTH + canvas.height = map.height * ATLAS_TILE_WIDTH + } + + let ctx = canvas.getContext("2d") + ctx.clearRect(0, 0, canvas.width, canvas.height) + + draw_map(ctx, atlas, map, last_map_drawn) + draw_items(ctx, atlas, data.items) + draw_players(ctx, atlas, data.players, frame) + update_style(map.width, map.height) + + last_map_drawn = map.id + + } + +} diff --git a/client/src/types.ts b/client/src/types.ts new file mode 100644 index 0000000..df0c8b1 --- /dev/null +++ b/client/src/types.ts @@ -0,0 +1,131 @@ + +export enum Wall { + EMPTY, + WALL_HZ, + WALL_VT, + TURN_Q1, + TURN_Q2, + TURN_Q3, + TURN_Q4, + TEE_NORTH, + TEE_EAST, + TEE_SOUTH, + TEE_WEST, + CROSS, + DOT, + WALL_END_NORTH, + WALL_END_SOUTH, + WALL_END_EAST, + WALL_END_WEST +} + +export enum ItemType { + DOT +} + +export enum Key { + NOTHING, + UP, + DOWN, + LEFT, + RIGHT +} + +export type KeyMap = { + [key: string]: Key +} + +export const GameKeyMap = { + "KeyW": Key.UP, + "KeyA": Key.LEFT, + "KeyS": Key.DOWN, + "KeyD": Key.RIGHT, +} + +export enum Rotation { + NOTHING, + NORTH, + EAST, + SOUTH, + WEST +} + +export type Vec2 = { + x: number, + y: number +} + +export type InputMap = { + [key: number]: Key +} + +export type Player = { + pos: Vec2, + moveRotation: Rotation, + inputRotation: Rotation, + name?: string, + moving: boolean +} + +export type PlayerInput = { + start: boolean, + key: Key, + name?: string +} + +export type Input = { + players: {[key: number]: PlayerInput}, + added?: number[], + removed?: number[], +} + +export type Message = { + type?: string; + connections?: number[], + added?: number, + removed?: number, + id?: number, + frame?: number, + data?: any, + connection?: number, + state?: GameState, + error?: string +} + +export type Players = { + [key: number]: Player +} + +export type Item = { + type: ItemType, + pos: Vec2 +} + +export type Items = { + [key: number]: Item +} + +export type Map = { + data: number[], + walls: number[], + width: number, + height: number, + id: number +} + +export type Maps = { + [key: number]: Map +} + +export type GameState = { + started: boolean, + input: InputMap, + players: Players, + items: Items, + mapId: number | undefined +} + +export type Frame = { + data: GameState, + input: Input +} |