One commited on
Commit
bcceda7
·
verified ·
1 Parent(s): 3ad954d

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +1857 -19
index.html CHANGED
@@ -1,19 +1,1857 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
+ <title>Star Drift — Space Flight Explorer</title>
7
+ <style>
8
+ html, body {
9
+ margin: 0;
10
+ padding: 0;
11
+ width: 100%;
12
+ height: 100%;
13
+ overflow: hidden;
14
+ background: #050510;
15
+ cursor: none; /* native cursor hidden; a fading one is drawn on the canvas */
16
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
17
+ -webkit-user-select: none;
18
+ user-select: none;
19
+ }
20
+ #game {
21
+ display: block;
22
+ width: 100%;
23
+ height: 100%;
24
+ cursor: none;
25
+ touch-action: none;
26
+ }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <canvas id="game"></canvas>
31
+
32
+ <script>
33
+ (function () {
34
+ 'use strict';
35
+
36
+ const canvas = document.getElementById('game');
37
+ const ctx = canvas.getContext('2d');
38
+
39
+ let W = 0, H = 0, DPR = 1, CX = 0, CY = 0, FOCAL = 0;
40
+ let bgGradient = null, vignette = null;
41
+
42
+ function clamp(v, a, b) {
43
+ return Math.max(a, Math.min(b, v));
44
+ }
45
+
46
+ function resize() {
47
+ DPR = Math.min(window.devicePixelRatio || 1, 2);
48
+ W = window.innerWidth;
49
+ H = window.innerHeight;
50
+ canvas.width = Math.floor(W * DPR);
51
+ canvas.height = Math.floor(H * DPR);
52
+ canvas.style.width = W + 'px';
53
+ canvas.style.height = H + 'px';
54
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
55
+ CX = W / 2;
56
+ CY = H / 2;
57
+ FOCAL = Math.max(1, H) * 0.9;
58
+
59
+ bgGradient = ctx.createLinearGradient(0, 0, 0, H);
60
+ bgGradient.addColorStop(0, '#050510');
61
+ bgGradient.addColorStop(0.5, '#070718');
62
+ bgGradient.addColorStop(1, '#050510');
63
+
64
+ // cinematic vignette (not UI — just darkened corners)
65
+ vignette = ctx.createRadialGradient(CX, CY, Math.min(W, H) * 0.34, CX, CY, Math.hypot(W, H) * 0.72);
66
+ vignette.addColorStop(0, 'rgba(0,0,0,0)');
67
+ vignette.addColorStop(0.62, 'rgba(2,3,10,0.16)');
68
+ vignette.addColorStop(1, 'rgba(2,3,12,0.48)');
69
+ }
70
+ window.addEventListener('resize', resize);
71
+ resize();
72
+
73
+ // ------------------------------------------------------------------
74
+ // 3D math helpers (plain objects, no libraries)
75
+ // ------------------------------------------------------------------
76
+ function sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
77
+ function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
78
+ function cross(a, b) {
79
+ return [
80
+ a[1] * b[2] - a[2] * b[1],
81
+ a[2] * b[0] - a[0] * b[2],
82
+ a[0] * b[1] - a[1] * b[0]
83
+ ];
84
+ }
85
+ function norm(a) {
86
+ const l = Math.hypot(a[0], a[1], a[2]) || 1;
87
+ return [a[0] / l, a[1] / l, a[2] / l];
88
+ }
89
+ function len(a) { return Math.hypot(a[0], a[1], a[2]); }
90
+ const TAU = Math.PI * 2;
91
+
92
+ // Rodrigues rotation of v about a unit axis — the primitive the steering
93
+ // uses to swing the nose along one continuous arc.
94
+ function rotateAbout(v, axis, ang) {
95
+ const c = Math.cos(ang), s = Math.sin(ang), t = 1 - c;
96
+ const k = axis[0] * v[0] + axis[1] * v[1] + axis[2] * v[2];
97
+ const cx = axis[1] * v[2] - axis[2] * v[1];
98
+ const cy = axis[2] * v[0] - axis[0] * v[2];
99
+ const cz = axis[0] * v[1] - axis[1] * v[0];
100
+ return [
101
+ v[0] * c + cx * s + axis[0] * k * t,
102
+ v[1] * c + cy * s + axis[1] * k * t,
103
+ v[2] * c + cz * s + axis[2] * k * t
104
+ ];
105
+ }
106
+
107
+ function smoothstep(t) { return t * t * (3 - 2 * t); }
108
+ function dot3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
109
+
110
+ // Ship state: position, velocity, and the nose as a real 3D unit vector.
111
+ // heading / pitch / bank are read back out of that vector for the camera
112
+ // and the mesh, so everything stays consistent.
113
+ const ship = {
114
+ pos: [0, 0, 0],
115
+ vel: [0, 0, 0],
116
+ fwd: [0, 0, -1], // unit nose direction; heading 0 = facing -Z
117
+ omega: [0, 0, 0], // live angular velocity (rad/s) of the nose
118
+ heading: 0,
119
+ pitch: 0,
120
+ bank: 0,
121
+ throttle: 0
122
+ };
123
+
124
+ // Turning: the nose is rotated about the single axis perpendicular to the
125
+ // plane spanned by (current nose, click target) — one continuous great-circle
126
+ // arc instead of independent yaw and pitch ramps. The turn rate is
127
+ // proportional to the remaining error (so it eases to a stop, never slams),
128
+ // capped (so it stays slow and readable) and slew-limited (so it ramps up
129
+ // instead of snapping). Result: soft start, gentle peak, soft arrival.
130
+ const TURN_MAX = 0.6; // rad/s ceiling on the turn (~34 deg/s)
131
+ const TURN_GAIN = 0.8; // proportional ease-out toward the target
132
+ const TURN_SLEW = 0.85; // rad/s^2 — how fast the turn can build or relax
133
+ const MAX_PITCH = 1.30; // ~74 deg nose authority (keeps the level camera sane)
134
+ const PITCH_SIN = Math.sin(MAX_PITCH);
135
+
136
+ const ACCEL = 88; // thrust strength
137
+ const DRAG = 0.95; // exponential drag coefficient
138
+ const MAX_SPEED = 118; // hard speed cap
139
+ const THROTTLE_RATE = 1.6; // how fast the engines ease in / out
140
+
141
+ const ENGINE_LOCAL = [[-0.3, 0.12, 1.32], [0.3, 0.12, 1.32]];
142
+
143
+ // The turn axis is remembered between frames so the nose cannot jitter as
144
+ // it converges (see update()).
145
+ const turnAxis = [0, 0, 1];
146
+
147
+ // ------------------------------------------------------------------
148
+ // Click-to-fly: click anywhere and the ship turns to face that screen
149
+ // direction, then keeps flying that way forever until the next click.
150
+ // The click is un-projected through the current camera basis, so the
151
+ // ship always aims exactly at the spot you click (WYSIWYG aiming).
152
+ // ------------------------------------------------------------------
153
+ let hasTarget = false;
154
+ const targetFwd = [0, 0, -1]; // unit world direction the nose steers toward
155
+ let lastCam = null; // camera basis from the last rendered frame
156
+
157
+ function setTargetFromClick(mx, my) {
158
+ if (!lastCam) return;
159
+
160
+ // screen point -> camera-space ray direction (x right, y up, z forward)
161
+ let dx = mx - CX, dy = CY - my, dz = FOCAL;
162
+ const l = Math.hypot(dx, dy, dz) || 1;
163
+ dx /= l; dy /= l; dz /= l;
164
+
165
+ // camera space -> world space via the camera basis vectors
166
+ const wx = dx * lastCam.xAxis[0] + dy * lastCam.yAxis[0] + dz * lastCam.zAxis[0];
167
+ const wy = dx * lastCam.xAxis[1] + dy * lastCam.yAxis[1] + dz * lastCam.zAxis[1];
168
+ const wz = dx * lastCam.xAxis[2] + dy * lastCam.yAxis[2] + dz * lastCam.zAxis[2];
169
+
170
+ const h = Math.atan2(wx, -wz);
171
+ const p = clamp(Math.asin(clamp(wy, -1, 1)), -MAX_PITCH, MAX_PITCH);
172
+ const cp = Math.cos(p);
173
+ targetFwd[0] = Math.sin(h) * cp;
174
+ targetFwd[1] = Math.sin(p);
175
+ targetFwd[2] = -Math.cos(h) * cp;
176
+
177
+ hasTarget = true;
178
+ }
179
+
180
+ // ------------------------------------------------------------------
181
+ // Cursor: fades out 1 second after the mouse stops moving, and fades
182
+ // back in as soon as it moves again.
183
+ // ------------------------------------------------------------------
184
+ let mouseX = CX, mouseY = CY;
185
+ let lastMove = -1e9; // long ago -> cursor starts hidden
186
+ let cursorAlpha = 0;
187
+
188
+ canvas.addEventListener('pointerdown', (e) => {
189
+ setTargetFromClick(e.clientX, e.clientY);
190
+ lastMove = performance.now();
191
+ });
192
+
193
+ canvas.addEventListener('pointermove', (e) => {
194
+ mouseX = e.clientX;
195
+ mouseY = e.clientY;
196
+ lastMove = performance.now();
197
+ });
198
+
199
+ // Fixed: use canvas mouseleave, not document mouseleave.
200
+ canvas.addEventListener('mouseleave', () => {
201
+ lastMove = -1e9;
202
+ });
203
+
204
+ window.addEventListener('blur', () => {
205
+ lastMove = -1e9;
206
+ });
207
+
208
+ // ------------------------------------------------------------------
209
+ // World objects
210
+ // ------------------------------------------------------------------
211
+ const STAR_COUNT = 15000;
212
+ const stars = [];
213
+ const starColors = [
214
+ [255, 255, 255], [150, 180, 255], [255, 225, 140], [255, 140, 130]
215
+ ];
216
+ const starStyle = starColors.map(c => 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')');
217
+ const FIELD = 2600; // respawn half-extent (infinite space wrap)
218
+
219
+ function makeStar() {
220
+ return {
221
+ x: (Math.random() * 2 - 1) * FIELD,
222
+ y: (Math.random() * 2 - 1) * FIELD,
223
+ z: (Math.random() * 2 - 1) * FIELD,
224
+ ci: (Math.random() * 4) | 0,
225
+ s: Math.random() < 0.18 ? 2 : 1
226
+ };
227
+ }
228
+ for (let i = 0; i < STAR_COUNT; i++) stars.push(makeStar());
229
+
230
+ // Seeded value noise + fBm (procedural detail for nebulae and asteroids)
231
+ function makeNoise(seed) {
232
+ const rnd = (h) => {
233
+ const s = Math.sin(h * 127.1 + seed * 311.7) * 43758.5453;
234
+ return s - Math.floor(s);
235
+ };
236
+ const grad = (x, y) => rnd(Math.floor(x) * 157 + Math.floor(y) * 113);
237
+ const smooth = (t) => t * t * (3 - 2 * t);
238
+ function noise(x, y) {
239
+ const xi = Math.floor(x), yi = Math.floor(y);
240
+ const xf = x - xi, yf = y - yi;
241
+ const u = smooth(xf), v = smooth(yf);
242
+ const a = grad(xi, yi), b = grad(xi + 1, yi);
243
+ const c = grad(xi, yi + 1), d = grad(xi + 1, yi + 1);
244
+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
245
+ }
246
+ function fbm(x, y, oct = 4) {
247
+ let v = 0, amp = 0.5, f = 1, tot = 0;
248
+ for (let o = 0; o < oct; o++) {
249
+ v += noise(x * f, y * f) * amp;
250
+ tot += amp;
251
+ amp *= 0.5;
252
+ f *= 2;
253
+ }
254
+ return v / tot;
255
+ }
256
+ return { noise, fbm };
257
+ }
258
+
259
+ // Nebulae — wrapped in a box 2x the ship field so they can exist far away
260
+ // and fade in from invisible instead of popping into view.
261
+ const FIELD_NEB = FIELD * 2;
262
+ const NEBULA_COUNT = 16; // 8x the volume of the old box -> same density
263
+ const nebulae = [];
264
+ const nebPalette = [
265
+ [255, 120, 200], [110, 170, 255], [120, 230, 170],
266
+ [255, 170, 90], [180, 120, 255]
267
+ ];
268
+ // Pre-render each nebula into a wispy gas-cloud texture: domain-warped fBm
269
+ // filaments, dark dust lanes, bright emission cores and embedded stars —
270
+ // so nebulae read as real clouds, not flat gradient circles.
271
+ function makeNebulaTexture(c, seed) {
272
+ const S = 128;
273
+ const cv = document.createElement('canvas');
274
+ cv.width = cv.height = S;
275
+ const c2 = cv.getContext('2d');
276
+ const nz = makeNoise(seed);
277
+ const img = c2.createImageData(S, S);
278
+ const data = img.data;
279
+ const R = S * 0.5;
280
+ for (let y = 0; y < S; y++) {
281
+ for (let x = 0; x < S; x++) {
282
+ const dx = x - R, dy = y - R;
283
+ const d = Math.hypot(dx, dy) / R;
284
+ if (d >= 1) continue;
285
+ const ang = Math.atan2(dy, dx);
286
+ // domain-warped fBm -> swirling gas filaments
287
+ const q1 = nz.fbm(dx * 0.05 + 5.2, dy * 0.05 + 1.3, 4);
288
+ const q2 = nz.fbm(dx * 0.05 + 9.1, dy * 0.05 + 7.7, 4);
289
+ const n = nz.fbm(dx * 0.06 + q1 * 8 + ang * 0.9, dy * 0.06 + q2 * 8, 5);
290
+ const fil = Math.pow(n, 2.3);
291
+ // dark dust lanes
292
+ const dust = nz.fbm(dx * 0.08 + 21.4, dy * 0.08 + 33.9, 3);
293
+ const lane = dust < 0.42 ? (0.42 - dust) * 2.2 : 0;
294
+ const soft = 1 - d;
295
+ const a = Math.max(0, fil * 1.9 - lane) * soft * soft;
296
+ const i = (y * S + x) * 4;
297
+ const hot = Math.pow(n, 6) * 120; // bright cores along filaments
298
+ data[i] = Math.min(255, c[0] + hot);
299
+ data[i + 1] = Math.min(255, c[1] + hot * 0.9);
300
+ data[i + 2] = Math.min(255, c[2] + hot * 0.8);
301
+ data[i + 3] = Math.min(255, a * 255);
302
+ }
303
+ }
304
+ c2.putImageData(img, 0, 0);
305
+ // a few faint background stars seen through the gas
306
+ for (let k = 0; k < 46; k++) {
307
+ const sx = nz.noise(k * 3.1, 7.7) * S;
308
+ const sy = nz.noise(2.2, k * 5.7) * S;
309
+ const sd = Math.hypot(sx - R, sy - R) / R;
310
+ if (sd > 0.85) continue;
311
+ c2.fillStyle = 'rgba(255,255,255,' + (0.2 + nz.noise(k, k) * 0.55).toFixed(2) + ')';
312
+ c2.fillRect(sx, sy, 1, 1);
313
+ }
314
+ return cv;
315
+ }
316
+ const nebulaTex = nebPalette.map((c, i) => makeNebulaTexture(c, 1234 + i * 77));
317
+ function makeNebula() {
318
+ const ci = (Math.random() * nebPalette.length) | 0;
319
+ return {
320
+ x: (Math.random() * 2 - 1) * FIELD_NEB,
321
+ y: (Math.random() * 2 - 1) * FIELD_NEB * 0.6,
322
+ z: (Math.random() * 2 - 1) * FIELD_NEB,
323
+ r: 700 + Math.random() * 1000,
324
+ ci,
325
+ rot: Math.random() * Math.PI * 2
326
+ };
327
+ }
328
+ for (let i = 0; i < NEBULA_COUNT; i++) nebulae.push(makeNebula());
329
+
330
+ // Asteroids — irregular gray polyhedra
331
+ // Asteroids wrap in a box 3x the ship field — a huge runway so they can
332
+ // fade in from invisible over many seconds instead of popping into view.
333
+ const FIELD_AST = FIELD * 3;
334
+ const ASTEROID_COUNT = 150; // 150 rocks in a 27x-larger box keeps the same encounter density as before
335
+ const asteroids = [];
336
+ function makeAsteroid() {
337
+ // Icosahedron base, one subdivision -> 42 verts / 80 triangles
338
+ const T = (1 + Math.sqrt(5)) / 2;
339
+ let verts = [
340
+ [-1, T, 0], [1, T, 0], [-1, -T, 0], [1, -T, 0],
341
+ [0, -1, T], [0, 1, T], [0, -1, -T], [0, 1, -T],
342
+ [T, 0, -1], [T, 0, 1], [-T, 0, -1], [-T, 0, 1]
343
+ ];
344
+ const il = Math.hypot(1, T);
345
+ verts = verts.map(p => [p[0] / il, p[1] / il, p[2] / il]);
346
+ const F = [
347
+ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
348
+ [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
349
+ [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
350
+ [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1]
351
+ ];
352
+ let faces = F.map(f => f.slice());
353
+ const midCache = {};
354
+ function midpoint(a, b) {
355
+ const key = a < b ? a + '_' + b : b + '_' + a;
356
+ const hit = midCache[key];
357
+ if (hit !== undefined) return hit;
358
+ const pa = verts[a], pb = verts[b];
359
+ let m = [(pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) / 2];
360
+ const l = Math.hypot(m[0], m[1], m[2]) || 1;
361
+ m = [m[0] / l, m[1] / l, m[2] / l];
362
+ verts.push(m);
363
+ midCache[key] = verts.length - 1;
364
+ return midCache[key];
365
+ }
366
+ const nextF = [];
367
+ for (const f of faces) {
368
+ const ab = midpoint(f[0], f[1]);
369
+ const bc = midpoint(f[1], f[2]);
370
+ const ca = midpoint(f[2], f[0]);
371
+ nextF.push([f[0], ab, ca], [ab, f[1], bc], [ca, bc, f[2]], [ab, bc, ca]);
372
+ }
373
+ faces = nextF;
374
+ // random tumble of the whole shape
375
+ const rz = Math.random() * Math.PI * 2, ry = Math.random() * Math.PI * 2;
376
+ const rx = (Math.random() - 0.5) * 1.1;
377
+ verts = verts.map(p => {
378
+ let x = p[0], y = p[1], z = p[2];
379
+ let t = x * Math.cos(rz) - y * Math.sin(rz);
380
+ y = x * Math.sin(rz) + y * Math.cos(rz); x = t;
381
+ t = y * Math.cos(rx) - z * Math.sin(rx);
382
+ z = y * Math.sin(rx) + z * Math.cos(rx); y = t;
383
+ t = x * Math.cos(ry) + z * Math.sin(ry);
384
+ z = -x * Math.sin(ry) + z * Math.cos(ry); x = t;
385
+ return [x, y, z];
386
+ });
387
+ // lumpy regolith displacement
388
+ const nz = makeNoise(3000 + ((Math.random() * 9999) | 0));
389
+ verts = verts.map(p => {
390
+ const k = 1 + 0.16 * (nz.fbm(p[0] * 2.3 + 5, p[1] * 2.3 + 9) - 0.5)
391
+ + 0.10 * (nz.fbm(p[1] * 5.1 + 3, p[2] * 5.1 + 7) - 0.5);
392
+ return [p[0] * k, p[1] * k, p[2] * k];
393
+ });
394
+ // mottled per-vertex tint
395
+ const seed = Math.random() * 97;
396
+ const vcol = verts.map(p => {
397
+ const g = 0.50 + (nz.fbm(p[0] * 3.7 + seed, p[2] * 3.7 + seed) - 0.5) * 0.42;
398
+ return [g, g, g * 1.05];
399
+ });
400
+ // craters: sink a random vertex, raise its neighbours into a rim
401
+ const nv = verts.length;
402
+ const craters = 8 + ((Math.random() * 7) | 0);
403
+ for (let k = 0; k < craters; k++) {
404
+ const vi = (Math.random() * nv) | 0;
405
+ const dir = verts[vi];
406
+ const l = Math.hypot(dir[0], dir[1], dir[2]) || 1;
407
+ const depth = 0.12 + Math.random() * 0.14;
408
+ verts[vi] = [dir[0] / l * (1 - depth), dir[1] / l * (1 - depth), dir[2] / l * (1 - depth)];
409
+ vcol[vi][0] -= 0.10; vcol[vi][1] -= 0.10; vcol[vi][2] -= 0.08;
410
+ const seen = {};
411
+ for (const f of faces) {
412
+ if (f[0] !== vi && f[1] !== vi && f[2] !== vi) continue;
413
+ for (const r of [f[0], f[1], f[2]]) {
414
+ if (r === vi || seen[r]) continue;
415
+ seen[r] = 1;
416
+ const rr = verts[r];
417
+ const rl = Math.hypot(rr[0], rr[1], rr[2]) || 1;
418
+ const lift = 1 + depth * 0.8;
419
+ verts[r] = [rr[0] / rl * lift, rr[1] / rl * lift, rr[2] / rl * lift];
420
+ vcol[r][0] += 0.14; vcol[r][1] += 0.14; vcol[r][2] += 0.11;
421
+ }
422
+ }
423
+ }
424
+ // per-face base colour (crater floors dark, rims bright)
425
+ const faceCol = faces.map(f => {
426
+ let r = 0, g = 0, b = 0;
427
+ for (const vi of f) { r += vcol[vi][0]; g += vcol[vi][1]; b += vcol[vi][2]; }
428
+ r /= 3; g /= 3; b /= 3;
429
+ const cc = v => Math.max(0, Math.min(1, v)) * 255;
430
+ return [cc(r), cc(g), cc(b)];
431
+ });
432
+ const scale = 14 + Math.random() * 22;
433
+ return {
434
+ x: (Math.random() * 2 - 1) * FIELD_AST,
435
+ y: (Math.random() * 2 - 1) * FIELD_AST * 0.7,
436
+ z: (Math.random() * 2 - 1) * FIELD_AST,
437
+ verts, faces, scale, faceCol,
438
+ spin: [Math.random() * 0.8 - 0.4, Math.random() * 0.8 - 0.4, Math.random() * 0.8 - 0.4],
439
+ rot: [Math.random() * 6, Math.random() * 6, Math.random() * 6],
440
+ shade: 90 + Math.random() * 50
441
+ };
442
+ }
443
+ for (let i = 0; i < ASTEROID_COUNT; i++) asteroids.push(makeAsteroid());
444
+
445
+ // ------------------------------------------------------------------
446
+ // Deep-space scenery: a galactic band across the whole sky, filamentary
447
+ // strands of stars and gas, spiral galaxies, sunlit planets, meteor
448
+ // streaks, near-field dust and engine exhaust. All procedural, all
449
+ // wrapping or direction-based, so the flight never runs out of sky.
450
+ // ------------------------------------------------------------------
451
+ const SUN_DIR = norm([0.42, 0.58, -0.7]);
452
+
453
+ // Soft glow sprites — far cheaper than a gradient per particle.
454
+ function makeSoftDot(rgb, size) {
455
+ const cv = document.createElement('canvas');
456
+ cv.width = cv.height = size;
457
+ const c2 = cv.getContext('2d');
458
+ const g = c2.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
459
+ g.addColorStop(0, 'rgba(' + rgb + ',1)');
460
+ g.addColorStop(0.22, 'rgba(' + rgb + ',0.5)');
461
+ g.addColorStop(0.55, 'rgba(' + rgb + ',0.13)');
462
+ g.addColorStop(1, 'rgba(' + rgb + ',0)');
463
+ c2.fillStyle = g;
464
+ c2.fillRect(0, 0, size, size);
465
+ return cv;
466
+ }
467
+ const softCool = makeSoftDot('190,230,255', 64);
468
+ const softWarm = makeSoftDot('255,226,190', 64);
469
+
470
+ // ---- galactic band ------------------------------------------------
471
+ // Direction-only: no wrap and no parallax, so it behaves like real
472
+ // infinity and can never pop into view.
473
+ const SKY_R = 13000;
474
+ const GAL_N = norm([0.45, 0.72, 0.53]); // galactic plane normal
475
+ const GAL_U = norm(cross(GAL_N, [0, 0, 1]));
476
+ const GAL_V = norm(cross(GAL_N, GAL_U));
477
+ const skyNoise = makeNoise(4242);
478
+
479
+ function bandDir(a, off) {
480
+ const ca = Math.cos(a), sa = Math.sin(a);
481
+ return norm([
482
+ GAL_U[0] * ca + GAL_V[0] * sa + GAL_N[0] * off,
483
+ GAL_U[1] * ca + GAL_V[1] * sa + GAL_N[1] * off,
484
+ GAL_U[2] * ca + GAL_V[2] * sa + GAL_N[2] * off
485
+ ]);
486
+ }
487
+
488
+ const bandBlobs = [];
489
+ const bandStars = [];
490
+ {
491
+ const N = 420;
492
+ for (let i = 0; i < N; i++) {
493
+ const a = i / N * TAU, ca = Math.cos(a), sa = Math.sin(a);
494
+ const b = skyNoise.fbm(ca * 2.4 + 11, sa * 2.4 + 5, 4);
495
+ const dust = skyNoise.fbm(ca * 5.5 + 47, sa * 5.5 + 19, 3);
496
+ const off = (skyNoise.noise(ca * 4.3, sa * 4.3) - 0.5) * 0.26;
497
+ // fbm brightness minus dark dust lanes where the noise dips
498
+ const bright = Math.max(0, b * 1.5 - (dust < 0.44 ? (0.44 - dust) * 2.8 : 0));
499
+ if (bright < 0.04) continue;
500
+ bandBlobs.push({
501
+ dir: bandDir(a, off),
502
+ r: 1500 + b * 3400,
503
+ a: Math.min(0.17, bright * 0.13),
504
+ tex: b > 0.58 ? softWarm : softCool
505
+ });
506
+ }
507
+ for (let i = 0; i < 2600; i++) {
508
+ const a = Math.random() * TAU;
509
+ const g = (Math.random() + Math.random() + Math.random() - 1.5) * 0.3; // hug the plane
510
+ bandStars.push({
511
+ dir: bandDir(a, g),
512
+ s: Math.random() < 0.1 ? 2 : 1,
513
+ b: (Math.random() * 3) | 0,
514
+ c: (Math.random() * 3) | 0
515
+ });
516
+ }
517
+ }
518
+
519
+ // ---- galactic filaments (cosmic web strands) ----------------------
520
+ const FIELD_FIL = FIELD * 4;
521
+ const FIL_COUNT = 26;
522
+ const filPalette = [
523
+ [120, 170, 255], [160, 200, 255], [140, 235, 220], [255, 200, 160], [200, 160, 255]
524
+ ];
525
+
526
+ function catmullRom(points, per) {
527
+ const out = [], n = points.length;
528
+ for (let i = 0; i < n - 1; i++) {
529
+ const p0 = points[Math.max(0, i - 1)], p1 = points[i];
530
+ const p2 = points[i + 1], p3 = points[Math.min(n - 1, i + 2)];
531
+ for (let s = 0; s < per; s++) {
532
+ const t = s / per, t2 = t * t, t3 = t2 * t;
533
+ out.push([
534
+ 0.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
535
+ 0.5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
536
+ 0.5 * (2 * p1[2] + (-p0[2] + p2[2]) * t + (2 * p0[2] - 5 * p1[2] + 4 * p2[2] - p3[2]) * t2 + (-p0[2] + 3 * p1[2] - 3 * p2[2] + p3[2]) * t3)
537
+ ]);
538
+ }
539
+ }
540
+ out.push(points[n - 1].slice());
541
+ return out;
542
+ }
543
+
544
+ function makeFilament() {
545
+ const seed = (Math.random() * 9999) | 0;
546
+ const nz = makeNoise(seed);
547
+ const n = 5 + ((Math.random() * 5) | 0);
548
+ let dir = norm([Math.random() * 2 - 1, (Math.random() * 2 - 1) * 0.55, Math.random() * 2 - 1]);
549
+ let x = (Math.random() * 2 - 1) * FIELD_FIL * 0.8;
550
+ let y = (Math.random() * 2 - 1) * FIELD_FIL * 0.5;
551
+ let z = (Math.random() * 2 - 1) * FIELD_FIL * 0.8;
552
+ const step = 500 + Math.random() * 600;
553
+ const nodes = [];
554
+ for (let i = 0; i < n; i++) {
555
+ nodes.push([x, y, z]);
556
+ const w1 = nz.noise(i * 1.7, seed * 0.01) - 0.5;
557
+ const w2 = nz.noise(seed * 0.02, i * 2.3) - 0.5;
558
+ dir = norm([dir[0] + w1 * 0.9, dir[1] + w2 * 0.5, dir[2] + w1 * 0.4]);
559
+ x += dir[0] * step; y += dir[1] * step; z += dir[2] * step;
560
+ }
561
+ // faint star clusters gathered at the knots, in local offsets
562
+ const knots = [];
563
+ for (let i = 0; i < n; i++) {
564
+ const k = [];
565
+ const m = 9 + ((Math.random() * 10) | 0);
566
+ for (let j = 0; j < m; j++) {
567
+ k.push([(Math.random() * 2 - 1) * 150, (Math.random() * 2 - 1) * 150, (Math.random() * 2 - 1) * 150]);
568
+ }
569
+ knots.push(k);
570
+ }
571
+ return {
572
+ nodes, knots,
573
+ pts: catmullRom(nodes, 7),
574
+ ci: (Math.random() * filPalette.length) | 0,
575
+ w: 22 + Math.random() * 46,
576
+ glow: 0.45 + Math.random() * 0.55,
577
+ off: [0, 0, 0]
578
+ };
579
+ }
580
+ const filaments = [];
581
+ for (let i = 0; i < FIL_COUNT; i++) filaments.push(makeFilament());
582
+
583
+ // Filaments live far away: they fade out before they reach the camera and
584
+ // before a wrap can land them, so they never pop and never straddle you.
585
+ function filFade(d) {
586
+ if (d < 700 || d > FIELD_FIL) return 0;
587
+ return smoothstep(Math.min(1, (d - 700) / 1300)) * smoothstep(Math.min(1, (FIELD_FIL - d) / 2600));
588
+ }
589
+
590
+ // ---- spiral galaxies ----------------------------------------------
591
+ const FIELD_GAL = FIELD * 2.4;
592
+ const GALAXY_COUNT = 12;
593
+ const galPalette = [[150, 190, 255], [255, 200, 170], [170, 240, 225], [215, 175, 255]];
594
+
595
+ function makeGalaxyTexture(seed, col) {
596
+ const S = 160, R = S / 2;
597
+ const cv = document.createElement('canvas');
598
+ cv.width = cv.height = S;
599
+ const c2 = cv.getContext('2d');
600
+ const nz = makeNoise(seed);
601
+ const cs = col[0] + ',' + col[1] + ',' + col[2];
602
+ c2.globalCompositeOperation = 'lighter';
603
+ const g0 = c2.createRadialGradient(R, R, 0, R, R, R);
604
+ g0.addColorStop(0, 'rgba(255,248,235,0.7)');
605
+ g0.addColorStop(0.16, 'rgba(' + cs + ',0.32)');
606
+ g0.addColorStop(0.52, 'rgba(' + cs + ',0.1)');
607
+ g0.addColorStop(1, 'rgba(' + cs + ',0)');
608
+ c2.fillStyle = g0;
609
+ c2.fillRect(0, 0, S, S);
610
+ const arms = 2 + (seed % 2);
611
+ const twist = 2.6 + nz.noise(seed, 3) * 1.9;
612
+ for (let arm = 0; arm < arms; arm++) {
613
+ const base = arm * TAU / arms + nz.noise(arm * 2.1, seed) * 1.2;
614
+ const steps = 110;
615
+ for (let i = 1; i < steps; i++) {
616
+ const t = i / steps;
617
+ const rr = 8 + t * R * 0.92;
618
+ const th = base + t * twist + (nz.noise(i * 0.19, arm * 3.7) - 0.5) * 0.7;
619
+ const jit = (nz.noise(i * 0.63, arm * 1.9) - 0.5) * 14 * t;
620
+ const x = R + Math.cos(th) * (rr + jit);
621
+ const y = R + Math.sin(th) * (rr + jit) * 0.58; // tilted disc
622
+ const rad = 4 + t * 9;
623
+ const a = (1 - t * 0.6) * 0.17;
624
+ const gg = c2.createRadialGradient(x, y, 0, x, y, rad);
625
+ gg.addColorStop(0, 'rgba(' + cs + ',' + a.toFixed(3) + ')');
626
+ gg.addColorStop(1, 'rgba(' + cs + ',0)');
627
+ c2.fillStyle = gg;
628
+ c2.beginPath(); c2.arc(x, y, rad, 0, TAU); c2.fill();
629
+ if (nz.noise(i * 1.31, arm * 7.7) > 0.86) { // young blue star knots
630
+ c2.fillStyle = 'rgba(200,230,255,' + (0.3 + nz.noise(i, arm) * 0.4).toFixed(2) + ')';
631
+ c2.beginPath(); c2.arc(x, y, 1.3, 0, TAU); c2.fill();
632
+ }
633
+ }
634
+ }
635
+ c2.globalCompositeOperation = 'destination-out';
636
+ for (let k = 0; k < 26; k++) { // dark dust lanes cutting the disc
637
+ const t = nz.noise(k * 2.7, seed);
638
+ const th = nz.noise(k * 1.1, seed * 0.3) * TAU;
639
+ const rr = 12 + t * R * 0.85;
640
+ const x = R + Math.cos(th) * rr, y = R + Math.sin(th) * rr * 0.58;
641
+ const rad = 9 + t * 12;
642
+ const gg = c2.createRadialGradient(x, y, 0, x, y, rad);
643
+ gg.addColorStop(0, 'rgba(0,0,0,0.75)');
644
+ gg.addColorStop(1, 'rgba(0,0,0,0)');
645
+ c2.fillStyle = gg;
646
+ c2.beginPath(); c2.arc(x, y, rad, 0, TAU); c2.fill();
647
+ }
648
+ c2.globalCompositeOperation = 'source-over';
649
+ return cv;
650
+ }
651
+ const galaxyTex = galPalette.map((c, i) => makeGalaxyTexture(511 + i * 53, c));
652
+
653
+ function makeGalaxy() {
654
+ return {
655
+ x: (Math.random() * 2 - 1) * FIELD_GAL,
656
+ y: (Math.random() * 2 - 1) * FIELD_GAL * 0.55,
657
+ z: (Math.random() * 2 - 1) * FIELD_GAL,
658
+ r: 380 + Math.random() * 720,
659
+ ci: (Math.random() * galaxyTex.length) | 0,
660
+ rot: Math.random() * TAU,
661
+ squash: 0.4 + Math.random() * 0.6
662
+ };
663
+ }
664
+ const galaxies = [];
665
+ for (let i = 0; i < GALAXY_COUNT; i++) galaxies.push(makeGalaxy());
666
+
667
+ // ---- sunlit planets -----------------------------------------------
668
+ const FIELD_PLANET = FIELD * 2.2;
669
+ const PLANET_COUNT = 8;
670
+ const planetPal = [
671
+ [[36, 64, 118], [58, 104, 146], [214, 228, 238]], // ocean
672
+ [[92, 66, 48], [132, 104, 72], [226, 216, 200]], // rocky
673
+ [[48, 82, 70], [108, 140, 96], [228, 236, 224]], // verdant
674
+ [[124, 86, 58], [176, 126, 84], [242, 222, 192]], // desert
675
+ [[40, 52, 82], [92, 112, 152], [222, 232, 246]] // ice
676
+ ];
677
+
678
+ function makePlanetTexture(seed) {
679
+ const S = 128;
680
+ const cv = document.createElement('canvas');
681
+ cv.width = cv.height = S;
682
+ const c2 = cv.getContext('2d');
683
+ const img = c2.createImageData(S, S), data = img.data;
684
+ const nz = makeNoise(seed);
685
+ const pal = planetPal[seed % planetPal.length];
686
+ for (let py = 0; py < S; py++) {
687
+ for (let px = 0; px < S; px++) {
688
+ const dx = (px - S / 2 + 0.5) / (S / 2), dy = (py - S / 2 + 0.5) / (S / 2);
689
+ const rr = dx * dx + dy * dy;
690
+ const i = (py * S + px) * 4;
691
+ if (rr >= 1) { data[i + 3] = 0; continue; }
692
+ data[i + 3] = 255;
693
+ const z = Math.sqrt(1 - rr);
694
+ const lon = Math.atan2(dy, dx) + seed * 0.37;
695
+ const lat = Math.asin(clamp(z, -1, 1)) - Math.PI / 2;
696
+ const cl = Math.cos(lat);
697
+ // seamless-in-longitude continent field
698
+ const c = nz.fbm(Math.cos(lon) * 2.1 + 7, Math.sin(lon) * 2.1 + 3 + lat * 2.6, 4);
699
+ const land = c > 0.52;
700
+ let base = land ? pal[1] : pal[0];
701
+ if (land) {
702
+ const h = Math.min(1, (c - 0.52) * 3.2);
703
+ base = [base[0] + (pal[2][0] - base[0]) * h * 0.45,
704
+ base[1] + (pal[2][1] - base[1]) * h * 0.45,
705
+ base[2] + (pal[2][2] - base[2]) * h * 0.45];
706
+ }
707
+ // ice caps
708
+ const ice = Math.abs(lat) > 1.18 + nz.noise(lon * 3, 9) * 0.3;
709
+ if (ice) {
710
+ const k = Math.min(1, (Math.abs(lat) - 1.1) * 3);
711
+ base = [base[0] + (240 - base[0]) * k, base[1] + (246 - base[1]) * k, base[2] + (252 - base[2]) * k];
712
+ }
713
+ // cloud banding
714
+ const cloud = nz.fbm(Math.cos(lon) * 3.3 + 21, Math.sin(lon) * 3.3 + 17 + lat * 3.4, 3);
715
+ const ca = clamp((cloud - 0.52) * 2.4, 0, 1) * 0.72;
716
+ data[i] = base[0] + (250 - base[0]) * ca;
717
+ data[i + 1] = base[1] + (252 - base[1]) * ca;
718
+ data[i + 2] = base[2] + (255 - base[2]) * ca;
719
+ // limb darkening so the sprite reads as a sphere even before shading
720
+ const limb = 0.72 + 0.28 * Math.pow(z, 0.45);
721
+ data[i] *= limb; data[i + 1] *= limb; data[i + 2] *= limb;
722
+ }
723
+ }
724
+ c2.putImageData(img, 0, 0);
725
+ return cv;
726
+ }
727
+ const planetTex = [];
728
+ for (let i = 0; i < 5; i++) planetTex.push(makePlanetTexture(101 + i * 37));
729
+
730
+ function makePlanet() {
731
+ const nrm = norm([Math.random() * 2 - 1, Math.random() * 1.2 + 0.4, Math.random() * 2 - 1]);
732
+ let A = cross(nrm, [0, 1, 0]);
733
+ if (len(A) < 1e-4) A = [1, 0, 0]; else A = norm(A);
734
+ const B = norm(cross(nrm, A));
735
+ const ringed = Math.random() < 0.4;
736
+ return {
737
+ x: (Math.random() * 2 - 1) * FIELD_PLANET * 0.9,
738
+ y: (Math.random() * 2 - 1) * FIELD_PLANET * 0.5,
739
+ z: (Math.random() * 2 - 1) * FIELD_PLANET * 0.9,
740
+ r: 240 + Math.random() * 620,
741
+ tex: (Math.random() * planetTex.length) | 0,
742
+ spin: Math.random() * TAU,
743
+ spinRate: (Math.random() - 0.5) * 0.06,
744
+ atmo: [[120, 190, 255], [255, 170, 120], [150, 240, 210], [205, 205, 255]][(Math.random() * 4) | 0],
745
+ ring: ringed,
746
+ ringA: A, ringB: B,
747
+ ringR: 1.7 + Math.random() * 0.9,
748
+ ringW: 26 + Math.random() * 40
749
+ };
750
+ }
751
+ const planets = [];
752
+ for (let i = 0; i < PLANET_COUNT; i++) planets.push(makePlanet());
753
+
754
+ // ---- meteors / shooting stars -------------------------------------
755
+ const meteors = [];
756
+ let nextMeteor = 0, exhaustTick = 0;
757
+ const exhaust = [];
758
+
759
+ function spawnMeteors(cam, now) {
760
+ if (now < nextMeteor) return;
761
+ nextMeteor = now + 500 + Math.random() * 2200;
762
+ const burst = Math.random() < 0.22 ? 2 + ((Math.random() * 3) | 0) : 1;
763
+ for (let i = 0; i < burst && meteors.length < 16; i++) {
764
+ // spawn in the forward hemisphere, off-axis
765
+ const d = norm([(Math.random() * 2 - 1) * 1.1, (Math.random() * 2 - 1) * 0.8, 0.45 + Math.random() * 0.55]);
766
+ const dist = 500 + Math.random() * 900;
767
+ // travel across the view so it reads as a streak
768
+ let lat = cross(d, [0, 1, 0]);
769
+ if (len(lat) < 1e-4) lat = [1, 0, 0]; else lat = norm(lat);
770
+ lat = rotateAbout(lat, d, Math.random() * TAU);
771
+ const speed = 700 + Math.random() * 1500;
772
+ const fireball = Math.random() < 0.14;
773
+ const vx = lat[0] * speed + d[0] * (Math.random() * 220 - 80);
774
+ const vy = lat[1] * speed + d[1] * (Math.random() * 220 - 80);
775
+ const vz = lat[2] * speed + d[2] * (Math.random() * 220 - 80);
776
+ meteors.push({
777
+ x: cam.camPos[0] + d[0] * dist + (Math.random() - 0.5) * 300,
778
+ y: cam.camPos[1] + d[1] * dist + (Math.random() - 0.5) * 300,
779
+ z: cam.camPos[2] + d[2] * dist + (Math.random() - 0.5) * 300,
780
+ vx: vx, vy: vy, vz: vz,
781
+ life: 0, max: 0.9 + Math.random() * 1.2,
782
+ trail: (fireball ? 0.22 : 0.12) + Math.random() * 0.06,
783
+ w: fireball ? 3.2 + Math.random() * 2 : 1.2 + Math.random() * 1.4,
784
+ warm: fireball || Math.random() < 0.3,
785
+ fireball: fireball
786
+ });
787
+ }
788
+ }
789
+
790
+ // ---- near-field dust motes (speed cue) ----------------------------
791
+ const DUST_COUNT = 460, DUST_R = 620;
792
+ const dust = [];
793
+ for (let i = 0; i < DUST_COUNT; i++) {
794
+ dust.push({
795
+ x: (Math.random() * 2 - 1) * DUST_R,
796
+ y: (Math.random() * 2 - 1) * DUST_R,
797
+ z: (Math.random() * 2 - 1) * DUST_R
798
+ });
799
+ }
800
+
801
+ // ------------------------------------------------------------------
802
+ // Camera: chase cam behind the ship with a FIXED WORLD-UP view.
803
+ // The camera's up vector is always (0,1,0), so the horizon stays level
804
+ // and the view never rolls or flips no matter how the ship yaws or
805
+ // pitches. Position and look-target are exponentially smoothed (no
806
+ // overshoot, no drift) so turns feel fluid.
807
+ // ------------------------------------------------------------------
808
+ const CAM_BACK = 110; // distance behind the ship
809
+ const CAM_UP = 55; // height above the ship
810
+ const CAM_FOLLOW = 7; // smoothing rate (1/s) — softer chase
811
+ const CAM_LOOKAHEAD = 26; // camera aims along the nose, ship leads the view
812
+ let camState = null;
813
+
814
+ function cameraBasis(dt) {
815
+ const fwd = ship.fwd;
816
+ const desiredPos = [
817
+ ship.pos[0] - fwd[0] * CAM_BACK,
818
+ ship.pos[1] - fwd[1] * CAM_BACK + CAM_UP,
819
+ ship.pos[2] - fwd[2] * CAM_BACK
820
+ ];
821
+ const desiredTgt = [
822
+ ship.pos[0] + fwd[0] * CAM_LOOKAHEAD,
823
+ ship.pos[1] + fwd[1] * CAM_LOOKAHEAD + 8,
824
+ ship.pos[2] + fwd[2] * CAM_LOOKAHEAD
825
+ ];
826
+ if (!camState) camState = { pos: desiredPos.slice(), tgt: desiredTgt.slice() };
827
+ const k = 1 - Math.exp(-CAM_FOLLOW * dt);
828
+ for (let i = 0; i < 3; i++) {
829
+ camState.pos[i] += (desiredPos[i] - camState.pos[i]) * k;
830
+ camState.tgt[i] += (desiredTgt[i] - camState.tgt[i]) * k;
831
+ }
832
+ const forward = norm(sub(camState.tgt, camState.pos));
833
+ let xAxis = cross(forward, [0, 1, 0]); // screen right
834
+ if (len(xAxis) < 1e-6) xAxis = [1, 0, 0]; else xAxis = norm(xAxis);
835
+ const yAxis = cross(xAxis, forward); // screen up (always world-level)
836
+ lastCam = { camPos: camState.pos, xAxis, yAxis, zAxis: forward };
837
+ return lastCam;
838
+ }
839
+
840
+ // Transform a world point into camera space and project. Returns null if behind.
841
+ function project(px, py, pz, cam) {
842
+ const dx = px - cam.camPos[0];
843
+ const dy = py - cam.camPos[1];
844
+ const dz = pz - cam.camPos[2];
845
+ const cx = dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2];
846
+ const cy = dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2];
847
+ const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2];
848
+ if (cz <= 0.5) return null;
849
+ return {
850
+ x: CX + (cx / cz) * FOCAL,
851
+ y: CY - (cy / cz) * FOCAL,
852
+ z: cz
853
+ };
854
+ }
855
+
856
+ // ------------------------------------------------------------------
857
+ // Ship geometry (local space, nose toward -Z, scale factor 15)
858
+ // ------------------------------------------------------------------
859
+ const SHIP_SCALE = 15;
860
+ const shipVerts = [
861
+ [0, 0, -1.4], // 0 nose tip
862
+ [-0.5, 0.15, -0.4], // 1 cockpit ring left
863
+ [0.5, 0.15, -0.4], // 2 cockpit ring right
864
+ [0, 0.42, -0.4], // 3 cockpit ring top
865
+ [-0.62, 0, 0.6], // 4 mid fuselage left
866
+ [0.62, 0, 0.6], // 5 mid fuselage right
867
+ [0, 0.5, 0.6], // 6 mid fuselage top
868
+ [-0.5, 0, 1.3], // 7 rear left
869
+ [0.5, 0, 1.3], // 8 rear right
870
+ [0, 0.4, 1.3], // 9 rear top
871
+ [-2.1, 0.05, 0.9], // 10 left wing tip (swept back)
872
+ [2.1, 0.05, 0.9], // 11 right wing tip (swept back)
873
+ [-0.62, 0, 1.25], // 12 left wing root rear
874
+ [0.62, 0, 1.25], // 13 right wing root rear
875
+ [0, 1.15, 1.55], // 14 tail fin tip
876
+ [-0.3, 0.35, 1.25], // 15 tail fin base left
877
+ [0.3, 0.35, 1.25], // 16 tail fin base right
878
+ [-1.0, 0.55, 1.6], // 17 left side fin tip
879
+ [1.0, 0.55, 1.6] // 18 right side fin tip
880
+ ];
881
+
882
+ // [a,b,c] index triples, plus a color key: h=hull, w=wing, g=glass, e=engine
883
+ const shipFaces = [
884
+ // nose cone
885
+ [0, 1, 2, 'h'],
886
+ [0, 2, 3, 'h'],
887
+ [0, 3, 1, 'h'],
888
+ // glowing cockpit window
889
+ [1, 3, 2, 'g'],
890
+ // upper fuselage
891
+ [1, 3, 6, 'h'],
892
+ [1, 6, 4, 'h'],
893
+ [2, 5, 6, 'h'],
894
+ [2, 6, 3, 'h'],
895
+ // lower fuselage
896
+ [4, 6, 5, 'h'],
897
+ [4, 5, 8, 'h'],
898
+ [4, 8, 7, 'h'],
899
+ // rear engine block
900
+ [7, 8, 9, 'e'],
901
+ [7, 9, 5, 'h'],
902
+ [8, 5, 9, 'h'],
903
+ // left swept wing
904
+ [4, 10, 12, 'w'],
905
+ [4, 12, 7, 'w'],
906
+ [6, 4, 10, 'w'],
907
+ // right swept wing
908
+ [5, 11, 13, 'w'],
909
+ [5, 13, 8, 'w'],
910
+ [6, 5, 11, 'w'],
911
+ // tail fin
912
+ [15, 16, 14, 'h'],
913
+ [9, 14, 15, 'h'],
914
+ [9, 16, 14, 'h'],
915
+ // side tail fins
916
+ [7, 17, 12, 'w'],
917
+ [8, 13, 18, 'w']
918
+ ];
919
+
920
+ const shipColors = {
921
+ h: [74, 80, 96], // dark gray hull
922
+ w: [104, 112, 132], // lighter wing
923
+ g: [130, 226, 255], // glowing blue cockpit
924
+ e: [40, 44, 58] // engine block
925
+ };
926
+
927
+ // ------------------------------------------------------------------
928
+ // Update
929
+ // ------------------------------------------------------------------
930
+ let distance = 0;
931
+ let last = performance.now();
932
+
933
+ function update(dt) {
934
+ // --- steering: one smooth arc, eased in and eased out -------------
935
+ let errTrue = 0;
936
+ if (hasTarget) {
937
+ errTrue = Math.acos(clamp(dot3(ship.fwd, targetFwd), -1, 1));
938
+
939
+ // Route: take the direct great circle, unless that arc would pitch the
940
+ // nose past its authority — then level out toward the target's bearing
941
+ // first and change pitch afterwards. That is what a real ship does, and
942
+ // it is what stops a 180-degree reversal from stalling against the
943
+ // pitch limit.
944
+ let tgt = targetFwd;
945
+ const a0 = cross(ship.fwd, targetFwd);
946
+ const a0l = len(a0);
947
+ if (a0l > 1e-4) {
948
+ const nx = a0[0] / a0l, ny = a0[1] / a0l, nz = a0[2] / a0l;
949
+ const err0 = Math.acos(clamp(dot3(ship.fwd, targetFwd), -1, 1));
950
+ // Along the arc, nose.y(t) = nose.y*cos t + cy*sin t where
951
+ // cy = (axis x nose).y. Its peak inside the swept range is what
952
+ // matters — not the peak of the whole great circle.
953
+ const cy = nz * ship.fwd[0] - nx * ship.fwd[2];
954
+ const fy = ship.fwd[1];
955
+ let hi = Math.max(fy, fy * Math.cos(err0) + cy * Math.sin(err0));
956
+ let lo = Math.min(fy, fy * Math.cos(err0) + cy * Math.sin(err0));
957
+ const tp = Math.atan2(cy, fy);
958
+ if (tp > 0 && tp < err0) hi = Math.max(hi, Math.hypot(fy, cy));
959
+ const tn = tp - Math.PI;
960
+ if (tn > 0 && tn < err0) lo = Math.min(lo, -Math.hypot(fy, cy));
961
+ if ((hi > PITCH_SIN || lo < -PITCH_SIN) && Math.hypot(targetFwd[0], targetFwd[2]) > 1e-3) {
962
+ const hh = Math.hypot(targetFwd[0], targetFwd[2]);
963
+ tgt = [targetFwd[0] / hh, 0, targetFwd[2] / hh];
964
+ }
965
+ }
966
+
967
+ const err = Math.acos(clamp(dot3(ship.fwd, tgt), -1, 1));
968
+ if (err > 1e-4) {
969
+ if (err > Math.PI - 0.02) {
970
+ // pointing exactly backwards: the cross product is undefined, so
971
+ // pick the natural rotation — yaw level about world up
972
+ const ax = cross(ship.fwd, [0, 1, 0]);
973
+ if (len(ax) > 1e-4) { turnAxis[0] = ax[0] / len(ax); turnAxis[1] = ax[1] / len(ax); turnAxis[2] = ax[2] / len(ax); }
974
+ else { turnAxis[0] = 1; turnAxis[1] = 0; turnAxis[2] = 0; }
975
+ } else if (err > 0.05) {
976
+ // recompute while the arc is well defined; below that the cross
977
+ // product of two near-parallel vectors is noise, and re-deriving it
978
+ // per frame makes the nose jitter. Remembering the axis instead lets
979
+ // the turn settle dead straight — the stored axis is perpendicular to
980
+ // both directions, so rotating by the remaining error lands exactly
981
+ // on the target.
982
+ const ax = cross(ship.fwd, tgt);
983
+ const al = len(ax) || 1;
984
+ turnAxis[0] = ax[0] / al; turnAxis[1] = ax[1] / al; turnAxis[2] = ax[2] / al;
985
+ }
986
+ const axis = turnAxis;
987
+ const cur = len(ship.omega);
988
+ const want = Math.min(TURN_MAX, err * TURN_GAIN);
989
+ const rate = Math.max(0, cur + clamp(want - cur, -TURN_SLEW * dt, TURN_SLEW * dt));
990
+ const step = Math.min(rate * dt, err); // never swings past the target
991
+ ship.fwd = rotateAbout(ship.fwd, axis, step);
992
+ ship.omega[0] = axis[0] * rate;
993
+ ship.omega[1] = axis[1] * rate;
994
+ ship.omega[2] = axis[2] * rate;
995
+ } else {
996
+ ship.omega[0] = ship.omega[1] = ship.omega[2] = 0;
997
+ }
998
+ // safety net: keep the nose off vertical so the level camera stays conditioned
999
+ if (Math.abs(ship.fwd[1]) > PITCH_SIN) {
1000
+ const y = clamp(ship.fwd[1], -PITCH_SIN, PITCH_SIN);
1001
+ const hh = Math.hypot(ship.fwd[0], ship.fwd[2]) || 1e-6;
1002
+ const k = Math.sqrt(Math.max(0, 1 - y * y)) / hh;
1003
+ ship.fwd[0] *= k; ship.fwd[1] = y; ship.fwd[2] *= k;
1004
+ }
1005
+ }
1006
+
1007
+ // heading / pitch are just the nose vector read back out
1008
+ ship.pitch = Math.asin(clamp(ship.fwd[1], -1, 1));
1009
+ ship.heading = Math.atan2(ship.fwd[0], -ship.fwd[2]);
1010
+
1011
+ // Bank into the turn (visual roll only — the camera itself stays level).
1012
+ // Heading grows when the ship rotates about -Y, so the yaw rate is -omega[1].
1013
+ const targetBank = clamp(-ship.omega[1] * 0.95, -0.62, 0.62);
1014
+ ship.bank += (targetBank - ship.bank) * Math.min(1, dt * 2.6);
1015
+
1016
+ // Throttle eases up once the nose is on course and stays low while the
1017
+ // ship is still swinging through the turn, so the burn matches the aim.
1018
+ const wantThrottle = hasTarget ? 0.35 + 0.65 * (1 - Math.min(1, errTrue / 1.15)) : 0;
1019
+ ship.throttle += (wantThrottle - ship.throttle) * Math.min(1, dt * THROTTLE_RATE);
1020
+
1021
+ // Thrust along the nose direction
1022
+ const fwd = ship.fwd;
1023
+ if (ship.throttle > 0.001) {
1024
+ const a = ACCEL * ship.throttle * dt;
1025
+ ship.vel[0] += fwd[0] * a;
1026
+ ship.vel[1] += fwd[1] * a;
1027
+ ship.vel[2] += fwd[2] * a;
1028
+ }
1029
+
1030
+ // integrate velocity with drag
1031
+ const dragF = Math.exp(-DRAG * dt);
1032
+ ship.vel[0] *= dragF;
1033
+ ship.vel[1] *= dragF;
1034
+ ship.vel[2] *= dragF;
1035
+
1036
+ // clamp speed
1037
+ const sp = len(ship.vel);
1038
+ if (sp > MAX_SPEED) {
1039
+ const k = MAX_SPEED / sp;
1040
+ ship.vel[0] *= k; ship.vel[1] *= k; ship.vel[2] *= k;
1041
+ }
1042
+
1043
+ // integrate position
1044
+ ship.pos[0] += ship.vel[0] * dt;
1045
+ ship.pos[1] += ship.vel[1] * dt;
1046
+ ship.pos[2] += ship.vel[2] * dt;
1047
+ distance += sp * dt;
1048
+
1049
+ // Spin asteroids, planets
1050
+ for (const a of asteroids) {
1051
+ a.rot[0] += a.spin[0] * dt;
1052
+ a.rot[1] += a.spin[1] * dt;
1053
+ a.rot[2] += a.spin[2] * dt;
1054
+ }
1055
+ for (const pl of planets) pl.spin += pl.spinRate * dt;
1056
+
1057
+ // Engine exhaust: soft particles shed from the nozzles, drifting back
1058
+ if (ship.throttle > 0.12) {
1059
+ for (let i = 0; i < 2; i++) {
1060
+ const e = ENGINE_LOCAL[exhaustTick++ & 1];
1061
+ const wp = shipWorldPoint(e);
1062
+ const back = 150 + Math.random() * 190;
1063
+ exhaust.push({
1064
+ x: wp[0], y: wp[1], z: wp[2],
1065
+ vx: ship.vel[0] * 0.4 - fwd[0] * back + (Math.random() - 0.5) * 45,
1066
+ vy: ship.vel[1] * 0.4 - fwd[1] * back + (Math.random() - 0.5) * 45,
1067
+ vz: ship.vel[2] * 0.4 - fwd[2] * back + (Math.random() - 0.5) * 45,
1068
+ life: 0, max: 0.5 + Math.random() * 0.5, r: 0.6 + Math.random() * 1.1
1069
+ });
1070
+ }
1071
+ if (exhaust.length > 240) exhaust.splice(0, exhaust.length - 240);
1072
+ }
1073
+ for (let i = exhaust.length - 1; i >= 0; i--) {
1074
+ const p = exhaust[i];
1075
+ p.life += dt;
1076
+ if (p.life >= p.max) { exhaust.splice(i, 1); continue; }
1077
+ p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
1078
+ p.vx *= 0.985; p.vy *= 0.985; p.vz *= 0.985;
1079
+ }
1080
+
1081
+ // Meteors
1082
+ for (let i = meteors.length - 1; i >= 0; i--) {
1083
+ const m = meteors[i];
1084
+ m.life += dt;
1085
+ if (m.life >= m.max) { meteors.splice(i, 1); continue; }
1086
+ m.x += m.vx * dt; m.y += m.vy * dt; m.z += m.vz * dt;
1087
+ }
1088
+ }
1089
+
1090
+ // ------------------------------------------------------------------
1091
+ // Rendering
1092
+ // ------------------------------------------------------------------
1093
+ function drawBackground() {
1094
+ if (bgGradient) {
1095
+ ctx.fillStyle = bgGradient;
1096
+ } else {
1097
+ ctx.fillStyle = '#050510';
1098
+ }
1099
+ ctx.fillRect(0, 0, W, H);
1100
+ }
1101
+
1102
+ function drawStars(cam) {
1103
+ // Batch stars into 4 color paths for fast rendering
1104
+ const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D()];
1105
+ const sx = ship.pos[0], sy = ship.pos[1], sz = ship.pos[2];
1106
+ const cp = cam.camPos;
1107
+ for (let i = 0; i < stars.length; i++) {
1108
+ const st = stars[i];
1109
+ // infinite space: wrap around the ship so stars respawn ahead
1110
+ if (st.x < sx - FIELD) st.x += 2 * FIELD; else if (st.x > sx + FIELD) st.x -= 2 * FIELD;
1111
+ if (st.y < sy - FIELD) st.y += 2 * FIELD; else if (st.y > sy + FIELD) st.y -= 2 * FIELD;
1112
+ if (st.z < sz - FIELD) st.z += 2 * FIELD; else if (st.z > sz + FIELD) st.z -= 2 * FIELD;
1113
+ const dx = st.x - cp[0], dy = st.y - cp[1], dz = st.z - cp[2];
1114
+ const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2];
1115
+ if (cz <= 0.5) continue;
1116
+ const cx = dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2];
1117
+ const cy = dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2];
1118
+ const px = CX + (cx / cz) * FOCAL;
1119
+ if (px < -4 || px > W + 4) continue;
1120
+ const py = CY - (cy / cz) * FOCAL;
1121
+ if (py < -4 || py > H + 4) continue;
1122
+ paths[st.ci].rect(px, py, st.s, st.s);
1123
+ }
1124
+ for (let i = 0; i < 4; i++) {
1125
+ ctx.fillStyle = starStyle[i];
1126
+ ctx.fill(paths[i]);
1127
+ }
1128
+ }
1129
+
1130
+ function drawNebulae(cam) {
1131
+ // Nebulae wrap in a box of +/-FIELD_NEB (2x the ship field) around the
1132
+ // ship: the furthest one can ever be is the box corner (NEB_MAX, ~9006u).
1133
+ // A wrap teleports a nebula to somewhere between FIELD_NEB (one axis) and
1134
+ // NEB_MAX (a corner) away, so alpha is a pure function of distance:
1135
+ // exactly 0 everywhere a wrap can land, ramping to full at a FIELD in.
1136
+ const NEB_MAX = FIELD_NEB * Math.sqrt(3); // view/cull distance, 2x the old one
1137
+ // painter's: far first
1138
+ const list = [];
1139
+ for (const n of nebulae) {
1140
+ // infinite space: wrap around the ship
1141
+ if (n.x < ship.pos[0] - FIELD_NEB) n.x += 2 * FIELD_NEB; else if (n.x > ship.pos[0] + FIELD_NEB) n.x -= 2 * FIELD_NEB;
1142
+ if (n.y < ship.pos[1] - FIELD_NEB) n.y += 2 * FIELD_NEB; else if (n.y > ship.pos[1] + FIELD_NEB) n.y -= 2 * FIELD_NEB;
1143
+ if (n.z < ship.pos[2] - FIELD_NEB) n.z += 2 * FIELD_NEB; else if (n.z > ship.pos[2] + FIELD_NEB) n.z -= 2 * FIELD_NEB;
1144
+ const dz = n.z - cam.camPos[2];
1145
+ const dx = n.x - cam.camPos[0];
1146
+ const dy = n.y - cam.camPos[1];
1147
+ const dist = Math.hypot(dx, dy, dz);
1148
+ if (dist > NEB_MAX) continue;
1149
+ const p = project(n.x, n.y, n.z, cam);
1150
+ if (!p) continue;
1151
+ list.push({ n, p, dist });
1152
+ }
1153
+ list.sort((a, b) => b.dist - a.dist);
1154
+ ctx.globalCompositeOperation = 'lighter';
1155
+ for (const { n, p, dist } of list) {
1156
+ const r = (n.r / p.z) * FOCAL;
1157
+ if (r < 1) continue;
1158
+ // fully transparent from FIELD_NEB (5200u) out to the horizon, full by
1159
+ // a FIELD (2600u) in — smoothstep so the ramp itself has no kinks
1160
+ const t = Math.max(0, Math.min(1, (FIELD_NEB - dist) / (FIELD_NEB - FIELD)));
1161
+ const fade = t * t * (3 - 2 * t);
1162
+ const alpha = 0.5 * fade;
1163
+ ctx.save();
1164
+ ctx.globalAlpha = alpha;
1165
+ ctx.translate(p.x, p.y);
1166
+ ctx.rotate(n.rot);
1167
+ ctx.drawImage(nebulaTex[n.ci], -r, -r, r * 2, r * 2);
1168
+ ctx.restore();
1169
+ }
1170
+ ctx.globalCompositeOperation = 'source-over';
1171
+ ctx.globalAlpha = 1;
1172
+ }
1173
+
1174
+ // Rotate a local point into world space, then translate & scale.
1175
+ // Yaw+pitch are one rotation matrix whose columns are the ship's right,
1176
+ // up and back axes in world space. The local nose (-Z) therefore maps to
1177
+ // exactly (sin h * cos p, sin p, -cos h * cos p) — the same direction
1178
+ // update() thrusts along — at every heading and pitch.
1179
+ function shipWorldPoint(v) {
1180
+ const s = SHIP_SCALE;
1181
+ const x = v[0] * s, y = v[1] * s, z = v[2] * s;
1182
+ // bank (visual roll) in the body frame about local forward (local Z)
1183
+ const cb = Math.cos(ship.bank), sb = Math.sin(ship.bank);
1184
+ const x1 = x * cb + y * sb;
1185
+ const y1 = -x * sb + y * cb;
1186
+ const z1 = z;
1187
+ const ch = Math.cos(ship.heading), sh = Math.sin(ship.heading);
1188
+ const cp = Math.cos(ship.pitch), sp2 = Math.sin(ship.pitch);
1189
+ const wx = ch * x1 - sh * sp2 * y1 - sh * cp * z1;
1190
+ const wy = cp * y1 - sp2 * z1;
1191
+ const wz = sh * x1 + ch * sp2 * y1 + ch * cp * z1;
1192
+ return [ship.pos[0] + wx, ship.pos[1] + wy, ship.pos[2] + wz];
1193
+ }
1194
+
1195
+ function worldShipPoint(v, cam) {
1196
+ const w = shipWorldPoint(v);
1197
+ return project(w[0], w[1], w[2], cam);
1198
+ }
1199
+
1200
+ function drawShip(cam) {
1201
+ // Precompute projected verts
1202
+ const pv = new Array(shipVerts.length);
1203
+ for (let i = 0; i < shipVerts.length; i++) {
1204
+ pv[i] = worldShipPoint(shipVerts[i], cam);
1205
+ }
1206
+ // Build face list with depth + normal (skip if any vert behind)
1207
+ const faces = [];
1208
+ for (const f of shipFaces) {
1209
+ const a = pv[f[0]], b = pv[f[1]], c = pv[f[2]];
1210
+ if (!a || !b || !c) continue;
1211
+ const z = (a.z + b.z + c.z) / 3;
1212
+ // simple backface: screen-space cross product sign
1213
+ const ex = b.x - a.x, ey = b.y - a.y;
1214
+ const fx = c.x - a.x, fy = c.y - a.y;
1215
+ const crossSign = ex * fy - ey * fx;
1216
+ faces.push({ f, a, b, c, z, crossSign });
1217
+ }
1218
+ faces.sort((p, q) => q.z - p.z);
1219
+
1220
+ for (const fc of faces) {
1221
+ const key = fc.f[3];
1222
+ const base = shipColors[key];
1223
+ // face normal in camera-ish space via projected verts (approx)
1224
+ const nx = (fc.b.x - fc.a.x) * (fc.c.y - fc.a.y) - (fc.c.x - fc.a.x) * (fc.b.y - fc.a.y);
1225
+ let shade = 0.55 + 0.45 * Math.abs(nx) / (Math.max(1, Math.hypot(fc.b.x - fc.a.x, fc.b.y - fc.a.y)));
1226
+ shade = Math.min(1.2, shade);
1227
+
1228
+ let col = [base[0] * shade, base[1] * shade, base[2] * shade];
1229
+ if (key === 'g') {
1230
+ // glowing cockpit: bright, additive
1231
+ col = [base[0], base[1], base[2]];
1232
+ }
1233
+
1234
+ ctx.beginPath();
1235
+ ctx.moveTo(fc.a.x, fc.a.y);
1236
+ ctx.lineTo(fc.b.x, fc.b.y);
1237
+ ctx.lineTo(fc.c.x, fc.c.y);
1238
+ ctx.closePath();
1239
+ ctx.fillStyle = 'rgb(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ')';
1240
+ ctx.fill();
1241
+ if (key !== 'g') {
1242
+ ctx.strokeStyle = 'rgba(0,0,0,0.25)';
1243
+ ctx.lineWidth = 0.5;
1244
+ ctx.stroke();
1245
+ }
1246
+ }
1247
+
1248
+ // Cockpit glow halo
1249
+ const cp = worldShipPoint([0, 0.35, -0.4], cam);
1250
+ if (cp) {
1251
+ ctx.globalCompositeOperation = 'lighter';
1252
+ const r = 26;
1253
+ const grad = ctx.createRadialGradient(cp.x, cp.y, 0, cp.x, cp.y, r);
1254
+ grad.addColorStop(0, 'rgba(120,225,255,0.55)');
1255
+ grad.addColorStop(1, 'rgba(120,225,255,0)');
1256
+ ctx.fillStyle = grad;
1257
+ ctx.beginPath();
1258
+ ctx.arc(cp.x, cp.y, r, 0, Math.PI * 2);
1259
+ ctx.fill();
1260
+ ctx.globalCompositeOperation = 'source-over';
1261
+ }
1262
+
1263
+ // Thruster flames at the REAR (two engines)
1264
+ drawThrusters(cam);
1265
+ }
1266
+
1267
+ function drawThrusters(cam) {
1268
+ const thr = ship.throttle;
1269
+ if (thr < 0.04) return;
1270
+ const t = performance.now() * 0.02;
1271
+ const flick = 0.72 + 0.28 * Math.sin(t) + 0.14 * Math.sin(t * 2.7);
1272
+ const baseLen = (0.55 + 1.25 * thr) * flick;
1273
+ const glow = 0.25 + 0.75 * thr;
1274
+
1275
+ ctx.globalCompositeOperation = 'lighter';
1276
+ for (const e of ENGINE_LOCAL) {
1277
+ const p0 = worldShipPoint(e, cam);
1278
+ if (!p0) continue;
1279
+ // flame extends further back (+Z local)
1280
+ const p1 = worldShipPoint([e[0], e[1], 1.32 + baseLen * 0.6], cam);
1281
+ const p2 = worldShipPoint([e[0], e[1], 1.32 + baseLen], cam);
1282
+ if (!p1 || !p2) continue;
1283
+
1284
+ const grad = ctx.createLinearGradient(p0.x, p0.y, p2.x, p2.y);
1285
+ grad.addColorStop(0, 'rgba(180,235,255,' + (0.9 * glow) + ')');
1286
+ grad.addColorStop(0.4, 'rgba(70,160,255,' + (0.7 * glow) + ')');
1287
+ grad.addColorStop(1, 'rgba(40,90,255,0)');
1288
+ ctx.strokeStyle = grad;
1289
+ ctx.lineWidth = 5 * flick * (0.55 + 0.5 * thr);
1290
+ ctx.lineCap = 'round';
1291
+ ctx.beginPath();
1292
+ ctx.moveTo(p0.x, p0.y);
1293
+ ctx.quadraticCurveTo(p1.x, p1.y, p2.x, p2.y);
1294
+ ctx.stroke();
1295
+
1296
+ // core glow
1297
+ ctx.fillStyle = 'rgba(200,240,255,' + (0.5 * flick * glow) + ')';
1298
+ ctx.beginPath();
1299
+ ctx.arc(p0.x, p0.y, 3.2, 0, Math.PI * 2);
1300
+ ctx.fill();
1301
+ }
1302
+ ctx.globalCompositeOperation = 'source-over';
1303
+ }
1304
+
1305
+ const AST_LIGHT = norm([0.42, 0.58, -0.7]); // fixed sun direction (world space)
1306
+
1307
+ function drawAsteroids(cam) {
1308
+ const list = [];
1309
+ for (const a of asteroids) {
1310
+ // infinite space: wrap around the ship
1311
+ if (a.x < ship.pos[0] - FIELD_AST) a.x += 2 * FIELD_AST; else if (a.x > ship.pos[0] + FIELD_AST) a.x -= 2 * FIELD_AST;
1312
+ if (a.y < ship.pos[1] - FIELD_AST) a.y += 2 * FIELD_AST; else if (a.y > ship.pos[1] + FIELD_AST) a.y -= 2 * FIELD_AST;
1313
+ if (a.z < ship.pos[2] - FIELD_AST) a.z += 2 * FIELD_AST; else if (a.z > ship.pos[2] + FIELD_AST) a.z -= 2 * FIELD_AST;
1314
+ const dx = a.x - cam.camPos[0], dy = a.y - cam.camPos[1], dz = a.z - cam.camPos[2];
1315
+ const dist = Math.hypot(dx, dy, dz);
1316
+ if (dist > FIELD_AST * Math.sqrt(3)) continue; // view distance, 3x the old 3640u
1317
+ // Pop-proof fade in (same idea as the nebulae, with a hard guard).
1318
+ // Two continuous factors, both pure functions of distance, so a wrap
1319
+ // (which can only land an asteroid >= ~7560u from the camera) always
1320
+ // lands it at EXACTLY 0 alpha. The runway is now ~5300u long (about
1321
+ // 40s of travel at max speed) and concave, so a rock is a faint speck
1322
+ // far away, becomes clear over many seconds, and eases up to solid:
1323
+ // 7000u -> ~8% 5500u -> ~46% 4500u -> ~70% 2800u -> ~98%
1324
+ // guard — hard zero within 300u of the far wrap edge (respawn zone)
1325
+ // main — smoothstep 7540u -> 2200u, gamma-lifted
1326
+ const guard = Math.max(0, Math.min(1, (7540 - dist) / 300));
1327
+ if (guard <= 0) continue; // guaranteed-invisible respawn zone
1328
+ const t = Math.max(0, Math.min(1, (7540 - dist) / 5340));
1329
+ const alpha = Math.pow(t * t * (3 - 2 * t), 0.7) * guard;
1330
+ if (alpha < 0.02) continue; // below this it's invisible anyway — skip work
1331
+ const p = project(a.x, a.y, a.z, cam);
1332
+ if (!p) continue;
1333
+ list.push({ a, p, dist, alpha });
1334
+ }
1335
+ list.sort((x, y) => y.dist - x.dist);
1336
+
1337
+ for (const { a, p, alpha } of list) {
1338
+ if (p.z < 2) continue;
1339
+ ctx.globalAlpha = alpha;
1340
+ const s = a.scale;
1341
+ const c0 = Math.cos(a.rot[0]), s0 = Math.sin(a.rot[0]);
1342
+ const c1 = Math.cos(a.rot[1]), s1 = Math.sin(a.rot[1]);
1343
+ const c2 = Math.cos(a.rot[2]), s2 = Math.sin(a.rot[2]);
1344
+ const pts = new Array(a.verts.length);
1345
+ const proj = new Array(a.verts.length);
1346
+ for (let i = 0; i < a.verts.length; i++) {
1347
+ const v = a.verts[i];
1348
+ let x = v[0] * s, y = v[1] * s, z = v[2] * s;
1349
+ let y1 = y * c0 - z * s0, z1 = y * s0 + z * c0;
1350
+ let x2 = x * c1 + z1 * s1, z2 = -x * s1 + z1 * c1;
1351
+ let x3 = x2 * c2 - y1 * s2, y3 = x2 * s2 + y1 * c2;
1352
+ pts[i] = [x3, y3, z2];
1353
+ proj[i] = project(a.x + x3, a.y + y3, a.z + z2, cam);
1354
+ }
1355
+ const faces = [];
1356
+ for (let fi = 0; fi < a.faces.length; fi++) {
1357
+ const f = a.faces[fi];
1358
+ const A = proj[f[0]], B = proj[f[1]], C = proj[f[2]];
1359
+ if (!A || !B || !C) continue;
1360
+ const v0 = pts[f[0]], v1 = pts[f[1]], v2 = pts[f[2]];
1361
+ // world-space face normal (uniform rotation preserves direction)
1362
+ const nx = (v1[1] - v0[1]) * (v2[2] - v0[2]) - (v1[2] - v0[2]) * (v2[1] - v0[1]);
1363
+ const ny = (v1[2] - v0[2]) * (v2[0] - v0[0]) - (v1[0] - v0[0]) * (v2[2] - v0[2]);
1364
+ const nz = (v1[0] - v0[0]) * (v2[1] - v0[1]) - (v1[1] - v0[1]) * (v2[0] - v0[0]);
1365
+ // backface cull: normal must point toward the camera
1366
+ const fx = (v0[0] + v1[0] + v2[0]) / 3, fy = (v0[1] + v1[1] + v2[1]) / 3, fz = (v0[2] + v1[2] + v2[2]) / 3;
1367
+ if (nx * (cam.camPos[0] - a.x - fx) + ny * (cam.camPos[1] - a.y - fy) + nz * (cam.camPos[2] - a.z - fz) <= 0) continue;
1368
+ const nl = Math.hypot(nx, ny, nz) || 1;
1369
+ const lit = Math.pow(Math.max(0, (nx * AST_LIGHT[0] + ny * AST_LIGHT[1] + nz * AST_LIGHT[2]) / nl), 0.8);
1370
+ const fc = a.faceCol[fi];
1371
+ const br = (a.shade / 100) * (0.28 + 0.95 * lit);
1372
+ const r = Math.min(255, fc[0] * br + 10);
1373
+ const g = Math.min(255, fc[1] * br + 10);
1374
+ const b = Math.min(255, fc[2] * br + 16);
1375
+ faces.push({ A, B, C, z: (A.z + B.z + C.z) / 3, col: 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')' });
1376
+ }
1377
+ faces.sort((m, n) => n.z - m.z);
1378
+ for (const fc of faces) {
1379
+ ctx.fillStyle = fc.col;
1380
+ ctx.beginPath();
1381
+ ctx.moveTo(fc.A.x, fc.A.y);
1382
+ ctx.lineTo(fc.B.x, fc.B.y);
1383
+ ctx.lineTo(fc.C.x, fc.C.y);
1384
+ ctx.closePath();
1385
+ ctx.fill();
1386
+ }
1387
+ }
1388
+ ctx.globalAlpha = 1;
1389
+ }
1390
+
1391
+ const BAND_ALPHAS = [0.16, 0.3, 0.5];
1392
+ const BAND_COLORS = ['rgb(150,180,235)', 'rgb(235,225,215)', 'rgb(175,215,245)'];
1393
+
1394
+ function drawSky(cam) {
1395
+ const cp = cam.camPos, xa = cam.xAxis, ya = cam.yAxis, za = cam.zAxis;
1396
+ ctx.globalCompositeOperation = 'lighter';
1397
+ // soft band glow with dust gaps
1398
+ for (const b of bandBlobs) {
1399
+ const cz = b.dir[0] * za[0] + b.dir[1] * za[1] + b.dir[2] * za[2];
1400
+ if (cz < 0.05) continue;
1401
+ const cx = b.dir[0] * xa[0] + b.dir[1] * xa[1] + b.dir[2] * xa[2];
1402
+ const cy = b.dir[0] * ya[0] + b.dir[1] * ya[1] + b.dir[2] * ya[2];
1403
+ const px = CX + (cx / cz) * FOCAL;
1404
+ const py = CY - (cy / cz) * FOCAL;
1405
+ // b.r is a world-space radius; the band lives on a unit-direction shell
1406
+ // of radius SKY_R, so convert it to an angular size first. Without the
1407
+ // /SKY_R these blobs are ~1.4M px wide and additive-blend the whole
1408
+ // screen to white.
1409
+ const r = (b.r / SKY_R / cz) * FOCAL;
1410
+ if (r < 3 || px < -r || px > W + r || py < -r || py > H + r) continue;
1411
+ ctx.globalAlpha = b.a * Math.min(1, cz * 1.5);
1412
+ ctx.drawImage(b.tex, px - r, py - r, r * 2, r * 2);
1413
+ }
1414
+ ctx.globalAlpha = 1;
1415
+ // faint deep-field dusting hugging the band
1416
+ const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D(), new Path2D(),
1417
+ new Path2D(), new Path2D(), new Path2D(), new Path2D()];
1418
+ for (const st of bandStars) {
1419
+ const cz = st.dir[0] * za[0] + st.dir[1] * za[1] + st.dir[2] * za[2];
1420
+ if (cz <= 0.05) continue;
1421
+ const cx = st.dir[0] * xa[0] + st.dir[1] * xa[1] + st.dir[2] * xa[2];
1422
+ const cy = st.dir[0] * ya[0] + st.dir[1] * ya[1] + st.dir[2] * ya[2];
1423
+ const px = CX + (cx / cz) * FOCAL;
1424
+ if (px < -2 || px > W + 2) continue;
1425
+ const py = CY - (cy / cz) * FOCAL;
1426
+ if (py < -2 || py > H + 2) continue;
1427
+ paths[st.c * 3 + st.b].rect(px, py, st.s, st.s);
1428
+ }
1429
+ for (let i = 0; i < 9; i++) {
1430
+ ctx.globalAlpha = BAND_ALPHAS[i % 3];
1431
+ ctx.fillStyle = BAND_COLORS[(i / 3) | 0];
1432
+ ctx.fill(paths[i]);
1433
+ }
1434
+ ctx.globalAlpha = 1;
1435
+ ctx.globalCompositeOperation = 'source-over';
1436
+ }
1437
+
1438
+ function drawFilaments(cam) {
1439
+ const cp = cam.camPos;
1440
+ ctx.globalCompositeOperation = 'lighter';
1441
+ const cluster = new Path2D();
1442
+ for (const f of filaments) {
1443
+ // wrap the whole strand around the ship (node data stays untouched)
1444
+ const c0 = f.nodes[0];
1445
+ for (let i = 0; i < 3; i++) {
1446
+ const p = c0[i] + f.off[i];
1447
+ if (p < ship.pos[i] - FIELD_FIL) f.off[i] += 2 * FIELD_FIL;
1448
+ else if (p > ship.pos[i] + FIELD_FIL) f.off[i] -= 2 * FIELD_FIL;
1449
+ }
1450
+ const cs = filPalette[f.ci][0] + ',' + filPalette[f.ci][1] + ',' + filPalette[f.ci][2];
1451
+ // project the smoothed polyline once
1452
+ const P = new Array(f.pts.length);
1453
+ for (let i = 0; i < f.pts.length; i++) {
1454
+ const nd = f.pts[i];
1455
+ const x = nd[0] + f.off[0], y = nd[1] + f.off[1], z = nd[2] + f.off[2];
1456
+ const dx = x - cp[0], dy = y - cp[1], dz = z - cp[2];
1457
+ const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2];
1458
+ const fade = filFade(Math.hypot(dx, dy, dz));
1459
+ if (cz < 40 || fade <= 0.01) { P[i] = null; continue; }
1460
+ const cx = dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2];
1461
+ const cy = dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2];
1462
+ const px = CX + (cx / cz) * FOCAL;
1463
+ const py = CY - (cy / cz) * FOCAL;
1464
+ if (px < -300 || px > W + 300 || py < -300 || py > H + 300) { P[i] = null; continue; }
1465
+ P[i] = { x: px, y: py, z: cz, fade: fade };
1466
+ }
1467
+ // two passes per segment: a wide soft haze and a bright core
1468
+ for (let i = 0; i + 1 < P.length; i++) {
1469
+ const A = P[i], B = P[i + 1];
1470
+ if (!A || !B) continue;
1471
+ const a = (A.fade + B.fade) * 0.5;
1472
+ const z = (A.z + B.z) * 0.5;
1473
+ const wpx = f.w * FOCAL / z;
1474
+ ctx.strokeStyle = 'rgba(' + cs + ',' + (a * 0.1 * f.glow).toFixed(3) + ')';
1475
+ ctx.lineWidth = Math.max(1.5, wpx * 2.2);
1476
+ ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.stroke();
1477
+ ctx.strokeStyle = 'rgba(' + cs + ',' + (a * 0.3 * f.glow).toFixed(3) + ')';
1478
+ ctx.lineWidth = Math.max(0.6, wpx * 0.45);
1479
+ ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.stroke();
1480
+ }
1481
+ // galaxy clusters gathered at the knots
1482
+ for (let i = 0; i < f.nodes.length; i++) {
1483
+ const nd = f.nodes[i];
1484
+ const x = nd[0] + f.off[0], y = nd[1] + f.off[1], z = nd[2] + f.off[2];
1485
+ const dx = x - cp[0], dy = y - cp[1], dz = z - cp[2];
1486
+ const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2];
1487
+ const fade = filFade(Math.hypot(dx, dy, dz));
1488
+ if (cz < 40 || fade <= 0.05) continue;
1489
+ const cx = dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2];
1490
+ const cy = dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2];
1491
+ const px = CX + (cx / cz) * FOCAL;
1492
+ const py = CY - (cy / cz) * FOCAL;
1493
+ if (px < -80 || px > W + 80 || py < -80 || py > H + 80) continue;
1494
+ const hs = Math.min(120, 120 * FOCAL / cz);
1495
+ ctx.globalAlpha = fade * 0.3 * f.glow;
1496
+ ctx.drawImage(softCool, px - hs / 2, py - hs / 2, hs, hs);
1497
+ ctx.globalAlpha = 1;
1498
+ const knots = f.knots[i];
1499
+ for (let k = 0; k < knots.length; k++) {
1500
+ const kp = [x + knots[k][0], y + knots[k][1], z + knots[k][2]];
1501
+ const kx = kp[0] - cp[0], ky = kp[1] - cp[1], kz = kp[2] - cp[2];
1502
+ const kcz = kx * cam.zAxis[0] + ky * cam.zAxis[1] + kz * cam.zAxis[2];
1503
+ if (kcz < 40) continue;
1504
+ const kkx = kx * cam.xAxis[0] + ky * cam.xAxis[1] + kz * cam.xAxis[2];
1505
+ const kky = kx * cam.yAxis[0] + ky * cam.yAxis[1] + kz * cam.yAxis[2];
1506
+ const qx = CX + (kkx / kcz) * FOCAL;
1507
+ const qy = CY - (kky / kcz) * FOCAL;
1508
+ if (qx < -2 || qx > W + 2 || qy < -2 || qy > H + 2) continue;
1509
+ cluster.rect(qx, qy, 1, 1);
1510
+ }
1511
+ }
1512
+ }
1513
+ ctx.globalAlpha = 0.45;
1514
+ ctx.fillStyle = 'rgb(215,230,255)';
1515
+ ctx.fill(cluster);
1516
+ ctx.globalAlpha = 1;
1517
+ ctx.globalCompositeOperation = 'source-over';
1518
+ }
1519
+
1520
+ function drawGalaxies(cam) {
1521
+ const G_MAX = FIELD_GAL * Math.sqrt(3);
1522
+ const list = [];
1523
+ for (const g of galaxies) {
1524
+ if (g.x < ship.pos[0] - FIELD_GAL) g.x += 2 * FIELD_GAL;
1525
+ else if (g.x > ship.pos[0] + FIELD_GAL) g.x -= 2 * FIELD_GAL;
1526
+ if (g.y < ship.pos[1] - FIELD_GAL) g.y += 2 * FIELD_GAL;
1527
+ else if (g.y > ship.pos[1] + FIELD_GAL) g.y -= 2 * FIELD_GAL;
1528
+ if (g.z < ship.pos[2] - FIELD_GAL) g.z += 2 * FIELD_GAL;
1529
+ else if (g.z > ship.pos[2] + FIELD_GAL) g.z -= 2 * FIELD_GAL;
1530
+ const dx = g.x - cam.camPos[0], dy = g.y - cam.camPos[1], dz = g.z - cam.camPos[2];
1531
+ const dist = Math.hypot(dx, dy, dz);
1532
+ if (dist > G_MAX) continue;
1533
+ const t = Math.max(0, Math.min(1, (FIELD_GAL - dist) / (FIELD_GAL - FIELD)));
1534
+ const fade = smoothstep(t);
1535
+ if (fade < 0.01) continue;
1536
+ const p = project(g.x, g.y, g.z, cam);
1537
+ if (!p) continue;
1538
+ list.push({ g, p, dist, fade });
1539
+ }
1540
+ list.sort((a, b) => b.dist - a.dist);
1541
+ ctx.globalCompositeOperation = 'lighter';
1542
+ for (const { g, p, fade } of list) {
1543
+ const r = (g.r / p.z) * FOCAL;
1544
+ if (r < 1.5) continue;
1545
+ ctx.save();
1546
+ ctx.globalAlpha = 0.62 * fade;
1547
+ ctx.translate(p.x, p.y);
1548
+ ctx.rotate(g.rot);
1549
+ ctx.scale(1, g.squash);
1550
+ ctx.drawImage(galaxyTex[g.ci], -r, -r, r * 2, r * 2);
1551
+ ctx.restore();
1552
+ }
1553
+ ctx.globalAlpha = 1;
1554
+ ctx.globalCompositeOperation = 'source-over';
1555
+ }
1556
+
1557
+ function drawPlanets(cam) {
1558
+ const P_MAX = FIELD_PLANET * Math.sqrt(3);
1559
+ const list = [];
1560
+ for (const pl of planets) {
1561
+ for (let i = 0; i < 3; i++) {
1562
+ if (pl.x < ship.pos[0] - FIELD_PLANET) pl.x += 2 * FIELD_PLANET;
1563
+ else if (pl.x > ship.pos[0] + FIELD_PLANET) pl.x -= 2 * FIELD_PLANET;
1564
+ if (pl.y < ship.pos[1] - FIELD_PLANET) pl.y += 2 * FIELD_PLANET;
1565
+ else if (pl.y > ship.pos[1] + FIELD_PLANET) pl.y -= 2 * FIELD_PLANET;
1566
+ if (pl.z < ship.pos[2] - FIELD_PLANET) pl.z += 2 * FIELD_PLANET;
1567
+ else if (pl.z > ship.pos[2] + FIELD_PLANET) pl.z -= 2 * FIELD_PLANET;
1568
+ }
1569
+ const dx = pl.x - cam.camPos[0], dy = pl.y - cam.camPos[1], dz = pl.z - cam.camPos[2];
1570
+ const dist = Math.hypot(dx, dy, dz);
1571
+ if (dist > P_MAX) continue;
1572
+ const t = Math.max(0, Math.min(1, (FIELD_PLANET - dist) / (FIELD_PLANET - FIELD)));
1573
+ const fade = smoothstep(t);
1574
+ if (fade < 0.01) continue;
1575
+ const p = project(pl.x, pl.y, pl.z, cam);
1576
+ if (!p) continue;
1577
+ const r = (pl.r / p.z) * FOCAL;
1578
+ if (r < 1.5) continue;
1579
+ list.push({ pl, p, r, fade, dx, dy, dz, dist });
1580
+ }
1581
+ list.sort((a, b) => b.dist - a.dist);
1582
+
1583
+ for (const it of list) {
1584
+ const pl = it.pl, p = it.p, r = it.r, fade = it.fade;
1585
+ // sun direction in camera space -> where the terminator falls on screen
1586
+ const lx = SUN_DIR[0] * cam.xAxis[0] + SUN_DIR[1] * cam.xAxis[1] + SUN_DIR[2] * cam.xAxis[2];
1587
+ const ly = SUN_DIR[0] * cam.yAxis[0] + SUN_DIR[1] * cam.yAxis[1] + SUN_DIR[2] * cam.yAxis[2];
1588
+ const dl = Math.hypot(it.dx, it.dy, it.dz) || 1;
1589
+ const f = (SUN_DIR[0] * it.dx + SUN_DIR[1] * it.dy + SUN_DIR[2] * it.dz) / dl; // +1 = night side to us
1590
+ const phase = (1 - f) * 0.5; // 1 = full, 0 = new
1591
+ let ux = lx, uy = -ly;
1592
+ const ul = Math.hypot(ux, uy);
1593
+ if (ul < 1e-4) { ux = 1; uy = 0; } else { ux /= ul; uy /= ul; }
1594
+
1595
+ const ringPts = pl.ring ? ringProjection(pl, cam) : null;
1596
+ if (ringPts) strokeRing(ringPts, pl, cam, false, fade);
1597
+
1598
+ // surface
1599
+ ctx.save();
1600
+ ctx.beginPath();
1601
+ ctx.arc(p.x, p.y, r, 0, TAU);
1602
+ ctx.clip();
1603
+ ctx.globalAlpha = fade;
1604
+ ctx.save();
1605
+ ctx.translate(p.x, p.y);
1606
+ ctx.rotate(pl.spin);
1607
+ ctx.drawImage(planetTex[pl.tex], -r, -r, r * 2, r * 2);
1608
+ ctx.restore();
1609
+ // day/night terminator multiplied over the surface only
1610
+ const soft = 0.24;
1611
+ const grad = ctx.createLinearGradient(p.x - ux * r, p.y - uy * r, p.x + ux * r, p.y + uy * r);
1612
+ const dark = 'rgb(11,13,22)', light = 'rgb(255,255,255)';
1613
+ grad.addColorStop(0, dark);
1614
+ grad.addColorStop(Math.min(1, phase), dark);
1615
+ grad.addColorStop(Math.max(0, Math.min(1, phase + soft)), light);
1616
+ grad.addColorStop(1, light);
1617
+ ctx.globalCompositeOperation = 'multiply';
1618
+ ctx.fillStyle = grad;
1619
+ ctx.beginPath();
1620
+ ctx.arc(p.x, p.y, r, 0, TAU);
1621
+ ctx.fill();
1622
+ ctx.restore();
1623
+
1624
+ // atmosphere rim
1625
+ const ac = pl.atmo[0] + ',' + pl.atmo[1] + ',' + pl.atmo[2];
1626
+ ctx.globalCompositeOperation = 'lighter';
1627
+ const ag = ctx.createRadialGradient(p.x, p.y, r * 0.82, p.x, p.y, r * 1.3);
1628
+ ag.addColorStop(0, 'rgba(' + ac + ',0)');
1629
+ ag.addColorStop(0.45, 'rgba(' + ac + ',' + (0.2 * fade).toFixed(3) + ')');
1630
+ ag.addColorStop(1, 'rgba(' + ac + ',0)');
1631
+ ctx.fillStyle = ag;
1632
+ ctx.beginPath();
1633
+ ctx.arc(p.x, p.y, r * 1.3, 0, TAU);
1634
+ ctx.fill();
1635
+ ctx.globalCompositeOperation = 'source-over';
1636
+
1637
+ if (ringPts) strokeRing(ringPts, pl, cam, true, fade);
1638
+ }
1639
+ ctx.globalAlpha = 1;
1640
+ }
1641
+
1642
+ // Ring points are classified by depth against the planet centre, so the
1643
+ // far half is drawn behind the body and the near half over it.
1644
+ function ringProjection(pl, cam) {
1645
+ const centre = project(pl.x, pl.y, pl.z, cam);
1646
+ if (!centre) return null;
1647
+ const out = [];
1648
+ const N = 56;
1649
+ for (let i = 0; i <= N; i++) {
1650
+ const a = i / N * TAU;
1651
+ const ca = Math.cos(a), sa = Math.sin(a);
1652
+ const R = pl.r * pl.ringR;
1653
+ const x = pl.x + (pl.ringA[0] * ca + pl.ringB[0] * sa) * R;
1654
+ const y = pl.y + (pl.ringA[1] * ca + pl.ringB[1] * sa) * R;
1655
+ const z = pl.z + (pl.ringA[2] * ca + pl.ringB[2] * sa) * R;
1656
+ const q = project(x, y, z, cam);
1657
+ out.push(q ? { q: q, front: q.z < centre.z } : null);
1658
+ }
1659
+ return out;
1660
+ }
1661
+
1662
+ function strokeRing(pts, pl, cam, front, fade) {
1663
+ const ac = pl.atmo[0] + ',' + pl.atmo[1] + ',' + pl.atmo[2];
1664
+ ctx.lineCap = 'round';
1665
+ for (let i = 0; i + 1 < pts.length; i++) {
1666
+ const A = pts[i], B = pts[i + 1];
1667
+ if (!A || !B) continue;
1668
+ if (A.front !== front || B.front !== front) continue;
1669
+ const z = (A.q.z + B.q.z) * 0.5;
1670
+ ctx.strokeStyle = 'rgba(' + ac + ',' + (0.26 * fade).toFixed(3) + ')';
1671
+ ctx.lineWidth = Math.max(0.7, pl.ringW * FOCAL / z);
1672
+ ctx.beginPath();
1673
+ ctx.moveTo(A.q.x, A.q.y);
1674
+ ctx.lineTo(B.q.x, B.q.y);
1675
+ ctx.stroke();
1676
+ }
1677
+ }
1678
+
1679
+ function drawMeteors(cam) {
1680
+ if (!meteors.length) return;
1681
+ ctx.globalCompositeOperation = 'lighter';
1682
+ ctx.lineCap = 'round';
1683
+ for (const m of meteors) {
1684
+ const head = project(m.x, m.y, m.z, cam);
1685
+ if (!head) continue;
1686
+ const sp = Math.hypot(m.vx, m.vy, m.vz) || 1;
1687
+ const tl = sp * m.trail;
1688
+ const tail = project(m.x - m.vx / sp * tl, m.y - m.vy / sp * tl, m.z - m.vz / sp * tl, cam);
1689
+ const t = m.life / m.max;
1690
+ let env = 1;
1691
+ if (t < 0.18) env = t / 0.18;
1692
+ else if (t > 0.6) env = Math.max(0, (1 - t) / 0.4);
1693
+ if (env <= 0.02 || !tail) continue;
1694
+ const cs = m.warm ? '255,226,190' : '205,235,255';
1695
+ const g = ctx.createLinearGradient(head.x, head.y, tail.x, tail.y);
1696
+ g.addColorStop(0, 'rgba(' + cs + ',' + (0.95 * env).toFixed(3) + ')');
1697
+ g.addColorStop(0.35, 'rgba(' + cs + ',' + (0.4 * env).toFixed(3) + ')');
1698
+ g.addColorStop(1, 'rgba(' + cs + ',0)');
1699
+ ctx.strokeStyle = g;
1700
+ ctx.lineWidth = m.w;
1701
+ ctx.beginPath();
1702
+ ctx.moveTo(head.x, head.y);
1703
+ ctx.lineTo(tail.x, tail.y);
1704
+ ctx.stroke();
1705
+ const hs = (m.fireball ? 52 : 24) * env;
1706
+ ctx.globalAlpha = 0.8 * env;
1707
+ ctx.drawImage(m.warm ? softWarm : softCool, head.x - hs / 2, head.y - hs / 2, hs, hs);
1708
+ ctx.globalAlpha = 1;
1709
+ }
1710
+ ctx.globalCompositeOperation = 'source-over';
1711
+ }
1712
+
1713
+ function drawExhaust(cam) {
1714
+ if (!exhaust.length) return;
1715
+ ctx.globalCompositeOperation = 'lighter';
1716
+ for (const p of exhaust) {
1717
+ const q = project(p.x, p.y, p.z, cam);
1718
+ if (!q) continue;
1719
+ const t = p.life / p.max;
1720
+ const a = (1 - t) * (1 - t) * 0.55;
1721
+ let hs = p.r * (1 + t * 2.4) * 2 * FOCAL / q.z;
1722
+ if (hs < 1) continue;
1723
+ if (hs > 34) hs = 34;
1724
+ ctx.globalAlpha = a;
1725
+ ctx.drawImage(softCool, q.x - hs / 2, q.y - hs / 2, hs, hs);
1726
+ }
1727
+ ctx.globalAlpha = 1;
1728
+ ctx.globalCompositeOperation = 'source-over';
1729
+ }
1730
+
1731
+ const DUST_ALPHAS = [0.1, 0.22, 0.36];
1732
+ function drawDust(cam) {
1733
+ const cp = cam.camPos;
1734
+ const paths = [new Path2D(), new Path2D(), new Path2D()];
1735
+ const TRAIL = 0.05; // motion-blur trail time — the speed cue
1736
+ const vx = ship.vel[0] * TRAIL, vy = ship.vel[1] * TRAIL, vz = ship.vel[2] * TRAIL;
1737
+ for (const d of dust) {
1738
+ if (d.x < cp[0] - DUST_R) d.x += 2 * DUST_R;
1739
+ else if (d.x > cp[0] + DUST_R) d.x -= 2 * DUST_R;
1740
+ if (d.y < cp[1] - DUST_R) d.y += 2 * DUST_R;
1741
+ else if (d.y > cp[1] + DUST_R) d.y -= 2 * DUST_R;
1742
+ if (d.z < cp[2] - DUST_R) d.z += 2 * DUST_R;
1743
+ else if (d.z > cp[2] + DUST_R) d.z -= 2 * DUST_R;
1744
+ const dx = d.x - cp[0], dy = d.y - cp[1], dz = d.z - cp[2];
1745
+ const dist = Math.hypot(dx, dy, dz);
1746
+ if (dist > DUST_R || dist < 12) continue;
1747
+ const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2];
1748
+ if (cz < 20) continue;
1749
+ const cx = dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2];
1750
+ const cy = dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2];
1751
+ const px = CX + (cx / cz) * FOCAL;
1752
+ if (px < -20 || px > W + 20) continue;
1753
+ const py = CY - (cy / cz) * FOCAL;
1754
+ if (py < -20 || py > H + 20) continue;
1755
+ // the mote appeared at here a moment ago: that's the streak
1756
+ const cz2 = cz + (vx * cam.zAxis[0] + vy * cam.zAxis[1] + vz * cam.zAxis[2]);
1757
+ if (cz2 < 20) continue;
1758
+ const cx2 = cx + vx * cam.xAxis[0] + vy * cam.xAxis[1] + vz * cam.xAxis[2];
1759
+ const cy2 = cy + vx * cam.yAxis[0] + vy * cam.yAxis[1] + vz * cam.yAxis[2];
1760
+ const qx = CX + (cx2 / cz2) * FOCAL;
1761
+ const qy = CY - (cy2 / cz2) * FOCAL;
1762
+ const bucket = Math.min(2, ((1 - dist / DUST_R) * 3) | 0);
1763
+ const path = paths[bucket];
1764
+ path.moveTo(px, py);
1765
+ path.lineTo(qx, qy);
1766
+ }
1767
+ ctx.globalCompositeOperation = 'lighter';
1768
+ ctx.strokeStyle = 'rgb(200,225,255)';
1769
+ ctx.lineWidth = 1;
1770
+ for (let i = 0; i < 3; i++) {
1771
+ ctx.globalAlpha = DUST_ALPHAS[i];
1772
+ ctx.stroke(paths[i]);
1773
+ }
1774
+ ctx.globalAlpha = 1;
1775
+ ctx.globalCompositeOperation = 'source-over';
1776
+ }
1777
+
1778
+ function drawCursor() {
1779
+ if (cursorAlpha <= 0.01) return;
1780
+ ctx.save();
1781
+ ctx.globalAlpha = cursorAlpha;
1782
+ ctx.strokeStyle = '#cfeaff';
1783
+ ctx.fillStyle = '#cfeaff';
1784
+ ctx.lineWidth = 1.5;
1785
+ ctx.lineCap = 'round';
1786
+ ctx.beginPath();
1787
+ ctx.moveTo(mouseX - 13, mouseY); ctx.lineTo(mouseX - 5, mouseY);
1788
+ ctx.moveTo(mouseX + 5, mouseY); ctx.lineTo(mouseX + 13, mouseY);
1789
+ ctx.moveTo(mouseX, mouseY - 13); ctx.lineTo(mouseX, mouseY - 5);
1790
+ ctx.moveTo(mouseX, mouseY + 5); ctx.lineTo(mouseX, mouseY + 13);
1791
+ ctx.stroke();
1792
+ ctx.beginPath();
1793
+ ctx.arc(mouseX, mouseY, 1.6, 0, Math.PI * 2);
1794
+ ctx.fill();
1795
+ ctx.restore();
1796
+ }
1797
+
1798
+ // ------------------------------------------------------------------
1799
+ // Main loop
1800
+ // ------------------------------------------------------------------
1801
+ function frame(now) {
1802
+ let dt = (now - last) / 1000;
1803
+ last = now;
1804
+
1805
+ // Fixed: guard against zero or negative dt to avoid NaN in yawRate.
1806
+ if (dt <= 0) {
1807
+ requestAnimationFrame(frame);
1808
+ return;
1809
+ }
1810
+
1811
+ if (dt > 0.05) dt = 0.05; // clamp big gaps
1812
+
1813
+ update(dt);
1814
+
1815
+ const cam = cameraBasis(dt);
1816
+ spawnMeteors(cam, now);
1817
+
1818
+ drawBackground();
1819
+ drawSky(cam);
1820
+ drawNebulae(cam);
1821
+ drawGalaxies(cam);
1822
+ drawFilaments(cam);
1823
+ drawStars(cam);
1824
+ drawPlanets(cam);
1825
+ drawAsteroids(cam);
1826
+ drawExhaust(cam);
1827
+ drawMeteors(cam);
1828
+ drawDust(cam);
1829
+ drawShip(cam);
1830
+
1831
+ if (vignette) {
1832
+ ctx.fillStyle = vignette;
1833
+ ctx.fillRect(0, 0, W, H);
1834
+ }
1835
+
1836
+ // Cursor fade: visible while the mouse moved within the last second,
1837
+ // eases out after that, eases back in on movement.
1838
+ {
1839
+ const idle = now - lastMove;
1840
+ const target = idle < 1000 ? 1 : 0;
1841
+ const speed = target === 1 ? 12 : 4;
1842
+ cursorAlpha += (target - cursorAlpha) * Math.min(1, speed * dt);
1843
+ if (target === 0 && cursorAlpha < 0.01) cursorAlpha = 0;
1844
+ else if (target === 1 && cursorAlpha > 0.99) cursorAlpha = 1;
1845
+ }
1846
+ drawCursor();
1847
+
1848
+ requestAnimationFrame(frame);
1849
+ }
1850
+
1851
+ // Fixed: ensure a valid camera basis exists before the first click.
1852
+ cameraBasis(0.016);
1853
+ requestAnimationFrame(frame);
1854
+ })();
1855
+ </script>
1856
+ </body>
1857
+ </html>