yuuna-engine 0.6.0 → 0.7.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
@@ -1,156 +1,160 @@
1
- <p align="center">
2
- <img src="dist/resources/yuuna.png" alt="Yuuna" width="120" height="120" />
3
- </p>
4
-
5
- # Yuuna
6
-
7
- [Live demo & playground](https://lucy-dot-exe.github.io/yuuna/) · [GitHub](https://github.com/lucy-dot-exe/yuuna)
8
-
9
- A lightweight, state-machine-based TypeScript game engine built for quick
10
- prototypes — drop it into a page and it's running, no editor or build step
11
- required. You describe your game as a `state`, a `render(state)` function,
12
- and a `nextState({ state, event, keyboard })` function — Yuuna owns the
13
- render loop, input handling, and canvas drawing. It's scratch paper for game
14
- ideas, not a replacement for Godot or Unity.
15
-
16
- ## Install
17
-
18
- ```sh
19
- npm install yuuna-engine
20
- ```
21
-
22
- ## Quick start
23
-
24
- Add a canvas with `id="yuuna"` to your page:
25
-
26
- ```html
27
- <canvas id="yuuna"></canvas>
28
- ```
29
-
30
- Then describe your game as state + render + nextState:
31
-
32
- ```ts
33
- import { runEngine } from "yuuna-engine";
34
-
35
- // The shape of your game's data whatever it takes to fully describe
36
- // what's on screen and how it behaves
37
- type GameState = { cookies: number };
38
-
39
- // What that state looks like before anything has happened yet
40
- const initialState: GameState = { cookies: 0 };
41
-
42
- runEngine<GameState>({
43
- initialState,
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.
48
- render: (state) => ({
49
- renderables: [
50
- {
51
- type: "TEXT",
52
- text: `${state.cookies} cookies`,
53
- color: "black",
54
- position: { x: 100, y: 50 },
55
- },
56
- {
57
- type: "CIRCLE",
58
- id: "cookie",
59
- isClickable: true,
60
- color: "brown",
61
- position: { x: 50, y: 50 },
62
- radius: 25,
63
- },
64
- ],
65
- }),
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.
70
- nextState: ({ state, event }) => {
71
- if (event.tag === "CLICK" && event.id === "cookie") {
72
- return { cookies: state.cookies + 1 };
73
- }
74
-
75
- return state;
76
- },
77
- });
78
- ```
79
-
80
- ## Concepts
81
-
82
- - **Renderables** declarative shapes drawn each frame: `RECTANGLE`,
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`, `MOUSE_LEAVE`,
87
- `MUSIC_END`, or a `CUSTOM` event of a type you define yourself, for
88
- reporting things like an async `fetch()` resolving back into your
89
- state machine.
90
- - **Keyboard, camera, sprites & animation, sound effects & music,
91
- canvas config, and mechanics pipelines** all follow the same idea:
92
- small, focused props and functions `runEngine`/`nextState` take, that
93
- compose with everything above instead of replacing it.
94
-
95
- This README stays intentionally thin the full concept-by-concept
96
- reference, with every option and example, lives on the
97
- [wiki](https://github.com/lucy-dot-exe/yuuna/wiki). The
98
- [playground](https://lucy-dot-exe.github.io/yuuna/#playground) also has a
99
- small, focused example for most of these you can run and edit directly.
100
-
101
- ## Templates
102
-
103
- Prefer a working starting point over typing the quick start out by
104
- hand? Grab one from [`templates/`](templates):
105
-
106
- - **[blank](templates/blank)** a single `index.html`, zero install —
107
- open it in a browser and it runs.
108
- - **[npm](templates/npm)** TypeScript + a dev server with hot reload
109
- (via Vite), for a real local project.
110
- - **[neutralino-desktop](templates/neutralino-desktop)** the `npm`
111
- template wrapped in [Neutralino](https://neutralino.js.org) to run as a
112
- native desktop window instead of a browser tab.
113
-
114
- ```sh
115
- npx degit lucy-dot-exe/yuuna/templates/blank my-game
116
- # or: npx degit lucy-dot-exe/yuuna/templates/npm my-game
117
- # or: npx degit lucy-dot-exe/yuuna/templates/neutralino-desktop my-game
118
- ```
119
-
120
- [`degit`](https://github.com/Rich-Harris/degit) copies the folder without
121
- its git history no cloning or forking the whole engine repo needed.
122
- Each template's own README has more on running it once copied.
123
-
124
- ## Development
125
-
126
- ```sh
127
- yarn install
128
- yarn build # builds lib/ (npm package) and dist/bundle.js (landing page)
129
- yarn watch # rebuild on change
130
- ```
131
-
132
- `dist/index.html` is the landing page it loads `dist/bundle.js` in the
133
- browser via a global `Yuuna` object and embeds a live Monaco editor so
134
- visitors can edit and run a game directly on the page.
135
-
136
- ## Assets
137
-
138
- The examples' art/sound/music lives in `dist/resources/`, gitignored
139
- rather than committed — this repo being open source doesn't make every
140
- asset in it free to redistribute. `runEngine()` falls back to a
141
- generated placeholder for any image that isn't there (and simply plays
142
- nothing for missing audio) instead of failing, so the examples still
143
- run without themjust with placeholder art in place of the real
144
- thing. Drop the real files in locally (or restore them from wherever
145
- you got this repo from) to see them for real.
146
-
147
- Currently used:
148
-
149
- - **[Free Pixel Food!](https://henrysoftware.itch.io/pixel-food)** by
150
- [Henry Software](https://henrysoftware.itch.io/) — the food icons in
151
- the Food Clicker and Sprites examples. CC0; credited here by choice,
152
- not requirement.
153
-
154
- ## License
155
-
156
- MIT © [lucy-dot-exe](https://github.com/lucy-dot-exe)
1
+ <p align="center">
2
+ <img src="dist/resources/yuuna.png" alt="Yuuna" width="120" height="120" />
3
+ </p>
4
+
5
+ # Yuuna
6
+
7
+ [Live demo & playground](https://lucy-dot-exe.github.io/yuuna/) · [GitHub](https://github.com/lucy-dot-exe/yuuna)
8
+
9
+ A lightweight, state-machine-based TypeScript game engine drop it into a
10
+ page and it's running, no editor or build step required. You describe your
11
+ game as a `state`, a `render(state)` function, and a
12
+ `nextState({ state, event, keyboard })` function — Yuuna owns the render
13
+ loop, input handling, and canvas drawing.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npm install yuuna-engine
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ Add a canvas with `id="yuuna"` to your page:
24
+
25
+ ```html
26
+ <canvas id="yuuna"></canvas>
27
+ ```
28
+
29
+ Then describe your game as state + render + nextState:
30
+
31
+ ```ts
32
+ import { runEngine } from "yuuna-engine";
33
+
34
+ // The shape of your game's data — whatever it takes to fully describe
35
+ // what's on screen and how it behaves
36
+ type GameState = { cookies: number };
37
+
38
+ // What that state looks like before anything has happened yet
39
+ const initialState: GameState = { cookies: 0 };
40
+
41
+ runEngine<GameState>({
42
+ initialState,
43
+
44
+ // Given the current state, what should be drawn this frame? Called
45
+ // every frame always derive the picture from state, instead of
46
+ // reaching for the canvas directly.
47
+ render: (state) => ({
48
+ renderables: [
49
+ {
50
+ type: "TEXT",
51
+ text: `${state.cookies} cookies`,
52
+ color: "black",
53
+ position: { x: 100, y: 50 },
54
+ },
55
+ {
56
+ type: "CIRCLE",
57
+ id: "cookie",
58
+ isClickable: true,
59
+ color: "brown",
60
+ position: { x: 50, y: 50 },
61
+ radius: 25,
62
+ },
63
+ ],
64
+ }),
65
+
66
+ // Given the current state and something that just happened, what's the
67
+ // next state? Called once per event (a click, a frame tick, ...) the
68
+ // only place game logic lives.
69
+ nextState: ({ state, event }) => {
70
+ if (event.tag === "CLICK" && event.id === "cookie") {
71
+ return { cookies: state.cookies + 1 };
72
+ }
73
+
74
+ return state;
75
+ },
76
+ });
77
+ ```
78
+
79
+ ## Concepts
80
+
81
+ - **Renderables** — declarative shapes drawn each frame: `RECTANGLE`,
82
+ `CIRCLE`, `TEXT`, `SPRITE`, `ANIMATED_SPRITE`, `LINE`, and `GROUP`. Give
83
+ one an `id` plus `isClickable`/`isHoverable` to make it interactive.
84
+ - **Events** `nextState` receives one `GameEvent` per call: `TIME`,
85
+ `CLICK`, `HOVER_IN`, `HOVER_OUT`, `MOUSE_MOVE`, `MOUSE_LEAVE`,
86
+ `MUSIC_END`, or a `CUSTOM` event of a type you define yourself, for
87
+ reporting things like an async `fetch()` resolving back into your
88
+ 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
+ - **[neutralino-desktop](templates/neutralino-desktop)** the `npm`
110
+ template wrapped in [Neutralino](https://neutralino.js.org) to run as a
111
+ native desktop window.
112
+
113
+ ```sh
114
+ npx degit lucy-dot-exe/yuuna/templates/blank my-game
115
+ # or: npx degit lucy-dot-exe/yuuna/templates/npm my-game
116
+ # or: npx degit lucy-dot-exe/yuuna/templates/neutralino-desktop my-game
117
+ ```
118
+
119
+ [`degit`](https://github.com/Rich-Harris/degit) copies the folder without
120
+ its git history — no cloning or forking the whole engine repo needed.
121
+ Each template's own README has more on running it once copied.
122
+
123
+ ## Made with Yuuna
124
+
125
+ - **[Yuuna's Heroes](https://lucinaexe.itch.io/yuunas-td)** — a game made
126
+ using Yuuna.
127
+
128
+ ## Development
129
+
130
+ ```sh
131
+ yarn install
132
+ yarn build # builds lib/ (npm package) and dist/bundle.js (landing page)
133
+ yarn watch # rebuild on change
134
+ ```
135
+
136
+ `dist/index.html` is the landing page — it loads `dist/bundle.js` in the
137
+ browser via a global `Yuuna` object and embeds a live Monaco editor so
138
+ visitors can edit and run a game directly on the page.
139
+
140
+ ## Assets
141
+
142
+ The examples' art/sound/music lives in `dist/resources/`, gitignored
143
+ rather than committedthis repo being open source doesn't make every
144
+ asset in it free to redistribute. `runEngine()` falls back to a
145
+ generated placeholder for any image that isn't there (and simply plays
146
+ nothing for missing audio) instead of failing, so the examples still
147
+ run without them — just with placeholder art in place of the real
148
+ thing. Drop the real files in locally (or restore them from wherever
149
+ you got this repo from) to see them for real.
150
+
151
+ Currently used:
152
+
153
+ - **[Free Pixel Food!](https://henrysoftware.itch.io/pixel-food)** by
154
+ [Henry Software](https://henrysoftware.itch.io/) — the food icons in
155
+ the Food Clicker and Sprites examples. CC0; credited here by choice,
156
+ not requirement.
157
+
158
+ ## License
159
+
160
+ MIT © [lucy-dot-exe](https://github.com/lucy-dot-exe)
@@ -0,0 +1,8 @@
1
+ import { AnimatedSpriteRenderable, CircleRenderable, GroupRenderable, LineRenderable, RectangleRenderable, SpriteRenderable, TextRenderable } from "./types";
2
+ export declare const rectangle: (props: Omit<RectangleRenderable, "type">) => RectangleRenderable;
3
+ export declare const circle: (props: Omit<CircleRenderable, "type">) => CircleRenderable;
4
+ export declare const text: (props: Omit<TextRenderable, "type">) => TextRenderable;
5
+ export declare const sprite: (props: Omit<SpriteRenderable, "type">) => SpriteRenderable;
6
+ export declare const animatedSprite: (props: Omit<AnimatedSpriteRenderable, "type">) => AnimatedSpriteRenderable;
7
+ export declare const line: (props: Omit<LineRenderable, "type">) => LineRenderable;
8
+ export declare const group: (props: Omit<GroupRenderable, "type">) => GroupRenderable;
@@ -54,7 +54,7 @@ export type SpriteRenderable = BaseRenderable & {
54
54
  y: number;
55
55
  };
56
56
  resourceId: string;
57
- frame: number;
57
+ frame?: number;
58
58
  opacity?: number;
59
59
  flipX?: boolean;
60
60
  swapColors?: {
@@ -191,6 +191,11 @@ export type NextStateProps<State, Custom = never> = {
191
191
  isJustPressed: boolean;
192
192
  isJustReleased: boolean;
193
193
  }>;
194
+ mouseButton: {
195
+ isPressed: boolean;
196
+ isJustPressed: boolean;
197
+ isJustReleased: boolean;
198
+ };
194
199
  playSound: (id: string) => void;
195
200
  playMusic: (id: string) => void;
196
201
  pauseMusic: () => void;
@@ -208,11 +213,11 @@ export type RunEngineProps<State, Custom = never> = {
208
213
  nextState: NextStateFunction<State, Custom> | NextStateFunction<State, Custom>[];
209
214
  resources?: Record<string, {
210
215
  src: string;
211
- size: {
216
+ size?: {
212
217
  width: number;
213
218
  height: number;
214
219
  };
215
- slices: {
220
+ slices?: {
216
221
  vertical: number;
217
222
  horizontal: number;
218
223
  };
@@ -234,6 +239,7 @@ export type RunEngineProps<State, Custom = never> = {
234
239
  height?: number;
235
240
  backgroundColor?: string;
236
241
  resize?: "none" | "fit" | "stretch";
242
+ pixelRatio?: number | true;
237
243
  };
238
244
  camera?: (state: State) => {
239
245
  x: number;
@@ -252,5 +258,12 @@ export type KeyboardState = Record<KeyboardKeys, boolean>;
252
258
  export declare var Yuuna: {
253
259
  runEngine: RunEngineFunction;
254
260
  STOP: typeof STOP;
261
+ rectangle: (props: Omit<RectangleRenderable, "type">) => RectangleRenderable;
262
+ circle: (props: Omit<CircleRenderable, "type">) => CircleRenderable;
263
+ text: (props: Omit<TextRenderable, "type">) => TextRenderable;
264
+ sprite: (props: Omit<SpriteRenderable, "type">) => SpriteRenderable;
265
+ animatedSprite: (props: Omit<AnimatedSpriteRenderable, "type">) => AnimatedSpriteRenderable;
266
+ line: (props: Omit<LineRenderable, "type">) => LineRenderable;
267
+ group: (props: Omit<GroupRenderable, "type">) => GroupRenderable;
255
268
  };
256
269
  export {};
package/lib/index.cjs CHANGED
@@ -149,6 +149,12 @@ const createPlaceholderSheet = (size) => {
149
149
  }
150
150
  return canvas;
151
151
  };
152
+ // Used only when a resource declares neither `size` (see settle() below)
153
+ // nor actually loads — there's no image to measure and nothing declared
154
+ // to fall back to, so there's no way to know the intended dimensions at
155
+ // all. An arbitrary, small-but-visible size, purely so the placeholder
156
+ // still draws as *something* instead of a 0x0/NaN canvas.
157
+ const DEFAULT_PLACEHOLDER_SIZE = { width: 64, height: 64 };
152
158
  const runEngine = async (props) => {
153
159
  const runId = ++latestRunId;
154
160
  resetCanvas?.();
@@ -172,9 +178,39 @@ const runEngine = async (props) => {
172
178
  if (props.canvas?.backgroundColor !== undefined) {
173
179
  canvas.style.backgroundColor = props.canvas.backgroundColor;
174
180
  }
181
+ // The canvas's *logical* resolution — the fixed space every renderable
182
+ // position, and every mouse/touch coordinate, is expressed in. Captured
183
+ // here, before pixelRatio (below) scales the actual backing buffer past
184
+ // it, so both stay anchored to this regardless of what pixelRatio does.
185
+ const logicalWidth = canvas.width;
186
+ const logicalHeight = canvas.height;
187
+ // Scales the backing buffer beyond logicalWidth/logicalHeight so text
188
+ // and vector shapes (fillText, arc, ...) render crisply on a HiDPI
189
+ // screen — most phones — instead of the same logical-resolution buffer
190
+ // just being stretched larger by applyResize below. `true` follows the
191
+ // display's own devicePixelRatio; a number sets it explicitly; leaving
192
+ // this unset keeps today's 1x behavior. Sprites are unaffected either
193
+ // way — imageSmoothingEnabled stays false below regardless — so pixel
194
+ // art has no reason to turn this on.
195
+ const pixelRatio = props.canvas?.pixelRatio === true ? window.devicePixelRatio || 1 : props.canvas?.pixelRatio ?? 1;
196
+ if (pixelRatio !== 1) {
197
+ // Resizing the backing buffer resets the 2D context's transform (and
198
+ // everything else about its state), so context.scale below has to
199
+ // come after this, not before.
200
+ canvas.width = logicalWidth * pixelRatio;
201
+ canvas.height = logicalHeight * pixelRatio;
202
+ canvas.style.width = `${logicalWidth}px`;
203
+ canvas.style.height = `${logicalHeight}px`;
204
+ context.scale(pixelRatio, pixelRatio);
205
+ }
175
206
  // Make the canvas focusable so keyboard input is scoped to it instead of
176
207
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
177
208
  canvas.tabIndex = 0;
209
+ // Stops the browser from treating a drag/pinch on the canvas as page
210
+ // scroll/zoom — backs up the touchstart/touchmove preventDefault calls
211
+ // below for gestures (e.g. a pinch starting on the canvas) preventDefault
212
+ // alone doesn't reliably stop.
213
+ canvas.style.touchAction = "none";
178
214
  // Resizes the *display* size only (CSS width/height) — canvas.width/
179
215
  // height above stays the fixed logical resolution every renderable's
180
216
  // position is already expressed in, so this never needs to touch any
@@ -212,24 +248,35 @@ const runEngine = async (props) => {
212
248
  const resources = props.resources ?? {};
213
249
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
214
250
  const image = new Image();
215
- const settle = (loadedImage) => {
251
+ // A single, unsliced image (1x1) if unset — only an actual
252
+ // spritesheet needs this declared.
253
+ const slices = value.slices ?? { horizontal: 1, vertical: 1 };
254
+ // sheetSize is the whole loaded sheet's pixel dimensions — value's
255
+ // declared `size` if set, otherwise whatever the image actually
256
+ // measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
257
+ // that isn't available, i.e. no `size` declared *and* the image
258
+ // failed to load too).
259
+ const settle = (loadedImage, sheetSize) => {
216
260
  resolve({
217
261
  image: loadedImage,
218
262
  size: {
219
- width: value.size.width / value.slices.horizontal,
220
- height: value.size.height / value.slices.vertical,
263
+ width: sheetSize.width / slices.horizontal,
264
+ height: sheetSize.height / slices.vertical,
221
265
  },
222
- slices: value.slices,
266
+ slices,
223
267
  animations: value.animations ?? {},
224
268
  });
225
269
  };
226
270
  image.src = value.src;
227
- image.onload = () => settle(image);
271
+ image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
228
272
  // Missing/failed-to-load asset (see .gitignore's dist/resources/
229
273
  // note) — a placeholder sheet, sized to match what this resource
230
274
  // declared, keeps every frame/slice/animation index the example
231
275
  // already computes valid instead of drawing nothing or throwing.
232
- image.onerror = () => settle(createPlaceholderSheet(value.size));
276
+ image.onerror = () => {
277
+ const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
278
+ settle(createPlaceholderSheet(placeholderSize), placeholderSize);
279
+ };
233
280
  }));
234
281
  const loadAudio = (src) => new Promise((resolve) => {
235
282
  const audio = new Audio(src);
@@ -590,8 +637,20 @@ const runEngine = async (props) => {
590
637
  // the same conversion to line up.
591
638
  const getCanvasPosition = (ev) => {
592
639
  return {
593
- x: (ev.offsetX * canvas.width) / canvas.clientWidth,
594
- y: (ev.offsetY * canvas.height) / canvas.clientHeight,
640
+ x: (ev.offsetX * logicalWidth) / canvas.clientWidth,
641
+ y: (ev.offsetY * logicalHeight) / canvas.clientHeight,
642
+ };
643
+ };
644
+ // Touch's equivalent of getCanvasPosition — a Touch has no offsetX/Y
645
+ // (that's a MouseEvent-only convenience), so this gets there manually
646
+ // via getBoundingClientRect instead. clientX/Y and the rect are both
647
+ // viewport-relative, so their difference stays correct regardless of
648
+ // page scroll.
649
+ const getTouchPosition = (touch) => {
650
+ const rect = canvas.getBoundingClientRect();
651
+ return {
652
+ x: ((touch.clientX - rect.left) * logicalWidth) / canvas.clientWidth,
653
+ y: ((touch.clientY - rect.top) * logicalHeight) / canvas.clientHeight,
595
654
  };
596
655
  };
597
656
  const updateState = (updateFn) => {
@@ -600,8 +659,11 @@ const runEngine = async (props) => {
600
659
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
601
660
  let lastFrame = Date.now();
602
661
  let hoveredId = null;
603
- const handleClick = (ev) => {
604
- const mouse = getCanvasPosition(ev);
662
+ // Shared by the mouse "click" listener and the touch handlers below —
663
+ // firing a CLICK is the same "is whatever's currently hovered
664
+ // isClickable" check either way; only how `mouse` was determined
665
+ // differs (a real click event vs. a lifted finger).
666
+ const fireClick = (mouse) => {
605
667
  if (hoveredId === null)
606
668
  return;
607
669
  const { renderables, camera } = renderState(state);
@@ -610,6 +672,7 @@ const runEngine = async (props) => {
610
672
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
611
673
  }
612
674
  };
675
+ const handleClick = (ev) => fireClick(getCanvasPosition(ev));
613
676
  canvas.addEventListener("click", handleClick);
614
677
  const initialState = {
615
678
  keyboardState: createRecord(keyboardKeys, () => false),
@@ -639,8 +702,34 @@ const runEngine = async (props) => {
639
702
  }
640
703
  };
641
704
  canvas.addEventListener("keyup", handleKeyUp);
642
- const handleMouseMoveHover = (ev) => {
643
- const mouse = getCanvasPosition(ev);
705
+ // mouseButton state (see NextStateProps.mouseButton) a double-buffer
706
+ // exactly like keyboardState above, just for the primary mouse button.
707
+ let previousMouseButtonState = { isPressed: false };
708
+ const currentMouseButtonState = { isPressed: false };
709
+ const handleMouseDown = (event) => {
710
+ // Left/primary button only — matching CLICK, which already only
711
+ // ever fires for it.
712
+ if (event.button === 0) {
713
+ currentMouseButtonState.isPressed = true;
714
+ }
715
+ };
716
+ canvas.addEventListener("mousedown", handleMouseDown);
717
+ const handleMouseUp = (event) => {
718
+ if (event.button === 0) {
719
+ currentMouseButtonState.isPressed = false;
720
+ }
721
+ };
722
+ // On window rather than the canvas — so releasing the button after
723
+ // having dragged off the canvas while still holding it down still
724
+ // clears isPressed, instead of leaving it stuck true forever.
725
+ window.addEventListener("mouseup", handleMouseUp);
726
+ // Shared by mousemove and the touch handlers below — updates hoveredId
727
+ // from a canvas position and fires HOVER_IN/HOVER_OUT as whatever's
728
+ // underneath it changes. Touch has no ambient hover the way a mouse
729
+ // does (nothing is "hovered" until a finger actually touches down), but
730
+ // feeding a touch's position through this the same as the mouse's is
731
+ // what lets an isHoverable renderable react to a tap/drag at all.
732
+ const updateHover = (mouse) => {
644
733
  const { renderables, camera } = renderState(state);
645
734
  const worldMouse = toWorldPosition(mouse, camera);
646
735
  const hovered = [...renderables]
@@ -657,9 +746,9 @@ const runEngine = async (props) => {
657
746
  }
658
747
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
659
748
  };
749
+ const handleMouseMoveHover = (ev) => updateHover(getCanvasPosition(ev));
660
750
  canvas.addEventListener("mousemove", handleMouseMoveHover);
661
- const handleMouseMoveTracking = (ev) => {
662
- const mouse = getCanvasPosition(ev);
751
+ const updateTracking = (mouse) => {
663
752
  if (hoveredId === null) {
664
753
  return;
665
754
  }
@@ -669,13 +758,17 @@ const runEngine = async (props) => {
669
758
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
670
759
  }
671
760
  };
761
+ const handleMouseMoveTracking = (ev) => updateTracking(getCanvasPosition(ev));
672
762
  canvas.addEventListener("mousemove", handleMouseMoveTracking);
673
763
  // No further mousemove fires once the mouse is off the canvas, so this
674
764
  // is also the only chance to report a HOVER_OUT for whatever was
675
765
  // hovered when it left — otherwise that hover would just dangle,
676
766
  // never explicitly ended.
677
- const handleMouseLeave = (ev) => {
678
- const mouse = getCanvasPosition(ev);
767
+ // Shared by mouseleave and the touch handlers below — clears whatever's
768
+ // hovered (firing HOVER_OUT for it) without a HOVER_IN taking its
769
+ // place. Returns worldMouse so callers that need it (MOUSE_LEAVE below)
770
+ // don't have to call renderState() a second time just to get it.
771
+ const clearHover = (mouse) => {
679
772
  const { renderables, camera } = renderState(state);
680
773
  const worldMouse = toWorldPosition(mouse, camera);
681
774
  if (hoveredId !== null) {
@@ -685,9 +778,79 @@ const runEngine = async (props) => {
685
778
  }
686
779
  }
687
780
  hoveredId = null;
781
+ return worldMouse;
782
+ };
783
+ const handleMouseLeave = (ev) => {
784
+ const mouse = getCanvasPosition(ev);
785
+ const worldMouse = clearHover(mouse);
688
786
  events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
689
787
  };
690
788
  canvas.addEventListener("mouseleave", handleMouseLeave);
789
+ // Translates touch into the same HOVER_IN/HOVER_OUT/MOUSE_MOVE/CLICK
790
+ // events mouse input already produces (via updateHover/updateTracking/
791
+ // fireClick/clearHover above), so existing game code written against
792
+ // those events works on a touchscreen with no changes of its own.
793
+ // Single-touch only — touches[0]/changedTouches[0] — the same "one
794
+ // active pointer" model mouse input already assumes; a second finger is
795
+ // ignored rather than tracked as its own pointer.
796
+ //
797
+ // preventDefault on start/move keeps a drag/tap on the canvas from also
798
+ // scrolling, pinch-zooming, or triggering pull-to-refresh — the browser
799
+ // gestures a touchscreen normally reserves that space for; { passive:
800
+ // false } is what makes preventDefault actually take effect here.
801
+ const handleTouchStart = (ev) => {
802
+ ev.preventDefault();
803
+ const touch = ev.touches[0];
804
+ if (touch === undefined)
805
+ return;
806
+ currentMouseButtonState.isPressed = true;
807
+ const mouse = getTouchPosition(touch);
808
+ updateHover(mouse);
809
+ updateTracking(mouse);
810
+ };
811
+ canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
812
+ const handleTouchMove = (ev) => {
813
+ ev.preventDefault();
814
+ const touch = ev.touches[0];
815
+ if (touch === undefined)
816
+ return;
817
+ const mouse = getTouchPosition(touch);
818
+ updateHover(mouse);
819
+ updateTracking(mouse);
820
+ };
821
+ canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
822
+ // A lifted finger both releases (mirroring mouseup) and clicks
823
+ // (mirroring the browser's own click-after-mouseup) — touch has no
824
+ // separate "up" and "click" events of its own the way mouse does, so
825
+ // both happen here together, in that order. hover is updated once more
826
+ // first so a plain tap (touchstart immediately followed by touchend,
827
+ // with no touchmove between them to have already done this) still
828
+ // fires CLICK against whatever's actually under it; hover is then
829
+ // cleared, since nothing's left touching it once the finger lifts.
830
+ const handleTouchEnd = (ev) => {
831
+ ev.preventDefault();
832
+ currentMouseButtonState.isPressed = false;
833
+ const touch = ev.changedTouches[0];
834
+ if (touch === undefined)
835
+ return;
836
+ const mouse = getTouchPosition(touch);
837
+ updateHover(mouse);
838
+ fireClick(mouse);
839
+ clearHover(mouse);
840
+ };
841
+ canvas.addEventListener("touchend", handleTouchEnd, { passive: false });
842
+ // A cancelled touch (e.g. an incoming call interrupting the page, or
843
+ // the OS deciding it's a system gesture instead) never fires touchend —
844
+ // handled the same as lifting the finger, minus the click, since
845
+ // there's no tap to speak of once the touch itself has been cancelled.
846
+ const handleTouchCancel = (ev) => {
847
+ currentMouseButtonState.isPressed = false;
848
+ const touch = ev.changedTouches[0];
849
+ if (touch === undefined)
850
+ return;
851
+ clearHover(getTouchPosition(touch));
852
+ };
853
+ canvas.addEventListener("touchcancel", handleTouchCancel, { passive: false });
691
854
  // Tab switches are a document-level concern (visibilitychange), not
692
855
  // something that ever reaches the canvas itself the way mouse/keyboard
693
856
  // events do.
@@ -819,11 +982,17 @@ const runEngine = async (props) => {
819
982
  isJustReleased: !isPressed && previouslyPressed,
820
983
  };
821
984
  });
985
+ const mouseButton = {
986
+ isPressed: currentMouseButtonState.isPressed,
987
+ isJustPressed: currentMouseButtonState.isPressed && !previousMouseButtonState.isPressed,
988
+ isJustReleased: !currentMouseButtonState.isPressed && previousMouseButtonState.isPressed,
989
+ };
822
990
  for (const nextState of nextStateFns) {
823
991
  const result = nextState({
824
992
  state,
825
993
  event,
826
994
  keyboard,
995
+ mouseButton,
827
996
  playSound,
828
997
  playMusic,
829
998
  pauseMusic,
@@ -843,7 +1012,7 @@ const runEngine = async (props) => {
843
1012
  }
844
1013
  }
845
1014
  events.splice(0, events.length);
846
- context.clearRect(0, 0, canvas.width, canvas.height);
1015
+ context.clearRect(0, 0, logicalWidth, logicalHeight);
847
1016
  const { cursor, renderables } = renderState(state);
848
1017
  canvas.style.cursor = cursor ?? "default";
849
1018
  for (const renderable of renderables) {
@@ -876,12 +1045,11 @@ const runEngine = async (props) => {
876
1045
  continue;
877
1046
  }
878
1047
  if (renderable.type === "SPRITE") {
879
- const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
1048
+ const { opacity = 1, flipX = false, frame: frameIndex = 0, modulate, swapColors } = renderable;
880
1049
  const resource = resourceById[renderable.resourceId];
881
1050
  const frame = {
882
- x: renderable.frame % resource.slices.horizontal,
883
- y: Math.floor(renderable.frame / resource.slices.horizontal) %
884
- resource.slices.vertical,
1051
+ x: frameIndex % resource.slices.horizontal,
1052
+ y: Math.floor(frameIndex / resource.slices.horizontal) % resource.slices.vertical,
885
1053
  };
886
1054
  const source = {
887
1055
  x: frame.x * resource.size.width,
@@ -901,7 +1069,7 @@ const runEngine = async (props) => {
901
1069
  let image = resource.image;
902
1070
  let imageSource = source;
903
1071
  if (swapColors !== undefined && swapColors.length > 0) {
904
- image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
1072
+ image = swappedSpriteFrame(renderable.resourceId, frameIndex, image, imageSource, swapColors);
905
1073
  imageSource = { x: 0, y: 0, width: source.width, height: source.height };
906
1074
  }
907
1075
  if (modulate !== undefined) {
@@ -955,6 +1123,7 @@ const runEngine = async (props) => {
955
1123
  }
956
1124
  lastFrame = now;
957
1125
  previousState.keyboardState = { ...currentState.keyboardState };
1126
+ previousMouseButtonState = { ...currentMouseButtonState };
958
1127
  }, 0);
959
1128
  resetCanvas = () => {
960
1129
  clearInterval(intervalId);
@@ -978,9 +1147,16 @@ const runEngine = async (props) => {
978
1147
  canvas.removeEventListener("click", handleClick);
979
1148
  canvas.removeEventListener("keydown", handleKeyDown);
980
1149
  canvas.removeEventListener("keyup", handleKeyUp);
1150
+ canvas.removeEventListener("mousedown", handleMouseDown);
981
1151
  canvas.removeEventListener("mousemove", handleMouseMoveHover);
982
1152
  canvas.removeEventListener("mousemove", handleMouseMoveTracking);
983
1153
  canvas.removeEventListener("mouseleave", handleMouseLeave);
1154
+ canvas.removeEventListener("touchstart", handleTouchStart);
1155
+ canvas.removeEventListener("touchmove", handleTouchMove);
1156
+ canvas.removeEventListener("touchend", handleTouchEnd);
1157
+ canvas.removeEventListener("touchcancel", handleTouchCancel);
1158
+ // On window, not the canvas — see where it's added above for why.
1159
+ window.removeEventListener("mouseup", handleMouseUp);
984
1160
  // This one's on `document`, not the canvas — same reasoning as above,
985
1161
  // just doubly true since `document` isn't even scoped to this canvas.
986
1162
  window.document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -991,6 +1167,50 @@ const runEngine = async (props) => {
991
1167
  return { sendEvent, requestFullscreen, exitFullscreen };
992
1168
  };
993
1169
 
1170
+ // One factory per Renderable variant — literally just `{ type: "X",
1171
+ // ...props }`, so `sprite({...})` (or `Yuuna.sprite({...})` in the
1172
+ // browser bundle/playground) reads the same as writing the object
1173
+ // literal by hand, minus needing to get `type` right yourself.
1174
+ // Deliberately not a place defaults live: besides SpriteRenderable.frame
1175
+ // (optional at the type level, see types.ts — the engine itself defaults
1176
+ // it to 0), each factory still requires whatever its Renderable type
1177
+ // still requires.
1178
+ const rectangle = (props) => ({
1179
+ type: "RECTANGLE",
1180
+ ...props,
1181
+ });
1182
+ const circle = (props) => ({
1183
+ type: "CIRCLE",
1184
+ ...props,
1185
+ });
1186
+ const text = (props) => ({
1187
+ type: "TEXT",
1188
+ ...props,
1189
+ });
1190
+ const sprite = (props) => ({
1191
+ type: "SPRITE",
1192
+ ...props,
1193
+ });
1194
+ const animatedSprite = (props) => ({
1195
+ type: "ANIMATED_SPRITE",
1196
+ ...props,
1197
+ });
1198
+ const line = (props) => ({
1199
+ type: "LINE",
1200
+ ...props,
1201
+ });
1202
+ const group = (props) => ({
1203
+ type: "GROUP",
1204
+ ...props,
1205
+ });
1206
+
994
1207
  exports.STOP = STOP;
1208
+ exports.animatedSprite = animatedSprite;
1209
+ exports.circle = circle;
1210
+ exports.group = group;
1211
+ exports.line = line;
1212
+ exports.rectangle = rectangle;
995
1213
  exports.runEngine = runEngine;
1214
+ exports.sprite = sprite;
1215
+ exports.text = text;
996
1216
  //# 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,4 +1,5 @@
1
1
  import { runEngine } from "./engine/runEngine";
2
2
  import { STOP } from "./engine/types";
3
- export { runEngine, STOP };
3
+ import { animatedSprite, circle, group, line, rectangle, sprite, text } from "./engine/renderables";
4
+ export { runEngine, STOP, rectangle, circle, text, sprite, animatedSprite, line, group };
4
5
  export type { NextStateFunction, NextStateProps, Renderable, GameEvent, CustomGameEvent, } from "./engine/types";
package/lib/index.js CHANGED
@@ -147,6 +147,12 @@ const createPlaceholderSheet = (size) => {
147
147
  }
148
148
  return canvas;
149
149
  };
150
+ // Used only when a resource declares neither `size` (see settle() below)
151
+ // nor actually loads — there's no image to measure and nothing declared
152
+ // to fall back to, so there's no way to know the intended dimensions at
153
+ // all. An arbitrary, small-but-visible size, purely so the placeholder
154
+ // still draws as *something* instead of a 0x0/NaN canvas.
155
+ const DEFAULT_PLACEHOLDER_SIZE = { width: 64, height: 64 };
150
156
  const runEngine = async (props) => {
151
157
  const runId = ++latestRunId;
152
158
  resetCanvas?.();
@@ -170,9 +176,39 @@ const runEngine = async (props) => {
170
176
  if (props.canvas?.backgroundColor !== undefined) {
171
177
  canvas.style.backgroundColor = props.canvas.backgroundColor;
172
178
  }
179
+ // The canvas's *logical* resolution — the fixed space every renderable
180
+ // position, and every mouse/touch coordinate, is expressed in. Captured
181
+ // here, before pixelRatio (below) scales the actual backing buffer past
182
+ // it, so both stay anchored to this regardless of what pixelRatio does.
183
+ const logicalWidth = canvas.width;
184
+ const logicalHeight = canvas.height;
185
+ // Scales the backing buffer beyond logicalWidth/logicalHeight so text
186
+ // and vector shapes (fillText, arc, ...) render crisply on a HiDPI
187
+ // screen — most phones — instead of the same logical-resolution buffer
188
+ // just being stretched larger by applyResize below. `true` follows the
189
+ // display's own devicePixelRatio; a number sets it explicitly; leaving
190
+ // this unset keeps today's 1x behavior. Sprites are unaffected either
191
+ // way — imageSmoothingEnabled stays false below regardless — so pixel
192
+ // art has no reason to turn this on.
193
+ const pixelRatio = props.canvas?.pixelRatio === true ? window.devicePixelRatio || 1 : props.canvas?.pixelRatio ?? 1;
194
+ if (pixelRatio !== 1) {
195
+ // Resizing the backing buffer resets the 2D context's transform (and
196
+ // everything else about its state), so context.scale below has to
197
+ // come after this, not before.
198
+ canvas.width = logicalWidth * pixelRatio;
199
+ canvas.height = logicalHeight * pixelRatio;
200
+ canvas.style.width = `${logicalWidth}px`;
201
+ canvas.style.height = `${logicalHeight}px`;
202
+ context.scale(pixelRatio, pixelRatio);
203
+ }
173
204
  // Make the canvas focusable so keyboard input is scoped to it instead of
174
205
  // leaking to the rest of the page (e.g. arrow keys scrolling the window).
175
206
  canvas.tabIndex = 0;
207
+ // Stops the browser from treating a drag/pinch on the canvas as page
208
+ // scroll/zoom — backs up the touchstart/touchmove preventDefault calls
209
+ // below for gestures (e.g. a pinch starting on the canvas) preventDefault
210
+ // alone doesn't reliably stop.
211
+ canvas.style.touchAction = "none";
176
212
  // Resizes the *display* size only (CSS width/height) — canvas.width/
177
213
  // height above stays the fixed logical resolution every renderable's
178
214
  // position is already expressed in, so this never needs to touch any
@@ -210,24 +246,35 @@ const runEngine = async (props) => {
210
246
  const resources = props.resources ?? {};
211
247
  const resourceById = await iterateRecordAsync(resources, async ({ value }) => new Promise((resolve) => {
212
248
  const image = new Image();
213
- const settle = (loadedImage) => {
249
+ // A single, unsliced image (1x1) if unset — only an actual
250
+ // spritesheet needs this declared.
251
+ const slices = value.slices ?? { horizontal: 1, vertical: 1 };
252
+ // sheetSize is the whole loaded sheet's pixel dimensions — value's
253
+ // declared `size` if set, otherwise whatever the image actually
254
+ // measures once it's loaded (or DEFAULT_PLACEHOLDER_SIZE if even
255
+ // that isn't available, i.e. no `size` declared *and* the image
256
+ // failed to load too).
257
+ const settle = (loadedImage, sheetSize) => {
214
258
  resolve({
215
259
  image: loadedImage,
216
260
  size: {
217
- width: value.size.width / value.slices.horizontal,
218
- height: value.size.height / value.slices.vertical,
261
+ width: sheetSize.width / slices.horizontal,
262
+ height: sheetSize.height / slices.vertical,
219
263
  },
220
- slices: value.slices,
264
+ slices,
221
265
  animations: value.animations ?? {},
222
266
  });
223
267
  };
224
268
  image.src = value.src;
225
- image.onload = () => settle(image);
269
+ image.onload = () => settle(image, value.size ?? { width: image.naturalWidth, height: image.naturalHeight });
226
270
  // Missing/failed-to-load asset (see .gitignore's dist/resources/
227
271
  // note) — a placeholder sheet, sized to match what this resource
228
272
  // declared, keeps every frame/slice/animation index the example
229
273
  // already computes valid instead of drawing nothing or throwing.
230
- image.onerror = () => settle(createPlaceholderSheet(value.size));
274
+ image.onerror = () => {
275
+ const placeholderSize = value.size ?? DEFAULT_PLACEHOLDER_SIZE;
276
+ settle(createPlaceholderSheet(placeholderSize), placeholderSize);
277
+ };
231
278
  }));
232
279
  const loadAudio = (src) => new Promise((resolve) => {
233
280
  const audio = new Audio(src);
@@ -588,8 +635,20 @@ const runEngine = async (props) => {
588
635
  // the same conversion to line up.
589
636
  const getCanvasPosition = (ev) => {
590
637
  return {
591
- x: (ev.offsetX * canvas.width) / canvas.clientWidth,
592
- y: (ev.offsetY * canvas.height) / canvas.clientHeight,
638
+ x: (ev.offsetX * logicalWidth) / canvas.clientWidth,
639
+ y: (ev.offsetY * logicalHeight) / canvas.clientHeight,
640
+ };
641
+ };
642
+ // Touch's equivalent of getCanvasPosition — a Touch has no offsetX/Y
643
+ // (that's a MouseEvent-only convenience), so this gets there manually
644
+ // via getBoundingClientRect instead. clientX/Y and the rect are both
645
+ // viewport-relative, so their difference stays correct regardless of
646
+ // page scroll.
647
+ const getTouchPosition = (touch) => {
648
+ const rect = canvas.getBoundingClientRect();
649
+ return {
650
+ x: ((touch.clientX - rect.left) * logicalWidth) / canvas.clientWidth,
651
+ y: ((touch.clientY - rect.top) * logicalHeight) / canvas.clientHeight,
593
652
  };
594
653
  };
595
654
  const updateState = (updateFn) => {
@@ -598,8 +657,11 @@ const runEngine = async (props) => {
598
657
  const nextStateFns = Array.isArray(props.nextState) ? props.nextState : [props.nextState];
599
658
  let lastFrame = Date.now();
600
659
  let hoveredId = null;
601
- const handleClick = (ev) => {
602
- const mouse = getCanvasPosition(ev);
660
+ // Shared by the mouse "click" listener and the touch handlers below —
661
+ // firing a CLICK is the same "is whatever's currently hovered
662
+ // isClickable" check either way; only how `mouse` was determined
663
+ // differs (a real click event vs. a lifted finger).
664
+ const fireClick = (mouse) => {
603
665
  if (hoveredId === null)
604
666
  return;
605
667
  const { renderables, camera } = renderState(state);
@@ -608,6 +670,7 @@ const runEngine = async (props) => {
608
670
  events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
609
671
  }
610
672
  };
673
+ const handleClick = (ev) => fireClick(getCanvasPosition(ev));
611
674
  canvas.addEventListener("click", handleClick);
612
675
  const initialState = {
613
676
  keyboardState: createRecord(keyboardKeys, () => false),
@@ -637,8 +700,34 @@ const runEngine = async (props) => {
637
700
  }
638
701
  };
639
702
  canvas.addEventListener("keyup", handleKeyUp);
640
- const handleMouseMoveHover = (ev) => {
641
- const mouse = getCanvasPosition(ev);
703
+ // mouseButton state (see NextStateProps.mouseButton) a double-buffer
704
+ // exactly like keyboardState above, just for the primary mouse button.
705
+ let previousMouseButtonState = { isPressed: false };
706
+ const currentMouseButtonState = { isPressed: false };
707
+ const handleMouseDown = (event) => {
708
+ // Left/primary button only — matching CLICK, which already only
709
+ // ever fires for it.
710
+ if (event.button === 0) {
711
+ currentMouseButtonState.isPressed = true;
712
+ }
713
+ };
714
+ canvas.addEventListener("mousedown", handleMouseDown);
715
+ const handleMouseUp = (event) => {
716
+ if (event.button === 0) {
717
+ currentMouseButtonState.isPressed = false;
718
+ }
719
+ };
720
+ // On window rather than the canvas — so releasing the button after
721
+ // having dragged off the canvas while still holding it down still
722
+ // clears isPressed, instead of leaving it stuck true forever.
723
+ window.addEventListener("mouseup", handleMouseUp);
724
+ // Shared by mousemove and the touch handlers below — updates hoveredId
725
+ // from a canvas position and fires HOVER_IN/HOVER_OUT as whatever's
726
+ // underneath it changes. Touch has no ambient hover the way a mouse
727
+ // does (nothing is "hovered" until a finger actually touches down), but
728
+ // feeding a touch's position through this the same as the mouse's is
729
+ // what lets an isHoverable renderable react to a tap/drag at all.
730
+ const updateHover = (mouse) => {
642
731
  const { renderables, camera } = renderState(state);
643
732
  const worldMouse = toWorldPosition(mouse, camera);
644
733
  const hovered = [...renderables]
@@ -655,9 +744,9 @@ const runEngine = async (props) => {
655
744
  }
656
745
  hoveredId = hovered === undefined ? null : hovered.id ?? null;
657
746
  };
747
+ const handleMouseMoveHover = (ev) => updateHover(getCanvasPosition(ev));
658
748
  canvas.addEventListener("mousemove", handleMouseMoveHover);
659
- const handleMouseMoveTracking = (ev) => {
660
- const mouse = getCanvasPosition(ev);
749
+ const updateTracking = (mouse) => {
661
750
  if (hoveredId === null) {
662
751
  return;
663
752
  }
@@ -667,13 +756,17 @@ const runEngine = async (props) => {
667
756
  events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
668
757
  }
669
758
  };
759
+ const handleMouseMoveTracking = (ev) => updateTracking(getCanvasPosition(ev));
670
760
  canvas.addEventListener("mousemove", handleMouseMoveTracking);
671
761
  // No further mousemove fires once the mouse is off the canvas, so this
672
762
  // is also the only chance to report a HOVER_OUT for whatever was
673
763
  // hovered when it left — otherwise that hover would just dangle,
674
764
  // never explicitly ended.
675
- const handleMouseLeave = (ev) => {
676
- const mouse = getCanvasPosition(ev);
765
+ // Shared by mouseleave and the touch handlers below — clears whatever's
766
+ // hovered (firing HOVER_OUT for it) without a HOVER_IN taking its
767
+ // place. Returns worldMouse so callers that need it (MOUSE_LEAVE below)
768
+ // don't have to call renderState() a second time just to get it.
769
+ const clearHover = (mouse) => {
677
770
  const { renderables, camera } = renderState(state);
678
771
  const worldMouse = toWorldPosition(mouse, camera);
679
772
  if (hoveredId !== null) {
@@ -683,9 +776,79 @@ const runEngine = async (props) => {
683
776
  }
684
777
  }
685
778
  hoveredId = null;
779
+ return worldMouse;
780
+ };
781
+ const handleMouseLeave = (ev) => {
782
+ const mouse = getCanvasPosition(ev);
783
+ const worldMouse = clearHover(mouse);
686
784
  events.push({ tag: "MOUSE_LEAVE", mouse, worldMouse });
687
785
  };
688
786
  canvas.addEventListener("mouseleave", handleMouseLeave);
787
+ // Translates touch into the same HOVER_IN/HOVER_OUT/MOUSE_MOVE/CLICK
788
+ // events mouse input already produces (via updateHover/updateTracking/
789
+ // fireClick/clearHover above), so existing game code written against
790
+ // those events works on a touchscreen with no changes of its own.
791
+ // Single-touch only — touches[0]/changedTouches[0] — the same "one
792
+ // active pointer" model mouse input already assumes; a second finger is
793
+ // ignored rather than tracked as its own pointer.
794
+ //
795
+ // preventDefault on start/move keeps a drag/tap on the canvas from also
796
+ // scrolling, pinch-zooming, or triggering pull-to-refresh — the browser
797
+ // gestures a touchscreen normally reserves that space for; { passive:
798
+ // false } is what makes preventDefault actually take effect here.
799
+ const handleTouchStart = (ev) => {
800
+ ev.preventDefault();
801
+ const touch = ev.touches[0];
802
+ if (touch === undefined)
803
+ return;
804
+ currentMouseButtonState.isPressed = true;
805
+ const mouse = getTouchPosition(touch);
806
+ updateHover(mouse);
807
+ updateTracking(mouse);
808
+ };
809
+ canvas.addEventListener("touchstart", handleTouchStart, { passive: false });
810
+ const handleTouchMove = (ev) => {
811
+ ev.preventDefault();
812
+ const touch = ev.touches[0];
813
+ if (touch === undefined)
814
+ return;
815
+ const mouse = getTouchPosition(touch);
816
+ updateHover(mouse);
817
+ updateTracking(mouse);
818
+ };
819
+ canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
820
+ // A lifted finger both releases (mirroring mouseup) and clicks
821
+ // (mirroring the browser's own click-after-mouseup) — touch has no
822
+ // separate "up" and "click" events of its own the way mouse does, so
823
+ // both happen here together, in that order. hover is updated once more
824
+ // first so a plain tap (touchstart immediately followed by touchend,
825
+ // with no touchmove between them to have already done this) still
826
+ // fires CLICK against whatever's actually under it; hover is then
827
+ // cleared, since nothing's left touching it once the finger lifts.
828
+ const handleTouchEnd = (ev) => {
829
+ ev.preventDefault();
830
+ currentMouseButtonState.isPressed = false;
831
+ const touch = ev.changedTouches[0];
832
+ if (touch === undefined)
833
+ return;
834
+ const mouse = getTouchPosition(touch);
835
+ updateHover(mouse);
836
+ fireClick(mouse);
837
+ clearHover(mouse);
838
+ };
839
+ canvas.addEventListener("touchend", handleTouchEnd, { passive: false });
840
+ // A cancelled touch (e.g. an incoming call interrupting the page, or
841
+ // the OS deciding it's a system gesture instead) never fires touchend —
842
+ // handled the same as lifting the finger, minus the click, since
843
+ // there's no tap to speak of once the touch itself has been cancelled.
844
+ const handleTouchCancel = (ev) => {
845
+ currentMouseButtonState.isPressed = false;
846
+ const touch = ev.changedTouches[0];
847
+ if (touch === undefined)
848
+ return;
849
+ clearHover(getTouchPosition(touch));
850
+ };
851
+ canvas.addEventListener("touchcancel", handleTouchCancel, { passive: false });
689
852
  // Tab switches are a document-level concern (visibilitychange), not
690
853
  // something that ever reaches the canvas itself the way mouse/keyboard
691
854
  // events do.
@@ -817,11 +980,17 @@ const runEngine = async (props) => {
817
980
  isJustReleased: !isPressed && previouslyPressed,
818
981
  };
819
982
  });
983
+ const mouseButton = {
984
+ isPressed: currentMouseButtonState.isPressed,
985
+ isJustPressed: currentMouseButtonState.isPressed && !previousMouseButtonState.isPressed,
986
+ isJustReleased: !currentMouseButtonState.isPressed && previousMouseButtonState.isPressed,
987
+ };
820
988
  for (const nextState of nextStateFns) {
821
989
  const result = nextState({
822
990
  state,
823
991
  event,
824
992
  keyboard,
993
+ mouseButton,
825
994
  playSound,
826
995
  playMusic,
827
996
  pauseMusic,
@@ -841,7 +1010,7 @@ const runEngine = async (props) => {
841
1010
  }
842
1011
  }
843
1012
  events.splice(0, events.length);
844
- context.clearRect(0, 0, canvas.width, canvas.height);
1013
+ context.clearRect(0, 0, logicalWidth, logicalHeight);
845
1014
  const { cursor, renderables } = renderState(state);
846
1015
  canvas.style.cursor = cursor ?? "default";
847
1016
  for (const renderable of renderables) {
@@ -874,12 +1043,11 @@ const runEngine = async (props) => {
874
1043
  continue;
875
1044
  }
876
1045
  if (renderable.type === "SPRITE") {
877
- const { opacity = 1, flipX = false, modulate, swapColors } = renderable;
1046
+ const { opacity = 1, flipX = false, frame: frameIndex = 0, modulate, swapColors } = renderable;
878
1047
  const resource = resourceById[renderable.resourceId];
879
1048
  const frame = {
880
- x: renderable.frame % resource.slices.horizontal,
881
- y: Math.floor(renderable.frame / resource.slices.horizontal) %
882
- resource.slices.vertical,
1049
+ x: frameIndex % resource.slices.horizontal,
1050
+ y: Math.floor(frameIndex / resource.slices.horizontal) % resource.slices.vertical,
883
1051
  };
884
1052
  const source = {
885
1053
  x: frame.x * resource.size.width,
@@ -899,7 +1067,7 @@ const runEngine = async (props) => {
899
1067
  let image = resource.image;
900
1068
  let imageSource = source;
901
1069
  if (swapColors !== undefined && swapColors.length > 0) {
902
- image = swappedSpriteFrame(renderable.resourceId, renderable.frame, image, imageSource, swapColors);
1070
+ image = swappedSpriteFrame(renderable.resourceId, frameIndex, image, imageSource, swapColors);
903
1071
  imageSource = { x: 0, y: 0, width: source.width, height: source.height };
904
1072
  }
905
1073
  if (modulate !== undefined) {
@@ -953,6 +1121,7 @@ const runEngine = async (props) => {
953
1121
  }
954
1122
  lastFrame = now;
955
1123
  previousState.keyboardState = { ...currentState.keyboardState };
1124
+ previousMouseButtonState = { ...currentMouseButtonState };
956
1125
  }, 0);
957
1126
  resetCanvas = () => {
958
1127
  clearInterval(intervalId);
@@ -976,9 +1145,16 @@ const runEngine = async (props) => {
976
1145
  canvas.removeEventListener("click", handleClick);
977
1146
  canvas.removeEventListener("keydown", handleKeyDown);
978
1147
  canvas.removeEventListener("keyup", handleKeyUp);
1148
+ canvas.removeEventListener("mousedown", handleMouseDown);
979
1149
  canvas.removeEventListener("mousemove", handleMouseMoveHover);
980
1150
  canvas.removeEventListener("mousemove", handleMouseMoveTracking);
981
1151
  canvas.removeEventListener("mouseleave", handleMouseLeave);
1152
+ canvas.removeEventListener("touchstart", handleTouchStart);
1153
+ canvas.removeEventListener("touchmove", handleTouchMove);
1154
+ canvas.removeEventListener("touchend", handleTouchEnd);
1155
+ canvas.removeEventListener("touchcancel", handleTouchCancel);
1156
+ // On window, not the canvas — see where it's added above for why.
1157
+ window.removeEventListener("mouseup", handleMouseUp);
982
1158
  // This one's on `document`, not the canvas — same reasoning as above,
983
1159
  // just doubly true since `document` isn't even scoped to this canvas.
984
1160
  window.document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -989,5 +1165,42 @@ const runEngine = async (props) => {
989
1165
  return { sendEvent, requestFullscreen, exitFullscreen };
990
1166
  };
991
1167
 
992
- export { STOP, runEngine };
1168
+ // One factory per Renderable variant — literally just `{ type: "X",
1169
+ // ...props }`, so `sprite({...})` (or `Yuuna.sprite({...})` in the
1170
+ // browser bundle/playground) reads the same as writing the object
1171
+ // literal by hand, minus needing to get `type` right yourself.
1172
+ // Deliberately not a place defaults live: besides SpriteRenderable.frame
1173
+ // (optional at the type level, see types.ts — the engine itself defaults
1174
+ // it to 0), each factory still requires whatever its Renderable type
1175
+ // still requires.
1176
+ const rectangle = (props) => ({
1177
+ type: "RECTANGLE",
1178
+ ...props,
1179
+ });
1180
+ const circle = (props) => ({
1181
+ type: "CIRCLE",
1182
+ ...props,
1183
+ });
1184
+ const text = (props) => ({
1185
+ type: "TEXT",
1186
+ ...props,
1187
+ });
1188
+ const sprite = (props) => ({
1189
+ type: "SPRITE",
1190
+ ...props,
1191
+ });
1192
+ const animatedSprite = (props) => ({
1193
+ type: "ANIMATED_SPRITE",
1194
+ ...props,
1195
+ });
1196
+ const line = (props) => ({
1197
+ type: "LINE",
1198
+ ...props,
1199
+ });
1200
+ const group = (props) => ({
1201
+ type: "GROUP",
1202
+ ...props,
1203
+ });
1204
+
1205
+ export { STOP, animatedSprite, circle, group, line, rectangle, runEngine, sprite, text };
993
1206
  //# 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.6.0",
3
+ "version": "0.7.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",
@@ -39,7 +39,9 @@
39
39
  "scripts": {
40
40
  "build": "node scripts/generate-playground-assets.mjs && rollup -c",
41
41
  "watch": "node scripts/generate-playground-assets.mjs && rollup -c -w",
42
- "prepublishOnly": "npm run build"
42
+ "prepublishOnly": "npm run build",
43
+ "sync-assets": "node scripts/sync-playground-assets.mjs",
44
+ "postinstall": "node -e \"const fs=require('fs');try{if(fs.existsSync('.githooks')&&fs.existsSync('.git')){require('child_process').execSync('git config core.hooksPath .githooks')}}catch(e){}\""
43
45
  },
44
46
  "devDependencies": {
45
47
  "nodemon": "^3.1.0",
@@ -1,6 +0,0 @@
1
- export declare const CONSTANTS: {
2
- WINDOW: {
3
- WIDTH: number;
4
- HEIGHT: number;
5
- };
6
- };