feat(packages): ludic.aim - what the crosshair is on: a ray through the middle of the screen against upright cylinders widened by a fixed angular forgiveness, the nearest one the ground does not hide, reach floored at three metres, and which thing the use key means (that, nothing and say why, or the nearest); the probe of the ground's own answer against the aim; the camera, the ground and the body through a port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 09:07:51 +03:00
parent 24a35f1174
commit dc71fc986c
10 changed files with 399 additions and 0 deletions

View file

@ -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.

View file

@ -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"

View file

@ -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

View file

@ -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 }

View file

@ -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 }

View file

@ -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
}

View file

@ -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)
}

View file

@ -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))
}
}

View file

@ -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
}