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:
parent
d2cd775309
commit
56e4b6d688
24 changed files with 230 additions and 152 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue