diff --git a/packages/README.md b/packages/README.md index 158216c5..c49c7df2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,6 +13,7 @@ section. The rules are in [ludic.base](ludic.base/README.md). | | | | --- | --- | | [ludic.base](ludic.base/README.md) | the shared vocabulary: Tick and phases, `Queue`, rng streams, the save tree, the system runner (`Systems`, an open registry) | +| [ludic.aim](ludic.aim/README.md) | what the crosshair is on: a forgiving ray against cylinders, the ground in front, reach floors, which thing the use key means | | [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.*` | diff --git a/packages/ludic.aim/README.md b/packages/ludic.aim/README.md new file mode 100644 index 00000000..685c2d55 --- /dev/null +++ b/packages/ludic.aim/README.md @@ -0,0 +1,60 @@ +# ludic.aim + +What the crosshair is on, and which thing the use key means. The crosshair is the exact middle of +the screen and the projection is symmetric, so the ray through it is the camera's position along its +forward - no unprojection. Each candidate is an upright cylinder widened by a fixed angular +forgiveness, and the pick is the nearest one the ray enters that the ground does not hide. Uses +[`ludic.base`](../ludic.base/README.md) and nothing else. + +```ludic +import "ludic.aim" +``` + +The package keeps no list of things: the game offers every candidate each frame (`aim_offer`), keeps +the object behind the id it gave, and draws the rim and the prompt. + +## The rule it keeps + +**The crosshair decides WHICH, the distance decides WHETHER** (`aim_use(kind)`): + +| | | +| --- | --- | +| under the crosshair and in reach | `AIM_USE_THIS` - that, whatever else is nearer | +| under the crosshair and too far, within `AIM_USE_MAX` (10 m) | `AIM_USE_NOTHING` - and the prompt says why | +| nothing of that kind aimed at, or aimed past 10 m | `AIM_USE_NEAREST` - proximity is right again | + +Every reach is floored at `AIM_REACH_MIN` (3 m) by `aim_reach_of(reach)`: a per-kind reach chosen +against its model was never chosen against standing between two props and looking at one. Anything +that asks "am I close enough to this" asks `aim_reach_of`. + +## The port + +```ludic +export port AimView { + x, y, z, fx, fy, fz, fov, # the camera (the ray) + ground: fn(float, float) -> float, + dry: fn(float, float) -> bool, # land, for the probe's viewpoints + body_x, body_z # distances and reach are the body's, not the camera's +} +``` + +Every member has a default: a camera 1.5 m up at the origin looking down -z over flat ground. + +## API + +| | | +| --- | --- | +| `aim_begin()`, `aim_offer(kind, id, x, y0, y1, z, r, reach, far) -> bool` | a frame's candidates: true when this one is now the pick (`reach` `AIM_NEVER` is never in reach) | +| `aim_kind()` (`AIM_NONE` for nothing), `aim_id()`, `aim_dist()`, `aim_in_reach()`, `aim_t()`, `aim_off_centre()` | the pick | +| `aim_use(kind)` -> `AIM_USE_THIS` / `_NOTHING` / `_NEAREST`, `aim_reach_of(reach)` | the use key | +| `aim_ray_cyl(cx, y0, y1, cz, r)`, `aim_slack_m(dist)`, `aim_clear_to(x, y, z)`, `aim_range(x, z)`, `aim_miss(cx, cz, r)` | the geometry, for a game's own probes | +| `aim_probe_ground(px, pz, look) -> bool`, `aim_probe_seen()`, `aim_probe_blocked()`, `aim_probe_disagree()` | from a point and a ring round it, lines at 60, 120 and 200 m on 36 bearings: the ground's own answer against `aim_clear_to`; `look(x, y, z)` puts the game's camera there | + +## Tests + +```bash +ludic test packages/ludic.aim +``` + +A camera over flat ground with a ridge: the ray against a cylinder, the forgiveness, near before +far, the ground hiding a thing, the reach floor and the three answers for the use key, and the probe. diff --git a/packages/ludic.aim/index.ludic b/packages/ludic.aim/index.ludic new file mode 100644 index 00000000..2a2a9ceb --- /dev/null +++ b/packages/ludic.aim/index.ludic @@ -0,0 +1,11 @@ +# ludic.aim - the crosshair is the exact middle of the screen, so the ray through it is the camera's +# position along its forward. Each candidate is an upright cylinder widened by a fixed angular +# forgiveness; the pick is the nearest one the ray enters that the ground does not hide. +module ludic_aim uses ludic_base +numbers float +import "ludic.base" +import "ports.ludic" +import "ray.ludic" +import "pick.ludic" +import "use.ludic" +import "probe.ludic" diff --git a/packages/ludic.aim/package.ludic b/packages/ludic.aim/package.ludic new file mode 100644 index 00000000..d4bf65d3 --- /dev/null +++ b/packages/ludic.aim/package.ludic @@ -0,0 +1,6 @@ +# ludic.aim - what the crosshair is on: a ray through the middle of the screen against upright +# cylinders with an angular forgiveness, the ground in front of them, reach floors, and which thing +# the use key means. Uses ludic.base and nothing else. See README.md. +package "ludic.aim" +version "0.1.0" +kind source diff --git a/packages/ludic.aim/pick.ludic b/packages/ludic.aim/pick.ludic new file mode 100644 index 00000000..da86bfac --- /dev/null +++ b/packages/ludic.aim/pick.ludic @@ -0,0 +1,55 @@ +# pick.ludic - one target a frame: the game offers every candidate, and the nearest one the ray enters +# that the ground does not hide is the pick. Near things occlude far ones for free, which is what a +# player means by "I am looking at that one". +export const AIM_NONE: int = 0 +export const AIM_REACH_MIN: float = 3.0 # about as far as a hand goes with a step into it +export const AIM_USE_MAX: float = 10.0 # past this the crosshair is for looking, not doing +export const AIM_NEVER: float = -1.0 # a reach that is never in reach (an animal without a camera) + +var am_kind: int = 0 +var am_id: int = -1 +var am_t: float = 10000.0 +var am_dist: float = 0.0 +var am_reach: bool = false +var am_off: float = 1.0 + +# The crosshair decides WHICH; the distance decides only WHETHER. Every reach is lifted to a floor of +# AIM_REACH_MIN, because a per-kind reach chosen against its model was never chosen against standing +# between two props and looking at one. +export function aim_reach_of(reach: float) -> float { return Math.max(reach, AIM_REACH_MIN) } + +export function aim_begin() -> void { + am_kind = AIM_NONE + am_id = -1 + am_t = 10000.0 + am_dist = 0.0 + am_reach = false + am_off = 1.0 +} + +# A candidate of `kind` (the game's own, not AIM_NONE) and `id`: an upright cylinder at (x, z) from y0 +# to y1 of radius r, within `far` of the body, in reach within `reach` (floored; AIM_NEVER never). +# True when it is now the pick. +export function aim_offer(kind: int, id: int, x: float, y0: float, y1: float, z: float, r: float, reach: float, far: float) -> bool { + let d = aim_range(x, z) + if d > far { return false } + let rr = r + aim_slack_m(d) + let hit = aim_ray_cyl(x, y0, y1, z, rr) + if hit < 0.0 or not (hit < am_t) { return false } + if not aim_clear_to(x, (y0 + y1) * 0.5, z) { return false } + am_t = hit + am_off = aim_miss(x, z, rr) + am_kind = kind + am_id = id + am_dist = d + am_reach = reach >= 0.0 and d < aim_reach_of(reach) + return true +} + +export function aim_kind() -> int { return am_kind } +export function aim_id() -> int { return am_id } +export function aim_dist() -> float { return am_dist } +export function aim_in_reach() -> bool { return am_reach } +export function aim_t() -> float { return am_t } +# how well centred the pick is: 0 dead centre, 1 at its (forgiven) edge +export function aim_off_centre() -> float { return am_off } diff --git a/packages/ludic.aim/ports.ludic b/packages/ludic.aim/ports.ludic new file mode 100644 index 00000000..5e071f43 --- /dev/null +++ b/packages/ludic.aim/ports.ludic @@ -0,0 +1,22 @@ +# ports.ludic - what the aim asks the game: the camera, the ground, and where the body stands. +# Unbound, a camera 1.5 m up at the origin looks down -z over flat ground, with the body under it. +export port AimView { + x: fn() -> float = fn aim__zero + y: fn() -> float = fn aim__eye + z: fn() -> float = fn aim__zero + fx: fn() -> float = fn aim__zero + fy: fn() -> float = fn aim__zero + fz: fn() -> float = fn aim__ahead + fov: fn() -> float = fn aim__fov # vertical, radians + ground: fn(float, float) -> float = fn aim__flat + dry: fn(float, float) -> bool = fn aim__dry # land, for the probe's viewpoints + body_x: fn() -> float = fn aim__zero # distances and reach are the body's, not the camera's + body_z: fn() -> float = fn aim__zero +} + +function aim__zero() -> float { return 0.0 } +function aim__eye() -> float { return 1.5 } +function aim__ahead() -> float { return -1.0 } +function aim__fov() -> float { return 1.0 } +function aim__flat(x: float, z: float) -> float { return 0.0 } +function aim__dry(x: float, z: float) -> bool { return true } diff --git a/packages/ludic.aim/probe.ludic b/packages/ludic.aim/probe.ludic new file mode 100644 index 00000000..e3303580 --- /dev/null +++ b/packages/ludic.aim/probe.ludic @@ -0,0 +1,61 @@ +# probe.ludic - does the aim agree with the ground about what it hides? From (px, pz) and a ring of +# eight points 250 m round it, at 60, 120 and 200 m on 36 bearings, the answer worked out by walking +# the ground under each line against aim_clear_to. Lines the ground merely grazes are left out: that +# is the probe's sampling against the aim's, not a disagreement. Moves the camera (AimView's) through +# `look`, which the game binds to put its camera at a point. +var am_seen: int = 0 +var am_blocked: int = 0 +var am_disagree: int = 0 + +export function aim_probe_seen() -> int { return am_seen } +export function aim_probe_blocked() -> int { return am_blocked } +export function aim_probe_disagree() -> int { return am_disagree } + +# true when at least one line is hidden and the aim disagrees with the ground on at most one in fifty +export function aim_probe_ground(px: float, pz: float, look: fn(float, float, float) -> void) -> bool { + am_seen = 0 + am_blocked = 0 + am_disagree = 0 + for sp in 0 .. 9 { + var sx = px + var sz = pz + if sp > 0 { + let sa = float(sp - 1) / 8.0 * 6.2831853 + sx = px + Math.sin(sa) * 250.0 + sz = pz + Math.cos(sa) * 250.0 + } + if AimView.dry(sx, sz) { am_probe_from(sx, AimView.ground(sx, sz) + 1.6, sz, look) } + } + return am_blocked >= 1 and not (am_disagree > am_seen / 50) +} + +function am_probe_from(sx: float, sy: float, sz: float, look: fn(float, float, float) -> void) -> void { + for di in 0 .. 3 { + var dist = 60.0 + if di == 1 { dist = 120.0 } + if di == 2 { dist = 200.0 } + for b in 0 .. 36 { + let bearing = float(b) / 36.0 * 6.2831853 + let ex = sx + Math.sin(bearing) * dist + let ez = sz + Math.cos(bearing) * dist + let ey = AimView.ground(ex, ez) + 0.4 + let truth = am_probe_gap(sx, sy, sz, ex, ey, ez, 0.5, 10000.0) + if am_probe_gap(sx, sy, sz, ex, ey, ez, -0.5, 0.5) and not truth { continue } + look(sx, sy, sz) + am_seen += 1 + let clear = aim_clear_to(ex, ey, ez) + if not clear { am_blocked += 1 } + if clear == truth { am_disagree += 1 } + } + } +} + +# somewhere between the ends (excluded), the ground stands between lo and hi above the line +function am_probe_gap(sx: float, sy: float, sz: float, ex: float, ey: float, ez: float, lo: float, hi: float) -> bool { + for k in 2 .. 38 { + let t = float(k) / 40.0 + let gap = AimView.ground(Math.lerp(sx, ex, t), Math.lerp(sz, ez, t)) - Math.lerp(sy, ey, t) + if gap > lo and gap < hi { return true } + } + return false +} diff --git a/packages/ludic.aim/ray.ludic b/packages/ludic.aim/ray.ludic new file mode 100644 index 00000000..102aaa73 --- /dev/null +++ b/packages/ludic.aim/ray.ludic @@ -0,0 +1,67 @@ +# ray.ludic - the ray through the crosshair against an upright cylinder, the forgiveness around a +# body, and the ground in front of a point +# t along the ray where it enters the cylinder at (cx, cz), radius r, from y0 up to y1; or -1 +export function aim_ray_cyl(cx: float, y0: float, y1: float, cz: float, r: float) -> float { + let ox = AimView.x() - cx + let oz = AimView.z() - cz + let dx = AimView.fx() + let dy = AimView.fy() + let dz = AimView.fz() + let a = dx * dx + dz * dz + var t0 = -10000.0 + var t1 = 10000.0 + if a < 0.000001 { + if ox * ox + oz * oz > r * r { return -1.0 } # straight up or down: inside or nothing + } else { + let b = 2.0 * (ox * dx + oz * dz) + let disc = b * b - 4.0 * (a * (ox * ox + oz * oz - r * r)) + if disc < 0.0 { return -1.0 } + t0 = (-b - Math.sqrt(disc)) / (2.0 * a) + t1 = (-b + Math.sqrt(disc)) / (2.0 * a) + } + let cy = AimView.y() + if Math.abs(dy) > 0.000001 { + let ta = (y0 - cy) / dy + let tb = (y1 - cy) / dy + t0 = Math.max(t0, Math.min(ta, tb)) + t1 = Math.min(t1, Math.max(ta, tb)) + } else if cy < y0 or cy > y1 { return -1.0 } + if t0 > t1 or t1 < 0.0 { return -1.0 } + return Math.max(t0, 0.0) +} + +# How much wider than itself a thing is treated as at `dist` metres: a fixed angle (twelve pixels of +# a 1080-tall screen), so a branch twenty metres off can be aimed at and the near ones are not easy +# to hit by accident +export function aim_slack_m(dist: float) -> float { return dist * (12.0 / 540.0) * Math.tan(AimView.fov() * 0.5) } + +# metres from the body to (x, z) +export function aim_range(x: float, z: float) -> float { + let dx = x - AimView.body_x() + let dz = z - AimView.body_z() + return Math.sqrt(dx * dx + dz * dz) +} + +# Is the line from the camera to a point clear of the ground? Without it the crosshair reads through a +# rise; within arm's length nothing is hidden, or a prop on a slope hides behind its own slope. +export function aim_clear_to(x: float, y: float, z: float) -> bool { + let cx = AimView.x() + let cy = AimView.y() + let cz = AimView.z() + if (x - cx) * (x - cx) + (z - cz) * (z - cz) < 9.0 { return true } + for s in 1 .. 10 { + let f = float(s) / 10.0 + if AimView.ground(Math.lerp(cx, x, f), Math.lerp(cz, z, f)) - 0.35 > Math.lerp(cy, y, f) { return false } + } + return true +} + +# how far the ray passes from a body's axis as a share of its width: 0 dead centre, 1 at the edge +export function aim_miss(cx: float, cz: float, r: float) -> float { + let ox = AimView.x() - cx + let oz = AimView.z() - cz + let a = Math.sqrt(AimView.fx() * AimView.fx() + AimView.fz() * AimView.fz()) + if a < 0.0001 { return 0.0 } + let proj = (ox * AimView.fx() + oz * AimView.fz()) / a + return Math.sqrt(Math.max(ox * ox + oz * oz - proj * proj, 0.0)) / Math.max(r, 0.01) +} diff --git a/packages/ludic.aim/tests/aim_test.ludic b/packages/ludic.aim/tests/aim_test.ludic new file mode 100644 index 00000000..f315deed --- /dev/null +++ b/packages/ludic.aim/tests/aim_test.ludic @@ -0,0 +1,101 @@ +# aim_test.ludic - a camera 1.5 m up looking down -z over flat ground with a ridge 42 m out: the ray +# against a cylinder, the forgiveness, near before far, the ground hiding a thing, the reach floor, +# which thing the use key means, and the probe agreeing with the ground +import "ludic.aim" +import "ludic.base" +program AimTest { + numbers float + + var cx: float = 0.0 + var cy: float = 1.5 + var cz: float = 0.0 + var fy: float = 0.0 + function fk_x() -> float { return cx } + function fk_y() -> float { return cy } + function fk_z() -> float { return cz } + function fk_fy() -> float { return fy } + function fk_fz() -> float { return -Math.sqrt(1.0 - fy * fy) } + # a 6 m ridge along z = -42, thirty metres across, the whole width of the view + function fk_ground(x: float, z: float) -> float { return 6.0 * Math.max(0.0, 1.0 - Math.abs(z + 42.0) / 15.0) } + function fk_look(x: float, y: float, z: float) -> void { + cx = x + cy = y + cz = z + } + bind AimView { x: fn fk_x, y: fn fk_y, z: fn fk_z, fy: fn fk_fy, fz: fn fk_fz, ground: fn fk_ground } + + function fresh() -> void { + cx = 0.0 + cy = 1.5 + cz = 0.0 + fy = 0.0 + aim_begin() + } + + test "the ray enters a cylinder in front and misses one to the side or behind" { + fresh() + expect_near(aim_ray_cyl(0.0, 0.0, 2.0, -10.0, 0.5), 9.5, 0.01) + expect(aim_ray_cyl(3.0, 0.0, 2.0, -10.0, 0.5) < 0.0) + expect(aim_ray_cyl(0.0, 0.0, 2.0, 10.0, 0.5) < 0.0) + expect(aim_ray_cyl(0.0, 0.0, 1.0, -10.0, 0.5) < 0.0) + expect_near(aim_ray_cyl(0.0, 0.0, 2.0, 0.0, 0.5), 0.0, 0.001) + } + + test "a thin thing far off is still aimable, by an angle and not a metre" { + fresh() + expect(aim_slack_m(20.0) > aim_slack_m(5.0) * 3.9) + expect(aim_offer(1, 7, 0.25, 0.0, 2.0, -20.0, 0.05, 1.5, 30.0)) + expect(aim_off_centre() > 0.5) + fresh() + expect(not aim_offer(1, 7, 3.0, 0.0, 2.0, -20.0, 0.05, 1.5, 30.0)) + } + + test "the nearest thing the ray enters is the pick, whatever order they are offered in" { + fresh() + expect(aim_offer(1, 1, 0.0, 0.0, 2.0, -20.0, 0.5, 1.5, 30.0)) + expect(aim_offer(1, 2, 0.0, 0.0, 2.0, -8.0, 0.5, 1.5, 30.0)) + expect(not aim_offer(1, 3, 0.0, 0.0, 2.0, -15.0, 0.5, 1.5, 30.0)) + expect_eq(aim_id(), 2) + expect_near(aim_dist(), 8.0, 0.01) + expect(not aim_offer(2, 4, 0.0, 0.0, 2.0, -25.0, 0.5, 1.5, 30.0)) + expect(not aim_offer(2, 5, 0.0, 0.0, 2.0, -3.0, 0.5, 1.5, 2.0)) + } + + test "the ground in front hides a thing, and nothing within arm's length is hidden" { + fresh() + expect(not aim_clear_to(0.0, 1.0, -60.0)) + expect(aim_clear_to(0.0, 1.0, -30.0)) + expect(not aim_offer(1, 1, 0.0, 0.0, 2.0, -60.0, 2.0, 1.5, 80.0)) + cy = -5.0 + expect(aim_clear_to(0.0, -5.0, -2.0)) + } + + test "the crosshair decides which, the distance whether: every reach is floored at three metres" { + fresh() + expect_near(aim_reach_of(1.5), AIM_REACH_MIN, 0.001) + expect_near(aim_reach_of(3.4), 3.4, 0.001) + aim_offer(1, 1, 0.0, 0.0, 2.0, -2.6, 0.3, 1.5, 30.0) + expect(aim_in_reach()) + expect_eq(aim_use(1), AIM_USE_THIS) + fresh() + aim_offer(1, 1, 0.0, 0.0, 2.0, -6.0, 0.3, 1.5, 30.0) + expect(not aim_in_reach()) + expect_eq(aim_use(1), AIM_USE_NOTHING) + expect_eq(aim_use(3), AIM_USE_NEAREST) + fresh() + aim_offer(1, 1, 0.0, 0.0, 3.0, -20.0, 0.3, 1.5, 30.0) + expect_eq(aim_use(1), AIM_USE_NEAREST) + fresh() + aim_offer(2, 9, 0.0, 0.0, 2.0, -2.0, 0.3, AIM_NEVER, 30.0) + expect(not aim_in_reach()) + expect_eq(aim_kind(), 2) + } + + test "the probe finds the bank and agrees with the ground about every line it hides" { + fresh() + expect(aim_probe_ground(0.0, 0.0, fn fk_look)) + expect(aim_probe_seen() > 100) + expect(aim_probe_blocked() > 0) + expect(not (aim_probe_disagree() > aim_probe_seen() / 50)) + } +} diff --git a/packages/ludic.aim/use.ludic b/packages/ludic.aim/use.ludic new file mode 100644 index 00000000..d886db3a --- /dev/null +++ b/packages/ludic.aim/use.ludic @@ -0,0 +1,15 @@ +# use.ludic - the thing the use key should act on. The ray comes first and proximity is its fallback, +# never its override: +# under the crosshair and in reach -> that, whatever else is nearer +# under the crosshair and too far -> NOTHING (the prompt says why), within AIM_USE_MAX +# nothing of that kind aimed at -> the nearest thing in reach +# A player looking at something who presses the key has said which thing they mean. +export const AIM_USE_THIS: int = 0 +export const AIM_USE_NOTHING: int = 1 +export const AIM_USE_NEAREST: int = 2 + +export function aim_use(kind: int) -> int { + if am_kind == kind and am_reach { return AIM_USE_THIS } + if am_kind == kind and am_dist < AIM_USE_MAX { return AIM_USE_NOTHING } + return AIM_USE_NEAREST +}