@guinetik/gcanvas 1.0.5 → 2.0.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/dist/aizawa.html +27 -0
- package/dist/clifford.html +25 -0
- package/dist/cmb.html +24 -0
- package/dist/dadras.html +26 -0
- package/dist/dejong.html +25 -0
- package/dist/gcanvas.es.js +5130 -372
- package/dist/gcanvas.es.min.js +1 -1
- package/dist/gcanvas.umd.js +1 -1
- package/dist/gcanvas.umd.min.js +1 -1
- package/dist/halvorsen.html +27 -0
- package/dist/index.html +96 -48
- package/dist/js/aizawa.js +425 -0
- package/dist/js/bezier.js +5 -5
- package/dist/js/clifford.js +236 -0
- package/dist/js/cmb.js +594 -0
- package/dist/js/dadras.js +405 -0
- package/dist/js/dejong.js +257 -0
- package/dist/js/halvorsen.js +405 -0
- package/dist/js/isometric.js +34 -46
- package/dist/js/lorenz.js +425 -0
- package/dist/js/painter.js +8 -8
- package/dist/js/rossler.js +480 -0
- package/dist/js/schrodinger.js +314 -18
- package/dist/js/thomas.js +394 -0
- package/dist/lorenz.html +27 -0
- package/dist/rossler.html +27 -0
- package/dist/scene-interactivity-test.html +220 -0
- package/dist/thomas.html +27 -0
- package/package.json +1 -1
- package/readme.md +30 -22
- package/src/game/objects/go.js +7 -0
- package/src/game/objects/index.js +2 -0
- package/src/game/objects/isometric-scene.js +53 -3
- package/src/game/objects/layoutscene.js +57 -0
- package/src/game/objects/mask.js +241 -0
- package/src/game/objects/scene.js +19 -0
- package/src/game/objects/wrapper.js +14 -2
- package/src/game/pipeline.js +17 -0
- package/src/game/ui/button.js +101 -16
- package/src/game/ui/theme.js +0 -6
- package/src/game/ui/togglebutton.js +25 -14
- package/src/game/ui/tooltip.js +12 -4
- package/src/index.js +3 -0
- package/src/io/gesture.js +409 -0
- package/src/io/index.js +4 -1
- package/src/io/keys.js +9 -1
- package/src/io/screen.js +476 -0
- package/src/math/attractors.js +664 -0
- package/src/math/heat.js +106 -0
- package/src/math/index.js +1 -0
- package/src/mixins/draggable.js +15 -19
- package/src/painter/painter.shapes.js +11 -5
- package/src/particle/particle-system.js +165 -1
- package/src/physics/index.js +26 -0
- package/src/physics/physics-updaters.js +333 -0
- package/src/physics/physics.js +375 -0
- package/src/shapes/image.js +5 -5
- package/src/shapes/index.js +2 -0
- package/src/shapes/parallelogram.js +147 -0
- package/src/shapes/righttriangle.js +115 -0
- package/src/shapes/svg.js +281 -100
- package/src/shapes/text.js +22 -6
- package/src/shapes/transformable.js +5 -0
- package/src/sound/effects.js +807 -0
- package/src/sound/index.js +13 -0
- package/src/webgl/index.js +7 -0
- package/src/webgl/shaders/clifford-point-shaders.js +131 -0
- package/src/webgl/shaders/dejong-point-shaders.js +131 -0
- package/src/webgl/shaders/point-sprite-shaders.js +152 -0
- package/src/webgl/webgl-clifford-renderer.js +477 -0
- package/src/webgl/webgl-dejong-renderer.js +472 -0
- package/src/webgl/webgl-line-renderer.js +391 -0
- package/src/webgl/webgl-particle-renderer.js +410 -0
- package/types/index.d.ts +30 -2
- package/types/io.d.ts +217 -0
- package/types/physics.d.ts +299 -0
- package/types/shapes.d.ts +8 -0
- package/types/webgl.d.ts +188 -109
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -235,7 +235,7 @@ game.start(); // Start the game loop
|
|
|
235
235
|
|
|
236
236
|
- `update(dt)` — Run game logic each frame
|
|
237
237
|
- `render()` — Optional custom rendering
|
|
238
|
-
- Event handling through
|
|
238
|
+
- Event handling through the event emitter pattern
|
|
239
239
|
|
|
240
240
|
This is the base class for all interactive entities:
|
|
241
241
|
|
|
@@ -244,7 +244,14 @@ class Player extends GameObject {
|
|
|
244
244
|
constructor(game) {
|
|
245
245
|
super(game);
|
|
246
246
|
this.shape = new Circle(100, 100, 40, { fillColor: "blue" });
|
|
247
|
-
|
|
247
|
+
|
|
248
|
+
// Enable interactivity
|
|
249
|
+
this.interactive = true;
|
|
250
|
+
|
|
251
|
+
// Listen for input events
|
|
252
|
+
this.on('inputdown', (e) => {
|
|
253
|
+
console.log('Player clicked!');
|
|
254
|
+
});
|
|
248
255
|
}
|
|
249
256
|
|
|
250
257
|
update(dt) {
|
|
@@ -257,10 +264,6 @@ class Player extends GameObject {
|
|
|
257
264
|
render() {
|
|
258
265
|
this.shape.draw();
|
|
259
266
|
}
|
|
260
|
-
|
|
261
|
-
onPointerDown(e) {
|
|
262
|
-
console.log('Player clicked!');
|
|
263
|
-
}
|
|
264
267
|
}
|
|
265
268
|
```
|
|
266
269
|
|
|
@@ -396,21 +399,26 @@ The `Tweenetik` system animates object properties directly over time using easin
|
|
|
396
399
|
|
|
397
400
|
```js
|
|
398
401
|
// Animate a button when pressed
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
402
|
+
constructor(game) {
|
|
403
|
+
super(game);
|
|
404
|
+
this.interactive = true;
|
|
405
|
+
|
|
406
|
+
this.on('inputdown', () => {
|
|
407
|
+
Tweenetik.to(this.shape,
|
|
408
|
+
{ scaleX: 1.2, scaleY: 1.2 },
|
|
409
|
+
0.2,
|
|
410
|
+
Easing.easeOutBack,
|
|
411
|
+
{
|
|
412
|
+
onComplete: () => {
|
|
413
|
+
Tweenetik.to(this.shape,
|
|
414
|
+
{ scaleX: 1.0, scaleY: 1.0 },
|
|
415
|
+
0.3,
|
|
416
|
+
Easing.easeInBack
|
|
417
|
+
);
|
|
418
|
+
}
|
|
411
419
|
}
|
|
412
|
-
|
|
413
|
-
);
|
|
420
|
+
);
|
|
421
|
+
});
|
|
414
422
|
}
|
|
415
423
|
```
|
|
416
424
|
|
|
@@ -545,7 +553,7 @@ class Bob extends GameObject {
|
|
|
545
553
|
constructor(game) {
|
|
546
554
|
super(game);
|
|
547
555
|
this.shape = new Circle(100, 100, 40, { fillColor: "tomato" });
|
|
548
|
-
this.
|
|
556
|
+
this.interactive = true;
|
|
549
557
|
}
|
|
550
558
|
|
|
551
559
|
update(dt) {
|
|
@@ -575,7 +583,7 @@ class SpinningShape extends GameObject {
|
|
|
575
583
|
constructor(game) {
|
|
576
584
|
super(game);
|
|
577
585
|
this.shape = new Circle(200, 200, 50, { fillColor: 'cyan' });
|
|
578
|
-
this.
|
|
586
|
+
this.interactive = true;
|
|
579
587
|
this.hovered = false;
|
|
580
588
|
|
|
581
589
|
this.on('mouseover', () => this.hovered = true);
|
package/src/game/objects/go.js
CHANGED
|
@@ -163,6 +163,13 @@ export class GameObject extends Transformable {
|
|
|
163
163
|
localX -= obj.x || 0;
|
|
164
164
|
localY -= obj.y || 0;
|
|
165
165
|
|
|
166
|
+
// Apply additional hit test offset (e.g., scroll offset from LayoutScene)
|
|
167
|
+
if (obj.getHitTestOffset) {
|
|
168
|
+
const offset = obj.getHitTestOffset();
|
|
169
|
+
localX -= offset.x || 0;
|
|
170
|
+
localY -= offset.y || 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
166
173
|
// Rotation: apply inverse rotation if needed
|
|
167
174
|
if (obj.rotation) {
|
|
168
175
|
const cos = Math.cos(-obj.rotation);
|
|
@@ -178,6 +178,46 @@ export class IsometricScene extends Scene {
|
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Compute depth value for sorting, accounting for camera rotation.
|
|
183
|
+
* Use this when implementing custom isoDepth getters for box-like objects.
|
|
184
|
+
*
|
|
185
|
+
* For a rectangular object, pass all 4 corners and this will return
|
|
186
|
+
* the depth of the "front" corner (highest rotated x+y) at the current camera angle.
|
|
187
|
+
*
|
|
188
|
+
* @param {Array<{x: number, y: number}>} corners - Array of corner positions in grid coords
|
|
189
|
+
* @param {number} [height=0] - Height of object for z-ordering
|
|
190
|
+
* @returns {number} Depth value for sorting
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* // In a custom GameObject with grid position and size:
|
|
194
|
+
* get isoDepth() {
|
|
195
|
+
* const corners = [
|
|
196
|
+
* { x: this.gridX, y: this.gridY },
|
|
197
|
+
* { x: this.gridX + this.width, y: this.gridY },
|
|
198
|
+
* { x: this.gridX, y: this.gridY + this.depth },
|
|
199
|
+
* { x: this.gridX + this.width, y: this.gridY + this.depth },
|
|
200
|
+
* ];
|
|
201
|
+
* return this.isoScene.getRotatedDepth(corners, this.height);
|
|
202
|
+
* }
|
|
203
|
+
*/
|
|
204
|
+
getRotatedDepth(corners, height = 0) {
|
|
205
|
+
const angle = this.camera ? this.camera.angle : 0;
|
|
206
|
+
const cos = Math.cos(angle);
|
|
207
|
+
const sin = Math.sin(angle);
|
|
208
|
+
|
|
209
|
+
let maxDepth = -Infinity;
|
|
210
|
+
for (const c of corners) {
|
|
211
|
+
const rotatedX = c.x * cos - c.y * sin;
|
|
212
|
+
const rotatedY = c.x * sin + c.y * cos;
|
|
213
|
+
const depth = rotatedX + rotatedY;
|
|
214
|
+
if (depth > maxDepth) maxDepth = depth;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Height factor of 0.5 matches demo for proper depth sorting
|
|
218
|
+
return maxDepth + height * 0.5;
|
|
219
|
+
}
|
|
220
|
+
|
|
181
221
|
/**
|
|
182
222
|
* Calculate scale factor based on Y position (for perspective effect).
|
|
183
223
|
*
|
|
@@ -219,16 +259,26 @@ export class IsometricScene extends Scene {
|
|
|
219
259
|
for (const child of this._collection.getSortedChildren()) {
|
|
220
260
|
if (!child.visible) continue;
|
|
221
261
|
|
|
222
|
-
// Use custom isoDepth if available, otherwise calculate
|
|
262
|
+
// Use custom isoDepth if available, otherwise calculate using rotated coords
|
|
223
263
|
let depth;
|
|
224
264
|
if (child.isoDepth !== undefined) {
|
|
225
265
|
depth = child.isoDepth;
|
|
226
266
|
} else {
|
|
267
|
+
// Apply camera rotation to get correct depth at all angles
|
|
268
|
+
let rotatedX = child.x;
|
|
269
|
+
let rotatedY = child.y;
|
|
270
|
+
if (this.camera) {
|
|
271
|
+
const angle = this.camera.angle;
|
|
272
|
+
const cos = Math.cos(angle);
|
|
273
|
+
const sin = Math.sin(angle);
|
|
274
|
+
rotatedX = child.x * cos - child.y * sin;
|
|
275
|
+
rotatedY = child.x * sin + child.y * cos;
|
|
276
|
+
}
|
|
227
277
|
// For moving objects, use z as height
|
|
228
278
|
const height = child.z ?? 0;
|
|
229
|
-
// Higher (
|
|
279
|
+
// Higher (rotatedX + rotatedY) = closer to viewer = higher depth = render later
|
|
230
280
|
// Higher z = on top = higher depth = render later
|
|
231
|
-
depth = (
|
|
281
|
+
depth = (rotatedX + rotatedY) + height * 0.05;
|
|
232
282
|
}
|
|
233
283
|
|
|
234
284
|
renderList.push({
|
|
@@ -431,6 +431,63 @@ export class LayoutScene extends Scene {
|
|
|
431
431
|
this._scrollOffset = { x: 0, y: 0 };
|
|
432
432
|
this._scrollVelocity = { x: 0, y: 0 };
|
|
433
433
|
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Returns scroll offset for hit testing coordinate transformation.
|
|
437
|
+
* @returns {{x: number, y: number}} Scroll offset to apply
|
|
438
|
+
*/
|
|
439
|
+
getHitTestOffset() {
|
|
440
|
+
if (!this.scrollable) {
|
|
441
|
+
return { x: 0, y: 0 };
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
x: this._scrollOffset?.x || 0,
|
|
445
|
+
y: this._scrollOffset?.y || 0,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Checks if a child is within the visible viewport and should be hittable.
|
|
451
|
+
* @param {GameObject} child - The child to check
|
|
452
|
+
* @returns {boolean} True if child is within viewport
|
|
453
|
+
*/
|
|
454
|
+
isChildHittable(child) {
|
|
455
|
+
// If not scrollable or doesn't need scrolling, all children are hittable
|
|
456
|
+
if (!this.scrollable || !this._needsScrolling()) {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const axis = this.getScrollAxis();
|
|
461
|
+
const vpW = this._viewportWidth ?? this.width;
|
|
462
|
+
const vpH = this._viewportHeight ?? this.height;
|
|
463
|
+
const scrollX = this._scrollOffset?.x || 0;
|
|
464
|
+
const scrollY = this._scrollOffset?.y || 0;
|
|
465
|
+
|
|
466
|
+
// Child position with scroll applied (relative to viewport center)
|
|
467
|
+
const childScrolledX = child.x + scrollX;
|
|
468
|
+
const childScrolledY = child.y + scrollY;
|
|
469
|
+
|
|
470
|
+
// Get child dimensions (use half for centered bounds check)
|
|
471
|
+
const childHalfW = (child.width || 0) / 2;
|
|
472
|
+
const childHalfH = (child.height || 0) / 2;
|
|
473
|
+
|
|
474
|
+
// Check if child overlaps with viewport
|
|
475
|
+
if (axis.horizontal) {
|
|
476
|
+
// Child right edge must be past viewport left, child left edge must be before viewport right
|
|
477
|
+
if (childScrolledX + childHalfW < -vpW / 2 || childScrolledX - childHalfW > vpW / 2) {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (axis.vertical) {
|
|
483
|
+
// Child bottom edge must be past viewport top, child top edge must be before viewport bottom
|
|
484
|
+
if (childScrolledY + childHalfH < -vpH / 2 || childScrolledY - childHalfH > vpH / 2) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
434
491
|
}
|
|
435
492
|
|
|
436
493
|
// HorizontalLayout with clean implementation
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mask - Clipping mask for scenes and game objects
|
|
3
|
+
*
|
|
4
|
+
* Supports multiple shapes with animatable properties:
|
|
5
|
+
* - circle: radius
|
|
6
|
+
* - rectangle: width, height, cornerRadius
|
|
7
|
+
* - ellipse: radiusX, radiusY
|
|
8
|
+
* - path: custom path function
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // Create a circular mask that grows
|
|
12
|
+
* const mask = new Mask({
|
|
13
|
+
* shape: 'circle',
|
|
14
|
+
* x: game.width / 2,
|
|
15
|
+
* y: game.height / 2,
|
|
16
|
+
* radius: 0,
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // Animate the radius
|
|
20
|
+
* mask.radius = 100;
|
|
21
|
+
*
|
|
22
|
+
* // Apply to context
|
|
23
|
+
* mask.apply(ctx);
|
|
24
|
+
* // ... render masked content ...
|
|
25
|
+
* mask.remove(ctx);
|
|
26
|
+
*/
|
|
27
|
+
export class Mask {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
// Shape type
|
|
30
|
+
this.shape = options.shape || 'circle';
|
|
31
|
+
|
|
32
|
+
// Position (center point)
|
|
33
|
+
this.x = options.x ?? 0;
|
|
34
|
+
this.y = options.y ?? 0;
|
|
35
|
+
|
|
36
|
+
// Scale (multiplies dimensions)
|
|
37
|
+
this.scaleX = options.scaleX ?? 1;
|
|
38
|
+
this.scaleY = options.scaleY ?? 1;
|
|
39
|
+
|
|
40
|
+
// Circle properties
|
|
41
|
+
this.radius = options.radius ?? 100;
|
|
42
|
+
|
|
43
|
+
// Rectangle properties
|
|
44
|
+
this.width = options.width ?? 200;
|
|
45
|
+
this.height = options.height ?? 200;
|
|
46
|
+
this.cornerRadius = options.cornerRadius ?? 0;
|
|
47
|
+
|
|
48
|
+
// Ellipse properties
|
|
49
|
+
this.radiusX = options.radiusX ?? 100;
|
|
50
|
+
this.radiusY = options.radiusY ?? 100;
|
|
51
|
+
|
|
52
|
+
// Custom path function: (ctx, mask) => void
|
|
53
|
+
this.pathFn = options.pathFn ?? null;
|
|
54
|
+
|
|
55
|
+
// Invert the mask (show outside, hide inside)
|
|
56
|
+
this.invert = options.invert ?? false;
|
|
57
|
+
|
|
58
|
+
// Track if currently applied (for safety)
|
|
59
|
+
this._applied = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply the mask to a canvas context
|
|
64
|
+
* @param {CanvasRenderingContext2D} ctx - The canvas context
|
|
65
|
+
*/
|
|
66
|
+
apply(ctx) {
|
|
67
|
+
if (this._applied) {
|
|
68
|
+
console.warn('Mask already applied. Call remove() first.');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ctx.save();
|
|
73
|
+
ctx.beginPath();
|
|
74
|
+
|
|
75
|
+
if (this.invert) {
|
|
76
|
+
// For inverted mask, draw full canvas then cut out the shape
|
|
77
|
+
// This requires knowing the canvas size, so we use a large rect
|
|
78
|
+
ctx.rect(-10000, -10000, 20000, 20000);
|
|
79
|
+
this._drawPath(ctx);
|
|
80
|
+
// evenodd fill rule creates the "hole"
|
|
81
|
+
ctx.clip('evenodd');
|
|
82
|
+
} else {
|
|
83
|
+
this._drawPath(ctx);
|
|
84
|
+
ctx.clip();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._applied = true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Remove the mask (restore context)
|
|
92
|
+
* @param {CanvasRenderingContext2D} ctx - The canvas context
|
|
93
|
+
*/
|
|
94
|
+
remove(ctx) {
|
|
95
|
+
if (!this._applied) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
ctx.restore();
|
|
99
|
+
this._applied = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Draw the mask path (internal)
|
|
104
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
105
|
+
*/
|
|
106
|
+
_drawPath(ctx) {
|
|
107
|
+
const sx = this.scaleX;
|
|
108
|
+
const sy = this.scaleY;
|
|
109
|
+
|
|
110
|
+
switch (this.shape) {
|
|
111
|
+
case 'circle':
|
|
112
|
+
// Use average scale for circle
|
|
113
|
+
const scale = (sx + sy) / 2;
|
|
114
|
+
ctx.arc(this.x, this.y, this.radius * scale, 0, Math.PI * 2);
|
|
115
|
+
break;
|
|
116
|
+
|
|
117
|
+
case 'rectangle':
|
|
118
|
+
const w = this.width * sx;
|
|
119
|
+
const h = this.height * sy;
|
|
120
|
+
const cr = this.cornerRadius * Math.min(sx, sy);
|
|
121
|
+
|
|
122
|
+
if (cr > 0) {
|
|
123
|
+
// Rounded rectangle
|
|
124
|
+
this._roundedRect(ctx, this.x - w/2, this.y - h/2, w, h, cr);
|
|
125
|
+
} else {
|
|
126
|
+
// Simple rectangle
|
|
127
|
+
ctx.rect(this.x - w/2, this.y - h/2, w, h);
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
|
|
131
|
+
case 'ellipse':
|
|
132
|
+
ctx.ellipse(this.x, this.y, this.radiusX * sx, this.radiusY * sy, 0, 0, Math.PI * 2);
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case 'path':
|
|
136
|
+
if (this.pathFn) {
|
|
137
|
+
this.pathFn(ctx, this);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
default:
|
|
142
|
+
console.warn(`Unknown mask shape: ${this.shape}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Draw a rounded rectangle path
|
|
148
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
149
|
+
* @param {number} x
|
|
150
|
+
* @param {number} y
|
|
151
|
+
* @param {number} w
|
|
152
|
+
* @param {number} h
|
|
153
|
+
* @param {number} r - Corner radius
|
|
154
|
+
*/
|
|
155
|
+
_roundedRect(ctx, x, y, w, h, r) {
|
|
156
|
+
// Clamp corner radius to half of smallest dimension
|
|
157
|
+
r = Math.min(r, w / 2, h / 2);
|
|
158
|
+
|
|
159
|
+
ctx.moveTo(x + r, y);
|
|
160
|
+
ctx.lineTo(x + w - r, y);
|
|
161
|
+
ctx.arcTo(x + w, y, x + w, y + r, r);
|
|
162
|
+
ctx.lineTo(x + w, y + h - r);
|
|
163
|
+
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
|
164
|
+
ctx.lineTo(x + r, y + h);
|
|
165
|
+
ctx.arcTo(x, y + h, x, y + h - r, r);
|
|
166
|
+
ctx.lineTo(x, y + r);
|
|
167
|
+
ctx.arcTo(x, y, x + r, y, r);
|
|
168
|
+
ctx.closePath();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Set position
|
|
173
|
+
* @param {number} x
|
|
174
|
+
* @param {number} y
|
|
175
|
+
* @returns {Mask} this for chaining
|
|
176
|
+
*/
|
|
177
|
+
setPosition(x, y) {
|
|
178
|
+
this.x = x;
|
|
179
|
+
this.y = y;
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Set uniform scale
|
|
185
|
+
* @param {number} scale
|
|
186
|
+
* @returns {Mask} this for chaining
|
|
187
|
+
*/
|
|
188
|
+
setScale(scale) {
|
|
189
|
+
this.scaleX = scale;
|
|
190
|
+
this.scaleY = scale;
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Set non-uniform scale
|
|
196
|
+
* @param {number} sx
|
|
197
|
+
* @param {number} sy
|
|
198
|
+
* @returns {Mask} this for chaining
|
|
199
|
+
*/
|
|
200
|
+
setScaleXY(sx, sy) {
|
|
201
|
+
this.scaleX = sx;
|
|
202
|
+
this.scaleY = sy;
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create a circle mask
|
|
208
|
+
* @param {number} x - Center X
|
|
209
|
+
* @param {number} y - Center Y
|
|
210
|
+
* @param {number} radius
|
|
211
|
+
* @returns {Mask}
|
|
212
|
+
*/
|
|
213
|
+
static circle(x, y, radius) {
|
|
214
|
+
return new Mask({ shape: 'circle', x, y, radius });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create a rectangle mask
|
|
219
|
+
* @param {number} x - Center X
|
|
220
|
+
* @param {number} y - Center Y
|
|
221
|
+
* @param {number} width
|
|
222
|
+
* @param {number} height
|
|
223
|
+
* @param {number} cornerRadius - Optional rounded corners
|
|
224
|
+
* @returns {Mask}
|
|
225
|
+
*/
|
|
226
|
+
static rectangle(x, y, width, height, cornerRadius = 0) {
|
|
227
|
+
return new Mask({ shape: 'rectangle', x, y, width, height, cornerRadius });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create an ellipse mask
|
|
232
|
+
* @param {number} x - Center X
|
|
233
|
+
* @param {number} y - Center Y
|
|
234
|
+
* @param {number} radiusX
|
|
235
|
+
* @param {number} radiusY
|
|
236
|
+
* @returns {Mask}
|
|
237
|
+
*/
|
|
238
|
+
static ellipse(x, y, radiusX, radiusY) {
|
|
239
|
+
return new Mask({ shape: 'ellipse', x, y, radiusX, radiusY });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -186,4 +186,23 @@ export class Scene extends GameObject {
|
|
|
186
186
|
get children() {
|
|
187
187
|
return this._collection.children;
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Returns additional offset to apply during hit testing.
|
|
192
|
+
* Override in subclasses (e.g., LayoutScene) to account for scroll offset.
|
|
193
|
+
* @returns {{x: number, y: number}} Additional offset for hit test coordinate transform
|
|
194
|
+
*/
|
|
195
|
+
getHitTestOffset() {
|
|
196
|
+
return { x: 0, y: 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Checks if a child should be hittable (receive input events).
|
|
201
|
+
* Override in subclasses (e.g., LayoutScene) to implement viewport culling.
|
|
202
|
+
* @param {GameObject} child - The child to check
|
|
203
|
+
* @returns {boolean} True if child should be hittable
|
|
204
|
+
*/
|
|
205
|
+
isChildHittable(child) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
189
208
|
}
|
|
@@ -68,7 +68,13 @@ export class GameObjectShapeWrapper extends GameObject {
|
|
|
68
68
|
* @param {Object} options - Configuration options
|
|
69
69
|
*/
|
|
70
70
|
constructor(game, shape, options = {}) {
|
|
71
|
-
|
|
71
|
+
// IMPORTANT: Strip 'anchor' from options passed to GameObject
|
|
72
|
+
// In shape context, 'anchor' means "image rendering anchor" (center, top-left, etc.)
|
|
73
|
+
// In GameObject, 'anchor' triggers applyAnchor mixin for auto-positioning
|
|
74
|
+
// These are different concepts - don't let shape anchor trigger positioning
|
|
75
|
+
const { anchor: _shapeAnchor, ...goOptions } = options;
|
|
76
|
+
|
|
77
|
+
super(game, goOptions);
|
|
72
78
|
|
|
73
79
|
// Validate shape
|
|
74
80
|
if (!shape || shape == null || shape == undefined) {
|
|
@@ -224,9 +230,15 @@ export class GameObjectShapeWrapper extends GameObject {
|
|
|
224
230
|
|
|
225
231
|
/**
|
|
226
232
|
* Draw method to render the shape
|
|
233
|
+
*
|
|
234
|
+
* IMPORTANT: Call shape.draw() NOT shape.render()!
|
|
235
|
+
* The wrapper's render() has already translated to (this.x, this.y).
|
|
236
|
+
* Calling shape.render() would call Painter.translateTo(shape.x, shape.y)
|
|
237
|
+
* which OVERWRITES (not adds to) the current translation.
|
|
238
|
+
* By calling shape.draw() directly, we render at the wrapper's position.
|
|
227
239
|
*/
|
|
228
240
|
draw() {
|
|
229
241
|
super.draw();
|
|
230
|
-
this.shape.
|
|
242
|
+
this.shape.draw();
|
|
231
243
|
}
|
|
232
244
|
}
|
package/src/game/pipeline.js
CHANGED
|
@@ -78,6 +78,17 @@ export class Pipeline extends Loggable {
|
|
|
78
78
|
if (scene.children && scene.children.length > 0) {
|
|
79
79
|
for (let i = scene.children.length - 1; i >= 0; i--) {
|
|
80
80
|
const child = scene.children[i];
|
|
81
|
+
|
|
82
|
+
// Check if child is hittable (e.g., within viewport for scrollable layouts)
|
|
83
|
+
if (scene.isChildHittable && !scene.isChildHittable(child)) {
|
|
84
|
+
// Force mouseout if child was hovered but is now outside viewport
|
|
85
|
+
if (child._hovered) {
|
|
86
|
+
child._hovered = false;
|
|
87
|
+
child.events.emit("mouseout", e);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
if (child instanceof Scene) {
|
|
82
93
|
this._hoverScene(child, e); // recurse into nested scenes
|
|
83
94
|
} else {
|
|
@@ -152,6 +163,12 @@ export class Pipeline extends Loggable {
|
|
|
152
163
|
// First check children (they render on top, so should get priority)
|
|
153
164
|
for (let i = scene.children.length - 1; i >= 0; i--) {
|
|
154
165
|
const child = scene.children[i];
|
|
166
|
+
|
|
167
|
+
// Check if child is hittable (e.g., within viewport for scrollable layouts)
|
|
168
|
+
if (scene.isChildHittable && !scene.isChildHittable(child)) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
155
172
|
if (child instanceof Scene) {
|
|
156
173
|
// Recurse deeper if child is also a Scene
|
|
157
174
|
const hit = this._dispatchToScene(child, type, e);
|