@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.
Files changed (78) hide show
  1. package/dist/aizawa.html +27 -0
  2. package/dist/clifford.html +25 -0
  3. package/dist/cmb.html +24 -0
  4. package/dist/dadras.html +26 -0
  5. package/dist/dejong.html +25 -0
  6. package/dist/gcanvas.es.js +5130 -372
  7. package/dist/gcanvas.es.min.js +1 -1
  8. package/dist/gcanvas.umd.js +1 -1
  9. package/dist/gcanvas.umd.min.js +1 -1
  10. package/dist/halvorsen.html +27 -0
  11. package/dist/index.html +96 -48
  12. package/dist/js/aizawa.js +425 -0
  13. package/dist/js/bezier.js +5 -5
  14. package/dist/js/clifford.js +236 -0
  15. package/dist/js/cmb.js +594 -0
  16. package/dist/js/dadras.js +405 -0
  17. package/dist/js/dejong.js +257 -0
  18. package/dist/js/halvorsen.js +405 -0
  19. package/dist/js/isometric.js +34 -46
  20. package/dist/js/lorenz.js +425 -0
  21. package/dist/js/painter.js +8 -8
  22. package/dist/js/rossler.js +480 -0
  23. package/dist/js/schrodinger.js +314 -18
  24. package/dist/js/thomas.js +394 -0
  25. package/dist/lorenz.html +27 -0
  26. package/dist/rossler.html +27 -0
  27. package/dist/scene-interactivity-test.html +220 -0
  28. package/dist/thomas.html +27 -0
  29. package/package.json +1 -1
  30. package/readme.md +30 -22
  31. package/src/game/objects/go.js +7 -0
  32. package/src/game/objects/index.js +2 -0
  33. package/src/game/objects/isometric-scene.js +53 -3
  34. package/src/game/objects/layoutscene.js +57 -0
  35. package/src/game/objects/mask.js +241 -0
  36. package/src/game/objects/scene.js +19 -0
  37. package/src/game/objects/wrapper.js +14 -2
  38. package/src/game/pipeline.js +17 -0
  39. package/src/game/ui/button.js +101 -16
  40. package/src/game/ui/theme.js +0 -6
  41. package/src/game/ui/togglebutton.js +25 -14
  42. package/src/game/ui/tooltip.js +12 -4
  43. package/src/index.js +3 -0
  44. package/src/io/gesture.js +409 -0
  45. package/src/io/index.js +4 -1
  46. package/src/io/keys.js +9 -1
  47. package/src/io/screen.js +476 -0
  48. package/src/math/attractors.js +664 -0
  49. package/src/math/heat.js +106 -0
  50. package/src/math/index.js +1 -0
  51. package/src/mixins/draggable.js +15 -19
  52. package/src/painter/painter.shapes.js +11 -5
  53. package/src/particle/particle-system.js +165 -1
  54. package/src/physics/index.js +26 -0
  55. package/src/physics/physics-updaters.js +333 -0
  56. package/src/physics/physics.js +375 -0
  57. package/src/shapes/image.js +5 -5
  58. package/src/shapes/index.js +2 -0
  59. package/src/shapes/parallelogram.js +147 -0
  60. package/src/shapes/righttriangle.js +115 -0
  61. package/src/shapes/svg.js +281 -100
  62. package/src/shapes/text.js +22 -6
  63. package/src/shapes/transformable.js +5 -0
  64. package/src/sound/effects.js +807 -0
  65. package/src/sound/index.js +13 -0
  66. package/src/webgl/index.js +7 -0
  67. package/src/webgl/shaders/clifford-point-shaders.js +131 -0
  68. package/src/webgl/shaders/dejong-point-shaders.js +131 -0
  69. package/src/webgl/shaders/point-sprite-shaders.js +152 -0
  70. package/src/webgl/webgl-clifford-renderer.js +477 -0
  71. package/src/webgl/webgl-dejong-renderer.js +472 -0
  72. package/src/webgl/webgl-line-renderer.js +391 -0
  73. package/src/webgl/webgl-particle-renderer.js +410 -0
  74. package/types/index.d.ts +30 -2
  75. package/types/io.d.ts +217 -0
  76. package/types/physics.d.ts +299 -0
  77. package/types/shapes.d.ts +8 -0
  78. package/types/webgl.d.ts +188 -109
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guinetik/gcanvas",
3
- "version": "1.0.5",
3
+ "version": "2.0.0",
4
4
  "description": "A batteries-included 2D canvas framework for games and generative art",
5
5
  "main": "dist/gcanvas.es.js",
6
6
  "types": "types/index.d.ts",
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 `enableInteractivity(shape)`
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
- this.enableInteractivity(this.shape);
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
- onPointerDown() {
400
- Tweenetik.to(this.shape,
401
- { scaleX: 1.2, scaleY: 1.2 },
402
- 0.2,
403
- Easing.easeOutBack,
404
- {
405
- onComplete: () => {
406
- Tweenetik.to(this.shape,
407
- { scaleX: 1.0, scaleY: 1.0 },
408
- 0.3,
409
- Easing.easeInOutQuad
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.enableInteractivity(this.shape);
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.enableInteractivity(this.shape);
586
+ this.interactive = true;
579
587
  this.hovered = false;
580
588
 
581
589
  this.on('mouseover', () => this.hovered = true);
@@ -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);
@@ -56,3 +56,5 @@ export { Sprite } from "./sprite.js";
56
56
  export { SpriteSheet } from "./spritesheet.js";
57
57
  export { Text } from "./text.js";
58
58
  export { ImageGo } from "./imagego.js";
59
+ // Utilities
60
+ export { Mask } from "./mask.js";
@@ -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 (x + y) = closer to viewer = higher depth = render later
279
+ // Higher (rotatedX + rotatedY) = closer to viewer = higher depth = render later
230
280
  // Higher z = on top = higher depth = render later
231
- depth = (child.x + child.y) + height * 0.05;
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
- super(game, options);
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.render();
242
+ this.shape.draw();
231
243
  }
232
244
  }
@@ -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);