SOURCE / PINNED RELEASE
Made of little things.
Soybert 3D
- Release
- 4bf80e758af1…
- Author-recorded commit
- 298fa2d0e8b1…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-9021e1b6a96
Archive hash verified: 7083223a53bbd320…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
ACESFilmicToneMapping, CircleGeometry, Color, CylinderGeometry, DirectionalLight, Fog,
DataTexture, Group, HemisphereLight, Mesh, MeshBasicMaterial, MeshStandardMaterial, PCFSoftShadowMap,
PerspectiveCamera, PlaneGeometry, RingGeometry, Scene, SphereGeometry, Vector3, WebGLRenderer,
} from 'three';
import { createSoybert } from './rig.js';
import { createTitle } from './title.js';
import { DEFAULT_MOVE_SPEED, initialState, step, type ActorState } from './simulation.js';
type Particle = { mesh: Mesh; velocity: Vector3; life: number; total: number; gravity: number };
export class Stage {
readonly renderer: WebGLRenderer;
readonly scene = new Scene();
readonly camera = new PerspectiveCamera(38, 1, .1, 90);
readonly soybert = createSoybert();
readonly actor: ActorState = initialState();
mode: 'intro' | 'stage' = 'intro';
paused = false;
reducedMotion = false;
movementSpeed = DEFAULT_MOVE_SPEED;
yaw = .38;
pitch = .22;
zoom = 1;
private time = 0;
private stridePhase = 0;
private landingSquash = 0;
private turnLean = 0;
private readonly title = createTitle();
private introRotation = -.16;
private cameraDistance = 0;
private target = new Vector3(0, 1.35, 0);
private particles: Particle[] = [];
private readonly floorMaterial = new MeshStandardMaterial({ color: '#1b2822', roughness: .92 });
private readonly edgeMaterial = new MeshStandardMaterial({ color: '#101713', roughness: .75 });
private readonly ringMaterial = new MeshBasicMaterial({ color: '#718061', transparent: true, opacity: .45 });
private readonly pedestal: Mesh;
private readonly platform = new Group();
private readonly shadow: Mesh;
private readonly particleGeometry = new SphereGeometry(1, 6, 4);
private readonly pelletMaterial = new MeshBasicMaterial({ color: '#fff3b4' });
private readonly confettiMaterials = ['#e9c874', '#ea896f', '#c5e59a', '#fbefca'].map(color => new MeshBasicMaterial({ color }));
private readonly flash: Mesh;
private width = 1;
private height = 1;
constructor(canvas: HTMLCanvasElement) {
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = PCFSoftShadowMap;
this.renderer.toneMapping = ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.18;
this.scene.background = new Color('#111815'); this.scene.fog = new Fog('#111815', 16, 42);
this.scene.add(new HemisphereLight('#fff3d7', '#69725d', 2.3));
const key = new DirectionalLight('#fff4dd', 3.1); key.position.set(-3, 7, 6); key.castShadow = true;
key.shadow.mapSize.set(1024, 1024); key.shadow.camera.left = -9; key.shadow.camera.right = 9;
key.shadow.camera.top = 9; key.shadow.camera.bottom = -9; key.shadow.normalBias = .026;
key.shadow.bias = -.0003; key.shadow.radius = 3; this.scene.add(key);
const rim = new DirectionalLight('#d7efb0', 2.3); rim.position.set(4, 5, -5); this.scene.add(rim);
const fill = new DirectionalLight('#ffe3ca', .7); fill.position.set(5, 2, 6); this.scene.add(fill);
const ground = new Mesh(new PlaneGeometry(200, 200), this.edgeMaterial); ground.rotation.x = -Math.PI / 2; ground.position.y = -.31; ground.receiveShadow = true; this.scene.add(ground);
const base = new Mesh(new CylinderGeometry(6.6, 6.7, .30, 96), this.edgeMaterial); base.position.y = -.16; base.receiveShadow = true; this.platform.add(base);
const top = new Mesh(new CircleGeometry(6.6, 96), this.floorMaterial); top.rotation.x = -Math.PI / 2; top.position.y = -.006; top.receiveShadow = true; this.platform.add(top);
for (const radius of [2.8, 4.6, 6.25]) {
const ring = new Mesh(new RingGeometry(radius, radius + .015, 96), this.ringMaterial); ring.rotation.x = -Math.PI / 2; ring.position.y = .005; this.platform.add(ring);
}
// Small perimeter ticks make the open stage legible as a bounded studio surface.
for (let i = 0; i < 48; i++) {
const a = i / 48 * Math.PI * 2;
const tick = new Mesh(new PlaneGeometry(i % 4 ? .016 : .026, i % 4 ? .08 : .17), this.ringMaterial);
tick.rotation.set(-Math.PI / 2, 0, -a); tick.position.set(Math.sin(a) * 6.1, .007, Math.cos(a) * 6.1); this.platform.add(tick);
}
this.scene.add(this.platform); this.platform.visible = false;
this.pedestal = new Mesh(new CylinderGeometry(2.15, 2.28, .23, 96), this.floorMaterial); this.pedestal.position.y = -.13; this.pedestal.receiveShadow = true; this.scene.add(this.pedestal);
const pixels = new Uint8Array(64 * 64 * 4);
for (let y = 0; y < 64; y++) for (let x = 0; x < 64; x++) {
const i = (y * 64 + x) * 4, radius = Math.hypot((x - 31.5) / 31.5, (y - 31.5) / 31.5);
pixels[i] = 20; pixels[i + 1] = 24; pixels[i + 2] = 15; pixels[i + 3] = Math.max(0, 1 - radius) ** 2 * 255;
}
const shadowTexture = new DataTexture(pixels, 64, 64); shadowTexture.needsUpdate = true;
const shadowMaterial = new MeshBasicMaterial({ map: shadowTexture, transparent: true, opacity: .32, depthWrite: false });
this.shadow = new Mesh(new PlaneGeometry(3.5, 3.5), shadowMaterial); this.shadow.rotation.x = -Math.PI / 2; this.shadow.position.y = .012; this.shadow.scale.set(1, .7, 1); this.scene.add(this.shadow);
this.scene.add(this.soybert.group);
this.camera.add(this.title.group); this.scene.add(this.camera);
this.flash = new Mesh(new SphereGeometry(.16, 8, 6), new MeshBasicMaterial({ color: '#fff0a4' })); this.flash.visible = false; this.scene.add(this.flash);
}
resize(width: number, height: number) {
this.width = Math.max(1, width); this.height = Math.max(1, height);
this.renderer.setSize(this.width, this.height, false); this.camera.aspect = this.width / this.height; this.camera.updateProjectionMatrix(); this.title.layout(this.camera.aspect, this.height);
}
theme(background: string, primary: string) {
const color = new Color(background); const accent = new Color(primary);
this.title.theme(color.r * .2126 + color.g * .7152 + color.b * .0722 > .3);
// Keep the studio dark for mascot contrast in both runtime themes; UI colors
// still follow the host, while the set picks up only a subtle palette tint.
const studio = new Color('#111815').lerp(color, .012);
this.scene.background = studio; (this.scene.fog as Fog).color.copy(studio);
this.edgeMaterial.color.set('#101713').lerp(accent, .002);
this.floorMaterial.color.set('#1b2822').lerp(accent, .005);
this.ringMaterial.color.copy(accent); this.ringMaterial.opacity = .28;
}
enter() { this.mode = 'stage'; this.title.group.visible = false; this.platform.visible = true; this.pedestal.visible = false; this.reset(); }
intro() { this.mode = 'intro'; this.title.group.visible = true; this.platform.visible = false; this.pedestal.visible = true; this.reset(); }
reset() {
Object.assign(this.actor, initialState()); this.yaw = .38; this.pitch = this.mode === 'stage' ? .48 : .22; this.zoom = 1;
this.introRotation = -.16; this.paused = false; this.stridePhase = 0; this.landingSquash = 0; this.turnLean = 0;
for (const p of this.particles) this.scene.remove(p.mesh); this.particles = [];
}
private burst(origin: Vector3, direction: Vector3, count: number, confetti: boolean) {
for (let i = 0; i < count && this.particles.length < 160; i++) {
const mesh = new Mesh(this.particleGeometry, confetti ? this.confettiMaterials[i % 4] : this.pelletMaterial);
mesh.position.copy(origin);
mesh.scale.setScalar(confetti ? .035 + Math.random() * .035 : .018 + Math.random() * .025);
if (confetti) mesh.scale.y *= .4;
const velocity = direction.clone().multiplyScalar(confetti ? 1 : 16 + Math.random() * 8);
velocity.x += (Math.random() - .5) * (confetti ? 4 : 2.4);
velocity.z += (Math.random() - .5) * (confetti ? 4 : 2.4);
velocity.y += confetti ? 2 + Math.random() * 3 : (Math.random() - .5) * 2;
const life = confetti ? 1.5 + Math.random() : .24 + Math.random() * .12;
this.particles.push({ mesh, velocity, life, total: life, gravity: confetti ? 5 : 0 }); this.scene.add(mesh);
}
}
fire() {
const shot = this.soybert.muzzleWorld();
this.burst(shot.origin, shot.direction, this.reducedMotion ? 3 : 10, false);
this.flash.position.copy(shot.origin).addScaledVector(shot.direction, .13); this.flash.scale.set(1, .65, 1);
this.flash.visible = !this.reducedMotion;
}
celebrate() { if (!this.reducedMotion) this.burst(new Vector3(this.actor.x, 2.3, this.actor.z), new Vector3(0, 1, 0), 65, true); }
update(dt: number, horizontal: number, vertical: number): boolean {
let landed = false;
if (!this.paused) {
this.time += dt;
if (this.mode === 'stage') {
// Positive vertical means down/toward the camera; movement follows orbit orientation.
const dx = horizontal * Math.cos(this.yaw) + vertical * Math.sin(this.yaw);
const dz = -horizontal * Math.sin(this.yaw) + vertical * Math.cos(this.yaw);
const previousHeading = this.actor.heading;
landed = step(this.actor, dx, dz, dt, this.movementSpeed);
const lean = Math.max(-.18, Math.min(.18, (this.actor.heading - previousHeading) / Math.max(dt, .001) * -.025));
this.turnLean += (lean - this.turnLean) * (1 - Math.exp(-12 * dt));
if (landed) this.landingSquash = 1;
this.soybert.group.position.set(this.actor.x, this.actor.y, this.actor.z);
this.soybert.group.rotation.y = this.actor.heading;
} else {
if (!this.reducedMotion) this.introRotation += dt * .28;
this.soybert.group.position.set(0, 0, 0); this.soybert.group.rotation.y = this.introRotation;
}
const speed = Math.hypot(this.actor.vx, this.actor.vz);
this.stridePhase += speed * dt * 3.4;
this.landingSquash = Math.max(0, this.landingSquash - dt * 5);
this.soybert.pose(this.time, this.stridePhase, speed, this.actor.y > 0, this.actor.vy, this.actor.recoil, this.actor.celebration, this.landingSquash, this.turnLean, this.reducedMotion);
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i]; p.life -= dt;
if (p.life <= 0) { this.scene.remove(p.mesh); this.particles.splice(i, 1); continue; }
p.velocity.y -= p.gravity * dt; p.mesh.position.addScaledVector(p.velocity, dt);
if (p.mesh.position.y < .03) { p.mesh.position.y = .03; p.velocity.set(0, 0, 0); }
p.mesh.rotation.x += dt * 3; p.mesh.rotation.z += dt * 4;
}
if (this.actor.recoil > .07) this.flash.visible = false;
}
this.shadow.position.set(this.actor.x, .012, this.actor.z); const spread = 1 + this.actor.y * .23; this.shadow.scale.set(spread, spread * .7, 1);
(this.shadow.material as MeshBasicMaterial).opacity = .32 / (1 + this.actor.y);
const intro = this.mode === 'intro';
const aspectCompensation = Math.max(1, .95 / this.camera.aspect);
const desiredDistance = (intro ? this.height < 260 ? 14.2 : 12.4 : 14.0) * Math.min(1.85, aspectCompensation) * this.zoom;
const ease = 1 - Math.exp(-5 * dt);
this.cameraDistance = this.cameraDistance === 0 ? desiredDistance : this.cameraDistance + (desiredDistance - this.cameraDistance) * ease;
const follow = intro ? new Vector3(0, this.height < 260 ? 1.85 : 1.28, 0) : new Vector3(this.actor.x * .62, .65 + this.actor.y * .18, this.actor.z * .62);
this.target.lerp(follow, ease);
this.camera.position.set(this.target.x + Math.sin(this.yaw) * Math.cos(this.pitch) * this.cameraDistance, this.target.y + Math.sin(this.pitch) * this.cameraDistance, this.target.z + Math.cos(this.yaw) * Math.cos(this.pitch) * this.cameraDistance);
this.camera.lookAt(this.target);
// Reserve a little visual room for the controls under the character.
this.camera.setViewOffset(this.width, this.height, 0, (this.height < 260 ? 0 : intro ? -.025 : .055) * this.height, this.width, this.height);
this.renderer.render(this.scene, this.camera); return landed;
}
dispose() {
this.soybert.dispose(); this.title.dispose();
const geometries = new Set<import('three').BufferGeometry>(); const materials = new Set<import('three').Material>();
this.scene.traverse(object => { if (object instanceof Mesh && object !== this.soybert.mesh) { geometries.add(object.geometry); (Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m)); } });
(this.shadow.material as MeshBasicMaterial).map?.dispose();
geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); this.particleGeometry.dispose(); this.pelletMaterial.dispose(); this.confettiMaterials.forEach(m => m.dispose()); this.renderer.dispose();
}
}
