Skip to content

How to add InputState + AssetCache as Resources and use them in systems

Goal

Store Input state and an Asset cache as world Resources, then access them inside systems using requireResource().

Example InputStateRes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
export class InputStateRes
{
    public keysDown = new Set<string>();
    public keysPressed = new Set<string>();   // pressed this frame
    public keysReleased = new Set<string>();  // released this frame

    public mouseX = 0;
    public mouseY = 0;
    public mouseButtonsDown = new Set<number>();
    public mousePressed = new Set<number>();   // pressed this frame
    public mouseReleased = new Set<number>();  // released this frame
    public wheelDeltaY = 0;

    beginFrame(): void
    {
        this.keysPressed.clear();
        this.keysReleased.clear();
        this.mousePressed.clear();
        this.mouseReleased.clear();
        this.wheelDeltaY = 0;
    }

    keyDown(code: string): void
    {
        if (!this.keysDown.has(code)) this.keysPressed.add(code);
        this.keysDown.add(code);
    }

    keyUp(code: string): void
    {
        if (this.keysDown.has(code)) this.keysReleased.add(code);
        this.keysDown.delete(code);
    }

    mouseMove(x: number, y: number): void {
        this.mouseX = x;
        this.mouseY = y;
    }

    mouseDown(btn: number): void
    {
        if (!this.mouseButtonsDown.has(btn)) this.mousePressed.add(btn);
        this.mouseButtonsDown.add(btn);
    }

    mouseUp(btn: number): void
    {
        if (this.mouseButtonsDown.has(btn)) this.mouseReleased.add(btn);
        this.mouseButtonsDown.delete(btn);
    }

    wheel(deltaY: number): void
    {
        this.wheelDeltaY += deltaY;
    }
}

Example AssetCacheRes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export class AssetCacheRes
{
    private images = new Map<string, HTMLImageElement>();
    private pending = new Map<string, Promise<HTMLImageElement>>();

    /** Loads once, dedupes concurrent calls, returns the same instance thereafter. */
    public getImage(url: string): Promise<HTMLImageElement>
    {
        const ready = this.images.get(url);
        if (ready) return Promise.resolve(ready);

        const p = this.pending.get(url);
        if (p) return p;

        const promise = new Promise<HTMLImageElement>((resolve, reject) => {
            const img = new Image();
            img.onload = () => {
                this.images.set(url, img);
                this.pending.delete(url);
                resolve(img);
            };
            img.onerror = (e) => {
                this.pending.delete(url);
                reject(e);
            };
            img.src = url;
        });

        this.pending.set(url, promise);
        return promise;
    }

    /** Returns the image if already loaded; otherwise undefined. */
    public peekImage(url: string): HTMLImageElement | undefined {
        return this.images.get(url);
    }
}

1) Register the resources at startup

1
2
world.initResource(InputStateRes, () => new InputStateRes());
world.initResource(AssetCacheRes, () => new AssetCacheRes());

That’s the only “required” setup. Everything else assumes these exist.


2) Wire DOM events into InputStateRes

Attach listeners once:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export function attachInput(world: WorldApi): void
{
    const input = world.requireResource(InputStateRes);

    window.addEventListener("keydown", e => input.keyDown(e.code));
    window.addEventListener("keyup",   e => input.keyUp(e.code));
    window.addEventListener("mousemove", e => input.mouseMove(e.clientX, e.clientY));
    window.addEventListener("mousedown", e => input.mouseDown(e.button));
    window.addEventListener("mouseup",   e => input.mouseUp(e.button));
    window.addEventListener("wheel",     e => input.wheel(e.deltaY), { passive: true });
}

Call it after initResource(...).


3) Reset “pressed/released” flags once per frame

Add a phase/system that runs before gameplay update:

1
2
3
4
export function beginFrameSystem(w: WorldApi, _dt: number): void
{
    w.requireResource(InputStateRes).beginFrame();
}

4) Read input from systems

Example “move player” system:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
export function playerMoveSystem(w: WorldApi, dt: number): void
{
    const input = w.requireResource(InputStateRes);

    let dx = 0, dy = 0;
    if (input.keysDown.has("KeyW")) dy -= 1;
    if (input.keysDown.has("KeyS")) dy += 1;
    if (input.keysDown.has("KeyA")) dx -= 1;
    if (input.keysDown.has("KeyD")) dx += 1;

    const speed = 220;

    for (const { c1: tr } of w.query(Transform, PlayerTag)) {
        tr.x += dx * speed * dt;
        tr.y += dy * speed * dt;
    }
}

5) Use AssetCacheRes in a render system (deduped async loads)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export function renderSpritesSystem(ctx: CanvasRenderingContext2D)
{
    return (w: WorldApi, _dt: number): void => {
        const assets = w.requireResource(AssetCacheRes);

        for (const { c1: tr, c2: sp } of w.query(Transform, Sprite)) {
            assets.getImage(sp.url).catch(() => {});
            const img = assets.peekImage(sp.url);
            if (!img) continue;

            ctx.drawImage(img, tr.x, tr.y, sp.w, sp.h);
        }
    };
}

6) Run phases in order

Minimal schedule:

1
2
3
sched.add("beginFrame", beginFrameSystem);
sched.add("update", playerMoveSystem);
sched.add("render", renderSpritesSystem(ctx));

Game loop:

1
sched.run(world, dt, ["beginFrame", "update", "render"]);

Common variations

Optional resource usage

If a resource is optional (debug/editor), use:

1
2
const dbg = w.getResource(DebugRes);
if (dbg) dbg.enabled = true;

Preload assets (menu/loading screen)

1
await Promise.all(urls.map(u => world.requireResource(AssetCacheRes).getImage(u)));