Spaces:
Running
Running
Codex CLI
commited on
Commit
·
1390db3
0
Parent(s):
Initial commit: Orcs In The Forest
Browse files- .gitignore +34 -0
- README.md +27 -0
- bun.lock +29 -0
- index.html +198 -0
- index.ts +1 -0
- package.json +12 -0
- src/audio.js +165 -0
- src/casings.js +131 -0
- src/clouds.js +183 -0
- src/combat.js +132 -0
- src/config.js +182 -0
- src/daynight.js +123 -0
- src/enemies.js +335 -0
- src/events.js +101 -0
- src/fx.js +137 -0
- src/globals.js +97 -0
- src/helmets.js +117 -0
- src/hud.js +145 -0
- src/lighting.js +101 -0
- src/main.js +202 -0
- src/mountains.js +133 -0
- src/pickups.js +136 -0
- src/player.js +73 -0
- src/projectiles.js +123 -0
- src/utils.js +11 -0
- src/waves.js +60 -0
- src/weapon.js +235 -0
- src/world.js +844 -0
- 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 |
+
}
|