feat(packages): ludic.compass - bearing marks on a strip: marks gathered once a frame from an open registry of providers (CompassProviders), gated by the viewer's tier, sorted tracked / note / plain and culled to a cap; bearings and distances off the viewer's facing, the tracked mark, a capture that lets a guide hear the providers whatever the tier, the eight points, and the radar's range per tier with blips on a unit disc; the viewer through a port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 09:35:52 +03:00
parent dc71fc986c
commit 1005a677f2
10 changed files with 453 additions and 0 deletions

View file

@ -17,6 +17,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.anim](ludic.anim/README.md) | glTF animation clips sampled and cross-faded onto a render3d Skin (uses `ludic_render3d`) |
| [ludic.character](ludic.character/README.md) | a third-person walking body: gait, steps and slopes, a jump, wading, swimming, sitting, a safe put-down, the orbit camera |
| [ludic.audio](ludic.audio/README.md) | world sounds with a gain, pan and pitch from a listener port, flat interface sounds, packed clips loaded, the one door to `Audio.*` |
| [ludic.compass](ludic.compass/README.md) | bearing marks on a strip: gathered from registered providers, gated by tier, sorted by priority, bearings, distances and a radar |
| [ludic.clock](ludic.clock/README.md) | the hour, the day, the moon and a calendar of seasons |
| [ludic.crafting](ludic.crafting/README.md) | recipes: what goes in and out, where and how long, making, refunding, hold-to-make |
| [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) |

View file

