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 +160 -156
- package/lib/engine/renderables.d.ts +8 -0
- package/lib/engine/types.d.ts +16 -3
- package/lib/index.cjs +242 -22
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.js +236 -23
- package/lib/index.js.map +1 -1
- package/package.json +4 -2
- package/lib/utils/constants.d.ts +0 -6
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
[
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
npx degit lucy-dot-exe/yuuna/templates/
|
|
116
|
-
# or: npx degit lucy-dot-exe/yuuna/templates/
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 committed — this 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;
|
package/lib/engine/types.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export type SpriteRenderable = BaseRenderable & {
|
|
|
54
54
|
y: number;
|
|
55
55
|
};
|
|
56
56
|
resourceId: string;
|
|
57
|
-
frame
|
|
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
|
-
|
|
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:
|
|
220
|
-
height:
|
|
263
|
+
width: sheetSize.width / slices.horizontal,
|
|
264
|
+
height: sheetSize.height / slices.vertical,
|
|
221
265
|
},
|
|
222
|
-
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 = () =>
|
|
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 *
|
|
594
|
-
y: (ev.offsetY *
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
643
|
-
|
|
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
|
|
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
|
-
|
|
678
|
-
|
|
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,
|
|
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:
|
|
883
|
-
y: Math.floor(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
218
|
-
height:
|
|
261
|
+
width: sheetSize.width / slices.horizontal,
|
|
262
|
+
height: sheetSize.height / slices.vertical,
|
|
219
263
|
},
|
|
220
|
-
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 = () =>
|
|
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 *
|
|
592
|
-
y: (ev.offsetY *
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
|
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
|
-
|
|
676
|
-
|
|
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,
|
|
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:
|
|
881
|
-
y: Math.floor(
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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",
|