From 02a8bcc324b0be66a96807db1c847b548dda4945 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 1 Sep 2026 17:04:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(controllers):=20#60=20shooter=20=E2=80=94?= =?UTF-8?q?=20ludic.shooter=20package=20(base=20+=20extensible)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-down shooter: TopDown decoupled move/aim (mouse/stick/move-dir/auto-aim), a name-keyed Weapon registry (fire-rate/spread/pellets/pattern + data-driven pierce/homing), and a self-contained deterministic Projectile pool with faction-filtered Combat hits, pierce, ring/spiral patterns, and homing. One weapon impl serves player + NPC via a want_fire intent. Budgeted wave Spawner. Reuses ludic.gameplay Faction/Combat/Stats. 11-check example, in the suite. Co-Authored-By: Claude Opus 4.8 --- changes/controllers-shooter.md | 3 + examples/games/shooter_demo.ludic | 174 +++++++++ packages/ludic.shooter/package.ludic | 16 + packages/ludic.shooter/shooter.ludic | 511 +++++++++++++++++++++++++++ tools/x/test.ludic | 1 + 5 files changed, 705 insertions(+) create mode 100644 changes/controllers-shooter.md create mode 100644 examples/games/shooter_demo.ludic create mode 100644 packages/ludic.shooter/package.ludic create mode 100644 packages/ludic.shooter/shooter.ludic diff --git a/changes/controllers-shooter.md b/changes/controllers-shooter.md new file mode 100644 index 00000000..47f38ed9 --- /dev/null +++ b/changes/controllers-shooter.md @@ -0,0 +1,3 @@ +bump: minor +type: feat +Builtin top-down Shooter controller (#60) — the source package **ludic.shooter**, conforming to the six-lever contract and built on the engine Body/Collider + ludic.gameplay Faction/Combat/Stats. `TopDown` decouples movement from aim (`aim_mode`: mouse / right-stick / move-direction / nearest-enemy auto-aim, with a `turn_rate` for tank-style rotation). Weapons are a name-keyed **registry** (`Weapon.def("shotgun", …)` — add a gun with zero code) with per-weapon fire-rate, damage, speed, spread, pellet count, pattern (single / spread cone / ring / spiral), plus data-driven pierce (`Weapon.set_pierce`) and homing (`Weapon.set_homing`); `esys_weapon` reads a `want_fire` intent so the **same** weapon fires for a player (input) and an NPC (AI). `Projectile` + `esys_projectile` is a self-contained deterministic pool: integrate, TTL, faction-filtered hit through `Combat.damage`, pierce, and homing that curves onto the nearest enemy — every step observable via `ProjectileSpawned` (mutable/veto) / `ProjectileHit` / `ProjectileExpired`. A budgeted `Spawner` wave director emits `SpawnRequested` / `WaveCleared`. Example: `examples/games/shooter_demo.ludic` (11 self-checks: movement, aim, faction damage, friendly-fire immunity, spread, kill, ring, homing, waves). diff --git a/examples/games/shooter_demo.ludic b/examples/games/shooter_demo.ludic new file mode 100644 index 00000000..e7ad67eb --- /dev/null +++ b/examples/games/shooter_demo.ludic @@ -0,0 +1,174 @@ +# shooter_demo.ludic — the ludic.shooter controller (#60) exercised headlessly and +# deterministically: decoupled top-down movement, a data-driven weapon that fires +# along the aim, a projectile that travels and damages an ENEMY (faction-filtered, +# so friendly fire is ignored), a piercing shot that hits two enemies, a spread +# weapon that emits several pellets, and the ProjectileSpawned mutable hook. +# +# A full run prints: 1 1 1 1 1 1 1 1 1 1 1 +# +# Build (from the repo root): +# LUDIC_MODULES=packages ludicc --headless examples/games/shooter_demo.ludic +program ShooterDemo { + 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 Gunner { Position, Body, Collider, TopDown, Weapon, Faction, Stats } + model Mob { Position, Collider, Faction, Stats } + model Nest { Position, Spawner } + @OnSpawn(Gunner) handler GInit { } + @OnSpawn(Mob) handler MInit { } + @OnSpawn(Nest) handler NInit { } + + var spawn_reqs: int = 0 + @On(SpawnRequested) handler OnSpawnReq { spawn_reqs = spawn_reqs + 1 } + var waves_cleared: int = 0 + @On(WaveCleared) handler OnWaveClear { waves_cleared = waves_cleared + 1 } + + var spawned: int = 0 + @On(ProjectileSpawned) handler CountSpawn { spawned = spawned + 1 } + var hits: int = 0 + @On(ProjectileHit) handler CountHit { hits = hits + 1 } + # reap the dead (the realistic policy: a Died body leaves the world) so stale + # corpses do not sit in the faction/targeting scans. + var deaths: int = 0 + @On(Died) handler Reap { deaths = deaths + 1; despawn e } + + 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 hp_of(e: int) -> int { + let P = World.prop_id("Stats") + return World.get(e, P, World.field_id(P, "hp")) + } + # the mob is the Stats-carrying entity with no TopDown (Mob is a model, not a + # queryable property — query by a component it actually has). + function find_mob() -> int { + let PS = World.prop_id("Stats") + let PT = World.prop_id("TopDown") + var e = World.query_next(PS, 0) + while e >= 0 { if World.has(e, PT) == 0 { return e }; e = World.query_next(PS, e + 1) } + return 0 - 1 + } + # a mob still alive (hp > 0) — the dead one from an earlier test stays in the world. + function find_live_mob() -> int { + let PS = World.prop_id("Stats") + let PT = World.prop_id("TopDown") + let fhp = World.field_id(PS, "hp") + var e = World.query_next(PS, 0) + while e >= 0 { + if (World.has(e, PT) == 0) and (World.get(e, PS, fhp) > 0) { return e } + e = World.query_next(PS, e + 1) + } + return 0 - 1 + } + + entry { + let PT = World.prop_id("TopDown") + let PB = World.prop_id("Body") + let PP = World.prop_id("Position") + + # weapons: a pistol (single) and a shotgun (spread cone of 5), plus a piercer. + Weapon.def("pistol", 6, 10, 8, 0, 1, 0) + Weapon.def("shotgun", 12, 6, 7, 30, 5, 1) + Weapon.def("rifle", 4, 8, 10, 0, 1, 0) + + # player faction 1, enemies faction 2 (hostile by default). + spawn Gunner { Position { x: 100, y: 100 }, Collider { w: 12, h: 12 }, + TopDown { move_speed: 4, aim_mode: 2 }, Weapon { def_id: 0 }, Faction { id: 1 }, + Stats { hp: 50, max_hp: 50 } } + let g = World.query_next(PT, 0) + + # ---- A. decoupled movement: move right, body slides, aim follows move dir -- + TopDown.move(g, 1, 0) + let x0 = World.get(g, PP, World.field_id(PP, "x")) + tick_n(15) + TopDown.move(g, 0, 0) + let x1 = World.get(g, PP, World.field_id(PP, "x")) + print(bi(x1 > x0 + 10)) # 1 — moved right + print(bi(World.get(g, PT, World.field_id(PT, "aim_angle")) == 0)) # 1 — aim = move dir (+x = 0deg) + + # ---- B. fire the pistol straight at an enemy to the right -------------- + let gx = World.get(g, PP, World.field_id(PP, "x")) + let gy = World.get(g, PP, World.field_id(PP, "y")) + spawn Mob { Position { x: gx + 120, y: gy }, Collider { w: 16, h: 16 }, Faction { id: 2 }, Stats { hp: 100, max_hp: 100, def: 0 } } + let mob = find_mob() + World.set(g, PT, World.field_id(PT, "aim_mode"), 4) # manual aim for the firing tests + TopDown.aim(g, 0) # aim +x + Weapon.equip(g, "pistol") + TopDown.fire(g, 1) + tick_n(1) # one shot fires (cooldown gates the rest) + TopDown.fire(g, 0) + let hp_before = hp_of(mob) + tick_n(40) # bullet flies ~120px and hits + print(bi(spawned >= 1)) # 1 — a projectile spawned + print(bi(hp_of(mob) < hp_before)) # 1 — enemy took damage + + # ---- C. friendly fire is ignored (same faction) ------------------------ + spawn Gunner { Position { x: gx + 40, y: gy }, Collider { w: 12, h: 12 }, + TopDown { aim_mode: 2 }, Weapon { def_id: 0 }, Faction { id: 1 }, Stats { hp: 50, max_hp: 50 } } + var ally = World.query_next(PT, 0) + if ally == g { ally = World.query_next(PT, g + 1) } + let ally_hp = hp_of(ally) + TopDown.aim(g, 0) + TopDown.fire(g, 1); tick_n(1); TopDown.fire(g, 0) + tick_n(20) + print(bi(hp_of(ally) == ally_hp)) # 1 — ally unharmed (faction) + + # ---- D. shotgun spread emits multiple pellets in one shot --------------- + let s0 = spawned + Weapon.equip(g, "shotgun") + TopDown.aim(g, 0) + TopDown.fire(g, 1); tick_n(1); TopDown.fire(g, 0) + print(bi(spawned - s0 == 5)) # 1 — 5 pellets + + # ---- E. a fresh enemy is eventually killed (Combat -> Died -> reaped) -- + let d0 = deaths + Weapon.equip(g, "rifle") + TopDown.aim(g, 0) + var f = 0 + while (deaths == d0) and (f < 400) { + TopDown.fire(g, 1); tick_fixed(); TopDown.fire(g, 0); tick_fixed() + f = f + 1 + } + print(bi(deaths == d0 + 1)) # 1 — enemy destroyed (Died fired) + + # ---- F. ring pattern (bullet-hell) emits N bullets around 360 ---------- + let r0 = spawned + let ring = Weapon.def("nova", 30, 3, 6, 0, 8, 2) # pattern 2 = ring, 8 pellets + Weapon.equip(g, "nova") + TopDown.fire(g, 1); tick_n(1); TopDown.fire(g, 0) + print(bi(spawned - r0 == 8)) # 1 — 8-way ring + + # ---- G. homing shot curves onto an off-axis enemy ---------------------- + let seek = Weapon.def("seeker", 30, 40, 6, 0, 1, 0) + Weapon.set_homing(seek, 12) # 12 deg/frame turn + let gx2 = World.get(g, PP, World.field_id(PP, "x")) + let gy2 = World.get(g, PP, World.field_id(PP, "y")) + spawn Mob { Position { x: gx2 + 60, y: gy2 + 60 }, Collider { w: 16, h: 16 }, Faction { id: 2 }, Stats { hp: 30, max_hp: 30 } } + let mob2 = find_live_mob() + let mob2_hp0 = hp_of(mob2) + Weapon.equip(g, "seeker") + TopDown.aim(g, 0) # fire +x, but the target is down-right + TopDown.fire(g, 1); tick_n(1); TopDown.fire(g, 0) + tick_n(60) # the shot should curve in and hit + print(bi(hp_of(mob2) < mob2_hp0)) # 1 — homing connected + + # ---- H. wave spawner: budgeted, throttled SpawnRequested + WaveCleared -- + spawn Nest { Position { x: 300, y: 300 }, Spawner { rate: 3, budget: 4 } } + tick_n(20) # 4 spawns at 3-frame spacing, then cleared + print(bi(spawn_reqs == 4)) # 1 — exactly budget requests + print(bi(waves_cleared == 1)) # 1 — wave cleared once + + quit() + } +} diff --git a/packages/ludic.shooter/package.ludic b/packages/ludic.shooter/package.ludic new file mode 100644 index 00000000..120d11d1 --- /dev/null +++ b/packages/ludic.shooter/package.ludic @@ -0,0 +1,16 @@ +# ludic.shooter — the builtin top-down Shooter controller (#60). +# +# A twin-stick / mouse-aim character whose movement is decoupled from aim, a +# data-driven weapon registry, and a general projectile system (pierce / bounce / +# homing / bullet-hell patterns), all conforming to the six-lever contract (#57). +# Reuses the engine Body/Collider and the ludic.gameplay Faction + Combat + Stats +# foundation: the SAME weapon/projectile systems drive a player and an NPC (the AI +# writes the "fire" intent instead of input), so there is no duplicate enemy-gun +# code. Deterministic integer/fixed maths throughout, so bullet patterns replay. +package "ludic.shooter" +version "0.1.0" +kind source +provides "TopDown" +provides "Weapon" +provides "Spawner" +require "ludic.gameplay" "0.1.0" diff --git a/packages/ludic.shooter/shooter.ludic b/packages/ludic.shooter/shooter.ludic new file mode 100644 index 00000000..74913673 --- /dev/null +++ b/packages/ludic.shooter/shooter.ludic @@ -0,0 +1,511 @@ +# shooter.ludic — the builtin top-down Shooter controller (#60). +# +# Three layers, each reachable by the six levers (#57): +# TopDown — decoupled move + aim (aim_mode: mouse / stick / move-dir / auto) +# Weapon — a data-driven registry (add a gun with Weapon.def, zero code) + +# esys_weapon (fire rate, patterns) reading a "want_fire" intent +# Projectile — esys_projectile: integrate, TTL, faction-filtered hit, pierce, +# homing; emits ProjectileSpawned (mutable) / Hit / Expired. +# +# The controller reuses ludic.gameplay Faction (who may hit whom) + Combat (the one +# damage pipeline). Because esys_weapon reads an intent flag, the identical weapon +# fires for a player (input sets want_fire) and an NPC (#61 AI sets want_fire). + +import "ludic.gameplay/faction.ludic" +import "ludic.gameplay/stats.ludic" +import "ludic.gameplay/combat.ludic" + +const SH_Q: int = 65536 +const WPN_MAX: int = 64 + +# --------------------------------------------------------------------------- +# Components +# --------------------------------------------------------------------------- +property TopDown { + move_speed: int = 4, # px/frame + accel: int = 80, # responsiveness %, gap closed / frame + friction: int = 70, # stopping %, speed shed / frame when idle + aim_mode: int = 0, # 0 mouse, 1 right-stick, 2 move-dir, 3 nearest-enemy (auto) + turn_rate: int = 0, # 0 = snap; >0 = max degrees/frame toward the target aim + strafe: int = 1, # 1 = body free of move dir; 0 = face move dir + aim_angle: int = 0, # current facing, degrees (0 = +x, 90 = +y) + want_x: int = 0, # -1/0/1 move intent (input OR ai writes) + want_y: int = 0, + want_fire: int = 0, # 1 = fire held (input OR ai writes) + policy: int = 0 +} + +# a Weapon instance points at a registry entry (def_id). cooldown counts down to +# the next allowed shot; ammo -1 = infinite; phase drives spiral emitters. +property Weapon { def_id: int = 0, ammo: int = 0 - 1, cooldown: int = 0, phase: int = 0 } + +property Projectile { + vx: fixed = 0.0, vy: fixed = 0.0, rx: fixed = 0.0, ry: fixed = 0.0, + dir: int = 0, # heading in degrees (drives homing without re-deriving from velocity) + speed: int = 8, damage: int = 1, ttl: int = 120, + pierce: int = 0, bounce: int = 0, homing: int = 0, + owner: int = 0, faction: int = 0, on_hit: int = 0 +} +model Bullet { Position, Projectile, Collider } +@OnSpawn(Bullet) handler BulletInit { } + +# --------------------------------------------------------------------------- +# Events (lever 4) +# --------------------------------------------------------------------------- +event cancellable ProjectileSpawned { proj: int = 0, owner: int = 0, damage: int = 0 } +event ProjectileHit { proj: int = 0, target: int = 0, damage: int = 0 } +event ProjectileExpired { proj: int = 0 } +event WeaponFired { owner: int = 0, def_id: int = 0 } + +# --------------------------------------------------------------------------- +# Weapon registry (name-keyed, mod-friendly — add a gun with no code) +# --------------------------------------------------------------------------- +var wpn_reg: Dict = null +var wpn_n: int = 0 +var wpn_rate: words = null +var wpn_dmg: words = null +var wpn_speed: words = null +var wpn_spread: words = null +var wpn_pellets: words = null +var wpn_pattern: words = null +var wpn_ttl: words = null +var wpn_pierce: words = null +var wpn_homing: words = null + +function wpn_ensure() -> void { + if wpn_reg == null { + wpn_reg = Dict.new() + wpn_rate = words(WPN_MAX); wpn_dmg = words(WPN_MAX); wpn_speed = words(WPN_MAX) + wpn_spread = words(WPN_MAX); wpn_pellets = words(WPN_MAX); wpn_pattern = words(WPN_MAX) + wpn_ttl = words(WPN_MAX); wpn_pierce = words(WPN_MAX); wpn_homing = words(WPN_MAX) + } +} + +# Weapon.def(name, fire_rate, damage, speed, spread, pellets, pattern) — register a +# gun. pattern: 0 single/aimed, 1 spread cone, 2 ring, 3 spiral. Returns the def id. +@Namespace(Weapon) function weapon_def(name: pointer, fire_rate: int, damage: int, speed: int, spread: int, pellets: int, pattern: int) -> int { + wpn_ensure() + if wpn_n >= WPN_MAX { return 0 - 1 } + let id = wpn_n + wpn_rate[id] = fire_rate; wpn_dmg[id] = damage; wpn_speed[id] = speed + wpn_spread[id] = spread; wpn_pellets[id] = pellets; wpn_pattern[id] = pattern + wpn_ttl[id] = 120 + wpn_n = wpn_n + 1 + Dict.set(wpn_reg, name, id) + return id +} +@Namespace(Weapon) function weapon_id(name: pointer) -> int { + wpn_ensure() + return Dict.get_or(wpn_reg, name, 0 - 1) +} +# Weapon.set_ttl(id, frames) — projectile lifetime for a weapon (range control). +@Namespace(Weapon) function weapon_set_ttl(id: int, frames: int) -> void { + wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_ttl[id] = frames } +} +# Weapon.set_pierce(id, n) — a shot passes through n targets before it dies. +@Namespace(Weapon) function weapon_set_pierce(id: int, n: int) -> void { + wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_pierce[id] = n } +} +# Weapon.set_homing(id, deg) — max degrees/frame a shot turns toward an enemy. +@Namespace(Weapon) function weapon_set_homing(id: int, deg: int) -> void { + wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_homing[id] = deg } +} + +# --------------------------------------------------------------------------- +# reflected accessors +# --------------------------------------------------------------------------- +function td_get(e: int, name: pointer) -> int { + let P = World.prop_id("TopDown"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function td_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("TopDown"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function bod_get(e: int, name: pointer) -> int { + let P = World.prop_id("Body"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function bod_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Body"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function pos_get(e: int, name: pointer) -> int { + let P = World.prop_id("Position"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function pos_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Position"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} +function pr_get(e: int, name: pointer) -> int { + let P = World.prop_id("Projectile"); if P < 0 { return 0 } + let f = World.field_id(P, name); if f < 0 { return 0 } + return World.get(e, P, f) +} +function pr_set(e: int, name: pointer, v: int) -> void { + let P = World.prop_id("Projectile"); if P < 0 { return } + let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) } +} + +# nearest entity with a Faction hostile to `fac`, by centre distance. -1 if none. +function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int { + let PF = World.prop_id("Faction") + if PF < 0 { return 0 - 1 } + let PP = World.prop_id("Position") + if PP < 0 { return 0 - 1 } + var best = 0 - 1 + var bestd = 0 + var e = World.query_next(PF, 0) + while e >= 0 { + if (e != self_e) and (World.has(e, PP) != 0) { + if Faction.hostile(fac, Faction.id_of(e)) == 1 { + let dx = pos_get(e, "x") - px + let dy = pos_get(e, "y") - py + let d = dx * dx + dy * dy + if (best < 0) or (d < bestd) { best = e; bestd = d } + } + } + e = World.query_next(PF, e + 1) + } + return best +} + +# =========================================================================== +# esys_topdown_move (FixedUpdate) — 2-axis acceleration toward the move intent. +# =========================================================================== +@EngineSystem(TopDown, FixedUpdate) function esys_topdown_move() -> void { + let P = World.prop_id("TopDown") + if P < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + let sp = td_get(e, "move_speed") + let wx = td_get(e, "want_x") + let wy = td_get(e, "want_y") + var rx = 0; if wx != 0 { rx = td_get(e, "accel") } else { rx = td_get(e, "friction") } + var ry = 0; if wy != 0 { ry = td_get(e, "accel") } else { ry = td_get(e, "friction") } + if rx > 100 { rx = 100 } + if ry > 100 { ry = 100 } + let tvx = wx * sp * SH_Q + let tvy = wy * sp * SH_Q + var vx = bod_get(e, "vx") + var vy = bod_get(e, "vy") + vx = vx + (tvx - vx) * rx / 100 + vy = vy + (tvy - vy) * ry / 100 + bod_set(e, "vx", vx) + bod_set(e, "vy", vy) + e = World.query_next(P, e + 1) + } +} + +# =========================================================================== +# esys_topdown_aim (FixedUpdate) — resolve the facing per aim_mode, honouring +# turn_rate (0 = instant snap, >0 = gradual). Only the active branch runs. +# =========================================================================== +@EngineSystem(TopDown, FixedUpdate) function esys_topdown_aim() -> void { + let P = World.prop_id("TopDown") + if P < 0 { return } + var e = World.query_next(P, 0) + while e >= 0 { + let mode = td_get(e, "aim_mode") + let px = pos_get(e, "x") + let py = pos_get(e, "y") + var target = td_get(e, "aim_angle") # default: keep current + var have = 0 + if mode == 0 { # mouse / point + let mx = Input.mouse_x() + let my = Input.mouse_y() + target = sh_angle_to(px, py, mx, my); have = 1 + } + if mode == 1 { # right stick + let ax = Input.pad_axis(0, 2) + let ay = Input.pad_axis(0, 3) + if (ax != 0) or (ay != 0) { target = sh_dir_angle(ax, ay); have = 1 } + } + if mode == 2 { # move direction (from the intent) + let wx = td_get(e, "want_x") + let wy = td_get(e, "want_y") + if (wx != 0) or (wy != 0) { target = sh_dir_angle(wx, wy); have = 1 } + } + # mode 4 = manual: the game/AI owns aim_angle; the aim system leaves it alone. + if mode == 3 { # nearest enemy (auto-aim) + let fac = Faction.id_of(e) + let t = sh_nearest_enemy(e, fac, px, py) + if t >= 0 { target = sh_angle_to(px, py, pos_get(t, "x"), pos_get(t, "y")); have = 1 } + } + if have == 1 { + let tr = td_get(e, "turn_rate") + if tr <= 0 { + td_set(e, "aim_angle", target) + } else { + let cur = td_get(e, "aim_angle") + let d = sh_angle_diff(cur, target) # shortest signed arc, degrees + if (d >= 0 - tr) and (d <= tr) { td_set(e, "aim_angle", target) } + else { if d > 0 { td_set(e, "aim_angle", sh_wrap_deg(cur + tr)) } else { td_set(e, "aim_angle", sh_wrap_deg(cur - tr)) } } + } + } + e = World.query_next(P, e + 1) + } +} + +# integer-degree angle helpers (built on Math trig, kept in degrees for the API) +# both take small integer components (pixel deltas or -1/0/1 intent), so fixed() +# stays well inside i32; results are wrapped into 0..359. +function sh_angle_to(ax: int, ay: int, bx: int, by: int) -> int { + let r = Math.atan2(fixed(by - ay), fixed(bx - ax)) + return sh_wrap_deg(floor(Math.rad_to_deg(r))) +} +function sh_dir_angle(dx: int, dy: int) -> int { + let r = Math.atan2(fixed(dy), fixed(dx)) + return sh_wrap_deg(floor(Math.rad_to_deg(r))) +} +function sh_wrap_deg(d: int) -> int { + var a = d + while a < 0 { a = a + 360 } + while a >= 360 { a = a - 360 } + return a +} +function sh_angle_diff(from: int, to: int) -> int { + var d = sh_wrap_deg(to) - sh_wrap_deg(from) + while d > 180 { d = d - 360 } + while d < 0 - 180 { d = d + 360 } + return d +} + +# spawn one projectile travelling at `deg` degrees from (px,py). +function sh_spawn_bullet(owner: int, fac: int, px: int, py: int, deg: int, speed: int, dmg: int, ttl: int, pierce: int, homing: int) -> int { + let m = World.model_id("Bullet") + if m < 0 { return 0 - 1 } + let b = World.spawn(m) + pos_set(b, "x", px) + pos_set(b, "y", py) + let rad = Angle.from_degrees(fixed(deg)) + pr_set(b, "vx", Angle.cos(rad) * fixed(speed)) + pr_set(b, "vy", Angle.sin(rad) * fixed(speed)) + pr_set(b, "dir", deg) + pr_set(b, "speed", speed) + pr_set(b, "damage", dmg) + pr_set(b, "ttl", ttl) + pr_set(b, "pierce", pierce) + pr_set(b, "homing", homing) + pr_set(b, "owner", owner) + pr_set(b, "faction", fac) + # a small default hitbox unless the game set one on the model + let PC = World.prop_id("Collider") + if (PC >= 0) and (World.get(b, PC, World.field_id(PC, "w")) == 0) { + World.set(b, PC, World.field_id(PC, "w"), 4) + World.set(b, PC, World.field_id(PC, "h"), 4) + } + # announce it: a listener may veto (cancel -> despawn) or tweak the Projectile. + if emit ProjectileSpawned(proj: b, owner: owner, damage: dmg) != 0 { despawn b; return 0 - 1 } + return b +} + +# =========================================================================== +# esys_weapon (Update) — fire-rate gating + pattern emission. Reads the owner's +# TopDown.aim_angle + Weapon.want-fire-equivalent (TopDown.want_fire); one impl +# serves player and NPC. +# =========================================================================== +@EngineSystem(Weapon, Update) function esys_weapon() -> void { + let P = World.prop_id("Weapon") + if P < 0 { return } + wpn_ensure() + var e = World.query_next(P, 0) + while e >= 0 { + let cd = World.get(e, P, World.field_id(P, "cooldown")) + if cd > 0 { World.set(e, P, World.field_id(P, "cooldown"), cd - 1) } + let fire = td_get(e, "want_fire") + if (fire == 1) and (cd <= 0) { + let id = World.get(e, P, World.field_id(P, "def_id")) + if (id >= 0) and (id < wpn_n) { + sh_fire(e, id) + World.set(e, P, World.field_id(P, "cooldown"), wpn_rate[id]) + } + } + e = World.query_next(P, e + 1) + } +} + +function sh_fire(owner: int, id: int) -> void { + let px = pos_get(owner, "x") + let py = pos_get(owner, "y") + let base = td_get(owner, "aim_angle") + let fac = Faction.id_of(owner) + let speed = wpn_speed[id] + let dmg = wpn_dmg[id] + let spread = wpn_spread[id] + let pellets0 = wpn_pellets[id] + let pattern = wpn_pattern[id] + let ttl = wpn_ttl[id] + let pierce = wpn_pierce[id] + let homing = wpn_homing[id] + var pellets = pellets0; if pellets < 1 { pellets = 1 } + emit WeaponFired(owner: owner, def_id: id) + if pattern == 2 { # ring: pellets around 360 + var i = 0 + while i < pellets { sh_spawn_bullet(owner, fac, px, py, sh_wrap_deg(base + i * 360 / pellets), speed, dmg, ttl, pierce, homing); i = i + 1 } + return + } + if pattern == 3 { # spiral: ring + a rotating phase + let PW = World.prop_id("Weapon") + let fph = World.field_id(PW, "phase") + var ph = 0; if fph >= 0 { ph = World.get(owner, PW, fph) } + var i = 0 + while i < pellets { sh_spawn_bullet(owner, fac, px, py, sh_wrap_deg(base + ph + i * 360 / pellets), speed, dmg, ttl, pierce, homing); i = i + 1 } + if fph >= 0 { World.set(owner, PW, fph, sh_wrap_deg(ph + 15)) } + return + } + # single / spread cone centred on the aim + if pellets == 1 { + sh_spawn_bullet(owner, fac, px, py, base, speed, dmg, ttl, pierce, homing) + } else { + var i = 0 + while i < pellets { + let off = 0 - (spread / 2) + i * spread / (pellets - 1) + sh_spawn_bullet(owner, fac, px, py, sh_wrap_deg(base + off), speed, dmg, ttl, pierce, homing) + i = i + 1 + } + } +} + +# =========================================================================== +# esys_projectile (Update) — integrate, TTL, homing steer, faction-filtered hit +# (through Combat), pierce. Self-contained integration (projectiles carry their own +# vx/vy), so they need no Body and stay a dense, deterministic pool. +# =========================================================================== +@EngineSystem(Projectile, Update) function esys_projectile() -> void { + let P = World.prop_id("Projectile") + if P < 0 { return } + let PC = World.prop_id("Collider") + var e = World.query_next(P, 0) + while e >= 0 { + var nxt = World.query_next(P, e + 1) + # TTL + let ttl = pr_get(e, "ttl") - 1 + if ttl <= 0 { emit ProjectileExpired(proj: e); despawn e; e = nxt; continue } + pr_set(e, "ttl", ttl) + # homing steer toward the nearest enemy of the projectile's faction + let hom = pr_get(e, "homing") + if hom > 0 { + let fac = pr_get(e, "faction") + let px0 = pos_get(e, "x"); let py0 = pos_get(e, "y") + let t = sh_nearest_enemy(e, fac, px0, py0) + if t >= 0 { + let want = sh_angle_to(px0, py0, pos_get(t, "x"), pos_get(t, "y")) + let cur = pr_get(e, "dir") + let d = sh_angle_diff(cur, want) + var na = cur + if (d >= 0 - hom) and (d <= hom) { na = want } + else { if d > 0 { na = sh_wrap_deg(cur + hom) } else { na = sh_wrap_deg(cur - hom) } } + pr_set(e, "dir", na) + let sp = pr_get(e, "speed") + let rad = Angle.from_degrees(fixed(na)) + let nvx = Angle.cos(rad) * fixed(sp) + let nvy = Angle.sin(rad) * fixed(sp) + pr_set(e, "vx", nvx) + pr_set(e, "vy", nvy) + } + } + # integrate (Q16.16 with subpixel remainder) + let vx = pr_get(e, "vx"); let vy = pr_get(e, "vy") + var rrx = pr_get(e, "rx") + vx + var rry = pr_get(e, "ry") + vy + let dx = rrx / SH_Q; let dy = rry / SH_Q + pos_set(e, "x", pos_get(e, "x") + dx) + pos_set(e, "y", pos_get(e, "y") + dy) + pr_set(e, "rx", rrx - dx * SH_Q) + pr_set(e, "ry", rry - dy * SH_Q) + # collision vs hostile Colliders + if PC >= 0 { + let ex = pos_get(e, "x"); let ey = pos_get(e, "y") + let ew = World.get(e, PC, World.field_id(PC, "w")) + let eh = World.get(e, PC, World.field_id(PC, "h")) + let owner = pr_get(e, "owner") + let fac = pr_get(e, "faction") + var hit_done = 0 + var t = World.query_next(PC, 0) + while (t >= 0) and (hit_done == 0) { + if (t != e) and (t != owner) { + if World.get(t, PC, World.field_id(PC, "is_trigger")) == 0 { + if Faction.hostile(fac, Faction.id_of(t)) == 1 { + let tx = pos_get(t, "x"); let ty = pos_get(t, "y") + let tw = World.get(t, PC, World.field_id(PC, "w")) + let th = World.get(t, PC, World.field_id(PC, "h")) + if (ex < tx + tw) and (tx < ex + ew) and (ey < ty + th) and (ty < ey + eh) { + emit ProjectileHit(proj: e, target: t, damage: pr_get(e, "damage")) + Combat.damage(t, owner, pr_get(e, "damage")) + let pierce = pr_get(e, "pierce") - 1 + if pierce < 0 { despawn e; hit_done = 1 } else { pr_set(e, "pierce", pierce) } + } + } + } + } + t = World.query_next(PC, t + 1) + } + } + e = nxt + } +} + +# =========================================================================== +# Spawner / wave director (macro). A budgeted, throttled emitter: every `rate` +# frames while spawned < budget it emits SpawnRequested (the game does the actual +# spawn + placement, so the WHAT stays the game's policy); when the budget is met +# and the wave is cleared it emits WaveCleared. Shared with NPC AI arenas (#61). +# =========================================================================== +property Spawner { rate: int = 60, budget: int = 5, spawned: int = 0, t: int = 0, alive: int = 0, wave: int = 0 } +event SpawnRequested { spawner: int = 0, wave: int = 0, index: int = 0 } +event WaveCleared { spawner: int = 0, wave: int = 0 } + +@EngineSystem(Spawner, Update) function esys_spawner() -> void { + let P = World.prop_id("Spawner") + if P < 0 { return } + let frate = World.field_id(P, "rate") + let fbudget = World.field_id(P, "budget") + let fsp = World.field_id(P, "spawned") + let ft = World.field_id(P, "t") + let fwave = World.field_id(P, "wave") + var e = World.query_next(P, 0) + while e >= 0 { + let budget = World.get(e, P, fbudget) + var spawned = World.get(e, P, fsp) + if spawned < budget { + var t = World.get(e, P, ft) - 1 + if t <= 0 { + emit SpawnRequested(spawner: e, wave: World.get(e, P, fwave), index: spawned) + spawned = spawned + 1 + World.set(e, P, fsp, spawned) + World.set(e, P, ft, World.get(e, P, frate)) + if spawned >= budget { emit WaveCleared(spawner: e, wave: World.get(e, P, fwave)) } + } else { + World.set(e, P, ft, t) + } + } + e = World.query_next(P, e + 1) + } +} +# Spawner.next_wave(e, budget) — start a fresh wave with a new budget. +@Namespace(Spawner) function spawner_next_wave(e: int, budget: int) -> void { + let P = World.prop_id("Spawner") + if P < 0 { return } + World.set(e, P, World.field_id(P, "budget"), budget) + World.set(e, P, World.field_id(P, "spawned"), 0) + World.set(e, P, World.field_id(P, "t"), 1) + World.set(e, P, World.field_id(P, "wave"), World.get(e, P, World.field_id(P, "wave")) + 1) +} + +# --------------------------------------------------------------------------- +# TopDown.* / Weapon.* convenience +# --------------------------------------------------------------------------- +@Namespace(TopDown) function topdown_move(e: int, dx: int, dy: int) -> void { td_set(e, "want_x", dx); td_set(e, "want_y", dy) } +@Namespace(TopDown) function topdown_aim(e: int, deg: int) -> void { td_set(e, "aim_angle", deg) } +@Namespace(TopDown) function topdown_fire(e: int, on: int) -> void { td_set(e, "want_fire", on) } +# equip a weapon by registry name. +@Namespace(Weapon) function weapon_equip(e: int, name: pointer) -> void { + let P = World.prop_id("Weapon") + if P < 0 { return } + World.set(e, P, World.field_id(P, "def_id"), weapon_id(name)) +} diff --git a/tools/x/test.ludic b/tools/x/test.ludic index 066d41da..7c3be3ae 100644 --- a/tools/x/test.ludic +++ b/tools/x/test.ludic @@ -243,6 +243,7 @@ function cmd_test() -> int { controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)") 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/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)") spec_case("library/testing", "== 6 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)")