Upload index.html
Browse files- index.html +1857 -19
index.html
CHANGED
|
@@ -1,19 +1,1857 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|