yuuna-engine 0.2.1 → 0.3.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 +194 -135
- package/lib/engine/types.d.ts +78 -27
- package/lib/index.cjs +404 -33
- package/lib/index.cjs.map +1 -1
- package/lib/index.js +404 -33
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -105,6 +105,19 @@ const keyboardKeys = [
|
|
|
105
105
|
|
|
106
106
|
let resetCanvas = null;
|
|
107
107
|
let latestRunId = 0;
|
|
108
|
+
// Shared between drawing TEXT renderables and hit-testing them for
|
|
109
|
+
// clicks/hovers, so the clickable area always matches what's on screen.
|
|
110
|
+
const DEFAULT_TEXT_FONT_SIZE = 30;
|
|
111
|
+
const textFont = (fontSize) => `${fontSize}px Arial`;
|
|
112
|
+
// Renderables with the same layer keep render()'s order — Array#sort is
|
|
113
|
+
// stable — so `layer` only needs to move something relative to the rest,
|
|
114
|
+
// not say anything about where exactly it lands among equal layers.
|
|
115
|
+
const sortByLayer = (renderables) => [...renderables].sort((a, b) => (a.layer ?? 0) - (b.layer ?? 0));
|
|
116
|
+
// A LINE has no `position`, just two endpoints — `from` anchors its scale
|
|
117
|
+
// the same way `position` anchors every other renderable's.
|
|
118
|
+
const anchorOf = (renderable) => renderable.type === "LINE" ? renderable.from : renderable.position;
|
|
119
|
+
// No RunEngineProps.camera set is the same as one that doesn't pan or zoom.
|
|
120
|
+
const DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
|
|
108
121
|
const runEngine = async (props) => {
|
|
109
122
|
const runId = ++latestRunId;
|
|
110
123
|
resetCanvas?.();
|
|
@@ -144,9 +157,51 @@ const runEngine = async (props) => {
|
|
|
144
157
|
height: value.size.height / value.slices.vertical,
|
|
145
158
|
},
|
|
146
159
|
slices: value.slices,
|
|
160
|
+
animations: value.animations ?? {},
|
|
147
161
|
});
|
|
148
162
|
};
|
|
149
163
|
}));
|
|
164
|
+
const loadAudio = (src) => new Promise((resolve) => {
|
|
165
|
+
const audio = new Audio(src);
|
|
166
|
+
audio.oncanplaythrough = function () {
|
|
167
|
+
resolve(audio);
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
const sounds = props.sounds ?? {};
|
|
171
|
+
const audioById = await iterateRecordAsync(sounds, ({ value }) => loadAudio(value.src));
|
|
172
|
+
// Cloning the loaded element per play (instead of reusing it directly)
|
|
173
|
+
// lets the same sound overlap itself — e.g. rapid clicks each get their
|
|
174
|
+
// own playback instead of restarting/cutting off the previous one.
|
|
175
|
+
const playSound = (id) => {
|
|
176
|
+
const audio = audioById[id];
|
|
177
|
+
if (audio === undefined) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const instance = audio.cloneNode();
|
|
181
|
+
instance.play();
|
|
182
|
+
};
|
|
183
|
+
const music = props.music ?? {};
|
|
184
|
+
const musicById = await iterateRecordAsync(music, ({ value }) => loadAudio(value.src));
|
|
185
|
+
// Unlike sounds, music reuses the same element instead of cloning it —
|
|
186
|
+
// there's only ever one track playing, and reusing it is what lets
|
|
187
|
+
// pauseMusic()/playMusic() resume from where playback left off instead
|
|
188
|
+
// of starting over.
|
|
189
|
+
let currentMusic = null;
|
|
190
|
+
const playMusic = (id) => {
|
|
191
|
+
const audio = musicById[id];
|
|
192
|
+
if (audio === undefined) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (currentMusic !== null && currentMusic !== audio) {
|
|
196
|
+
currentMusic.pause();
|
|
197
|
+
}
|
|
198
|
+
audio.loop = true;
|
|
199
|
+
audio.play();
|
|
200
|
+
currentMusic = audio;
|
|
201
|
+
};
|
|
202
|
+
const pauseMusic = () => {
|
|
203
|
+
currentMusic?.pause();
|
|
204
|
+
};
|
|
150
205
|
// A newer runEngine() call started while this one was still loading
|
|
151
206
|
// resources (e.g. a spritesheet) — abandon this run instead of setting
|
|
152
207
|
// up a second, orphaned render loop alongside the newer one.
|
|
@@ -154,18 +209,185 @@ const runEngine = async (props) => {
|
|
|
154
209
|
return;
|
|
155
210
|
}
|
|
156
211
|
context.imageSmoothingEnabled = false;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
212
|
+
// An offscreen 1x1 canvas used only to resolve a CSS color string (a
|
|
213
|
+
// name, hex, rgb(), hsl(), ...) into concrete RGBA bytes: paint it that
|
|
214
|
+
// color and read the pixel back. Lets modulateColor() below multiply
|
|
215
|
+
// any two colors together without hand-rolling a CSS color parser.
|
|
216
|
+
const colorSwatchContext = window.document.createElement("canvas").getContext("2d");
|
|
217
|
+
// Color strings are almost always the same literal reused every frame,
|
|
218
|
+
// and getImageData is one of the slower canvas operations — cached so a
|
|
219
|
+
// given color only actually gets resolved once.
|
|
220
|
+
const resolvedColorByString = {};
|
|
221
|
+
const resolveColor = (color) => {
|
|
222
|
+
const cached = resolvedColorByString[color];
|
|
223
|
+
if (cached !== undefined) {
|
|
224
|
+
return cached;
|
|
225
|
+
}
|
|
226
|
+
if (colorSwatchContext === null) {
|
|
227
|
+
return [255, 255, 255, 255];
|
|
228
|
+
}
|
|
229
|
+
colorSwatchContext.clearRect(0, 0, 1, 1);
|
|
230
|
+
colorSwatchContext.fillStyle = color;
|
|
231
|
+
colorSwatchContext.fillRect(0, 0, 1, 1);
|
|
232
|
+
const [r, g, b, a] = colorSwatchContext.getImageData(0, 0, 1, 1).data;
|
|
233
|
+
const resolved = [r, g, b, a];
|
|
234
|
+
resolvedColorByString[color] = resolved;
|
|
235
|
+
return resolved;
|
|
236
|
+
};
|
|
237
|
+
// Multiplies two colors channel-by-channel, the same way Godot's
|
|
238
|
+
// `modulate` works — e.g. modulating "white" by "#808080" halves
|
|
239
|
+
// brightness, by "#ff0000" keeps only the red channel.
|
|
240
|
+
const modulateColor = (color, modulate) => {
|
|
241
|
+
const [r1, g1, b1, a1] = resolveColor(color);
|
|
242
|
+
const [r2, g2, b2, a2] = resolveColor(modulate);
|
|
243
|
+
const mixChannel = (c1, c2) => (c1 * c2) / 255;
|
|
244
|
+
return `rgba(${mixChannel(r1, r2)}, ${mixChannel(g1, g2)}, ${mixChannel(b1, b2)}, ${mixChannel(a1, a2) / 255})`;
|
|
245
|
+
};
|
|
246
|
+
// Combines a parent's already-composed modulate with a child's own —
|
|
247
|
+
// undefined means "no tint from this side", so it just passes the
|
|
248
|
+
// other one through instead of multiplying against an implicit white.
|
|
249
|
+
const composeModulate = (parent, own) => {
|
|
250
|
+
if (parent === undefined)
|
|
251
|
+
return own;
|
|
252
|
+
if (own === undefined)
|
|
253
|
+
return parent;
|
|
254
|
+
return modulateColor(parent, own);
|
|
255
|
+
};
|
|
256
|
+
const IDENTITY_TRANSFORM = {
|
|
257
|
+
anchor: { x: 0, y: 0 },
|
|
258
|
+
scale: { x: 1, y: 1 },
|
|
259
|
+
modulate: undefined,
|
|
260
|
+
layer: 0,
|
|
261
|
+
};
|
|
262
|
+
// A child's own position is authored relative to its parent, in the
|
|
263
|
+
// parent's local (unscaled) units — the same way Godot composes
|
|
264
|
+
// Node2D transforms — so it needs both the parent's scale and its
|
|
265
|
+
// accumulated offset applied to land in absolute canvas coordinates.
|
|
266
|
+
const transformPoint = (parent, localPoint) => ({
|
|
267
|
+
x: parent.anchor.x + localPoint.x * parent.scale.x,
|
|
268
|
+
y: parent.anchor.y + localPoint.y * parent.scale.y,
|
|
269
|
+
});
|
|
270
|
+
// Each id's accumulated playback time for its current animation, plus
|
|
271
|
+
// when that was last updated — keyed by id, since an ANIMATED_SPRITE
|
|
272
|
+
// has no state of its own (render() returns brand-new objects every
|
|
273
|
+
// frame). elapsedMs only advances while not paused (see
|
|
274
|
+
// resolveAnimatedSprite), so pausing genuinely stops the clock rather
|
|
275
|
+
// than just freezing which frame gets drawn — unpausing resumes from
|
|
276
|
+
// the same frame instead of skipping ahead by however long it was
|
|
277
|
+
// paused. Accumulating incrementally like this (instead of computing
|
|
278
|
+
// elapsed from an absolute start time every call) is also what makes
|
|
279
|
+
// pausing possible at all, and as a side effect means a `timeScale`
|
|
280
|
+
// that changes over an animation's lifetime scales each tick as it
|
|
281
|
+
// happens rather than retroactively rescaling the whole duration so
|
|
282
|
+
// far. Pruned each renderState() call (see seenAnimationIds there) to
|
|
283
|
+
// only ids actually present that frame, so a game that spawns entities
|
|
284
|
+
// with ever-incrementing ids doesn't leak one entry per entity ever
|
|
285
|
+
// spawned.
|
|
286
|
+
const animationStateById = new Map();
|
|
287
|
+
const seenAnimationIds = new Set();
|
|
288
|
+
// Resolves an ANIMATED_SPRITE down to a plain SPRITE with `frame`
|
|
289
|
+
// computed from how long its current animation has been playing —
|
|
290
|
+
// everything past this point (flattenRenderable's position/scale/
|
|
291
|
+
// modulate/layer composition, hit-testing, drawing) treats the result
|
|
292
|
+
// exactly like any other SPRITE, with no idea ANIMATED_SPRITE exists.
|
|
293
|
+
const resolveAnimatedSprite = (renderable) => {
|
|
294
|
+
seenAnimationIds.add(renderable.id);
|
|
295
|
+
const resource = resourceById[renderable.resourceId];
|
|
296
|
+
const animation = resource.animations[renderable.animation];
|
|
297
|
+
const timeScale = renderable.timeScale ?? 1;
|
|
298
|
+
const paused = renderable.paused ?? false;
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
const tracked = animationStateById.get(renderable.id);
|
|
301
|
+
// A new id, or the same id switching to a different animation, both
|
|
302
|
+
// start that animation over from frame 0 rather than picking up
|
|
303
|
+
// wherever the previous timer happened to be.
|
|
304
|
+
const previous = tracked !== undefined && tracked.animation === renderable.animation
|
|
305
|
+
? tracked
|
|
306
|
+
: { animation: renderable.animation, elapsedMs: 0, lastUpdateTime: now };
|
|
307
|
+
const elapsedMs = paused
|
|
308
|
+
? previous.elapsedMs
|
|
309
|
+
: previous.elapsedMs + Math.max(0, now - previous.lastUpdateTime) * timeScale;
|
|
310
|
+
// lastUpdateTime always moves forward, paused or not — otherwise the
|
|
311
|
+
// first tick after unpausing would see a gap stretching back to
|
|
312
|
+
// whenever it was paused, and (dis)count that whole gap as elapsed
|
|
313
|
+
// playback time in one jump.
|
|
314
|
+
animationStateById.set(renderable.id, { animation: renderable.animation, elapsedMs, lastUpdateTime: now });
|
|
315
|
+
const frameCursor = Math.floor(elapsedMs / animation.frameDuration);
|
|
316
|
+
const frameIndex = animation.loop
|
|
317
|
+
? frameCursor % animation.frames.length
|
|
318
|
+
: Math.min(frameCursor, animation.frames.length - 1);
|
|
319
|
+
return { ...renderable, type: "SPRITE", frame: animation.frames[frameIndex] };
|
|
320
|
+
};
|
|
321
|
+
// Turns a render() tree into the flat list the rest of the engine
|
|
322
|
+
// already knows how to sort/draw/hit-test — each renderable's own
|
|
323
|
+
// position/from/to/scale/modulate/layer replaced by the *effective*
|
|
324
|
+
// (absolute, fully composed with its ancestors') values, and its
|
|
325
|
+
// children peeled off into their own entries in the returned list
|
|
326
|
+
// instead of staying nested. Nothing past this point needs to know
|
|
327
|
+
// parent/child relationships existed at all.
|
|
328
|
+
const flattenRenderable = (renderable, parent) => {
|
|
329
|
+
const resolved = renderable.type === "ANIMATED_SPRITE" ? resolveAnimatedSprite(renderable) : renderable;
|
|
330
|
+
const localScale = resolved.scale ?? { x: 1, y: 1 };
|
|
331
|
+
const scale = { x: parent.scale.x * localScale.x, y: parent.scale.y * localScale.y };
|
|
332
|
+
const modulate = composeModulate(parent.modulate, resolved.modulate);
|
|
333
|
+
const layer = parent.layer + (resolved.layer ?? 0);
|
|
334
|
+
const effective = resolved.type === "LINE"
|
|
335
|
+
? {
|
|
336
|
+
...resolved,
|
|
337
|
+
from: transformPoint(parent, resolved.from),
|
|
338
|
+
to: transformPoint(parent, resolved.to),
|
|
339
|
+
scale,
|
|
340
|
+
modulate,
|
|
341
|
+
layer,
|
|
342
|
+
children: undefined,
|
|
343
|
+
}
|
|
344
|
+
: {
|
|
345
|
+
...resolved,
|
|
346
|
+
position: transformPoint(parent, resolved.position),
|
|
347
|
+
scale,
|
|
348
|
+
modulate,
|
|
349
|
+
layer,
|
|
350
|
+
children: undefined,
|
|
351
|
+
};
|
|
352
|
+
const childTransform = { anchor: anchorOf(effective), scale, modulate, layer };
|
|
353
|
+
const children = (resolved.children ?? []).flatMap((child) => flattenRenderable(child, childTransform));
|
|
354
|
+
return [effective, ...children];
|
|
355
|
+
};
|
|
356
|
+
// parent defaults to the identity transform (used for screen-space
|
|
357
|
+
// renderables, and by everything before the camera existed) — callers
|
|
358
|
+
// that need a different root, like renderState's camera below, pass
|
|
359
|
+
// their own.
|
|
360
|
+
const flattenRenderables = (renderables, parent = IDENTITY_TRANSFORM) => renderables.flatMap((renderable) => flattenRenderable(renderable, parent));
|
|
361
|
+
const getFocusedElement = (position, r) => {
|
|
362
|
+
const isNonInteractable = r.type === "LINE" ||
|
|
363
|
+
r.type === "GROUP" ||
|
|
364
|
+
// Unreachable — flattenRenderable always resolves ANIMATED_SPRITE
|
|
365
|
+
// to a plain SPRITE before hit-testing ever sees one. Listed here
|
|
366
|
+
// (rather than left for exhaust() to catch) so getFocusedElement
|
|
367
|
+
// itself type-checks as exhaustive.
|
|
368
|
+
r.type === "ANIMATED_SPRITE" ||
|
|
160
369
|
((r.isHoverable === undefined || !r.isHoverable) &&
|
|
161
370
|
(r.isClickable === undefined || !r.isClickable));
|
|
162
371
|
if (isNonInteractable) {
|
|
163
372
|
return false;
|
|
164
373
|
}
|
|
374
|
+
// Scale is applied around the renderable's anchor as a draw-time canvas
|
|
375
|
+
// transform (see applyScale below) rather than by inflating its
|
|
376
|
+
// size — so hit-testing does the inverse instead: bring the mouse
|
|
377
|
+
// position into the renderable's own unscaled coordinate space, then
|
|
378
|
+
// run the exact same math below as if scale were untouched. For
|
|
379
|
+
// CIRCLE this also happens to be the standard "is this point inside
|
|
380
|
+
// this ellipse" test, for free, once it's drawing as one.
|
|
381
|
+
const { x: scaleX, y: scaleY } = r.scale ?? { x: 1, y: 1 };
|
|
382
|
+
const anchor = anchorOf(r);
|
|
383
|
+
const localPosition = {
|
|
384
|
+
x: anchor.x + (position.x - anchor.x) / scaleX,
|
|
385
|
+
y: anchor.y + (position.y - anchor.y) / scaleY,
|
|
386
|
+
};
|
|
165
387
|
if (r.type === "CIRCLE") {
|
|
166
388
|
const delta = {
|
|
167
|
-
x: Math.abs(
|
|
168
|
-
y: Math.abs(
|
|
389
|
+
x: Math.abs(localPosition.x - r.position.x),
|
|
390
|
+
y: Math.abs(localPosition.y - r.position.y),
|
|
169
391
|
};
|
|
170
392
|
const distance = Math.sqrt(delta.x * delta.x + delta.y * delta.y);
|
|
171
393
|
const isHovered = distance < r.radius;
|
|
@@ -177,26 +399,86 @@ const runEngine = async (props) => {
|
|
|
177
399
|
x: r.position.x + r.size.width,
|
|
178
400
|
y: r.position.y + r.size.height,
|
|
179
401
|
};
|
|
180
|
-
const isInsideX =
|
|
181
|
-
const isInsideY =
|
|
402
|
+
const isInsideX = localPosition.x > topLeft.x && localPosition.x < bottomRight.x;
|
|
403
|
+
const isInsideY = localPosition.y > topLeft.y && localPosition.y < bottomRight.y;
|
|
182
404
|
const isHovered = isInsideX && isInsideY;
|
|
183
405
|
return isHovered;
|
|
184
406
|
}
|
|
185
407
|
if (r.type === "SPRITE") {
|
|
186
408
|
const topLeft = { x: r.position.x, y: r.position.y };
|
|
187
409
|
const resource = resourceById[r.resourceId];
|
|
188
|
-
const { scale = 1 } = r;
|
|
189
410
|
const bottomRight = {
|
|
190
|
-
x: r.position.x + resource.size.width
|
|
191
|
-
y: r.position.y + resource.size.height
|
|
411
|
+
x: r.position.x + resource.size.width,
|
|
412
|
+
y: r.position.y + resource.size.height,
|
|
192
413
|
};
|
|
193
|
-
const isInsideX =
|
|
194
|
-
const isInsideY =
|
|
414
|
+
const isInsideX = localPosition.x > topLeft.x && localPosition.x < bottomRight.x;
|
|
415
|
+
const isInsideY = localPosition.y > topLeft.y && localPosition.y < bottomRight.y;
|
|
195
416
|
const isHovered = isInsideX && isInsideY;
|
|
196
417
|
return isHovered;
|
|
197
418
|
}
|
|
419
|
+
if (r.type === "TEXT") {
|
|
420
|
+
// Text has no explicit size, so its clickable area is derived from
|
|
421
|
+
// measuring it the same way it's drawn (see the TEXT branch in the
|
|
422
|
+
// render loop below) — anchored the same way its `align` positions
|
|
423
|
+
// it relative to `position`.
|
|
424
|
+
const fontSize = r.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
|
425
|
+
context.font = textFont(fontSize);
|
|
426
|
+
const width = context.measureText(r.text).width;
|
|
427
|
+
const height = fontSize;
|
|
428
|
+
const alignX = r.align?.x ?? "left";
|
|
429
|
+
const left = alignX === "center" ? r.position.x - width / 2 : alignX === "right" ? r.position.x - width : r.position.x;
|
|
430
|
+
const alignY = r.align?.y ?? "top";
|
|
431
|
+
const top = alignY === "middle" ? r.position.y - height / 2 : alignY === "bottom" ? r.position.y - height : r.position.y;
|
|
432
|
+
const isInsideX = localPosition.x > left && localPosition.x < left + width;
|
|
433
|
+
const isInsideY = localPosition.y > top && localPosition.y < top + height;
|
|
434
|
+
return isInsideX && isInsideY;
|
|
435
|
+
}
|
|
198
436
|
exhaust(r);
|
|
199
|
-
}
|
|
437
|
+
};
|
|
438
|
+
// The camera is just another ancestor transform, exactly like a
|
|
439
|
+
// renderable's own parent — { x, y } is the world position mapped to
|
|
440
|
+
// canvas (0, 0), so it's the anchor transformPoint already expects:
|
|
441
|
+
// screen = (world - camera.position) * zoom.
|
|
442
|
+
const cameraTransformOf = (camera) => ({
|
|
443
|
+
anchor: { x: -camera.x * camera.zoom, y: -camera.y * camera.zoom },
|
|
444
|
+
scale: { x: camera.zoom, y: camera.zoom },
|
|
445
|
+
modulate: undefined,
|
|
446
|
+
layer: 0,
|
|
447
|
+
});
|
|
448
|
+
// The inverse of cameraTransformOf, for turning a raw canvas position
|
|
449
|
+
// (mouse) into a world position (worldMouse) — see ClickEvent etc.
|
|
450
|
+
const toWorldPosition = (position, camera) => ({
|
|
451
|
+
x: camera.x + position.x / camera.zoom,
|
|
452
|
+
y: camera.y + position.y / camera.zoom,
|
|
453
|
+
});
|
|
454
|
+
// Every call site needs renderables flattened and in draw order — the
|
|
455
|
+
// mousemove/click handlers below to find whichever's topmost under the
|
|
456
|
+
// mouse, the draw loop to actually draw them that way — so both are
|
|
457
|
+
// applied once here instead of separately wherever props.render() gets
|
|
458
|
+
// called. Screen-space renderables skip the camera entirely (flattened
|
|
459
|
+
// from the identity transform, same as before there was a camera);
|
|
460
|
+
// everything else is flattened as if the camera were its shared parent.
|
|
461
|
+
const renderState = (state) => {
|
|
462
|
+
const result = props.render(state);
|
|
463
|
+
const camera = props.camera?.(state) ?? DEFAULT_CAMERA;
|
|
464
|
+
const cameraTransform = cameraTransformOf(camera);
|
|
465
|
+
const worldRenderables = result.renderables.filter((r) => !r.screenSpace);
|
|
466
|
+
const screenRenderables = result.renderables.filter((r) => r.screenSpace);
|
|
467
|
+
seenAnimationIds.clear();
|
|
468
|
+
const renderables = sortByLayer([
|
|
469
|
+
...flattenRenderables(worldRenderables, cameraTransform),
|
|
470
|
+
...flattenRenderables(screenRenderables),
|
|
471
|
+
]);
|
|
472
|
+
// Anything not seen this pass is no longer being rendered (e.g. the
|
|
473
|
+
// entity it belonged to died) — drop its tracked start time instead
|
|
474
|
+
// of keeping it forever.
|
|
475
|
+
for (const id of animationStateById.keys()) {
|
|
476
|
+
if (!seenAnimationIds.has(id)) {
|
|
477
|
+
animationStateById.delete(id);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { cursor: result.cursor, renderables, camera };
|
|
481
|
+
};
|
|
200
482
|
// ev.offsetX/offsetY are in CSS-rendered pixels, which differ from the
|
|
201
483
|
// canvas's drawing-buffer resolution whenever it's displayed at a
|
|
202
484
|
// different size (e.g. scaled down to fit its container). Renderable
|
|
@@ -219,10 +501,10 @@ const runEngine = async (props) => {
|
|
|
219
501
|
const mouse = getCanvasPosition(ev);
|
|
220
502
|
if (hoveredId === null)
|
|
221
503
|
return;
|
|
222
|
-
const { renderables } =
|
|
504
|
+
const { renderables, camera } = renderState(state);
|
|
223
505
|
const hovered = renderables.find((e) => e.id === hoveredId);
|
|
224
506
|
if (hovered !== undefined && hovered.isClickable) {
|
|
225
|
-
events.push({ tag: "CLICK", id: hovered.id, mouse });
|
|
507
|
+
events.push({ tag: "CLICK", id: hovered.id, mouse, worldMouse: toWorldPosition(mouse, camera) });
|
|
226
508
|
}
|
|
227
509
|
});
|
|
228
510
|
const initialState = {
|
|
@@ -253,17 +535,18 @@ const runEngine = async (props) => {
|
|
|
253
535
|
});
|
|
254
536
|
canvas.addEventListener("mousemove", (ev) => {
|
|
255
537
|
const mouse = getCanvasPosition(ev);
|
|
256
|
-
const { renderables } =
|
|
538
|
+
const { renderables, camera } = renderState(state);
|
|
539
|
+
const worldMouse = toWorldPosition(mouse, camera);
|
|
257
540
|
const hovered = [...renderables]
|
|
258
541
|
.reverse()
|
|
259
542
|
.find((r) => getFocusedElement(mouse, r));
|
|
260
543
|
if (hovered !== undefined && hovered.isHoverable) {
|
|
261
|
-
events.push({ tag: "HOVER_IN", id: hovered.id, mouse });
|
|
544
|
+
events.push({ tag: "HOVER_IN", id: hovered.id, mouse, worldMouse });
|
|
262
545
|
}
|
|
263
546
|
if (hoveredId !== null) {
|
|
264
547
|
const lastHovered = renderables.find((r) => r.id === hoveredId);
|
|
265
548
|
if (lastHovered !== undefined && lastHovered.id !== hovered?.id) {
|
|
266
|
-
events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse });
|
|
549
|
+
events.push({ tag: "HOVER_OUT", id: lastHovered.id, mouse, worldMouse });
|
|
267
550
|
}
|
|
268
551
|
}
|
|
269
552
|
hoveredId = hovered === undefined ? null : hovered.id ?? null;
|
|
@@ -273,13 +556,62 @@ const runEngine = async (props) => {
|
|
|
273
556
|
if (hoveredId === null) {
|
|
274
557
|
return;
|
|
275
558
|
}
|
|
276
|
-
const { renderables } =
|
|
559
|
+
const { renderables, camera } = renderState(state);
|
|
277
560
|
const hovered = renderables.find((r) => r.id === hoveredId);
|
|
278
561
|
if (hovered !== undefined && hovered.trackMouseMovement) {
|
|
279
|
-
events.push({ tag: "MOUSE_MOVE", mouse, id: hovered.id });
|
|
562
|
+
events.push({ tag: "MOUSE_MOVE", mouse, worldMouse: toWorldPosition(mouse, camera), id: hovered.id });
|
|
280
563
|
}
|
|
281
564
|
});
|
|
282
565
|
context.imageSmoothingEnabled = false;
|
|
566
|
+
// Scale is a canvas transform around the renderable's anchor, applied
|
|
567
|
+
// before its type-specific drawing runs below — everything drawn under
|
|
568
|
+
// it (fills, strokes, images, even font size) comes out scaled without
|
|
569
|
+
// each renderable type needing its own size math, and a CIRCLE drawn
|
|
570
|
+
// under a non-uniform scale comes out an ellipse for free.
|
|
571
|
+
const applyScale = (renderable) => {
|
|
572
|
+
const { x: scaleX, y: scaleY } = renderable.scale ?? { x: 1, y: 1 };
|
|
573
|
+
if (scaleX === 1 && scaleY === 1) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const anchor = anchorOf(renderable);
|
|
577
|
+
context.translate(anchor.x, anchor.y);
|
|
578
|
+
context.scale(scaleX, scaleY);
|
|
579
|
+
context.translate(-anchor.x, -anchor.y);
|
|
580
|
+
};
|
|
581
|
+
// Reused across every tinted SPRITE draw this run, instead of allocating
|
|
582
|
+
// a fresh offscreen canvas per sprite per frame — see tintedSpriteFrame.
|
|
583
|
+
const tintBuffer = window.document.createElement("canvas");
|
|
584
|
+
const tintBufferContext = tintBuffer.getContext("2d");
|
|
585
|
+
// Renders one frame of a spritesheet, tinted by `modulate`, onto the
|
|
586
|
+
// shared offscreen buffer and returns it ready to draw — multiply-
|
|
587
|
+
// blending a filled rectangle over the frame would also color its
|
|
588
|
+
// fully-transparent pixels, so this clips that back down to the
|
|
589
|
+
// frame's own shape afterward with `destination-in`.
|
|
590
|
+
//
|
|
591
|
+
// This has to happen on an *isolated* buffer rather than directly on
|
|
592
|
+
// the main canvas: destination-in isn't scoped to this draw call's own
|
|
593
|
+
// area, it's a whole-buffer operation that erases anything the new
|
|
594
|
+
// draw doesn't cover. Doing it on the main canvas would erase whatever
|
|
595
|
+
// was already drawn underneath the sprite's transparent pixels (e.g.
|
|
596
|
+
// terrain showing through the gaps in a character) instead of leaving
|
|
597
|
+
// it alone. The buffer starts out empty, so there's nothing under it
|
|
598
|
+
// to lose — only the finished, correctly-masked result ever reaches
|
|
599
|
+
// the main canvas, via a normal (source-over) drawImage.
|
|
600
|
+
const tintedSpriteFrame = (image, source, modulate) => {
|
|
601
|
+
if (tintBufferContext === null) {
|
|
602
|
+
return image;
|
|
603
|
+
}
|
|
604
|
+
tintBuffer.width = source.width;
|
|
605
|
+
tintBuffer.height = source.height;
|
|
606
|
+
tintBufferContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
|
|
607
|
+
tintBufferContext.globalCompositeOperation = "multiply";
|
|
608
|
+
tintBufferContext.fillStyle = modulate;
|
|
609
|
+
tintBufferContext.fillRect(0, 0, source.width, source.height);
|
|
610
|
+
tintBufferContext.globalCompositeOperation = "destination-in";
|
|
611
|
+
tintBufferContext.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
|
|
612
|
+
tintBufferContext.globalCompositeOperation = "source-over";
|
|
613
|
+
return tintBuffer;
|
|
614
|
+
};
|
|
283
615
|
const intervalId = setInterval(() => {
|
|
284
616
|
const now = Date.now();
|
|
285
617
|
const delta = now - lastFrame;
|
|
@@ -294,7 +626,7 @@ const runEngine = async (props) => {
|
|
|
294
626
|
};
|
|
295
627
|
});
|
|
296
628
|
for (const nextState of nextStateFns) {
|
|
297
|
-
const result = nextState({ state, event, keyboard });
|
|
629
|
+
const result = nextState({ state, event, keyboard, playSound, playMusic, pauseMusic });
|
|
298
630
|
// STOP stops the rest of the list from running for this event,
|
|
299
631
|
// instead of every later mechanic needing to repeat the same
|
|
300
632
|
// guard. undefined just means this mechanic made no change, so
|
|
@@ -309,63 +641,102 @@ const runEngine = async (props) => {
|
|
|
309
641
|
}
|
|
310
642
|
events.splice(0, events.length);
|
|
311
643
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
312
|
-
const { cursor, renderables } =
|
|
644
|
+
const { cursor, renderables } = renderState(state);
|
|
313
645
|
canvas.style.cursor = cursor ?? "default";
|
|
314
646
|
for (const renderable of renderables) {
|
|
647
|
+
context.save();
|
|
648
|
+
applyScale(renderable);
|
|
315
649
|
if (renderable.type === "RECTANGLE") {
|
|
316
|
-
context.fillStyle =
|
|
650
|
+
context.fillStyle =
|
|
651
|
+
renderable.modulate === undefined ? renderable.color : modulateColor(renderable.color, renderable.modulate);
|
|
317
652
|
context.fillRect(renderable.position.x, renderable.position.y, renderable.size.width, renderable.size.height);
|
|
653
|
+
context.restore();
|
|
318
654
|
continue;
|
|
319
655
|
}
|
|
320
656
|
if (renderable.type === "CIRCLE") {
|
|
321
|
-
context.fillStyle =
|
|
657
|
+
context.fillStyle =
|
|
658
|
+
renderable.modulate === undefined ? renderable.color : modulateColor(renderable.color, renderable.modulate);
|
|
322
659
|
context.beginPath();
|
|
323
660
|
context.arc(renderable.position.x, renderable.position.y, renderable.radius, 0, 2 * Math.PI);
|
|
324
661
|
context.fill();
|
|
662
|
+
context.restore();
|
|
325
663
|
continue;
|
|
326
664
|
}
|
|
327
665
|
if (renderable.type === "TEXT") {
|
|
328
|
-
const { color, position: { x, y }, text, align, } = renderable;
|
|
329
|
-
context.fillStyle = color;
|
|
330
|
-
context.font =
|
|
666
|
+
const { color, position: { x, y }, text, align, fontSize, modulate, } = renderable;
|
|
667
|
+
context.fillStyle = modulate === undefined ? color : modulateColor(color, modulate);
|
|
668
|
+
context.font = textFont(fontSize ?? DEFAULT_TEXT_FONT_SIZE);
|
|
331
669
|
context.textAlign = align?.x ?? "left";
|
|
332
670
|
context.textBaseline = align?.y ?? "top";
|
|
333
671
|
context.fillText(text, x, y);
|
|
672
|
+
context.restore();
|
|
334
673
|
continue;
|
|
335
674
|
}
|
|
336
675
|
if (renderable.type === "SPRITE") {
|
|
337
|
-
const {
|
|
676
|
+
const { opacity = 1, flipX = false, modulate } = renderable;
|
|
338
677
|
const resource = resourceById[renderable.resourceId];
|
|
339
678
|
const frame = {
|
|
340
679
|
x: renderable.frame % resource.slices.horizontal,
|
|
341
680
|
y: Math.floor(renderable.frame / resource.slices.horizontal) %
|
|
342
681
|
resource.slices.vertical,
|
|
343
682
|
};
|
|
344
|
-
const
|
|
345
|
-
|
|
683
|
+
const source = {
|
|
684
|
+
x: frame.x * resource.size.width,
|
|
685
|
+
y: frame.y * resource.size.height,
|
|
686
|
+
width: resource.size.width,
|
|
687
|
+
height: resource.size.height,
|
|
688
|
+
};
|
|
689
|
+
const destWidth = resource.size.width;
|
|
690
|
+
const destHeight = resource.size.height;
|
|
691
|
+
// Tinting swaps in an already-tinted offscreen copy of this frame
|
|
692
|
+
// as the image to draw — everything past this point (flipX,
|
|
693
|
+
// positioning) treats it exactly like the untinted spritesheet,
|
|
694
|
+
// just drawn starting at (0, 0) instead of cropped from a sheet.
|
|
695
|
+
const image = modulate === undefined ? resource.image : tintedSpriteFrame(resource.image, source, modulate);
|
|
696
|
+
const imageSource = modulate === undefined ? source : { x: 0, y: 0, width: source.width, height: source.height };
|
|
346
697
|
context.globalAlpha = opacity;
|
|
698
|
+
const drawSprite = (destX, destY) => {
|
|
699
|
+
context.drawImage(image, imageSource.x, imageSource.y, imageSource.width, imageSource.height, destX, destY, destWidth, destHeight);
|
|
700
|
+
};
|
|
347
701
|
if (flipX) {
|
|
348
702
|
context.save();
|
|
349
703
|
context.translate(renderable.position.x + destWidth, renderable.position.y);
|
|
350
704
|
context.scale(-1, 1);
|
|
351
|
-
|
|
705
|
+
drawSprite(0, 0);
|
|
352
706
|
context.restore();
|
|
353
707
|
}
|
|
354
708
|
else {
|
|
355
|
-
|
|
709
|
+
drawSprite(renderable.position.x, renderable.position.y);
|
|
356
710
|
}
|
|
357
711
|
context.globalAlpha = 1;
|
|
712
|
+
context.restore();
|
|
358
713
|
continue;
|
|
359
714
|
}
|
|
360
715
|
if (renderable.type === "LINE") {
|
|
361
|
-
context.strokeStyle =
|
|
716
|
+
context.strokeStyle =
|
|
717
|
+
renderable.modulate === undefined ? renderable.color : modulateColor(renderable.color, renderable.modulate);
|
|
362
718
|
context.lineWidth = renderable.width ?? 2;
|
|
363
719
|
context.beginPath();
|
|
364
720
|
context.moveTo(renderable.from.x, renderable.from.y);
|
|
365
721
|
context.lineTo(renderable.to.x, renderable.to.y);
|
|
366
722
|
context.stroke();
|
|
723
|
+
context.restore();
|
|
367
724
|
continue;
|
|
368
725
|
}
|
|
726
|
+
if (renderable.type === "GROUP") {
|
|
727
|
+
// Draws nothing itself — it only exists to give its children
|
|
728
|
+
// (already peeled off into their own entries by flattenRenderables)
|
|
729
|
+
// something to be positioned/scaled/tinted relative to.
|
|
730
|
+
context.restore();
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (renderable.type === "ANIMATED_SPRITE") {
|
|
734
|
+
// Unreachable — flattenRenderable always resolves ANIMATED_SPRITE
|
|
735
|
+
// to a plain SPRITE before it gets here. A real error (not a
|
|
736
|
+
// silent skip) if it's somehow still one, since that would mean
|
|
737
|
+
// an actual engine bug rather than anything a game author did.
|
|
738
|
+
throw new Error("ANIMATED_SPRITE reached the draw loop unresolved — this is an engine bug");
|
|
739
|
+
}
|
|
369
740
|
exhaust(renderable);
|
|
370
741
|
}
|
|
371
742
|
lastFrame = now;
|
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