Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0c0fb74
Remove jsdoc types from math utils
bengsfort Feb 23, 2025
2cf51a0
Add 2D and 3D vector implementations.
bengsfort Feb 23, 2025
c723b88
Make vec2 and vec3 constructor more robust
bengsfort Feb 24, 2025
d07bbb7
Implement scaffolding for quad tree, spatial hashes and collision pri…
bengsfort Feb 28, 2025
81bf2cf
Implement AABB + point detection
bengsfort Feb 28, 2025
4eacaa6
Split collision handling to 2d and 3d modules, add more implementations
bengsfort Mar 3, 2025
615757e
Continue adding tests for collisions
bengsfort Mar 6, 2025
1fb17cd
Implement closest point on circle and contains point on circle
bengsfort Mar 6, 2025
658349f
Add circle collision
bengsfort Mar 7, 2025
13d3502
Add dot to v2 and v3, cross to v3
bengsfort Mar 9, 2025
7d1cac0
Base ray implementation
bengsfort Mar 12, 2025
5af2cb2
Implement 2d ray collisions
bengsfort Mar 14, 2025
1641318
Scaffold drawables and init sandbox.
bengsfort Mar 14, 2025
81c1d44
Scaffold 2d sandbox renderer
bengsfort Mar 15, 2025
9f1ccb0
Implement customizable unit scaling
bengsfort Mar 15, 2025
5acda8c
Update hierarchy for renderer
bengsfort Mar 15, 2025
ca3c1f6
Scaffold event dispatcher and input manager for sandbox
bengsfort Mar 16, 2025
a3173b4
Implement event dispatcher, adad tests
bengsfort Mar 18, 2025
9c62890
Expose event dispatcher, add documentation.
bengsfort Mar 18, 2025
6d11690
Compositor test
bengsfort Mar 23, 2025
1d2bcbd
Finish base implementation of compositor
bengsfort Mar 23, 2025
da37dac
Migrate to composable renderer
bengsfort Mar 27, 2025
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
5 changes: 5 additions & 0 deletions .changeset/eager-spiders-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bengsfort/stdlib': minor
---

Add vector2 implementation.
5 changes: 5 additions & 0 deletions .changeset/famous-books-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bengsfort/stdlib': minor
---

Added dot and cross product to vector3
5 changes: 5 additions & 0 deletions .changeset/fast-chefs-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bengsfort/stdlib': minor
---

Add vector3 implementation.
5 changes: 5 additions & 0 deletions .changeset/fine-numbers-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bengsfort/stdlib': patch
---

Remove jsdoc types in math utils in favor of ts types.
5 changes: 5 additions & 0 deletions .changeset/little-ghosts-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bengsfort/stdlib': minor
---

Add type-safe EventDispatcher implementation.
5 changes: 5 additions & 0 deletions .changeset/nasty-colts-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bengsfort/stdlib': minor
---

Added dot product to vector2
5 changes: 5 additions & 0 deletions .changeset/true-adults-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bengsfort/stdlib': minor
---

Add support for 2d ray collisions.
Empty file.
5 changes: 5 additions & 0 deletions lib/diagnostics/measure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class Measure {
public readonly id: string;

Check failure on line 2 in lib/diagnostics/measure.ts

View workflow job for this annotation

GitHub Actions / Compile ⚒️

Property 'id' has no initializer and is not definitely assigned in the constructor.
#_start: number;

Check failure on line 3 in lib/diagnostics/measure.ts

View workflow job for this annotation

GitHub Actions / Compile ⚒️

'#_start' is declared but its value is never read.

Check failure on line 3 in lib/diagnostics/measure.ts

View workflow job for this annotation

GitHub Actions / Compile ⚒️

Property '#_start' has no initializer and is not definitely assigned in the constructor.
#_end: number;

Check failure on line 4 in lib/diagnostics/measure.ts

View workflow job for this annotation

GitHub Actions / Compile ⚒️

'#_end' is declared but its value is never read.

Check failure on line 4 in lib/diagnostics/measure.ts

View workflow job for this annotation

GitHub Actions / Compile ⚒️

Property '#_end' has no initializer and is not definitely assigned in the constructor.
}
117 changes: 117 additions & 0 deletions lib/events/__tests__/event-dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';

import { EventDispatcher, EventMap } from '../event-dispatcher.js';

interface TestEventMap extends EventMap {
foo: [value: number, bool: boolean];
bar: [str: string];
gaz: [];
}

