@netforge/template-arena 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 +75 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +34 -0
- package/README.md +82 -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-full.txt +346 -0
- package/llms.txt +164 -0
- package/package.json +80 -3
- package/src/game.ts +117 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +109 -0
package/client/input.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twin-stick input for VOLTYARD.
|
|
3
|
+
*
|
|
4
|
+
* - Keyboard and mouse: WASD or arrows move, the mouse aims, hold the left
|
|
5
|
+
* button to fire, 1 2 3 pick a weapon, Q or the wheel cycles, Space or
|
|
6
|
+
* Shift dashes.
|
|
7
|
+
* - Gamepad: left stick moves, right stick aims (pushing it far fires),
|
|
8
|
+
* RT fires, LB or RB cycles, A dashes. Rumble on hits.
|
|
9
|
+
* - Touch: a stick on the left half moves, a stick on the right half aims
|
|
10
|
+
* and fires while pushed; SWAP and DASH buttons.
|
|
11
|
+
*
|
|
12
|
+
* The sim's frame: moveX is +x (screen right), moveZ is -z (screen up),
|
|
13
|
+
* yaw is the aim (0 aims up the screen). Presses are held two ticks so the
|
|
14
|
+
* sim sees the edge even if a tick is skipped.
|
|
15
|
+
*/
|
|
16
|
+
import { Button } from "@netforge/engine-sim";
|
|
17
|
+
import type { InputCommand } from "@netforge/engine-sim";
|
|
18
|
+
|
|
19
|
+
/** Screen point (CSS px) to an aim yaw from our character, or null before the first frame. */
|
|
20
|
+
export type AimFrom = (clientX: number, clientY: number) => number | null;
|
|
21
|
+
|
|
22
|
+
export class ArenaInput {
|
|
23
|
+
private readonly keys = new Set<string>();
|
|
24
|
+
private mouseX = -1;
|
|
25
|
+
private mouseY = -1;
|
|
26
|
+
private mouseDown = false;
|
|
27
|
+
private yaw = 0;
|
|
28
|
+
private moveStick = { x: 0, y: 0 };
|
|
29
|
+
private aimStick = { x: 0, y: 0, on: false };
|
|
30
|
+
private dashHeld = false;
|
|
31
|
+
private pressBits = 0;
|
|
32
|
+
private pressTicks = 0;
|
|
33
|
+
private readonly cleanup: (() => void)[] = [];
|
|
34
|
+
readonly touch: boolean;
|
|
35
|
+
private pad: Gamepad | null = null;
|
|
36
|
+
private padPrev = 0;
|
|
37
|
+
/** Mouse aim is live (the cursor moved over the game). */
|
|
38
|
+
private mouseAim = false;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly root: HTMLElement,
|
|
42
|
+
private readonly aimFrom: AimFrom,
|
|
43
|
+
) {
|
|
44
|
+
this.touch = globalThis.matchMedia?.("(pointer: coarse)").matches || "ontouchstart" in globalThis;
|
|
45
|
+
const down = (e: KeyboardEvent) => {
|
|
46
|
+
if (e.code === "Space" || e.code.startsWith("Arrow")) e.preventDefault();
|
|
47
|
+
if (e.repeat) return;
|
|
48
|
+
this.keys.add(e.code);
|
|
49
|
+
if (e.code === "Digit1" || e.code === "Numpad1") this.press(Button.Choice1);
|
|
50
|
+
if (e.code === "Digit2" || e.code === "Numpad2") this.press(Button.Choice2);
|
|
51
|
+
if (e.code === "Digit3" || e.code === "Numpad3") this.press(Button.Choice3);
|
|
52
|
+
if (e.code === "KeyQ" || e.code === "KeyE") this.press(Button.Alt);
|
|
53
|
+
};
|
|
54
|
+
const up = (e: KeyboardEvent) => this.keys.delete(e.code);
|
|
55
|
+
const blur = () => {
|
|
56
|
+
this.keys.clear();
|
|
57
|
+
this.mouseDown = false;
|
|
58
|
+
};
|
|
59
|
+
const move = (e: PointerEvent) => {
|
|
60
|
+
if (e.pointerType !== "mouse") return;
|
|
61
|
+
this.mouseX = e.clientX;
|
|
62
|
+
this.mouseY = e.clientY;
|
|
63
|
+
this.mouseAim = true;
|
|
64
|
+
};
|
|
65
|
+
const pdown = (e: PointerEvent) => {
|
|
66
|
+
if (e.pointerType !== "mouse" || e.button !== 0) return;
|
|
67
|
+
if ((e.target as HTMLElement | null)?.closest?.("button, aside, .panel")) return;
|
|
68
|
+
this.mouseDown = true;
|
|
69
|
+
this.mouseX = e.clientX;
|
|
70
|
+
this.mouseY = e.clientY;
|
|
71
|
+
this.mouseAim = true;
|
|
72
|
+
};
|
|
73
|
+
const pup = (e: PointerEvent) => {
|
|
74
|
+
if (e.pointerType === "mouse" && e.button === 0) this.mouseDown = false;
|
|
75
|
+
};
|
|
76
|
+
const wheel = (e: WheelEvent) => {
|
|
77
|
+
if (Math.abs(e.deltaY) > 4) this.press(Button.Alt);
|
|
78
|
+
};
|
|
79
|
+
const menu = (e: Event) => e.preventDefault();
|
|
80
|
+
addEventListener("keydown", down);
|
|
81
|
+
addEventListener("keyup", up);
|
|
82
|
+
addEventListener("blur", blur);
|
|
83
|
+
addEventListener("pointermove", move);
|
|
84
|
+
addEventListener("pointerdown", pdown);
|
|
85
|
+
addEventListener("pointerup", pup);
|
|
86
|
+
addEventListener("wheel", wheel, { passive: true });
|
|
87
|
+
root.addEventListener("contextmenu", menu);
|
|
88
|
+
this.cleanup.push(() => {
|
|
89
|
+
removeEventListener("keydown", down);
|
|
90
|
+
removeEventListener("keyup", up);
|
|
91
|
+
removeEventListener("blur", blur);
|
|
92
|
+
removeEventListener("pointermove", move);
|
|
93
|
+
removeEventListener("pointerdown", pdown);
|
|
94
|
+
removeEventListener("pointerup", pup);
|
|
95
|
+
removeEventListener("wheel", wheel);
|
|
96
|
+
root.removeEventListener("contextmenu", menu);
|
|
97
|
+
});
|
|
98
|
+
if (this.touch) void this.bindTouch();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Press a button for the next two ticks (weapon picks from keys, the HUD and pads). */
|
|
102
|
+
press(bits: number): void {
|
|
103
|
+
this.pressBits |= bits;
|
|
104
|
+
this.pressTicks = 2;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Is the player firing right now (for the crosshair). */
|
|
108
|
+
get firing(): boolean {
|
|
109
|
+
return this.mouseDown || (this.aimStick.on && Math.hypot(this.aimStick.x, this.aimStick.y) > 0.5);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async bindTouch(): Promise<void> {
|
|
113
|
+
const nipple = await import("nipplejs");
|
|
114
|
+
const left = document.createElement("div");
|
|
115
|
+
left.className = "stick-zone left";
|
|
116
|
+
const right = document.createElement("div");
|
|
117
|
+
right.className = "stick-zone right";
|
|
118
|
+
this.root.append(left, right);
|
|
119
|
+
const mover = nipple.create({ zone: left, mode: "dynamic", color: "rgba(22,224,255,0.7)", size: 120 });
|
|
120
|
+
mover.on("move", (evt) => {
|
|
121
|
+
this.moveStick = { x: evt.data.vector.x, y: evt.data.vector.y };
|
|
122
|
+
});
|
|
123
|
+
mover.on("end", () => {
|
|
124
|
+
this.moveStick = { x: 0, y: 0 };
|
|
125
|
+
});
|
|
126
|
+
const aimer = nipple.create({ zone: right, mode: "dynamic", color: "rgba(255,79,216,0.7)", size: 120 });
|
|
127
|
+
aimer.on("move", (evt) => {
|
|
128
|
+
this.aimStick = { x: evt.data.vector.x, y: evt.data.vector.y, on: true };
|
|
129
|
+
this.mouseAim = false;
|
|
130
|
+
});
|
|
131
|
+
aimer.on("end", () => {
|
|
132
|
+
this.aimStick = { x: 0, y: 0, on: false };
|
|
133
|
+
});
|
|
134
|
+
const button = (label: string, cls: string, down: () => void, upFn?: () => void) => {
|
|
135
|
+
const b = document.createElement("button");
|
|
136
|
+
b.className = `touch-button ${cls}`;
|
|
137
|
+
b.textContent = label;
|
|
138
|
+
b.addEventListener("pointerdown", (e) => {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
down();
|
|
142
|
+
});
|
|
143
|
+
if (upFn) for (const ev of ["pointerup", "pointercancel", "pointerleave"]) b.addEventListener(ev, upFn);
|
|
144
|
+
this.root.append(b);
|
|
145
|
+
return b;
|
|
146
|
+
};
|
|
147
|
+
const dash = button("DASH", "dash", () => (this.dashHeld = true), () => (this.dashHeld = false));
|
|
148
|
+
const swap = button("SWAP", "swap", () => this.press(Button.Alt));
|
|
149
|
+
this.cleanup.push(() => {
|
|
150
|
+
mover.destroy();
|
|
151
|
+
aimer.destroy();
|
|
152
|
+
left.remove();
|
|
153
|
+
right.remove();
|
|
154
|
+
dash.remove();
|
|
155
|
+
swap.remove();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private readPad(): { x: number; y: number; aimX: number; aimY: number; buttons: number } | null {
|
|
160
|
+
const pads = navigator.getGamepads?.() ?? [];
|
|
161
|
+
this.pad = null;
|
|
162
|
+
for (const p of pads) if (p && p.connected) this.pad = p;
|
|
163
|
+
const p = this.pad;
|
|
164
|
+
if (!p) return null;
|
|
165
|
+
const dz = (v: number) => (Math.abs(v) < 0.2 ? 0 : v);
|
|
166
|
+
const held = (i: number) => (p.buttons[i]?.pressed ? 1 << i : 0);
|
|
167
|
+
const now = held(0) | held(4) | held(5) | held(7);
|
|
168
|
+
const pressed = now & ~this.padPrev;
|
|
169
|
+
this.padPrev = now;
|
|
170
|
+
let buttons = 0;
|
|
171
|
+
if (now & 1) buttons |= Button.Sprint;
|
|
172
|
+
if (now & (1 << 7)) buttons |= Button.Fire;
|
|
173
|
+
if (pressed & ((1 << 4) | (1 << 5))) this.press(Button.Alt);
|
|
174
|
+
return { x: dz(p.axes[0] ?? 0), y: dz(p.axes[1] ?? 0), aimX: dz(p.axes[2] ?? 0), aimY: dz(p.axes[3] ?? 0), buttons };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Controller rumble (Gamepad API), when a pad that supports it is connected. */
|
|
178
|
+
rumble(strength: number, ms: number): void {
|
|
179
|
+
const act = (this.pad as (Gamepad & { vibrationActuator?: { playEffect?: (t: string, p: object) => Promise<unknown> } }) | null)?.vibrationActuator;
|
|
180
|
+
void act?.playEffect?.("dual-rumble", { duration: ms, strongMagnitude: strength, weakMagnitude: strength * 0.6 }).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The current aim, radians (0 aims up the screen). */
|
|
184
|
+
get aim(): number {
|
|
185
|
+
return this.yaw;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One command for the next fixed tick. */
|
|
189
|
+
sample(tick: number): InputCommand {
|
|
190
|
+
const k = this.keys;
|
|
191
|
+
let mx = (k.has("KeyD") || k.has("ArrowRight") ? 1 : 0) - (k.has("KeyA") || k.has("ArrowLeft") ? 1 : 0);
|
|
192
|
+
let my = (k.has("KeyW") || k.has("ArrowUp") ? 1 : 0) - (k.has("KeyS") || k.has("ArrowDown") ? 1 : 0);
|
|
193
|
+
let buttons = 0;
|
|
194
|
+
if (k.has("Space") || k.has("ShiftLeft") || k.has("ShiftRight") || this.dashHeld) buttons |= Button.Sprint;
|
|
195
|
+
if (this.mouseDown) buttons |= Button.Fire;
|
|
196
|
+
if (this.mouseAim && this.mouseX >= 0) {
|
|
197
|
+
const y = this.aimFrom(this.mouseX, this.mouseY);
|
|
198
|
+
if (y !== null) this.yaw = y;
|
|
199
|
+
}
|
|
200
|
+
const pad = this.readPad();
|
|
201
|
+
if (pad) {
|
|
202
|
+
if (pad.x !== 0 || pad.y !== 0) {
|
|
203
|
+
mx = pad.x;
|
|
204
|
+
my = -pad.y;
|
|
205
|
+
}
|
|
206
|
+
if (pad.aimX !== 0 || pad.aimY !== 0) {
|
|
207
|
+
// Stick down is +z on screen.
|
|
208
|
+
this.yaw = Math.atan2(-pad.aimX, -pad.aimY);
|
|
209
|
+
this.mouseAim = false;
|
|
210
|
+
if (Math.hypot(pad.aimX, pad.aimY) > 0.85) buttons |= Button.Fire;
|
|
211
|
+
}
|
|
212
|
+
buttons |= pad.buttons;
|
|
213
|
+
}
|
|
214
|
+
if (this.moveStick.x !== 0 || this.moveStick.y !== 0) {
|
|
215
|
+
mx = this.moveStick.x;
|
|
216
|
+
my = this.moveStick.y;
|
|
217
|
+
}
|
|
218
|
+
if (this.aimStick.on && (this.aimStick.x !== 0 || this.aimStick.y !== 0)) {
|
|
219
|
+
// nipplejs: y is up the screen, which is -z.
|
|
220
|
+
this.yaw = Math.atan2(-this.aimStick.x, this.aimStick.y);
|
|
221
|
+
if (Math.hypot(this.aimStick.x, this.aimStick.y) > 0.5) buttons |= Button.Fire;
|
|
222
|
+
}
|
|
223
|
+
if (this.pressTicks > 0) {
|
|
224
|
+
buttons |= this.pressBits;
|
|
225
|
+
if (--this.pressTicks === 0) this.pressBits = 0;
|
|
226
|
+
}
|
|
227
|
+
return { seq: 0, tick, moveX: mx, moveZ: my, yaw: this.yaw, buttons };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
dispose(): void {
|
|
231
|
+
for (const f of this.cleanup.splice(0)) f();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fallback shapes. Every library id the game
|
|
3
|
+
* references is drawn from the per-style asset library
|
|
4
|
+
* (@netforge/asset-library: real CC0 models, see scene.ts); these
|
|
5
|
+
* primitives are what the scene shows until a model loads, and what it
|
|
6
|
+
* keeps when the library is not built or not reachable, so the game always
|
|
7
|
+
* draws. The keys are the template's original ids, which the library keeps
|
|
8
|
+
* as aliases.
|
|
9
|
+
*/
|
|
10
|
+
import { assetById } from "@netforge/asset-library";
|
|
11
|
+
import {
|
|
12
|
+
BoxGeometry,
|
|
13
|
+
BufferAttribute,
|
|
14
|
+
BufferGeometry,
|
|
15
|
+
CapsuleGeometry,
|
|
16
|
+
Color,
|
|
17
|
+
ConeGeometry,
|
|
18
|
+
CylinderGeometry,
|
|
19
|
+
IcosahedronGeometry,
|
|
20
|
+
Material,
|
|
21
|
+
MeshStandardMaterial,
|
|
22
|
+
OctahedronGeometry,
|
|
23
|
+
TetrahedronGeometry,
|
|
24
|
+
} from "three/webgpu";
|
|
25
|
+
|
|
26
|
+
/** Concatenate geometries (positions and normals) into one draw. */
|
|
27
|
+
export function merge(parts: readonly BufferGeometry[]): BufferGeometry {
|
|
28
|
+
const flat = parts.map((p) => (p.index ? p.toNonIndexed() : p));
|
|
29
|
+
let n = 0;
|
|
30
|
+
for (const p of flat) n += p.getAttribute("position").count;
|
|
31
|
+
const pos = new Float32Array(n * 3);
|
|
32
|
+
const nor = new Float32Array(n * 3);
|
|
33
|
+
let o = 0;
|
|
34
|
+
for (const p of flat) {
|
|
35
|
+
p.computeVertexNormals();
|
|
36
|
+
const a = p.getAttribute("position");
|
|
37
|
+
const b = p.getAttribute("normal");
|
|
38
|
+
for (let i = 0; i < a.count; i++) {
|
|
39
|
+
pos[(o + i) * 3] = a.getX(i);
|
|
40
|
+
pos[(o + i) * 3 + 1] = a.getY(i);
|
|
41
|
+
pos[(o + i) * 3 + 2] = a.getZ(i);
|
|
42
|
+
nor[(o + i) * 3] = b.getX(i);
|
|
43
|
+
nor[(o + i) * 3 + 1] = b.getY(i);
|
|
44
|
+
nor[(o + i) * 3 + 2] = b.getZ(i);
|
|
45
|
+
}
|
|
46
|
+
o += a.count;
|
|
47
|
+
}
|
|
48
|
+
const g = new BufferGeometry();
|
|
49
|
+
g.setAttribute("position", new BufferAttribute(pos, 3));
|
|
50
|
+
g.setAttribute("normal", new BufferAttribute(nor, 3));
|
|
51
|
+
return g;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LibraryAsset {
|
|
55
|
+
readonly geometry: BufferGeometry;
|
|
56
|
+
readonly material: Material;
|
|
57
|
+
/** Height of the model's origin above the floor, m. */
|
|
58
|
+
readonly lift: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const mat = (color: number, emissive = 0x000000, emissiveIntensity = 1, roughness = 0.7, metalness = 0.1) =>
|
|
62
|
+
new MeshStandardMaterial({ color: new Color(color), emissive: new Color(emissive), emissiveIntensity, roughness, metalness, flatShading: true });
|
|
63
|
+
|
|
64
|
+
/** A figure facing -z: torso capsule, a visor, shoulder pads and a gun barrel forward. */
|
|
65
|
+
function figure(scale: number, bulk: number): BufferGeometry {
|
|
66
|
+
const torso = new CapsuleGeometry(0.36 * bulk, 0.7, 4, 8);
|
|
67
|
+
torso.translate(0, 0.75, 0);
|
|
68
|
+
const visor = new BoxGeometry(0.42, 0.14, 0.2);
|
|
69
|
+
visor.translate(0, 1.25, -0.22);
|
|
70
|
+
const padL = new BoxGeometry(0.22, 0.16, 0.3);
|
|
71
|
+
padL.translate(-0.38 * bulk, 1.05, 0);
|
|
72
|
+
const padR = padL.clone();
|
|
73
|
+
padR.translate(0.76 * bulk, 0, 0);
|
|
74
|
+
const barrel = new CylinderGeometry(0.06, 0.08, 0.85, 6);
|
|
75
|
+
barrel.rotateX(Math.PI / 2);
|
|
76
|
+
barrel.translate(0.22, 0.85, -0.55);
|
|
77
|
+
const pack = new BoxGeometry(0.4, 0.5, 0.2);
|
|
78
|
+
pack.translate(0, 0.9, 0.32);
|
|
79
|
+
const g = merge([torso, visor, padL, padR, barrel, pack]);
|
|
80
|
+
g.scale(scale, scale, scale);
|
|
81
|
+
return g;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const builders: Record<string, (radius: number) => LibraryAsset> = {
|
|
85
|
+
wirejack: () => ({ geometry: figure(1, 1), material: mat(0xffffff, 0x000000, 1, 0.45, 0.35), lift: 0 }),
|
|
86
|
+
bruiser: () => ({ geometry: figure(1.08, 1.25), material: mat(0xffffff, 0x000000, 1, 0.5, 0.4), lift: 0 }),
|
|
87
|
+
volt_cell: () => {
|
|
88
|
+
const g = new OctahedronGeometry(0.3);
|
|
89
|
+
g.scale(1, 1.5, 1);
|
|
90
|
+
return { geometry: g, material: mat(0x7dff9a, 0x2bff6a, 1.8, 0.25), lift: 0.6 };
|
|
91
|
+
},
|
|
92
|
+
spark_cell: () => {
|
|
93
|
+
const g = new IcosahedronGeometry(0.22);
|
|
94
|
+
return { geometry: g, material: mat(0x7ad7ff, 0x1e9bff, 1.8, 0.25), lift: 0.5 };
|
|
95
|
+
},
|
|
96
|
+
drone: (r) => {
|
|
97
|
+
const g = new ConeGeometry(r * 1.1, r * 1.6, 4);
|
|
98
|
+
g.rotateX(Math.PI / 2);
|
|
99
|
+
g.translate(0, r * 1.4, 0);
|
|
100
|
+
return { geometry: g, material: mat(0xff5a4f, 0x7a1208, 1, 0.5), lift: 0 };
|
|
101
|
+
},
|
|
102
|
+
scrap_mite: (r) => {
|
|
103
|
+
const g = new TetrahedronGeometry(r * 1.3);
|
|
104
|
+
g.translate(0, r * 1.2, 0);
|
|
105
|
+
return { geometry: g, material: mat(0xffb020, 0x6a3a00, 0.9, 0.55), lift: 0 };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Resolve a library id ("style.lowpoly.mini_male_f") to its fallback shape for an entity of `radius` m. Unknown ids get a marked box. */
|
|
110
|
+
export function resolveAsset(id: string, radius = 0.35): LibraryAsset {
|
|
111
|
+
const name = id.split(".")[2] ?? "";
|
|
112
|
+
const b = builders[name];
|
|
113
|
+
if (b) return b(radius);
|
|
114
|
+
// A library id: its alias's builder, else a figure for characters.
|
|
115
|
+
const a = assetById(id);
|
|
116
|
+
for (const alias of a?.aliases ?? []) {
|
|
117
|
+
const ab = builders[alias.split(".")[2] ?? ""];
|
|
118
|
+
if (ab) return ab(radius);
|
|
119
|
+
}
|
|
120
|
+
if (a?.kind === "character") return builders.wirejack?.(radius) ?? { geometry: figure(1, 1), material: mat(0xffffff), lift: 0 };
|
|
121
|
+
const g = new BoxGeometry(radius * 2, radius * 2, radius * 2);
|
|
122
|
+
g.translate(0, radius, 0);
|
|
123
|
+
return { geometry: g, material: mat(0xff00ff, 0x400040), lift: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Projectile, beam and effect meshes (template machinery, not library assets). */
|
|
127
|
+
export const fx = {
|
|
128
|
+
/** A rifle bolt: a thin streak along -z. */
|
|
129
|
+
bolt: () => {
|
|
130
|
+
const g = new BoxGeometry(0.08, 0.08, 1.1);
|
|
131
|
+
return { geometry: g, material: mat(0x9ff6ff, 0x16e0ff, 3, 0.2) };
|
|
132
|
+
},
|
|
133
|
+
/** A scatter pellet. */
|
|
134
|
+
pellet: () => {
|
|
135
|
+
const g = new OctahedronGeometry(0.11);
|
|
136
|
+
g.scale(1, 1, 2);
|
|
137
|
+
return { geometry: g, material: mat(0xffd27a, 0xff8a00, 3, 0.2) };
|
|
138
|
+
},
|
|
139
|
+
/** A beam: a unit-long bar along -z, scaled to the beam's reach. */
|
|
140
|
+
beam: () => {
|
|
141
|
+
const g = new BoxGeometry(0.12, 0.12, 1);
|
|
142
|
+
g.translate(0, 0, -0.5);
|
|
143
|
+
return g;
|
|
144
|
+
},
|
|
145
|
+
shard: () => new TetrahedronGeometry(0.1),
|
|
146
|
+
};
|