724 lines
32 KiB
Text
724 lines
32 KiB
Text
# 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
|
|
# ---------------------------------------------------------------------------
|
|
enum AimMode { Mouse, RightStick, MoveDirection, NearestEnemy, Manual, Auto } # TopDown.aim_mode; Auto = mouse, or the right stick while a pad is connected
|
|
enum WeaponPattern { Single, Cone, Ring, Spiral } # Weapon.def pattern
|
|
|
|
# gamepad axis indices as Input.pad_axis numbers them: left stick 0/1, right stick 2/3
|
|
const STICK_RIGHT_X: int = 2
|
|
const STICK_RIGHT_Y: int = 3
|
|
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,
|
|
reticle: int = 0, # a colour: the engine draws an aim line of reticle_length px (0 = none)
|
|
reticle_length: int = 18 # and a cross at the mouse while aiming with it
|
|
}
|
|
|
|
# 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 = -1, cooldown: int = 0, phase: int = 0 }
|
|
|
|
property Projectile {
|
|
color: int = 0xffffff, # drawn by the engine as a 3x3 square (Weapon.set_color)
|
|
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)
|
|
# ---------------------------------------------------------------------------
|
|
state ShooterState {
|
|
wpn_reg: Dict = Dict.new()
|
|
wpn_n: int = 0
|
|
wpn_rate: words = words(WPN_MAX)
|
|
wpn_dmg: words = words(WPN_MAX)
|
|
wpn_speed: words = words(WPN_MAX)
|
|
wpn_spread: words = words(WPN_MAX)
|
|
wpn_pellets: words = words(WPN_MAX)
|
|
wpn_pattern: words = words(WPN_MAX)
|
|
wpn_ttl: words = words(WPN_MAX)
|
|
wpn_pierce: words = words(WPN_MAX)
|
|
wpn_homing: words = words(WPN_MAX)
|
|
wpn_color: words = words(WPN_MAX)
|
|
wpn_rate_base: words = words(WPN_MAX)
|
|
wpn_fire_color: int = 0xffffff # the colour of the weapon being fired (sh_fire)
|
|
}
|
|
|
|
|
|
# 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(shooter_st: mut ShooterState, name: pointer, fire_rate: int, damage: int, speed: int, spread: int, pellets: int, pattern: int) -> int {
|
|
if shooter_st.wpn_n >= WPN_MAX { return -1 }
|
|
let id = shooter_st.wpn_n
|
|
shooter_st.wpn_rate[id] = fire_rate; shooter_st.wpn_dmg[id] = damage; shooter_st.wpn_speed[id] = speed
|
|
shooter_st.wpn_spread[id] = spread; shooter_st.wpn_pellets[id] = pellets; shooter_st.wpn_pattern[id] = pattern
|
|
shooter_st.wpn_ttl[id] = 120
|
|
shooter_st.wpn_color[id] = 0xffffff
|
|
shooter_st.wpn_rate_base[id] = fire_rate
|
|
shooter_st.wpn_n += 1
|
|
Dict.set(shooter_st.wpn_reg, name, id)
|
|
return id
|
|
}
|
|
@Namespace(Weapon) function weapon_id(shooter_st: mut ShooterState, name: pointer) -> int {
|
|
return Dict.get_or(shooter_st.wpn_reg, name, -1)
|
|
}
|
|
# Weapon.set_rate(id, frames) — frames between shots (a fire-rate upgrade changes it in place).
|
|
@Namespace(Weapon) function weapon_set_rate(shooter_st: mut ShooterState, id: int, frames: int) -> void {
|
|
if (id >= 0) and (id < WPN_MAX) { shooter_st.wpn_rate[id] = frames }
|
|
}
|
|
@Namespace(Weapon) function weapon_rate(shooter_st: mut ShooterState, id: int) -> int {
|
|
if (id >= 0) and (id < WPN_MAX) { return shooter_st.wpn_rate[id] }; return 0
|
|
}
|
|
# Weapon.reset(id) — back to the definition: no pierce, no homing, the defined fire rate.
|
|
@Namespace(Weapon) function weapon_reset(shooter_st: mut ShooterState, id: int) -> void {
|
|
if (id < 0) or (id >= WPN_MAX) { return }
|
|
shooter_st.wpn_pierce[id] = 0; shooter_st.wpn_homing[id] = 0; shooter_st.wpn_rate[id] = shooter_st.wpn_rate_base[id]
|
|
}
|
|
# Weapon.set_color(id, color) — the colour the engine draws this weapon's shots in.
|
|
@Namespace(Weapon) function weapon_set_color(shooter_st: mut ShooterState, id: int, color: int) -> void {
|
|
if (id >= 0) and (id < WPN_MAX) { shooter_st.wpn_color[id] = color }
|
|
}
|
|
# Weapon.set_ttl(id, frames) — projectile lifetime for a weapon (range control).
|
|
@Namespace(Weapon) function weapon_set_ttl(shooter_st: mut ShooterState, id: int, frames: int) -> void {
|
|
if (id >= 0) and (id < WPN_MAX) { shooter_st.wpn_ttl[id] = frames }
|
|
}
|
|
# Weapon.set_pierce(id, n) — a shot passes through n targets before it dies.
|
|
@Namespace(Weapon) function weapon_set_pierce(shooter_st: mut ShooterState, id: int, n: int) -> void {
|
|
if (id >= 0) and (id < WPN_MAX) { shooter_st.wpn_pierce[id] = n }
|
|
}
|
|
# Weapon.set_homing(id, deg) — max degrees/frame a shot turns toward an enemy.
|
|
@Namespace(Weapon) function weapon_set_homing(shooter_st: mut ShooterState, id: int, deg: int) -> void {
|
|
if (id >= 0) and (id < WPN_MAX) { shooter_st.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 -1 }
|
|
let PP = World.prop_id("Position")
|
|
if PP < 0 { return -1 }
|
|
var best = -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 = sh_cx(e) - px
|
|
let dy = sh_cy(e) - 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.
|
|
# ===========================================================================
|
|
# ===========================================================================
|
|
# Dash — the dodge roll: a burst of `speed` for `frames` frames in a fixed
|
|
# direction, invulnerable while it lasts, then `cooldown_frames` before the next.
|
|
# Dash.start(e, dx, dy) -> bool begin one (a zero direction dashes along the aim);
|
|
# false while cooling down
|
|
# Dash.active(e) -> bool mid-dash?
|
|
# esys_dash (FixedUpdate, before the mover) overrides the movement intent and the
|
|
# fire intent for the dash, restoring the walking speed when it ends.
|
|
# ===========================================================================
|
|
property Dash {
|
|
frames: int = 10, speed: int = 11, cooldown_frames: int = 35,
|
|
frames_left: countdown = 0, cooldown: countdown = 0, dx: int = 0, dy: int = 0, saved_speed: int = 0
|
|
}
|
|
# a component field by name (the reflected accessor the Dash / Melee helpers use)
|
|
function sh_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 sh_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 dash_get(e: int, name: pointer) -> int { return sh_get("Dash", e, name) }
|
|
function dash_set(e: int, name: pointer, v: int) -> void { sh_set("Dash", e, name, v) }
|
|
@Namespace(Dash) function dash_start(e: int, dx: int, dy: int) -> bool {
|
|
if dash_get(e, "cooldown") > 0 { return false }
|
|
var d = IVec2.make(dx, dy)
|
|
if (dx == 0) and (dy == 0) { d = IVec2.step(td_get(e, "aim_angle")) }
|
|
if (d.x == 0) and (d.y == 0) { d = IVec2.make(1, 0) }
|
|
dash_set(e, "frames_left", dash_get(e, "frames"))
|
|
dash_set(e, "cooldown", dash_get(e, "cooldown_frames"))
|
|
dash_set(e, "dx", d.x)
|
|
dash_set(e, "dy", d.y)
|
|
dash_set(e, "saved_speed", td_get(e, "move_speed"))
|
|
td_set(e, "move_speed", dash_get(e, "speed"))
|
|
return true
|
|
}
|
|
@Namespace(Dash) function dash_active(e: int) -> bool { return dash_get(e, "frames_left") > 0 }
|
|
@EngineSystem(Dash, FixedUpdate) function esys_dash() -> void {
|
|
let P = World.prop_id("Dash")
|
|
if P < 0 { return }
|
|
var e = World.query_next(P, 0)
|
|
while e >= 0 {
|
|
if dash_get(e, "frames_left") > 0 {
|
|
td_set(e, "want_x", dash_get(e, "dx"))
|
|
td_set(e, "want_y", dash_get(e, "dy"))
|
|
td_set(e, "want_fire", 0)
|
|
} else { if dash_get(e, "saved_speed") > 0 {
|
|
td_set(e, "move_speed", dash_get(e, "saved_speed"))
|
|
dash_set(e, "saved_speed", 0)
|
|
} }
|
|
e = World.query_next(P, e + 1)
|
|
}
|
|
}
|
|
@On(DamageAboutToApply) handler DashGuard { if dash_active(target) { cancel } } # i-frames while dashing
|
|
|
|
# ===========================================================================
|
|
# Melee — a swing that hits every hostile inside an arc around the aim.
|
|
# Melee.swing(e) -> int hits dealt (-1 while cooling down); each hit takes
|
|
# `damage`, is pushed `knockback` px away (never into a
|
|
# solid tile), and fires MeleeHit
|
|
# Melee.ready(e) / Melee.active(e)
|
|
# ===========================================================================
|
|
property Melee {
|
|
range: int = 24, half_arc: int = 50, damage: int = 10, knockback: int = 8,
|
|
frames: int = 8, cooldown_frames: int = 20, frames_left: countdown = 0, cooldown: countdown = 0,
|
|
arc: int = 0 # a colour: the engine draws the swing's arc while it lasts (0 = none)
|
|
}
|
|
# esys_melee_draw (Render): the swing as a circle at the reach, growing with its frames
|
|
@EngineSystem(Melee, Render) function esys_melee_draw() -> void {
|
|
let P = World.prop_id("Melee")
|
|
if P < 0 { return }
|
|
var e = World.query_next(P, 0)
|
|
while e >= 0 {
|
|
let color = melee_get(e, "arc")
|
|
let left = melee_get(e, "frames_left")
|
|
if (color != 0) and (left > 0) {
|
|
let at = IVec2.along(IVec2.make(sh_cx(e), sh_cy(e)), td_get(e, "aim_angle"), melee_get(e, "range"))
|
|
Screen.circle(x: at.x, y: at.y, radius: 6 + left, color: color)
|
|
}
|
|
e = World.query_next(P, e + 1)
|
|
}
|
|
}
|
|
event MeleeHit { attacker: int = 0, target: int = 0 }
|
|
function melee_get(e: int, name: pointer) -> int { return sh_get("Melee", e, name) }
|
|
@Namespace(Melee) function melee_ready(e: int) -> bool { return melee_get(e, "cooldown") == 0 }
|
|
@Namespace(Melee) function melee_active(e: int) -> bool { return melee_get(e, "frames_left") > 0 }
|
|
@Namespace(Melee) function melee_swing(e: int) -> int {
|
|
if melee_get(e, "cooldown") > 0 { return -1 }
|
|
sh_set("Melee", e, "frames_left", melee_get(e, "frames"))
|
|
sh_set("Melee", e, "cooldown", melee_get(e, "cooldown_frames"))
|
|
let PF = World.prop_id("Faction")
|
|
let PP = World.prop_id("Position")
|
|
if (PF < 0) or (PP < 0) { return 0 }
|
|
let me = IVec2.make(sh_cx(e), sh_cy(e))
|
|
let aim = td_get(e, "aim_angle")
|
|
let fac = Faction.id_of(e)
|
|
var hits = 0
|
|
var o = World.query_next(PF, 0)
|
|
while o >= 0 {
|
|
if (o != e) and (World.has(o, PP) != 0) and (Faction.hostile(fac, Faction.id_of(o)) == 1) {
|
|
let other = IVec2.make(sh_cx(o), sh_cy(o))
|
|
let reach = melee_get(e, "range") + sh_half_w(o)
|
|
if IVec2.within(me, other, reach) {
|
|
let heading = IVec2.heading(me, other)
|
|
if abs(Angle.diff_degrees(a: aim, b: heading)) <= melee_get(e, "half_arc") {
|
|
Combat.damage(o, e, melee_get(e, "damage"))
|
|
let push = IVec2.scale(IVec2.step(heading), melee_get(e, "knockback"))
|
|
let nx = pos_get(o, "x") + push.x
|
|
let ny = pos_get(o, "y") + push.y
|
|
if not Map.is_solid_at(nx, ny) { pos_set(o, "x", nx); pos_set(o, "y", ny) }
|
|
emit MeleeHit(attacker: e, target: o)
|
|
hits += 1
|
|
}
|
|
}
|
|
}
|
|
o = World.query_next(PF, o + 1)
|
|
}
|
|
return hits
|
|
}
|
|
function sh_half_w(e: int) -> int {
|
|
let PC = World.prop_id("Collider")
|
|
if (PC < 0) or (World.has(e, PC) == 0) { return 0 }
|
|
return World.get(e, PC, World.field_id(PC, "w")) / 2
|
|
}
|
|
|
|
# esys_topdown_draw (Render): the aim line, and a cross at the mouse while it is the aim
|
|
@EngineSystem(TopDown, Render) function esys_topdown_draw() -> void {
|
|
let P = World.prop_id("TopDown")
|
|
if P < 0 { return }
|
|
var e = World.query_next(P, 0)
|
|
while e >= 0 {
|
|
let color = td_get(e, "reticle")
|
|
if color != 0 {
|
|
let from = IVec2.make(sh_cx(e), sh_cy(e))
|
|
let tip = IVec2.along(from, td_get(e, "aim_angle"), td_get(e, "reticle_length"))
|
|
Screen.line(x1: from.x, y1: from.y, x2: tip.x, y2: tip.y, color: color)
|
|
var mode = td_get(e, "aim_mode")
|
|
if (mode == AimMode.Auto) and (not Input.pad_connected(0)) { mode = AimMode.Mouse }
|
|
if mode == AimMode.Mouse {
|
|
let mx = Input.mouse_x(); let my = Input.mouse_y()
|
|
Screen.line(x1: mx - 3, y1: my, x2: mx + 3, y2: my, color: 0xffffff)
|
|
Screen.line(x1: mx, y1: my - 3, x2: mx, y2: my + 3, color: 0xffffff)
|
|
}
|
|
}
|
|
e = World.query_next(P, e + 1)
|
|
}
|
|
}
|
|
|
|
# esys_projectile_draw (Render, after the sprites): every shot as a 3x3 square in
|
|
# its weapon's colour, through the camera, so a game draws no bullets by hand.
|
|
@EngineSystem(Projectile, Render) function esys_projectile_draw() -> void {
|
|
let P = World.prop_id("Projectile")
|
|
if P < 0 { return }
|
|
let fc = World.field_id(P, "color")
|
|
var e = World.query_next(P, 0)
|
|
while e >= 0 {
|
|
if pr_get(e, "ttl") > 0 { Screen.fill_rectangle(x: pos_get(e, "x") - 1, y: pos_get(e, "y") - 1, width: 3, height: 3, color: World.get(e, P, fc)) }
|
|
e = World.query_next(P, e + 1)
|
|
}
|
|
}
|
|
|
|
@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 {
|
|
var mode = td_get(e, "aim_mode")
|
|
if mode == AimMode.Auto { mode = AimMode.Mouse; if Input.pad_connected(0) { mode = AimMode.RightStick } }
|
|
let px = sh_cx(e)
|
|
let py = sh_cy(e)
|
|
var target = td_get(e, "aim_angle") # default: keep current
|
|
var have = 0
|
|
if mode == AimMode.Mouse {
|
|
let mx = Input.mouse_x()
|
|
let my = Input.mouse_y()
|
|
target = sh_angle_to(px, py, mx, my); have = 1
|
|
}
|
|
if mode == AimMode.RightStick {
|
|
let ax = Input.pad_axis(0, STICK_RIGHT_X)
|
|
let ay = Input.pad_axis(0, STICK_RIGHT_Y)
|
|
if (ax != 0) or (ay != 0) { target = sh_dir_angle_fx(ax, ay); have = 1 }
|
|
}
|
|
if mode == AimMode.MoveDirection { # from the movement 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 }
|
|
}
|
|
# AimMode.Manual: the game/AI owns aim_angle; the aim system leaves it alone.
|
|
if mode == AimMode.NearestEnemy { # 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, sh_cx(t), sh_cy(t)); 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 >= -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.
|
|
# the centre of an entity's body: Position + Collider offset + half size when it has
|
|
# a Collider (Position is a box's top-left anchor), else Position itself.
|
|
function sh_cx(e: int) -> int {
|
|
let PC = World.prop_id("Collider")
|
|
if (PC >= 0) and (World.has(e, PC) != 0) {
|
|
return pos_get(e, "x") + World.get(e, PC, World.field_id(PC, "offx")) + World.get(e, PC, World.field_id(PC, "w")) / 2
|
|
}
|
|
return pos_get(e, "x")
|
|
}
|
|
function sh_cy(e: int) -> int {
|
|
let PC = World.prop_id("Collider")
|
|
if (PC >= 0) and (World.has(e, PC) != 0) {
|
|
return pos_get(e, "y") + World.get(e, PC, World.field_id(PC, "offy")) + World.get(e, PC, World.field_id(PC, "h")) / 2
|
|
}
|
|
return pos_get(e, "y")
|
|
}
|
|
|
|
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)))
|
|
}
|
|
# a stick's deflection is already fixed: fixed(dy) on it would scale it by 65536 again and
|
|
# overflow past half a push, turning the aim round
|
|
function sh_dir_angle_fx(dx: fixed, dy: fixed) -> int {
|
|
let r = Math.atan2(dy, 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 += 360 }
|
|
while a >= 360 { 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 -= 360 }
|
|
while d < -180 { d += 360 }
|
|
return d
|
|
}
|
|
|
|
# spawn one projectile travelling at `deg` degrees from (px,py).
|
|
function sh_spawn_bullet(shooter_st: ShooterState, 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 -1 }
|
|
let b = World.spawn(m)
|
|
pos_set(b, "x", px)
|
|
pos_set(b, "y", py)
|
|
let rad = Angle.from_degrees(fixed(deg))
|
|
# World.set stores a field's raw word: a fixed field is handed its bits by name
|
|
pr_set(b, "vx", as_int(Angle.cos(rad) * fixed(speed)))
|
|
pr_set(b, "vy", as_int(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.
|
|
pr_set(b, "color", shooter_st.wpn_fire_color)
|
|
if emit ProjectileSpawned(proj: b, owner: owner, damage: dmg) != 0 { despawn b; return -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(shooter_st: mut ShooterState) -> void {
|
|
let P = World.prop_id("Weapon")
|
|
if P < 0 { return }
|
|
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 < shooter_st.wpn_n) {
|
|
sh_fire(shooter_st, e, id)
|
|
World.set(e, P, World.field_id(P, "cooldown"), shooter_st.wpn_rate[id] * stats_field(e, "fire_rate_pct", 100) / 100)
|
|
}
|
|
}
|
|
e = World.query_next(P, e + 1)
|
|
}
|
|
}
|
|
|
|
function sh_fire(shooter_st: mut ShooterState, owner: int, id: int) -> void {
|
|
let px = sh_cx(owner)
|
|
let py = sh_cy(owner)
|
|
let base = td_get(owner, "aim_angle")
|
|
let fac = Faction.id_of(owner)
|
|
let speed = shooter_st.wpn_speed[id]
|
|
let dmg = shooter_st.wpn_dmg[id]
|
|
let spread = shooter_st.wpn_spread[id]
|
|
let pellets0 = shooter_st.wpn_pellets[id]
|
|
let pattern = shooter_st.wpn_pattern[id]
|
|
let ttl = shooter_st.wpn_ttl[id]
|
|
let pierce = shooter_st.wpn_pierce[id]
|
|
let homing = shooter_st.wpn_homing[id]
|
|
shooter_st.wpn_fire_color = shooter_st.wpn_color[id]
|
|
var pellets = pellets0; if pellets < 1 { pellets = 1 }
|
|
emit WeaponFired(owner: owner, def_id: id)
|
|
if pattern == WeaponPattern.Ring { # pellets around 360
|
|
var i = 0
|
|
while i < pellets { sh_spawn_bullet(shooter_st, owner, fac, px, py, sh_wrap_deg(base + i * 360 / pellets), speed, dmg, ttl, pierce, homing); i += 1 }
|
|
return
|
|
}
|
|
if pattern == WeaponPattern.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(shooter_st, owner, fac, px, py, sh_wrap_deg(base + ph + i * 360 / pellets), speed, dmg, ttl, pierce, homing); 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(shooter_st, owner, fac, px, py, base, speed, dmg, ttl, pierce, homing)
|
|
} else {
|
|
var i = 0
|
|
while i < pellets {
|
|
let off = -(spread / 2) + i * spread / (pellets - 1)
|
|
sh_spawn_bullet(shooter_st, owner, fac, px, py, sh_wrap_deg(base + off), speed, dmg, ttl, pierce, homing)
|
|
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)
|
|
# a solid tile (the Solids config) stops a shot; with no Solids nothing is solid
|
|
if Map.is_solid_at(pos_get(e, "x"), pos_get(e, "y")) { emit ProjectileExpired(proj: e); despawn e; e = nxt; continue }
|
|
# 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, sh_cx(t), sh_cy(t))
|
|
let cur = pr_get(e, "dir")
|
|
let d = sh_angle_diff(cur, want)
|
|
var na = cur
|
|
if (d >= -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", as_int(nvx))
|
|
pr_set(e, "vy", as_int(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 += 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(shooter_st: mut ShooterState, 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(shooter_st, name))
|
|
}
|