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

@ -23,11 +23,14 @@ property Vision { range: int = 140, fov: int = 360, scan: int = 6, scan_t: int =
property Memory { has_target: int = 0, target: int = 0, last_x: int = 0, last_y: int = 0, alertness: int = 0, ttl: int = 0 }
# model: 0 FSM, 1 utility, 2 behaviour-tree. state (FSM): 0 patrol,1 chase,2 attack,3 flee.
enum BrainModel { StateMachine, Utility, BehaviourTree } # Brain.model
enum AiState { Patrol, Chase, Attack, Flee } # Brain.state / DecisionMade.action
property Brain {
model: int = 0, state: int = 0,
attack_range: int = 40, flee_pct: int = 0,
intent_x: int = 0, intent_y: int = 0, want_fire: int = 0,
home_x: int = 0, home_y: int = 0, seed: int = 1
home_x: int = 0, home_y: int = 0, seed: int = 1,
hunt_blind: int = 0 # 1 = with no target in sight, seek the nearest hostile anyway (no idle patrol)
}
property Follower { leader: int = 0 - 1, distance: int = 40, mode: int = 0 } # mode 0 follow,1 guard,2 aggressive
property Steering { separate: int = 0, cohere: int = 0, align: int = 0, radius: int = 48 }
@ -147,9 +150,46 @@ function brain_set_state(e: int, st: int) -> void {
}
# steer the Brain intent toward / away from a point (8-way signed intent).
# the Solids config (tile size / wall glyph) when the game declared one, else 0
function ai_solid_tile() -> int {
let ps = World.prop_id("Solids")
if ps < 0 { return 0 }
let se = World.query_next(ps, 0)
if se < 0 { return 0 }
let ft = World.field_id(ps, "tile")
if ft < 0 { return 0 }
return World.get(se, ps, ft)
}
function ai_solid_wall() -> int {
let ps = World.prop_id("Solids")
let se = World.query_next(ps, 0)
return World.get(se, ps, World.field_id(ps, "wall"))
}
# move toward (tx,ty). With a tilemap, a blocked straight line is routed around
# obstacles with Grid.a_star on the tile grid: the intent points at the next
# waypoint, so bodies flow around pillars instead of pushing into them.
function brain_seek(e: int, tx: int, ty: int) -> void {
let px = ai_get("Position", e, "x")
let py = ai_get("Position", e, "y")
let ts = ai_solid_tile()
if ts > 0 {
let wall = ai_solid_wall()
let cx = px / ts; let cy = py / ts
let gx = tx / ts; let gy = ty / ts
if (cx != gx) or (cy != gy) {
if not Grid.line_of_sight(x0: cx, y0: cy, x1: gx, y1: gy, wall: wall) {
let path = Grid.a_star(x0: cx, y0: cy, x1: gx, y1: gy, wall: wall)
if len(path) > 1 {
let nx = path[1].x * ts + ts / 2
let ny = path[1].y * ts + ts / 2
ai_set("Brain", e, "intent_x", sign_i(nx - (px + ts / 2)))
ai_set("Brain", e, "intent_y", sign_i(ny - (py + ts / 2)))
return
}
}
}
}
ai_set("Brain", e, "intent_x", sign_i(tx - px))
ai_set("Brain", e, "intent_y", sign_i(ty - py))
}
@ -226,9 +266,36 @@ function ai_decide_fsm(e: int) -> void {
}
}
} else {
if emit DecisionMade(e: e, action: 0) == 0 { brain_set_state(e, 0); brain_wander(e) }
let prey = brain_nearest_hostile(e)
if (ai_get("Brain", e, "hunt_blind") == 1) and (prey >= 0) {
if emit DecisionMade(e: e, action: 1) == 0 { brain_set_state(e, 1); brain_seek(e, ai_get("Position", prey, "x"), ai_get("Position", prey, "y")) }
} else {
if emit DecisionMade(e: e, action: 0) == 0 { brain_set_state(e, 0); brain_wander(e) }
}
}
}
# the nearest entity hostile to e (by Faction) that has a Position, or -1
function brain_nearest_hostile(e: int) -> int {
let PF = World.prop_id("Faction")
let PP = World.prop_id("Position")
if (PF < 0) or (PP < 0) { return 0 - 1 }
let fac = Faction.id_of(e)
let px = ai_get("Position", e, "x")
let py = ai_get("Position", e, "y")
var best = 0 - 1
var bestd = 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 dx = ai_get("Position", o, "x") - px
let dy = ai_get("Position", o, "y") - py
let d = dx * dx + dy * dy
if (best < 0) or (d < bestd) { best = o; bestd = d }
}
o = World.query_next(PF, o + 1)
}
return best
}
# --- (b) utility AI: score each action, act on the highest ---
function ai_decide_utility(e: int) -> void {
@ -386,6 +453,7 @@ function ai_decide_bt(e: int) -> void {
# Ai.* convenience
# ---------------------------------------------------------------------------
@Namespace(Ai) function ai_set_model(e: int, model: int) -> void { ai_set("Brain", e, "model", model) }
@Namespace(Ai) function ai_seek(e: int, tx: int, ty: int) -> void { brain_seek(e, tx, ty) } # path-aware move-toward
@Namespace(Ai) function ai_state(e: int) -> int { return ai_get("Brain", e, "state") }
@Namespace(Ai) function ai_target(e: int) -> int {
if ai_get("Memory", e, "has_target") == 1 { return ai_get("Memory", e, "target") }