feat(engine): #90 atlas-aware Sprite component, #91 become from listeners, 0.3.x ergonomics batch
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 32s
ci / build-and-test (push) Successful in 2m49s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 30s

Closes the two open issues and lands the pending unreleased batch:

- #90: `Sprite { atlas: 1 }` routes esys_sprite through atlas_draw_ex
  (scale/flip/tint), so cell / cell_span / strip ids of any size draw
  through the engine sprite-render system. examples/library/sprite_atlas
  is the pixel-readback regression.
- #91: `become` from an @On(Event) listener / global handler / plain
  function no longer segfaults the compiler; it emits @L_scene_leave()
  (a dispatch on the live scene id) so the leaving scene's on-exit runs.
  UI_* handles are readable from any code (widget table built on first
  use). examples/library/scene_menus covers it.
- fix: a windowed `ludicc -o` build that reaches the audio runtime only
  through the atlas/Assets preload import now links audio.ll +
  AVFoundation (the audio backend link was gated on a game-level
  Audio.* call, so any windowed game declaring Sprite failed to link).
- the hand-written "Unreleased" CHANGELOG section is converted to
  changesets under changes/ so `x release` generates it.
- plus the batch: engine-driven retained UI + UiClicked event, Overlay
  phase, TileSkin tilemap-render system, Key.* constants, Font/Ui/File
  namespaces, Sprite.strip, prefabs, managers, countdown fields,
  enum-typed machines, layer @Queries, ludic.prefs / ludic.dungeon
  packages, Ai.seek pathing, Solids.solid2, cursor confine (mode 3)
  fix, shooter centre-aim fix, reserved-word function diagnostic.

Verified: x test (124/124), x test-tools, check-impl, check-vocabulary,
check-docs, docs-gen + docs-check, bootstrap-cfree (seed is a fixpoint).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-04 01:36:08 +03:00
parent e9c2c51bc3
commit ad548840c7
139 changed files with 58981 additions and 43671 deletions

View file

@ -21,6 +21,8 @@ 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
property TopDown {
move_speed: int = 4, # px/frame
accel: int = 80, # responsiveness %, gap closed / frame
@ -32,7 +34,9 @@ property TopDown {
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
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
@ -40,6 +44,7 @@ property TopDown {
property Weapon { def_id: int = 0, ammo: int = 0 - 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,
@ -71,6 +76,7 @@ var wpn_pattern: words = null
var wpn_ttl: words = null
var wpn_pierce: words = null
var wpn_homing: words = null
var wpn_color: words = null
function wpn_ensure() -> void {
if wpn_reg == null {
@ -78,6 +84,7 @@ function wpn_ensure() -> void {
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)
wpn_color = words(WPN_MAX); wpn_rate_base = words(WPN_MAX)
}
}
@ -90,6 +97,8 @@ function wpn_ensure() -> void {
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_color[id] = 0xffffff
wpn_rate_base[id] = fire_rate
wpn_n = wpn_n + 1
Dict.set(wpn_reg, name, id)
return id
@ -98,6 +107,23 @@ function wpn_ensure() -> void {
wpn_ensure()
return Dict.get_or(wpn_reg, name, 0 - 1)
}
# Weapon.set_rate(id, frames) — frames between shots (a fire-rate upgrade changes it in place).
@Namespace(Weapon) function weapon_set_rate(id: int, frames: int) -> void {
wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_rate[id] = frames }
}
@Namespace(Weapon) function weapon_rate(id: int) -> int {
wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { return wpn_rate[id] }; return 0
}
# Weapon.reset(id) — back to the definition: no pierce, no homing, the defined fire rate.
var wpn_rate_base: words = null
@Namespace(Weapon) function weapon_reset(id: int) -> void {
wpn_ensure(); if (id < 0) or (id >= WPN_MAX) { return }
wpn_pierce[id] = 0; wpn_homing[id] = 0; wpn_rate[id] = 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(id: int, color: int) -> void {
wpn_ensure(); if (id >= 0) and (id < WPN_MAX) { wpn_color[id] = color }
}
# 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 }
@ -163,8 +189,8 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
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 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 }
}
@ -177,6 +203,169 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
# ===========================================================================
# 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 0 - 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 = 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 }
@ -210,9 +399,10 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
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 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 == 0 { # mouse / point
@ -234,7 +424,7 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
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 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")
@ -254,6 +444,23 @@ function sh_nearest_enemy(self_e: int, fac: int, px: int, py: int) -> int {
# 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)))
@ -300,9 +507,11 @@ function sh_spawn_bullet(owner: int, fac: int, px: int, py: int, deg: int, speed
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", wpn_fire_color)
if emit ProjectileSpawned(proj: b, owner: owner, damage: dmg) != 0 { despawn b; return 0 - 1 }
return b
}
var wpn_fire_color: int = 0xffffff # the colour of the weapon being fired (sh_fire)
# ===========================================================================
# esys_weapon (Update) — fire-rate gating + pattern emission. Reads the owner's
@ -322,7 +531,7 @@ function sh_spawn_bullet(owner: int, fac: int, px: int, py: int, deg: int, speed
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])
World.set(e, P, World.field_id(P, "cooldown"), wpn_rate[id] * stats_field(e, "fire_rate_pct", 100) / 100)
}
}
e = World.query_next(P, e + 1)
@ -330,8 +539,8 @@ function sh_spawn_bullet(owner: int, fac: int, px: int, py: int, deg: int, speed
}
function sh_fire(owner: int, id: int) -> void {
let px = pos_get(owner, "x")
let py = pos_get(owner, "y")
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 = wpn_speed[id]
@ -342,6 +551,7 @@ function sh_fire(owner: int, id: int) -> void {
let ttl = wpn_ttl[id]
let pierce = wpn_pierce[id]
let homing = wpn_homing[id]
wpn_fire_color = wpn_color[id]
var pellets = pellets0; if pellets < 1 { pellets = 1 }
emit WeaponFired(owner: owner, def_id: id)
if pattern == 2 { # ring: pellets around 360
@ -387,6 +597,8 @@ function sh_fire(owner: int, id: int) -> void {
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 {
@ -394,7 +606,7 @@ function sh_fire(owner: int, id: int) -> void {
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 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