@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
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Halvorsen Attractor 3D Visualization
3
+ *
4
+ * A symmetric chaotic attractor with three-fold rotational symmetry.
5
+ * Creates beautiful intertwined spiral structures.
6
+ *
7
+ * Uses the Attractors module for pure math functions and WebGL for
8
+ * high-performance line rendering.
9
+ */
10
+
11
+ import { Game, Gesture, Screen, Attractors } from "/gcanvas.es.min.js";
12
+ import { Camera3D } from "/gcanvas.es.min.js";
13
+ import { WebGLLineRenderer } from "/gcanvas.es.min.js";
14
+
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // CONFIGURATION
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ const CONFIG = {
20
+ // Attractor settings (uses Attractors.halvorsen for equations)
21
+ attractor: {
22
+ dt: 0.004, // Integration time step
23
+ scale: 25, // Scale factor for display
24
+ },
25
+
26
+ // Particle settings
27
+ particles: {
28
+ count: 420,
29
+ trailLength: 280,
30
+ spawnRange: 1,
31
+ },
32
+
33
+ // Center offset - Halvorsen barycenter is around (-5,-5,-5) due to symmetry
34
+ // With Y/Z swap: x→screen X, z→screen Y, y→depth
35
+ center: {
36
+ x: 0,
37
+ y: 0,
38
+ z: 0,
39
+ },
40
+
41
+ // Camera settings
42
+ camera: {
43
+ perspective: 300,
44
+ rotationX: 0.615,
45
+ rotationY: 0.495,
46
+ inertia: true,
47
+ friction: 0.95,
48
+ clampX: false,
49
+ },
50
+
51
+ // Visual settings - cool blue/purple palette
52
+ visual: {
53
+ minHue: 320, // Pink (fast)
54
+ maxHue: 220, // Blue (slow)
55
+ maxSpeed: 40,
56
+ saturation: 80,
57
+ lightness: 55,
58
+ maxAlpha: 0.85,
59
+ hueShiftSpeed: 15,
60
+ },
61
+
62
+ // Glitch/blink effect
63
+ blink: {
64
+ chance: 0.02,
65
+ minDuration: 0.04,
66
+ maxDuration: 0.18,
67
+ intensityBoost: 1.5,
68
+ saturationBoost: 1.2,
69
+ alphaBoost: 1.3,
70
+ },
71
+
72
+ // Zoom settings
73
+ zoom: {
74
+ min: 0.25,
75
+ max: 2.5,
76
+ speed: 0.5,
77
+ easing: 0.12,
78
+ baseScreenSize: 600,
79
+ },
80
+ };
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // HELPER FUNCTIONS
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+
86
+ function hslToRgb(h, s, l) {
87
+ s /= 100;
88
+ l /= 100;
89
+ const k = (n) => (n + h / 30) % 12;
90
+ const a = s * Math.min(l, 1 - l);
91
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
92
+ return {
93
+ r: Math.round(255 * f(0)),
94
+ g: Math.round(255 * f(8)),
95
+ b: Math.round(255 * f(4)),
96
+ };
97
+ }
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // ATTRACTOR PARTICLE
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ class AttractorParticle {
104
+ constructor(stepFn, spawnRange) {
105
+ this.stepFn = stepFn;
106
+ this.position = {
107
+ x: (Math.random() - 0.5) * spawnRange,
108
+ y: (Math.random() - 0.5) * spawnRange,
109
+ z: (Math.random() - 0.5) * spawnRange,
110
+ };
111
+ this.trail = [];
112
+ this.speed = 0;
113
+ this.blinkTime = 0;
114
+ this.blinkIntensity = 0;
115
+ }
116
+
117
+ updateBlink(dt) {
118
+ const { chance, minDuration, maxDuration } = CONFIG.blink;
119
+
120
+ if (this.blinkTime > 0) {
121
+ this.blinkTime -= dt;
122
+ this.blinkIntensity = Math.max(
123
+ 0,
124
+ this.blinkTime > 0
125
+ ? Math.sin((this.blinkTime / ((minDuration + maxDuration) * 0.5)) * Math.PI)
126
+ : 0
127
+ );
128
+ } else {
129
+ if (Math.random() < chance) {
130
+ this.blinkTime = minDuration + Math.random() * (maxDuration - minDuration);
131
+ this.blinkIntensity = 1;
132
+ } else {
133
+ this.blinkIntensity = 0;
134
+ }
135
+ }
136
+ }
137
+
138
+ update(dt, scale, spawnRange) {
139
+ const result = this.stepFn(this.position, dt);
140
+ this.position = result.position;
141
+ this.speed = result.speed;
142
+
143
+ // Small chance to respawn at random position (keeps transient "thickness")
144
+ if (Math.random() < 0.003) {
145
+ this.position = {
146
+ x: (Math.random() - 0.5) * spawnRange,
147
+ y: (Math.random() - 0.5) * spawnRange,
148
+ z: (Math.random() - 0.5) * spawnRange,
149
+ };
150
+ this.trail = [];
151
+ }
152
+
153
+ // Add to trail (centered and scaled for display)
154
+ // Swap Y/Z so vertical mouse drag rotates naturally
155
+ this.trail.unshift({
156
+ x: (this.position.x - CONFIG.center.x) * scale,
157
+ y: (this.position.z - CONFIG.center.z) * scale, // Z becomes screen Y (vertical)
158
+ z: (this.position.y - CONFIG.center.y) * scale, // Y becomes depth
159
+ speed: this.speed,
160
+ });
161
+
162
+ if (this.trail.length > CONFIG.particles.trailLength) {
163
+ this.trail.pop();
164
+ }
165
+ }
166
+ }
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // DEMO CLASS
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ class HalvorsenDemo extends Game {
173
+ constructor(canvas) {
174
+ super(canvas);
175
+ this.backgroundColor = "#000";
176
+ this.enableFluidSize();
177
+ }
178
+
179
+ init() {
180
+ super.init();
181
+
182
+ this.attractor = Attractors.halvorsen;
183
+ console.log(`Attractor: ${this.attractor.name}`);
184
+ console.log(`Equations:`, this.attractor.equations);
185
+
186
+ this.stepFn = this.attractor.createStepper();
187
+
188
+ const { min, max, baseScreenSize } = CONFIG.zoom;
189
+ const initialZoom = Math.min(max, Math.max(min, Screen.minDimension() / baseScreenSize));
190
+ this.zoom = initialZoom;
191
+ this.targetZoom = initialZoom;
192
+ this.defaultZoom = initialZoom;
193
+
194
+ this.camera = new Camera3D({
195
+ perspective: CONFIG.camera.perspective,
196
+ rotationX: CONFIG.camera.rotationX,
197
+ rotationY: CONFIG.camera.rotationY,
198
+ inertia: CONFIG.camera.inertia,
199
+ friction: CONFIG.camera.friction,
200
+ clampX: CONFIG.camera.clampX,
201
+ });
202
+ this.camera.enableMouseControl(this.canvas);
203
+
204
+ this.gesture = new Gesture(this.canvas, {
205
+ onZoom: (delta) => {
206
+ this.targetZoom *= 1 + delta * CONFIG.zoom.speed;
207
+ },
208
+ onPan: null,
209
+ });
210
+
211
+ this.canvas.addEventListener("dblclick", () => {
212
+ this.targetZoom = this.defaultZoom;
213
+ });
214
+
215
+ // Log camera params and barycenter on mouse release
216
+ this.canvas.addEventListener("mouseup", () => {
217
+ console.log(`Camera: rotationX: ${this.camera.rotationX.toFixed(3)}, rotationY: ${this.camera.rotationY.toFixed(3)}`);
218
+ let sumX = 0, sumY = 0, sumZ = 0, count = 0;
219
+ for (const p of this.particles) {
220
+ sumX += p.position.x;
221
+ sumY += p.position.y;
222
+ sumZ += p.position.z;
223
+ count++;
224
+ }
225
+ console.log(`Barycenter: x: ${(sumX/count).toFixed(3)}, y: ${(sumY/count).toFixed(3)}, z: ${(sumZ/count).toFixed(3)}`);
226
+ });
227
+
228
+ this.particles = [];
229
+ for (let i = 0; i < CONFIG.particles.count; i++) {
230
+ this.particles.push(
231
+ new AttractorParticle(this.stepFn, CONFIG.particles.spawnRange)
232
+ );
233
+ }
234
+
235
+ const maxSegments = CONFIG.particles.count * CONFIG.particles.trailLength;
236
+ this.lineRenderer = new WebGLLineRenderer(maxSegments, {
237
+ width: this.width,
238
+ height: this.height,
239
+ blendMode: "additive",
240
+ });
241
+
242
+ this.segments = [];
243
+
244
+ if (!this.lineRenderer.isAvailable()) {
245
+ console.warn("WebGL not available, falling back to Canvas 2D");
246
+ this.useWebGL = false;
247
+ } else {
248
+ this.useWebGL = true;
249
+ console.log(`WebGL enabled, ${maxSegments} max segments`);
250
+ }
251
+
252
+ this.time = 0;
253
+ }
254
+
255
+ onResize() {
256
+ if (this.lineRenderer?.isAvailable()) {
257
+ this.lineRenderer.resize(this.width, this.height);
258
+ }
259
+ const { min, max, baseScreenSize } = CONFIG.zoom;
260
+ this.defaultZoom = Math.min(max, Math.max(min, Screen.minDimension() / baseScreenSize));
261
+ }
262
+
263
+ update(dt) {
264
+ super.update(dt);
265
+ this.camera.update(dt);
266
+ this.zoom += (this.targetZoom - this.zoom) * CONFIG.zoom.easing;
267
+ this.time += dt;
268
+
269
+ for (const particle of this.particles) {
270
+ particle.update(CONFIG.attractor.dt, CONFIG.attractor.scale, CONFIG.particles.spawnRange);
271
+ particle.updateBlink(dt);
272
+ }
273
+ }
274
+
275
+ collectSegments(cx, cy) {
276
+ const { minHue, maxHue, maxSpeed, saturation, lightness, maxAlpha, hueShiftSpeed } =
277
+ CONFIG.visual;
278
+ const { intensityBoost, saturationBoost, alphaBoost } = CONFIG.blink;
279
+ const hueOffset = (this.time * hueShiftSpeed) % 360;
280
+
281
+ this.segments.length = 0;
282
+
283
+ for (const particle of this.particles) {
284
+ if (particle.trail.length < 2) continue;
285
+
286
+ const blink = particle.blinkIntensity;
287
+
288
+ for (let i = 1; i < particle.trail.length; i++) {
289
+ const curr = particle.trail[i];
290
+ const prev = particle.trail[i - 1];
291
+
292
+ const p1 = this.camera.project(prev.x, prev.y, prev.z);
293
+ const p2 = this.camera.project(curr.x, curr.y, curr.z);
294
+
295
+ if (p1.scale <= 0 || p2.scale <= 0) continue;
296
+
297
+ const age = i / particle.trail.length;
298
+ const speedNorm = Math.min(curr.speed / maxSpeed, 1);
299
+ const baseHue = maxHue + speedNorm * (minHue - maxHue);
300
+ const hue = (baseHue + hueOffset + 360) % 360;
301
+
302
+ const sat = Math.min(100, saturation * (1 + blink * (saturationBoost - 1)));
303
+ const lit = Math.min(100, lightness * (1 + blink * (intensityBoost - 1)));
304
+ const rgb = hslToRgb(hue, sat, lit);
305
+ const alpha = Math.min(1, (1 - age) * maxAlpha * (1 + blink * (alphaBoost - 1)));
306
+
307
+ this.segments.push({
308
+ x1: cx + p1.x * this.zoom,
309
+ y1: cy + p1.y * this.zoom,
310
+ x2: cx + p2.x * this.zoom,
311
+ y2: cy + p2.y * this.zoom,
312
+ r: rgb.r,
313
+ g: rgb.g,
314
+ b: rgb.b,
315
+ a: alpha,
316
+ });
317
+ }
318
+ }
319
+
320
+ return this.segments.length;
321
+ }
322
+
323
+ renderCanvas2D(cx, cy) {
324
+ const { minHue, maxHue, maxSpeed, saturation, lightness, maxAlpha, hueShiftSpeed } =
325
+ CONFIG.visual;
326
+ const { intensityBoost, saturationBoost, alphaBoost } = CONFIG.blink;
327
+ const hueOffset = (this.time * hueShiftSpeed) % 360;
328
+
329
+ const ctx = this.ctx;
330
+ ctx.save();
331
+ ctx.globalCompositeOperation = "lighter";
332
+ ctx.lineCap = "round";
333
+
334
+ for (const particle of this.particles) {
335
+ if (particle.trail.length < 2) continue;
336
+
337
+ const blink = particle.blinkIntensity;
338
+
339
+ for (let i = 1; i < particle.trail.length; i++) {
340
+ const curr = particle.trail[i];
341
+ const prev = particle.trail[i - 1];
342
+
343
+ const p1 = this.camera.project(prev.x, prev.y, prev.z);
344
+ const p2 = this.camera.project(curr.x, curr.y, curr.z);
345
+
346
+ if (p1.scale <= 0 || p2.scale <= 0) continue;
347
+
348
+ const age = i / particle.trail.length;
349
+ const speedNorm = Math.min(curr.speed / maxSpeed, 1);
350
+ const baseHue = maxHue + speedNorm * (minHue - maxHue);
351
+ const hue = (baseHue + hueOffset + 360) % 360;
352
+
353
+ const sat = Math.min(100, saturation * (1 + blink * (saturationBoost - 1)));
354
+ const lit = Math.min(100, lightness * (1 + blink * (intensityBoost - 1)));
355
+ const alpha = Math.min(1, (1 - age) * maxAlpha * (1 + blink * (alphaBoost - 1)));
356
+
357
+ ctx.strokeStyle = `hsla(${hue}, ${sat}%, ${lit}%, ${alpha})`;
358
+ ctx.lineWidth = 1;
359
+
360
+ ctx.beginPath();
361
+ ctx.moveTo(cx + p1.x * this.zoom, cy + p1.y * this.zoom);
362
+ ctx.lineTo(cx + p2.x * this.zoom, cy + p2.y * this.zoom);
363
+ ctx.stroke();
364
+ }
365
+ }
366
+
367
+ ctx.restore();
368
+ }
369
+
370
+ render() {
371
+ super.render();
372
+ if (!this.particles) return;
373
+
374
+ const cx = this.width / 2;
375
+ const cy = this.height / 2;
376
+
377
+ if (this.useWebGL && this.lineRenderer.isAvailable()) {
378
+ const segmentCount = this.collectSegments(cx, cy);
379
+ if (segmentCount > 0) {
380
+ this.lineRenderer.clear();
381
+ this.lineRenderer.updateLines(this.segments);
382
+ this.lineRenderer.render(segmentCount);
383
+ this.lineRenderer.compositeOnto(this.ctx, 0, 0);
384
+ }
385
+ } else {
386
+ this.renderCanvas2D(cx, cy);
387
+ }
388
+ }
389
+
390
+ destroy() {
391
+ this.gesture?.destroy();
392
+ this.lineRenderer?.destroy();
393
+ super.destroy?.();
394
+ }
395
+ }
396
+
397
+ // ─────────────────────────────────────────────────────────────────────────────
398
+ // INITIALIZATION
399
+ // ─────────────────────────────────────────────────────────────────────────────
400
+
401
+ window.addEventListener("load", () => {
402
+ const canvas = document.getElementById("game");
403
+ const demo = new HalvorsenDemo(canvas);
404
+ demo.start();
405
+ });
@@ -99,12 +99,17 @@ class IsometricBox extends GameObject {
99
99
  }
