Spaces:
Running
Running
Grace Chase
Browse files- README.md +40 -3
- index.html +1745 -18
- style.css +0 -28
README.md
CHANGED
|
@@ -1,10 +1,47 @@
|
|
| 1 |
---
|
| 2 |
title: Grace Chase
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: yellow
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Grace Chase
|
| 3 |
+
emoji: 🌾
|
| 4 |
colorFrom: yellow
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
license: other
|
| 9 |
+
short_description: One ghost, always ahead. A single-file WebGL chase.
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Grace Chase
|
| 13 |
+
|
| 14 |
+
She runs a flawless line through the golden grass. You do not.
|
| 15 |
+
|
| 16 |
+
A ghost is always ahead of you. She never stumbles, never clips a wall, never
|
| 17 |
+
mistimes a jump. Survive everything she survives, close the distance metre by
|
| 18 |
+
metre, and touch her before the field forgets you were here.
|
| 19 |
+
|
| 20 |
+
Every clean second you run, you gain on her. Every fall hands it back.
|
| 21 |
+
|
| 22 |
+
## Controls
|
| 23 |
+
|
| 24 |
+
The game detects your device and rewires itself.
|
| 25 |
+
|
| 26 |
+
| | Steer | Hop | Brake & duck |
|
| 27 |
+
|---|---|---|---|
|
| 28 |
+
| **Keyboard** | `A` `D` / arrows | `Space` | `S` / `↓` |
|
| 29 |
+
| **Gamepad** | either stick or d-pad | `A` | `B` / `LT` |
|
| 30 |
+
| **Touch** | drag ← → (keep dragging) | flick ↑ | flick ↓ |
|
| 31 |
+
|
| 32 |
+
`Esc` pauses · `R` restarts · `M` mutes
|
| 33 |
+
|
| 34 |
+
## What this is
|
| 35 |
+
|
| 36 |
+
One HTML file. No engine, no libraries, no images, no audio files, no network
|
| 37 |
+
requests. The grass — over a hundred thousand blades, with wind moving through
|
| 38 |
+
them and sunlight shining through each one — along with the metal, the sky and
|
| 39 |
+
every sound, is generated at runtime from WebGL2 and the Web Audio API. The
|
| 40 |
+
entire endless course is a single seed.
|
| 41 |
+
|
| 42 |
+
Requires a browser with WebGL2. Best times are stored locally on your device.
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
© 2026. All rights reserved. This is not open-source software; it is published
|
| 47 |
+
here to be played, not reused.
|
index.html
CHANGED
|
@@ -1,19 +1,1746 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
| 6 |
+
<meta name="theme-color" content="#0d1220">
|
| 7 |
+
<title>Grace Chase</title>
|
| 8 |
+
|
| 9 |
+
<!-- Link preview for X / Discord / iMessage / Slack.
|
| 10 |
+
Replace YOUR-URL with your real https host once it's live, and put a
|
| 11 |
+
1200x630 PNG next to this file as preview.png. Without an absolute
|
| 12 |
+
https:// image URL the card will not render. -->
|
| 13 |
+
<meta name="twitter:card" content="summary_large_image">
|
| 14 |
+
<meta name="twitter:title" content="Grace Chase">
|
| 15 |
+
<meta name="twitter:description" content="She runs a flawless line through the golden grass. You do not. One ghost, always ahead.">
|
| 16 |
+
<meta name="twitter:image" content="https://YOUR-URL/preview.png">
|
| 17 |
+
<meta property="og:type" content="website">
|
| 18 |
+
<meta property="og:site_name" content="Grace Chase">
|
| 19 |
+
<meta property="og:title" content="Grace Chase">
|
| 20 |
+
<meta property="og:description" content="She runs a flawless line through the golden grass. You do not. One ghost, always ahead.">
|
| 21 |
+
<meta property="og:image" content="https://YOUR-URL/preview.png">
|
| 22 |
+
<meta property="og:url" content="https://YOUR-URL/">
|
| 23 |
+
<meta name="description" content="A single-file WebGL chase through tall grass. Catch the ghost.">
|
| 24 |
+
|
| 25 |
+
<!-- Home-screen app. These sidecar files are optional: without them the game
|
| 26 |
+
still runs perfectly as one standalone file, the browser just 404s them. -->
|
| 27 |
+
<link rel="manifest" href="manifest.webmanifest">
|
| 28 |
+
<link rel="icon" href="icon-192.png">
|
| 29 |
+
<link rel="apple-touch-icon" href="icon-192.png">
|
| 30 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 31 |
+
<meta name="apple-mobile-web-app-title" content="Grace Chase">
|
| 32 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 33 |
+
<style>
|
| 34 |
+
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
| 35 |
+
html,body{width:100%;height:100%;overflow:hidden;background:#0d1220;
|
| 36 |
+
font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
| 37 |
+
color:#f4ead6;touch-action:none;overscroll-behavior:none;user-select:none}
|
| 38 |
+
#c{position:fixed;inset:0;width:100%;height:100%;display:block;
|
| 39 |
+
touch-action:none;-ms-touch-action:none}
|
| 40 |
+
#ui{position:fixed;inset:0;pointer-events:none}
|
| 41 |
+
.mono{font-variant-numeric:tabular-nums;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace}
|
| 42 |
+
|
| 43 |
+
/* ---- HUD ---- */
|
| 44 |
+
#hud{position:absolute;inset:0;opacity:0;transition:opacity .5s}
|
| 45 |
+
#hud.on{opacity:1}
|
| 46 |
+
#timer{position:absolute;top:max(18px,env(safe-area-inset-top));left:50%;transform:translateX(-50%);
|
| 47 |
+
font-size:clamp(28px,7vw,46px);font-weight:200;letter-spacing:.06em;
|
| 48 |
+
text-shadow:0 2px 24px rgba(0,0,0,.55);color:#fff8e7}
|
| 49 |
+
#sub{position:absolute;top:calc(max(18px,env(safe-area-inset-top)) + clamp(34px,8vw,52px));left:50%;
|
| 50 |
+
transform:translateX(-50%);font-size:11px;letter-spacing:.22em;text-transform:uppercase;
|
| 51 |
+
opacity:.55;white-space:nowrap}
|
| 52 |
+
#gapwrap{position:absolute;left:50%;transform:translateX(-50%);bottom:max(26px,env(safe-area-inset-bottom));
|
| 53 |
+
width:min(46vw,320px);text-align:center}
|
| 54 |
+
#gaptrack{height:3px;border-radius:3px;background:rgba(255,255,255,.13);overflow:hidden}
|
| 55 |
+
#gapbar{height:100%;width:100%;border-radius:3px;
|
| 56 |
+
background:linear-gradient(90deg,#ffd27a,#fff6df);
|
| 57 |
+
box-shadow:0 0 14px rgba(255,214,138,.75);transition:width .12s linear}
|
| 58 |
+
#gaptxt{margin-top:9px;font-size:11px;letter-spacing:.2em;opacity:.6;text-transform:uppercase}
|
| 59 |
+
#crashes{position:absolute;top:calc(max(18px,env(safe-area-inset-top)) + 46px);
|
| 60 |
+
right:max(18px,env(safe-area-inset-right));
|
| 61 |
+
font-size:12px;letter-spacing:.14em;opacity:.5;text-align:right;line-height:1.7}
|
| 62 |
+
#pausebtn{position:absolute;top:max(13px,env(safe-area-inset-top));right:max(13px,env(safe-area-inset-right));
|
| 63 |
+
width:40px;height:40px;border-radius:50%;border:1px solid rgba(244,234,214,.2);
|
| 64 |
+
background:rgba(10,14,24,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
|
| 65 |
+
display:flex;align-items:center;justify-content:center;gap:4px;
|
| 66 |
+
cursor:pointer;pointer-events:auto;opacity:.5;transition:.2s}
|
| 67 |
+
#pausebtn:hover{opacity:1;border-color:rgba(255,214,138,.45)}
|
| 68 |
+
#pausebtn i{display:block;width:3px;height:13px;background:#f4ead6;border-radius:1px}
|
| 69 |
+
#hud:not(.on) #pausebtn{pointer-events:none}
|
| 70 |
+
.minirow{margin-top:20px;display:flex;gap:26px;justify-content:center;flex-wrap:wrap}
|
| 71 |
+
.lnk{font-size:11px;letter-spacing:.24em;text-transform:uppercase;opacity:.45;cursor:pointer;
|
| 72 |
+
pointer-events:auto;padding:8px 4px;transition:.2s;border-bottom:1px solid transparent}
|
| 73 |
+
.lnk:hover{opacity:.95;border-bottom-color:rgba(255,214,138,.5)}
|
| 74 |
+
#speed{position:absolute;top:max(18px,env(safe-area-inset-top));left:max(18px,env(safe-area-inset-left));
|
| 75 |
+
font-size:12px;letter-spacing:.14em;opacity:.5;line-height:1.7}
|
| 76 |
+
#toast{position:absolute;top:38%;left:50%;transform:translate(-50%,-50%);text-align:center;
|
| 77 |
+
font-size:clamp(15px,4vw,22px);letter-spacing:.3em;text-transform:uppercase;opacity:0;
|
| 78 |
+
text-shadow:0 0 30px rgba(255,200,120,.6);pointer-events:none}
|
| 79 |
+
@keyframes toastpop{0%{opacity:0;transform:translate(-50%,-40%) scale(.94)}
|
| 80 |
+
18%{opacity:1;transform:translate(-50%,-50%) scale(1)}
|
| 81 |
+
70%{opacity:1}100%{opacity:0;transform:translate(-50%,-58%) scale(1.02)}}
|
| 82 |
+
.toasting{animation:toastpop 1.5s ease-out}
|
| 83 |
+
|
| 84 |
+
/* ---- vignettes / flashes ---- */
|
| 85 |
+
#flash{position:absolute;inset:0;background:radial-gradient(ellipse at center,transparent 35%,rgba(180,40,30,.72) 100%);
|
| 86 |
+
opacity:0;transition:opacity .45s}
|
| 87 |
+
#white{position:absolute;inset:0;background:#fff;opacity:0;transition:opacity .8s}
|
| 88 |
+
#vig{position:absolute;inset:0;background:radial-gradient(ellipse at center,transparent 45%,rgba(0,0,0,.55) 100%)}
|
| 89 |
+
|
| 90 |
+
/* ---- panels ---- */
|
| 91 |
+
.panel{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
| 92 |
+
padding:24px;background:radial-gradient(ellipse at 50% 40%,rgba(14,19,32,.55),rgba(8,11,19,.92));
|
| 93 |
+
backdrop-filter:blur(7px);-webkit-backdrop-filter:blur(7px);pointer-events:auto;text-align:center;
|
| 94 |
+
transition:opacity .55s;overflow:auto}
|
| 95 |
+
.panel.hide{opacity:0;pointer-events:none}
|
| 96 |
+
h1{font-size:clamp(40px,12vw,86px);font-weight:200;letter-spacing:.16em;line-height:1;
|
| 97 |
+
background:linear-gradient(180deg,#fffdf6 20%,#e6b96a 95%);-webkit-background-clip:text;background-clip:text;
|
| 98 |
+
color:transparent;filter:drop-shadow(0 6px 40px rgba(230,185,106,.32))}
|
| 99 |
+
.tag{margin-top:16px;font-size:clamp(11px,2.6vw,13px);letter-spacing:.42em;text-transform:uppercase;opacity:.45}
|
| 100 |
+
.blurb{margin-top:26px;max-width:34em;font-size:14px;line-height:1.75;opacity:.66;font-weight:300}
|
| 101 |
+
.keys{margin-top:26px;display:flex;gap:10px;flex-wrap:wrap;justify-content:center;max-width:30em}
|
| 102 |
+
.k{border:1px solid rgba(244,234,214,.18);border-radius:9px;padding:9px 13px;font-size:11.5px;
|
| 103 |
+
letter-spacing:.1em;opacity:.8;background:rgba(255,255,255,.035)}
|
| 104 |
+
.k b{font-weight:600;color:#ffd899}
|
| 105 |
+
.go{margin-top:34px;font-size:12px;letter-spacing:.34em;text-transform:uppercase;opacity:.85;
|
| 106 |
+
border:1px solid rgba(255,214,138,.4);border-radius:999px;padding:15px 34px;cursor:pointer;
|
| 107 |
+
background:rgba(255,214,138,.07);transition:.25s;pointer-events:auto}
|
| 108 |
+
.go:hover{background:rgba(255,214,138,.16);letter-spacing:.4em}
|
| 109 |
+
@keyframes pulse{0%,100%{opacity:.55}50%{opacity:1}}
|
| 110 |
+
.pulse{animation:pulse 2.2s ease-in-out infinite}
|
| 111 |
+
#board{margin-top:30px;width:min(420px,90vw);text-align:left}
|
| 112 |
+
#board h3{font-size:10px;letter-spacing:.34em;text-transform:uppercase;opacity:.4;
|
| 113 |
+
margin-bottom:12px;text-align:center;font-weight:400}
|
| 114 |
+
.row{display:flex;justify-content:space-between;gap:14px;padding:8px 12px;border-radius:7px;font-size:13px}
|
| 115 |
+
.row:nth-child(odd){background:rgba(255,255,255,.04)}
|
| 116 |
+
.row.me{background:rgba(255,214,138,.16);color:#ffe6b8}
|
| 117 |
+
.row .n{opacity:.4;width:2em}
|
| 118 |
+
.row .d{opacity:.35;font-size:11px}
|
| 119 |
+
#wintime{font-size:clamp(38px,11vw,70px);font-weight:200;letter-spacing:.04em;margin-top:6px;color:#fff8e7}
|
| 120 |
+
.small{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;opacity:.4;margin-top:18px}
|
| 121 |
+
#err{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:30px;
|
| 122 |
+
text-align:center;font-size:15px;line-height:1.7;opacity:.8}
|
| 123 |
+
</style>
|
| 124 |
+
</head>
|
| 125 |
+
<body>
|
| 126 |
+
<canvas id="c"></canvas>
|
| 127 |
+
|
| 128 |
+
<div id="ui">
|
| 129 |
+
<div id="vig"></div>
|
| 130 |
+
<div id="flash"></div>
|
| 131 |
+
<div id="white"></div>
|
| 132 |
+
|
| 133 |
+
<div id="hud">
|
| 134 |
+
<div id="timer" class="mono">0.00</div>
|
| 135 |
+
<div id="sub">chase</div>
|
| 136 |
+
<div id="speed" class="mono">— m/s</div>
|
| 137 |
+
<div id="crashes" class="mono">0 falls</div>
|
| 138 |
+
<div id="gapwrap">
|
| 139 |
+
<div id="gaptrack"><div id="gapbar"></div></div>
|
| 140 |
+
<div id="gaptxt" class="mono">gap 18.0 m</div>
|
| 141 |
+
</div>
|
| 142 |
+
<div id="toast"></div>
|
| 143 |
+
<div id="pausebtn" role="button" aria-label="Pause"><i></i><i></i></div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div class="panel hide" id="pause">
|
| 147 |
+
<div class="tag">paused</div>
|
| 148 |
+
<div class="blurb mono" id="pausestat"></div>
|
| 149 |
+
<div class="go" id="resumebtn">Resume</div>
|
| 150 |
+
<div class="minirow">
|
| 151 |
+
<span class="lnk" id="restartbtn">Restart</span>
|
| 152 |
+
<span class="lnk" id="quitbtn">Quit to title</span>
|
| 153 |
+
</div>
|
| 154 |
+
<div class="small">Esc resume · R restart · M mute</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<div class="panel" id="title">
|
| 158 |
+
<h1>Grace Chase</h1>
|
| 159 |
+
<div class="tag">one ghost · always ahead</div>
|
| 160 |
+
<div class="blurb">She runs a flawless line through the golden grass. You do not.
|
| 161 |
+
Survive what she survives, close the distance, and touch her before the field forgets you were here.</div>
|
| 162 |
+
<div class="keys" id="hint"></div>
|
| 163 |
+
<div class="go pulse" id="startbtn">Begin the chase</div>
|
| 164 |
+
<div class="small" id="besttop"></div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div class="panel hide" id="win">
|
| 168 |
+
<div class="tag">caught her</div>
|
| 169 |
+
<div id="wintime" class="mono">0.00</div>
|
| 170 |
+
<div class="blurb" id="winblurb"></div>
|
| 171 |
+
<div id="board"><h3>Hall of the Chase</h3><div id="boardrows"></div></div>
|
| 172 |
+
<div class="go" id="againbtn">Run it again</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div id="err"></div>
|
| 177 |
+
|
| 178 |
+
<script>
|
| 179 |
+
"use strict";
|
| 180 |
+
(function () {
|
| 181 |
+
|
| 182 |
+
/* ================================================================
|
| 183 |
+
GRACE CHASE — a single-file WebGL2 pursuit through tall grass.
|
| 184 |
+
No external assets. No libraries. Everything below is generated.
|
| 185 |
+
================================================================ */
|
| 186 |
+
|
| 187 |
+
const CV = document.getElementById('c');
|
| 188 |
+
const gl = CV.getContext('webgl2', {antialias:true, alpha:false, depth:true,
|
| 189 |
+
powerPreference:'high-performance', stencil:false});
|
| 190 |
+
if (!gl) {
|
| 191 |
+
document.getElementById('err').style.display = 'flex';
|
| 192 |
+
document.getElementById('err').textContent =
|
| 193 |
+
'Grace Chase needs WebGL2. Try a recent Chrome, Firefox, Edge or Safari.';
|
| 194 |
+
document.querySelectorAll('.panel').forEach(p => p.style.display='none');
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* ---------------------------------------------------------------
|
| 199 |
+
tiny math
|
| 200 |
+
--------------------------------------------------------------- */
|
| 201 |
+
const M = {
|
| 202 |
+
ident(){ return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); },
|
| 203 |
+
mul(o,a,b){
|
| 204 |
+
for (let c=0;c<4;c++){
|
| 205 |
+
const b0=b[c*4],b1=b[c*4+1],b2=b[c*4+2],b3=b[c*4+3];
|
| 206 |
+
o[c*4 ]=a[0]*b0+a[4]*b1+a[8]*b2+a[12]*b3;
|
| 207 |
+
o[c*4+1]=a[1]*b0+a[5]*b1+a[9]*b2+a[13]*b3;
|
| 208 |
+
o[c*4+2]=a[2]*b0+a[6]*b1+a[10]*b2+a[14]*b3;
|
| 209 |
+
o[c*4+3]=a[3]*b0+a[7]*b1+a[11]*b2+a[15]*b3;
|
| 210 |
+
}
|
| 211 |
+
return o;
|
| 212 |
+
},
|
| 213 |
+
persp(o,fovy,asp,n,f){
|
| 214 |
+
const t=1/Math.tan(fovy/2);
|
| 215 |
+
o.fill(0); o[0]=t/asp; o[5]=t; o[11]=-1;
|
| 216 |
+
o[10]=(f+n)/(n-f); o[14]=2*f*n/(n-f); return o;
|
| 217 |
+
},
|
| 218 |
+
look(o,e,c,u){
|
| 219 |
+
let zx=e[0]-c[0], zy=e[1]-c[1], zz=e[2]-c[2];
|
| 220 |
+
let l=1/Math.hypot(zx,zy,zz); zx*=l; zy*=l; zz*=l;
|
| 221 |
+
let xx=u[1]*zz-u[2]*zy, xy=u[2]*zx-u[0]*zz, xz=u[0]*zy-u[1]*zx;
|
| 222 |
+
l=Math.hypot(xx,xy,xz); l = l ? 1/l : 0; xx*=l; xy*=l; xz*=l;
|
| 223 |
+
const yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
|
| 224 |
+
o[0]=xx; o[1]=yx; o[2]=zx; o[3]=0;
|
| 225 |
+
o[4]=xy; o[5]=yy; o[6]=zy; o[7]=0;
|
| 226 |
+
o[8]=xz; o[9]=yz; o[10]=zz; o[11]=0;
|
| 227 |
+
o[12]=-(xx*e[0]+xy*e[1]+xz*e[2]);
|
| 228 |
+
o[13]=-(yx*e[0]+yy*e[1]+yz*e[2]);
|
| 229 |
+
o[14]=-(zx*e[0]+zy*e[1]+zz*e[2]);
|
| 230 |
+
o[15]=1; return o;
|
| 231 |
+
},
|
| 232 |
+
invert(o,m){
|
| 233 |
+
const a00=m[0],a01=m[1],a02=m[2],a03=m[3], a10=m[4],a11=m[5],a12=m[6],a13=m[7],
|
| 234 |
+
a20=m[8],a21=m[9],a22=m[10],a23=m[11], a30=m[12],a31=m[13],a32=m[14],a33=m[15];
|
| 235 |
+
const b00=a00*a11-a01*a10, b01=a00*a12-a02*a10, b02=a00*a13-a03*a10,
|
| 236 |
+
b03=a01*a12-a02*a11, b04=a01*a13-a03*a11, b05=a02*a13-a03*a12,
|
| 237 |
+
b06=a20*a31-a21*a30, b07=a20*a32-a22*a30, b08=a20*a33-a23*a30,
|
| 238 |
+
b09=a21*a32-a22*a31, b10=a21*a33-a23*a31, b11=a22*a33-a23*a32;
|
| 239 |
+
let d=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06;
|
| 240 |
+
if(!d) return o; d=1/d;
|
| 241 |
+
o[0]=(a11*b11-a12*b10+a13*b09)*d; o[1]=(a02*b10-a01*b11-a03*b09)*d;
|
| 242 |
+
o[2]=(a31*b05-a32*b04+a33*b03)*d; o[3]=(a22*b04-a21*b05-a23*b03)*d;
|
| 243 |
+
o[4]=(a12*b08-a10*b11-a13*b07)*d; o[5]=(a00*b11-a02*b08+a03*b07)*d;
|
| 244 |
+
o[6]=(a32*b02-a30*b05-a33*b01)*d; o[7]=(a20*b05-a22*b02+a23*b01)*d;
|
| 245 |
+
o[8]=(a10*b10-a11*b08+a13*b06)*d; o[9]=(a01*b08-a00*b10-a03*b06)*d;
|
| 246 |
+
o[10]=(a30*b04-a31*b02+a33*b00)*d; o[11]=(a21*b02-a20*b04-a23*b00)*d;
|
| 247 |
+
o[12]=(a11*b07-a10*b09-a12*b06)*d; o[13]=(a00*b09-a01*b07+a02*b06)*d;
|
| 248 |
+
o[14]=(a31*b01-a30*b03-a32*b00)*d; o[15]=(a20*b03-a21*b01+a22*b00)*d;
|
| 249 |
+
return o;
|
| 250 |
+
},
|
| 251 |
+
axisAngle(o,x,y,z,r){
|
| 252 |
+
const l=Math.hypot(x,y,z)||1; x/=l; y/=l; z/=l;
|
| 253 |
+
const s=Math.sin(r), c=Math.cos(r), t=1-c;
|
| 254 |
+
o[0]=x*x*t+c; o[1]=y*x*t+z*s; o[2]=z*x*t-y*s; o[3]=0;
|
| 255 |
+
o[4]=x*y*t-z*s; o[5]=y*y*t+c; o[6]=z*y*t+x*s; o[7]=0;
|
| 256 |
+
o[8]=x*z*t+y*s; o[9]=y*z*t-x*s; o[10]=z*z*t+c; o[11]=0;
|
| 257 |
+
o[12]=0;o[13]=0;o[14]=0;o[15]=1; return o;
|
| 258 |
+
}
|
| 259 |
+
};
|
| 260 |
+
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
|
| 261 |
+
const lerp=(a,b,t)=>a+(b-a)*t;
|
| 262 |
+
const smooth=(a,b,t)=>a+(b-a)*(1-Math.exp(-t));
|
| 263 |
+
|
| 264 |
+
/* seeded PRNG — the entire infinite course lives in these 5 lines */
|
| 265 |
+
function rng(seed){
|
| 266 |
+
let s = seed>>>0;
|
| 267 |
+
return function(){
|
| 268 |
+
s = (s + 0x6D2B79F5)>>>0;
|
| 269 |
+
let t = Math.imul(s ^ (s>>>15), 1|s);
|
| 270 |
+
t = (t + Math.imul(t ^ (t>>>7), 61|t)) ^ t;
|
| 271 |
+
return ((t ^ (t>>>14))>>>0) / 4294967296;
|
| 272 |
+
};
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/* ---------------------------------------------------------------
|
| 276 |
+
GL helpers
|
| 277 |
+
--------------------------------------------------------------- */
|
| 278 |
+
function sh(type, src){
|
| 279 |
+
const s = gl.createShader(type);
|
| 280 |
+
gl.shaderSource(s, src); gl.compileShader(s);
|
| 281 |
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
|
| 282 |
+
throw new Error(gl.getShaderInfoLog(s) + '\n' + src.split('\n').map((l,i)=>(i+1)+': '+l).join('\n'));
|
| 283 |
+
return s;
|
| 284 |
+
}
|
| 285 |
+
function prog(vs, fs){
|
| 286 |
+
const p = gl.createProgram();
|
| 287 |
+
gl.attachShader(p, sh(gl.VERTEX_SHADER, vs));
|
| 288 |
+
gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs));
|
| 289 |
+
gl.linkProgram(p);
|
| 290 |
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
|
| 291 |
+
const cache = {};
|
| 292 |
+
return { p, u(n){ if(!(n in cache)) cache[n]=gl.getUniformLocation(p,n); return cache[n]; },
|
| 293 |
+
use(){ gl.useProgram(p); return this; } };
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* shared GLSL: atmosphere, hashing, lighting */
|
| 297 |
+
const COMMON = `
|
| 298 |
+
const vec3 SUN = normalize(vec3(-0.42, 0.40, 0.81));
|
| 299 |
+
const vec3 SUNCOL = vec3(1.00, 0.86, 0.60);
|
| 300 |
+
const vec3 SKYLOW = vec3(0.98, 0.79, 0.52);
|
| 301 |
+
const vec3 SKYHI = vec3(0.15, 0.30, 0.60);
|
| 302 |
+
float hash11(float p){ p = fract(p*0.1031); p *= p+33.33; p *= p+p; return fract(p); }
|
| 303 |
+
vec3 hash31(float p){
|
| 304 |
+
vec3 q = fract(vec3(p)*vec3(0.1031,0.1030,0.0973));
|
| 305 |
+
q += dot(q, q.yzx+33.33); return fract((q.xxy+q.yzz)*q.zyx);
|
| 306 |
+
}
|
| 307 |
+
float vnoise(vec2 p){
|
| 308 |
+
vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);
|
| 309 |
+
float a=hash11(i.x+i.y*57.0), b=hash11(i.x+1.0+i.y*57.0);
|
| 310 |
+
float c=hash11(i.x+(i.y+1.0)*57.0), d=hash11(i.x+1.0+(i.y+1.0)*57.0);
|
| 311 |
+
return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);
|
| 312 |
+
}
|
| 313 |
+
float fbm(vec2 p){ float s=0.0, a=0.5; for(int i=0;i<4;i++){ s+=a*vnoise(p); p*=2.03; a*=0.5;} return s; }
|
| 314 |
+
vec3 skyColor(vec3 d){
|
| 315 |
+
float h = clamp(d.y*0.5+0.5, 0.0, 1.0);
|
| 316 |
+
vec3 c = mix(SKYLOW, SKYHI, pow(h, 0.72));
|
| 317 |
+
float sun = max(dot(d, SUN), 0.0);
|
| 318 |
+
c += SUNCOL * pow(sun, 60.0) * 1.9;
|
| 319 |
+
c += SUNCOL * pow(sun, 6.0) * 0.30;
|
| 320 |
+
c = mix(c, vec3(1.0,0.93,0.78), pow(1.0-abs(d.y), 9.0)*0.55);
|
| 321 |
+
return c;
|
| 322 |
+
}
|
| 323 |
+
vec3 applyFog(vec3 col, float dist, vec3 dir){
|
| 324 |
+
float f = 1.0 - exp(-pow(dist*0.0088, 1.55));
|
| 325 |
+
return mix(col, skyColor(normalize(dir)), clamp(f,0.0,1.0));
|
| 326 |
+
}`;
|
| 327 |
+
|
| 328 |
+
/* ---------------------------------------------------------------
|
| 329 |
+
SKY (fullscreen triangle, no attributes)
|
| 330 |
+
--------------------------------------------------------------- */
|
| 331 |
+
const SKY = prog(`#version 300 es
|
| 332 |
+
precision highp float;
|
| 333 |
+
uniform mat4 uInvVP; uniform vec3 uCam;
|
| 334 |
+
out vec3 vDir;
|
| 335 |
+
void main(){
|
| 336 |
+
vec2 p = vec2(float((gl_VertexID & 1) << 2) - 1.0, float((gl_VertexID & 2) << 1) - 1.0);
|
| 337 |
+
vec4 w = uInvVP * vec4(p, 1.0, 1.0);
|
| 338 |
+
vDir = w.xyz / w.w - uCam;
|
| 339 |
+
gl_Position = vec4(p, 1.0, 1.0);
|
| 340 |
+
}`,
|
| 341 |
+
`#version 300 es
|
| 342 |
+
precision highp float;
|
| 343 |
+
${COMMON}
|
| 344 |
+
in vec3 vDir; out vec4 o;
|
| 345 |
+
uniform float uTime;
|
| 346 |
+
void main(){
|
| 347 |
+
vec3 d = normalize(vDir);
|
| 348 |
+
vec3 c = skyColor(d);
|
| 349 |
+
// thin high cloud streaks, procedurally drifting
|
| 350 |
+
if (d.y > 0.02) {
|
| 351 |
+
vec2 sp = d.xz / max(d.y, 0.02) * 0.055 + vec2(uTime*0.004, uTime*0.0016);
|
| 352 |
+
float cl = fbm(sp*1.6);
|
| 353 |
+
cl = smoothstep(0.52, 0.86, cl) * smoothstep(0.0, 0.35, d.y);
|
| 354 |
+
c = mix(c, vec3(1.0, 0.95, 0.88), cl*0.55);
|
| 355 |
+
}
|
| 356 |
+
o = vec4(c, 1.0);
|
| 357 |
+
}`);
|
| 358 |
+
|
| 359 |
+
/* ---------------------------------------------------------------
|
| 360 |
+
GROUND (4-vertex strip, generated in the vertex shader)
|
| 361 |
+
--------------------------------------------------------------- */
|
| 362 |
+
const GROUND = prog(`#version 300 es
|
| 363 |
+
precision highp float;
|
| 364 |
+
uniform mat4 uVP; uniform float uCamZ;
|
| 365 |
+
out vec3 vW;
|
| 366 |
+
void main(){
|
| 367 |
+
float x = ((gl_VertexID & 1) == 1) ? 130.0 : -130.0;
|
| 368 |
+
float z = ((gl_VertexID & 2) == 2) ? uCamZ + 300.0 : uCamZ - 60.0;
|
| 369 |
+
vW = vec3(x, 0.0, z);
|
| 370 |
+
gl_Position = uVP * vec4(vW, 1.0);
|
| 371 |
+
}`,
|
| 372 |
+
`#version 300 es
|
| 373 |
+
precision highp float;
|
| 374 |
+
${COMMON}
|
| 375 |
+
in vec3 vW; out vec4 o;
|
| 376 |
+
uniform vec3 uCam;
|
| 377 |
+
void main(){
|
| 378 |
+
vec2 p = vW.xz;
|
| 379 |
+
float n = fbm(p*0.14);
|
| 380 |
+
float n2 = fbm(p*0.62 + 11.3);
|
| 381 |
+
vec3 soil = vec3(0.19, 0.16, 0.10);
|
| 382 |
+
vec3 grass = mix(vec3(0.34,0.30,0.13), vec3(0.52,0.44,0.17), n);
|
| 383 |
+
vec3 c = mix(soil, grass, 0.55 + 0.45*n2);
|
| 384 |
+
// the worn line the Ghost's ancestors have run forever
|
| 385 |
+
float lane = exp(-abs(vW.x)*0.055);
|
| 386 |
+
c = mix(c, vec3(0.46,0.40,0.20), lane*0.35);
|
| 387 |
+
float d = distance(vW, uCam);
|
| 388 |
+
c *= 0.55 + 0.45*clamp(1.0 - d*0.004, 0.0, 1.0);
|
| 389 |
+
o = vec4(applyFog(c, d, vW - uCam), 1.0);
|
| 390 |
+
}`);
|
| 391 |
+
|
| 392 |
+
/* ---------------------------------------------------------------
|
| 393 |
+
GRASS — instanced blades, positioned & animated entirely on GPU
|
| 394 |
+
--------------------------------------------------------------- */
|
| 395 |
+
const GRASS = prog(`#version 300 es
|
| 396 |
+
precision highp float;
|
| 397 |
+
${COMMON}
|
| 398 |
+
layout(location=0) in vec2 aTS; // .x = height param 0..1, .y = side -1/+1
|
| 399 |
+
uniform mat4 uVP;
|
| 400 |
+
uniform vec3 uCam;
|
| 401 |
+
uniform float uTime, uFW, uFL, uGust;
|
| 402 |
+
uniform vec3 uBall, uGhost; // spheres flatten the grass they pass through
|
| 403 |
+
out vec3 vN, vW; out float vT, vTone;
|
| 404 |
+
|
| 405 |
+
void main(){
|
| 406 |
+
float id = float(gl_InstanceID);
|
| 407 |
+
vec3 h = hash31(id*1.13 + 7.0);
|
| 408 |
+
vec3 h2 = hash31(id*2.71 + 91.0);
|
| 409 |
+
|
| 410 |
+
float zl = h.y * uFL;
|
| 411 |
+
float wz = zl + floor((uCam.z - 14.0 - zl) / uFL + 1.0) * uFL;
|
| 412 |
+
float wx = h.x * uFW - uFW*0.5;
|
| 413 |
+
|
| 414 |
+
float clump = fbm(vec2(wx, wz)*0.09);
|
| 415 |
+
float hgt = mix(0.30, 0.86, h.z) * (0.60 + 0.85*clump);
|
| 416 |
+
float ang = h2.x * 6.2831;
|
| 417 |
+
vec2 rgt = vec2(cos(ang), sin(ang));
|
| 418 |
+
|
| 419 |
+
float t = aTS.x;
|
| 420 |
+
// wind: two travelling waves plus a gust envelope
|
| 421 |
+
float w = sin(uTime*1.55 + wx*0.28 + wz*0.19) * 0.5
|
| 422 |
+
+ sin(uTime*2.90 + wz*0.44 - wx*0.11) * 0.26
|
| 423 |
+
+ (fbm(vec2(wz*0.05 - uTime*0.55, wx*0.05)) - 0.5) * 1.1;
|
| 424 |
+
w *= 0.26 + uGust*0.55;
|
| 425 |
+
float lean = (h2.y - 0.5) * 0.34;
|
| 426 |
+
|
| 427 |
+
// flatten under the spheres
|
| 428 |
+
vec3 base = vec3(wx, 0.0, wz);
|
| 429 |
+
float pb = 1.0 - smoothstep(0.30, 1.55, distance(base.xz, uBall.xz)) * clamp(1.3-uBall.y,0.0,1.0);
|
| 430 |
+
float pg = 1.0 - smoothstep(0.30, 1.35, distance(base.xz, uGhost.xz)) * clamp(1.3-uGhost.y,0.0,1.0);
|
| 431 |
+
float press = min(pb, pg);
|
| 432 |
+
hgt *= mix(0.22, 1.0, press);
|
| 433 |
+
|
| 434 |
+
float bend = (w + lean) * t * t;
|
| 435 |
+
float wide = 0.052 * (1.0 - t*0.92) * (0.7 + 0.6*h.z);
|
| 436 |
+
vec3 pos = base;
|
| 437 |
+
pos.xz += rgt * aTS.y * wide;
|
| 438 |
+
pos.y += t * hgt * (1.0 - bend*bend*0.28);
|
| 439 |
+
pos.xz += vec2(-rgt.y, rgt.x) * bend * hgt * 0.72;
|
| 440 |
+
|
| 441 |
+
vec3 face = normalize(vec3(-rgt.y, 0.55 + bend*0.5, rgt.x));
|
| 442 |
+
vN = face; vW = pos; vT = t; vTone = h2.z;
|
| 443 |
+
gl_Position = uVP * vec4(pos, 1.0);
|
| 444 |
+
}`,
|
| 445 |
+
`#version 300 es
|
| 446 |
+
precision highp float;
|
| 447 |
+
${COMMON}
|
| 448 |
+
in vec3 vN, vW; in float vT, vTone; out vec4 o;
|
| 449 |
+
uniform vec3 uCam;
|
| 450 |
+
void main(){
|
| 451 |
+
vec3 root = vec3(0.16, 0.17, 0.07);
|
| 452 |
+
vec3 mid = vec3(0.47, 0.40, 0.14);
|
| 453 |
+
vec3 tip = mix(vec3(0.92, 0.79, 0.40), vec3(1.00, 0.90, 0.58), vTone);
|
| 454 |
+
vec3 alb = mix(root, mid, smoothstep(0.0, 0.55, vT));
|
| 455 |
+
alb = mix(alb, tip, smoothstep(0.45, 1.0, vT));
|
| 456 |
+
|
| 457 |
+
vec3 n = normalize(vN);
|
| 458 |
+
vec3 v = normalize(uCam - vW);
|
| 459 |
+
if (dot(n, v) < 0.0) n = -n;
|
| 460 |
+
float dif = max(dot(n, SUN), 0.0);
|
| 461 |
+
// subsurface: the sun shining THROUGH a thin blade is the whole look
|
| 462 |
+
float trans = pow(max(dot(-v, SUN)*0.5+0.5, 0.0), 3.0) * vT;
|
| 463 |
+
float spec = pow(max(dot(reflect(-SUN, n), v), 0.0), 26.0);
|
| 464 |
+
|
| 465 |
+
vec3 c = alb * (0.30 + 0.85*dif)
|
| 466 |
+
+ SUNCOL * trans * 0.62 * alb
|
| 467 |
+
+ SUNCOL * spec * 0.30
|
| 468 |
+
+ vec3(0.20,0.26,0.38) * 0.16 * vT;
|
| 469 |
+
|
| 470 |
+
float d = distance(vW, uCam);
|
| 471 |
+
o = vec4(applyFog(c, d, vW - uCam), 1.0);
|
| 472 |
+
}`);
|
| 473 |
+
|
| 474 |
+
/* ---------------------------------------------------------------
|
| 475 |
+
OBJECTS — obstacles (matte), player sphere (chrome), ghost (light)
|
| 476 |
+
--------------------------------------------------------------- */
|
| 477 |
+
const OBJ = prog(`#version 300 es
|
| 478 |
+
precision highp float;
|
| 479 |
+
layout(location=0) in vec3 aP;
|
| 480 |
+
layout(location=1) in vec3 aN;
|
| 481 |
+
uniform mat4 uVP, uModel;
|
| 482 |
+
out vec3 vW, vN;
|
| 483 |
+
void main(){
|
| 484 |
+
vec4 w = uModel * vec4(aP, 1.0);
|
| 485 |
+
vW = w.xyz;
|
| 486 |
+
vN = normalize(mat3(uModel) * aN);
|
| 487 |
+
gl_Position = uVP * w;
|
| 488 |
+
}`,
|
| 489 |
+
`#version 300 es
|
| 490 |
+
precision highp float;
|
| 491 |
+
${COMMON}
|
| 492 |
+
in vec3 vW, vN; out vec4 o;
|
| 493 |
+
uniform vec3 uCam, uColor, uEmit;
|
| 494 |
+
uniform float uMetal, uAlpha, uRough;
|
| 495 |
+
void main(){
|
| 496 |
+
vec3 n = normalize(vN), v = normalize(uCam - vW);
|
| 497 |
+
float dif = max(dot(n, SUN), 0.0);
|
| 498 |
+
float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0);
|
| 499 |
+
vec3 c;
|
| 500 |
+
if (uMetal > 0.5) {
|
| 501 |
+
vec3 r = reflect(-v, n);
|
| 502 |
+
vec3 env = skyColor(r);
|
| 503 |
+
if (r.y < 0.0) { // the field, reflected
|
| 504 |
+
float k = clamp(-r.y*3.0, 0.0, 1.0);
|
| 505 |
+
env = mix(env, vec3(0.30, 0.26, 0.11), k*0.88);
|
| 506 |
+
}
|
| 507 |
+
float spec = pow(max(dot(reflect(-SUN, n), v), 0.0), mix(500.0, 40.0, uRough));
|
| 508 |
+
c = env * uColor * (0.86 + 0.3*dif) + SUNCOL*spec*1.5 + fres*0.35;
|
| 509 |
+
// a faint equator seam so the roll is readable
|
| 510 |
+
float seam = smoothstep(0.985, 1.0, 1.0 - abs(n.y));
|
| 511 |
+
c *= 1.0 - seam*0.20;
|
| 512 |
+
} else {
|
| 513 |
+
vec3 amb = mix(vec3(0.16,0.19,0.28), vec3(0.55,0.48,0.33), n.y*0.5+0.5);
|
| 514 |
+
float spec = pow(max(dot(reflect(-SUN, n), v), 0.0), 30.0);
|
| 515 |
+
c = uColor*(amb + SUNCOL*dif*0.95) + SUNCOL*spec*0.25*(1.0-uRough) + uColor*fres*0.30;
|
| 516 |
+
}
|
| 517 |
+
c += uEmit * (0.55 + fres*1.7);
|
| 518 |
+
float d = distance(vW, uCam);
|
| 519 |
+
o = vec4(applyFog(c, d, vW - uCam), uAlpha);
|
| 520 |
+
}`);
|
| 521 |
+
|
| 522 |
+
/* ---------------------------------------------------------------
|
| 523 |
+
POINTS — dust, sparks, and the Ghost's fading breath
|
| 524 |
+
--------------------------------------------------------------- */
|
| 525 |
+
const PTS = prog(`#version 300 es
|
| 526 |
+
precision highp float;
|
| 527 |
+
layout(location=0) in vec3 aP;
|
| 528 |
+
layout(location=1) in vec4 aC; // rgb + alpha
|
| 529 |
+
layout(location=2) in float aS; // world size
|
| 530 |
+
uniform mat4 uVP; uniform vec2 uRes;
|
| 531 |
+
out vec4 vC;
|
| 532 |
+
void main(){
|
| 533 |
+
vec4 cp = uVP * vec4(aP, 1.0);
|
| 534 |
+
gl_Position = cp;
|
| 535 |
+
gl_PointSize = clamp(aS * uRes.y / max(cp.w, 0.4), 1.0, 220.0);
|
| 536 |
+
vC = aC;
|
| 537 |
+
}`,
|
| 538 |
+
`#version 300 es
|
| 539 |
+
precision highp float;
|
| 540 |
+
in vec4 vC; out vec4 o;
|
| 541 |
+
void main(){
|
| 542 |
+
vec2 d = gl_PointCoord - 0.5;
|
| 543 |
+
float r = dot(d,d);
|
| 544 |
+
if (r > 0.25) discard;
|
| 545 |
+
float a = vC.a * pow(1.0 - r*4.0, 1.7);
|
| 546 |
+
o = vec4(vC.rgb * a, a);
|
| 547 |
+
}`);
|
| 548 |
+
|
| 549 |
+
/* ---------------------------------------------------------------
|
| 550 |
+
geometry
|
| 551 |
+
--------------------------------------------------------------- */
|
| 552 |
+
function makeBlade(seg){
|
| 553 |
+
const a = [];
|
| 554 |
+
for (let i=0;i<seg;i++){ const t=i/seg; a.push(t,-1, t,1); }
|
| 555 |
+
a.push(1,0);
|
| 556 |
+
const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
|
| 557 |
+
const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);
|
| 558 |
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(a), gl.STATIC_DRAW);
|
| 559 |
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0);
|
| 560 |
+
gl.bindVertexArray(null);
|
| 561 |
+
return { vao, count: seg*2+1 };
|
| 562 |
+
}
|
| 563 |
+
function mesh(pos, nrm, idx){
|
| 564 |
+
const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
|
| 565 |
+
const pb=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,pb);
|
| 566 |
+
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(pos),gl.STATIC_DRAW);
|
| 567 |
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,0,0);
|
| 568 |
+
const nb=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,nb);
|
| 569 |
+
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(nrm),gl.STATIC_DRAW);
|
| 570 |
+
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,3,gl.FLOAT,false,0,0);
|
| 571 |
+
const ib=gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ib);
|
| 572 |
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint16Array(idx),gl.STATIC_DRAW);
|
| 573 |
+
gl.bindVertexArray(null);
|
| 574 |
+
return { vao, count: idx.length };
|
| 575 |
+
}
|
| 576 |
+
function sphereMesh(seg, ring){
|
| 577 |
+
const p=[],n=[],i=[];
|
| 578 |
+
for (let y=0;y<=ring;y++){
|
| 579 |
+
const v=y/ring, th=v*Math.PI, sy=Math.cos(th), sr=Math.sin(th);
|
| 580 |
+
for (let x=0;x<=seg;x++){
|
| 581 |
+
const u=x/seg, ph=u*Math.PI*2;
|
| 582 |
+
const px=Math.cos(ph)*sr, py=sy, pz=Math.sin(ph)*sr;
|
| 583 |
+
p.push(px,py,pz); n.push(px,py,pz);
|
| 584 |
+
}
|
| 585 |
+
}
|
| 586 |
+
for (let y=0;y<ring;y++) for (let x=0;x<seg;x++){
|
| 587 |
+
const a=y*(seg+1)+x, b=a+seg+1;
|
| 588 |
+
i.push(a,b,a+1, b,b+1,a+1);
|
| 589 |
+
}
|
| 590 |
+
return mesh(p,n,i);
|
| 591 |
+
}
|
| 592 |
+
function boxMesh(){
|
| 593 |
+
const f=[[0,0,1],[0,0,-1],[1,0,0],[-1,0,0],[0,1,0],[0,-1,0]];
|
| 594 |
+
const p=[],n=[],i=[];
|
| 595 |
+
for (let k=0;k<6;k++){
|
| 596 |
+
const nn=f[k];
|
| 597 |
+
let u,v;
|
| 598 |
+
if (Math.abs(nn[1])===1){ u=[1,0,0]; v=[0,0,nn[1]]; }
|
| 599 |
+
else if (Math.abs(nn[0])===1){ u=[0,0,-nn[0]]; v=[0,1,0]; }
|
| 600 |
+
else { u=[nn[2],0,0]; v=[0,1,0]; }
|
| 601 |
+
const base=p.length/3;
|
| 602 |
+
for (const [su,sv] of [[-1,-1],[1,-1],[1,1],[-1,1]]){
|
| 603 |
+
p.push(nn[0]+u[0]*su+v[0]*sv, nn[1]+u[1]*su+v[1]*sv, nn[2]+u[2]*su+v[2]*sv);
|
| 604 |
+
n.push(nn[0],nn[1],nn[2]);
|
| 605 |
+
}
|
| 606 |
+
i.push(base,base+1,base+2, base,base+2,base+3);
|
| 607 |
+
}
|
| 608 |
+
return mesh(p,n,i);
|
| 609 |
+
}
|
| 610 |
+
function cylMesh(seg){
|
| 611 |
+
const p=[],n=[],i=[];
|
| 612 |
+
for (let k=0;k<=seg;k++){
|
| 613 |
+
const a=k/seg*Math.PI*2, c=Math.cos(a), s=Math.sin(a);
|
| 614 |
+
p.push(c,-1,s); n.push(c,0,s);
|
| 615 |
+
p.push(c, 1,s); n.push(c,0,s);
|
| 616 |
+
}
|
| 617 |
+
for (let k=0;k<seg;k++){ const a=k*2; i.push(a,a+1,a+2, a+1,a+3,a+2); }
|
| 618 |
+
const cb=p.length/3;
|
| 619 |
+
p.push(0,1,0); n.push(0,1,0);
|
| 620 |
+
for (let k=0;k<=seg;k++){ const a=k/seg*Math.PI*2; p.push(Math.cos(a),1,Math.sin(a)); n.push(0,1,0); }
|
| 621 |
+
for (let k=0;k<seg;k++) i.push(cb, cb+1+k, cb+2+k);
|
| 622 |
+
return mesh(p,n,i);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
const BLADE = makeBlade(5);
|
| 626 |
+
const SPHERE = sphereMesh(34, 22);
|
| 627 |
+
const LOSPH = sphereMesh(16, 11);
|
| 628 |
+
const BOX = boxMesh();
|
| 629 |
+
const CYL = cylMesh(14);
|
| 630 |
+
|
| 631 |
+
/* point cloud buffers */
|
| 632 |
+
const PTBUF = (() => {
|
| 633 |
+
const MAX = 3200;
|
| 634 |
+
const pos=new Float32Array(MAX*3), col=new Float32Array(MAX*4), siz=new Float32Array(MAX);
|
| 635 |
+
const vao=gl.createVertexArray(); gl.bindVertexArray(vao);
|
| 636 |
+
const bp=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,bp);
|
| 637 |
+
gl.bufferData(gl.ARRAY_BUFFER,pos.byteLength,gl.DYNAMIC_DRAW);
|
| 638 |
+
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,0,0);
|
| 639 |
+
const bc=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,bc);
|
| 640 |
+
gl.bufferData(gl.ARRAY_BUFFER,col.byteLength,gl.DYNAMIC_DRAW);
|
| 641 |
+
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,4,gl.FLOAT,false,0,0);
|
| 642 |
+
const bs=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,bs);
|
| 643 |
+
gl.bufferData(gl.ARRAY_BUFFER,siz.byteLength,gl.DYNAMIC_DRAW);
|
| 644 |
+
gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,1,gl.FLOAT,false,0,0);
|
| 645 |
+
gl.bindVertexArray(null);
|
| 646 |
+
return {vao,MAX,pos,col,siz,bp,bc,bs};
|
| 647 |
+
})();
|
| 648 |
+
|
| 649 |
+
/* ---------------------------------------------------------------
|
| 650 |
+
audio — every sound synthesised at runtime
|
| 651 |
+
--------------------------------------------------------------- */
|
| 652 |
+
const Audio_ = {
|
| 653 |
+
ctx:null, ok:false, master:null, windG:null, rumbleG:null, rumbleO:null, windF:null, muted:false,
|
| 654 |
+
init(){
|
| 655 |
+
if (this.ctx) { if (this.ctx.state==='suspended') this.ctx.resume(); return; }
|
| 656 |
+
const AC = window.AudioContext || window.webkitAudioContext;
|
| 657 |
+
if (!AC) return;
|
| 658 |
+
try {
|
| 659 |
+
const c = new AC(); this.ctx = c;
|
| 660 |
+
const m = c.createGain(); m.gain.value = 0.0; m.connect(c.destination); this.master = m;
|
| 661 |
+
|
| 662 |
+
// noise bed
|
| 663 |
+
const len = c.sampleRate*2, buf = c.createBuffer(1, len, c.sampleRate), d = buf.getChannelData(0);
|
| 664 |
+
let last=0;
|
| 665 |
+
for (let i=0;i<len;i++){ const w=Math.random()*2-1; last = last*0.86 + w*0.14; d[i]=last*2.2; }
|
| 666 |
+
const src = c.createBufferSource(); src.buffer = buf; src.loop = true;
|
| 667 |
+
const f = c.createBiquadFilter(); f.type='bandpass'; f.frequency.value=520; f.Q.value=0.7;
|
| 668 |
+
const g = c.createGain(); g.gain.value=0.0;
|
| 669 |
+
src.connect(f); f.connect(g); g.connect(m); src.start();
|
| 670 |
+
this.windG = g; this.windF = f;
|
| 671 |
+
|
| 672 |
+
// rolling rumble
|
| 673 |
+
const o = c.createOscillator(); o.type='sawtooth'; o.frequency.value=52;
|
| 674 |
+
const lf = c.createBiquadFilter(); lf.type='lowpass'; lf.frequency.value=220; lf.Q.value=3.0;
|
| 675 |
+
const rg = c.createGain(); rg.gain.value=0.0;
|
| 676 |
+
o.connect(lf); lf.connect(rg); rg.connect(m); o.start();
|
| 677 |
+
this.rumbleG = rg; this.rumbleO = o;
|
| 678 |
+
this.ok = true;
|
| 679 |
+
} catch(e){ this.ok = false; }
|
| 680 |
+
},
|
| 681 |
+
vol(v){ if(this.ok) this.master.gain.setTargetAtTime(this.muted?0:v, this.ctx.currentTime, 0.25); },
|
| 682 |
+
drive(speed, air){
|
| 683 |
+
if (!this.ok) return;
|
| 684 |
+
const t=this.ctx.currentTime, k=clamp(speed/26,0,1.4);
|
| 685 |
+
this.windG.gain.setTargetAtTime(0.028+k*0.10, t, 0.15);
|
| 686 |
+
this.windF.frequency.setTargetAtTime(420+k*760, t, 0.2);
|
| 687 |
+
this.rumbleG.gain.setTargetAtTime(air?0.004:(0.020+k*0.048), t, 0.09);
|
| 688 |
+
this.rumbleO.frequency.setTargetAtTime(38+k*46, t, 0.12);
|
| 689 |
+
},
|
| 690 |
+
blip(f0,f1,dur,type,vol){
|
| 691 |
+
if (!this.ok) return;
|
| 692 |
+
const c=this.ctx, t=c.currentTime;
|
| 693 |
+
const o=c.createOscillator(), g=c.createGain();
|
| 694 |
+
o.type=type||'sine'; o.frequency.setValueAtTime(f0,t);
|
| 695 |
+
o.frequency.exponentialRampToValueAtTime(Math.max(f1,20), t+dur);
|
| 696 |
+
g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(vol||0.16, t+0.008);
|
| 697 |
+
g.gain.exponentialRampToValueAtTime(0.0008, t+dur);
|
| 698 |
+
o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.03);
|
| 699 |
+
},
|
| 700 |
+
crash(){
|
| 701 |
+
if (!this.ok) return;
|
| 702 |
+
const c=this.ctx, t=c.currentTime, len=(c.sampleRate*0.5)|0;
|
| 703 |
+
const b=c.createBuffer(1,len,c.sampleRate), d=b.getChannelData(0);
|
| 704 |
+
for (let i=0;i<len;i++){ const e=1-i/len; d[i]=(Math.random()*2-1)*e*e; }
|
| 705 |
+
const s=c.createBufferSource(); s.buffer=b;
|
| 706 |
+
const f=c.createBiquadFilter(); f.type='lowpass';
|
| 707 |
+
f.frequency.setValueAtTime(2600,t); f.frequency.exponentialRampToValueAtTime(160,t+0.45);
|
| 708 |
+
const g=c.createGain(); g.gain.value=0.30;
|
| 709 |
+
s.connect(f); f.connect(g); g.connect(this.master); s.start(t);
|
| 710 |
+
this.blip(150, 44, 0.42, 'triangle', 0.22);
|
| 711 |
+
},
|
| 712 |
+
win(){
|
| 713 |
+
if (!this.ok) return;
|
| 714 |
+
const base=[392,523.25,659.25,783.99,1046.5];
|
| 715 |
+
base.forEach((f,i)=>{
|
| 716 |
+
const c=this.ctx, t=c.currentTime+i*0.085;
|
| 717 |
+
const o=c.createOscillator(), g=c.createGain();
|
| 718 |
+
o.type='sine'; o.frequency.value=f;
|
| 719 |
+
g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(0.15,t+0.02);
|
| 720 |
+
g.gain.exponentialRampToValueAtTime(0.0008,t+2.4);
|
| 721 |
+
o.connect(g); g.connect(this.master); o.start(t); o.stop(t+2.5);
|
| 722 |
+
});
|
| 723 |
+
}
|
| 724 |
+
};
|
| 725 |
+
|
| 726 |
+
/* ---------------------------------------------------------------
|
| 727 |
+
input — the chameleon half
|
| 728 |
+
--------------------------------------------------------------- */
|
| 729 |
+
/* Touch step sizes are recomputed on resize, never inside the gesture handler —
|
| 730 |
+
reading innerWidth on every touchmove risks a style/layout flush. */
|
| 731 |
+
let STEP_H = 24, STEP_V = 30, DEAD_PX = 5;
|
| 732 |
+
function updateTouchSteps(){
|
| 733 |
+
const m = Math.min(innerWidth, innerHeight);
|
| 734 |
+
STEP_H = clamp(m * 0.052, 22, 48); // one lane per this much thumb travel
|
| 735 |
+
STEP_V = clamp(m * 0.064, 26, 60); // deliberately larger, so sliding never fires a hop
|
| 736 |
+
DEAD_PX = Math.min(5, STEP_H * 0.22); // ignore thumb jitter on a tap
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
const Input = {
|
| 740 |
+
mode: 'kb', // 'kb' | 'touch' | 'pad'
|
| 741 |
+
lead: 0, // -1..1, how far into the next lane your thumb is
|
| 742 |
+
keys: Object.create(null),
|
| 743 |
+
steer: 0, jumpEdge: false, duckHold: false,
|
| 744 |
+
hopQueue: 0, duckPulse: 0,
|
| 745 |
+
padIdx: null,
|
| 746 |
+
touch: { id:null, ax:0, ay:0, acted:false },
|
| 747 |
+
onModeChange: null,
|
| 748 |
+
setMode(m){
|
| 749 |
+
if (this.mode === m) return;
|
| 750 |
+
this.mode = m;
|
| 751 |
+
if (this.onModeChange) this.onModeChange(m);
|
| 752 |
+
},
|
| 753 |
+
init(){
|
| 754 |
+
addEventListener('keydown', e => {
|
| 755 |
+
if (e.repeat) { return; }
|
| 756 |
+
const k = e.key.toLowerCase();
|
| 757 |
+
this.keys[k] = true;
|
| 758 |
+
if ([' ','arrowup','arrowdown','arrowleft','arrowright'].includes(k)) e.preventDefault();
|
| 759 |
+
if (k === ' ' || k === 'w' || k === 'arrowup') this.jumpEdge = true;
|
| 760 |
+
this.setMode('kb');
|
| 761 |
+
if (k === 'm') { Audio_.muted = !Audio_.muted; Audio_.vol(0.9); }
|
| 762 |
+
}, {passive:false});
|
| 763 |
+
addEventListener('keyup', e => { this.keys[e.key.toLowerCase()] = false; });
|
| 764 |
+
|
| 765 |
+
/* ---- touch ----------------------------------------------------
|
| 766 |
+
Not flick-detection. Your thumb stays down and the ball tracks it:
|
| 767 |
+
every STEP_H pixels of sideways travel ratchets one lane, so a single
|
| 768 |
+
unbroken drag can cross the whole field. Fires on move, never waits
|
| 769 |
+
for you to lift, and has no time limit — slow drags work fine.
|
| 770 |
+
Each axis keeps its own anchor, so you can slide and hop in one motion.
|
| 771 |
+
---------------------------------------------------------------- */
|
| 772 |
+
const el = document.body;
|
| 773 |
+
const T = this.touch;
|
| 774 |
+
|
| 775 |
+
el.addEventListener('touchstart', e => {
|
| 776 |
+
this.setMode('touch');
|
| 777 |
+
if (T.id !== null) return; // ignore extra fingers
|
| 778 |
+
const t = e.changedTouches[0];
|
| 779 |
+
T.id = t.identifier; T.ax = t.clientX; T.ay = t.clientY; this.lead = 0;
|
| 780 |
+
}, {passive:true});
|
| 781 |
+
|
| 782 |
+
// Records intent only. No game state, no maths beyond a few compares, and
|
| 783 |
+
// not a single object allocated — nothing here can stutter a drag.
|
| 784 |
+
el.addEventListener('touchmove', e => {
|
| 785 |
+
if (T.id === null) return;
|
| 786 |
+
const ct = e.changedTouches;
|
| 787 |
+
let tx = 0, ty = 0, got = false;
|
| 788 |
+
for (let i = 0; i < ct.length; i++)
|
| 789 |
+
if (ct[i].identifier === T.id) { tx = ct[i].clientX; ty = ct[i].clientY; got = true; }
|
| 790 |
+
if (!got) return;
|
| 791 |
+
|
| 792 |
+
// sideways: ratchet one lane per STEP_H, repeatedly, without lifting
|
| 793 |
+
let dx = tx - T.ax, guard = 0;
|
| 794 |
+
while ((dx >= STEP_H || dx <= -STEP_H) && guard++ < 5) {
|
| 795 |
+
const s = dx < 0 ? -1 : 1;
|
| 796 |
+
this.hopQueue += s; // queued, so none are dropped
|
| 797 |
+
T.ax += s * STEP_H;
|
| 798 |
+
dx = tx - T.ax;
|
| 799 |
+
buzz(7);
|
| 800 |
+
}
|
| 801 |
+
// ...and lean continuously in between, so the ball tracks your thumb 1:1
|
| 802 |
+
const ad = (dx < 0 ? -dx : dx) - DEAD_PX;
|
| 803 |
+
this.lead = ad <= 0 ? 0 : (dx < 0 ? -1 : 1) * ad / (STEP_H - DEAD_PX);
|
| 804 |
+
|
| 805 |
+
// vertical: re-arms after each fire. The dominance test stops a sideways
|
| 806 |
+
// drag from being misread as a hop.
|
| 807 |
+
const dy = ty - T.ay, ady = dy < 0 ? -dy : dy;
|
| 808 |
+
if (ady >= STEP_V && ady >= (dx < 0 ? -dx : dx) * 0.9) {
|
| 809 |
+
if (dy < 0) this.jumpEdge = true; else this.duckPulse = 0.5;
|
| 810 |
+
T.ay = ty;
|
| 811 |
+
buzz(11);
|
| 812 |
+
}
|
| 813 |
+
}, {passive:true});
|
| 814 |
+
|
| 815 |
+
const endTouch = e => {
|
| 816 |
+
if (T.id === null) return;
|
| 817 |
+
for (let i = 0; i < e.changedTouches.length; i++)
|
| 818 |
+
if (e.changedTouches[i].identifier === T.id) { T.id = null; this.lead = 0; return; }
|
| 819 |
+
};
|
| 820 |
+
el.addEventListener('touchend', endTouch, {passive:true});
|
| 821 |
+
el.addEventListener('touchcancel', endTouch, {passive:true});
|
| 822 |
+
addEventListener('gamepadconnected', e => { this.padIdx = e.gamepad.index; this.setMode('pad'); });
|
| 823 |
+
addEventListener('gamepaddisconnected', () => { this.padIdx = null; this.setMode('kb'); });
|
| 824 |
+
if (('ontouchstart' in window) && matchMedia('(pointer: coarse)').matches) this.mode = 'touch';
|
| 825 |
+
},
|
| 826 |
+
poll(dt){
|
| 827 |
+
let s = 0, jump = false, duck = false;
|
| 828 |
+
const k = this.keys;
|
| 829 |
+
if (k['a'] || k['arrowleft']) s -= 1;
|
| 830 |
+
if (k['d'] || k['arrowright']) s += 1;
|
| 831 |
+
if (k['s'] || k['arrowdown'] || k['shift']) duck = true;
|
| 832 |
+
|
| 833 |
+
if (this.padIdx !== null && navigator.getGamepads) {
|
| 834 |
+
const gp = navigator.getGamepads()[this.padIdx];
|
| 835 |
+
if (gp) {
|
| 836 |
+
// either stick steers — whichever one you're actually pushing
|
| 837 |
+
const axL = gp.axes[0] || 0, axR = gp.axes[2] || 0;
|
| 838 |
+
const ax = Math.abs(axR) > Math.abs(axL) ? axR : axL;
|
| 839 |
+
if (Math.abs(ax) > 0.14) { s = ax; this.setMode('pad'); }
|
| 840 |
+
// d-pad, for anyone who'd rather flick than lean
|
| 841 |
+
const dl = gp.buttons[14] && gp.buttons[14].pressed;
|
| 842 |
+
const dr = gp.buttons[15] && gp.buttons[15].pressed;
|
| 843 |
+
if (dl || dr) { s = (dr ? 1 : 0) - (dl ? 1 : 0); this.setMode('pad'); }
|
| 844 |
+
const A = gp.buttons[0] && gp.buttons[0].pressed;
|
| 845 |
+
const B = (gp.buttons[1] && gp.buttons[1].pressed) ||
|
| 846 |
+
(gp.buttons[6] && gp.buttons[6].value > 0.4);
|
| 847 |
+
if (A && !this._padA) { this.jumpEdge = true; this.setMode('pad'); }
|
| 848 |
+
this._padA = A;
|
| 849 |
+
if (B) { duck = true; this.setMode('pad'); }
|
| 850 |
+
}
|
| 851 |
+
}
|
| 852 |
+
if (s !== 0 && this.mode === 'touch') this.setMode('kb');
|
| 853 |
+
|
| 854 |
+
this.duckPulse = Math.max(0, this.duckPulse - dt);
|
| 855 |
+
if (this.duckPulse > 0) duck = true;
|
| 856 |
+
|
| 857 |
+
this.steer = s;
|
| 858 |
+
this.duckHold = duck;
|
| 859 |
+
jump = this.jumpEdge; this.jumpEdge = false;
|
| 860 |
+
const hop = this.hopQueue; this.hopQueue = 0; // whole queue, so fast drags keep every step
|
| 861 |
+
return { steer:s, jump, duck, hop };
|
| 862 |
+
}
|
| 863 |
+
};
|
| 864 |
+
/* ---------------------------------------------------------------
|
| 865 |
+
native bridge — used when wrapped for an app store, completely
|
| 866 |
+
inert in a browser. The file stays one file.
|
| 867 |
+
--------------------------------------------------------------- */
|
| 868 |
+
const Native = { on:false, hap:null, app:null, wake:null };
|
| 869 |
+
|
| 870 |
+
// iOS WKWebView has no Vibration API at all, so on native we go through the
|
| 871 |
+
// real Taptic Engine instead and fall back to navigator.vibrate on the web.
|
| 872 |
+
function buzz(ms){
|
| 873 |
+
if (Native.hap) {
|
| 874 |
+
try {
|
| 875 |
+
if (Array.isArray(ms)) { Native.hap.notification({ type:'WARNING' }); return; }
|
| 876 |
+
Native.hap.impact({ style: ms >= 24 ? 'HEAVY' : ms >= 16 ? 'MEDIUM' : 'LIGHT' });
|
| 877 |
+
return;
|
| 878 |
+
} catch(e){ /* fall through to the web API */ }
|
| 879 |
+
}
|
| 880 |
+
if (navigator.vibrate) { try { navigator.vibrate(ms); } catch(e){} }
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
// a runner that dims the screen mid-chase is a one-star review
|
| 884 |
+
function keepAwake(on){
|
| 885 |
+
try {
|
| 886 |
+
if (on) {
|
| 887 |
+
if (!Native.wake && navigator.wakeLock)
|
| 888 |
+
navigator.wakeLock.request('screen')
|
| 889 |
+
.then(w => { Native.wake = w; w.addEventListener('release', () => { Native.wake = null; }); })
|
| 890 |
+
.catch(() => {});
|
| 891 |
+
} else if (Native.wake) {
|
| 892 |
+
Native.wake.release().catch(()=>{}); Native.wake = null;
|
| 893 |
+
}
|
| 894 |
+
} catch(e){ Native.wake = null; }
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
/* ---------------------------------------------------------------
|
| 898 |
+
the course — deterministic, infinite, weightless
|
| 899 |
+
--------------------------------------------------------------- */
|
| 900 |
+
const LANE = 2.2, BOUND = 5.7, PR = 0.55, JUMPV = 6.6, GRAV = 17.2;
|
| 901 |
+
const LANES = [-2,-1,0,1,2].map(i => i*LANE);
|
| 902 |
+
|
| 903 |
+
const World = {
|
| 904 |
+
seed: 1, obs: [], genZ: 0, evIdx: 0, rand: null,
|
| 905 |
+
reset(seed){
|
| 906 |
+
this.seed = seed>>>0; this.obs.length = 0; this.genZ = 46; this.evIdx = 0;
|
| 907 |
+
},
|
| 908 |
+
// difficulty is a property of WHERE you are in the field, not how long you've
|
| 909 |
+
// been alive — so falling over and over never spirals out of reach
|
| 910 |
+
diff(z){ return clamp((z - 40) / 1150, 0, 1); },
|
| 911 |
+
ensure(z){
|
| 912 |
+
let guard = 0;
|
| 913 |
+
while (this.genZ < z && guard++ < 300) {
|
| 914 |
+
const d = this.diff(this.genZ);
|
| 915 |
+
// an event reserves the ground it actually occupies, so a multi-beat combo
|
| 916 |
+
// can never overrun into the next event's slot
|
| 917 |
+
const extra = this.event(this.evIdx++, this.genZ, d) || 0;
|
| 918 |
+
// never tighter than a full jump arc at top speed, or the field becomes a lie
|
| 919 |
+
this.genZ += extra + lerp(44, 29, d) * (0.92 + rng(this.seed ^ (this.evIdx*7919))() * 0.20);
|
| 920 |
+
}
|
| 921 |
+
},
|
| 922 |
+
push(o){ o.x0 = o.x; this.obs.push(o); },
|
| 923 |
+
// a full-width wall with an opening at lane `open` (width in lanes)
|
| 924 |
+
gapWall(z, open, wide, h){
|
| 925 |
+
const l = LANES[open] - LANE*wide*0.5, r = LANES[open] + LANE*wide*0.5;
|
| 926 |
+
const L = -BOUND-2.5, R = BOUND+2.5;
|
| 927 |
+
if (l > L+0.2) this.push({x:(L+l)/2, z, hw:(l-L)/2, hl:0.42, y0:0, y1:h, k:'w'});
|
| 928 |
+
if (R > r+0.2) this.push({x:(r+R)/2, z, hw:(R-r)/2, hl:0.42, y0:0, y1:h, k:'w'});
|
| 929 |
+
},
|
| 930 |
+
event(i, z, d){
|
| 931 |
+
const R = rng(this.seed ^ Math.imul(i+1, 0x9E3779B1));
|
| 932 |
+
const pick = R();
|
| 933 |
+
const lane = () => Math.floor(R()*5);
|
| 934 |
+
if (i < 2) { this.gapWall(z, lane(), 2.6, 2.6); return 0; }
|
| 935 |
+
if (i < 4) { this.gapWall(z, lane(), 2.2, 2.6); return 0; }
|
| 936 |
+
|
| 937 |
+
let kinds = ['gap','hurdle'];
|
| 938 |
+
if (d > 0.16) kinds.push('gap','pend');
|
| 939 |
+
if (d > 0.34) kinds.push('roll','hurdle');
|
| 940 |
+
if (d > 0.52) kinds.push('pend','roll','gap');
|
| 941 |
+
if (d > 0.66) kinds.push('combo','combo');
|
| 942 |
+
const kind = kinds[Math.floor(pick*kinds.length) % kinds.length];
|
| 943 |
+
|
| 944 |
+
if (kind === 'gap') {
|
| 945 |
+
this.gapWall(z, lane(), lerp(2.1,1.6,d), 2.6);
|
| 946 |
+
return 0;
|
| 947 |
+
}
|
| 948 |
+
if (kind === 'hurdle') {
|
| 949 |
+
// low wall spanning everything: hop it
|
| 950 |
+
this.push({x:0, z, hw:BOUND+2.5, hl:0.40, y0:0, y1:lerp(0.72,0.95,d), k:'h'});
|
| 951 |
+
return 0;
|
| 952 |
+
}
|
| 953 |
+
if (kind === 'pend') {
|
| 954 |
+
// hanging blocker: duck under it
|
| 955 |
+
this.push({x:0, z, hw:BOUND+2.5, hl:0.42, y0:lerp(1.14,1.04,d), y1:3.4, k:'p',
|
| 956 |
+
amp:0, spd:1.6+R(), ph:R()*6.28});
|
| 957 |
+
return 0;
|
| 958 |
+
}
|
| 959 |
+
if (kind === 'roll') {
|
| 960 |
+
// two logs in their own bands — they can crowd you, but they can never
|
| 961 |
+
// between them seal the corridor
|
| 962 |
+
const n = 2;
|
| 963 |
+
for (let j=0;j<n;j++){
|
| 964 |
+
this.push({x: (j===0?-2.9:2.9), z: z + j*2.8, hw:0.78, hl:0.60, y0:0, y1:0.95, k:'r',
|
| 965 |
+
amp: lerp(1.2,2.2,d)*(R()>0.5?1:-1), spd: lerp(0.8,1.6,d)*(0.7+R()*0.6),
|
| 966 |
+
ph: R()*6.28});
|
| 967 |
+
}
|
| 968 |
+
return (n-1)*2.8;
|
| 969 |
+
}
|
| 970 |
+
// combo: slip the gap, THEN hop — the rhythm test.
|
| 971 |
+
// the beats must sit further apart than a single jump lasts (~19 m at top speed).
|
| 972 |
+
const l = lane();
|
| 973 |
+
this.gapWall(z, l, lerp(1.9,1.7,d), 2.6);
|
| 974 |
+
this.push({x:0, z: z + 21.0, hw:BOUND+2.5, hl:0.40, y0:0, y1:0.88, k:'h'});
|
| 975 |
+
if (d > 0.80) { this.gapWall(z + 42.0, lane(), 1.7, 2.6); return 42.0; }
|
| 976 |
+
return 21.0;
|
| 977 |
+
},
|
| 978 |
+
update(t){
|
| 979 |
+
for (const o of this.obs) {
|
| 980 |
+
if (o.amp) o.x = o.x0 + Math.sin(t*o.spd + o.ph) * o.amp;
|
| 981 |
+
if (o.k === 'p') o.sw = Math.sin(t*o.spd*0.8 + o.ph) * 0.30;
|
| 982 |
+
}
|
| 983 |
+
},
|
| 984 |
+
prune(z){
|
| 985 |
+
if (this.obs.length && this.obs[0].z < z - 40) {
|
| 986 |
+
let i = 0; while (i < this.obs.length && this.obs[i].z < z - 40) i++;
|
| 987 |
+
this.obs.splice(0, i);
|
| 988 |
+
}
|
| 989 |
+
},
|
| 990 |
+
// is x safe at z, at ground height?
|
| 991 |
+
blockedAt(x, z, ymin, ymax, r){
|
| 992 |
+
for (const o of this.obs) {
|
| 993 |
+
if (Math.abs(o.z - z) > o.hl + r + 0.05) continue;
|
| 994 |
+
if (Math.abs(o.x - x) > o.hw + r) continue;
|
| 995 |
+
if (ymax > o.y0 && ymin < o.y1) return o;
|
| 996 |
+
}
|
| 997 |
+
return null;
|
| 998 |
+
}
|
| 999 |
+
};
|
| 1000 |
+
|
| 1001 |
+
/* ---------------------------------------------------------------
|
| 1002 |
+
entities
|
| 1003 |
+
--------------------------------------------------------------- */
|
| 1004 |
+
function newBall(){
|
| 1005 |
+
return { x:0, y:PR, z:0, vx:0, vy:0, air:false, duck:0, rot:M.ident(), lane:2, tx:0, spin:0 };
|
| 1006 |
+
}
|
| 1007 |
+
const player = newBall();
|
| 1008 |
+
const ghost = newBall();
|
| 1009 |
+
let ghostTrail = []; // {x,y,z,t}
|
| 1010 |
+
let dust = []; // {x,y,z,vx,vy,vz,life,max,r,g,b,s}
|
| 1011 |
+
|
| 1012 |
+
/* ---------------------------------------------------------------
|
| 1013 |
+
game state
|
| 1014 |
+
--------------------------------------------------------------- */
|
| 1015 |
+
const S = {
|
| 1016 |
+
phase: 'title', // title | run | won
|
| 1017 |
+
t: 0, clock: 0, clean: 0, crashes: 0,
|
| 1018 |
+
gap: 34, startGap: 34,
|
| 1019 |
+
tumble: 0, invuln: 0, shake: 0, flash: 0,
|
| 1020 |
+
breath: 0, camDist: 8.2, camHi: 3.15, camX: 0,
|
| 1021 |
+
grassN: 0, gust: 0, whiteout: 0, best: null, paused: false
|
| 1022 |
+
};
|
| 1023 |
+
|
| 1024 |
+
const DOM = {
|
| 1025 |
+
timer: document.getElementById('timer'), sub: document.getElementById('sub'),
|
| 1026 |
+
speed: document.getElementById('speed'), crashes: document.getElementById('crashes'),
|
| 1027 |
+
gapbar: document.getElementById('gapbar'), gaptxt: document.getElementById('gaptxt'),
|
| 1028 |
+
hud: document.getElementById('hud'), flash: document.getElementById('flash'),
|
| 1029 |
+
white: document.getElementById('white'), title: document.getElementById('title'),
|
| 1030 |
+
win: document.getElementById('win'), wintime: document.getElementById('wintime'),
|
| 1031 |
+
winblurb: document.getElementById('winblurb'), rows: document.getElementById('boardrows'),
|
| 1032 |
+
hint: document.getElementById('hint'), toast: document.getElementById('toast'),
|
| 1033 |
+
besttop: document.getElementById('besttop'), pause: document.getElementById('pause'),
|
| 1034 |
+
pausestat: document.getElementById('pausestat')
|
| 1035 |
+
};
|
| 1036 |
+
|
| 1037 |
+
const LS_KEY = 'graceChase.v1';
|
| 1038 |
+
function loadBoard(){
|
| 1039 |
+
try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch(e){ return []; }
|
| 1040 |
+
}
|
| 1041 |
+
function saveScore(sc){
|
| 1042 |
+
const b = loadBoard(); b.push(sc);
|
| 1043 |
+
b.sort((a,c) => a.time - c.time);
|
| 1044 |
+
const top = b.slice(0, 12);
|
| 1045 |
+
try { localStorage.setItem(LS_KEY, JSON.stringify(top)); } catch(e){}
|
| 1046 |
+
return top;
|
| 1047 |
+
}
|
| 1048 |
+
function fmt(t){ return t.toFixed(2); }
|
| 1049 |
+
function showBest(){
|
| 1050 |
+
const b = loadBoard();
|
| 1051 |
+
DOM.besttop.textContent = b.length
|
| 1052 |
+
? 'best catch · ' + fmt(b[0].time) + 's · ' + b[0].falls + ' falls'
|
| 1053 |
+
: 'no one has caught her yet';
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
function renderHint(){
|
| 1057 |
+
const m = Input.mode;
|
| 1058 |
+
const H = m === 'touch'
|
| 1059 |
+
? [['Drag ←/→','slide lanes · keep dragging'],['Flick ↑','bunny hop'],
|
| 1060 |
+
['Flick ↓','brake & duck'],['❙❙ top right','pause']]
|
| 1061 |
+
: m === 'pad'
|
| 1062 |
+
? [['Stick / D-pad','carve'],['A','bunny hop'],['B / LT','brake & duck'],
|
| 1063 |
+
['Esc','pause']]
|
| 1064 |
+
: [['A D ←→','carve'],['Space','bunny hop'],['S ↓','brake & duck'],
|
| 1065 |
+
['Esc','pause'],['R','restart']];
|
| 1066 |
+
DOM.hint.innerHTML = H.map(h => '<div class="k"><b>'+h[0]+'</b> '+h[1]+'</div>').join('');
|
| 1067 |
+
DOM.sub.textContent = m === 'touch' ? 'swipe' : m === 'pad' ? 'gamepad' : 'chase';
|
| 1068 |
+
}
|
| 1069 |
+
Input.onModeChange = () => {
|
| 1070 |
+
renderHint();
|
| 1071 |
+
if (S.phase === 'run') toast(
|
| 1072 |
+
Input.mode === 'touch' ? 'swipe control' :
|
| 1073 |
+
Input.mode === 'pad' ? 'gamepad control' : 'keyboard control');
|
| 1074 |
+
};
|
| 1075 |
+
function toast(txt){
|
| 1076 |
+
DOM.toast.textContent = txt;
|
| 1077 |
+
DOM.toast.classList.remove('toasting');
|
| 1078 |
+
void DOM.toast.offsetWidth;
|
| 1079 |
+
DOM.toast.classList.add('toasting');
|
| 1080 |
+
}
|
| 1081 |
+
function renderBoard(mark){
|
| 1082 |
+
const b = loadBoard();
|
| 1083 |
+
DOM.rows.innerHTML = b.slice(0,8).map((s,i) =>
|
| 1084 |
+
'<div class="row'+(mark && Math.abs(s.time-mark.time)<1e-9 && s.d===mark.d ? ' me' : '')+'">'+
|
| 1085 |
+
'<span class="n">'+(i+1)+'</span>'+
|
| 1086 |
+
'<span class="mono">'+fmt(s.time)+'s</span>'+
|
| 1087 |
+
'<span class="d">'+s.falls+' falls</span>'+
|
| 1088 |
+
'<span class="d">'+s.d+'</span>'+
|
| 1089 |
+
'</div>').join('') || '<div class="row"><span class="d">no one has caught her yet</span></div>';
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
/* ---------------------------------------------------------------
|
| 1093 |
+
speeds
|
| 1094 |
+
--------------------------------------------------------------- */
|
| 1095 |
+
// speed rises with ground covered, not with time survived
|
| 1096 |
+
function baseSpeed(){ return 13.2 + Math.min(11.8, Math.max(0, player.z) * 0.0092); }
|
| 1097 |
+
// you only gain on her once you've earned a few clean seconds — and you lose it all in one fall
|
| 1098 |
+
function playerSpeed(){
|
| 1099 |
+
const bonus = Math.min(0.85, Math.max(0, (S.clean - 3.5) * 0.13));
|
| 1100 |
+
const pen = S.tumble > 0 ? -5.5 : 0;
|
| 1101 |
+
const duck = player.duck > 0 ? -3.4 : 0;
|
| 1102 |
+
return Math.max(5, baseSpeed() + bonus + pen + duck);
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
/* ---------------------------------------------------------------
|
| 1106 |
+
Ghost AI — she reads the field one and a half seconds ahead
|
| 1107 |
+
--------------------------------------------------------------- */
|
| 1108 |
+
const GMARGIN = 0.42;
|
| 1109 |
+
// Reads the field ahead of a ball and returns the line through it.
|
| 1110 |
+
// The Ghost obeys this perfectly; it is also the guarantee that every
|
| 1111 |
+
// course this file can generate is survivable with either control scheme.
|
| 1112 |
+
function solveLine(bx, bz, speed, lane){
|
| 1113 |
+
const zA = bz - 1.2, zB = bz + speed * 1.4;
|
| 1114 |
+
let nz = 1e9;
|
| 1115 |
+
for (const o of World.obs) {
|
| 1116 |
+
const oz = o.z - o.hl;
|
| 1117 |
+
if (oz < zA || oz > zB) continue;
|
| 1118 |
+
if (oz < nz) nz = oz;
|
| 1119 |
+
}
|
| 1120 |
+
if (nz > 1e8) return { tx: bx, jump:false, duck:false, eta: 9 };
|
| 1121 |
+
|
| 1122 |
+
const grp = World.obs.filter(o => Math.abs((o.z - o.hl) - nz) < 5.4);
|
| 1123 |
+
const eta = Math.max(0.001, (nz - bz) / speed);
|
| 1124 |
+
// each obstacle predicted at ITS OWN arrival time, not the group's
|
| 1125 |
+
const etaOf = o => Math.max(0.001, (o.z - o.hl - bz) / speed);
|
| 1126 |
+
const xOf = o => o.amp ? o.x0 + Math.sin((S.t + etaOf(o)) * o.spd + o.ph) * o.amp : o.x;
|
| 1127 |
+
// a rolling log is judged across a slice of time, not one instant, so a late
|
| 1128 |
+
// arrival can never be punished by a metre the solver never looked at
|
| 1129 |
+
const blocks = (x, o) => {
|
| 1130 |
+
const m = o.hw + PR + (o.amp ? 0.78 : GMARGIN);
|
| 1131 |
+
if (!o.amp) return Math.abs(x - o.x) < m;
|
| 1132 |
+
const e = etaOf(o);
|
| 1133 |
+
for (let k = -2; k <= 2; k++) {
|
| 1134 |
+
const ox = o.x0 + Math.sin((S.t + e + k*0.10) * o.spd + o.ph) * o.amp;
|
| 1135 |
+
if (Math.abs(x - ox) < m) return true;
|
| 1136 |
+
}
|
| 1137 |
+
return false;
|
| 1138 |
+
};
|
| 1139 |
+
|
| 1140 |
+
let bestX = null, bestC = 1e9;
|
| 1141 |
+
const step = lane ? LANE : 0.25;
|
| 1142 |
+
const from = lane ? -LANE*2 : -BOUND;
|
| 1143 |
+
for (let x = from; x <= BOUND + 1e-6; x += step) {
|
| 1144 |
+
let hit = false;
|
| 1145 |
+
for (const o of grp) {
|
| 1146 |
+
if (o.y0 > 0.45) continue; // it hangs; a lane change won't help
|
| 1147 |
+
if (blocks(x, o)) { hit = true; break; }
|
| 1148 |
+
}
|
| 1149 |
+
if (hit) continue;
|
| 1150 |
+
const c = Math.abs(x - bx) + Math.abs(x) * 0.05;
|
| 1151 |
+
if (c < bestC) { bestC = c; bestX = x; }
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
let act = 0; // 1 = hop, 2 = duck
|
| 1155 |
+
for (const o of grp) {
|
| 1156 |
+
const e = etaOf(o);
|
| 1157 |
+
const clearLat = bestX !== null && !blocks(bestX, o);
|
| 1158 |
+
if (o.y0 < 0.06 && o.y1 < 1.32 && !clearLat && e < 0.44) act = 1;
|
| 1159 |
+
if (o.y0 > 0.50 && o.y1 > 2.00 && e < 0.32) act = 2;
|
| 1160 |
+
}
|
| 1161 |
+
if (bestX === null && act === 0) {
|
| 1162 |
+
let maxTop = 0, minBot = 9;
|
| 1163 |
+
for (const o of grp) { maxTop = Math.max(maxTop, o.y1); minBot = Math.min(minBot, o.y0); }
|
| 1164 |
+
if (minBot > 0.55) { if (eta < 0.32) act = 2; }
|
| 1165 |
+
else if (eta < 0.44) act = 1;
|
| 1166 |
+
}
|
| 1167 |
+
return { tx: bestX === null ? bx : bestX, jump: act === 1, duck: act === 2, eta };
|
| 1168 |
+
}
|
| 1169 |
+
|
| 1170 |
+
function ghostThink(dt, gs){
|
| 1171 |
+
const a = solveLine(ghost.x, ghost.z, gs, false);
|
| 1172 |
+
if (a.jump && !ghost.air) { ghost.vy = JUMPV; ghost.air = true; }
|
| 1173 |
+
if (a.duck) ghost.duck = Math.max(ghost.duck, 0.34);
|
| 1174 |
+
ghost.x = lerp(ghost.x, clamp(a.tx, -BOUND, BOUND), clamp(dt * 10.0, 0, 1));
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
/* ---------------------------------------------------------------
|
| 1178 |
+
step
|
| 1179 |
+
--------------------------------------------------------------- */
|
| 1180 |
+
function stepBall(b, dt, groundY){
|
| 1181 |
+
b.vy -= GRAV * dt;
|
| 1182 |
+
b.y += b.vy * dt;
|
| 1183 |
+
if (b.y <= groundY) { if (b.air) { b.air = false; b.vy = 0; } b.y = groundY; b.vy = Math.max(0, b.vy); }
|
| 1184 |
+
else b.air = true;
|
| 1185 |
+
b.duck = Math.max(0, b.duck - dt);
|
| 1186 |
+
}
|
| 1187 |
+
const _rollA = new Float32Array(16), _rollB = new Float32Array(16);
|
| 1188 |
+
function rollBall(b, dz, dx){
|
| 1189 |
+
const d = Math.hypot(dz, dx);
|
| 1190 |
+
if (d < 1e-6) return;
|
| 1191 |
+
// spin axis = up × travel direction = (dz, 0, -dx) / d
|
| 1192 |
+
const inc = M.axisAngle(_rollA, dz/d, 0, -dx/d, d / PR);
|
| 1193 |
+
b.rot = M.mul(_rollB.slice(), inc, b.rot);
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
function spawnDust(x,y,z,n,speed,r,g,bl,size){
|
| 1197 |
+
for (let i=0;i<n;i++){
|
| 1198 |
+
if (dust.length > 900) break;
|
| 1199 |
+
const a = Math.random()*6.283, e = Math.random();
|
| 1200 |
+
dust.push({
|
| 1201 |
+
x:x+(Math.random()-0.5)*0.6, y:y+Math.random()*0.4, z:z+(Math.random()-0.5)*0.6,
|
| 1202 |
+
vx:Math.cos(a)*speed*e, vy:Math.random()*speed*0.9+0.4, vz:Math.sin(a)*speed*e - 2.0,
|
| 1203 |
+
life:0, max:0.6+Math.random()*0.9, r, g, b:bl, s:size*(0.6+Math.random()*0.8)
|
| 1204 |
+
});
|
| 1205 |
+
}
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
function crash(o){
|
| 1209 |
+
if (S.invuln > 0 || S.tumble > 0) return;
|
| 1210 |
+
S.crashes++; S.clean = 0; S.tumble = 0.95; S.invuln = 1.5; S.shake = 1;
|
| 1211 |
+
S.flash = 1;
|
| 1212 |
+
player.z -= 4.5; // you go tumbling backwards. she does not wait.
|
| 1213 |
+
player.vx = (player.x - o.x) * 2.6 + (Math.random()-0.5)*3;
|
| 1214 |
+
player.vy = 4.6; player.air = true;
|
| 1215 |
+
player.spin = 14 + Math.random()*8;
|
| 1216 |
+
spawnDust(player.x, player.y, player.z, 120, 6.5, 0.82, 0.72, 0.42, 0.16);
|
| 1217 |
+
Audio_.crash(); buzz([26, 40, 24]);
|
| 1218 |
+
DOM.flash.style.opacity = '0.95';
|
| 1219 |
+
setTimeout(()=>{ DOM.flash.style.opacity='0'; }, 130);
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
function startRun(){
|
| 1223 |
+
Audio_.init(); Audio_.vol(0.9);
|
| 1224 |
+
S.paused = false; DOM.pause.classList.add('hide'); keepAwake(true);
|
| 1225 |
+
World.reset((Math.random()*0xFFFFFFFF)>>>0);
|
| 1226 |
+
Object.assign(player, newBall());
|
| 1227 |
+
Object.assign(ghost, newBall());
|
| 1228 |
+
ghostTrail = []; dust = [];
|
| 1229 |
+
S.phase='run'; S.t=0; S.clock=0; S.clean=0; S.crashes=0;
|
| 1230 |
+
S.gap = S.startGap; S.tumble=0; S.invuln=1.2; S.shake=0; S.breath=0; S.whiteout=0;
|
| 1231 |
+
ghost.z = player.z + S.gap;
|
| 1232 |
+
DOM.title.classList.add('hide'); DOM.win.classList.add('hide');
|
| 1233 |
+
DOM.hud.classList.add('on'); DOM.white.style.opacity='0';
|
| 1234 |
+
toast('go');
|
| 1235 |
+
}
|
| 1236 |
+
function setPaused(p){
|
| 1237 |
+
if (S.phase !== 'run' || S.paused === p) return;
|
| 1238 |
+
S.paused = p;
|
| 1239 |
+
DOM.pause.classList.toggle('hide', !p);
|
| 1240 |
+
if (p) {
|
| 1241 |
+
DOM.pausestat.innerHTML = fmt(S.clock) + 's · ' + S.gap.toFixed(1) +
|
| 1242 |
+
' m behind her · ' + S.crashes + (S.crashes===1?' fall':' falls');
|
| 1243 |
+
if (Audio_.ok) Audio_.vol(0);
|
| 1244 |
+
} else {
|
| 1245 |
+
last = performance.now(); // don't hand the next frame a huge dt
|
| 1246 |
+
if (Audio_.ok) Audio_.vol(0.9);
|
| 1247 |
+
}
|
| 1248 |
+
}
|
| 1249 |
+
function quitToTitle(){
|
| 1250 |
+
S.paused = false; S.phase = 'title'; keepAwake(false);
|
| 1251 |
+
DOM.pause.classList.add('hide');
|
| 1252 |
+
DOM.win.classList.add('hide');
|
| 1253 |
+
DOM.hud.classList.remove('on');
|
| 1254 |
+
DOM.title.classList.remove('hide');
|
| 1255 |
+
DOM.white.style.opacity = '0';
|
| 1256 |
+
if (Audio_.ok) Audio_.vol(0);
|
| 1257 |
+
Object.assign(player, newBall());
|
| 1258 |
+
Object.assign(ghost, newBall());
|
| 1259 |
+
ghostTrail = []; dust = [];
|
| 1260 |
+
World.reset(12345); World.ensure(300);
|
| 1261 |
+
ghost.z = 12; S.gap = S.startGap; S.breath = 0; S.shake = 0; S.tumble = 0;
|
| 1262 |
+
showBest();
|
| 1263 |
+
}
|
| 1264 |
+
function winRun(){
|
| 1265 |
+
S.phase='won'; S.whiteout=1; keepAwake(false);
|
| 1266 |
+
Audio_.win(); buzz([18,60,18,60,120]);
|
| 1267 |
+
DOM.white.style.opacity='1';
|
| 1268 |
+
const now = new Date();
|
| 1269 |
+
const sc = { time:S.clock, falls:S.crashes,
|
| 1270 |
+
d: now.toLocaleDateString(undefined,{month:'short',day:'numeric'}) };
|
| 1271 |
+
saveScore(sc);
|
| 1272 |
+
setTimeout(()=>{
|
| 1273 |
+
DOM.white.style.opacity='0';
|
| 1274 |
+
DOM.hud.classList.remove('on');
|
| 1275 |
+
DOM.wintime.textContent = fmt(sc.time) + 's';
|
| 1276 |
+
DOM.winblurb.textContent = S.crashes === 0
|
| 1277 |
+
? 'Not once did the grass close over you. She never even heard you coming.'
|
| 1278 |
+
: S.crashes < 4
|
| 1279 |
+
? 'A few stumbles in the gold, and still you reached her.'
|
| 1280 |
+
: 'You fell ' + S.crashes + ' times and got up ' + S.crashes + ' times. That is the whole trick.';
|
| 1281 |
+
renderBoard(sc);
|
| 1282 |
+
DOM.win.classList.remove('hide');
|
| 1283 |
+
}, 850);
|
| 1284 |
+
}
|
| 1285 |
+
|
| 1286 |
+
/* ---------------------------------------------------------------
|
| 1287 |
+
main loop
|
| 1288 |
+
--------------------------------------------------------------- */
|
| 1289 |
+
let W=0, H=0, DPR=1;
|
| 1290 |
+
function resize(){
|
| 1291 |
+
DPR = Math.min(devicePixelRatio || 1, isCoarse ? 1.5 : 1.75);
|
| 1292 |
+
W = Math.max(2, Math.floor(innerWidth * DPR));
|
| 1293 |
+
H = Math.max(2, Math.floor(innerHeight * DPR));
|
| 1294 |
+
CV.width = W; CV.height = H;
|
| 1295 |
+
updateTouchSteps(); // gesture sizes follow the viewport, not the gesture
|
| 1296 |
+
}
|
| 1297 |
+
addEventListener('orientationchange', () => setTimeout(updateTouchSteps, 120));
|
| 1298 |
+
addEventListener('resize', resize);
|
| 1299 |
+
|
| 1300 |
+
const isCoarse = ('ontouchstart' in window) && matchMedia('(pointer: coarse)').matches;
|
| 1301 |
+
S.grassN = isCoarse ? 34000 : 105000;
|
| 1302 |
+
const GRASS_MAX = isCoarse ? 46000 : 150000;
|
| 1303 |
+
const FW = 76, FL = 152;
|
| 1304 |
+
|
| 1305 |
+
const vp = M.ident(), view = M.ident(), proj = M.ident(), invVP = M.ident(), model = M.ident();
|
| 1306 |
+
const tmpA = M.ident(), tmpB = M.ident();
|
| 1307 |
+
|
| 1308 |
+
let last = performance.now(), fpsAcc = 0, fpsN = 0;
|
| 1309 |
+
|
| 1310 |
+
function drawMesh(m){ gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.count, gl.UNSIGNED_SHORT, 0); }
|
| 1311 |
+
|
| 1312 |
+
function setModel(px,py,pz, sx,sy,sz, rot){
|
| 1313 |
+
const m = model;
|
| 1314 |
+
if (rot) {
|
| 1315 |
+
m.set(rot);
|
| 1316 |
+
m[0]*=sx; m[1]*=sx; m[2]*=sx;
|
| 1317 |
+
m[4]*=sy; m[5]*=sy; m[6]*=sy;
|
| 1318 |
+
m[8]*=sz; m[9]*=sz; m[10]*=sz;
|
| 1319 |
+
} else {
|
| 1320 |
+
m.fill(0); m[0]=sx; m[5]=sy; m[10]=sz; m[15]=1;
|
| 1321 |
+
}
|
| 1322 |
+
m[3]=0; m[7]=0; m[11]=0; m[15]=1;
|
| 1323 |
+
m[12]=px; m[13]=py; m[14]=pz;
|
| 1324 |
+
return m;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
function frame(now){
|
| 1328 |
+
requestAnimationFrame(frame);
|
| 1329 |
+
let dt = (now - last) / 1000; last = now;
|
| 1330 |
+
if (dt > 0.05) dt = 0.05;
|
| 1331 |
+
if (dt <= 0) return;
|
| 1332 |
+
if (CV.width !== Math.floor(innerWidth*DPR)) resize();
|
| 1333 |
+
// paused: the field stops breathing entirely, but we keep drawing it
|
| 1334 |
+
if (S.paused) { render(0); return; }
|
| 1335 |
+
S.t += dt;
|
| 1336 |
+
|
| 1337 |
+
/* adaptive grass density */
|
| 1338 |
+
fpsAcc += dt; fpsN++;
|
| 1339 |
+
if (fpsN >= 45) {
|
| 1340 |
+
const avg = fpsAcc / fpsN; fpsAcc = 0; fpsN = 0;
|
| 1341 |
+
if (avg > 0.0215 && S.grassN > 14000) S.grassN = Math.floor(S.grassN * 0.82);
|
| 1342 |
+
else if (avg < 0.0138 && S.grassN < GRASS_MAX) S.grassN = Math.floor(S.grassN * 1.08);
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
const inp = Input.poll(dt);
|
| 1346 |
+
S.gust = 0.5 + 0.5 * Math.sin(S.t * 0.31) * Math.sin(S.t * 0.13 + 1.7);
|
| 1347 |
+
|
| 1348 |
+
if (S.phase === 'run') {
|
| 1349 |
+
S.clock += dt;
|
| 1350 |
+
S.clean += dt;
|
| 1351 |
+
S.invuln = Math.max(0, S.invuln - dt);
|
| 1352 |
+
S.tumble = Math.max(0, S.tumble - dt);
|
| 1353 |
+
S.shake = Math.max(0, S.shake - dt*2.1);
|
| 1354 |
+
|
| 1355 |
+
// the field must exist ahead of whoever is furthest along — she often is
|
| 1356 |
+
World.ensure(Math.max(player.z, ghost.z) + 260);
|
| 1357 |
+
World.prune(player.z);
|
| 1358 |
+
World.update(S.t);
|
| 1359 |
+
|
| 1360 |
+
/* ---- player control: two souls, one ball ---- */
|
| 1361 |
+
if (S.tumble <= 0) {
|
| 1362 |
+
if (Input.mode === 'touch') {
|
| 1363 |
+
if (inp.hop) {
|
| 1364 |
+
const nl = clamp(player.lane + inp.hop, 0, 4);
|
| 1365 |
+
if (nl !== player.lane) { player.lane = nl; Audio_.blip(660, 900, 0.07, 'triangle', 0.07); }
|
| 1366 |
+
}
|
| 1367 |
+
// The ball leans toward your thumb between lanes instead of waiting for
|
| 1368 |
+
// a threshold to snap. One lane of lean == one STEP_H of travel, so when
|
| 1369 |
+
// the ratchet fires the target doesn't jump at all — it's continuous.
|
| 1370 |
+
player.tx = clamp(LANES[player.lane] + Input.lead * LANE, -BOUND, BOUND);
|
| 1371 |
+
// near-critically-damped slide; stiff enough that a queued double-hop
|
| 1372 |
+
// still arrives before the next obstacle does
|
| 1373 |
+
player.vx += (player.tx - player.x) * 62 * dt;
|
| 1374 |
+
player.vx *= Math.exp(-11.0 * dt);
|
| 1375 |
+
player.vx = clamp(player.vx, -13, 13);
|
| 1376 |
+
} else {
|
| 1377 |
+
player.vx += inp.steer * 36 * dt;
|
| 1378 |
+
// carve while you hold it, settle the moment you let go
|
| 1379 |
+
player.vx *= Math.exp((inp.steer === 0 ? -8.5 : -3.6) * dt);
|
| 1380 |
+
player.vx = clamp(player.vx, -10.0, 10.0);
|
| 1381 |
+
player.lane = clamp(Math.round(player.x / LANE) + 2, 0, 4);
|
| 1382 |
+
}
|
| 1383 |
+
if (inp.jump && !player.air) {
|
| 1384 |
+
player.vy = JUMPV; player.air = true;
|
| 1385 |
+
Audio_.blip(300, 720, 0.16, 'sine', 0.12); buzz(14);
|
| 1386 |
+
spawnDust(player.x, 0.1, player.z, 14, 2.2, 0.75, 0.68, 0.42, 0.10);
|
| 1387 |
+
}
|
| 1388 |
+
if (inp.duck && !player.air) player.duck = Math.max(player.duck, dt + 0.02);
|
| 1389 |
+
} else {
|
| 1390 |
+
player.vx *= Math.exp(-2.0 * dt);
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
player.x = clamp(player.x + player.vx * dt, -BOUND, BOUND);
|
| 1394 |
+
if (Math.abs(player.x) >= BOUND - 1e-4) player.vx *= 0.3;
|
| 1395 |
+
stepBall(player, dt, PR);
|
| 1396 |
+
|
| 1397 |
+
const ps = playerSpeed();
|
| 1398 |
+
const dz = ps * dt;
|
| 1399 |
+
player.z += dz;
|
| 1400 |
+
|
| 1401 |
+
/* ---- ghost ---- */
|
| 1402 |
+
const gs = baseSpeed() + 0.05;
|
| 1403 |
+
ghostThink(dt, gs);
|
| 1404 |
+
stepBall(ghost, dt, PR);
|
| 1405 |
+
ghost.z += gs * dt;
|
| 1406 |
+
|
| 1407 |
+
S.gap = ghost.z - player.z;
|
| 1408 |
+
if (S.gap < 1.15) { winRun(); }
|
| 1409 |
+
|
| 1410 |
+
/* ---- collisions ---- */
|
| 1411 |
+
if (S.invuln <= 0 && S.tumble <= 0) {
|
| 1412 |
+
const bot = player.y - PR;
|
| 1413 |
+
const top = player.y + PR * (player.duck > 0 ? 0.42 : 1.0);
|
| 1414 |
+
const hit = World.blockedAt(player.x, player.z, bot + 0.02, top - 0.02, PR*0.92);
|
| 1415 |
+
if (hit) crash(hit);
|
| 1416 |
+
}
|
| 1417 |
+
|
| 1418 |
+
/* ---- tumble aftermath ---- */
|
| 1419 |
+
if (S.tumble > 0) {
|
| 1420 |
+
player.spin = Math.max(0, player.spin - dt*8);
|
| 1421 |
+
if (S.tumble < dt*1.5) { player.spin = 0; }
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
/* ---- trails & dust ---- */
|
| 1425 |
+
if (!ghostTrail.length || player.z - 0 > 0) {
|
| 1426 |
+
const lastT = ghostTrail[ghostTrail.length-1];
|
| 1427 |
+
if (!lastT || (ghost.z - lastT.z) > 0.42)
|
| 1428 |
+
ghostTrail.push({x:ghost.x, y:ghost.y, z:ghost.z, t:S.t});
|
| 1429 |
+
}
|
| 1430 |
+
// her breath in the grass lasts about three seconds, and not one frame longer
|
| 1431 |
+
while (ghostTrail.length && (ghostTrail[0].z < player.z - 16 || S.t - ghostTrail[0].t > 3.2))
|
| 1432 |
+
ghostTrail.shift();
|
| 1433 |
+
|
| 1434 |
+
if (!player.air && Math.random() < dt*34)
|
| 1435 |
+
spawnDust(player.x, 0.08, player.z - 0.4, 1, 0.9, 0.70, 0.63, 0.40, 0.07);
|
| 1436 |
+
if (!ghost.air && Math.random() < dt*20)
|
| 1437 |
+
spawnDust(ghost.x, 0.08, ghost.z - 0.4, 1, 0.9, 0.95, 0.92, 0.80, 0.07);
|
| 1438 |
+
|
| 1439 |
+
/* Ghost's Breath — the trail vanishes near the end */
|
| 1440 |
+
S.breath = smooth(S.breath, S.gap < 5.2 ? 1 : 0, dt*2.2);
|
| 1441 |
+
|
| 1442 |
+
Audio_.drive(ps, player.air);
|
| 1443 |
+
|
| 1444 |
+
/* HUD */
|
| 1445 |
+
DOM.timer.textContent = fmt(S.clock);
|
| 1446 |
+
DOM.speed.textContent = ps.toFixed(1) + ' m/s';
|
| 1447 |
+
DOM.crashes.textContent = S.crashes + (S.crashes===1?' fall':' falls');
|
| 1448 |
+
const pct = clamp(1 - (S.gap - 1.1) / (S.startGap - 1.1), 0, 1);
|
| 1449 |
+
DOM.gapbar.style.width = (pct*100).toFixed(1) + '%';
|
| 1450 |
+
DOM.gaptxt.textContent = S.breath > 0.4 ? "ghost's breath" : 'gap ' + S.gap.toFixed(1) + ' m';
|
| 1451 |
+
} else {
|
| 1452 |
+
/* idle camera drifts along the field on the menus */
|
| 1453 |
+
World.ensure(player.z + 220);
|
| 1454 |
+
World.update(S.t);
|
| 1455 |
+
if (S.phase === 'title') {
|
| 1456 |
+
player.z += 5.0 * dt; ghost.z = player.z + 12;
|
| 1457 |
+
ghost.x = Math.sin(S.t*0.55)*2.6; player.x = Math.sin(S.t*0.55 - 0.9)*2.0;
|
| 1458 |
+
const lastT = ghostTrail[ghostTrail.length-1];
|
| 1459 |
+
if (!lastT || (ghost.z - lastT.z) > 0.42) ghostTrail.push({x:ghost.x,y:ghost.y,z:ghost.z,t:S.t});
|
| 1460 |
+
while (ghostTrail.length && ghostTrail[0].z < player.z - 16) ghostTrail.shift();
|
| 1461 |
+
World.prune(player.z);
|
| 1462 |
+
}
|
| 1463 |
+
}
|
| 1464 |
+
|
| 1465 |
+
/* dust integration */
|
| 1466 |
+
for (let i=dust.length-1;i>=0;i--){
|
| 1467 |
+
const p = dust[i];
|
| 1468 |
+
p.life += dt;
|
| 1469 |
+
if (p.life > p.max) { dust.splice(i,1); continue; }
|
| 1470 |
+
p.vy -= 6.0*dt; p.vx *= Math.exp(-2.2*dt); p.vz *= Math.exp(-2.2*dt);
|
| 1471 |
+
p.x += p.vx*dt; p.y += p.vy*dt; p.z += p.vz*dt;
|
| 1472 |
+
if (p.y < 0.02) { p.y = 0.02; p.vy *= -0.24; }
|
| 1473 |
+
}
|
| 1474 |
+
|
| 1475 |
+
/* rolling rotation */
|
| 1476 |
+
rollBall(player, playerSpeedSafe()*dt, player.vx*dt);
|
| 1477 |
+
rollBall(ghost, (S.phase==='run'? baseSpeed():5)*dt, 0);
|
| 1478 |
+
if (player.spin > 0) {
|
| 1479 |
+
const inc = M.axisAngle(tmpA, 0.6, 0.5, 0.3, player.spin*dt);
|
| 1480 |
+
player.rot = M.mul(new Float32Array(16), inc, player.rot);
|
| 1481 |
+
}
|
| 1482 |
+
|
| 1483 |
+
render(dt);
|
| 1484 |
+
}
|
| 1485 |
+
function playerSpeedSafe(){ return S.phase==='run' ? playerSpeed() : 5.0; }
|
| 1486 |
+
|
| 1487 |
+
/* ---------------------------------------------------------------
|
| 1488 |
+
render
|
| 1489 |
+
--------------------------------------------------------------- */
|
| 1490 |
+
const camPos = [0,0,0], camAt = [0,0,0], UP = [0,1,0];
|
| 1491 |
+
|
| 1492 |
+
function render(dt){
|
| 1493 |
+
/* camera: sits behind, drifts wide as she pulls away */
|
| 1494 |
+
const wantDist = 8.0 + clamp((S.gap - S.startGap) * 0.14, 0, 5.2) + (S.phase==='title'?1.5:0);
|
| 1495 |
+
const wantHi = 3.05 + clamp((S.gap - S.startGap) * 0.045, 0, 1.5);
|
| 1496 |
+
S.camDist = smooth(S.camDist, wantDist, dt*2.0);
|
| 1497 |
+
S.camHi = smooth(S.camHi, wantHi, dt*2.0);
|
| 1498 |
+
S.camX = smooth(S.camX, player.x * 0.62, dt*7.0);
|
| 1499 |
+
|
| 1500 |
+
const sh = S.paused ? 0 : S.shake * S.shake;
|
| 1501 |
+
camPos[0] = S.camX + (Math.random()-0.5)*sh*0.7;
|
| 1502 |
+
camPos[1] = S.camHi + player.y*0.28 + (Math.random()-0.5)*sh*0.5;
|
| 1503 |
+
camPos[2] = player.z - S.camDist;
|
| 1504 |
+
camAt[0] = player.x*0.30 + (S.phase==='run' ? ghost.x*0.16 : 0);
|
| 1505 |
+
camAt[1] = 1.15 + player.y*0.32;
|
| 1506 |
+
camAt[2] = player.z + 8.5;
|
| 1507 |
+
|
| 1508 |
+
const asp = W/H;
|
| 1509 |
+
const fov = (58 + clamp((playerSpeedSafe()-13)*0.85, 0, 9)) * Math.PI/180;
|
| 1510 |
+
M.persp(proj, fov, asp, 0.1, 620);
|
| 1511 |
+
// The chase runs along +Z and the camera faces that way. A right-handed lookAt
|
| 1512 |
+
// gives such a camera the right-vector (-1,0,0), which would put world +X on the
|
| 1513 |
+
// LEFT of the screen — so steering right would visibly carve left. Mirror the
|
| 1514 |
+
// projection's X once, here, rather than negating every input path separately.
|
| 1515 |
+
// (Safe: nothing is face-culled, and lighting is evaluated in world space.)
|
| 1516 |
+
proj[0] = -proj[0];
|
| 1517 |
+
M.look(view, camPos, camAt, UP);
|
| 1518 |
+
M.mul(vp, proj, view);
|
| 1519 |
+
M.invert(invVP, vp);
|
| 1520 |
+
|
| 1521 |
+
gl.viewport(0,0,W,H);
|
| 1522 |
+
gl.clearColor(0.60,0.66,0.78,1);
|
| 1523 |
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
| 1524 |
+
|
| 1525 |
+
/* sky */
|
| 1526 |
+
gl.disable(gl.DEPTH_TEST); gl.depthMask(false); gl.disable(gl.BLEND);
|
| 1527 |
+
SKY.use();
|
| 1528 |
+
gl.uniformMatrix4fv(SKY.u('uInvVP'), false, invVP);
|
| 1529 |
+
gl.uniform3fv(SKY.u('uCam'), camPos);
|
| 1530 |
+
gl.uniform1f(SKY.u('uTime'), S.t);
|
| 1531 |
+
gl.bindVertexArray(null);
|
| 1532 |
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
| 1533 |
+
|
| 1534 |
+
gl.enable(gl.DEPTH_TEST); gl.depthMask(true); gl.depthFunc(gl.LEQUAL);
|
| 1535 |
+
|
| 1536 |
+
/* ground */
|
| 1537 |
+
GROUND.use();
|
| 1538 |
+
gl.uniformMatrix4fv(GROUND.u('uVP'), false, vp);
|
| 1539 |
+
gl.uniform1f(GROUND.u('uCamZ'), camPos[2]);
|
| 1540 |
+
gl.uniform3fv(GROUND.u('uCam'), camPos);
|
| 1541 |
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
| 1542 |
+
|
| 1543 |
+
/* obstacles */
|
| 1544 |
+
OBJ.use();
|
| 1545 |
+
gl.uniformMatrix4fv(OBJ.u('uVP'), false, vp);
|
| 1546 |
+
gl.uniform3fv(OBJ.u('uCam'), camPos);
|
| 1547 |
+
gl.uniform1f(OBJ.u('uAlpha'), 1.0);
|
| 1548 |
+
gl.uniform1f(OBJ.u('uMetal'), 0.0);
|
| 1549 |
+
gl.uniform3f(OBJ.u('uEmit'), 0,0,0);
|
| 1550 |
+
gl.uniform1f(OBJ.u('uRough'), 0.75);
|
| 1551 |
+
for (const o of World.obs) {
|
| 1552 |
+
if (o.z < camPos[2] - 6 || o.z > camPos[2] + 210) continue;
|
| 1553 |
+
const cy = (o.y0 + o.y1)/2, hy = (o.y1 - o.y0)/2;
|
| 1554 |
+
if (o.k === 'p') {
|
| 1555 |
+
gl.uniform3f(OBJ.u('uColor'), 0.30, 0.24, 0.26);
|
| 1556 |
+
const tilt = o.sw || 0;
|
| 1557 |
+
const rot = M.axisAngle(tmpB, 0,0,1, tilt*0.16);
|
| 1558 |
+
setModel(o.x, cy, o.z, o.hw, hy, o.hl, rot);
|
| 1559 |
+
gl.uniformMatrix4fv(OBJ.u('uModel'), false, model);
|
| 1560 |
+
drawMesh(BOX);
|
| 1561 |
+
// the post it hangs from
|
| 1562 |
+
gl.uniform3f(OBJ.u('uColor'), 0.20, 0.17, 0.15);
|
| 1563 |
+
setModel(o.x, (o.y1+3.9)/2, o.z, 0.10, (3.9-o.y1)/2, 0.10, null);
|
| 1564 |
+
gl.uniformMatrix4fv(OBJ.u('uModel'), false, model);
|
| 1565 |
+
drawMesh(BOX);
|
| 1566 |
+
} else if (o.k === 'r') {
|
| 1567 |
+
gl.uniform3f(OBJ.u('uColor'), 0.33, 0.22, 0.13);
|
| 1568 |
+
const rot = M.axisAngle(tmpB, 0,0,1, Math.PI/2);
|
| 1569 |
+
setModel(o.x, o.y1*0.5, o.z, o.hl, o.hw, o.y1*0.5, rot);
|
| 1570 |
+
gl.uniformMatrix4fv(OBJ.u('uModel'), false, model);
|
| 1571 |
+
drawMesh(CYL);
|
| 1572 |
+
} else {
|
| 1573 |
+
gl.uniform3f(OBJ.u('uColor'), o.k==='h' ? 0.29 : 0.24, o.k==='h' ? 0.25 : 0.21, 0.16);
|
| 1574 |
+
setModel(o.x, cy, o.z, o.hw, hy, o.hl, null);
|
| 1575 |
+
gl.uniformMatrix4fv(OBJ.u('uModel'), false, model);
|
| 1576 |
+
drawMesh(BOX);
|
| 1577 |
+
}
|
| 1578 |
+
}
|
| 1579 |
+
|
| 1580 |
+
/* grass */
|
| 1581 |
+
GRASS.use();
|
| 1582 |
+
gl.uniformMatrix4fv(GRASS.u('uVP'), false, vp);
|
| 1583 |
+
gl.uniform3fv(GRASS.u('uCam'), camPos);
|
| 1584 |
+
gl.uniform1f(GRASS.u('uTime'), S.t);
|
| 1585 |
+
gl.uniform1f(GRASS.u('uFW'), FW);
|
| 1586 |
+
gl.uniform1f(GRASS.u('uFL'), FL);
|
| 1587 |
+
gl.uniform1f(GRASS.u('uGust'), S.gust);
|
| 1588 |
+
gl.uniform3f(GRASS.u('uBall'), player.x, player.y, player.z);
|
| 1589 |
+
gl.uniform3f(GRASS.u('uGhost'), ghost.x, ghost.y, ghost.z);
|
| 1590 |
+
gl.disable(gl.CULL_FACE);
|
| 1591 |
+
gl.bindVertexArray(BLADE.vao);
|
| 1592 |
+
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, BLADE.count, S.grassN);
|
| 1593 |
+
|
| 1594 |
+
/* the two spheres */
|
| 1595 |
+
OBJ.use();
|
| 1596 |
+
gl.uniformMatrix4fv(OBJ.u('uVP'), false, vp);
|
| 1597 |
+
gl.uniform3fv(OBJ.u('uCam'), camPos);
|
| 1598 |
+
|
| 1599 |
+
// player — chrome, squashing when it ducks
|
| 1600 |
+
const dsq = player.duck > 0 ? 1 : 0;
|
| 1601 |
+
const sy = PR * lerp(1, 0.56, dsq), sxz = PR * lerp(1, 1.20, dsq);
|
| 1602 |
+
gl.uniform1f(OBJ.u('uMetal'), 1.0);
|
| 1603 |
+
gl.uniform1f(OBJ.u('uRough'), 0.22);
|
| 1604 |
+
gl.uniform1f(OBJ.u('uAlpha'), 1.0);
|
| 1605 |
+
const hot = S.invuln > 0 && S.tumble <= 0 ? (Math.sin(S.t*30)*0.5+0.5)*0.35 : 0;
|
| 1606 |
+
gl.uniform3f(OBJ.u('uColor'), 1.0, 0.95, 0.88);
|
| 1607 |
+
gl.uniform3f(OBJ.u('uEmit'), hot*0.9, hot*0.25, hot*0.1);
|
| 1608 |
+
setModel(player.x, player.y - (PR - sy), player.z, sxz, sy, sxz, player.rot);
|
| 1609 |
+
gl.uniformMatrix4fv(OBJ.u('uModel'), false, model);
|
| 1610 |
+
drawMesh(SPHERE);
|
| 1611 |
+
|
| 1612 |
+
// ghost — light with a body, fading into breath
|
| 1613 |
+
const galpha = lerp(1.0, 0.13, S.breath);
|
| 1614 |
+
gl.uniform1f(OBJ.u('uMetal'), 0.0);
|
| 1615 |
+
gl.uniform1f(OBJ.u('uRough'), 0.1);
|
| 1616 |
+
gl.uniform3f(OBJ.u('uColor'), 0.62, 0.66, 0.78);
|
| 1617 |
+
gl.uniform3f(OBJ.u('uEmit'), 1.05, 0.98, 0.86);
|
| 1618 |
+
gl.uniform1f(OBJ.u('uAlpha'), galpha);
|
| 1619 |
+
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
| 1620 |
+
gl.depthMask(galpha > 0.85);
|
| 1621 |
+
const gsq = ghost.duck > 0 ? 1 : 0;
|
| 1622 |
+
const gsy = PR*lerp(1,0.56,gsq), gsxz = PR*lerp(1,1.20,gsq);
|
| 1623 |
+
setModel(ghost.x, ghost.y - (PR-gsy), ghost.z, gsxz, gsy, gsxz, ghost.rot);
|
| 1624 |
+
gl.uniformMatrix4fv(OBJ.u('uModel'), false, model);
|
| 1625 |
+
drawMesh(SPHERE);
|
| 1626 |
+
gl.depthMask(true);
|
| 1627 |
+
|
| 1628 |
+
/* points: halo, breath-trail, dust */
|
| 1629 |
+
let n = 0;
|
| 1630 |
+
const P = PTBUF.pos, C = PTBUF.col, Z = PTBUF.siz;
|
| 1631 |
+
function put(x,y,z,r,g,b,a,s){
|
| 1632 |
+
if (n >= PTBUF.MAX) return;
|
| 1633 |
+
P[n*3]=x; P[n*3+1]=y; P[n*3+2]=z;
|
| 1634 |
+
C[n*4]=r; C[n*4+1]=g; C[n*4+2]=b; C[n*4+3]=a; Z[n]=s; n++;
|
| 1635 |
+
}
|
| 1636 |
+
// ghost halo
|
| 1637 |
+
put(ghost.x, ghost.y, ghost.z, 1.0, 0.95, 0.80, lerp(0.55, 0.16, S.breath), 3.4);
|
| 1638 |
+
put(ghost.x, ghost.y, ghost.z, 1.0, 1.00, 0.95, lerp(0.35, 0.10, S.breath), 1.6);
|
| 1639 |
+
// her line through the grass — always faint, the only thing left in Breath mode
|
| 1640 |
+
for (let i=0;i<ghostTrail.length;i++){
|
| 1641 |
+
const p = ghostTrail[i];
|
| 1642 |
+
const age = S.t - p.t;
|
| 1643 |
+
const fade = clamp(1 - age*0.34, 0, 1);
|
| 1644 |
+
const a = fade * lerp(0.13, 0.60, S.breath);
|
| 1645 |
+
if (a <= 0.004) continue;
|
| 1646 |
+
put(p.x, 0.10, p.z, 1.0, 0.92, 0.68, a, lerp(0.5, 0.95, S.breath));
|
| 1647 |
+
}
|
| 1648 |
+
for (const p of dust){
|
| 1649 |
+
const k = 1 - p.life/p.max;
|
| 1650 |
+
put(p.x, p.y, p.z, p.r, p.g, p.b, k*k*0.55, p.s*(1+ (1-k)*1.6));
|
| 1651 |
+
}
|
| 1652 |
+
if (n > 0) {
|
| 1653 |
+
gl.bindVertexArray(PTBUF.vao);
|
| 1654 |
+
gl.bindBuffer(gl.ARRAY_BUFFER, PTBUF.bp); gl.bufferSubData(gl.ARRAY_BUFFER, 0, P, 0, n*3);
|
| 1655 |
+
gl.bindBuffer(gl.ARRAY_BUFFER, PTBUF.bc); gl.bufferSubData(gl.ARRAY_BUFFER, 0, C, 0, n*4);
|
| 1656 |
+
gl.bindBuffer(gl.ARRAY_BUFFER, PTBUF.bs); gl.bufferSubData(gl.ARRAY_BUFFER, 0, Z, 0, n);
|
| 1657 |
+
PTS.use();
|
| 1658 |
+
gl.uniformMatrix4fv(PTS.u('uVP'), false, vp);
|
| 1659 |
+
gl.uniform2f(PTS.u('uRes'), W, H);
|
| 1660 |
+
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
|
| 1661 |
+
gl.depthMask(false);
|
| 1662 |
+
gl.drawArrays(gl.POINTS, 0, n);
|
| 1663 |
+
gl.depthMask(true);
|
| 1664 |
+
}
|
| 1665 |
+
gl.disable(gl.BLEND);
|
| 1666 |
+
gl.bindVertexArray(null);
|
| 1667 |
+
}
|
| 1668 |
+
|
| 1669 |
+
/* ---------------------------------------------------------------
|
| 1670 |
+
boot
|
| 1671 |
+
--------------------------------------------------------------- */
|
| 1672 |
+
Input.init();
|
| 1673 |
+
renderHint();
|
| 1674 |
+
resize();
|
| 1675 |
+
World.reset(12345);
|
| 1676 |
+
World.ensure(300);
|
| 1677 |
+
ghost.z = 12;
|
| 1678 |
+
|
| 1679 |
+
showBest();
|
| 1680 |
+
|
| 1681 |
+
function begin(){ if (S.phase !== 'run' || S.paused) startRun(); }
|
| 1682 |
+
document.getElementById('startbtn').addEventListener('click', begin);
|
| 1683 |
+
document.getElementById('againbtn').addEventListener('click', () => {
|
| 1684 |
+
DOM.win.classList.add('hide'); setTimeout(begin, 120);
|
| 1685 |
+
});
|
| 1686 |
+
document.getElementById('pausebtn').addEventListener('click', () => setPaused(!S.paused));
|
| 1687 |
+
document.getElementById('resumebtn').addEventListener('click', () => setPaused(false));
|
| 1688 |
+
document.getElementById('restartbtn').addEventListener('click', () => startRun());
|
| 1689 |
+
document.getElementById('quitbtn').addEventListener('click', quitToTitle);
|
| 1690 |
+
|
| 1691 |
+
addEventListener('keydown', e => {
|
| 1692 |
+
if (e.repeat) return;
|
| 1693 |
+
const k = e.key.toLowerCase();
|
| 1694 |
+
if (k === 'escape' || k === 'p') {
|
| 1695 |
+
if (S.phase === 'run') { e.preventDefault(); setPaused(!S.paused); }
|
| 1696 |
+
return;
|
| 1697 |
+
}
|
| 1698 |
+
if (k === 'r') { e.preventDefault(); startRun(); return; }
|
| 1699 |
+
if ((k === ' ' || k === 'enter') && S.phase !== 'run') { e.preventDefault(); begin(); }
|
| 1700 |
+
});
|
| 1701 |
+
document.getElementById('title').addEventListener('touchend', e => { e.preventDefault(); begin(); }, {passive:false});
|
| 1702 |
+
|
| 1703 |
+
// wandering off mid-chase pauses rather than quietly costing you the run
|
| 1704 |
+
const away = () => { if (S.phase === 'run') setPaused(true); else if (Audio_.ok) Audio_.vol(0); };
|
| 1705 |
+
addEventListener('blur', away);
|
| 1706 |
+
document.addEventListener('visibilitychange', () => { if (document.hidden) away(); });
|
| 1707 |
+
addEventListener('focus', () => {
|
| 1708 |
+
if (Audio_.ok && S.phase === 'run' && !S.paused) Audio_.vol(0.9);
|
| 1709 |
+
if (S.phase === 'run' && !S.paused) keepAwake(true); // wake locks die when hidden
|
| 1710 |
+
});
|
| 1711 |
+
|
| 1712 |
+
// Wire up the native shell if we're inside one. On the web this whole block
|
| 1713 |
+
// finds no Capacitor object and does nothing at all.
|
| 1714 |
+
(function connectNative(){
|
| 1715 |
+
try {
|
| 1716 |
+
const C = window.Capacitor;
|
| 1717 |
+
if (!C || typeof C.isNativePlatform !== 'function' || !C.isNativePlatform()) return;
|
| 1718 |
+
const P = C.Plugins || {};
|
| 1719 |
+
Native.on = true;
|
| 1720 |
+
Native.hap = P.Haptics || null;
|
| 1721 |
+
Native.app = P.App || null;
|
| 1722 |
+
if (Native.app && Native.app.addListener) {
|
| 1723 |
+
// Android's back button must pause the chase, never kill the app mid-run
|
| 1724 |
+
Native.app.addListener('backButton', () => {
|
| 1725 |
+
if (S.phase === 'run') setPaused(!S.paused);
|
| 1726 |
+
else if (S.phase === 'won') quitToTitle();
|
| 1727 |
+
else if (Native.app.exitApp) Native.app.exitApp();
|
| 1728 |
+
});
|
| 1729 |
+
Native.app.addListener('appStateChange', st => {
|
| 1730 |
+
if (st && st.isActive === false && S.phase === 'run') setPaused(true);
|
| 1731 |
+
});
|
| 1732 |
+
}
|
| 1733 |
+
} catch(e){ Native.on = false; }
|
| 1734 |
+
})();
|
| 1735 |
+
|
| 1736 |
+
// Offline support when served over https. Silently skipped on file:// and http.
|
| 1737 |
+
if ('serviceWorker' in navigator && location.protocol === 'https:') {
|
| 1738 |
+
addEventListener('load', () => { navigator.serviceWorker.register('sw.js').catch(()=>{}); });
|
| 1739 |
+
}
|
| 1740 |
+
|
| 1741 |
+
requestAnimationFrame(t => { last = t; requestAnimationFrame(frame); });
|
| 1742 |
+
|
| 1743 |
+
})();
|
| 1744 |
+
</script>
|
| 1745 |
+
</body>
|
| 1746 |
</html>
|
style.css
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
body {
|
| 2 |
-
padding: 2rem;
|
| 3 |
-
font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
h1 {
|
| 7 |
-
font-size: 16px;
|
| 8 |
-
margin-top: 0;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
p {
|
| 12 |
-
color: rgb(107, 114, 128);
|
| 13 |
-
font-size: 15px;
|
| 14 |
-
margin-bottom: 10px;
|
| 15 |
-
margin-top: 5px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|