feat(senses): walkers slide round still things, and lines of sight are the world's to answer

- ludic.wildlife: WildlifeWorld.push / pushed_x / pushed_z (defaults change nothing); a ground
  walker's step is slid clear of a still thing with a body from its species' size and scale, and
  a step that gets nowhere turns away as the water does. Birds are not pushed; no dice added.
- ludic.npc: NpcWorld.push / pushed_x / pushed_z (a person 0.35 m round) in npc_walk, and
  NpcWorld.clear (default true), which npc_line_ok asks at 1 m so a detour does not cut a trunk.
- ludic.aim: AimView.clear replaces AimView.ground and the ground march; the pick asks the line
  a body's width short of where the ray enters the thing (aim_clear_at(t, back)), so a solid
  thing does not hide itself. probe.ludic (the ground-agreement probe) and AimView.dry are gone.
- ludic.photo: PhotoLens.clear replaces PhotoLens.ground, PhotoLens.trunk and the march.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-27 00:59:39 +03:00
parent d2cd775309
commit 56e4b6d688
24 changed files with 230 additions and 152 deletions

View file

@ -3,7 +3,9 @@
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
forgiveness, and the pick is the nearest one the ray enters that nothing hides: the line up to
where the ray enters it is asked of the game (`clear`), so a thing that is itself solid does not
hide itself. Uses
[`ludic.base`](../ludic.base/README.md) and nothing else.
```ludic
@ -32,13 +34,12 @@ that asks "am I close enough to this" asks `aim_reach_of`.
```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
clear: fn(ax, ay, az, bx, by, bz) -> bool, # nothing between: the ground and every still thing
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.
Every member has a default: a camera 1.5 m up at the origin looking down -z at nothing in the way.
## API
@ -47,8 +48,7 @@ Every member has a default: a camera 1.5 m up at the origin looking down -z over
| `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 |
| `aim_ray_cyl(cx, y0, y1, cz, r)`, `aim_slack_m(dist)`, `aim_clear_to(x, y, z)`, `aim_clear_at(t, back)`, `aim_range(x, z)`, `aim_miss(cx, cz, r)` | the geometry, for a game's own probes (`aim_clear_at`: the ray clear to `back` short of t along it) |
## Tests
@ -57,4 +57,5 @@ 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.
far, the ridge hiding a thing and a solid thing not hiding itself, the reach floor and the three
answers for the use key.

View file

@ -1,6 +1,6 @@
# 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.
# forgiveness; the pick is the nearest one the ray enters that nothing hides.
module ludic_aim uses ludic_base
numbers float
import "ludic.base"
@ -8,4 +8,3 @@ import "ports.ludic"
import "ray.ludic"
import "pick.ludic"
import "use.ludic"
import "probe.ludic"

View file

@ -1,5 +1,5 @@
# 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
# that nothing hides 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
@ -13,9 +13,6 @@ export state AimState {
am_dist: float = 0.0
am_reach: bool = false
am_off: float = 1.0
am_seen: int = 0
am_blocked: int = 0
am_disagree: int = 0
}
# The crosshair decides WHICH; the distance decides only WHETHER. Every reach is lifted to a floor of
@ -41,7 +38,7 @@ export function aim_offer(aim_st: mut AimState, kind: int, id: int, x: float, y0
let rr = r + aim_slack_m(d)
let hit = aim_ray_cyl(x, y0, y1, z, rr)
if hit < 0.0 or not (hit < aim_st.am_t) { return false }
if not aim_clear_to(x, (y0 + y1) * 0.5, z) { return false }
if not aim_clear_at(hit, rr) { return false }
aim_st.am_t = hit
aim_st.am_off = aim_miss(x, z, rr)
aim_st.am_kind = kind

View file

@ -1,5 +1,5 @@
# 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.
# ports.ludic - what the aim asks the game: the camera, whether a line is clear, and where the body
# stands. Unbound, a camera 1.5 m up at the origin looks down -z at nothing in the way, over the body.
export port AimView {
x: fn() -> float = fn aim__zero
y: fn() -> float = fn aim__eye
@ -8,8 +8,7 @@ export port AimView {
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
clear: fn(float, float, float, float, float, float) -> bool = fn aim__clear # nothing between two points
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
}
@ -18,5 +17,4 @@ 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 }
function aim__clear(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool { return true }

View file

@ -1,58 +0,0 @@
# 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.
export function aim_probe_seen(aim_st: AimState) -> int { return aim_st.am_seen }
export function aim_probe_blocked(aim_st: AimState) -> int { return aim_st.am_blocked }
export function aim_probe_disagree(aim_st: AimState) -> int { return aim_st.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(aim_st: mut AimState, px: float, pz: float, look: fn(float, float, float) -> void) -> bool {
aim_st.am_seen = 0
aim_st.am_blocked = 0
aim_st.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(aim_st, sx, AimView.ground(sx, sz) + 1.6, sz, look) }
}
return aim_st.am_blocked >= 1 and not (aim_st.am_disagree > aim_st.am_seen / 50)
}
function am_probe_from(aim_st: mut AimState, 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)
aim_st.am_seen += 1
let clear = aim_clear_to(ex, ey, ez)
if not clear { aim_st.am_blocked += 1 }
if clear == truth { aim_st.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

@ -1,5 +1,5 @@
# ray.ludic - the ray through the crosshair against an upright cylinder, the forgiveness around a
# body, and the ground in front of a point
# body, and whether anything stands 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
@ -42,18 +42,15 @@ export function aim_range(x: float, z: float) -> float {
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
# is the line from the camera to a point clear? (the ground and every still thing: the port's)
export function aim_clear_to(x: float, y: float, z: float) -> bool { return AimView.clear(AimView.x(), AimView.y(), AimView.z(), x, y, z) }
# is the ray clear up to `back` short of t, where it enters a thing? Asked a body's width short, so
# a solid thing (a trunk, a tent) whose collider is wider than its aim does not hide itself
export function aim_clear_at(t: float, back: float) -> bool {
let u = t - Math.max(back, 0.05)
if u < 0.1 { return true }
return aim_clear_to(AimView.x() + AimView.fx() * u, AimView.y() + AimView.fy() * u, AimView.z() + AimView.fz() * u)
}
# how far the ray passes from a body's axis as a share of its width: 0 dead centre, 1 at the edge

View file

@ -1,6 +1,6 @@
# 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
# against a cylinder, the forgiveness, near before far, the ridge hiding a thing, the reach floor,
# and which thing the use key means
import "ludic.aim"
import "ludic.base"
program AimTest {
@ -11,6 +11,7 @@ program AimTest {
cy: float = 1.5
cz: float = 0.0
fy: float = 0.0
post: bool = false
}
function fk_x(aim_test_st: AimTestState) -> float { return aim_test_st.cx }
function fk_y(aim_test_st: AimTestState) -> float { return aim_test_st.cy }
@ -19,18 +20,29 @@ program AimTest {
function fk_fz(aim_test_st: AimTestState) -> float { return -Math.sqrt(1.0 - aim_test_st.fy * aim_test_st.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(aim_test_st: mut AimTestState, x: float, y: float, z: float) -> void {
aim_test_st.cx = x
aim_test_st.cy = y
aim_test_st.cz = z
# the ground under ten points of the line, with 35 cm of slack, and (when it stands) a solid post
# 0.75 m round at (0, -20), wider than its aim: what a physics ray would say
function fk_clear(aim_test_st: AimTestState, ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool {
let lx = bx - ax
let lz = bz - az
let t = Math.clamp(((0.0 - ax) * lx + (-20.0 - az) * lz) / Math.max(lx * lx + lz * lz, 0.0001), 0.0, 1.0)
let px = ax + lx * t
let pz = az + lz * t
if aim_test_st.post and px * px + (pz + 20.0) * (pz + 20.0) < 0.5625 { return false }
for s in 1 .. 10 {
let f = float(s) / 10.0
if fk_ground(Math.lerp(ax, bx, f), Math.lerp(az, bz, f)) - 0.35 > Math.lerp(ay, by, f) { return false }
}
return true
}
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 }
bind AimView { x: fn fk_x, y: fn fk_y, z: fn fk_z, fy: fn fk_fy, fz: fn fk_fz, clear: fn fk_clear }
function fresh(aim_st: mut AimState, aim_test_st: mut AimTestState) -> void {
aim_test_st.cx = 0.0
aim_test_st.cy = 1.5
aim_test_st.cz = 0.0
aim_test_st.fy = 0.0
aim_test_st.post = false
aim_begin(aim_st)
}
@ -63,13 +75,18 @@ program AimTest {
expect(not aim_offer(aim_st, 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" (aim_st: mut AimState, aim_test_st: mut AimTestState) {
test "the ridge in front hides a thing, a thing does not hide itself, and nothing a hand's breadth off is hidden" (aim_st: mut AimState, aim_test_st: mut AimTestState) {
fresh(aim_st, aim_test_st)
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(aim_st, 1, 1, 0.0, 0.0, 2.0, -60.0, 2.0, 1.5, 80.0))
aim_test_st.post = true
expect(not aim_clear_to(0.0, 1.0, -20.0))
expect(aim_offer(aim_st, 1, 2, 0.0, 0.0, 2.0, -20.0, 0.5, 1.5, 80.0))
fresh(aim_st, aim_test_st)
aim_test_st.cy = -5.0
expect(aim_clear_to(0.0, -5.0, -2.0))
expect(aim_clear_at(0.5, 0.45))
expect(not aim_clear_at(50.0, 0.5))
}
test "the crosshair decides which, the distance whether: every reach is floored at three metres" (aim_st: mut AimState, aim_test_st: mut AimTestState) {
@ -92,12 +109,4 @@ program AimTest {
expect(not aim_in_reach(aim_st))
expect_eq(aim_kind(aim_st), 2)
}
test "the probe finds the bank and agrees with the ground about every line it hides" (aim_st: mut AimState, aim_test_st: mut AimTestState) {
fresh(aim_st, aim_test_st)
expect(aim_probe_ground(aim_st, 0.0, 0.0, fn fk_look))
expect(aim_probe_seen(aim_st) > 100)
expect(aim_probe_blocked(aim_st) > 0)
expect(not (aim_probe_disagree(aim_st) > aim_probe_seen(aim_st) / 50))
}
}