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 { noControls, type Controls, type Fighter } from './fighter.js';
import type { Solid } from './types.js';
import { GRAB_MAX } from './world.js';
export type Difficulty = 'easy' | 'normal' | 'hard';
interface Skill {
reaction: number;
block: number;
parry: number;
combo: number;
heavy: number;
pause: number;
aimError: number;
/** Chance to dodge-roll sideways out of a strike instead of blocking. */
roll: number;
}
const SKILL: Record<Difficulty, Skill> = {
easy: { reaction: 0.6, block: 0.12, parry: 0, combo: 1, heavy: 0.1, pause: 1.3, aimError: 0.5, roll: 0 },
normal: { reaction: 0.38, block: 0.35, parry: 0.08, combo: 2, heavy: 0.16, pause: 0.8, aimError: 0.25, roll: 0.1 },
hard: { reaction: 0.22, block: 0.6, parry: 0.3, combo: 3, heavy: 0.24, pause: 0.4, aimError: 0.08, roll: 0.22 },
};
type EdgeKind = 'walk' | 'climb' | 'drop';
interface Edge {
to: number;
kind: EdgeKind;
}
/** Walkable surfaces (box tops) and how a samurai gets between them. */
export class NavGraph {
readonly nodes: Solid[] = [];
readonly edges: Edge[][] = [];
constructor(solids: Solid[]) {
for (const s of solids) {
const w = s.maxX - s.minX;
const d = s.maxZ - s.minZ;
if (s.maxY > 20 || w < 0.5 || d < 0.5) continue;
if (s.grab === false && s.maxY > 0.01) continue;
this.nodes.push(s);
}
// A jump lifts the feet ~1.35, then the hands can catch a ledge GRAB_MAX above them.
const climbMax = GRAB_MAX + 1.15;
for (let i = 0; i < this.nodes.length; i++) {
const a = this.nodes[i]!;
const list: Edge[] = [];
for (let j = 0; j < this.nodes.length; j++) {
if (i === j) continue;
const b = this.nodes[j]!;
const gap = rectGap(a, b);
const dh = b.maxY - a.maxY;
// Small rises can be reached with a running jump across a wider gap.
if (dh > 0.3 && dh <= climbMax && gap <= (dh < 1.6 ? 1.6 : 1.1)) list.push({ to: j, kind: 'climb' });
else if (Math.abs(dh) <= 0.3 && gap <= 0.9) list.push({ to: j, kind: 'walk' });
else if (dh < -0.3 && gap <= 0.9) list.push({ to: j, kind: 'drop' });
}
this.edges.push(list);
}
}
indexOf(s: Solid | null): number {
return s ? this.nodes.indexOf(s) : -1;
}
/** Surface under a point (for fighters who are airborne or hanging). */
surfaceAt(p: THREE.Vector3): number {
let best = -1;
for (let i = 0; i < this.nodes.length; i++) {
const s = this.nodes[i]!;
if (p.x < s.minX || p.x > s.maxX || p.z < s.minZ || p.z > s.maxZ || s.maxY > p.y + 0.4) continue;
if (best < 0 || s.maxY > this.nodes[best]!.maxY) best = i;
}
return best;
}
/** Breadth-first route; returns the first step after `from`, or null. */
nextStep(from: number, to: number): Edge | null {
if (from < 0 || to < 0 || from === to) return null;
const prev = new Map<number, { from: number; edge: Edge }>();
const queue = [from];
const seen = new Set([from]);
while (queue.length) {
const n = queue.shift()!;
if (n === to) break;
for (const e of this.edges[n]!) {
if (seen.has(e.to)) continue;
seen.add(e.to);
prev.set(e.to, { from: n, edge: e });
queue.push(e.to);
}
}
if (!prev.has(to)) return null;
let cur = to;
let step = prev.get(cur)!;
while (step.from !== from) {
cur = step.from;
step = prev.get(cur)!;
}
return step.edge;
}
}
function rectGap(a: Solid, b: Solid): number {
const dx = Math.max(0, b.minX - a.maxX, a.minX - b.maxX);
const dz = Math.max(0, b.minZ - a.maxZ, a.minZ - b.maxZ);
return Math.hypot(dx, dz);
}
function clampToRect(s: Solid, x: number, z: number, inset: number, out: THREE.Vector3): THREE.Vector3 {
const ix = Math.min(inset, (s.maxX - s.minX) / 2);
const iz = Math.min(inset, (s.maxZ - s.minZ) / 2);
return out.set(Math.min(s.maxX - ix, Math.max(s.minX + ix, x)), s.maxY, Math.min(s.maxZ - iz, Math.max(s.minZ + iz, z)));
}
export interface BrainHost {
fighters: Fighter[];
nav: NavGraph;
pickups: { pos: THREE.Vector3; resting: boolean; owner: Fighter | null; throwable: boolean }[];
}
export class Brain {
private readonly skill: Skill;
private target: Fighter | null = null;
private retarget = 0;
private attackCooldown = 0.6;
private comboLeft = 0;
private comboTimer = 0;
private blockFor = 0;
private reactTo: unknown = null;
private strafe = Math.random() < 0.5 ? 1 : -1;
private strafeTimer = 0;
private stuck = 0;
private detour = 0;
private detourDir = new THREE.Vector3();
private lastPos = new THREE.Vector3();
private surface = -1;
private climbing = false;
private hangWait = 0;
private sideTry = 0;
private readonly aim = new THREE.Vector3();
constructor(
readonly fighter: Fighter,
private readonly host: BrainHost,
difficulty: Difficulty,
) {
this.skill = SKILL[difficulty];
}
think(dt: number): Controls {
const c = noControls();
const me = this.fighter;
if (!me.alive || me.state === 'victory') return c;
this.attackCooldown -= dt;
this.comboTimer -= dt;
this.blockFor -= dt;
this.strafeTimer -= dt;
this.retarget -= dt;
if (me.support) this.surface = this.host.nav.indexOf(me.support);
else if (me.state !== 'hang' && me.state !== 'climb' && me.state !== 'air') this.surface = this.host.nav.surfaceAt(me.pos);
if (me.state === 'hang') {
// Pause a beat on the ledge like a person would, then pull up.
this.hangWait += dt;
if (this.hangWait > 0.15 + Math.random() * 0.2) c.climb = true;
return c;
}
this.hangWait = 0;
if (!this.target || !this.target.alive || this.retarget <= 0) {
this.target = this.pickTarget();
this.retarget = 1;
}
const foe = this.target;
if (!foe) return c;
// Holding something overhead: get in range and throw it.
if (me.carrying) {
const d = foe.pos.distanceTo(me.pos);
if (d < 9 && me.state === 'move') c.light = true;
else this.goTo(foe.pos, foe.support ? this.host.nav.indexOf(foe.support) : this.host.nav.surfaceAt(foe.pos), c, dt, false);
return c;
}
// Disarmed: go and find something to hit with.
if (!me.weapon) {
const item = this.nearestItem(foe);
if (item) {
if (me.pos.distanceTo(item.pos) < 0.6 && me.body.onGround) {
c.grab = true;
return c;
}
this.goTo(item.pos, this.host.nav.surfaceAt(item.pos.clone().setY(item.pos.y + 0.2)), c, dt, true);
if (foe.pos.distanceTo(me.pos) < 1.2) this.fight(foe, c, dt);
return c;
}
}
const dx = foe.pos.x - me.pos.x;
const dz = foe.pos.z - me.pos.z;
const flat = Math.hypot(dx, dz);
const dy = foe.pos.y - me.pos.y;
const sameLevel = Math.abs(dy) < 0.6;
if (sameLevel && flat < 3.2) {
this.fight(foe, c, dt);
} else {
const foeSurface = foe.support ? this.host.nav.indexOf(foe.support) : this.host.nav.surfaceAt(foe.pos);
this.goTo(foe.pos, foeSurface, c, dt, flat > 4);
}
return c;
}
private pickTarget(): Fighter | null {
const me = this.fighter;
let best: Fighter | null = null;
let bestD = Infinity;
for (const f of this.host.fighters) {
if (f.team === me.team || !f.alive) continue;
// Prefer whoever is closest, with a little stickiness to the current target.
const d = f.pos.distanceTo(me.pos) - (f === this.target ? 2 : 0);
if (d < bestD) {
bestD = d;
best = f;
}
}
return best;
}
private nearestItem(foe: Fighter): { pos: THREE.Vector3 } | null {
const me = this.fighter;
let best: { pos: THREE.Vector3 } | null = null;
let bestD = 14;
for (const p of this.host.pickups) {
if (!p.resting || p.owner) continue;
// A weapon beats something to throw, unless the throwable is much closer.
const d = p.pos.distanceTo(me.pos) + Math.abs(p.pos.y - me.pos.y) * 2 + (p.throwable ? 5 : 0);
// Don't run past the enemy's blade to reach it.
const danger = foe.pos.distanceTo(p.pos) < 1 ? 4 : 0;
if (d + danger < bestD) {
bestD = d + danger;
best = p;
}
}
return best;
}
/** Close-range swordplay. */
private fight(foe: Fighter, c: Controls, dt: number): void {
const me = this.fighter;
const s = this.skill;
const to = new THREE.Vector3(foe.pos.x - me.pos.x, 0, foe.pos.z - me.pos.z);
const dist = to.length();
to.normalize();
const reach = (me.weapon ? me.weapon.def.length * 0.85 : 0.35) + 0.45;
// Defence: react to a foe who is winding up nearby.
const threat = foe.state === 'attack' && foe.attack && dist < (foe.weapon ? foe.weapon.def.length : 0.4) + 1.1;
if (threat && this.reactTo !== foe.attack && foe.attackTime < foe.attack!.active[1]) {
this.reactTo = foe.attack;
const lead = foe.attack!.active[0] / foe.attackRate - foe.stateTime;
// Sometimes tumble sideways out of the way instead of guarding.
if (Math.random() < s.roll && lead > s.reaction * 0.5) {
const side = Math.random() < 0.5 ? 1 : -1;
c.moveX = -to.z * side;
c.moveZ = to.x * side;
c.roll = true;
return;
}
const wantBlock = Math.random() < s.block;
// Parry means blocking right as the strike lands; otherwise block after reaction time.
if (wantBlock) {
const parry = Math.random() < s.parry;
const delay = parry ? Math.max(0, lead - 0.1) : s.reaction;
this.blockFor = 0.5 + delay;
this.blockDelay = delay;
}
}
if (this.blockFor > 0) {
this.blockDelay -= dt;
if (this.blockDelay <= 0) c.block = true;
if (foe.state !== 'attack' && this.blockDelay <= -0.1) this.blockFor = 0;
if (c.block) return;
}
// Combo in progress.
if (this.comboLeft > 0) {
if (this.comboTimer <= 0) {
this.comboLeft--;
this.comboTimer = 0.2;
if (this.comboLeft === 0 && Math.random() < s.heavy) c.heavy = true;
else c.light = true;
}
return;
}
if (dist <= reach + 0.1 && this.attackCooldown <= 0 && me.state === 'move') {
// Strike: occasionally open with a heavy, a low sweep, or a full combo.
const roll = Math.random();
if (roll < s.heavy) c.heavy = true;
else if (roll < s.heavy + 0.1) {
c.crouch = true;
c.light = true;
} else {
c.light = true;
this.comboLeft = Math.floor(Math.random() * s.combo);
this.comboTimer = 0.22;
}
this.attackCooldown = s.pause + Math.random() * s.pause;
return;
}
// Footwork: close in, keep a little distance, circle.
if (this.strafeTimer <= 0) {
this.strafe = Math.random() < 0.5 ? -1 : 1;
this.strafeTimer = 0.8 + Math.random() * 1.5;
}
const side = new THREE.Vector3(-to.z, 0, to.x).multiplyScalar(this.strafe);
const want = new THREE.Vector3();
if (dist > reach) want.copy(to);
else if (dist < reach * 0.55) want.copy(to).negate();
want.addScaledVector(side, dist < reach + 0.8 ? 0.6 : 0.2);
if (want.lengthSq() > 1) want.normalize();
c.moveX = want.x;
c.moveZ = want.z;
c.run = dist > 3;
// Running starts get a dash attack sometimes.
if (me.speed > 3.6 && dist < reach + 1.2 && this.attackCooldown <= 0) {
c.light = true;
this.attackCooldown = s.pause + 0.4;
}
}
private blockDelay = 0;
/** Walk/climb/drop toward a world position on surface `goal`. */
private goTo(goalPos: THREE.Vector3, goal: number, c: Controls, dt: number, run: boolean): void {
const me = this.fighter;
const nav = this.host.nav;
const step = this.surface >= 0 && goal >= 0 ? nav.nextStep(this.surface, goal) : null;
let climb = false;
if (step) {
const next = nav.nodes[step.to]!;
if (step.kind === 'climb') {
climb = true;
// Aim for the edge of the next surface, trying other sides if stuck.
const target = clampToRect(next, me.pos.x, me.pos.z, 0.3, this.aim);
if (this.sideTry % 2 === 1) {
target.x = this.sideTry % 4 === 1 ? next.minX + 0.3 : next.maxX - 0.3;
}
} else {
clampToRect(next, goalPos.x, goalPos.z, 0.3, this.aim);
}
} else {
this.aim.copy(goalPos);
}
const dir = new THREE.Vector3(this.aim.x - me.pos.x, 0, this.aim.z - me.pos.z);
const dist = dir.length();
if (dist > 0.01) dir.divideScalar(dist);
// Unstick: if we stop making progress, sidestep around the obstacle.
const moved = me.pos.distanceTo(this.lastPos);
this.lastPos.copy(me.pos);
if (dist > 0.4 && moved < 0.6 * dt && me.state === 'move') this.stuck += dt;
else this.stuck = Math.max(0, this.stuck - dt);
if (this.stuck > 0.45 && this.detour <= 0) {
this.detour = 0.6;
const sign = Math.random() < 0.5 ? 1 : -1;
this.detourDir.set(-dir.z * sign, 0, dir.x * sign);
this.stuck = 0;
this.sideTry++;
if (Math.random() < 0.5) c.jump = true;
}
if (this.detour > 0) {
this.detour -= dt;
dir.lerp(this.detourDir, 0.8).normalize();
}
const edgeDist = step && climb ? rectGap1(nav.nodes[step.to]!, me.pos) : 99;
c.moveX = dir.x;
c.moveZ = dir.z;
c.run = run && !climb && dist > 2;
// Jump at the target's face, or before running off the edge of the current surface.
const cur = this.surface >= 0 ? nav.nodes[this.surface]! : null;
const leaving = cur !== null && rectGap1(cur, _ahead.set(me.pos.x + dir.x * 0.3, 0, me.pos.z + dir.z * 0.3)) > 0;
if (climb && (edgeDist < 0.5 || (leaving && edgeDist < 1.7)) && me.body.onGround && me.state === 'move') {
c.jump = true;
this.climbing = true;
}
if (me.state === 'air' && this.climbing) {
// Keep pressing into the ledge while airborne.
c.moveX = dir.x;
c.moveZ = dir.z;
}
if (me.body.onGround) this.climbing = false;
}
}
const _ahead = new THREE.Vector3();
function rectGap1(s: Solid, p: THREE.Vector3): number {
const dx = Math.max(0, s.minX - p.x, p.x - s.maxX);
const dz = Math.max(0, s.minZ - p.z, p.z - s.maxZ);
return Math.hypot(dx, dz);
}
