@netforge/template-party 0.0.0-stage → 0.2.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/AGENTS.md +63 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +74 -2
- package/client/debris.ts +267 -0
- package/client/decor.ts +13 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +576 -0
- package/client/icons.ts +42 -0
- package/client/index.html +277 -0
- package/client/input.ts +172 -0
- package/client/library.ts +250 -0
- package/client/main.ts +607 -0
- package/client/names.ts +47 -0
- package/client/palette.ts +7 -0
- package/client/scene.ts +941 -0
- package/client/stage.ts +266 -0
- package/client/stake.ts +101 -0
- package/client/threats.ts +157 -0
- package/llms-full.txt +716 -0
- package/llms.txt +375 -0
- package/package.json +82 -3
- package/src/game.ts +326 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
package/client/scene.ts
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ROMPHOUSE scene, on the engine's render stack and feel kit:
|
|
3
|
+
* - lighting with a following sun shadow, sky, fog, bloom, colour grade
|
|
4
|
+
* and tone mapping from `createRenderStack` (the game's `look`);
|
|
5
|
+
* - one animated instanced draw per enemy type (bob, waddle, lean, hit
|
|
6
|
+
* squash and flash in the vertex stage);
|
|
7
|
+
* - GPU particles, ground decals and shockwave rings for hits and kills;
|
|
8
|
+
* - client-side Rapier dressing: chunks off kills, crates and barrels the
|
|
9
|
+
* keepers shove and novas blast (./debris.ts);
|
|
10
|
+
* - FeelKit for flash, knockback offsets, damage numbers, shake, hit-stop,
|
|
11
|
+
* sound and rumble, honouring each prefab's and weapon's `feel`.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
CanvasTexture,
|
|
15
|
+
Color,
|
|
16
|
+
Group,
|
|
17
|
+
InstancedMesh,
|
|
18
|
+
Material,
|
|
19
|
+
Mesh,
|
|
20
|
+
MeshBasicMaterial,
|
|
21
|
+
MeshStandardMaterial,
|
|
22
|
+
PlaneGeometry,
|
|
23
|
+
PointLight,
|
|
24
|
+
Scene,
|
|
25
|
+
SphereGeometry,
|
|
26
|
+
SRGBColorSpace,
|
|
27
|
+
Vector3,
|
|
28
|
+
} from "three/webgpu";
|
|
29
|
+
import type { BufferGeometry, Texture } from "three/webgpu";
|
|
30
|
+
import { AnimatedCrowd, CameraRig, createRenderStack, fieldBounds, HeroHighlight } from "@netforge/engine/runtime";
|
|
31
|
+
import type { FeelKit, RenderStack, RendererHandle, StyleKit } from "@netforge/engine/runtime";
|
|
32
|
+
import { CROWD_UNITS, F, PLAYER_UNITS, threatOf } from "@netforge/engine/game";
|
|
33
|
+
import type { CompiledGame } from "@netforge/engine/game";
|
|
34
|
+
import type { FeelDef, LookDef } from "@netforge/engine";
|
|
35
|
+
import { EntityFlag, EntityKind, orbitAngle, PICKUP_SPECIAL, scaledWeapon, slotLevel, slotWeapon, weaponModsFor } from "@netforge/engine-sim";
|
|
36
|
+
import type { ResolvedSimConfig } from "@netforge/engine-sim";
|
|
37
|
+
import { assetById, restyleId } from "@netforge/asset-library";
|
|
38
|
+
import type { CharacterAnimator, LibraryClient } from "@netforge/asset-library/three";
|
|
39
|
+
import { PhysicsDressing } from "./debris.js";
|
|
40
|
+
import { ENEMY_HEIGHT_PER_RADIUS, KEEPER_HEIGHT } from "./decor.js";
|
|
41
|
+
import { fx, resolveAsset } from "./library.js";
|
|
42
|
+
import { ThreatLayer } from "./threats.js";
|
|
43
|
+
import { PartyStage } from "./stage.js";
|
|
44
|
+
import { KEEPER_COLORS, DISCO } from "./palette.js";
|
|
45
|
+
|
|
46
|
+
/** One entity as the scene sees it (netcode values in the engine wire schema). */
|
|
47
|
+
export interface SceneEntity {
|
|
48
|
+
readonly id: number;
|
|
49
|
+
readonly kind: number;
|
|
50
|
+
readonly owner: number;
|
|
51
|
+
readonly values: readonly number[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FrameInput {
|
|
55
|
+
/** Seconds since the last frame (already scaled by hit-stop). */
|
|
56
|
+
readonly dt: number;
|
|
57
|
+
/** Real seconds since the last frame (shake keeps moving during a hit-stop). */
|
|
58
|
+
readonly realDt: number;
|
|
59
|
+
/** Server tick the world is drawn at (fractional), for orbit blades. */
|
|
60
|
+
readonly worldTick: number;
|
|
61
|
+
/** Our own predicted character (drawn ahead of the server). */
|
|
62
|
+
readonly self: SceneEntity | undefined;
|
|
63
|
+
/** Everyone and everything else, interpolated ~100 ms in the past. */
|
|
64
|
+
readonly others: readonly SceneEntity[];
|
|
65
|
+
readonly selfPlayer: number;
|
|
66
|
+
/** 0..1 red vignette (FeelKit.hurtPulse). */
|
|
67
|
+
readonly hurt: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FrameEvents {
|
|
71
|
+
/** Our character took damage this frame (amount). */
|
|
72
|
+
selfHurt: number;
|
|
73
|
+
enemiesDrawn: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MAX_PER_TYPE = 600;
|
|
77
|
+
const MAX_GEMS = 160;
|
|
78
|
+
const MAX_BOLTS = 160;
|
|
79
|
+
const MAX_BLADES = 96;
|
|
80
|
+
/** Camera distance, m (engine SURVIVOR_VIEWS must match: spawnEdge brings the crowd in just past this view). */
|
|
81
|
+
const CAMERA_DISTANCE = 17;
|
|
82
|
+
const CAMERA_DISTANCE_PORTRAIT = 24;
|
|
83
|
+
const CAMERA_PITCH = 0.98;
|
|
84
|
+
/** Metres of rock past the outer wall the camera may show. */
|
|
85
|
+
const OUTSIDE_VIEW = 16;
|
|
86
|
+
/** Props draw at their model's natural height times this. */
|
|
87
|
+
const PROP_SCALE = 1.25;
|
|
88
|
+
|
|
89
|
+
/** Defaults when a prefab or weapon declares no `feel`. */
|
|
90
|
+
const SMALL_KILL: FeelDef = { particles: "burst", decal: "splat", sound: "crunch" };
|
|
91
|
+
const BIG_KILL: FeelDef = { particles: "burst", decal: "scorch", sound: "boom", shake: 0.5, hitStop: 0.06 };
|
|
92
|
+
/** SOUND_CUES of the game API mapped onto FeelAudio's procedural cues. */
|
|
93
|
+
const CUE: Record<string, string> = { hit: "hit", crunch: "kill", boom: "bigKill", zap: "dash", chime: "coin", thud: "land" };
|
|
94
|
+
|
|
95
|
+
const hex = (c: string | undefined, fallback: number): number => (c ? parseInt(c.slice(1), 16) : fallback);
|
|
96
|
+
/** Teammates' area effects (novas, auras, sweeps) at this share of full brightness. */
|
|
97
|
+
const TEAM_FX = 0.35;
|
|
98
|
+
/** A colour scaled toward black (additive effects: dimmer). */
|
|
99
|
+
const dim = (c: number, k: number): number => (Math.round(((c >> 16) & 0xff) * k) << 16) | (Math.round(((c >> 8) & 0xff) * k) << 8) | Math.round((c & 0xff) * k);
|
|
100
|
+
|
|
101
|
+
const pos = (e: SceneEntity): [number, number, number] =>
|
|
102
|
+
e.kind === EntityKind.Player
|
|
103
|
+
? [(e.values[F.px] ?? 0) / PLAYER_UNITS, (e.values[F.py] ?? 0) / PLAYER_UNITS, (e.values[F.pz] ?? 0) / PLAYER_UNITS]
|
|
104
|
+
: [(e.values[F.ex] ?? 0) / CROWD_UNITS, 0, (e.values[F.ez] ?? 0) / CROWD_UNITS];
|
|
105
|
+
|
|
106
|
+
/** A stable 0..1 phase per entity id (desyncs crowd steps). */
|
|
107
|
+
const phaseOf = (id: number): number => (((Math.imul(id, 0x9e3779b1) >>> 0) % 1000) / 1000);
|
|
108
|
+
|
|
109
|
+
function radialTexture(): CanvasTexture {
|
|
110
|
+
const c = document.createElement("canvas");
|
|
111
|
+
c.width = c.height = 128;
|
|
112
|
+
const g = c.getContext("2d");
|
|
113
|
+
if (!g) throw new Error("no 2d canvas");
|
|
114
|
+
const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64);
|
|
115
|
+
grad.addColorStop(0, "rgba(255,255,255,0.9)");
|
|
116
|
+
grad.addColorStop(0.45, "rgba(255,255,255,0.35)");
|
|
117
|
+
grad.addColorStop(1, "rgba(255,255,255,0)");
|
|
118
|
+
g.fillStyle = grad;
|
|
119
|
+
g.fillRect(0, 0, 128, 128);
|
|
120
|
+
const t = new CanvasTexture(c);
|
|
121
|
+
t.colorSpace = SRGBColorSpace;
|
|
122
|
+
return t;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Plain instanced props (gems, bolts, blades): one draw each, no skeleton. */
|
|
126
|
+
class Crowd {
|
|
127
|
+
mesh: InstancedMesh;
|
|
128
|
+
count = 0;
|
|
129
|
+
constructor(geometry: BufferGeometry, material: Material, readonly capacity: number) {
|
|
130
|
+
this.mesh = new InstancedMesh(geometry, material, capacity);
|
|
131
|
+
this.mesh.count = 0;
|
|
132
|
+
this.mesh.frustumCulled = false;
|
|
133
|
+
}
|
|
134
|
+
/** Draw a library model instead of the shape (same capacity and instances). */
|
|
135
|
+
swap(geometry: BufferGeometry, material: Material): void {
|
|
136
|
+
const m = new InstancedMesh(geometry, material, this.capacity);
|
|
137
|
+
m.instanceMatrix = this.mesh.instanceMatrix;
|
|
138
|
+
m.count = this.mesh.count;
|
|
139
|
+
m.frustumCulled = false;
|
|
140
|
+
this.mesh = m;
|
|
141
|
+
}
|
|
142
|
+
begin(): void {
|
|
143
|
+
this.count = 0;
|
|
144
|
+
}
|
|
145
|
+
push(x: number, y: number, z: number, yaw: number, scale: number): void {
|
|
146
|
+
if (this.count >= this.capacity) return;
|
|
147
|
+
const m = this.mesh.instanceMatrix.array as Float32Array;
|
|
148
|
+
const o = this.count * 16;
|
|
149
|
+
const c = Math.cos(yaw) * scale;
|
|
150
|
+
const s = Math.sin(yaw) * scale;
|
|
151
|
+
m[o] = c;
|
|
152
|
+
m[o + 1] = 0;
|
|
153
|
+
m[o + 2] = -s;
|
|
154
|
+
m[o + 3] = 0;
|
|
155
|
+
m[o + 4] = 0;
|
|
156
|
+
m[o + 5] = scale;
|
|
157
|
+
m[o + 6] = 0;
|
|
158
|
+
m[o + 7] = 0;
|
|
159
|
+
m[o + 8] = s;
|
|
160
|
+
m[o + 9] = 0;
|
|
161
|
+
m[o + 10] = c;
|
|
162
|
+
m[o + 11] = 0;
|
|
163
|
+
m[o + 12] = x;
|
|
164
|
+
m[o + 13] = y;
|
|
165
|
+
m[o + 14] = z;
|
|
166
|
+
m[o + 15] = 1;
|
|
167
|
+
this.count++;
|
|
168
|
+
}
|
|
169
|
+
end(): void {
|
|
170
|
+
this.mesh.count = this.count;
|
|
171
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface PlayerMeshes {
|
|
176
|
+
pool: Mesh;
|
|
177
|
+
/** The keeper: the fallback shape until the library character loads, then the character. */
|
|
178
|
+
body: Group;
|
|
179
|
+
animator: CharacterAnimator | null;
|
|
180
|
+
downed: boolean;
|
|
181
|
+
lantern: Mesh;
|
|
182
|
+
light: PointLight;
|
|
183
|
+
cosmetic: number;
|
|
184
|
+
keeper: number;
|
|
185
|
+
lastWcd: number;
|
|
186
|
+
lastFlags: number;
|
|
187
|
+
lean: number;
|
|
188
|
+
stride: number;
|
|
189
|
+
/** The library character once loaded (the local hero gets an outline and rim on it). */
|
|
190
|
+
model: Group | null;
|
|
191
|
+
highlight: HeroHighlight | null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface EnemyKind {
|
|
195
|
+
crowd: AnimatedCrowd;
|
|
196
|
+
/** More draws of the same instances when a library model has more than one material group. */
|
|
197
|
+
extras: AnimatedCrowd[];
|
|
198
|
+
big: boolean;
|
|
199
|
+
/** A boss (or mini-boss): its death slows the world for a beat. */
|
|
200
|
+
boss: boolean;
|
|
201
|
+
feel: FeelDef;
|
|
202
|
+
color: number;
|
|
203
|
+
radius: number;
|
|
204
|
+
/** Scenery (urns, campfires): culled when it stands between the camera and your keeper. */
|
|
205
|
+
prop: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Feature switches for measurement and bisecting (URL ?shadows=0|1 &post=0 &lights=0 &props=0). */
|
|
209
|
+
export interface SceneSwitches {
|
|
210
|
+
readonly shadows?: boolean;
|
|
211
|
+
readonly post?: boolean;
|
|
212
|
+
readonly lights?: boolean;
|
|
213
|
+
readonly props?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export class PartyScene {
|
|
217
|
+
private readonly switches: SceneSwitches;
|
|
218
|
+
readonly scene = new Scene();
|
|
219
|
+
readonly rig: CameraRig;
|
|
220
|
+
readonly stack: RenderStack;
|
|
221
|
+
readonly dressing: PhysicsDressing;
|
|
222
|
+
private readonly enemies: EnemyKind[] = [];
|
|
223
|
+
private readonly gemCrowds: Crowd[] = [];
|
|
224
|
+
private readonly boltCrowd: Crowd;
|
|
225
|
+
private readonly lanceCrowd: Crowd;
|
|
226
|
+
private readonly bladeCrowd: Crowd;
|
|
227
|
+
/** Teammates' orbit blades: the same shape, a third as bright. */
|
|
228
|
+
private readonly teamBladeCrowd: Crowd;
|
|
229
|
+
private readonly players = new Map<number, PlayerMeshes>();
|
|
230
|
+
private readonly lastHp = new Map<number, number>();
|
|
231
|
+
/** Enemies' crit counters (timers bits 8..11 on the wire): a change is a crit. */
|
|
232
|
+
private readonly lastCrit = new Map<number, number>();
|
|
233
|
+
/** Embers for enemies rising on screen, capped per frame. */
|
|
234
|
+
private risesThisFrame = 0;
|
|
235
|
+
/** Keepers this frame: who is down and who could revive them (the reviver path). */
|
|
236
|
+
private readonly downedAt: [number, number][] = [];
|
|
237
|
+
private readonly upAt: [number, number][] = [];
|
|
238
|
+
private readonly dead = new Set<number>();
|
|
239
|
+
private prevPos = new Map<number, [number, number, number]>();
|
|
240
|
+
private nextPos = new Map<number, [number, number, number]>();
|
|
241
|
+
/** Projectiles seen last frame: id → weapon index (a vanished one sparks where it ended). */
|
|
242
|
+
private shots = new Map<number, number>();
|
|
243
|
+
private nextShots = new Map<number, number>();
|
|
244
|
+
private readonly flames: [number, number, number][] = [];
|
|
245
|
+
/** Bosses whose arrival has tolled, and boss hoards already announced. */
|
|
246
|
+
private readonly bossesHeard = new Set<number>();
|
|
247
|
+
private readonly hoardsSeen = new Set<number>();
|
|
248
|
+
/** Special pickup indices that are boss hoards (chests with evolve). */
|
|
249
|
+
private readonly hoardTypes: Set<number>;
|
|
250
|
+
/** Pillars, to fade the ones that stand between the camera and your keeper. */
|
|
251
|
+
private readonly pillars: { x: number; z: number; r: number; h: number; material: MeshStandardMaterial; fade: number; top: Mesh[] }[] = [];
|
|
252
|
+
private readonly focus = new Vector3();
|
|
253
|
+
private time = 0;
|
|
254
|
+
private selfHp = -1;
|
|
255
|
+
/** Effects fired so far (playtest checks read these). */
|
|
256
|
+
readonly counts = { rings: 0, bursts: 0, flashes: 0, decals: 0, impacts: 0, crits: 0, rises: 0, slowMos: 0, levelUps: 0 };
|
|
257
|
+
/** The lantern's pool of light on the ground (the art style may paint it). */
|
|
258
|
+
private readonly poolTexture: Texture;
|
|
259
|
+
/** Telegraph wind-ups and hostile shots (client/threats.ts), and when each last showed (an impact ring fires as one lands). */
|
|
260
|
+
private readonly threats: ThreatLayer;
|
|
261
|
+
private telegraphs = new Map<number, { x: number; z: number; r: number; p: number; friendly: boolean }>();
|
|
262
|
+
private nextTelegraphs = new Map<number, { x: number; z: number; r: number; p: number; friendly: boolean }>();
|
|
263
|
+
private width = 1;
|
|
264
|
+
private height = 1;
|
|
265
|
+
/** The asset library (real models); null draws the fallback shapes only. */
|
|
266
|
+
private readonly library: LibraryClient | null;
|
|
267
|
+
/** The art style drawn (the game's declared style, or ?style=), and its kit (null: the template's own low-poly drawing). */
|
|
268
|
+
readonly style: string;
|
|
269
|
+
private readonly kit: StyleKit | null;
|
|
270
|
+
/** The playroom's floor, walls and party lights. */
|
|
271
|
+
private readonly level: PartyStage;
|
|
272
|
+
|
|
273
|
+
constructor(
|
|
274
|
+
private readonly handle: RendererHandle,
|
|
275
|
+
private readonly game: CompiledGame,
|
|
276
|
+
private readonly config: ResolvedSimConfig,
|
|
277
|
+
private readonly feel: FeelKit,
|
|
278
|
+
opts: { quality?: "low" | "medium" | "high" | "auto"; switches?: SceneSwitches; library?: LibraryClient | null; style?: string } = {},
|
|
279
|
+
) {
|
|
280
|
+
const s = this.scene;
|
|
281
|
+
this.library = opts.library ?? null;
|
|
282
|
+
this.style = opts.style ?? game.manifest.game.style;
|
|
283
|
+
this.hoardTypes = new Set(game.specialPickups.flatMap((id, i) => (game.manifest.prefabs.get(id)?.pickup?.evolve ? [i] : [])));
|
|
284
|
+
this.rig = new CameraRig(1, { distance: CAMERA_DISTANCE, height: 0.5, follow: 7, lead: 0.3, leadMax: 4, fov: 50, minPitch: 0.9, maxPitch: 1.1 });
|
|
285
|
+
this.rig.pitch = CAMERA_PITCH;
|
|
286
|
+
this.threats = new ThreatLayer(s);
|
|
287
|
+
const look: LookDef | undefined = game.manifest.game.look;
|
|
288
|
+
this.stack = createRenderStack(handle, s, this.rig.camera, {
|
|
289
|
+
preset: look?.preset ?? "night",
|
|
290
|
+
// The art style (game.ts's, or ?style=): the stack applies its kit's lighting, fog, grade, particles, post and session.
|
|
291
|
+
style: this.style,
|
|
292
|
+
quality: opts.quality ?? "auto",
|
|
293
|
+
bloom: (look?.bloom ?? 1) > 0,
|
|
294
|
+
outline: look?.outline ?? false,
|
|
295
|
+
shadowExtent: 30,
|
|
296
|
+
// A high top-down camera (17-24 m out): no height fog, and the haze starts past the playroom.
|
|
297
|
+
atmosphere: { heightFog: 0, near: 40, far: 120 },
|
|
298
|
+
// Library id to URL, for a style's own textures (ps1's surfaces).
|
|
299
|
+
resolve: (id) => {
|
|
300
|
+
try {
|
|
301
|
+
return opts.library?.url(id) ?? null;
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
// The style's kit (null: the template's own low-poly drawing), for its world builders below.
|
|
308
|
+
this.kit = this.stack.kit;
|
|
309
|
+
this.applyLook(look);
|
|
310
|
+
this.poolTexture = this.stack.session?.surface?.("glow") ?? radialTexture();
|
|
311
|
+
const sw = opts.switches ?? {};
|
|
312
|
+
this.switches = sw;
|
|
313
|
+
// Sun shadows are opt-in here (look.shadows: true, a style that wants them, or ?shadows=1) until the shadow pass
|
|
314
|
+
// over the instanced crowds is tuned: with them on, headless Chrome on WebGPU held ~30 fps instead of 60.
|
|
315
|
+
const shadowsOn = sw.shadows ?? look?.shadows ?? this.kit?.stack?.(look?.preset ?? "night").shadows === true;
|
|
316
|
+
if (!shadowsOn) this.stack.lighting.setShadowMapSize(0);
|
|
317
|
+
if (sw.post === false) this.stack.post.configure({ enabled: false });
|
|
318
|
+
this.feel.particles = this.stack.particles;
|
|
319
|
+
|
|
320
|
+
const half = config.arena.halfExtent;
|
|
321
|
+
// The camera stops at the field: past the outer rock it never shows the void.
|
|
322
|
+
this.rig.setBounds(fieldBounds(half, OUTSIDE_VIEW));
|
|
323
|
+
// The playroom: carpet, the dance floor, walls and party lights from the game's field (game.ts).
|
|
324
|
+
const surface = this.stack.session?.surface ? (k: "floor" | "wall" | "stone" | "ground") => this.stack.session?.surface?.(k, { mood: "party playroom" }) ?? null : null;
|
|
325
|
+
this.level = new PartyStage(config.arena.walls ?? [], config.arena.floors ?? [], half, surface);
|
|
326
|
+
s.add(this.level.group);
|
|
327
|
+
this.flames.push(...this.level.flames);
|
|
328
|
+
|
|
329
|
+
// Enemies: one animated instanced draw per prefab.
|
|
330
|
+
game.enemyPrefabs.forEach((id, t) => {
|
|
331
|
+
const def = game.manifest.prefabs.get(id);
|
|
332
|
+
const radius = game.sim.content?.enemyTypes?.[t]?.radius ?? 0.35;
|
|
333
|
+
// The model in the art style the game plays in (?style= or the declared style).
|
|
334
|
+
const model = this.model(def?.model ?? "style.lowpoly.missing");
|
|
335
|
+
const a = resolveAsset(model, radius);
|
|
336
|
+
const big = def?.boss === true || radius >= 0.5;
|
|
337
|
+
const motion = { bob: Math.min(0.25, radius * 0.35), fullSpeed: Math.max(1.5, def?.speed ?? 3), squash: big ? 0.18 : 0.32 };
|
|
338
|
+
const capacity = def?.boss ? 4 : MAX_PER_TYPE;
|
|
339
|
+
// Props are drawn at their model's natural size (a campfire stays a campfire), not by hit radius.
|
|
340
|
+
const libHeight = def?.prop ? (assetById(model)?.height ?? radius * 2) * PROP_SCALE : radius * ENEMY_HEIGHT_PER_RADIUS;
|
|
341
|
+
const crowd = new AnimatedCrowd(a.geometry, a.material as MeshStandardMaterial, capacity, motion).addTo(s);
|
|
342
|
+
const color = (a.material as MeshStandardMaterial).color?.getHex?.() ?? 0xb59cff;
|
|
343
|
+
const boss = def?.boss !== undefined && def.boss !== false;
|
|
344
|
+
const kind: EnemyKind = { crowd, extras: [], big: (big || boss) && !def?.prop, boss, feel: def?.feel ?? (big && !def?.prop ? BIG_KILL : SMALL_KILL), color, radius, prop: def?.prop === true };
|
|
345
|
+
this.enemies.push(kind);
|
|
346
|
+
// The library model, about 3.2 hit radii tall, dressed by the prefab's appearance, keeping a faint glow in the shape's colour for the night.
|
|
347
|
+
this.library?.crowd(model, def?.appearance, { height: libHeight }).then(
|
|
348
|
+
(c) => {
|
|
349
|
+
const glow = (a.material as MeshStandardMaterial).emissive;
|
|
350
|
+
const made = c.parts.map((p) => {
|
|
351
|
+
const m = p.material.clone() as MeshStandardMaterial;
|
|
352
|
+
// Props keep their own colours (a glow made the stumps read as pink crystals).
|
|
353
|
+
if (m.emissive && glow && !def?.prop) {
|
|
354
|
+
m.emissive.copy(glow);
|
|
355
|
+
m.emissiveIntensity = 0.35;
|
|
356
|
+
}
|
|
357
|
+
return new AnimatedCrowd(p.geometry, m, capacity, motion).addTo(s);
|
|
358
|
+
});
|
|
359
|
+
const [first, ...rest] = made;
|
|
360
|
+
if (!first) return;
|
|
361
|
+
s.remove(kind.crowd.mesh);
|
|
362
|
+
kind.crowd = first;
|
|
363
|
+
kind.extras = rest;
|
|
364
|
+
},
|
|
365
|
+
(e: unknown) => console.warn(`library: ${model} stays a shape (${e instanceof Error ? e.message : String(e)})`),
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
for (const id of [...game.pickupPrefabs, ...game.specialPickups]) {
|
|
369
|
+
const p = game.manifest.prefabs.get(id);
|
|
370
|
+
const model = this.model(p?.model ?? "style.lowpoly.missing");
|
|
371
|
+
const a = resolveAsset(model);
|
|
372
|
+
const crowd = new Crowd(a.geometry, a.material, MAX_GEMS);
|
|
373
|
+
this.gemCrowds.push(crowd);
|
|
374
|
+
s.add(crowd.mesh);
|
|
375
|
+
const asset = p ? assetById(model) : undefined;
|
|
376
|
+
// A style kit draws its own procedural pickups (voxel gems); low-poly keeps the template's shapes.
|
|
377
|
+
if (this.library && p && asset && (!("procedural" in asset.source) || this.kit)) {
|
|
378
|
+
this.library.crowd(model, p.appearance, { height: 0.5 }).then(
|
|
379
|
+
(c) => {
|
|
380
|
+
const part = c.parts[0];
|
|
381
|
+
if (!part) return;
|
|
382
|
+
s.remove(crowd.mesh);
|
|
383
|
+
crowd.swap(part.geometry, part.material);
|
|
384
|
+
s.add(crowd.mesh);
|
|
385
|
+
},
|
|
386
|
+
() => undefined,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const b = fx.bolt();
|
|
391
|
+
this.boltCrowd = new Crowd(b.geometry, b.material, MAX_BOLTS);
|
|
392
|
+
const l = fx.lance();
|
|
393
|
+
this.lanceCrowd = new Crowd(l.geometry, l.material, MAX_BOLTS);
|
|
394
|
+
const bl = fx.blade();
|
|
395
|
+
this.bladeCrowd = new Crowd(bl.geometry, bl.material, MAX_BLADES);
|
|
396
|
+
const teamBlade = (bl.material as MeshStandardMaterial).clone();
|
|
397
|
+
teamBlade.emissiveIntensity *= TEAM_FX;
|
|
398
|
+
teamBlade.color.multiplyScalar(0.6);
|
|
399
|
+
this.teamBladeCrowd = new Crowd(bl.geometry, teamBlade, MAX_BLADES);
|
|
400
|
+
for (const c of [this.boltCrowd, this.lanceCrowd, this.bladeCrowd, this.teamBladeCrowd]) s.add(c.mesh);
|
|
401
|
+
this.dressing = new PhysicsDressing(s, config, { props: opts.switches?.props !== false, ...(this.kit?.shape ? { shape: (n) => this.kit?.shape?.(n) ?? null } : {}) });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Compile every pipeline before the first real frame. WebGPU builds a
|
|
406
|
+
* pipeline the first time a material, instancing layout or pass is drawn,
|
|
407
|
+
* which is a 100 ms+ hitch mid-fight (the first kill, the first decal);
|
|
408
|
+
* drawing one instance of everything out of sight at load moves that cost
|
|
409
|
+
* behind the loading banner.
|
|
410
|
+
*/
|
|
411
|
+
async warmup(): Promise<void> {
|
|
412
|
+
const hide = -60;
|
|
413
|
+
for (const k of this.enemies) {
|
|
414
|
+
k.crowd.begin();
|
|
415
|
+
k.crowd.push(0, hide, 0, 0, 0, 1, 0.5, 0.5, 1);
|
|
416
|
+
k.crowd.end(0);
|
|
417
|
+
}
|
|
418
|
+
for (const c of [...this.gemCrowds, this.boltCrowd, this.lanceCrowd, this.bladeCrowd, this.teamBladeCrowd]) {
|
|
419
|
+
c.begin();
|
|
420
|
+
c.push(0, hide, 0, 0, 1);
|
|
421
|
+
c.end();
|
|
422
|
+
}
|
|
423
|
+
for (const preset of ["burst", "kill", "bigKill", "sparks", "hitSpark", "dust", "magic", "embers", "smoke"]) {
|
|
424
|
+
this.stack.particles.burst(preset, 0, hide, 0, { count: 2 });
|
|
425
|
+
}
|
|
426
|
+
for (const kind of ["scorch", "splat", "ring"] as const) this.stack.decals.add(kind, 1000, 1000, { life: 0.1 });
|
|
427
|
+
this.stack.rings.fire(1000, 1000, 1, { duration: 0.05 });
|
|
428
|
+
this.dressing.shatter(0, 0, 0.3, new Color(0xffffff), 1);
|
|
429
|
+
const r = this.handle.renderer as { compileAsync?: (scene: Scene, camera: unknown) => Promise<void> };
|
|
430
|
+
try {
|
|
431
|
+
// Never let a slow or stuck compile hold the game: the frames below build anything it missed.
|
|
432
|
+
await Promise.race([r.compileAsync?.(this.scene, this.rig.camera), new Promise((ok) => setTimeout(ok, 1500))]);
|
|
433
|
+
} catch {
|
|
434
|
+
// Older renderers: the frames below still build the pipelines.
|
|
435
|
+
}
|
|
436
|
+
this.rig.update(this.focus, 0);
|
|
437
|
+
for (let i = 0; i < 2; i++) this.stack.frame(0, this.focus);
|
|
438
|
+
for (const k of this.enemies) {
|
|
439
|
+
k.crowd.begin();
|
|
440
|
+
k.crowd.end(0);
|
|
441
|
+
}
|
|
442
|
+
for (const c of [...this.gemCrowds, this.boltCrowd, this.lanceCrowd, this.bladeCrowd, this.teamBladeCrowd]) {
|
|
443
|
+
c.begin();
|
|
444
|
+
c.end();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** A library id in the style drawn: "style.lowpoly.ghost" is "style.voxel.ghost" in the voxel style. */
|
|
449
|
+
private model(id: string): string {
|
|
450
|
+
return restyleId(id, this.style);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Map the game's declared `look` onto the stack's grade and lighting. */
|
|
454
|
+
private applyLook(look: LookDef | undefined): void {
|
|
455
|
+
if (!look) return;
|
|
456
|
+
const post = this.stack.post;
|
|
457
|
+
if (look.bloom !== undefined && post.bloom) post.bloom.strength.value *= look.bloom;
|
|
458
|
+
if (look.exposure !== undefined) post.exposure.value *= look.exposure;
|
|
459
|
+
if (look.saturation !== undefined) post.saturation.value = look.saturation;
|
|
460
|
+
if (look.contrast !== undefined) post.contrast.value = look.contrast;
|
|
461
|
+
if (look.vignette !== undefined) post.vignette.value = look.vignette;
|
|
462
|
+
if (look.tint) {
|
|
463
|
+
const c = new Color(hex(look.tint, 0xffffff));
|
|
464
|
+
post.highlightTint.value.set(c.r, c.g, c.b);
|
|
465
|
+
}
|
|
466
|
+
if (look.shadows === false) this.stack.lighting.setShadowMapSize(0);
|
|
467
|
+
if (look.fog !== undefined) {
|
|
468
|
+
// 0 = clear to the walls, 1 = thick: linear fog pulls in, exp2 thickens.
|
|
469
|
+
const a = this.stack.atmosphere;
|
|
470
|
+
a.far.value = 200 - 170 * look.fog;
|
|
471
|
+
a.near.value = a.far.value * 0.3;
|
|
472
|
+
a.density.value = 0.06 * look.fog;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
resize(width: number, height: number): void {
|
|
477
|
+
this.width = width;
|
|
478
|
+
this.height = height;
|
|
479
|
+
this.stack.resize(width, height);
|
|
480
|
+
this.rig.resize(width / height);
|
|
481
|
+
// Portrait phones: pull back so the swarm stays on screen.
|
|
482
|
+
this.rig.setDistance(width < height ? CAMERA_DISTANCE_PORTRAIT : CAMERA_DISTANCE);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** World position → CSS pixels on the canvas (for damage numbers and HUD markers). */
|
|
486
|
+
project(x: number, y: number, z: number, width = this.width, height = this.height): { x: number; y: number; onScreen: boolean } {
|
|
487
|
+
const v = new Vector3(x, y, z).project(this.rig.camera);
|
|
488
|
+
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 };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Play a declared `feel` at a world point (kills, impacts, novas). */
|
|
492
|
+
private playFeel(f: FeelDef, x: number, y: number, z: number, color: number, size: number, dirX = 0, dirZ = 0): void {
|
|
493
|
+
const c = hex(f.color, color);
|
|
494
|
+
if (f.particles) {
|
|
495
|
+
this.stack.particles.burst(f.particles, x, y, z, { color: c, dirX, dirZ, count: Math.round(6 + size * 10) });
|
|
496
|
+
this.counts.bursts++;
|
|
497
|
+
}
|
|
498
|
+
if (f.decal && f.decal !== "none") {
|
|
499
|
+
this.stack.decals.add(f.decal, x, z, { size: size * 2.6, color: f.decal === "scorch" ? 0x1a1208 : c, life: 7, opacity: 0.7 });
|
|
500
|
+
this.counts.decals++;
|
|
501
|
+
}
|
|
502
|
+
if (f.shake) this.rig.addTrauma(f.shake);
|
|
503
|
+
if (f.hitStop) this.feel.clock.hitStop(f.hitStop * 1000);
|
|
504
|
+
const cue = f.sound && f.sound !== "none" ? CUE[f.sound] : undefined;
|
|
505
|
+
if (cue) this.feel.audio?.play(cue, { screenX: this.project(x, y, z).x, width: this.width });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private player(id: number, cosmetic: number, keeperIndex = 0): PlayerMeshes {
|
|
509
|
+
let p = this.players.get(id);
|
|
510
|
+
if (p && p.cosmetic === cosmetic && p.keeper === keeperIndex) return p;
|
|
511
|
+
if (p) {
|
|
512
|
+
this.scene.remove(p.body, p.light, p.pool);
|
|
513
|
+
this.players.delete(id);
|
|
514
|
+
}
|
|
515
|
+
// A keeper brings its own model and tint (the Disco palette still wins).
|
|
516
|
+
const keeperDef = [...this.game.manifest.keepers.values()][keeperIndex];
|
|
517
|
+
const keeperTint = keeperDef?.tint ? parseInt(keeperDef.tint.slice(1), 16) : null;
|
|
518
|
+
const color = cosmetic === 1 ? DISCO.body : (keeperTint ?? KEEPER_COLORS[(id - 1) % KEEPER_COLORS.length] ?? 0xffffff);
|
|
519
|
+
const glow = cosmetic === 1 ? DISCO.glow : 0xffb347;
|
|
520
|
+
const players = this.game.manifest.game.players;
|
|
521
|
+
const model = this.model(keeperDef?.model ?? players.prefab);
|
|
522
|
+
const a = resolveAsset(model);
|
|
523
|
+
const shape = new Mesh(a.geometry, new MeshStandardMaterial({ color, roughness: 0.45, flatShading: true, emissive: new Color(color).multiplyScalar(0.12) }));
|
|
524
|
+
shape.castShadow = true;
|
|
525
|
+
const body = new Group();
|
|
526
|
+
body.add(shape);
|
|
527
|
+
const lantern = new Mesh(new SphereGeometry(0.16, 10, 8), new MeshBasicMaterial({ color: new Color(glow).multiplyScalar(5) }));
|
|
528
|
+
lantern.position.set(0.35, 0.3, -0.25);
|
|
529
|
+
shape.add(lantern);
|
|
530
|
+
const light = new PointLight(glow, 30, 14, 1.4);
|
|
531
|
+
light.visible = this.switches.lights !== false;
|
|
532
|
+
const pool = new Mesh(new PlaneGeometry(9, 9), new MeshBasicMaterial({ map: this.poolTexture, color: glow, transparent: true, opacity: 0.4, depthWrite: false }));
|
|
533
|
+
pool.rotation.x = -Math.PI / 2;
|
|
534
|
+
this.scene.add(body, light, pool);
|
|
535
|
+
const made: PlayerMeshes = { pool, body, animator: null, downed: false, lantern, light, cosmetic, keeper: keeperIndex, lastWcd: 0, lastFlags: 0, lean: 0, stride: 0, model: null, highlight: null };
|
|
536
|
+
p = made;
|
|
537
|
+
this.players.set(id, p);
|
|
538
|
+
// The library character in the keeper's slot colour (or the Disco palette), carrying the lantern.
|
|
539
|
+
const own = players.appearance;
|
|
540
|
+
const appearance = { ...own, parts: own?.parts ?? [], tint: cosmetic === 1 || keeperTint !== null || !own?.tint ? `#${color.toString(16).padStart(6, "0")}` : own.tint };
|
|
541
|
+
this.library?.character(model, appearance, { height: KEEPER_HEIGHT }).then(
|
|
542
|
+
(c) => {
|
|
543
|
+
if (this.players.get(id) !== made) return;
|
|
544
|
+
body.remove(shape);
|
|
545
|
+
c.root.traverse((o) => {
|
|
546
|
+
o.castShadow = true;
|
|
547
|
+
});
|
|
548
|
+
body.add(c.root);
|
|
549
|
+
lantern.position.set(0.32, KEEPER_HEIGHT * 0.45, -0.25);
|
|
550
|
+
body.add(lantern);
|
|
551
|
+
made.animator = c.animator;
|
|
552
|
+
made.model = c.root as Group;
|
|
553
|
+
},
|
|
554
|
+
(e: unknown) => console.warn(`library: keeper stays a shape (${e instanceof Error ? e.message : String(e)})`),
|
|
555
|
+
);
|
|
556
|
+
return p;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
frame(input: FrameInput): FrameEvents {
|
|
560
|
+
const dt = input.dt;
|
|
561
|
+
this.time += dt;
|
|
562
|
+
const feel = this.feel;
|
|
563
|
+
const out: FrameEvents = { selfHurt: 0, enemiesDrawn: 0 };
|
|
564
|
+
this.risesThisFrame = 0;
|
|
565
|
+
this.downedAt.length = 0;
|
|
566
|
+
this.upAt.length = 0;
|
|
567
|
+
for (const k of this.enemies) for (const c of [k.crowd, ...k.extras]) c.begin();
|
|
568
|
+
for (const c of this.gemCrowds) c.begin();
|
|
569
|
+
this.boltCrowd.begin();
|
|
570
|
+
this.lanceCrowd.begin();
|
|
571
|
+
this.bladeCrowd.begin();
|
|
572
|
+
this.teamBladeCrowd.begin();
|
|
573
|
+
this.threats.begin(dt);
|
|
574
|
+
const enemyTypes = this.config.content.enemyTypes;
|
|
575
|
+
const seeThreat = (id: number, t: ReturnType<typeof threatOf>, friendly = false) => {
|
|
576
|
+
if (!t) return;
|
|
577
|
+
this.threats.draw(t, friendly);
|
|
578
|
+
if (t.kind === "circle" && !t.shot) this.nextTelegraphs.set(id, { x: t.x, z: t.z, r: t.r, p: t.progress, friendly });
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const seenPlayers = new Set<number>();
|
|
582
|
+
const [sx, , sz] = input.self ? pos(input.self) : [this.focus.x, 0, this.focus.z];
|
|
583
|
+
const all = input.self ? [input.self, ...input.others] : input.others;
|
|
584
|
+
const next = this.nextPos;
|
|
585
|
+
next.clear();
|
|
586
|
+
this.nextShots.clear();
|
|
587
|
+
|
|
588
|
+
for (const e of all) {
|
|
589
|
+
const [x, y, z] = pos(e);
|
|
590
|
+
const prev = this.prevPos.get(e.id);
|
|
591
|
+
next.set(e.id, [x, y, z]);
|
|
592
|
+
if (e.kind === EntityKind.Enemy) {
|
|
593
|
+
const type = e.values[F.type] ?? 0;
|
|
594
|
+
const flags = e.values[F.flags] ?? 0;
|
|
595
|
+
const hp = e.values[F.health] ?? 0;
|
|
596
|
+
const kind = this.enemies[type];
|
|
597
|
+
if (!kind) continue;
|
|
598
|
+
// Push direction: away from the nearest keeper we know of (ours).
|
|
599
|
+
let dx = x - sx;
|
|
600
|
+
let dz = z - sz;
|
|
601
|
+
const dl = Math.hypot(dx, dz) || 1;
|
|
602
|
+
dx /= dl;
|
|
603
|
+
dz /= dl;
|
|
604
|
+
if ((flags & EntityFlag.Dead) !== 0) {
|
|
605
|
+
if (!this.dead.has(e.id)) {
|
|
606
|
+
this.dead.add(e.id);
|
|
607
|
+
const near = (x - sx) ** 2 + (z - sz) ** 2 < 49;
|
|
608
|
+
feel.kill({ x, y: 0.4, z, big: kind.big && near, color: kind.color, dirX: dx, dirZ: dz, particles: kind.feel.particles ?? (kind.big ? "bigKill" : "kill") });
|
|
609
|
+
this.playFeel({ ...kind.feel, particles: undefined }, x, 0.4, z, kind.color, kind.radius, dx, dz);
|
|
610
|
+
this.dressing.shatter(x, z, kind.radius, new Color(kind.color), kind.big ? 12 : 3, dx, dz);
|
|
611
|
+
this.counts.bursts++;
|
|
612
|
+
// A boss falls: the world slows for a beat.
|
|
613
|
+
if (kind.boss) {
|
|
614
|
+
feel.clock.slowMo(0.25, 900, 500);
|
|
615
|
+
this.stack.particles.burst("magic", x, 1.2, z, { count: 80, color: 0xffd36b, speed: 9 });
|
|
616
|
+
this.counts.slowMos++;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const last = this.lastHp.get(e.id);
|
|
622
|
+
const crit = e.values[F.timers] ?? 0;
|
|
623
|
+
const lastCrit = this.lastCrit.get(e.id);
|
|
624
|
+
this.lastCrit.set(e.id, crit);
|
|
625
|
+
if (last === undefined && kind.boss && !this.bossesHeard.has(e.id)) {
|
|
626
|
+
// A boss arrives: the bell tolls (once per boss).
|
|
627
|
+
this.bossesHeard.add(e.id);
|
|
628
|
+
feel.audio?.play("boss");
|
|
629
|
+
}
|
|
630
|
+
if (last === undefined && this.risesThisFrame < 6 && (x - sx) ** 2 + (z - sz) ** 2 < 400) {
|
|
631
|
+
// Something rose where we can see it (a summon, a split, a scattered urn): embers from the ground.
|
|
632
|
+
this.risesThisFrame++;
|
|
633
|
+
this.stack.particles.burst("embers", x, 0.2, z, { count: 8, color: 0xff8a3d, speed: 2.5 });
|
|
634
|
+
this.counts.rises++;
|
|
635
|
+
}
|
|
636
|
+
if (last !== undefined && hp < last) {
|
|
637
|
+
this.counts.flashes++;
|
|
638
|
+
const isCrit = lastCrit !== undefined && crit !== lastCrit;
|
|
639
|
+
if (isCrit) this.counts.crits++;
|
|
640
|
+
if (kind.feel.flash !== false || isCrit) {
|
|
641
|
+
feel.hit({ id: isCrit ? -e.id - 1 : e.id, x, y: 1 + (kind.big ? 2 : 0), z, amount: last - hp, big: kind.big || isCrit, dirX: dx, dirZ: dz, ...(isCrit ? { kind: "crit" } : {}) });
|
|
642
|
+
}
|
|
643
|
+
if (this.counts.flashes % 3 === 0) this.stack.particles.burst("hitSpark", x, 0.8, z, { count: 3, dirX: dx, dirZ: dz });
|
|
644
|
+
}
|
|
645
|
+
this.lastHp.set(e.id, hp);
|
|
646
|
+
let yaw = 0;
|
|
647
|
+
let speed = 0;
|
|
648
|
+
if (prev) {
|
|
649
|
+
const mx = x - prev[0];
|
|
650
|
+
const mz = z - prev[2];
|
|
651
|
+
const d2 = mx * mx + mz * mz;
|
|
652
|
+
yaw = d2 > 1e-6 ? Math.atan2(-mx, -mz) : Math.atan2(sx - x, sz - z) + Math.PI;
|
|
653
|
+
speed = dt > 0 ? Math.sqrt(d2) / dt : 0;
|
|
654
|
+
}
|
|
655
|
+
// Scenery between the camera and your keeper is not drawn (it never hides you).
|
|
656
|
+
if (kind.prop && z - sz > -0.3 && z - sz < 1.8 && Math.abs(x - sx) < 1.4 + kind.radius) continue;
|
|
657
|
+
const k = feel.knock.get(e.id);
|
|
658
|
+
const squash = Math.max(0, 1 - k.sy);
|
|
659
|
+
const elite = (flags & EntityFlag.Elite) !== 0;
|
|
660
|
+
for (const c of [kind.crowd, ...kind.extras]) c.push(x + k.x, 0, z + k.z, yaw, phaseOf(e.id), Math.min(speed, 8), squash * 3, feel.flash.value(e.id), elite ? 1.5 : 1);
|
|
661
|
+
const er = enemyTypes[type]?.radius ?? 0.35;
|
|
662
|
+
if (elite) this.threats.mark(x, z, er * 1.5 + 0.35, 0xffc35a);
|
|
663
|
+
if ((flags & EntityFlag.Shielded) !== 0) this.threats.mark(x, z, er + 0.5, 0x6fd3ff);
|
|
664
|
+
const level = e.values[F.level] ?? 0;
|
|
665
|
+
if (level !== 0) seeThreat(e.id, threatOf(enemyTypes, e.kind, flags, type, level, x, z));
|
|
666
|
+
out.enemiesDrawn++;
|
|
667
|
+
} else if (e.kind === EntityKind.Pickup) {
|
|
668
|
+
// Gem tiers 0..2, then special pickups (type PICKUP_SPECIAL + i), which bob higher and spin slower.
|
|
669
|
+
const type = e.values[F.type] ?? 0;
|
|
670
|
+
const special = type >= PICKUP_SPECIAL;
|
|
671
|
+
const gems = this.game.pickupPrefabs.length;
|
|
672
|
+
const crowd = special ? this.gemCrowds[gems + (type - PICKUP_SPECIAL)] : this.gemCrowds[Math.min(gems - 1, type)];
|
|
673
|
+
crowd?.push(x, (special ? 0.6 : 0.35) + Math.sin(this.time * 4 + e.id) * (special ? 0.15 : 0.08), z, this.time * (special ? 1 : 2) + e.id, special ? 1.4 : 1);
|
|
674
|
+
if (special && this.hoardTypes.has(type - PICKUP_SPECIAL)) {
|
|
675
|
+
// A boss's hoard: a gold pulse on the ground, and a burst and a chime when it first drops.
|
|
676
|
+
this.threats.mark(x, z, 1.3 + 0.25 * Math.sin(this.time * 5), 0xffcf5a);
|
|
677
|
+
if (!this.hoardsSeen.has(e.id)) {
|
|
678
|
+
this.hoardsSeen.add(e.id);
|
|
679
|
+
this.stack.particles.burst("magic", x, 1, z, { count: 60, color: 0xffd36b, speed: 7 });
|
|
680
|
+
this.stack.rings.fire(x, z, 6, { color: 0xffcf5a, glow: 2.4, duration: 0.6 });
|
|
681
|
+
feel.audio?.play("coin");
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
} else if (e.kind === EntityKind.Projectile && ((e.values[F.flags] ?? 0) & EntityFlag.Hostile) !== 0) {
|
|
685
|
+
seeThreat(e.id, threatOf(enemyTypes, e.kind, e.values[F.flags] ?? 0, e.values[F.type] ?? 0, e.values[F.level] ?? 0, x, z));
|
|
686
|
+
} else if (e.kind === EntityKind.Projectile) {
|
|
687
|
+
let yaw = 0;
|
|
688
|
+
if (prev) yaw = Math.atan2(-(x - prev[0]), -(z - prev[2]));
|
|
689
|
+
const weapon = e.values[F.type] ?? 0;
|
|
690
|
+
const wdef = this.config.content.weapons[weapon];
|
|
691
|
+
if ((e.values[F.flags] ?? 0) & EntityFlag.Elite) {
|
|
692
|
+
// A keeper's meteor: a gold circle that fills, then lands.
|
|
693
|
+
seeThreat(e.id, threatOf(enemyTypes, EntityKind.Projectile, EntityFlag.Hostile | EntityFlag.Elite, weapon, e.values[F.level] ?? 0, x, z), true);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (wdef?.kind === "boomerang") this.bladeCrowd.push(x, 0.8, z, this.time * 18 + e.id, 1.2);
|
|
697
|
+
else (wdef && wdef.pierce < 3 ? this.boltCrowd : this.lanceCrowd).push(x, 0.8, z, yaw, 1);
|
|
698
|
+
if (!this.shots.has(e.id)) this.stack.particles.burst("sparks", x, 0.8, z, { count: 3, color: 0xffd36b, speed: 3 });
|
|
699
|
+
this.nextShots.set(e.id, weapon);
|
|
700
|
+
} else if (e.kind === EntityKind.Player) {
|
|
701
|
+
seenPlayers.add(e.owner);
|
|
702
|
+
this.drawPlayer(e, x, y, z, prev, input, out);
|
|
703
|
+
const pf = e.values[F.flags] ?? 0;
|
|
704
|
+
if ((pf & EntityFlag.Downed) !== 0) this.downedAt.push([x, z]);
|
|
705
|
+
else if ((pf & EntityFlag.Dead) === 0) this.upAt.push([x, z]);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// The reviver path: dots on the ground from each downed keeper to the nearest one standing.
|
|
709
|
+
for (const [dx, dz] of this.downedAt) {
|
|
710
|
+
let best: [number, number] | null = null;
|
|
711
|
+
let bd = 45 * 45;
|
|
712
|
+
for (const u of this.upAt) {
|
|
713
|
+
const d2 = (u[0] - dx) ** 2 + (u[1] - dz) ** 2;
|
|
714
|
+
if (d2 < bd) {
|
|
715
|
+
bd = d2;
|
|
716
|
+
best = u;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (!best) continue;
|
|
720
|
+
const len = Math.sqrt(bd);
|
|
721
|
+
const n = Math.min(18, Math.floor(len / 1.8));
|
|
722
|
+
const pulse = (this.time * 1.5) % 1;
|
|
723
|
+
for (let i = 1; i < n; i++) {
|
|
724
|
+
const t = (i - pulse) / n;
|
|
725
|
+
this.threats.mark(dx + (best[0] - dx) * t, dz + (best[1] - dz) * t, 0.16, 0xff8a7a);
|
|
726
|
+
}
|
|
727
|
+
this.threats.mark(dx, dz, 1.4 + 0.2 * Math.sin(this.time * 6), 0xff5c5c);
|
|
728
|
+
}
|
|
729
|
+
// Projectiles that vanished hit something (an enemy or a pillar): a spark where they ended.
|
|
730
|
+
for (const [id, weapon] of this.shots) {
|
|
731
|
+
if (this.nextShots.has(id)) continue;
|
|
732
|
+
const p = this.prevPos.get(id);
|
|
733
|
+
if (!p) continue;
|
|
734
|
+
const wf = this.game.manifest.weapons.get(this.game.weaponIds[weapon] ?? "")?.feel;
|
|
735
|
+
this.stack.particles.burst(wf?.particles ?? "hitSpark", p[0], 0.8, p[2], { count: 6, color: hex(wf?.color, 0xffe08a) });
|
|
736
|
+
if (wf) this.playFeel({ ...wf, particles: undefined }, p[0], 0.8, p[2], 0xffe08a, 0.4);
|
|
737
|
+
this.counts.impacts++;
|
|
738
|
+
}
|
|
739
|
+
[this.shots, this.nextShots] = [this.nextShots, this.shots];
|
|
740
|
+
this.prevPos = next;
|
|
741
|
+
this.nextPos = new Map();
|
|
742
|
+
// Forget hp for entities no longer in view.
|
|
743
|
+
if (this.lastHp.size > 2000) {
|
|
744
|
+
for (const id of this.lastHp.keys()) if (!next.has(id)) this.lastHp.delete(id);
|
|
745
|
+
for (const id of this.dead) if (!next.has(id)) this.dead.delete(id);
|
|
746
|
+
}
|
|
747
|
+
for (const [id, p] of this.players) {
|
|
748
|
+
if (seenPlayers.has(id)) continue;
|
|
749
|
+
this.scene.remove(p.body, p.light, p.pool);
|
|
750
|
+
this.players.delete(id);
|
|
751
|
+
}
|
|
752
|
+
this.dressing.keepOnly(seenPlayers);
|
|
753
|
+
this.dressing.update(dt);
|
|
754
|
+
for (const hit of this.dressing.impacts) {
|
|
755
|
+
if (hit.speed > 5) feel.audio?.play("land", { gain: Math.min(1, hit.speed / 12), screenX: this.project(hit.x, hit.y, hit.z).x, width: this.width });
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Embers rising off the pillar braziers.
|
|
759
|
+
for (const [fx0, fy, fz] of this.flames) {
|
|
760
|
+
if ((fx0 - sx) ** 2 + (fz - sz) ** 2 < 900) this.stack.particles.emit("embers", fx0, fy, fz, 5, dt, { color: 0xffa040 });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const now = this.stack.time + dt;
|
|
764
|
+
for (const k of this.enemies) for (const c of [k.crowd, ...k.extras]) c.end(now);
|
|
765
|
+
for (const c of this.gemCrowds) c.end();
|
|
766
|
+
this.boltCrowd.end();
|
|
767
|
+
this.lanceCrowd.end();
|
|
768
|
+
this.bladeCrowd.end();
|
|
769
|
+
this.teamBladeCrowd.end();
|
|
770
|
+
this.threats.end();
|
|
771
|
+
// A circle that was nearly full and is gone has landed: an impact ring where it hit.
|
|
772
|
+
for (const [id, t] of this.telegraphs) {
|
|
773
|
+
if (!this.nextTelegraphs.has(id) && t.p >= 0.8) this.stack.rings.fire(t.x, t.z, t.r, { color: t.friendly ? 0xffd36b : 0xff6a3c, duration: 0.4, glow: 2 });
|
|
774
|
+
}
|
|
775
|
+
[this.telegraphs, this.nextTelegraphs] = [this.nextTelegraphs, this.telegraphs];
|
|
776
|
+
this.nextTelegraphs.clear();
|
|
777
|
+
|
|
778
|
+
if (input.self) {
|
|
779
|
+
const [x, y, z] = pos(input.self);
|
|
780
|
+
this.focus.set(x, y, z);
|
|
781
|
+
}
|
|
782
|
+
this.rig.yaw = 0;
|
|
783
|
+
this.rig.update(this.focus, dt, input.realDt);
|
|
784
|
+
this.fadePillars(dt);
|
|
785
|
+
// Walls between the camera and your player open up (x-ray cut).
|
|
786
|
+
this.level.cut.update(this.rig.camera.position, this.focus);
|
|
787
|
+
// (A party has no fog of war: every floor is lit from the start.)
|
|
788
|
+
this.level.update(dt, [...this.upAt, ...this.downedAt]);
|
|
789
|
+
this.stack.post.hurt.value = input.hurt;
|
|
790
|
+
this.stack.frame(dt, this.focus);
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Pillars between the camera (south, looking north and down) and your keeper fade to a ghost. */
|
|
795
|
+
private fadePillars(dt: number): void {
|
|
796
|
+
const reach = 1 / Math.tan(this.rig.pitch);
|
|
797
|
+
const hx = this.focus.x;
|
|
798
|
+
const hz = this.focus.z;
|
|
799
|
+
for (const p of this.pillars) {
|
|
800
|
+
const dz = p.z - hz;
|
|
801
|
+
const covers = dz > -p.r && dz < p.h * reach + p.r && Math.abs(p.x - hx) < p.r + 0.9;
|
|
802
|
+
const goal = covers ? 0.25 : 1;
|
|
803
|
+
p.fade += (goal - p.fade) * Math.min(1, dt * 8);
|
|
804
|
+
const m = p.material;
|
|
805
|
+
const see = p.fade < 0.985;
|
|
806
|
+
if (m.transparent !== see) {
|
|
807
|
+
m.transparent = see;
|
|
808
|
+
m.depthWrite = !see;
|
|
809
|
+
m.needsUpdate = true;
|
|
810
|
+
}
|
|
811
|
+
m.opacity = see ? p.fade : 1;
|
|
812
|
+
for (const t of p.top) t.visible = p.fade > 0.6;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private drawPlayer(e: SceneEntity, x: number, y: number, z: number, prev: [number, number, number] | undefined, input: FrameInput, out: FrameEvents): void {
|
|
817
|
+
const flags = e.values[F.flags] ?? 0;
|
|
818
|
+
const pm = this.player(e.owner, e.values[F.type] ?? 0, ((e.values[F.value] ?? 0) >>> 12) & 0xf);
|
|
819
|
+
const downed = (flags & EntityFlag.Downed) !== 0;
|
|
820
|
+
const dead = (flags & EntityFlag.Dead) !== 0;
|
|
821
|
+
const dashing = (flags & EntityFlag.Dashing) !== 0;
|
|
822
|
+
const dt = input.dt;
|
|
823
|
+
const mine = e.owner === input.selfPlayer;
|
|
824
|
+
// Procedural locomotion: stride bob and a lean into the run.
|
|
825
|
+
let speed = 0;
|
|
826
|
+
if (prev && dt > 0) speed = Math.hypot(x - prev[0], z - prev[2]) / dt;
|
|
827
|
+
pm.stride += dt * (2 + speed * 1.4);
|
|
828
|
+
pm.lean += (Math.min(speed, 12) * 0.035 - pm.lean) * Math.min(1, dt * 10);
|
|
829
|
+
const bob = Math.abs(Math.sin(pm.stride * Math.PI)) * Math.min(1, speed / 6) * 0.12;
|
|
830
|
+
if (pm.animator) {
|
|
831
|
+
// A library character stands on its feet (the sim's y is the capsule centre, 0.9 m up) and animates itself.
|
|
832
|
+
pm.body.position.set(x, Math.max(0, y - 0.9), z);
|
|
833
|
+
pm.body.rotation.set(downed ? 0 : -pm.lean * 0.5, e.values[F.yaw] ?? 0, 0, "YXZ");
|
|
834
|
+
if (downed && !pm.downed) pm.animator.play("die", { hold: true });
|
|
835
|
+
else if (!downed && pm.downed) pm.animator.play("idle");
|
|
836
|
+
if (!downed) pm.animator.setSpeed(speed);
|
|
837
|
+
pm.animator.update(dt);
|
|
838
|
+
} else {
|
|
839
|
+
pm.body.position.set(x, (downed ? 0.45 : y) + bob, z);
|
|
840
|
+
pm.body.rotation.set(downed ? Math.PI / 2 : -pm.lean, e.values[F.yaw] ?? 0, 0, "YXZ");
|
|
841
|
+
}
|
|
842
|
+
pm.downed = downed;
|
|
843
|
+
// Your own keeper is the brightest thing on screen: a dark outline and a warm rim on the character.
|
|
844
|
+
if (mine && pm.model && pm.highlight?.root !== pm.model) {
|
|
845
|
+
pm.highlight?.dispose();
|
|
846
|
+
pm.highlight = new HeroHighlight(pm.model, { rim: 0xffd27a, rimStrength: 0.7, rimPower: 3, outline: 0x140a02, outlineWidth: 0.05 });
|
|
847
|
+
}
|
|
848
|
+
if (pm.highlight) pm.highlight.strength = downed ? 0.4 : 1;
|
|
849
|
+
const iframes = (e.values[F.iframes] ?? 0) > 0;
|
|
850
|
+
pm.body.visible = !dead && !(iframes && Math.floor(this.time * 20) % 2 === 0 && !dashing);
|
|
851
|
+
pm.light.position.set(x, 2.2, z);
|
|
852
|
+
pm.pool.position.set(x, 0.03, z);
|
|
853
|
+
pm.pool.visible = !dead;
|
|
854
|
+
pm.light.intensity = dead ? 0 : downed ? 6 + Math.sin(this.time * 6) * 4 : 22;
|
|
855
|
+
this.dressing.keeper(e.owner, x, 1, z, !dead && !downed);
|
|
856
|
+
// Dash: dust, a ring and (for us) the kit's dash punch.
|
|
857
|
+
if (dashing && (pm.lastFlags & EntityFlag.Dashing) === 0) {
|
|
858
|
+
this.stack.particles.burst("dust", x, 0.1, z, { count: 10 });
|
|
859
|
+
if (mine) this.feel.dash();
|
|
860
|
+
}
|
|
861
|
+
pm.lastFlags = flags;
|
|
862
|
+
if (mine) this.feel.footstep(dead || downed ? 0 : speed);
|
|
863
|
+
// A weapon fired when its cooldown jumped up since last frame. Sizes come from the sim's own scaling.
|
|
864
|
+
const wcd = e.values[F.wcd] ?? 0;
|
|
865
|
+
const weapons = e.values[F.weapons] ?? 0;
|
|
866
|
+
const mods = weaponModsFor(this.config, e.values[F.stats] ?? 0);
|
|
867
|
+
// Teammates' area effects draw at a third of the brightness: yours stay loud, theirs never wash over you or the HUD.
|
|
868
|
+
const fxk = mine ? 1 : TEAM_FX;
|
|
869
|
+
for (let s = 0; s < 4; s++) {
|
|
870
|
+
const wi = slotWeapon(weapons, s);
|
|
871
|
+
const def = wi >= 0 ? this.config.content.weapons[wi] : undefined;
|
|
872
|
+
if (!def) continue;
|
|
873
|
+
const lvl = slotLevel(weapons, s);
|
|
874
|
+
const sc = scaledWeapon(def, lvl, mods);
|
|
875
|
+
const fired = ((wcd >>> (s * 8)) & 0xff) > ((pm.lastWcd >>> (s * 8)) & 0xff) + 1;
|
|
876
|
+
if (def.kind === "aura" && !dead && !downed) {
|
|
877
|
+
// The field you carry: a steady ring at its edge, a soft pulse when it bites.
|
|
878
|
+
this.threats.mark(x, z, sc.range, mine ? 0xffa040 : dim(0xffa040, fxk));
|
|
879
|
+
if (fired) this.stack.rings.fire(x, z, sc.range * 2, { color: 0xffa040, duration: 0.25, glow: 1.2 * fxk });
|
|
880
|
+
} else if (def.kind === "cone" && fired) {
|
|
881
|
+
// A sweep where the keeper faces: sparks across the arc.
|
|
882
|
+
const yaw = e.values[F.yaw] ?? 0;
|
|
883
|
+
const fx0 = -Math.sin(yaw);
|
|
884
|
+
const fz0 = -Math.cos(yaw);
|
|
885
|
+
const base = Math.atan2(fz0, fx0);
|
|
886
|
+
const n = Math.max(5, Math.round((sc.spread / Math.PI) * 8));
|
|
887
|
+
for (let i = 0; i < n; i++) {
|
|
888
|
+
const a = base + (i / Math.max(1, n - 1) - 0.5) * sc.spread;
|
|
889
|
+
this.stack.particles.burst("hitSpark", x + Math.cos(a) * sc.range * 0.8, 0.6, z + Math.sin(a) * sc.range * 0.8, { count: mine ? 3 : 1, color: mine ? 0xffc070 : dim(0xffc070, fxk), speed: 2 });
|
|
890
|
+
}
|
|
891
|
+
} else if (def.kind === "chain" && fired) {
|
|
892
|
+
// Lightning to the nearest enemies in reach (the sim picks the same nearest first).
|
|
893
|
+
let lx = x;
|
|
894
|
+
let lz = z;
|
|
895
|
+
const taken = new Set<number>();
|
|
896
|
+
for (let j = 0; j <= sc.count; j++) {
|
|
897
|
+
let best: SceneEntity | undefined;
|
|
898
|
+
let bd = (j === 0 ? sc.range : Math.max(2.5, sc.speed)) ** 2;
|
|
899
|
+
for (const o of input.others) {
|
|
900
|
+
if (o.kind !== EntityKind.Enemy || taken.has(o.id)) continue;
|
|
901
|
+
const [ox, , oz] = pos(o);
|
|
902
|
+
const d2 = (ox - lx) ** 2 + (oz - lz) ** 2;
|
|
903
|
+
if (d2 < bd) {
|
|
904
|
+
bd = d2;
|
|
905
|
+
best = o;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (!best) break;
|
|
909
|
+
taken.add(best.id);
|
|
910
|
+
const [bx, , bz] = pos(best);
|
|
911
|
+
for (let t = 0.25; t < 1; t += 0.25) this.stack.particles.burst("sparks", lx + (bx - lx) * t, 0.9, lz + (bz - lz) * t, { count: 2, color: 0x9fd8ff, speed: 1.5 });
|
|
912
|
+
lx = bx;
|
|
913
|
+
lz = bz;
|
|
914
|
+
}
|
|
915
|
+
} else if (def.kind === "nova") {
|
|
916
|
+
if (fired) {
|
|
917
|
+
const r = sc.range;
|
|
918
|
+
const wf = this.game.manifest.weapons.get(this.game.weaponIds[wi] ?? "")?.feel;
|
|
919
|
+
const color = hex(wf?.color, 0xffb347);
|
|
920
|
+
this.stack.rings.fire(x, z, r * 2, { color, glow: 2.2 * fxk });
|
|
921
|
+
this.stack.particles.burst(wf?.particles ?? "magic", x, 0.4, z, { count: mine ? 14 : 4, color: mine ? color : dim(color, fxk), speed: r * 2.2, spread: 1 });
|
|
922
|
+
this.stack.decals.add(wf?.decal && wf.decal !== "none" ? wf.decal : "ring", x, z, { size: r * 2, color, life: 2.5, opacity: 0.5 * fxk, glow: 1.6 * fxk });
|
|
923
|
+
this.dressing.blast(x, z, r + 0.6, 4 + def.knockback * 0.6);
|
|
924
|
+
if (mine && wf?.shake) this.rig.addTrauma(wf.shake);
|
|
925
|
+
this.counts.rings++;
|
|
926
|
+
}
|
|
927
|
+
} else if (def.kind === "orbit" && !dead && !downed) {
|
|
928
|
+
for (let b = 0; b < sc.count; b++) {
|
|
929
|
+
const a = orbitAngle(input.worldTick, this.config.tickRate, sc.speed, b, sc.count);
|
|
930
|
+
(mine ? this.bladeCrowd : this.teamBladeCrowd).push(x + Math.cos(a) * sc.range, 0.7, z + Math.sin(a) * sc.range, -a, 1);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
pm.lastWcd = wcd;
|
|
935
|
+
if (mine) {
|
|
936
|
+
const hp = e.values[F.health] ?? 0;
|
|
937
|
+
if (this.selfHp >= 0 && hp < this.selfHp) out.selfHurt += this.selfHp - hp;
|
|
938
|
+
this.selfHp = hp;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|