Codex CLI commited on
Commit
1390db3
·
0 Parent(s):

Initial commit: Orcs In The Forest

Browse files
Files changed (29) hide show
  1. .gitignore +34 -0
  2. README.md +27 -0
  3. bun.lock +29 -0
  4. index.html +198 -0
  5. index.ts +1 -0
  6. package.json +12 -0
  7. src/audio.js +165 -0
  8. src/casings.js +131 -0
  9. src/clouds.js +183 -0
  10. src/combat.js +132 -0
  11. src/config.js +182 -0
  12. src/daynight.js +123 -0
  13. src/enemies.js +335 -0
  14. src/events.js +101 -0
  15. src/fx.js +137 -0
  16. src/globals.js +97 -0
  17. src/helmets.js +117 -0
  18. src/hud.js +145 -0
  19. src/lighting.js +101 -0
  20. src/main.js +202 -0
  21. src/mountains.js +133 -0
  22. src/pickups.js +136 -0
  23. src/player.js +73 -0
  24. src/projectiles.js +123 -0
  25. src/utils.js +11 -0
  26. src/waves.js +60 -0
  27. src/weapon.js +235 -0
  28. src/world.js +844 -0
  29. tsconfig.json +27 -0
.gitignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dependencies (bun install)
2
+ node_modules
3
+
4
+ # output
5
+ out
6
+ dist
7
+ *.tgz
8
+
9
+ # code coverage
10
+ coverage
11
+ *.lcov
12
+
13
+ # logs
14
+ logs
15
+ _.log
16
+ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17
+
18
+ # dotenv environment variable files
19
+ .env
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+ .env.local
24
+
25
+ # caches
26
+ .eslintcache
27
+ .cache
28
+ *.tsbuildinfo
29
+
30
+ # IntelliJ based IDEs
31
+ .idea
32
+
33
+ # Finder (MacOS) folder config
34
+ .DS_Store
README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Orcs In The Forest
3
+ emoji: 🧌
4
+ colorFrom: green
5
+ colorTo: purple
6
+ sdk: static
7
+ pinned: false
8
+ ---
9
+
10
+ # Orcs In The Forest
11
+
12
+ This project was created using `bun init` in bun v1.2.4. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
13
+
14
+ ## Foliage (Grass/Bushes/Rocks/Flowers)
15
+
16
+ Ground cover is procedurally generated and batched with `THREE.InstancedMesh` in chunked grids for performance. Wind sway for grass/flowers reuses the forest wind uniform and runs entirely in the vertex shader.
17
+
18
+ - Config: edit `CFG.foliage` in `src/config.js` to tune density and chunk size.
19
+ - `chunkSize`: larger reduces draw calls (fewer chunks).
20
+ - `grassPerChunk`, `flowersPerChunk`, `bushesPerChunk`, `rocksPerChunk`: density per chunk.
21
+ - `windStrength`: vertex bend amount for grass/flowers.
22
+ - `densityNearClear`: density at the clearing edge; blends to 1 outward.
23
+
24
+ Performance tips:
25
+ - Lower `grassPerChunk` and/or increase `chunkSize` if GPU load is high.
26
+ - Keep rocks/bush counts modest; they cast shadows by default.
27
+ - Grass and flowers don’t receive/cast shadows for cheaper fills.
bun.lock ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lockfileVersion": 1,
3
+ "workspaces": {
4
+ "": {
5
+ "name": "shooter",
6
+ "devDependencies": {
7
+ "@types/bun": "latest",
8
+ },
9
+ "peerDependencies": {
10
+ "typescript": "^5",
11
+ },
12
+ },
13
+ },
14
+ "packages": {
15
+ "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
16
+
17
+ "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
18
+
19
+ "@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="],
20
+
21
+ "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
22
+
23
+ "csstype": ["[email protected]", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
24
+
25
+ "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
26
+
27
+ "undici-types": ["[email protected]", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
28
+ }
29
+ }
index.html ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Orcs In The Forest</title>
7
+ <style>
8
+ body {
9
+ margin: 0;
10
+ overflow: hidden;
11
+ font-family: monospace;
12
+ background: #000;
13
+ }
14
+ canvas { display: block; }
15
+ :root {
16
+ --hud-bg: rgba(14, 20, 28, 0.55);
17
+ --hud-border: rgba(120, 200, 255, 0.55);
18
+ --hud-glow: 0 0 18px rgba(80, 180, 255, 0.35);
19
+ --accent: #34d399; /* green */
20
+ --accent2: #60a5fa; /* blue */
21
+ --text: #e8f0ff;
22
+ --muted: rgba(200, 230, 255, 0.65);
23
+ }
24
+ #hud {
25
+ position: absolute;
26
+ top: 0; left: 0; right: 0; bottom: 0;
27
+ pointer-events: none;
28
+ color: white;
29
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
30
+ }
31
+ #crosshair {
32
+ position: absolute;
33
+ top: 50%; left: 50%;
34
+ transform: translate(-50%, -50%);
35
+ width: 0; height: 0;
36
+ }
37
+ .crosshair-arm {
38
+ position: absolute;
39
+ background: rgba(255,255,255,0.85);
40
+ box-shadow: 0 0 6px rgba(255,255,255,0.5);
41
+ }
42
+ .arm-h { height: 2px; }
43
+ .arm-v { width: 2px; }
44
+ /* HUD Cards */
45
+ .hud-card {
46
+ position: absolute;
47
+ padding: 12px 14px;
48
+ background: var(--hud-bg);
49
+ border: 1px solid var(--hud-border);
50
+ border-radius: 10px;
51
+ box-shadow: var(--hud-glow);
52
+ backdrop-filter: blur(6px);
53
+ color: var(--text);
54
+ pointer-events: none;
55
+ }
56
+ .hud-title { font-size: 12px; letter-spacing: 1.5px; color: var(--muted); margin-bottom: 6px; }
57
+ .hud-value { font-size: 26px; font-weight: 700; color: #fff; }
58
+ .tl { top: 18px; left: 20px; }
59
+ .tr { top: 18px; right: 20px; }
60
+ .bl { bottom: 20px; left: 20px; }
61
+ .br { bottom: 20px; right: 20px; }
62
+
63
+ /* Health gauge */
64
+ #ui-health { width: 360px; padding: 14px 16px; }
65
+ #health-bar {
66
+ position: relative;
67
+ width: 100%; height: 18px;
68
+ background: linear-gradient(180deg, rgba(20,30,36,0.9), rgba(12,16,22,0.9));
69
+ border: 1px solid rgba(80, 120, 140, 0.55);
70
+ border-radius: 8px;
71
+ overflow: hidden;
72
+ box-shadow: inset 0 0 10px rgba(0,0,0,0.45);
73
+ }
74
+ #health-fill {
75
+ position: absolute; left: 0; top: 0; bottom: 0; width: 60%;
76
+ background: linear-gradient(90deg, rgba(34,197,94,0.85), rgba(16,185,129,0.95));
77
+ box-shadow: 0 0 12px rgba(34,197,94,0.55);
78
+ }
79
+ #health-stripes {
80
+ position: absolute; inset: 0;
81
+ background: repeating-linear-gradient(45deg, rgba(255,255,255,0.05) 0, rgba(255,255,255,0.05) 6px, transparent 6px, transparent 12px);
82
+ pointer-events: none;
83
+ }
84
+ #health-text { font-size: 16px; color: #eafff1; text-shadow: 0 0 6px rgba(34,197,94,0.6); }
85
+ #health-label { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: baseline; }
86
+ #health-label .hud-title { margin: 0; }
87
+
88
+ /* Ammo */
89
+ #ui-ammo { min-width: 220px; text-align: right; }
90
+ #ammo { font-size: 40px; font-weight: 800; color: #e6f7ff; text-shadow: 0 0 8px rgba(96,165,250,0.6); }
91
+ #ammo:before { content: 'AMMO'; display: block; font-size: 12px; color: var(--muted); letter-spacing: 1.5px; margin-bottom: 6px; text-align: right; }
92
+
93
+ /* Score */
94
+ #ui-score { min-width: 160px; }
95
+ #score:before { content: 'SCORE'; display: block; font-size: 12px; color: var(--muted); letter-spacing: 1.5px; margin-bottom: 6px; }
96
+ #score { font-size: 28px; font-weight: 800; color: #fff; }
97
+
98
+ /* Top-right stats */
99
+ #ui-topright { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
100
+ .mini-card { padding: 8px 10px; background: rgba(12,18,26,0.55); border: 1px solid rgba(120, 200, 255, 0.35); border-radius: 8px; }
101
+ .mini-card .label { font-size: 11px; color: var(--muted); letter-spacing: 1.2px; }
102
+ .mini-card .value { font-size: 22px; font-weight: 700; color: #fff; }
103
+ #overlay {
104
+ position: absolute;
105
+ top: 0; left: 0; right: 0; bottom: 0;
106
+ display: flex; align-items: center; justify-content: center;
107
+ background: rgba(0,0,0,0.7);
108
+ pointer-events: auto; cursor: pointer;
109
+ }
110
+ #overlay-content { text-align: center; color: white; font-size: 24px; }
111
+ #overlay-content h1 { margin: 20px 0; }
112
+ #overlay-content p { margin: 10px 0; font-size: 18px; }
113
+ .hidden { display: none !important; }
114
+ #wave-banner {
115
+ position: absolute;
116
+ top: 40%; left: 50%; transform: translate(-50%, -50%);
117
+ font-size: 48px; color: #ff0;
118
+ text-shadow: 3px 3px 6px rgba(0,0,0,0.9);
119
+ opacity: 0; transition: opacity 0.5s;
120
+ }
121
+ #wave-banner.show { opacity: 1; }
122
+ #damage-overlay {
123
+ position: absolute;
124
+ top: 0; left: 0; right: 0; bottom: 0;
125
+ pointer-events: none;
126
+ background: radial-gradient(ellipse at center, rgba(255,0,0,0.0) 40%, rgba(255,0,0,0.35) 80%, rgba(255,0,0,0.7) 100%);
127
+ opacity: 0;
128
+ }
129
+ #heal-overlay {
130
+ position: absolute;
131
+ top: 0; left: 0; right: 0; bottom: 0;
132
+ pointer-events: none;
133
+ background: radial-gradient(ellipse at center, rgba(0,255,128,0.0) 40%, rgba(0,255,128,0.28) 80%, rgba(0,255,128,0.55) 100%);
134
+ opacity: 0;
135
+ }
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <div id="hud">
140
+ <!-- Score Top-Left -->
141
+ <div id="ui-score" class="hud-card tl">
142
+ <div class="hud-value" id="score">0</div>
143
+ </div>
144
+
145
+ <!-- Wave + Enemies Top-Right -->
146
+ <div id="ui-topright" class="hud-card tr">
147
+ <div class="mini-card">
148
+ <div class="label">WAVE</div>
149
+ <div class="value" id="wave">1</div>
150
+ </div>
151
+ <div class="mini-card">
152
+ <div class="label">ENEMIES</div>
153
+ <div class="value" id="enemies">0</div>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Health Bottom-Left -->
158
+ <div id="ui-health" class="hud-card bl">
159
+ <div id="health-label"><span class="hud-title">HEALTH</span><span id="health-text">100</span></div>
160
+ <div id="health-bar">
161
+ <div id="health-fill" style="width: 100%"></div>
162
+ <div id="health-stripes"></div>
163
+ </div>
164
+ </div>
165
+
166
+ <!-- Ammo Bottom-Right -->
167
+ <div id="ui-ammo" class="hud-card br">
168
+ <div class="hud-value" id="ammo">0/∞</div>
169
+ </div>
170
+ <div id="crosshair">
171
+ <div id="ch-left" class="crosshair-arm arm-h"></div>
172
+ <div id="ch-right" class="crosshair-arm arm-h"></div>
173
+ <div id="ch-top" class="crosshair-arm arm-v"></div>
174
+ <div id="ch-bottom" class="crosshair-arm arm-v"></div>
175
+ </div>
176
+ <div id="wave-banner"></div>
177
+ <div id="heal-overlay"></div>
178
+ <div id="damage-overlay"></div>
179
+ </div>
180
+ <div id="overlay">
181
+ <div id="overlay-content">
182
+ <h1>Orcs In The Forest</h1>
183
+ <p>Click to Start</p>
184
+ <p style="font-size: 14px;">Move: WASD | Look: Mouse | Fire: Click | Reload: R | Light: F | Pause: ESC</p>
185
+ </div>
186
+ </div>
187
+
188
+ <script type="importmap">
189
+ {
190
+ "imports": {
191
+ "three": "https://unpkg.com/[email protected]/build/three.module.js",
192
+ "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
193
+ }
194
+ }
195
+ </script>
196
+ <script type="module" src="src/main.js"></script>
197
+ </body>
198
+ </html>
index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ console.log("Hello via Bun!");
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "shooter",
3
+ "module": "index.ts",
4
+ "type": "module",
5
+ "private": true,
6
+ "devDependencies": {
7
+ "@types/bun": "latest"
8
+ },
9
+ "peerDependencies": {
10
+ "typescript": "^5"
11
+ }
12
+ }
src/audio.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Lightweight Web Audio synth for SFX
2
+ import { CFG } from './config.js';
3
+
4
+ let ctx = null;
5
+ let master = null;
6
+
7
+ function ensureContext() {
8
+ if (!ctx) {
9
+ const AC = window.AudioContext || window.webkitAudioContext;
10
+ ctx = new AC();
11
+ master = ctx.createGain();
12
+ master.gain.value = (CFG.audio && CFG.audio.master != null) ? CFG.audio.master : 0.6;
13
+ master.connect(ctx.destination);
14
+ }
15
+ if (ctx.state === 'suspended') ctx.resume();
16
+ }
17
+
18
+ export function initAudio() {
19
+ ensureContext();
20
+ }
21
+
22
+ export function resumeAudio() {
23
+ ensureContext();
24
+ }
25
+
26
+ // Simple gunshot: noise burst + filtered click + low thump
27
+ export function playGunshot() {
28
+ ensureContext();
29
+ const t0 = ctx.currentTime;
30
+
31
+ // White noise burst
32
+ const noiseBuf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.12), ctx.sampleRate);
33
+ const data = noiseBuf.getChannelData(0);
34
+ for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * 0.9;
35
+ const noise = ctx.createBufferSource();
36
+ noise.buffer = noiseBuf;
37
+
38
+ const hp = ctx.createBiquadFilter();
39
+ hp.type = 'highpass';
40
+ hp.frequency.value = 800;
41
+
42
+ const lp = ctx.createBiquadFilter();
43
+ lp.type = 'lowpass';
44
+ lp.frequency.setValueAtTime(6000, t0);
45
+ lp.frequency.exponentialRampToValueAtTime(1200, t0 + 0.12);
46
+
47
+ const nGain = ctx.createGain();
48
+ nGain.gain.setValueAtTime(0.0, t0);
49
+ nGain.gain.linearRampToValueAtTime((CFG.audio?.gunshotVol ?? 0.9) * 0.7, t0 + 0.002);
50
+ nGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.12);
51
+
52
+ noise.connect(hp).connect(lp).connect(nGain).connect(master);
53
+ noise.start(t0);
54
+ noise.stop(t0 + 0.13);
55
+
56
+ // Low thump
57
+ const osc = ctx.createOscillator();
58
+ osc.type = 'sine';
59
+ osc.frequency.setValueAtTime(160, t0);
60
+ osc.frequency.exponentialRampToValueAtTime(60, t0 + 0.12);
61
+ const oGain = ctx.createGain();
62
+ oGain.gain.setValueAtTime(0.0, t0);
63
+ oGain.gain.linearRampToValueAtTime((CFG.audio?.gunshotVol ?? 0.9) * 0.3, t0 + 0.005);
64
+ oGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.14);
65
+ osc.connect(oGain).connect(master);
66
+ osc.start(t0);
67
+ osc.stop(t0 + 0.16);
68
+ }
69
+
70
+ // Headshot: bright, short bell with slight pitch down
71
+ export function playHeadshot() {
72
+ ensureContext();
73
+ const t0 = ctx.currentTime;
74
+ const baseVol = (CFG.audio?.headshotVol ?? 0.8);
75
+
76
+ // Two detuned triangles
77
+ const makeVoice = (freq, detune) => {
78
+ const o = ctx.createOscillator();
79
+ o.type = 'triangle';
80
+ o.frequency.setValueAtTime(freq, t0);
81
+ o.detune.setValueAtTime(detune, t0);
82
+ o.frequency.exponentialRampToValueAtTime(freq * 0.75, t0 + 0.18);
83
+ const g = ctx.createGain();
84
+ g.gain.setValueAtTime(0.0, t0);
85
+ g.gain.linearRampToValueAtTime(baseVol * 0.45, t0 + 0.005);
86
+ g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.22);
87
+ o.connect(g);
88
+ return { o, g };
89
+ };
90
+
91
+ const v1 = makeVoice(1100, 0);
92
+ const v2 = makeVoice(1100 * 1.5, 8);
93
+
94
+ const bp = ctx.createBiquadFilter();
95
+ bp.type = 'bandpass';
96
+ bp.frequency.setValueAtTime(1500, t0);
97
+ bp.Q.value = 3;
98
+
99
+ v1.g.connect(bp);
100
+ v2.g.connect(bp);
101
+ bp.connect(master);
102
+
103
+ v1.o.start(t0); v1.o.stop(t0 + 0.24);
104
+ v2.o.start(t0); v2.o.stop(t0 + 0.24);
105
+
106
+ // Tiny click to emphasize
107
+ const click = ctx.createBufferSource();
108
+ const buf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.01), ctx.sampleRate);
109
+ const ch = buf.getChannelData(0);
110
+ for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length);
111
+ click.buffer = buf;
112
+ const cGain = ctx.createGain();
113
+ cGain.gain.value = baseVol * 0.25;
114
+ click.connect(cGain).connect(master);
115
+ click.start(t0);
116
+ }
117
+
118
+ // FM metal ping helper
119
+ function fmPing(t, { freq = 650, mod = 1200, index = 1.2, dur = 0.14, gain = 0.28, bpFreq = 1800, q = 8 }) {
120
+ const o = ctx.createOscillator(); o.type = 'sine';
121
+ const m = ctx.createOscillator(); m.type = 'sine';
122
+ const mg = ctx.createGain(); mg.gain.value = freq * index;
123
+ m.connect(mg).connect(o.frequency);
124
+ o.frequency.setValueAtTime(freq, t);
125
+ m.frequency.setValueAtTime(mod, t);
126
+
127
+ const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = q; bp.frequency.value = bpFreq;
128
+ const g = ctx.createGain();
129
+ g.gain.setValueAtTime(0.0001, t);
130
+ g.gain.linearRampToValueAtTime(gain * (CFG.audio?.reloadVol ?? 0.7), t + 0.006);
131
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
132
+
133
+ o.connect(bp).connect(g).connect(master);
134
+ o.start(t); m.start(t);
135
+ o.stop(t + dur + 0.02); m.stop(t + dur + 0.02);
136
+ }
137
+
138
+ function tick(t, len = 0.012, gain = 0.18) {
139
+ const src = ctx.createBufferSource();
140
+ const b = ctx.createBuffer(1, Math.floor(ctx.sampleRate * len), ctx.sampleRate);
141
+ const ch = b.getChannelData(0);
142
+ for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length);
143
+ src.buffer = b;
144
+ const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2000;
145
+ const g = ctx.createGain(); g.gain.value = gain * (CFG.audio?.reloadVol ?? 0.7);
146
+ src.connect(hp).connect(g).connect(master);
147
+ src.start(t); src.stop(t + len + 0.005);
148
+ }
149
+
150
+ // Start-of-reload metallic cue (short, crisp)
151
+ export function playReloadStart() {
152
+ ensureContext();
153
+ const t0 = ctx.currentTime;
154
+ fmPing(t0 + 0.0, { freq: 900, mod: 1800, index: 1.4, dur: 0.10, gain: 0.22, bpFreq: 2200, q: 10 });
155
+ tick(t0 + 0.01, 0.01, 0.12);
156
+ }
157
+
158
+ // End-of-reload latch (deeper metallic clack)
159
+ export function playReloadEnd() {
160
+ ensureContext();
161
+ const t0 = ctx.currentTime;
162
+ fmPing(t0, { freq: 520, mod: 900, index: 1.0, dur: 0.16, gain: 0.32, bpFreq: 1500, q: 7 });
163
+ fmPing(t0 + 0.02, { freq: 780, mod: 1400, index: 0.9, dur: 0.12, gain: 0.18, bpFreq: 1900, q: 9 });
164
+ tick(t0 + 0.005, 0.012, 0.2);
165
+ }
src/casings.js ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { G } from './globals.js';
3
+
4
+ // Shared casing geometry/materials to avoid per-shot allocations
5
+ const CASING = (() => {
6
+ const bodyGeo = new THREE.CylinderGeometry(0.018, 0.018, 0.12, 10);
7
+ const capGeo = new THREE.CylinderGeometry(0.018, 0.018, 0.01, 10);
8
+ const brass = new THREE.MeshStandardMaterial({ color: 0xb48a3a, metalness: 0.7, roughness: 0.35 });
9
+ const capMat = new THREE.MeshStandardMaterial({ color: 0x222222, metalness: 0.2, roughness: 0.7 });
10
+ return { bodyGeo, capGeo, brass, capMat };
11
+ })();
12
+
13
+ const MAX_CASINGS = 40;
14
+
15
+ // Spawn a brass shell casing at the weapon's ejector anchor
16
+ export function spawnShellCasing() {
17
+ if (!G.weapon || !G.weapon.ejector) return;
18
+
19
+ const anchor = G.weapon.ejector;
20
+ const pos = new THREE.Vector3();
21
+ const q = new THREE.Quaternion();
22
+ anchor.getWorldPosition(pos);
23
+ anchor.getWorldQuaternion(q);
24
+
25
+ // Visual: small brass cylinder + dark cap
26
+ const group = new THREE.Group();
27
+
28
+ // Cylinder aligned along X: use rotation
29
+ const body = new THREE.Mesh(CASING.bodyGeo, CASING.brass);
30
+ body.rotation.z = Math.PI / 2;
31
+ body.castShadow = false; body.receiveShadow = false;
32
+ group.add(body);
33
+
34
+ const cap = new THREE.Mesh(CASING.capGeo, CASING.capMat);
35
+ cap.position.x = 0.06;
36
+ cap.rotation.z = Math.PI / 2;
37
+ cap.castShadow = false; cap.receiveShadow = false;
38
+ group.add(cap);
39
+
40
+ group.position.copy(pos);
41
+ G.scene.add(group);
42
+
43
+ // Orientation basis from weapon
44
+ const right = new THREE.Vector3(1, 0, 0).applyQuaternion(q).normalize();
45
+ const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q).normalize();
46
+ const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(q).normalize();
47
+
48
+ // Ejection velocity: mostly right, a bit up, slightly backward
49
+ const baseRight = 2.0 + G.random() * 1.2;
50
+ const baseUp = 1.6 + G.random() * 0.8;
51
+ const back = 0.6 + G.random() * 0.6;
52
+ const jitter = new THREE.Vector3((G.random() - 0.5) * 0.4, (G.random() - 0.5) * 0.4, (G.random() - 0.5) * 0.4);
53
+ const vel = new THREE.Vector3()
54
+ .addScaledVector(right, baseRight)
55
+ .addScaledVector(up, baseUp)
56
+ .addScaledVector(fwd, -back)
57
+ .add(jitter);
58
+
59
+ // Random angular velocity for spin
60
+ const angVel = new THREE.Vector3(
61
+ (G.random() - 0.5) * 20,
62
+ (G.random() - 0.5) * 30,
63
+ (G.random() - 0.5) * 20
64
+ );
65
+
66
+ // Keep list bounded to avoid unbounded accumulation
67
+ if (G.casings.length >= MAX_CASINGS) {
68
+ const old = G.casings.shift();
69
+ if (old) G.scene.remove(old.mesh);
70
+ }
71
+
72
+ G.casings.push({
73
+ mesh: group,
74
+ pos: group.position,
75
+ vel,
76
+ angVel,
77
+ life: 6,
78
+ grounded: false
79
+ });
80
+ }
81
+
82
+ export function updateCasings(delta) {
83
+ const gravity = 20;
84
+ const bounce = 0.25;
85
+ for (let i = G.casings.length - 1; i >= 0; i--) {
86
+ const c = G.casings[i];
87
+
88
+ // Integrate
89
+ c.vel.y -= gravity * delta;
90
+ c.pos.addScaledVector(c.vel, delta);
91
+
92
+ // Spin
93
+ if (c.angVel) {
94
+ c.mesh.rotateX(c.angVel.x * delta);
95
+ c.mesh.rotateY(c.angVel.y * delta);
96
+ c.mesh.rotateZ(c.angVel.z * delta);
97
+ }
98
+
99
+ // Ground collision (approximate at y ~ body radius)
100
+ const floor = 0.02;
101
+ if (c.pos.y <= floor) {
102
+ c.pos.y = floor;
103
+ if (Math.abs(c.vel.y) > 0.3) {
104
+ c.vel.y = -c.vel.y * bounce;
105
+ } else {
106
+ c.vel.y = 0;
107
+ }
108
+ // Horizontal friction
109
+ c.vel.x *= 0.75;
110
+ c.vel.z *= 0.75;
111
+ // Dampen spin
112
+ if (c.angVel) c.angVel.multiplyScalar(0.88);
113
+ }
114
+
115
+ // Lifetime fade and cleanup
116
+ c.life -= delta;
117
+ if (c.life <= 1.5) {
118
+ for (const child of c.mesh.children) {
119
+ const m = child.material;
120
+ if (m && m.opacity !== undefined) {
121
+ m.transparent = true;
122
+ m.opacity = Math.max(0, c.life / 1.5);
123
+ }
124
+ }
125
+ }
126
+ if (c.life <= 0) {
127
+ G.scene.remove(c.mesh);
128
+ G.casings.splice(i, 1);
129
+ }
130
+ }
131
+ }
src/clouds.js ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { G } from './globals.js';
3
+ import { CLOUDS } from './config.js';
4
+
5
+ const COLOR_N = new THREE.Color(CLOUDS.colorNight);
6
+ const COLOR_D = new THREE.Color(CLOUDS.colorDay);
7
+ const TMP_COLOR = new THREE.Color();
8
+
9
+ // Lightweight value-noise + FBM for soft, natural cloud edges
10
+ function hash2i(xi, yi, seed) {
11
+ let h = Math.imul(xi, 374761393) ^ Math.imul(yi, 668265263) ^ Math.imul(seed, 2147483647);
12
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
13
+ h = (h ^ (h >>> 16)) >>> 0;
14
+ return h / 4294967296; // [0,1)
15
+ }
16
+
17
+ function smoothstep(a, b, t) {
18
+ if (t <= a) return 0;
19
+ if (t >= b) return 1;
20
+ t = (t - a) / (b - a);
21
+ return t * t * (3 - 2 * t);
22
+ }
23
+
24
+ function lerp(a, b, t) { return a + (b - a) * t; }
25
+
26
+ function valueNoise2(x, y, seed) {
27
+ const xi = Math.floor(x);
28
+ const yi = Math.floor(y);
29
+ const xf = x - xi;
30
+ const yf = y - yi;
31
+ const sx = smoothstep(0, 1, xf);
32
+ const sy = smoothstep(0, 1, yf);
33
+ const v00 = hash2i(xi, yi, seed);
34
+ const v10 = hash2i(xi + 1, yi, seed);
35
+ const v01 = hash2i(xi, yi + 1, seed);
36
+ const v11 = hash2i(xi + 1, yi + 1, seed);
37
+ const ix0 = lerp(v00, v10, sx);
38
+ const ix1 = lerp(v01, v11, sx);
39
+ return lerp(ix0, ix1, sy) * 2 - 1; // [-1,1]
40
+ }
41
+
42
+ function fbm2(x, y, baseFreq, octaves, lacunarity, gain, seed) {
43
+ let sum = 0;
44
+ let amp = 1;
45
+ let freq = baseFreq;
46
+ for (let i = 0; i < octaves; i++) {
47
+ sum += amp * valueNoise2(x * freq, y * freq, seed + i * 1013);
48
+ freq *= lacunarity;
49
+ amp *= gain;
50
+ }
51
+ return sum; // ~[-ampSum, ampSum]
52
+ }
53
+
54
+ function makeCloudTexture(size = 256, puffCount = 10) {
55
+ const canvas = document.createElement('canvas');
56
+ canvas.width = canvas.height = size;
57
+ const ctx = canvas.getContext('2d');
58
+ if (!ctx) return null;
59
+ ctx.clearRect(0, 0, size, size);
60
+
61
+ // Draw several soft circles to form a cloud shape
62
+ const r = size / 2;
63
+ ctx.fillStyle = 'white';
64
+ ctx.globalCompositeOperation = 'source-over';
65
+ for (let i = 0; i < puffCount; i++) {
66
+ const pr = r * (0.42 + Math.random() * 0.38);
67
+ const px = r + (Math.random() * 2 - 1) * r * 0.48;
68
+ const py = r + (Math.random() * 2 - 1) * r * 0.22; // slightly flatter vertically
69
+ const grad = ctx.createRadialGradient(px, py, pr * 0.18, px, py, pr);
70
+ grad.addColorStop(0, 'rgba(255,255,255,0.95)');
71
+ grad.addColorStop(1, 'rgba(255,255,255,0.0)');
72
+ ctx.fillStyle = grad;
73
+ ctx.beginPath();
74
+ ctx.arc(px, py, pr, 0, Math.PI * 2);
75
+ ctx.fill();
76
+ }
77
+
78
+ // Apply subtle FBM noise to alpha for irregular, more realistic edges
79
+ const img = ctx.getImageData(0, 0, size, size);
80
+ const data = img.data;
81
+ const seed = 1337;
82
+ for (let y = 0; y < size; y++) {
83
+ for (let x = 0; x < size; x++) {
84
+ const idx = (y * size + x) * 4;
85
+ const a = data[idx + 3] / 255; // base alpha from puffs
86
+ if (a <= 0) continue;
87
+ // FBM noise in [0..1]
88
+ const nx = x / size;
89
+ const ny = y / size;
90
+ const n = fbm2(nx, ny, 8.0, 3, 2.0, 0.5, seed) * 0.5 + 0.5;
91
+ // Edge breakup and slight interior variation
92
+ let alpha = a * (0.78 + 0.35 * n);
93
+ // Gentle bottom shading (darker underside)
94
+ const shade = 0.90 + 0.10 * (1.0 - ny); // 1.0 at top -> 0.90 at bottom
95
+ data[idx] = Math.min(255, data[idx] * shade);
96
+ data[idx+1] = Math.min(255, data[idx+1] * shade);
97
+ data[idx+2] = Math.min(255, data[idx+2] * shade);
98
+ // Contrast alpha for crisper silhouettes
99
+ alpha = Math.pow(alpha, 0.85);
100
+ // Hard clip tiny alphas to help alphaTest (reduces overdraw)
101
+ if (alpha < 0.02) alpha = 0;
102
+ data[idx + 3] = Math.round(alpha * 255);
103
+ }
104
+ }
105
+ ctx.putImageData(img, 0, 0);
106
+
107
+ const tex = new THREE.CanvasTexture(canvas);
108
+ tex.generateMipmaps = true;
109
+ tex.anisotropy = 2;
110
+ tex.minFilter = THREE.LinearMipmapLinearFilter;
111
+ tex.magFilter = THREE.LinearFilter;
112
+ return tex;
113
+ }
114
+
115
+ export function setupClouds() {
116
+ if (!CLOUDS.enabled) return;
117
+ // Create shared texture
118
+ const tex = makeCloudTexture(256, 12);
119
+ if (!tex) return;
120
+
121
+ // Wind vector
122
+ const ang = THREE.MathUtils.degToRad(CLOUDS.windDeg || 0);
123
+ const wind = new THREE.Vector3(Math.cos(ang), 0, Math.sin(ang));
124
+
125
+ for (let i = 0; i < CLOUDS.count; i++) {
126
+ const mat = new THREE.SpriteMaterial({
127
+ map: tex,
128
+ color: new THREE.Color(CLOUDS.colorDay),
129
+ transparent: true,
130
+ opacity: CLOUDS.opacityDay,
131
+ alphaTest: 0.03, // discard near-transparent pixels, reduces fill cost
132
+ depthTest: true,
133
+ depthWrite: false,
134
+ fog: false
135
+ });
136
+ const sp = new THREE.Sprite(mat);
137
+ // Randomize in-texture rotation for variety without extra draw cost
138
+ sp.material.rotation = Math.random() * Math.PI * 2;
139
+ const size = THREE.MathUtils.lerp(CLOUDS.sizeMin, CLOUDS.sizeMax, Math.random());
140
+ sp.scale.set(size, size * 0.6, 1); // a bit flattened
141
+ sp.castShadow = false; sp.receiveShadow = false;
142
+
143
+ // Position in ring
144
+ const t = Math.random() * Math.PI * 2;
145
+ const r = CLOUDS.radius * (0.6 + Math.random() * 0.4);
146
+ sp.position.set(Math.cos(t) * r, CLOUDS.height + (Math.random() - 0.5) * 10, Math.sin(t) * r);
147
+ sp.renderOrder = 0;
148
+ G.scene.add(sp);
149
+
150
+ const speed = CLOUDS.speed * (0.6 + Math.random() * 0.8);
151
+ G.clouds.push({ sprite: sp, speed, wind: wind.clone(), size });
152
+ }
153
+ }
154
+
155
+ export function updateClouds(delta) {
156
+ if (!CLOUDS.enabled || G.clouds.length === 0) return;
157
+
158
+ // Day factor for opacity/color blending
159
+ const dayF = 0.5 - 0.5 * Math.cos(2 * Math.PI * (G.timeOfDay || 0));
160
+
161
+ for (const c of G.clouds) {
162
+ // Drift
163
+ c.sprite.position.addScaledVector(c.wind, c.speed * delta);
164
+
165
+ // Wrap around ring bounds
166
+ const p = c.sprite.position;
167
+ const r = Math.hypot(p.x, p.z);
168
+ const minR = CLOUDS.radius * 0.5;
169
+ const maxR = CLOUDS.radius * 1.1;
170
+ if (r < minR || r > maxR) {
171
+ // Reposition opposite side keeping height
172
+ const ang = Math.atan2(p.z, p.x) + Math.PI;
173
+ p.x = Math.cos(ang) * CLOUDS.radius;
174
+ p.z = Math.sin(ang) * CLOUDS.radius;
175
+ }
176
+
177
+ // Blend opacity and color via day/night
178
+ const op = CLOUDS.opacityNight * (1 - dayF) + CLOUDS.opacityDay * dayF;
179
+ c.sprite.material.opacity = op;
180
+ TMP_COLOR.copy(COLOR_N).lerp(COLOR_D, dayF);
181
+ c.sprite.material.color.copy(TMP_COLOR);
182
+ }
183
+ }
src/combat.js ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+ import { updateHUD } from './hud.js';
5
+ import { spawnTracer, spawnImpact, spawnMuzzleFlash } from './fx.js';
6
+ import { spawnShellCasing } from './casings.js';
7
+ import { popHelmet } from './helmets.js';
8
+ import { beginReload } from './weapon.js';
9
+ import { spawnHealthOrbs } from './pickups.js';
10
+ import { playGunshot, playHeadshot } from './audio.js';
11
+
12
+ // Reusable temps to reduce GC
13
+ const TMP2 = new THREE.Vector2();
14
+ const TMPv1 = new THREE.Vector3();
15
+ const TMPv2 = new THREE.Vector3();
16
+ const TMPn = new THREE.Vector3();
17
+ const HIT_OBJECTS = [];
18
+ const UP = new THREE.Vector3(0, 1, 0);
19
+
20
+ export function performShooting(delta) {
21
+ G.shootCooldown -= delta;
22
+
23
+ if (G.input.shoot && G.shootCooldown <= 0 && G.state === 'playing') {
24
+ if (G.weapon.reloading) return;
25
+
26
+ if (G.weapon.ammo <= 0) {
27
+ G.shootCooldown = 0.2;
28
+ G.weapon.recoil += CFG.gun.recoilKick * 0.25;
29
+ return;
30
+ }
31
+
32
+ G.shootCooldown = 1 / CFG.gun.rof;
33
+ G.weapon.ammo--;
34
+ G.weapon.recoil += CFG.gun.recoilKick;
35
+ updateHUD();
36
+
37
+ // Increase dynamic spread per shot (clamped)
38
+ const inc = CFG.gun.spreadShotIncrease || 0;
39
+ const maxS = CFG.gun.spreadMax || 0.02;
40
+ G.weapon.spread = Math.min(maxS, G.weapon.spread + inc);
41
+
42
+ // View recoil: add a pitch up and small random yaw
43
+ const pitchKick = THREE.MathUtils.degToRad(CFG.gun.viewKickPitchDeg || 0);
44
+ const yawKick = THREE.MathUtils.degToRad((CFG.gun.viewKickYawDeg || 0) * (G.random() * 2 - 1));
45
+ G.weapon.viewPitch += pitchKick;
46
+ G.weapon.viewYaw += yawKick;
47
+
48
+ const spread = G.weapon.spread || (CFG.gun.bloom || 0);
49
+ const nx = (G.random() - 0.5) * spread * 2;
50
+ const ny = (G.random() - 0.5) * spread * 2;
51
+
52
+ TMP2.set(nx, ny);
53
+ G.raycaster.setFromCamera(TMP2, G.camera);
54
+ G.raycaster.far = CFG.gun.range;
55
+
56
+ // Build hit list without creating new arrays
57
+ HIT_OBJECTS.length = 0;
58
+ for (let i = 0; i < G.enemies.length; i++) {
59
+ const e = G.enemies[i];
60
+ if (e.alive) HIT_OBJECTS.push(e.mesh);
61
+ }
62
+ for (let i = 0; i < G.blockers.length; i++) HIT_OBJECTS.push(G.blockers[i]);
63
+
64
+ const hits = G.raycaster.intersectObjects(HIT_OBJECTS, true);
65
+
66
+ G.weapon.muzzle.getWorldPosition(TMPv1);
67
+
68
+ G.camera.getWorldDirection(TMPv2);
69
+
70
+ let end = TMPv2.clone().multiplyScalar(CFG.gun.range).add(G.camera.position);
71
+ let firstHit = null;
72
+
73
+ if (hits.length > 0) {
74
+ firstHit = hits[0];
75
+ end.copy(firstHit.point);
76
+
77
+ // Find enemy and hit zone by traversing up the hierarchy
78
+ function findEnemyAndZone(obj) {
79
+ let cur = obj;
80
+ while (cur) {
81
+ if (cur.userData && cur.userData.enemy) {
82
+ return { enemy: cur.userData.enemy, zone: cur.userData.hitZone || 'body' };
83
+ }
84
+ cur = cur.parent;
85
+ }
86
+ return { enemy: null, zone: null };
87
+ }
88
+
89
+ const { enemy, zone } = findEnemyAndZone(firstHit.object);
90
+ if (enemy && enemy.alive) {
91
+ const isHead = zone === 'head';
92
+ const dmg = CFG.gun.damage * (isHead ? CFG.gun.headshotMult : 1);
93
+ enemy.hp -= dmg;
94
+
95
+ if (isHead) playHeadshot();
96
+
97
+ // If headshot, pop the helmet off with a kick in shot direction
98
+ if (isHead && enemy.helmetAttached) {
99
+ const shotDir = new THREE.Vector3();
100
+ G.camera.getWorldDirection(shotDir);
101
+ popHelmet(enemy, shotDir, firstHit.point);
102
+ }
103
+
104
+ // Debug hit indicator removed to avoid per-shot allocations
105
+
106
+ if (enemy.hp <= 0) {
107
+ enemy.alive = false;
108
+ enemy.deathTimer = 0;
109
+ G.waves.aliveCount--;
110
+ G.player.score += isHead ? 15 : 10;
111
+ // Drop 1-5 green health orbs
112
+ const cnt = 1 + Math.floor(G.random() * 5);
113
+ spawnHealthOrbs(enemy.pos, cnt);
114
+ }
115
+ } else if (firstHit.object !== G.ground) {
116
+ const n = firstHit.face?.normal
117
+ ? TMPn.copy(firstHit.face.normal).transformDirection(firstHit.object.matrixWorld)
118
+ : UP;
119
+ spawnImpact(firstHit.point, n);
120
+ }
121
+ }
122
+
123
+ spawnTracer(TMPv1, end);
124
+ spawnMuzzleFlash();
125
+ spawnShellCasing();
126
+ playGunshot();
127
+ }
128
+
129
+ if (!G.weapon.reloading && G.weapon.ammo === 0 && (G.weapon.reserve > 0 || G.weapon.reserve === Infinity)) {
130
+ beginReload();
131
+ }
132
+ }
src/config.js ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Game configuration constants
2
+ export const CFG = {
3
+ forestSize: 300,
4
+ treeCount: 400,
5
+ clearRadius: 12,
6
+ // Ground cover (instanced) config
7
+ foliage: {
8
+ chunkSize: 30, // world units per chunk (controls draw calls)
9
+ grassPerChunk: 220, // avg blades per chunk (denser)
10
+ flowersPerChunk: 10, // avg flower sprites per chunk
11
+ bushesPerChunk: 2, // avg bushes per chunk
12
+ rocksPerChunk: 1, // avg rocks per chunk
13
+ maxSlope: 0.98, // skip very steep surfaces (0..1, 1 = flat)
14
+ windStrength: 0.45, // vertex sway amount for grass/flowers
15
+ densityNearClear: 0.45, // density multiplier at the edge of the clearing
16
+ // Distance culling for instanced ground cover
17
+ grassViewDist: 95, // draw grass within this radius from camera
18
+ flowerViewDist: 85, // draw flowers within this radius from camera
19
+ seedOffset: 8888 // extra salt for deterministic placement
20
+ },
21
+ player: {
22
+ speed: 8,
23
+ sprintMult: 1.5,
24
+ radius: 0.8,
25
+ health: 100,
26
+ jumpVel: 5
27
+ },
28
+ flashlight: {
29
+ on: true,
30
+ distance: 150,
31
+ intensity: 8.0,
32
+ angle: 0.6
33
+ },
34
+ fogDensity: 0.008,
35
+ waves: {
36
+ baseCount: 6,
37
+ perWaveAdd: 4,
38
+ spawnEvery: 0.7,
39
+ spawnDecay: 0.03,
40
+ spawnMin: 0.25,
41
+ maxAlive: 30,
42
+ annulusMin: 28,
43
+ annulusMax: 45,
44
+ breakTime: 2.5
45
+ },
46
+ enemy: {
47
+ hp: 100,
48
+ radius: 0.9,
49
+ baseSpeed: 2.5,
50
+ speedPerWave: 0.25,
51
+ dps: 15,
52
+ // Ranged weapon settings
53
+ rof: 0.6, // shots per second (archers)
54
+ range: 120,
55
+ shotDamage: 8,
56
+ bloom: 0.015, // aim error
57
+ // Arrow projectile settings
58
+ arrowSpeed: 40,
59
+ arrowGravity: 20,
60
+ arrowDamage: 12,
61
+ arrowLife: 6,
62
+ arrowHitRadius: 0.6
63
+ },
64
+ gun: {
65
+ // Faster, closer to AK full-auto feel (~600 RPM is 10 RPS)
66
+ rof: 10.5,
67
+ damage: 34,
68
+ range: 120,
69
+ // Minimum hipfire spread (normalized device coords)
70
+ bloom: 0.004,
71
+ // CS-style dynamic spread tuning
72
+ spreadMin: 0.003,
73
+ spreadMax: 0.028,
74
+ spreadShotIncrease: 0.0030, // added per shot (faster bloom)
75
+ spreadDecay: 6.8, // slightly slower recovery while spraying
76
+ spreadMoveMult: 2.2, // walking
77
+ spreadSprintMult: 3.2, // sprinting
78
+ spreadAirMult: 4.0, // not grounded
79
+ magSize: 24,
80
+ reloadTime: 1.6,
81
+ recoilKick: 0.065,
82
+ recoilRecover: 9.0,
83
+ headshotMult: 2.0,
84
+ // View recoil (adds to camera; radians suggested)
85
+ viewKickPitchDeg: 1.2, // a touch more kick
86
+ viewKickYawDeg: 0.5, // slightly more horizontal wander
87
+ viewReturn: 9.0 // per second return to neutral
88
+ },
89
+ fx: {
90
+ tracerLife: 0.08,
91
+ impactLife: 0.25,
92
+ muzzleLife: 0.05
93
+ },
94
+ audio: {
95
+ master: 0.6,
96
+ gunshotVol: 0.9,
97
+ headshotVol: 0.8,
98
+ reloadVol: 0.7,
99
+ reloadStart: true,
100
+ reloadEnd: true
101
+ },
102
+ hud: {
103
+ damageMaxOpacity: 0.6,
104
+ damageFadeSpeed: 2.5, // per second
105
+ damagePulsePerHit: 0.5, // adds to flash on single hit
106
+ damagePulsePerHP: 0.01, // adds per HP of damage
107
+ // Heal overlay
108
+ healMaxOpacity: 0.5,
109
+ healFadeSpeed: 2.5, // per second
110
+ healPulsePerPickup: 0.45, // adds to flash per orb pickup
111
+ healPulsePerHP: 0.01 // adds per HP healed
112
+ },
113
+ seed: 1337
114
+ };
115
+
116
+ // Day/Night cycle configuration
117
+ export const DAY_NIGHT = {
118
+ enabled: true,
119
+ lengthSec: 180, // full cycle duration
120
+ // Orbit
121
+ sunDistance: 200, // distance of sun sprite from origin
122
+ moonDistance: 200, // distance of moon sprite from origin
123
+ sunTiltDeg: -30, // yaw tilt of the sun path (azimuth)
124
+ // Intensities
125
+ nightAmbient: 0.45,
126
+ dayAmbient: 0.65,
127
+ nightKey: 0.9, // keep night brighter than before
128
+ dayKey: 1.5,
129
+ // Fog densities
130
+ nightFogDensity: 0.006,
131
+ dayFogDensity: 0.0035,
132
+ // Colors
133
+ colors: {
134
+ ambientSkyNight: 0x6a7f9a,
135
+ ambientSkyDay: 0xbfd4ff,
136
+ ambientGroundNight: 0x2a3544,
137
+ ambientGroundDay: 0x7a8c6e,
138
+ dirNight: 0x8baae0,
139
+ dirDay: 0xfff1cc,
140
+ // Sun color grading
141
+ sunSunrise: 0xffa04a, // warmer at horizon
142
+ sunNoon: 0xfff1cc, // softer pale at zenith
143
+ fogNight: 0x101922,
144
+ fogDay: 0x9cc3ff,
145
+ bgNight: 0x121a24,
146
+ bgDay: 0x6ea7e0
147
+ }
148
+ };
149
+
150
+ // Simple clouds config
151
+ export const CLOUDS = {
152
+ enabled: true,
153
+ count: 12,
154
+ height: 92,
155
+ radius: 200,
156
+ sizeMin: 28,
157
+ sizeMax: 48,
158
+ speed: 2.0, // base drift speed
159
+ windDeg: 35, // wind direction in degrees (0 = +X, 90 = +Z)
160
+ opacityDay: 0.55,
161
+ opacityNight: 0.25,
162
+ colorDay: 0xffffff,
163
+ colorNight: 0xa0b0c8
164
+ };
165
+
166
+ // Background mountains (purely visual)
167
+ export const MOUNTAINS = {
168
+ enabled: true,
169
+ radius: 420,
170
+ segments: 96,
171
+ baseHeight: 20,
172
+ heightVar: 60,
173
+ yOffset: -30,
174
+ colorDay: 0x6e8ba6,
175
+ colorNight: 0x223140,
176
+ // Per-vertex gradient along height
177
+ colorBase: 0x1d2a35,
178
+ colorPeak: 0xd7e0ea,
179
+ // Bottom fade to avoid visible base demarcation
180
+ fadeEdge: 0.45, // 0..1, where it starts to become opaque
181
+ fadePow: 1.3
182
+ };
src/daynight.js ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { G } from './globals.js';
3
+ import { DAY_NIGHT } from './config.js';
4
+
5
+ const c = DAY_NIGHT.colors;
6
+
7
+ const color = (hex) => new THREE.Color(hex);
8
+
9
+ const SKY_N = color(c.ambientSkyNight);
10
+ const SKY_D = color(c.ambientSkyDay);
11
+ const GRD_N = color(c.ambientGroundNight);
12
+ const GRD_D = color(c.ambientGroundDay);
13
+ const DIR_N = color(c.dirNight);
14
+ const DIR_D = color(c.dirDay);
15
+ const SUN_R = color(c.sunSunrise || 0xffa04a);
16
+ const SUN_N = color(c.sunNoon || 0xfff1cc);
17
+ const FOG_N = color(c.fogNight);
18
+ const FOG_D = color(c.fogDay);
19
+ const BG_N = color(c.bgNight);
20
+ const BG_D = color(c.bgDay);
21
+
22
+ // Reusable colors to avoid allocs each frame
23
+ const tmpA = new THREE.Color();
24
+ const tmpB = new THREE.Color();
25
+
26
+ function clamp01(x){ return Math.max(0, Math.min(1, x)); }
27
+
28
+ // Returns [dayFactor, nightFactor]
29
+ function dayNightFactors(t) {
30
+ // Cosine-based curve: 1 at noon, 0 at midnight
31
+ const day = 0.5 - 0.5 * Math.cos(2 * Math.PI * t);
32
+ const night = 1 - day;
33
+ return [day, night];
34
+ }
35
+
36
+ export function updateDayNight(delta) {
37
+ if (!DAY_NIGHT.enabled) return;
38
+
39
+ // Advance time
40
+ const len = Math.max(10, DAY_NIGHT.lengthSec);
41
+ G.timeOfDay = (G.timeOfDay + delta / len) % 1;
42
+
43
+ const [dayF, nightF] = dayNightFactors(G.timeOfDay);
44
+
45
+ // Intensities
46
+ const ambI = DAY_NIGHT.nightAmbient * nightF + DAY_NIGHT.dayAmbient * dayF;
47
+ const sunI = DAY_NIGHT.dayKey * dayF;
48
+ const moonI = DAY_NIGHT.nightKey * nightF;
49
+ const fogD = DAY_NIGHT.nightFogDensity * nightF + DAY_NIGHT.dayFogDensity * dayF;
50
+
51
+ // Colors
52
+ tmpA.copy(SKY_N).lerp(SKY_D, dayF);
53
+ if (G.ambientLight) {
54
+ G.ambientLight.intensity = ambI;
55
+ G.ambientLight.color.copy(tmpA);
56
+ tmpB.copy(GRD_N).lerp(GRD_D, dayF);
57
+ // HemisphereLight has groundColor
58
+ G.ambientLight.groundColor.copy(tmpB);
59
+ }
60
+
61
+ // Compute sun/moon directions along an arced path
62
+ // Noon at t=0.5, midnight at t=0.0, use phi in [-pi, pi]
63
+ const phi = (G.timeOfDay - 0.25) * Math.PI * 2; // -pi/2 at t=0, +pi/2 at t=0.5
64
+ const sunDir = new THREE.Vector3(0, Math.sin(phi), Math.cos(phi)); // YZ plane
65
+ // Apply yaw tilt
66
+ const tilt = THREE.MathUtils.degToRad(DAY_NIGHT.sunTiltDeg || 0);
67
+ sunDir.applyAxisAngle(new THREE.Vector3(0, 1, 0), tilt).normalize();
68
+ const moonDir = sunDir.clone().multiplyScalar(-1);
69
+
70
+ // Update sun light
71
+ if (G.sunLight) {
72
+ G.sunLight.intensity = sunI;
73
+ // Sun tint warms near horizon and whites near zenith
74
+ const elev = Math.max(0, sunDir.y);
75
+ const warmT = Math.pow(elev, 0.75);
76
+ tmpA.copy(SUN_R).lerp(SUN_N, warmT);
77
+ G.sunLight.color.copy(tmpA);
78
+ const dist = DAY_NIGHT.sunDistance || 200;
79
+ G.sunLight.position.copy(sunDir).multiplyScalar(dist);
80
+ if (G.sunLight.target) G.sunLight.target.position.set(0, 0, 0);
81
+ G.sunLight.visible = sunI > 0.01;
82
+ }
83
+
84
+ // Update moon light
85
+ if (G.moonLight) {
86
+ G.moonLight.intensity = moonI;
87
+ G.moonLight.color.copy(DIR_N);
88
+ const distM = DAY_NIGHT.moonDistance || 200;
89
+ G.moonLight.position.copy(moonDir).multiplyScalar(distM);
90
+ if (G.moonLight.target) G.moonLight.target.position.set(0, 0, 0);
91
+ G.moonLight.visible = moonI > 0.01;
92
+ }
93
+
94
+ // Sun/moon sprites
95
+ if (G.sunSprite) {
96
+ const d = DAY_NIGHT.sunDistance || 200;
97
+ G.sunSprite.position.copy(sunDir).multiplyScalar(d);
98
+ // Match sprite tint to sun light color and boost opacity with day
99
+ const elev = Math.max(0, sunDir.y);
100
+ const warmT = Math.pow(elev, 0.75);
101
+ tmpA.copy(SUN_R).lerp(SUN_N, warmT);
102
+ G.sunSprite.material.color.copy(tmpA);
103
+ G.sunSprite.material.opacity = 0.65 + 0.35 * dayF;
104
+ G.sunSprite.visible = sunDir.y > 0.02; // only above horizon
105
+ }
106
+ if (G.moonSprite) {
107
+ const d = DAY_NIGHT.moonDistance || 200;
108
+ G.moonSprite.position.copy(moonDir).multiplyScalar(d);
109
+ G.moonSprite.material.opacity = 0.5 + 0.3 * nightF;
110
+ G.moonSprite.visible = moonDir.y > 0.02; // only above horizon
111
+ }
112
+
113
+ tmpA.copy(FOG_N).lerp(FOG_D, dayF);
114
+ if (G.scene && G.scene.fog) {
115
+ G.scene.fog.color.copy(tmpA);
116
+ G.scene.fog.density = fogD;
117
+ }
118
+
119
+ tmpA.copy(BG_N).lerp(BG_D, dayF);
120
+ if (G.scene && G.scene.background) {
121
+ G.scene.background.copy(tmpA);
122
+ }
123
+ }
src/enemies.js ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+ import { getTerrainHeight } from './world.js';
5
+ import { spawnMuzzleFlashAt, spawnDustAt } from './fx.js';
6
+ import { spawnEnemyArrow } from './projectiles.js';
7
+
8
+ // Reusable temps
9
+ const TMPv1 = new THREE.Vector3();
10
+ const TMPv2 = new THREE.Vector3();
11
+ const TMPv3 = new THREE.Vector3();
12
+ const ORIGIN = new THREE.Vector3();
13
+ const TO_PLAYER = new THREE.Vector3();
14
+ const START = new THREE.Vector3();
15
+ const TARGET = new THREE.Vector3();
16
+
17
+ // Shared materials for enemies
18
+ const MAT = {
19
+ skin: new THREE.MeshStandardMaterial({ color: 0x5a8f3a, roughness: 0.9 }),
20
+ tunic: new THREE.MeshStandardMaterial({ color: 0x3b2f1c, roughness: 0.85 }),
21
+ pants: new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.9 }),
22
+ metal: new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.35, roughness: 0.4 }),
23
+ leather: new THREE.MeshStandardMaterial({ color: 0x6a3e1b, roughness: 0.8 }),
24
+ silver: new THREE.MeshStandardMaterial({ color: 0xcfd3d6, metalness: 0.95, roughness: 0.25 })
25
+ };
26
+
27
+ // Also share per-enemy odds-and-ends that were previously recreated
28
+ const RIVET_MAT = new THREE.MeshStandardMaterial({ color: 0xb0b4b8, metalness: 0.8, roughness: 0.3 });
29
+ const STRING_MAT = new THREE.LineBasicMaterial({ color: 0xffffff });
30
+
31
+ function ballisticVelocity(start, target, speed, gravity, preferHigh = false) {
32
+ const disp = TMPv1.subVectors(target, start);
33
+ const dxz = Math.hypot(disp.x, disp.z);
34
+ if (dxz < 0.0001) return null;
35
+ const v2 = speed * speed;
36
+ const g = gravity;
37
+ const y = disp.y;
38
+ const under = v2 * v2 - g * (g * dxz * dxz + 2 * y * v2);
39
+ if (under < 0) return null; // no ballistic solution at this speed
40
+ const root = Math.sqrt(under);
41
+ const tan1 = (v2 + (preferHigh ? +root : -root)) / (g * dxz);
42
+ const cos = 1 / Math.sqrt(1 + tan1 * tan1);
43
+ const sin = tan1 * cos;
44
+ const vxz = speed * cos;
45
+ const vy = speed * sin;
46
+ const hdir = TMPv2.set(disp.x, 0, disp.z).normalize();
47
+ return hdir.multiplyScalar(vxz).add(TMPv3.set(0, vy, 0));
48
+ }
49
+
50
+ export function spawnEnemy() {
51
+ // Spawn near a single wave anchor, not around center
52
+ const halfSize = CFG.forestSize / 2;
53
+ const anchor = G.waves.spawnAnchor || new THREE.Vector3(
54
+ (G.random() - 0.5) * (CFG.forestSize - 40), 0, (G.random() - 0.5) * (CFG.forestSize - 40)
55
+ );
56
+ // jitter around anchor (avoid exact center of map)
57
+ let x = 0, z = 0;
58
+ {
59
+ let tries = 0;
60
+ while (tries++ < 6) {
61
+ const r = 8 + G.random() * 14;
62
+ const t = G.random() * Math.PI * 2;
63
+ x = anchor.x + Math.cos(t) * r;
64
+ z = anchor.z + Math.sin(t) * r;
65
+ if (Math.abs(x) <= halfSize && Math.abs(z) <= halfSize) break;
66
+ }
67
+ if (Math.abs(x) > halfSize || Math.abs(z) > halfSize) return;
68
+ }
69
+
70
+ // Create enemy mesh (orc with cute helmet + bow)
71
+ const enemyGroup = new THREE.Group();
72
+
73
+ const skin = MAT.skin;
74
+ const tunic = MAT.tunic;
75
+ const pants = MAT.pants;
76
+ const metal = MAT.metal;
77
+ const leather = MAT.leather;
78
+ const silver = MAT.silver;
79
+
80
+ // Torso (bulky base under armor)
81
+ const torso = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.1, 0.4), tunic);
82
+ torso.position.set(0, 1.3, 0);
83
+ torso.castShadow = true; torso.receiveShadow = true;
84
+ enemyGroup.add(torso);
85
+
86
+ // Silver armor: keep back plate + pauldrons; remove front plate for subtler look
87
+ const armorPieces = [];
88
+ {
89
+ const backPlate = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.58, 0.10), metal);
90
+ backPlate.position.set(0, 1.34, 0.26);
91
+ backPlate.castShadow = true; backPlate.receiveShadow = true;
92
+ backPlate.userData = { enemy: null, hitZone: 'body' };
93
+ enemyGroup.add(backPlate); armorPieces.push(backPlate);
94
+
95
+ // Shoulder pauldrons (simple domes)
96
+ const pauldronGeo = new THREE.SphereGeometry(0.27, 12, 10);
97
+ const pL = new THREE.Mesh(pauldronGeo, silver);
98
+ pL.scale.y = 0.6; pL.position.set(-0.52, 1.62, -0.02);
99
+ pL.castShadow = true; pL.receiveShadow = true; pL.userData = { enemy: null, hitZone: 'body' };
100
+ enemyGroup.add(pL); armorPieces.push(pL);
101
+ const pR = new THREE.Mesh(pauldronGeo, silver);
102
+ pR.scale.y = 0.6; pR.position.set(0.52, 1.62, -0.02);
103
+ pR.castShadow = true; pR.receiveShadow = true; pR.userData = { enemy: null, hitZone: 'body' };
104
+ enemyGroup.add(pR); armorPieces.push(pR);
105
+
106
+ // Leather belt with a simple metal buckle
107
+ const belt = new THREE.Mesh(new THREE.TorusGeometry(0.36, 0.04, 8, 20), leather);
108
+ belt.rotation.x = Math.PI / 2; belt.position.set(0, 1.0, 0);
109
+ belt.castShadow = true; belt.receiveShadow = true; belt.userData = { enemy: null, hitZone: 'body' };
110
+ enemyGroup.add(belt); armorPieces.push(belt);
111
+ const buckle = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.12, 0.03), metal);
112
+ buckle.position.set(0, 1.0, -0.34);
113
+ buckle.castShadow = true; buckle.receiveShadow = true; buckle.userData = { enemy: null, hitZone: 'body' };
114
+ enemyGroup.add(buckle); armorPieces.push(buckle);
115
+
116
+ // Small rivets on back plate corners only
117
+ const rivetGeo = new THREE.SphereGeometry(0.04, 8, 6);
118
+ const rivets = [
119
+ new THREE.Vector3(-0.34, 1.64, 0.26), new THREE.Vector3(0.34, 1.64, 0.26),
120
+ new THREE.Vector3(-0.34, 1.06, 0.26), new THREE.Vector3(0.34, 1.06, 0.26)
121
+ ];
122
+ for (const pos of rivets) {
123
+ const r = new THREE.Mesh(rivetGeo, RIVET_MAT);
124
+ r.position.copy(pos);
125
+ r.castShadow = true; r.receiveShadow = true; r.userData = { enemy: null, hitZone: 'body' };
126
+ enemyGroup.add(r); armorPieces.push(r);
127
+ }
128
+ }
129
+
130
+ // Head (slightly larger) + cute helmet (small dome)
131
+ const head = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), skin);
132
+ head.position.set(0, 1.95, 0);
133
+ head.castShadow = true; head.receiveShadow = true;
134
+ enemyGroup.add(head);
135
+
136
+ const helmet = new THREE.Mesh(new THREE.SphereGeometry(0.33, 12, 10), metal);
137
+ helmet.scale.y = 0.7;
138
+ helmet.position.set(0, 2.07, 0);
139
+ helmet.castShadow = true; helmet.receiveShadow = true;
140
+ // Tag helmet as a head hit zone so headshots register even when helmet is hit
141
+ helmet.userData = { enemy: null, hitZone: 'head', isHelmet: true };
142
+ enemyGroup.add(helmet);
143
+
144
+ // Arms
145
+ const armL = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
146
+ armL.position.set(-0.5, 1.35, 0);
147
+ armL.castShadow = true; enemyGroup.add(armL);
148
+
149
+ const armR = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.55, 0.18), tunic);
150
+ armR.position.set(0.5, 1.35, 0);
151
+ armR.castShadow = true; enemyGroup.add(armR);
152
+
153
+ // Legs
154
+ const legL = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
155
+ legL.position.set(-0.22, 0.5, 0);
156
+ legL.castShadow = true; enemyGroup.add(legL);
157
+
158
+ const legR = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.75, 0.24), pants);
159
+ legR.position.set(0.22, 0.5, 0);
160
+ legR.castShadow = true; enemyGroup.add(legR);
161
+
162
+ // Bow (curved torus segment + string) held slightly forward on left side
163
+ const bowGroup = new THREE.Group();
164
+ const bow = new THREE.Mesh(
165
+ new THREE.TorusGeometry(0.45, 0.03, 8, 24, Math.PI * 0.9),
166
+ leather
167
+ );
168
+ bow.rotation.z = Math.PI / 2; // orient curve
169
+ bowGroup.add(bow);
170
+
171
+ const stringGeo = new THREE.BufferGeometry().setFromPoints([
172
+ new THREE.Vector3(0, -0.42, 0), new THREE.Vector3(0, 0.42, 0)
173
+ ]);
174
+ const string = new THREE.Line(stringGeo, STRING_MAT);
175
+ bowGroup.add(string);
176
+
177
+ bowGroup.position.set(-0.32, 1.35, -0.35);
178
+ enemyGroup.add(bowGroup);
179
+
180
+ // Arrow spawn point near top-tip of bow, facing forwards
181
+ const projectileSpawn = new THREE.Object3D();
182
+ projectileSpawn.position.set(-0.32, 1.35, -0.55);
183
+ enemyGroup.add(projectileSpawn);
184
+
185
+ enemyGroup.position.set(x, getTerrainHeight(x, z), z);
186
+ G.scene.add(enemyGroup);
187
+
188
+ // Tag hit zones for headshot logic
189
+ torso.userData = { enemy: null, hitZone: 'body' };
190
+ head.userData = { enemy: null, hitZone: 'head' };
191
+ armL.userData = { enemy: null, hitZone: 'limb' };
192
+ armR.userData = { enemy: null, hitZone: 'limb' };
193
+ legL.userData = { enemy: null, hitZone: 'limb' };
194
+ legR.userData = { enemy: null, hitZone: 'limb' };
195
+ bow.userData = { enemy: null, hitZone: 'gear' };
196
+
197
+ const enemy = {
198
+ mesh: enemyGroup,
199
+ body: torso,
200
+ pos: enemyGroup.position,
201
+ radius: CFG.enemy.radius,
202
+ hp: CFG.enemy.hp,
203
+ baseSpeed: CFG.enemy.baseSpeed + CFG.enemy.speedPerWave * (G.waves.current - 1),
204
+ damagePerSecond: CFG.enemy.dps,
205
+ alive: true,
206
+ deathTimer: 0,
207
+ projectileSpawn,
208
+ shootCooldown: 0,
209
+ helmet,
210
+ helmetAttached: true
211
+ };
212
+
213
+ enemyGroup.userData = { enemy };
214
+ torso.userData.enemy = enemy;
215
+ head.userData.enemy = enemy;
216
+ armL.userData.enemy = enemy;
217
+ armR.userData.enemy = enemy;
218
+ legL.userData.enemy = enemy;
219
+ legR.userData.enemy = enemy;
220
+ bow.userData.enemy = enemy;
221
+ helmet.userData.enemy = enemy;
222
+ // Assign enemy to armor pieces so they count as body hits
223
+ for (const part of armorPieces) {
224
+ if (part && part.userData) part.userData.enemy = enemy;
225
+ }
226
+
227
+ G.enemies.push(enemy);
228
+ G.waves.aliveCount++;
229
+ }
230
+
231
+ export function updateEnemies(delta, onPlayerDeath) {
232
+
233
+ for (let i = G.enemies.length - 1; i >= 0; i--) {
234
+ const enemy = G.enemies[i];
235
+
236
+ if (!enemy.alive) {
237
+ // Spawn a quick dust puff at death position, then despawn
238
+ spawnDustAt(enemy.pos);
239
+ enemy.mesh.traverse((obj) => {
240
+ if (obj.isMesh && obj.geometry && obj.geometry.dispose) {
241
+ obj.geometry.dispose();
242
+ }
243
+ });
244
+ G.scene.remove(enemy.mesh);
245
+ G.enemies.splice(i, 1);
246
+ continue;
247
+ }
248
+
249
+ // Move towards player
250
+ const dir = G.tmpV1.copy(G.player.pos).sub(enemy.pos);
251
+ dir.y = 0;
252
+ const dist = dir.length();
253
+
254
+ if (dist > 0) {
255
+ dir.normalize();
256
+ const moveSpeed = enemy.baseSpeed * delta;
257
+ enemy.pos.add(dir.multiplyScalar(moveSpeed));
258
+
259
+ // Simple tree avoidance
260
+ for (const tree of G.treeColliders) {
261
+ const dx = enemy.pos.x - tree.x;
262
+ const dz = enemy.pos.z - tree.z;
263
+ const treeDist = Math.sqrt(dx * dx + dz * dz);
264
+ const minDist = enemy.radius + tree.radius;
265
+
266
+ if (treeDist < minDist && treeDist > 0) {
267
+ const pushX = (dx / treeDist) * (minDist - treeDist);
268
+ const pushZ = (dz / treeDist) * (minDist - treeDist);
269
+ enemy.pos.x += pushX;
270
+ enemy.pos.z += pushZ;
271
+ }
272
+ }
273
+ }
274
+
275
+ // Stick to terrain height
276
+ enemy.pos.y = getTerrainHeight(enemy.pos.x, enemy.pos.z);
277
+
278
+ // Attack player (ranged) and optional contact damage when very close
279
+ enemy.shootCooldown -= delta;
280
+
281
+ // Ranged conditions
282
+ if (dist < CFG.enemy.range && enemy.alive) {
283
+ // LOS check against trees/ground
284
+ ORIGIN.set(enemy.pos.x, 1.6, enemy.pos.z);
285
+ TO_PLAYER.subVectors(G.player.pos, ORIGIN);
286
+ const distanceToPlayer = TO_PLAYER.length();
287
+ TO_PLAYER.normalize();
288
+
289
+ G.raycaster.set(ORIGIN, TO_PLAYER);
290
+ G.raycaster.far = distanceToPlayer;
291
+ const hits = G.raycaster.intersectObjects(G.blockers, true);
292
+ const hasBlocker = hits.length > 0;
293
+
294
+ if (!hasBlocker && enemy.shootCooldown <= 0) {
295
+
296
+ // Aim with bloom + ballistic compensation; shoot an arrow
297
+ const spread = CFG.enemy.bloom;
298
+ if (enemy.projectileSpawn) enemy.projectileSpawn.getWorldPosition(START); else START.copy(ORIGIN);
299
+
300
+ TARGET.set(G.player.pos.x, G.player.pos.y - 0.2, G.player.pos.z);
301
+ // Add small random jitter to target for bloom
302
+ TARGET.x += (G.random() - 0.5) * spread * 20;
303
+ TARGET.y += (G.random() - 0.5) * spread * 8;
304
+ TARGET.z += (G.random() - 0.5) * spread * 20;
305
+
306
+ // If too far for this speed/gravity, don't waste a shot
307
+ const dxz = Math.hypot(TARGET.x - START.x, TARGET.z - START.z);
308
+ const maxRangeFlat = (CFG.enemy.arrowSpeed * CFG.enemy.arrowSpeed) / CFG.enemy.arrowGravity;
309
+ if (dxz <= maxRangeFlat * 0.98) {
310
+ let vel = ballisticVelocity(START, TARGET, CFG.enemy.arrowSpeed, CFG.enemy.arrowGravity, false);
311
+ if (vel) {
312
+ enemy.shootCooldown = 1 / CFG.enemy.rof;
313
+ spawnEnemyArrow(START, vel, true);
314
+ spawnMuzzleFlashAt(START, 0xffc080);
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ // Contact damage if very close
321
+ if (dist < G.player.radius + enemy.radius) {
322
+ const dmg = enemy.damagePerSecond * delta * 0.5; // reduced to stack less with ranged
323
+ G.player.health -= dmg;
324
+ G.damageFlash = Math.min(1, G.damageFlash + dmg * CFG.hud.damagePulsePerHP);
325
+ if (G.player.health <= 0) {
326
+ G.player.health = 0;
327
+ G.player.alive = false;
328
+ if (onPlayerDeath) onPlayerDeath();
329
+ }
330
+ }
331
+
332
+ // Look at player
333
+ enemy.mesh.lookAt(G.player.pos.x, enemy.pos.y + 1.4, G.player.pos.z);
334
+ }
335
+ }
src/events.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { G } from './globals.js';
2
+ import { CFG } from './config.js';
3
+ import { showOverlay } from './hud.js';
4
+ import { initAudio, resumeAudio } from './audio.js';
5
+
6
+ export function setupEvents({ startGame, restartGame, beginReload, updateWeaponAnchor }) {
7
+ const overlay = document.getElementById('overlay');
8
+ if (!overlay) return;
9
+
10
+ overlay.addEventListener('click', () => {
11
+ // Initialize audio on user gesture
12
+ initAudio();
13
+ if (G.state === 'menu') {
14
+ G.controls.lock();
15
+ } else if (G.state === 'paused') {
16
+ G.controls.lock();
17
+ }
18
+ });
19
+
20
+ G.controls.addEventListener('lock', () => {
21
+ if (G.state === 'menu') {
22
+ startGame();
23
+ } else if (G.state === 'paused') {
24
+ G.state = 'playing';
25
+ overlay.classList.add('hidden');
26
+ }
27
+ });
28
+
29
+ G.controls.addEventListener('unlock', () => {
30
+ if (G.state === 'playing') {
31
+ G.state = 'paused';
32
+ showOverlay('paused');
33
+ }
34
+ });
35
+
36
+ document.addEventListener('keydown', (e) => {
37
+ switch (e.code) {
38
+ case 'KeyW': G.input.w = true; break;
39
+ case 'KeyA': G.input.a = true; break;
40
+ case 'KeyS': G.input.s = true; break;
41
+ case 'KeyD': G.input.d = true; break;
42
+ case 'ShiftLeft': G.input.sprint = true; break;
43
+ case 'Space':
44
+ if (G.state === 'playing' && G.player.grounded) {
45
+ // Jump
46
+ G.player.yVel = CFG.player.jumpVel;
47
+ G.player.grounded = false;
48
+ }
49
+ break;
50
+ case 'KeyF':
51
+ if (G.state === 'playing' && G.flashlight) {
52
+ G.flashlight.visible = !G.flashlight.visible;
53
+ }
54
+ break;
55
+ case 'KeyP':
56
+ case 'Escape':
57
+ if (G.state === 'playing') {
58
+ G.controls.unlock();
59
+ }
60
+ break;
61
+ case 'KeyR':
62
+ if (G.state === 'playing') {
63
+ beginReload();
64
+ } else if (G.state === 'gameover') {
65
+ restartGame();
66
+ }
67
+ break;
68
+ }
69
+ });
70
+
71
+ document.addEventListener('keyup', (e) => {
72
+ switch (e.code) {
73
+ case 'KeyW': G.input.w = false; break;
74
+ case 'KeyA': G.input.a = false; break;
75
+ case 'KeyS': G.input.s = false; break;
76
+ case 'KeyD': G.input.d = false; break;
77
+ case 'ShiftLeft': G.input.sprint = false; break;
78
+ }
79
+ });
80
+
81
+ document.addEventListener('mousedown', (e) => {
82
+ if (e.button === 0 && G.state === 'playing') {
83
+ // Ensure audio context is running on interaction
84
+ resumeAudio();
85
+ G.input.shoot = true;
86
+ }
87
+ });
88
+
89
+ document.addEventListener('mouseup', (e) => {
90
+ if (e.button === 0) {
91
+ G.input.shoot = false;
92
+ }
93
+ });
94
+
95
+ window.addEventListener('resize', () => {
96
+ G.camera.aspect = window.innerWidth / window.innerHeight;
97
+ G.camera.updateProjectionMatrix();
98
+ G.renderer.setSize(window.innerWidth, window.innerHeight);
99
+ updateWeaponAnchor();
100
+ });
101
+ }
src/fx.js ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+
5
+ export function spawnTracer(from, to) {
6
+ const geo = new THREE.BufferGeometry().setFromPoints([from.clone(), to.clone()]);
7
+ const mat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, depthTest: false });
8
+ const line = new THREE.Line(geo, mat);
9
+ line.renderOrder = 9;
10
+ G.scene.add(line);
11
+ G.fx.tracers.push({ mesh: line, life: CFG.fx.tracerLife });
12
+ }
13
+
14
+ export function spawnTracerColored(from, to, color = 0xff4444, opacity = 0.85) {
15
+ const geo = new THREE.BufferGeometry().setFromPoints([from.clone(), to.clone()]);
16
+ const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthTest: false });
17
+ const line = new THREE.Line(geo, mat);
18
+ line.renderOrder = 9;
19
+ G.scene.add(line);
20
+ G.fx.tracers.push({ mesh: line, life: CFG.fx.tracerLife });
21
+ }
22
+
23
+ export function spawnImpact(point, normal) {
24
+ const plane = new THREE.Mesh(
25
+ new THREE.PlaneGeometry(0.15, 0.15),
26
+ new THREE.MeshBasicMaterial({ color: 0xffbb55, transparent: true, opacity: 0.9, depthTest: true })
27
+ );
28
+ plane.position.copy(point);
29
+ plane.lookAt(G.camera.position);
30
+ G.scene.add(plane);
31
+ G.fx.impacts.push({ mesh: plane, life: CFG.fx.impactLife });
32
+ }
33
+
34
+ export function spawnMuzzleFlash() {
35
+ if (!G.weapon.muzzle) return;
36
+ const quad = new THREE.Mesh(
37
+ new THREE.PlaneGeometry(0.2, 0.2),
38
+ new THREE.MeshBasicMaterial({ color: 0xffe070, transparent: true, opacity: 1.0, depthTest: false })
39
+ );
40
+ G.weapon.muzzle.getWorldPosition(quad.position);
41
+ quad.lookAt(G.camera.position);
42
+ quad.renderOrder = 11;
43
+ G.scene.add(quad);
44
+
45
+ const light = new THREE.PointLight(0xfff2b0, 7, 6, 2);
46
+ light.position.copy(quad.position);
47
+ G.scene.add(light);
48
+
49
+ G.fx.flashes.push({ mesh: quad, light, life: CFG.fx.muzzleLife });
50
+ }
51
+
52
+ export function spawnMuzzleFlashAt(worldPos, color = 0xffc060) {
53
+ const quad = new THREE.Mesh(
54
+ new THREE.PlaneGeometry(0.18, 0.18),
55
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 1.0, depthTest: false })
56
+ );
57
+ quad.position.copy(worldPos);
58
+ quad.lookAt(G.camera.position);
59
+ quad.renderOrder = 11;
60
+ G.scene.add(quad);
61
+
62
+ const light = new THREE.PointLight(color, 5.5, 5, 2);
63
+ light.position.copy(worldPos);
64
+ G.scene.add(light);
65
+
66
+ G.fx.flashes.push({ mesh: quad, light, life: CFG.fx.muzzleLife });
67
+ }
68
+
69
+ // Quick, subtle dust puff at a world position
70
+ export function spawnDustAt(worldPos, color = 0xcdbf9e, size = 0.55, life = 0.14) {
71
+ const quad = new THREE.Mesh(
72
+ new THREE.PlaneGeometry(size, size),
73
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.85, depthTest: true })
74
+ );
75
+ quad.position.copy(worldPos);
76
+ quad.position.y += 0.15; // lift slightly off the ground
77
+ quad.lookAt(G.camera.position);
78
+ quad.renderOrder = 8;
79
+ G.scene.add(quad);
80
+
81
+ G.fx.dusts.push({ mesh: quad, life, maxLife: life });
82
+ }
83
+
84
+ export function updateFX(delta) {
85
+ for (let i = G.fx.tracers.length - 1; i >= 0; i--) {
86
+ const t = G.fx.tracers[i];
87
+ t.life -= delta;
88
+ t.mesh.material.opacity = Math.max(0, t.life / CFG.fx.tracerLife);
89
+ if (t.life <= 0) {
90
+ G.scene.remove(t.mesh);
91
+ t.mesh.geometry.dispose();
92
+ if (t.mesh.material && t.mesh.material.dispose) t.mesh.material.dispose();
93
+ G.fx.tracers.splice(i, 1);
94
+ }
95
+ }
96
+ for (let i = G.fx.impacts.length - 1; i >= 0; i--) {
97
+ const s = G.fx.impacts[i];
98
+ s.life -= delta;
99
+ s.mesh.material.opacity = Math.max(0, s.life / CFG.fx.impactLife);
100
+ s.mesh.scale.setScalar(1 + (1 - s.life / CFG.fx.impactLife) * 0.5);
101
+ if (s.life <= 0) {
102
+ G.scene.remove(s.mesh);
103
+ s.mesh.geometry.dispose();
104
+ if (s.mesh.material && s.mesh.material.dispose) s.mesh.material.dispose();
105
+ G.fx.impacts.splice(i, 1);
106
+ }
107
+ }
108
+ for (let i = G.fx.flashes.length - 1; i >= 0; i--) {
109
+ const m = G.fx.flashes[i];
110
+ m.life -= delta;
111
+ m.mesh.material.opacity = Math.max(0, m.life / CFG.fx.muzzleLife);
112
+ m.mesh.scale.setScalar(1 + (1 - m.life / CFG.fx.muzzleLife) * 0.6);
113
+ if (m.light) m.light.intensity = 3 + 4 * (m.life / CFG.fx.muzzleLife);
114
+ if (m.life <= 0) {
115
+ G.scene.remove(m.mesh);
116
+ if (m.light) G.scene.remove(m.light);
117
+ m.mesh.geometry.dispose();
118
+ if (m.mesh.material && m.mesh.material.dispose) m.mesh.material.dispose();
119
+ G.fx.flashes.splice(i, 1);
120
+ }
121
+ }
122
+ // Dust puffs: very short-lived billboards that expand and fade
123
+ for (let i = G.fx.dusts.length - 1; i >= 0; i--) {
124
+ const d = G.fx.dusts[i];
125
+ d.life -= delta;
126
+ const t = Math.max(0, d.life / d.maxLife);
127
+ d.mesh.material.opacity = t;
128
+ d.mesh.scale.setScalar(1 + (1 - t) * 0.8);
129
+ d.mesh.lookAt(G.camera.position);
130
+ if (d.life <= 0) {
131
+ G.scene.remove(d.mesh);
132
+ d.mesh.geometry.dispose();
133
+ if (d.mesh.material && d.mesh.material.dispose) d.mesh.material.dispose();
134
+ G.fx.dusts.splice(i, 1);
135
+ }
136
+ }
137
+ }
src/globals.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+
3
+ // Centralized mutable game state shared across modules
4
+ export const G = {
5
+ renderer: null,
6
+ scene: null,
7
+ camera: null,
8
+ controls: null,
9
+ clock: null,
10
+
11
+ state: 'menu',
12
+
13
+ ground: null,
14
+ flashlight: null,
15
+ sunLight: null,
16
+ moonLight: null,
17
+ sunSprite: null,
18
+ moonSprite: null,
19
+ ambientLight: null,
20
+ // Lightweight sky clouds
21
+ clouds: [],
22
+ // Foliage chunk refs for LOD/culling
23
+ foliage: { grass: [], flowers: [] },
24
+ mountains: null,
25
+
26
+ input: {
27
+ w: false,
28
+ a: false,
29
+ s: false,
30
+ d: false,
31
+ shoot: false,
32
+ sprint: false,
33
+ jump: false
34
+ },
35
+
36
+ player: null, // initialized in main
37
+
38
+ enemies: [],
39
+ treeColliders: [],
40
+ treeMeshes: [],
41
+ // Static blockers array for raycasting (ground + trees)
42
+ blockers: [],
43
+
44
+ shootCooldown: 0,
45
+ raycaster: new THREE.Raycaster(),
46
+ tmpV1: new THREE.Vector3(),
47
+ tmpV2: new THREE.Vector3(),
48
+ tmpV3: new THREE.Vector3(),
49
+
50
+ weapon: {
51
+ group: null,
52
+ muzzle: null,
53
+ basePos: new THREE.Vector3(),
54
+ baseRot: new THREE.Euler(0, 0, 0),
55
+ recoil: 0,
56
+ swayT: 0,
57
+ ammo: 0,
58
+ reserve: Infinity,
59
+ reloading: false,
60
+ reloadTimer: 0,
61
+ anchor: { depth: 0.92, right: 0.48, bottom: 0.42 },
62
+ // Dynamic aim spread (NDC units) and helpers
63
+ spread: 0,
64
+ targetSpread: 0,
65
+ // View recoil offsets applied to camera (radians)
66
+ viewPitch: 0,
67
+ viewYaw: 0,
68
+ appliedPitch: 0,
69
+ appliedYaw: 0
70
+ },
71
+
72
+ fx: { tracers: [], impacts: [], flashes: [], dusts: [] },
73
+ enemyProjectiles: [],
74
+ // Health orbs and other pickups
75
+ orbs: [],
76
+ // Ejected shell casings
77
+ casings: [],
78
+ // Detached props (helmets) with simple physics
79
+ helmets: [],
80
+ damageFlash: 0,
81
+ healFlash: 0,
82
+
83
+ waves: {
84
+ current: 1,
85
+ aliveCount: 0,
86
+ spawnQueue: 0,
87
+ nextSpawnTimer: 0,
88
+ breakTimer: 0,
89
+ inBreak: false,
90
+ spawnAnchor: null
91
+ },
92
+
93
+ random: null,
94
+
95
+ // Day/night cycle
96
+ timeOfDay: 0.25 // 0..1, where ~0.5 is noon
97
+ };
src/helmets.js ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { G } from './globals.js';
3
+ import { getTerrainHeight } from './world.js';
4
+
5
+ // Pop the enemy's helmet off and add simple physics so it drops to ground
6
+ export function popHelmet(enemy, impulseDir = new THREE.Vector3(0, 1, 0), hitPoint = null) {
7
+ if (!enemy || !enemy.helmet || !enemy.helmetAttached) return;
8
+
9
+ const h = enemy.helmet;
10
+
11
+ // Get world transform before detaching
12
+ const worldPos = new THREE.Vector3();
13
+ const worldQuat = new THREE.Quaternion();
14
+ const worldScale = new THREE.Vector3();
15
+ h.updateMatrixWorld();
16
+ h.getWorldPosition(worldPos);
17
+ h.getWorldQuaternion(worldQuat);
18
+ h.getWorldScale(worldScale);
19
+
20
+ // Detach from enemy and add to scene root
21
+ if (h.parent) h.parent.remove(h);
22
+ h.position.copy(worldPos);
23
+ h.quaternion.copy(worldQuat);
24
+ h.scale.copy(worldScale);
25
+ G.scene.add(h);
26
+
27
+ // It should no longer count as enemy geometry for ray hits
28
+ if (h.userData) {
29
+ h.userData.enemy = null;
30
+ h.userData.hitZone = null;
31
+ h.userData.isHelmet = true;
32
+ }
33
+
34
+ // Initial velocity: away from shot direction with a fun hop up
35
+ const dir = impulseDir.clone().normalize();
36
+ const upBoost = 3 + G.random() * 1.5;
37
+ const sideJitter = new THREE.Vector3((G.random() - 0.5) * 1.5, 0, (G.random() - 0.5) * 1.5);
38
+ const vel = dir.multiplyScalar(2.2).add(new THREE.Vector3(0, upBoost, 0)).add(sideJitter);
39
+
40
+ // Angular velocity for comedic spin
41
+ const angVel = new THREE.Vector3(
42
+ (G.random() - 0.5) * 6,
43
+ (G.random() - 0.5) * 8,
44
+ (G.random() - 0.5) * 6
45
+ );
46
+
47
+ G.helmets.push({
48
+ mesh: h,
49
+ pos: h.position,
50
+ vel,
51
+ angVel,
52
+ life: 12, // fade after a while
53
+ grounded: false
54
+ });
55
+
56
+ enemy.helmetAttached = false;
57
+ }
58
+
59
+ export function updateHelmets(delta) {
60
+ const gravity = 14; // stronger than arrows for punchy drop
61
+ const bounce = 0.35;
62
+
63
+ for (let i = G.helmets.length - 1; i >= 0; i--) {
64
+ const h = G.helmets[i];
65
+
66
+ // Integrate
67
+ h.vel.y -= gravity * delta;
68
+ h.pos.addScaledVector(h.vel, delta);
69
+
70
+ // Simple rotation integration
71
+ if (h.angVel) {
72
+ h.mesh.rotateX(h.angVel.x * delta);
73
+ h.mesh.rotateY(h.angVel.y * delta);
74
+ h.mesh.rotateZ(h.angVel.z * delta);
75
+ }
76
+
77
+ // Ground collision and bounce against terrain
78
+ const groundY = getTerrainHeight(h.pos.x, h.pos.z);
79
+ if (h.pos.y <= groundY) {
80
+ if (!h.grounded) {
81
+ // First impact gets a stronger bounce
82
+ h.grounded = true;
83
+ }
84
+ h.pos.y = groundY;
85
+ if (Math.abs(h.vel.y) > 0.4) {
86
+ h.vel.y = -h.vel.y * bounce;
87
+ } else {
88
+ h.vel.y = 0;
89
+ }
90
+ // Friction on ground
91
+ h.vel.x *= 0.7;
92
+ h.vel.z *= 0.7;
93
+ // Damp spin as it settles
94
+ if (h.angVel) h.angVel.multiplyScalar(0.8);
95
+ if (Math.hypot(h.vel.x, h.vel.z) < 0.2 && Math.abs(h.vel.y) < 0.2) {
96
+ h.vel.set(0, 0, 0);
97
+ if (h.angVel) h.angVel.set(0, 0, 0);
98
+ }
99
+ }
100
+
101
+ // Lifetime fade/cleanup
102
+ h.life -= delta;
103
+ if (h.life <= 2) {
104
+ const m = h.mesh.material;
105
+ if (m && m.opacity !== undefined) {
106
+ m.transparent = true;
107
+ m.opacity = Math.max(0, h.life / 2);
108
+ }
109
+ }
110
+ if (h.life <= 0) {
111
+ // Dispose per-helmet geometries
112
+ h.mesh.traverse((obj) => { if (obj.isMesh && obj.geometry?.dispose) obj.geometry.dispose(); });
113
+ G.scene.remove(h.mesh);
114
+ G.helmets.splice(i, 1);
115
+ }
116
+ }
117
+ }
src/hud.js ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { G } from './globals.js';
2
+ import { CFG } from './config.js';
3
+
4
+ // Cache HUD element refs and last values to minimize DOM churn
5
+ const HUD = {
6
+ waveEl: /** @type {HTMLElement|null} */(document.getElementById('wave')),
7
+ scoreEl: /** @type {HTMLElement|null} */(document.getElementById('score')),
8
+ enemiesEl: /** @type {HTMLElement|null} */(document.getElementById('enemies')),
9
+ ammoEl: /** @type {HTMLElement|null} */(document.getElementById('ammo')),
10
+ healthText: /** @type {HTMLElement|null} */(document.getElementById('health-text')),
11
+ healthFill: /** @type {HTMLElement|null} */(document.getElementById('health-fill')),
12
+ ch: {
13
+ root: /** @type {HTMLElement|null} */(document.getElementById('crosshair')),
14
+ left: /** @type {HTMLElement|null} */(document.getElementById('ch-left')),
15
+ right: /** @type {HTMLElement|null} */(document.getElementById('ch-right')),
16
+ top: /** @type {HTMLElement|null} */(document.getElementById('ch-top')),
17
+ bottom: /** @type {HTMLElement|null} */(document.getElementById('ch-bottom')),
18
+ lastGap: -1,
19
+ lastLen: -1
20
+ },
21
+ last: {
22
+ hp: -1,
23
+ hpPct: -1,
24
+ wave: -1,
25
+ score: -1,
26
+ enemies: -1,
27
+ ammo: '',
28
+ }
29
+ };
30
+
31
+ export function updateHUD() {
32
+ const hp = Math.ceil(G.player.health);
33
+ const hpPct = Math.max(0, Math.min(1, G.player.health / CFG.player.health));
34
+
35
+ if (hp !== HUD.last.hp) {
36
+ HUD.last.hp = hp;
37
+ if (HUD.healthText) HUD.healthText.textContent = String(hp);
38
+ }
39
+ if (Math.abs(hpPct - HUD.last.hpPct) > 0.005) {
40
+ HUD.last.hpPct = hpPct;
41
+ if (HUD.healthFill) HUD.healthFill.style.width = ((hpPct * 100) | 0) + '%';
42
+ }
43
+
44
+ if (G.waves.current !== HUD.last.wave) {
45
+ HUD.last.wave = G.waves.current;
46
+ if (HUD.waveEl) HUD.waveEl.textContent = String(G.waves.current);
47
+ }
48
+ if (G.player.score !== HUD.last.score) {
49
+ HUD.last.score = G.player.score;
50
+ if (HUD.scoreEl) HUD.scoreEl.textContent = String(G.player.score);
51
+ }
52
+ if (G.waves.aliveCount !== HUD.last.enemies) {
53
+ HUD.last.enemies = G.waves.aliveCount;
54
+ if (HUD.enemiesEl) HUD.enemiesEl.textContent = String(G.waves.aliveCount);
55
+ }
56
+ const reserveText = G.weapon.reserve === Infinity ? '∞' : String(G.weapon.reserve);
57
+ const ammoText = `${G.weapon.ammo}/${reserveText}`;
58
+ if (ammoText !== HUD.last.ammo) {
59
+ HUD.last.ammo = ammoText;
60
+ if (HUD.ammoEl) HUD.ammoEl.textContent = ammoText;
61
+ }
62
+ }
63
+
64
+ export function showWaveBanner(text) {
65
+ const banner = document.getElementById('wave-banner');
66
+ if (!banner) return;
67
+ banner.textContent = text;
68
+ banner.classList.add('show');
69
+ setTimeout(() => banner.classList.remove('show'), 2000);
70
+ }
71
+
72
+ export function showOverlay(type) {
73
+ const overlay = document.getElementById('overlay');
74
+ const content = document.getElementById('overlay-content');
75
+ if (!overlay || !content) return;
76
+
77
+ overlay.classList.remove('hidden');
78
+
79
+ if (type === 'paused') {
80
+ content.innerHTML = `
81
+ <h1>Paused</h1>
82
+ <p>Click to Resume</p>
83
+ `;
84
+ } else if (type === 'gameover') {
85
+ content.innerHTML = `
86
+ <h1>You Died</h1>
87
+ <p>Score: ${G.player.score}</p>
88
+ <p>Wave: ${G.waves.current}</p>
89
+ <p>Press R to Restart</p>
90
+ `;
91
+ }
92
+ }
93
+
94
+ // Simple red damage overlay that fades over time
95
+ export function updateDamageEffect(delta) {
96
+ const el = document.getElementById('damage-overlay');
97
+ if (!el) return;
98
+
99
+ // Fade
100
+ G.damageFlash = Math.max(0, G.damageFlash - CFG.hud.damageFadeSpeed * delta);
101
+ const opacity = Math.min(CFG.hud.damageMaxOpacity, G.damageFlash * CFG.hud.damageMaxOpacity);
102
+ el.style.opacity = String(opacity);
103
+ }
104
+
105
+ // Green heal overlay that fades over time
106
+ export function updateHealEffect(delta) {
107
+ const el = document.getElementById('heal-overlay');
108
+ if (!el) return;
109
+
110
+ G.healFlash = Math.max(0, G.healFlash - CFG.hud.healFadeSpeed * delta);
111
+ const opacity = Math.min(CFG.hud.healMaxOpacity, G.healFlash * CFG.hud.healMaxOpacity);
112
+ el.style.opacity = String(opacity);
113
+ }
114
+
115
+ // Crosshair widening based on current spread
116
+ export function updateCrosshair(delta) {
117
+ const { ch } = HUD;
118
+ if (!ch.root || !ch.left || !ch.right || !ch.top || !ch.bottom) return;
119
+
120
+ // Convert NDC spread to pixel gap (approximate using viewport width)
121
+ const ndc = Math.max(G.weapon.spread, CFG.gun.spreadMin || CFG.gun.bloom || 0);
122
+ const baseGap = 6; // px baseline gap
123
+ const gapPx = baseGap + ndc * 0.5 * window.innerWidth; // half-width maps NDC to px
124
+ const armLen = 10 + Math.min(20, ndc * window.innerWidth * 0.4); // grow a bit with spread
125
+
126
+ if (Math.abs(gapPx - ch.lastGap) < 0.5 && Math.abs(armLen - ch.lastLen) < 0.5) return;
127
+ ch.lastGap = gapPx;
128
+ ch.lastLen = armLen;
129
+
130
+ // Horizontal arms
131
+ ch.left.style.width = armLen + 'px';
132
+ ch.left.style.left = -(gapPx + armLen) + 'px';
133
+ ch.left.style.top = '-1px';
134
+ ch.right.style.width = armLen + 'px';
135
+ ch.right.style.left = gapPx + 'px';
136
+ ch.right.style.top = '-1px';
137
+
138
+ // Vertical arms
139
+ ch.top.style.height = armLen + 'px';
140
+ ch.top.style.top = -(gapPx + armLen) + 'px';
141
+ ch.top.style.left = '-1px';
142
+ ch.bottom.style.height = armLen + 'px';
143
+ ch.bottom.style.top = gapPx + 'px';
144
+ ch.bottom.style.left = '-1px';
145
+ }
src/lighting.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+
5
+ function makeRadialCircleTexture(size = 256, innerAlpha = 1, outerAlpha = 0) {
6
+ const canvas = document.createElement('canvas');
7
+ canvas.width = canvas.height = size;
8
+ const ctx = canvas.getContext('2d');
9
+ const r = size / 2;
10
+ ctx.clearRect(0, 0, size, size);
11
+ const grad = ctx.createRadialGradient(r, r, 0, r, r, r);
12
+ grad.addColorStop(0, `rgba(255,255,255,${innerAlpha})`);
13
+ grad.addColorStop(1, `rgba(255,255,255,${outerAlpha})`);
14
+ ctx.fillStyle = grad;
15
+ ctx.beginPath();
16
+ ctx.arc(r, r, r, 0, Math.PI * 2);
17
+ ctx.fill();
18
+ const tex = new THREE.CanvasTexture(canvas);
19
+ tex.generateMipmaps = false;
20
+ tex.minFilter = THREE.LinearFilter;
21
+ tex.magFilter = THREE.LinearFilter;
22
+ return tex;
23
+ }
24
+
25
+ export function setupLights() {
26
+ // Ambient
27
+ const ambientLight = new THREE.HemisphereLight(0x6a7f9a, 0x203050, 0.3);
28
+ G.scene.add(ambientLight);
29
+ G.ambientLight = ambientLight;
30
+
31
+ // Sun light (key during day)
32
+ const sun = new THREE.DirectionalLight(0xfff1cc, 1.2);
33
+ sun.position.set(0, 100, 0);
34
+ sun.castShadow = true;
35
+ sun.shadow.camera.left = -60;
36
+ sun.shadow.camera.right = 60;
37
+ sun.shadow.camera.top = 60;
38
+ sun.shadow.camera.bottom = -60;
39
+ sun.shadow.camera.near = 0.1;
40
+ sun.shadow.camera.far = 240;
41
+ sun.shadow.mapSize.width = 2048;
42
+ sun.shadow.mapSize.height = 2048;
43
+ sun.target = new THREE.Object3D();
44
+ G.scene.add(sun);
45
+ G.scene.add(sun.target);
46
+ G.sunLight = sun;
47
+
48
+ // Moon light (key during night)
49
+ const moon = new THREE.DirectionalLight(0x6a8fc5, 0.8);
50
+ moon.position.set(50, 80, -50);
51
+ moon.castShadow = true;
52
+ moon.shadow.camera.left = -60;
53
+ moon.shadow.camera.right = 60;
54
+ moon.shadow.camera.top = 60;
55
+ moon.shadow.camera.bottom = -60;
56
+ moon.shadow.camera.near = 0.1;
57
+ moon.shadow.camera.far = 240;
58
+ moon.shadow.mapSize.width = 2048;
59
+ moon.shadow.mapSize.height = 2048;
60
+ moon.target = new THREE.Object3D();
61
+ G.scene.add(moon);
62
+ G.scene.add(moon.target);
63
+ G.moonLight = moon;
64
+
65
+ // Simple sun sprite (billboard, round via radial alpha)
66
+ const sunTex = makeRadialCircleTexture(256, 1, 0);
67
+ const sunMat = new THREE.SpriteMaterial({ color: 0xfff1cc, map: sunTex, transparent: true, opacity: 0.95, depthTest: true, depthWrite: false, fog: false });
68
+ const sunSprite = new THREE.Sprite(sunMat);
69
+ sunSprite.scale.set(10, 10, 1);
70
+ sunSprite.renderOrder = 1;
71
+ G.scene.add(sunSprite);
72
+ G.sunSprite = sunSprite;
73
+
74
+ // Simple moon sprite
75
+ const moonTex = makeRadialCircleTexture(256, 1, 0);
76
+ const moonMat = new THREE.SpriteMaterial({ color: 0xaec7ff, map: moonTex, transparent: true, opacity: 0.8, depthTest: true, depthWrite: false, fog: false });
77
+ const moonSprite = new THREE.Sprite(moonMat);
78
+ moonSprite.scale.set(6, 6, 1);
79
+ moonSprite.renderOrder = 1;
80
+ G.scene.add(moonSprite);
81
+ G.moonSprite = moonSprite;
82
+
83
+ // Flashlight
84
+ const flashlight = new THREE.SpotLight(0xffffff, CFG.flashlight.intensity);
85
+ flashlight.angle = CFG.flashlight.angle;
86
+ flashlight.penumbra = 0.2;
87
+ flashlight.distance = CFG.flashlight.distance;
88
+ flashlight.decay = 1.5;
89
+ flashlight.castShadow = true;
90
+ flashlight.shadow.mapSize.width = 2048;
91
+ flashlight.shadow.mapSize.height = 2048;
92
+ flashlight.shadow.camera.near = 0.1;
93
+ flashlight.shadow.camera.far = CFG.flashlight.distance;
94
+ flashlight.visible = CFG.flashlight.on;
95
+ G.camera.add(flashlight);
96
+ flashlight.position.set(0, 0, 0);
97
+ flashlight.target.position.set(0, 0, -1);
98
+ G.camera.add(flashlight.target);
99
+ G.scene.add(G.camera);
100
+ G.flashlight = flashlight;
101
+ }
src/main.js ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
3
+ import { CFG } from './config.js';
4
+ import { G } from './globals.js';
5
+ import { makeRandom } from './utils.js';
6
+ import { setupLights } from './lighting.js';
7
+ import { setupGround, generateForest, generateGroundCover, getTerrainHeight, tickForest } from './world.js';
8
+ import { setupWeapon, updateWeaponAnchor, beginReload, updateWeapon } from './weapon.js';
9
+ import { setupEvents } from './events.js';
10
+ import { updatePlayer } from './player.js';
11
+ import { updateEnemies } from './enemies.js';
12
+ import { startNextWave, updateWaves } from './waves.js';
13
+ import { updateHUD, showOverlay, updateDamageEffect, updateHealEffect, updateCrosshair } from './hud.js';
14
+ import { updateFX } from './fx.js';
15
+ import { updateCasings } from './casings.js';
16
+ import { updateEnemyProjectiles } from './projectiles.js';
17
+ import { updateDayNight } from './daynight.js';
18
+ import { performShooting } from './combat.js';
19
+ import { updatePickups } from './pickups.js';
20
+ import { updateHelmets } from './helmets.js';
21
+ import { setupClouds, updateClouds } from './clouds.js';
22
+ import { setupMountains, updateMountains } from './mountains.js';
23
+
24
+ init();
25
+ animate();
26
+
27
+ function init() {
28
+ // Renderer
29
+ G.renderer = new THREE.WebGLRenderer({ antialias: true });
30
+ G.renderer.setSize(window.innerWidth, window.innerHeight);
31
+ G.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
32
+ G.renderer.shadowMap.enabled = true;
33
+ G.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
34
+ document.body.appendChild(G.renderer.domElement);
35
+
36
+ // Scene
37
+ G.scene = new THREE.Scene();
38
+ G.scene.background = new THREE.Color(0x0a1015);
39
+ G.scene.fog = new THREE.FogExp2(0x05070a, CFG.fogDensity);
40
+
41
+ // Camera
42
+ G.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
43
+
44
+ // Controls
45
+ G.controls = new PointerLockControls(G.camera, document.body);
46
+
47
+ // Clock
48
+ G.clock = new THREE.Clock();
49
+
50
+ // Seeded random
51
+ G.random = makeRandom(CFG.seed);
52
+
53
+ // Player
54
+ const startY = getTerrainHeight(0, 0) + 1.8;
55
+ G.player = {
56
+ pos: new THREE.Vector3(0, startY, 0),
57
+ vel: new THREE.Vector3(),
58
+ speed: CFG.player.speed,
59
+ radius: CFG.player.radius,
60
+ health: CFG.player.health,
61
+ alive: true,
62
+ score: 0,
63
+ yVel: 0,
64
+ grounded: true
65
+ };
66
+ G.weapon.ammo = CFG.gun.magSize;
67
+ G.weapon.reserve = Infinity;
68
+
69
+ // Add camera to scene
70
+ G.camera.position.copy(G.player.pos);
71
+
72
+ // Lights
73
+ setupLights();
74
+
75
+ // Ground and world
76
+ setupGround();
77
+ // Ground cover before or after trees — independent
78
+ generateGroundCover();
79
+ generateForest();
80
+ setupClouds();
81
+ setupMountains();
82
+
83
+ // Weapon
84
+ setupWeapon();
85
+ updateWeaponAnchor();
86
+
87
+ // Events
88
+ setupEvents({
89
+ startGame,
90
+ restartGame,
91
+ beginReload,
92
+ updateWeaponAnchor
93
+ });
94
+ }
95
+
96
+ function startGame() {
97
+ G.state = 'playing';
98
+ G.player.health = CFG.player.health;
99
+ G.player.score = 0;
100
+ G.player.alive = true;
101
+ G.player.pos.set(0, getTerrainHeight(0, 0) + 1.8, 0);
102
+ G.player.vel.set(0, 0, 0);
103
+ G.camera.position.copy(G.player.pos);
104
+ G.damageFlash = 0;
105
+ G.healFlash = 0;
106
+
107
+ // Clear enemies
108
+ for (const enemy of G.enemies) {
109
+ G.scene.remove(enemy.mesh);
110
+ }
111
+ G.enemies.length = 0;
112
+ // Clear enemy projectiles
113
+ for (const p of G.enemyProjectiles) {
114
+ G.scene.remove(p.mesh);
115
+ }
116
+ G.enemyProjectiles.length = 0;
117
+
118
+ // Clear pickups/orbs
119
+ for (const o of G.orbs) {
120
+ G.scene.remove(o.mesh);
121
+ }
122
+ G.orbs.length = 0;
123
+
124
+ // Clear any detached helmets
125
+ for (const h of G.helmets) {
126
+ G.scene.remove(h.mesh);
127
+ }
128
+ G.helmets.length = 0;
129
+
130
+ // Clear ejected casings
131
+ for (const c of G.casings) {
132
+ G.scene.remove(c.mesh);
133
+ }
134
+ G.casings.length = 0;
135
+
136
+ // Reset waves
137
+ G.waves.current = 1;
138
+ G.waves.aliveCount = 0;
139
+ G.waves.spawnQueue = 0;
140
+ G.waves.nextSpawnTimer = 0;
141
+ G.waves.breakTimer = 0;
142
+ G.waves.inBreak = false;
143
+
144
+ // Reset weapon
145
+ G.weapon.ammo = CFG.gun.magSize;
146
+ G.weapon.reloading = false;
147
+ G.weapon.reloadTimer = 0;
148
+ G.weapon.recoil = 0;
149
+ G.weapon.spread = CFG.gun.spreadMin ?? CFG.gun.bloom ?? 0;
150
+ G.weapon.targetSpread = G.weapon.spread;
151
+ G.weapon.viewPitch = 0;
152
+ G.weapon.viewYaw = 0;
153
+ G.weapon.appliedPitch = 0;
154
+ G.weapon.appliedYaw = 0;
155
+
156
+ const overlay = document.getElementById('overlay');
157
+ if (overlay) overlay.classList.add('hidden');
158
+ updateHUD();
159
+ // Initialize day/night visuals immediately
160
+ updateDayNight(0);
161
+ startNextWave();
162
+ }
163
+
164
+ function restartGame() {
165
+ G.controls.lock();
166
+ }
167
+
168
+ function gameOver() {
169
+ G.state = 'gameover';
170
+ G.controls.unlock();
171
+ showOverlay('gameover');
172
+ }
173
+
174
+ function animate() {
175
+ requestAnimationFrame(animate);
176
+
177
+ const delta = Math.min(G.clock.getDelta(), 0.1);
178
+
179
+ if (G.state === 'playing') {
180
+ updatePlayer(delta);
181
+ updateEnemies(delta, gameOver);
182
+ updateEnemyProjectiles(delta, gameOver);
183
+ updateWaves(delta);
184
+ performShooting(delta);
185
+ updateWeapon(delta);
186
+ updatePickups(delta);
187
+ updateHUD();
188
+ updateCrosshair(delta);
189
+ updateDamageEffect(delta);
190
+ updateHealEffect(delta);
191
+ }
192
+ updateDayNight(delta);
193
+ updateFX(delta);
194
+ updateHelmets(delta);
195
+ updateCasings(delta);
196
+ updateClouds(delta);
197
+ updateMountains(delta);
198
+ // Update subtle foliage wind sway using elapsedTime (avoid double-advancing clock)
199
+ tickForest(G.clock.elapsedTime);
200
+
201
+ G.renderer.render(G.scene, G.camera);
202
+ }
src/mountains.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { G } from './globals.js';
3
+ import { MOUNTAINS } from './config.js';
4
+ const COLOR_N = new THREE.Color(MOUNTAINS.colorNight);
5
+ const COLOR_D = new THREE.Color(MOUNTAINS.colorDay);
6
+ const TMP_COLOR = new THREE.Color();
7
+
8
+ function dayFactor(t) {
9
+ return 0.5 - 0.5 * Math.cos(2 * Math.PI * t);
10
+ }
11
+
12
+ function buildMountainRing({ radius, segments, baseHeight, heightVar, yOffset }) {
13
+ const vertCount = segments * 2;
14
+ const positions = new Float32Array(vertCount * 3);
15
+ const colors = new Float32Array(vertCount * 3);
16
+ const aH = new Float32Array(vertCount); // 0 at bottom ring, 1 at peaks
17
+ const indices = new Uint16Array(segments * 6);
18
+
19
+ const colBottom = new THREE.Color(MOUNTAINS.colorBase);
20
+ const colTop = new THREE.Color(MOUNTAINS.colorPeak);
21
+
22
+ function ridge(a) {
23
+ const s1 = Math.sin(a * 3.0) * 0.7 + Math.sin(a * 7.0) * 0.3;
24
+ const s2 = Math.sin(a * 1.7 + 1.3) * 0.5 + Math.sin(a * 4.3 + 0.7) * 0.5;
25
+ const v = 0.5 * s1 + 0.5 * s2;
26
+ return baseHeight + heightVar * (0.5 + 0.5 * v);
27
+ }
28
+
29
+ for (let i = 0; i < segments; i++) {
30
+ const a0 = (i / segments) * Math.PI * 2;
31
+ const h = ridge(a0);
32
+ const x = Math.cos(a0) * radius;
33
+ const z = Math.sin(a0) * radius;
34
+
35
+ const iBot = i * 3;
36
+ positions[iBot + 0] = x;
37
+ positions[iBot + 1] = yOffset;
38
+ positions[iBot + 2] = z;
39
+
40
+ const iTop = (segments + i) * 3;
41
+ positions[iTop + 0] = x;
42
+ positions[iTop + 1] = yOffset + h;
43
+ positions[iTop + 2] = z;
44
+
45
+ colors[iBot + 0] = colBottom.r;
46
+ colors[iBot + 1] = colBottom.g;
47
+ colors[iBot + 2] = colBottom.b;
48
+ colors[iTop + 0] = colTop.r;
49
+ colors[iTop + 1] = colTop.g;
50
+ colors[iTop + 2] = colTop.b;
51
+
52
+ aH[i] = 0.0; // bottom vertex ratio
53
+ aH[segments + i] = 1.0; // top vertex ratio
54
+ }
55
+
56
+ let idx = 0;
57
+ for (let i = 0; i < segments; i++) {
58
+ const n = (i + 1) % segments;
59
+ const b0 = i;
60
+ const b1 = n;
61
+ const t0 = segments + i;
62
+ const t1 = segments + n;
63
+ indices[idx++] = b0;
64
+ indices[idx++] = t0;
65
+ indices[idx++] = b1;
66
+ indices[idx++] = b1;
67
+ indices[idx++] = t0;
68
+ indices[idx++] = t1;
69
+ }
70
+
71
+ const geo = new THREE.BufferGeometry();
72
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
73
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
74
+ geo.setIndex(new THREE.BufferAttribute(indices, 1));
75
+ geo.setAttribute('aH', new THREE.BufferAttribute(aH, 1));
76
+ geo.computeBoundingSphere();
77
+ return geo;
78
+ }
79
+
80
+ export function setupMountains() {
81
+ if (!MOUNTAINS.enabled) return;
82
+ const geo = buildMountainRing({
83
+ radius: MOUNTAINS.radius,
84
+ segments: MOUNTAINS.segments,
85
+ baseHeight: MOUNTAINS.baseHeight,
86
+ heightVar: MOUNTAINS.heightVar,
87
+ yOffset: MOUNTAINS.yOffset
88
+ });
89
+ const mat = new THREE.MeshBasicMaterial({
90
+ color: new THREE.Color(MOUNTAINS.colorDay),
91
+ vertexColors: true,
92
+ fog: false, // keep visible even in heavy fog
93
+ depthTest: true,
94
+ depthWrite: false,
95
+ side: THREE.DoubleSide, // ensure visible regardless of winding
96
+ transparent: true
97
+ });
98
+ mat.onBeforeCompile = (shader) => {
99
+ shader.uniforms.uFadeEdge = { value: MOUNTAINS.fadeEdge ?? 0.35 };
100
+ shader.uniforms.uFadePow = { value: MOUNTAINS.fadePow ?? 1.5 };
101
+ shader.vertexShader = (
102
+ 'attribute float aH;\n' +
103
+ 'varying float vH;\n' +
104
+ shader.vertexShader
105
+ ).replace(
106
+ '#include <begin_vertex>',
107
+ `#include <begin_vertex>\n vH = aH;`
108
+ );
109
+ shader.fragmentShader = (
110
+ 'varying float vH;\n uniform float uFadeEdge; uniform float uFadePow;\n' +
111
+ shader.fragmentShader
112
+ ).replace(
113
+ '#include <dithering_fragment>',
114
+ `diffuseColor.a *= pow(smoothstep(uFadeEdge, 1.0, vH), uFadePow);\n#include <dithering_fragment>`
115
+ );
116
+ };
117
+ const mesh = new THREE.Mesh(geo, mat);
118
+ mesh.castShadow = false;
119
+ mesh.receiveShadow = false;
120
+ mesh.renderOrder = -10;
121
+ G.scene.add(mesh);
122
+ G.mountains = mesh;
123
+ }
124
+
125
+ export function updateMountains(delta) {
126
+ if (!MOUNTAINS.enabled || !G.mountains) return;
127
+ const p = G.player ? G.player.pos : G.camera.position;
128
+ G.mountains.position.set(p.x, 0, p.z);
129
+
130
+ const dayF = dayFactor(G.timeOfDay || 0);
131
+ TMP_COLOR.copy(COLOR_N).lerp(COLOR_D, dayF);
132
+ G.mountains.material.color.copy(TMP_COLOR);
133
+ }
src/pickups.js ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { G } from './globals.js';
3
+ import { CFG } from './config.js';
4
+ import { getTerrainHeight } from './world.js';
5
+
6
+ // Share orb material to avoid per-orb material allocations
7
+ const ORB_MAT = new THREE.MeshStandardMaterial({
8
+ color: 0x33ff66,
9
+ emissive: 0x1faa4e,
10
+ emissiveIntensity: 1.1,
11
+ roughness: 0.3,
12
+ metalness: 0.0
13
+ });
14
+
15
+ // Spawns N small glowing green health orbs around a position
16
+ export function spawnHealthOrbs(center, count) {
17
+ const n = Math.max(1, Math.min(5, Math.floor(count)));
18
+ for (let i = 0; i < n; i++) {
19
+ const group = new THREE.Group();
20
+
21
+ // Slight radial scatter around center (tighter grouping)
22
+ const r = 0.12 + G.random() * 0.48; // was up to ~1.4
23
+ const t = G.random() * Math.PI * 2;
24
+ const startY = 0.9 + G.random() * 0.8; // spawn a bit in the air
25
+ group.position.set(
26
+ center.x + Math.cos(t) * r,
27
+ startY,
28
+ center.z + Math.sin(t) * r
29
+ );
30
+
31
+ const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.12, 14, 12), ORB_MAT);
32
+ sphere.castShadow = true;
33
+ sphere.receiveShadow = false;
34
+ group.add(sphere);
35
+
36
+ // Soft green point light for glow
37
+ const light = new THREE.PointLight(0x33ff66, 0.9, 6, 2);
38
+ light.position.set(0, 0.1, 0);
39
+ group.add(light);
40
+
41
+ G.scene.add(group);
42
+
43
+ // Initial outward + upward velocity (reduced to keep grouping tighter)
44
+ const dir = new THREE.Vector3(Math.cos(t), 0, Math.sin(t));
45
+ const speed = 0.8 + G.random() * 1.4; // was up to ~4.8
46
+ const vel = dir.multiplyScalar(speed);
47
+ vel.y = 2.2 + G.random() * 1.6; // was up to ~5.5
48
+
49
+ const orb = {
50
+ mesh: group,
51
+ light,
52
+ pos: group.position,
53
+ radius: 0.7, // pickup radius
54
+ heal: 1,
55
+ bobT: G.random() * Math.PI * 2,
56
+ vel,
57
+ state: 'air', // 'air' | 'settled'
58
+ settleTimer: 0,
59
+ baseY: 0.2
60
+ };
61
+ G.orbs.push(orb);
62
+ }
63
+ }
64
+
65
+ export function updatePickups(delta) {
66
+ for (let i = G.orbs.length - 1; i >= 0; i--) {
67
+ const o = G.orbs[i];
68
+
69
+ // Simple physics: integrate while in air, bounce on ground, then settle to bob
70
+ if (o.state !== 'settled') {
71
+ // Gravity
72
+ if (o.vel) o.vel.y -= 18 * delta;
73
+ // Integrate
74
+ if (o.vel) o.pos.addScaledVector(o.vel, delta);
75
+
76
+ // Ground collision (sphere radius ~0.12)
77
+ const ground = getTerrainHeight(o.pos.x, o.pos.z);
78
+ const floor = ground + 0.12;
79
+ if (o.pos.y <= floor) {
80
+ o.pos.y = floor;
81
+ if (o.vel) {
82
+ const bounce = 0.35;
83
+ const friction = 10.0; // stronger horizontal damping for tighter spread
84
+ if (Math.abs(o.vel.y) > 0.6) {
85
+ o.vel.y = -o.vel.y * bounce;
86
+ } else {
87
+ o.vel.y = 0;
88
+ }
89
+ // Horizontal friction
90
+ const fr = Math.max(0, 1 - friction * delta);
91
+ o.vel.x *= fr;
92
+ o.vel.z *= fr;
93
+
94
+ // Settle detection
95
+ const horizSpeed = Math.hypot(o.vel.x, o.vel.z);
96
+ if (horizSpeed < 0.15 && Math.abs(o.vel.y) < 0.05) {
97
+ o.settleTimer += delta;
98
+ if (o.settleTimer > 0.15) {
99
+ o.state = 'settled';
100
+ o.baseY = ground + 0.2;
101
+ // Snap to a clean base height
102
+ o.pos.y = o.baseY;
103
+ }
104
+ } else {
105
+ o.settleTimer = 0;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // Visuals: rotation and glow pulse
112
+ o.bobT += delta * 2.0;
113
+ o.mesh.rotation.y += delta * 1.5;
114
+ if (o.light) o.light.intensity = 0.7 + 0.4 * (0.5 + 0.5 * Math.sin(o.bobT * 2.0));
115
+
116
+ // If settled, apply gentle bob around baseY
117
+ if (o.state === 'settled') {
118
+ o.pos.y = o.baseY + Math.sin(o.bobT) * 0.06;
119
+ }
120
+
121
+ // Pickup check (2D distance on ground plane)
122
+ const dx = o.pos.x - G.player.pos.x;
123
+ const dz = o.pos.z - G.player.pos.z;
124
+ const dist = Math.hypot(dx, dz);
125
+ const minDist = o.radius + G.player.radius;
126
+ if (dist <= minDist && G.player.alive && G.state === 'playing') {
127
+ G.player.health = Math.min(CFG.player.health, G.player.health + o.heal);
128
+ // Pulse green heal overlay
129
+ G.healFlash = Math.min(1, G.healFlash + CFG.hud.healPulsePerPickup + o.heal * CFG.hud.healPulsePerHP);
130
+ // Dispose unique geometries (materials are shared)
131
+ o.mesh.traverse((obj) => { if (obj.isMesh && obj.geometry?.dispose) obj.geometry.dispose(); });
132
+ G.scene.remove(o.mesh);
133
+ G.orbs.splice(i, 1);
134
+ }
135
+ }
136
+ }
src/player.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+ import { getTerrainHeight } from './world.js';
5
+
6
+ const MOVE = new THREE.Vector3();
7
+ const FWD = new THREE.Vector3();
8
+ const RIGHT = new THREE.Vector3();
9
+ const NEXT = new THREE.Vector3();
10
+
11
+ export function updatePlayer(delta) {
12
+ if (!G.player.alive) return;
13
+
14
+ // Movement
15
+ MOVE.set(0, 0, 0);
16
+
17
+ G.camera.getWorldDirection(FWD);
18
+ FWD.y = 0;
19
+ FWD.normalize();
20
+
21
+ RIGHT.crossVectors(FWD, G.camera.up);
22
+
23
+ if (G.input.w) MOVE.add(FWD);
24
+ if (G.input.s) MOVE.sub(FWD);
25
+ if (G.input.a) MOVE.sub(RIGHT);
26
+ if (G.input.d) MOVE.add(RIGHT);
27
+
28
+ if (MOVE.length() > 0) {
29
+ MOVE.normalize();
30
+ const speed = G.player.speed * (G.input.sprint ? CFG.player.sprintMult : 1);
31
+ MOVE.multiplyScalar(speed * delta);
32
+ }
33
+
34
+ // Apply movement with collision
35
+ NEXT.copy(G.player.pos).add(MOVE);
36
+
37
+ // Tree collisions
38
+ for (const tree of G.treeColliders) {
39
+ const dx = NEXT.x - tree.x;
40
+ const dz = NEXT.z - tree.z;
41
+ const dist = Math.sqrt(dx * dx + dz * dz);
42
+ const minDist = G.player.radius + tree.radius;
43
+
44
+ if (dist < minDist && dist > 0) {
45
+ const pushX = (dx / dist) * (minDist - dist);
46
+ const pushZ = (dz / dist) * (minDist - dist);
47
+ NEXT.x += pushX;
48
+ NEXT.z += pushZ;
49
+ }
50
+ }
51
+
52
+ // Bounds
53
+ const halfSize = CFG.forestSize / 2 - G.player.radius;
54
+ NEXT.x = Math.max(-halfSize, Math.min(halfSize, NEXT.x));
55
+ NEXT.z = Math.max(-halfSize, Math.min(halfSize, NEXT.z));
56
+
57
+ // Gravity
58
+ G.player.yVel -= 15 * delta; // gravity
59
+ NEXT.y += G.player.yVel * delta;
60
+
61
+ // Grounded against terrain height
62
+ const groundEye = getTerrainHeight(NEXT.x, NEXT.z) + 1.8;
63
+ if (NEXT.y <= groundEye) {
64
+ NEXT.y = groundEye;
65
+ G.player.yVel = 0;
66
+ G.player.grounded = true;
67
+ } else {
68
+ G.player.grounded = false;
69
+ }
70
+
71
+ G.player.pos.copy(NEXT);
72
+ G.camera.position.copy(G.player.pos);
73
+ }
src/projectiles.js ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+ import { getTerrainHeight } from './world.js';
5
+ import { spawnImpact } from './fx.js';
6
+
7
+ // Shared arrow geometry/material to avoid per-shot allocations
8
+ const ARROW = (() => {
9
+ const shaftGeo = new THREE.CylinderGeometry(0.03, 0.03, 0.9, 6);
10
+ const headGeo = new THREE.ConeGeometry(0.08, 0.2, 8);
11
+ const shaftMat = new THREE.MeshStandardMaterial({ color: 0xdeb887, roughness: 0.8 });
12
+ const headMat = new THREE.MeshStandardMaterial({ color: 0x9e9e9e, metalness: 0.2, roughness: 0.5 });
13
+ return { shaftGeo, headGeo, shaftMat, headMat };
14
+ })();
15
+
16
+ const UP = new THREE.Vector3(0, 1, 0);
17
+ const TMPv = new THREE.Vector3();
18
+ const TMPq = new THREE.Quaternion();
19
+
20
+ // Spawns a visible enemy arrow projectile
21
+ export function spawnEnemyArrow(start, dirOrVel, asVelocity = false) {
22
+ const speed = CFG.enemy.arrowSpeed;
23
+ // Arrow visual: shaft + head as a small cone (shared geos/materials)
24
+ const group = new THREE.Group();
25
+ const shaft = new THREE.Mesh(ARROW.shaftGeo, ARROW.shaftMat);
26
+ shaft.position.y = 0; // centered
27
+ shaft.castShadow = true; shaft.receiveShadow = true;
28
+ group.add(shaft);
29
+
30
+ const head = new THREE.Mesh(ARROW.headGeo, ARROW.headMat);
31
+ head.position.y = 0.55;
32
+ head.castShadow = true; head.receiveShadow = true;
33
+ group.add(head);
34
+
35
+ // Orient to direction (geometry points +Y by default)
36
+ const dir = TMPv.copy(dirOrVel).normalize();
37
+ TMPq.setFromUnitVectors(UP, dir);
38
+ group.quaternion.copy(TMPq);
39
+
40
+ group.position.copy(start);
41
+ G.scene.add(group);
42
+
43
+ const vel = asVelocity
44
+ ? dirOrVel.clone()
45
+ : TMPv.copy(dirOrVel).normalize().multiplyScalar(speed);
46
+
47
+ const projectile = {
48
+ mesh: group,
49
+ pos: group.position,
50
+ vel,
51
+ life: CFG.enemy.arrowLife
52
+ };
53
+
54
+ G.enemyProjectiles.push(projectile);
55
+ }
56
+
57
+ export function updateEnemyProjectiles(delta, onPlayerDeath) {
58
+ const gravity = CFG.enemy.arrowGravity;
59
+ const hitR = CFG.enemy.arrowHitRadius;
60
+
61
+ for (let i = G.enemyProjectiles.length - 1; i >= 0; i--) {
62
+ const p = G.enemyProjectiles[i];
63
+
64
+ // Integrate
65
+ p.vel.y -= gravity * delta;
66
+ p.pos.addScaledVector(p.vel, delta);
67
+
68
+ // Re-orient to velocity
69
+ const vdir = TMPv.copy(p.vel).normalize();
70
+ TMPq.setFromUnitVectors(UP, vdir);
71
+ p.mesh.quaternion.copy(TMPq);
72
+
73
+ p.life -= delta;
74
+
75
+ // Ground hit against terrain
76
+ const gy = getTerrainHeight(p.pos.x, p.pos.z);
77
+ if (p.pos.y <= gy) {
78
+ TMPv.set(p.pos.x, gy + 0.02, p.pos.z);
79
+ spawnImpact(TMPv, UP);
80
+ G.scene.remove(p.mesh);
81
+ G.enemyProjectiles.splice(i, 1);
82
+ continue;
83
+ }
84
+
85
+ // Tree collision (2D cylinder test)
86
+ for (const tree of G.treeColliders) {
87
+ const dx = p.pos.x - tree.x;
88
+ const dz = p.pos.z - tree.z;
89
+ const dist2 = dx * dx + dz * dz;
90
+ const r = tree.radius + 0.2; // small allowance for arrow
91
+ if (dist2 < r * r && p.pos.y < 8) { // below canopy-ish
92
+ spawnImpact(p.pos, UP);
93
+ G.scene.remove(p.mesh);
94
+ G.enemyProjectiles.splice(i, 1);
95
+ continue;
96
+ }
97
+ }
98
+
99
+ // Player collision (sphere)
100
+ const pr = hitR + G.player.radius * 0.6; // slightly generous
101
+ if (p.pos.distanceTo(G.player.pos) < pr) {
102
+ const dmg = CFG.enemy.arrowDamage;
103
+ G.player.health -= dmg;
104
+ G.damageFlash = Math.min(1, G.damageFlash + CFG.hud.damagePulsePerHit + dmg * CFG.hud.damagePulsePerHP);
105
+ if (G.player.health <= 0 && G.player.alive) {
106
+ G.player.health = 0;
107
+ G.player.alive = false;
108
+ if (onPlayerDeath) onPlayerDeath();
109
+ }
110
+ spawnImpact(p.pos, UP);
111
+ G.scene.remove(p.mesh);
112
+ G.enemyProjectiles.splice(i, 1);
113
+ continue;
114
+ }
115
+
116
+ // Timeout
117
+ if (p.life <= 0) {
118
+ G.scene.remove(p.mesh);
119
+ G.enemyProjectiles.splice(i, 1);
120
+ continue;
121
+ }
122
+ }
123
+ }
src/utils.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Seeded pseudo-random number generator (mulberry32)
2
+ export function makeRandom(seed) {
3
+ let a = seed >>> 0;
4
+ return function random() {
5
+ let t = (a += 0x6D2B79F5);
6
+ t = Math.imul(t ^ (t >>> 15), t | 1);
7
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
8
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
9
+ };
10
+ }
11
+
src/waves.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+ import { getTerrainHeight } from './world.js';
5
+ import { showWaveBanner } from './hud.js';
6
+ import { spawnEnemy } from './enemies.js';
7
+
8
+ export function startNextWave() {
9
+ const waveCount = Math.min(
10
+ CFG.waves.baseCount + CFG.waves.perWaveAdd * (G.waves.current - 1),
11
+ CFG.waves.maxAlive
12
+ );
13
+ G.waves.spawnQueue = waveCount;
14
+ G.waves.nextSpawnTimer = 0;
15
+
16
+ // Choose a single spawn anchor for this wave (not near the center)
17
+ const half = CFG.forestSize / 2;
18
+ const margin = 24;
19
+ const minR = Math.max(CFG.waves.annulusMin, 40);
20
+ const maxR = Math.min(CFG.waves.annulusMax * 2.2, half - margin);
21
+ const angle = G.random() * Math.PI * 2;
22
+ const r = minR + G.random() * (maxR - minR);
23
+ const ax = Math.cos(angle) * r;
24
+ const az = Math.sin(angle) * r;
25
+ G.waves.spawnAnchor = new THREE.Vector3(ax, getTerrainHeight(ax, az), az);
26
+
27
+ showWaveBanner(`Wave ${G.waves.current}`);
28
+ }
29
+
30
+ export function updateWaves(delta) {
31
+ if (G.waves.inBreak) {
32
+ G.waves.breakTimer -= delta;
33
+ if (G.waves.breakTimer <= 0) {
34
+ G.waves.inBreak = false;
35
+ G.waves.current++;
36
+ startNextWave();
37
+ }
38
+ return;
39
+ }
40
+
41
+ // Spawn enemies
42
+ if (G.waves.spawnQueue > 0 && G.waves.aliveCount < CFG.waves.maxAlive) {
43
+ G.waves.nextSpawnTimer -= delta;
44
+ if (G.waves.nextSpawnTimer <= 0) {
45
+ spawnEnemy();
46
+ G.waves.spawnQueue--;
47
+ const spawnRate = Math.max(
48
+ CFG.waves.spawnMin,
49
+ CFG.waves.spawnEvery - CFG.waves.spawnDecay * (G.waves.current - 1)
50
+ );
51
+ G.waves.nextSpawnTimer = spawnRate;
52
+ }
53
+ }
54
+
55
+ // Check wave complete
56
+ if (G.waves.spawnQueue === 0 && G.waves.aliveCount === 0) {
57
+ G.waves.inBreak = true;
58
+ G.waves.breakTimer = CFG.waves.breakTime;
59
+ }
60
+ }
src/weapon.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+ import { updateHUD } from './hud.js';
5
+ import { playReloadStart, playReloadEnd } from './audio.js';
6
+
7
+ function computeWeaponBasePos() {
8
+ const d = G.weapon.anchor.depth;
9
+ const halfH = Math.tan(THREE.MathUtils.degToRad(G.camera.fov * 0.5)) * d;
10
+ const halfW = halfH * G.camera.aspect;
11
+ const x = halfW - G.weapon.anchor.right;
12
+ const y = -halfH + G.weapon.anchor.bottom;
13
+ return new THREE.Vector3(x, y, -d);
14
+ }
15
+
16
+ export function updateWeaponAnchor() {
17
+ G.weapon.basePos.copy(computeWeaponBasePos());
18
+ if (G.weapon.group) {
19
+ G.weapon.group.position.copy(G.weapon.basePos);
20
+ G.weapon.group.rotation.copy(G.weapon.baseRot);
21
+ }
22
+ }
23
+
24
+ export function setupWeapon() {
25
+ const makeVM = (color, metal = 0.4, rough = 0.6) => {
26
+ const m = new THREE.MeshStandardMaterial({ color, metalness: metal, roughness: rough });
27
+ m.fog = false;
28
+ m.depthTest = false;
29
+ return m;
30
+ };
31
+
32
+ const steel = makeVM(0x2a2d30, 0.8, 0.35);
33
+ const polymer = makeVM(0x1b1f23, 0.1, 0.8);
34
+ const tan = makeVM(0x7b6a4d, 0.2, 0.7);
35
+
36
+ const g = new THREE.Group();
37
+ g.renderOrder = 10;
38
+ g.castShadow = false;
39
+ g.receiveShadow = false;
40
+
41
+ const handguardL = 0.62;
42
+ const receiverL = 0.40;
43
+ const barrelL = 0.60;
44
+ const muzzleL = 0.10;
45
+ const stockL = 0.34;
46
+
47
+ const receiver = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.18, receiverL), steel);
48
+ receiver.position.set(0.00, 0.00, -0.42);
49
+ g.add(receiver);
50
+
51
+ const lower = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.10, 0.22), steel);
52
+ lower.position.set(0.00, -0.10, -0.36);
53
+ g.add(lower);
54
+
55
+ const grip = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.08), polymer);
56
+ grip.position.set(-0.06, -0.19, -0.28);
57
+ grip.rotation.x = -0.6;
58
+ g.add(grip);
59
+
60
+ const mag = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.14), polymer);
61
+ mag.position.set(0.02, -0.16, -0.44);
62
+ mag.rotation.x = 0.35;
63
+ g.add(mag);
64
+
65
+ const stock = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.15, stockL), tan);
66
+ stock.position.set(-0.02, 0.01, +0.02);
67
+ g.add(stock);
68
+
69
+ const butt = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.16, 0.06), polymer);
70
+ butt.position.set(-0.02, 0.01, +0.22);
71
+ g.add(butt);
72
+
73
+ const handguard = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.12, handguardL), tan);
74
+ handguard.position.set(0.00, 0.00, -0.90);
75
+ g.add(handguard);
76
+
77
+ const rail = new THREE.Group();
78
+ const lugW = 0.035, lugH = 0.01, lugD = 0.03;
79
+ const lugCount = 12;
80
+ for (let i = 0; i < lugCount; i++) {
81
+ const lug = new THREE.Mesh(new THREE.BoxGeometry(lugW, lugH, lugD), steel);
82
+ lug.position.set(0, 0.10, -0.55 - i * 0.03);
83
+ rail.add(lug);
84
+ }
85
+ g.add(rail);
86
+
87
+ const base = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.05, 0.08), steel);
88
+ base.position.set(0.00, 0.09, -0.62);
89
+ g.add(base);
90
+
91
+ const hood = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.08, 12), steel);
92
+ hood.rotation.x = Math.PI / 2;
93
+ hood.position.set(0.00, 0.09, -0.70);
94
+ g.add(hood);
95
+
96
+ const lens = new THREE.Mesh(
97
+ new THREE.CircleGeometry(0.028, 16),
98
+ new THREE.MeshStandardMaterial({ color: 0x66aaff, emissive: 0x112244, metalness: 0.2, roughness: 0.1 })
99
+ );
100
+ lens.position.set(0.00, 0.09, -0.66);
101
+ lens.rotation.x = Math.PI / 2;
102
+ lens.material.fog = false;
103
+ lens.material.depthTest = false;
104
+ g.add(lens);
105
+
106
+ const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.016, 0.016, barrelL, 12), steel);
107
+ barrel.rotation.x = Math.PI / 2;
108
+ barrel.position.set(0.00, 0.00, -1.25);
109
+ g.add(barrel);
110
+
111
+ const muzzle = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.028, muzzleL, 10), steel);
112
+ muzzle.rotation.x = Math.PI / 2;
113
+ muzzle.position.set(0.00, 0.00, -1.58);
114
+ g.add(muzzle);
115
+
116
+ const frontPost = new THREE.Mesh(new THREE.BoxGeometry(0.01, 0.02, 0.02), steel);
117
+ frontPost.position.set(0.00, 0.06, -1.55);
118
+ g.add(frontPost);
119
+
120
+ const muzzleAnchor = new THREE.Object3D();
121
+ muzzleAnchor.position.set(0.00, 0.00, -1.63);
122
+ g.add(muzzleAnchor);
123
+
124
+ // Ejector anchor (right side of receiver)
125
+ const ejectorAnchor = new THREE.Object3D();
126
+ ejectorAnchor.position.set(0.09, 0.06, -0.42);
127
+ g.add(ejectorAnchor);
128
+
129
+ G.weapon.group = g;
130
+ G.weapon.muzzle = muzzleAnchor;
131
+ G.weapon.ejector = ejectorAnchor;
132
+
133
+ G.camera.add(g);
134
+ g.scale.setScalar(1.55);
135
+ updateWeaponAnchor();
136
+ }
137
+
138
+ export function beginReload() {
139
+ if (G.weapon.reloading) return;
140
+ if (G.weapon.ammo >= CFG.gun.magSize) return;
141
+ G.weapon.reloading = true;
142
+ G.weapon.reloadTimer = CFG.gun.reloadTime;
143
+ if (CFG.audio.reloadStart) playReloadStart();
144
+ }
145
+
146
+ export function updateWeapon(delta) {
147
+ if (!G.weapon.group) return;
148
+
149
+ const moving = G.input.w || G.input.a || G.input.s || G.input.d;
150
+ const sprinting = moving && G.input.sprint;
151
+ // Reduce sway/bob intensity and slightly lower frequencies
152
+ G.weapon.swayT += delta * (sprinting ? 9 : (moving ? 7 : 2.5));
153
+ const bobAmp = sprinting ? 0.008 : (moving ? 0.006 : 0.0035);
154
+ const swayAmp = sprinting ? 0.0045 : (moving ? 0.0035 : 0.002);
155
+
156
+ const bobX = Math.sin(G.weapon.swayT * 1.8) * bobAmp;
157
+ const bobY = Math.cos(G.weapon.swayT * 3.6) * bobAmp * 0.6;
158
+ const swayZRot = Math.sin(G.weapon.swayT * 1.4) * swayAmp;
159
+
160
+ G.weapon.recoil = Math.max(0, G.weapon.recoil - CFG.gun.recoilRecover * delta);
161
+
162
+ // ----- Dynamic spread update (CS-style) -----
163
+ const base = CFG.gun.spreadMin ?? CFG.gun.bloom ?? 0;
164
+ const moveMult = moving ? (sprinting ? (CFG.gun.spreadSprintMult || 1) : (CFG.gun.spreadMoveMult || 1)) : 1;
165
+ const airMult = G.player.grounded ? 1 : (CFG.gun.spreadAirMult || 1);
166
+ const target = Math.min(CFG.gun.spreadMax || 0.02, base * moveMult * airMult);
167
+ G.weapon.targetSpread = target;
168
+ const decay = CFG.gun.spreadDecay || 6.0;
169
+ // Exponential approach to target
170
+ const k = 1 - Math.exp(-decay * delta);
171
+ G.weapon.spread += (target - G.weapon.spread) * k;
172
+
173
+ let reloadTilt = 0;
174
+ if (G.weapon.reloading) {
175
+ G.weapon.reloadTimer -= delta;
176
+ reloadTilt = 0.4 * Math.sin(Math.min(1, 1 - G.weapon.reloadTimer / CFG.gun.reloadTime) * Math.PI);
177
+ if (G.weapon.reloadTimer <= 0) {
178
+ const needed = CFG.gun.magSize - G.weapon.ammo;
179
+ if (G.weapon.reserve === Infinity) {
180
+ G.weapon.ammo += needed;
181
+ } else {
182
+ const taken = Math.min(needed, G.weapon.reserve);
183
+ G.weapon.ammo += taken;
184
+ G.weapon.reserve -= taken;
185
+ }
186
+ G.weapon.reloading = false;
187
+ if (CFG.audio.reloadEnd) playReloadEnd();
188
+ updateHUD();
189
+ }
190
+ }
191
+
192
+ G.weapon.group.position.set(
193
+ G.weapon.basePos.x + bobX,
194
+ G.weapon.basePos.y + bobY,
195
+ G.weapon.basePos.z - G.weapon.recoil
196
+ );
197
+
198
+ // Aim barrel at crosshair
199
+ const muzzleWorld = G.tmpV1;
200
+ G.weapon.muzzle.getWorldPosition(muzzleWorld);
201
+ const muzzleCam = G.tmpV2.copy(muzzleWorld);
202
+ G.camera.worldToLocal(muzzleCam);
203
+
204
+ const aimPointCam = G.tmpV3.set(0, 0, -10);
205
+ const aimDirCam = aimPointCam.sub(muzzleCam).normalize();
206
+
207
+ // Reuse quaternions to reduce GC
208
+ const FWD = G.tmpFwd || (G.tmpFwd = new THREE.Vector3(0, 0, -1));
209
+ const QAIM = G.tmpQAim || (G.tmpQAim = new THREE.Quaternion());
210
+ const QROLL = G.tmpQRoll || (G.tmpQRoll = new THREE.Quaternion());
211
+ const QREL = G.tmpQRel || (G.tmpQRel = new THREE.Quaternion());
212
+
213
+ QAIM.setFromUnitVectors(FWD, aimDirCam);
214
+ const styleRoll = THREE.MathUtils.degToRad(-3);
215
+ QROLL.setFromAxisAngle(FWD, swayZRot + styleRoll + reloadTilt);
216
+ QREL.setFromAxisAngle(new THREE.Vector3(1, 0, 0), reloadTilt * 0.2);
217
+
218
+ G.weapon.group.quaternion.copy(QAIM).multiply(QROLL).multiply(QREL);
219
+
220
+ // ----- Apply view recoil to camera (non-destructive) -----
221
+ // Smoothly return view kick to zero
222
+ const ret = CFG.gun.viewReturn || 9.0;
223
+ const rk = 1 - Math.exp(-ret * delta);
224
+ G.weapon.viewPitch -= G.weapon.viewPitch * rk;
225
+ G.weapon.viewYaw -= G.weapon.viewYaw * rk;
226
+
227
+ // Apply the delta since last frame to the camera so it cancels on return
228
+ const dPitch = G.weapon.viewPitch - G.weapon.appliedPitch;
229
+ const dYaw = G.weapon.viewYaw - G.weapon.appliedYaw;
230
+ // Pitch up (negative X rotation) feels like CS, invert sign accordingly
231
+ G.camera.rotation.x -= dPitch;
232
+ G.camera.rotation.y += dYaw;
233
+ G.weapon.appliedPitch = G.weapon.viewPitch;
234
+ G.weapon.appliedYaw = G.weapon.viewYaw;
235
+ }
src/world.js ADDED
@@ -0,0 +1,844 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CFG } from './config.js';
3
+ import { G } from './globals.js';
4
+
5
+ // --- Shared materials, geometries, and wind uniforms for trees ---
6
+ // We keep these module-scoped so all trees can share them efficiently.
7
+ const FOLIAGE_WIND = { uTime: { value: 0 }, uStrength: { value: 0.35 } };
8
+
9
+ const TRUNK_MAT = new THREE.MeshStandardMaterial({
10
+ color: 0x6b4f32,
11
+ roughness: 0.9,
12
+ metalness: 0.0
13
+ });
14
+
15
+ // Foliage material with simple vertex sway, inspired by reference project
16
+ const FOLIAGE_MAT = new THREE.MeshStandardMaterial({
17
+ color: 0x2f6b3d,
18
+ roughness: 0.8,
19
+ metalness: 0.0
20
+ });
21
+ FOLIAGE_MAT.onBeforeCompile = (shader) => {
22
+ shader.uniforms.uTime = FOLIAGE_WIND.uTime;
23
+ shader.uniforms.uStrength = FOLIAGE_WIND.uStrength;
24
+ shader.vertexShader = (
25
+ 'uniform float uTime;\n' +
26
+ 'uniform float uStrength;\n' +
27
+ shader.vertexShader
28
+ ).replace(
29
+ '#include <begin_vertex>',
30
+ `#include <begin_vertex>
31
+ float sway = sin(uTime * 1.7 + position.y * 0.35) * 0.5 +
32
+ sin(uTime * 0.9 + position.y * 0.7) * 0.5;
33
+ transformed.x += sway * uStrength * (0.4 + position.y * 0.06);
34
+ transformed.z += cos(uTime * 1.1 + position.y * 0.42) * uStrength * (0.3 + position.y * 0.05);`
35
+ );
36
+ };
37
+ FOLIAGE_MAT.needsUpdate = true;
38
+
39
+ // Reusable base geometries (scaled per-tree)
40
+ // Trunk: slight taper, height 10, translated so base at y=0
41
+ const GEO_TRUNK = new THREE.CylinderGeometry(0.7, 1.2, 10, 8, 1, false);
42
+ GEO_TRUNK.translate(0, 5, 0);
43
+
44
+ // Foliage stack (positions expect trunk height ~10)
45
+ const GEO_CONE1 = new THREE.ConeGeometry(6, 10, 8); GEO_CONE1.translate(0, 14, 0);
46
+ const GEO_CONE2 = new THREE.ConeGeometry(5, 9, 8); GEO_CONE2.translate(0, 20, 0);
47
+ const GEO_CONE3 = new THREE.ConeGeometry(4, 8, 8); GEO_CONE3.translate(0, 25, 0);
48
+ const GEO_SPH = new THREE.SphereGeometry(3.5, 8, 6); GEO_SPH.translate(0, 28.5, 0);
49
+
50
+ // Allow main loop to advance wind time
51
+ export function tickForest(timeSec) {
52
+ FOLIAGE_WIND.uTime.value = timeSec;
53
+ // Distance-based chunk culling for grass/flowers (cheap per-frame visibility)
54
+ const cam = G.camera;
55
+ if (!cam || !G.foliage) return;
56
+ const px = cam.position.x;
57
+ const pz = cam.position.z;
58
+ const gv = CFG.foliage;
59
+ const g2 = gv.grassViewDist * gv.grassViewDist;
60
+ const f2 = gv.flowerViewDist * gv.flowerViewDist;
61
+ for (let i = 0; i < G.foliage.grass.length; i++) {
62
+ const m = G.foliage.grass[i];
63
+ const dx = (m.position.x) - px;
64
+ const dz = (m.position.z) - pz;
65
+ m.visible = (dx * dx + dz * dz) <= g2;
66
+ }
67
+ for (let i = 0; i < G.foliage.flowers.length; i++) {
68
+ const m = G.foliage.flowers[i];
69
+ const dx = (m.position.x) - px;
70
+ const dz = (m.position.z) - pz;
71
+ m.visible = (dx * dx + dz * dz) <= f2;
72
+ }
73
+ }
74
+
75
+ // --- Ground cover (grass, flowers, bushes, rocks) ---
76
+ // Shared materials
77
+ const GRASS_MAT = new THREE.MeshStandardMaterial({
78
+ color: 0xffffff,
79
+ roughness: 0.95,
80
+ metalness: 0.0,
81
+ vertexColors: true,
82
+ side: THREE.DoubleSide
83
+ });
84
+ GRASS_MAT.onBeforeCompile = (shader) => {
85
+ shader.uniforms.uTime = FOLIAGE_WIND.uTime;
86
+ shader.uniforms.uStrength = FOLIAGE_WIND.uStrength;
87
+ shader.vertexShader = (
88
+ 'uniform float uTime;\n' +
89
+ 'uniform float uStrength;\n' +
90
+ shader.vertexShader
91
+ ).replace(
92
+ '#include <begin_vertex>',
93
+ `#include <begin_vertex>
94
+ #ifdef USE_INSTANCING
95
+ // derive a per-instance pseudo-random from translation
96
+ float iRand = fract(sin(instanceMatrix[3].x*12.9898 + instanceMatrix[3].z*78.233) * 43758.5453);
97
+ #else
98
+ float iRand = 0.5;
99
+ #endif
100
+ float h = clamp(position.y, 0.0, 1.0);
101
+ float sway = (sin(uTime*2.2 + iRand*6.2831) * 0.6 + cos(uTime*1.3 + iRand*11.0) * 0.4);
102
+ float bend = uStrength * h * h; // bend increases towards tip
103
+ transformed.x += sway * bend * 0.25;
104
+ transformed.z += sway * bend * 0.18;`
105
+ );
106
+ };
107
+ GRASS_MAT.needsUpdate = true;
108
+
109
+ const FLOWER_MAT = new THREE.MeshStandardMaterial({
110
+ color: 0xffffff,
111
+ roughness: 0.9,
112
+ metalness: 0.0,
113
+ vertexColors: true,
114
+ side: THREE.DoubleSide
115
+ });
116
+ FLOWER_MAT.onBeforeCompile = (shader) => {
117
+ shader.uniforms.uTime = FOLIAGE_WIND.uTime;
118
+ shader.uniforms.uStrength = FOLIAGE_WIND.uStrength;
119
+ shader.vertexShader = (
120
+ 'uniform float uTime;\n' +
121
+ 'uniform float uStrength;\n' +
122
+ shader.vertexShader
123
+ ).replace(
124
+ '#include <begin_vertex>',
125
+ `#include <begin_vertex>
126
+ #ifdef USE_INSTANCING
127
+ float iRand = fract(sin(instanceMatrix[3].x*19.123 + instanceMatrix[3].z*47.321) * 15731.123);
128
+ #else
129
+ float iRand = 0.5;
130
+ #endif
131
+ float h = clamp(position.y, 0.0, 1.0);
132
+ float sway = sin(uTime*2.6 + iRand*8.0);
133
+ float bend = uStrength * h;
134
+ transformed.x += sway * bend * 0.18;
135
+ transformed.z += sway * bend * 0.14;`
136
+ );
137
+ };
138
+ FLOWER_MAT.needsUpdate = true;
139
+
140
+ const BUSH_MAT = new THREE.MeshStandardMaterial({
141
+ color: 0x2b6a37,
142
+ roughness: 0.95,
143
+ metalness: 0.0,
144
+ flatShading: false
145
+ });
146
+
147
+ const ROCK_MAT = new THREE.MeshStandardMaterial({
148
+ color: 0x7b7066,
149
+ roughness: 1.0,
150
+ metalness: 0.0,
151
+ flatShading: true
152
+ });
153
+
154
+ // Base geometries (kept small, cloned per-chunk to inject bounding spheres)
155
+ function makeCrossBladeGeometry(width = 0.5, height = 1.2) {
156
+ // Two crossed quads around Y axis
157
+ const hw = width * 0.5;
158
+ const h = height;
159
+ const positions = [
160
+ // quad A (X axis)
161
+ -hw, 0, 0, hw, 0, 0, hw, h, 0,
162
+ -hw, 0, 0, hw, h, 0, -hw, h, 0,
163
+ // quad B (Z axis)
164
+ 0, 0, -hw, 0, 0, hw, 0, h, hw,
165
+ 0, 0, -hw, 0, h, hw, 0, h, -hw,
166
+ ];
167
+ const colors = [];
168
+ for (let i = 0; i < 12; i++) {
169
+ const y = positions[i*3 + 1];
170
+ const t = y / h; // 0 at base -> 1 at tip
171
+ const r = THREE.MathUtils.lerp(0.13, 0.25, t);
172
+ const g = THREE.MathUtils.lerp(0.28, 0.55, t);
173
+ const b = THREE.MathUtils.lerp(0.12, 0.22, t);
174
+ colors.push(r, g, b);
175
+ }
176
+ const geo = new THREE.BufferGeometry();
177
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
178
+ geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
179
+ geo.computeVertexNormals();
180
+ return geo;
181
+ }
182
+
183
+ const BASE_GEOM = {
184
+ // Smaller blades and petals relative to trees
185
+ grass: makeCrossBladeGeometry(0.22, 0.45),
186
+ flower: makeCrossBladeGeometry(0.18, 0.32),
187
+ bush: new THREE.SphereGeometry(0.8, 8, 6),
188
+ rock: new THREE.IcosahedronGeometry(0.7, 0)
189
+ };
190
+
191
+ // Deterministic per-chunk RNG
192
+ function lcg(seed) {
193
+ let s = (seed >>> 0) || 1;
194
+ return () => (s = (1664525 * s + 1013904223) >>> 0) / 4294967296;
195
+ }
196
+
197
+ export function generateGroundCover() {
198
+ const S = CFG.foliage;
199
+ FOLIAGE_WIND.uStrength.value = S.windStrength;
200
+
201
+ const half = CFG.forestSize / 2;
202
+ const chunk = Math.max(8, S.chunkSize | 0);
203
+ const chunksX = Math.ceil(CFG.forestSize / chunk);
204
+ const chunksZ = Math.ceil(CFG.forestSize / chunk);
205
+ const halfChunk = chunk * 0.5;
206
+
207
+ const seed = (CFG.seed | 0) ^ (S.seedOffset | 0);
208
+
209
+ // Helper to set a safe bounding sphere for a chunk-sized instanced mesh
210
+ function setChunkBounds(mesh) {
211
+ const g = mesh.geometry;
212
+ const r = Math.sqrt(halfChunk*halfChunk*2 + 25); // generous Y span
213
+ g.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), r);
214
+ }
215
+
216
+ // Skip center clearing smoothly
217
+ function densityAt(x, z) {
218
+ const r = Math.hypot(x, z);
219
+ const edge = CFG.clearRadius;
220
+ const k = THREE.MathUtils.clamp((r - edge) / (edge * 1.2), 0, 1);
221
+ return THREE.MathUtils.lerp(S.densityNearClear, 1, k);
222
+ }
223
+
224
+ // Reset chunk refs
225
+ if (!G.foliage) G.foliage = { grass: [], flowers: [] };
226
+ G.foliage.grass.length = 0;
227
+ G.foliage.flowers.length = 0;
228
+
229
+ // Per-chunk generation
230
+ for (let iz = 0; iz < chunksZ; iz++) {
231
+ for (let ix = 0; ix < chunksX; ix++) {
232
+ const cx = -half + ix * chunk + halfChunk;
233
+ const cz = -half + iz * chunk + halfChunk;
234
+
235
+ // Keep within world bounds
236
+ if (Math.abs(cx) > half || Math.abs(cz) > half) continue;
237
+
238
+ // Density scaler by clearing and macro noise for patchiness
239
+ const den = densityAt(cx, cz);
240
+ const patch = (fbm(cx, cz, 1 / 60, 3, 2, 0.5, seed) * 0.5 + 0.5);
241
+ const grassCount = Math.max(0, Math.round(S.grassPerChunk * den * (0.6 + 0.8 * patch)));
242
+ const flowerCount = Math.max(0, Math.round(S.flowersPerChunk * den * (0.6 + 0.8 * (1 - patch))));
243
+ const bushesCount = Math.max(0, Math.round(S.bushesPerChunk * den * (0.7 + 0.6 * patch)));
244
+ const rocksCount = Math.max(0, Math.round(S.rocksPerChunk * den * (0.7 + 0.6 * (1 - patch))));
245
+
246
+ // No work for empty chunks
247
+ if (!grassCount && !flowerCount && !bushesCount && !rocksCount) continue;
248
+
249
+ const rng = lcg(((ix + 1) * 73856093) ^ ((iz + 1) * 19349663) ^ seed);
250
+
251
+ // Grass
252
+ if (grassCount > 0) {
253
+ const geom = BASE_GEOM.grass.clone();
254
+ const mesh = new THREE.InstancedMesh(geom, GRASS_MAT, grassCount);
255
+ mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage);
256
+ mesh.castShadow = false;
257
+ mesh.receiveShadow = false;
258
+ mesh.position.set(cx, 0, cz);
259
+ setChunkBounds(mesh);
260
+ const m = new THREE.Matrix4();
261
+ const pos = new THREE.Vector3();
262
+ const quat = new THREE.Quaternion();
263
+ const scl = new THREE.Vector3();
264
+ const color = new THREE.Color();
265
+ for (let i = 0; i < grassCount; i++) {
266
+ const dx = (rng() - 0.5) * chunk;
267
+ const dz = (rng() - 0.5) * chunk;
268
+ const wx = cx + dx;
269
+ const wz = cz + dz;
270
+ const wy = getTerrainHeight(wx, wz);
271
+ // Simple placement; allow gentle slopes without extra checks
272
+ const yaw = rng() * Math.PI * 2;
273
+ quat.setFromEuler(new THREE.Euler(0, yaw, 0));
274
+ // 1.5x larger than current small baseline
275
+ const scale = (0.6 + rng() * 0.35) * 1.5;
276
+ scl.set(scale, scale, scale);
277
+ pos.set(dx, wy, dz);
278
+ m.compose(pos, quat, scl);
279
+ mesh.setMatrixAt(i, m);
280
+ // instance color variation
281
+ color.setRGB(THREE.MathUtils.lerp(0.18, 0.26, rng()), THREE.MathUtils.lerp(0.45, 0.62, rng()), THREE.MathUtils.lerp(0.16, 0.24, rng()));
282
+ mesh.setColorAt(i, color);
283
+ }
284
+ mesh.instanceMatrix.needsUpdate = true;
285
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
286
+ G.scene.add(mesh);
287
+ G.foliage.grass.push(mesh);
288
+ }
289
+
290
+ // Flowers
291
+ if (flowerCount > 0) {
292
+ const geom = BASE_GEOM.flower.clone();
293
+ const mesh = new THREE.InstancedMesh(geom, FLOWER_MAT, flowerCount);
294
+ mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage);
295
+ mesh.castShadow = false;
296
+ mesh.receiveShadow = false;
297
+ mesh.position.set(cx, 0, cz);
298
+ setChunkBounds(mesh);
299
+ const m = new THREE.Matrix4();
300
+ const pos = new THREE.Vector3();
301
+ const quat = new THREE.Quaternion();
302
+ const scl = new THREE.Vector3();
303
+ const color = new THREE.Color();
304
+ for (let i = 0; i < flowerCount; i++) {
305
+ const dx = (rng() - 0.5) * chunk;
306
+ const dz = (rng() - 0.5) * chunk;
307
+ const wx = cx + dx;
308
+ const wz = cz + dz;
309
+ const wy = getTerrainHeight(wx, wz) + 0.02;
310
+ const yaw = rng() * Math.PI * 2;
311
+ quat.setFromEuler(new THREE.Euler(0, yaw, 0));
312
+ // Flowers 1.5x larger than current small baseline
313
+ const scale = (0.7 + rng() * 0.3) * 1.5;
314
+ scl.set(scale, scale, scale);
315
+ pos.set(dx, wy, dz);
316
+ m.compose(pos, quat, scl);
317
+ mesh.setMatrixAt(i, m);
318
+ // bright palette variations
319
+ const palettes = [
320
+ new THREE.Color(0xff6fb3), // pink
321
+ new THREE.Color(0xffda66), // yellow
322
+ new THREE.Color(0x8be37c), // mint
323
+ new THREE.Color(0x6fc3ff), // sky
324
+ new THREE.Color(0xff8a6f) // peach
325
+ ];
326
+ const base = palettes[Math.floor(rng() * palettes.length)];
327
+ color.copy(base).multiplyScalar(0.9 + rng()*0.2);
328
+ mesh.setColorAt(i, color);
329
+ }
330
+ mesh.instanceMatrix.needsUpdate = true;
331
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
332
+ G.scene.add(mesh);
333
+ G.foliage.flowers.push(mesh);
334
+ }
335
+
336
+ // Bushes
337
+ if (bushesCount > 0) {
338
+ const geom = BASE_GEOM.bush.clone();
339
+ const mesh = new THREE.InstancedMesh(geom, BUSH_MAT, bushesCount);
340
+ mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage);
341
+ mesh.castShadow = false;
342
+ mesh.receiveShadow = true;
343
+ mesh.position.set(cx, 0, cz);
344
+ setChunkBounds(mesh);
345
+ const m = new THREE.Matrix4();
346
+ const pos = new THREE.Vector3();
347
+ const quat = new THREE.Quaternion();
348
+ const scl = new THREE.Vector3();
349
+ for (let i = 0; i < bushesCount; i++) {
350
+ const dx = (rng() - 0.5) * chunk;
351
+ const dz = (rng() - 0.5) * chunk;
352
+ const wx = cx + dx;
353
+ const wz = cz + dz;
354
+ const wy = getTerrainHeight(wx, wz) + 0.2;
355
+ quat.identity();
356
+ const s = 0.7 + rng() * 0.8;
357
+ scl.set(s, s, s);
358
+ pos.set(dx, wy, dz);
359
+ m.compose(pos, quat, scl);
360
+ mesh.setMatrixAt(i, m);
361
+ }
362
+ mesh.instanceMatrix.needsUpdate = true;
363
+ G.scene.add(mesh);
364
+ }
365
+
366
+ // Rocks
367
+ if (rocksCount > 0) {
368
+ const geom = BASE_GEOM.rock.clone();
369
+ const mesh = new THREE.InstancedMesh(geom, ROCK_MAT, rocksCount);
370
+ mesh.instanceMatrix.setUsage(THREE.StaticDrawUsage);
371
+ mesh.castShadow = true;
372
+ mesh.receiveShadow = true;
373
+ mesh.position.set(cx, 0, cz);
374
+ setChunkBounds(mesh);
375
+ const m = new THREE.Matrix4();
376
+ const pos = new THREE.Vector3();
377
+ const quat = new THREE.Quaternion();
378
+ const scl = new THREE.Vector3();
379
+ const color = new THREE.Color();
380
+ for (let i = 0; i < rocksCount; i++) {
381
+ const dx = (rng() - 0.5) * chunk;
382
+ const dz = (rng() - 0.5) * chunk;
383
+ const wx = cx + dx;
384
+ const wz = cz + dz;
385
+ const wy = getTerrainHeight(wx, wz) + 0.05;
386
+ const yaw = rng() * Math.PI * 2;
387
+ quat.setFromEuler(new THREE.Euler(0, yaw, 0));
388
+ const s = 0.4 + rng() * 0.9;
389
+ scl.set(s * (0.8 + rng()*0.4), s, s * (0.8 + rng()*0.4));
390
+ pos.set(dx, wy, dz);
391
+ m.compose(pos, quat, scl);
392
+ mesh.setMatrixAt(i, m);
393
+ // slight per-rock color tint
394
+ color.setHSL(0.07, 0.08, THREE.MathUtils.lerp(0.32, 0.46, rng()));
395
+ if (mesh.instanceColor) mesh.setColorAt(i, color);
396
+ }
397
+ mesh.instanceMatrix.needsUpdate = true;
398
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
399
+ G.scene.add(mesh);
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ // --- Procedural terrain helpers ---
406
+ // Hash-based 2D value noise for deterministic hills (pure 32-bit integer math)
407
+ function hash2i(xi, yi, seed) {
408
+ let h = Math.imul(xi, 374761393) ^ Math.imul(yi, 668265263) ^ Math.imul(seed, 2147483647);
409
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
410
+ h = (h ^ (h >>> 16)) >>> 0;
411
+ return h / 4294967296; // [0,1)
412
+ }
413
+
414
+ function smoothstep(a, b, t) {
415
+ if (t <= a) return 0;
416
+ if (t >= b) return 1;
417
+ t = (t - a) / (b - a);
418
+ return t * t * (3 - 2 * t);
419
+ }
420
+
421
+ function lerp(a, b, t) { return a + (b - a) * t; }
422
+
423
+ function valueNoise2(x, z, seed) {
424
+ const xi = Math.floor(x);
425
+ const zi = Math.floor(z);
426
+ const xf = x - xi;
427
+ const zf = z - zi;
428
+ const s = smoothstep(0, 1, xf);
429
+ const t = smoothstep(0, 1, zf);
430
+ const v00 = hash2i(xi, zi, seed);
431
+ const v10 = hash2i(xi + 1, zi, seed);
432
+ const v01 = hash2i(xi, zi + 1, seed);
433
+ const v11 = hash2i(xi + 1, zi + 1, seed);
434
+ const x1 = lerp(v00, v10, s);
435
+ const x2 = lerp(v01, v11, s);
436
+ return lerp(x1, x2, t) * 2 - 1; // [-1,1]
437
+ }
438
+
439
+ function fbm(x, z, baseFreq, octaves, lacunarity, gain, seed) {
440
+ let sum = 0;
441
+ let amp = 1;
442
+ let freq = baseFreq;
443
+ for (let i = 0; i < octaves; i++) {
444
+ sum += amp * valueNoise2(x * freq, z * freq, seed);
445
+ freq *= lacunarity;
446
+ amp *= gain;
447
+ }
448
+ return sum;
449
+ }
450
+
451
+ // Exported height sampler so other systems can stick to the ground
452
+ export function getTerrainHeight(x, z) {
453
+ const seed = (CFG.seed | 0) ^ 0x9e3779b9;
454
+ // Gentle rolling hills with subtle detail
455
+ const h1 = fbm(x, z, 1 / 90, 4, 2, 0.5, seed);
456
+ const h2 = fbm(x, z, 1 / 28, 3, 2, 0.5, seed + 1337);
457
+ const h3 = fbm(x, z, 1 / 9, 2, 2, 0.5, seed + 4242);
458
+ let h = h1 * 3.6 + h2 * 1.7 + h3 * 0.6; // total amplitude ~ up to ~6-7
459
+
460
+ // Soften near the center to keep spawn area playable
461
+ const r = Math.hypot(x, z);
462
+ const mask = smoothstep(CFG.clearRadius * 0.8, CFG.clearRadius * 1.8, r);
463
+ h *= mask;
464
+
465
+ return h;
466
+ }
467
+
468
+ // --- Procedural ground textures (albedo + normal) ---
469
+ function generateGroundTextures(size = 1024) {
470
+ const seed = (CFG.seed | 0) ^ 0x51f9ac4d;
471
+ const canvas = document.createElement('canvas');
472
+ canvas.width = size; canvas.height = size;
473
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
474
+
475
+ const nCanvas = document.createElement('canvas');
476
+ nCanvas.width = size; nCanvas.height = size;
477
+ const nctx = nCanvas.getContext('2d');
478
+
479
+ // Choose a periodic cell count that divides nicely for octaves
480
+ // Bigger = less obvious repeats; must keep perf reasonable
481
+ const cells = 64; // base lattice cells across the tile
482
+
483
+ // Periodic hash: wrap lattice coordinates to make the noise tile
484
+ function hash2Periodic(xi, yi, period, s) {
485
+ const px = ((xi % period) + period) % period;
486
+ const py = ((yi % period) + period) % period;
487
+ return hash2i(px, py, s);
488
+ }
489
+
490
+ function valueNoise2Periodic(x, z, period, s) {
491
+ const xi = Math.floor(x);
492
+ const zi = Math.floor(z);
493
+ const xf = x - xi;
494
+ const zf = z - zi;
495
+ const sx = smoothstep(0, 1, xf);
496
+ const sz = smoothstep(0, 1, zf);
497
+ const v00 = hash2Periodic(xi, zi, period, s);
498
+ const v10 = hash2Periodic(xi + 1, zi, period, s);
499
+ const v01 = hash2Periodic(xi, zi + 1, period, s);
500
+ const v11 = hash2Periodic(xi + 1, zi + 1, period, s);
501
+ const x1 = lerp(v00, v10, sx);
502
+ const x2 = lerp(v01, v11, sx);
503
+ return lerp(x1, x2, sz) * 2 - 1;
504
+ }
505
+
506
+ function fbmPeriodic(x, z, octaves, s) {
507
+ let sum = 0;
508
+ let amp = 0.5;
509
+ let freq = 1;
510
+ for (let i = 0; i < octaves; i++) {
511
+ const period = cells * freq;
512
+ sum += amp * valueNoise2Periodic(x * freq, z * freq, period, s + i * 1013);
513
+ freq *= 2;
514
+ amp *= 0.5;
515
+ }
516
+ return sum; // roughly [-1,1]
517
+ }
518
+
519
+ // Precompute a height-ish field for normal derivation (two-pass)
520
+ const H = new Float32Array(size * size);
521
+ const idx = (x, y) => y * size + x;
522
+
523
+ for (let y = 0; y < size; y++) {
524
+ for (let x = 0; x < size; x++) {
525
+ // Map pixel -> tile domain [0, cells]
526
+ const u = (x / size) * cells;
527
+ const v = (y / size) * cells;
528
+
529
+ const nLow = fbmPeriodic(u * 0.75, v * 0.75, 3, seed);
530
+ const nHi = fbmPeriodic(u * 3.0, v * 3.0, 2, seed + 999);
531
+ // Height proxy for normals: mix broad and fine details
532
+ const h = 0.6 * (nLow * 0.5 + 0.5) + 0.4 * (nHi * 0.5 + 0.5);
533
+ H[idx(x, y)] = h;
534
+ }
535
+ }
536
+
537
+ const img = ctx.createImageData(size, size);
538
+ const data = img.data;
539
+ const nimg = nctx.createImageData(size, size);
540
+ const ndata = nimg.data;
541
+
542
+ // Palettes
543
+ const grassDark = [0x20, 0x5a, 0x2b]; // #205a2b deep green
544
+ const grassLight = [0x4c, 0x9a, 0x3b]; // #4c9a3b lively green
545
+ const dryGrass = [0x88, 0xa0, 0x55]; // #88a055 sun-kissed
546
+ const dirtDark = [0x4f, 0x39, 0x2c]; // #4f392c rich soil
547
+ const dirtLight = [0x73, 0x5a, 0x48]; // #735a48 lighter soil
548
+
549
+ function mixColor(a, b, t) {
550
+ return [
551
+ Math.round(lerp(a[0], b[0], t)),
552
+ Math.round(lerp(a[1], b[1], t)),
553
+ Math.round(lerp(a[2], b[2], t))
554
+ ];
555
+ }
556
+
557
+ // Second pass: color + normal
558
+ const strength = 2.2; // normal intensity
559
+ for (let y = 0; y < size; y++) {
560
+ for (let x = 0; x < size; x++) {
561
+ const u = (x / size) * cells;
562
+ const v = (y / size) * cells;
563
+
564
+ // Patchiness control
565
+ const broad = fbmPeriodic(u * 0.8, v * 0.8, 3, seed + 17) * 0.5 + 0.5;
566
+ const detail = fbmPeriodic(u * 3.2, v * 3.2, 2, seed + 23) * 0.5 + 0.5;
567
+ let grassness = smoothstep(0.38, 0.62, broad);
568
+ grassness = lerp(grassness, grassness * (0.7 + 0.3 * detail), 0.5);
569
+
570
+ // Choose palette and mix for variation
571
+ const grassMid = mixColor(grassDark, grassLight, 0.6);
572
+ const grassCol = mixColor(grassMid, dryGrass, 0.25 + 0.35 * detail);
573
+ const dirtCol = mixColor(dirtDark, dirtLight, 0.35 + 0.4 * detail);
574
+ const col = mixColor(dirtCol, grassCol, grassness);
575
+
576
+ const p = idx(x, y) * 4;
577
+ data[p + 0] = col[0];
578
+ data[p + 1] = col[1];
579
+ data[p + 2] = col[2];
580
+ data[p + 3] = 255;
581
+
582
+ // Normal from height field with wrapping
583
+ const xL = (x - 1 + size) % size, xR = (x + 1) % size;
584
+ const yT = (y - 1 + size) % size, yB = (y + 1) % size;
585
+ const hL = H[idx(xL, y)], hR = H[idx(xR, y)];
586
+ const hT = H[idx(x, yT)], hB = H[idx(x, yB)];
587
+ const dx = (hR - hL) * strength;
588
+ const dy = (hB - hT) * strength;
589
+ let nx = -dx, ny = -dy, nz = 1.0;
590
+ const invLen = 1 / Math.hypot(nx, ny, nz);
591
+ nx *= invLen; ny *= invLen; nz *= invLen;
592
+ ndata[p + 0] = Math.round((nx * 0.5 + 0.5) * 255);
593
+ ndata[p + 1] = Math.round((ny * 0.5 + 0.5) * 255);
594
+ ndata[p + 2] = Math.round((nz * 0.5 + 0.5) * 255);
595
+ ndata[p + 3] = 255;
596
+ }
597
+ }
598
+
599
+ ctx.putImageData(img, 0, 0);
600
+ nctx.putImageData(nimg, 0, 0);
601
+
602
+ const map = new THREE.CanvasTexture(canvas);
603
+ map.colorSpace = THREE.SRGBColorSpace;
604
+ map.generateMipmaps = true;
605
+ map.minFilter = THREE.LinearMipmapLinearFilter;
606
+ map.magFilter = THREE.LinearFilter;
607
+ map.wrapS = map.wrapT = THREE.RepeatWrapping;
608
+
609
+ const normalMap = new THREE.CanvasTexture(nCanvas);
610
+ normalMap.generateMipmaps = true;
611
+ normalMap.minFilter = THREE.LinearMipmapLinearFilter;
612
+ normalMap.magFilter = THREE.LinearFilter;
613
+ normalMap.wrapS = normalMap.wrapT = THREE.RepeatWrapping;
614
+
615
+ // Anisotropy if available
616
+ try {
617
+ const maxAniso = G.renderer && G.renderer.capabilities ? G.renderer.capabilities.getMaxAnisotropy() : 0;
618
+ if (maxAniso && maxAniso > 0) {
619
+ map.anisotropy = Math.min(8, maxAniso);
620
+ normalMap.anisotropy = Math.min(8, maxAniso);
621
+ }
622
+ } catch (_) {}
623
+
624
+ return { map, normalMap };
625
+ }
626
+
627
+ export function setupGround() {
628
+ const segs = 160; // enough resolution for smooth hills
629
+ const geometry = new THREE.PlaneGeometry(CFG.forestSize, CFG.forestSize, segs, segs);
630
+ geometry.rotateX(-Math.PI / 2);
631
+
632
+ // Displace vertices along Y using our height function
633
+ const pos = geometry.attributes.position;
634
+ const colors = new Float32Array(pos.count * 3);
635
+ let minY = Infinity, maxY = -Infinity;
636
+ for (let i = 0; i < pos.count; i++) {
637
+ const x = pos.getX(i);
638
+ const z = pos.getZ(i);
639
+ const y = getTerrainHeight(x, z);
640
+ pos.setY(i, y);
641
+ if (y < minY) minY = y;
642
+ if (y > maxY) maxY = y;
643
+ }
644
+ pos.needsUpdate = true;
645
+ geometry.computeVertexNormals();
646
+
647
+ // Macro vertex colors based on slope (normal.y) and height
648
+ const nrm = geometry.attributes.normal;
649
+ for (let i = 0; i < pos.count; i++) {
650
+ const x = pos.getX(i);
651
+ const z = pos.getZ(i);
652
+ const y = pos.getY(i);
653
+ const ny = nrm.getY(i);
654
+
655
+ const r = Math.hypot(x, z);
656
+ const clear = 1.0 - smoothstep(CFG.clearRadius * 0.7, CFG.clearRadius * 1.4, r);
657
+ const flat = smoothstep(0.6, 0.96, ny); // flat areas -> grass
658
+ const hNorm = (y - minY) / Math.max(1e-5, (maxY - minY));
659
+
660
+ // Grass tint varies with height; dirt tint more constant
661
+ const grassDark = new THREE.Color(0x1f4f28);
662
+ const grassLight = new THREE.Color(0x3f8f3a);
663
+ const dirtDark = new THREE.Color(0x4a3a2e);
664
+ const dirtLight = new THREE.Color(0x6a5040);
665
+
666
+ const grassTint = grassDark.clone().lerp(grassLight, 0.35 + 0.45 * hNorm);
667
+ const dirtTint = dirtDark.clone().lerp(dirtLight, 0.35);
668
+
669
+ // Reduce grass in the central clearing for readability
670
+ const grassness = THREE.MathUtils.clamp(flat * (1.0 - 0.65 * clear), 0, 1);
671
+ const tint = dirtTint.clone().lerp(grassTint, grassness);
672
+
673
+ colors[i * 3 + 0] = tint.r;
674
+ colors[i * 3 + 1] = tint.g;
675
+ colors[i * 3 + 2] = tint.b;
676
+ }
677
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
678
+
679
+ // High-frequency detail textures (tileable) + macro vertex tint
680
+ const { map, normalMap } = generateGroundTextures(1024);
681
+ // Repeat detail across the forest (fewer repeats = larger features)
682
+ // Previously: forestSize / 4 (very fine, looked too uniform)
683
+ const repeats = Math.max(12, Math.round(CFG.forestSize / 12));
684
+ map.repeat.set(repeats, repeats);
685
+ normalMap.repeat.set(repeats, repeats);
686
+
687
+ const material = new THREE.MeshStandardMaterial({
688
+ color: 0xffffff,
689
+ map,
690
+ normalMap,
691
+ roughness: 0.95,
692
+ metalness: 0.0,
693
+ vertexColors: true
694
+ });
695
+
696
+ // Add a subtle world-space macro variation to break tiling repetition
697
+ material.onBeforeCompile = (shader) => {
698
+ shader.uniforms.uMacroScale = { value: 0.035 }; // frequency in world units
699
+ shader.uniforms.uMacroStrength = { value: 0.28 }; // mix into base color
700
+
701
+ shader.vertexShader = (
702
+ 'varying vec3 vWorldPos;\n' +
703
+ shader.vertexShader
704
+ ).replace(
705
+ '#include <worldpos_vertex>',
706
+ `#include <worldpos_vertex>
707
+ vWorldPos = worldPosition.xyz;`
708
+ );
709
+
710
+ // Cheap 2D value-noise FBM in fragment to modulate albedo in world space
711
+ const NOISE_CHUNK = `
712
+ varying vec3 vWorldPos;
713
+ uniform float uMacroScale;
714
+ uniform float uMacroStrength;
715
+
716
+ float hash12(vec2 p){
717
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
718
+ p3 += dot(p3, p3.yzx + 33.33);
719
+ return fract((p3.x + p3.y) * p3.z);
720
+ }
721
+ float vnoise(vec2 p){
722
+ vec2 i = floor(p);
723
+ vec2 f = fract(p);
724
+ float a = hash12(i);
725
+ float b = hash12(i + vec2(1.0, 0.0));
726
+ float c = hash12(i + vec2(0.0, 1.0));
727
+ float d = hash12(i + vec2(1.0, 1.0));
728
+ vec2 u = f*f*(3.0-2.0*f);
729
+ return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
730
+ }
731
+ float fbm2(vec2 p){
732
+ float t = 0.0;
733
+ float amp = 0.5;
734
+ for(int i=0;i<4;i++){
735
+ t += amp * vnoise(p);
736
+ p *= 2.0;
737
+ amp *= 0.5;
738
+ }
739
+ return t;
740
+ }
741
+ `;
742
+
743
+ shader.fragmentShader = (
744
+ NOISE_CHUNK + shader.fragmentShader
745
+ ).replace(
746
+ '#include <map_fragment>',
747
+ `#include <map_fragment>
748
+ // World-space macro color variation to reduce visible tiling
749
+ vec2 st = vWorldPos.xz * uMacroScale;
750
+ float macro = fbm2(st);
751
+ macro = macro * 0.5 + 0.5; // [0,1]
752
+ float m = mix(0.82, 1.18, macro);
753
+ diffuseColor.rgb *= mix(1.0, m, uMacroStrength);`
754
+ );
755
+ };
756
+ const ground = new THREE.Mesh(geometry, material);
757
+ ground.receiveShadow = true;
758
+ G.scene.add(ground);
759
+ G.ground = ground;
760
+ // Keep blockers in sync if trees already exist
761
+ if (G.treeMeshes && Array.isArray(G.treeMeshes)) {
762
+ G.blockers = [ground, ...G.treeMeshes];
763
+ }
764
+ }
765
+
766
+ export function generateForest() {
767
+ const clearRadiusSq = CFG.clearRadius * CFG.clearRadius;
768
+ const halfSize = CFG.forestSize / 2;
769
+ let placed = 0;
770
+ const maxAttempts = CFG.treeCount * 3;
771
+ let attempts = 0;
772
+
773
+ G.treeColliders.length = 0;
774
+ G.treeMeshes.length = 0;
775
+
776
+ while (placed < CFG.treeCount && attempts < maxAttempts) {
777
+ attempts++;
778
+
779
+ const x = (G.random() - 0.5) * CFG.forestSize;
780
+ const z = (G.random() - 0.5) * CFG.forestSize;
781
+
782
+ // Check clearing
783
+ if (x * x + z * z < clearRadiusSq) continue;
784
+
785
+ // Check distance to other trees
786
+ let tooClose = false;
787
+ for (const collider of G.treeColliders) {
788
+ const dx = x - collider.x;
789
+ const dz = z - collider.z;
790
+ if (dx * dx + dz * dz < Math.pow(collider.radius * 2, 2)) {
791
+ tooClose = true;
792
+ break;
793
+ }
794
+ }
795
+ if (tooClose) continue;
796
+
797
+ // Create a layered pine-like tree with shared materials and wind-swaying foliage
798
+ const tree = new THREE.Group();
799
+
800
+ // Random uniform scale for size variety
801
+ const s = 0.75 + G.random() * 0.8; // ~0.75..1.55
802
+
803
+ // Trunk (reuses base geometry, scaled)
804
+ const trunk = new THREE.Mesh(GEO_TRUNK, TRUNK_MAT);
805
+ trunk.scale.set(s, s, s);
806
+ trunk.castShadow = true;
807
+ trunk.receiveShadow = true;
808
+ tree.add(trunk);
809
+
810
+ // Foliage: three cones + a small spherical crown
811
+ const foliage1 = new THREE.Mesh(GEO_CONE1, FOLIAGE_MAT);
812
+ const foliage2 = new THREE.Mesh(GEO_CONE2, FOLIAGE_MAT);
813
+ const foliage3 = new THREE.Mesh(GEO_CONE3, FOLIAGE_MAT);
814
+ const crown = new THREE.Mesh(GEO_SPH, FOLIAGE_MAT);
815
+ const fScale = s * (0.9 + G.random() * 0.15); // slight variance per tree
816
+ foliage1.scale.set(fScale, fScale, fScale);
817
+ foliage2.scale.set(fScale, fScale, fScale);
818
+ foliage3.scale.set(fScale, fScale, fScale);
819
+ crown.scale.set(fScale, fScale, fScale);
820
+ foliage1.castShadow = foliage2.castShadow = foliage3.castShadow = crown.castShadow = true;
821
+ foliage1.receiveShadow = foliage2.receiveShadow = foliage3.receiveShadow = crown.receiveShadow = true;
822
+ tree.add(foliage1, foliage2, foliage3, crown);
823
+
824
+ // Random rotation for natural look
825
+ tree.rotation.y = G.random() * Math.PI * 2;
826
+
827
+ const y = getTerrainHeight(x, z);
828
+ tree.position.set(x, y, z);
829
+ G.scene.add(tree);
830
+ G.treeMeshes.push(tree);
831
+
832
+ // Add collider roughly matching trunk base radius
833
+ const trunkBaseRadius = 1.2 * s;
834
+ G.treeColliders.push({ x, z, radius: trunkBaseRadius });
835
+
836
+ placed++;
837
+ }
838
+ // Update blockers list once trees are generated
839
+ if (G.ground) {
840
+ G.blockers = [G.ground, ...G.treeMeshes];
841
+ } else {
842
+ G.blockers = [...G.treeMeshes];
843
+ }
844
+ }
tsconfig.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ // Enable latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+
22
+ // Some stricter flags (disabled by default)
23
+ "noUnusedLocals": false,
24
+ "noUnusedParameters": false,
25
+ "noPropertyAccessFromIndexSignature": false
26
+ }
27
+ }