Sahek commited on
Commit
4dd4344
·
verified ·
1 Parent(s): 2ad0012

Grace Chase

Browse files
Files changed (3) hide show
  1. README.md +40 -3
  2. index.html +1745 -18
  3. style.css +0 -28
README.md CHANGED
@@ -1,10 +1,47 @@
1
  ---
2
  title: Grace Chase
3
- emoji: 🦀
4
  colorFrom: yellow
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,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 &nbsp;·&nbsp; R restart &nbsp;·&nbsp; 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 &amp; duck'],['❙❙ top right','pause']]
1061
+ : m === 'pad'
1062
+ ? [['Stick / D-pad','carve'],['A','bunny hop'],['B / LT','brake &amp; duck'],
1063
+ ['Esc','pause']]
1064
+ : [['A D ←→','carve'],['Space','bunny hop'],['S ↓','brake &amp; duck'],
1065
+ ['Esc','pause'],['R','restart']];
1066
+ DOM.hint.innerHTML = H.map(h => '<div class="k"><b>'+h[0]+'</b> &nbsp;'+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 &nbsp;·&nbsp; ' + S.gap.toFixed(1) +
1242
+ ' m behind her &nbsp;·&nbsp; ' + 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
- }