Merge scriptable properties: @Computed + @OnSpawn (ECS-safe subset)
Properties stay pure data; behavior is expressed as annotations that desugar to handlers and inline expressions. @Computed (derived fields, no storage) and @OnSpawn (spawn-time constructors). Behaviour-preserving; test.sh 15/15, C-free fixpoint holds, goldens byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
28abd3a2c3
9 changed files with 4177 additions and 3503 deletions
29
LANGUAGE.md
29
LANGUAGE.md
|
|
@ -281,11 +281,38 @@ for (Transform, Velocity) in query [Transform, Velocity, {Actor}]
|
|||
`Prop{constraint}` block reads its bare names as fields of `Prop`, and `on: Model`
|
||||
adds a `{Model}` tag filter. The body runs once per matching entity.
|
||||
|
||||
**`@Computed` — a derived field.** A property field marked `@Computed` is **not
|
||||
stored**; `x.field` expands inline to its expression with the bare names read as
|
||||
fields of `x`. It reads like a field but costs nothing at runtime — no getter, no
|
||||
storage — so it doesn't reattach behavior to data:
|
||||
|
||||
```ludic
|
||||
# doc-check: skip — a property with a derived field
|
||||
property Velocity {
|
||||
dx: int = 0
|
||||
dy: int = 0
|
||||
@Computed speed2: int = dx * dx + dy * dy # v.speed2 == v.dx*v.dx + v.dy*v.dy
|
||||
}
|
||||
```
|
||||
|
||||
**`@OnSpawn` — a constructor.** `@OnSpawn(Model)` on a handler runs its body every
|
||||
time a `Model` is spawned, with the model's properties bound by name — a place to
|
||||
initialize an entity. It's a handler keyed to the spawn, not an observer on the
|
||||
data, so the ECS model is untouched:
|
||||
|
||||
```ludic
|
||||
# doc-check: skip — a spawn hook
|
||||
@OnSpawn(Enemy)
|
||||
handler InitEnemy { Health.hp = Health.max } # start every Enemy at full HP
|
||||
```
|
||||
|
||||
**`@Handles` — the handlers a program drives.** Written in front of the
|
||||
`program`, `@Handles(Move)` names the handlers it uses. It parses and reads as
|
||||
documentation; every declared handler still runs (registration is implicit).
|
||||
|
||||
See [`examples/annotations.ludic`](examples/annotations.ludic).
|
||||
See [`examples/annotations.ludic`](examples/annotations.ludic), which uses all
|
||||
four. (`@OnDespawn` and change-reactions are future work — despawn doesn't
|
||||
statically know an entity's model, and reactions need change-tracking.)
|
||||
|
||||
## Structs, arrays and slices
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
# annotations.ludic — the annotation-first style. A handler's query is an
|
||||
# @Queries decorator instead of a clause, and the program lists the handlers it
|
||||
# drives with @Handles. Both lower to the same code the older spellings did.
|
||||
# annotations.ludic — the annotation-first style. Behaviour is expressed with
|
||||
# @-decorators that desugar to plain handlers, queries and expressions, so the
|
||||
# properties stay pure data and the data-oriented model is untouched.
|
||||
|
||||
@Handles(Move)
|
||||
program RPG2D {
|
||||
property Transform { x: int = 0, y: int = 0, scale: int = 1 }
|
||||
property Velocity { dx: int = 0, dy: int = 0 }
|
||||
property Velocity {
|
||||
dx: int = 0
|
||||
dy: int = 0
|
||||
@Computed speed2: int = dx * dx + dy * dy # derived; no storage, inlined at use
|
||||
}
|
||||
model Actor { Transform, Velocity }
|
||||
|
||||
# A constructor: every Actor starts visible. Runs at spawn with the model's
|
||||
# properties bound by name — no per-frame cost, no observer machinery.
|
||||
@OnSpawn(Actor)
|
||||
handler InitActor { Transform.scale = 2 }
|
||||
|
||||
handler Spawn phase Start {
|
||||
spawn Actor { Transform { x: 0, scale: 2 } Velocity { dx: 3, dy: 1 } }
|
||||
spawn Actor { Transform { x: 0, scale: 0 } Velocity { dx: 9, dy: 9 } }
|
||||
spawn Actor { Transform { x: 0 } Velocity { dx: 3, dy: 4 } }
|
||||
spawn Actor { Transform { x: 0 } Velocity { dx: 0, dy: 0 } }
|
||||
}
|
||||
|
||||
# Move every Actor whose scale is positive and that is actually moving. The
|
||||
# query lives in the annotation; each bound property is addressed by its own
|
||||
# name in the body (`Transform`, `Velocity`), and a constraint like
|
||||
# `Transform{scale > 0}` reads `scale` as a field of Transform.
|
||||
# query is the decorator; each bound property is addressed by its own name.
|
||||
@Queries(these: [Transform{scale > 0}, Velocity{dx > 0 or dy > 0}], on: Actor)
|
||||
handler Move phase Update {
|
||||
Transform.x = Transform.x + Velocity.dx
|
||||
|
|
@ -24,7 +31,7 @@ program RPG2D {
|
|||
}
|
||||
|
||||
handler Report phase Render {
|
||||
for (t) in query [Transform] { print_int(t.x); print_int(t.y) }
|
||||
for (t, v) in query [Transform, Velocity] { print_int(t.x); print_int(v.speed2) }
|
||||
quit()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,42 @@ fn find_fn(name: ptr) -> Node {
|
|||
return ptr_null()
|
||||
}
|
||||
|
||||
# @Computed derived fields: a per-property (Prop.field -> expression) registry.
|
||||
# These are NOT stored in the component layout; `x.field` expands inline to the
|
||||
# expression with its bare names read as fields of `x`. Populated at parse time.
|
||||
var g_computed: []Node # each: s = "Prop.field", ty = result type, a = expr
|
||||
|
||||
fn register_computed(prop: ptr, field: ptr, ty: ptr, e: Node) -> void {
|
||||
let cf = node(N_FIELD); cf.s = sconcat(prop, sconcat(".", field)); cf.ty = ty; cf.a = e
|
||||
push(g_computed, cf)
|
||||
}
|
||||
fn computed_expr(prop: ptr, field: ptr) -> Node {
|
||||
if ptr_is_null(prop) { return ptr_null() }
|
||||
let key = sconcat(prop, sconcat(".", field))
|
||||
let i = 0
|
||||
while i < len(g_computed) { if streq(g_computed[i].s, key) { return g_computed[i].a }; i = i + 1 }
|
||||
return ptr_null()
|
||||
}
|
||||
# best-effort static type of an expression (for computed-field lookup; emits nothing)
|
||||
fn static_type(e: Node) -> ptr {
|
||||
if e.kind == E_ID { let li = loc_find(e.s); if li >= 0 { return loc_ty[li] } }
|
||||
return ptr_null()
|
||||
}
|
||||
|
||||
# @OnSpawn(Model) hooks: a Model -> hook-body registry. Populated at parse time;
|
||||
# `spawn Model { … }` runs the body with the model's properties bound (like a
|
||||
# constructor). Spawn statically knows the model, so no runtime dispatch is needed.
|
||||
var g_onspawn: []Node # each: s = Model name, a = hook body block
|
||||
|
||||
fn register_onspawn(model: ptr, body: Node) -> void {
|
||||
let n = node(N_BLOCK); n.s = model; n.a = body; push(g_onspawn, n)
|
||||
}
|
||||
fn onspawn_body(model: ptr) -> Node {
|
||||
let i = 0
|
||||
while i < len(g_onspawn) { if streq(g_onspawn[i].s, model) { return g_onspawn[i].a }; i = i + 1 }
|
||||
return ptr_null()
|
||||
}
|
||||
|
||||
# local variable environment
|
||||
fn loc_reset() -> void { nloc = 0 }
|
||||
fn loc_push(name: ptr, r: ptr, ty: ptr) -> void {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,11 @@ fn emit_expr(e: Node) -> Val {
|
|||
let ord = enum_ordinal(e.a.s, e.s)
|
||||
if ord >= 0 { return val(itoa(ord), "int") }
|
||||
}
|
||||
let bt = static_type(e.a) # `x.field` where field is @Computed -> inline it
|
||||
if not ptr_is_null(bt) {
|
||||
let cx = computed_expr(bt, e.s)
|
||||
if not ptr_is_null(cx) { return emit_expr(qualify_fields(cx, e.a)) }
|
||||
}
|
||||
let a = emit_member_addr(e); return emit_load_at(a, g_addr_ty)
|
||||
}
|
||||
if e.kind == E_INDEX { let a = emit_index_addr(e); return emit_load_at(a, g_addr_ty) }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,22 @@ fn emit_init_component(e: ptr, comp: ptr, rec: Node) -> void {
|
|||
}
|
||||
}
|
||||
|
||||
# bind each of a model's properties to entity `e`'s component storage, so an
|
||||
# @OnSpawn hook body can address them by name (like a query binding for one entity).
|
||||
fn emit_bind_props(model: Node, e: ptr) -> void {
|
||||
let me = itoa(MAX_ENT)
|
||||
let c = 0
|
||||
while c < len(model.kids) {
|
||||
let pname = model.kids[c].s
|
||||
let slot = nreg()
|
||||
emit(" "); emit(slot); emit(" = getelementptr inbounds ["); emit(me); emit(" x %Cmp_"); emit(pname); emit("], ptr @S_"); emit(pname); emit(", i32 0, i32 "); emit(e); emit("\n")
|
||||
let vslot = emit_alloca("ptr")
|
||||
emit(" store ptr "); emit(slot); emit(", ptr "); emit(vslot); emit("\n")
|
||||
loc_push(pname, vslot, pname)
|
||||
c = c + 1
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_spawn(st: Node) -> void {
|
||||
let e = emit_bind("call i32 @L_alloc()")
|
||||
let ak = find_arch_id(st.s)
|
||||
|
|
@ -56,6 +72,13 @@ fn emit_spawn(st: Node) -> void {
|
|||
emit_init_component(e, cn, rec)
|
||||
c = c + 1
|
||||
}
|
||||
let ob = onspawn_body(st.s) # @OnSpawn(Model) hook runs after init
|
||||
if not ptr_is_null(ob) {
|
||||
let save = nloc
|
||||
emit_bind_props(arch, e)
|
||||
emit_block(ob)
|
||||
nloc = save
|
||||
}
|
||||
} else {
|
||||
let i = 0
|
||||
while i < len(st.kids) { emit_init_component(e, st.kids[i].s, st.kids[i].a); i = i + 1 }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -282,12 +282,14 @@ fn already_loaded(full: ptr) -> bool {
|
|||
fn parse_one_decl() -> void {
|
||||
let is_export = false
|
||||
let qspec: Node = ptr_null()
|
||||
let onspawn_model: ptr = ptr_null()
|
||||
while is_op("@") {
|
||||
pi = pi + 1; let a = eat_id() # collect a leading @annotation
|
||||
if streq(a, "export") { is_export = true }
|
||||
if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
|
||||
else { if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
|
||||
else { if streq(a, "OnSpawn") { eat_op("("); onspawn_model = eat_id(); eat_op(")") } # @OnSpawn(Model)
|
||||
else { if is_op("(") { let d = 0 # any other @anno(args) — parsed and skipped
|
||||
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } }
|
||||
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } }
|
||||
skipnl()
|
||||
}
|
||||
if is_id("import") { pi = pi + 1
|
||||
|
|
@ -303,6 +305,9 @@ fn parse_one_decl() -> void {
|
|||
if is_id("model") { push(prog, parse_archetype()); return }
|
||||
if is_id("handler") {
|
||||
let h = parse_system()
|
||||
if not ptr_is_null(onspawn_model) { # @OnSpawn(Model): a spawn hook, not a phased handler
|
||||
register_onspawn(onspawn_model, h.a); return
|
||||
}
|
||||
if not ptr_is_null(qspec) { # @Queries wraps the body in its S_QUERY
|
||||
qspec.a = h.a
|
||||
let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap
|
||||
|
|
@ -346,6 +351,8 @@ fn maybe_splice_runtime() -> void {
|
|||
|
||||
fn parse_program() -> void {
|
||||
prog = new []Node
|
||||
g_computed = new []Node
|
||||
g_onspawn = new []Node
|
||||
loaded_paths = new []ptr
|
||||
skipnl()
|
||||
g_game_name = "Ludic"
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@
|
|||
fn parse_component() -> Node {
|
||||
pi = pi + 1; let n = node(N_COMP); n.s = eat_id(); skipnl(); eat_op("{")
|
||||
while true { skipnl(); if is_op("}") { break }
|
||||
let is_computed = false
|
||||
if is_op("@") { pi = pi + 1; let ann = eat_id(); if streq(ann, "Computed") { is_computed = true }; skipnl() }
|
||||
let f = node(N_FIELD); f.s = eat_id(); eat_op(":"); f.ty = ptype()
|
||||
if is_op("=") { pi = pi + 1; f.a = expr() }
|
||||
push(n.kids, f); if is_op(",") { pi = pi + 1 } }
|
||||
if is_computed { register_computed(n.s, f.s, f.ty, f.a) } # derived: no storage
|
||||
else { push(n.kids, f) }
|
||||
if is_op(",") { pi = pi + 1 } }
|
||||
eat_op("}"); return n
|
||||
}
|
||||
|
||||
|
|
@ -95,15 +99,21 @@ fn mk_and(a: Node, b: Node) -> Node {
|
|||
let n = node(E_BIN); n.s = "and"; n.a = a; n.b = b; return n
|
||||
}
|
||||
|
||||
# In `Prop{scale > 0}` the bare names are fields of Prop; qualify each to
|
||||
# `Prop.field` (the binding var is the property name) for the desugared where.
|
||||
fn qualify_fields(e: Node, prop: ptr) -> Node {
|
||||
# Rewrite each bare identifier in `e` as `base.field` — used both by
|
||||
# `Prop{constraint}` (base is the property binding) and by @Computed field
|
||||
# expansion (base is the accessed value). Non-destructive: builds a fresh tree,
|
||||
# so a stored computed expression can be expanded at many access sites.
|
||||
fn qualify_fields(e: Node, base: Node) -> Node {
|
||||
if ptr_is_null(e) { return e }
|
||||
if e.kind == E_ID {
|
||||
let m = node(E_MEMBER); let base = node(E_ID); base.s = prop; m.a = base; m.s = e.s; return m
|
||||
let m = node(E_MEMBER); m.a = base; m.s = e.s; return m
|
||||
}
|
||||
if e.kind == E_BIN {
|
||||
let n2 = node(E_BIN); n2.s = e.s; n2.a = qualify_fields(e.a, base); n2.b = qualify_fields(e.b, base); return n2
|
||||
}
|
||||
if e.kind == E_UN {
|
||||
let n2 = node(E_UN); n2.s = e.s; n2.a = qualify_fields(e.a, base); return n2
|
||||
}
|
||||
if e.kind == E_BIN { e.a = qualify_fields(e.a, prop); e.b = qualify_fields(e.b, prop); return e }
|
||||
if e.kind == E_UN { e.a = qualify_fields(e.a, prop); return e }
|
||||
return e
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +134,8 @@ fn parse_queries_anno() -> Node {
|
|||
let pname = eat_id()
|
||||
let v = node(E_ID); v.s = pname; push(qn.kids, v) # binding var = property name
|
||||
let t = node(E_ID); t.s = pname; t.ival = 0; push(terms.kids, t)
|
||||
if is_op("{") { pi = pi + 1; let ce = expr(); eat_op("}"); wh = mk_and(wh, qualify_fields(ce, pname)) }
|
||||
if is_op("{") { pi = pi + 1; let ce = expr(); eat_op("}")
|
||||
let cb = node(E_ID); cb.s = pname; wh = mk_and(wh, qualify_fields(ce, cb)) }
|
||||
if is_op(",") { pi = pi + 1 }
|
||||
skipnl()
|
||||
}
|
||||
|
|
|
|||
7
test.sh
7
test.sh
|
|
@ -50,10 +50,11 @@ qsmoke() { # name
|
|||
else bad "$n: $(tail -1 /tmp/qs.out)"; fi
|
||||
}
|
||||
qsmoke qdecl
|
||||
# @Queries desugars to a query loop; @Handles parses. Check the desugared behavior.
|
||||
# The annotation DSL: @Queries desugars to a query, @Computed inlines a derived
|
||||
# field, @OnSpawn runs a constructor, @Handles parses. Check the composite output.
|
||||
if ./selfhost/game-build.sh build/ludicc examples/annotations.ludic "/tmp/ludic_ann" >/tmp/ann.out 2>&1 \
|
||||
&& [ "$(/tmp/ludic_ann | tr '\n' ' ')" = "3 1 0 0 " ]; then
|
||||
ok "annotations.ludic (@Queries desugars to a query, @Handles parses)"
|
||||
&& [ "$(/tmp/ludic_ann </dev/null | tr '\n' ' ')" = "3 25 0 0 " ]; then
|
||||
ok "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)"
|
||||
else bad "annotations: $(tail -1 /tmp/ann.out)"; fi
|
||||
# --- Toolchain-agent CLI smoke tests append below this line ---
|
||||
echo "== self-hosted front-end binaries (ludicc / ludic) =="
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue