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
7
docs/language/query/_section.md
Normal file
7
docs/language/query/_section.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: query
|
||||
title: Query
|
||||
order: 30
|
||||
---
|
||||
|
||||
Spatial and set queries over the live entities that carry a property, built on the <a href="world"><code>World</code></a> reflection ABI. <a href="query-count"><code>Query.count</code></a> and <a href="query-first"><code>Query.first</code></a> ask how many bearers there are and which is first; <a href="query-nearest"><code>Query.nearest</code></a> and <a href="query-within"><code>Query.within</code></a> add a position — read from a coordinate property's two int fields — to find the closest entity to a point or every entity inside a radius. A property id comes from <a href="world-prop_id"><code>World.prop_id</code></a> and a field id from <a href="world-field_id"><code>World.field_id</code></a>. Everything is integer and deterministic: the same world reproduces the same answers, entity order included, every run. The scan is linear over the entity table — ample for the entity counts Ludic targets — and a game that uses <code>Query.*</code> gets the reflection ABI emitted automatically, no <code>@event</code> required.
|
||||
30
docs/language/query/query-count.md
Normal file
30
docs/language/query/query-count.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
id: query-count
|
||||
name: Query.count
|
||||
category: query
|
||||
kind: namespace-method
|
||||
tokens: Query.count
|
||||
sig: Query.count(prop) -> int
|
||||
tip: How many live entities carry a property.
|
||||
order: 1
|
||||
ns: Query
|
||||
member: count
|
||||
---
|
||||
|
||||
Returns the number of live entities that carry the property <code>prop</code> (a property id from <a href="world-prop_id"><code>World.prop_id</code></a>). A quick population count — how many enemies are alive, how many pickups remain — without writing a query loop.
|
||||
|
||||
Parameters:
|
||||
- `prop` — a property id (from `World.prop_id("Name")`)
|
||||
|
||||
```ludic
|
||||
program Demo {
|
||||
property Enemy { hp: int = 0 }
|
||||
model Slime { Enemy }
|
||||
entry {
|
||||
spawn Slime { Enemy { hp: 3 } }
|
||||
spawn Slime { Enemy { hp: 3 } }
|
||||
let E = World.prop_id("Enemy")
|
||||
print(Query.count(E)) # 2
|
||||
}
|
||||
}
|
||||
```
|
||||
31
docs/language/query/query-first.md
Normal file
31
docs/language/query/query-first.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: query-first
|
||||
name: Query.first
|
||||
category: query
|
||||
kind: namespace-method
|
||||
tokens: Query.first
|
||||
sig: Query.first(prop) -> int
|
||||
tip: The lowest-id live entity carrying a property, or -1.
|
||||
order: 2
|
||||
ns: Query
|
||||
member: first
|
||||
---
|
||||
|
||||
Returns the first (lowest entity id) live entity that carries <code>prop</code>, or <code>-1</code> if none do. Handy for a singleton-ish lookup — the player, the current boss, the one active portal — and to test existence (<code>Query.first(prop) >= 0</code>). Read its fields with <a href="world-get"><code>World.get</code></a>.
|
||||
|
||||
Parameters:
|
||||
- `prop` — a property id (from `World.prop_id("Name")`)
|
||||
|
||||
```ludic
|
||||
program Demo {
|
||||
property Player { hp: int = 0 }
|
||||
model Hero { Player }
|
||||
entry {
|
||||
spawn Hero { Player { hp: 100 } }
|
||||
let P = World.prop_id("Player")
|
||||
let hp = World.field_id(P, "hp")
|
||||
let e = Query.first(P)
|
||||
if e >= 0 { print(World.get(e, P, hp)) } # 100
|
||||
}
|
||||
}
|
||||
```
|
||||
38
docs/language/query/query-nearest.md
Normal file
38
docs/language/query/query-nearest.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
id: query-nearest
|
||||
name: Query.nearest
|
||||
category: query
|
||||
kind: namespace-method
|
||||
tokens: Query.nearest
|
||||
sig: Query.nearest(prop, pos, x_field, y_field, x, y) -> int
|
||||
tip: The bearer of prop closest to a point, by squared distance.
|
||||
order: 3
|
||||
ns: Query
|
||||
member: nearest
|
||||
---
|
||||
|
||||
Returns the live entity carrying <code>prop</code> whose position is closest to <code>(x, y)</code>, or <code>-1</code> if none. Position comes from property <code>pos</code> at its integer fields <code>x_field</code> and <code>y_field</code> — pass the same id for <code>prop</code> and <code>pos</code> to query the coordinate component itself, or a separate tag for <code>prop</code> to mean "nearest <em>Enemy</em>". Distance is compared by exact squared integer distance (no square root), and ties go to the lower entity id, so the result is fully deterministic. The go-to for target acquisition, "interact with the closest thing", or nearest-neighbour AI.
|
||||
|
||||
Parameters:
|
||||
- `prop` — the property that filters which entities are considered
|
||||
- `pos` — the property holding the coordinates
|
||||
- `x_field`, `y_field` — field ids of the x / y coordinate within `pos`
|
||||
- `x`, `y` — the point to measure from
|
||||
|
||||
```ludic
|
||||
program Demo {
|
||||
property Position { col: int = 0, row: int = 0 }
|
||||
property Enemy { hp: int = 0 }
|
||||
model Slime { Position, Enemy }
|
||||
entry {
|
||||
spawn Slime { Position { col: 2, row: 0 }, Enemy { hp: 3 } }
|
||||
spawn Slime { Position { col: 9, row: 0 }, Enemy { hp: 3 } }
|
||||
let E = World.prop_id("Enemy")
|
||||
let P = World.prop_id("Position")
|
||||
let cx = World.field_id(P, "col")
|
||||
let cy = World.field_id(P, "row")
|
||||
let e = Query.nearest(E, P, cx, cy, 0, 0) # the enemy at col 2
|
||||
print(World.get(e, P, cx)) # 2
|
||||
}
|
||||
}
|
||||
```
|
||||
39
docs/language/query/query-within.md
Normal file
39
docs/language/query/query-within.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: query-within
|
||||
name: Query.within
|
||||
category: query
|
||||
kind: namespace-method
|
||||
tokens: Query.within
|
||||
sig: Query.within(prop, pos, x, y, radius, x_field, y_field) -> []int
|
||||
tip: Every bearer of prop inside a radius of a point.
|
||||
order: 4
|
||||
ns: Query
|
||||
member: within
|
||||
---
|
||||
|
||||
Returns the live entities carrying <code>prop</code> whose position is within <code>radius</code> of <code>(x, y)</code>, as a <code>[]int</code> of entity ids in ascending id order — index it with <code>len</code> / <code>[i]</code>. Position is read from property <code>pos</code> at <code>x_field</code>/<code>y_field</code>, exactly as in <a href="query-nearest"><code>Query.nearest</code></a>. The boundary is inclusive (distance <code><= radius</code>), measured by exact integer distance. Use it for area-of-effect damage, proximity triggers, flocking neighbours, or "what's near the cursor".
|
||||
|
||||
Parameters:
|
||||
- `prop` — the property that filters which entities are considered
|
||||
- `pos` — the property holding the coordinates
|
||||
- `x`, `y` — the centre of the query
|
||||
- `radius` — the inclusive radius
|
||||
- `x_field`, `y_field` — field ids of the x / y coordinate within `pos`
|
||||
|
||||
```ludic
|
||||
program Demo {
|
||||
property Position { col: int = 0, row: int = 0 }
|
||||
property Unit { hp: int = 0 }
|
||||
model Soldier { Position, Unit }
|
||||
entry {
|
||||
spawn Soldier { Position { col: 1, row: 1 }, Unit { hp: 5 } }
|
||||
spawn Soldier { Position { col: 8, row: 8 }, Unit { hp: 5 } }
|
||||
let U = World.prop_id("Unit")
|
||||
let P = World.prop_id("Position")
|
||||
let cx = World.field_id(P, "col")
|
||||
let cy = World.field_id(P, "row")
|
||||
let hit = Query.within(U, P, 0, 0, 3, cx, cy) # just the (1,1) soldier
|
||||
print(len(hit)) # 1
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue