diff --git a/src/common/display/text.ts b/src/common/display/text.ts index 9276a5d..471c23c 100644 --- a/src/common/display/text.ts +++ b/src/common/display/text.ts @@ -484,8 +484,8 @@ export class TextRegion { constructor( chars: Char[][] | string | string[], - fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG, - bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG, + fg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_FG, + bg: ColorLike | ColorLike[] | ColorLike[][] = DEFAULT_BG, ) { if (typeof chars === 'string') { chars = chars.split('\n'); diff --git a/src/common/game.ts b/src/common/game.ts index 5c9195f..044c3c6 100644 --- a/src/common/game.ts +++ b/src/common/game.ts @@ -4,6 +4,7 @@ import { nextFrame } from "./utils"; interface FrameMeta { fps: number; + now: number; } type Awaitable = PromiseLike | T; @@ -32,7 +33,7 @@ export function gameLoop(setupOrFrame: Setup | Frame, frame?: Frame) let prevFrame = performance.now(); let fpsCounter = 0; let fpsTimer = 0; - const meta: FrameMeta = { fps: 0 }; + const meta: FrameMeta = { fps: 0, now: 0 }; while (true) { await nextFrame(); Input.updateKeys(); @@ -41,6 +42,7 @@ export function gameLoop(setupOrFrame: Setup | Frame, frame?: Frame) const dt = (now - prevFrame) / 1000; if (dt < 1) { // skip long pause to avoid blowing up values + meta.now += dt; const newState = await frame(dt, state, meta); if (newState) { state = newState; diff --git a/src/common/input.ts b/src/common/input.ts index 9412a79..c1346c0 100644 --- a/src/common/input.ts +++ b/src/common/input.ts @@ -4,7 +4,12 @@ interface IKeyState { released?: boolean; pressed?: boolean; + repeated?: boolean; held?: boolean; + + changedAt: number; + eventAt: number; + repeatAt?: number; } namespace Input { export enum KeyCode { @@ -16,6 +21,42 @@ namespace Input { SHIFT = 'Shift', SHIFT_LEFT = 'ShiftLeft', SHIFT_RIGHT = 'ShiftRight', + A = 'KeyA', + B = 'KeyB', + C = 'KeyC', + D = 'KeyD', + E = 'KeyE', + F = 'KeyF', + G = 'KeyG', + H = 'KeyH', + I = 'KeyI', + J = 'KeyJ', + K = 'KeyK', + L = 'KeyL', + M = 'KeyM', + N = 'KeyN', + O = 'KeyO', + P = 'KeyP', + Q = 'KeyQ', + R = 'KeyR', + S = 'KeyS', + T = 'KeyT', + U = 'KeyU', + V = 'KeyV', + W = 'KeyW', + X = 'KeyX', + Y = 'KeyY', + Z = 'KeyZ', + NUM_0 = 'Digit0', + NUM_1 = 'Digit1', + NUM_2 = 'Digit2', + NUM_3 = 'Digit3', + NUM_4 = 'Digit4', + NUM_5 = 'Digit5', + NUM_6 = 'Digit6', + NUM_7 = 'Digit7', + NUM_8 = 'Digit8', + NUM_9 = 'Digit9', }; export enum GamepadAxis { @@ -47,13 +88,21 @@ namespace Input { const DEAD_ZONE = 0.05; const KEYS: Partial> = {}; + let repeatIntervalMs = 10; + let repeatDelayMs = 300; const onStateChange = (keyId: KeyCode, state: boolean) => { console.debug(`[Input] ${state ? 'Pressed' : 'Released'} ${keyId}`); + const now = Date.now(); + if (KEYS[keyId]) { + if (state !== KEYS[keyId].state) { + KEYS[keyId].changedAt = now; + } KEYS[keyId].state = state; + KEYS[keyId].eventAt = now; } else { - KEYS[keyId] = { state }; + KEYS[keyId] = { state, changedAt: now, eventAt: now }; } if (keyId === KeyCode.SHIFT_LEFT || keyId === KeyCode.SHIFT_RIGHT) { @@ -65,9 +114,15 @@ namespace Input { 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 isPressed = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.pressed); + export const isReleased = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.released); + export const isRepeated = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.repeated); + export const isHeld = (...keys: KeyCode[]) => keys.some(key => KEYS[key]?.held); + + export const hasPressed = () => Object.values(KEYS).some(k => k.pressed); + export const hasReleased = () => Object.values(KEYS).some(k => k.released); + export const hasRepeated = () => Object.values(KEYS).some(k => k.repeated); + export const hasHeld = () => Object.values(KEYS).some(k => k.held); export const getGamepad = () => navigator.getGamepads().find(g => g != null); @@ -93,6 +148,14 @@ namespace Input { + getGamepadAxis(GamepadAxis.LY) ); + export const setRepeatInterval = (intervalMs: number) => { + repeatIntervalMs = intervalMs; + } + + export const setRepeatDelay = (delayMs: number) => { + repeatDelayMs = delayMs; + } + export function updateKeys() { for (const key of Object.values(KEYS)) { key.released = false; @@ -102,10 +165,15 @@ namespace Input { key.pressed = !key.held; key.held = true; } else { - key.released = true; + key.released = key.held; key.held = false; } + const now = Date.now(); + key.repeated = key.held && (now - (key.repeatAt ?? 0) > repeatIntervalMs) && (now - key.changedAt > repeatDelayMs); + if (key.repeated) { + key.repeatAt = now; + } key.prevState = key.state; } } diff --git a/src/games/crawler/index.ts b/src/games/crawler/index.ts index 8ccf0b1..a7cb3b8 100644 --- a/src/games/crawler/index.ts +++ b/src/games/crawler/index.ts @@ -52,7 +52,50 @@ function createPlayer(world: World, x = 0, y = 0) { return player; } +function handleMovement() { + let dx = 0; + let dy = 0; + + const isReleasedOrRepeated = (...keys: Input.KeyCode[]) => + keys.some(key => Input.isReleased(key) || Input.isRepeated(key)); + + const left = isReleasedOrRepeated(Input.KeyCode.LEFT, Input.KeyCode.H); + const right = isReleasedOrRepeated(Input.KeyCode.RIGHT, Input.KeyCode.L); + const up = isReleasedOrRepeated(Input.KeyCode.UP, Input.KeyCode.K); + const down = isReleasedOrRepeated(Input.KeyCode.DOWN, Input.KeyCode.J); + + const upLeft = isReleasedOrRepeated(Input.KeyCode.Y); + const upRight = isReleasedOrRepeated(Input.KeyCode.U); + const downLeft = isReleasedOrRepeated(Input.KeyCode.B); + const downRight = isReleasedOrRepeated(Input.KeyCode.N); + + if (left) dx -= 1; + if (right) dx += 1; + if (up) dy -= 1; + if (down) dy += 1; + + if (upLeft) { + dx -= 1; + dy -= 1; + } + if (upRight) { + dx += 1; + dy -= 1; + } + if (downLeft) { + dx -= 1; + dy += 1; + } + if (downRight) { + dx += 1; + dy += 1; + } + + return { dx, dy }; +} + export default gameLoop(() => { + Input.setRepeatInterval(50); const world = new World(); const display = world.addSystem(new TextDisplaySystem(100, 25)).display; @@ -82,7 +125,7 @@ export default gameLoop(() => { }); const player = createPlayer(world, startCell.x, startCell.y); - return { + const state = { display, world, map, mapData, @@ -90,69 +133,74 @@ export default gameLoop(() => { random, viewport, player, - lastMove: 0, now: 0, isWall: (x: number, y: number): boolean => { - const [ch] = mapData.get(x, y); + const [ch] = state.mapData.get(x, y); return ch === WALL; }, + updateMask: () => { + if (!state.maskDirty) return; + + const { mapData, maskData, player, isWall } = state; + + const playerPos = getPosition(player)!; + for (let x = 0; x < mapData.width; x++) { + for (let y = 0; y < mapData.height; y++) { + const [ch, fg] = maskData.get(x + mapData.width, y + mapData.height); + if (ch !== ' ' && fg !== Color.GRAY) { + maskData.set(x + mapData.width, y + mapData.height, [mapData.get(x, y)[0], Color.GRAY]); + } + } + } + for (const { x, y } of bresenhamCircleGen(playerPos.x, playerPos.y, 10, { fill: 'shadow', breaker: isWall })) { + maskData.set(x + mapData.width, y + mapData.height, '\0'); + } + state.maskDirty = false; + } }; + + return state; }, (dt, state) => { const { world, viewport, player, - lastMove, now, - mapData, maskData, + updateMask, isWall, } = state; - let dx = -Math.sign(Input.getHorizontal()); - let dy = -Math.sign(Input.getVertical()); + const hasInput = Input.hasReleased() || Input.hasRepeated(); const playerPos = getPosition(player)!; - if (now - lastMove > 0.05) { - if (isWall(playerPos.x + dx, playerPos.y)) { - dx = 0; - } - if (isWall(playerPos.x, playerPos.y + dy)) { - dy = 0; - } - if (isWall(playerPos.x + dx, playerPos.y + dy)) { - dx = 0 - dy = 0; - } - } else { - dx = 0; - dy = 0; - } + if (hasInput) { + if (Input.isHeld(Input.KeyCode.SHIFT)) { + } else { + let { dx, dy } = handleMovement(); - if (dx || dy) { - move(player, dx, dy); - move(viewport, dx, dy); + if (isWall(playerPos.x + dx, playerPos.y)) { + dx = 0; + } + if (isWall(playerPos.x, playerPos.y + dy)) { + dy = 0; + } + if (isWall(playerPos.x + dx, playerPos.y + dy)) { + dx = 0 + dy = 0; + } - playerPos.x + dx; - playerPos.y + dy; + if (dx || dy) { + move(player, dx, dy); + move(viewport, dx, dy); - state.maskDirty = true; - state.lastMove = now; - } - - if (state.maskDirty) { - for (let x = 0; x < mapData.width; x++) { - for (let y = 0; y < mapData.height; y++) { - const [ch, fg] = maskData.get(x + mapData.width, y + mapData.height); - if (ch !== ' ' && fg !== Color.GRAY) { - maskData.set(x + mapData.width, y + mapData.height, [mapData.get(x, y)[0], Color.GRAY]); - } + playerPos.x + dx; + playerPos.y + dy; } } - for (const { x, y } of bresenhamCircleGen(playerPos.x, playerPos.y, 10, { fill: 'shadow', breaker: isWall })) { - maskData.set(x + mapData.width, y + mapData.height, '\0'); - } - state.maskDirty = false; + + // TODO environment step + + state.maskDirty = true; } + updateMask(); world.update(dt); - - state.now += dt; });