Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ panel, a background job.

## Features

- Canvas 2D dino-runner-style minigame (original visuals — not a Chrome clone)
- Keyboard (Space / ArrowUp) and touch/pointer input, with press-to-retry on game over
- Two built-in Canvas 2D minigames (original visuals — neither is a clone of anything):
- `dino-runner` (default) — jump over obstacles with Space / ArrowUp / tap.
- `orbit-dodger` — dodge falling obstacles by moving left/right with Arrow keys or WASD, or by
dragging/holding a pointer or finger — the ship follows it directly.
- Keyboard and touch/pointer input, with press-to-retry on game over
- DPR-aware canvas scaling
- Configurable `threshold` (delay before the game appears), `minPlayMs` (minimum time before the
player can leave), and `maxPlayMs` (safety cap that auto-dismisses if they never do)
Expand Down Expand Up @@ -218,7 +221,7 @@ class GameEngine {

| Option | Type | Default | Description |
| -------------------- | ------------------------- | ------------------ | ---------------------------------------------------------------------------- |
| `game` | `string` | `'dino-runner'` | Registered game id |
| `game` | `string` | `'dino-runner'` | Registered game id — built in: `'dino-runner'`, `'orbit-dodger'` |
| `threshold` | `number` | `1500` | Ms of loading before the game appears |
| `minPlayMs` | `number` | `3000` | Ms the game must stay visible (from when it appeared) before the player can leave |
| `maxPlayMs` | `number` | `30000` | Ms the "Continue" affordance waits for a manual click before auto-dismissing |
Expand Down Expand Up @@ -331,6 +334,10 @@ registerGame('my-game', () => myGame);

Then pass `game: 'my-game'` in your config.

`InputState` also carries optional `heldDirection` (-1/0/1, from Arrow keys / WASD) and `pointerX`
(canvas-local logical-pixel X of an actively-down pointer/touch, for "follow the pointer" style
control) — see `orbit-dodger`'s source for a reference implementation that uses both.

## Browser Support

Any browser with Canvas 2D and `requestAnimationFrame` support — all evergreen browsers.
Expand Down
1 change: 1 addition & 0 deletions examples/angular/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ <h1>boredload — Angular example</h1>
<div
boredload
[isLoading]="isLoading()"
[game]="game"
[threshold]="1000"
[minPlayMs]="2500"
[maxPlayMs]="15000"
Expand Down
2 changes: 2 additions & 0 deletions examples/angular/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { BoredloadGameDirective } from 'boredload/angular';
export class App {
protected readonly isLoading = signal(true);
protected readonly dismissed = signal(false);
// Try ?game=orbit-dodger in the URL to switch minigames.
protected readonly game = new URLSearchParams(window.location.search).get('game') ?? undefined;

protected simulateLoad(): void {
this.dismissed.set(false);
Expand Down
8 changes: 8 additions & 0 deletions examples/nextjs/app/loading-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { LoadingGame } from 'boredload/react';
export function LoadingDemo() {
const [isLoading, setIsLoading] = useState(true);
const [dismissed, setDismissed] = useState(false);
// Lazy-initialized so this never runs during server rendering — window
// isn't available there. Try ?game=orbit-dodger in the URL.
const [game] = useState<string | undefined>(() =>
typeof window === 'undefined'
? undefined
: (new URLSearchParams(window.location.search).get('game') ?? undefined),
);

useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 3000);
Expand All @@ -16,6 +23,7 @@ export function LoadingDemo() {
<>
<LoadingGame
isLoading={isLoading}
game={game}
width={320}
height={160}
threshold={1000}
Expand Down
4 changes: 4 additions & 0 deletions examples/react-vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useEffect, useState } from 'react';
import { LoadingGame } from 'boredload/react';

// Try ?game=orbit-dodger in the URL to switch minigames.
const gameParam = new URLSearchParams(window.location.search).get('game') ?? undefined;

export function App() {
const [isLoading, setIsLoading] = useState(true);
const [dismissed, setDismissed] = useState(false);
Expand All @@ -15,6 +18,7 @@ export function App() {
<h1>boredload — React + Vite example</h1>
<LoadingGame
isLoading={isLoading}
game={gameParam}
width={320}
height={160}
threshold={1000}
Expand Down
4 changes: 4 additions & 0 deletions examples/vanilla/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ const button = document.getElementById('reload') as HTMLButtonElement;

const CONTINUE_LABEL = "Let's go →";

// Try ?game=orbit-dodger in the URL to switch minigames.
const gameParam = new URLSearchParams(location.search).get('game');

const game = mount(slot, {
game: gameParam ?? 'dino-runner',
threshold: 1000,
minPlayMs: 2500,
maxPlayMs: 15000,
Expand Down
3 changes: 2 additions & 1 deletion src/core/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class GameEngine<TState = unknown> {
}
this.ctx = ctx;

this.input = new InputController(this.canvas);
this.input = new InputController(this.canvas, this.viewport.width);
this.game = options.game as Game<TState>;
this.persistHighScore = options.persistHighScore ?? true;
this.highScoreKey = options.highScoreKey ?? `boredload:${this.game.id}`;
Expand Down Expand Up @@ -115,6 +115,7 @@ export class GameEngine<TState = unknown> {
resize(width: number, height: number): void {
if (this.destroyed) return;
this.viewport = resizeCanvasToDisplaySize(this.canvas, width, height, this.viewport.dpr);
this.input.setLogicalWidth(width);
}

setTheme(theme: Partial<Theme>): void {
Expand Down
102 changes: 98 additions & 4 deletions src/core/input.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InputController } from './input';

function keyEvent(type: string, code: string): KeyboardEvent {
Expand All @@ -15,10 +15,10 @@ describe('InputController', () => {
target.remove();
});

function setup(): InputController {
function setup(logicalWidth?: number): InputController {
target = document.createElement('div');
document.body.appendChild(target);
controller = new InputController(target);
controller = new InputController(target, logicalWidth);
return controller;
}

Expand All @@ -44,7 +44,7 @@ describe('InputController', () => {

it('ignores unrelated keys', () => {
const c = setup();
window.dispatchEvent(keyEvent('keydown', 'KeyA'));
window.dispatchEvent(keyEvent('keydown', 'KeyB'));
expect(c.getState().primaryActionDown).toBe(false);
});

Expand All @@ -70,4 +70,98 @@ describe('InputController', () => {
window.dispatchEvent(keyEvent('keydown', 'Space'));
expect(c.getState().primaryActionDown).toBe(false);
});

it('sets heldDirection from ArrowLeft/ArrowRight independently of the primary action', () => {
const c = setup();
window.dispatchEvent(keyEvent('keydown', 'ArrowLeft'));
expect(c.getState().heldDirection).toBe(-1);
window.dispatchEvent(keyEvent('keyup', 'ArrowLeft'));
window.dispatchEvent(keyEvent('keydown', 'ArrowRight'));
expect(c.getState().heldDirection).toBe(1);
window.dispatchEvent(keyEvent('keyup', 'ArrowRight'));
expect(c.getState().heldDirection).toBe(0);
});

it('supports WASD alongside arrow keys for held direction', () => {
const c = setup();
window.dispatchEvent(keyEvent('keydown', 'KeyA'));
expect(c.getState().heldDirection).toBe(-1);
window.dispatchEvent(keyEvent('keyup', 'KeyA'));
window.dispatchEvent(keyEvent('keydown', 'KeyD'));
expect(c.getState().heldDirection).toBe(1);
});

it('reports heldDirection and primaryActionPressed together when both are active', () => {
const c = setup();
window.dispatchEvent(keyEvent('keydown', 'ArrowLeft'));
window.dispatchEvent(keyEvent('keydown', 'Space'));
const state = c.getState();
expect(state.heldDirection).toBe(-1);
expect(state.primaryActionPressed).toBe(true);
});

it('pointerX is undefined until a pointer is down', () => {
const c = setup(300);
expect(c.getState().pointerX).toBeUndefined();
});

it('sets pointerX on pointerdown without requiring a move', () => {
const c = setup(300);
vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 300,
} as DOMRect);
target.dispatchEvent(new PointerEvent('pointerdown', { cancelable: true, clientX: 120 }));
expect(c.getState().pointerX).toBe(120);
});

it('updates pointerX on pointermove only while the pointer is down', () => {
const c = setup(300);
vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 300,
} as DOMRect);

window.dispatchEvent(new PointerEvent('pointermove', { clientX: 50 }));
expect(c.getState().pointerX).toBeUndefined();

target.dispatchEvent(new PointerEvent('pointerdown', { cancelable: true, clientX: 10 }));
window.dispatchEvent(new PointerEvent('pointermove', { clientX: 200 }));
expect(c.getState().pointerX).toBe(200);
});

it('resets pointerX on pointerup/touchend', () => {
const c = setup(300);
vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 300,
} as DOMRect);
target.dispatchEvent(new PointerEvent('pointerdown', { cancelable: true, clientX: 10 }));
expect(c.getState().pointerX).toBe(10);
window.dispatchEvent(new PointerEvent('pointerup', { cancelable: true }));
expect(c.getState().pointerX).toBeUndefined();
});

it('converts clientX to logical-pixel X accounting for CSS scaling', () => {
// Canvas rendered at half its logical size (rect.width=150 for logicalWidth=300):
// a clientX 30px into the rendered box should map to 60 logical px.
const c = setup(300);
vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({
left: 10,
width: 150,
} as DOMRect);
target.dispatchEvent(new PointerEvent('pointerdown', { cancelable: true, clientX: 40 }));
expect(c.getState().pointerX).toBe(60);
});

it('updates the conversion after setLogicalWidth()', () => {
const c = setup(300);
vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 300,
} as DOMRect);
c.setLogicalWidth(600);
target.dispatchEvent(new PointerEvent('pointerdown', { cancelable: true, clientX: 100 }));
expect(c.getState().pointerX).toBe(200);
});
});
65 changes: 61 additions & 4 deletions src/core/input.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,114 @@
import type { InputState } from './types';

const ACTION_KEYS = new Set(['Space', 'ArrowUp']);
const LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']);
const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']);

/**
* Normalizes keyboard (Space / ArrowUp) and touch/pointer input on a target
* element into a single primary-action signal. `primaryActionDown` reflects
* Normalizes keyboard (Space / ArrowUp for the primary action, ArrowLeft+A /
* ArrowRight+D for held direction) and touch/pointer input on a target
* element into a single `InputState` snapshot. `primaryActionDown` reflects
* the current held state; `primaryActionPressed` is true only on the frame
* the action transitioned from up to down (edge-triggered, consumed via
* `consumePressed()`).
* `getState()`). `pointerX` tracks an actively-down pointer/touch's
* canvas-local logical-pixel X position, for games that want "follow the
* pointer" style control.
*/
export class InputController {
private down = false;
private pressedEdge = false;
private leftDown = false;
private rightDown = false;
private pointerXValue: number | undefined = undefined;
private target: HTMLElement;
private logicalWidth: number;

private onKeyDown = (e: KeyboardEvent): void => {
if (ACTION_KEYS.has(e.code)) {
e.preventDefault();
this.setDown(true);
} else if (LEFT_KEYS.has(e.code)) {
e.preventDefault();
this.leftDown = true;
} else if (RIGHT_KEYS.has(e.code)) {
e.preventDefault();
this.rightDown = true;
}
};

private onKeyUp = (e: KeyboardEvent): void => {
if (ACTION_KEYS.has(e.code)) {
e.preventDefault();
this.setDown(false);
} else if (LEFT_KEYS.has(e.code)) {
e.preventDefault();
this.leftDown = false;
} else if (RIGHT_KEYS.has(e.code)) {
e.preventDefault();
this.rightDown = false;
}
};

private onPointerDown = (e: PointerEvent): void => {
e.preventDefault();
this.setDown(true);
this.pointerXValue = this.toLocalX(e.clientX);
};

private onPointerMove = (e: PointerEvent): void => {
if (!this.down) return;
this.pointerXValue = this.toLocalX(e.clientX);
};

private onPointerUp = (e: PointerEvent): void => {
e.preventDefault();
this.setDown(false);
this.pointerXValue = undefined;
};

private onTouchStart = (e: TouchEvent): void => {
e.preventDefault();
this.setDown(true);
const touch = e.touches[0];
if (touch) this.pointerXValue = this.toLocalX(touch.clientX);
};

private onTouchMove = (e: TouchEvent): void => {
if (!this.down) return;
const touch = e.touches[0];
if (touch) this.pointerXValue = this.toLocalX(touch.clientX);
};

private onTouchEnd = (e: TouchEvent): void => {
e.preventDefault();
this.setDown(false);
this.pointerXValue = undefined;
};

constructor(target: HTMLElement) {
constructor(target: HTMLElement, initialLogicalWidth?: number) {
this.target = target;
this.logicalWidth = initialLogicalWidth ?? target.getBoundingClientRect().width;
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp);
target.addEventListener('pointerdown', this.onPointerDown);
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp);
target.addEventListener('touchstart', this.onTouchStart, { passive: false });
window.addEventListener('touchmove', this.onTouchMove, { passive: false });
window.addEventListener('touchend', this.onTouchEnd, { passive: false });
}

/** Call whenever the canvas's logical (CSS-pixel) width changes, e.g. on engine resize. */
setLogicalWidth(width: number): void {
this.logicalWidth = width;
}

private toLocalX(clientX: number): number {
const rect = this.target.getBoundingClientRect();
const scaleX = rect.width > 0 ? this.logicalWidth / rect.width : 1;
return (clientX - rect.left) * scaleX;
}

private setDown(down: boolean): void {
if (down && !this.down) {
this.pressedEdge = true;
Expand All @@ -67,9 +118,13 @@ export class InputController {

/** Snapshot of the current input state; clears the "pressed" edge flag. */
getState(): InputState {
const heldDirection: -1 | 0 | 1 =
this.rightDown && !this.leftDown ? 1 : this.leftDown && !this.rightDown ? -1 : 0;
const state: InputState = {
primaryActionDown: this.down,
primaryActionPressed: this.pressedEdge,
heldDirection,
pointerX: this.pointerXValue,
};
this.pressedEdge = false;
return state;
Expand All @@ -79,8 +134,10 @@ export class InputController {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
this.target.removeEventListener('pointerdown', this.onPointerDown);
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
this.target.removeEventListener('touchstart', this.onTouchStart);
window.removeEventListener('touchmove', this.onTouchMove);
window.removeEventListener('touchend', this.onTouchEnd);
}
}
Loading
Loading