@inglorious/engine 0.4.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 CHANGED
@@ -3,74 +3,120 @@
3
3
  [![NPM version](https://img.shields.io/npm/v/@inglorious/engine.svg)](https://www.npmjs.com/package/@inglorious/engine)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- A JavaScript game engine written with global state, immutability, and pure functions in mind. Have fun(ctional programming) with it!
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.
7
7
 
8
- ## Features
8
+ ---
9
9
 
10
- - **Functional & Data-Oriented**: Uses a single, immutable state object as the source of truth, inspired by functional programming principles.
11
- - **Composable by Design**: Build complex behaviors by composing pure functions and decorators, offering a powerful alternative to inheritance.
12
- - **Renderer Agnostic**: The engine is headless. You can use any rendering technology you like, from Canvas2D and HTML to React components.
13
- - **Zero Build Step**: Write plain JavaScript and run it directly in the browser. No complex build configurations to worry about.
10
+ ## Core Concepts
14
11
 
15
- ## Documentation
12
+ The `@inglorious/engine` package acts as the central hub that brings together all the engine's components. Its main responsibilities are:
16
13
 
17
- The best way to get started is with the official documentation, which includes a **[Quick Start Guide](https://inglorious-engine.vercel.app/?path=/docs/quick-start--docs)**.
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`.
18
15
 
19
- Full documentation is available at: **[https://inglorious-engine.vercel.app/](https://inglorious-engine.vercel.app/)**
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.
20
17
 
21
- ## Why Functional Programming?
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.
22
19
 
23
- What makes this engine different from all the others is that, instead of Object Oriented Programming (OOP), which seems the most obvious choice for a game engine, this one is based on Functional Programming (FP).
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.
24
21
 
25
- FP has many advantages:
22
+ ---
26
23
 
27
- 1. **Single Source of Truth**: Your entire game state is a single, plain JavaScript object. This gives you complete control over your game's world at any moment, rather than having state scattered across countless objects. This is the core idea behind the Data-Oriented Design (DOD) paradigm that many modern engines are now adopting. With this engine, you get that benefit naturally.
24
+ ## Installation
28
25
 
29
- 2. **Efficient Immutability**: A common misconception is that creating a new state on every change is slow. This engine uses structural sharing (via Immer), meaning only the parts of the state that actually change are copied. The rest of the state tree is shared by reference, making updates extremely fast. This provides a huge benefit:
30
- - **Optimized Rendering**: Detecting changes becomes trivial and fast. A simple reference check (`prevState === nextState`) is all that's needed to determine if data has changed, enabling highly performant UIs (especially with libraries like React). This is much faster than the deep, recursive comparisons required in mutable systems.
26
+ ```bash
27
+ npm install @inglorious/engine
28
+ ```
31
29
 
32
- 3. **Pure Functions**: Game logic is built with pure functions — functions that return a value based only on their inputs, with no side effects. This makes your game logic predictable, easy to test in isolation, and highly reusable, freeing you from the complexity of class methods with hidden side effects.
30
+ ---
33
31
 
34
- 4. **Composition over Inheritance**: Instead of complex class hierarchies, you build entities by composing functions. Think of it as a pipeline of operations applied to your data. This is a more flexible and powerful alternative to inheritance. You can mix and match behaviors (e.g., `canBeControlledByPlayer`, `canBeHurt`, `canShoot`) on the fly, avoiding the rigidity and common problems of deep inheritance chains.
32
+ ## API
35
33
 
36
- 5. **Dynamic by Nature**: JavaScript objects are dynamic. You can add or remove properties from an entity at any time without being constrained by a rigid class definition. This is perfect for game development, where an entity's state can change unpredictably (e.g., gaining a temporary power-up). This flexibility allows for more emergent game mechanics.
34
+ ### `new Engine(gameConfig)`
37
35
 
38
- 6. **Unparalleled Debugging and Tooling**: Because the entire game state is a single, serializable object, you can unlock powerful development patterns that are difficult to achieve in traditional OOP engines.
39
- - **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.
40
- - **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.
41
- - **Simplified Persistence**: Saving and loading a game is as simple as serializing and deserializing a single JSON object.
42
- - **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.
43
37
 
44
- 7. **Leverage the Full JavaScript Ecosystem**: As a pure JavaScript engine, you have immediate access to the world's largest software repository: npm. Need advanced physics, complex AI, or a specific UI library? Integrate it with a simple `npm install`. You aren't limited to the built-in features or proprietary plugin ecosystem of a monolithic engine like Godot or Unity.
38
+ **Parameters:**
45
39
 
46
- ## Architecture: State Management
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.
47
48
 
48
- The engine's state management is inspired by Redux, but it's specifically tailored for the demands of game development. If you're familiar with Redux, you'll recognize the core pattern: the UI (or game view) is a projection of the state, and the only way to change the state is to dispatch an action.
49
+ **Returns:**
49
50
 
50
- However, there are several key differences that make it unique:
51
+ - A new `Engine` instance.
51
52
 
52
- 1. **Events, not Actions**: In Redux, you "dispatch actions." Here, we "notify of events." This is a deliberate semantic choice. An event handler is a function that reacts to a specific occurrence in the game world (e.g., `playerMove`, `enemyDestroy`). The naming convention is similar to standard JavaScript event handlers like `onClick`, where the handler name describes the event it's listening for.
53
+ ### `engine.start()`
53
54
 
54
- 2. **Asynchronous Event Queue**: Unlike Redux's synchronous dispatch, events are not processed immediately. They are added to a central event queue. The engine's main loop processes this queue once per frame. This approach has several advantages:
55
- - It decouples game logic from state updates.
56
- - It ensures state changes happen at a predictable point in the game loop, preventing race conditions or cascading updates within a single frame.
57
- - 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.
58
56
 
59
- 3. **Core Engine Events & Naming Convention**: The engine has a few built-in, **single-word** events that drive its core functionality. To avoid conflicts, you should use **multi-word `camelCase`** names for your own custom game events (`playerJump`, `itemCollect`). This convention is similar to how custom HTML elements require a hyphen to distinguish them from standard elements. Key engine events include:
60
- - `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).
61
- - `add`: Used to add a new entity to the game state.
62
- - `remove`: Used to remove an entity from the game state.
63
- - `morph`: Used to dynamically change the behaviors associated with an entity's type.
57
+ ### `engine.stop()`
64
58
 
65
- 4. **Ergonomic Immutability with Immer**: The state is immutable, but to make this easy to work with, we use Immer. Inside your event handlers, you can write code that looks like it's mutating the state directly. Immer handles the magic behind the scenes, producing a new, updated state with structural sharing, giving you the performance benefits of immutability with the developer experience of mutable code.
59
+ Halts the game loop and cleans up any resources. This method also processes a final `stop` event to ensure a clean shutdown.
66
60
 
67
- 5. **Composable Handlers via Function Piping**: Instead of large, monolithic "reducers," you build event handlers by composing smaller, pure functions. The engine encourages a pipeline pattern where an event and the current state are passed through a series of decorators or transformations. This makes your logic highly modular, reusable, and easy to test in isolation.
61
+ ---
68
62
 
69
- 6. **Handlers Can Issue New Events (Controlled Side-Effects)**: In a strict Redux pattern, reducers must be pure. We relax this rule for a pragmatic reason: event handlers in this engine **can notify of new events**. This allows you to create powerful, reactive chains of logic. For example, an `enemy:take_damage` handler might check the enemy's health and, if it drops to zero, notify of a new `enemy:destroyed` event.
70
- - **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
71
64
 
72
- - **A Word of Caution**: This power comes with responsibility. It is possible to create infinite loops (e.g., event A's handler notifies of event B, and event B's handler notifies of event A). Developers should be mindful of this when designing their event chains.
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
+ ---
73
119
 
74
120
  ## Contributing
75
121
 
76
- 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 for details on how to get started.
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.4.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,20 +20,20 @@
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
- "@inglorious/store": "1.0.0",
36
- "@inglorious/utils": "1.1.0"
35
+ "@inglorious/utils": "1.1.0",
36
+ "@inglorious/store": "1.2.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "prettier": "^3.5.3",
@@ -22,7 +22,7 @@ export function camera(params) {
22
22
  params = extend(DEFAULT_PARAMS, params)
23
23
 
24
24
  return {
25
- start(entity) {
25
+ create(entity) {
26
26
  defaults(entity, params)
27
27
  entity.targetZoom = entity.zoom
28
28
  // Cache the initial size to calculate the viewport in dev mode
@@ -24,8 +24,8 @@ export function modernAcceleration(params) {
24
24
  "moveUpDown",
25
25
  ]),
26
26
 
27
- start(entity, api) {
28
- type.start?.(entity, api)
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
- start(entity, api) {
37
- type.start?.(entity, api)
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
- start(entity, api) {
31
- type.start?.(entity, api)
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
- start(entity, api) {
28
- type.start?.(entity, api)
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
- start(entity, api) {
36
- type.start?.(entity, api)
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
- start(entity, api) {
30
- type.start?.(entity, api)
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
@@ -12,7 +12,7 @@ export function fps(params) {
12
12
  params = extend(DEFAULT_PARAMS, params)
13
13
 
14
14
  return {
15
- start(entity) {
15
+ create(entity) {
16
16
  entity.dt ??= { ...params }
17
17
  },
18
18
 
@@ -9,8 +9,8 @@ export function fsm(states) {
9
9
 
10
10
  return (type) => {
11
11
  return extend(type, {
12
- start(entity, api) {
13
- type.start?.(entity, api)
12
+ create(entity, event, api) {
13
+ type.start?.(entity, event, api)
14
14
 
15
15
  entity.state ??= DEFAULT_STATE
16
16
  },
@@ -4,7 +4,7 @@ const DEFAULT_PARAMS = {
4
4
 
5
5
  export function gamepadsPoller() {
6
6
  return {
7
- start(entity) {
7
+ create(entity) {
8
8
  entity.gamepadStateCache ??= {}
9
9
  },
10
10
 
@@ -7,7 +7,7 @@ export function keyboard() {
7
7
  let currentDocument = null
8
8
 
9
9
  return {
10
- start(_, api) {
10
+ create(entity, event, api) {
11
11
  currentDocument = document.body.ownerDocument || document
12
12
 
13
13
  handleKeyDown = createKeyboardHandler("keyboardKeyDown", api)
@@ -10,7 +10,7 @@ const NO_Y = 0
10
10
 
11
11
  export function mouse() {
12
12
  return {
13
- start(entity) {
13
+ create(entity) {
14
14
  entity.collisions ??= {}
15
15
  entity.collisions.bounds ??= { shape: "point" }
16
16
  },
@@ -10,7 +10,7 @@ export function bouncy(params) {
10
10
 
11
11
  return (type) =>
12
12
  extend(type, {
13
- start(entity) {
13
+ create(entity) {
14
14
  type.start?.(entity)
15
15
  defaults(entity, params)
16
16
  },
@@ -11,8 +11,8 @@ export function clamped(params) {
11
11
 
12
12
  return (type) =>
13
13
  extend(type, {
14
- start(entity, api) {
15
- type.start?.(entity, api)
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
- start(entity, api) {
31
- type.start?.(entity, api)
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
+ ]
@@ -1,17 +1,4 @@
1
- export const ACTION_BLACKLIST = [
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) => !ACTION_BLACKLIST.includes(action.type),
20
+ predicate: (state, action) => !coreEvents.includes(action.type),
34
21
  actionCreators: {
35
22
  jump: () => ({ type: "jump", payload: { inputId: "input0" } }),
36
23
  },
@@ -3,13 +3,11 @@ import { createApi } from "@inglorious/store/api.js"
3
3
  import { createStore } from "@inglorious/store/store.js"
4
4
  import { extend } from "@inglorious/utils/data-structures/objects.js"
5
5
 
6
- import {
7
- ACTION_BLACKLIST,
8
- disconnectDevTools,
9
- initDevTools,
10
- sendAction,
11
- } from "./dev-tools.js"
6
+ import { coreEvents } from "./core-events.js"
7
+ import { disconnectDevTools, initDevTools, sendAction } from "./dev-tools.js"
12
8
  import Loop from "./loop.js"
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
- this._store = createStore({ ...this._config, systems })
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", this._api)
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", this._api)
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 }) => !ACTION_BLACKLIST.includes(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
+ }