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 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" width="960" height="540"></canvas>
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
 
@@ -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 Renderable = RectangleRenderable | CircleRenderable | SpriteRenderable | TextRenderable;
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: (props: NextStateProps<State>) => State;
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 = { x: ev.offsetX, y: ev.offsetY };
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
- document.addEventListener("keydown", (event) => {
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
- document.addEventListener("keyup", (event) => {
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 = { x: ev.offsetX, y: ev.offsetY };
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 = { x: ev.offsetX, y: ev.offsetY };
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
- updateState((state) => props.nextState({
255
- state,
256
- event,
257
- keyboard: iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
258
- const isPressed = currentState.keyboardState[key];
259
- return {
260
- isPressed,
261
- isJustPressed: isPressed && !previouslyPressed,
262
- isJustReleased: !isPressed && previouslyPressed,
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, CONSTANTS.WINDOW.WIDTH, CONSTANTS.WINDOW.HEIGHT);
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.vertical) %
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
- 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, resource.size.width * scale, resource.size.height * scale);
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
@@ -1,2 +1,3 @@
1
1
  import { runEngine } from "./engine/runEngine";
2
- export { runEngine };
2
+ import { STOP } from "./engine/types";
3
+ export { runEngine, STOP };
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 = { x: ev.offsetX, y: ev.offsetY };
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
- document.addEventListener("keydown", (event) => {
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
- document.addEventListener("keyup", (event) => {
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 = { x: ev.offsetX, y: ev.offsetY };
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 = { x: ev.offsetX, y: ev.offsetY };
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
- updateState((state) => props.nextState({
253
- state,
254
- event,
255
- keyboard: iterateRecord(previousState.keyboardState, ({ key, value: previouslyPressed }) => {
256
- const isPressed = currentState.keyboardState[key];
257
- return {
258
- isPressed,
259
- isJustPressed: isPressed && !previouslyPressed,
260
- isJustReleased: !isPressed && previouslyPressed,
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, CONSTANTS.WINDOW.WIDTH, CONSTANTS.WINDOW.HEIGHT);
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.vertical) %
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
- 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, resource.size.width * scale, resource.size.height * scale);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yuuna-engine",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",