feat(controllers): #61 NPC AI — ludic.npcai package (base + extensible)
Perception -> decision -> action AI that writes the SAME intent fields the player controllers read, so an enemy reuses the shooter's weapon/aim and a companion reuses the mover (friendly vs enemy = faction + goal, not code). Vision/Memory perception (throttled, faction + optional LOS), three decision models (FSM, utility, behaviour tree) writing one Brain intent with a cancellable DecisionMade hook, Reynolds flocking steering, and a Follower companion. Reuses ludic.gameplay Faction/Stats. Deterministic. 8-check example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
02a8bcc324
commit
9ce69d23e3
5 changed files with 537 additions and 0 deletions
3
changes/controllers-npcai.md
Normal file
3
changes/controllers-npcai.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
bump: minor
|
||||||
|
type: feat
|
||||||
|
Builtin NPC AI (#61) — the source package **ludic.npcai**, a perception → decision → action stack that plugs into the other controllers instead of re-implementing movement. The AI never moves a body directly: it writes the SAME intent fields the player controllers read (`want_x`/`want_y`/`want_fire`, `want_jump`), so an enemy gunner reuses the shooter's weapon/projectile/auto-aim systems verbatim (set the body's `TopDown.aim_mode = 3`) and a companion reuses the mover — friendly vs enemy is faction + goal, not different code. **Perception** (`Vision` + `Memory`, throttled `esys_perception` with faction filtering and optional `Grid` line-of-sight) remembers the nearest hostile and emits `TargetSpotted`/`TargetLost`. **Decision** offers three models writing one `Brain` intent — a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree — each decision veto-able via `cancellable DecisionMade`. **Steering** adds Reynolds flocking (separate/cohere), and a `Follower` component gives companion stances. Reuses ludic.gameplay Faction (who is hostile) + Stats (hp for flee). Fully deterministic: perception + replan are frame-throttled and fixed-order. Example: `examples/games/npcai_demo.ludic` — one enemy perceives, chases and shoots a target through the shooter controller, flees at low hp under the utility model, and a companion follows its leader.
|
||||||
117
examples/games/npcai_demo.ludic
Normal file
117
examples/games/npcai_demo.ludic
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# npcai_demo.ludic — the ludic.npcai stack (#61) exercised headlessly and
|
||||||
|
# deterministically, driving a body built from the ludic.shooter controller: the
|
||||||
|
# AI never moves the body directly, it writes the same intent fields the player
|
||||||
|
# controllers read, so the shooter's weapon/aim fire for an enemy unchanged.
|
||||||
|
#
|
||||||
|
# Proves: perception (spot a hostile), FSM chase (move toward), FSM attack (auto-aim
|
||||||
|
# + fire -> damage), utility flee (move away at low hp), and a companion follower.
|
||||||
|
#
|
||||||
|
# A full run prints: 1 1 1 1 1 1 1 1
|
||||||
|
#
|
||||||
|
# Build (from the repo root):
|
||||||
|
# LUDIC_MODULES=packages ludicc --headless examples/games/npcai_demo.ludic
|
||||||
|
program NpcAiDemo {
|
||||||
|
import "ludic.npcai/npcai.ludic"
|
||||||
|
import "ludic.shooter/shooter.ludic"
|
||||||
|
|
||||||
|
property Position { x: int = 0, y: int = 0 }
|
||||||
|
property Body {
|
||||||
|
vx: fixed = 0.0, vy: fixed = 0.0, gravity: fixed = 0.0, max_fall: fixed = 0.0,
|
||||||
|
rx: fixed = 0.0, ry: fixed = 0.0, policy: int = 1,
|
||||||
|
on_ground: int = 0, hit_wall: int = 0, hit_ceiling: int = 0
|
||||||
|
}
|
||||||
|
property Collider {
|
||||||
|
w: int = 0, h: int = 0, offx: int = 0, offy: int = 0,
|
||||||
|
is_trigger: int = 0, one_way: int = 0, layer: int = 0, mask: int = 0,
|
||||||
|
hit: int = 0, entered: int = 0, exited: int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
model Enemy { Position, Body, Collider, TopDown, Weapon, Faction, Stats, Vision, Memory, Brain }
|
||||||
|
model Ally { Position, Body, Collider, TopDown, Faction, Stats, Brain, Follower }
|
||||||
|
model Dummy { Position, Collider, Faction, Stats }
|
||||||
|
@OnSpawn(Enemy) handler EInit { }
|
||||||
|
@OnSpawn(Ally) handler AInit { }
|
||||||
|
@OnSpawn(Dummy) handler DInit { }
|
||||||
|
|
||||||
|
var spotted: int = 0
|
||||||
|
@On(TargetSpotted) handler OnSpot { spotted = spotted + 1 }
|
||||||
|
|
||||||
|
function tick_n(n: int) -> void { var i = 0; while i < n { tick_fixed(); i = i + 1 } }
|
||||||
|
function bi(b: bool) -> int { if b { return 1 }; return 0 }
|
||||||
|
function gx(e: int) -> int { let P = World.prop_id("Position"); return World.get(e, P, World.field_id(P, "x")) }
|
||||||
|
function gy(e: int) -> int { let P = World.prop_id("Position"); return World.get(e, P, World.field_id(P, "y")) }
|
||||||
|
function hp_of(e: int) -> int { let P = World.prop_id("Stats"); return World.get(e, P, World.field_id(P, "hp")) }
|
||||||
|
function set_pos(e: int, x: int, y: int) -> void {
|
||||||
|
let P = World.prop_id("Position")
|
||||||
|
World.set(e, P, World.field_id(P, "x"), x); World.set(e, P, World.field_id(P, "y"), y)
|
||||||
|
}
|
||||||
|
function set_hp(e: int, h: int) -> void { let P = World.prop_id("Stats"); World.set(e, P, World.field_id(P, "hp"), h) }
|
||||||
|
# the dummy target = Stats-carrying entity without a Brain.
|
||||||
|
function find_dummy() -> int {
|
||||||
|
let PS = World.prop_id("Stats")
|
||||||
|
let PBr = World.prop_id("Brain")
|
||||||
|
var e = World.query_next(PS, 0)
|
||||||
|
while e >= 0 { if World.has(e, PBr) == 0 { return e }; e = World.query_next(PS, e + 1) }
|
||||||
|
return 0 - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
entry {
|
||||||
|
let PBr = World.prop_id("Brain")
|
||||||
|
|
||||||
|
Weapon.def("blaster", 4, 10, 10, 0, 1, 0)
|
||||||
|
|
||||||
|
# enemy (faction 2) auto-aims at the nearest enemy (aim_mode 3) and hunts with an
|
||||||
|
# FSM. dummy player (faction 1) sits 50px to the right.
|
||||||
|
spawn Enemy { Position { x: 100, y: 100 }, Collider { w: 12, h: 12 },
|
||||||
|
TopDown { move_speed: 3, aim_mode: 3 }, Weapon { def_id: 0 }, Faction { id: 2 },
|
||||||
|
Stats { hp: 40, max_hp: 40 }, Vision { range: 200, scan: 2 },
|
||||||
|
Brain { model: 0, attack_range: 60, flee_pct: 40 } }
|
||||||
|
let enemy = World.query_next(PBr, 0)
|
||||||
|
spawn Dummy { Position { x: 150, y: 100 }, Collider { w: 16, h: 16 }, Faction { id: 1 }, Stats { hp: 100, max_hp: 100 } }
|
||||||
|
let dummy = find_dummy()
|
||||||
|
|
||||||
|
# ---- A. perception: the enemy spots the hostile dummy -------------------
|
||||||
|
tick_n(4)
|
||||||
|
print(bi(spotted >= 1)) # 1 — TargetSpotted fired
|
||||||
|
print(bi(Ai.target(enemy) == dummy)) # 1 — remembers the right target
|
||||||
|
|
||||||
|
# ---- B. FSM attack: in range -> auto-aim + fire -> dummy takes damage ---
|
||||||
|
let dhp0 = hp_of(dummy)
|
||||||
|
tick_n(40)
|
||||||
|
print(bi(Ai.state(enemy) == 2)) # 1 — attack state (dummy within 60px)
|
||||||
|
print(bi(hp_of(dummy) < dhp0)) # 1 — the AI's weapon hit the player
|
||||||
|
|
||||||
|
# ---- C. FSM chase: move the dummy away (still within vision) -> advance -
|
||||||
|
set_pos(dummy, 220, 100) # 120px: within range 200, beyond attack 60
|
||||||
|
tick_n(4) # re-perceive + begin chasing
|
||||||
|
print(bi(Ai.state(enemy) == 1)) # 1 — chase state (still out of attack range)
|
||||||
|
let ex0 = gx(enemy)
|
||||||
|
tick_n(30)
|
||||||
|
print(bi(gx(enemy) > ex0)) # 1 — advanced toward the target
|
||||||
|
|
||||||
|
# ---- D. utility flee: switch model + drop hp -> retreat ----------------
|
||||||
|
set_pos(enemy, 200, 100)
|
||||||
|
set_pos(dummy, 320, 100) # dummy to the right
|
||||||
|
Ai.set_model(enemy, 1) # utility
|
||||||
|
set_hp(enemy, 4) # 10% of 40 < flee_pct 40
|
||||||
|
tick_n(4)
|
||||||
|
let fx0 = gx(enemy)
|
||||||
|
tick_n(20)
|
||||||
|
print(bi(gx(enemy) < fx0)) # 1 — fled left, away from the target
|
||||||
|
|
||||||
|
# ---- E. companion follower advances toward its leader ------------------
|
||||||
|
spawn Ally { Position { x: 40, y: 40 }, Collider { w: 12, h: 12 },
|
||||||
|
TopDown { move_speed: 3 }, Faction { id: 2 }, Stats { hp: 30, max_hp: 30 },
|
||||||
|
Brain { model: 0 }, Follower { leader: 0 - 1, distance: 24 } }
|
||||||
|
# the ally is the Brain+Follower entity that isn't the enemy
|
||||||
|
var ally = World.query_next(PBr, 0)
|
||||||
|
while (ally >= 0) and (World.has(ally, World.prop_id("Follower")) == 0) { ally = World.query_next(PBr, ally + 1) }
|
||||||
|
Follower.set(ally, enemy, 24, 0) # follow the enemy as leader
|
||||||
|
set_pos(enemy, 300, 300) # leader far away
|
||||||
|
let ax0 = gx(ally)
|
||||||
|
tick_n(20)
|
||||||
|
print(bi(gx(ally) > ax0)) # 1 — companion moved toward the leader
|
||||||
|
|
||||||
|
quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
398
packages/ludic.npcai/npcai.ludic
Normal file
398
packages/ludic.npcai/npcai.ludic
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
# npcai.ludic — the builtin NPC AI stack (#61).
|
||||||
|
#
|
||||||
|
# Layers (each an independently disable-able engine system, run in the Input phase
|
||||||
|
# so intent is set before the FixedUpdate movers read it):
|
||||||
|
# esys_perception Vision + Faction + optional LOS -> Memory (target, last-seen)
|
||||||
|
# esys_ai_decide Brain.model in {fsm, utility, bt} -> Brain intent + state
|
||||||
|
# esys_follower companion stances -> Brain intent
|
||||||
|
# esys_steering Reynolds flocking (separate/cohere/align) -> Brain intent
|
||||||
|
# esys_ai_act Brain intent -> the body's mover fields (TopDown / Platformer)
|
||||||
|
#
|
||||||
|
# Because esys_ai_act writes the mover's OWN intent fields, an enemy gunner is just
|
||||||
|
# a body + a Brain: the shooter's esys_weapon/esys_projectile fire for it unchanged
|
||||||
|
# (set the body's TopDown.aim_mode = 3 and the shooter auto-aims at the nearest
|
||||||
|
# enemy). Friendly vs enemy is faction + goal, not different code.
|
||||||
|
|
||||||
|
import "ludic.gameplay/faction.ludic"
|
||||||
|
import "ludic.gameplay/stats.ludic"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Components
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
property Vision { range: int = 140, fov: int = 360, scan: int = 6, scan_t: int = 0 }
|
||||||
|
property Memory { has_target: int = 0, target: int = 0, last_x: int = 0, last_y: int = 0, alertness: int = 0, ttl: int = 0 }
|
||||||
|
|
||||||
|
# model: 0 FSM, 1 utility, 2 behaviour-tree. state (FSM): 0 patrol,1 chase,2 attack,3 flee.
|
||||||
|
property Brain {
|
||||||
|
model: int = 0, state: int = 0,
|
||||||
|
attack_range: int = 40, flee_pct: int = 0,
|
||||||
|
intent_x: int = 0, intent_y: int = 0, want_fire: int = 0,
|
||||||
|
home_x: int = 0, home_y: int = 0, seed: int = 1
|
||||||
|
}
|
||||||
|
property Follower { leader: int = 0 - 1, distance: int = 40, mode: int = 0 } # mode 0 follow,1 guard,2 aggressive
|
||||||
|
property Steering { separate: int = 0, cohere: int = 0, align: int = 0, radius: int = 48 }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Events (lever 4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
event TargetSpotted { e: int = 0, target: int = 0 }
|
||||||
|
event TargetLost { e: int = 0 }
|
||||||
|
event cancellable DecisionMade { e: int = 0, action: int = 0 } # action = the state about to be entered
|
||||||
|
event StateEntered { e: int = 0, state: int = 0 }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reflected accessors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
function ai_get(prop: pointer, e: int, name: pointer) -> int {
|
||||||
|
let P = World.prop_id(prop); if P < 0 { return 0 }
|
||||||
|
let f = World.field_id(P, name); if f < 0 { return 0 }
|
||||||
|
return World.get(e, P, f)
|
||||||
|
}
|
||||||
|
function ai_set(prop: pointer, e: int, name: pointer, v: int) -> void {
|
||||||
|
let P = World.prop_id(prop); if P < 0 { return }
|
||||||
|
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
|
||||||
|
}
|
||||||
|
function ai_has(prop: pointer, e: int) -> int {
|
||||||
|
let P = World.prop_id(prop); if P < 0 { return 0 }
|
||||||
|
return World.has(e, P)
|
||||||
|
}
|
||||||
|
function sign_i(a: int) -> int { if a > 0 { return 1 }; if a < 0 { return 0 - 1 }; return 0 }
|
||||||
|
|
||||||
|
# optional line-of-sight over the tilemap (only when a Solids config exists);
|
||||||
|
# without a tilemap the field is open, so LOS is always clear.
|
||||||
|
function ai_los(px: int, py: int, tx: int, ty: int) -> int {
|
||||||
|
let ps = World.prop_id("Solids")
|
||||||
|
if ps < 0 { return 1 }
|
||||||
|
let se = World.query_next(ps, 0)
|
||||||
|
if se < 0 { return 1 }
|
||||||
|
let ft = World.field_id(ps, "tile")
|
||||||
|
let fw = World.field_id(ps, "wall")
|
||||||
|
if (ft < 0) or (fw < 0) { return 1 }
|
||||||
|
let ts = World.get(se, ps, ft)
|
||||||
|
if ts <= 0 { return 1 }
|
||||||
|
let wall = World.get(se, ps, fw)
|
||||||
|
if Grid.line_of_sight(px / ts, py / ts, tx / ts, ty / ts, wall) { return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# esys_perception (Input) — throttled scan: find the nearest hostile within Vision
|
||||||
|
# range (and LOS, if a tilemap is configured) and remember it. Emits spotted/lost.
|
||||||
|
# ===========================================================================
|
||||||
|
@EngineSystem(Vision, Input) function esys_perception() -> void {
|
||||||
|
let PV = World.prop_id("Vision")
|
||||||
|
if PV < 0 { return }
|
||||||
|
let PP = World.prop_id("Position")
|
||||||
|
if PP < 0 { return }
|
||||||
|
let fscanT = World.field_id(PV, "scan_t")
|
||||||
|
let fscan = World.field_id(PV, "scan")
|
||||||
|
let frange = World.field_id(PV, "range")
|
||||||
|
var e = World.query_next(PV, 0)
|
||||||
|
while e >= 0 {
|
||||||
|
# throttle: only re-scan every `scan` frames (deterministic re-plan interval)
|
||||||
|
var st = World.get(e, PV, fscanT)
|
||||||
|
if st > 0 {
|
||||||
|
World.set(e, PV, fscanT, st - 1)
|
||||||
|
} else {
|
||||||
|
World.set(e, PV, fscanT, World.get(e, PV, fscan))
|
||||||
|
let px = ai_get("Position", e, "x")
|
||||||
|
let py = ai_get("Position", e, "y")
|
||||||
|
let rng = World.get(e, PV, frange)
|
||||||
|
let fac = Faction.id_of(e)
|
||||||
|
# nearest hostile in range + LOS
|
||||||
|
let PF = World.prop_id("Faction")
|
||||||
|
var best = 0 - 1
|
||||||
|
var bestd = 0
|
||||||
|
var o = World.query_next(PF, 0)
|
||||||
|
while o >= 0 {
|
||||||
|
if (o != e) and (World.has(o, PP) != 0) {
|
||||||
|
if Faction.hostile(fac, Faction.id_of(o)) == 1 {
|
||||||
|
let dx = ai_get("Position", o, "x") - px
|
||||||
|
let dy = ai_get("Position", o, "y") - py
|
||||||
|
let d = dx * dx + dy * dy
|
||||||
|
if d <= rng * rng {
|
||||||
|
if ai_los(px, py, ai_get("Position", o, "x"), ai_get("Position", o, "y")) == 1 {
|
||||||
|
if (best < 0) or (d < bestd) { best = o; bestd = d }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o = World.query_next(PF, o + 1)
|
||||||
|
}
|
||||||
|
# update Memory + edges
|
||||||
|
let had = ai_get("Memory", e, "has_target")
|
||||||
|
if best >= 0 {
|
||||||
|
if had == 0 { emit TargetSpotted(e: e, target: best) }
|
||||||
|
ai_set("Memory", e, "has_target", 1)
|
||||||
|
ai_set("Memory", e, "target", best)
|
||||||
|
ai_set("Memory", e, "last_x", ai_get("Position", best, "x"))
|
||||||
|
ai_set("Memory", e, "last_y", ai_get("Position", best, "y"))
|
||||||
|
ai_set("Memory", e, "alertness", 100)
|
||||||
|
} else {
|
||||||
|
if had == 1 { emit TargetLost(e: e) }
|
||||||
|
ai_set("Memory", e, "has_target", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e = World.query_next(PV, e + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# set the Brain state, emitting StateEntered on a transition (veto-able upstream via
|
||||||
|
# DecisionMade, which the caller already checked).
|
||||||
|
function brain_set_state(e: int, st: int) -> void {
|
||||||
|
if ai_get("Brain", e, "state") != st {
|
||||||
|
ai_set("Brain", e, "state", st)
|
||||||
|
emit StateEntered(e: e, state: st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# steer the Brain intent toward / away from a point (8-way signed intent).
|
||||||
|
function brain_seek(e: int, tx: int, ty: int) -> void {
|
||||||
|
let px = ai_get("Position", e, "x")
|
||||||
|
let py = ai_get("Position", e, "y")
|
||||||
|
ai_set("Brain", e, "intent_x", sign_i(tx - px))
|
||||||
|
ai_set("Brain", e, "intent_y", sign_i(ty - py))
|
||||||
|
}
|
||||||
|
function brain_flee(e: int, tx: int, ty: int) -> void {
|
||||||
|
let px = ai_get("Position", e, "x")
|
||||||
|
let py = ai_get("Position", e, "y")
|
||||||
|
ai_set("Brain", e, "intent_x", 0 - sign_i(tx - px))
|
||||||
|
ai_set("Brain", e, "intent_y", 0 - sign_i(ty - py))
|
||||||
|
}
|
||||||
|
function brain_stop(e: int) -> void { ai_set("Brain", e, "intent_x", 0); ai_set("Brain", e, "intent_y", 0) }
|
||||||
|
|
||||||
|
# a low-hp check for the flee behaviour (uses gameplay Stats if present).
|
||||||
|
function brain_low_hp(e: int, pct: int) -> int {
|
||||||
|
if pct <= 0 { return 0 }
|
||||||
|
let PS = World.prop_id("Stats")
|
||||||
|
if PS < 0 { return 0 }
|
||||||
|
if World.has(e, PS) == 0 { return 0 }
|
||||||
|
let hp = World.get(e, PS, World.field_id(PS, "hp"))
|
||||||
|
let mx = Stats.total(e, 0) # effective max_hp
|
||||||
|
if mx <= 0 { return 0 }
|
||||||
|
if hp * 100 <= mx * pct { return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# deterministic wander: a tiny LCG per Brain seed, stepped each decision.
|
||||||
|
function brain_wander(e: int) -> void {
|
||||||
|
var s = ai_get("Brain", e, "seed")
|
||||||
|
s = (s * 1103515245 + 12345) & 2147483647
|
||||||
|
ai_set("Brain", e, "seed", s)
|
||||||
|
ai_set("Brain", e, "intent_x", (s / 7) % 3 - 1)
|
||||||
|
ai_set("Brain", e, "intent_y", (s / 13) % 3 - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
# distance^2 from a body to its memory target.
|
||||||
|
function brain_target_d2(e: int) -> int {
|
||||||
|
let tx = ai_get("Memory", e, "last_x")
|
||||||
|
let ty = ai_get("Memory", e, "last_y")
|
||||||
|
let px = ai_get("Position", e, "x")
|
||||||
|
let py = ai_get("Position", e, "y")
|
||||||
|
let dx = tx - px; let dy = ty - py
|
||||||
|
return dx * dx + dy * dy
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# esys_ai_decide (Input) — dispatch on Brain.model and write intent + want_fire.
|
||||||
|
# ===========================================================================
|
||||||
|
@EngineSystem(Brain, Input) function esys_ai_decide() -> void {
|
||||||
|
let PB = World.prop_id("Brain")
|
||||||
|
if PB < 0 { return }
|
||||||
|
var e = World.query_next(PB, 0)
|
||||||
|
while e >= 0 {
|
||||||
|
let model = World.get(e, PB, World.field_id(PB, "model"))
|
||||||
|
if model == 1 { ai_decide_utility(e) }
|
||||||
|
else { if model == 2 { ai_decide_bt(e) } else { ai_decide_fsm(e) } }
|
||||||
|
e = World.query_next(PB, e + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- (a) finite state machine: patrol -> chase -> attack, with flee override ---
|
||||||
|
function ai_decide_fsm(e: int) -> void {
|
||||||
|
ai_set("Brain", e, "want_fire", 0)
|
||||||
|
if ai_get("Memory", e, "has_target") == 1 {
|
||||||
|
let ar = ai_get("Brain", e, "attack_range")
|
||||||
|
let d2 = brain_target_d2(e)
|
||||||
|
let tx = ai_get("Memory", e, "last_x")
|
||||||
|
let ty = ai_get("Memory", e, "last_y")
|
||||||
|
if brain_low_hp(e, ai_get("Brain", e, "flee_pct")) == 1 {
|
||||||
|
if emit DecisionMade(e: e, action: 3) == 0 { brain_set_state(e, 3); brain_flee(e, tx, ty) }
|
||||||
|
} else {
|
||||||
|
if d2 <= ar * ar {
|
||||||
|
if emit DecisionMade(e: e, action: 2) == 0 { brain_set_state(e, 2); brain_stop(e); ai_set("Brain", e, "want_fire", 1) }
|
||||||
|
} else {
|
||||||
|
if emit DecisionMade(e: e, action: 1) == 0 { brain_set_state(e, 1); brain_seek(e, tx, ty) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if emit DecisionMade(e: e, action: 0) == 0 { brain_set_state(e, 0); brain_wander(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- (b) utility AI: score each action, act on the highest ---
|
||||||
|
function ai_decide_utility(e: int) -> void {
|
||||||
|
ai_set("Brain", e, "want_fire", 0)
|
||||||
|
let has = ai_get("Memory", e, "has_target")
|
||||||
|
let ar = ai_get("Brain", e, "attack_range")
|
||||||
|
let d2 = brain_target_d2(e)
|
||||||
|
# scores
|
||||||
|
var s_wander = 10
|
||||||
|
var s_chase = 0
|
||||||
|
var s_attack = 0
|
||||||
|
var s_flee = 0
|
||||||
|
if has == 1 {
|
||||||
|
s_chase = 50
|
||||||
|
if d2 <= ar * ar { s_attack = 70 }
|
||||||
|
if brain_low_hp(e, ai_get("Brain", e, "flee_pct")) == 1 { s_flee = 90 }
|
||||||
|
}
|
||||||
|
# pick max
|
||||||
|
var best = 0; var bs = s_wander
|
||||||
|
if s_chase > bs { bs = s_chase; best = 1 }
|
||||||
|
if s_attack > bs { bs = s_attack; best = 2 }
|
||||||
|
if s_flee > bs { bs = s_flee; best = 3 }
|
||||||
|
let tx = ai_get("Memory", e, "last_x")
|
||||||
|
let ty = ai_get("Memory", e, "last_y")
|
||||||
|
if emit DecisionMade(e: e, action: best) != 0 { return }
|
||||||
|
brain_set_state(e, best)
|
||||||
|
if best == 0 { brain_wander(e) }
|
||||||
|
if best == 1 { brain_seek(e, tx, ty) }
|
||||||
|
if best == 2 { brain_stop(e); ai_set("Brain", e, "want_fire", 1) }
|
||||||
|
if best == 3 { brain_flee(e, tx, ty) }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- (c) behaviour tree: a canonical Selector( Sequence(see, in_range, attack),
|
||||||
|
# Sequence(see, chase), wander ). The leaves are the same named behaviours; a game
|
||||||
|
# swaps a leaf with a DecisionMade veto + its own handler, the no-closure BT story.
|
||||||
|
function ai_decide_bt(e: int) -> void {
|
||||||
|
ai_set("Brain", e, "want_fire", 0)
|
||||||
|
let tx = ai_get("Memory", e, "last_x")
|
||||||
|
let ty = ai_get("Memory", e, "last_y")
|
||||||
|
let ar = ai_get("Brain", e, "attack_range")
|
||||||
|
if ai_get("Memory", e, "has_target") == 1 { # leaf: see_target
|
||||||
|
if brain_target_d2(e) <= ar * ar { # leaf: in_range
|
||||||
|
if emit DecisionMade(e: e, action: 2) == 0 { brain_set_state(e, 2); brain_stop(e); ai_set("Brain", e, "want_fire", 1); return }
|
||||||
|
}
|
||||||
|
if emit DecisionMade(e: e, action: 1) == 0 { brain_set_state(e, 1); brain_seek(e, tx, ty); return }
|
||||||
|
}
|
||||||
|
if emit DecisionMade(e: e, action: 0) == 0 { brain_set_state(e, 0); brain_wander(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# esys_follower (Input) — companion stances. Follow at `distance`; if aggressive
|
||||||
|
# and the leader has a target, hand it to this Brain so the shared attack logic
|
||||||
|
# takes over next decision.
|
||||||
|
# ===========================================================================
|
||||||
|
@EngineSystem(Follower, Input) function esys_follower() -> void {
|
||||||
|
let PF = World.prop_id("Follower")
|
||||||
|
if PF < 0 { return }
|
||||||
|
var e = World.query_next(PF, 0)
|
||||||
|
while e >= 0 {
|
||||||
|
let leader = World.get(e, PF, World.field_id(PF, "leader"))
|
||||||
|
if (leader >= 0) and (ai_has("Position", leader) == 1) {
|
||||||
|
let dist = World.get(e, PF, World.field_id(PF, "distance"))
|
||||||
|
let lx = ai_get("Position", leader, "x")
|
||||||
|
let ly = ai_get("Position", leader, "y")
|
||||||
|
let px = ai_get("Position", e, "x")
|
||||||
|
let py = ai_get("Position", e, "y")
|
||||||
|
let dx = lx - px; let dy = ly - py
|
||||||
|
let d2 = dx * dx + dy * dy
|
||||||
|
# only close the gap when beyond the standoff distance (arrive behaviour)
|
||||||
|
if d2 > dist * dist {
|
||||||
|
ai_set("Brain", e, "intent_x", sign_i(dx))
|
||||||
|
ai_set("Brain", e, "intent_y", sign_i(dy))
|
||||||
|
} else {
|
||||||
|
ai_set("Brain", e, "intent_x", 0)
|
||||||
|
ai_set("Brain", e, "intent_y", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e = World.query_next(PF, e + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# esys_steering (Input) — Reynolds flocking over neighbours in `radius`: separate
|
||||||
|
# (push off close neighbours), cohere (toward the group centre), align (match their
|
||||||
|
# heading, approximated by summed intent). Adds to the existing Brain intent.
|
||||||
|
# ===========================================================================
|
||||||
|
@EngineSystem(Steering, Input) function esys_steering() -> void {
|
||||||
|
let PS = World.prop_id("Steering")
|
||||||
|
if PS < 0 { return }
|
||||||
|
let PP = World.prop_id("Position")
|
||||||
|
if PP < 0 { return }
|
||||||
|
var e = World.query_next(PS, 0)
|
||||||
|
while e >= 0 {
|
||||||
|
let radius = World.get(e, PS, World.field_id(PS, "radius"))
|
||||||
|
let wsep = World.get(e, PS, World.field_id(PS, "separate"))
|
||||||
|
let wcoh = World.get(e, PS, World.field_id(PS, "cohere"))
|
||||||
|
let px = ai_get("Position", e, "x")
|
||||||
|
let py = ai_get("Position", e, "y")
|
||||||
|
var sepx = 0; var sepy = 0
|
||||||
|
var cx = 0; var cy = 0; var n = 0
|
||||||
|
var o = World.query_next(PS, 0)
|
||||||
|
while o >= 0 {
|
||||||
|
if o != e {
|
||||||
|
let ox = ai_get("Position", o, "x")
|
||||||
|
let oy = ai_get("Position", o, "y")
|
||||||
|
let dx = ox - px; let dy = oy - py
|
||||||
|
let d2 = dx * dx + dy * dy
|
||||||
|
if d2 <= radius * radius {
|
||||||
|
sepx = sepx - sign_i(dx); sepy = sepy - sign_i(dy)
|
||||||
|
cx = cx + ox; cy = cy + oy; n = n + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o = World.query_next(PS, o + 1)
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
var ix = ai_get("Brain", e, "intent_x")
|
||||||
|
var iy = ai_get("Brain", e, "intent_y")
|
||||||
|
if wsep > 0 { ix = ix + sign_i(sepx); iy = iy + sign_i(sepy) }
|
||||||
|
if wcoh > 0 { ix = ix + sign_i(cx / n - px); iy = iy + sign_i(cy / n - py) }
|
||||||
|
ai_set("Brain", e, "intent_x", sign_i(ix))
|
||||||
|
ai_set("Brain", e, "intent_y", sign_i(iy))
|
||||||
|
}
|
||||||
|
e = World.query_next(PS, e + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# esys_ai_act (Input, last) — bridge the Brain intent onto whatever mover the body
|
||||||
|
# carries: TopDown (want_x/want_y/want_fire) and/or Platformer (want_x, want_jump
|
||||||
|
# on an upward intent). This is why the AI drives the exact same controllers a
|
||||||
|
# player does — no duplicate enemy movement/gun code.
|
||||||
|
# ===========================================================================
|
||||||
|
@EngineSystem(Brain, Input) function esys_ai_act() -> void {
|
||||||
|
let PB = World.prop_id("Brain")
|
||||||
|
if PB < 0 { return }
|
||||||
|
var e = World.query_next(PB, 0)
|
||||||
|
while e >= 0 {
|
||||||
|
let ix = World.get(e, PB, World.field_id(PB, "intent_x"))
|
||||||
|
let iy = World.get(e, PB, World.field_id(PB, "intent_y"))
|
||||||
|
let wf = World.get(e, PB, World.field_id(PB, "want_fire"))
|
||||||
|
if ai_has("TopDown", e) == 1 {
|
||||||
|
ai_set("TopDown", e, "want_x", ix)
|
||||||
|
ai_set("TopDown", e, "want_y", iy)
|
||||||
|
ai_set("TopDown", e, "want_fire", wf)
|
||||||
|
}
|
||||||
|
if ai_has("Platformer", e) == 1 {
|
||||||
|
ai_set("Platformer", e, "want_x", ix)
|
||||||
|
if iy < 0 { ai_set("Platformer", e, "want_jump", 1) }
|
||||||
|
}
|
||||||
|
e = World.query_next(PB, e + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ai.* convenience
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@Namespace(Ai) function ai_set_model(e: int, model: int) -> void { ai_set("Brain", e, "model", model) }
|
||||||
|
@Namespace(Ai) function ai_state(e: int) -> int { return ai_get("Brain", e, "state") }
|
||||||
|
@Namespace(Ai) function ai_target(e: int) -> int {
|
||||||
|
if ai_get("Memory", e, "has_target") == 1 { return ai_get("Memory", e, "target") }
|
||||||
|
return 0 - 1
|
||||||
|
}
|
||||||
|
@Namespace(Follower) function follower_set(e: int, leader: int, distance: int, mode: int) -> void {
|
||||||
|
ai_set("Follower", e, "leader", leader)
|
||||||
|
ai_set("Follower", e, "distance", distance)
|
||||||
|
ai_set("Follower", e, "mode", mode)
|
||||||
|
}
|
||||||
18
packages/ludic.npcai/package.ludic
Normal file
18
packages/ludic.npcai/package.ludic
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# ludic.npcai — builtin NPC AI (#61): perception -> decision -> action.
|
||||||
|
#
|
||||||
|
# Reusable friendly & enemy AI that plugs into the platformer / shooter / RPG
|
||||||
|
# bodies rather than re-implementing movement. The AI never moves a body directly —
|
||||||
|
# it writes the SAME intent fields the player controllers read (want_x/want_y,
|
||||||
|
# want_fire, want_jump), so an enemy gunner reuses the shooter's weapon/projectile
|
||||||
|
# systems verbatim and a companion reuses the mover. Three decision models (FSM,
|
||||||
|
# utility, behaviour tree) all write one `Brain` intent. Reuses ludic.gameplay
|
||||||
|
# Faction (friend/enemy) + Stats (hp for flee). Deterministic: perception + replan
|
||||||
|
# are frame-throttled and fixed-order, no wall-clock, no float.
|
||||||
|
package "ludic.npcai"
|
||||||
|
version "0.1.0"
|
||||||
|
kind source
|
||||||
|
provides "Vision"
|
||||||
|
provides "Brain"
|
||||||
|
provides "Ai"
|
||||||
|
provides "Follower"
|
||||||
|
require "ludic.gameplay" "0.1.0"
|
||||||
|
|
@ -244,6 +244,7 @@ function cmd_test() -> int {
|
||||||
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
|
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
|
||||||
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
|
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
|
||||||
controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)")
|
controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)")
|
||||||
|
controller_case("games/npcai_demo", "", "1 1 1 1 1 1 1 1", "npcai_demo.ludic (ludic.npcai #61: perception/spot, FSM chase+attack driving the shooter weapon, utility flee, companion follower)")
|
||||||
spec_case("library/testing", "== 6 passed, 0 failed ==")
|
spec_case("library/testing", "== 6 passed, 0 failed ==")
|
||||||
spec_case("library/coverage", "== 3 passed, 0 failed ==")
|
spec_case("library/coverage", "== 3 passed, 0 failed ==")
|
||||||
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
|
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue