@inglorious/engine 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/engine",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@inglorious/utils": "1.1.0",
36
- "@inglorious/store": "1.2.0"
35
+ "@inglorious/store": "2.0.0",
36
+ "@inglorious/utils": "1.1.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "prettier": "^3.5.3",
@@ -2,37 +2,48 @@ const DEFAULT_STATE = "default"
2
2
  const DEFAULT_VALUE = 0
3
3
  const COUNTER_RESET = 0
4
4
 
5
- export const Ticker = { tick }
5
+ export const Ticker = {
6
+ /**
7
+ * Ticks a counter and calls a function when a target interval is reached.
8
+ *
9
+ * @param {object} options The options for the tick.
10
+ * @param {object} options.target An object to hold the ticker's internal state.
11
+ * @param {number} options.target.speed The interval in milliseconds for the ticker to "tick".
12
+ * @param {number} options.dt The time elapsed since the last tick.
13
+ * @param {function} options.onTick The function to call when the ticker "ticks".
14
+ * @param {string} [options.state="default"] An optional state to track changes and reset the ticker.
15
+ * @param {number} [options.defaultValue=0] The default value for the target's counter.
16
+ */
17
+ tick({ target, state = DEFAULT_STATE, dt, onTick, ...options }) {
18
+ const missing = [target == null && "'target'", dt == null && "'dt'"]
19
+ .filter(Boolean)
20
+ .join(", ")
21
+ if (missing.length) {
22
+ throw new Error(`Ticker.tick is missing mandatory parameters: ${missing}`)
23
+ }
6
24
 
7
- function tick({ target, state = DEFAULT_STATE, dt, onTick, ...options }) {
8
- const missing = [target == null && "'target'", dt == null && "'dt'"]
9
- .filter(Boolean)
10
- .join(", ")
11
- if (missing.length) {
12
- throw new Error(`Ticker.tick is missing mandatory parameters: ${missing}`)
13
- }
25
+ const { speed, defaultValue = DEFAULT_VALUE } = target
14
26
 
15
- const { speed, defaultValue = DEFAULT_VALUE } = target
27
+ // The `state` property on a sprite is used to declare the desired animation.
28
+ // The Ticker needs its own internal property to track the *current* animation
29
+ // to detect when it changes. We'll use `target.animation`.
30
+ if (state !== target.animation) {
31
+ target.animation = state
32
+ target.counter = COUNTER_RESET
33
+ target.value = defaultValue
34
+ }
16
35
 
17
- // The `state` property on a sprite is used to declare the desired animation.
18
- // The Ticker needs its own internal property to track the *current* animation
19
- // to detect when it changes. We'll use `target.animation`.
20
- if (state !== target.animation) {
21
- target.animation = state
22
- target.counter = COUNTER_RESET
23
- target.value = defaultValue
24
- }
36
+ // Always ensure the public `state` property reflects the intended animation for `onTick`.
37
+ target.state = state
25
38
 
26
- // Always ensure the public `state` property reflects the intended animation for `onTick`.
27
- target.state = state
39
+ // Ensure properties are initialized on the first run for a given target.
40
+ target.counter ??= COUNTER_RESET
41
+ target.value ??= defaultValue
28
42
 
29
- // Ensure properties are initialized on the first run for a given target.
30
- target.counter ??= COUNTER_RESET
31
- target.value ??= defaultValue
32
-
33
- target.counter += dt
34
- if (target.counter >= speed) {
35
- target.counter = COUNTER_RESET
36
- onTick?.(target, dt, options)
37
- }
43
+ target.counter += dt
44
+ if (target.counter >= speed) {
45
+ target.counter = COUNTER_RESET
46
+ onTick?.(target, dt, options)
47
+ }
48
+ },
38
49
  }
@@ -10,7 +10,7 @@ export function fsm(states) {
10
10
  return (type) => {
11
11
  return extend(type, {
12
12
  create(entity, event, api) {
13
- type.start?.(entity, event, api)
13
+ type.create?.(entity, event, api)
14
14
 
15
15
  entity.state ??= DEFAULT_STATE
16
16
  },
@@ -9,7 +9,7 @@ test("it should add a finite state machine", () => {
9
9
  kitty: [
10
10
  fsm({
11
11
  default: {
12
- catMeow(entity) {
12
+ meow(entity) {
13
13
  entity.state = "meowing"
14
14
  },
15
15
  },
@@ -40,8 +40,8 @@ test("it should add a finite state machine", () => {
40
40
  }
41
41
 
42
42
  const store = createStore(config)
43
- store.notify("start")
44
- store.notify("catMeow")
43
+ store.notify("meow")
44
+ store.notify("update")
45
45
  store.update()
46
46
 
47
47
  const state = store.getState()
@@ -11,7 +11,7 @@ export function bouncy(params) {
11
11
  return (type) =>
12
12
  extend(type, {
13
13
  create(entity) {
14
- type.start?.(entity)
14
+ type.create?.(entity)
15
15
  defaults(entity, params)
16
16
  },
17
17
 
@@ -5,9 +5,11 @@ import { extend } from "@inglorious/utils/data-structures/objects.js"
5
5
 
6
6
  import { coreEvents } from "./core-events.js"
7
7
  import { disconnectDevTools, initDevTools, sendAction } from "./dev-tools.js"
8
- import Loop from "./loop.js"
9
- import { applyMiddlewares } from "./middlewares.js"
10
- import { multiplayerMiddleware } from "./multiplayer.js"
8
+ import { Loops } from "./loops/loops.js"
9
+ import { entityPoolMiddleware } from "./middlewares/entity-pool/entity-pool-middleware.js"
10
+ import { EntityPools } from "./middlewares/entity-pool/entity-pools.js"
11
+ import { applyMiddlewares } from "./middlewares/middlewares.js"
12
+ import { multiplayerMiddleware } from "./middlewares/multiplayer-middleware.js"
11
13
 
12
14
  // Default game configuration
13
15
  // loop.type specifies the type of loop to use (defaults to "animationFrame").
@@ -28,15 +30,10 @@ const DEFAULT_GAME_CONFIG = {
28
30
 
29
31
  const ONE_SECOND = 1000 // Number of milliseconds in one second.
30
32
 
31
- // Delta time for the final update call when stopping the engine.
32
- const FINAL_UPDATE_DELTA_TIME = 0 // This ensures any pending events (like 'stop') are processed before shutdown.
33
-
34
33
  /**
35
34
  * Engine class responsible for managing the game loop, state, and rendering.
36
35
  */
37
36
  export class Engine {
38
- _devMode = false
39
-
40
37
  /**
41
38
  * @param {Object} [gameConfig] - Game-specific configuration.
42
39
  * @param {Object} [renderer] - UI entity responsible for rendering. It must have a `render` method.
@@ -44,30 +41,46 @@ export class Engine {
44
41
  constructor(gameConfig) {
45
42
  this._config = extend(DEFAULT_GAME_CONFIG, gameConfig)
46
43
 
44
+ // Determine devMode from the entities config.
45
+ const devMode = this._config.entities.game?.devMode
46
+ this._devMode = devMode
47
+
48
+ // Add user-defined systems
47
49
  const systems = [...(this._config.systems ?? [])]
48
50
  if (this._config.renderer) {
49
51
  systems.push(...this._config.renderer.getSystems())
50
52
  }
51
53
 
52
- let store = createStore({ ...this._config, systems })
54
+ this._store = createStore({ ...this._config, systems })
55
+
56
+ // Create API layer, with optional methods for debugging
57
+ this._api = createApi(this._store)
58
+
59
+ this._entityPools = new EntityPools()
60
+ this._api = applyMiddlewares(entityPoolMiddleware(this._entityPools))(
61
+ this._api,
62
+ )
63
+ this._api.getAllActivePoolEntities = () =>
64
+ this._entityPools.getAllActiveEntities()
65
+
66
+ if (this._devMode) {
67
+ this._api.getEntityPoolsStats = () => this._entityPools.getStats()
68
+ }
53
69
 
54
70
  // Apply multiplayer if specified.
55
71
  const multiplayer = this._config.entities.game?.multiplayer
56
72
  if (multiplayer) {
57
- store = applyMiddlewares(multiplayerMiddleware(multiplayer))(store)
73
+ this._api = applyMiddlewares(multiplayerMiddleware(multiplayer))(
74
+ this._api,
75
+ )
58
76
  }
59
77
 
60
- this._store = store
61
- this._loop = new Loop[this._config.loop.type]()
62
- this._api = createApi(this._store)
78
+ this._loop = new Loops[this._config.loop.type]()
63
79
 
64
80
  // The renderer might need the engine instance to initialize itself (e.g., to set up DOM events).
65
81
  this._config.renderer?.init(this)
66
82
 
67
- // Determine devMode from the entities config.
68
- const devMode = this._config.entities.game?.devMode
69
- this._devMode = devMode
70
- if (devMode) {
83
+ if (this._devMode) {
71
84
  initDevTools(this._store)
72
85
  }
73
86
  }
@@ -76,7 +89,7 @@ export class Engine {
76
89
  * Starts the game engine, initializing the loop and notifying the store.
77
90
  */
78
91
  start() {
79
- this._store.notify("start")
92
+ this._api.notify("start")
80
93
  this._loop.start(this, ONE_SECOND / this._config.loop.fps)
81
94
  this.isRunning = true
82
95
  }
@@ -85,8 +98,8 @@ export class Engine {
85
98
  * Stops the game engine, halting the loop and notifying the store.
86
99
  */
87
100
  stop() {
88
- this._store.notify("stop")
89
- this._store.update(FINAL_UPDATE_DELTA_TIME, this._api)
101
+ this._api.notify("stop")
102
+ this._store.update(this._api)
90
103
  this._loop.stop()
91
104
  this.isRunning = false
92
105
  }
@@ -96,14 +109,15 @@ export class Engine {
96
109
  * @param {number} dt - Delta time since the last update in milliseconds.
97
110
  */
98
111
  update(dt) {
99
- const processedEvents = this._store.update(dt, this._api)
112
+ this._api.notify("update", dt)
113
+ const processedEvents = this._store.update(this._api)
100
114
  const state = this._store.getState()
101
115
 
102
116
  // Check for devMode changes and connect/disconnect dev tools accordingly.
103
117
  const newDevMode = state.entities.game?.devMode
104
118
  if (newDevMode !== this._devMode) {
105
119
  if (newDevMode) {
106
- initDevTools(this._store)
120
+ initDevTools(this._api)
107
121
  } else {
108
122
  disconnectDevTools()
109
123
  }
@@ -0,0 +1,15 @@
1
+ import { AnimationFrameLoop } from "./animation-frame.js"
2
+ import { ElapsedLoop } from "./elapsed.js"
3
+ import { FixedLoop } from "./fixed.js"
4
+ import { FlashLoop } from "./flash.js"
5
+ import { LagLoop } from "./lag.js"
6
+
7
+ // @see https://gameprogrammingpatterns.com/game-loop.html
8
+
9
+ export const Loops = {
10
+ flash: FlashLoop,
11
+ fixed: FixedLoop,
12
+ elapsed: ElapsedLoop,
13
+ lag: LagLoop,
14
+ animationFrame: AnimationFrameLoop,
15
+ }
@@ -0,0 +1,38 @@
1
+ import { EventMap } from "@inglorious/store/event-map"
2
+
3
+ export function entityPoolMiddleware(pools) {
4
+ return (api) => {
5
+ const types = api.getTypes()
6
+ const eventMap = new EventMap()
7
+
8
+ return (next) => (event) => {
9
+ switch (event.type) {
10
+ case "spawn": {
11
+ const entity = pools.acquire(event.payload)
12
+ const type = types[entity.type]
13
+ eventMap.addEntity(entity.id, type)
14
+ break
15
+ }
16
+
17
+ case "despawn": {
18
+ const entity = pools.recycle(event.payload)
19
+ const type = types[entity.type]
20
+ eventMap.removeEntity(entity.id, type)
21
+ break
22
+ }
23
+
24
+ default: {
25
+ const entityIds = eventMap.getEntitiesForEvent(event.type)
26
+ for (const id of entityIds) {
27
+ const entity = pools.activeEntitiesById.get(id)
28
+ const type = types[entity.type]
29
+ const handle = type[event.type]
30
+ handle?.(entity, event.payload, api)
31
+ }
32
+ }
33
+ }
34
+
35
+ return next(event)
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,40 @@
1
+ const INITIAL_ID = 0
2
+ const NOT_FOUND = -1
3
+ const ITEMS_TO_REMOVE = 1
4
+
5
+ export class EntityPool {
6
+ _activeEntities = []
7
+ _inactiveEntities = []
8
+ _nextId = INITIAL_ID
9
+
10
+ populate(factory, count) {
11
+ for (let i = 0; i < count; i++) {
12
+ this._activeEntities.push(factory())
13
+ }
14
+ }
15
+
16
+ acquire(props) {
17
+ const entity = this._inactiveEntities.pop() || {
18
+ id: `entity-${this._nextId++}`,
19
+ }
20
+ Object.assign(entity, props)
21
+ this._activeEntities.push(entity)
22
+ return entity
23
+ }
24
+
25
+ recycle(entity) {
26
+ const index = this._activeEntities.indexOf(entity)
27
+ if (index !== NOT_FOUND) {
28
+ const [entity] = this._activeEntities.splice(index, ITEMS_TO_REMOVE)
29
+ this._inactiveEntities.push(entity)
30
+ }
31
+ return entity
32
+ }
33
+
34
+ getStats() {
35
+ return {
36
+ active: this._activeEntities.length,
37
+ inactive: this._inactiveEntities.length,
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,42 @@
1
+ import { EntityPool } from "./entity-pool"
2
+
3
+ export class EntityPools {
4
+ _pools = new Map()
5
+ _activeEntitiesById = new Map()
6
+
7
+ get activeEntitiesById() {
8
+ return this._activeEntitiesById
9
+ }
10
+
11
+ acquire(props) {
12
+ this.lazyInit(props)
13
+ const entity = this._pools.get(props.type).acquire(props)
14
+ this._activeEntitiesById.set(entity.id, entity)
15
+ return entity
16
+ }
17
+
18
+ recycle(props) {
19
+ this.lazyInit(props)
20
+ const entity = this._pools.get(props.type).recycle(props)
21
+ this._activeEntitiesById.delete(entity.id)
22
+ return entity
23
+ }
24
+
25
+ getStats() {
26
+ const stats = {}
27
+ for (const [type, pool] of this._pools.entries()) {
28
+ stats[type] = pool.getStats()
29
+ }
30
+ return stats
31
+ }
32
+
33
+ lazyInit(entity) {
34
+ if (!this._pools.get(entity.type)) {
35
+ this._pools.set(entity.type, new EntityPool())
36
+ }
37
+ }
38
+
39
+ getAllActiveEntities() {
40
+ return Array.from(this._activeEntitiesById.values())
41
+ }
42
+ }
@@ -16,9 +16,9 @@ export function applyMiddlewares(...middlewares) {
16
16
 
17
17
  // The middleware API that can be passed to each middleware function.
18
18
  const api = {
19
+ ...store,
19
20
  dispatch: (...args) => dispatch(...args),
20
- getState: store.getState,
21
- setState: store.setState,
21
+ notify: (type, payload) => dispatch({ type, payload }),
22
22
  }
23
23
 
24
24
  // Create a chain of middleware functions.
@@ -27,10 +27,6 @@ export function applyMiddlewares(...middlewares) {
27
27
  // Compose the middleware chain to create the final dispatch function.
28
28
  dispatch = compose(...chain)(store.dispatch)
29
29
 
30
- return {
31
- ...store,
32
- dispatch,
33
- notify: (type, payload) => dispatch({ type, payload }),
34
- }
30
+ return api
35
31
  }
36
32
  }
@@ -1,6 +1,6 @@
1
1
  import { extend } from "@inglorious/utils/data-structures/objects.js"
2
2
 
3
- import { coreEvents } from "./core-events.js"
3
+ import { coreEvents } from "../core-events.js"
4
4
 
5
5
  // A constant for the server's WebSocket URL.
6
6
  const DEFAULT_SERVER_URL = `ws://${window.location.hostname}:3000`
@@ -0,0 +1,30 @@
1
+ import { Ticker } from "@inglorious/engine/animation/ticker.js"
2
+
3
+ const DEFAULT_SPAWN_INTERVAL = 1 // Time in seconds between spawning entities.
4
+
5
+ /**
6
+ * A system responsible for creating new entities, like asteroids, at regular intervals.
7
+ *
8
+ * @param {object} params Configuration parameters for the system.
9
+ * @param {number} [params.spawnInterval=1] The time in seconds between spawning entities.
10
+ * @param {function} params.factory A function that returns the properties for a new entity.
11
+ * @returns {object} The configured entity creator system.
12
+ */
13
+ export function entityCreator(params = {}) {
14
+ const spawnInterval = params.spawnInterval ?? DEFAULT_SPAWN_INTERVAL
15
+ const factory = params.factory
16
+
17
+ const ticker = { speed: spawnInterval }
18
+
19
+ return {
20
+ update(state, dt, api) {
21
+ Ticker.tick({
22
+ target: ticker,
23
+ dt,
24
+ onTick: () => {
25
+ api.notify("add", factory())
26
+ },
27
+ })
28
+ },
29
+ }
30
+ }
package/src/core/loop.js DELETED
@@ -1,15 +0,0 @@
1
- import { AnimationFrameLoop } from "./loops/animation-frame.js"
2
- import { ElapsedLoop } from "./loops/elapsed.js"
3
- import { FixedLoop } from "./loops/fixed.js"
4
- import { FlashLoop } from "./loops/flash.js"
5
- import { LagLoop } from "./loops/lag.js"
6
-
7
- // @see https://gameprogrammingpatterns.com/game-loop.html
8
-
9
- export default {
10
- flash: FlashLoop,
11
- fixed: FixedLoop,
12
- elapsed: ElapsedLoop,
13
- lag: LagLoop,
14
- animationFrame: AnimationFrameLoop,
15
- }