describe('EventDispatcher', () => {
it('should expose how many listeners are currently attached', () => {
const dispatch = new EventDispatcher<TestEventMap>();

expect(dispatch.getListenerCount()).toEqual(0);

const onFoo = vi.fn();
const onBar = vi.fn();

dispatch.addListener('foo', onFoo);
dispatch.addListener('bar', onBar);
expect(dispatch.getListenerCount()).toEqual(2);

dispatch.removeListener('foo', onFoo);
expect(dispatch.getListenerCount()).toEqual(1);

dispatch.removeAllListeners();
expect(dispatch.getListenerCount()).toEqual(0);
});

it('should expose the event names that have listeners', () => {
const dispatch = new EventDispatcher<TestEventMap>();

expect(dispatch.getEvents()).toHaveLength(0);

dispatch.addListener('foo', () => {});
expect(dispatch.getEvents()).toContain('foo');

const onGaz = (): void => {};
dispatch.addListener('foo', () => {});
dispatch.addListener('gaz', onGaz);
const withGaz = dispatch.getEvents();

expect(withGaz).toHaveLength(2);
expect(withGaz).toContain('gaz');

dispatch.removeListener('gaz', onGaz);
const withoutGaz = dispatch.getEvents();

expect(withoutGaz).toHaveLength(1);
expect(withoutGaz).not.toContain('gaz');
});

it('should support adding event listeners and triggering events', () => {
const dispatch = new EventDispatcher<TestEventMap>();
const onFoo = vi.fn();

dispatch.addListener('foo', onFoo);
dispatch.trigger('foo', 52, true);
expect(onFoo).toHaveBeenCalledExactlyOnceWith(52, true);
});

it('should not call a listener after removing it', () => {
const dispatch = new EventDispatcher<TestEventMap>();
const onFoo = vi.fn();

dispatch.addListener('foo', onFoo);
dispatch.trigger('foo', 52, true);
dispatch.removeListener('foo', onFoo);
dispatch.trigger('foo', 5, false);

expect(onFoo).toHaveBeenCalledExactlyOnceWith(52, true);
});

it('should only call listeners for the event that is being triggered', () => {
const dispatch = new EventDispatcher<TestEventMap>();
const onFoo = vi.fn();
const onBar = vi.fn();

dispatch.addListener('foo', onFoo);
dispatch.addListener('bar', onBar);

dispatch.trigger('foo', 52, true);
dispatch.trigger('bar', 'triggered');

expect(onFoo).toHaveBeenCalledExactlyOnceWith(52, true);
expect(onBar).toHaveBeenCalledExactlyOnceWith('triggered');
});

it('should support removing a listener after one event', () => {
const dispatch = new EventDispatcher<TestEventMap>();
const onFoo = vi.fn();

dispatch.addListener('foo', onFoo, true);
expect(dispatch.getListenerCount()).toEqual(1);

dispatch.trigger('foo', 52, true);
dispatch.trigger('foo', 30, false);
expect(onFoo).toHaveBeenCalledExactlyOnceWith(52, true);

expect(dispatch.getListenerCount()).toEqual(0);
});

it('should support removing a listener via the unsub callback', () => {
const dispatch = new EventDispatcher<TestEventMap>();
const onFoo = vi.fn();

const unsub = dispatch.addListener('foo', onFoo);
expect(dispatch.getListenerCount()).toEqual(1);
dispatch.trigger('foo', 52, true);

unsub();
expect(dispatch.getListenerCount()).toEqual(0);
dispatch.trigger('foo', 30, false);
expect(onFoo).toHaveBeenCalledExactlyOnceWith(52, true);
});
});
188 changes: 188 additions & 0 deletions lib/events/event-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
export type EventMap = object;
export type EventType<Map extends EventMap> = keyof Map;
export type EventPayload<
Map extends EventMap,
Key extends EventType<Map> = EventType<Map>,
> = Map[Key] extends unknown[] ? Map[Key] : unknown[];
export type EventListener<
Map extends EventMap,
Key extends EventType<Map> = EventType<Map>,
> = (...args: EventPayload<Map, Key>) => void | Promise<void>;

type LazyListenerMap<Map extends EventMap> = {
[K in keyof Map]?: Set<EventListener<Map, K>>;
};

type UnsubListener = () => void;