100
100
 
101
101
  /**
102
- * Custom depth value for sorting - uses front corner for proper overlap
102
+ * Custom depth value for sorting - uses rotated front corner for proper overlap at all camera angles
103
103
  */
104
104
  get isoDepth() {
105
- // Use the front-most corner (x+w, y+d) plus height
106
- // Height factor matches ball's z factor for consistent sorting
107
- return (this.x + this.w) + (this.y + this.d) + this.h * 0.5;
105
+ // Use the scene's helper to get depth based on rotated coordinates
106
+ const corners = [
107
+ { x: this.x, y: this.y },
108
+ { x: this.x + this.w, y: this.y },
109
+ { x: this.x, y: this.y + this.d },
110
+ { x: this.x + this.w, y: this.y + this.d },
111
+ ];
112
+ return this.isoScene.getRotatedDepth(corners, this.h);
108
113
  }
109
114
 
110
115
  /**
@@ -150,15 +155,7 @@ class IsometricBox extends GameObject {
150
155
  render() {
151
156
  const scene = this.isoScene;
152
157
 
153
- // Get camera angle (direction camera is looking FROM)
154
- const cameraAngle = scene.camera ? scene.camera.angle : 0;
155
-
156
- // Camera view direction (where camera is looking TOWARD)
157
- // In isometric, default view looks toward +X +Y direction (angle π/4 from +X axis)
158
- // Camera rotation rotates around Z axis
159
- const viewDirection = Math.PI / 4 + cameraAngle;
160
-
161
- // Get all 8 corners of the box
158
+ // Get all 8 corners of the box (camera rotation is applied inside toIsometric)
162
159
  const topNW = scene.toIsometric(this.x, this.y, this.h);
163
160
  const topNE = scene.toIsometric(this.x + this.w, this.y, this.h);
164
161
  const topSE = scene.toIsometric(this.x + this.w, this.y + this.d, this.h);
@@ -192,21 +189,11 @@ class IsometricBox extends GameObject {
192
189
  }
193
190
  ];
194
191
 
195
- // Calculate shading and visibility for each face
192
+ // Calculate screen-space center Y for depth sorting, and lighting for shading
196
193
  for (const face of faces) {
197
- // Rotate the face normal by camera angle
198
- const rotatedNormal = face.normalAngle + cameraAngle;
199
-
200
- // Face visibility: a face is visible if its rotated normal
201
- // has a component pointing toward the camera (away from view direction)
202
- // In isometric, visible faces are those facing generally toward -Y screen direction
203
- const normalToView = rotatedNormal - viewDirection;
204
- face.facingCamera = Math.cos(normalToView) < 0;
205
-
206
- // For depth sorting: faces with normals pointing more toward +Y screen
207
- // (into the screen in isometric) should be drawn first
208
- face.depth = Math.sin(rotatedNormal);
209
-
194
+ // Screen Y for depth sorting (lower Y = further back = draw first)
195
+ face.screenY = face.verts.reduce((sum, v) => sum + v.y, 0) / 4;
196
+
210
197
  // Lighting: based on angle between world-space normal and light
211
198
  const lightDiff = face.normalAngle - lightAngle;
212
199
  const lightFactor = (Math.cos(lightDiff) + 1) / 2; // 0 to 1
@@ -214,16 +201,9 @@ class IsometricBox extends GameObject {
214
201
  face.color = this.shadeColor(this.baseColor, shadeFactor);
215
202
  }
216
203
 
217
- // Sort faces: draw back-facing first, then front-facing
218
- // Within each group, sort by depth
219
- faces.sort((a, b) => {
220
- // Back-facing faces drawn first
221
- if (a.facingCamera !== b.facingCamera) {
222
- return a.facingCamera ? 1 : -1;
223
- }
224
- // Then by depth (lower depth = further back = draw first)
225
- return a.depth - b.depth;
226
- });
204
+ // Sort all faces by screen Y (back to front: lower Y drawn first)
205
+ // This is the correct approach - no visibility culling needed
206
+ faces.sort((a, b) => a.screenY - b.screenY);
227
207
 
228
208
  // Draw faces in order: back faces first (with strokes), then front faces (fill covers back strokes)
229
209
  Painter.useCtx((ctx) => {
@@ -364,22 +344,30 @@ class Ball extends GameObject {
364
344
  }
365
345
 
366
346
  /**
367
- * Custom depth value for sorting - ensures ball renders on top of platforms
347
+ * Custom depth value for sorting - ensures ball renders on top of platforms.
348
+ * Uses rotated coordinates for correct sorting at all camera angles.
368
349
  */
