diff --git a/src/common/display/text.ts b/src/common/display/text.ts index 8a97b29..9276a5d 100644 --- a/src/common/display/text.ts +++ b/src/common/display/text.ts @@ -2,10 +2,11 @@ import '@common/assets/fonts/vga.font.css'; import { randInt } from "@common/utils"; import { createCanvas } from './canvas'; import type { Rect } from '@common/geometry'; +import { bresenhamCircleGen, bresenhamLineGen, type BresenhamCircleOptions, type BresenhamLineOptions } from '@common/navigation/bresenham'; -export type IColorLike = string | number | Color; -export type IChar = [string, IColorLike?, IColorLike?] | string; -export type IDefinedChar = [string, IColorLike, IColorLike]; +export type ColorLike = string | number | Color; +export type Char = [string, ColorLike?, ColorLike?] | string; +export type DefinedChar = [string, ColorLike, ColorLike]; export const randChar = (min = ' ', max = '~') => String.fromCharCode(randInt( @@ -68,16 +69,16 @@ export const isVertical = (char: string) => char === '│'; export const isHorizontal = (char: string) => char === '─'; export const isCorner = (char: string) => '┌┐└┘'.includes(char); -interface IBoxOptions { +interface BoxOptions { vertical?: string; horizontal?: string; topLeft?: string; topRight?: string; bottomLeft?: string; bottomRight?: string; - fill?: IChar; - fg?: IColorLike; - bg?: IColorLike; + fill?: Char; + fg?: ColorLike; + bg?: ColorLike; title?: string; } @@ -87,23 +88,23 @@ const NATIVE_FONT = `${CHAR_H}px "IBM VGA 8x16"`; const FALLBACK_FONT = `${CHAR_H}px monospace`; const REGION_DATA = Symbol('TextRegion.data'); -const colorToCSS = (c: IColorLike): string => +const colorToCSS = (c: ColorLike): string => typeof c === 'number' ? COLORS[c] : c as string; -const parseChar = (char: IChar): IDefinedChar => ( +const parseChar = (char: Char, fg: ColorLike = DEFAULT_FG, bg: ColorLike = DEFAULT_BG): DefinedChar => ( typeof char === 'string' - ? [char, DEFAULT_FG, DEFAULT_BG] + ? [char, fg, bg] : [ char[0], - char[1] ?? DEFAULT_FG, - char[2] ?? DEFAULT_BG, + char[1] ?? fg, + char[2] ?? bg, ] ); export class TextDisplay { private chars: string[]; - private fgs: IColorLike[]; - private bgs: IColorLike[]; + private fgs: ColorLike[]; + private bgs: ColorLike[]; private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private font = FALLBACK_FONT; @@ -112,12 +113,13 @@ export class TextDisplay { private clipTop: number = 0; private clipRight: number; private clipBottom: number; + private dirtySet = new Set(); constructor( public width = GAME_WIDTH, public height = GAME_HEIGHT, parent?: HTMLCanvasElement, - letterboxColor: IColorLike = DEFAULT_BG, + letterboxColor: ColorLike = DEFAULT_BG, ) { this.letterboxColor = colorToCSS(letterboxColor); const canvas = parent ?? createCanvas(width * CHAR_W, height * CHAR_H); @@ -158,6 +160,14 @@ export class TextDisplay { document.body.style.background = this.letterboxColor; } + private redrawDirty() { + for (const i of this.dirtySet) { + const x = i % this.width; + const y = Math.floor(i / this.width); + this.drawCell(x, y); + } + } + private redraw() { for (let y = 0; y < this.height; y++) { for (let x = 0; x < this.width; x++) { @@ -166,6 +176,10 @@ export class TextDisplay { } } + private dirtyCell(x: number, y: number) { + this.dirtySet.add(y * this.width + x); + } + private drawCell(x: number, y: number) { const i = y * this.width + x; const px = x * CHAR_W; @@ -185,9 +199,10 @@ export class TextDisplay { this.ctx.fillText(char, px, py); this.ctx.restore(); } + this.dirtySet.delete(i); } - private setCharRaw(x: number, y: number, char: string, fg: IColorLike, bg: IColorLike) { + private setCharRaw(x: number, y: number, char: string, fg: ColorLike, bg: ColorLike) { if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) return; if (!char || char === '\0') return; @@ -206,15 +221,15 @@ export class TextDisplay { dirty = true; } if (dirty) { - this.drawCell(x | 0, y | 0); + this.dirtyCell(x | 0, y | 0); } } - setChar(x: number, y: number, char: IChar = '█') { + setChar(x: number, y: number, char: Char = '█') { this.setCharRaw(x | 0, y | 0, ...parseChar(char)); } - getChar(x: number, y: number): IDefinedChar { + getChar(x: number, y: number): DefinedChar { x = x | 0; y = y | 0; if (x < this.clipLeft || y < this.clipTop || x >= this.clipRight || y >= this.clipBottom) { return [' ', DEFAULT_FG, DEFAULT_BG]; @@ -239,53 +254,54 @@ export class TextDisplay { const regRow = row - y; const regBase = regRow * rw + regColOffset; const dispBase = row * this.width + x0; - const regRowStr = chars[regRow]; for (let i = 0; i < copyW; i++) { - const ch = regRowStr[regColOffset + i]; + const ch = chars[regBase + i]; if (ch === '\0') continue; - this.chars[dispBase + i] = ch; - this.fgs[dispBase + i] = fgs[regBase + i]; - this.bgs[dispBase + i] = bgs[regBase + i]; - this.drawCell(x0 + i, row); + let dirty = false; + if (ch !== this.chars[dispBase + i]) { + dirty = true; + this.chars[dispBase + i] = ch; + } + if (this.fgs[dispBase + i] !== fgs[regBase + i]) { + this.fgs[dispBase + i] = fgs[regBase + i]; + dirty = true; + } + if (this.bgs[dispBase + i] !== bgs[regBase + i]) { + this.bgs[dispBase + i] = bgs[regBase + i]; + dirty = true; + } + if (dirty) { + this.dirtyCell(x0 + i, row); + } } } } getRegion(x: number, y: number, w: number, h: number): TextRegion { x = x | 0; y = y | 0; w = w | 0; h = h | 0; - const rows: string[] = []; - const fgs: IColorLike[][] = []; - const bgs: IColorLike[][] = []; + const data: DefinedChar[][] = Array(h); for (let row = 0; row < h; row++) { const dispRow = y + row; - let rowStr = ''; - const fgRow: IColorLike[] = []; - const bgRow: IColorLike[] = []; + const charsRow: DefinedChar[] = Array(w); for (let col = 0; col < w; col++) { const dispCol = x + col; if (dispCol < this.clipLeft || dispRow < this.clipTop || dispCol >= this.clipRight || dispRow >= this.clipBottom) { - rowStr += ' '; - fgRow.push(DEFAULT_FG); - bgRow.push(DEFAULT_BG); + charsRow[col] = ['\0', DEFAULT_FG, DEFAULT_BG]; } else { const i = dispRow * this.width + dispCol; - rowStr += this.chars[i]; - fgRow.push(this.fgs[i]); - bgRow.push(this.bgs[i]); + charsRow[col] = [this.chars[i], this.fgs[i], this.bgs[i]] } } - rows.push(rowStr); - fgs.push(fgRow); - bgs.push(bgRow); + data[row] = charsRow; } - return new TextRegion(rows, fgs, bgs); + return new TextRegion(data); } - drawText(x: number, y: number, text: string, fg: IColorLike = DEFAULT_FG, bg: IColorLike = DEFAULT_BG) { + drawString(text: unknown, x: number, y: number, fg: ColorLike = DEFAULT_FG, bg: ColorLike = DEFAULT_BG) { x = x | 0; y = y | 0; - const lines = text.split('\n'); + const lines = String(text).split('\n'); for (let row = 0; row < lines.length; row++) { const line = lines[row]; const ry = y + row; @@ -296,7 +312,7 @@ export class TextDisplay { } } - drawVLine(x: number, y1: number, y2: number, char: IChar = '│') { + drawVLine(x: number, y1: number, y2: number, char: Char = '│') { x = x | 0; y1 = y1 | 0; y2 = y2 | 0; if (y2 < y1) { const t = y2; y2 = y1; y1 = t; } @@ -310,7 +326,7 @@ export class TextDisplay { } } - drawHLine(x1: number, x2: number, y: number, char: IChar = '─') { + drawHLine(x1: number, x2: number, y: number, char: Char = '─') { x1 = x1 | 0; x2 = x2 | 0; y = y | 0; if (x2 < x1) { const t = x2; x2 = x1; x1 = t; } x1 = Math.max(this.clipLeft, x1); @@ -323,7 +339,7 @@ export class TextDisplay { } } - drawBox(x: number, y: number, width: number, height: number, options: IBoxOptions = {}) { + drawBox(x: number, y: number, width: number, height: number, options: BoxOptions = {}) { x = x | 0; y = y | 0; const { vertical = '│', @@ -354,35 +370,67 @@ export class TextDisplay { this.drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]); if (title) { - this.drawText(x + 1, y, title, fg, bg); + this.drawString(title, x + 1, y, fg, bg); } } - drawTextInBox(x: number, y: number, text: string, options: IBoxOptions = {}) { + drawStringInBox(text: unknown, x: number, y: number, options: BoxOptions = {}) { x = x | 0; y = y | 0; const { fg = DEFAULT_FG, bg = DEFAULT_BG, } = options; - let width = 0; - const lines = text.split('\n'); + const lines = String(text).split('\n'); + const width = lines.reduce((m, line) => Math.max(m, line.length), 0); const height = lines.length; - for (const line of lines) { - if (line.length > width) width = line.length; - } - - this.drawBox(x, y, width, height, { ...options, fill: [' '] }); - this.drawText(x + 1, y + 1, text, fg, bg); + this.drawBox(x, y, width, height, { ...options, fill: ' ' }); + this.drawString(text, x + 1, y + 1, fg, bg); } - fillBox(x: number, y: number, width: number, height: number, char: IChar = '█') { + fillBox(x: number, y: number, width: number, height: number, char: Char = '█') { x = x | 0; y = y | 0; for (let i = y; i < y + height; i++) { this.drawHLine(x, x + width - 1, i, char); } } + drawLine(fromX: number, fromY: number, toX: number, toY: number, char: Char = '█') { + const [ch, fg, bg] = parseChar(char); + const options: BresenhamLineOptions = { + minX: this.clipLeft, + minY: this.clipTop, + maxX: this.clipRight - 1, + maxY: this.clipBottom - 1, + directions: 8, + } + for (const { x, y } of bresenhamLineGen(fromX, fromY, toX, toY, options)) { + this.setCharRaw(x, y, ch, fg, bg); + } + } + + #circle(cx: number, cy: number, radius: number, char: Char, fill: boolean) { + const [ch, fg, bg] = parseChar(char); + const options: BresenhamCircleOptions = { + minX: this.clipLeft, + minY: this.clipTop, + maxX: this.clipRight - 1, + maxY: this.clipBottom - 1, + fill, + } + for (const { x, y } of bresenhamCircleGen(cx, cy, radius, options)) { + this.setCharRaw(x, y, ch, fg, bg); + } + } + + drawCircle(cx: number, cy: number, radius: number, char: Char = '█') { + this.#circle(cx, cy, radius, char, false); + } + + fillCircle(cx: number, cy: number, radius: number, char: Char = '█') { + this.#circle(cx, cy, radius, char, true); + } + getClipRect(): Rect { return { x: this.clipLeft, @@ -398,72 +446,86 @@ export class TextDisplay { this.clipRight = Math.min(this.width, rect.x + rect.width); this.clipBottom = Math.min(this.height, rect.y + rect.height); } + + update() { + this.redrawDirty(); + } +} + +function expandColors(chars: string[] | Char[][], colors: ColorLike | ColorLike[] | ColorLike[][]): ColorLike[][] { + if (!Array.isArray(colors)) { + return chars.map(row => Array.from(row, () => colors)); + } + if (colors.length === 0) { + return []; + } + if (Array.isArray(colors[0])) { + return colors as ColorLike[][]; + } + const oldColors = colors as ColorLike[]; + const newColors: ColorLike[][] = []; + let i = 0; + for (const row of chars) { + newColors.push(Array.from(row, () => oldColors[i++])); + } + return newColors; } export class TextRegion { - readonly #chars: string[]; // one string per row, all same length - readonly #fgs: IColorLike[]; - readonly #bgs: IColorLike[]; + readonly #chars: string[]; + readonly #fgs: ColorLike[]; + readonly #bgs: ColorLike[]; + readonly width: number; + readonly height: number; - get width(): number { return this.#chars[0]?.length ?? 0; } - get height(): number { return this.#chars.length; } - - constructor(chars: IChar[][]); - constructor(chars: string, fg?: IColorLike | IColorLike[], bg?: IColorLike | IColorLike[]); - constructor(chars: string[], fg?: IColorLike | IColorLike[][], bg?: IColorLike | IColorLike[][]); + constructor(chars: Char[][], fg?: ColorLike, bg?: ColorLike); + constructor(chars: string, fg?: ColorLike | ColorLike[], bg?: ColorLike | ColorLike[]); + constructor(chars: string[], fg?: ColorLike | ColorLike[][], bg?: ColorLike | ColorLike[][]); constructor( - chars: IChar[][] | string | string[], - fg?: IColorLike | IColorLike[] | IColorLike[][], - bg?: IColorLike | IColorLike[] | IColorLike[][], + chars: Char[][] | string | string[], + fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG, + bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG, ) { if (typeof chars === 'string') { chars = chars.split('\n'); } - if (chars.length === 0 || typeof chars[0] === 'string') { - const rows = chars as string[]; - const w = rows.reduce((m, r) => Math.max(m, r.length), 0); - const h = rows.length; - this.#chars = rows.map(r => r.padEnd(w, ' ')); - this.#fgs = Array(w * h).fill(DEFAULT_FG); - this.#bgs = Array(w * h).fill(DEFAULT_BG); - if (fg != null && !Array.isArray(fg)) this.#fgs.fill(fg); - if (bg != null && !Array.isArray(bg)) this.#bgs.fill(bg); - if (Array.isArray(fg) || Array.isArray(bg)) { - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const i = y * w + x; - if (Array.isArray(fg)) this.#fgs[i] = (fg as IColorLike[][])[y]?.[x] ?? DEFAULT_FG; - if (Array.isArray(bg)) this.#bgs[i] = (bg as IColorLike[][])[y]?.[x] ?? DEFAULT_BG; - } - } - } - } else { - const ichars = chars as IChar[][]; - const h = ichars.length; - const w = ichars.reduce((m, r) => Math.max(m, r.length), 0); - this.#chars = ichars.map(row => row.map(ch => ch[0]).join('').padEnd(w, ' ')); - this.#fgs = Array(w * h).fill(DEFAULT_FG); - this.#bgs = Array(w * h).fill(DEFAULT_BG); - for (let y = 0; y < h; y++) { - for (let x = 0; x < ichars[y].length; x++) { - const ch = ichars[y][x]; - const i = y * w + x; - if (ch[1] != null) this.#fgs[i] = ch[1]; - if (ch[2] != null) this.#bgs[i] = ch[2]; - } + this.width = chars.reduce((m, r) => Math.max(m, r.length), 0); + this.height = chars.length; + const defaultFg = Array.isArray(fg) ? DEFAULT_FG : fg; + const defaultBg = Array.isArray(bg) ? DEFAULT_BG : bg; + + fg = expandColors(chars, fg ?? defaultFg); + bg = expandColors(chars, bg ?? defaultBg); + + const s = this.width * this.height; + this.#chars = Array(s); + this.#fgs = Array(s); + this.#bgs = Array(s); + + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const i = y * this.width + x; + const char = chars[y]?.[x] ?? '\0'; + const charFG = fg[y]?.[x] ?? defaultFg; + const charBG = bg[y]?.[x] ?? defaultBg; + + const ch = parseChar(char, charFG, charBG); + this.#chars[i] = ch[0]; + this.#fgs[i] = ch[1]; + this.#bgs[i] = ch[2]; } } } - get(x: number, y: number): IDefinedChar { + get(x: number, y: number): DefinedChar { const i = y * this.width + x; - return [this.#chars[y][x], this.#fgs[i], this.#bgs[i]]; + return [this.#chars[i], this.#fgs[i], this.#bgs[i]]; } - set(x: number, y: number, char: IChar) { + set(x: number, y: number, char: Char) { const ch = parseChar(char); - this.#chars[y] = this.#chars[y].slice(0, x) + ch[0] + this.#chars[y].slice(x + 1); + this.#chars[y * this.width + x] = ch[0]; this.#fgs[y * this.width + x] = ch[1]; this.#bgs[y * this.width + x] = ch[2]; } @@ -475,4 +537,4 @@ export class TextRegion { bgs: this.#bgs, }; } -} \ No newline at end of file +} diff --git a/src/common/game.ts b/src/common/game.ts index 75af856..5c9195f 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -2,13 +2,17 @@ import { formatError, formatErrorMessage } from "./errors"; import Input from "./input"; import { nextFrame } from "./utils"; -type Setup = () => Promise | T; -type Frame = (dt: number, state: T) => Promise | T | void; -type GameMain = () => Promise; +interface FrameMeta { + fps: number; +} +type Awaitable = PromiseLike | T; -export function gameLoop(frame: Frame): GameMain; -export function gameLoop(setup: Setup, frame: Frame): GameMain; -export function gameLoop(setupOrFrame: Setup | Frame, frame?: Frame): GameMain { +type Setup = () => Awaitable; +type Frame = (dt: number, state: T, meta: FrameMeta) => Awaitable; + +export function gameLoop(frame: Frame): RunGame; +export function gameLoop(setup: Setup, frame: Frame): RunGame; +export function gameLoop(setupOrFrame: Setup | Frame, frame?: Frame): RunGame { return async () => { let state: T; try { @@ -26,6 +30,9 @@ export function gameLoop(setupOrFrame: Setup | Frame, frame?: Frame) try { let prevFrame = performance.now(); + let fpsCounter = 0; + let fpsTimer = 0; + const meta: FrameMeta = { fps: 0 }; while (true) { await nextFrame(); Input.updateKeys(); @@ -33,13 +40,20 @@ export function gameLoop(setupOrFrame: Setup | Frame, frame?: Frame) const now = performance.now(); const dt = (now - prevFrame) / 1000; - if (dt < 1) { - const newState = await frame(dt, state); + if (dt < 1) { // skip long pause to avoid blowing up values + const newState = await frame(dt, state, meta); if (newState) { state = newState; } } + fpsCounter += 1; + if (now - fpsTimer > 500) { + meta.fps = fpsCounter * 2; + fpsCounter = 0; + fpsTimer = now; + } + prevFrame = performance.now(); } } catch (e) { diff --git a/src/common/navigation/bresenham.ts b/src/common/navigation/bresenham.ts index aaf796a..a2cd0b9 100644 --- a/src/common/navigation/bresenham.ts +++ b/src/common/navigation/bresenham.ts @@ -41,7 +41,7 @@ interface BresenhamCircleFovOptions extends BresenhamCircleBaseOptions<'fov' | ' breaker?: (x: number, y: number) => boolean; } -type BresenhamCircleOptions = BresenhamCircleFillOptions | BresenhamCircleFovOptions; +export type BresenhamCircleOptions = BresenhamCircleFillOptions | BresenhamCircleFovOptions; // --------------------------------------------------------------------------- // Cohen-Sutherland outcodes diff --git a/src/common/rpg/systems/render/text.ts b/src/common/rpg/systems/render/text.ts index f3d5a79..d7c0c0c 100644 --- a/src/common/rpg/systems/render/text.ts +++ b/src/common/rpg/systems/render/text.ts @@ -48,5 +48,7 @@ export class TextDisplaySystem extends System { this.display.setClipRect(clipRect); } } + + this.display.update(); } } \ No newline at end of file diff --git a/src/games/crawler/index.ts b/src/games/crawler/index.ts index 135471f..8ccf0b1 100644 --- a/src/games/crawler/index.ts +++ b/src/games/crawler/index.ts @@ -21,8 +21,8 @@ function createMap(world: World, random: SeededRandom) { BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, { minWidth: 8, - minHeight: 8, - depth: 8, + minHeight: 4, + depth: 10, random, }); const mapDataString: string[] = []; @@ -49,17 +49,9 @@ function createPlayer(world: World, x = 0, y = 0) { const player = world.createEntity('player'); player.add(new Position(x, y, 10)); player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW)))); - ; return player; } -function createFPS(world: World, x = 0, y = 0) { - const fps = world.createEntity('fps'); - fps.add(new Position(x, y, 100, true)); - fps.add(new Sprite(Resources.add('fps', '60'))); - return fps; -} - export default gameLoop(() => { const world = new World(); const display = world.addSystem(new TextDisplaySystem(100, 25)).display; @@ -89,14 +81,12 @@ export default gameLoop(() => { screenY: 0, }); const player = createPlayer(world, startCell.x, startCell.y); - createFPS(world, 0, 0); return { + display, world, map, mapData, mask, maskData, maskDirty: true, - fps: 0, - fpsTimer: 0, random, viewport, player, @@ -107,13 +97,20 @@ export default gameLoop(() => { }, }; }, (dt, state) => { - const { world, viewport, player, lastMove, now, mapData, maskData, isWall } = state; + const { + world, + viewport, + player, + lastMove, now, + mapData, maskData, + isWall, + } = state; let dx = -Math.sign(Input.getHorizontal()); let dy = -Math.sign(Input.getVertical()); const playerPos = getPosition(player)!; - if (now - lastMove > 0.03) { + if (now - lastMove > 0.05) { if (isWall(playerPos.x + dx, playerPos.y)) { dx = 0; } @@ -157,14 +154,5 @@ export default gameLoop(() => { world.update(dt); - state.fpsTimer += dt; - if (state.fpsTimer >= 0.5) { - Resources.update('fps', (state.fps * 2).toString()); - state.fps = 0; - state.fpsTimer = 0; - } else { - state.fps++; - }; - state.now += dt; }); diff --git a/src/games/text-dungeon/index.ts b/src/games/text-dungeon/index.ts index fdfb3ac..4955b43 100644 --- a/src/games/text-dungeon/index.ts +++ b/src/games/text-dungeon/index.ts @@ -19,7 +19,7 @@ export default async function main() { const display = new TextDisplay(); world.addSystem(new TextDisplaySystem(display)); - display.drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN }); + display.drawStringInBox('Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', 8, 11, { fg: Color.CYAN }); let roomEntity = getRoom(world, 0, 0, 0); let room = roomEntity?.get(Room); @@ -33,14 +33,14 @@ export default async function main() { if (!player) { throw new Error('No player found'); } - + function drawRoom() { if (!room) return; display.drawBox(ROOM_AREA_X, ROOM_AREA_Y, ROOM_AREA_WIDTH - 2, ROOM_AREA_HEIGHT - 2, { fill: ' ' }); display.drawBox(room.x, room.y, room.width, room.height, { fill: ['.', Color.GRAY] }); } - + function drawMap() { display.drawBox(MAP_X, MAP_Y, MAP_WIDTH - 2, MAP_HEIGHT - 2, { fill: [' '], title: 'Map' }); const worldPos = roomEntity.get(Position, 'world'); @@ -72,10 +72,10 @@ export default async function main() { const foundItems = inv ? Object.values(inv.state.slots).length : 0; const totalItems = Array.from(world.query(Item)).length; const items = `${foundItems}/${totalItems}`.padStart(coords.length - 2); - display.drawTextInBox(0, 0, `Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, { fg: Color.YELLOW, title: 'Info' }); + display.drawStringInBox(`Pos: ${coords}\nRooms: ${rooms}\nItems: ${items}`, 0, 0, { fg: Color.YELLOW, title: 'Info' }); } - let lastMove = Date.now(); + let lastMove = Date.now(); function handleInput() { if (!room || !player) return; Input.updateKeys();