SOURCE / PINNED RELEASE
Made of little things.
Mini Samurai
- Release
- a7beb798adbc…
- Author-recorded commit
- 4f97be1515b8…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b
Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import * as THREE from 'three';
interface Particle {
alive: boolean;
pos: THREE.Vector3;
vel: THREE.Vector3;
life: number;
maxLife: number;
size: number;
grow: number;
gravity: number;
drag: number;
spin: THREE.Vector3;
rot: THREE.Euler;
color: THREE.Color;
floor: number;
}
/**
* Billowing smoke: soft round puffs that always face the camera, drawn as one
* instanced quad mesh. Each puff swells, slows, rises and fades on its own, so many
* overlapping puffs read as a single cloud.
*/
class SmokeCloud {
readonly mesh: THREE.InstancedMesh;
private readonly n: number;
private readonly pos: Float32Array;
private readonly vel: Float32Array;
private readonly age: Float32Array;
private readonly life: Float32Array;
private readonly size0: Float32Array;
private readonly size1: Float32Array;
private readonly peak: Float32Array;
private readonly alpha: THREE.InstancedBufferAttribute;
private readonly size: THREE.InstancedBufferAttribute;
private readonly spin: THREE.InstancedBufferAttribute;
private cursor = 0;
private readonly m = new THREE.Matrix4();
constructor(scene: THREE.Object3D, count: number) {
this.n = count;
this.pos = new Float32Array(count * 3);
this.vel = new Float32Array(count * 3);
this.age = new Float32Array(count).fill(1);
this.life = new Float32Array(count).fill(1);
this.size0 = new Float32Array(count);
this.size1 = new Float32Array(count);
this.peak = new Float32Array(count);
const geo = new THREE.PlaneGeometry(1, 1);
this.alpha = new THREE.InstancedBufferAttribute(new Float32Array(count), 1).setUsage(THREE.DynamicDrawUsage);
this.size = new THREE.InstancedBufferAttribute(new Float32Array(count), 1).setUsage(THREE.DynamicDrawUsage);
this.spin = new THREE.InstancedBufferAttribute(new Float32Array(count).map(() => Math.random() * Math.PI * 2), 1);
geo.setAttribute('aAlpha', this.alpha);
geo.setAttribute('aSize', this.size);
geo.setAttribute('aSpin', this.spin);
const mat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
uniforms: { uColor: { value: new THREE.Color(0xf4f4f2) }, uShade: { value: new THREE.Color(0xc9cacc) } },
vertexShader: `
attribute float aAlpha;
attribute float aSize;
attribute float aSpin;
varying float vAlpha;
varying vec2 vUv;
void main() {
// Billboard: a quad centred on the puff, turned to face the camera.
vec4 centre = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
float c = cos(aSpin), s = sin(aSpin);
vec2 corner = vec2(position.x * c - position.y * s, position.x * s + position.y * c);
centre.xy += corner * aSize;
vUv = uv;
vAlpha = aAlpha;
gl_Position = projectionMatrix * centre;
}`,
fragmentShader: `
uniform vec3 uColor;
uniform vec3 uShade;
varying float vAlpha;
varying vec2 vUv;
void main() {
vec2 p = vUv - 0.5;
float d = length(p) * 2.0;
if (d > 1.0) discard;
// Soft edge, a slightly lumpy outline, and a little shading toward the bottom.
float lump = 0.88 + 0.12 * sin(atan(p.y, p.x) * 5.0 + vAlpha * 3.0);
float a = vAlpha * smoothstep(1.0, 0.25, d / lump);
vec3 col = mix(uShade, uColor, clamp(0.55 + p.y * 1.4 + (1.0 - d) * 0.3, 0.0, 1.0));
gl_FragColor = vec4(col, a);
}`,
});
this.mesh = new THREE.InstancedMesh(geo, mat, count);
this.mesh.frustumCulled = false;
this.mesh.renderOrder = 3;
for (let i = 0; i < count; i++) this.mesh.setMatrixAt(i, this.m.makeTranslation(0, -999, 0));
scene.add(this.mesh);
}
/** A puff at (x, y, z) moving at (vx, vy, vz), growing from size a to b over its life. */
puff(x: number, y: number, z: number, vx: number, vy: number, vz: number, a: number, b: number, life: number, peak: number): void {
const i = this.cursor;
this.cursor = (this.cursor + 1) % this.n;
this.pos.set([x, y, z], i * 3);
this.vel.set([vx, vy, vz], i * 3);
this.age[i] = 0;
this.life[i] = life;
this.size0[i] = a;
this.size1[i] = b;
this.peak[i] = peak;
}
update(dt: number): void {
const alpha = this.alpha.array as Float32Array;
const size = this.size.array as Float32Array;
const drag = Math.exp(-1.6 * dt);
for (let i = 0; i < this.n; i++) {
if (this.age[i]! >= this.life[i]!) {
if (alpha[i] !== 0) {
alpha[i] = 0;
size[i] = 0;
this.mesh.setMatrixAt(i, this.m.makeTranslation(0, -999, 0));
}
continue;
}
this.age[i]! += dt;
const t = Math.min(1, this.age[i]! / this.life[i]!);
const k = i * 3;
// Slows down, drifts upward and keeps spreading.
this.vel[k]! *= drag;
this.vel[k + 2]! *= drag;
this.vel[k + 1] = this.vel[k + 1]! * drag + 0.5 * dt;
this.pos[k]! += this.vel[k]! * dt;
this.pos[k + 1]! += this.vel[k + 1]! * dt;
this.pos[k + 2]! += this.vel[k + 2]! * dt;
size[i] = this.size0[i]! + (this.size1[i]! - this.size0[i]!) * (1 - (1 - t) * (1 - t));
// Thickens fast, then thins out slowly.
alpha[i] = this.peak[i]! * Math.min(1, t * 6) * (1 - t) * (1 - t * 0.3);
this.mesh.setMatrixAt(i, this.m.makeTranslation(this.pos[k]!, this.pos[k + 1]!, this.pos[k + 2]!));
}
this.alpha.needsUpdate = true;
this.size.needsUpdate = true;
this.mesh.instanceMatrix.needsUpdate = true;
}
}
/** Pool of tiny low-poly chips drawn with one instanced mesh. */
class ParticlePool {
readonly mesh: THREE.InstancedMesh;
private readonly items: Particle[] = [];
private cursor = 0;
constructor(geometry: THREE.BufferGeometry, material: THREE.Material, count: number, scene: THREE.Object3D) {
this.mesh = new THREE.InstancedMesh(geometry, material, count);
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.mesh.frustumCulled = false;
this.mesh.count = 0;
for (let i = 0; i < count; i++) {
this.items.push({
alive: false,
pos: new THREE.Vector3(),
vel: new THREE.Vector3(),
life: 0,
maxLife: 1,
size: 1,
grow: 0,
gravity: 0,
drag: 0,
spin: new THREE.Vector3(),
rot: new THREE.Euler(),
color: new THREE.Color(),
floor: -99,
});
}
this.mesh.setColorAt(0, new THREE.Color());
scene.add(this.mesh);
}
spawn(init: (p: Particle) => void): void {
const p = this.items[this.cursor]!;
this.cursor = (this.cursor + 1) % this.items.length;
p.alive = true;
p.grow = 0;
p.drag = 0;
p.floor = -99;
p.rot.set(Math.random() * 6, Math.random() * 6, Math.random() * 6);
p.spin.set((Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20, (Math.random() - 0.5) * 20);
init(p);
p.life = p.maxLife;
}
update(dt: number): void {
let n = 0;
for (const p of this.items) {
if (!p.alive) continue;
p.life -= dt;
if (p.life <= 0) {
p.alive = false;
continue;
}
p.vel.y -= p.gravity * dt;
p.vel.multiplyScalar(Math.exp(-p.drag * dt));
p.pos.addScaledVector(p.vel, dt);
if (p.pos.y < p.floor) {
p.pos.y = p.floor;
p.vel.y *= -0.3;
p.vel.x *= 0.5;
p.vel.z *= 0.5;
}
p.rot.x += p.spin.x * dt;
p.rot.y += p.spin.y * dt;
p.rot.z += p.spin.z * dt;
const k = p.life / p.maxLife;
const s = Math.max(0.0001, p.size * (p.grow ? 1 + (1 - k) * p.grow : k ** 0.5));
_m.compose(p.pos, _q.setFromEuler(p.rot), _s.set(s, s, s));
this.mesh.setMatrixAt(n, _m);
this.mesh.setColorAt(n, p.color);
n++;
}
this.mesh.count = n;
this.mesh.instanceMatrix.needsUpdate = true;
if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
}
}
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _s = new THREE.Vector3();
/** Fading ribbon swept by a blade. */
class Trail {
private readonly bases: THREE.Vector3[] = [];
private readonly tips: THREE.Vector3[] = [];
private readonly ages: number[] = [];
readonly mesh: THREE.Mesh;
private readonly positions: Float32Array;
private readonly colors: Float32Array;
private static readonly MAX = 14;
constructor(scene: THREE.Object3D, private readonly tint: THREE.Color) {
const n = Trail.MAX;
this.positions = new Float32Array(n * 2 * 3);
this.colors = new Float32Array(n * 2 * 3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('color', new THREE.BufferAttribute(this.colors, 3).setUsage(THREE.DynamicDrawUsage));
const index: number[] = [];
for (let i = 0; i < n - 1; i++) {
const a = i * 2;
index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
}
geo.setIndex(index);
this.mesh = new THREE.Mesh(
geo,
new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }),
);
this.mesh.frustumCulled = false;
scene.add(this.mesh);
}
push(base: THREE.Vector3, tip: THREE.Vector3, emit: boolean): void {
if (emit) {
// Add an interpolated midpoint so fast swings stay smooth.
const last = this.tips.length - 1;
if (last >= 0 && this.ages[last]! < 0.02) {
const mb = this.bases[last]!.clone().lerp(base, 0.5);
const mt = this.tips[last]!.clone().lerp(tip, 0.5);
const len = mt.distanceTo(mb);
mt.sub(mb).setLength(len).add(mb);
this.bases.push(mb);
this.tips.push(mt);
this.ages.push(0);
}
this.bases.push(base.clone());
this.tips.push(tip.clone());
this.ages.push(0);
while (this.tips.length > Trail.MAX) {
this.bases.shift();
this.tips.shift();
this.ages.shift();
}
}
}
update(dt: number): void {
for (let i = 0; i < this.ages.length; i++) this.ages[i]! += dt;
while (this.ages.length && this.ages[0]! > 0.16) {
this.ages.shift();
this.bases.shift();
this.tips.shift();
}
const n = this.tips.length;
for (let i = 0; i < Trail.MAX; i++) {
const j = Math.min(i, n - 1);
const base = n ? this.bases[j]! : _zero;
const tip = n ? this.tips[j]! : _zero;
const fade = n ? Math.max(0, 1 - this.ages[j]! / 0.16) * (j / Math.max(1, n - 1)) : 0;
this.positions.set([base.x, base.y, base.z, tip.x, tip.y, tip.z], i * 6);
const c = this.tint;
this.colors.set([c.r * fade * 0.15, c.g * fade * 0.15, c.b * fade * 0.15, c.r * fade, c.g * fade, c.b * fade], i * 6);
}
const geo = this.mesh.geometry;
geo.attributes.position!.needsUpdate = true;
geo.attributes.color!.needsUpdate = true;
this.mesh.visible = n > 1;
}
}
const _zero = new THREE.Vector3();
interface Flash {
mesh: THREE.Mesh;
life: number;
maxLife: number;
size: number;
kind: 'star' | 'ring';
}
export class Effects {
private readonly chips: ParticlePool;
private readonly glow: ParticlePool;
private readonly dustPool: ParticlePool;
private readonly smoke: SmokeCloud;
private readonly trails = new Map<number, Trail>();
private readonly flashes: Flash[] = [];
private readonly starGeo: THREE.BufferGeometry;
private readonly ringGeo = new THREE.RingGeometry(0.85, 1, 24);
constructor(private readonly scene: THREE.Object3D) {
this.chips = new ParticlePool(
new THREE.TetrahedronGeometry(0.05),
new THREE.MeshStandardMaterial({ flatShading: true, roughness: 0.6 }),
220,
scene,
);
this.glow = new ParticlePool(new THREE.OctahedronGeometry(0.025), new THREE.MeshBasicMaterial({ toneMapped: false }), 260, scene);
this.dustPool = new ParticlePool(
new THREE.IcosahedronGeometry(0.04, 0),
new THREE.MeshStandardMaterial({ flatShading: true, roughness: 1, transparent: true, opacity: 0.6, depthWrite: false }),
200,
scene,
);
this.smoke = new SmokeCloud(scene, 600);
// Four-point impact star.
const shape = new THREE.Shape();
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
const r = i % 2 ? 0.18 : 1;
if (i === 0) shape.moveTo(Math.cos(a) * r, Math.sin(a) * r);
else shape.lineTo(Math.cos(a) * r, Math.sin(a) * r);
}
this.starGeo = new THREE.ShapeGeometry(shape);
}
/** Blade trail for a fighter; call every frame with emit=true while striking. */
trail(id: number, base: THREE.Vector3, tip: THREE.Vector3, emit: boolean, tint = 0xdff4ff): void {
let t = this.trails.get(id);
if (!t) {
t = new Trail(this.scene, new THREE.Color(tint));
this.trails.set(id, t);
}
t.push(base, tip, emit);
}
removeTrail(id: number): void {
const t = this.trails.get(id);
if (!t) return;
t.mesh.removeFromParent();
t.mesh.geometry.dispose();
(t.mesh.material as THREE.Material).dispose();
this.trails.delete(id);
}
sparks(at: THREE.Vector3, count: number, power = 1, color = 0xffd27a): void {
for (let i = 0; i < count; i++) {
this.glow.spawn((p) => {
p.pos.copy(at);
p.vel.set(Math.random() - 0.5, Math.random() * 0.9 - 0.2, Math.random() - 0.5).normalize().multiplyScalar((2 + Math.random() * 5) * power);
p.maxLife = 0.18 + Math.random() * 0.25;
p.size = 0.8 + Math.random() * 0.8;
p.gravity = 14;
p.drag = 2;
p.color.set(Math.random() < 0.3 ? 0xffffff : color);
});
}
}
/** Armour chips and cloth bits in the victim's colours. */
chipsBurst(at: THREE.Vector3, dir: THREE.Vector3, colors: number[], count: number, floor: number): void {
for (let i = 0; i < count; i++) {
this.chips.spawn((p) => {
p.pos.copy(at);
p.vel
.set(dir.x + (Math.random() - 0.5) * 1.4, 0.6 + Math.random() * 1.2, dir.z + (Math.random() - 0.5) * 1.4)
.multiplyScalar(1.5 + Math.random() * 2.5);
p.maxLife = 0.5 + Math.random() * 0.6;
p.size = 0.5 + Math.random() * 0.8;
p.gravity = 16;
p.floor = floor;
p.color.set(colors[i % colors.length]!);
});
}
}
dust(at: THREE.Vector3, count: number, color = 0xd9c9ad): void {
for (let i = 0; i < count; i++) {
this.dustPool.spawn((p) => {
const a = Math.random() * Math.PI * 2;
p.pos.set(at.x + Math.cos(a) * 0.15, at.y + 0.04, at.z + Math.sin(a) * 0.15);
p.vel.set(Math.cos(a) * (0.6 + Math.random()), 0.3 + Math.random() * 0.4, Math.sin(a) * (0.6 + Math.random()));
p.maxLife = 0.45 + Math.random() * 0.35;
p.size = 0.5 + Math.random() * 0.5;
p.grow = 1.6;
p.gravity = -0.4;
p.drag = 3.5;
p.color.set(color);
});
}
}
/**
* A skidding tyre: thick white smoke that billows out behind it, carried a little by
* the car, then slows, swells, rises and hangs in the air as it thins; and tiny black
* bits of rubber flicked off behind. back points away from the way the car faces.
*/
tyreSmoke(at: THREE.Vector3, back: THREE.Vector3, floor: number, carVel?: THREE.Vector3): void {
const cvx = carVel ? carVel.x * 0.35 : 0;
const cvz = carVel ? carVel.z * 0.35 : 0;
for (let i = 0; i < 2; i++) {
this.smoke.puff(
at.x + (Math.random() - 0.5) * 0.3,
at.y + 0.15 + Math.random() * 0.15,
at.z + (Math.random() - 0.5) * 0.3,
cvx + back.x * (0.6 + Math.random() * 1.2) + (Math.random() - 0.5) * 1.4,
0.25 + Math.random() * 0.45,
cvz + back.z * (0.6 + Math.random() * 1.2) + (Math.random() - 0.5) * 1.4,
0.5 + Math.random() * 0.4,
3 + Math.random() * 2.5,
1.8 + Math.random() * 1.6,
0.32 + Math.random() * 0.18,
);
}
for (let i = 0; i < 2; i++) {
this.chips.spawn((p) => {
p.pos.set(at.x, at.y + 0.08, at.z);
p.vel.set(back.x * (1.5 + Math.random() * 2) + (Math.random() - 0.5) * 2.4, 1 + Math.random() * 1.8, back.z * (1.5 + Math.random() * 2) + (Math.random() - 0.5) * 2.4);
p.maxLife = 0.5 + Math.random() * 0.5;
p.size = 0.25 + Math.random() * 0.25;
p.gravity = 16;
p.floor = floor;
p.color.set(Math.random() < 0.5 ? 0x151517 : 0x2a2a2d);
});
}
}
/** Bright star flash at the point of impact, facing the camera. */
impact(at: THREE.Vector3, size: number, color = 0xffffff): void {
const mesh = new THREE.Mesh(
this.starGeo,
new THREE.MeshBasicMaterial({ color, transparent: true, depthTest: false, toneMapped: false, side: THREE.DoubleSide }),
);
mesh.position.copy(at);
mesh.renderOrder = 10;
mesh.rotation.z = Math.random() * Math.PI;
this.scene.add(mesh);
this.flashes.push({ mesh, life: 0.14, maxLife: 0.14, size, kind: 'star' });
}
shockwave(at: THREE.Vector3, size: number, color = 0xfff2d6): void {
const mesh = new THREE.Mesh(
this.ringGeo,
new THREE.MeshBasicMaterial({ color, transparent: true, depthWrite: false, side: THREE.DoubleSide }),
);
mesh.position.copy(at).y += 0.03;
mesh.rotation.x = -Math.PI / 2;
this.scene.add(mesh);
this.flashes.push({ mesh, life: 0.35, maxLife: 0.35, size, kind: 'ring' });
}
update(dt: number, camera: THREE.Camera): void {
this.chips.update(dt);
this.glow.update(dt);
this.dustPool.update(dt);
this.smoke.update(dt);
for (const t of this.trails.values()) t.update(dt);
for (let i = this.flashes.length - 1; i >= 0; i--) {
const f = this.flashes[i]!;
f.life -= dt;
const k = 1 - f.life / f.maxLife;
const mat = f.mesh.material as THREE.MeshBasicMaterial;
if (f.kind === 'star') {
f.mesh.quaternion.copy(camera.quaternion);
f.mesh.rotateZ(k * 0.6);
const s = f.size * (0.4 + 1.2 * Math.sin(Math.min(1, k * 1.4) * Math.PI * 0.5)) * (1 - k * 0.6);
f.mesh.scale.set(s, s * 0.8, s);
} else {
const s = f.size * (0.2 + k);
f.mesh.scale.set(s, s, s);
}
mat.opacity = 1 - k * k;
if (f.life <= 0) {
f.mesh.removeFromParent();
mat.dispose();
this.flashes.splice(i, 1);
}
}
}
clear(): void {
for (const f of this.flashes) {
f.mesh.removeFromParent();
(f.mesh.material as THREE.Material).dispose();
}
this.flashes.length = 0;
}
}