/**
* Event Dispatcher with support for full listener and payload type safety.
*
* Instantiate or type with an event map that contains an array of arguments for
* each key. The keys become the events and their associated array becomes the
* event payload. Event Maps should extend the `EventMap` type.
*
* @example
* ```
* interface ExampleMap extends EventMap {
* foo: [num: number, bool: boolean];
* }
*
* const dispatcher = new EventDispatcher<ExampleMap>();
* dispatcher.addListener('foo', (num, bool) => {});
* dispatcher.trigger('foo', 500, true);
* ```
*/
export class EventDispatcher<Map extends EventMap> {
#_listeners: LazyListenerMap<Map> = {};
#_autoRemoveListeners: LazyListenerMap<Map> = {};

#_listenerCount = 0;
#_events = new Set<EventType<Map>>();

/**
* Adds a listener for the given event. If `once` is set to true, the listener
* will be removed automatically after the first time the listener is triggered.
* Returns a callback that can be used to remove the listener without needing
* to call `removeListener`.
*
* @param event The event name to listen for.
* @param listener The callback to trigger once the event triggers.
* @param once If the listener should be removed after first invocation.
* @returns A callback to remove the listener.
*/
public addListener<Event extends EventType<Map> = EventType<Map>>(
event: Event,
listener: EventListener<Map, Event>,
once?: boolean,
): UnsubListener {
// Lazy-instantiate the listener set.
let listeners = this.#_listeners[event];
if (!listeners) {
listeners = new Set();
this.#_listeners[event] = listeners;
this.#_events.add(event);
}

const unsub: UnsubListener = () => {
this.removeListener(event, listener);
};

// Only add the listener if we don't already have it.
if (listeners.has(listener)) {
return unsub;
}

this.#_listenerCount++;
listeners.add(listener);

// Cache the listener reference to the auto remove set so we can remove it
// after it triggers for the first time.
if (once) {
let autoRemoveListeners = this.#_autoRemoveListeners[event];

if (!autoRemoveListeners) {
autoRemoveListeners = new Set();
this.#_autoRemoveListeners[event] = autoRemoveListeners;
}

autoRemoveListeners.add(listener);
}

return unsub;
}

/**
* Removes a given listener from the event dispatcher. The listener provided
* **MUST BE THE SAME REFERENCE**, otherwise the listener will not be
* removed.
*
* @param event The event the listener was registered to.
* @param listener The listener that was registered.
*/
public removeListener<Event extends EventType<Map> = EventType<Map>>(
event: Event,
listener: EventListener<Map, Event>,
): void {
const listeners = this.#_listeners[event];
if (listeners?.delete(listener)) {
this.#_listenerCount = Math.max(this.#_listenerCount - 1, 0);
}

const autoRemoveListeners = this.#_autoRemoveListeners[event];
autoRemoveListeners?.delete(listener);

this.#_removeEmptyEvent(event);
}

/**
* Removes all listeners that have been registered on this event dispatcher,
* and resets all internal dispatcher state.
*/
public removeAllListeners(): void {
for (const event in this.#_listeners) {
this.#_listeners[event]?.clear();
}

for (const event in this.#_autoRemoveListeners) {
this.#_autoRemoveListeners[event]?.clear();
}

this.#_autoRemoveListeners = {};
this.#_listeners = {};

this.#_events.clear();
this.#_listenerCount = 0;
}

/**
* Triggers an event dispatch, calling all registered listeners for the given
* event with the provided payload.
*
* @param event The event to trigger.
* @param ...payload The payload to pass to all listeners.
*/
public trigger<Event extends EventType<Map> = EventType<Map>>(
event: Event,
...payload: EventPayload<Map, Event>
): void {
const listeners = this.#_listeners[event];

listeners?.forEach((listener) => {
void listener(...payload);

if (this.#_autoRemoveListeners[event]?.has(listener)) {
this.removeListener(event, listener);
}
});
}

/**
* Returns the current number of registered event listeners on this dispatcher.
* @returns The total registered event listeners.
*/
public getListenerCount(): number {
return this.#_listenerCount;
}

/**
* Returns a list of the events that have listeners registered on this dispatcher.
* @returns The events with registered listeners.
*/
public getEvents(): EventType<Map>[] {
return [...this.#_events];
}

#_removeEmptyEvent(event: EventType<Map>): void {
if ((this.#_listeners[event]?.size ?? 0) < 1) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.#_listeners[event];
this.#_events.delete(event);
}

if ((this.#_autoRemoveListeners[event]?.size ?? 0) < 1) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.#_autoRemoveListeners[event];
}
}
}
Loading