# 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 { } state NpcAiDemoState { spotted: int = 0 } @On(TargetSpotted) handler OnSpot(npc_ai_demo_st: mut NpcAiDemoState) { npc_ai_demo_st.spotted += 1 } function tick_n(n: int) -> void { var i = 0; while i < n { tick_fixed(); 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 -1 } entry (npc_ai_demo_st: NpcAiDemoState) { 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(npc_ai_demo_st.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: -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() } }