feat(stdlib): add Query.* — ECS spatial queries over the reflection ABI (#42)
Completes the half of #24 that was explicitly deferred as blocked: entity-space
queries to sit alongside the grid-space Grid.*/pathfinding that shipped in
07e5a20. Query.* answers questions about the live entities that carry a
property, built directly on the EV2 reflection ABI (world_query_next/world_get):
- Query.count(prop) -> int how many live entities carry prop
- Query.first(prop) -> int the lowest-id bearer, or -1
- Query.nearest(prop, pos, xf, yf, x, y) the bearer closest to (x,y), or -1
- Query.within(prop, pos, x, y, r, xf, yf) -> []int every bearer within r
prop is a property id (World.prop_id); the spatial forms read a position from a
coordinate property `pos` at two int field ids (World.field_id), so `prop` can be
a discriminating tag distinct from the position component ("nearest Enemy"), or
the same id to query the coordinate component itself. Distances are exact squared
integers (no sqrt), ties break to the lower entity id, and `within` returns
entities in ascending id order — so every answer is deterministic and replay-safe.
The engine (runtime/native/query.ludic, ~55 lines of Ludic, C-free) is a linear
scan over the entity table — ample for the entity counts Ludic targets, the same
reasoning as the grid pathfinder's open set; a bucketed/quadtree index is a
future optimisation, not a correctness need. It is spliced on demand when the
parser sees Query.* (g_uses_query), which also force-emits the reflection ABI so
a Query program needs no @events of its own (previously the ABI required them).
examples/library/query.ludic asserts 18 cases over five entities at known
positions (count/first with a component filter, nearest with a separate tag vs
position property, within radii incl. r=0 and the empty-property case), wired
into x test (now 63 passed). Docs: a Query section + 4 per-symbol pages,
inventory/coverage green. Seed reseeded; the C-free bootstrap fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e4d1e95dcb
commit
b25dc328a2
12 changed files with 14777 additions and 13946 deletions
76
runtime/native/query.ludic
Normal file
76
runtime/native/query.ludic
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# ============================================================================
|
||||
# query.ludic — ECS spatial queries over the reflection ABI, in Ludic.
|
||||
#
|
||||
# The Query.* namespace (see emit_call.ludic) answers questions about the live
|
||||
# entities that carry a given property: how many, the first, the nearest to a
|
||||
# point, and every one inside a radius. `prop` is a property id from
|
||||
# World.prop_id("Name"); the spatial forms read two int fields of that property
|
||||
# (field ids from World.field_id) as an (x, y) position. Everything is integer
|
||||
# and deterministic — same world + same query reproduce the same answer, entity
|
||||
# order included, every run.
|
||||
#
|
||||
# ludicc splices this file into a game when it sees Query.* (parse.ludic), and
|
||||
# force-emits the reflection ABI it stands on (emit_decl.ludic) so a game that
|
||||
# uses Query needs no @events of its own. The scan is linear over the entity
|
||||
# table — ample for the entity counts Ludic targets, exactly like the grid
|
||||
# pathfinder's open set — and allocates nothing except the one result slice
|
||||
# `within` returns. world_query_next / world_get are the bare reflection
|
||||
# builtins (they lower to the generated @ludic_query_next / @ludic_get).
|
||||
# ============================================================================
|
||||
|
||||
# how many live entities carry `prop`.
|
||||
function query_count(prop: int) -> int {
|
||||
var n = 0
|
||||
var e = world_query_next(prop, 0)
|
||||
while e >= 0 {
|
||||
n = n + 1
|
||||
e = world_query_next(prop, e + 1)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
# the first (lowest-id) live entity carrying `prop`, or -1 if none.
|
||||
function query_first(prop: int) -> int {
|
||||
return world_query_next(prop, 0)
|
||||
}
|
||||
|
||||
# the entity carrying `prop` whose position is closest to (px, py) by squared
|
||||
# distance, or -1 if none. Ties go to the lower entity id. The position is read
|
||||
# from `pos` — the property that holds the coordinates — at its (x_field,
|
||||
# y_field); pass the same id for `prop` and `pos` to query by the position
|
||||
# component itself, or a separate tag for `prop` (e.g. "nearest Enemy").
|
||||
function query_nearest(prop: int, pos: int, x_field: int, y_field: int, px: int, py: int) -> int {
|
||||
var best = 0 - 1
|
||||
var bestd = 0
|
||||
var e = world_query_next(prop, 0)
|
||||
while e >= 0 {
|
||||
let dx = world_get(e, pos, x_field) - px
|
||||
let dy = world_get(e, pos, y_field) - py
|
||||
let d = dx * dx + dy * dy
|
||||
if best < 0 or d < bestd {
|
||||
best = e
|
||||
bestd = d
|
||||
}
|
||||
e = world_query_next(prop, e + 1)
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
# every live entity carrying `prop` whose position is within `radius` of
|
||||
# (px, py), in ascending entity-id order. Returns a []int of entity ids — index
|
||||
# it with len / [i]. The boundary is inclusive (distance <= radius). Position is
|
||||
# read from `pos` at (x_field, y_field), as in query_nearest.
|
||||
function query_within(prop: int, pos: int, px: int, py: int, radius: int, x_field: int, y_field: int) -> []int {
|
||||
let out = new []int
|
||||
let r2 = radius * radius
|
||||
var e = world_query_next(prop, 0)
|
||||
while e >= 0 {
|
||||
let dx = world_get(e, pos, x_field) - px
|
||||
let dy = world_get(e, pos, y_field) - py
|
||||
if dx * dx + dy * dy <= r2 {
|
||||
push(out, e)
|
||||
}
|
||||
e = world_query_next(prop, e + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue