@netforge/template-arena 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/NOTICE +34 -0
- package/README.md +63 -2
- package/client/audio.ts +43 -0
- package/client/decor.ts +22 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +330 -0
- package/client/index.html +230 -0
- package/client/input.ts +233 -0
- package/client/library.ts +146 -0
- package/client/main.ts +544 -0
- package/client/names.ts +43 -0
- package/client/palette.ts +35 -0
- package/client/scene.ts +1075 -0
- package/client/stake.ts +143 -0
- package/llms.txt +164 -0
- package/package.json +54 -3
- package/src/game.ts +117 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +109 -0
package/client/scene.ts
ADDED
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The VOLTYARD scene: three.js (WebGPU with WebGL2 fallback, via the engine
|
|
3
|
+
* runtime). The yard is one floor, one fence and three merged draws for all
|
|
4
|
+
* the transformers; projectiles, heal cells and sparks are instanced; each
|
|
5
|
+
* wirejack is a few meshes. Game feel that lives in
|
|
6
|
+
* the world: muzzle flashes, beams, hit flashes, death bursts, spawn
|
|
7
|
+
* shields, camera lead toward the aim and shake.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
AdditiveBlending,
|
|
11
|
+
AmbientLight,
|
|
12
|
+
BoxGeometry,
|
|
13
|
+
CanvasTexture,
|
|
14
|
+
Color,
|
|
15
|
+
ConeGeometry,
|
|
16
|
+
CylinderGeometry,
|
|
17
|
+
DirectionalLight,
|
|
18
|
+
DoubleSide,
|
|
19
|
+
Fog,
|
|
20
|
+
Group,
|
|
21
|
+
HemisphereLight,
|
|
22
|
+
InstancedMesh,
|
|
23
|
+
Material,
|
|
24
|
+
Mesh,
|
|
25
|
+
MeshBasicMaterial,
|
|
26
|
+
MeshStandardMaterial,
|
|
27
|
+
Plane,
|
|
28
|
+
PlaneGeometry,
|
|
29
|
+
PointLight,
|
|
30
|
+
Raycaster,
|
|
31
|
+
RepeatWrapping,
|
|
32
|
+
RingGeometry,
|
|
33
|
+
Scene,
|
|
34
|
+
SphereGeometry,
|
|
35
|
+
SRGBColorSpace,
|
|
36
|
+
TorusGeometry,
|
|
37
|
+
Vector2,
|
|
38
|
+
Vector3,
|
|
39
|
+
} from "three/webgpu";
|
|
40
|
+
import type { BufferGeometry } from "three/webgpu";
|
|
41
|
+
import { CameraRig, createRenderStack, fieldBounds, HeroHighlight, styleKit } from "@netforge/engine/runtime";
|
|
42
|
+
import type { FeelKit, FieldSpec, RenderStack, RendererHandle, StyleKit } from "@netforge/engine/runtime";
|
|
43
|
+
import { CROWD_UNITS, F, PLAYER_UNITS } from "@netforge/engine/game";
|
|
44
|
+
import type { CompiledGame } from "@netforge/engine/game";
|
|
45
|
+
import { beamRange, BOT_PLAYER_BASE, EntityFlag, EntityKind, slotWeapon } from "@netforge/engine-sim";
|
|
46
|
+
import type { ResolvedSimConfig, WeaponDef } from "@netforge/engine-sim";
|
|
47
|
+
import type { CharacterAnimator, LibraryClient } from "@netforge/asset-library/three";
|
|
48
|
+
import { placeStatic } from "@netforge/asset-library/three";
|
|
49
|
+
import { assetById, restyleId } from "@netforge/asset-library";
|
|
50
|
+
import { BOT_MODEL, HELD_MODEL, PILLAR_MODEL, PLAYER_HEIGHT, YARD_DECOR } from "./decor.js";
|
|
51
|
+
import { fx, merge, resolveAsset } from "./library.js";
|
|
52
|
+
import { finishOf, playerColor } from "./palette.js";
|
|
53
|
+
|
|
54
|
+
/** One entity as the scene sees it (netcode values in the engine wire schema). */
|
|
55
|
+
export interface SceneEntity {
|
|
56
|
+
readonly id: number;
|
|
57
|
+
readonly kind: number;
|
|
58
|
+
readonly owner: number;
|
|
59
|
+
readonly values: readonly number[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FrameInput {
|
|
63
|
+
/** Seconds since the last frame (already scaled by hit-stop). */
|
|
64
|
+
readonly dt: number;
|
|
65
|
+
/** Real seconds since the last frame (shake and punches keep moving during a hit-stop). */
|
|
66
|
+
readonly realDt?: number;
|
|
67
|
+
/** Our own predicted character (drawn ahead of the server). */
|
|
68
|
+
readonly self: SceneEntity | undefined;
|
|
69
|
+
/** Everyone and everything else, interpolated ~100 ms in the past. */
|
|
70
|
+
readonly others: readonly SceneEntity[];
|
|
71
|
+
readonly selfPlayer: number;
|
|
72
|
+
/** Our aim this frame (the input's, ahead of the sim), radians. */
|
|
73
|
+
readonly aim: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FrameEvents {
|
|
77
|
+
/** Opponents hurt soon after our own shots (ours, very likely): position, amount, who and the push direction. */
|
|
78
|
+
hits: { x: number; y: number; z: number; amount: number; id: number; dirX: number; dirZ: number }[];
|
|
79
|
+
/** Opponents that went down soon after we hit them (our kills, very likely). */
|
|
80
|
+
kills: { x: number; z: number; id: number; color: number; dirX: number; dirZ: number }[];
|
|
81
|
+
/** Our character took damage this frame (amount). */
|
|
82
|
+
selfHurt: number;
|
|
83
|
+
/** Our character healed (a cell). */
|
|
84
|
+
selfHealed: boolean;
|
|
85
|
+
/** Our character went down this frame. */
|
|
86
|
+
selfDown: boolean;
|
|
87
|
+
/** Someone else went down this frame (near us). */
|
|
88
|
+
downs: number;
|
|
89
|
+
/** Shots seen this frame. */
|
|
90
|
+
shots: { kind: string; mine: boolean }[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Particle {
|
|
94
|
+
x: number;
|
|
95
|
+
y: number;
|
|
96
|
+
z: number;
|
|
97
|
+
vx: number;
|
|
98
|
+
vy: number;
|
|
99
|
+
vz: number;
|
|
100
|
+
life: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const MAX_BOLTS = 240;
|
|
104
|
+
const MAX_PELLETS = 320;
|
|
105
|
+
const MAX_CELLS = 48;
|
|
106
|
+
const MAX_PARTICLES = 700;
|
|
107
|
+
const BEAM_SECONDS = 0.2;
|
|
108
|
+
const FLASH_SECONDS = 0.06;
|
|
109
|
+
const PLAYER_HITBOX = 0.6;
|
|
110
|
+
/** How much of its own colour a wirejack glows with: enough to read against the dark yard from any distance. */
|
|
111
|
+
const GLOW = 0.55;
|
|
112
|
+
/** Seconds after one of our bolts reaches a wirejack on screen that its damage (and death) count as ours. */
|
|
113
|
+
const OURS_WINDOW = 0.6;
|
|
114
|
+
const MAX_LOCAL_SHOTS = 96;
|
|
115
|
+
/** The camera shows at most this much of the lot past the fence, m. */
|
|
116
|
+
const CAMERA_MARGIN = 5;
|
|
117
|
+
/** Preview camera: fighters this close to one another count as one fight, m. */
|
|
118
|
+
const PREVIEW_RADIUS = 20;
|
|
119
|
+
/** Preview camera distance bounds, m (the game's own is 31), and its pitch (the game's is 1.22). */
|
|
120
|
+
const PREVIEW_MIN_DISTANCE = 20;
|
|
121
|
+
const PREVIEW_MAX_DISTANCE = 48;
|
|
122
|
+
const PREVIEW_PITCH = 0.92;
|
|
123
|
+
/** The world theme a style kit dresses VOLTYARD as (the voxel style's YARD palette). */
|
|
124
|
+
const YARD_THEME = "yard";
|
|
125
|
+
|
|
126
|
+
const pos = (e: SceneEntity): [number, number, number] =>
|
|
127
|
+
e.kind === EntityKind.Player
|
|
128
|
+
? [(e.values[F.px] ?? 0) / PLAYER_UNITS, (e.values[F.py] ?? 0) / PLAYER_UNITS, (e.values[F.pz] ?? 0) / PLAYER_UNITS]
|
|
129
|
+
: [(e.values[F.ex] ?? 0) / CROWD_UNITS, 0, (e.values[F.ez] ?? 0) / CROWD_UNITS];
|
|
130
|
+
|
|
131
|
+
function floorTexture(half: number): CanvasTexture {
|
|
132
|
+
const c = document.createElement("canvas");
|
|
133
|
+
c.width = c.height = 512;
|
|
134
|
+
const g = c.getContext("2d");
|
|
135
|
+
if (!g) throw new Error("no 2d canvas");
|
|
136
|
+
g.fillStyle = "#151b26";
|
|
137
|
+
g.fillRect(0, 0, 512, 512);
|
|
138
|
+
let s = 11;
|
|
139
|
+
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
|
|
140
|
+
for (let i = 0; i < 9000; i++) {
|
|
141
|
+
const v = 20 + Math.floor(rnd() * 22);
|
|
142
|
+
g.fillStyle = `rgb(${v},${v + 4},${v + 12})`;
|
|
143
|
+
g.fillRect(Math.floor(rnd() * 512), Math.floor(rnd() * 512), 2, 2);
|
|
144
|
+
}
|
|
145
|
+
// Panel seams every 4 m.
|
|
146
|
+
const cells = (half * 2) / 4;
|
|
147
|
+
g.strokeStyle = "rgba(120,170,220,0.10)";
|
|
148
|
+
g.lineWidth = 2;
|
|
149
|
+
for (let i = 0; i <= cells; i++) {
|
|
150
|
+
const p = (i / cells) * 512;
|
|
151
|
+
g.beginPath();
|
|
152
|
+
g.moveTo(p, 0);
|
|
153
|
+
g.lineTo(p, 512);
|
|
154
|
+
g.moveTo(0, p);
|
|
155
|
+
g.lineTo(512, p);
|
|
156
|
+
g.stroke();
|
|
157
|
+
}
|
|
158
|
+
// A painted centre circle and lane marks.
|
|
159
|
+
g.strokeStyle = "rgba(22,224,255,0.18)";
|
|
160
|
+
g.lineWidth = 4;
|
|
161
|
+
g.beginPath();
|
|
162
|
+
g.arc(256, 256, 70, 0, Math.PI * 2);
|
|
163
|
+
g.stroke();
|
|
164
|
+
g.strokeStyle = "rgba(255,176,32,0.28)";
|
|
165
|
+
g.setLineDash([18, 14]);
|
|
166
|
+
g.beginPath();
|
|
167
|
+
g.moveTo(256, 20);
|
|
168
|
+
g.lineTo(256, 180);
|
|
169
|
+
g.moveTo(256, 332);
|
|
170
|
+
g.lineTo(256, 492);
|
|
171
|
+
g.moveTo(20, 256);
|
|
172
|
+
g.lineTo(180, 256);
|
|
173
|
+
g.moveTo(332, 256);
|
|
174
|
+
g.lineTo(492, 256);
|
|
175
|
+
g.stroke();
|
|
176
|
+
const t = new CanvasTexture(c);
|
|
177
|
+
t.colorSpace = SRGBColorSpace;
|
|
178
|
+
return t;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Hazard stripes for the fence's top band. */
|
|
182
|
+
function stripeTexture(): CanvasTexture {
|
|
183
|
+
const c = document.createElement("canvas");
|
|
184
|
+
c.width = 64;
|
|
185
|
+
c.height = 16;
|
|
186
|
+
const g = c.getContext("2d");
|
|
187
|
+
if (!g) throw new Error("no 2d canvas");
|
|
188
|
+
g.fillStyle = "#151515";
|
|
189
|
+
g.fillRect(0, 0, 64, 16);
|
|
190
|
+
g.fillStyle = "#ffb020";
|
|
191
|
+
for (let x = -16; x < 64; x += 16) {
|
|
192
|
+
g.beginPath();
|
|
193
|
+
g.moveTo(x, 16);
|
|
194
|
+
g.lineTo(x + 8, 0);
|
|
195
|
+
g.lineTo(x + 16, 0);
|
|
196
|
+
g.lineTo(x + 8, 16);
|
|
197
|
+
g.fill();
|
|
198
|
+
}
|
|
199
|
+
const t = new CanvasTexture(c);
|
|
200
|
+
t.wrapS = RepeatWrapping;
|
|
201
|
+
t.colorSpace = SRGBColorSpace;
|
|
202
|
+
return t;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The lot outside the fence: dark gravel with a few oil stains, repeated `repeat` times across. */
|
|
206
|
+
function gravelTexture(repeat: number): CanvasTexture {
|
|
207
|
+
const c = document.createElement("canvas");
|
|
208
|
+
c.width = c.height = 256;
|
|
209
|
+
const g = c.getContext("2d");
|
|
210
|
+
if (!g) throw new Error("no 2d canvas");
|
|
211
|
+
g.fillStyle = "#1c2028";
|
|
212
|
+
g.fillRect(0, 0, 256, 256);
|
|
213
|
+
let s = 23;
|
|
214
|
+
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
|
|
215
|
+
for (let i = 0; i < 6; i++) {
|
|
216
|
+
g.fillStyle = "rgba(8,10,14,0.35)";
|
|
217
|
+
g.beginPath();
|
|
218
|
+
g.ellipse(rnd() * 256, rnd() * 256, 18 + rnd() * 30, 10 + rnd() * 18, rnd() * 3, 0, Math.PI * 2);
|
|
219
|
+
g.fill();
|
|
220
|
+
}
|
|
221
|
+
for (let i = 0; i < 5000; i++) {
|
|
222
|
+
const v = 22 + Math.floor(rnd() * 30);
|
|
223
|
+
g.fillStyle = `rgb(${v},${v + 2},${v + 6})`;
|
|
224
|
+
g.fillRect(Math.floor(rnd() * 256), Math.floor(rnd() * 256), 2, 2);
|
|
225
|
+
}
|
|
226
|
+
const t = new CanvasTexture(c);
|
|
227
|
+
t.wrapS = t.wrapT = RepeatWrapping;
|
|
228
|
+
t.repeat.set(repeat, repeat);
|
|
229
|
+
t.colorSpace = SRGBColorSpace;
|
|
230
|
+
return t;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class Crowd {
|
|
234
|
+
mesh: InstancedMesh;
|
|
235
|
+
count = 0;
|
|
236
|
+
constructor(geometry: BufferGeometry, material: Material, readonly capacity: number) {
|
|
237
|
+
this.mesh = new InstancedMesh(geometry, material, capacity);
|
|
238
|
+
this.mesh.count = 0;
|
|
239
|
+
this.mesh.frustumCulled = false;
|
|
240
|
+
}
|
|
241
|
+
/** Draw a library model instead of the shape (same capacity and instances). */
|
|
242
|
+
swap(geometry: BufferGeometry, material: Material): void {
|
|
243
|
+
const m = new InstancedMesh(geometry, material, this.capacity);
|
|
244
|
+
m.instanceMatrix = this.mesh.instanceMatrix;
|
|
245
|
+
m.count = this.mesh.count;
|
|
246
|
+
m.frustumCulled = false;
|
|
247
|
+
this.mesh = m;
|
|
248
|
+
}
|
|
249
|
+
begin(): void {
|
|
250
|
+
this.count = 0;
|
|
251
|
+
}
|
|
252
|
+
push(x: number, y: number, z: number, yaw: number, scale: number): void {
|
|
253
|
+
if (this.count >= this.capacity) return;
|
|
254
|
+
const m = this.mesh.instanceMatrix.array as Float32Array;
|
|
255
|
+
const o = this.count * 16;
|
|
256
|
+
const c = Math.cos(yaw) * scale;
|
|
257
|
+
const s = Math.sin(yaw) * scale;
|
|
258
|
+
m[o] = c;
|
|
259
|
+
m[o + 1] = 0;
|
|
260
|
+
m[o + 2] = -s;
|
|
261
|
+
m[o + 3] = 0;
|
|
262
|
+
m[o + 4] = 0;
|
|
263
|
+
m[o + 5] = scale;
|
|
264
|
+
m[o + 6] = 0;
|
|
265
|
+
m[o + 7] = 0;
|
|
266
|
+
m[o + 8] = s;
|
|
267
|
+
m[o + 9] = 0;
|
|
268
|
+
m[o + 10] = c;
|
|
269
|
+
m[o + 11] = 0;
|
|
270
|
+
m[o + 12] = x;
|
|
271
|
+
m[o + 13] = y;
|
|
272
|
+
m[o + 14] = z;
|
|
273
|
+
m[o + 15] = 1;
|
|
274
|
+
this.count++;
|
|
275
|
+
}
|
|
276
|
+
end(): void {
|
|
277
|
+
this.mesh.count = this.count;
|
|
278
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface PlayerMeshes {
|
|
283
|
+
root: Group;
|
|
284
|
+
/** Turns with the aim: the fallback figure until the library character loads, then the character and its blaster. */
|
|
285
|
+
body: Group;
|
|
286
|
+
/** Materials the hit flash drives. */
|
|
287
|
+
flashMats: MeshStandardMaterial[];
|
|
288
|
+
animator: CharacterAnimator | null;
|
|
289
|
+
lastX: number;
|
|
290
|
+
lastZ: number;
|
|
291
|
+
ring: Mesh;
|
|
292
|
+
shield: Mesh;
|
|
293
|
+
finish: number;
|
|
294
|
+
lastWcd: number;
|
|
295
|
+
lastHp: number;
|
|
296
|
+
dead: boolean;
|
|
297
|
+
flash: number;
|
|
298
|
+
/** Last time (scene seconds) this wirejack took damage soon after one of our shots. */
|
|
299
|
+
hitByUs: number;
|
|
300
|
+
/** The colour it is drawn in. */
|
|
301
|
+
color: number;
|
|
302
|
+
/** Outline and a rim in its own colour (engine HeroHighlight), once the library character loads. */
|
|
303
|
+
highlight: HeroHighlight | null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
interface Beam {
|
|
307
|
+
mesh: Mesh;
|
|
308
|
+
t: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export class ArenaScene {
|
|
312
|
+
readonly scene = new Scene();
|
|
313
|
+
readonly rig: CameraRig;
|
|
314
|
+
private readonly bolts: Crowd;
|
|
315
|
+
private readonly pellets: Crowd;
|
|
316
|
+
private readonly cells: Crowd[] = [];
|
|
317
|
+
private readonly sparks: Crowd;
|
|
318
|
+
private readonly particles: Particle[] = [];
|
|
319
|
+
private readonly players = new Map<number, PlayerMeshes>();
|
|
320
|
+
private readonly beams: Beam[] = [];
|
|
321
|
+
private readonly flashes: { mesh: Mesh; t: number }[] = [];
|
|
322
|
+
/** Our own blaster bolts, drawn the frame we fire. */
|
|
323
|
+
private readonly localShots: { x: number; z: number; dx: number; dz: number; yaw: number; speed: number; left: number; pellet: boolean }[] = [];
|
|
324
|
+
private prevPos = new Map<number, [number, number, number]>();
|
|
325
|
+
private readonly focus = new Vector3();
|
|
326
|
+
private readonly aimLead = new Vector3();
|
|
327
|
+
/** Preview camera (?preview=1, the Launcher's style previews): frame the busiest fight, not our own fighter. */
|
|
328
|
+
private preview: { distance: number } | null = null;
|
|
329
|
+
/** Standing fighters in the fight the preview camera frames (0 outside preview mode). */
|
|
330
|
+
previewFight = 0;
|
|
331
|
+
private time = 0;
|
|
332
|
+
private lastShotAt = -10;
|
|
333
|
+
/** When our own bolts or beams last reached each wirejack as drawn (owner to scene seconds): hits and kills we can claim. */
|
|
334
|
+
private readonly ourHitAt = new Map<number, number>();
|
|
335
|
+
/** The wirejack the last beamLength call stopped at, or -1. */
|
|
336
|
+
private beamHit = -1;
|
|
337
|
+
private readonly weapons: readonly WeaponDef[];
|
|
338
|
+
private readonly raycaster = new Raycaster();
|
|
339
|
+
private readonly ground = new Plane(new Vector3(0, 1, 0), -1);
|
|
340
|
+
/** Effects fired so far (playtest checks read these). */
|
|
341
|
+
readonly counts = { beams: 0, flashes: 0, bursts: 0, shots: 0 };
|
|
342
|
+
/** The art style drawn (the game's declared style, or ?style=). */
|
|
343
|
+
readonly style: string;
|
|
344
|
+
/** That style's kit, or null: the template's own low-poly drawing (no render stack, the scene's own lights and fog). */
|
|
345
|
+
private readonly kit: StyleKit | null;
|
|
346
|
+
/** The engine render stack, only when a style kit draws the frame. */
|
|
347
|
+
private readonly stack: RenderStack | null = null;
|
|
348
|
+
/** The field as the style's kit drew it (the voxel yard), or null. */
|
|
349
|
+
private readonly field: FieldSpec | null = null;
|
|
350
|
+
|
|
351
|
+
constructor(
|
|
352
|
+
private readonly handle: RendererHandle,
|
|
353
|
+
private readonly game: CompiledGame,
|
|
354
|
+
private readonly config: ResolvedSimConfig,
|
|
355
|
+
/** The asset library (real models); null draws the fallback shapes only. */
|
|
356
|
+
private readonly library: LibraryClient | null = null,
|
|
357
|
+
/** The engine feel kit: hit flash and knockback offsets on the wirejacks. */
|
|
358
|
+
private readonly feel: FeelKit | null = null,
|
|
359
|
+
opts: { style?: string } = {},
|
|
360
|
+
) {
|
|
361
|
+
this.weapons = config.content.weapons;
|
|
362
|
+
const s = this.scene;
|
|
363
|
+
this.rig = new CameraRig(1, { distance: 31, height: 0.5, follow: 9, lead: 0.12, fov: 42, minPitch: 0.9, maxPitch: 1.4 });
|
|
364
|
+
this.style = opts.style ?? game.manifest.game.style;
|
|
365
|
+
this.kit = styleKit(this.style);
|
|
366
|
+
const half = config.arena.halfExtent;
|
|
367
|
+
if (this.kit) {
|
|
368
|
+
// An art style draws the frame through the engine render stack: its lighting, fog, grade, particles, post and session.
|
|
369
|
+
this.stack = createRenderStack(handle, s, this.rig.camera, {
|
|
370
|
+
preset: "night",
|
|
371
|
+
style: this.style,
|
|
372
|
+
quality: "auto",
|
|
373
|
+
shadowExtent: 30,
|
|
374
|
+
// The camera sits ~31-44 m out: fog starts past the fence and swallows the lot, as the yard's own fog does.
|
|
375
|
+
atmosphere: { heightFog: 0, near: 40, far: 95 },
|
|
376
|
+
// Library id to URL, for a style's own textures (ps1's surfaces).
|
|
377
|
+
resolve: (id) => {
|
|
378
|
+
try {
|
|
379
|
+
return library?.url(id) ?? null;
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
if (this.kit.stack?.("night").shadows !== true) this.stack.lighting.setShadowMapSize(0);
|
|
386
|
+
if (feel) feel.particles = this.stack.particles;
|
|
387
|
+
// A kit that draws fields (voxel) builds the yard: its floor, the lot past the fence and the fence.
|
|
388
|
+
if (this.kit.field) {
|
|
389
|
+
this.field = { half, wallHeight: config.arena.wallHeight, outside: 40, theme: YARD_THEME, pillars: config.arena.pillars.map((p) => ({ x: p.x, z: p.z, radius: p.radius, height: p.height })) };
|
|
390
|
+
s.add(...this.kit.field(this.field));
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
const sky = new Color(0x0b1020);
|
|
394
|
+
s.background = sky;
|
|
395
|
+
// Fog swallows the ground well past the fence, so the yard never ends in a hard edge.
|
|
396
|
+
s.fog = new Fog(sky, 34, 78);
|
|
397
|
+
s.add(new HemisphereLight(0x8fb4ff, 0x1a1408, 1.3));
|
|
398
|
+
s.add(new AmbientLight(0xffffff, 0.3));
|
|
399
|
+
const moon = new DirectionalLight(0xcfe0ff, 1.5);
|
|
400
|
+
moon.position.set(-18, 40, 12);
|
|
401
|
+
s.add(moon);
|
|
402
|
+
}
|
|
403
|
+
const styledField = this.field !== null;
|
|
404
|
+
const session = this.stack?.session ?? null;
|
|
405
|
+
|
|
406
|
+
// The style may paint the floor and the lot (hand-drawn); its texture says how many metres one tile covers.
|
|
407
|
+
const floorMap = session?.surface?.("floor") ?? null;
|
|
408
|
+
if (floorMap) {
|
|
409
|
+
const tile = (floorMap.userData as { tile?: number }).tile ?? 8;
|
|
410
|
+
floorMap.repeat.set((half * 2) / tile, (half * 2) / tile);
|
|
411
|
+
}
|
|
412
|
+
const floor = new Mesh(new PlaneGeometry(half * 2, half * 2), new MeshStandardMaterial({ map: floorMap ?? floorTexture(half), roughness: 0.95, metalness: 0.05 }));
|
|
413
|
+
floor.rotation.x = -Math.PI / 2;
|
|
414
|
+
// Surface roles: an art style that textures surfaces (ps1) dresses these with its own maps.
|
|
415
|
+
floor.userData.surface = "plate";
|
|
416
|
+
if (!styledField) s.add(floor);
|
|
417
|
+
// Outside the fence: a gravel lot the parked trucks stand on, lit and fading into the fog.
|
|
418
|
+
const lot = half * 2 + 160;
|
|
419
|
+
const lotMap = session?.surface?.("ground") ?? null;
|
|
420
|
+
if (lotMap) {
|
|
421
|
+
const tile = (lotMap.userData as { tile?: number }).tile ?? 9.4;
|
|
422
|
+
lotMap.repeat.set(lot / tile, lot / tile);
|
|
423
|
+
}
|
|
424
|
+
const apron = new Mesh(new PlaneGeometry(lot, lot), new MeshStandardMaterial({ map: lotMap ?? gravelTexture(lot / 6), color: lotMap ? 0xffffff : 0x9aa3b4, roughness: 1, metalness: 0 }));
|
|
425
|
+
apron.rotation.x = -Math.PI / 2;
|
|
426
|
+
apron.position.y = -0.02;
|
|
427
|
+
apron.userData.surface = "gravel";
|
|
428
|
+
if (!styledField) s.add(apron);
|
|
429
|
+
// Yard lamps on the four corners outside the fence: warm pools on the lot.
|
|
430
|
+
const poleMat = new MeshStandardMaterial({ color: 0x2a303c, roughness: 0.7, metalness: 0.4 });
|
|
431
|
+
const headMat = new MeshBasicMaterial({ color: 0xffd9a0 });
|
|
432
|
+
for (const [cx, cz] of [
|
|
433
|
+
[1, 1],
|
|
434
|
+
[-1, 1],
|
|
435
|
+
[1, -1],
|
|
436
|
+
[-1, -1],
|
|
437
|
+
] as const) {
|
|
438
|
+
const x = cx * (half + 3);
|
|
439
|
+
const z = cz * (half + 3);
|
|
440
|
+
const gy = this.groundAt(x, z);
|
|
441
|
+
const pole = new Mesh(new CylinderGeometry(0.12, 0.16, 6, 6), poleMat);
|
|
442
|
+
pole.position.set(x, gy + 3, z);
|
|
443
|
+
pole.userData.surface = "metal";
|
|
444
|
+
const head = new Mesh(new BoxGeometry(0.9, 0.18, 0.4), headMat);
|
|
445
|
+
head.position.set(x - cx * 0.4, gy + 6, z - cz * 0.4);
|
|
446
|
+
s.add(pole, head);
|
|
447
|
+
const l = new PointLight(0xffc98a, 40, 22, 1.4);
|
|
448
|
+
l.position.set(x - cx * 1.5, gy + 5.5, z - cz * 1.5);
|
|
449
|
+
s.add(l);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The fence: a low dark barrier (the sim's wall is taller; nobody jumps) with a hazard strip on its inner face.
|
|
453
|
+
const h = 1.2;
|
|
454
|
+
const panelMat = new MeshStandardMaterial({ color: 0x1d2433, roughness: 0.8, metalness: 0.3, flatShading: true });
|
|
455
|
+
const stripes = stripeTexture();
|
|
456
|
+
for (const [x, z, w, d, ix, iz] of [
|
|
457
|
+
[0, half + 0.3, half * 2 + 1.2, 0.6, 0, -1],
|
|
458
|
+
[0, -half - 0.3, half * 2 + 1.2, 0.6, 0, 1],
|
|
459
|
+
[half + 0.3, 0, 0.6, half * 2 + 1.2, -1, 0],
|
|
460
|
+
[-half - 0.3, 0, 0.6, half * 2 + 1.2, 1, 0],
|
|
461
|
+
] as const) {
|
|
462
|
+
// A style that draws its own field (voxel) builds its own fence.
|
|
463
|
+
if (styledField) continue;
|
|
464
|
+
const m = new Mesh(new BoxGeometry(w, h, d), panelMat);
|
|
465
|
+
m.position.set(x, h / 2, z);
|
|
466
|
+
m.userData.surface = "metal";
|
|
467
|
+
s.add(m);
|
|
468
|
+
const along = Math.max(w, d);
|
|
469
|
+
const map = stripes.clone();
|
|
470
|
+
map.repeat.set(along / 1.5, 1);
|
|
471
|
+
map.needsUpdate = true;
|
|
472
|
+
const band = new Mesh(new PlaneGeometry(along, 0.4), new MeshStandardMaterial({ map, emissive: new Color(0x3a2400), roughness: 0.6 }));
|
|
473
|
+
band.position.set(x + ix * (w / 2 + 0.01), h - 0.3, z + iz * (d / 2 + 0.01));
|
|
474
|
+
band.rotation.y = Math.atan2(ix, iz);
|
|
475
|
+
band.userData.surface = "hazard";
|
|
476
|
+
s.add(band);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Transformers: bodies, glowing coils and caps, each a single merged draw.
|
|
480
|
+
const bodies: BufferGeometry[] = [];
|
|
481
|
+
const coils: BufferGeometry[] = [];
|
|
482
|
+
const caps: BufferGeometry[] = [];
|
|
483
|
+
for (const p of config.arena.pillars) {
|
|
484
|
+
const body = new CylinderGeometry(p.radius * 0.92, p.radius, p.height, 10);
|
|
485
|
+
body.translate(p.x, p.height / 2, p.z);
|
|
486
|
+
bodies.push(body);
|
|
487
|
+
const base = new BoxGeometry(p.radius * 2.3, 0.3, p.radius * 2.3);
|
|
488
|
+
base.translate(p.x, 0.15, p.z);
|
|
489
|
+
bodies.push(base);
|
|
490
|
+
// One low coil band per transformer: a hint of charge, never brighter than a wirejack.
|
|
491
|
+
const ring = new TorusGeometry(p.radius * 0.97, 0.04 + p.radius * 0.01, 6, 28);
|
|
492
|
+
ring.rotateX(Math.PI / 2);
|
|
493
|
+
ring.translate(p.x, 0.55, p.z);
|
|
494
|
+
coils.push(ring);
|
|
495
|
+
for (let i = 0; i < 3; i++) {
|
|
496
|
+
const a = (i / 3) * Math.PI * 2;
|
|
497
|
+
const ins = new ConeGeometry(0.16, 0.7, 6);
|
|
498
|
+
ins.translate(p.x + Math.cos(a) * p.radius * 0.5, p.height + 0.35, p.z + Math.sin(a) * p.radius * 0.5);
|
|
499
|
+
caps.push(ins);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const shapes: Mesh[] = [];
|
|
503
|
+
if (bodies.length > 0) {
|
|
504
|
+
const kitPillars = styledField && this.kit?.pillar ? this.kit.pillar : null;
|
|
505
|
+
if (kitPillars) {
|
|
506
|
+
// The style's own transformer posts (voxel blocks); the coils still glow around them.
|
|
507
|
+
config.arena.pillars.forEach((p, i) => {
|
|
508
|
+
const drawn = kitPillars({ x: p.x, z: p.z, radius: p.radius, height: p.height }, YARD_THEME, i);
|
|
509
|
+
s.add(drawn.body, ...drawn.top);
|
|
510
|
+
});
|
|
511
|
+
} else {
|
|
512
|
+
const body = new Mesh(merge(bodies), new MeshStandardMaterial({ color: 0x2c3340, roughness: 0.6, metalness: 0.5, flatShading: true }));
|
|
513
|
+
body.userData.surface = "metal";
|
|
514
|
+
const cap = new Mesh(merge(caps), new MeshStandardMaterial({ color: 0x7d786e, roughness: 0.5, flatShading: true }));
|
|
515
|
+
cap.userData.surface = "stone";
|
|
516
|
+
shapes.push(body, cap);
|
|
517
|
+
s.add(...shapes);
|
|
518
|
+
}
|
|
519
|
+
s.add(new Mesh(merge(coils), new MeshStandardMaterial({ color: 0x2b3f48, emissive: new Color(0x0e6878), emissiveIntensity: 0.5, roughness: 0.4 })));
|
|
520
|
+
}
|
|
521
|
+
if (library) this.dress(library, config, shapes, styledField);
|
|
522
|
+
// A few lights: the centre and two flanks.
|
|
523
|
+
for (const [x, z, color] of [
|
|
524
|
+
[0, 0, 0x16e0ff],
|
|
525
|
+
[half * 0.6, -half * 0.6, 0xff4fd8],
|
|
526
|
+
[-half * 0.6, half * 0.6, 0xff4fd8],
|
|
527
|
+
] as const) {
|
|
528
|
+
// Under a style's own lighting rig the coloured pools are a tint, not the only light.
|
|
529
|
+
const l = new PointLight(color, this.stack ? 28 : 60, 26, 1.6);
|
|
530
|
+
l.position.set(x, 5, z);
|
|
531
|
+
s.add(l);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Instanced projectiles, cells and sparks.
|
|
535
|
+
// An art style may redraw the machinery shapes (voxel bolts and shards); the rest keep the template's own.
|
|
536
|
+
const styled = (name: "bolt" | "pellet" | "shard") => this.kit?.shape?.(name) ?? null;
|
|
537
|
+
const b = styled("bolt") ?? fx.bolt();
|
|
538
|
+
this.bolts = new Crowd(b.geometry, b.material, MAX_BOLTS);
|
|
539
|
+
const p = styled("pellet") ?? fx.pellet();
|
|
540
|
+
this.pellets = new Crowd(p.geometry, p.material, MAX_PELLETS);
|
|
541
|
+
const shard = styled("shard");
|
|
542
|
+
this.sparks = new Crowd(shard?.geometry ?? fx.shard(), shard?.material ?? new MeshBasicMaterial({ color: 0xbff8ff }), MAX_PARTICLES);
|
|
543
|
+
for (const id of game.pickupPrefabs) {
|
|
544
|
+
const a = resolveAsset(this.model(game.manifest.prefabs.get(id)?.model ?? "style.lowpoly.missing"));
|
|
545
|
+
const crowd = new Crowd(a.geometry, a.material, MAX_CELLS);
|
|
546
|
+
this.cells.push(crowd);
|
|
547
|
+
s.add(crowd.mesh);
|
|
548
|
+
// A file-backed library pickup (a gem, a coin, food) replaces the shape once it loads.
|
|
549
|
+
const p = game.manifest.prefabs.get(id);
|
|
550
|
+
const model = p ? this.model(p.model) : "";
|
|
551
|
+
const asset = p ? assetById(model) : undefined;
|
|
552
|
+
// A style kit draws its own procedural pickups (voxel gems); low-poly keeps the template's shapes.
|
|
553
|
+
if (library && p && asset && (!("procedural" in asset.source) || this.kit)) {
|
|
554
|
+
library.crowd(model, p.appearance, { height: 0.6 }).then(
|
|
555
|
+
(c) => {
|
|
556
|
+
const part = c.parts[0];
|
|
557
|
+
if (!part) return;
|
|
558
|
+
s.remove(crowd.mesh);
|
|
559
|
+
crowd.swap(part.geometry, part.material);
|
|
560
|
+
s.add(crowd.mesh);
|
|
561
|
+
},
|
|
562
|
+
() => undefined,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
s.add(this.bolts.mesh, this.pellets.mesh, this.sparks.mesh);
|
|
567
|
+
|
|
568
|
+
const beamGeo = fx.beam();
|
|
569
|
+
for (let i = 0; i < 16; i++) {
|
|
570
|
+
const mesh = new Mesh(beamGeo, new MeshBasicMaterial({ color: 0xff7ef0, transparent: true, opacity: 0, depthWrite: false, blending: AdditiveBlending }));
|
|
571
|
+
mesh.visible = false;
|
|
572
|
+
s.add(mesh);
|
|
573
|
+
this.beams.push({ mesh, t: 1 });
|
|
574
|
+
}
|
|
575
|
+
const flashGeo = new SphereGeometry(0.28, 8, 6);
|
|
576
|
+
for (let i = 0; i < 16; i++) {
|
|
577
|
+
const mesh = new Mesh(flashGeo, new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, depthWrite: false, blending: AdditiveBlending }));
|
|
578
|
+
mesh.visible = false;
|
|
579
|
+
s.add(mesh);
|
|
580
|
+
this.flashes.push({ mesh, t: 1 });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// The camera never shows more than CAMERA_MARGIN m of the lot past the fence.
|
|
584
|
+
this.rig.setBounds(fieldBounds(half, CAMERA_MARGIN));
|
|
585
|
+
this.rig.pitch = 1.22;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Library dressing (template machinery): each transformer body becomes a
|
|
590
|
+
* generator machine inside its glowing coils, and parked trucks, vans and
|
|
591
|
+
* barrels line the yard outside the fence.
|
|
592
|
+
*/
|
|
593
|
+
private dress(library: LibraryClient, config: ResolvedSimConfig, shapes: readonly Mesh[], styledField: boolean): void {
|
|
594
|
+
const s = this.scene;
|
|
595
|
+
const pillars = config.arena.pillars;
|
|
596
|
+
// A style that builds its own field (voxel) draws its own transformer posts.
|
|
597
|
+
if (!styledField) library.crowd(this.model(PILLAR_MODEL), undefined, { height: 1 }).then(
|
|
598
|
+
(c) => {
|
|
599
|
+
let half = 0.1;
|
|
600
|
+
for (const p of c.parts) {
|
|
601
|
+
p.geometry.computeBoundingBox();
|
|
602
|
+
const b = p.geometry.boundingBox;
|
|
603
|
+
if (b) half = Math.max(half, (b.max.x - b.min.x) / 2, (b.max.z - b.min.z) / 2);
|
|
604
|
+
}
|
|
605
|
+
const placed = placeStatic(c, pillars.map((p, i) => ({ x: p.x, z: p.z, yaw: (i % 4) * (Math.PI / 2), across: (p.radius * 0.95) / half, up: p.height })));
|
|
606
|
+
// Scenery stays quiet: the machines are drawn dark and grey so the wirejacks are the loudest colour on screen.
|
|
607
|
+
const grey = new Color(0x4a505c);
|
|
608
|
+
for (const o of placed) {
|
|
609
|
+
o.traverse((n) => {
|
|
610
|
+
const mesh = n as Mesh;
|
|
611
|
+
const src = mesh.material as MeshStandardMaterial | undefined;
|
|
612
|
+
if (!mesh.isMesh || !src || Array.isArray(src) || !src.color) return;
|
|
613
|
+
// A copy: library materials may be shared with other models.
|
|
614
|
+
const m = src.clone();
|
|
615
|
+
m.color.lerp(grey, 0.8).multiplyScalar(0.65);
|
|
616
|
+
if (m.emissive) m.emissive.multiplyScalar(0.3);
|
|
617
|
+
mesh.material = m;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
s.add(...placed);
|
|
621
|
+
for (const m of shapes) m.visible = false;
|
|
622
|
+
},
|
|
623
|
+
() => undefined,
|
|
624
|
+
);
|
|
625
|
+
const half = config.arena.halfExtent;
|
|
626
|
+
YARD_DECOR.forEach(([id, height, count], k) => {
|
|
627
|
+
library.crowd(this.model(id), undefined, { height }).then(
|
|
628
|
+
(c) => {
|
|
629
|
+
const places = Array.from({ length: count }, (_, i) => {
|
|
630
|
+
const side = (i + k) % 4;
|
|
631
|
+
const along = ((i * 7 + k * 3) % 11) / 10 - 0.5;
|
|
632
|
+
const out = half + 3 + ((i * 5 + k) % 4) * 1.6;
|
|
633
|
+
const pos = side === 0 ? [along * half * 1.8, out] : side === 1 ? [along * half * 1.8, -out] : side === 2 ? [out, along * half * 1.8] : [-out, along * half * 1.8];
|
|
634
|
+
const x = pos[0] ?? 0;
|
|
635
|
+
const z = pos[1] ?? 0;
|
|
636
|
+
return { x, y: this.groundAt(x, z), z, yaw: side * (Math.PI / 2) + (i % 2) * Math.PI };
|
|
637
|
+
});
|
|
638
|
+
s.add(...placeStatic(c, places));
|
|
639
|
+
},
|
|
640
|
+
() => undefined,
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** A library id in the style drawn: "style.lowpoly.van" is "style.voxel.van" in the voxel style. */
|
|
646
|
+
private model(id: string): string {
|
|
647
|
+
return restyleId(id, this.style);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Height of the style's ground at (x, z) (the voxel yard is terraced past the fence); 0 on the template's flat lot. */
|
|
651
|
+
private groundAt(x: number, z: number): number {
|
|
652
|
+
return this.field && this.kit?.groundHeight ? this.kit.groundHeight(this.field, x, z) : 0;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
resize(width: number, height: number): void {
|
|
656
|
+
if (this.stack) this.stack.resize(width, height);
|
|
657
|
+
else this.handle.renderer.setSize(width, height, false);
|
|
658
|
+
this.rig.resize(width / height);
|
|
659
|
+
this.rig.setDistance(width < height ? 44 : 31);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
shake(amplitude: number, seconds: number): void {
|
|
663
|
+
this.rig.shake(amplitude, seconds);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** World position → CSS pixels on the canvas (name tags, damage numbers). */
|
|
667
|
+
project(x: number, y: number, z: number, width: number, height: number): { x: number; y: number; onScreen: boolean } {
|
|
668
|
+
const v = new Vector3(x, y, z).project(this.rig.camera);
|
|
669
|
+
return { x: (v.x * 0.5 + 0.5) * width, y: (-v.y * 0.5 + 0.5) * height, onScreen: v.z < 1 && Math.abs(v.x) <= 1 && Math.abs(v.y) <= 1 };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** A screen point (CSS px) on the gun-height plane, or null. */
|
|
673
|
+
groundPoint(clientX: number, clientY: number, width: number, height: number): { x: number; z: number } | null {
|
|
674
|
+
const ndc = new Vector2((clientX / width) * 2 - 1, -(clientY / height) * 2 + 1);
|
|
675
|
+
this.raycaster.setFromCamera(ndc, this.rig.camera);
|
|
676
|
+
const hit = new Vector3();
|
|
677
|
+
return this.raycaster.ray.intersectPlane(this.ground, hit) ? { x: hit.x, z: hit.z } : null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private burst(x: number, y: number, z: number, n: number): void {
|
|
681
|
+
this.counts.bursts++;
|
|
682
|
+
for (let i = 0; i < n && this.particles.length < MAX_PARTICLES; i++) {
|
|
683
|
+
const a = Math.random() * Math.PI * 2;
|
|
684
|
+
const sp = 3 + Math.random() * 6;
|
|
685
|
+
this.particles.push({ x, y: y + 0.8, z, vx: Math.cos(a) * sp, vy: 2 + Math.random() * 5, vz: Math.sin(a) * sp, life: 0.4 + Math.random() * 0.4 });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private flash(x: number, y: number, z: number, color: number): void {
|
|
690
|
+
const f = this.flashes.find((q) => q.t >= 1) ?? this.flashes[0];
|
|
691
|
+
if (!f) return;
|
|
692
|
+
this.counts.flashes++;
|
|
693
|
+
f.t = 0;
|
|
694
|
+
f.mesh.position.set(x, y, z);
|
|
695
|
+
(f.mesh.material as MeshBasicMaterial).color.setHex(color);
|
|
696
|
+
f.mesh.visible = true;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private beam(x: number, z: number, yaw: number, length: number, color: number): void {
|
|
700
|
+
const b = this.beams.find((q) => q.t >= 1) ?? this.beams[0];
|
|
701
|
+
if (!b) return;
|
|
702
|
+
this.counts.beams++;
|
|
703
|
+
b.t = 0;
|
|
704
|
+
b.mesh.position.set(x, 1.0, z);
|
|
705
|
+
b.mesh.rotation.set(0, yaw, 0);
|
|
706
|
+
b.mesh.scale.set(1, 1, Math.max(0.1, length));
|
|
707
|
+
(b.mesh.material as MeshBasicMaterial).color.setHex(color);
|
|
708
|
+
b.mesh.visible = true;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private player(owner: number, finish: number): PlayerMeshes {
|
|
712
|
+
let p = this.players.get(owner);
|
|
713
|
+
if (p && p.finish === finish) return p;
|
|
714
|
+
if (p) {
|
|
715
|
+
this.scene.remove(p.root);
|
|
716
|
+
p.highlight?.dispose();
|
|
717
|
+
this.players.delete(owner);
|
|
718
|
+
}
|
|
719
|
+
const look = finishOf(finish, playerColor(owner));
|
|
720
|
+
const bot = owner >= BOT_PLAYER_BASE;
|
|
721
|
+
const players = this.game.manifest.game.players;
|
|
722
|
+
const model = this.model(bot ? BOT_MODEL : players.prefab);
|
|
723
|
+
const a = resolveAsset(model);
|
|
724
|
+
const shapeMat = new MeshStandardMaterial({ color: look.body, roughness: look.roughness, metalness: look.metalness, flatShading: true, emissive: new Color(look.glow).multiplyScalar(GLOW) });
|
|
725
|
+
const shape = new Mesh(a.geometry, shapeMat);
|
|
726
|
+
shape.scale.setScalar(1.3);
|
|
727
|
+
const body = new Group();
|
|
728
|
+
body.add(shape);
|
|
729
|
+
// A bright base ring in the wirejack's colour: the one thing on the floor that marks a fighter.
|
|
730
|
+
const ring = new Mesh(new RingGeometry(0.58, 0.92, 32), new MeshBasicMaterial({ color: look.glow, transparent: true, opacity: 0.9, side: DoubleSide, depthWrite: false, blending: AdditiveBlending }));
|
|
731
|
+
ring.rotation.x = -Math.PI / 2;
|
|
732
|
+
ring.position.y = 0.04;
|
|
733
|
+
const shield = new Mesh(new SphereGeometry(1.0, 16, 12), new MeshBasicMaterial({ color: 0x9ff6ff, transparent: true, opacity: 0.18, depthWrite: false, blending: AdditiveBlending }));
|
|
734
|
+
shield.position.y = 0.9;
|
|
735
|
+
const root = new Group();
|
|
736
|
+
root.add(body, ring, shield);
|
|
737
|
+
this.scene.add(root);
|
|
738
|
+
const made: PlayerMeshes = { root, body, flashMats: [shapeMat], animator: null, lastX: 0, lastZ: 0, ring, shield, finish, lastWcd: 0, lastHp: -1, dead: false, flash: 0, hitByUs: -10, color: look.glow, highlight: null };
|
|
739
|
+
p = made;
|
|
740
|
+
this.players.set(owner, p);
|
|
741
|
+
// The library character in this player's colour (or finish), carrying a library blaster.
|
|
742
|
+
const library = this.library;
|
|
743
|
+
if (library) {
|
|
744
|
+
const tint = `#${look.body.toString(16).padStart(6, "0")}`;
|
|
745
|
+
const l = bot ? { tint, parts: [] } : { ...players.appearance, parts: players.appearance?.parts ?? [], tint: players.appearance?.tint && finish === 0 ? players.appearance.tint : tint };
|
|
746
|
+
Promise.all([library.character(model, l, { height: PLAYER_HEIGHT }), library.prop(this.model(HELD_MODEL), undefined, { height: 0.42 })]).then(
|
|
747
|
+
([c, gun]) => {
|
|
748
|
+
if (this.players.get(owner) !== made) return;
|
|
749
|
+
body.remove(shape);
|
|
750
|
+
// Held at the right hand in the shooting pose, pointing along the aim (-z).
|
|
751
|
+
gun.position.set(c.width * 0.32, c.height * 0.5, -c.depth * 0.75);
|
|
752
|
+
c.root.add(gun);
|
|
753
|
+
body.add(c.root);
|
|
754
|
+
const mats: MeshStandardMaterial[] = [];
|
|
755
|
+
c.root.traverse((o) => {
|
|
756
|
+
const m = (o as Mesh).material as MeshStandardMaterial | undefined;
|
|
757
|
+
if (m && (o as Mesh).isMesh && m.emissive && !mats.includes(m)) {
|
|
758
|
+
m.emissive.setHex(look.glow).multiplyScalar(GLOW);
|
|
759
|
+
m.metalness = Math.max(m.metalness ?? 0, look.metalness * 0.6);
|
|
760
|
+
mats.push(m);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
made.flashMats = mats;
|
|
764
|
+
made.animator = c.animator;
|
|
765
|
+
// Every wirejack reads off the yard: a dark outline and a rim in its own colour.
|
|
766
|
+
made.highlight = new HeroHighlight(c.root, { rim: look.glow, rimStrength: 1.3, rimPower: 2.4, outline: 0x05070c, outlineWidth: 0.045 });
|
|
767
|
+
},
|
|
768
|
+
(e: unknown) => console.warn(`library: ${model} stays a shape (${e instanceof Error ? e.message : String(e)})`),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
return p;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The beam's visible reach from (x, z) along yaw: walls, pillars, then the first player in the way. */
|
|
775
|
+
private beamLength(x: number, z: number, yaw: number, range: number, shooter: number, drawn: readonly SceneEntity[]): number {
|
|
776
|
+
const dx = -Math.sin(yaw);
|
|
777
|
+
const dz = -Math.cos(yaw);
|
|
778
|
+
this.beamHit = -1;
|
|
779
|
+
let len = beamRange(this.config, x, z, dx, dz, range);
|
|
780
|
+
for (const e of drawn) {
|
|
781
|
+
if (e.kind !== EntityKind.Player || e.owner === shooter || ((e.values[F.flags] ?? 0) & EntityFlag.Dead) !== 0) continue;
|
|
782
|
+
const [px, , pz] = pos(e);
|
|
783
|
+
const mx = x - px;
|
|
784
|
+
const mz = z - pz;
|
|
785
|
+
const b = mx * dx + mz * dz;
|
|
786
|
+
const c = mx * mx + mz * mz - PLAYER_HITBOX * PLAYER_HITBOX;
|
|
787
|
+
const disc = b * b - c;
|
|
788
|
+
if (disc < 0 || (c > 0 && b > 0)) continue;
|
|
789
|
+
const t = Math.max(0, -b - Math.sqrt(disc));
|
|
790
|
+
if (t < len) {
|
|
791
|
+
len = t;
|
|
792
|
+
this.beamHit = e.owner;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return len;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
frame(input: FrameInput): FrameEvents {
|
|
799
|
+
const dt = input.dt;
|
|
800
|
+
this.time += dt;
|
|
801
|
+
const out: FrameEvents = { hits: [], kills: [], selfHurt: 0, selfHealed: false, selfDown: false, downs: 0, shots: [] };
|
|
802
|
+
this.bolts.begin();
|
|
803
|
+
this.pellets.begin();
|
|
804
|
+
for (const c of this.cells) c.begin();
|
|
805
|
+
|
|
806
|
+
const all = input.self ? [input.self, ...input.others] : input.others;
|
|
807
|
+
const next = new Map<number, [number, number, number]>();
|
|
808
|
+
const seen = new Set<number>();
|
|
809
|
+
const [sx, , sz] = input.self ? pos(input.self) : [this.focus.x, 0, this.focus.z];
|
|
810
|
+
|
|
811
|
+
for (const e of all) {
|
|
812
|
+
const [x, y, z] = pos(e);
|
|
813
|
+
const prev = this.prevPos.get(e.id);
|
|
814
|
+
next.set(e.id, [x, y, z]);
|
|
815
|
+
if (e.kind === EntityKind.Projectile) {
|
|
816
|
+
// Our own bolts are drawn locally from the moment we fire (stepLocalShots).
|
|
817
|
+
if (e.owner === input.selfPlayer) continue;
|
|
818
|
+
const def = this.weapons[e.values[F.type] ?? 0];
|
|
819
|
+
let yaw = 0;
|
|
820
|
+
if (prev && (x !== prev[0] || z !== prev[2])) yaw = Math.atan2(-(x - prev[0]), -(z - prev[2]));
|
|
821
|
+
(def && def.count > 1 ? this.pellets : this.bolts).push(x, 1.0, z, yaw, 1);
|
|
822
|
+
} else if (e.kind === EntityKind.Pickup) {
|
|
823
|
+
const tier = Math.min(this.cells.length - 1, e.values[F.type] ?? 0);
|
|
824
|
+
this.cells[tier]?.push(x, 0.6 + Math.sin(this.time * 3 + e.id) * 0.12, z, this.time * 1.6 + e.id, 1);
|
|
825
|
+
} else if (e.kind === EntityKind.Player) {
|
|
826
|
+
seen.add(e.owner);
|
|
827
|
+
this.drawPlayer(e, x, y, z, e.owner === input.selfPlayer, input, all, out, sx, sz);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
this.prevPos = next;
|
|
831
|
+
this.stepLocalShots(dt, all, input.selfPlayer);
|
|
832
|
+
for (const [id, p] of this.players) {
|
|
833
|
+
if (seen.has(id)) continue;
|
|
834
|
+
this.scene.remove(p.root);
|
|
835
|
+
p.highlight?.dispose();
|
|
836
|
+
this.players.delete(id);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Sparks.
|
|
840
|
+
this.sparks.begin();
|
|
841
|
+
for (let i = this.particles.length - 1; i >= 0; i--) {
|
|
842
|
+
const p = this.particles[i] as Particle;
|
|
843
|
+
p.life -= dt;
|
|
844
|
+
if (p.life <= 0) {
|
|
845
|
+
this.particles[i] = this.particles[this.particles.length - 1] as Particle;
|
|
846
|
+
this.particles.pop();
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
p.vy -= 16 * dt;
|
|
850
|
+
p.x += p.vx * dt;
|
|
851
|
+
p.y = Math.max(0.05, p.y + p.vy * dt);
|
|
852
|
+
p.z += p.vz * dt;
|
|
853
|
+
this.sparks.push(p.x, p.y, p.z, p.life * 11, Math.min(1, p.life * 2.5));
|
|
854
|
+
}
|
|
855
|
+
for (const b of this.beams) {
|
|
856
|
+
if (b.t >= 1) continue;
|
|
857
|
+
b.t = Math.min(1, b.t + dt / BEAM_SECONDS);
|
|
858
|
+
(b.mesh.material as MeshBasicMaterial).opacity = 0.95 * (1 - b.t);
|
|
859
|
+
b.mesh.scale.x = b.mesh.scale.y = 1 + b.t * 1.5;
|
|
860
|
+
if (b.t >= 1) b.mesh.visible = false;
|
|
861
|
+
}
|
|
862
|
+
for (const f of this.flashes) {
|
|
863
|
+
if (f.t >= 1) continue;
|
|
864
|
+
f.t = Math.min(1, f.t + dt / FLASH_SECONDS);
|
|
865
|
+
(f.mesh.material as MeshBasicMaterial).opacity = 1 - f.t;
|
|
866
|
+
f.mesh.scale.setScalar(1 + f.t);
|
|
867
|
+
if (f.t >= 1) f.mesh.visible = false;
|
|
868
|
+
}
|
|
869
|
+
this.bolts.end();
|
|
870
|
+
this.pellets.end();
|
|
871
|
+
this.sparks.end();
|
|
872
|
+
for (const c of this.cells) c.end();
|
|
873
|
+
|
|
874
|
+
// Camera: follow our wirejack, leaning a few metres toward the aim.
|
|
875
|
+
if (this.preview) this.framePreview(all, dt);
|
|
876
|
+
else if (input.self) {
|
|
877
|
+
const [x, y, z] = pos(input.self);
|
|
878
|
+
const k = Math.min(1, dt * 6);
|
|
879
|
+
this.aimLead.x += (-Math.sin(input.aim) * 3.2 - this.aimLead.x) * k;
|
|
880
|
+
this.aimLead.z += (-Math.cos(input.aim) * 3.2 - this.aimLead.z) * k;
|
|
881
|
+
this.focus.set(x + this.aimLead.x, y, z + this.aimLead.z);
|
|
882
|
+
}
|
|
883
|
+
this.rig.yaw = 0;
|
|
884
|
+
this.rig.update(this.focus, dt, input.realDt ?? dt);
|
|
885
|
+
if (this.stack) this.stack.frame(dt, this.focus);
|
|
886
|
+
else this.handle.renderer.render(this.scene, this.rig.camera);
|
|
887
|
+
return out;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Preview mode: no hero. Screenshots of a style need the fight, so the
|
|
892
|
+
* camera eases to the biggest group of standing fighters (everyone within
|
|
893
|
+
* PREVIEW_RADIUS of one of them) and pulls back far enough to hold all of
|
|
894
|
+
* that group, at a lower, three-quarter pitch that shows their bodies.
|
|
895
|
+
*/
|
|
896
|
+
setPreview(on: boolean): void {
|
|
897
|
+
this.preview = on ? { distance: 31 } : null;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private framePreview(all: readonly SceneEntity[], dt: number): void {
|
|
901
|
+
const p = this.preview;
|
|
902
|
+
if (!p) return;
|
|
903
|
+
const fighters: [number, number][] = [];
|
|
904
|
+
for (const e of all) {
|
|
905
|
+
if (e.kind !== EntityKind.Player || ((e.values[F.flags] ?? 0) & EntityFlag.Dead) !== 0) continue;
|
|
906
|
+
const [x, , z] = pos(e);
|
|
907
|
+
fighters.push([x, z]);
|
|
908
|
+
}
|
|
909
|
+
this.rig.pitch = PREVIEW_PITCH;
|
|
910
|
+
if (fighters.length === 0) return;
|
|
911
|
+
let best: [number, number][] = [];
|
|
912
|
+
for (const [ax, az] of fighters) {
|
|
913
|
+
const group = fighters.filter(([x, z]) => Math.hypot(x - ax, z - az) <= PREVIEW_RADIUS);
|
|
914
|
+
if (group.length > best.length) best = group;
|
|
915
|
+
}
|
|
916
|
+
this.previewFight = best.length;
|
|
917
|
+
let cx = 0;
|
|
918
|
+
let cz = 0;
|
|
919
|
+
for (const [x, z] of best) {
|
|
920
|
+
cx += x / best.length;
|
|
921
|
+
cz += z / best.length;
|
|
922
|
+
}
|
|
923
|
+
let spread = 0;
|
|
924
|
+
for (const [x, z] of best) spread = Math.max(spread, Math.hypot(x - cx, z - cz));
|
|
925
|
+
const k = Math.min(1, dt * 1.5);
|
|
926
|
+
this.focus.x += (cx - this.focus.x) * k;
|
|
927
|
+
this.focus.z += (cz - this.focus.z) * k;
|
|
928
|
+
p.distance += (Math.min(PREVIEW_MAX_DISTANCE, Math.max(PREVIEW_MIN_DISTANCE, 14 + spread * 1.6)) - p.distance) * k;
|
|
929
|
+
this.rig.setDistance(p.distance);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
private drawPlayer(
|
|
933
|
+
e: SceneEntity,
|
|
934
|
+
x: number,
|
|
935
|
+
y: number,
|
|
936
|
+
z: number,
|
|
937
|
+
mine: boolean,
|
|
938
|
+
input: FrameInput,
|
|
939
|
+
drawn: readonly SceneEntity[],
|
|
940
|
+
out: FrameEvents,
|
|
941
|
+
sx: number,
|
|
942
|
+
sz: number,
|
|
943
|
+
): void {
|
|
944
|
+
const flags = e.values[F.flags] ?? 0;
|
|
945
|
+
const dead = (flags & EntityFlag.Dead) !== 0;
|
|
946
|
+
const pm = this.player(e.owner, e.values[F.type] ?? 0);
|
|
947
|
+
// Our own gun follows the input's aim this frame; everyone else's the snapshot's.
|
|
948
|
+
const yaw = mine && !dead ? input.aim : (e.values[F.yaw] ?? 0);
|
|
949
|
+
// Knockback (FeelKit): a hit pushes the drawn wirejack back a little and squashes it.
|
|
950
|
+
const k = this.feel?.knock.get(e.owner);
|
|
951
|
+
pm.root.position.set(x + (k?.x ?? 0), Math.max(0, y - 0.9), z + (k?.z ?? 0));
|
|
952
|
+
pm.body.scale.set(k?.sx ?? 1, k?.sy ?? 1, k?.sz ?? 1);
|
|
953
|
+
pm.body.rotation.set(0, yaw, 0);
|
|
954
|
+
const iframes = (e.values[F.iframes] ?? 0) > 0 && (flags & EntityFlag.Dashing) === 0;
|
|
955
|
+
pm.shield.visible = iframes && !dead;
|
|
956
|
+
pm.shield.scale.setScalar(1 + Math.sin(this.time * 8) * 0.04);
|
|
957
|
+
pm.root.visible = !dead;
|
|
958
|
+
(pm.ring.material as MeshBasicMaterial).opacity = mine ? 0.95 : 0.85;
|
|
959
|
+
pm.ring.scale.setScalar(mine ? 1.1 + Math.sin(this.time * 4) * 0.05 : 1 + Math.sin(this.time * 6 + e.owner) * 0.06);
|
|
960
|
+
|
|
961
|
+
const hp = e.values[F.health] ?? 0;
|
|
962
|
+
// Ours: one of our own bolts or beams reached this wirejack on our screen just now.
|
|
963
|
+
const ours = !mine && this.time - (this.ourHitAt.get(e.owner) ?? -10) < OURS_WINDOW;
|
|
964
|
+
const dirX = x - sx;
|
|
965
|
+
const dirZ = z - sz;
|
|
966
|
+
const dl = Math.hypot(dirX, dirZ) || 1;
|
|
967
|
+
if (dead && !pm.dead) {
|
|
968
|
+
this.burst(x, 0, z, 26);
|
|
969
|
+
if (mine) out.selfDown = true;
|
|
970
|
+
else {
|
|
971
|
+
if ((x - sx) ** 2 + (z - sz) ** 2 < 900) out.downs++;
|
|
972
|
+
if (ours || this.time - pm.hitByUs < OURS_WINDOW) out.kills.push({ x, z, id: e.owner, color: pm.color, dirX: dirX / dl, dirZ: dirZ / dl });
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (!dead && pm.lastHp >= 0 && hp < pm.lastHp && !pm.dead) {
|
|
976
|
+
pm.flash = 0.1;
|
|
977
|
+
this.burst(x, 0.2, z, 4);
|
|
978
|
+
if (mine) out.selfHurt += pm.lastHp - hp;
|
|
979
|
+
else if (ours) {
|
|
980
|
+
pm.hitByUs = this.time;
|
|
981
|
+
out.hits.push({ x, y: 2.1, z, amount: pm.lastHp - hp, id: e.owner, dirX: dirX / dl, dirZ: dirZ / dl });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (mine && !dead && !pm.dead && pm.lastHp >= 0 && hp > pm.lastHp) out.selfHealed = true;
|
|
985
|
+
pm.lastHp = hp;
|
|
986
|
+
pm.dead = dead;
|
|
987
|
+
if (pm.flash > 0) pm.flash -= input.dt;
|
|
988
|
+
const flash = Math.max(pm.flash > 0 ? pm.flash * 10 : 0, this.feel?.flash.value(e.owner) ?? 0);
|
|
989
|
+
const glow = 1 + flash * 4;
|
|
990
|
+
for (const m of pm.flashMats) m.emissiveIntensity = glow;
|
|
991
|
+
if (pm.animator) {
|
|
992
|
+
pm.animator.setSpeed(Math.hypot(x - pm.lastX, z - pm.lastZ) / Math.max(1e-3, input.dt));
|
|
993
|
+
pm.animator.update(input.dt);
|
|
994
|
+
}
|
|
995
|
+
pm.lastX = x;
|
|
996
|
+
pm.lastZ = z;
|
|
997
|
+
|
|
998
|
+
// Shots: a weapon slot's cooldown jumped up since last frame.
|
|
999
|
+
const wcd = e.values[F.wcd] ?? 0;
|
|
1000
|
+
const weapons = e.values[F.weapons] ?? 0;
|
|
1001
|
+
for (let s = 0; s < 4; s++) {
|
|
1002
|
+
const now = (wcd >>> (s * 8)) & 0xff;
|
|
1003
|
+
const before = (pm.lastWcd >>> (s * 8)) & 0xff;
|
|
1004
|
+
if (dead || now <= before + 1) continue;
|
|
1005
|
+
const def = this.weapons[slotWeapon(weapons, s)];
|
|
1006
|
+
if (!def) continue;
|
|
1007
|
+
this.counts.shots++;
|
|
1008
|
+
const mx = x - Math.sin(yaw) * 0.75;
|
|
1009
|
+
const mz = z - Math.cos(yaw) * 0.75;
|
|
1010
|
+
const color = def.kind === "beam" ? 0xff7ef0 : def.count > 1 ? 0xffb020 : 0x9ff6ff;
|
|
1011
|
+
this.flash(mx, 1.0, mz, color);
|
|
1012
|
+
if (def.kind === "beam") {
|
|
1013
|
+
this.beam(mx, mz, yaw, this.beamLength(x, z, yaw, def.range, e.owner, drawn) - 0.75, color);
|
|
1014
|
+
if (mine && this.beamHit >= 0) this.ourHitAt.set(this.beamHit, this.time);
|
|
1015
|
+
}
|
|
1016
|
+
// Our own bolts leave the muzzle this frame (the server's copies arrive a round trip later and are not drawn).
|
|
1017
|
+
else if (mine && def.kind === "blaster") this.localVolley(x, z, yaw, def);
|
|
1018
|
+
out.shots.push({ kind: def.kind === "blaster" && def.count > 1 ? "blaster-wide" : def.kind, mine });
|
|
1019
|
+
pm.animator?.play("shoot", { once: true });
|
|
1020
|
+
if (mine) this.lastShotAt = this.time;
|
|
1021
|
+
}
|
|
1022
|
+
pm.lastWcd = wcd;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Our blaster volley, drawn at once: the same fan, speed and reach as the sim's, stopped by pillars and walls. */
|
|
1026
|
+
private localVolley(x: number, z: number, yaw: number, def: WeaponDef): void {
|
|
1027
|
+
const muzzle = this.config.player.radius + 0.25;
|
|
1028
|
+
const spread = def.spread ?? 0;
|
|
1029
|
+
for (let b = 0; b < def.count; b++) {
|
|
1030
|
+
if (this.localShots.length >= MAX_LOCAL_SHOTS) this.localShots.shift();
|
|
1031
|
+
const a = yaw + (def.count > 1 ? (b / (def.count - 1) - 0.5) * spread : 0);
|
|
1032
|
+
const dx = -Math.sin(a);
|
|
1033
|
+
const dz = -Math.cos(a);
|
|
1034
|
+
const ox = x + dx * muzzle;
|
|
1035
|
+
const oz = z + dz * muzzle;
|
|
1036
|
+
this.localShots.push({ x: ox, z: oz, dx, dz, yaw: a, speed: def.speed, left: beamRange(this.config, ox, oz, dx, dz, Math.max(0, def.range - muzzle)), pellet: def.count > 1 });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Move our own bolts; one that reaches a wirejack (as drawn) or cover stops in a spark. */
|
|
1041
|
+
private stepLocalShots(dt: number, drawn: readonly SceneEntity[], self: number): void {
|
|
1042
|
+
const r = PLAYER_HITBOX + 0.2;
|
|
1043
|
+
for (let i = this.localShots.length - 1; i >= 0; i--) {
|
|
1044
|
+
const s = this.localShots[i];
|
|
1045
|
+
if (!s) continue;
|
|
1046
|
+
const step = Math.min(s.left, s.speed * dt);
|
|
1047
|
+
let stop = s.left - step <= 0;
|
|
1048
|
+
for (const e of drawn) {
|
|
1049
|
+
if (e.kind !== EntityKind.Player || e.owner === self || ((e.values[F.flags] ?? 0) & EntityFlag.Dead) !== 0) continue;
|
|
1050
|
+
const [px, , pz] = pos(e);
|
|
1051
|
+
const mx = s.x - px;
|
|
1052
|
+
const mz = s.z - pz;
|
|
1053
|
+
const b = mx * s.dx + mz * s.dz;
|
|
1054
|
+
const c = mx * mx + mz * mz - r * r;
|
|
1055
|
+
const disc = b * b - c;
|
|
1056
|
+
if (disc < 0 || (c > 0 && b > 0)) continue;
|
|
1057
|
+
const t = Math.max(0, -b - Math.sqrt(disc));
|
|
1058
|
+
if (t <= step) {
|
|
1059
|
+
stop = true;
|
|
1060
|
+
this.ourHitAt.set(e.owner, this.time);
|
|
1061
|
+
this.burst(s.x + s.dx * t, 0.2, s.z + s.dz * t, 3);
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
s.x += s.dx * step;
|
|
1066
|
+
s.z += s.dz * step;
|
|
1067
|
+
s.left -= step;
|
|
1068
|
+
if (stop) {
|
|
1069
|
+
this.localShots.splice(i, 1);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
(s.pellet ? this.pellets : this.bolts).push(s.x, 1.0, s.z, s.yaw, 1);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|