@inglorious/engine 0.3.0 → 0.5.0
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 +90 -43
- package/package.json +6 -7
- package/src/behaviors/camera.js +1 -1
- package/src/behaviors/controls/dynamic/modern.js +2 -2
- package/src/behaviors/controls/dynamic/shooter.js +2 -2
- package/src/behaviors/controls/dynamic/tank.js +2 -2
- package/src/behaviors/controls/kinematic/modern.js +2 -2
- package/src/behaviors/controls/kinematic/shooter.js +2 -2
- package/src/behaviors/controls/kinematic/tank.js +2 -2
- package/src/behaviors/fps.js +1 -1
- package/src/behaviors/fsm.js +2 -2
- package/src/behaviors/fsm.test.js +1 -1
- package/src/behaviors/input/gamepad.js +1 -1
- package/src/behaviors/input/keyboard.js +1 -1
- package/src/behaviors/input/mouse.js +1 -1
- package/src/behaviors/physics/bouncy.js +1 -1
- package/src/behaviors/physics/clamped.js +2 -2
- package/src/behaviors/physics/jumpable.js +2 -2
- package/src/core/core-events.js +16 -0
- package/src/core/dev-tools.js +2 -15
- package/src/core/engine.js +18 -12
- package/src/core/middlewares.js +36 -0
- package/src/core/multiplayer.js +91 -0
- package/src/core/api.js +0 -34
- package/src/core/select.js +0 -26
- package/src/core/store.js +0 -178
- package/src/core/store.test.js +0 -110
package/README.md
CHANGED
|
@@ -1,75 +1,122 @@
|
|
|
1
1
|
# Inglorious Engine
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@inglorious/engine)
|
|
3
4
|
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
The core orchestrator for the [Inglorious Engine](https://github.com/IngloriousCoderz/inglorious-engine). This package provides a complete game loop, state management, and rendering pipeline in a single, cohesive unit. It is designed to be highly configurable and extensible, allowing you to build games with a functional, data-oriented approach.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
---
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
- **Composable by Design**: Build complex behaviors by composing pure functions and decorators, offering a powerful alternative to inheritance.
|
|
11
|
-
- **Renderer Agnostic**: The engine is headless. You can use any rendering technology you like, from Canvas2D and HTML to React components.
|
|
12
|
-
- **Zero Build Step**: Write plain JavaScript and run it directly in the browser. No complex build configurations to worry about.
|
|
10
|
+
## Core Concepts
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
The `@inglorious/engine` package acts as the central hub that brings together all the engine's components. Its main responsibilities are:
|
|
15
13
|
|
|
16
|
-
The
|
|
14
|
+
1. **Orchestrating the Game Loop**: The `Engine` class manages the game loop, which is responsible for continuously updating the state and rendering the game. The loop can be configured to use different timing mechanisms, such as `animationFrame` or a fixed `fps`.
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
2. **State Management**: It leverages the `@inglorious/store` package to manage the game's state. It provides methods to start, stop, and update the state manager, processing a queue of events on each frame.
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
3. **Integrating the Renderer**: The engine is headless by design, but it can be configured with a renderer to display the game. The engine takes a renderer object in its configuration and integrates its systems and logic into the main game loop.
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
4. **Dev Tools Integration**: The engine automatically connects to a browser's dev tools for debugging and time-travel capabilities if `devMode` is enabled in the game's state.
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
---
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
## Installation
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
```bash
|
|
27
|
+
npm install @inglorious/engine
|
|
28
|
+
```
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
---
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
## API
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
### `new Engine(gameConfig)`
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
- **Time-Travel Debugging**: Save the state at any frame. You can step backward and forward through state changes to find exactly when a bug was introduced.
|
|
39
|
-
- **Hot-Reloading**: Modify your game's logic and instantly see the results without restarting. The engine can reload the code and re-apply it to the current state, dramatically speeding up iteration.
|
|
40
|
-
- **Simplified Persistence**: Saving and loading a game is as simple as serializing and deserializing a single JSON object.
|
|
41
|
-
- **Simplified Networking**: For multiplayer games, you don't need to synchronize complex objects. You just send small, serializable `event` objects over the network. Each client processes the same event with the same pure event handler, guaranteeing their game states stay in sync.
|
|
36
|
+
Creates a new `Engine` instance.
|
|
42
37
|
|
|
43
|
-
|
|
38
|
+
**Parameters:**
|
|
44
39
|
|
|
45
|
-
|
|
40
|
+
- `gameConfig` (object): The game-specific configuration. It is an object with the following properties:
|
|
41
|
+
- `loop` (object, optional): Configuration for the game loop.
|
|
42
|
+
- `type` (string, optional): The type of loop to use (`animationFrame` or `fixed`). Defaults to `animationFrame`.
|
|
43
|
+
- `fps` (number, optional): The target frames per second. Only used with the `fixed` loop type. Defaults to `60`.
|
|
44
|
+
- `systems` (array, optional): An array of system objects, which define behaviors for the whole state.
|
|
45
|
+
- `types` (object, optional): A map of entity types.
|
|
46
|
+
- `entities` (object, optional): A map of initial entities.
|
|
47
|
+
- `renderer` (object, optional): A renderer entity responsible for drawing the game. It must implement `getSystems()` and `init(engine)` methods.
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
**Returns:**
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
- A new `Engine` instance.
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
### `engine.start()`
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
- It decouples game logic from state updates.
|
|
55
|
-
- It ensures state changes happen at a predictable point in the game loop, preventing race conditions or cascading updates within a single frame.
|
|
56
|
-
- It allows for event batching and provides a solid foundation for networking and time-travel debugging.
|
|
55
|
+
Starts the game loop, triggering the first `update` and `render` calls.
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
- `update`: Fired on every frame, typically carrying the `deltaTime` since the last frame. This is where you'll put most of your continuous game logic (like movement).
|
|
60
|
-
- `add`: Used to add a new entity to the game state.
|
|
61
|
-
- `remove`: Used to remove an entity from the game state.
|
|
62
|
-
- `morph`: Used to dynamically change the behaviors associated with an entity's type.
|
|
57
|
+
### `engine.stop()`
|
|
63
58
|
|
|
64
|
-
|
|
59
|
+
Halts the game loop and cleans up any resources. This method also processes a final `stop` event to ensure a clean shutdown.
|
|
65
60
|
|
|
66
|
-
|
|
61
|
+
---
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
- **How it works**: Any event notified from within a handler is simply added to the end of the main event queue. It will be processed in a subsequent pass of the game loop, not immediately. This prevents synchronous, cascading updates within a single frame and makes the flow of logic easier to trace.
|
|
63
|
+
## Basic Usage
|
|
70
64
|
|
|
71
|
-
|
|
65
|
+
Here is a complete example showing how to set up and run a game using the engine.
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<!doctype html>
|
|
69
|
+
<html lang="en">
|
|
70
|
+
|
|
71
|
+
<head>
|
|
72
|
+
|
|
73
|
+
<meta charset="UTF-8" />
|
|
74
|
+
|
|
75
|
+
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
|
76
|
+
|
|
77
|
+
<link rel="stylesheet" href="/style.css" />
|
|
78
|
+
|
|
79
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
<title>Inglorious Engine</title>
|
|
83
|
+
|
|
84
|
+
</head>
|
|
85
|
+
|
|
86
|
+
<body>
|
|
87
|
+
<canvas id="canvas"></canvas>
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
<script type="text/javascript">
|
|
91
|
+
window.process = { env: "development" }
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
<script type="importmap">
|
|
96
|
+
{
|
|
97
|
+
"imports": {
|
|
98
|
+
"immer": "https://unpkg.com/immer@10.1.1/dist/immer.mjs",
|
|
99
|
+
"@inglorious/utils/": "https://unpkg.com/@inglorious%2Futils@1.1.0/",
|
|
100
|
+
"@inglorious/store/": "https://unpkg.com/@inglorious%2Fstore@0.1.0/",
|
|
101
|
+
"@inglorious/engine/": "https://unpkg.com/@inglorious%2Fengine@0.4.0/",
|
|
102
|
+
"@inglorious/renderers/": "https://unpkg.com/@inglorious%2Frenderer-2d@0.2.0/",
|
|
103
|
+
"game": "/game.js"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
<script
|
|
110
|
+
type="module"
|
|
111
|
+
src="https://unpkg.com/@inglorious%2Fengine@0.2.0/src/main.js"
|
|
112
|
+
></script>
|
|
113
|
+
|
|
114
|
+
</body>
|
|
115
|
+
</html>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
72
119
|
|
|
73
120
|
## Contributing
|
|
74
121
|
|
|
75
|
-
We welcome contributions from the community
|
|
122
|
+
We welcome contributions from the community\! Whether you're fixing a bug, adding a feature, or improving the documentation, your help is appreciated. Please read our [Contributing Guidelines](https://github.com/IngloriousCoderz/inglorious-engine/blob/main/CONTRIBUTING.md) for details on how to get started.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A JavaScript game engine written with global state, immutability, and pure functions in mind. Have fun(ctional programming) with it!",
|
|
5
5
|
"author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,23 +20,22 @@
|
|
|
20
20
|
"game-engine"
|
|
21
21
|
],
|
|
22
22
|
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
"./*": "./src/*"
|
|
25
|
+
},
|
|
23
26
|
"files": [
|
|
24
27
|
"src",
|
|
25
28
|
"README.md",
|
|
26
29
|
"LICENSE"
|
|
27
30
|
],
|
|
28
|
-
"exports": {
|
|
29
|
-
"./*": "./src/*"
|
|
30
|
-
},
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"
|
|
36
|
-
"@inglorious/
|
|
35
|
+
"@inglorious/utils": "1.1.0",
|
|
36
|
+
"@inglorious/store": "1.2.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"husky": "^9.1.7",
|
|
40
39
|
"prettier": "^3.5.3",
|
|
41
40
|
"vite": "^7.1.3",
|
|
42
41
|
"vitest": "^1.6.0"
|
package/src/behaviors/camera.js
CHANGED
|
@@ -24,8 +24,8 @@ export function modernAcceleration(params) {
|
|
|
24
24
|
"moveUpDown",
|
|
25
25
|
]),
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
type.
|
|
27
|
+
create(entity, event, api) {
|
|
28
|
+
type.create?.(entity, event, api)
|
|
29
29
|
|
|
30
30
|
entity.maxAcceleration ??= params.maxAcceleration
|
|
31
31
|
entity.movement ??= {}
|
|
@@ -33,8 +33,8 @@ export function shooterControls(params) {
|
|
|
33
33
|
"turn",
|
|
34
34
|
]),
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
type.
|
|
36
|
+
create(entity, event, api) {
|
|
37
|
+
type.create?.(entity, event, api)
|
|
38
38
|
|
|
39
39
|
entity.maxSpeed ??= params.maxSpeed
|
|
40
40
|
entity.maxAngularSpeed ??= params.maxAngularSpeed
|
|
@@ -27,8 +27,8 @@ export function tankControls(params) {
|
|
|
27
27
|
"turn",
|
|
28
28
|
]),
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
type.
|
|
30
|
+
create(entity, event, api) {
|
|
31
|
+
type.create?.(entity, event, api)
|
|
32
32
|
|
|
33
33
|
entity.maxSpeed ??= params.maxSpeed
|
|
34
34
|
entity.maxAngularSpeed ??= params.maxAngularSpeed
|
|
@@ -24,8 +24,8 @@ export function modernVelocity(params) {
|
|
|
24
24
|
"moveUpDown",
|
|
25
25
|
]),
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
type.
|
|
27
|
+
create(entity, event, api) {
|
|
28
|
+
type.create?.(entity, event, api)
|
|
29
29
|
|
|
30
30
|
entity.maxSpeed ??= params.maxSpeed
|
|
31
31
|
entity.movement ??= {}
|
|
@@ -32,8 +32,8 @@ export function shooterControls(params) {
|
|
|
32
32
|
"turn",
|
|
33
33
|
]),
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
type.
|
|
35
|
+
create(entity, event, api) {
|
|
36
|
+
type.create?.(entity, event, api)
|
|
37
37
|
|
|
38
38
|
entity.maxSpeed ??= params.maxSpeed
|
|
39
39
|
entity.maxAngularSpeed ??= params.maxAngularSpeed
|
|
@@ -26,8 +26,8 @@ export function tankControls(params) {
|
|
|
26
26
|
"turn",
|
|
27
27
|
]),
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
type.
|
|
29
|
+
create(entity, event, api) {
|
|
30
|
+
type.create?.(entity, event, api)
|
|
31
31
|
|
|
32
32
|
entity.maxSpeed ??= params.maxSpeed
|
|
33
33
|
entity.maxAngularSpeed ??= params.maxAngularSpeed
|
package/src/behaviors/fps.js
CHANGED
package/src/behaviors/fsm.js
CHANGED
|
@@ -11,8 +11,8 @@ export function clamped(params) {
|
|
|
11
11
|
|
|
12
12
|
return (type) =>
|
|
13
13
|
extend(type, {
|
|
14
|
-
|
|
15
|
-
type.
|
|
14
|
+
create(entity, event, api) {
|
|
15
|
+
type.create?.(entity, event, api)
|
|
16
16
|
|
|
17
17
|
entity.collisions ??= {}
|
|
18
18
|
entity.collisions[params.collisionGroup] ??= {}
|
|
@@ -27,8 +27,8 @@ export function jumpable(params) {
|
|
|
27
27
|
|
|
28
28
|
return (type) =>
|
|
29
29
|
extend(type, {
|
|
30
|
-
|
|
31
|
-
type.
|
|
30
|
+
create(entity, event, api) {
|
|
31
|
+
type.create?.(entity, event, api)
|
|
32
32
|
defaults(entity, params)
|
|
33
33
|
entity.jumpsLeft ??= entity.maxJumps
|
|
34
34
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const coreEvents = [
|
|
2
|
+
"create",
|
|
3
|
+
"update",
|
|
4
|
+
"destroy",
|
|
5
|
+
"gamepadAxis",
|
|
6
|
+
"gamepadPress",
|
|
7
|
+
"gamepadRelease",
|
|
8
|
+
"keyboardKeyDown",
|
|
9
|
+
"keyboardKeyUp",
|
|
10
|
+
"inputAxis",
|
|
11
|
+
"inputPress",
|
|
12
|
+
"inputRelease",
|
|
13
|
+
"mouseMove",
|
|
14
|
+
"mouseClick",
|
|
15
|
+
"spriteAnimationEnd",
|
|
16
|
+
]
|
package/src/core/dev-tools.js
CHANGED
|
@@ -1,17 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
"update",
|
|
3
|
-
"gamepadAxis",
|
|
4
|
-
"gamepadPress",
|
|
5
|
-
"gamepadRelease",
|
|
6
|
-
"keyboardKeyDown",
|
|
7
|
-
"keyboardKeyUp",
|
|
8
|
-
"inputAxis",
|
|
9
|
-
"inputPress",
|
|
10
|
-
"inputRelease",
|
|
11
|
-
"mouseMove",
|
|
12
|
-
"mouseClick",
|
|
13
|
-
"spriteAnimationEnd",
|
|
14
|
-
]
|
|
1
|
+
import { coreEvents } from "./core-events.js"
|
|
15
2
|
|
|
16
3
|
const LAST_STATE = 1
|
|
17
4
|
|
|
@@ -30,7 +17,7 @@ export function initDevTools(store) {
|
|
|
30
17
|
|
|
31
18
|
devToolsInstance = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
|
|
32
19
|
name: "Inglorious Engine",
|
|
33
|
-
predicate: (state, action) => !
|
|
20
|
+
predicate: (state, action) => !coreEvents.includes(action.type),
|
|
34
21
|
actionCreators: {
|
|
35
22
|
jump: () => ({ type: "jump", payload: { inputId: "input0" } }),
|
|
36
23
|
},
|
package/src/core/engine.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { game } from "@inglorious/engine/behaviors/game.js"
|
|
2
|
+
import { createApi } from "@inglorious/store/api.js"
|
|
3
|
+
import { createStore } from "@inglorious/store/store.js"
|
|
2
4
|
import { extend } from "@inglorious/utils/data-structures/objects.js"
|
|
3
5
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
ACTION_BLACKLIST,
|
|
7
|
-
disconnectDevTools,
|
|
8
|
-
initDevTools,
|
|
9
|
-
sendAction,
|
|
10
|
-
} from "./dev-tools.js"
|
|
6
|
+
import { coreEvents } from "./core-events.js"
|
|
7
|
+
import { disconnectDevTools, initDevTools, sendAction } from "./dev-tools.js"
|
|
11
8
|
import Loop from "./loop.js"
|
|
12
|
-
import {
|
|
9
|
+
import { applyMiddlewares } from "./middlewares.js"
|
|
10
|
+
import { multiplayerMiddleware } from "./multiplayer.js"
|
|
13
11
|
|
|
14
12
|
// Default game configuration
|
|
15
13
|
// loop.type specifies the type of loop to use (defaults to "animationFrame").
|
|
@@ -51,7 +49,15 @@ export class Engine {
|
|
|
51
49
|
systems.push(...this._config.renderer.getSystems())
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
|
|
52
|
+
let store = createStore({ ...this._config, systems })
|
|
53
|
+
|
|
54
|
+
// Apply multiplayer if specified.
|
|
55
|
+
const multiplayer = this._config.entities.game?.multiplayer
|
|
56
|
+
if (multiplayer) {
|
|
57
|
+
store = applyMiddlewares(multiplayerMiddleware(multiplayer))(store)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this._store = store
|
|
55
61
|
this._loop = new Loop[this._config.loop.type]()
|
|
56
62
|
this._api = createApi(this._store)
|
|
57
63
|
|
|
@@ -70,7 +76,7 @@ export class Engine {
|
|
|
70
76
|
* Starts the game engine, initializing the loop and notifying the store.
|
|
71
77
|
*/
|
|
72
78
|
start() {
|
|
73
|
-
this._store.notify("start"
|
|
79
|
+
this._store.notify("start")
|
|
74
80
|
this._loop.start(this, ONE_SECOND / this._config.loop.fps)
|
|
75
81
|
this.isRunning = true
|
|
76
82
|
}
|
|
@@ -79,7 +85,7 @@ export class Engine {
|
|
|
79
85
|
* Stops the game engine, halting the loop and notifying the store.
|
|
80
86
|
*/
|
|
81
87
|
stop() {
|
|
82
|
-
this._store.notify("stop"
|
|
88
|
+
this._store.notify("stop")
|
|
83
89
|
this._store.update(FINAL_UPDATE_DELTA_TIME, this._api)
|
|
84
90
|
this._loop.stop()
|
|
85
91
|
this.isRunning = false
|
|
@@ -105,7 +111,7 @@ export class Engine {
|
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
const eventsToLog = processedEvents.filter(
|
|
108
|
-
({ type }) => !
|
|
114
|
+
({ type }) => !coreEvents.includes(type),
|
|
109
115
|
)
|
|
110
116
|
|
|
111
117
|
if (eventsToLog.length) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { compose } from "@inglorious/utils/functions/functions.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Applies a list of middleware functions to a store's dispatch method.
|
|
5
|
+
*
|
|
6
|
+
* @param {...Function} middlewares The middleware functions to apply.
|
|
7
|
+
* @returns {Function} A store enhancer function.
|
|
8
|
+
*/
|
|
9
|
+
export function applyMiddlewares(...middlewares) {
|
|
10
|
+
return (store) => {
|
|
11
|
+
let dispatch = () => {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"Dispatching while constructing your middleware is not allowed.",
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The middleware API that can be passed to each middleware function.
|
|
18
|
+
const api = {
|
|
19
|
+
dispatch: (...args) => dispatch(...args),
|
|
20
|
+
getState: store.getState,
|
|
21
|
+
setState: store.setState,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Create a chain of middleware functions.
|
|
25
|
+
const chain = middlewares.map((middleware) => middleware(api))
|
|
26
|
+
|
|
27
|
+
// Compose the middleware chain to create the final dispatch function.
|
|
28
|
+
dispatch = compose(...chain)(store.dispatch)
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...store,
|
|
32
|
+
dispatch,
|
|
33
|
+
notify: (type, payload) => dispatch({ type, payload }),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { extend } from "@inglorious/utils/data-structures/objects.js"
|
|
2
|
+
|
|
3
|
+
import { coreEvents } from "./core-events.js"
|
|
4
|
+
|
|
5
|
+
// A constant for the server's WebSocket URL.
|
|
6
|
+
const DEFAULT_SERVER_URL = `ws://${window.location.hostname}:3000`
|
|
7
|
+
const DEFAULT_RECONNECTION_DELAY = 1000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates and returns the multiplayer middleware.
|
|
11
|
+
* @returns {Function} The middleware function.
|
|
12
|
+
*/
|
|
13
|
+
export function multiplayerMiddleware(config = {}) {
|
|
14
|
+
const serverUrl = config.serverUrl ?? DEFAULT_SERVER_URL
|
|
15
|
+
const reconnectionDelay =
|
|
16
|
+
config.reconnectionDelay ?? DEFAULT_RECONNECTION_DELAY
|
|
17
|
+
|
|
18
|
+
let ws = null
|
|
19
|
+
const localQueue = []
|
|
20
|
+
|
|
21
|
+
// The middleware function that will be applied to the store.
|
|
22
|
+
return (api) => (next) => (event) => {
|
|
23
|
+
if (coreEvents.includes(event.type)) {
|
|
24
|
+
return next(event)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Establish the connection on the first event.
|
|
28
|
+
if (!ws) {
|
|
29
|
+
establishConnection(api)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Only send the event to the server if it didn't come from the server.
|
|
33
|
+
if (!event.fromServer) {
|
|
34
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
35
|
+
// If the connection is open, send the event immediately.
|
|
36
|
+
ws.send(JSON.stringify(event))
|
|
37
|
+
} else {
|
|
38
|
+
// If the connection is not open, queue the event for later.
|
|
39
|
+
localQueue.push(event)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pass the event to the next middleware in the chain,
|
|
44
|
+
// which is eventually the store's original dispatch function.
|
|
45
|
+
return next(event)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attempts to establish a WebSocket connection to the server.
|
|
50
|
+
*/
|
|
51
|
+
function establishConnection(api) {
|
|
52
|
+
// If a connection already exists, close it first.
|
|
53
|
+
if (ws) {
|
|
54
|
+
ws.close()
|
|
55
|
+
}
|
|
56
|
+
ws = new WebSocket(serverUrl)
|
|
57
|
+
|
|
58
|
+
// =====================================================================
|
|
59
|
+
// WebSocket Event Handlers
|
|
60
|
+
// =====================================================================
|
|
61
|
+
|
|
62
|
+
ws.onopen = () => {
|
|
63
|
+
// Send any queued events to the server.
|
|
64
|
+
while (localQueue.length) {
|
|
65
|
+
ws.send(JSON.stringify(localQueue.shift()))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ws.onmessage = (event) => {
|
|
70
|
+
const serverEvent = JSON.parse(event.data)
|
|
71
|
+
|
|
72
|
+
if (serverEvent.type === "initialState") {
|
|
73
|
+
// Merge the server's initial state with the client's local state.
|
|
74
|
+
const nextState = extend(api.getState(), serverEvent.payload)
|
|
75
|
+
api.setState(nextState)
|
|
76
|
+
} else {
|
|
77
|
+
// Dispatch the event to the local store to update the client's state.
|
|
78
|
+
api.dispatch({ ...serverEvent, fromServer: true })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ws.onclose = () => {
|
|
83
|
+
// Attempt to reconnect after a short delay.
|
|
84
|
+
setTimeout(() => establishConnection(api), reconnectionDelay)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ws.onerror = () => {
|
|
88
|
+
ws.close() // The 'onclose' handler will trigger the reconnect.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/core/api.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { createSelector as _createSelector } from "./select.js"
|
|
2
|
-
|
|
3
|
-
export function createApi(store) {
|
|
4
|
-
const createSelector = (inputSelectors, resultFunc) => {
|
|
5
|
-
const selector = _createSelector(inputSelectors, resultFunc)
|
|
6
|
-
return () => selector(store.getState())
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const getTypes = () => store.getTypes()
|
|
10
|
-
|
|
11
|
-
const getEntities = () => store.getState().entities
|
|
12
|
-
|
|
13
|
-
const getEntity = (id) => getEntities()[id]
|
|
14
|
-
|
|
15
|
-
const notify = (type, payload) => {
|
|
16
|
-
store.notify(type, payload)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const dispatch = (action) => {
|
|
20
|
-
store.dispatch(action)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const getType = (id) => store.getOriginalTypes()?.[id]
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
createSelector,
|
|
27
|
-
getTypes,
|
|
28
|
-
getEntities,
|
|
29
|
-
getEntity,
|
|
30
|
-
getType,
|
|
31
|
-
notify,
|
|
32
|
-
dispatch,
|
|
33
|
-
}
|
|
34
|
-
}
|
package/src/core/select.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Creates a memoized selector function.
|
|
3
|
-
* NB: this implementation does not support spreading the input selectors for clarity, please just put them in an array.
|
|
4
|
-
* @param {Array<Function>} inputSelectors - An array of input selector functions.
|
|
5
|
-
* @param {Function} resultFunc - A function that receives the results of the input selectors and returns a computed value.
|
|
6
|
-
* @returns {Function} A memoized selector function that, when called, returns the selected state.
|
|
7
|
-
*/
|
|
8
|
-
export function createSelector(inputSelectors, resultFunc) {
|
|
9
|
-
let lastInputs = []
|
|
10
|
-
let lastResult = null
|
|
11
|
-
|
|
12
|
-
return (state) => {
|
|
13
|
-
const nextInputs = inputSelectors.map((selector) => selector(state))
|
|
14
|
-
const inputsChanged =
|
|
15
|
-
lastInputs.length !== nextInputs.length ||
|
|
16
|
-
nextInputs.some((input, index) => input !== lastInputs[index])
|
|
17
|
-
|
|
18
|
-
if (!inputsChanged) {
|
|
19
|
-
return lastResult
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
lastInputs = nextInputs
|
|
23
|
-
lastResult = resultFunc(...nextInputs)
|
|
24
|
-
return lastResult
|
|
25
|
-
}
|
|
26
|
-
}
|
package/src/core/store.js
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { map } from "@inglorious/utils/data-structures/object.js"
|
|
2
|
-
import { extend } from "@inglorious/utils/data-structures/objects.js"
|
|
3
|
-
import { pipe } from "@inglorious/utils/functions/functions.js"
|
|
4
|
-
import { produce } from "immer"
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Creates a store to manage state and events.
|
|
8
|
-
* @param {Object} config - Configuration options for the store.
|
|
9
|
-
* @param {Object} [config.types] - The initial types configuration.
|
|
10
|
-
* @param {Object} [config.entities] - The initial entities configuration.
|
|
11
|
-
* @returns {Object} The store with methods to interact with state and events.
|
|
12
|
-
*/
|
|
13
|
-
export function createStore({
|
|
14
|
-
types: originalTypes,
|
|
15
|
-
entities: originalEntities,
|
|
16
|
-
systems = [],
|
|
17
|
-
}) {
|
|
18
|
-
const listeners = new Set()
|
|
19
|
-
let incomingEvents = []
|
|
20
|
-
|
|
21
|
-
let types = augmentTypes(originalTypes)
|
|
22
|
-
let entities = augmentEntities(originalEntities)
|
|
23
|
-
|
|
24
|
-
const initialState = { entities }
|
|
25
|
-
let state = initialState
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
subscribe,
|
|
29
|
-
update,
|
|
30
|
-
notify,
|
|
31
|
-
dispatch, // needed for compatibility with Redux
|
|
32
|
-
getTypes,
|
|
33
|
-
getOriginalTypes,
|
|
34
|
-
getState,
|
|
35
|
-
setState,
|
|
36
|
-
reset,
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Subscribes a listener to state updates.
|
|
41
|
-
* @param {Function} listener - The listener function to call on updates.
|
|
42
|
-
* @returns {Function} A function to unsubscribe the listener.
|
|
43
|
-
*/
|
|
44
|
-
function subscribe(listener) {
|
|
45
|
-
listeners.add(listener)
|
|
46
|
-
|
|
47
|
-
return function unsubscribe() {
|
|
48
|
-
listeners.delete(listener)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Updates the state based on elapsed time and processes events.
|
|
54
|
-
* @param {number} dt - The delta time since the last update in milliseconds.
|
|
55
|
-
* @param {Object} api - The engine's public API.
|
|
56
|
-
*/
|
|
57
|
-
function update(dt, api) {
|
|
58
|
-
const processedEvents = []
|
|
59
|
-
|
|
60
|
-
state = produce(state, (state) => {
|
|
61
|
-
incomingEvents.push({ type: "update", payload: dt })
|
|
62
|
-
|
|
63
|
-
while (incomingEvents.length) {
|
|
64
|
-
const event = incomingEvents.shift()
|
|
65
|
-
processedEvents.push(event)
|
|
66
|
-
|
|
67
|
-
if (event.type === "morph") {
|
|
68
|
-
const { id, type } = event.payload
|
|
69
|
-
originalTypes[id] = type
|
|
70
|
-
types = augmentTypes(originalTypes)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (event.type === "add") {
|
|
74
|
-
const { id, ...entity } = event.payload
|
|
75
|
-
state.entities[id] = augmentEntity(id, entity)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (event.type === "remove") {
|
|
79
|
-
const id = event.payload
|
|
80
|
-
delete state.entities[id]
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
for (const id in state.entities) {
|
|
84
|
-
const entity = state.entities[id]
|
|
85
|
-
const type = types[entity.type]
|
|
86
|
-
const handle = type[event.type]
|
|
87
|
-
handle?.(entity, event.payload, api)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
systems.forEach((system) => {
|
|
91
|
-
const handle = system[event.type]
|
|
92
|
-
handle?.(state, event.payload, api)
|
|
93
|
-
})
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
listeners.forEach((onUpdate) => onUpdate())
|
|
98
|
-
|
|
99
|
-
return processedEvents
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Notifies the store of a new event.
|
|
104
|
-
* @param {string} type - The event object type to notify.
|
|
105
|
-
* @param {any} payload - The event object payload to notify.
|
|
106
|
-
*/
|
|
107
|
-
function notify(type, payload) {
|
|
108
|
-
dispatch({ type, payload })
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Dispatches an event to be processed in the next update cycle.
|
|
113
|
-
* @param {Object} event - The event object.
|
|
114
|
-
* @param {string} event.type - The type of the event.
|
|
115
|
-
* @param {any} [event.payload] - The payload of the event.
|
|
116
|
-
*/
|
|
117
|
-
function dispatch(event) {
|
|
118
|
-
incomingEvents.push(event)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Retrieves the augmented types configuration.
|
|
123
|
-
* This includes composed behaviors and event handlers wrapped for immutability.
|
|
124
|
-
* @returns {Object} The augmented types configuration.
|
|
125
|
-
*/
|
|
126
|
-
function getTypes() {
|
|
127
|
-
return types
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Retrieves the original, un-augmented types configuration.
|
|
132
|
-
* @returns {Object} The original types configuration.
|
|
133
|
-
*/
|
|
134
|
-
function getOriginalTypes() {
|
|
135
|
-
return originalTypes
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Retrieves the current state.
|
|
140
|
-
* @returns {Object} The current state.
|
|
141
|
-
*/
|
|
142
|
-
function getState() {
|
|
143
|
-
return state
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function setState(newState) {
|
|
147
|
-
state = newState
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function reset() {
|
|
151
|
-
state = initialState // Reset state to its originally computed value
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function augmentTypes(types) {
|
|
156
|
-
return pipe(applyBehaviors)(types)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function applyBehaviors(types) {
|
|
160
|
-
return map(types, (_, type) => {
|
|
161
|
-
if (!Array.isArray(type)) {
|
|
162
|
-
return type
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const behaviors = type.map((fn) =>
|
|
166
|
-
typeof fn !== "function" ? (type) => extend(type, fn) : fn,
|
|
167
|
-
)
|
|
168
|
-
return pipe(...behaviors)({})
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function augmentEntities(entities) {
|
|
173
|
-
return map(entities, augmentEntity)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function augmentEntity(id, entity) {
|
|
177
|
-
return { ...entity, id }
|
|
178
|
-
}
|
package/src/core/store.test.js
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "vitest"
|
|
2
|
-
|
|
3
|
-
import { createStore } from "./store.js"
|
|
4
|
-
|
|
5
|
-
test("it should process events by mutating state inside handlers", () => {
|
|
6
|
-
const config = {
|
|
7
|
-
types: {
|
|
8
|
-
kitty: {
|
|
9
|
-
feed(entity) {
|
|
10
|
-
entity.isFed = true
|
|
11
|
-
},
|
|
12
|
-
update(entity) {
|
|
13
|
-
entity.isMeowing = true
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
entities: {
|
|
18
|
-
kitty1: { type: "kitty" },
|
|
19
|
-
},
|
|
20
|
-
}
|
|
21
|
-
const afterState = {
|
|
22
|
-
entities: {
|
|
23
|
-
kitty1: {
|
|
24
|
-
id: "kitty1",
|
|
25
|
-
type: "kitty",
|
|
26
|
-
isFed: true,
|
|
27
|
-
isMeowing: true,
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const store = createStore(config)
|
|
33
|
-
store.notify("feed")
|
|
34
|
-
store.update(0, {})
|
|
35
|
-
|
|
36
|
-
const state = store.getState()
|
|
37
|
-
expect(state).toStrictEqual(afterState)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test("it should send an event from an entity and process it in the same update cycle", () => {
|
|
41
|
-
const config = {
|
|
42
|
-
types: {
|
|
43
|
-
doggo: {
|
|
44
|
-
update(entity, dt, api) {
|
|
45
|
-
api.notify("bark")
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
kitty: {
|
|
49
|
-
bark(entity) {
|
|
50
|
-
entity.position = "far"
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
entities: {
|
|
55
|
-
doggo1: { type: "doggo" },
|
|
56
|
-
kitty1: { type: "kitty", position: "near" },
|
|
57
|
-
},
|
|
58
|
-
}
|
|
59
|
-
const afterState = {
|
|
60
|
-
entities: {
|
|
61
|
-
doggo1: { id: "doggo1", type: "doggo" },
|
|
62
|
-
kitty1: { id: "kitty1", type: "kitty", position: "far" },
|
|
63
|
-
},
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const store = createStore(config)
|
|
67
|
-
const api = { notify: store.notify }
|
|
68
|
-
store.update(0, api)
|
|
69
|
-
|
|
70
|
-
const state = store.getState()
|
|
71
|
-
expect(state).toStrictEqual(afterState)
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test("it should add an entity via an 'add' event", () => {
|
|
75
|
-
const config = {
|
|
76
|
-
types: {
|
|
77
|
-
kitty: {},
|
|
78
|
-
},
|
|
79
|
-
entities: {},
|
|
80
|
-
}
|
|
81
|
-
const newEntity = { id: "kitty1", type: "kitty" }
|
|
82
|
-
|
|
83
|
-
const store = createStore(config)
|
|
84
|
-
store.notify("add", newEntity)
|
|
85
|
-
store.update(0, {})
|
|
86
|
-
|
|
87
|
-
const state = store.getState()
|
|
88
|
-
expect(state).toStrictEqual({
|
|
89
|
-
entities: {
|
|
90
|
-
kitty1: { id: "kitty1", type: "kitty" },
|
|
91
|
-
},
|
|
92
|
-
})
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test("it should remove an entity via a 'remove' event", () => {
|
|
96
|
-
const config = {
|
|
97
|
-
types: {},
|
|
98
|
-
entities: {
|
|
99
|
-
kitty1: { type: "kitty" },
|
|
100
|
-
},
|
|
101
|
-
}
|
|
102
|
-
const store = createStore(config)
|
|
103
|
-
|
|
104
|
-
store.notify("remove", "kitty1")
|
|
105
|
-
|
|
106
|
-
store.update(0, {})
|
|
107
|
-
|
|
108
|
-
const state = store.getState()
|
|
109
|
-
expect(state.entities.kitty1).toBeUndefined()
|
|
110
|
-
})
|