yuuna-engine 0.1.0 → 0.2.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 +8 -1
- package/lib/engine/types.d.ts +29 -4
- package/lib/index.cjs +94 -28
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.js +94 -29
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npm install yuuna-engine
|
|
|
22
22
|
Add a canvas with `id="yuuna"` to your page:
|
|
23
23
|
|
|
24
24
|
```html
|
|
25
|
-
<canvas id="yuuna"
|
|
25
|
+
<canvas id="yuuna"></canvas>
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
Then describe your game as state + render + nextState:
|
|
@@ -35,6 +35,9 @@ type GameState = { cookies: number };
|
|
|
35
35
|
runEngine<GameState>({
|
|
36
36
|
initialState: { cookies: 0 },
|
|
37
37
|
|
|
38
|
+
// Optional — size and color the canvas from code instead of HTML/CSS
|
|
39
|
+
canvas: { width: 960, height: 540, backgroundColor: "#0d1831" },
|
|
40
|
+
|
|
38
41
|
render: (state) => ({
|
|
39
42
|
renderables: [
|
|
40
43
|
{
|
|
@@ -78,6 +81,10 @@ runEngine<GameState>({
|
|
|
78
81
|
- **Sprites** — pass a `resources` map of `{ src, size, slices }` to
|
|
79
82
|
`runEngine` to load spritesheets, then reference them by id with a
|
|
80
83
|
`SPRITE` renderable's `resourceId` and `frame`.
|
|
84
|
+
- **Canvas** — pass `canvas: { width, height, backgroundColor }` to
|
|
85
|
+
`runEngine` to size and color the canvas from code. All three are
|
|
86
|
+
optional; anything you don't set falls back to the canvas element's
|
|
87
|
+
existing HTML/CSS.
|
|
81
88
|
|
|
82
89
|
## Development
|
|
83
90
|
|
package/lib/engine/types.d.ts
CHANGED
|
@@ -54,12 +54,30 @@ export type SpriteRenderable = {
|
|
|
54
54
|
frame: number;
|
|
55
55
|
scale?: number;
|
|
56
56
|
opacity?: number;
|
|
57
|
+
flipX?: boolean;
|
|
57
58
|
id?: string;
|
|
58
59
|
isClickable?: boolean;
|
|
59
60
|
isHoverable?: boolean;
|
|
60
61
|
trackMouseMovement?: boolean;
|
|
61
62
|
};
|
|
62
|
-
export type
|
|
63
|
+
export type LineRenderable = {
|
|
64
|
+
type: "LINE";
|
|
65
|
+
from: {
|
|
66
|
+
x: number;
|
|
67
|
+
y: number;
|
|
68
|
+
};
|
|
69
|
+
to: {
|
|
70
|
+
x: number;
|
|
71
|
+
y: number;
|
|
72
|
+
};
|
|
73
|
+
color: string;
|
|
74
|
+
width?: number;
|
|
75
|
+
id?: string;
|
|
76
|
+
isClickable?: boolean;
|
|
77
|
+
isHoverable?: boolean;
|
|
78
|
+
trackMouseMovement?: boolean;
|
|
79
|
+
};
|
|
80
|
+
export type Renderable = RectangleRenderable | CircleRenderable | SpriteRenderable | TextRenderable | LineRenderable;
|
|
63
81
|
export type TimeEvent = {
|
|
64
82
|
tag: "TIME";
|
|
65
83
|
delta: number;
|
|
@@ -97,7 +115,7 @@ export type MouseMoveEvent = {
|
|
|
97
115
|
};
|
|
98
116
|
};
|
|
99
117
|
export type GameEvent = TimeEvent | ClickEvent | HoverInEvent | HoverOutEvent | MouseMoveEvent;
|
|
100
|
-
type NextStateProps<State> = {
|
|
118
|
+
export type NextStateProps<State> = {
|
|
101
119
|
state: State;
|
|
102
120
|
event: GameEvent;
|
|
103
121
|
keyboard: Record<KeyboardKeys, {
|
|
@@ -106,13 +124,15 @@ type NextStateProps<State> = {
|
|
|
106
124
|
isJustReleased: boolean;
|
|
107
125
|
}>;
|
|
108
126
|
};
|
|
127
|
+
export declare const STOP: "Yuuna.STOP";
|
|
128
|
+
export type NextStateFunction<State> = (props: NextStateProps<State>) => State | typeof STOP | undefined;
|
|
109
129
|
export type RunEngineProps<State> = {
|
|
110
130
|
initialState: State;
|
|
111
131
|
render: (state: State) => {
|
|
112
132
|
cursor?: "default" | "pointer";
|
|
113
133
|
renderables: Renderable[];
|
|
114
134
|
};
|
|
115
|
-
nextState:
|
|
135
|
+
nextState: NextStateFunction<State> | NextStateFunction<State>[];
|
|
116
136
|
resources?: Record<string, {
|
|
117
137
|
src: string;
|
|
118
138
|
size: {
|
|
@@ -124,6 +144,11 @@ export type RunEngineProps<State> = {
|
|
|
124
144
|
horizontal: number;
|
|
125
145
|
};
|
|
126
146
|
}>;
|
|
147
|
+
canvas?: {
|
|
148
|
+
width?: number;
|
|
149
|
+
height?: number;
|
|
150
|
+
backgroundColor?: string;
|
|
151
|
+
};
|
|
127
152
|
};
|
|
128
153
|
export type RunEngineFunction = <State>(props: RunEngineProps<State>) => Promise<void>;
|
|
129
154
|
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"];
|
|
@@ -131,5 +156,5 @@ export type KeyboardKeys = (typeof keyboardKeys)[number];
|
|
|
131
156
|
export type KeyboardState = Record<KeyboardKeys, boolean>;
|
|
132
157
|
export declare var Yuuna: {
|
|
133
158
|
runEngine: RunEngineFunction;
|
|
159
|
+
STOP: typeof STOP;
|
|
134
160
|
};
|
|
135
|
-
export {};
|
package/lib/index.cjs
CHANGED
|
@@ -4,13 +4,6 @@ function exhaust(value) {
|
|
|
4
4
|
throw new Error(`${value} was expected to be never.`);
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
const CONSTANTS = {
|
|
8
|
-
WINDOW: {
|
|
9
|
-
WIDTH: 960,
|
|
10
|
-
HEIGHT: 540,
|
|
11
|
-
},
|
|
12
|
-
};
|
|
13
|
-
|
|
14
7
|
function unsafe(input) {
|
|
15
8
|
//@ts-ignore
|
|
16
9
|
return input;
|
|
@@ -44,6 +37,11 @@ const createRecord = (keys, fn) => {
|
|
|
44
37
|
return mapped;
|
|
45
38
|
};
|
|
46
39
|
|
|
40
|
+
// Return this from a NextStateFunction to stop the rest of a nextState
|
|
41
|
+
// list from running for this event, instead of every mechanic after it
|
|
42
|
+
// needing to repeat the same guard (only meaningful when nextState is an
|
|
43
|
+
// array — see RunEngineProps.nextState below).
|
|
44
|
+
const STOP = "Yuuna.STOP";
|
|
47
45
|
const keyboardKeys = [
|
|
48
46
|
"ControlLeft",
|
|
49
47
|
"ControlRight",
|
|
@@ -108,7 +106,9 @@ const keyboardKeys = [
|
|
|
108
106
|
];
|
|
109
107
|
|
|
110
108
|
let resetCanvas = null;
|
|
109
|
+
let latestRunId = 0;
|
|
111
110
|
const runEngine = async (props) => {
|
|
111
|
+
const runId = ++latestRunId;
|
|
112
112
|
resetCanvas?.();
|
|
113
113
|
const canvas = window.document.getElementById("yuuna");
|
|
114
114
|
if (canvas === null) {
|
|
@@ -121,6 +121,18 @@ const runEngine = async (props) => {
|
|
|
121
121
|
if (context === null) {
|
|
122
122
|
throw new Error("Failed to get context from canvas");
|
|
123
123
|
}
|
|
124
|
+
if (props.canvas?.width !== undefined) {
|
|
125
|
+
canvas.width = props.canvas.width;
|
|
126
|
+
}
|
|
127
|
+
if (props.canvas?.height !== undefined) {
|
|
128
|
+
canvas.height = props.canvas.height;
|
|
129
|
+
}
|
|
130
|
+
if (props.canvas?.backgroundColor !== undefined) {
|
|
131
|
+
canvas.style.backgroundColor = props.canvas.backgroundColor;
|
|
132
|
+
}
|
|
133
|
+
// Make the canvas focusable so keyboard input is scoped to it instead of
|
|
134
|
+
// leaking to the rest of the page (e.g. arrow keys scrolling the window).
|
|
135
|
+
canvas.tabIndex = 0;
|
|
124
136
|
let state = props.initialState;
|
|
125
137
|
const resources = props.resources ?? {};
|
|
126
138
|
const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
|
|
@@ -137,9 +149,16 @@ const runEngine = async (props) => {
|
|
|
137
149
|
});
|
|
138
150
|
};
|
|
139
151
|
}));
|
|
152
|
+
// A newer runEngine() call started while this one was still loading
|
|
153
|
+
// resources (e.g. a spritesheet) — abandon this run instead of setting
|
|
154
|
+
// up a second, orphaned render loop alongside the newer one.
|
|
155
|
+
if (runId !== latestRunId) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
140
158
|
context.imageSmoothingEnabled = false;
|
|
141
159
|
function getFocusedElement(position, r) {
|
|
142
160
|
const isNonInteractable = r.type === "TEXT" ||
|
|
161
|
+
r.type === "LINE" ||
|
|
143
162
|
((r.isHoverable === undefined || !r.isHoverable) &&
|
|
144
163
|
(r.isClickable === undefined || !r.isClickable));
|
|
145
164
|
if (isNonInteractable) {
|
|
@@ -180,14 +199,26 @@ const runEngine = async (props) => {
|
|
|
180
199
|
}
|
|
181
200
|
exhaust(r);
|
|
182
201
|
}
|
|
202
|
+
// ev.offsetX/offsetY are in CSS-rendered pixels, which differ from the
|
|
203
|
+
// canvas's drawing-buffer resolution whenever it's displayed at a
|
|
204
|
+
// different size (e.g. scaled down to fit its container). Renderable
|
|
205
|
+
// positions are all in buffer coordinates, so mouse coordinates need
|
|
206
|
+
// the same conversion to line up.
|
|
207
|
+
const getCanvasPosition = (ev) => {
|
|
208
|
+
return {
|
|
209
|
+
x: (ev.offsetX * canvas.width) / canvas.clientWidth,
|
|
210
|
+
y: (ev.offsetY * canvas.height) / canvas.clientHeight,
|
|
211
|
+
};
|
|
212
|
+
};
|
|
183
213
|
const updateState = (updateFn) => {
|
|
184
214
|
state = updateFn(state);
|
|
185
215
|
};
|
|
216
|
+
const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
|
|
186
217
|
let lastFrame = Date.now();
|
|
187
218
|
let hoveredId = null;
|
|
188
219
|
const events = [];
|
|
189
220
|
canvas.addEventListener("click", (ev) => {
|
|
190
|
-
const mouse =
|
|
221
|
+
const mouse = getCanvasPosition(ev);
|
|
191
222
|
if (hoveredId === null)
|
|
192
223
|
return;
|
|
193
224
|
const { renderables } = props.render(state);
|
|
@@ -205,20 +236,25 @@ const runEngine = async (props) => {
|
|
|
205
236
|
const currentState = {
|
|
206
237
|
keyboardState: { ...initialState.keyboardState },
|
|
207
238
|
};
|
|
208
|
-
|
|
239
|
+
canvas.addEventListener("keydown", (event) => {
|
|
209
240
|
const pressedKey = keyboardKeys.find((key) => key === event.code);
|
|
210
241
|
if (pressedKey !== undefined) {
|
|
242
|
+
// Stop tracked keys (arrows, space, ...) from also scrolling the
|
|
243
|
+
// page or triggering other browser defaults while the canvas is
|
|
244
|
+
// focused.
|
|
245
|
+
event.preventDefault();
|
|
211
246
|
currentState.keyboardState[pressedKey] = true;
|
|
212
247
|
}
|
|
213
248
|
});
|
|
214
|
-
|
|
249
|
+
canvas.addEventListener("keyup", (event) => {
|
|
215
250
|
const releasedKey = keyboardKeys.find((key) => key === event.code);
|
|
216
251
|
if (releasedKey !== undefined) {
|
|
252
|
+
event.preventDefault();
|
|
217
253
|
currentState.keyboardState[releasedKey] = false;
|
|
218
254
|
}
|
|
219
255
|
});
|
|
220
256
|
canvas.addEventListener("mousemove", (ev) => {
|
|
221
|
-
const mouse =
|
|
257
|
+
const mouse = getCanvasPosition(ev);
|
|
222
258
|
const { renderables } = props.render(state);
|
|
223
259
|
const hovered = [...renderables]
|
|
224
260
|
.reverse()
|
|
@@ -235,7 +271,7 @@ const runEngine = async (props) => {
|
|
|
235
271
|
hoveredId = hovered === undefined ? null : hovered.id ?? null;
|
|
236
272
|
});
|
|
237
273
|
canvas.addEventListener("mousemove", (ev) => {
|
|
238
|
-
const mouse =
|
|
274
|
+
const mouse = getCanvasPosition(ev);
|
|
239
275
|
if (hoveredId === null) {
|
|
240
276
|
return;
|
|
241
277
|
}
|
|
@@ -251,21 +287,30 @@ const runEngine = async (props) => {
|
|
|
251
287
|
const delta = now - lastFrame;
|
|
252
288
|
events.push({ tag: "TIME", delta: delta });
|
|
253
289
|
for (const event of events) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
290
|
+
const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
|
|
291
|
+
const isPressed = currentState.keyboardState[key];
|
|
292
|
+
return {
|
|
293
|
+
isPressed,
|
|
294
|
+
isJustPressed: isPressed && !previouslyPressed,
|
|
295
|
+
isJustReleased: !isPressed && previouslyPressed,
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
for (const nextState of nextStateFns) {
|
|
299
|
+
const result = nextState({ state, event, keyboard });
|
|
300
|
+
// STOP stops the rest of the list from running for this event,
|
|
301
|
+
// instead of every later mechanic needing to repeat the same
|
|
302
|
+
// guard. undefined just means this mechanic made no change, so
|
|
303
|
+
// the rest of the list still runs.
|
|
304
|
+
if (result === STOP) {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
if (result !== undefined) {
|
|
308
|
+
updateState(() => result);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
266
311
|
}
|
|
267
312
|
events.splice(0, events.length);
|
|
268
|
-
context.clearRect(0, 0,
|
|
313
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
269
314
|
const { cursor, renderables } = props.render(state);
|
|
270
315
|
canvas.style.cursor = cursor ?? "default";
|
|
271
316
|
for (const renderable of renderables) {
|
|
@@ -291,18 +336,38 @@ const runEngine = async (props) => {
|
|
|
291
336
|
continue;
|
|
292
337
|
}
|
|
293
338
|
if (renderable.type === "SPRITE") {
|
|
294
|
-
const { scale = 1, opacity = 1 } = renderable;
|
|
339
|
+
const { scale = 1, opacity = 1, flipX = false } = renderable;
|
|
295
340
|
const resource = resourceById[renderable.resourceId];
|
|
296
341
|
const frame = {
|
|
297
342
|
x: renderable.frame % resource.slices.horizontal,
|
|
298
|
-
y: Math.floor(renderable.frame / resource.slices.
|
|
343
|
+
y: Math.floor(renderable.frame / resource.slices.horizontal) %
|
|
299
344
|
resource.slices.vertical,
|
|
300
345
|
};
|
|
346
|
+
const destWidth = resource.size.width * scale;
|
|
347
|
+
const destHeight = resource.size.height * scale;
|
|
301
348
|
context.globalAlpha = opacity;
|
|
302
|
-
|
|
349
|
+
if (flipX) {
|
|
350
|
+
context.save();
|
|
351
|
+
context.translate(renderable.position.x + destWidth, renderable.position.y);
|
|
352
|
+
context.scale(-1, 1);
|
|
353
|
+
context.drawImage(resource.image, frame.x * resource.size.width, frame.y * resource.size.height, resource.size.width, resource.size.height, 0, 0, destWidth, destHeight);
|
|
354
|
+
context.restore();
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
context.drawImage(resource.image, frame.x * resource.size.width, frame.y * resource.size.height, resource.size.width, resource.size.height, renderable.position.x, renderable.position.y, destWidth, destHeight);
|
|
358
|
+
}
|
|
303
359
|
context.globalAlpha = 1;
|
|
304
360
|
continue;
|
|
305
361
|
}
|
|
362
|
+
if (renderable.type === "LINE") {
|
|
363
|
+
context.strokeStyle = renderable.color;
|
|
364
|
+
context.lineWidth = renderable.width ?? 2;
|
|
365
|
+
context.beginPath();
|
|
366
|
+
context.moveTo(renderable.from.x, renderable.from.y);
|
|
367
|
+
context.lineTo(renderable.to.x, renderable.to.y);
|
|
368
|
+
context.stroke();
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
306
371
|
exhaust(renderable);
|
|
307
372
|
}
|
|
308
373
|
lastFrame = now;
|
|
@@ -313,5 +378,6 @@ const runEngine = async (props) => {
|
|
|
313
378
|
};
|
|
314
379
|
};
|
|
315
380
|
|
|
381
|
+
exports.STOP = STOP;
|
|
316
382
|
exports.runEngine = runEngine;
|
|
317
383
|
//# sourceMappingURL=index.cjs.map
|
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
package/lib/index.js
CHANGED
|
@@ -2,13 +2,6 @@ function exhaust(value) {
|
|
|
2
2
|
throw new Error(`${value} was expected to be never.`);
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
const CONSTANTS = {
|
|
6
|
-
WINDOW: {
|
|
7
|
-
WIDTH: 960,
|
|
8
|
-
HEIGHT: 540,
|
|
9
|
-
},
|
|
10
|
-
};
|
|
11
|
-
|
|
12
5
|
function unsafe(input) {
|
|
13
6
|
//@ts-ignore
|
|
14
7
|
return input;
|
|
@@ -42,6 +35,11 @@ const createRecord = (keys, fn) => {
|
|
|
42
35
|
return mapped;
|
|
43
36
|
};
|
|
44
37
|
|
|
38
|
+
// Return this from a NextStateFunction to stop the rest of a nextState
|
|
39
|
+
// list from running for this event, instead of every mechanic after it
|
|
40
|
+
// needing to repeat the same guard (only meaningful when nextState is an
|
|
41
|
+
// array — see RunEngineProps.nextState below).
|
|
42
|
+
const STOP = "Yuuna.STOP";
|
|
45
43
|
const keyboardKeys = [
|
|
46
44
|
"ControlLeft",
|
|
47
45
|
"ControlRight",
|
|
@@ -106,7 +104,9 @@ const keyboardKeys = [
|
|
|
106
104
|
];
|
|
107
105
|
|
|
108
106
|
let resetCanvas = null;
|
|
107
|
+
let latestRunId = 0;
|
|
109
108
|
const runEngine = async (props) => {
|
|
109
|
+
const runId = ++latestRunId;
|
|
110
110
|
resetCanvas?.();
|
|
111
111
|
const canvas = window.document.getElementById("yuuna");
|
|
112
112
|
if (canvas === null) {
|
|
@@ -119,6 +119,18 @@ const runEngine = async (props) => {
|
|
|
119
119
|
if (context === null) {
|
|
120
120
|
throw new Error("Failed to get context from canvas");
|
|
121
121
|
}
|
|
122
|
+
if (props.canvas?.width !== undefined) {
|
|
123
|
+
canvas.width = props.canvas.width;
|
|
124
|
+
}
|
|
125
|
+
if (props.canvas?.height !== undefined) {
|
|
126
|
+
canvas.height = props.canvas.height;
|
|
127
|
+
}
|
|
128
|
+
if (props.canvas?.backgroundColor !== undefined) {
|
|
129
|
+
canvas.style.backgroundColor = props.canvas.backgroundColor;
|
|
130
|
+
}
|
|
131
|
+
// Make the canvas focusable so keyboard input is scoped to it instead of
|
|
132
|
+
// leaking to the rest of the page (e.g. arrow keys scrolling the window).
|
|
133
|
+
canvas.tabIndex = 0;
|
|
122
134
|
let state = props.initialState;
|
|
123
135
|
const resources = props.resources ?? {};
|
|
124
136
|
const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
|
|
@@ -135,9 +147,16 @@ const runEngine = async (props) => {
|
|
|
135
147
|
});
|
|
136
148
|
};
|
|
137
149
|
}));
|
|
150
|
+
// A newer runEngine() call started while this one was still loading
|
|
151
|
+
// resources (e.g. a spritesheet) — abandon this run instead of setting
|
|
152
|
+
// up a second, orphaned render loop alongside the newer one.
|
|
153
|
+
if (runId !== latestRunId) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
138
156
|
context.imageSmoothingEnabled = false;
|
|
139
157
|
function getFocusedElement(position, r) {
|
|
140
158
|
const isNonInteractable = r.type === "TEXT" ||
|
|
159
|
+
r.type === "LINE" ||
|
|
141
160
|
((r.isHoverable === undefined || !r.isHoverable) &&
|
|
142
161
|
(r.isClickable === undefined || !r.isClickable));
|
|
143
162
|
if (isNonInteractable) {
|
|
@@ -178,14 +197,26 @@ const runEngine = async (props) => {
|
|
|
178
197
|
}
|
|
179
198
|
exhaust(r);
|
|
180
199
|
}
|
|
200
|
+
// ev.offsetX/offsetY are in CSS-rendered pixels, which differ from the
|
|
201
|
+
// canvas's drawing-buffer resolution whenever it's displayed at a
|
|
202
|
+
// different size (e.g. scaled down to fit its container). Renderable
|
|
203
|
+
// positions are all in buffer coordinates, so mouse coordinates need
|
|
204
|
+
// the same conversion to line up.
|
|
205
|
+
const getCanvasPosition = (ev) => {
|
|
206
|
+
return {
|
|
207
|
+
x: (ev.offsetX * canvas.width) / canvas.clientWidth,
|
|
208
|
+
y: (ev.offsetY * canvas.height) / canvas.clientHeight,
|
|
209
|
+
};
|
|
210
|
+
};
|
|
181
211
|
const updateState = (updateFn) => {
|
|
182
212
|
state = updateFn(state);
|
|
183
213
|
};
|
|
214
|
+
const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
|
|
184
215
|
let lastFrame = Date.now();
|
|
185
216
|
let hoveredId = null;
|
|
186
217
|
const events = [];
|
|
187
218
|
canvas.addEventListener("click", (ev) => {
|
|
188
|
-
const mouse =
|
|
219
|
+
const mouse = getCanvasPosition(ev);
|
|
189
220
|
if (hoveredId === null)
|
|
190
221
|
return;
|
|
191
222
|
const { renderables } = props.render(state);
|
|
@@ -203,20 +234,25 @@ const runEngine = async (props) => {
|
|
|
203
234
|
const currentState = {
|
|
204
235
|
keyboardState: { ...initialState.keyboardState },
|
|
205
236
|
};
|
|
206
|
-
|
|
237
|
+
canvas.addEventListener("keydown", (event) => {
|
|
207
238
|
const pressedKey = keyboardKeys.find((key) => key === event.code);
|
|
208
239
|
if (pressedKey !== undefined) {
|
|
240
|
+
// Stop tracked keys (arrows, space, ...) from also scrolling the
|
|
241
|
+
// page or triggering other browser defaults while the canvas is
|
|
242
|
+
// focused.
|
|
243
|
+
event.preventDefault();
|
|
209
244
|
currentState.keyboardState[pressedKey] = true;
|
|
210
245
|
}
|
|
211
246
|
});
|
|
212
|
-
|
|
247
|
+
canvas.addEventListener("keyup", (event) => {
|
|
213
248
|
const releasedKey = keyboardKeys.find((key) => key === event.code);
|
|
214
249
|
if (releasedKey !== undefined) {
|
|
250
|
+
event.preventDefault();
|
|
215
251
|
currentState.keyboardState[releasedKey] = false;
|
|
216
252
|
}
|
|
217
253
|
});
|
|
218
254
|
canvas.addEventListener("mousemove", (ev) => {
|
|
219
|
-
const mouse =
|
|
255
|
+
const mouse = getCanvasPosition(ev);
|
|
220
256
|
const { renderables } = props.render(state);
|
|
221
257
|
const hovered = [...renderables]
|
|
222
258
|
.reverse()
|
|
@@ -233,7 +269,7 @@ const runEngine = async (props) => {
|
|
|
233
269
|
hoveredId = hovered === undefined ? null : hovered.id ?? null;
|
|
234
270
|
});
|
|
235
271
|
canvas.addEventListener("mousemove", (ev) => {
|
|
236
|
-
const mouse =
|
|
272
|
+
const mouse = getCanvasPosition(ev);
|
|
237
273
|
if (hoveredId === null) {
|
|
238
274
|
return;
|
|
239
275
|
}
|
|
@@ -249,21 +285,30 @@ const runEngine = async (props) => {
|
|
|
249
285
|
const delta = now - lastFrame;
|
|
250
286
|
events.push({ tag: "TIME", delta: delta });
|
|
251
287
|
for (const event of events) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
288
|
+
const keyboard = iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
|
|
289
|
+
const isPressed = currentState.keyboardState[key];
|
|
290
|
+
return {
|
|
291
|
+
isPressed,
|
|
292
|
+
isJustPressed: isPressed && !previouslyPressed,
|
|
293
|
+
isJustReleased: !isPressed && previouslyPressed,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
for (const nextState of nextStateFns) {
|
|
297
|
+
const result = nextState({ state, event, keyboard });
|
|
298
|
+
// STOP stops the rest of the list from running for this event,
|
|
299
|
+
// instead of every later mechanic needing to repeat the same
|
|
300
|
+
// guard. undefined just means this mechanic made no change, so
|
|
301
|
+
// the rest of the list still runs.
|
|
302
|
+
if (result === STOP) {
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
if (result !== undefined) {
|
|
306
|
+
updateState(() => result);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
264
309
|
}
|
|
265
310
|
events.splice(0, events.length);
|
|
266
|
-
context.clearRect(0, 0,
|
|
311
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
267
312
|
const { cursor, renderables } = props.render(state);
|
|
268
313
|
canvas.style.cursor = cursor ?? "default";
|
|
269
314
|
for (const renderable of renderables) {
|
|
@@ -289,18 +334,38 @@ const runEngine = async (props) => {
|
|
|
289
334
|
continue;
|
|
290
335
|
}
|
|
291
336
|
if (renderable.type === "SPRITE") {
|
|
292
|
-
const { scale = 1, opacity = 1 } = renderable;
|
|
337
|
+
const { scale = 1, opacity = 1, flipX = false } = renderable;
|
|
293
338
|
const resource = resourceById[renderable.resourceId];
|
|
294
339
|
const frame = {
|
|
295
340
|
x: renderable.frame % resource.slices.horizontal,
|
|
296
|
-
y: Math.floor(renderable.frame / resource.slices.
|
|
341
|
+
y: Math.floor(renderable.frame / resource.slices.horizontal) %
|
|
297
342
|
resource.slices.vertical,
|
|
298
343
|
};
|
|
344
|
+
const destWidth = resource.size.width * scale;
|
|
345
|
+
const destHeight = resource.size.height * scale;
|
|
299
346
|
context.globalAlpha = opacity;
|
|
300
|
-
|
|
347
|
+
if (flipX) {
|
|
348
|
+
context.save();
|
|
349
|
+
context.translate(renderable.position.x + destWidth, renderable.position.y);
|
|
350
|
+
context.scale(-1, 1);
|
|
351
|
+
context.drawImage(resource.image, frame.x * resource.size.width, frame.y * resource.size.height, resource.size.width, resource.size.height, 0, 0, destWidth, destHeight);
|
|
352
|
+
context.restore();
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
context.drawImage(resource.image, frame.x * resource.size.width, frame.y * resource.size.height, resource.size.width, resource.size.height, renderable.position.x, renderable.position.y, destWidth, destHeight);
|
|
356
|
+
}
|
|
301
357
|
context.globalAlpha = 1;
|
|
302
358
|
continue;
|
|
303
359
|
}
|
|
360
|
+
if (renderable.type === "LINE") {
|
|
361
|
+
context.strokeStyle = renderable.color;
|
|
362
|
+
context.lineWidth = renderable.width ?? 2;
|
|
363
|
+
context.beginPath();
|
|
364
|
+
context.moveTo(renderable.from.x, renderable.from.y);
|
|
365
|
+
context.lineTo(renderable.to.x, renderable.to.y);
|
|
366
|
+
context.stroke();
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
304
369
|
exhaust(renderable);
|
|
305
370
|
}
|
|
306
371
|
lastFrame = now;
|
|
@@ -311,5 +376,5 @@ const runEngine = async (props) => {
|
|
|
311
376
|
};
|
|
312
377
|
};
|
|
313
378
|
|
|
314
|
-
export { runEngine };
|
|
379
|
+
export { STOP, runEngine };
|
|
315
380
|
//# sourceMappingURL=index.js.map
|
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