diff --git a/packages/README.md b/packages/README.md index c49c7df2..fe0c5770 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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) | diff --git a/packages/ludic.compass/README.md b/packages/ludic.compass/README.md new file mode 100644 index 00000000..5efb82e7 --- /dev/null +++ b/packages/ludic.compass/README.md @@ -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. diff --git a/packages/ludic.compass/bearing.ludic b/packages/ludic.compass/bearing.ludic new file mode 100644 index 00000000..9fd60caa --- /dev/null +++ b/packages/ludic.compass/bearing.ludic @@ -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 +} diff --git a/packages/ludic.compass/gather.ludic b/packages/ludic.compass/gather.ludic new file mode 100644 index 00000000..041870ae --- /dev/null +++ b/packages/ludic.compass/gather.ludic @@ -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 } diff --git a/packages/ludic.compass/index.ludic b/packages/ludic.compass/index.ludic new file mode 100644 index 00000000..c016bdd9 --- /dev/null +++ b/packages/ludic.compass/index.ludic @@ -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" diff --git a/packages/ludic.compass/marks.ludic b/packages/ludic.compass/marks.ludic new file mode 100644 index 00000000..855b2922 --- /dev/null +++ b/packages/ludic.compass/marks.ludic @@ -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 +} diff --git a/packages/ludic.compass/package.ludic b/packages/ludic.compass/package.ludic new file mode 100644 index 00000000..ccdead75 --- /dev/null +++ b/packages/ludic.compass/package.ludic @@ -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 diff --git a/packages/ludic.compass/ports.ludic b/packages/ludic.compass/ports.ludic new file mode 100644 index 00000000..a397bd52 --- /dev/null +++ b/packages/ludic.compass/ports.ludic @@ -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 diff --git a/packages/ludic.compass/radar.ludic b/packages/ludic.compass/radar.ludic new file mode 100644 index 00000000..f830a8b9 --- /dev/null +++ b/packages/ludic.compass/radar.ludic @@ -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) +} diff --git a/packages/ludic.compass/tests/compass_test.ludic b/packages/ludic.compass/tests/compass_test.ludic new file mode 100644 index 00000000..fbcda331 --- /dev/null +++ b/packages/ludic.compass/tests/compass_test.ludic @@ -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) + } +}