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 { buildRoom } from './rooms.js';
import { buildMachines } from './machines.js';
import type { Body, Mover, MoverEvents } from './movers.js';
import { HEIGHT, RADIUS } from './world.js';
import { Fighter, noControls, type Controls, type FighterHost } from './fighter.js';
import { Brain, NavGraph, type Difficulty } from './ai.js';
import { Effects } from './fx.js';
import { FightCamera } from './camera.js';
import { ITEMS, KATANA, buildPickupMarker, itemById } from './items.js';
import { World } from './world.js';
import type { Livery } from './rig.js';
import type { RawInput } from './input.js';
import type { DripDef, PoolDef, RoomData, RoomId, Solid, ThrowDef, WeaponDef } from './types.js';
import { POWERUPS, THROWABLES, THROWS_BY_ROOM, throwById } from './throwables.js';
import { Roller } from './rollers.js';
import { Car, CRATE_SIZE, Crate } from './cars.js';
import * as sfx from './sound.js';
export interface MatchOptions {
room: RoomId;
/**
* 'explore' has no enemies or rounds: just climb and try out items.
* 'waves' puts the players on one team against ever bigger waves of enemies.
* 'ffa' is free for all: up to four samurai, each on their own.
*/
mode: '1v1' | '2v2' | 'ffa' | 'waves' | 'explore';
/** Human players, 1-4 (1v1 allows 2). */
players: number;
/** 2v2: humans on one team against CPUs ('together') or spread across both teams ('split'). */
teams: 'together' | 'split';
/** Free for all: CPUs take the places no player has joined ('cpus'), or they stay empty. */
fill: 'cpus' | 'empty';
difficulty: Difficulty;
/** Menu backdrop: CPUs spar while the camera orbits. */
attract?: boolean;
}
export const TEAM_NAMES = ['Red', 'Blue', 'Yellow', 'Green'];
const LIVERIES: Livery[][] = [
[
{ armor: 0xc8372d, trim: 0xe3b23c, cloth: 0x2b2d42, ribbon: 0xf4efe4 },
{ armor: 0xdd7a2c, trim: 0xe3b23c, cloth: 0x3a2f2f, ribbon: 0xffe29a },
// Extra co-op colours for players 3 and 4 in Waves and Explore.
{ armor: 0xd9a92b, trim: 0xf4efe4, cloth: 0x2f2a20, ribbon: 0xfff3c4 },
{ armor: 0xa83c7c, trim: 0xe3b23c, cloth: 0x2a2030, ribbon: 0xf7c8e4 },
],
[
{ armor: 0x2f56a8, trim: 0xd3dae3, cloth: 0x22252d, ribbon: 0x9fd6ff },
{ armor: 0x23897f, trim: 0xd3dae3, cloth: 0x1f2b2a, ribbon: 0xc4f0e2 },
],
];
/** Team colours; in free for all every samurai is a team of one, in these four colours. */
export const TEAM_CSS = ['#d8443a', '#3b6fd1', '#d9a92b', '#2a9a8e'];
/** Free for all: four samurai who can't be confused with each other. */
const FFA_LIVERIES: Livery[] = [
{ armor: 0xc8372d, trim: 0xe3b23c, cloth: 0x2b2d42, ribbon: 0xf4efe4 },
{ armor: 0x2f56a8, trim: 0xd3dae3, cloth: 0x22252d, ribbon: 0x9fd6ff },
{ armor: 0xd9a92b, trim: 0xf4efe4, cloth: 0x2f2a20, ribbon: 0xfff3c4 },
{ armor: 0x23897f, trim: 0xd3dae3, cloth: 0x1f2b2a, ribbon: 0xc4f0e2 },
];
/** Waves mode enemies: a mixed band of ronin in cool and dark lacquer. */
const RONIN: Livery[] = [
{ armor: 0x2f56a8, trim: 0xd3dae3, cloth: 0x22252d, ribbon: 0x9fd6ff },
{ armor: 0x5b3f8f, trim: 0xc9b6e8, cloth: 0x231f2b, ribbon: 0xd9c7ff },
{ armor: 0x23897f, trim: 0xd3dae3, cloth: 0x1f2b2a, ribbon: 0xc4f0e2 },
{ armor: 0x3c4048, trim: 0xa9b0ba, cloth: 0x18191c, ribbon: 0xe8e8e8 },
{ armor: 0x4f7a3a, trim: 0xd9cf8a, cloth: 0x202619, ribbon: 0xe6efb0 },
];
const ROUNDS_TO_WIN = 2;
const PROJECTILE_GRAVITY = 18;
const _spinQ = new THREE.Quaternion();
const _spinAxis = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
const _shadowX = new THREE.Vector3();
const _shadowY = new THREE.Vector3();
const _shadowZ = new THREE.Vector3();
const _shadowC = new THREE.Vector3();
const _moverPos = new THREE.Vector3();
const _moverVel = new THREE.Vector3();
const _restQ = new THREE.Quaternion();
/** Thrown objects are told apart from weapons by their weight. */
function isThrow(def: WeaponDef | ThrowDef): def is ThrowDef {
return 'weight' in def;
}
/** A household object flying through the air after a throw. */
interface Projectile {
def: ThrowDef;
obj: THREE.Group;
pos: THREE.Vector3;
vel: THREE.Vector3;
spin: THREE.Vector3;
owner: Fighter;
/** Already struck someone: it can't hurt again, it just tumbles to the floor. */
spent: boolean;
gravity: number;
}
/** A slippery puddle: from the dripping tap (it grows) or a burst water balloon (it dries up). */
interface Puddle {
x: number;
y: number;
z: number;
r: number;
max: number;
/** Units of radius gained per second while growing. */
grow: number;
/** Seconds left before it dries up (Infinity for the tap's). */
ttl: number;
mesh: THREE.Mesh;
}
/** The bath filling (and draining) with water. */
interface Pool {
def: PoolDef;
level: number;
clock: number;
water: THREE.Mesh;
stream: THREE.Mesh;
}
/** A dripping tap and its falling drops. */
interface Drip {
def: DripDef;
floor: number;
puddle: Puddle;
clock: number;
drops: { mesh: THREE.Mesh; vy: number; live: boolean }[];
}
const WATER_MAT = new THREE.MeshStandardMaterial({ color: 0x2f9ad8, transparent: true, opacity: 0.62, roughness: 0.15, metalness: 0.1, depthWrite: false });
const PUDDLE_MAT = new THREE.MeshStandardMaterial({ color: 0x8fd4f0, transparent: true, opacity: 0.55, roughness: 0.05, metalness: 0.2, depthWrite: false });
const PUDDLE_GEO = new THREE.CircleGeometry(1, 20).rotateX(-Math.PI / 2);
/**
* Something lying around (or tumbling) that can be picked up. `obj` holds the full
* orientation and sits at the item's centre (`pos`); the mesh inside is offset so
* it turns about that centre. Collisions use the corners of the real mesh.
*/
class Pickup {
readonly obj = new THREE.Group();
readonly item: THREE.Object3D;
readonly marker: THREE.Object3D;
/** Centre of the item. */
readonly pos = new THREE.Vector3();
readonly vel = new THREE.Vector3();
/** Angular velocity (axis × radians per second). */
readonly spin = new THREE.Vector3();
resting = false;
/** Touching something underneath this frame (rolls toward lying still). */
grounded = false;
/** Has touched down once: no more bouncing, it stays on the surface from now on. */
landed = false;
/** Height of the surface it rests on (for the marker ring). */
floorY = 0;
owner: null = null;
age = 0;
constructor(
readonly def: WeaponDef | ThrowDef,
public durability: number,
scene: THREE.Object3D,
) {
this.item = def.build();
// Turn about the middle of the object, not its grip/base.
if (isThrow(def)) this.item.position.set(0, -def.height / 2, 0);
else this.item.position.set(0, -def.length * 0.4, 0);
this.obj.add(this.item);
scene.add(this.obj);
this.marker = buildPickupMarker();
scene.add(this.marker);
this.marker.visible = false;
}
get throwable(): boolean {
return isThrow(this.def);
}
/**
* How it lies when still, keeping its current heading: household objects stand
* upright; weapons lie flat on their side along the way they point.
*/
restQuaternion(out: THREE.Quaternion): THREE.Quaternion {
if (isThrow(this.def) && this.def.anyFace) {
// A die settles flat on the face that is already lowest, keeping how it's turned.
const q = this.obj.quaternion;
let low: THREE.Vector3 | null = null;
for (const axis of CUBE_AXES) {
const v = _pickV.copy(axis).applyQuaternion(q);
if (!low || v.y < low.y) low = _pickLow.copy(v);
}
return out.setFromUnitVectors(low!, _down).multiply(q);
}
if (isThrow(this.def)) {
const e = _pickEuler.setFromQuaternion(this.obj.quaternion, 'YXZ');
return out.setFromEuler(_pickEuler.set(0, e.y, 0, 'YXZ'));
}
// Flat weapon: Yaw(ψ)·(π/2, 0, π/2) points the blade along (-cos ψ, 0, sin ψ).
const blade = _pickV.set(0, 1, 0).applyQuaternion(this.obj.quaternion);
const psi = Math.hypot(blade.x, blade.z) > 0.05 ? Math.atan2(blade.z, -blade.x) : 0;
return out.setFromEuler(_pickEuler.set(0, psi, 0, 'YXZ')).multiply(_flatQ);
}
dispose(): void {
this.obj.removeFromParent();
this.marker.removeFromParent();
}
}
const _pickEuler = new THREE.Euler();
const _pickV = new THREE.Vector3();
const _flatQ = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI / 2, 0, Math.PI / 2));
const _pickLow = new THREE.Vector3();
const _down = new THREE.Vector3(0, -1, 0);
const CUBE_AXES = [
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(-1, 0, 0),
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, -1, 0),
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(0, 0, -1),
];
/** A floating word over a fighter ("Parry!", "Disarmed!"). */
export interface Popup {
text: string;
at: THREE.Vector3;
color: string;
age: number;
}
export interface HudState {
fighters: Fighter[];
wins: number[];
round: number;
banner: { text: string; sub?: string; age: number } | null;
popups: Popup[];
/** Waves mode: current wave, enemies still to beat in it, and total KOs. */
wave: number;
enemiesLeft: number;
kills: number;
}
export class Game implements FighterHost {
readonly renderer: THREE.WebGLRenderer;
readonly scene = new THREE.Scene();
readonly cam = new FightCamera();
/** Player 2's own camera in split screen. */
readonly cam2 = new FightCamera();
readonly cam3 = new FightCamera();
readonly cam4 = new FightCamera();
/** One camera per player slot (the 4th doubles as the overview with 3 players). */
get cams(): FightCamera[] {
return [this.cam, this.cam2, this.cam3, this.cam4];
}
readonly fx: Effects;
readonly wind = new THREE.Vector3(0.3, 0, 0.2);
world = new World([]);
nav = new NavGraph([]);
room: RoomData | null = null;
fighters: Fighter[] = [];
brains = new Map<Fighter, Brain>();
pickups: Pickup[] = [];
options: MatchOptions | null = null;
phase: 'intro' | 'fight' | 'roundEnd' | 'matchEnd' = 'intro';
phaseTime = 0;
wins = [0, 0];
round = 1;
paused = false;
slowmo = 1;
private slowTime = 0;
banner: HudState['banner'] = null;
readonly popups: Popup[] = [];
private readonly sun: THREE.DirectionalLight;
private readonly hemi: THREE.HemisphereLight;
private width = 1;
private height = 1;
/** Called when the match is over. */
onMatchEnd: (winnerTeam: number) => void = () => undefined;
private readonly fwd = new THREE.Vector3();
private readonly right = new THREE.Vector3();
constructor(readonly canvas: HTMLCanvasElement) {
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.05;
this.hemi = new THREE.HemisphereLight(0xfff1dc, 0x7a6450, 1.25);
this.scene.add(this.hemi);
this.sun = new THREE.DirectionalLight(0xffe2b8, 2.6);
this.sun.castShadow = true;
this.sun.shadow.mapSize.set(2048, 2048);
this.sun.shadow.bias = -0.0004;
this.sun.shadow.normalBias = 0.02;
this.scene.add(this.sun, this.sun.target);
this.fx = new Effects(this.scene);
}
resize(w: number, h: number): void {
this.width = Math.max(1, w);
this.height = Math.max(1, h);
this.renderer.setSize(this.width, this.height, false);
}
// ---- Match setup ----------------------------------------------------------------
start(options: MatchOptions): void {
this.options = options;
this.loadRoom(options.room);
for (const f of this.fighters) f.rig.dispose();
this.fighters = [];
this.brains.clear();
const humans: [number, number][] = [];
const roster: { team: number; human: number | null }[] = [];
if (options.attract) {
roster.push({ team: 0, human: null }, { team: 1, human: null });
} else {
const n = Math.max(1, Math.min(4, options.players));
const human = (i: number) => (i < n ? i : null);
if (options.mode === 'explore' || options.mode === 'waves') {
// Everyone on one team.
for (let i = 0; i < n; i++) roster.push({ team: 0, human: i });
} else if (options.mode === 'ffa') {
// Everyone for themselves: each samurai is their own team.
for (let i = 0; i < n; i++) roster.push({ team: i, human: i });
const total = options.fill === 'cpus' ? 4 : Math.max(2, n);
for (let i = n; i < total; i++) roster.push({ team: i, human: null });
} else if (options.mode === '1v1') {
roster.push({ team: 0, human: 0 }, { team: 1, human: n >= 2 ? 1 : null });
} else if (options.teams === 'together' && n <= 2) {
// Players side by side against CPUs. Roster order matches the spawns: [A1, B1, A2, B2].
roster.push({ team: 0, human: 0 }, { team: 1, human: null }, { team: 0, human: human(1) }, { team: 1, human: null });
} else {
// Split: P1 & P3 red, P2 & P4 blue; empty places are CPUs.
roster.push({ team: 0, human: 0 }, { team: 1, human: human(1) }, { team: 0, human: human(2) }, { team: 1, human: human(3) });
}
}
const perTeam = [0, 0, 0, 0];
const ffa = options.mode === 'ffa' && !options.attract;
const cpuCount = roster.filter((r) => r.human === null).length;
let cpuNo = 0;
roster.forEach((r, i) => {
const slot = perTeam[r.team]!++;
// Several CPUs in free for all are told apart by number.
const name = r.human !== null ? `P${r.human + 1}` : ffa && cpuCount > 1 ? `CPU ${++cpuNo}` : 'CPU';
const livery = ffa ? FFA_LIVERIES[r.team]! : LIVERIES[r.team]![slot]!;
const f = new Fighter(i, r.team, name, livery, r.human !== null, this.scene, this);
if (r.human !== null) humans.push([i, r.human]);
this.fighters.push(f);
if (r.human === null) this.brains.set(f, new Brain(f, this, options.attract ? 'normal' : options.difficulty));
});
this.humanSlots = new Map(humans);
this.wins = new Array<number>(Math.max(2, ...roster.map((r) => r.team + 1))).fill(0);
this.round = 1;
this.startRound();
}
/** Fighter index → human player number. */
private humanSlots = new Map<number, number>();
private loadRoom(id: RoomId): void {
if (this.room?.id === id) return;
if (this.room) this.scene.remove(this.room.root);
const room = buildRoom(id);
this.room = room;
this.scene.add(room.root);
// Moving machines (the train, the fan...) are solids that move; the AI's map leaves them out.
this.movers = buildMachines(room);
this.bodyOf.clear();
this.airFrom.clear();
for (const m of this.movers) {
room.root.add(m.root);
for (const b of m.bodies) {
room.solids.push(...b.solids);
for (const sol of b.solids) this.bodyOf.set(sol, b);
}
}
this.world = new World(room.solids);
this.statics = room.solids.filter((sol) => !sol.moving);
this.buildHazards(room);
// Edges of moving things (cars come and go, so look them up each time).
this.world.movingLedges = (pos, dir) => {
let best: ReturnType<Body['ledge']> = null;
for (const m of this.movers) {
for (const b of m.bodies) {
const l = b.ledge(pos, dir);
if (l && (!best || l.score < best.score)) best = l;
}
}
return best;
};
this.nav = new NavGraph(room.solids.filter((s) => !s.moving));
this.scene.background = new THREE.Color(room.sky);
this.scene.fog = new THREE.Fog(room.sky, 40, 110);
// Shadow camera covers the whole room.
const b = room.bounds;
const cx = (b.minX + b.maxX) / 2;
const cz = (b.minZ + b.maxZ) / 2;
const span = Math.max(b.maxX - b.minX, b.maxZ - b.minZ) * 0.62;
this.sun.target.position.set(cx, 0, cz);
this.sun.position.set(cx, 0, cz).addScaledVector(room.sunDir, -40);
const sc = this.sun.shadow.camera;
sc.left = -span;
sc.right = span;
sc.top = span;
sc.bottom = -span;
sc.near = 1;
sc.far = 90;
sc.updateProjectionMatrix();
this.shadowHalf = 0;
const s0 = room.spawns[0]!;
const s1 = room.spawns[1]!;
this.cam.setRoom(room, Math.atan2(s1.x - s0.x, s1.z - s0.z) + Math.PI / 2);
this.cam2.setRoom(room, Math.atan2(s0.x - s1.x, s0.z - s1.z) + Math.PI / 2);
this.cam3.setRoom(room, Math.atan2(s1.x - s0.x, s1.z - s0.z) + Math.PI / 2);
this.cam4.setRoom(room, Math.atan2(s0.x - s1.x, s0.z - s1.z) + Math.PI / 2);
}
private startRound(): void {
const room = this.room!;
for (const p of this.pickups) p.dispose();
this.pickups = [];
this.fx.clear();
this.popups.length = 0;
this.fighters.forEach((f, i) => {
const s = room.spawns[i]!;
f.equip(KATANA);
f.reset(s.x, s.y, s.z, s.facing);
});
// Scatter household items (more of them in the whole house).
const many = room.areas ? 3 : 1;
const spots = [...room.itemSpawns].sort(() => Math.random() - 0.5).slice(0, this.exploring || this.waveMode ? 99 : 7 * many);
for (const spot of spots) {
const kinds = spot.kinds ?? room.itemKinds;
const def = itemById(kinds[Math.floor(Math.random() * kinds.length)]!);
const p = new Pickup(def, def.durability, this.scene);
this.placePickup(p, spot.x, spot.y, spot.z);
this.pickups.push(p);
}
for (const p of [...this.projectiles]) this.removeProjectile(p);
this.scatterThrowables((this.exploring || this.waveMode ? 10 : 6) * many);
this.scatterThrowables(2 * many, POWERUPS.map((d) => d.id));
// The whole house always has at least one of every weapon, throwable and pick-up somewhere.
if (room.areas && !this.options?.attract) this.ensureEveryItem();
this.powerClock = 0;
this.resetHazards();
this.resetCars();
this.phase = 'intro';
this.phaseTime = 0;
this.slowmo = 1;
this.slowTime = 0;
const attract = this.options?.attract;
this.banner = attract ? null : { text: this.isFinalRound() ? 'Final Round' : `Round ${this.round}`, age: 0 };
if (this.exploring) {
// Straight into free play.
this.phase = 'fight';
this.banner = { text: 'Explore', sub: 'No enemies. Climb anything, try every item.', age: 0 };
}
if (this.waveMode) {
this.phase = 'fight';
this.banner = null;
this.wave = 0;
this.waveLeft = 0;
this.waveBreak = 1.2;
this.spawnTimer = 0;
this.kills = 0;
}
if (!attract) sfx.sfxGong();
this.cam.orbit = !!attract;
// Each match starts with the automatic side-on camera and default tilt.
if (this.round === 1) {
for (const cam of this.cams) {
cam.manual = false;
cam.pitch = 0.6;
}
}
for (const cam of this.cams) cam.minDist = this.exploring ? 10 : 6.5;
this.viewSlots = [];
this.refreshViews();
if (this.split) {
// Each samurai's camera starts behind them, looking toward the nearest foe.
for (const view of this.views()) {
const me = view.fighter;
if (!me) {
view.cam.snap(this.focusPoints(), view.w / view.h);
continue;
}
const duel = this.viewDuel(me);
view.cam.snap(this.viewPoints(me), view.w / view.h, duel?.[0], duel?.[1]);
}
} else {
const duel = this.duelPair();
this.cam.snap(this.focusPoints(), this.width / this.height, duel?.[0], duel?.[1]);
}
}
get waveMode(): boolean {
return this.options?.mode === 'waves' && !this.options.attract;
}
get exploring(): boolean {
return this.options?.mode === 'explore' && !this.options.attract;
}
private isFinalRound(): boolean {
return this.wins[0] === ROUNDS_TO_WIN - 1 && this.wins[1] === ROUNDS_TO_WIN - 1;
}
stop(): void {
for (const f of this.fighters) f.rig.dispose();
this.fighters = [];
this.brains.clear();
for (const p of this.pickups) p.dispose();
this.pickups = [];
for (const p of [...this.projectiles]) this.removeProjectile(p);
}
// ---- FighterHost ------------------------------------------------------------------
nearestEnemy(f: Fighter, range: number): Fighter | null {
let best: Fighter | null = null;
let bestD = range;
for (const o of this.fighters) {
if (o.team === f.team || !o.alive) continue;
const d = o.pos.distanceTo(f.pos);
if (d < bestD && Math.abs(o.pos.y - f.pos.y) < 0.8) {
bestD = d;
best = o;
}
}
return best;
}
pickupNear(f: Fighter): Pickup | null {
let best: Pickup | null = null;
let bestD = 0.8;
for (const p of this.pickups) {
if (!p.resting) continue;
const d = Math.hypot(p.pos.x - f.pos.x, p.pos.z - f.pos.z);
if (d < bestD && Math.abs(p.pos.y - f.pos.y) < 0.7) {
bestD = d;
best = p;
}
}
return best;
}
takePickup(f: Fighter): void {
const p = this.pickupNear(f);
if (!p) return;
if (isThrow(p.def) && p.def.power) {
f.applyPower(p.def.power);
p.dispose();
this.pickups.splice(this.pickups.indexOf(p), 1);
sfx.sfxPickup();
this.fx.sparks(p.pos, 10, 0.6, 0xfff6d0);
return;
}
if (isThrow(p.def)) {
// Household objects are heaved overhead; the weapon goes on the back meanwhile.
f.carry(p.def);
} else {
if (f.weapon) this.dropWeapon(f, new THREE.Vector3(0, 0, 0), 0.6);
f.equip(p.def, p.durability);
}
p.dispose();
this.pickups.splice(this.pickups.indexOf(p), 1);
sfx.sfxPickup();
this.popup(f, p.def.name, '#ffe7a8');
}
hearing(p: THREE.Vector3): number {
const d = Math.min(...this.views().map((v) => p.distanceTo(v.cam.camera.position)));
return Math.max(0, Math.min(1, 14 / (d + 4)));
}
surfaceVelocity(support: Solid | null, at: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 {
const body = support ? this.bodyOf.get(support) : undefined;
return body ? body.pointVelocity(at, out).setY(0) : out.set(0, 0, 0);
}
dust(p: THREE.Vector3, amount: number): void {
this.fx.dust(p, amount);
}
// ---- Weapons in the world ------------------------------------------------------------
/** Throw the fighter's item out of their hands. */
private dropWeapon(f: Fighter, dir: THREE.Vector3, power: number): void {
if (!f.weapon) return;
const { def, durability } = f.weapon;
const p = new Pickup(def, durability, this.scene);
f.rig.handR.updateMatrixWorld(true);
p.pos.setFromMatrixPosition(f.rig.handR.matrixWorld);
p.vel.set(dir.x * 2.5 * power + (Math.random() - 0.5), 3 + 4 * power, dir.z * 2.5 * power + (Math.random() - 0.5));
p.spin.set((Math.random() - 0.5) * 6, (Math.random() - 0.5) * 4, 10 + Math.random() * 8);
this.pickups.push(p);
f.equip(null);
}
private disarm(f: Fighter, away: THREE.Vector3): void {
if (!f.weapon) return;
this.dropWeapon(f, away, 1.4);
if (f.state !== 'down' && f.state !== 'ko') f.disarmed();
sfx.sfxDisarm();
this.popup(f, 'Disarmed!', '#ffd166');
}
/** World-space corners of every mesh in an item. */
private itemCorners(obj: THREE.Object3D): THREE.Vector3[] {
obj.updateMatrixWorld(true);
const out: THREE.Vector3[] = [];
obj.traverse((o) => {
if (!(o instanceof THREE.Mesh)) return;
if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
const b = o.geometry.boundingBox!;
for (let i = 0; i < 8; i++) {
out.push(new THREE.Vector3(i & 1 ? b.max.x : b.min.x, i & 2 ? b.max.y : b.min.y, i & 4 ? b.max.z : b.min.z).applyMatrix4(o.matrixWorld));
}
});
return out;
}
/**
* How far to move an item so none of its corners are inside the floor or furniture:
* up onto a surface it is landing on, otherwise out sideways on its own side.
*/
private itemContact(obj: THREE.Object3D, centre: THREE.Vector3): { up: number; x: number; z: number } {
const corners = this.itemCorners(obj);
const res = { up: 0, x: 0, z: 0 };
const near = this.world.solids.filter(
(s) => s.maxX > centre.x - 2 && s.minX < centre.x + 2 && s.maxZ > centre.z - 2 && s.minZ < centre.z + 2 && s.maxY > centre.y - 2 && s.minY < centre.y + 2,
);
for (const c of corners) {
for (const s of near) {
if (c.x <= s.minX || c.x >= s.maxX || c.z <= s.minZ || c.z >= s.maxZ || c.y <= s.minY || c.y >= s.maxY) continue;
const options: [number, 'up' | 'x' | 'z'][] = [];
const centreOver = centre.x > s.minX && centre.x < s.maxX && centre.z > s.minZ && centre.z < s.maxZ;
if (centre.y >= s.maxY - 0.2 || centreOver) options.push([s.maxY - c.y, 'up']);
if (centre.x <= s.minX) options.push([s.minX - c.x, 'x']);
if (centre.x >= s.maxX) options.push([s.maxX - c.x, 'x']);
if (centre.z <= s.minZ) options.push([s.minZ - c.z, 'z']);
if (centre.z >= s.maxZ) options.push([s.maxZ - c.z, 'z']);
if (!options.length) options.push([s.maxY - c.y, 'up']);
options.sort((a, b) => Math.abs(a[0]) - Math.abs(b[0]));
const [d, axis] = options[0]!;
if (axis === 'up') res.up = Math.max(res.up, d);
else if (Math.abs(d) > Math.abs(res[axis])) res[axis] = d;
}
}
return res;
}
private updatePickups(dt: number): void {
const b = this.room!.bounds;
for (const p of this.pickups) {
p.age += dt;
if (!p.resting) {
p.vel.y -= 24 * dt;
p.pos.addScaledVector(p.vel, dt);
p.pos.x = Math.min(b.maxX - 0.3, Math.max(b.minX + 0.3, p.pos.x));
p.pos.z = Math.min(b.maxZ - 0.3, Math.max(b.minZ + 0.3, p.pos.z));
// Tumble about the centre; once touching down, roll over onto its side/base.
const w = p.spin.length();
if (w > 1e-4) p.obj.quaternion.premultiply(_spinQ.setFromAxisAngle(_spinAxis.copy(p.spin).divideScalar(w), w * dt));
const tumbles = isThrow(p.def) && p.def.anyFace;
const rolls = isThrow(p.def) && p.def.rolls;
if (rolls && p.grounded) {
// A marble rolls: it turns as it goes and hardly slows.
const sp = Math.hypot(p.vel.x, p.vel.z);
if (sp > 1e-3) p.obj.quaternion.premultiply(_spinQ.setFromAxisAngle(_spinAxis.set(p.vel.z, 0, -p.vel.x).divideScalar(sp), (sp * dt) / p.def.radius));
p.spin.set(0, 0, 0);
}
p.spin.multiplyScalar(Math.exp(-(p.grounded ? (tumbles ? 3.5 : 10) : 0.5) * dt));
if (p.grounded && !rolls) p.obj.quaternion.slerp(p.restQuaternion(_restQ), 1 - Math.exp(-9 * dt));
p.obj.position.copy(p.pos);
// Keep every corner of the mesh out of the floor and furniture.
p.grounded = false;
for (let iter = 0; iter < 6; iter++) {
const hit = this.itemContact(p.obj, p.pos);
if (!hit.up && !hit.x && !hit.z) break;
p.pos.x += hit.x;
p.pos.z += hit.z;
p.pos.y += hit.up;
if (hit.x) p.vel.x *= -0.3;
if (hit.z) p.vel.z *= -0.3;
if (hit.up > 0) {
if (!p.landed && p.vel.y <= 0.5) {
// Touching down: land with a thud (no bouncing), losing most of the spin.
if (p.vel.y < 0) p.vel.y = 0;
if (isThrow(p.def) && p.def.anyFace) {
// A die tumbles over a face or two when it hits, so any face can end up on top.
const a = Math.random() * Math.PI * 2;
p.spin.set(Math.cos(a), 0, Math.sin(a)).multiplyScalar(9 + Math.random() * 9);
} else p.spin.multiplyScalar(0.4);
if (p.throwable) sfx.sfxLand(false);
else {
sfx.sfxClang();
this.fx.sparks(p.pos, 4, 0.5);
}
p.landed = true;
} else if (p.vel.y < 0) p.vel.y = 0;
p.grounded = true;
}
p.obj.position.copy(p.pos);
}
// Once down, stay down: rolling onto its side can lift it a hair off the surface,
// so set it straight back onto it instead of letting it fall and hop again.
if (p.landed && !p.grounded && p.vel.y <= 0.5) {
let low = Infinity;
for (const c of this.itemCorners(p.obj)) low = Math.min(low, c.y);
const surface = this.world.heightAt(p.pos.x, low + 0.3, p.pos.z);
const gap = low - surface;
if (gap > 0 && gap < 0.35) {
p.pos.y -= gap - 0.001;
p.vel.y = 0;
p.obj.position.copy(p.pos);
const hit = this.itemContact(p.obj, p.pos);
p.pos.x += hit.x;
p.pos.z += hit.z;
p.pos.y += hit.up;
p.obj.position.copy(p.pos);
p.grounded = true;
}
}
// Wedged between two things that push opposite ways (a gap too narrow for it):
// move to the nearest spot nearby where it fits.
const left = this.itemContact(p.obj, p.pos);
if (left.up || left.x || left.z) {
if (this.freeItem(p.obj, p.pos)) {
p.vel.x *= 0.3;
p.vel.z *= 0.3;
p.grounded = true;
}
}
if (p.grounded) {
const rollsOn = isThrow(p.def) && p.def.rolls;
const f = Math.exp(-(rollsOn ? 0.7 : 7) * dt);
p.vel.x *= f;
p.vel.z *= f;
// Lying still in its resting pose: done.
if (rollsOn && Math.hypot(p.vel.x, p.vel.z) < 0.15 && Math.abs(p.vel.y) < 0.5) this.restPickup(p);
else if (Math.hypot(p.vel.x, p.vel.z) < 0.25 && Math.abs(p.vel.y) < 0.5 && p.obj.quaternion.angleTo(p.restQuaternion(_restQ)) < 0.06) {
this.restPickup(p);
}
}
}
// Resting items glint with a ring so they're easy to spot.
p.marker.visible = p.resting;
if (p.resting) {
const pulse = 1 + Math.sin(p.age * 4) * 0.08;
p.marker.position.set(p.pos.x, p.floorY + 0.02, p.pos.z);
p.marker.scale.setScalar(pulse);
p.obj.position.y = p.pos.y + Math.max(0, Math.sin(p.age * 3)) * 0.02;
}
}
}
/** Find the nearest nearby position (around and a little above) where an item overlaps nothing. */
private freeItem(obj: THREE.Object3D, pos: THREE.Vector3): boolean {
const start = pos.clone();
for (const r of [0.12, 0.25, 0.4, 0.6, 0.9, 1.3, 1.8, 2.4, 3]) {
for (const lift of [0, 0.25, 0.6, 1.2, 2.2]) {
for (let k = 0; k < 8; k++) {
const a = (k / 8) * Math.PI * 2;
pos.set(start.x + Math.cos(a) * r, start.y + lift, start.z + Math.sin(a) * r);
obj.position.copy(pos);
const hit = this.itemContact(obj, pos);
if (!hit.up && !hit.x && !hit.z) return true;
}
}
}
pos.copy(start);
obj.position.copy(pos);
return false;
}
/** Snap into the exact resting pose, sitting on whatever is underneath. */
private restPickup(p: Pickup): void {
p.obj.quaternion.copy(p.restQuaternion(_restQ));
p.obj.position.copy(p.pos);
for (let iter = 0; iter < 6; iter++) {
const hit = this.itemContact(p.obj, p.pos);
if (!hit.up && !hit.x && !hit.z) break;
p.pos.x += hit.x;
p.pos.z += hit.z;
p.pos.y += hit.up;
p.obj.position.copy(p.pos);
}
const left = this.itemContact(p.obj, p.pos);
if (left.up || left.x || left.z) this.freeItem(p.obj, p.pos);
let low = Infinity;
for (const c of this.itemCorners(p.obj)) low = Math.min(low, c.y);
p.floorY = low;
p.vel.set(0, 0, 0);
p.spin.set(0, 0, 0);
p.resting = true;
}
/** Put an item down at rest on a surface (round start, scattered objects). */
private placePickup(p: Pickup, x: number, surfaceY: number, z: number): void {
// Dice lie on a random face; everything else gets a random heading.
const quarter = () => Math.floor(Math.random() * 4) * (Math.PI / 2);
const anyFace = isThrow(p.def) && p.def.anyFace;
p.obj.quaternion.setFromEuler(new THREE.Euler(anyFace ? quarter() : 0, Math.random() * Math.PI * 2, anyFace ? quarter() : 0, 'YXZ'));
p.obj.quaternion.copy(p.restQuaternion(_restQ));
p.pos.set(x, surfaceY + 2, z);
p.obj.position.copy(p.pos);
let low = Infinity;
for (const c of this.itemCorners(p.obj)) low = Math.min(low, c.y);
p.pos.y += surfaceY + 0.002 - low;
this.restPickup(p);
}
// ---- Thrown objects ---------------------------------------------------------------------
private projectiles: Projectile[] = [];
dropCarry(f: Fighter, away?: THREE.Vector3): void {
const from = f.carryPos(new THREE.Vector3());
const def = f.releaseCarry();
if (!def) return;
const p = new Pickup(def, 0, this.scene);
// Let go in front of the samurai, or away from a wall/ledge they just grabbed.
const dir = away ? away.clone().setY(0).normalize() : f.forward();
p.pos.copy(from);
if (away) p.pos.addScaledVector(dir, def.radius + 0.15);
this.pushOutOfSolids(p.pos, def.radius);
p.vel.set(dir.x * 1.2, 1.5, dir.z * 1.2);
p.spin.set((Math.random() - 0.5) * 4, (Math.random() - 0.5) * 3, (Math.random() - 0.5) * 4);
this.pickups.push(p);
}
/**
* Move a small object out of any solid it overlaps: onto the top if it's near the
* top, otherwise sideways the shortest way. Returns the axis it was pushed along.
*/
private pushOutOfSolids(pos: THREE.Vector3, radius: number): 'x' | 'z' | 'y' | null {
let axis: 'x' | 'z' | 'y' | null = null;
for (let iter = 0; iter < 3; iter++) {
const s = this.world.solids.find(
(o) => pos.x > o.minX - radius && pos.x < o.maxX + radius && pos.z > o.minZ - radius && pos.z < o.maxZ + radius && pos.y > o.minY && pos.y < o.maxY,
);
if (!s) break;
const exits: [number, 'x' | 'z' | 'y', number][] = [
[pos.x - (s.minX - radius), 'x', s.minX - radius - 0.01],
[s.maxX + radius - pos.x, 'x', s.maxX + radius + 0.01],
[pos.z - (s.minZ - radius), 'z', s.minZ - radius - 0.01],
[s.maxZ + radius - pos.z, 'z', s.maxZ + radius + 0.01],
[s.maxY - pos.y < 0.35 && s.grab !== false ? s.maxY - pos.y : Infinity, 'y', s.maxY + 0.01],
];
exits.sort((a, b) => a[0] - b[0]);
const [, which, value] = exits[0]!;
pos[which] = value;
axis = which;
}
return axis;
}
/** Nearest foe roughly in front of the thrower, at any height, within throwing range. */
throwTarget(f: Fighter): Fighter | null {
const fwd = f.forward();
let best: Fighter | null = null;
let bestScore = Infinity;
for (const o of this.fighters) {
if (o.team === f.team || !o.alive) continue;
const dx = o.pos.x - f.pos.x;
const dz = o.pos.z - f.pos.z;
const d = Math.hypot(dx, dz);
if (d > 14 || d < 0.01) continue;
const facing = (dx * fwd.x + dz * fwd.z) / d;
if (facing < 0.25) continue;
const score = d * (2 - facing);
if (score < bestScore) {
bestScore = score;
best = o;
}
}
return best;
}
throwItem(f: Fighter, def: ThrowDef, from: THREE.Vector3): void {
// Rubber bands are flicked fast and flat; everything else is lobbed.
const speed = def.flick ?? 11 - def.weight * 2.5;
const gravity = def.flick ? 2.5 : PROJECTILE_GRAVITY;
const target = this.throwTarget(f);
const vel = new THREE.Vector3();
if (target) {
// Lob it so it arrives at the target's chest.
const aim = target.pos.clone().setY(target.pos.y + 0.55);
const dx = aim.x - from.x;
const dz = aim.z - from.z;
const d = Math.hypot(dx, dz);
const t = Math.max(0.15, d / speed);
vel.set((dx / d) * speed, (aim.y - from.y + 0.5 * gravity * t * t) / t, (dz / d) * speed);
} else {
const fwd = f.forward();
vel.set(fwd.x * speed, def.flick ? 0.4 : 3.2, fwd.z * speed);
}
const obj = new THREE.Group();
const mesh = def.build();
mesh.position.y = -def.height / 2;
obj.add(mesh);
obj.position.copy(from);
this.scene.add(obj);
this.projectiles.push({
def,
obj,
pos: from.clone().setY(from.y + def.height / 2),
vel,
// Tumbling end over end; a die also gets a random twist on every axis, so it lands any way up.
spin: def.anyFace
? new THREE.Vector3(6 + Math.random() * 10, (Math.random() - 0.5) * 16, (Math.random() - 0.5) * 16)
: new THREE.Vector3(8 + Math.random() * 6, (Math.random() - 0.5) * 6, (Math.random() - 0.5) * 4),
owner: f,
spent: false,
gravity,
});
}
private updateProjectiles(dt: number): void {
const b = this.room!.bounds;
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i]!;
const prev = p.pos.clone();
p.vel.y -= p.gravity * dt;
p.pos.addScaledVector(p.vel, dt);
p.obj.position.copy(p.pos);
p.obj.rotation.x += p.spin.x * dt;
p.obj.rotation.y += p.spin.y * dt;
p.obj.rotation.z += p.spin.z * dt;
// Hit a fighter?
if (!p.spent) {
for (const e of this.fighters) {
if (e.team === p.owner.team || !e.vulnerable) continue;
const dy = p.pos.y - e.pos.y;
if (dy < -p.def.radius || dy > e.hurtHeight + p.def.radius) continue;
if (Math.hypot(p.pos.x - e.pos.x, p.pos.z - e.pos.z) > 0.3 + p.def.radius * 0.8) continue;
this.projectileHit(p, e);
break;
}
if (!this.projectiles.includes(p)) continue;
}
// Hit the room? Any corner of the spinning object entering the floor, a wall or furniture.
const out = p.pos.x < b.minX || p.pos.x > b.maxX || p.pos.z < b.minZ || p.pos.z > b.maxZ;
const hit = this.itemContact(p.obj, p.pos);
if (!out && !hit.up && !hit.x && !hit.z) continue;
if (p.def.breaks && p.vel.length() > 4) {
this.shatter(p);
continue;
}
// From here it tumbles like any dropped item, keeping its motion and spin.
const pick = new Pickup(p.def, 0, this.scene);
pick.obj.quaternion.copy(p.obj.quaternion);
pick.pos.copy(out ? prev : p.pos);
pick.vel.copy(p.vel);
pick.spin.copy(p.spin);
pick.obj.position.copy(pick.pos);
this.pickups.push(pick);
sfx.sfxLand(false);
this.removeProjectile(p);
}
}
private projectileHit(p: Projectile, e: Fighter): void {
const def = p.def;
const from = p.pos.clone().addScaledVector(p.vel, -0.1);
const toThrower = new THREE.Vector3(p.owner.pos.x - e.pos.x, 0, p.owner.pos.z - e.pos.z).normalize();
p.spent = true;
if (e.blocking && e.forward().dot(toThrower) > 0.1) {
// Batted away by the guard.
e.blocked(from, 1 + def.knock * 0.3);
sfx.sfxClang();
this.fx.sparks(p.pos, 8, 0.7);
this.popup(e, 'Blocked!', '#d6ecff');
} else {
const wasAlive = e.alive;
const lethal = e.health - def.damage <= 0;
if (!e.hurt(def.damage, from, def.knock, def.knock >= 5 || lethal)) {
sfx.sfxClang();
p.vel.x *= -0.3;
p.vel.z *= -0.3;
return;
}
e.freeze = e.shake = 0.07;
this.fx.impact(p.pos, 0.8, 0xfff1c8);
this.fx.chipsBurst(p.pos, p.vel.clone().setY(0).normalize(), [e.livery.armor, e.livery.cloth], 6, e.pos.y);
this.shake(def.knock >= 5 ? 0.35 : 0.18);
sfx.sfxHit(def.knock >= 5);
this.popup(e, `${def.name}!`, '#ffcf8a');
if (wasAlive && !e.alive) this.onKO(e, p.vel.clone().setY(0).normalize());
}
if (def.breaks) {
this.shatter(p);
return;
}
// Sturdy things rebound and drop.
p.vel.x *= -0.3;
p.vel.z *= -0.3;
p.vel.y = Math.max(p.vel.y, 2);
}
private shatter(p: Projectile): void {
const floor = this.world.heightAt(p.pos.x, p.pos.y + 0.1, p.pos.z);
this.fx.chipsBurst(p.pos, p.vel.clone().setY(0).normalize().multiplyScalar(0.5), p.def.chips, 16, floor);
this.fx.sparks(p.pos, 6, 0.6, 0xffffff);
sfx.sfxBreak();
for (const id of p.def.burst ?? []) {
const def = itemById(id);
const spill = new Pickup(def, def.durability, this.scene);
spill.pos.copy(p.pos);
this.pushOutOfSolids(spill.pos, 0.2);
spill.vel.set((Math.random() - 0.5) * 5, 3 + Math.random() * 2, (Math.random() - 0.5) * 5);
spill.spin.set((Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14);
this.pickups.push(spill);
}
if (p.def.splash) {
this.addPuddle(p.pos.x, floor, p.pos.z, p.def.splash, 14);
this.effect('splash', p.pos);
}
this.removeProjectile(p);
}
private removeProjectile(p: Projectile): void {
p.obj.removeFromParent();
this.projectiles.splice(this.projectiles.indexOf(p), 1);
}
/** Scatter throwable objects on open spots of the floor and furniture tops. */
private scatterThrowables(count: number, only?: string[]): void {
const room = this.room!;
let placed = 0;
for (let i = 0; i < 300 + count * 20 && placed < count; i++) {
const { x, z, area } = this.randomSpot(1.5);
const kinds = only ?? THROWS_BY_ROOM[area ?? room.id];
const top = this.world.groundBelow(x, 14, z, 0.4);
if (!top || top.maxY > 12 || top.slide || top.moving) continue;
const y = top.maxY;
if (this.world.blocked(x, y, z, 1.2, 0.4)) continue;
if (this.pickups.some((p) => p.pos.distanceTo(new THREE.Vector3(x, y, z)) < 1.2)) continue;
if (this.fighters.some((f) => Math.hypot(f.pos.x - x, f.pos.z - z) < 1.5)) continue;
const id = kinds[Math.floor(Math.random() * kinds.length)]!;
const def = POWERUPS.find((d) => d.id === id) ?? throwById(id);
const p = new Pickup(def, 0, this.scene);
this.placePickup(p, x, y, z);
this.pickups.push(p);
placed++;
}
}
/** Put down one of each weapon, throwable and pick-up that isn't lying somewhere already. */
private ensureEveryItem(): void {
const have = new Set(this.pickups.map((p) => p.def.id));
const missing: (WeaponDef | ThrowDef)[] = [
...Object.values(ITEMS).filter((d) => d.id !== KATANA.id),
...Object.values(THROWABLES),
...POWERUPS,
].filter((d) => !have.has(d.id));
for (const def of missing) {
for (let i = 0; i < 200; i++) {
const { x, z } = this.randomSpot(1.5);
const top = this.world.groundBelow(x, 14, z, 0.4);
if (!top || top.maxY > 12 || top.slide || top.moving) continue;
const y = top.maxY;
if (this.world.blocked(x, y, z, 1.2, 0.4)) continue;
if (this.pickups.some((p) => p.pos.distanceTo(_moverPos.set(x, y, z)) < 1.2)) continue;
if (this.fighters.some((f) => Math.hypot(f.pos.x - x, f.pos.z - z) < 1.5)) continue;
const p = new Pickup(def, isThrow(def) ? 0 : def.durability, this.scene);
this.placePickup(p, x, y, z);
this.pickups.push(p);
break;
}
}
}
// ---- Frame ---------------------------------------------------------------------------
update(rawDt: number, raw: RawInput[]): void {
if (!this.options || !this.room) return;
const dt = Math.min(1 / 30, rawDt) * this.slowmo;
if (this.paused) {
this.render();
return;
}
if (this.slowTime > 0) {
this.slowTime -= rawDt;
if (this.slowTime <= 0) this.slowmo = 1;
}
this.phaseTime += dt;
if (this.banner) this.banner.age += rawDt;
// Players steer their camera: mouse look (player 1), Z/X, numpad 7/9 or a right stick.
// In split screen each player turns only their own camera.
if (!this.options.attract) {
raw.forEach((r, i) => {
if (i >= this.humanSlots.size && i > 0) return;
const cam = this.split ? this.cams[i]! : this.cam;
if (r.turn) cam.turn(-Math.max(-1, Math.min(1, r.turn)) * rawDt * 2.2);
if (r.tilt) cam.tilt(Math.max(-1, Math.min(1, r.tilt)) * rawDt * 1.3);
if (r.lookX || r.lookY) cam.look(r.lookX, r.lookY);
});
}
if (this.movers.length) this.updateMovers(dt);
this.updateCars(dt);
this.updateRollers(dt);
this.updateHazards(dt);
this.updatePowerups(dt);
if (this.room.areas) this.fitHouseShadow();
const acting = this.phase === 'fight' || this.phase === 'roundEnd' || this.options.attract;
if (this.options.attract && this.phase === 'intro') this.phase = 'fight';
for (const f of this.fighters) {
let c: Controls;
const human = this.humanSlots.get(f.index);
if (!acting || (this.phase === 'roundEnd' && f.alive && f.state === 'victory')) c = noControls();
else if (human !== undefined) c = this.controlsFrom(raw[human] ?? null, this.split ? this.cams[human]! : this.cam);
else c = this.brains.get(f)!.think(dt);
if (this.phase === 'roundEnd' && !this.options.attract) c = noControls();
// At the wheel: W/S (stick up/down) are the pedals, A/D (stick left/right) steer, jump hops out.
const car = this.driving.get(f);
if (car) {
const r = human !== undefined ? raw[human] : null;
car.throttle = r?.y ?? 0;
car.turning = r?.x ?? 0;
car.handbrake = r?.handbrake ?? false;
if (c.jump) this.leaveCar(f, car);
}
f.update(dt, c);
}
this.separate();
this.keepInside();
for (const f of this.fighters) f.animate(dt);
this.resolveStrikes();
for (const f of this.fighters) this.fx.trail(f.index, f.bladeBase, f.bladeTip, f.state === 'attack' && f.striking && !f.attack?.limb && !!f.weapon, f.team ? 0xcfe6ff : 0xffe2d0);
this.updatePickups(dt);
this.updateProjectiles(dt);
this.updatePhase(dt);
for (let i = this.popups.length - 1; i >= 0; i--) {
const p = this.popups[i]!;
p.age += rawDt;
p.at.y += rawDt * 0.6;
if (p.age > 1.1) this.popups.splice(i, 1);
}
this.refreshViews();
if (this.split) {
for (const view of this.views()) {
const me = view.fighter;
// The spare quarter with three samurai shows the whole fight.
if (me) view.cam.update(rawDt, this.viewPoints(me), this.viewDuel(me), view.w / view.h);
else view.cam.update(rawDt, this.focusPoints(), this.duelPair(), view.w / view.h);
}
} else this.cam.update(rawDt, this.focusPoints(), this.duelPair(), this.width / this.height);
this.fx.update(dt, this.cam.camera);
this.render();
}
render(): void {
const r = this.renderer;
if (!this.split) {
this.cam.beginRender();
r.render(this.scene, this.cam.camera);
this.cam.endRender();
return;
}
// One viewport per player; each camera applies its own wall cutaway and see-through furniture.
r.setScissorTest(true);
for (const v of this.views()) {
const y = this.height - v.y - v.h;
r.setViewport(v.x, y, v.w, v.h);
r.setScissor(v.x, y, v.w, v.h);
v.cam.beginRender();
r.render(this.scene, v.cam.camera);
v.cam.endRender();
}
r.setScissorTest(false);
r.setViewport(0, 0, this.width, this.height);
}
// ---- Split screen ----------------------------------------------------------------------
/** Human players in this match. */
get humanCount(): number {
return this.humanSlots.size;
}
/**
* Who each screen section follows: every samurai in the fight gets one, players first
* (in player order) and then CPUs, up to four. In Waves, enemies take a free section
* as they arrive and give it up once they're gone.
*/
private viewSlots: Fighter[] = [];
private refreshViews(): void {
if (!this.options || this.options.attract) {
this.viewSlots = [];
return;
}
const before = this.viewSlots;
const keep = before.filter((f) => this.fighters.includes(f));
const order = [...this.fighters].sort((a, b) => this.viewRank(a) - this.viewRank(b));
for (const f of order) {
if (keep.length >= 4) break;
if (!keep.includes(f) && (f.alive || f.human)) keep.push(f);
}
keep.sort((a, b) => this.viewRank(a) - this.viewRank(b) || before.indexOf(a) - before.indexOf(b));
// A section that now follows someone new jumps straight to them.
const changed = keep.some((f, i) => before[i] !== f);
this.viewSlots = keep;
if (changed && this.phase !== 'intro') {
for (const view of this.views()) {
if (!view.fighter || before[this.cams.indexOf(view.cam)] === view.fighter) continue;
const duel = this.viewDuel(view.fighter);
view.cam.snap(this.viewPoints(view.fighter), view.w / view.h, duel?.[0], duel?.[1]);
}
}
}
/** Players (in player order) come before CPUs. */
private viewRank(f: Fighter): number {
const player = this.humanSlots.get(f.index);
return player !== undefined ? player : 10 + this.fighters.indexOf(f);
}
/** Every samurai gets their own camera whenever there's more than one (not in the menu backdrop). */
get split(): boolean {
return !!this.options && !this.options.attract && this.viewSlots.length > 1;
}
/**
* Screen areas in CSS pixels (top-left origin): the whole canvas; two halves (side
* by side on wide frames, stacked on tall ones); or quarters for 3-4 samurai, where
* the 4th quarter is an overview of the whole fight when only three are fighting.
* `human` is the player number a view belongs to (-1 for a CPU or the overview).
*/
views(): { cam: FightCamera; human: number; fighter: Fighter | null; x: number; y: number; w: number; h: number }[] {
const humanOf = (f: Fighter | null | undefined) => (f ? (this.humanSlots.get(f.index) ?? -1) : -1);
if (!this.split) {
const solo = this.viewSlots[0] ?? this.humanFighter(0) ?? null;
return [{ cam: this.cam, human: 0, fighter: solo, x: 0, y: 0, w: this.width, h: this.height }];
}
const slots = this.viewSlots;
if (slots.length === 2) {
const wide = this.width >= this.height;
const w = wide ? Math.floor(this.width / 2) : this.width;
const h = wide ? this.height : Math.floor(this.height / 2);
return [
{ cam: this.cam, human: humanOf(slots[0]), fighter: slots[0]!, x: 0, y: 0, w, h },
{ cam: this.cam2, human: humanOf(slots[1]), fighter: slots[1]!, x: wide ? this.width - w : 0, y: wide ? 0 : this.height - h, w, h },
];
}
const w = Math.floor(this.width / 2);
const h = Math.floor(this.height / 2);
return [0, 1, 2, 3].map((i) => ({
cam: this.cams[i]!,
human: humanOf(slots[i]),
fighter: slots[i] ?? null,
x: i % 2 ? this.width - w : 0,
y: i >= 2 ? this.height - h : 0,
w,
h,
}));
}
humanFighter(player: number): Fighter | undefined {
for (const [index, human] of this.humanSlots) if (human === player) return this.fighters.find((f) => f.index === index);
return undefined;
}
/** Nearest living foe anywhere (HUD: whose health a player sees beside their own). */
nearestFoe(me: Fighter): Fighter | null {
let best: Fighter | null = null;
let bestD = Infinity;
for (const f of this.fighters) {
if (f.team === me.team || !f.alive) continue;
const d = f.pos.distanceTo(me.pos);
if (d < bestD) {
bestD = d;
best = f;
}
}
return best;
}
/** Nearest living foe close enough to share a player's view. */
private viewFoe(me: Fighter): Fighter | null {
let best: Fighter | null = null;
let bestD = 7;
for (const f of this.fighters) {
if (f.team === me.team || !f.alive) continue;
const d = f.pos.distanceTo(me.pos);
if (d < bestD) {
bestD = d;
best = f;
}
}
return best;
}
/** A split-screen camera follows its player, widening to include a nearby foe. */
private viewPoints(me: Fighter): THREE.Vector3[] {
const foe = this.viewFoe(me);
return foe ? [me.pos, foe.pos] : [me.pos];
}
private viewDuel(me: Fighter): [THREE.Vector3, THREE.Vector3] | null {
const foe = this.viewFoe(me);
return foe ? [me.pos, foe.pos] : null;
}
/** Camera shake on every camera that's showing. */
private shake(amount: number): void {
if (!this.cam.shakeEnabled) return;
for (const v of this.views()) v.cam.shake(amount);
}
/** Raw stick input → camera-relative world controls. */
private controlsFrom(r: RawInput | null, cam: FightCamera): Controls {
const c = noControls();
if (!r) return c;
cam.basis(this.fwd, this.right);
c.moveX = this.right.x * r.x + this.fwd.x * r.y;
c.moveZ = this.right.z * r.x + this.fwd.z * r.y;
c.run = r.run;
c.crouch = r.crouch;
c.jump = r.jump;
c.climb = r.up;
c.drop = r.down;
c.light = r.light;
c.heavy = r.heavy;
c.block = r.block;
c.grab = r.grab;
c.roll = r.roll;
return c;
}
private focusPoints(): THREE.Vector3[] {
const living = this.fighters.filter((f) => f.alive || this.phase !== 'fight');
return (living.length ? living : this.fighters).map((f) => f.pos);
}
/** The duel the camera stays side-on to: the first human and their nearest foe. */
private duelPair(): [THREE.Vector3, THREE.Vector3] | null {
const lead = this.fighters.find((f) => f.human && f.alive) ?? this.fighters.find((f) => f.alive);
if (!lead) return null;
let foe: Fighter | null = null;
let best = Infinity;
for (const f of this.fighters) {
if (f.team === lead.team || !f.alive) continue;
const d = f.pos.distanceTo(lead.pos);
if (d < best) {
best = d;
foe = f;
}
}
return foe ? [lead.pos, foe.pos] : null;
}
/** Safety net: anyone outside the walls, on top of a wall or stuck inside furniture goes back to safe ground. */
private keepInside(): void {
const room = this.room!;
const b = room.bounds;
for (const f of this.fighters) {
const p = f.pos;
const away = room.areas ? !room.areas.some((a) => p.x >= a.minX && p.x <= a.maxX && p.z >= a.minZ && p.z <= a.maxZ) : p.x < b.minX || p.x > b.maxX || p.z < b.minZ || p.z > b.maxZ;
const outside = away || p.y > room.ceiling + 4 || p.y < -1;
const stuck = f.state !== 'hang' && f.state !== 'climb' && this.world.blockedStatic(p.x, p.y + 0.05, p.z, 0.3, 0.1);
if (outside || stuck) f.rescue();
}
}
/** A random point on the floor: anywhere in a single room, or in one of the house's rooms (bigger ones more often). */
private randomSpot(margin: number): { x: number; z: number; area?: RoomId } {
const room = this.room!;
const areas = room.areas?.filter((a) => !a.doorway) ?? [{ ...room.bounds, room: undefined }];
const size = (a: { minX: number; maxX: number; minZ: number; maxZ: number }) => (a.maxX - a.minX) * (a.maxZ - a.minZ);
let pick = Math.random() * areas.reduce((t, a) => t + size(a), 0);
let area = areas[0]!;
for (const a of areas) {
area = a;
pick -= size(a);
if (pick <= 0) break;
}
return {
x: area.minX + margin + Math.random() * Math.max(0, area.maxX - area.minX - 2 * margin),
z: area.minZ + margin + Math.random() * Math.max(0, area.maxZ - area.minZ - 2 * margin),
area: area.room,
};
}
// ---- Shadows in the house --------------------------------------------------------------
private shadowHalf = 0;
/**
* The house is too big for one sharp shadow map, so the sun's shadow box covers just
* the fighters (growing when they are far apart). Its centre moves in whole shadow
* texels, so shadows don't shimmer as it follows them.
*/
private fitHouseShadow(): void {
const room = this.room!;
const watched = this.viewSlots.filter((f) => f.alive);
const who = watched.length ? watched : this.fighters;
if (!who.length) return;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const f of who) {
minX = Math.min(minX, f.pos.x);
maxX = Math.max(maxX, f.pos.x);
minZ = Math.min(minZ, f.pos.z);
maxZ = Math.max(maxZ, f.pos.z);
}
const half = Math.min(90, Math.ceil((Math.max(maxX - minX, maxZ - minZ) / 2 + 26) / 8) * 8);
const sc = this.sun.shadow.camera;
if (half !== this.shadowHalf) {
this.shadowHalf = half;
sc.left = sc.bottom = -half;
sc.right = sc.top = half;
sc.near = 1;
sc.far = 160;
sc.updateProjectionMatrix();
}
const texel = (2 * half) / this.sun.shadow.mapSize.x;
const dir = room.sunDir;
const back = _shadowZ.copy(dir).negate();
const right = _shadowX.crossVectors(_up, back).normalize();
const up = _shadowY.crossVectors(back, right);
const c = _shadowC.set((minX + maxX) / 2, 3, (minZ + maxZ) / 2);
const a = Math.round(c.dot(right) / texel) * texel;
const b = Math.round(c.dot(up) / texel) * texel;
const d = c.dot(back);
c.copy(right).multiplyScalar(a).addScaledVector(up, b).addScaledVector(back, d);
this.sun.target.position.copy(c);
this.sun.position.copy(c).addScaledVector(dir, -70);
}
// ---- Moving machines ----------------------------------------------------------------
private movers: Mover[] = [];
private readonly bodyOf = new Map<Solid, Body>();
/** Samurai who were riding (or hanging from) a moving body last frame. */
private readonly airFrom = new Map<Fighter, Body>();
/**
* Run the machines and carry anyone (and anything) standing on them or hanging from
* their edges. Leaving one keeps its motion: off the train you go on moving with it
* until you land; off a fan blade or a pendulum you're flung the way it was going.
* The train nudges samurai in its way off the track, or scoops them up if there's
* no room beside it.
*/
private updateMovers(dt: number): void {
const launches: { solids: Solid[]; vel: THREE.Vector3 }[] = [];
const events: MoverEvents = {
launch: (solids, vel) => launches.push({ solids, vel: vel.clone() }),
sound: (kind, at) => this.machineSound(kind, at),
};
for (const m of this.movers) m.update(dt, events);
for (const f of this.fighters) {
const body = f.body;
f.carryVel.set(0, 0, 0);
const on = body.onGround && body.support ? this.bodyOf.get(body.support) : undefined;
if (on) {
f.facing += on.carry(body.pos);
this.airFrom.set(f, on);
continue;
}
// Hanging from (or climbing onto) a moving edge: go where it goes.
const ledge = f.heldLedge;
const held = ledge ? this.bodyOf.get(ledge.solid) : undefined;
if (held) {
f.followLedge((p) => held.carry(p), held.turn);
this.airFrom.set(f, held);
continue;
}
const left = this.airFrom.get(f);
if (left) {
if (left.options.pushes && !body.onGround && f.state !== 'hang' && f.state !== 'climb') {
// Off the train: still moving with it, so a hop lands back on the same spot.
f.facing += left.carry(body.pos);
f.carryVel.copy(left.pointVelocity(body.pos, _moverVel)).setY(0);
continue;
}
if (!left.options.pushes) {
// Let go of something swinging or spinning: flung the way it was going.
left.pointVelocity(body.pos, _moverVel);
body.vel.x += _moverVel.x;
body.vel.z += _moverVel.z;
body.vel.y += Math.max(0, _moverVel.y);
}
this.airFrom.delete(f);
}
if (f.state === 'hang' || f.state === 'climb') continue;
for (const m of this.movers) {
for (const b of m.bodies) {
if (!b.options.pushes) continue;
const part = b.partAt(body.pos.x, body.pos.z, RADIUS);
if (!part || body.pos.y >= b.y + part.top - 0.25 || body.pos.y + HEIGHT < b.y + 0.05) continue;
const { side, along } = b.local(body.pos.x, body.pos.z);
const clear = part.hw + RADIUS + 0.04;
const sign = side >= 0 ? 1 : -1;
// Slide out sideways; while still in front of it, get pushed along ahead.
let sd = side + sign * Math.min(clear - Math.abs(side), 7 * dt);
let a = along;
if (Math.abs(sd) < clear) a = along >= (part.z0 + part.z1) / 2 ? Math.max(along, part.z1 + RADIUS + 0.02) : Math.min(along, part.z0 - RADIUS - 0.02);
_moverPos.y = body.pos.y;
b.world(sd, a, _moverPos);
if (this.world.blockedStatic(_moverPos.x, body.pos.y, _moverPos.z, body.height)) {
// Hemmed in (chair legs, a doorway): ride along on top instead.
sd = Math.max(-part.hw + 0.3, Math.min(part.hw - 0.3, side));
a = Math.max(part.z0 + 0.3, Math.min(part.z1 - 0.3, along));
_moverPos.y = b.y + part.top + 0.02;
b.world(sd, a, _moverPos);
body.vel.set(0, 0, 0);
body.onGround = false;
}
body.pos.copy(_moverPos);
break;
}
}
}
// Things lying on a moving body ride along; things in the train's way get knocked aside.
for (const p of this.pickups) {
if (!p.resting) continue;
outer: for (const m of this.movers) {
for (const b of m.bodies) {
const part = b.partAt(p.pos.x, p.pos.z, 0.15);
if (!part) continue;
const top = b.y + part.top;
if (Math.abs(p.floorY - top) < 0.06 || (p.floorY > top - 0.1 && p.floorY < top + 0.4 && b.vel.y > 0)) {
const turn = b.carry(p.pos);
p.floorY = top;
p.obj.quaternion.premultiply(_spinQ.setFromAxisAngle(_up, turn));
p.obj.position.copy(p.pos);
break outer;
}
if (b.options.pushes && p.floorY < top - 0.2 && p.floorY > b.y - 0.2) {
const { side } = b.local(p.pos.x, p.pos.z);
b.world(side >= 0 ? 1 : -1, 0, _moverPos.set(0, 0, 0));
const out = _moverPos.sub(_moverVel.set(b.x, 0, b.z)).normalize();
p.vel.set(out.x * 3.5, 2.5, out.z * 3.5);
p.spin.set(Math.random() * 4, Math.random() * 4, Math.random() * 4);
p.resting = false;
p.landed = false;
break outer;
}
}
}
}
// The toaster pops: fling whoever (and whatever) is standing on it.
for (const l of launches) {
for (const f of this.fighters) {
if (!f.body.onGround || !f.body.support || !l.solids.includes(f.body.support)) continue;
f.launch(l.vel.x + (Math.random() - 0.5) * 3, l.vel.y, l.vel.z + (Math.random() - 0.5) * 3);
this.airFrom.delete(f);
this.popup(f, 'Boing!', '#ffd27a');
}
for (const p of this.pickups) {
if (!p.resting) continue;
const under = this.world.groundBelow(p.pos.x, p.floorY + 0.05, p.pos.z, 0.1);
if (!under || !l.solids.includes(under)) continue;
p.vel.set((Math.random() - 0.5) * 3, l.vel.y * 0.8, (Math.random() - 0.5) * 3);
p.spin.set(Math.random() * 10, Math.random() * 10, Math.random() * 10);
p.resting = false;
p.landed = false;
}
}
}
/** A machine's sound, as loud as the nearest player is close to it. */
private machineSound(kind: 'chuff' | 'whistle' | 'pop' | 'tick' | 'whirr', at: THREE.Vector3): void {
let near = Infinity;
const listeners = this.fighters.filter((f) => f.human);
for (const f of listeners.length ? listeners : this.fighters) near = Math.min(near, f.pos.distanceTo(at));
const v = Math.max(0, 1 - near / 28);
if (v <= 0) return;
if (kind === 'chuff') sfx.sfxChuff(v);
else if (kind === 'whistle') sfx.sfxWhistle(v);
else if (kind === 'pop') sfx.sfxPop(v);
else if (kind === 'tick') sfx.sfxTick(v);
}
// ---- Rolling things -----------------------------------------------------------------
private rollers: Roller[] = [];
private statics: Solid[] = [];
/**
* Balls, cans and reels: walk into one and it rolls away, hit one and it shoots off,
* and one rolling fast bowls over anyone in its way.
*/
private updateRollers(dt: number): void {
for (const r of this.rollers) {
r.update(dt, this.statics);
for (const f of this.fighters) {
const dy = f.pos.y - r.pos.y;
if (dy > r.r - 0.1 || dy < -r.r - HEIGHT) continue;
// A strike sends it flying.
if (f.striking && (r.lastHitBy !== f || r.sinceHit > 0.3)) {
for (const tip of [f.bladeTip, f.bladeBase]) {
if (tip.distanceTo(r.pos) > r.r + (r.len ? r.len * 0.5 : 0) + 0.25) continue;
const fwd = f.forward();
const power = f.attack?.kind === 'heavy' ? 12 : 8;
r.push(fwd.x * power - r.vel.x * 0.5, fwd.z * power - r.vel.z * 0.5, r.def.kind === 'ball' ? 4 : 2.5);
r.lastHitBy = f;
r.sinceHit = 0;
sfx.sfxHit(false);
this.fx.impact(tip, 0.5, 0xfff1c8);
break;
}
}
// Bumping into it.
const dx = r.pos.x - f.pos.x;
const dz = r.pos.z - f.pos.z;
const reach = (r.len ? Math.max(r.r, r.len * 0.45) : r.r) + RADIUS;
const d = Math.hypot(dx, dz);
if (d >= reach || d < 1e-4) continue;
const nx = dx / d;
const nz = dz / d;
const fast = r.speed > 3.2 && !(r.lastHitBy === f && r.sinceHit < 0.6);
if (fast && f.vulnerable && f.alive) {
// Bowled over.
if (f.hurt(4, r.pos, 5, true)) this.popup(f, 'Bowled over!', '#ffd27a');
r.vel.x *= 0.35;
r.vel.z *= 0.35;
sfx.sfxHit(true);
continue;
}
// Pushed along: it gets out of the way at the speed you walked into it.
const into = Math.max(0, f.body.vel.x * nx + f.body.vel.z * nz);
const along = r.vel.x * nx + r.vel.z * nz;
if (along < into * 1.05 + 0.6) r.push(nx * (into * 1.05 + 0.6 - along), nz * (into * 1.05 + 0.6 - along));
const ox = r.pos.x;
const oz = r.pos.z;
r.pos.x = f.pos.x + nx * reach;
r.pos.z = f.pos.z + nz * reach;
if (r.stuck(this.statics)) {
// Jammed against something: it stays put and the samurai stops against it instead.
r.pos.x = ox;
r.pos.z = oz;
r.vel.x = r.vel.z = 0;
f.pos.x = r.pos.x - nx * reach;
f.pos.z = r.pos.z - nz * reach;
}
r.lastHitBy = f;
r.sinceHit = 0;
}
}
}
// ---- Car boxes and toy cars ---------------------------------------------------------------
private crates: Crate[] = [];
private driftClock = 0;
private skidClock = 0;
private cars: Car[] = [];
/** Who is driving which car. */
private readonly driving = new Map<Fighter, Car>();
/** Fresh crates, no cars: as the round starts. */
private resetCars(): void {
const room = this.room!;
for (const c of this.crates) {
this.removeSolid(c.solid);
c.root.removeFromParent();
}
for (const car of this.cars) this.removeCar(car);
this.crates = [];
this.cars = [];
this.driving.clear();
for (const d of room.crates ?? []) {
const c = new Crate(d.x, d.y, d.z);
room.root.add(c.root);
room.solids.push(c.solid);
this.crates.push(c);
}
this.statics = room.solids.filter((sol) => !sol.moving);
if (this.crates.length) this.nav = new NavGraph(room.solids.filter((sol) => !sol.moving));
}
private removeSolid(s: Solid): void {
const list = this.room!.solids;
const i = list.indexOf(s);
if (i >= 0) list.splice(i, 1);
}
private removeCar(car: Car): void {
for (const sol of car.body.solids) {
this.removeSolid(sol);
this.bodyOf.delete(sol);
}
car.root.removeFromParent();
const m = this.movers.findIndex((mv) => mv.bodies[0] === car.body);
if (m >= 0) this.movers.splice(m, 1);
}
tryDrive(f: Fighter): boolean {
if (this.driving.has(f) || !f.alive) return false;
for (const car of this.cars) {
if (car.driver || !car.touches(f.pos.x, f.pos.z, 0.9) || Math.abs(f.pos.y - car.pos.y) > 0.8) continue;
car.driver = f;
this.driving.set(f, car);
f.startDriving();
return true;
}
return false;
}
private leaveCar(f: Fighter, car: Car, hop = true): void {
car.driver = null;
car.throttle = car.turning = 0;
car.handbrake = false;
this.driving.delete(f);
if (!hop) return;
const side = _moverPos.set(Math.cos(car.yaw), 0, -Math.sin(car.yaw));
f.pos.addScaledVector(side, 0.9);
f.pos.y = car.pos.y + 0.8;
f.leaveCar(Math.sin(car.yaw) * car.speed, Math.cos(car.yaw) * car.speed, side);
}
/**
* Crates splinter under blows and let their car out; cars drive where their drivers
* steer, carry anyone standing on them, and bowl over anyone they hit at speed.
*/
private updateCars(dt: number): void {
for (const c of [...this.crates]) {
c.update(dt);
for (const f of this.fighters) {
if (!f.striking || (c.lastHitBy === f && c.sinceHit < 0.35)) continue;
const s = c.solid;
const inside = [f.bladeTip, f.bladeBase].some((p) => p.x > s.minX - 0.25 && p.x < s.maxX + 0.25 && p.z > s.minZ - 0.25 && p.z < s.maxZ + 0.25 && p.y > s.minY - 0.2 && p.y < s.maxY + 0.2);
if (!inside) continue;
c.lastHitBy = f;
c.sinceHit = 0;
const at = f.bladeTip.clone();
this.fx.chipsBurst(at, f.forward(), [0xc99a5e, 0x8a6238], 8, s.minY);
sfx.sfxHit(false);
if (c.hit(f.attack?.kind === 'heavy' ? 2 : 1)) this.breakCrate(c, f);
break;
}
}
for (const car of this.cars) {
const f = car.driver as Fighter | null;
if (f && (this.driving.get(f) !== car || f.state !== 'drive' || !f.alive)) {
// Knocked out of the seat.
this.leaveCar(f, car, false);
}
}
for (const car of this.cars) {
const f = car.driver as Fighter | null;
if (f) {
car.seat(f.pos);
f.facing = car.yaw;
f.body.vel.set(Math.sin(car.yaw) * car.speed, 0, Math.cos(car.yaw) * car.speed);
f.body.onGround = true;
f.body.support = null;
}
// Drifting: grey smoke and bits of rubber off the back wheels, and a squeal.
if (car.drifting) {
this.driftClock += dt;
if (this.driftClock > 0.03) {
this.driftClock = 0;
const back = _moverVel.set(-Math.sin(car.yaw), 0, -Math.cos(car.yaw));
for (const s of [-0.6, 0.6]) {
car.body.world(s, -0.65, _moverPos.set(0, car.pos.y, 0));
this.fx.tyreSmoke(_moverPos, back, car.pos.y, car.body.vel);
}
}
this.skidClock -= dt;
if (this.skidClock <= 0) {
this.skidClock = 0.16;
sfx.sfxSkid(Math.min(1, Math.abs(car.slip) / 8) * this.hearing(car.pos));
}
}
// Shove balls, cans and reels out of the way.
for (const r of this.rollers) {
if (Math.abs(r.pos.y - r.r - car.pos.y) > 0.6) continue;
const d = car.nearest(r.pos.x, r.pos.z, _moverPos);
const reach = r.r + 0.05;
if (d >= reach) continue;
const nx = d > 1e-4 ? (r.pos.x - _moverPos.x) / d : Math.sin(car.yaw);
const nz = d > 1e-4 ? (r.pos.z - _moverPos.z) / d : Math.cos(car.yaw);
r.pos.x = _moverPos.x + nx * reach;
r.pos.z = _moverPos.z + nz * reach;
const along = r.vel.x * nx + r.vel.z * nz;
const want = Math.abs(car.speed) * 1.15 + 0.5;
if (along < want) r.push(nx * (want - along), nz * (want - along), Math.abs(car.speed) > 4 ? 2 : 0);
r.lastHitBy = f;
r.sinceHit = 0;
}
// Running people over at speed; nudging them aside when creeping.
for (const o of this.fighters) {
if (o === f || !o.alive) continue;
if (o.pos.y >= car.pos.y + 0.6 || o.pos.y + HEIGHT < car.pos.y) continue;
const d = car.nearest(o.pos.x, o.pos.z, _moverPos);
if (d >= RADIUS) continue;
if (Math.hypot(car.speed, car.slip) >= 3 && o.vulnerable) {
if (o.hurt(6, car.pos, 6, true)) this.popup(o, 'Run over!', '#ff9a8a');
car.speed *= 0.5;
sfx.sfxHit(true);
continue;
}
const nx = d > 1e-4 ? (o.pos.x - _moverPos.x) / d : Math.cos(car.yaw);
const nz = d > 1e-4 ? (o.pos.z - _moverPos.z) / d : -Math.sin(car.yaw);
o.pos.x = _moverPos.x + nx * (RADIUS + 0.02);
o.pos.z = _moverPos.z + nz * (RADIUS + 0.02);
}
}
}
private breakCrate(c: Crate, by: Fighter): void {
const room = this.room!;
this.crates.splice(this.crates.indexOf(c), 1);
this.removeSolid(c.solid);
c.root.removeFromParent();
const mid = new THREE.Vector3(c.x, c.y + CRATE_SIZE / 2, c.z);
this.fx.chipsBurst(mid, new THREE.Vector3(0, 1, 0), [0xc99a5e, 0x8a6238, 0xd8262b], 26, c.y);
this.shake(0.2);
sfx.sfxBreak();
// The car inside faces away from whoever broke it open.
const car = new Car(c.x, c.y, c.z, Math.atan2(c.x - by.pos.x, c.z - by.pos.z));
room.root.add(car.root);
room.solids.push(...car.body.solids);
for (const sol of car.body.solids) this.bodyOf.set(sol, car.body);
const own = new Set(car.body.solids);
this.movers.push({ root: car.root, bodies: [car.body], update: (dt) => car.update(dt, this.world, own) });
this.cars.push(car);
this.statics = room.solids.filter((sol) => !sol.moving);
this.nav = new NavGraph(room.solids.filter((sol) => !sol.moving));
this.popup(by, 'A car!', '#ffcf8a');
}
// ---- Water and puddles ----------------------------------------------------------------
private pools: Pool[] = [];
private drips: Drip[] = [];
private puddles: Puddle[] = [];
private buildHazards(room: RoomData): void {
for (const r of this.rollers) r.root.removeFromParent();
this.rollers = (room.rollers ?? []).map((d) => new Roller(d));
for (const r of this.rollers) room.root.add(r.root);
for (const p of this.puddles) p.mesh.removeFromParent();
this.puddles = [];
this.pools = (room.pools ?? []).map((def) => {
const water = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), WATER_MAT);
water.position.set((def.minX + def.maxX) / 2, def.floor, (def.minZ + def.maxZ) / 2);
water.scale.set(def.maxX - def.minX - 0.04, 0.001, def.maxZ - def.minZ - 0.04);
water.renderOrder = 2;
room.root.add(water);
const stream = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 1, 8), WATER_MAT);
room.root.add(stream);
return { def, level: def.low, clock: 0, water, stream };
});
this.drips = (room.drips ?? []).map((def) => {
const floor = this.world.heightAt(def.x, def.y - 0.05, def.z);
const puddle = this.addPuddle(def.x, floor, def.z, def.r, Infinity, 0.02);
const drops = Array.from({ length: 5 }, () => {
const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.07, 0), WATER_MAT);
mesh.visible = false;
room.root.add(mesh);
return { mesh, vy: 0, live: false };
});
return { def, floor, puddle, clock: 0, drops };
});
}
/** Back to how they start: an empty bath, a dry floor, rollers in their places. */
private resetHazards(): void {
for (const p of this.pools) {
p.clock = 0;
p.level = p.def.low;
}
for (const d of this.drips) {
d.puddle.r = 0.3;
d.clock = 0;
}
for (const p of [...this.puddles]) if (p.ttl !== Infinity) this.removePuddle(p);
for (const r of this.rollers) {
r.pos.set(r.def.x, r.def.y + r.r, r.def.z);
r.vel.set(0, 0, 0);
}
}
private addPuddle(x: number, y: number, z: number, max: number, ttl: number, grow = 6): Puddle {
const mesh = new THREE.Mesh(PUDDLE_GEO, PUDDLE_MAT);
mesh.position.set(x, y + 0.03, z);
mesh.renderOrder = 1;
this.room!.root.add(mesh);
const p: Puddle = { x, y, z, r: 0.3, max, grow, ttl, mesh };
mesh.scale.setScalar(p.r);
this.puddles.push(p);
return p;
}
private removePuddle(p: Puddle): void {
p.mesh.removeFromParent();
this.puddles.splice(this.puddles.indexOf(p), 1);
}
private updateHazards(dt: number): void {
// The bath fills slowly, stays full a while, drains, and starts again.
for (const p of this.pools) {
const { fill, low, high, floor, tap } = p.def;
const hold = 12;
const drain = 18;
const empty = 10;
p.clock = (p.clock + dt) % (fill + hold + drain + empty);
const t = p.clock;
p.level = t < fill ? low + ((high - low) * t) / fill : t < fill + hold ? high : t < fill + hold + drain ? high - ((high - low) * (t - fill - hold)) / drain : low;
const depth = Math.max(0.001, p.level - floor);
p.water.scale.y = depth;
p.water.position.y = floor + depth / 2;
p.water.visible = p.level > floor + 0.02;
// The tap runs while it fills.
const pouring = t < fill;
p.stream.visible = pouring;
if (pouring) {
const len = Math.max(0.1, tap.y - p.level);
p.stream.position.set(tap.x, p.level + len / 2, tap.z);
p.stream.scale.set(1, len, 1);
}
}
// The tap drips and the puddle under it spreads.
for (const d of this.drips) {
d.clock += dt;
d.puddle.r = Math.min(d.def.r, d.puddle.r + dt * 0.05);
d.puddle.mesh.scale.setScalar(d.puddle.r);
if (d.clock > 0.8) {
d.clock = 0;
const drop = d.drops.find((o) => !o.live);
if (drop) {
drop.live = true;
drop.vy = 0;
drop.mesh.visible = true;
drop.mesh.position.set(d.def.x, d.def.y, d.def.z);
}
}
for (const drop of d.drops) {
if (!drop.live) continue;
drop.vy -= 24 * dt;
drop.mesh.position.y += drop.vy * dt;
if (drop.mesh.position.y <= d.floor + 0.05) {
drop.live = false;
drop.mesh.visible = false;
}
}
}
// Splashes dry up.
for (const p of [...this.puddles]) {
if (p.r < p.max) {
p.r = Math.min(p.max, p.r + p.grow * dt);
p.mesh.scale.setScalar(p.r);
}
if (p.ttl === Infinity) continue;
p.ttl -= dt;
if (p.ttl < 2) p.mesh.scale.setScalar(p.r * Math.max(0.05, p.ttl / 2));
if (p.ttl <= 0) this.removePuddle(p);
}
}
slipAt(p: THREE.Vector3): number {
let slip = 0;
for (const q of this.puddles) {
if (Math.abs(p.y - q.y) > 0.3) continue;
const d = Math.hypot(p.x - q.x, p.z - q.z);
if (d < q.r) slip = Math.max(slip, Math.min(1, (q.r - d) / 0.4));
}
return slip;
}
waterAt(p: THREE.Vector3): number | null {
for (const pool of this.pools) {
const d = pool.def;
if (p.x < d.minX || p.x > d.maxX || p.z < d.minZ || p.z > d.maxZ) continue;
if (pool.level <= d.floor + 0.05 || p.y > pool.level + 0.5) continue;
return pool.level;
}
return null;
}
effect(kind: 'splash' | 'sparkle' | 'guard', p: THREE.Vector3): void {
if (kind === 'splash') {
this.fx.chipsBurst(p, new THREE.Vector3(0, 1, 0), [0x9fd8f5, 0xffffff, 0x6cc2e6], 12, p.y);
sfx.sfxLand(false);
} else if (kind === 'sparkle') this.fx.sparks(_moverPos.copy(p).setY(p.y + 0.4 + Math.random() * 0.5), 1, 0.35, 0xffffff);
else {
this.fx.sparks(p, 14, 1.0, 0xfff0b0);
sfx.sfxClang();
}
}
// ---- Pick-ups ------------------------------------------------------------------------
private powerClock = 0;
/** Every so often a new pick-up turns up, so there are always a few about. */
private updatePowerups(dt: number): void {
if (this.phase !== 'fight' || this.options?.attract) return;
this.powerClock += dt;
if (this.powerClock < 18) return;
this.powerClock = 0;
const lying = this.pickups.filter((p) => isThrow(p.def) && p.def.power).length;
if (lying < (this.room?.areas ? 5 : 2)) this.scatterThrowables(1, POWERUPS.map((d) => d.id));
}
/** Fighters push each other apart instead of overlapping. */
private separate(): void {
const fs = this.fighters;
for (let i = 0; i < fs.length; i++) {
for (let j = i + 1; j < fs.length; j++) {
const a = fs[i]!;
const b = fs[j]!;
if (!a.alive || !b.alive || a.state === 'hang' || b.state === 'hang' || a.state === 'climb' || b.state === 'climb') continue;
if (Math.abs(a.pos.y - b.pos.y) > 0.8) continue;
const dx = b.pos.x - a.pos.x;
const dz = b.pos.z - a.pos.z;
const d = Math.hypot(dx, dz);
const min = 0.42;
if (d < min && d > 1e-4) {
const push = (min - d) / 2;
const nx = dx / d;
const nz = dz / d;
const pa = a.pos.clone();
const pb = b.pos.clone();
a.pos.x -= nx * push;
a.pos.z -= nz * push;
b.pos.x += nx * push;
b.pos.z += nz * push;
// Don't shove anyone into furniture.
if (this.world.blocked(a.pos.x, a.pos.y, a.pos.z, a.body.height)) a.pos.copy(pa);
if (this.world.blocked(b.pos.x, b.pos.y, b.pos.z, b.body.height)) b.pos.copy(pb);
}
}
}
}
// ---- Combat ----------------------------------------------------------------------------
private resolveStrikes(): void {
for (const a of this.fighters) {
if (!a.striking || a.freeze > 0) continue;
const att = a.attack!;
for (const e of this.fighters) {
if (e === a || e.team === a.team || a.struck.has(e) || !e.vulnerable) continue;
const contact = att.radius ? this.radialHit(a, e, att.radius) : this.bladeHit(a, e, att.limb ? 0.13 : 0.05);
if (contact) this.resolveHit(a, e, contact);
}
}
}
private radialHit(a: Fighter, e: Fighter, radius: number): THREE.Vector3 | null {
const d = Math.hypot(e.pos.x - a.pos.x, e.pos.z - a.pos.z);
if (d > radius || Math.abs(e.pos.y - a.pos.y) > 0.5) return null;
return e.pos.clone().setY(e.pos.y + 0.3);
}
/** Sweep the blade from last frame to this frame against the victim's body capsule. */
private bladeHit(a: Fighter, e: Fighter, pad: number): THREE.Vector3 | null {
const r = 0.27 + pad;
const y0 = e.pos.y + 0.05;
const y1 = e.pos.y + e.hurtHeight;
const p = new THREE.Vector3();
const q = new THREE.Vector3();
for (let m = 0; m <= 3; m++) {
const mt = m / 3;
for (let s = 0; s <= 6; s++) {
const st = s / 6;
p.lerpVectors(a.prevBase, a.prevTip, st);
q.lerpVectors(a.bladeBase, a.bladeTip, st);
p.lerp(q, mt);
if (p.y < y0 - pad || p.y > y1 + pad) continue;
if (Math.hypot(p.x - e.pos.x, p.z - e.pos.z) < r) return p.clone();
}
}
return null;
}
private resolveHit(a: Fighter, e: Fighter, at: THREE.Vector3): void {
const att = a.attack!;
a.struck.add(e);
const toA = new THREE.Vector3(a.pos.x - e.pos.x, 0, a.pos.z - e.pos.z).normalize();
const away = toA.clone().negate();
const frontal = e.forward().dot(toA) > 0.1;
const hasBlade = !!a.weapon && !att.limb;
const dmgMul = att.limb ? 1 : (a.weapon?.def.damage ?? 1);
const knockMul = att.limb ? 1 : (a.weapon?.def.knockback ?? 1);
// A button shield covers the sides too, and soaks up blows that would break a guard.
const covered = e.hasShield ? e.forward().dot(toA) > -0.3 : frontal;
if (e.blocking && covered) {
if (e.hasShield && !e.parryWindow()) {
e.blocked(a.pos, att.knock * 0.4 + 0.6);
a.freeze = e.freeze = 0.07;
this.fx.sparks(at, 12, 0.9, 0xffd0c0);
sfx.sfxClang();
if (att.guardBreak || att.kind === 'heavy') e.shieldHit();
this.wear(a);
return;
}
if (e.parryWindow()) {
// Perfect timing: the attacker is thrown wide open and may lose their weapon.
a.stagger();
a.freeze = e.freeze = 0.13;
this.fx.sparks(at, 26, 1.4, 0xfff0b0);
this.fx.impact(at, 0.9, 0xfff6d0);
this.shake(0.25);
sfx.sfxParry();
this.popup(e, 'Parry!', '#fff3b0');
if (a.weapon && (att.kind === 'heavy' || Math.random() < 0.3)) this.disarm(a, toA.clone().negate());
this.wear(e);
return;
}
if (att.guardBreak) {
e.health = Math.max(1, e.health - att.damage * dmgMul * 0.25);
e.stagger();
e.blocked(a.pos, 3);
a.freeze = e.freeze = 0.12;
e.shake = 0.12;
this.fx.sparks(at, 18, 1.2);
this.fx.impact(at, 0.7);
this.shake(0.3);
sfx.sfxClang();
this.popup(e, 'Guard break!', '#ffb36b');
if (e.weapon && Math.random() < 0.35) this.disarm(e, away);
this.wear(a);
this.wear(e);
return;
}
e.blocked(a.pos, att.knock * 0.5 + 0.8);
a.freeze = e.freeze = 0.06;
this.fx.sparks(at, 10, 0.9);
sfx.sfxClang();
this.wear(a);
this.wear(e);
return;
}
const counter = e.state === 'attack';
const damage = att.damage * dmgMul * (counter ? 1.2 : 1);
const lethal = e.health - damage <= 0;
const wasAlive = e.alive;
if (!e.hurt(damage, a.pos, att.knock * knockMul, !!att.knockdown || lethal)) {
a.freeze = e.freeze = 0.07;
this.fx.sparks(at, 10, 0.9);
sfx.sfxClang();
this.wear(a);
return;
}
const stop = att.hitstop * (lethal ? 1.6 : 1);
a.freeze = stop;
e.freeze = stop;
e.shake = stop;
this.fx.impact(at, att.kind === 'heavy' || lethal ? 1.1 : 0.65, hasBlade ? 0xffffff : 0xfff1c8);
this.fx.chipsBurst(at, away, [e.livery.armor, e.livery.cloth, e.livery.trim], att.kind === 'heavy' ? 12 : 6, e.pos.y);
if (hasBlade) this.fx.sparks(at, 6, 0.8);
this.shake(att.kind === 'heavy' || lethal ? 0.4 : 0.14);
sfx.sfxHit(att.kind === 'heavy' || !!att.knockdown);
if (counter) this.popup(e, 'Counter!', '#ff8f6b');
if (wasAlive && !e.alive) this.onKO(e, away);
else if (e.weapon && att.disarm && Math.random() < att.disarm + (counter ? 0.2 : 0)) this.disarm(e, away);
this.wear(a);
}
/** Improvised items wear out and snap. */
private wear(f: Fighter): void {
const w = f.weapon;
if (!w || !Number.isFinite(w.durability)) return;
w.durability -= 1;
if (w.durability > 0) return;
const at = f.bladeTip.clone().lerp(f.bladeBase, 0.5);
this.fx.chipsBurst(at, f.forward(), [0xd8d0c0, 0x8d8a85, 0xe0b25c], 12, f.pos.y);
this.fx.sparks(at, 8, 0.6, 0xfff2cc);
sfx.sfxBreak();
this.popup(f, `${w.def.name} broke!`, '#ffd9a0');
f.equip(null);
}
private onKO(e: Fighter, away: THREE.Vector3): void {
sfx.sfxKO();
if (e.weapon) this.dropWeapon(e, away, 0.8);
if (this.waveMode && e.team === 1) this.kills++;
const teamLeft = this.fighters.some((f) => f.team === e.team && f.alive) || (this.waveMode && e.team === 1 && this.waveLeft > 0);
// The deciding blow: in waves the last enemy of a wave, otherwise when only one side is left standing.
const decided = this.waveMode ? !teamLeft : new Set(this.fighters.filter((f) => f.alive).map((f) => f.team)).size <= 1;
if (decided) {
// Final blow: dramatic slow motion.
this.slowmo = 0.25;
this.slowTime = 1.1;
this.shake(0.5);
}
}
popup(f: Fighter, text: string, color: string): void {
const at = new THREE.Vector3();
f.headPos(at);
this.popups.push({ text, at, color, age: 0 });
}
// ---- Rounds ------------------------------------------------------------------------------
private updatePhase(dt: number): void {
const opts = this.options!;
if (this.phase === 'intro') {
if (this.phaseTime > 1.3 && this.banner?.text !== 'Fight!') {
this.banner = { text: 'Fight!', age: 0 };
sfx.sfxTaiko();
}
if (this.phaseTime > 1.7) {
this.phase = 'fight';
this.phaseTime = 0;
}
return;
}
if (this.waveMode) {
if (this.phase === 'fight') this.updateWaves(dt);
return;
}
if (this.phase === 'fight') {
if (this.exploring) return;
// No time limit: a round lasts until only one side (a team, or one samurai in free for all) is left.
const standing = [...new Set(this.fighters.filter((f) => f.alive).map((f) => f.team))];
if (standing.length <= 1) {
const winner = standing[0] ?? -1;
this.phase = 'roundEnd';
this.phaseTime = 0;
if (winner >= 0) {
this.wins[winner]!++;
for (const f of this.fighters) if (f.team === winner) f.win();
}
if (!opts.attract) {
this.banner = {
text: 'Ippon!',
sub: winner >= 0 ? this.winnerName(winner) : 'Draw',
age: 0,
};
}
}
return;
}
if (this.phase === 'roundEnd' && this.phaseTime > (opts.attract ? 4 : 3.4)) {
if (opts.attract) {
this.startRound();
return;
}
const champion = this.wins.findIndex((w) => w >= ROUNDS_TO_WIN);
if (champion >= 0) {
this.phase = 'matchEnd';
this.phaseTime = 0;
this.banner = null;
this.onMatchEnd(champion);
} else {
this.round++;
this.startRound();
}
}
}
// ---- Waves ---------------------------------------------------------------------------------
wave = 0;
kills = 0;
/** Enemies of the current wave that have not entered yet. */
private waveLeft = 0;
private waveBreak = 0;
private spawnTimer = 0;
private nextIndex = 100;
private updateWaves(dt: number): void {
// Fallen enemies are cleared away so the room does not fill with bodies.
for (const f of [...this.fighters]) {
if (f.team === 1 && f.state === 'ko' && f.stateTime > 3.5) this.removeFighter(f);
}
if (!this.fighters.some((f) => f.human && f.alive)) {
this.phase = 'matchEnd';
this.banner = null;
this.onMatchEnd(1);
return;
}
const enemies = this.fighters.filter((f) => f.team === 1 && f.alive).length;
if (this.waveBreak > 0) {
this.waveBreak -= dt;
if (this.waveBreak <= 0) {
this.wave++;
this.waveLeft = 2 + this.wave;
this.spawnTimer = 0.4;
this.banner = { text: `Wave ${this.wave}`, sub: `${this.waveLeft} enemies incoming`, age: 0 };
sfx.sfxGong();
}
return;
}
// Enemies drop in a few at a time; more can be in the room at once in later waves.
const maxAlive = Math.min(5, 2 + Math.floor(this.wave / 2));
if (this.waveLeft > 0 && enemies < maxAlive) {
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
this.spawnEnemy();
this.waveLeft--;
this.spawnTimer = 0.9;
}
}
if (this.waveLeft === 0 && enemies === 0) {
// Wave cleared: heal up, get fallen friends back on their feet, short breather.
for (const f of this.fighters) {
if (!f.human) continue;
if (!f.alive) {
f.reset(f.lastSafe.x, f.lastSafe.y, f.lastSafe.z, f.facing);
f.health = 40;
if (!f.weapon) f.equip(KATANA);
} else f.health = Math.min(100, f.health + 30);
}
this.banner = { text: 'Wave cleared!', sub: 'Health restored. Catch your breath.', age: 0 };
sfx.sfxTaiko();
this.waveBreak = 3.5;
}
}
private spawnEnemy(): void {
const room = this.room!;
const players = this.fighters.filter((f) => f.human && f.alive);
// Somewhere open on the floor, away from the players, with nothing overhead to land on.
let x = room.spawns[1]!.x;
let z = room.spawns[1]!.z;
for (let i = 0; i < 60; i++) {
const { x: tx, z: tz } = this.randomSpot(2);
if (this.world.blocked(tx, 0.01, tz, 9)) continue;
if (players.some((p) => Math.hypot(p.pos.x - tx, p.pos.z - tz) < 7)) continue;
x = tx;
z = tz;
break;
}
const n = this.nextIndex++;
const f = new Fighter(n, 1, 'Ronin', RONIN[n % RONIN.length]!, false, this.scene, this);
const target = players[0];
f.reset(x, 6, z, target ? Math.atan2(target.pos.x - x, target.pos.z - z) : 0);
f.body.onGround = false;
// Early waves fight with whatever they found lying around; later ones bring katanas.
if (Math.random() < Math.min(0.85, 0.15 + this.wave * 0.12)) f.equip(KATANA);
else {
const kinds = room.itemKinds;
f.equip(itemById(kinds[Math.floor(Math.random() * kinds.length)]!));
}
this.fighters.push(f);
this.brains.set(f, new Brain(f, this, this.wave <= 2 ? 'easy' : this.wave <= 5 ? 'normal' : 'hard'));
}
private removeFighter(f: Fighter): void {
f.rig.dispose();
this.fx.removeTrail(f.index);
this.brains.delete(f);
this.fighters.splice(this.fighters.indexOf(f), 1);
}
winnerName(team: number): string {
const names = this.fighters.filter((f) => f.team === team).map((f) => f.name);
const humans = names.filter((n) => !n.startsWith('CPU'));
if (this.fighters.length === 2 || names.length === 1) return `${names[0]} wins`;
return humans.length ? `${TEAM_NAMES[team]} team (${humans.join(' & ')}) wins` : `${TEAM_NAMES[team]} team wins`;
}
hudState(): HudState {
const enemiesLeft = this.waveLeft + this.fighters.filter((f) => f.team === 1 && f.alive).length;
return {
fighters: this.fighters,
wins: this.wins,
round: this.round,
banner: this.banner,
popups: this.popups,
wave: this.wave,
enemiesLeft,
kills: this.kills,
};
}
/**
* Project a world point to canvas pixels (null when off-screen). In split screen it
* lands in the given player's view, or else the view of whichever player is closer.
*/
project(p: THREE.Vector3, player?: number): { x: number; y: number } | null {
const views = this.views();
let view = views[0]!;
if (views.length > 1) {
if (player !== undefined) view = views.find((v) => v.human === player) ?? view;
else {
let best = Infinity;
for (const v of views) {
const me = v.fighter;
const d = me ? me.pos.distanceTo(p) : Infinity;
if (d < best) {
best = d;
view = v;
}
}
}
}
const n = p.clone().project(view.cam.camera);
if (n.z > 1 || Math.abs(n.x) > 1.05 || Math.abs(n.y) > 1.05) return null;
return { x: view.x + ((n.x + 1) / 2) * view.w, y: view.y + ((1 - n.y) / 2) * view.h };
}
}
