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 +47 -108
- package/lib/engine/types.d.ts +20 -7
- package/lib/index.cjs +44 -4
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +44 -4
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
38
|
-
|
|
39
|
+
// What that state looks like before anything has happened yet
|
|
40
|
+
const initialState: GameState = { cookies: 0 };
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
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`, `
|
|
76
|
-
one an `id` plus `isClickable`
|
|
77
|
-
|
|
78
|
-
`
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
package/lib/engine/types.d.ts
CHANGED
|
@@ -145,10 +145,18 @@ export type MouseMoveEvent = {
|
|
|
145
145
|
y: number;
|
|
146
146
|
};
|
|
147
147
|
};
|
|
148
|
-
export type
|
|
149
|
-
|
|
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<
|
|
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({
|
|
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({
|
|
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