diff --git a/bun.lock b/bun.lock index ac5e76e..e4b9111 100644 --- a/bun.lock +++ b/bun.lock @@ -111,7 +111,7 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], - "@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="], + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], "@types/clean-css": ["@types/clean-css@4.2.11", "", { "dependencies": { "@types/node": "*", "source-map": "^0.6.0" } }, "sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw=="], @@ -123,6 +123,8 @@ "@types/node": ["@types/node@20.14.10", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ=="], + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/relateurl": ["@types/relateurl@0.2.33", "", {}, "sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw=="], "@types/through": ["@types/through@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="], @@ -145,7 +147,7 @@ "browser-detect": ["browser-detect@0.2.28", "", { "dependencies": { "core-js": "^2.5.7" } }, "sha512-KeWGHqYQmHDkCFG2dIiX/2wFUgqevbw/rd6wNi9N6rZbaSJFtG5kel0HtprRwCGp8sqpQP79LzDJXf/WCx4WAw=="], - "bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="], + "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], "camel-case": ["camel-case@3.0.0", "", { "dependencies": { "no-case": "^2.2.0", "upper-case": "^1.1.1" } }, "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w=="], @@ -173,6 +175,8 @@ "cross-spawn": ["cross-spawn@7.0.5", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "delay": ["delay@6.0.0", "", {}, "sha512-2NJozoOHQ4NuZuVIr5CWd0iiLVIRSDepakaovIN+9eIDHEhdCAEvSy2cuf1DCrPPQLvHmbqTHODlhHg8UCy4zw=="], "detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="], diff --git a/src/common/game.ts b/src/common/game.ts new file mode 100644 index 0000000..0d6632c --- /dev/null +++ b/src/common/game.ts @@ -0,0 +1,33 @@ +import Input from "./input"; +import { nextFrame } from "./utils"; + +type Setup | void> = () => Promise | T; +type Frame | void> = (dt: number, state: T) => Promise | void; +type GameMain = () => void; + +export function gameLoop | void>(frame: Frame): GameMain; +export function gameLoop | void>(setup: Setup, frame: Frame): GameMain; +export function gameLoop | void>(setupOrFrame: Setup | Frame, frame?: Frame): GameMain { + return async () => { + let state: T; + if (frame) { + state = await (setupOrFrame as Setup)(); + } else { + frame = setupOrFrame as Frame; + state = {} as T; + } + + let prevFrame = performance.now(); + while (true) { + await nextFrame(); + Input.updateKeys(); + + const now = performance.now(); + const dt = (now - prevFrame) / 1000; + + await frame(dt, state); + + prevFrame = performance.now(); + } + } +}; diff --git a/src/common/input.ts b/src/common/input.ts index a0a9740..7e059df 100644 --- a/src/common/input.ts +++ b/src/common/input.ts @@ -6,45 +6,109 @@ interface IKeyState { pressed?: boolean; held?: boolean; } -type KeyCode = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'Space'; +namespace Input { + export enum KeyCode { + UP = 'ArrowUp', + DOWN = 'ArrowDown', + LEFT = 'ArrowLeft', + RIGHT = 'ArrowRight', + SPACE = 'Space', + SHIFT = 'Shift', + SHIFT_LEFT = 'ShiftLeft', + SHIFT_RIGHT = 'ShiftRight', + }; -const KEYS: Partial> = {}; - -document.body.addEventListener('keydown', (e) => { - const keyId = e.code as KeyCode; - console.debug(`[Input] Pressed ${keyId}`); - if (KEYS[keyId]) { - KEYS[keyId].state = true; - } else { - KEYS[keyId] = { state: true }; + export enum GamepadAxis { + LX = 0, + LY = 1, + RX = 2, + RY = 3, } -}); -document.body.addEventListener('keyup', (e) => { - const keyId = e.code as KeyCode; - console.debug(`[Input] Released ${keyId}`); - if (KEYS[keyId]) { - KEYS[keyId].state = false; + export enum GamepadButton { + A = 0, + B = 1, + X = 2, + Y = 3, + LB = 4, + RB = 5, + LT = 6, + RT = 7, + SELECT = 8, + START = 9, + L3 = 10, + R3 = 11, + D_UP = 12, + D_DOWN = 13, + D_LEFT = 14, + D_RIGHT = 15, + HOME = 16, } -}); -export const isPressed = (key: KeyCode): boolean => KEYS[key]?.pressed ?? false; -export const isReleased = (key: KeyCode): boolean => KEYS[key]?.released ?? false; -export const isHeld = (key: KeyCode): boolean => KEYS[key]?.held ?? false; + const DEAD_ZONE = 0.05; + const KEYS: Partial> = {}; -export function updateKeys() { - for (const key of Object.values(KEYS)) { - key.released = false; - key.pressed = false; - - if (key.state) { - key.pressed = !key.held; - key.held = true; + const onStateChange = (keyId: KeyCode, state: boolean) => { + console.debug(`[Input] Pressed ${keyId}`); + if (KEYS[keyId]) { + KEYS[keyId].state = state; } else { - key.released = true; - key.held = false; + KEYS[keyId] = { state }; } - key.prevState = key.state; + if (keyId === KeyCode.SHIFT_LEFT || keyId === KeyCode.SHIFT_RIGHT) { + const shiftState = Boolean(KEYS[KeyCode.SHIFT_LEFT]?.state || KEYS[KeyCode.SHIFT_RIGHT]?.state); + onStateChange(KeyCode.SHIFT, shiftState); + } + }; + + document.body.addEventListener('keydown', (e) => onStateChange(e.code as KeyCode, true)); + document.body.addEventListener('keyup', (e) => onStateChange(e.code as KeyCode, false)); + + export const isPressed = (key: KeyCode): boolean => KEYS[key]?.pressed ?? false; + export const isReleased = (key: KeyCode): boolean => KEYS[key]?.released ?? false; + export const isHeld = (key: KeyCode): boolean => KEYS[key]?.held ?? false; + + export const getGamepad = () => navigator.getGamepads().find(g => g != null); + + export const getGamepadAxis = (i: GamepadAxis) => { + const value = getGamepad()?.axes[i] ?? 0; + return Math.abs(value) < DEAD_ZONE ? 0 : value; } -} \ No newline at end of file + export const getGamepadButton = (btn: GamepadButton) => getGamepad()?.buttons[btn]?.value ?? 0; + + export const getLeft = () => isHeld(KeyCode.LEFT) ? 1 : Math.max(0, -getGamepadAxis(GamepadAxis.LX)); + export const getRight = () => isHeld(KeyCode.RIGHT) ? 1 : Math.max(0, getGamepadAxis(GamepadAxis.LX)); + + export const getUp = () => isHeld(KeyCode.UP) ? 1 : Math.max(0, -getGamepadAxis(GamepadAxis.LY)); + export const getDown = () => isHeld(KeyCode.DOWN) ? 1 : Math.max(0, getGamepadAxis(GamepadAxis.LY)); + + export const getHorizontal = () => ( + (Number(isHeld(KeyCode.LEFT)) - Number(isHeld(KeyCode.RIGHT))) + + getGamepadAxis(GamepadAxis.LX) + ); + + export const getVertical = () => ( + (Number(isHeld(KeyCode.UP)) - Number(isHeld(KeyCode.DOWN))) + + getGamepadAxis(GamepadAxis.LY) + ); + + export function updateKeys() { + for (const key of Object.values(KEYS)) { + key.released = false; + key.pressed = false; + + if (key.state) { + key.pressed = !key.held; + key.held = true; + } else { + key.released = true; + key.held = false; + } + + key.prevState = key.state; + } + } +} + +export default Input; \ No newline at end of file diff --git a/src/games/brick-dungeon/index.ts b/src/games/brick-dungeon/index.ts index 18ee0b7..05a62ba 100644 --- a/src/games/brick-dungeon/index.ts +++ b/src/games/brick-dungeon/index.ts @@ -1,9 +1,10 @@ import { BrickDisplay } from "@common/display/brick"; -import { isPressed, updateKeys } from "@common/input"; +import Input from "@common/input"; import spritesheetImage from './assets/spritesheet.png'; import { ITEMS, Items, MONSTERS, MONSTERS_ORDER, loadData, type Item, type Monster } from "./data"; import { randBool, weightedChoice } from "@common/utils"; +import { gameLoop } from "@common/game"; let display: BrickDisplay; const spritesheet = BrickDisplay.convertImage(spritesheetImage); @@ -47,9 +48,9 @@ let secondLootItem: Items | null = null; let frames = 0; let prevFrameTime: number = 0; -async function loop(time: number) { + +async function frame(time: number) { frames++; - const dt = time - prevFrameTime; prevFrameTime = time; if (frames % 8 == 0) { @@ -105,17 +106,17 @@ async function loop(time: number) { } else if (y === secondLootY) { lootToConfirm = secondLootItem; playerTurn = false; - } else if (isPressed('ArrowLeft')) { + } else if (Input.isPressed(Input.KeyCode.LEFT)) { selectedSlot = (selectedSlot + inventory.length - 1) % inventory.length; - } else if (isPressed('ArrowRight')) { + } else if (Input.isPressed(Input.KeyCode.RIGHT)) { selectedSlot = (selectedSlot + 1) % inventory.length; - } else if (isPressed('ArrowUp') && (!monsterAlive || y < monsterY - 5)) { + } else if (Input.isPressed(Input.KeyCode.UP) && (!monsterAlive || y < monsterY - 5)) { targetY = y + 4; playerTurn = false; - } else if (isPressed('ArrowDown')) { + } else if (Input.isPressed(Input.KeyCode.DOWN)) { targetY = y - 4; playerTurn = false; - } else if (isPressed('Space') && (monsterAlive && (y >= monsterY - 5 || item.ranged || item.heal))) { + } else if (Input.isPressed(Input.KeyCode.SPACE) && (monsterAlive && (y >= monsterY - 5 || item.ranged || item.heal))) { if (item.consumable) { inventory.splice(selectedSlot, 1); selectedSlot = (selectedSlot + inventory.length - 1) % inventory.length; @@ -138,7 +139,7 @@ async function loop(time: number) { } else if (lootToConfirm) { console.log('Loot confirm'); - if (isPressed('Space')) { + if (Input.isPressed(Input.KeyCode.SPACE)) { const i = ITEMS[lootToConfirm]; if (i.instantUse && i.heal) { playerHealth += i.heal; @@ -150,10 +151,10 @@ async function loop(time: number) { } lootConfirmed = true; - } else if (isPressed('ArrowUp')) { + } else if (Input.isPressed(Input.KeyCode.UP)) { targetY = y + 4; playerTurn = true; - } else if (isPressed('ArrowDown')) { + } else if (Input.isPressed(Input.KeyCode.DOWN)) { targetY = y - 4; playerTurn = true; } @@ -279,9 +280,6 @@ async function loop(time: number) { } display.update(); - - updateKeys(); - requestAnimationFrame(loop); } function spawnNextMonster() { @@ -321,11 +319,12 @@ function damagePlayer(monster: Monster) { } } -export default function main() { +const setup = () => { display = new BrickDisplay(); display.init(); loadData(spritesheet); spawnNextMonster(); - requestAnimationFrame(loop); -} \ No newline at end of file +} + +export default gameLoop(setup, frame); \ No newline at end of file diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index b141282..38684fc 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,5 +1,34 @@ -import awoo from "./awoo.cpp"; +import { gameLoop } from "@common/game"; +import Input from "@common/input"; -export default function main() { - console.log(awoo, awoo.play()); -} \ No newline at end of file +const setup = () => { + let x = window.innerWidth / 2 - 16; + let y = window.innerHeight / 2 - 16; + const ball = document.createElement('div'); + + ball.style.display = 'block'; + ball.style.width = '32px'; + ball.style.height = '32px'; + ball.style.borderRadius = '50%'; + ball.style.backgroundColor = 'red'; + ball.style.position = 'absolute'; + + document.body.append(ball); + + const speed = Math.min(window.innerHeight, window.innerWidth); + + return { x, y, speed, ball }; +} + +const frame = (dt: number, state: ReturnType) => { + const dx = Input.getHorizontal(); + const dy = Input.getVertical(); + + state.x += state.speed * dx * dt; + state.y += state.speed * dy * dt; + + state.ball.style.left = `${state.x}px`; + state.ball.style.top = `${state.y}px`; +} + +export default gameLoop(setup, frame); \ No newline at end of file diff --git a/src/games/text-dungeon/game.ts b/src/games/text-dungeon/game.ts index 4fa47ca..b5faebf 100644 --- a/src/games/text-dungeon/game.ts +++ b/src/games/text-dungeon/game.ts @@ -1,6 +1,6 @@ import { Color, Direction, getOppositeDirection } from './const'; import { drawTextInBox, tick } from './display'; -import { isHeld, isPressed, updateKeys } from '@common/input'; +import Input from '@common/input'; import { createItems, getItemsCount } from './item'; import { GameMap } from './map'; import { Player } from './player'; @@ -13,30 +13,30 @@ const player = new Player(currentRoom.x + currentRoom.width / 2, currentRoom.y + let lastMove = Date.now(); function handleInput() { - const isSpacePressed = isPressed(' '); + const isSpacePressed = Input.isPressed(Input.KeyCode.SPACE); - if (Date.now() - lastMove < 75 && !isHeld('shift') && !isSpacePressed) return; + if (Date.now() - lastMove < 75 && !Input.isHeld(Input.KeyCode.SHIFT) && !isSpacePressed) return; lastMove = Date.now(); let newX = player.x; let newY = player.y; let moved = isSpacePressed; - if (isHeld('arrowup')) { + if (Input.isHeld(Input.KeyCode.UP)) { newY--; moved = true; } - if (isHeld('arrowdown')) { + if (Input.isHeld(Input.KeyCode.DOWN)) { newY++; moved = true; } - if (isHeld('arrowleft')) { + if (Input.isHeld(Input.KeyCode.LEFT)) { newX--; moved = true; } - if (isHeld('arrowright')) { + if (Input.isHeld(Input.KeyCode.RIGHT)) { newX++; moved = true; } @@ -131,7 +131,7 @@ export async function main() { drawTextInBox(8, 11, 'Use arrow keys to move\nSHIFT to run\nSPACE to activate\nAWOO!', { fg: Color.CYAN }); while (true) { - updateKeys(); + Input.updateKeys(); handleInput(); handleLogic(); draw(); diff --git a/src/games/zombies/player.ts b/src/games/zombies/player.ts index f1226a1..776beb4 100644 --- a/src/games/zombies/player.ts +++ b/src/games/zombies/player.ts @@ -84,7 +84,10 @@ export default class Player extends Character { } public spendItem(item: Item | null | undefined): boolean { - return Boolean(item?.isSpendingWeapon) && this.removeItem(item); + if (!item) return false; + if (item.isSpendingWeapon) return this.removeItem(item); + + return true; } public removeItem(item: Item | null | undefined): boolean {