murow 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/dist/core/binary-codec/binary-codec.d.ts +159 -0
- package/dist/core/binary-codec/binary-codec.js +336 -0
- package/dist/core/binary-codec/index.d.ts +1 -0
- package/dist/core/binary-codec/index.js +1 -0
- package/dist/core/events/event-system.d.ts +71 -0
- package/dist/core/events/event-system.js +88 -0
- package/dist/core/events/index.d.ts +1 -0
- package/dist/core/events/index.js +1 -0
- package/dist/core/fixed-ticker/fixed-ticker.d.ts +105 -0
- package/dist/core/fixed-ticker/fixed-ticker.js +91 -0
- package/dist/core/fixed-ticker/index.d.ts +1 -0
- package/dist/core/fixed-ticker/index.js +1 -0
- package/dist/core/generate-id/generate-id.d.ts +21 -0
- package/dist/core/generate-id/generate-id.js +25 -0
- package/dist/core/generate-id/index.d.ts +1 -0
- package/dist/core/generate-id/index.js +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.js +8 -0
- package/dist/core/lerp/index.d.ts +1 -0
- package/dist/core/lerp/index.js +1 -0
- package/dist/core/lerp/lerp.d.ts +40 -0
- package/dist/core/lerp/lerp.js +42 -0
- package/dist/core/navmesh/index.d.ts +1 -0
- package/dist/core/navmesh/index.js +1 -0
- package/dist/core/navmesh/navmesh.d.ts +116 -0
- package/dist/core/navmesh/navmesh.js +666 -0
- package/dist/core/pooled-codec/index.d.ts +1 -0
- package/dist/core/pooled-codec/index.js +1 -0
- package/dist/core/pooled-codec/pooled-codec.d.ts +140 -0
- package/dist/core/pooled-codec/pooled-codec.js +213 -0
- package/dist/core/prediction/index.d.ts +1 -0
- package/dist/core/prediction/index.js +1 -0
- package/dist/core/prediction/prediction.d.ts +64 -0
- package/dist/core/prediction/prediction.js +90 -0
- package/dist/core.esm.js +1 -0
- package/dist/core.js +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +18 -0
- package/dist/protocol/index.d.ts +43 -0
- package/dist/protocol/index.js +43 -0
- package/dist/protocol/intent/index.d.ts +39 -0
- package/dist/protocol/intent/index.js +38 -0
- package/dist/protocol/intent/intent-registry.d.ts +54 -0
- package/dist/protocol/intent/intent-registry.js +73 -0
- package/dist/protocol/intent/intent.d.ts +12 -0
- package/dist/protocol/intent/intent.js +1 -0
- package/dist/protocol/snapshot/index.d.ts +44 -0
- package/dist/protocol/snapshot/index.js +43 -0
- package/dist/protocol/snapshot/snapshot-codec.d.ts +48 -0
- package/dist/protocol/snapshot/snapshot-codec.js +56 -0
- package/dist/protocol/snapshot/snapshot-registry.d.ts +100 -0
- package/dist/protocol/snapshot/snapshot-registry.js +136 -0
- package/dist/protocol/snapshot/snapshot.d.ts +19 -0
- package/dist/protocol/snapshot/snapshot.js +30 -0
- package/package.json +54 -0
- package/src/core/binary-codec/README.md +60 -0
- package/src/core/binary-codec/binary-codec.test.ts +300 -0
- package/src/core/binary-codec/binary-codec.ts +430 -0
- package/src/core/binary-codec/index.ts +1 -0
- package/src/core/events/README.md +47 -0
- package/src/core/events/event-system.test.ts +243 -0
- package/src/core/events/event-system.ts +140 -0
- package/src/core/events/index.ts +1 -0
- package/src/core/fixed-ticker/README.md +77 -0
- package/src/core/fixed-ticker/fixed-ticker.test.ts +151 -0
- package/src/core/fixed-ticker/fixed-ticker.ts +158 -0
- package/src/core/fixed-ticker/index.ts +1 -0
- package/src/core/generate-id/README.md +18 -0
- package/src/core/generate-id/generate-id.test.ts +79 -0
- package/src/core/generate-id/generate-id.ts +37 -0
- package/src/core/generate-id/index.ts +1 -0
- package/src/core/index.ts +8 -0
- package/src/core/lerp/README.md +79 -0
- package/src/core/lerp/index.ts +1 -0
- package/src/core/lerp/lerp.test.ts +90 -0
- package/src/core/lerp/lerp.ts +42 -0
- package/src/core/navmesh/README.md +124 -0
- package/src/core/navmesh/index.ts +1 -0
- package/src/core/navmesh/navmesh.test.ts +344 -0
- package/src/core/navmesh/navmesh.ts +850 -0
- package/src/core/pooled-codec/README.md +70 -0
- package/src/core/pooled-codec/index.ts +1 -0
- package/src/core/pooled-codec/pooled-codec.test.ts +349 -0
- package/src/core/pooled-codec/pooled-codec.ts +239 -0
- package/src/core/prediction/README.md +64 -0
- package/src/core/prediction/index.ts +1 -0
- package/src/core/prediction/prediction.test.ts +422 -0
- package/src/core/prediction/prediction.ts +101 -0
- package/src/index.ts +20 -0
- package/src/protocol/README.md +310 -0
- package/src/protocol/index.ts +44 -0
- package/src/protocol/intent/index.ts +40 -0
- package/src/protocol/intent/intent-registry.test.ts +237 -0
- package/src/protocol/intent/intent-registry.ts +88 -0
- package/src/protocol/intent/intent.ts +12 -0
- package/src/protocol/snapshot/index.ts +45 -0
- package/src/protocol/snapshot/snapshot-codec.test.ts +138 -0
- package/src/protocol/snapshot/snapshot-codec.ts +71 -0
- package/src/protocol/snapshot/snapshot-registry.test.ts +302 -0
- package/src/protocol/snapshot/snapshot-registry.ts +162 -0
- package/src/protocol/snapshot/snapshot.test.ts +76 -0
- package/src/protocol/snapshot/snapshot.ts +41 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, expect, test, spyOn } from "bun:test";
|
|
2
|
+
import { EventSystem } from "./event-system";
|
|
3
|
+
|
|
4
|
+
type TestEvents = [
|
|
5
|
+
["userJoined", { userId: string; name: string }],
|
|
6
|
+
["userLeft", { userId: string }],
|
|
7
|
+
["messageReceived", { from: string; message: string }],
|
|
8
|
+
["scoreUpdated", { playerId: string; score: number }]
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe("EventSystem", () => {
|
|
12
|
+
test("should initialize with provided events", () => {
|
|
13
|
+
const events = new EventSystem<TestEvents>({
|
|
14
|
+
events: ["userJoined", "userLeft"],
|
|
15
|
+
});
|
|
16
|
+
expect(events).toBeDefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("should register and call event callback", () => {
|
|
20
|
+
const events = new EventSystem<TestEvents>({
|
|
21
|
+
events: ["userJoined"],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let called = false;
|
|
25
|
+
events.on("userJoined", (data) => {
|
|
26
|
+
called = true;
|
|
27
|
+
expect(data.userId).toBe("123");
|
|
28
|
+
expect(data.name).toBe("Alice");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
events.emit("userJoined", { userId: "123", name: "Alice" });
|
|
32
|
+
expect(called).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("should call multiple callbacks for same event", () => {
|
|
36
|
+
const events = new EventSystem<TestEvents>({
|
|
37
|
+
events: ["userJoined"],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let count = 0;
|
|
41
|
+
events.on("userJoined", () => count++);
|
|
42
|
+
events.on("userJoined", () => count++);
|
|
43
|
+
events.on("userJoined", () => count++);
|
|
44
|
+
|
|
45
|
+
events.emit("userJoined", { userId: "123", name: "Bob" });
|
|
46
|
+
expect(count).toBe(3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("should handle once() callback that runs only once", () => {
|
|
50
|
+
const events = new EventSystem<TestEvents>({
|
|
51
|
+
events: ["messageReceived"],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let count = 0;
|
|
55
|
+
events.once("messageReceived", () => count++);
|
|
56
|
+
|
|
57
|
+
events.emit("messageReceived", { from: "Alice", message: "Hello" });
|
|
58
|
+
events.emit("messageReceived", { from: "Bob", message: "Hi" });
|
|
59
|
+
events.emit("messageReceived", { from: "Charlie", message: "Hey" });
|
|
60
|
+
|
|
61
|
+
expect(count).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("should remove callback with off()", () => {
|
|
65
|
+
const events = new EventSystem<TestEvents>({
|
|
66
|
+
events: ["scoreUpdated"],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let count = 0;
|
|
70
|
+
const callback = () => count++;
|
|
71
|
+
|
|
72
|
+
events.on("scoreUpdated", callback);
|
|
73
|
+
events.emit("scoreUpdated", { playerId: "p1", score: 100 });
|
|
74
|
+
expect(count).toBe(1);
|
|
75
|
+
|
|
76
|
+
events.off("scoreUpdated", callback);
|
|
77
|
+
events.emit("scoreUpdated", { playerId: "p1", score: 200 });
|
|
78
|
+
expect(count).toBe(1); // Should still be 1
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("should clear specific event callbacks", () => {
|
|
82
|
+
const events = new EventSystem<TestEvents>({
|
|
83
|
+
events: ["userJoined", "userLeft"],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let joinCount = 0;
|
|
87
|
+
let leaveCount = 0;
|
|
88
|
+
|
|
89
|
+
events.on("userJoined", () => joinCount++);
|
|
90
|
+
events.on("userJoined", () => joinCount++);
|
|
91
|
+
events.on("userLeft", () => leaveCount++);
|
|
92
|
+
|
|
93
|
+
events.clear("userJoined");
|
|
94
|
+
|
|
95
|
+
events.emit("userJoined", { userId: "123", name: "Alice" });
|
|
96
|
+
events.emit("userLeft", { userId: "123" });
|
|
97
|
+
|
|
98
|
+
expect(joinCount).toBe(0);
|
|
99
|
+
expect(leaveCount).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("should clear all event callbacks", () => {
|
|
103
|
+
const events = new EventSystem<TestEvents>({
|
|
104
|
+
events: ["userJoined", "userLeft", "messageReceived"],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let count = 0;
|
|
108
|
+
events.on("userJoined", () => count++);
|
|
109
|
+
events.on("userLeft", () => count++);
|
|
110
|
+
events.on("messageReceived", () => count++);
|
|
111
|
+
|
|
112
|
+
events.clear();
|
|
113
|
+
|
|
114
|
+
events.emit("userJoined", { userId: "123", name: "Alice" });
|
|
115
|
+
events.emit("userLeft", { userId: "123" });
|
|
116
|
+
events.emit("messageReceived", { from: "Alice", message: "Hi" });
|
|
117
|
+
|
|
118
|
+
expect(count).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("should warn when registering callback for non-existent event", () => {
|
|
122
|
+
const events = new EventSystem<TestEvents>({
|
|
123
|
+
events: ["userJoined"],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const consoleWarn = spyOn(console, "warn");
|
|
127
|
+
// @ts-expect-error Testing invalid event name
|
|
128
|
+
events.on("nonExistent", () => {});
|
|
129
|
+
expect(consoleWarn).toHaveBeenCalledWith('Event "nonExistent" does not exist.');
|
|
130
|
+
consoleWarn.mockRestore();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("should warn when emitting non-existent event", () => {
|
|
134
|
+
const events = new EventSystem<TestEvents>({
|
|
135
|
+
events: ["userJoined"],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const consoleWarn = spyOn(console, "warn");
|
|
139
|
+
// @ts-expect-error Testing invalid event name
|
|
140
|
+
events.emit("nonExistent", {});
|
|
141
|
+
expect(consoleWarn).toHaveBeenCalledWith('Event "nonExistent" does not exist.');
|
|
142
|
+
consoleWarn.mockRestore();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("should pass correct data to callbacks", () => {
|
|
146
|
+
const events = new EventSystem<TestEvents>({
|
|
147
|
+
events: ["scoreUpdated"],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const receivedData: Array<{ playerId: string; score: number }> = [];
|
|
151
|
+
events.on("scoreUpdated", (data) => receivedData.push(data));
|
|
152
|
+
|
|
153
|
+
events.emit("scoreUpdated", { playerId: "p1", score: 100 });
|
|
154
|
+
events.emit("scoreUpdated", { playerId: "p2", score: 200 });
|
|
155
|
+
|
|
156
|
+
expect(receivedData).toEqual([
|
|
157
|
+
{ playerId: "p1", score: 100 },
|
|
158
|
+
{ playerId: "p2", score: 200 },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("should handle callbacks that throw errors", () => {
|
|
163
|
+
const events = new EventSystem<TestEvents>({
|
|
164
|
+
events: ["userJoined"],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
let successCount = 0;
|
|
168
|
+
events.on("userJoined", () => {
|
|
169
|
+
throw new Error("Callback error");
|
|
170
|
+
});
|
|
171
|
+
events.on("userJoined", () => successCount++);
|
|
172
|
+
|
|
173
|
+
expect(() =>
|
|
174
|
+
events.emit("userJoined", { userId: "123", name: "Alice" })
|
|
175
|
+
).toThrow();
|
|
176
|
+
|
|
177
|
+
// First callback threw, so second wasn't reached
|
|
178
|
+
expect(successCount).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("should maintain callback order", () => {
|
|
182
|
+
const events = new EventSystem<TestEvents>({
|
|
183
|
+
events: ["messageReceived"],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const order: number[] = [];
|
|
187
|
+
events.on("messageReceived", () => order.push(1));
|
|
188
|
+
events.on("messageReceived", () => order.push(2));
|
|
189
|
+
events.on("messageReceived", () => order.push(3));
|
|
190
|
+
|
|
191
|
+
events.emit("messageReceived", { from: "Test", message: "Hello" });
|
|
192
|
+
expect(order).toEqual([1, 2, 3]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("should allow same callback to be registered multiple times", () => {
|
|
196
|
+
const events = new EventSystem<TestEvents>({
|
|
197
|
+
events: ["userJoined"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let count = 0;
|
|
201
|
+
const callback = () => count++;
|
|
202
|
+
|
|
203
|
+
events.on("userJoined", callback);
|
|
204
|
+
events.on("userJoined", callback);
|
|
205
|
+
|
|
206
|
+
events.emit("userJoined", { userId: "123", name: "Alice" });
|
|
207
|
+
// Set only stores unique callbacks, so it should be called once
|
|
208
|
+
expect(count).toBe(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("should handle rapid event emissions", () => {
|
|
212
|
+
const events = new EventSystem<TestEvents>({
|
|
213
|
+
events: ["scoreUpdated"],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
let count = 0;
|
|
217
|
+
events.on("scoreUpdated", () => count++);
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < 1000; i++) {
|
|
220
|
+
events.emit("scoreUpdated", { playerId: "p1", score: i });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
expect(count).toBe(1000);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("should handle once() with multiple callbacks", () => {
|
|
227
|
+
const events = new EventSystem<TestEvents>({
|
|
228
|
+
events: ["userJoined"],
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
let count1 = 0;
|
|
232
|
+
let count2 = 0;
|
|
233
|
+
|
|
234
|
+
events.once("userJoined", () => count1++);
|
|
235
|
+
events.on("userJoined", () => count2++);
|
|
236
|
+
|
|
237
|
+
events.emit("userJoined", { userId: "123", name: "Alice" });
|
|
238
|
+
events.emit("userJoined", { userId: "456", name: "Bob" });
|
|
239
|
+
|
|
240
|
+
expect(count1).toBe(1);
|
|
241
|
+
expect(count2).toBe(2);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* ---------- Types ---------- */
|
|
2
|
+
type Callback<Props> = (props: Props) => void;
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
type EventMapFromTuple<T extends [string, unknown][]> = {
|
|
6
|
+
[K in T[number]as K[0]]: K[1];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/* ---------- Interfaces ---------- */
|
|
10
|
+
interface EventSystemProps<EventNames extends string> {
|
|
11
|
+
/**
|
|
12
|
+
* @description
|
|
13
|
+
* The list of events to ever be registered.
|
|
14
|
+
*/
|
|
15
|
+
events: EventNames[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @description
|
|
20
|
+
* A callback-based event handling system designed to simplify
|
|
21
|
+
* event-driven programming.
|
|
22
|
+
*/
|
|
23
|
+
export class EventSystem<EventTuple extends [string, unknown][]> {
|
|
24
|
+
/**
|
|
25
|
+
* @private
|
|
26
|
+
* @description
|
|
27
|
+
* The map of registered events and their callbacks.
|
|
28
|
+
*/
|
|
29
|
+
private callbacks: Map<string, Set<Callback<unknown>>>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @private
|
|
33
|
+
* @description
|
|
34
|
+
* The list of events that were registered.
|
|
35
|
+
*/
|
|
36
|
+
private events: string[];
|
|
37
|
+
|
|
38
|
+
constructor({ events }: EventSystemProps<string>) {
|
|
39
|
+
this.callbacks = new Map();
|
|
40
|
+
this.events = events;
|
|
41
|
+
|
|
42
|
+
for (const name of this.events) {
|
|
43
|
+
this.callbacks.set(name, new Set());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @description
|
|
49
|
+
* Registers a callback for an event.
|
|
50
|
+
*
|
|
51
|
+
* @param name Event name
|
|
52
|
+
* @param callback Callback to run when the event is emitted
|
|
53
|
+
*/
|
|
54
|
+
on<EventName extends keyof EventMapFromTuple<EventTuple> & string>(
|
|
55
|
+
name: EventName,
|
|
56
|
+
callback: Callback<EventMapFromTuple<EventTuple>[EventName]>
|
|
57
|
+
): void {
|
|
58
|
+
const event = this.callbacks.get(name) as Set<Callback<EventMapFromTuple<EventTuple>[EventName]>> | undefined;
|
|
59
|
+
if (!event) return console.warn(`Event "${name}" does not exist.`);
|
|
60
|
+
|
|
61
|
+
event.add(callback);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @description
|
|
66
|
+
* Registers a callback for an event that runs only once.
|
|
67
|
+
*
|
|
68
|
+
* @param name Event name
|
|
69
|
+
* @param callback Callback to run when the event is emitted
|
|
70
|
+
*/
|
|
71
|
+
once<EventName extends keyof EventMapFromTuple<EventTuple> & string>(
|
|
72
|
+
name: EventName,
|
|
73
|
+
callback: Callback<EventMapFromTuple<EventTuple>[EventName]>
|
|
74
|
+
): void {
|
|
75
|
+
const wrapper: Callback<EventMapFromTuple<EventTuple>[EventName]> = (props) => {
|
|
76
|
+
callback(props);
|
|
77
|
+
this.off(name, wrapper);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.on(name, wrapper);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @description
|
|
85
|
+
* Emits an event, running all registered callbacks.
|
|
86
|
+
*
|
|
87
|
+
* @param name Event name
|
|
88
|
+
* @param data Event data
|
|
89
|
+
*/
|
|
90
|
+
emit<EventName extends keyof EventMapFromTuple<EventTuple> & string>(
|
|
91
|
+
name: EventName,
|
|
92
|
+
data: EventMapFromTuple<EventTuple>[EventName]
|
|
93
|
+
) {
|
|
94
|
+
const event = this.callbacks.get(name);
|
|
95
|
+
if (!event) return console.warn(`Event "${name}" does not exist.`);
|
|
96
|
+
|
|
97
|
+
for (const callback of event) {
|
|
98
|
+
callback(data);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @description
|
|
104
|
+
* Removes a callback from an event.
|
|
105
|
+
*
|
|
106
|
+
* @param name Event name
|
|
107
|
+
* @param callback Callback to remove
|
|
108
|
+
*/
|
|
109
|
+
off<EventName extends keyof EventMapFromTuple<EventTuple> & string>(
|
|
110
|
+
name: EventName,
|
|
111
|
+
callback: Callback<EventMapFromTuple<EventTuple>[EventName]>
|
|
112
|
+
): void {
|
|
113
|
+
const event = this.callbacks.get(name) as Set<Callback<EventMapFromTuple<EventTuple>[EventName]>> | undefined;
|
|
114
|
+
if (!event) return console.warn(`Event "${name}" does not exist.`);
|
|
115
|
+
|
|
116
|
+
event.delete(callback);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @description
|
|
121
|
+
* Removes all callbacks.
|
|
122
|
+
*
|
|
123
|
+
* @param name Optional event name
|
|
124
|
+
*/
|
|
125
|
+
clear<EventName extends keyof EventMapFromTuple<EventTuple> & string>(name?: EventName): void {
|
|
126
|
+
if (!name) {
|
|
127
|
+
this.callbacks.clear();
|
|
128
|
+
for (const name of this.events) {
|
|
129
|
+
this.callbacks.set(name, new Set());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const event = this.callbacks.get(name);
|
|
136
|
+
if (!event) return console.warn(`Event "${name}" does not exist.`);
|
|
137
|
+
|
|
138
|
+
event.clear();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './event-system';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# FixedTicker
|
|
2
|
+
|
|
3
|
+
`FixedTicker` is a utility class for managing fixed-rate update ticks in JavaScript/TypeScript applications. It is designed for deterministic game loops, working both in the browser (e.g., with Pixi.js) and on Node.js servers.
|
|
4
|
+
|
|
5
|
+
This class ensures that updates run at a fixed timestep, which is essential for predictable, lockstep multiplayer simulations.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Fixed-timestep updates with configurable tick rate.
|
|
12
|
+
- Accumulates elapsed time and determines how many ticks to process per frame.
|
|
13
|
+
- Limits the maximum number of ticks per frame to prevent runaway loops.
|
|
14
|
+
- Provides a tick count for deterministic simulation.
|
|
15
|
+
- Optional callback for detecting skipped ticks (useful for debugging or network reconciliation).
|
|
16
|
+
- Compatible with both client and server environments.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { FixedTicker } from './fixed-ticker';
|
|
22
|
+
|
|
23
|
+
const ticker = new FixedTicker({
|
|
24
|
+
rate: 30, // ticks per second
|
|
25
|
+
onTick: (deltaTime, tick) => {
|
|
26
|
+
// Your fixed-step game logic here
|
|
27
|
+
// deltaTime = 1 / rate
|
|
28
|
+
console.log(`Tick ${tick} - dt: ${deltaTime}`);
|
|
29
|
+
},
|
|
30
|
+
onTickSkipped: (skipped) => {
|
|
31
|
+
console.warn(`Skipped ${skipped} ticks due to high delta time`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Call once per frame, passing elapsed time in seconds
|
|
36
|
+
function gameLoop(deltaTime: number) {
|
|
37
|
+
ticker.tick(deltaTime);
|
|
38
|
+
|
|
39
|
+
// Optional: interpolate visuals using accumulatedTime
|
|
40
|
+
const alpha = ticker.accumulatedTime / (1 / 30); // for smooth rendering
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
- This class does not automatically guarantee determinism; your game logic must also be deterministic (e.g., using seeded random numbers and consistent math operations).
|
|
47
|
+
- For lockstep multiplayer, always use the tickCount to synchronize inputs and simulation steps.
|
|
48
|
+
- Use accumulatedTime for smooth client-side rendering between fixed ticks.
|
|
49
|
+
|
|
50
|
+
## Example: Deterministic Multiplayer Loop
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
const ticker = new FixedTicker({
|
|
54
|
+
rate: 12, // will fire the onTick callback 12 times per second
|
|
55
|
+
onTick: (deltaTime, tick) => {
|
|
56
|
+
applyInputsForTick(tick);
|
|
57
|
+
updateSimulation(deltaTime);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// in browser
|
|
62
|
+
function frame(deltaTime: number) {
|
|
63
|
+
ticker.tick(deltaTime);
|
|
64
|
+
render(interpolate(ticker.accumulatedTime));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// in Node.js
|
|
68
|
+
setInterval(() => {
|
|
69
|
+
const deltaTime = 1 / ticker.rate; // fixed timestep
|
|
70
|
+
ticker.tick(deltaTime);
|
|
71
|
+
updateSimulation(deltaTime);
|
|
72
|
+
}, 1000 / ticker.rate);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
`FixedTicker` provides a reliable, minimal, and deterministic foundation for game loops and simulations where consistent, fixed-step updates are crucial.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { FixedTicker } from "./fixed-ticker";
|
|
3
|
+
|
|
4
|
+
describe("FixedTicker", () => {
|
|
5
|
+
test("should initialize with correct rate", () => {
|
|
6
|
+
const ticker = new FixedTicker({ rate: 60, onTick: () => { } });
|
|
7
|
+
expect(ticker.rate).toBe(60);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("should call onTick with correct deltaTime", () => {
|
|
11
|
+
let calledDelta = 0;
|
|
12
|
+
const ticker = new FixedTicker({
|
|
13
|
+
rate: 60,
|
|
14
|
+
onTick: (dt) => {
|
|
15
|
+
calledDelta = dt;
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
ticker.tick(1 / 60);
|
|
20
|
+
expect(calledDelta).toBeCloseTo(1 / 60, 5);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("should accumulate time and execute multiple ticks", () => {
|
|
24
|
+
let tickCount = 0;
|
|
25
|
+
const ticker = new FixedTicker({
|
|
26
|
+
rate: 60,
|
|
27
|
+
onTick: () => {
|
|
28
|
+
tickCount++;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Simulate 3 frames worth of time (add small epsilon for floating point)
|
|
33
|
+
ticker.tick(3.01 / 60);
|
|
34
|
+
expect(tickCount).toBe(3);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("should track tick count correctly", () => {
|
|
38
|
+
const ticker = new FixedTicker({
|
|
39
|
+
rate: 60,
|
|
40
|
+
onTick: () => { },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
ticker.tick(5.01 / 60);
|
|
44
|
+
expect(ticker.tickCount).toBe(5);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("should reset tick count", () => {
|
|
48
|
+
const ticker = new FixedTicker({
|
|
49
|
+
rate: 60,
|
|
50
|
+
onTick: () => { },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
ticker.tick(3.01 / 60);
|
|
54
|
+
expect(ticker.tickCount).toBe(3);
|
|
55
|
+
|
|
56
|
+
ticker.resetTickCount();
|
|
57
|
+
expect(ticker.tickCount).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("should pass tick number to onTick callback", () => {
|
|
61
|
+
const tickNumbers: number[] = [];
|
|
62
|
+
const ticker = new FixedTicker({
|
|
63
|
+
rate: 60,
|
|
64
|
+
onTick: (_dt, tick) => {
|
|
65
|
+
if (tick !== undefined) tickNumbers.push(tick);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
ticker.tick(3.01 / 60);
|
|
70
|
+
expect(tickNumbers).toEqual([0, 1, 2]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("should limit ticks per frame to maxTicksPerFrame", () => {
|
|
74
|
+
let tickCount = 0;
|
|
75
|
+
const ticker = new FixedTicker({
|
|
76
|
+
rate: 60,
|
|
77
|
+
onTick: () => {
|
|
78
|
+
tickCount++;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Simulate a huge delta time (2 seconds)
|
|
83
|
+
ticker.tick(2);
|
|
84
|
+
// maxTicksPerFrame should be Math.max(1, Math.floor(60 / 2)) = 30
|
|
85
|
+
expect(tickCount).toBe(30);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("should provide accumulated time", () => {
|
|
89
|
+
const ticker = new FixedTicker({
|
|
90
|
+
rate: 60,
|
|
91
|
+
onTick: () => { },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Give it 1.5 ticks worth of time
|
|
95
|
+
ticker.tick(1.5 / 60);
|
|
96
|
+
expect(ticker.accumulatedTime).toBeCloseTo(0.5 / 60, 5);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("should call onTickSkipped when ticks are skipped", () => {
|
|
100
|
+
let skippedCount = 0;
|
|
101
|
+
const ticker = new FixedTicker({
|
|
102
|
+
rate: 60,
|
|
103
|
+
onTick: () => { },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Access the private onTickSkipped through constructor
|
|
107
|
+
const tickerWithSkip = new FixedTicker({
|
|
108
|
+
rate: 60,
|
|
109
|
+
onTick: () => { },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Monkey patch to add onTickSkipped
|
|
113
|
+
(tickerWithSkip as any).onTickSkipped = (count: number) => {
|
|
114
|
+
skippedCount = count;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Simulate huge delta that exceeds maxTicksPerFrame
|
|
118
|
+
tickerWithSkip.tick(2);
|
|
119
|
+
expect(skippedCount).toBeGreaterThan(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("should handle zero delta time", () => {
|
|
123
|
+
let tickCount = 0;
|
|
124
|
+
const ticker = new FixedTicker({
|
|
125
|
+
rate: 60,
|
|
126
|
+
onTick: () => {
|
|
127
|
+
tickCount++;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
ticker.tick(0);
|
|
132
|
+
expect(tickCount).toBe(0);
|
|
133
|
+
expect(ticker.tickCount).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("should handle very small delta times", () => {
|
|
137
|
+
let tickCount = 0;
|
|
138
|
+
const ticker = new FixedTicker({
|
|
139
|
+
rate: 60,
|
|
140
|
+
onTick: () => {
|
|
141
|
+
tickCount++;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Simulate 100 very small frames that add up to slightly more than 1 tick
|
|
146
|
+
for (let i = 0; i < 100; i++) {
|
|
147
|
+
ticker.tick(1.01 / 60 / 100);
|
|
148
|
+
}
|
|
149
|
+
expect(tickCount).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
});
|