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';
import type { RoomData } from './types.js';
const _a = new THREE.Vector3();
const _dir = new THREE.Vector3();
const _min = new THREE.Vector3();
const _max = new THREE.Vector3();
const _f = new THREE.Vector3();
const _r = new THREE.Vector3();
const _u = new THREE.Vector3();
const _a2 = new THREE.Vector3();
/**
* Fighting-game camera: frames every living fighter from above and to the side,
* swinging slowly to stay side-on to the main duel. Walls and furniture that
* block the view are hidden or faded.
*/
export class FightCamera {
readonly camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
yaw = 0;
private yawTarget = 0;
private turning = false;
/** Set once the player steers the camera; stops the automatic side-on swing. */
manual = false;
/** Mouse-look speed multiplier (the in-game Mouse speed slider). */
sensitivity = 1;
pitch = 0.6;
private dist = 9;
private readonly focus = new THREE.Vector3();
private shakeAmt = 0;
private shakeTime = 0;
/** Automatic slow orbit (menu backdrop). */
orbit = false;
/** Closest the camera may come (pulled back further when exploring). */
minDist = 6.5;
/** Shift the subject right by this fraction of the frame width (to clear a side menu). */
offsetX = 0;
shakeEnabled = true;
/** Furniture this camera sees through: its own see-through copy of each material, swapped in only while it renders. */
private readonly faded = new Map<THREE.Mesh, { original: THREE.Material | THREE.Material[]; clone: THREE.Material; fade: number }>();
private readonly raycaster = new THREE.Raycaster();
constructor(private room: RoomData | null = null) {}
setRoom(room: RoomData, yaw: number): void {
this.restoreFaded();
this.room = room;
this.yaw = this.yawTarget = yaw;
this.wallFade.clear();
// Wall meshes fade as whole walls; the furniture fader leaves them alone.
for (const w of room.cutaway) w.mesh.traverse((o) => (o.userData.wall = true));
}
/** How solid each wall is for this camera: 1 = solid, low = see-through. */
private readonly wallFade = new Map<THREE.Object3D, number>();
/** See-through copies of wall materials, by material and opacity step. */
private readonly wallMats = new Map<string, THREE.Material>();
private readonly swapped: { mesh: THREE.Mesh; material: THREE.Material | THREE.Material[] }[] = [];
shake(amount: number): void {
if (this.shakeEnabled) this.shakeAmt = Math.min(0.5, this.shakeAmt + amount);
}
/** Place the camera instantly (round start), with `left` on the left of the screen. */
snap(points: THREE.Vector3[], aspect: number, left?: THREE.Vector3, right?: THREE.Vector3): void {
if (left && right) {
this.yaw = this.yawTarget = Math.atan2(right.x - left.x, right.z - left.z) - Math.PI / 2;
this.turning = false;
}
this.update(0, points, null, aspect, true);
}
/** Manually orbit the camera. Positive turns the view left. */
turn(amount: number): void {
this.yaw += amount;
this.yawTarget = this.yaw;
this.turning = false;
this.manual = true;
}
/** Mouse look: horizontal pixels orbit, vertical pixels tilt. */
look(dx: number, dy: number): void {
if (dx) this.turn(-dx * 0.0045 * this.sensitivity);
if (dy) {
this.pitch = Math.min(1.2, Math.max(0.15, this.pitch + dy * 0.003 * this.sensitivity));
this.manual = true;
}
}
/** Tilt the view: positive looks down from higher up (like moving the mouse down). */
tilt(amount: number): void {
this.pitch = Math.min(1.2, Math.max(0.15, this.pitch + amount));
this.manual = true;
}
/** Horizontal forward and right vectors, for camera-relative controls. */
basis(forward: THREE.Vector3, right: THREE.Vector3): void {
forward.set(-Math.sin(this.yaw), 0, -Math.cos(this.yaw));
right.set(-forward.z, 0, forward.x);
}
update(dt: number, points: THREE.Vector3[], duel: [THREE.Vector3, THREE.Vector3] | null, aspect: number, instant = false): void {
const cam = this.camera;
cam.aspect = aspect;
// Narrow frames need a wider lens to fit the fighters.
cam.fov = aspect < 0.8 ? 58 : aspect < 1.2 ? 48 : 40;
if (this.offsetX) cam.setViewOffset(1000 * aspect, 1000, -this.offsetX * 1000 * aspect, 0, 1000 * aspect, 1000);
else cam.clearViewOffset();
cam.updateProjectionMatrix();
// Focus on the middle of everyone's bounding box.
_min.set(Infinity, Infinity, Infinity);
_max.set(-Infinity, -Infinity, -Infinity);
for (const p of points) {
_min.min(p);
_max.max(p);
}
const center = points.length ? _a.addVectors(_min, _max).multiplyScalar(0.5) : _a.set(0, 0, 0);
center.y += 0.55;
if (this.orbit) {
this.yawTarget = this.yaw + dt * 0.12;
this.turning = true;
} else if (duel && !this.manual) {
// Side-on to the line between the duellists, picking the side we're already on.
const lx = duel[1].x - duel[0].x;
const lz = duel[1].z - duel[0].z;
if (Math.hypot(lx, lz) > 0.6) {
const base = Math.atan2(lx, lz) + Math.PI / 2;
const a = wrap(base - this.yaw);
const b = wrap(base + Math.PI - this.yaw);
const want = this.yaw + (Math.abs(a) < Math.abs(b) ? a : b);
if (Math.abs(wrap(want - this.yaw)) > 0.55) this.turning = true;
if (this.turning) this.yawTarget = want;
if (Math.abs(wrap(this.yawTarget - this.yaw)) < 0.06) this.turning = false;
}
}
const yawStep = wrap(this.yawTarget - this.yaw);
this.yaw += instant ? yawStep : Math.sign(yawStep) * Math.min(Math.abs(yawStep), dt * (0.25 + Math.abs(yawStep) * 0.9));
const wantDist = Math.min(30, Math.max(aspect < 1 ? this.minDist + 1.5 : this.minDist, this.fitDistance(points, center, aspect)));
const k = instant ? 1 : 1 - Math.exp(-dt * 3.5);
this.dist += (wantDist - this.dist) * (instant ? 1 : 1 - Math.exp(-dt * 1.6));
this.focus.lerp(center, k);
if (instant) this.focus.copy(center);
const cp = Math.cos(this.pitch);
cam.position.set(
this.focus.x + Math.sin(this.yaw) * cp * this.dist,
this.focus.y + Math.sin(this.pitch) * this.dist,
this.focus.z + Math.cos(this.yaw) * cp * this.dist,
);
cam.lookAt(this.focus);
if (this.shakeAmt > 0.001) {
this.shakeTime += dt * 60;
const s = this.shakeAmt * 0.12;
cam.position.x += Math.sin(this.shakeTime * 1.7) * s;
cam.position.y += Math.sin(this.shakeTime * 2.3) * s;
cam.rotateZ(Math.sin(this.shakeTime * 1.3) * s * 0.15);
this.shakeAmt *= Math.exp(-dt * 9);
}
this.fadeOccluders(points, dt);
this.cutaway(dt);
}
/**
* Smallest distance at which every fighter (feet to head, plus a margin)
* fits inside the frame for the current yaw and pitch.
*/
private fitDistance(points: THREE.Vector3[], center: THREE.Vector3, aspect: number): number {
const cp = Math.cos(this.pitch);
const sp = Math.sin(this.pitch);
const f = _f.set(-Math.sin(this.yaw) * cp, -sp, -Math.cos(this.yaw) * cp);
const r = _r.set(Math.cos(this.yaw), 0, -Math.sin(this.yaw));
const u = _u.crossVectors(r, f);
const tanV = Math.tan((this.camera.fov * Math.PI) / 360);
const tanH = tanV * aspect;
let need = 0;
for (const p of points) {
for (const lift of [-0.1, 1.25]) {
const rel = _a2.set(p.x, p.y + lift, p.z).sub(center);
const along = rel.dot(f);
// Leave room for the HUD across the top of the frame.
need = Math.max(need, Math.abs(rel.dot(r)) / (tanH * 0.82) - along, rel.dot(u) / (tanV * 0.62) - along, -rel.dot(u) / (tanV * 0.85) - along);
}
}
return need + 1.5;
}
/**
* Fade the walls on the camera's side of the room (their bottom strip stays, so the
* room never looks open to the outside), and bring them back when it moves on.
*/
private cutaway(dt: number): void {
if (!this.room) return;
const hidden = this.room.areas ? this.houseCutaway() : this.roomCutaway();
const k = dt > 0 ? 1 - Math.exp(-dt * 10) : 1;
for (const w of this.room.cutaway) {
const want = hidden.has(w.mesh) ? 0.12 : 1;
const now = this.wallFade.get(w.mesh) ?? 1;
this.wallFade.set(w.mesh, now + (want - now) * k);
}
}
private roomCutaway(): Set<THREE.Object3D> {
const out = new Set<THREE.Object3D>();
this.camera.getWorldDirection(_dir);
_dir.y = 0;
_dir.normalize();
for (const w of this.room!.cutaway) if (w.normal.dot(_dir) >= 0.25) out.add(w.mesh);
return out;
}
/**
* In the house: hide every wall standing between the camera and what it looks at,
* so the rooms beyond (and the walls behind the fight) stay up.
*/
private houseCutaway(): Set<THREE.Object3D> {
const out = new Set<THREE.Object3D>();
const c = this.camera.position;
const f = this.focus;
for (const w of this.room!.cutaway) {
const p = w.plane;
if (!p) continue;
const ca = p.axis === 'x' ? c.x : c.z;
const fa = p.axis === 'x' ? f.x : f.z;
const cu = p.axis === 'x' ? c.z : c.x;
const fu = p.axis === 'x' ? f.z : f.x;
const pad = p.pad ?? 7;
// Right up against it, or looking through it.
let hide = Math.abs(ca - p.at) < 2 && cu > p.u0 - pad && cu < p.u1 + pad;
if (!hide && (ca - p.at) * (fa - p.at) < 0) {
// Where the sight line crosses the wall's plane.
const u = cu + ((fu - cu) * (p.at - ca)) / (fa - ca);
hide = u > p.u0 - pad && u < p.u1 + pad;
}
if (hide) out.add(w.mesh);
}
return out;
}
/** Fade furniture standing between the camera and any fighter. */
private fadeOccluders(points: THREE.Vector3[], dt: number): void {
if (!this.room) return;
const hits = new Set<THREE.Mesh>();
const from = this.camera.position;
for (const p of points) {
for (const lift of [0.3, 0.8]) {
const target = _a.set(p.x, p.y + lift, p.z);
_dir.subVectors(target, from);
const len = _dir.length();
this.raycaster.set(from, _dir.divideScalar(len));
this.raycaster.far = len - 0.35;
for (const hit of this.raycaster.intersectObject(this.room.root, true)) {
const m = hit.object;
if (m instanceof THREE.Mesh && !(m instanceof THREE.InstancedMesh) && m.visible && !m.userData.wall) hits.add(m);
}
}
}
for (const m of hits) {
if (!this.faded.has(m)) {
const original = m.material;
const clone = (Array.isArray(original) ? original[0]! : original).clone();
clone.transparent = true;
clone.depthWrite = false;
this.faded.set(m, { original, clone, fade: 1 });
}
}
for (const [m, state] of this.faded) {
const want = hits.has(m) ? 0.14 : 1;
state.fade += (want - state.fade) * Math.min(1, dt * 10);
state.clone.opacity = state.fade;
if (want === 1 && state.fade > 0.97) {
state.clone.dispose();
this.faded.delete(m);
}
}
}
/**
* Apply this camera's view of the room (hidden walls, see-through furniture) just
* before rendering with it; endRender puts everything back for the next camera.
*/
beginRender(): void {
for (const [m, state] of this.faded) m.material = state.clone;
if (!this.room) return;
for (const w of this.room.cutaway) {
const fade = this.wallFade.get(w.mesh) ?? 1;
if (fade > 0.98) continue;
const step = Math.max(1, Math.round(fade * 20));
w.mesh.traverse((o) => {
if (!(o instanceof THREE.Mesh) || Array.isArray(o.material)) return;
const key = `${o.material.id}:${step}`;
let see = this.wallMats.get(key);
if (!see) {
const made = o.material.clone();
made.transparent = true;
made.depthWrite = false;
made.opacity = step / 20;
this.wallMats.set(key, made);
see = made;
}
this.swapped.push({ mesh: o, material: o.material });
o.material = see;
});
}
}
endRender(): void {
for (const [m, state] of this.faded) m.material = state.original;
for (const s of this.swapped) s.mesh.material = s.material;
this.swapped.length = 0;
}
restoreFaded(): void {
for (const state of this.faded.values()) state.clone.dispose();
this.faded.clear();
for (const m of this.wallMats.values()) m.dispose();
this.wallMats.clear();
}
}
function wrap(a: number): number {
return Math.atan2(Math.sin(a), Math.cos(a));
}
