incanto 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/bin/incanto-new.mjs +87 -0
- package/dist/2d.d.ts +2 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +219 -82
- package/dist/3d.js +74 -4
- package/dist/{behavior-Bod1AyJS.d.ts → behavior-CPibUfnH.d.ts} +4 -2
- package/dist/{create-game-C_xf4gm9.js → create-game-Ct86zczq.js} +5 -5
- package/dist/{create-game-B2fewJUy.js → create-game-OSntcIiM.js} +5 -5
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-BEvGBtb_.js → duplicate-BINwP0WG.js} +1 -1
- package/dist/{gameplay-Dn3yidJw.js → gameplay-sSqrUcnz.js} +75 -3
- package/dist/gameplay.d.ts +37 -2
- package/dist/gameplay.js +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -4
- package/dist/{loader-B4OEXDZ8.js → loader-BZqOKfI2.js} +3 -1
- package/dist/{loader-BcUDfNHn.d.ts → loader-DILt9PGC.d.ts} +1 -1
- package/dist/net.d.ts +1 -1
- package/dist/net.js +3 -3
- package/dist/{pathfinding-BSiXjd6W.d.ts → pathfinding-RWYkNKx9.d.ts} +1 -1
- package/dist/{physics-2d-B6D864oZ.js → physics-2d-BiIdl51r.js} +2 -2
- package/dist/{physics-3d-RLNaxRyw.js → physics-3d-D_kUdOjr.js} +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-Cwc-yCYx.js → register-BFFE1Mh1.js} +2 -2
- package/dist/{register-ysLR3WQS.js → register-C9-16x_t.js} +562 -157
- package/dist/{register-BD6zgrv_.js → register-Djwckzgx.js} +2 -2
- package/dist/{register-DJZXxwAn.js → register-nObreUQR.js} +1 -1
- package/dist/test.d.ts +2 -2
- package/dist/test.js +10 -10
- package/editor/assets/{agent8-D_4QY8Aq.js → agent8-BDC4LhjC.js} +1 -1
- package/editor/assets/{index-s0DZuroe.js → index-7qOYg7t8.js} +74 -74
- package/editor/index.html +1 -1
- package/package.json +4 -2
- package/schemas/scene.schema.json +266 -6
- package/skills/incanto-3d-character.md +35 -0
- package/skills/incanto-building-3d-games.md +48 -0
- package/skills/incanto-environment.md +15 -16
- package/skills/incanto-gameplay-behaviors.md +23 -1
- package/skills/incanto-node-reference.md +54 -3
- package/templates-app/beacon-isle-3d/PROJECT/Context.md +44 -0
- package/templates-app/beacon-isle-3d/PROJECT/Requirements.md +16 -0
- package/templates-app/beacon-isle-3d/PROJECT/Status.md +38 -0
- package/templates-app/beacon-isle-3d/PROJECT/Structure.md +24 -0
- package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +38 -0
- package/templates-app/beacon-isle-3d/generate-world.ts +812 -0
- package/templates-app/beacon-isle-3d/index.html +68 -0
- package/templates-app/beacon-isle-3d/package.json +26 -0
- package/templates-app/beacon-isle-3d/src/behaviors.ts +570 -0
- package/templates-app/beacon-isle-3d/src/game.scene.json +2969 -0
- package/templates-app/beacon-isle-3d/src/main.ts +33 -0
- package/templates-app/beacon-isle-3d/tsconfig.json +13 -0
- package/templates-app/beacon-isle-3d/verify.ts +285 -0
- package/templates-app/beacon-isle-3d/vite.config.ts +5 -0
- package/templates-app/tps-3d/PROJECT/Context.md +49 -0
- package/templates-app/tps-3d/PROJECT/Requirements.md +38 -0
- package/templates-app/tps-3d/PROJECT/Status.md +41 -0
- package/templates-app/tps-3d/PROJECT/Structure.md +44 -0
- package/templates-app/tps-3d/docs/project-3d-rules.md +38 -0
- package/templates-app/tps-3d/index.html +244 -0
- package/templates-app/tps-3d/package.json +25 -0
- package/templates-app/tps-3d/src/behaviors.ts +405 -0
- package/templates-app/tps-3d/src/game.scene.json +677 -0
- package/templates-app/tps-3d/src/main.ts +64 -0
- package/templates-app/tps-3d/tsconfig.json +13 -0
- package/templates-app/tps-3d/verify.ts +167 -0
- package/templates-app/tps-3d/vite.config.ts +5 -0
- package/templates-app/village-quest-3d/PROJECT/Context.md +39 -0
- package/templates-app/village-quest-3d/PROJECT/Requirements.md +36 -0
- package/templates-app/village-quest-3d/PROJECT/Status.md +26 -0
- package/templates-app/village-quest-3d/PROJECT/Structure.md +30 -0
- package/templates-app/village-quest-3d/docs/project-3d-rules.md +38 -0
- package/templates-app/village-quest-3d/index.html +68 -0
- package/templates-app/village-quest-3d/package.json +25 -0
- package/templates-app/village-quest-3d/src/behaviors.ts +529 -0
- package/templates-app/village-quest-3d/src/grove.scene.json +826 -0
- package/templates-app/village-quest-3d/src/main.ts +35 -0
- package/templates-app/village-quest-3d/src/quest.ts +18 -0
- package/templates-app/village-quest-3d/src/village.scene.json +680 -0
- package/templates-app/village-quest-3d/tsconfig.json +13 -0
- package/templates-app/village-quest-3d/verify.ts +254 -0
- package/templates-app/village-quest-3d/vite.config.ts +5 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2220%22 fill=%22%233b6ea5%22/><text x=%2250%22 y=%2268%22 font-size=%2255%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-weight=%22bold%22 fill=%22%236ee7dc%22>vg</text></svg>" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Vanguard — Incanto TPS</title>
|
|
8
|
+
<style>
|
|
9
|
+
html,
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
padding: 0;
|
|
13
|
+
height: 100%;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
font-family: Arial, sans-serif;
|
|
16
|
+
background: #0b0d14;
|
|
17
|
+
}
|
|
18
|
+
canvas {
|
|
19
|
+
display: block;
|
|
20
|
+
width: 100vw;
|
|
21
|
+
height: 100vh;
|
|
22
|
+
}
|
|
23
|
+
.loading-container {
|
|
24
|
+
position: fixed;
|
|
25
|
+
inset: 0;
|
|
26
|
+
z-index: 50;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
background: black;
|
|
32
|
+
color: white;
|
|
33
|
+
}
|
|
34
|
+
.loading-title {
|
|
35
|
+
font-size: 1.5rem;
|
|
36
|
+
font-weight: bold;
|
|
37
|
+
margin-bottom: 1rem;
|
|
38
|
+
}
|
|
39
|
+
/* Crosshair (the camera aim point — where the hitscan goes) */
|
|
40
|
+
#crosshair {
|
|
41
|
+
position: fixed;
|
|
42
|
+
left: 50%;
|
|
43
|
+
top: 50%;
|
|
44
|
+
width: 26px;
|
|
45
|
+
height: 26px;
|
|
46
|
+
transform: translate(-50%, -50%);
|
|
47
|
+
pointer-events: none;
|
|
48
|
+
z-index: 50;
|
|
49
|
+
}
|
|
50
|
+
.ch-h,
|
|
51
|
+
.ch-v {
|
|
52
|
+
position: absolute;
|
|
53
|
+
left: 50%;
|
|
54
|
+
top: 50%;
|
|
55
|
+
background: #eafffb;
|
|
56
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.85);
|
|
57
|
+
}
|
|
58
|
+
.ch-h {
|
|
59
|
+
width: 16px;
|
|
60
|
+
height: 2px;
|
|
61
|
+
transform: translate(-50%, -50%);
|
|
62
|
+
}
|
|
63
|
+
.ch-v {
|
|
64
|
+
width: 2px;
|
|
65
|
+
height: 16px;
|
|
66
|
+
transform: translate(-50%, -50%);
|
|
67
|
+
}
|
|
68
|
+
.ch-dot {
|
|
69
|
+
position: absolute;
|
|
70
|
+
left: 50%;
|
|
71
|
+
top: 50%;
|
|
72
|
+
width: 3px;
|
|
73
|
+
height: 3px;
|
|
74
|
+
border-radius: 50%;
|
|
75
|
+
background: #6ee7dc;
|
|
76
|
+
transform: translate(-50%, -50%);
|
|
77
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.85);
|
|
78
|
+
}
|
|
79
|
+
/* On-screen buttons (mobile + desktop parity) */
|
|
80
|
+
.action-buttons {
|
|
81
|
+
position: fixed;
|
|
82
|
+
bottom: 2rem;
|
|
83
|
+
right: 2rem;
|
|
84
|
+
z-index: 1001;
|
|
85
|
+
}
|
|
86
|
+
.round-btn {
|
|
87
|
+
border: none;
|
|
88
|
+
border-radius: 9999px;
|
|
89
|
+
background: rgba(255, 255, 255, 0.25);
|
|
90
|
+
color: white;
|
|
91
|
+
font-weight: bold;
|
|
92
|
+
font-size: 0.75rem;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
transition: transform 0.1s;
|
|
95
|
+
user-select: none;
|
|
96
|
+
-webkit-user-select: none;
|
|
97
|
+
}
|
|
98
|
+
.round-btn:active {
|
|
99
|
+
transform: scale(0.9);
|
|
100
|
+
}
|
|
101
|
+
.round-btn.fire {
|
|
102
|
+
width: 84px;
|
|
103
|
+
height: 84px;
|
|
104
|
+
background: rgba(59, 110, 165, 0.5);
|
|
105
|
+
}
|
|
106
|
+
.round-btn.jump {
|
|
107
|
+
position: absolute;
|
|
108
|
+
left: -3.25rem;
|
|
109
|
+
top: -3.25rem;
|
|
110
|
+
width: 56px;
|
|
111
|
+
height: 56px;
|
|
112
|
+
}
|
|
113
|
+
/* HUD overlay (3D scenes have no 2D UI nodes — the HUD is plain DOM) */
|
|
114
|
+
#hud {
|
|
115
|
+
position: fixed;
|
|
116
|
+
top: 14px;
|
|
117
|
+
left: 14px;
|
|
118
|
+
z-index: 40;
|
|
119
|
+
pointer-events: none;
|
|
120
|
+
color: #f4f4f4;
|
|
121
|
+
font-weight: bold;
|
|
122
|
+
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
|
123
|
+
line-height: 1.5;
|
|
124
|
+
}
|
|
125
|
+
#hud-wave {
|
|
126
|
+
color: #ffd166;
|
|
127
|
+
font-size: 22px;
|
|
128
|
+
}
|
|
129
|
+
#hud-hp {
|
|
130
|
+
color: #6ee7dc;
|
|
131
|
+
font-size: 20px;
|
|
132
|
+
}
|
|
133
|
+
#hud-hp-track {
|
|
134
|
+
width: 180px;
|
|
135
|
+
height: 7px;
|
|
136
|
+
margin: 3px 0 6px;
|
|
137
|
+
background: rgba(255, 255, 255, 0.18);
|
|
138
|
+
border-radius: 9999px;
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
}
|
|
141
|
+
#hud-hp-fill {
|
|
142
|
+
width: 100%;
|
|
143
|
+
height: 100%;
|
|
144
|
+
background: #6ee7dc;
|
|
145
|
+
transition: width 0.12s linear;
|
|
146
|
+
}
|
|
147
|
+
#hud-kills {
|
|
148
|
+
font-size: 20px;
|
|
149
|
+
}
|
|
150
|
+
#hud-ammo {
|
|
151
|
+
color: #8fc7ff;
|
|
152
|
+
font-size: 20px;
|
|
153
|
+
}
|
|
154
|
+
#hint {
|
|
155
|
+
position: fixed;
|
|
156
|
+
bottom: 14px;
|
|
157
|
+
left: 14px;
|
|
158
|
+
z-index: 40;
|
|
159
|
+
pointer-events: none;
|
|
160
|
+
color: rgba(255, 255, 255, 0.7);
|
|
161
|
+
font-size: 13px;
|
|
162
|
+
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
|
163
|
+
}
|
|
164
|
+
#banner {
|
|
165
|
+
position: fixed;
|
|
166
|
+
inset: 0;
|
|
167
|
+
z-index: 60;
|
|
168
|
+
display: flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
justify-content: center;
|
|
171
|
+
pointer-events: none;
|
|
172
|
+
font-size: 52px;
|
|
173
|
+
font-weight: bold;
|
|
174
|
+
color: #fff;
|
|
175
|
+
opacity: 0;
|
|
176
|
+
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
|
|
177
|
+
transition: opacity 0.25s;
|
|
178
|
+
}
|
|
179
|
+
@keyframes spin {
|
|
180
|
+
to {
|
|
181
|
+
transform: rotate(360deg);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
.progress-track {
|
|
185
|
+
width: 256px;
|
|
186
|
+
height: 8px;
|
|
187
|
+
background: #374151;
|
|
188
|
+
border-radius: 9999px;
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
}
|
|
191
|
+
.progress-fill {
|
|
192
|
+
width: 0%;
|
|
193
|
+
height: 100%;
|
|
194
|
+
background: #3b6ea5;
|
|
195
|
+
transition: width 0.3s;
|
|
196
|
+
}
|
|
197
|
+
.progress-text {
|
|
198
|
+
margin-top: 0.5rem;
|
|
199
|
+
font-size: 0.9rem;
|
|
200
|
+
}
|
|
201
|
+
</style>
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<div class="loading-container" id="loading">
|
|
205
|
+
<h2 class="loading-title">Vanguard</h2>
|
|
206
|
+
<div class="progress-track"><div class="progress-fill" id="progress"></div></div>
|
|
207
|
+
<div class="progress-text" id="progress-text">0%</div>
|
|
208
|
+
</div>
|
|
209
|
+
<canvas id="game"></canvas>
|
|
210
|
+
<div id="hud">
|
|
211
|
+
<div id="hud-wave">Wave 1 / 3</div>
|
|
212
|
+
<div id="hud-hp">HP 100</div>
|
|
213
|
+
<div id="hud-hp-track"><div id="hud-hp-fill"></div></div>
|
|
214
|
+
<div id="hud-kills">Kills 0</div>
|
|
215
|
+
<div id="hud-ammo">Ammo 18 / 18</div>
|
|
216
|
+
</div>
|
|
217
|
+
<div id="hint">WASD move · mouse look (click to lock) · Shift sprint · Space jump · click / F fire · R reload</div>
|
|
218
|
+
<div id="banner"></div>
|
|
219
|
+
<div id="crosshair">
|
|
220
|
+
<div class="ch-h"></div>
|
|
221
|
+
<div class="ch-v"></div>
|
|
222
|
+
<div class="ch-dot"></div>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="action-buttons">
|
|
225
|
+
<button type="button" id="btn-fire" class="round-btn fire">FIRE</button>
|
|
226
|
+
<button type="button" id="btn-jump" class="round-btn jump">JUMP</button>
|
|
227
|
+
</div>
|
|
228
|
+
<script>
|
|
229
|
+
// agent8 embed harness: report the game size to a hosting iframe parent.
|
|
230
|
+
function postGameSize() {
|
|
231
|
+
window.parent.postMessage(
|
|
232
|
+
{ type: 'GAME_SIZE_RESPONSE', width: document.body.scrollWidth, height: document.body.scrollHeight },
|
|
233
|
+
'*',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
window.addEventListener('load', postGameSize);
|
|
237
|
+
window.addEventListener('resize', postGameSize);
|
|
238
|
+
window.addEventListener('message', (e) => {
|
|
239
|
+
if (e.data && e.data.type === 'REQUEST_GAME_SIZE') postGameSize();
|
|
240
|
+
});
|
|
241
|
+
</script>
|
|
242
|
+
<script type="module" src="/src/main.ts"></script>
|
|
243
|
+
</body>
|
|
244
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tps-3d",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"check": "incanto-check src",
|
|
10
|
+
"verify": "bun verify.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@dimforge/rapier2d-compat": "0.19.3",
|
|
14
|
+
"@dimforge/rapier3d-compat": "0.19.3",
|
|
15
|
+
"@pixiv/three-vrm": "^3.5.3",
|
|
16
|
+
"incanto": "^0.14.0",
|
|
17
|
+
"three": "^0.184.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^24.0.0",
|
|
21
|
+
"@types/three": "^0.184.1",
|
|
22
|
+
"typescript": "^6.0.3",
|
|
23
|
+
"vite": "^8.0.16"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TWO custom behaviors in Vanguard. Everything else — movement, the
|
|
3
|
+
* over-the-shoulder camera, enemy AI, contact damage, wave spawning, death,
|
|
4
|
+
* win/lose — is BUILT-IN gameplay behavior wired in `game.scene.json`. These two
|
|
5
|
+
* cover the only gaps a built-in can't fill:
|
|
6
|
+
*
|
|
7
|
+
* - `Shoot` — a click-to-fire HITSCAN gun. The aim is the THIRD-PERSON camera's
|
|
8
|
+
* look direction (CharacterController3D `view: 'free'` exposes yaw/pitch); a
|
|
9
|
+
* per-frame raycast finds the nearest enemy in the crosshair cone. It also owns
|
|
10
|
+
* the ammo clip, muzzle flash, tracer, spatial gun SFX, and — the TPS touch —
|
|
11
|
+
* snaps the visible character to FACE the aim on each shot so the soldier turns
|
|
12
|
+
* to shoot where you're looking.
|
|
13
|
+
* - `HudUpdater` — pure presentation glue. The gameplay library owns the numbers,
|
|
14
|
+
* not how they're drawn, and 3D scenes have no 2D UI nodes, so this reads
|
|
15
|
+
* ScoreKeeper.score / Health.current / the wave index / the ammo count each
|
|
16
|
+
* frame into the HTML HUD overlay (index.html) and flips the win/lose banner.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Node } from 'incanto';
|
|
20
|
+
import { Behavior } from 'incanto';
|
|
21
|
+
import type { CharacterController3D, ModelInstance3D, Node3D } from 'incanto/3d';
|
|
22
|
+
import { MeshInstance3D, Particles3D } from 'incanto/3d';
|
|
23
|
+
|
|
24
|
+
// CharacterController3D movement state → mixamo clip (retargets onto the GLB).
|
|
25
|
+
const STATE_CLIPS: Record<string, string> = {
|
|
26
|
+
idle: '$anims/idle',
|
|
27
|
+
walk: '$anims/walk',
|
|
28
|
+
run: '$anims/run',
|
|
29
|
+
fastRun: '$anims/runFast',
|
|
30
|
+
airborne: '$anims/jump',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Shoot — hitscan gun on the CharacterController3D (reads its yaw/pitch/eye).
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const CLIP_SIZE = 18;
|
|
38
|
+
const FIRE_COOLDOWN_MS = 130;
|
|
39
|
+
const RELOAD_MS = 950;
|
|
40
|
+
const RANGE = 70; // meters a shot reaches
|
|
41
|
+
const AIM_COS = Math.cos(0.16); // ~9° aim cone (dot-product threshold)
|
|
42
|
+
const DAMAGE = 30; // one-shots a 30-HP enemy
|
|
43
|
+
const FLASH_MS = 55;
|
|
44
|
+
const TRACER_MS = 45; // a quick flash — short enough not to linger when moving
|
|
45
|
+
const TRACER_OPACITY = 0.85; // start opacity; the streak FADES to 0 over its life
|
|
46
|
+
const TRACER_LEN = 2.5; // SHORT dash leaving the muzzle — NOT a full beam to the target
|
|
47
|
+
const CHEST_OFFSET = 0.4; // tracer/flash origin above the body centre (≈ chest)
|
|
48
|
+
const RAD2DEG = 180 / Math.PI;
|
|
49
|
+
|
|
50
|
+
interface HealthLike {
|
|
51
|
+
damage(n: number): void;
|
|
52
|
+
isDead: boolean;
|
|
53
|
+
}
|
|
54
|
+
interface ScoreLike {
|
|
55
|
+
addScore(n: number): void;
|
|
56
|
+
}
|
|
57
|
+
interface AudioLike {
|
|
58
|
+
play(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const vlen = (x: number, y: number, z: number): number => Math.hypot(x, y, z);
|
|
62
|
+
const normalize3 = (v: [number, number, number]): [number, number, number] => {
|
|
63
|
+
const l = Math.hypot(v[0], v[1], v[2]) || 1;
|
|
64
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Euler (degrees, order XYZ — the engine default) that points a node's local +Z along
|
|
69
|
+
* `(dx,dy,dz)`. For XYZ the +Z basis column is `(sin ry, −sin rx·cos ry, cos rx·cos ry)`,
|
|
70
|
+
* so `ry = atan2(Dx, √(Dy²+Dz²))`, `rx = atan2(−Dy, Dz)` (unit D). The level-only
|
|
71
|
+
* `[atan2(dy,horiz), …]` form skews a Z-aligned rod (tracer) when aiming up/down.
|
|
72
|
+
*/
|
|
73
|
+
function aimEulerXYZ(dx: number, dy: number, dz: number): [number, number, number] {
|
|
74
|
+
const l = Math.hypot(dx, dy, dz) || 1;
|
|
75
|
+
const ux = dx / l;
|
|
76
|
+
const uy = dy / l;
|
|
77
|
+
const uz = dz / l;
|
|
78
|
+
return [Math.atan2(-uy, uz) * RAD2DEG, Math.atan2(ux, Math.hypot(uy, uz)) * RAD2DEG, 0];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class Shoot extends Behavior {
|
|
82
|
+
ammo = CLIP_SIZE;
|
|
83
|
+
readonly clip = CLIP_SIZE;
|
|
84
|
+
reloading = false;
|
|
85
|
+
private clock = 0;
|
|
86
|
+
private lastShot = -Infinity;
|
|
87
|
+
private reloadDone = 0;
|
|
88
|
+
private flashUntil = 0;
|
|
89
|
+
private tracer: Node3D | null = null;
|
|
90
|
+
private tracerUntil = 0;
|
|
91
|
+
|
|
92
|
+
/** The controller carries this script; its parent is the Player body. */
|
|
93
|
+
private get controller(): CharacterController3D {
|
|
94
|
+
return this.node as unknown as CharacterController3D;
|
|
95
|
+
}
|
|
96
|
+
private get player(): Node3D {
|
|
97
|
+
return this.node.parent as unknown as Node3D;
|
|
98
|
+
}
|
|
99
|
+
private muzzle(name: string): AudioLike | null {
|
|
100
|
+
return (this.player.getNodeOrNull(`Muzzle/${name}`) as unknown as AudioLike) ?? null;
|
|
101
|
+
}
|
|
102
|
+
private get flash(): (Node3D & { intensity: number }) | null {
|
|
103
|
+
return (
|
|
104
|
+
(this.player.getNodeOrNull('Muzzle/Flash') as unknown as Node3D & { intensity: number }) ??
|
|
105
|
+
null
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
private get scoreKeeper(): ScoreLike {
|
|
109
|
+
return this.getNode('/root').behavior as unknown as ScoreLike;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Camera-eye world position — the AIM ray origin (player is a root child). */
|
|
113
|
+
private eye(): [number, number, number] {
|
|
114
|
+
const p = this.player.position;
|
|
115
|
+
return [p[0] ?? 0, (p[1] ?? 0) + this.controller.eyeHeight, p[2] ?? 0];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Where the VISIBLE tracer + flash emanate from: the character's CHEST, nudged
|
|
119
|
+
* forward along the aim. The camera eye sits ABOVE the head in third person, so
|
|
120
|
+
* firing the streak from there looks like it spawns from nowhere — the body is
|
|
121
|
+
* ~1.7 m tall with its origin at the waist, so chest ≈ +0.4, front ≈ +0.35·aim. */
|
|
122
|
+
private muzzlePoint(fwd: [number, number, number]): [number, number, number] {
|
|
123
|
+
const p = this.player.position;
|
|
124
|
+
return [
|
|
125
|
+
(p[0] ?? 0) + fwd[0] * 0.35,
|
|
126
|
+
(p[1] ?? 0) + CHEST_OFFSET + fwd[1] * 0.35,
|
|
127
|
+
(p[2] ?? 0) + fwd[2] * 0.35,
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Forward unit vector from the camera yaw/pitch (same convention as the rig). */
|
|
132
|
+
private forward(): [number, number, number] {
|
|
133
|
+
const { yaw, pitch } = this.controller;
|
|
134
|
+
const cp = Math.cos(pitch);
|
|
135
|
+
return [-Math.sin(yaw) * cp, -Math.sin(pitch), -Math.cos(yaw) * cp];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Drive the GLB character's mixamo animation from the controller's move state. */
|
|
139
|
+
override onReady(): void {
|
|
140
|
+
const skin = this.player.getNodeOrNull('Skin') as ModelInstance3D | null;
|
|
141
|
+
if (!skin) return;
|
|
142
|
+
this.controller.on('movementStateChanged', (state) => {
|
|
143
|
+
const clip = STATE_CLIPS[state as string];
|
|
144
|
+
if (clip) skin.animation = clip;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override update(dt: number): void {
|
|
149
|
+
this.clock += dt * 1000;
|
|
150
|
+
|
|
151
|
+
if (this.reloading && this.clock >= this.reloadDone) {
|
|
152
|
+
this.reloading = false;
|
|
153
|
+
this.ammo = this.clip;
|
|
154
|
+
}
|
|
155
|
+
if (this.input.justPressed('reload')) this.startReload();
|
|
156
|
+
|
|
157
|
+
const fl = this.flash;
|
|
158
|
+
if (fl) fl.intensity = this.clock < this.flashUntil ? 6 : 0;
|
|
159
|
+
if (this.tracer) {
|
|
160
|
+
const remain = this.tracerUntil - this.clock;
|
|
161
|
+
if (remain <= 0) {
|
|
162
|
+
this.tracer.parent?.removeChild(this.tracer as unknown as Node);
|
|
163
|
+
this.tracer = null;
|
|
164
|
+
} else {
|
|
165
|
+
// fade the streak out so a moving shot doesn't leave a "stick" hanging in the air
|
|
166
|
+
(this.tracer as unknown as MeshInstance3D).material.opacity =
|
|
167
|
+
TRACER_OPACITY * (remain / TRACER_MS);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
this.input.isPressed('fire') &&
|
|
173
|
+
!this.reloading &&
|
|
174
|
+
this.ammo > 0 &&
|
|
175
|
+
this.clock - this.lastShot >= FIRE_COOLDOWN_MS
|
|
176
|
+
) {
|
|
177
|
+
this.lastShot = this.clock;
|
|
178
|
+
this.fire();
|
|
179
|
+
} else if (this.input.justPressed('fire') && !this.reloading && this.ammo <= 0) {
|
|
180
|
+
this.muzzle('EmptySfx')?.play();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private startReload(): void {
|
|
185
|
+
if (this.reloading || this.ammo === this.clip) return;
|
|
186
|
+
this.reloading = true;
|
|
187
|
+
this.reloadDone = this.clock + RELOAD_MS;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private fire(): void {
|
|
191
|
+
this.ammo--;
|
|
192
|
+
this.muzzle('ShotSfx')?.play();
|
|
193
|
+
this.flashUntil = this.clock + FLASH_MS;
|
|
194
|
+
|
|
195
|
+
const eye = this.eye();
|
|
196
|
+
const fwd = this.forward();
|
|
197
|
+
this.faceAim(fwd); // TPS: turn the soldier to face where you're shooting.
|
|
198
|
+
|
|
199
|
+
// Hitscan: nearest enemy inside the aim cone within RANGE.
|
|
200
|
+
const tree = this.node.tree;
|
|
201
|
+
const enemies = tree ? tree.getNodesInGroup('enemy') : [];
|
|
202
|
+
let best: Node | null = null;
|
|
203
|
+
let bestDist = RANGE;
|
|
204
|
+
for (const e of enemies) {
|
|
205
|
+
const ep = (e as unknown as Node3D).position;
|
|
206
|
+
const dx = (ep[0] ?? 0) - eye[0];
|
|
207
|
+
const dy = (ep[1] ?? 0) - eye[1];
|
|
208
|
+
const dz = (ep[2] ?? 0) - eye[2];
|
|
209
|
+
const dist = vlen(dx, dy, dz);
|
|
210
|
+
if (dist < 0.001 || dist > RANGE) continue;
|
|
211
|
+
const dot = (dx * fwd[0] + dy * fwd[1] + dz * fwd[2]) / dist;
|
|
212
|
+
if (dot < AIM_COS) continue; // outside the cone
|
|
213
|
+
if (dist < bestDist) {
|
|
214
|
+
bestDist = dist;
|
|
215
|
+
best = e;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const end: [number, number, number] = best
|
|
220
|
+
? [...((best as unknown as Node3D).position as [number, number, number])]
|
|
221
|
+
: [eye[0] + fwd[0] * 28, eye[1] + fwd[1] * 28, eye[2] + fwd[2] * 28];
|
|
222
|
+
const muzzle = this.muzzlePoint(fwd);
|
|
223
|
+
this.spawnMuzzleFlash(muzzle); // a spark POP at the chest muzzle
|
|
224
|
+
this.spawnTracer(muzzle, end); // SHORT streak from the chest, not a long beam
|
|
225
|
+
|
|
226
|
+
if (best) {
|
|
227
|
+
const hp = this.findHealth(best);
|
|
228
|
+
if (hp) {
|
|
229
|
+
hp.damage(DAMAGE);
|
|
230
|
+
if (hp.isDead) {
|
|
231
|
+
this.scoreKeeper.addScore(1);
|
|
232
|
+
this.muzzle('KillSfx')?.play();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Snap the visible Skin (sibling Node3D) to face the aim — the controller's
|
|
239
|
+
* move-facing reclaims it on the next move, so this reads as "aim while still". */
|
|
240
|
+
private faceAim(fwd: [number, number, number]): void {
|
|
241
|
+
const skin = this.player.getNodeOrNull('Skin') as Node3D | null;
|
|
242
|
+
if (!skin) return;
|
|
243
|
+
const yaw = Math.atan2(fwd[0], fwd[2]) * RAD2DEG; // same mapping as the rig's skin-facing
|
|
244
|
+
skin.rotation = [skin.rotation[0] ?? 0, yaw, skin.rotation[2] ?? 0];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Robust Health lookup: the node, then its descendants. */
|
|
248
|
+
private findHealth(node: Node): HealthLike | null {
|
|
249
|
+
const b = (node as unknown as { behavior?: unknown }).behavior;
|
|
250
|
+
if (b && typeof (b as HealthLike).damage === 'function' && 'isDead' in (b as object)) {
|
|
251
|
+
return b as HealthLike;
|
|
252
|
+
}
|
|
253
|
+
for (const c of node.children) {
|
|
254
|
+
const h = this.findHealth(c);
|
|
255
|
+
if (h) return h;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A round spark POP at the muzzle — the "shot bursting" punch (sphere via spreadZ). */
|
|
261
|
+
private spawnMuzzleFlash(pos: [number, number, number]): void {
|
|
262
|
+
const f = new Particles3D('Muzzle');
|
|
263
|
+
f.position = [...pos];
|
|
264
|
+
f.preset = 'custom';
|
|
265
|
+
f.emitting = false;
|
|
266
|
+
f.rate = 0;
|
|
267
|
+
f.spreadDeg = 360;
|
|
268
|
+
f.burst = 14;
|
|
269
|
+
f.lifetime = [0.05, 0.16]; // very brief — a flash, not a lingering spray
|
|
270
|
+
f.speed = [50, 190];
|
|
271
|
+
f.gravity = [0, 0];
|
|
272
|
+
f.drag = 6; // sparks brake fast → a tight pop at the muzzle
|
|
273
|
+
f.sizeStart = 30;
|
|
274
|
+
f.sizeEnd = 4;
|
|
275
|
+
f.paletteColors = ['#fff6d0', '#ffd76a', '#ff9a3c'];
|
|
276
|
+
f.colorEnd = '#ff7a1e';
|
|
277
|
+
f.alphaStart = 1;
|
|
278
|
+
f.alphaEnd = 0;
|
|
279
|
+
f.blend = 'add'; // a muzzle flash is light → additive pop
|
|
280
|
+
f.depthTest = false;
|
|
281
|
+
f.renderOrder = 100;
|
|
282
|
+
f.maxParticles = 16;
|
|
283
|
+
this.node.getRoot().addChild(f as unknown as Node);
|
|
284
|
+
f.on('finished', () => f.parent?.removeChild(f as unknown as Node));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private spawnTracer(from: [number, number, number], to: [number, number, number]): void {
|
|
288
|
+
if (this.tracer) this.tracer.parent?.removeChild(this.tracer as unknown as Node);
|
|
289
|
+
const dir = normalize3([to[0] - from[0], to[1] - from[1], to[2] - from[2]]);
|
|
290
|
+
const full = vlen(to[0] - from[0], to[1] - from[1], to[2] - from[2]);
|
|
291
|
+
// A SHORT dash that leaves the muzzle along the aim — NOT a long beam to the target
|
|
292
|
+
// (a long world-space rod lingers like a "stick" when you move). Anchored at the
|
|
293
|
+
// muzzle: centre = from + dir*(seg/2), oriented with the XYZ-order euler (aimEulerXYZ).
|
|
294
|
+
const seg = Math.min(full, TRACER_LEN);
|
|
295
|
+
const t = new MeshInstance3D('Tracer');
|
|
296
|
+
t.mesh = 'box';
|
|
297
|
+
t.size = [0.03, 0.03, Math.max(0.4, seg)];
|
|
298
|
+
t.material = {
|
|
299
|
+
color: '#ffffff',
|
|
300
|
+
emissive: '#ffe08a',
|
|
301
|
+
emissiveIntensity: 5,
|
|
302
|
+
opacity: TRACER_OPACITY,
|
|
303
|
+
};
|
|
304
|
+
t.position = [
|
|
305
|
+
from[0] + dir[0] * (seg / 2),
|
|
306
|
+
from[1] + dir[1] * (seg / 2),
|
|
307
|
+
from[2] + dir[2] * (seg / 2),
|
|
308
|
+
];
|
|
309
|
+
t.rotation = aimEulerXYZ(dir[0], dir[1], dir[2]);
|
|
310
|
+
this.node.getRoot().addChild(t);
|
|
311
|
+
this.tracer = t as unknown as Node3D;
|
|
312
|
+
this.tracerUntil = this.clock + TRACER_MS;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// HudUpdater — writes the live numbers into the HTML HUD overlay (index.html).
|
|
318
|
+
// 3D scenes have no 2D UILayer/Label nodes, so the HUD is plain DOM. All DOM
|
|
319
|
+
// access is guarded: headless runs (verify, runScript) have no `document`, so
|
|
320
|
+
// every write is a no-op and the gameplay logic is unaffected.
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
interface ScoreState {
|
|
324
|
+
score: number;
|
|
325
|
+
}
|
|
326
|
+
interface HealthState {
|
|
327
|
+
current: number;
|
|
328
|
+
max: number;
|
|
329
|
+
}
|
|
330
|
+
interface ShootState {
|
|
331
|
+
ammo: number;
|
|
332
|
+
clip: number;
|
|
333
|
+
reloading: boolean;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const TOTAL_WAVES = 3;
|
|
337
|
+
const hasDom = typeof document !== 'undefined';
|
|
338
|
+
|
|
339
|
+
export class HudUpdater extends Behavior {
|
|
340
|
+
private wave = 1;
|
|
341
|
+
private over = false;
|
|
342
|
+
|
|
343
|
+
private get score(): ScoreState {
|
|
344
|
+
return this.getNode('/root').behavior as unknown as ScoreState;
|
|
345
|
+
}
|
|
346
|
+
private get health(): HealthState {
|
|
347
|
+
return this.getNode('/root/Player').behavior as unknown as HealthState;
|
|
348
|
+
}
|
|
349
|
+
private get shoot(): ShootState {
|
|
350
|
+
return this.getNode('/root/Player/Controller').behavior as unknown as ShootState;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private set(id: string, text: string): void {
|
|
354
|
+
if (!hasDom) return;
|
|
355
|
+
const el = document.getElementById(id);
|
|
356
|
+
if (el) el.textContent = text;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
override onReady(): void {
|
|
360
|
+
this.getNode('/root').on('lost', () => this.show('MISSION FAILED', '#ef476f'));
|
|
361
|
+
this.getNode('/root/Spawner').on('waveStarted', (i) => {
|
|
362
|
+
this.wave = (i as number) + 1;
|
|
363
|
+
});
|
|
364
|
+
this.refresh();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Wired from Spawner.allCleared in the scene connections → WIN. */
|
|
368
|
+
onAllCleared(): void {
|
|
369
|
+
this.show('AREA SECURED', '#ffd166');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
override update(): void {
|
|
373
|
+
this.refresh();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private refresh(): void {
|
|
377
|
+
if (this.over) return;
|
|
378
|
+
const hp = Math.max(0, Math.round(this.health.current));
|
|
379
|
+
this.set('hud-hp', `HP ${hp}`);
|
|
380
|
+
this.set('hud-kills', `Kills ${this.score.score}`);
|
|
381
|
+
this.set('hud-wave', `Wave ${Math.min(this.wave, TOTAL_WAVES)} / ${TOTAL_WAVES}`);
|
|
382
|
+
const s = this.shoot;
|
|
383
|
+
this.set('hud-ammo', s.reloading ? 'Reloading…' : `Ammo ${s.ammo} / ${s.clip}`);
|
|
384
|
+
if (hasDom) {
|
|
385
|
+
const bar = document.getElementById('hud-hp-fill');
|
|
386
|
+
if (bar) {
|
|
387
|
+
const frac = Math.max(0, Math.min(1, hp / (this.health.max || 100)));
|
|
388
|
+
bar.style.width = `${frac * 100}%`;
|
|
389
|
+
bar.style.background = frac > 0.5 ? '#6ee7dc' : frac > 0.25 ? '#ffd166' : '#ef476f';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private show(text: string, color: string): void {
|
|
395
|
+
if (this.over && text === 'MISSION FAILED') return; // don't overwrite a win with a same-frame death
|
|
396
|
+
this.over = true;
|
|
397
|
+
if (!hasDom) return;
|
|
398
|
+
const b = document.getElementById('banner');
|
|
399
|
+
if (b) {
|
|
400
|
+
b.textContent = text;
|
|
401
|
+
b.style.color = color;
|
|
402
|
+
b.style.opacity = '1';
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|