feat(controllers): #60 shooter — ludic.shooter package (base + extensible)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 21s
ci / build-and-test (push) Successful in 2m9s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 28s

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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 17:04:49 +03:00
parent dd5cb5817b
commit 02a8bcc324
5 changed files with 705 additions and 0 deletions

View file

@ -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"

View file

@ -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))
}