Back to Mini Samurai
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.

src/game/ai.ts
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);
}