@ -0,0 +1,84 @@
# ludic.compass
Bearing markers on a strip: where things are from where you stand. Each frame the package asks
every provider the game registered, keeps the marks the viewer's compass is good enough to show,
sorts them by priority and culls them to a cap, and answers each one's bearing and distance - for a
strip across the top of the screen, a radar in a corner, a map, or a guide's arrow. Uses
[`ludic.base`](../ludic.base/README.md) and nothing else.
```ludic
import "ludic.compass"
```
The package never draws and never names a picture. A mark's `icon` and `colour` are the game's
keys (an index into its icon sheet and its palette), and its `label` is the game's words.
## A mark
| | |
| --- | --- |
| `x`, `z` | where it is in the world |
| `icon`, `colour` | the game's keys for its picture and its colour |
| `tier` | the least compass that shows it: the viewer's `CompassWorld.tier()` below it, the mark is dropped |
| `prio` | `COMPASS_TRACKED` (the objective: its distance is shown, and it stays at the strip's edge when off it), `COMPASS_NOTE` (one the player asked for), `COMPASS_PLAIN` |
| `label` | its name |
## Providers
A provider is a function that calls `compass_mark` for each thing it knows about. The game
registers them in the open registry, and `compass_gather()` asks them in the registry's order:
```ludic
def CompassProviders places { gather: fn cp_places }
def CompassProviders story { gather: fn story_marks }
```
Then the marks are sorted - tracked first, then notes, then the rest, each band in the order the
providers gave them - and culled to `compass_config(cap)`. A viewer with tier 0 gathers nothing.
A **capture** lets something else hear where the providers point without touching the frame's
list, whatever the tier (a guide that points at the objective before a compass is bought):
```ludic
compass_capture_begin()
story_marks()
let marks = compass_capture_end()
```
## The port
```ludic
export port CompassWorld { # every member has a default: at the origin facing -z, every tier
x: fn() -> float
z: fn() -> float
yaw: fn() -> float # radians; 0 faces -z, and +yaw turns toward -x
tier: fn() -> int # the compass carried; 0 none
}
```
## API
| | |
| --- | --- |
| `compass_config(cap)` | how many marks a frame keeps |
| `compass_gather()` | once a frame: begin, every provider, end |
| `compass_begin()`, `compass_mark(x, z, icon, colour, tier, prio, label)`, `compass_end()` | the frame by hand (a test, one provider asked alone) |
| `compass_capture_begin()`, `compass_capture_end() -> []CompassMark`, `compass_capturing()` | the providers heard by someone else |
| `compass_count()`, `compass_at(i) -> CompassMark`, `compass_tracked()` | the frame's marks; the first tracked one or -1 |
| `compass_bearing(i)`, `compass_dist(i)`, `compass_bearing_to(x, z)`, `compass_dist_to(x, z)` | from the viewer: a bearing in -pi .. pi off its facing, a distance in metres |
| `compass_heading(dx, dz)`, `compass_rel(heading)`, `compass_wrap(a)`, `compass_strip_t(bearing, half)` | the arithmetic: a world heading, as the strip sees it, wrapped, and across a strip showing `half` either side (-1 .. 1 on it) |
| `compass_octant(dx, dz)` | which of eight points an offset lies toward, 0 at heading 0 and going toward -x (N, NW, W ...) |
| `compass_config_radar(ranges, from_tier)` | the radar's range per compass tier (0 none; past the table the last) and the least mark tier it shows (the tracked mark always) |
| `compass_radar_on()`, `compass_radar_range()`, `compass_radar_has(i)`, `compass_radar_x(i)`, `compass_radar_y(i)` | the radar: carried, how far, whether a mark is a blip, and where on a unit disc (x right, y down, ahead is up) |
There is no save: the marks are the providers' state, asked again every frame.
## Tests
```bash
ludic test packages/ludic.compass
```
A viewer and two providers: the order and the bands, the tier gate and no compass, the cap,
bearings and distances as the viewer turns and moves, a capture, the radar's tiers and blips, and
the eight points.

View file

@ -0,0 +1,61 @@
# bearing.ludic - the frame's marks as the strip reads them: how many, each one, where it bears
# from the viewer's facing and how far it is. A heading is the world's (0 toward -z, a quarter
# turn toward -x), a bearing is the heading less the viewer's yaw, in -pi .. pi.
export function compass_count() -> int {
if cmp_list == null { return 0 }
return len(cmp_list)
}
# the i-th mark, or an empty one past the end
export function compass_at(i: int) -> CompassMark {
if i < 0 or i >= compass_count() { return new CompassMark }
return cmp_list[i]
}
export function compass_wrap(a: float) -> float {
var r = a
let turn = 2.0 * CMP_PI
while r > CMP_PI { r -= turn }
while r < -CMP_PI { r += turn }
return r
}
export function compass_heading(dx: float, dz: float) -> float { return Math.atan2(-dx, -dz) }
# a world heading as the strip sees it
export function compass_rel(heading: float) -> float { return compass_wrap(heading - CompassWorld.yaw()) }
export function compass_bearing_to(x: float, z: float) -> float { return compass_rel(compass_heading(x - CompassWorld.x(), z - CompassWorld.z())) }
export function compass_dist_to(x: float, z: float) -> float {
let dx = x - CompassWorld.x()
let dz = z - CompassWorld.z()
return Math.sqrt(dx * dx + dz * dz)
}
export function compass_bearing(i: int) -> float {
let m = compass_at(i)
return compass_bearing_to(m.x, m.z)
}
export function compass_dist(i: int) -> float {
let m = compass_at(i)
return compass_dist_to(m.x, m.z)
}
# where a bearing falls across a strip showing `half` radians either side: -1 .. 1, or past it
export function compass_strip_t(bearing: float, half: float) -> float { return bearing / Math.max(half, 0.001) }
# the objective: the first tracked mark of the frame, or -1
export function compass_tracked() -> int {
for i in 0 .. compass_count() { if cmp_list[i].prio >= COMPASS_TRACKED { return i } }
return -1
}
# which of eight points an offset lies toward: 0 the heading 0, then a quarter turn in two steps
# at a time toward -x (for a world where 0 is north: N, NW, W, SW, S, SE, E, NE)
export function compass_octant(dx: float, dz: float) -> int {
var deg = compass_heading(dx, dz) * 180.0 / CMP_PI
if deg < 0.0 { deg += 360.0 }
return int(Math.floor(deg / 45.0 + 0.5)) % 8
}

View file

@ -0,0 +1,56 @@
# gather.ludic - a frame's marks: begin, every provider marks, end sorts and culls. While a
# capture is open the marks go to whoever is listening instead, whatever the tier.
export function compass_begin() -> void { cmp_list = new []CompassMark }
# a provider's verb: a thing at (x, z), shown from compass `tier` up
export function compass_mark(x: float, z: float, icon: int, colour: int, tier: int, prio: int, label: string) -> void {
if cmp_list == null { compass_begin() }
let m = cmp_new(x, z, icon, colour, tier, prio, label)
if cmp_capturing {
push(cmp_caught, m)
return
}
if CompassWorld.tier() < tier { return }
push(cmp_list, m)
}
# highest priority first, the providers' order kept within one; then the cap
export function compass_end() -> void {
if cmp_list == null { compass_begin() }
let out = new []CompassMark
for p in 0 .. 3 {
let want = COMPASS_TRACKED - p
for i in 0 .. len(cmp_list) { if cmp_band(cmp_list[i].prio) == want and len(out) < cmp_cap { push(out, cmp_list[i]) } }
}
cmp_list = out
}
function cmp_band(p: int) -> int { return Math.clamp(p, COMPASS_PLAIN, COMPASS_TRACKED) }
# once a frame: ask every registered provider, in the registry's order
export function compass_gather() -> void {
compass_begin()
if CompassWorld.tier() <= 0 { return }
for i in 0 .. COMPASS_FROM_COUNT {
let f = CompassProviders[i].gather
if f != null { f() }
}
compass_end()
}
# someone else wants to know where the providers point (a guide's arrow): until the end every
# mark is theirs, whatever the tier, and the frame's list is left alone
export function compass_capture_begin() -> void {
cmp_capturing = true
cmp_caught = new []CompassMark
}
export function compass_capture_end() -> []CompassMark {
cmp_capturing = false
var got = cmp_caught
cmp_caught = new []CompassMark
if got == null { got = new []CompassMark }
return got
}
export function compass_capturing() -> bool { return cmp_capturing }

View file

@ -0,0 +1,11 @@
# ludic.compass - where things are from where you stand: marks gathered once a frame from an open
# registry of providers, gated by a tier and sorted by priority, turned into bearings for a strip
# and blips for a radar. The game draws both.
module ludic_compass uses ludic_base
numbers float
import "ludic.base"
import "ports.ludic"
import "marks.ludic"
import "gather.ludic"
import "bearing.ludic"
import "radar.ludic"

View file

@ -0,0 +1,46 @@
# marks.ludic - a mark, the providers the game registers, and the frame's list
export const COMPASS_PLAIN: int = 0 # priorities: an ordinary mark
export const COMPASS_NOTE: int = 1 # one the player asked for (a mark of their own)
export const COMPASS_TRACKED: int = 2 # the objective: its distance is shown, at the edge too
export property CompassMark {
x: float = 0.0
z: float = 0.0
icon: int = 0 # the game's key for its picture
colour: int = 0 # the game's key for its colour
tier: int = 1 # the least compass that shows it
prio: int = 0
label: string = ""
}
# a provider says where its things are by calling compass_mark; the game fills the registry
# (`def CompassProviders story { gather: fn story_marks }`) and they are asked in its order
export property CompassProvider {
key: string = ""
gather: fn() -> void = null
}
export open registry CompassProviders of CompassProvider as COMPASS_FROM
var cmp_cap: int = 64
var cmp_list: []CompassMark = null # this frame's, sorted and culled
var cmp_capturing: bool = false
var cmp_caught: []CompassMark = null # what providers said while someone was listening
# how many marks a frame keeps (the lowest priorities go first past it)
export function compass_config(cap: int) -> void {
cmp_cap = Math.max(cap, 1)
compass_begin()
}
function cmp_new(x: float, z: float, icon: int, colour: int, tier: int, prio: int, label: string) -> CompassMark {
let m = new CompassMark
m.x = x
m.z = z
m.icon = icon
m.colour = colour
m.tier = tier
m.prio = prio
m.label = label
return m
}

View file

@ -0,0 +1,6 @@
# ludic.compass - bearing markers on a strip: marks gathered each frame from the game's providers,
# gated by the viewer's tier, sorted and culled by priority, as bearings and distances, and the
# radar's tiers. Uses ludic.base and nothing else. See README.md.
package "ludic.compass"
version "0.1.0"
kind source

View file

@ -0,0 +1,14 @@
# ports.ludic - what the compass asks the game: where the viewer stands, which way the strip
# faces and which compass is in the pack. Unbound, it stands at the origin facing -z (yaw 0) and
# carries every tier.
export port CompassWorld {
x: fn() -> float = fn cmp__zero
z: fn() -> float = fn cmp__zero
yaw: fn() -> float = fn cmp__zero # radians; 0 faces -z, and +yaw turns toward -x
tier: fn() -> int = fn cmp__every # 0 no compass: nothing at all is gathered
}
function cmp__zero() -> float { return 0.0 }
function cmp__every() -> int { return 1000 }
const CMP_PI: float = 3.14159265

View file

@ -0,0 +1,40 @@
# radar.ludic - the radar's tiers: a range for each compass tier (0 is no radar), which marks it
# shows, and where each falls on a unit disc with ahead at the top
var cmp_ranges: []float = null
var cmp_radar_from: int = 2 # marks of at least this tier are blips; the tracked one always is
export function compass_config_radar(ranges: []float, from_tier: int) -> void {
cmp_ranges = new []float
for i in 0 .. len(ranges) { push(cmp_ranges, ranges[i]) }
cmp_radar_from = from_tier
}
# how far the radar reaches with the viewer's compass, 0 without one; a tier past the table has
# the last range
export function compass_radar_range() -> float {
if cmp_ranges == null or len(cmp_ranges) == 0 { return 0.0 }
let t = CompassWorld.tier()
if t < 0 { return 0.0 }
if t >= len(cmp_ranges) { return cmp_ranges[len(cmp_ranges) - 1] }
return cmp_ranges[t]
}
export function compass_radar_on() -> bool { return compass_radar_range() > 0.0 }
# the i-th mark is a blip: a radar is carried, it is shown there and it is in range
export function compass_radar_has(i: int) -> bool {
if i < 0 or i >= compass_count() or not compass_radar_on() { return false }
let m = cmp_list[i]
if m.tier < cmp_radar_from and m.prio < COMPASS_TRACKED { return false }
return compass_dist(i) <= compass_radar_range()
}
# where on a unit disc: x right, y down, so ahead is (0, -1) and the edge is the range
export function compass_radar_x(i: int) -> float { return -Math.sin(compass_bearing(i)) * cmp_radar_frac(i) }
export function compass_radar_y(i: int) -> float { return -Math.cos(compass_bearing(i)) * cmp_radar_frac(i) }
function cmp_radar_frac(i: int) -> float {
let r = compass_radar_range()
if r <= 0.0 { return 0.0 }
return Math.min(compass_dist(i) / r, 1.0)
}

View file

@ -0,0 +1,134 @@
# compass_test.ludic - a viewer at (vx, vz) facing yaw with a compass of `tier`, and two providers:
# the places (a camp ahead, a sign west, a far legend) and the objective. The tier gate, the order
# and the cap, bearings and distances, the tracked mark, a capture, the radar's tiers, the points.
import "ludic.compass"
import "ludic.base"
program CompassTest {
numbers float
var vx: float = 0.0
var vz: float = 0.0
var yaw: float = 0.0
var tier: int = 4
var objective: bool = true
function fk_x() -> float { return vx }
function fk_z() -> float { return vz }
function fk_yaw() -> float { return yaw }
function fk_tier() -> int { return tier }
bind CompassWorld { x: fn fk_x, z: fn fk_z, yaw: fn fk_yaw, tier: fn fk_tier }
function places() -> void {
compass_mark(0.0, -100.0, 1, 10, 1, COMPASS_PLAIN, "camp")
compass_mark(-50.0, 0.0, 2, 11, 2, COMPASS_PLAIN, "sign")
compass_mark(0.0, 300.0, 3, 12, 4, COMPASS_PLAIN, "legend")
compass_mark(10.0, 10.0, 4, 13, 1, COMPASS_NOTE, "my mark")
}
function goal() -> void { if objective { compass_mark(80.0, 0.0, 5, 14, 2, COMPASS_TRACKED, "the dock") } }
def CompassProviders places { gather: fn places }
def CompassProviders goal { gather: fn goal }
function fresh() -> void {
compass_config(64)
compass_config_radar([0.0, 0.0, 0.0, 120.0, 260.0], 2)
vx = 0.0
vz = 0.0
yaw = 0.0
tier = 4
objective = true
}
function labels() -> string {
var s = ""
for i in 0 .. compass_count() { s = s + compass_at(i).label + ";" }
return s
}
test "every provider is asked, the tracked mark first, then the player's, then the rest in order" {
fresh()
compass_gather()
expect_eq(compass_count(), 5)
expect(labels() == "the dock;my mark;camp;sign;legend;")
expect_eq(compass_tracked(), 0)
}
test "a mark shows from its tier up, and without a compass nothing is gathered" {
fresh()
tier = 1
compass_gather()
expect(labels() == "my mark;camp;")
expect_eq(compass_tracked(), -1)
tier = 0
compass_gather()
expect_eq(compass_count(), 0)
}
test "past the cap the lowest priorities go" {
fresh()
compass_config(2)
compass_gather()
expect(labels() == "the dock;my mark;")
}
test "a bearing is from the viewer's facing, a distance from where it stands" {
fresh()
compass_gather()
expect_near(compass_bearing(2), 0.0, 0.001)
expect_near(compass_dist(2), 100.0, 0.01)
expect_near(compass_bearing(3), 1.5708, 0.001)
expect_near(compass_bearing(0), -1.5708, 0.001)
yaw = 1.5708
expect_near(compass_bearing(3), 0.0, 0.001)
expect_near(compass_bearing(2), -1.5708, 0.001)
vx = -50.0
vz = 100.0
expect_near(compass_dist(3), 100.0, 0.01)
expect_near(compass_wrap(7.0), 7.0 - 6.2832, 0.001)
expect_near(compass_strip_t(0.5, 1.0), 0.5, 0.001)
}
test "a capture hears every mark whatever the tier and leaves the frame's list alone" {
fresh()
compass_gather()
tier = 1
compass_capture_begin()
expect(compass_capturing())
goal()
places()
let got = compass_capture_end()
expect(not compass_capturing())
expect_eq(len(got), 5)
expect(got[0].label == "the dock")
expect_eq(compass_count(), 5)
}
test "the radar: its range by tier, the blips in it, and where each falls on the disc" {
fresh()
tier = 2
compass_gather()
expect(not compass_radar_on())
expect(not compass_radar_has(0))
tier = 3
compass_gather()
expect(compass_radar_on())
expect_near(compass_radar_range(), 120.0, 0.01)
expect(compass_radar_has(0))
expect(not compass_radar_has(1))
expect(not compass_radar_has(2))
expect(compass_radar_has(3))
expect_near(compass_radar_x(3), -50.0 / 120.0, 0.001)
expect_near(compass_radar_y(3), 0.0, 0.001)
expect_near(compass_radar_x(0), 80.0 / 120.0, 0.001)
tier = 9
expect_near(compass_radar_range(), 260.0, 0.01)
}
test "the eight points go round from the heading 0 toward -x" {
expect_eq(compass_octant(0.0, -10.0), 0)
expect_eq(compass_octant(-10.0, -10.0), 1)
expect_eq(compass_octant(-10.0, 0.0), 2)
expect_eq(compass_octant(0.0, 10.0), 4)
expect_eq(compass_octant(10.0, 0.0), 6)
expect_eq(compass_octant(10.0, -10.0), 7)
expect_eq(compass_octant(1.0, -10.0), 0)
}
}