@guinetik/gcanvas 1.0.4 → 1.0.5

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 (193) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/animations.html +31 -0
  3. package/dist/basic.html +38 -0
  4. package/dist/baskara.html +31 -0
  5. package/dist/bezier.html +35 -0
  6. package/dist/beziersignature.html +29 -0
  7. package/dist/blackhole.html +28 -0
  8. package/dist/blob.html +35 -0
  9. package/dist/coordinates.html +698 -0
  10. package/dist/cube3d.html +23 -0
  11. package/dist/demos.css +303 -0
  12. package/dist/dino.html +42 -0
  13. package/dist/easing.html +28 -0
  14. package/dist/events.html +195 -0
  15. package/dist/fluent.html +647 -0
  16. package/dist/fluid-simple.html +22 -0
  17. package/dist/fluid.html +37 -0
  18. package/dist/fractals.html +36 -0
  19. package/dist/gameobjects.html +626 -0
  20. package/dist/gcanvas.es.js +517 -0
  21. package/dist/gcanvas.es.min.js +1 -1
  22. package/dist/gcanvas.umd.js +1 -1
  23. package/dist/gcanvas.umd.min.js +1 -1
  24. package/dist/genart.html +26 -0
  25. package/dist/gendream.html +26 -0
  26. package/dist/group.html +36 -0
  27. package/dist/home.html +587 -0
  28. package/dist/hyperbolic001.html +23 -0
  29. package/dist/hyperbolic002.html +23 -0
  30. package/dist/hyperbolic003.html +23 -0
  31. package/dist/hyperbolic004.html +23 -0
  32. package/dist/hyperbolic005.html +22 -0
  33. package/dist/index.html +398 -0
  34. package/dist/isometric.html +34 -0
  35. package/dist/js/animations.js +452 -0
  36. package/dist/js/basic.js +204 -0
  37. package/dist/js/baskara.js +751 -0
  38. package/dist/js/bezier.js +692 -0
  39. package/dist/js/beziersignature.js +241 -0
  40. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  41. package/dist/js/blackhole/blackhole.obj.js +318 -0
  42. package/dist/js/blackhole/index.js +409 -0
  43. package/dist/js/blackhole/particle.js +56 -0
  44. package/dist/js/blackhole/starfield.obj.js +218 -0
  45. package/dist/js/blob.js +2276 -0
  46. package/dist/js/coordinates.js +840 -0
  47. package/dist/js/cube3d.js +789 -0
  48. package/dist/js/dino.js +1420 -0
  49. package/dist/js/easing.js +477 -0
  50. package/dist/js/fluent.js +183 -0
  51. package/dist/js/fluid-simple.js +253 -0
  52. package/dist/js/fluid.js +527 -0
  53. package/dist/js/fractals.js +932 -0
  54. package/dist/js/fractalworker.js +93 -0
  55. package/dist/js/gameobjects.js +176 -0
  56. package/dist/js/genart.js +268 -0
  57. package/dist/js/gendream.js +209 -0
  58. package/dist/js/group.js +140 -0
  59. package/dist/js/hyperbolic001.js +310 -0
  60. package/dist/js/hyperbolic002.js +388 -0
  61. package/dist/js/hyperbolic003.js +319 -0
  62. package/dist/js/hyperbolic004.js +345 -0
  63. package/dist/js/hyperbolic005.js +340 -0
  64. package/dist/js/info-toggle.js +25 -0
  65. package/dist/js/isometric.js +863 -0
  66. package/dist/js/kerr.js +1547 -0
  67. package/dist/js/lavalamp.js +590 -0
  68. package/dist/js/layout.js +354 -0
  69. package/dist/js/mondrian.js +285 -0
  70. package/dist/js/opacity.js +275 -0
  71. package/dist/js/painter.js +484 -0
  72. package/dist/js/particles-showcase.js +514 -0
  73. package/dist/js/particles.js +299 -0
  74. package/dist/js/patterns.js +397 -0
  75. package/dist/js/penrose/artifact.js +69 -0
  76. package/dist/js/penrose/blackhole.js +121 -0
  77. package/dist/js/penrose/constants.js +73 -0
  78. package/dist/js/penrose/game.js +943 -0
  79. package/dist/js/penrose/lore.js +278 -0
  80. package/dist/js/penrose/penrosescene.js +892 -0
  81. package/dist/js/penrose/ship.js +216 -0
  82. package/dist/js/penrose/sounds.js +211 -0
  83. package/dist/js/penrose/voidparticle.js +55 -0
  84. package/dist/js/penrose/voidscene.js +258 -0
  85. package/dist/js/penrose/voidship.js +144 -0
  86. package/dist/js/penrose/wormhole.js +46 -0
  87. package/dist/js/pipeline.js +555 -0
  88. package/dist/js/plane3d.js +256 -0
  89. package/dist/js/platformer.js +1579 -0
  90. package/dist/js/scene.js +304 -0
  91. package/dist/js/scenes.js +320 -0
  92. package/dist/js/schrodinger.js +410 -0
  93. package/dist/js/schwarzschild.js +1015 -0
  94. package/dist/js/shapes.js +628 -0
  95. package/dist/js/space/alien.js +171 -0
  96. package/dist/js/space/boom.js +98 -0
  97. package/dist/js/space/boss.js +353 -0
  98. package/dist/js/space/buff.js +73 -0
  99. package/dist/js/space/bullet.js +102 -0
  100. package/dist/js/space/constants.js +85 -0
  101. package/dist/js/space/game.js +1884 -0
  102. package/dist/js/space/hud.js +112 -0
  103. package/dist/js/space/laserbeam.js +179 -0
  104. package/dist/js/space/lightning.js +277 -0
  105. package/dist/js/space/minion.js +192 -0
  106. package/dist/js/space/missile.js +212 -0
  107. package/dist/js/space/player.js +430 -0
  108. package/dist/js/space/powerup.js +90 -0
  109. package/dist/js/space/starfield.js +58 -0
  110. package/dist/js/space/starpower.js +90 -0
  111. package/dist/js/spacetime.js +559 -0
  112. package/dist/js/sphere3d.js +229 -0
  113. package/dist/js/sprite.js +473 -0
  114. package/dist/js/starfaux/config.js +118 -0
  115. package/dist/js/starfaux/enemy.js +353 -0
  116. package/dist/js/starfaux/hud.js +78 -0
  117. package/dist/js/starfaux/index.js +482 -0
  118. package/dist/js/starfaux/laser.js +182 -0
  119. package/dist/js/starfaux/player.js +468 -0
  120. package/dist/js/starfaux/terrain.js +560 -0
  121. package/dist/js/study001.js +275 -0
  122. package/dist/js/study002.js +366 -0
  123. package/dist/js/study003.js +331 -0
  124. package/dist/js/study004.js +389 -0
  125. package/dist/js/study005.js +209 -0
  126. package/dist/js/study006.js +194 -0
  127. package/dist/js/study007.js +192 -0
  128. package/dist/js/study008.js +413 -0
  129. package/dist/js/svgtween.js +204 -0
  130. package/dist/js/tde/accretiondisk.js +471 -0
  131. package/dist/js/tde/blackhole.js +219 -0
  132. package/dist/js/tde/blackholescene.js +209 -0
  133. package/dist/js/tde/config.js +59 -0
  134. package/dist/js/tde/index.js +820 -0
  135. package/dist/js/tde/jets.js +290 -0
  136. package/dist/js/tde/lensedstarfield.js +154 -0
  137. package/dist/js/tde/tdestar.js +297 -0
  138. package/dist/js/tde/tidalstream.js +372 -0
  139. package/dist/js/tde_old/blackhole.obj.js +354 -0
  140. package/dist/js/tde_old/debris.obj.js +791 -0
  141. package/dist/js/tde_old/flare.obj.js +239 -0
  142. package/dist/js/tde_old/index.js +448 -0
  143. package/dist/js/tde_old/star.obj.js +812 -0
  144. package/dist/js/tetris/config.js +157 -0
  145. package/dist/js/tetris/grid.js +286 -0
  146. package/dist/js/tetris/index.js +1195 -0
  147. package/dist/js/tetris/renderer.js +634 -0
  148. package/dist/js/tetris/tetrominos.js +280 -0
  149. package/dist/js/tiles.js +312 -0
  150. package/dist/js/tweendemo.js +79 -0
  151. package/dist/js/visibility.js +102 -0
  152. package/dist/kerr.html +28 -0
  153. package/dist/lavalamp.html +27 -0
  154. package/dist/layouts.html +37 -0
  155. package/dist/logo.svg +4 -0
  156. package/dist/loop.html +84 -0
  157. package/dist/mondrian.html +32 -0
  158. package/dist/og_image.png +0 -0
  159. package/dist/opacity.html +36 -0
  160. package/dist/painter.html +39 -0
  161. package/dist/particles-showcase.html +28 -0
  162. package/dist/particles.html +24 -0
  163. package/dist/patterns.html +33 -0
  164. package/dist/penrose-game.html +31 -0
  165. package/dist/pipeline.html +737 -0
  166. package/dist/plane3d.html +24 -0
  167. package/dist/platformer.html +43 -0
  168. package/dist/scene.html +33 -0
  169. package/dist/scenes.html +96 -0
  170. package/dist/schrodinger.html +27 -0
  171. package/dist/schwarzschild.html +27 -0
  172. package/dist/shapes.html +16 -0
  173. package/dist/space.html +85 -0
  174. package/dist/spacetime.html +27 -0
  175. package/dist/sphere3d.html +24 -0
  176. package/dist/sprite.html +18 -0
  177. package/dist/starfaux.html +22 -0
  178. package/dist/study001.html +23 -0
  179. package/dist/study002.html +23 -0
  180. package/dist/study003.html +23 -0
  181. package/dist/study004.html +23 -0
  182. package/dist/study005.html +22 -0
  183. package/dist/study006.html +24 -0
  184. package/dist/study007.html +24 -0
  185. package/dist/study008.html +22 -0
  186. package/dist/svgtween.html +29 -0
  187. package/dist/tde.html +28 -0
  188. package/dist/tetris3d.html +25 -0
  189. package/dist/tiles.html +28 -0
  190. package/dist/transforms.html +400 -0
  191. package/dist/tween.html +45 -0
  192. package/dist/visibility.html +33 -0
  193. package/package.json +1 -1
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Terrain - Wireframe ground grid with Perlin noise deformation for StarFaux
3
+ *
4
+ * Uses Perlin noise to create smooth, procedural terrain that the
5
+ * grid deforms to - just like classic StarFox SNES/N64!
6
+ *
7
+ * Pattern borrowed from spacetime.js: pre-compute grid vertices,
8
+ * update heights per frame, project all then draw.
9
+ */
10
+
11
+ import { Painter, Noise } from "/gcanvas.es.min.js";
12
+ import { CONFIG } from "./config.js";
13
+
14
+ export class Terrain {
15
+ constructor(game, camera) {
16
+ this.game = game;
17
+ this.camera = camera;
18
+ this.config = CONFIG.terrain;
19
+
20
+ // Seed the noise for consistent terrain
21
+ Noise.seed(42);
22
+
23
+ // Grid dimensions from config
24
+ this.numRows = this.config.totalRows || 80;
25
+ this.numCols = this.config.numCols || 60;
26
+
27
+ // Calculate dynamic width based on screen dimensions
28
+ this.updateDimensions();
29
+
30
+ // Pre-compute flat grid structure (will be updated each frame)
31
+ this.gridVertices = [];
32
+
33
+ // Track the nearest visible row for ground fill
34
+ this.nearestVisibleRow = null;
35
+ }
36
+
37
+ /**
38
+ * Update terrain dimensions based on current screen size
39
+ * Called on init and resize
40
+ */
41
+ updateDimensions() {
42
+ // Width scales with screen width to always reach edges
43
+ const multiplier = this.config.widthMultiplier || 6;
44
+ this.terrainWidth = this.game.width * multiplier;
45
+
46
+ // Column spacing adapts to maintain grid density
47
+ this.colSpacing = this.terrainWidth / this.numCols;
48
+ }
49
+
50
+ update(dt) {
51
+ // Rebuild grid vertices each frame based on current scroll position
52
+ this.buildGridVertices();
53
+ }
54
+
55
+ /**
56
+ * Build grid of vertices at current scroll position with terrain heights
57
+ */
58
+ buildGridVertices() {
59
+ const cfg = this.config;
60
+ // Use dynamic width calculated in updateDimensions
61
+ const halfWidth = this.terrainWidth / 2;
62
+ const groundY = cfg.yPosition;
63
+ const colSpacing = this.colSpacing;
64
+
65
+ // Use config values for grid density
66
+ const nearSpacing = cfg.nearSpacing || 25;
67
+ const totalRows = this.numRows;
68
+ const startZ = cfg.nearPlaneZ || 50;
69
+
70
+ this.gridVertices = [];
71
+ this.nearestVisibleRow = null;
72
+
73
+ // Calculate scroll offset based on our actual spacing
74
+ const scrollOffset = this.game.distance % nearSpacing;
75
+
76
+ for (let row = 0; row < totalRows; row++) {
77
+ const rowVertices = [];
78
+
79
+ // Start grid at bottom of screen, scrolling smoothly
80
+ const lineZ = startZ + row * nearSpacing - scrollOffset;
81
+
82
+ // Skip if behind camera
83
+ if (lineZ <= 0) {
84
+ this.gridVertices.push(null);
85
+ continue;
86
+ }
87
+
88
+ // World Z for this row
89
+ const worldZ = this.game.distance + lineZ;
90
+
91
+ for (let col = 0; col <= this.numCols; col++) {
92
+ const worldX = -halfWidth + col * colSpacing;
93
+
94
+ // Get terrain height at this point using Perlin noise
95
+ const terrainHeight = this.getHeightAt(worldX, worldZ);
96
+
97
+ // Y position: ground minus terrain height (terrain rises UP = negative Y)
98
+ const y = groundY - terrainHeight;
99
+
100
+ rowVertices.push({
101
+ worldX,
102
+ worldZ,
103
+ localZ: lineZ, // Z relative to camera for projection
104
+ y,
105
+ height: terrainHeight,
106
+ });
107
+ }
108
+
109
+ this.gridVertices.push(rowVertices);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Get terrain height at a world position
115
+ * Canyon-style: dramatic mountains on edges, mostly flat middle with occasional features
116
+ */
117
+ getHeightAt(worldX, worldZ) {
118
+ const cfg = this.config.features;
119
+ const maxH = cfg.maxHeight;
120
+
121
+ // How far from center (0 = center, 1 = edge)
122
+ const halfWidth = this.terrainWidth / 2;
123
+ const edgeFactor = Math.abs(worldX) / halfWidth;
124
+
125
+ // DRAMATIC canyon walls - steep mountains on left/right margins
126
+ // Use steeper curve (power of 3) and higher multiplier for more pronounced edges
127
+ const edgeHeight = Math.pow(edgeFactor, 3) * maxH * 1.8;
128
+
129
+ // Add dramatic noise variation to canyon walls - jagged peaks
130
+ const wallNoise = Noise.perlin2(worldX * 0.004, worldZ * 0.004);
131
+ const wallVariation = wallNoise * maxH * 0.6 * edgeFactor;
132
+
133
+ // Secondary ridge detail on edges
134
+ const ridgeNoise = Noise.perlin2(worldX * 0.012, worldZ * 0.008);
135
+ const ridges = Math.max(0, ridgeNoise) * maxH * 0.3 * Math.pow(edgeFactor, 2);
136
+
137
+ // TALL obstacle mountains in the middle - these are the main hazards to dodge
138
+ const obstaclePeakNoise = Noise.perlin2(worldX * 0.002 + 200, worldZ * 0.0015);
139
+ let obstaclePeak = 0;
140
+ if (obstaclePeakNoise > 0.4 && edgeFactor < 0.5) {
141
+ // Tall, sharp mountains that require dodging
142
+ const sharpness = Noise.perlin2(worldX * 0.02, worldZ * 0.02);
143
+ obstaclePeak = (obstaclePeakNoise - 0.4) * 8 * maxH * Math.max(0.5, sharpness + 0.5);
144
+ }
145
+
146
+ // Additional scattered tall spires in the middle
147
+ const spireNoise = Noise.perlin2(worldX * 0.004 + 300, worldZ * 0.003 + 100);
148
+ let spire = 0;
149
+ if (spireNoise > 0.5 && edgeFactor < 0.4) {
150
+ spire = (spireNoise - 0.5) * 6 * maxH;
151
+ }
152
+
153
+ // Depressions/valleys in the middle (negative contribution to make dips)
154
+ const valleyNoise = Noise.perlin2(worldX * 0.002 + 50, worldZ * 0.002 + 50);
155
+ let valley = 0;
156
+ if (valleyNoise < -0.3 && edgeFactor < 0.4) {
157
+ // Create subtle dips/depressions
158
+ valley = (valleyNoise + 0.3) * maxH * 0.2; // Negative value = depression
159
+ }
160
+
161
+ // Rolling hills in middle area (gentle undulation)
162
+ const hillNoise = Noise.perlin2(worldX * 0.003, worldZ * 0.003);
163
+ const middleHills = Math.max(0, hillNoise * 0.5 + 0.1) * maxH * 0.2 * (1 - edgeFactor);
164
+
165
+ // Combine all layers
166
+ let height = edgeHeight + wallVariation + ridges + obstaclePeak + spire + valley + middleHills;
167
+
168
+ // Ensure non-negative
169
+ height = Math.max(0, height);
170
+
171
+ return height;
172
+ }
173
+
174
+ /**
175
+ * Check if player collides with terrain
176
+ */
177
+ checkPlayerCollision(playerX, playerY) {
178
+ const playerWorldZ = this.game.distance + CONFIG.player.shipZ;
179
+ const terrainHeight = this.getHeightAt(playerX, playerWorldZ);
180
+
181
+ // Only check collision for reasonably tall terrain
182
+ const collisionThreshold = this.config.features.collisionHeight || 100;
183
+ if (terrainHeight < collisionThreshold) {
184
+ return false; // Terrain too short to collide
185
+ }
186
+
187
+ // groundY is 0, terrainHeight is positive (e.g. 200)
188
+ // surfaceY = 0 - 200 = -200 (negative = higher on screen)
189
+ // playerY is from offsetY + screenY, can be negative (high) or positive (low)
190
+ // Player collides if their Y is GREATER than surfaceY (lower on screen = into mountain)
191
+ const groundY = this.config.yPosition;
192
+ const surfaceY = groundY - terrainHeight;
193
+
194
+ // Small buffer for ship size
195
+ const collisionBuffer = 30;
196
+ return playerY > surfaceY + collisionBuffer;
197
+ }
198
+
199
+ render(parallaxOffsetY = 0) {
200
+ const ctx = Painter.ctx;
201
+
202
+ // Store parallax for use in sky rendering
203
+ this.parallaxOffsetY = parallaxOffsetY;
204
+
205
+ // Render sky gradient first (behind everything)
206
+ this.renderSky(ctx);
207
+
208
+ // Fill the gap between horizon and terrain with dark color
209
+ this.renderHorizonFill(ctx);
210
+
211
+ // Build projection data
212
+ const projected = this.buildProjectedGrid();
213
+
214
+ // Render the grid lines - NO ground fill, just clean lines like Joy Division
215
+ this.renderGridLines(ctx, projected);
216
+ }
217
+
218
+ /**
219
+ * Fill the area below the horizon to eliminate the gap
220
+ */
221
+ renderHorizonFill(ctx) {
222
+ const w = this.game.width;
223
+ const h = this.game.height;
224
+ const groundY = this.config.yPosition;
225
+
226
+ // Horizon stays FIXED - no parallax
227
+ const horizonPoint = this.camera.project(0, groundY, 10000);
228
+ const horizonY = horizonPoint.y;
229
+
230
+ // Fill from horizon down (but not all the way - leave room for HUD)
231
+ // The fill height should just cover the gap, terrain lines will render on top
232
+ const fillHeight = h * 0.6; // Fill about 60% down from horizon
233
+
234
+ const gradient = ctx.createLinearGradient(0, horizonY, 0, horizonY + fillHeight);
235
+ gradient.addColorStop(0, "#001a00"); // Very dark green at horizon
236
+ gradient.addColorStop(0.5, "#000800"); // Darker
237
+ gradient.addColorStop(1, "#000000"); // Black
238
+
239
+ ctx.fillStyle = gradient;
240
+ ctx.fillRect(-w, horizonY, w * 2, fillHeight);
241
+ }
242
+
243
+ /**
244
+ * Build projected grid data for rendering
245
+ */
246
+ buildProjectedGrid() {
247
+ const camera = this.camera;
248
+ const projected = [];
249
+
250
+ // Track nearest visible row for ground fill
251
+ this.nearestVisibleRow = null;
252
+
253
+ for (let row = 0; row < this.gridVertices.length; row++) {
254
+ const rowVerts = this.gridVertices[row];
255
+ if (!rowVerts) {
256
+ projected.push(null);
257
+ continue;
258
+ }
259
+
260
+ const rowProjected = [];
261
+ let hasVisiblePoints = false;
262
+
263
+ for (const vertex of rowVerts) {
264
+ const p = camera.project(vertex.worldX, vertex.y, vertex.localZ);
265
+
266
+ if (p.scale > 0.008) {
267
+ rowProjected.push({
268
+ x: p.x,
269
+ y: p.y,
270
+ scale: p.scale,
271
+ height: vertex.height,
272
+ });
273
+ hasVisiblePoints = true;
274
+ } else {
275
+ rowProjected.push(null);
276
+ }
277
+ }
278
+
279
+ projected.push(rowProjected);
280
+
281
+ // Track the nearest (first) visible row for ground fill
282
+ if (hasVisiblePoints && !this.nearestVisibleRow) {
283
+ this.nearestVisibleRow = rowProjected;
284
+ }
285
+ }
286
+
287
+ return projected;
288
+ }
289
+
290
+ /**
291
+ * Draw grid lines from projected data - Joy Division style (horizontal only)
292
+ * Like the Unknown Pleasures album cover - stacked waveform lines
293
+ * Mountains above horizon render as black silhouettes for contrast
294
+ */
295
+ renderGridLines(ctx, projected) {
296
+ const scale = this.game.scaleFactor || 1;
297
+ const groundY = this.config.yPosition;
298
+
299
+ // Horizon stays FIXED
300
+ const horizonPoint = this.camera.project(0, groundY, 10000);
301
+ const horizonY = horizonPoint.y;
302
+
303
+ // Joy Division style horizontal waveform lines
304
+ for (let row = 0; row < projected.length; row++) {
305
+ const rowPoints = projected[row];
306
+ if (!rowPoints) continue;
307
+
308
+ // Each line is one continuous stroke - consistent color
309
+ ctx.strokeStyle = "#00ff00";
310
+ ctx.lineWidth = 1.5 * scale;
311
+ ctx.beginPath();
312
+ let started = false;
313
+
314
+ for (let col = 0; col < rowPoints.length; col++) {
315
+ const point = rowPoints[col];
316
+ if (!point) {
317
+ if (started) ctx.stroke();
318
+ ctx.beginPath();
319
+ started = false;
320
+ continue;
321
+ }
322
+
323
+ if (!started) {
324
+ ctx.moveTo(point.x, point.y);
325
+ started = true;
326
+ } else {
327
+ ctx.lineTo(point.x, point.y);
328
+ }
329
+ }
330
+ if (started) ctx.stroke();
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Render black silhouettes for mountain peaks that extend above the horizon
336
+ * This creates contrast against the bright sun/sky
337
+ */
338
+ renderSilhouettes(ctx, projected, horizonY, scale) {
339
+ ctx.fillStyle = "#000000";
340
+
341
+ // Process each row - fill area from line to horizon if above horizon
342
+ for (let row = projected.length - 1; row >= 0; row--) {
343
+ const rowPoints = projected[row];
344
+ if (!rowPoints) continue;
345
+
346
+ // Check if any points in this row are above horizon
347
+ let hasAboveHorizon = false;
348
+ for (const point of rowPoints) {
349
+ if (point && point.y < horizonY) {
350
+ hasAboveHorizon = true;
351
+ break;
352
+ }
353
+ }
354
+
355
+ if (!hasAboveHorizon) continue;
356
+
357
+ // Draw filled polygon from the line down to horizon
358
+ ctx.beginPath();
359
+ let started = false;
360
+ let firstX = 0;
361
+
362
+ for (let col = 0; col < rowPoints.length; col++) {
363
+ const point = rowPoints[col];
364
+ if (!point) continue;
365
+
366
+ // Only include points above or near horizon
367
+ const y = Math.min(point.y, horizonY);
368
+
369
+ if (!started) {
370
+ firstX = point.x;
371
+ ctx.moveTo(point.x, y);
372
+ started = true;
373
+ } else {
374
+ ctx.lineTo(point.x, y);
375
+ }
376
+ }
377
+
378
+ if (started) {
379
+ // Close the shape by going down to horizon and back
380
+ const lastPoint = rowPoints[rowPoints.length - 1] || rowPoints.find(p => p);
381
+ if (lastPoint) {
382
+ ctx.lineTo(lastPoint.x, horizonY + 2); // Go to horizon
383
+ ctx.lineTo(firstX, horizonY + 2); // Go back along horizon
384
+ ctx.closePath();
385
+ ctx.fill();
386
+ }
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Get color based on terrain height - green with brighter peaks
393
+ */
394
+ getHeightColor(height) {
395
+ // Green terrain - brighter for peaks like classic StarFox
396
+ if (height < 5) {
397
+ return "#005500"; // Flat ground - dark green
398
+ } else if (height < 40) {
399
+ return "#007700"; // Low hills - medium green
400
+ } else if (height < 80) {
401
+ return "#00aa00"; // Medium hills - brighter green
402
+ } else {
403
+ return "#00ff00"; // Peaks - bright green
404
+ }
405
+ }
406
+
407
+ renderSky(ctx) {
408
+ const camera = this.camera;
409
+ const groundY = this.config.yPosition;
410
+ const scale = this.game.scaleFactor || 1;
411
+ const w = this.game.width;
412
+ const h = this.game.height;
413
+
414
+ // Horizon stays FIXED - no parallax on sky/horizon
415
+ const horizonPoint = camera.project(0, groundY, 10000);
416
+ const horizonY = horizonPoint.y;
417
+
418
+ // === DARK PURPLE/MAGENTA SKY - contrasts with green terrain ===
419
+
420
+ // Dark purple gradient sky - complementary to green
421
+ const skyGradient = ctx.createLinearGradient(0, -h / 2, 0, horizonY);
422
+ skyGradient.addColorStop(0, "#050008"); // Near black with purple tint
423
+ skyGradient.addColorStop(0.3, "#0a0012"); // Very dark purple
424
+ skyGradient.addColorStop(0.5, "#120020"); // Dark purple
425
+ skyGradient.addColorStop(0.7, "#1a0030"); // Medium purple
426
+ skyGradient.addColorStop(0.85, "#220040"); // Lighter purple near horizon
427
+ skyGradient.addColorStop(1, "#2a0050"); // Purple at horizon
428
+ ctx.fillStyle = skyGradient;
429
+ ctx.fillRect(-w, -h / 2, w * 2, horizonY + h / 2);
430
+
431
+ // === TERMINAL SUN ===
432
+ const sunRadius = 120 * scale;
433
+ const sunY = horizonY - sunRadius * 0.4; // Sun sitting on horizon
434
+
435
+ // Sun glow (outer) - green/cyan glow against purple sky
436
+ const glowGradient = ctx.createRadialGradient(0, sunY, sunRadius * 0.5, 0, sunY, sunRadius * 2.5);
437
+ glowGradient.addColorStop(0, "rgba(100, 255, 150, 0.4)");
438
+ glowGradient.addColorStop(0.4, "rgba(0, 255, 100, 0.2)");
439
+ glowGradient.addColorStop(0.7, "rgba(0, 200, 100, 0.1)");
440
+ glowGradient.addColorStop(1, "rgba(50, 0, 80, 0)");
441
+ ctx.fillStyle = glowGradient;
442
+ ctx.fillRect(-w, horizonY - sunRadius * 3, w * 2, sunRadius * 3);
443
+
444
+ // Sun body - gradient from white/cyan to green
445
+ const sunGradient = ctx.createLinearGradient(0, sunY - sunRadius, 0, sunY + sunRadius);
446
+ sunGradient.addColorStop(0, "#eeffff"); // White/cyan top
447
+ sunGradient.addColorStop(0.25, "#88ffcc"); // Cyan-green
448
+ sunGradient.addColorStop(0.5, "#00ff88"); // Bright green-cyan
449
+ sunGradient.addColorStop(0.75, "#00cc66"); // Medium green
450
+ sunGradient.addColorStop(1, "#006633"); // Dark green bottom
451
+
452
+ ctx.save();
453
+ // Clip sun to above horizon (sun setting)
454
+ ctx.beginPath();
455
+ ctx.rect(-w, -h, w * 2, horizonY + h);
456
+ ctx.clip();
457
+
458
+ ctx.fillStyle = sunGradient;
459
+ ctx.beginPath();
460
+ ctx.arc(0, sunY, sunRadius, 0, Math.PI * 2);
461
+ ctx.fill();
462
+
463
+ // No scanlines - clean sun
464
+ ctx.restore();
465
+
466
+ // === CLOUDS ===
467
+ this.renderSynthwaveClouds(ctx, horizonY, scale);
468
+
469
+ // === HORIZON LINE ===
470
+ ctx.strokeStyle = "#00ff88"; // Cyan-green horizon to match sun
471
+ ctx.lineWidth = 2 * scale;
472
+ ctx.shadowColor = "#00ff88";
473
+ ctx.shadowBlur = 15 * scale;
474
+ ctx.beginPath();
475
+ ctx.moveTo(-w, horizonY);
476
+ ctx.lineTo(w, horizonY);
477
+ ctx.stroke();
478
+ ctx.shadowBlur = 0;
479
+ }
480
+
481
+ /**
482
+ * Render synthwave-style geometric clouds
483
+ */
484
+ renderSynthwaveClouds(ctx, horizonY, scale) {
485
+ const w = this.game.width;
486
+ const h = this.game.height;
487
+
488
+ // Scroll clouds slowly based on game distance
489
+ const scrollOffset = (this.game.distance * 0.02) % (w * 2);
490
+
491
+ ctx.save();
492
+ ctx.globalAlpha = 0.25;
493
+
494
+ // Layer 1: Far clouds (slower, smaller) - dark purple
495
+ const farOffset = scrollOffset * 0.3;
496
+ this.drawCloudLayer(ctx, horizonY - 180 * scale, 40 * scale, farOffset, 5, "#442266", scale);
497
+
498
+ // Layer 2: Mid clouds - purple/magenta
499
+ const midOffset = scrollOffset * 0.5;
500
+ this.drawCloudLayer(ctx, horizonY - 120 * scale, 30 * scale, midOffset, 4, "#663388", scale);
501
+
502
+ // Layer 3: Near clouds (faster, larger) - cyan-green glow
503
+ const nearOffset = scrollOffset * 0.8;
504
+ this.drawCloudLayer(ctx, horizonY - 60 * scale, 20 * scale, nearOffset, 3, "#00cc88", scale);
505
+
506
+ ctx.restore();
507
+ }
508
+
509
+ /**
510
+ * Draw a single layer of geometric clouds
511
+ */
512
+ drawCloudLayer(ctx, y, height, offset, count, color, scale) {
513
+ const w = this.game.width;
514
+ ctx.strokeStyle = color;
515
+ ctx.lineWidth = 1.5 * scale;
516
+
517
+ for (let i = 0; i < count; i++) {
518
+ // Position clouds across the sky with offset for scrolling
519
+ const baseX = (i / count) * w * 2 - w + offset;
520
+ const x = ((baseX + w * 2) % (w * 2)) - w; // Wrap around
521
+
522
+ // Draw geometric cloud shape (horizontal lines stacked)
523
+ const cloudWidth = (80 + Math.sin(i * 1.5) * 40) * scale;
524
+ const lineCount = 3 + (i % 2);
525
+
526
+ for (let j = 0; j < lineCount; j++) {
527
+ const lineY = y + j * (height / lineCount);
528
+ const lineWidth = cloudWidth * (1 - j * 0.15); // Taper toward bottom
529
+ ctx.beginPath();
530
+ ctx.moveTo(x - lineWidth / 2, lineY);
531
+ ctx.lineTo(x + lineWidth / 2, lineY);
532
+ ctx.stroke();
533
+ }
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Render ground fill - dark gradient at bottom ~15% of screen
539
+ */
540
+ renderGroundFill(ctx) {
541
+ const w = this.game.width;
542
+ const halfH = this.game.height / 2;
543
+
544
+ // Fill bottom 15% with dark gradient
545
+ const fillHeight = halfH * 0.3; // 15% of screen height
546
+ const fillStart = halfH - fillHeight;
547
+
548
+ const gradient = ctx.createLinearGradient(0, fillStart, 0, halfH);
549
+ gradient.addColorStop(0, "rgba(0, 15, 0, 0)"); // Transparent at top
550
+ gradient.addColorStop(0.3, "rgba(0, 10, 0, 0.7)");
551
+ gradient.addColorStop(1, "rgba(0, 5, 5, 1)"); // Solid dark at bottom
552
+
553
+ ctx.fillStyle = gradient;
554
+ ctx.fillRect(-w, fillStart, w * 2, fillHeight + 50);
555
+ }
556
+
557
+ reset() {
558
+ // Nothing to reset - terrain is procedural
559
+ }
560
+ }