369
350
  get isoDepth() {
370
- // Find the platform we're over (if any) and use its front corner as base
371
- let baseDepth = this.x + this.y;
372
-
351
+ const angle = this.isoScene.camera ? this.isoScene.camera.angle : 0;
352
+ const cos = Math.cos(angle);
353
+ const sin = Math.sin(angle);
354
+
355
+ // Rotate ball position
356
+ const rotatedBallX = this.x * cos - this.y * sin;
357
+ const rotatedBallY = this.x * sin + this.y * cos;
358
+ let baseDepth = rotatedBallX + rotatedBallY;
359
+
360
+ // Find the platform we're over and use its rotated front corner
373
361
  for (const platform of this.platforms) {
374
362
  if (platform.containsPoint(this.x, this.y, 0)) {
375
- // Use the platform's front corner as our base depth
376
- const platformFront = (platform.x + platform.w) + (platform.y + platform.d);
377
- if (platformFront > baseDepth) {
378
- baseDepth = platformFront;
363
+ // Get platform's rotated depth (max corner)
364
+ const platformDepth = platform.isoDepth - platform.h * 0.01; // Remove height factor
365
+ if (platformDepth > baseDepth) {
366
+ baseDepth = platformDepth;
379
367
  }
380
368
  }
381
369
  }
382
-
370
+
383
371
  // Add height plus small offset to ensure we render on top of platforms
384
372
  return baseDepth + this.z * 0.5 + 1;
385
373
  }