yuuna-engine 0.3.0 → 0.4.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
@@ -32,14 +32,19 @@ Then describe your game as state + render + nextState:
32
32
  ```ts
33
33
  import { runEngine } from "yuuna-engine";
34
34
 
35
+ // The shape of your game's data — whatever it takes to fully describe
36
+ // what's on screen and how it behaves
35
37
  type GameState = { cookies: number };
36
38
 
37
- runEngine<GameState>({
38
- initialState: { cookies: 0 },
39
+ // What that state looks like before anything has happened yet
40
+ const initialState: GameState = { cookies: 0 };
39
41
 
40
- // Optional — size and color the canvas from code instead of HTML/CSS
41
- canvas: { width: 960, height: 540, backgroundColor: "#0d1831" },
42
+ runEngine<GameState>({
43
+ initialState,
42
44
 
45
+ // Given the current state, what should be drawn this frame? Called
46
+ // every frame — always derive the picture from state, instead of
47
+ // reaching for the canvas directly.
43
48
  render: (state) => ({
44
49
  renderables: [
45
50
  {
@@ -59,6 +64,9 @@ runEngine<GameState>({
59
64
  ],
60
65
  }),
61
66
 
67
+ // Given the current state and something that just happened, what's the
68
+ // next state? Called once per event (a click, a frame tick, ...) — the
69
+ // only place game logic lives.
62
70
  nextState: ({ state, event }) => {
63
71
  if (event.tag === "CLICK" && event.id === "cookie") {
64
72
  return { cookies: state.cookies + 1 };
@@ -72,110 +80,41 @@ runEngine<GameState>({
72
80
  ## Concepts
73
81
 
74
82
  - **Renderables** — declarative shapes drawn each frame: `RECTANGLE`,
75
- `CIRCLE`, `TEXT`, `SPRITE`, `LINE`, `GROUP`, and `ANIMATED_SPRITE`. Give
76
- one an `id` plus `isClickable` / `isHoverable` / `trackMouseMovement` to
77
- make it interactive. `TEXT` also takes a `fontSize` (defaults to `30`).
78
- `GROUP` draws nothing itself — it's just a `position` (plus the usual
79
- `scale`/`modulate`/`layer` below) for `children` to hang off of, for
80
- grouping renderables that should move/scale/tint together without
81
- needing a shape of its own; it's never interactable, since it has no
82
- shape to hit-test. `ANIMATED_SPRITE` is `SPRITE` with an `animation`
83
- (name of one defined on that resource — see Sprites below) and an
84
- optional `timeScale` instead of a `frame` number; the engine picks the
85
- frame for you based on how long it's been playing. Unlike every other
86
- renderable, its `id` is required — that's how the engine recognizes
87
- "this is the same sprite as last frame" across renders (and switches to
88
- frame 0 if `animation` changes for that `id`), so reusing an id across
89
- two different entities will make their animations bleed together.
90
- Every renderable also takes:
91
- - `layer` — higher values render later, i.e. in front of lower ones.
92
- Defaults to `0`; renderables on the same layer keep `render()`'s order.
93
- - `scale` — `{ x, y }` multiplier on the renderable's size, anchored at
94
- its `position` (or `from`, for `LINE`). Defaults to `{ x: 1, y: 1 }`.
95
- A `CIRCLE` scaled unevenly draws (and hit-tests) as an ellipse.
96
- - `modulate` — a CSS color string that multiplies the renderable's color
97
- channel-by-channel, the same way Godot's `modulate` works — e.g.
98
- `"#808080"` halves brightness, `"#ff0000"` keeps only the red channel.
99
- - `children` — nested renderables, positioned relative to this one, like
100
- Godot's parent/child nodes. A child's `position` is added to its
101
- parent's (scaled by the parent's own `scale`), and `scale` / `modulate`
102
- / `layer` all compose down the tree — a child's effective scale is the
103
- parent's times its own, `modulate` multiplies the same way, and
104
- `layer` adds (relative to the parent's, matching Godot's default: a
105
- deeply-nested child can still end up drawn in front of an unrelated
106
- top-level renderable if its accumulated layer says so). A child is a
107
- full `Renderable`, so it can have its own `id` / `isClickable` / even
108
- its own `children`. `screenSpace: true` makes a renderable (and its
109
- whole subtree) ignore the camera below and stay fixed to the screen —
110
- for UI/HUD that shouldn't pan or zoom with the game world.
111
- - **Camera** — pass `camera: (state) => ({ x, y, zoom })` to `runEngine` to
112
- pan/zoom every world-space renderable (anything without
113
- `screenSpace: true`) as a group, the same way a `GROUP` parent works for
114
- its children — `{ x, y }` is the world position mapped to canvas
115
- `(0, 0)`, and `zoom` scales everything around that same point. It's a
116
- function of state, so the camera can follow something or react to a
117
- zoom level you're tracking yourself.
118
- - **Events** — your `nextState` function receives one `GameEvent` per call:
119
- `TIME` (frame tick with `delta`), `CLICK`, `HOVER_IN`, `HOVER_OUT`, or
120
- `MOUSE_MOVE`. The mouse-carrying ones include both `mouse` (raw canvas
121
- pixels — use for `screenSpace`/UI logic) and `worldMouse` (that same
122
- position run through the camera's inverse transform — use to
123
- place/locate world-space things, e.g. build a turret where the player
124
- clicked). With no `camera` set, `worldMouse` always equals `mouse`.
125
- - **Keyboard** — `nextState` also receives a `keyboard` map keyed by
126
- `KeyCode`-style keys (e.g. `"KeyW"`, `"ArrowLeft"`, `"Space"`), each with
127
- `isPressed` / `isJustPressed` / `isJustReleased`.
128
- - **Sprites** — pass a `resources` map of `{ src, size, slices }` to
129
- `runEngine` to load spritesheets, then reference them by id with a
130
- `SPRITE` renderable's `resourceId` and `frame`. Set `flipX: true` to
131
- mirror a sprite horizontally — useful when the art is drawn facing one
132
- direction but needs to move the other way. Add an `animations` map to a
133
- resource — `{ frames: number[], frameDuration, loop }` each — to play
134
- one with `ANIMATED_SPRITE` instead of managing `frame` by hand.
135
- - **Sound effects** — pass a `sounds` map of `{ src }` to `runEngine`, then
136
- call the `playSound(id)` function `nextState` receives to play one, e.g.
137
- `playSound("collect")` when a cookie is clicked. Calling it again while
138
- a sound is still playing overlaps a new copy instead of cutting the
139
- first one off.
140
- - **Music** — pass a `music` map of `{ src }` to `runEngine`, then use the
141
- `playMusic(id)` / `pauseMusic()` functions `nextState` receives to
142
- control a looping background track. Unlike `playSound`, only one track
143
- plays at a time and it keeps running in the background across frames
144
- instead of firing once; `pauseMusic()` leaves it where it stopped, so
145
- calling `playMusic(id)` again resumes it instead of starting over.
146
- - **Canvas** — pass `canvas: { width, height, backgroundColor }` to
147
- `runEngine` to size and color the canvas from code. All three are
148
- optional; anything you don't set falls back to the canvas element's
149
- existing HTML/CSS.
150
- - **Mechanics** — `nextState` can also be an array of small
151
- `NextStateFunction`s instead of one big function. Each one is run in
152
- order for every event, and can return:
153
- - a new state, to update to
154
- - `undefined` (or no `return` at all) — no change, but the rest of the
155
- list still runs, so a guard can just be `if (...) return;`
156
- - `STOP` (imported from `yuuna-engine`) — no change, and the rest of
157
- the list is skipped for this event, so a shared rule (like "nothing
158
- happens once the game is over") only needs to be written once
159
-
160
- ```ts
161
- import { runEngine, STOP, type NextStateFunction } from "yuuna-engine";
162
-
163
- const freezeOnGameOver: NextStateFunction<GameState> = ({ state }) => {
164
- if (state.lives <= 0) return STOP;
165
- };
166
-
167
- const moveEnemies: NextStateFunction<GameState> = ({ state, event }) => {
168
- if (event.tag === "TIME") {
169
- return { ...state, enemies: move(state.enemies, event.delta) };
170
- }
171
- };
172
-
173
- runEngine<GameState>({
174
- initialState,
175
- render,
176
- nextState: [freezeOnGameOver, moveEnemies /* ... */],
177
- });
178
- ```
83
+ `CIRCLE`, `TEXT`, `SPRITE`, `ANIMATED_SPRITE`, `LINE`, and `GROUP`. Give
84
+ one an `id` plus `isClickable`/`isHoverable` to make it interactive.
85
+ - **Events** — `nextState` receives one `GameEvent` per call: `TIME`,
86
+ `CLICK`, `HOVER_IN`, `HOVER_OUT`, `MOUSE_MOVE`, `MUSIC_END`, or a
87
+ `CUSTOM` event of a type you define yourself, for reporting things like
88
+ an async `fetch()` resolving back into your state machine.
89
+ - **Keyboard, camera, sprites & animation, sound effects & music,
90
+ canvas config, and mechanics pipelines** all follow the same idea:
91
+ small, focused props and functions `runEngine`/`nextState` take, that
92
+ compose with everything above instead of replacing it.
93
+
94
+ This README stays intentionally thin — the full concept-by-concept
95
+ reference, with every option and example, lives on the
96
+ [wiki](https://github.com/lucy-dot-exe/yuuna/wiki). The
97
+ [playground](https://lucy-dot-exe.github.io/yuuna/#playground) also has a
98
+ small, focused example for most of these you can run and edit directly.
99
+
100
+ ## Templates
101
+
102
+ Prefer a working starting point over typing the quick start out by
103
+ hand? Grab one from [`templates/`](templates):
104
+
105
+ - **[blank](templates/blank)** — a single `index.html`, zero install —
106
+ open it in a browser and it runs.
107
+ - **[npm](templates/npm)** — TypeScript + a dev server with hot reload
108
+ (via Vite), for a real local project.
109
+
110
+ ```sh
111
+ npx degit lucy-dot-exe/yuuna/templates/blank my-game
112
+ # or: npx degit lucy-dot-exe/yuuna/templates/npm my-game
113
+ ```
114
+
115
+ [`degit`](https://github.com/Rich-Harris/degit) copies the folder without
116
+ its git history — no cloning or forking the whole engine repo needed.
117
+ Each template's own README has more on running it once copied.
179
118
 
180
119
  ## Development
181
120
 
@@ -145,10 +145,18 @@ export type MouseMoveEvent = {
145
145
  y: number;
146
146
  };
147
147
  };
148
- export type GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent;
149
- export type NextStateProps<State> = {
148
+ export type MusicEndEvent = {
149
+ tag: "MUSIC_END";
150
+ id: string;
151
+ };
152
+ export type GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent | MusicEndEvent;
153
+ export type CustomGameEvent<Custom> = {
154
+ tag: "CUSTOM";
155
+ event: Custom;
156
+ };
157
+ export type NextStateProps<State, Custom = never> = {
150
158
  state: State;
151
- event: GameEvent;
159
+ event: GameEvent | CustomGameEvent<Custom>;
152
160
  keyboard: Record<KeyboardKeys, {
153
161
  isPressed: boolean;
154
162
  isJustPressed: boolean;
@@ -157,16 +165,18 @@ export type NextStateProps<State> = {
157
165
  playSound: (id: string) => void;
158
166
  playMusic: (id: string) => void;
159
167
  pauseMusic: () => void;
168
+ resumeMusic: () => void;
169
+ setMusicVolume: (volume: number) => void;
160
170
  };
161
171
  export declare const STOP: "Yuuna.STOP";
162
- export type NextStateFunction<State> = (props: NextStateProps<State>) => State | typeof STOP | undefined;
163
- export type RunEngineProps<State> = {
172
+ export type NextStateFunction<State, Custom = never> = (props: NextStateProps<State, Custom>) => State | typeof STOP | undefined;
173
+ export type RunEngineProps<State, Custom = never> = {
164
174
  initialState: State;
165
175
  render: (state: State) => {
166
176
  cursor?: "default" | "pointer";
167
177
  renderables: Renderable[];
168
178
  };
169
- nextState: NextStateFunction<State> | NextStateFunction<State>[];
179
+ nextState: NextStateFunction<State, Custom> | NextStateFunction<State, Custom>[];
170
180
  resources?: Record<string, {
171
181
  src: string;
172
182
  size: {
@@ -188,6 +198,7 @@ export type RunEngineProps<State> = {
188
198
  }>;
189
199
  music?: Record<string, {
190
200
  src: string;
201
+ loop?: boolean;
191
202
  }>;
192
203
  canvas?: {
193
204
  width?: number;
@@ -200,7 +211,9 @@ export type RunEngineProps<State> = {
200
211
  zoom: number;
201
212
  };
202
213
  };
203
- export type RunEngineFunction = <State>(props: RunEngineProps<State>) => Promise<void>;
214
+ export type RunEngineFunction = <State, Custom = never>(props: RunEngineProps<State, Custom>) => Promise<{
215
+ sendEvent: (event: Custom) => void;
216
+ }>;
204
217
  export declare const keyboardKeys: readonly ["ControlLeft", "ControlRight", "AltLeft", "AltRight", "CapsLock", "End", "Delete", "Tab", "Space", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ"];
205
218
  export type KeyboardKeys = (typeof keyboardKeys)[number];
206
219
  export type KeyboardState = Record<KeyboardKeys, boolean>;
package/lib/index.cjs CHANGED
@@ -147,6 +147,14 @@ const runEngine = async (props) => {
147
147
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
148
148
  canvas.tabIndex = 0;
149
149
  let state = props.initialState;
150
+ const events = [];
151
+ // Lets a caller report something that happened outside the render loop
152
+ // (e.g. a fetch().then() callback) back into it — the event is queued
153
+ // here and delivered to nextState as a CustomGameEvent on the next tick,
154
+ // the same as any built-in event.
155
+ const sendEvent = (event) => {
156
+ events.push({ tag: "CUSTOM", event });
157
+ };
150
158
  const resources = props.resources ?? {};
151
159
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
152
160
  const image = new Image();
@@ -184,11 +192,24 @@ const runEngine = async (props) => {
184
192
  };
185
193
  const music = props.music ?? {};
186
194
  const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
195
+ // Registered once per track at load time — fires only for a track
196
+ // whose `loop` is false, since a looping <audio> never reaches "ended"
197
+ // (the browser restarts it before the event would fire).
198
+ for (const id of getKeys(musicById)) {
199
+ musicById[id].addEventListener("ended", () => {
200
+ events.push({ tag: "MUSIC_END", id });
201
+ });
202
+ }
187
203
  // Unlike sounds, music reuses the same element instead of cloning it —
188
204
  // there's only ever one track playing, and reusing it is what lets
189
205
  // pauseMusic()/playMusic() resume from where playback left off instead
190
206
  // of starting over.
191
207
  let currentMusic = null;
208
+ // Volume is a property of each HTMLAudioElement, not global — tracked
209
+ // separately here and (re)applied on every playMusic() so switching
210
+ // tracks keeps the volume the game last set instead of resetting to
211
+ // each element's default of 1.
212
+ let musicVolume = 1;
192
213
  const playMusic = (id) => {
193
214
  const audio = musicById[id];
194
215
  if (audio === undefined) {
@@ -197,18 +218,28 @@ const runEngine = async (props) => {
197
218
  if (currentMusic !== null && currentMusic !== audio) {
198
219
  currentMusic.pause();
199
220
  }
200
- audio.loop = true;
221
+ audio.loop = music[id]?.loop ?? true;
222
+ audio.volume = musicVolume;
201
223
  audio.play();
202
224
  currentMusic = audio;
203
225
  };
204
226
  const pauseMusic = () => {
205
227
  currentMusic?.pause();
206
228
  };
229
+ const resumeMusic = () => {
230
+ currentMusic?.play();
231
+ };
232
+ const setMusicVolume = (volume) => {
233
+ musicVolume = Math.min(1, Math.max(0, volume));
234
+ if (currentMusic !== null) {
235
+ currentMusic.volume = musicVolume;
236
+ }
237
+ };
207
238
  // A newer runEngine() call started while this one was still loading
208
239
  // resources (e.g. a spritesheet) — abandon this run instead of setting
209
240
  // up a second, orphaned render loop alongside the newer one.
210
241
  if (runId !== latestRunId) {
211
- return;
242
+ return { sendEvent };
212
243
  }
213
244
  context.imageSmoothingEnabled = false;
214
245
  // An offscreen 1x1 canvas used only to resolve a CSS color string (a
@@ -498,7 +529,6 @@ const runEngine = async (props) => {
498
529
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
499
530
  let lastFrame = Date.now();
500
531
  let hoveredId = null;
501
- const events = [];
502
532
  canvas.addEventListener("click", (ev) => {
503
533
  const mouse = getCanvasPosition(ev);
504
534
  if (hoveredId === null)
@@ -628,7 +658,16 @@ const runEngine = async (props) => {
628
658
  };
629
659
  });
630
660
  for (const nextState of nextStateFns) {
631
- const result = nextState({ state, event, keyboard, playSound, playMusic, pauseMusic });
661
+ const result = nextState({
662
+ state,
663
+ event,
664
+ keyboard,
665
+ playSound,
666
+ playMusic,
667
+ pauseMusic,
668
+ resumeMusic,
669
+ setMusicVolume,
670
+ });
632
671
  // STOP stops the rest of the list from running for this event,
633
672
  // instead of every later mechanic needing to repeat the same
634
673
  // guard. undefined just means this mechanic made no change, so
@@ -747,6 +786,7 @@ const runEngine = async (props) => {
747
786
  resetCanvas = () => {
748
787
  clearInterval(intervalId);
749
788
  };
789
+ return { sendEvent };
750
790
  };
751
791
 
752
792
  exports.STOP = STOP;
package/lib/index.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import { runEngine } from "./engine/runEngine";
2
2
  import { STOP } from "./engine/types";
3
3
  export { runEngine, STOP };
4
- export type { NextStateFunction, NextStateProps, Renderable, GameEvent } from "./engine/types";
4
+ export type { NextStateFunction, NextStateProps, Renderable, GameEvent, CustomGameEvent, } from "./engine/types";
package/lib/index.js CHANGED
@@ -145,6 +145,14 @@ const runEngine = async (props) => {
145
145
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
146
146
  canvas.tabIndex = 0;
147
147
  let state = props.initialState;
148
+ const events = [];
149
+ // Lets a caller report something that happened outside the render loop
150
+ // (e.g. a fetch().then() callback) back into it — the event is queued
151
+ // here and delivered to nextState as a CustomGameEvent on the next tick,
152
+ // the same as any built-in event.
153
+ const sendEvent = (event) => {
154
+ events.push({ tag: "CUSTOM", event });
155
+ };
148
156
  const resources = props.resources ?? {};
149
157
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
150
158
  const image = new Image();
@@ -182,11 +190,24 @@ const runEngine = async (props) => {
182
190
  };
183
191
  const music = props.music ?? {};
184
192
  const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
193
+ // Registered once per track at load time — fires only for a track
194
+ // whose `loop` is false, since a looping <audio> never reaches "ended"
195
+ // (the browser restarts it before the event would fire).
196
+ for (const id of getKeys(musicById)) {
197
+ musicById[id].addEventListener("ended", () => {
198
+ events.push({ tag: "MUSIC_END", id });
199
+ });
200
+ }
185
201
  // Unlike sounds, music reuses the same element instead of cloning it —
186
202
  // there's only ever one track playing, and reusing it is what lets
187
203
  // pauseMusic()/playMusic() resume from where playback left off instead
188
204
  // of starting over.
189
205
  let currentMusic = null;
206
+ // Volume is a property of each HTMLAudioElement, not global — tracked
207
+ // separately here and (re)applied on every playMusic() so switching
208
+ // tracks keeps the volume the game last set instead of resetting to
209
+ // each element's default of 1.
210
+ let musicVolume = 1;
190
211
  const playMusic = (id) => {
191
212
  const audio = musicById[id];
192
213
  if (audio === undefined) {
@@ -195,18 +216,28 @@ const runEngine = async (props) => {
195
216
  if (currentMusic !== null && currentMusic !== audio) {
196
217
  currentMusic.pause();
197
218
  }
198
- audio.loop = true;
219
+ audio.loop = music[id]?.loop ?? true;
220
+ audio.volume = musicVolume;
199
221
  audio.play();
200
222
  currentMusic = audio;
201
223
  };
202
224
  const pauseMusic = () => {
203
225
  currentMusic?.pause();
204
226
  };
227
+ const resumeMusic = () => {
228
+ currentMusic?.play();
229
+ };
230
+ const setMusicVolume = (volume) => {
231
+ musicVolume = Math.min(1, Math.max(0, volume));
232
+ if (currentMusic !== null) {
233
+ currentMusic.volume = musicVolume;
234
+ }
235
+ };
205
236
  // A newer runEngine() call started while this one was still loading
206
237
  // resources (e.g. a spritesheet) — abandon this run instead of setting
207
238
  // up a second, orphaned render loop alongside the newer one.
208
239
  if (runId !== latestRunId) {
209
- return;
240
+ return { sendEvent };
210
241
  }
211
242
  context.imageSmoothingEnabled = false;
212
243
  // An offscreen 1x1 canvas used only to resolve a CSS color string (a
@@ -496,7 +527,6 @@ const runEngine = async (props) => {
496
527
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
497
528
  let lastFrame = Date.now();
498
529
  let hoveredId = null;
499
- const events = [];
500
530
  canvas.addEventListener("click", (ev) => {
501
531
  const mouse = getCanvasPosition(ev);
502
532
  if (hoveredId === null)
@@ -626,7 +656,16 @@ const runEngine = async (props) => {
626
656
  };
627
657
  });
628
658
  for (const nextState of nextStateFns) {
629
- const result = nextState({ state, event, keyboard, playSound, playMusic, pauseMusic });
659
+ const result = nextState({
660
+ state,
661
+ event,
662
+ keyboard,
663
+ playSound,
664
+ playMusic,
665
+ pauseMusic,
666
+ resumeMusic,
667
+ setMusicVolume,
668
+ });
630
669
  // STOP stops the rest of the list from running for this event,
631
670
  // instead of every later mechanic needing to repeat the same
632
671
  // guard. undefined just means this mechanic made no change, so
@@ -745,6 +784,7 @@ const runEngine = async (props) => {
745
784
  resetCanvas = () => {
746
785
  clearInterval(intervalId);
747
786
  };
787
+ return { sendEvent };
748
788
  };
749
789
 
750
790
  export { STOP, runEngine };
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yuuna-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A lightweight, state-machine-based TypeScript game engine for quick prototypes. Runs directly in the browser.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.cjs",