merge lang/physics-senses: animals and people slide round still things, the aim's, photo's and animals' sight lines are one Jolt ray

Conflicts with 0.R5's state defaults in ludic.wildlife resolved; wl_step writes the push's answer, so
its callers take WildlifeState mut again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-27 01:23:17 +03:00
commit ac815bf638
28 changed files with 236 additions and 158 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))
}
}

View file

@ -78,6 +78,8 @@ a `fill` line's words are `NpcTalk.say(p, line)`, and "" means it has nothing tr
export port NpcWorld { # every member has a default: a flat dry field at noon, one player, nobody wanted
ground, water, slope: fn(float, float) -> float
allowed: fn(float, float) -> bool # may a person stop here
push: fn(x, z, r, feet, head) -> bool, pushed_x, pushed_z # a walker slid clear of still things (r 0.35)
clear: fn(ax, ay, az, bx, by, bz) -> bool # no still thing between: npc_line_ok asks it at 1 m
hour: fn() -> float, day: fn() -> int # the clock
players: fn() -> int, player_on: fn(int) -> bool, player_x / player_z: fn(int) -> float
want: fn() -> int, extra: fn() -> int # the census: how many, and a kind brought out regardless (-1)

View file

@ -1,5 +1,5 @@
# ground.ludic - where a person can stand and walk: dry, gentle, allowed; a straight walk with no
# water and no scramble on it; a spot in a ring, a spot toward something, and the nearest shore.
# water, scramble or still thing on it; a spot in a ring, a spot toward something, the nearest shore.
# A finder that succeeds leaves its answer in npc_px / npc_pz (and the water in npc_wx / npc_wz).
export function npc_px(npc_st: NpcState) -> float { return npc_st.np_px }
@ -18,6 +18,7 @@ export function npc_line_ok(ax: float, az: float, bx: float, bz: float) -> bool
var n = int(d / 1.5)
if n < 1 { n = 1 }
var prev = NpcWorld.ground(ax, az)
if not NpcWorld.clear(ax, prev + 1.0, az, bx, NpcWorld.ground(bx, bz) + 1.0, bz) { return false } # a trunk, a tent
for i in 1 .. n + 1 {
let t = float(i) / float(n)
let x = ax + (bx - ax) * t

View file

@ -19,6 +19,8 @@ export state NpcState {
nw_ids: int = 0
np_near_i: int = -1
np_said: string = ""
np_ask_x: float = 0.0 # the point handed to NpcWorld.push, then where it came out
np_ask_z: float = 0.0
}
const NP_PI: float = 3.1415927
const NP_TAU: float = 6.2831853

View file

@ -5,6 +5,11 @@ export port NpcWorld {
water: fn(float, float) -> float = fn nw_dry # the water's surface there
slope: fn(float, float) -> float = fn nw_zero2
allowed: fn(float, float) -> bool = fn nw_yes2 # a person may stop here (not the player's camp)
# (x, z, r, feet, head): true when a still thing takes a body of radius r there; pushed_x / _z say where
push: fn(float, float, float, float, float) -> bool = fn nw_no_push
pushed_x: fn() -> float = fn nw_asked_x
pushed_z: fn() -> float = fn nw_asked_z
clear: fn(float, float, float, float, float, float) -> bool = fn nw_clear # nothing between two points
hour: fn() -> float = fn nw_noon
day: fn() -> int = fn nw_day_one
players: fn() -> int = fn nw_one # player slots
@ -34,6 +39,10 @@ function nw_zero1(p: int) -> float { return 0.0 }
function nw_zero2(x: float, z: float) -> float { return 0.0 }
function nw_dry(x: float, z: float) -> float { return -1000.0 }
function nw_yes2(x: float, z: float) -> bool { return true }
function nw_no_push(x: float, z: float, r: float, y0: float, y1: float) -> bool { return false }
function nw_asked_x(npc_st: NpcState) -> float { return npc_st.np_ask_x }
function nw_asked_z(npc_st: NpcState) -> float { return npc_st.np_ask_z }
function nw_clear(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool { return true }
function nw_noon() -> float { return 12.0 }
function nw_day_one() -> int { return 1 }
function nw_one() -> int { return 1 }

View file

@ -11,6 +11,9 @@ export state NpcTestsFakeState {
fk_on: []bool = null
fk_born: int = 0
fk_gone: int = 0
fk_trunk: bool = false # a trunk 1 m round at (10, 0.3)
fk_out_x: float = 0.0
fk_out_z: float = 0.0
}
function fk_setup(npc_st: mut NpcState, npc_tests_fake_st: mut NpcTestsFakeState) -> void {
@ -23,6 +26,7 @@ function fk_setup(npc_st: mut NpcState, npc_tests_fake_st: mut NpcTestsFakeState
npc_tests_fake_st.fk_on = [true, false]
npc_tests_fake_st.fk_born = 0
npc_tests_fake_st.fk_gone = 0
npc_tests_fake_st.fk_trunk = false
npc_clear(npc_st)
npc_reset(npc_st)
npc_seed(npc_st, 7)
@ -61,3 +65,22 @@ function fk_count(npc_st: NpcState, what: int) -> int {
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
return n
}
# the trunk, when it stands: a body inside it is pushed straight out from its axis, and a line
# passing within its radius is not clear
function fk_push(npc_tests_fake_st: mut NpcTestsFakeState, x: float, z: float, r: float, y0: float, y1: float) -> bool {
let d = npc_d(x, z, 10.0, 0.3)
if not npc_tests_fake_st.fk_trunk or d > 1.0 + r or d < 0.0001 { return false }
npc_tests_fake_st.fk_out_x = 10.0 + (x - 10.0) / d * (1.0 + r)
npc_tests_fake_st.fk_out_z = 0.3 + (z - 0.3) / d * (1.0 + r)
return true
}
function fk_pushed_x(npc_tests_fake_st: NpcTestsFakeState) -> float { return npc_tests_fake_st.fk_out_x }
function fk_pushed_z(npc_tests_fake_st: NpcTestsFakeState) -> float { return npc_tests_fake_st.fk_out_z }
function fk_clear(npc_tests_fake_st: NpcTestsFakeState, ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool {
if not npc_tests_fake_st.fk_trunk { return true }
let lx = bx - ax
let lz = bz - az
let t = Math.clamp(((10.0 - ax) * lx + (0.3 - az) * lz) / Math.max(lx * lx + lz * lz, 0.0001), 0.0, 1.0)
return npc_d(ax + lx * t, az + lz * t, 10.0, 0.3) > 1.0
}

View file

@ -10,6 +10,10 @@ program NpcTest {
ground: fn fk_ground
water: fn fk_water
allowed: fn fk_allowed
push: fn fk_push
pushed_x: fn fk_pushed_x
pushed_z: fn fk_pushed_z
clear: fn fk_clear
hour: fn fk_hour_now
day: fn fk_day_now
players: fn fk_players
@ -95,6 +99,21 @@ program NpcTest {
expect_near(p.fx, 30.0, 0.01)
}
test "a trunk in the way is walked round, and a line through it is not a clear walk" (npc_st: mut NpcState, npc_tests_fake_st: mut NpcTestsFakeState) {
let p = one(npc_st, npc_tests_fake_st, NPCK_HIKER)
npc_tests_fake_st.fk_trunk = true
expect(not npc_line_ok(0.0, 0.0, 20.0, 0.0))
expect(npc_line_ok(0.0, 5.0, 20.0, 5.0))
npc_go(npc_st, p, 20.0, 0.0, NPCA_SIT, 30.0)
var closest = 100.0
for k in 0 .. 600 {
npc_tick(npc_st, 0.05)
closest = Math.min(closest, npc_d(p.x, p.z, 10.0, 0.3))
}
expect(closest > 1.3)
expect_eq(p.act, NPCA_SIT)
}
test "nobody walks into the lake" (npc_st: mut NpcState, npc_tests_fake_st: mut NpcTestsFakeState) {
let p = one(npc_st, npc_tests_fake_st, NPCK_HIKER)
npc_go(npc_st, p, -80.0, 0.0, NPCA_SIT, 30.0)

View file

@ -1,5 +1,8 @@
# walk.ludic - walking to the target: turn toward it, step, never into the water and never up a
# scramble - step round it instead - and give up on a target that gets no closer for twenty seconds
# walk.ludic - walking to the target: turn toward it, step, slid round a trunk or a tent, never into
# the water and never up a scramble - step round it instead - and give up on a target that gets no
# closer for twenty seconds
const NP_BODY_R: float = 0.35 # a person's width, for the still things
export function npc_walk(npc_st: mut NpcState, p: NpcPerson, dt: float) -> void {
var gx = p.tx
var gz = p.tz
@ -29,10 +32,12 @@ export function npc_walk(npc_st: mut NpcState, p: NpcPerson, dt: float) -> void
let r = 3.0 * dt
p.yaw = p.yaw + Math.clamp(npc_wrap(want - p.yaw), -r, r)
let step = sp * dt
let nx = p.x - Math.sin(p.yaw) * step
let nz = p.z - Math.cos(p.yaw) * step
np_slide(npc_st, p, p.x - Math.sin(p.yaw) * step, p.z - Math.cos(p.yaw) * step)
let nx = npc_st.np_ask_x
let nz = npc_st.np_ask_z
let nh = NpcWorld.ground(nx, nz)
if nh < NpcWorld.water(nx, nz) + 0.4 or nh - p.y > step * 1.4 {
let slid = Math.abs(nx - p.x) + Math.abs(nz - p.z)
if nh < NpcWorld.water(nx, nz) + 0.4 or nh - p.y > step * 1.4 or slid < step * 0.2 {
p.stall = p.stall + dt
if p.stall > p.stall_max { p.stall_max = p.stall }
if p.detours < 10 and np_detour(p, want) {
@ -49,6 +54,17 @@ export function npc_walk(npc_st: mut NpcState, p: NpcPerson, dt: float) -> void
p.phase = p.phase + step * 2.6
}
# (x, z) slid clear of every still thing for a person; np_ask_x / _z hold where it came out
function np_slide(npc_st: mut NpcState, p: NpcPerson, x: float, z: float) -> void {
npc_st.np_ask_x = x
npc_st.np_ask_z = z
if not NpcWorld.push(x, z, NP_BODY_R, p.y + 0.1, p.y + 1.8) { return }
let ox = NpcWorld.pushed_x()
let oz = NpcWorld.pushed_z()
npc_st.np_ask_x = ox
npc_st.np_ask_z = oz
}
# there: a step round something ends, or the errand begins, facing what it came for
function np_arrive(npc_st: mut NpcState, p: NpcPerson) -> void {
if p.via_on {

View file

@ -36,14 +36,14 @@ export port PhotoLens {
x, y, z, fx, fy, fz, fov, # the camera
project(x, y, z) -> bool, sx(), sy(), # into the picture, 0..1
visible(x, y, z, r) -> bool, # the frustum
ground(x, z), trunk(x, z, r) -> bool, # what a line of sight walks
clear(ax, ay, az, bx, by, bz) -> bool, # a line of sight: the ground and every still thing
range(), steady() -> bool # the lens in the pack
}
export port PhotoLight { hour, night, moon, dim, bonus, sun_x, sun_z }
export port PhotoRules { behaviour, subject_value(sp), legend_value(sp), mult }
```
Every member has a default: a lens at the origin looking down -z on flat open ground at noon.
Every member has a default: a lens at the origin looking down -z with nothing in the way at noon.
## API

View file

@ -1,6 +1,6 @@
# ports.ludic - what a photograph asks the game: where the lens is and what it can see, the light,
# and the rules of the market. Unbound, the lens sits at the origin looking down -z on flat open
# ground at noon under a clear sky, every point projects to the middle of the frame, and a
# and the rules of the market. Unbound, the lens sits at the origin looking down -z with nothing in
# the way at noon under a clear sky, every point projects to the middle of the frame, and a
# subject's first frame is worth 12 and a legend 120.
export port PhotoLens {
x: fn() -> float = fn pht__zero # the camera's position
@ -14,8 +14,7 @@ export port PhotoLens {
sx: fn() -> float = fn pht__half # 0..1 across
sy: fn() -> float = fn pht__half # 0..1 down
visible: fn(float, float, float, float) -> bool = fn pht__seen # a sphere inside the frustum
ground: fn(float, float) -> float = fn pht__flat
trunk: fn(float, float, float) -> bool = fn pht__clear # (x, z, r): a trunk there
clear: fn(float, float, float, float, float, float) -> bool = fn pht__clear # nothing between two points
range: fn() -> float = fn pht__range # how far the lens reaches, m
steady: fn() -> bool = fn pht__no # on a tripod
}
@ -43,8 +42,7 @@ function pht__fov() -> float { return 1.0 }
function pht__centred(x: float, y: float, z: float) -> bool { return true }
function pht__half() -> float { return 0.5 }
function pht__seen(x: float, y: float, z: float, r: float) -> bool { return true }
function pht__flat(x: float, z: float) -> float { return 0.0 }
function pht__clear(x: float, z: float, r: float) -> bool { return false }
function pht__clear(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool { return true }
function pht__range() -> float { return 100.0 }
function pht__no() -> bool { return false }
function pht__noon() -> float { return 12.0 }

View file

@ -1,27 +1,7 @@
# sight.ludic - in the picture, within reach, and actually seen. A frustum says a subject is in FRONT
# of the lens, not that the lens can see it: the ray walks the ground and the trunks between. It is
# forgiving on purpose - the subject passes if its body or its head is clear.
const PHT_STEP: float = 3.0 # metres between the ray's samples
function pht_ray_point(x: float, y: float, z: float) -> bool {
let cx = PhotoLens.x()
let cy = PhotoLens.y()
let cz = PhotoLens.z()
let d = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy) + (z - cz) * (z - cz))
if d < 3.0 { return true }
let n = int(Math.clamp(d / PHT_STEP, 6.0, 120.0))
for s in 1 .. n {
let f = float(s) / float(n)
let px = Math.lerp(cx, x, f)
let py = Math.lerp(cy, y, f)
let pz = Math.lerp(cz, z, f)
let g = PhotoLens.ground(px, pz)
# the ground with 35 cm of slack; a trunk once past the lens's own few metres
if g - 0.35 > py { return false }
if d * f > 6.0 and py - g < 7.0 and PhotoLens.trunk(px, pz, 0.45) { return false }
}
return true
}
# of the lens, not that the lens can see it: the line must be clear of the ground and the still
# things (the port's). It is forgiving on purpose - the subject passes if its body or its head is clear.
function pht_ray_point(x: float, y: float, z: float) -> bool { return PhotoLens.clear(PhotoLens.x(), PhotoLens.y(), PhotoLens.z(), x, y, z) }
# is (x, y, z) in the picture, within `far`, and can the lens see it?
export function photo_in_view(x: float, y: float, z: float, far: float) -> bool {

View file

@ -34,7 +34,19 @@ program PhotoTest {
if x < 0.0 and z < -45.0 and z > -50.0 { return 10.0 }
return 0.0
}
function fk_trunk(x: float, z: float, r: float) -> bool { return Math.abs(x - 3.75) < 1.6 and Math.abs(z + 30.0) < 1.6 }
function fk_trunk(x: float, z: float) -> bool { return Math.abs(x - 3.75) < 1.6 and Math.abs(z + 30.0) < 1.6 }
# what a physics ray would say: forty points of the line against the ridge and the trunk
function fk_clear(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool {
let n = 40
for s in 1 .. n {
let f = float(s) / float(n)
let px = Math.lerp(ax, bx, f)
let pz = Math.lerp(az, bz, f)
let g = fk_ground(px, pz)
if g - 0.35 > Math.lerp(ay, by, f) or (Math.lerp(ay, by, f) - g < 7.0 and fk_trunk(px, pz)) { return false }
}
return true
}
function fk_range() -> float { return 80.0 }
function fk_hour(photo_test_st: PhotoTestState) -> float { return photo_test_st.hour }
function fk_night(photo_test_st: PhotoTestState) -> bool { return photo_test_st.night }
@ -44,7 +56,7 @@ program PhotoTest {
function fk_behaviour(photo_test_st: PhotoTestState) -> bool { return photo_test_st.behaviour }
function fk_value(sp: int) -> int { return 10 + sp }
bind PhotoLens { y: fn fk_y, fz: fn fk_fz, fov: fn fk_fov, project: fn fk_project, sx: fn fk_sx, sy: fn fk_sy, ground: fn fk_ground, trunk: fn fk_trunk, range: fn fk_range }
bind PhotoLens { y: fn fk_y, fz: fn fk_fz, fov: fn fk_fov, project: fn fk_project, sx: fn fk_sx, sy: fn fk_sy, clear: fn fk_clear, range: fn fk_range }
bind PhotoLight { hour: fn fk_hour, night: fn fk_night, moon: fn fk_moon, dim: fn fk_dim, sun_z: fn fk_sun_z }
bind PhotoRules { behaviour: fn fk_behaviour, subject_value: fn fk_value }

View file

@ -31,6 +31,7 @@ export port WildlifeWorld { # every member has a default: a flat dry mead
ground, water, slope: fn(float, float) -> float # the ground, the water's surface, the gradient
forest: fn(float, float) -> bool
sight: fn(ax, ay, az, bx, by, bz) -> bool # nothing between the two points
push: fn(x, z, r, feet, head) -> bool, pushed_x, pushed_z # a walker slid clear of still things
players: fn() -> int # slots; player_on(p), player_x / _y / _z(p)
hidden, noise, scent, stillness # per player: in a hide, 0..1, 0..1, WILD_MOVING / _STILL / _SITTING
hour, light, winterness, wind, wind_dir: fn() -> float # the clock, how well it sees now, the season, the weather

View file

@ -63,7 +63,7 @@ function wl_take_off(wildlife_st: WildlifeState, a: WildAnimal, h: float) -> voi
a.fly_h = Math.max(a.fly_h, h)
}
function wl_land(wildlife_st: WildlifeState, a: WildAnimal, dh: float, dt: float) -> void {
function wl_land(wildlife_st: mut WildlifeState, a: WildAnimal, dh: float, dt: float) -> void {
let s = wl_sp(wildlife_st, a.sp)
a.fly_h = Math.max(0.0, a.fly_h - dt * 3.2)
a.speed = s.walk * 0.8

View file

@ -41,7 +41,7 @@ export function wildlife_tick_ground(wildlife_st: mut WildlifeState, a: WildAnim
}
# a charger close enough comes at the nearest player; true while it is charging
function wl_charge(wildlife_st: WildlifeState, a: WildAnimal, np: int, dh: float, dt: float) -> bool {
function wl_charge(wildlife_st: mut WildlifeState, a: WildAnimal, np: int, dh: float, dt: float) -> bool {
let s = wl_sp(wildlife_st, a.sp)
if s.charges and a.state != WILD_CHARGE and dh < WildlifeWorld.charge_reach() and not (a.calm > 0.0) {
a.state = WILD_CHARGE

View file

@ -15,6 +15,10 @@ export port WildlifeWorld {
slope: fn(float, float) -> float = fn wl_zero2
forest: fn(float, float) -> bool = fn wl_no2
sight: fn(float, float, float, float, float, float) -> bool = fn wl_clear # nothing between
# (x, z, r, feet, head): true when a still thing takes a body of radius r there; pushed_x / _z say where
push: fn(float, float, float, float, float) -> bool = fn wl_no_push
pushed_x: fn() -> float = fn wl_asked_x
pushed_z: fn() -> float = fn wl_asked_z
players: fn() -> int = fn wl_one # player slots
player_on: fn(int) -> bool = fn wl_first
player_x: fn(int) -> float = fn wl_zero1
@ -52,6 +56,9 @@ function wl_no1(p: int) -> bool { return false }
function wl_no2(x: float, z: float) -> bool { return false }
function wl_yes1(u: int) -> bool { return true }
function wl_clear(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool { return true }
function wl_no_push(x: float, z: float, r: float, y0: float, y1: float) -> bool { return false }
function wl_asked_x(wildlife_st: WildlifeState) -> float { return wildlife_st.wl_ask_x }
function wl_asked_z(wildlife_st: WildlifeState) -> float { return wildlife_st.wl_ask_z }
function wl_one() -> int { return 1 }
function wl_none0() -> int { return 0 }
function wl_first(p: int) -> bool { return p == 0 }

View file

@ -1,6 +1,6 @@
# react.ludic - alert (it stops and looks: the window the camera wants), wary (it puts ground between
# you without running) and fleeing; true when it is doing one of them, which is all it does this tick
function wl_react(wildlife_st: WildlifeState, a: WildAnimal, np: int, dt: float) -> bool {
function wl_react(wildlife_st: mut WildlifeState, a: WildAnimal, np: int, dt: float) -> bool {
let s = wl_sp(wildlife_st, a.sp)
if a.state == WILD_ALERT {
a.speed = 0.0
@ -29,7 +29,7 @@ function wl_react(wildlife_st: WildlifeState, a: WildAnimal, np: int, dt: float)
}
# walking to (or reached) something set out; true while it is
function wl_approach(wildlife_st: WildlifeState, a: WildAnimal, dt: float) -> bool {
function wl_approach(wildlife_st: mut WildlifeState, a: WildAnimal, dt: float) -> bool {
if (a.state == WILD_IDLE or a.state == WILD_WANDER) and wl_attraction(wildlife_st, a) { wl_take_lure(wildlife_st, a) }
if a.state != WILD_APPROACH { return false }
if a.lure == 0 or not WildlifeWorld.present(a.lure) {

View file

@ -42,6 +42,10 @@ export state WildlifeState {
wl_seed_any: bool = false
wl_seed_t: float = 0.0
wl_ids: int = 0
wl_ask_x: float = 0.0 # the point last handed to WildlifeWorld.push, and where it came out
wl_ask_z: float = 0.0
wl_out_x: float = 0.0
wl_out_z: float = 0.0
wl_ranges: []WildRange = new []WildRange
wl_found: []int = null # found flags a load brought before the ranges were set out
wl_seen: int = 0

View file

@ -13,14 +13,20 @@ function wl_face(a: WildAnimal, tx: float, tz: float, rate: float, dt: float) ->
a.yaw = a.yaw + Math.clamp(wl_wrap(want - a.yaw), -r, r)
}
# true when it moved
function wl_step(wildlife_st: WildlifeState, a: WildAnimal, dt: float) -> bool {
# true when it moved; a walker is slid round a still thing (a trunk, a tent), a bird is not
function wl_step(wildlife_st: mut WildlifeState, a: WildAnimal, dt: float) -> bool {
let d = a.speed * dt
if d < 0.0001 { return false }
let nx = a.x - Math.sin(a.yaw) * d
let nz = a.z - Math.cos(a.yaw) * d
var nx = a.x - Math.sin(a.yaw) * d
var nz = a.z - Math.cos(a.yaw) * d
let s = wl_sp(wildlife_st, a.sp)
if not s.flies and wl_push(wildlife_st, a, s, nx, nz) {
nx = wildlife_st.wl_out_x
nz = wildlife_st.wl_out_z
}
let nh = WildlifeWorld.ground(nx, nz)
if nh < WildlifeWorld.water(nx, nz) + 0.6 or nh - a.y > d * 1.2 {
let moved = Math.abs(nx - a.x) + Math.abs(nz - a.z)
if nh < WildlifeWorld.water(nx, nz) + 0.6 or nh - a.y > d * 1.2 or moved < d * 0.2 {
a.yaw = a.yaw + Math.deg_to_rad(75.0)
a.timer = 0.0
return false
@ -29,13 +35,29 @@ function wl_step(wildlife_st: WildlifeState, a: WildAnimal, dt: float) -> bool {
a.z = nz
a.y = nh
a.track_d = a.track_d + d
if wl_sp(wildlife_st, a.sp).tracks and a.track_d > 2.8 {
if s.tracks and a.track_d > 2.8 {
a.track_d = 0.0
wl_fact(wildlife_st, WILD_PRINT, a)
}
return true
}
# (x, z) pushed clear of every still thing for a body its size; wl_out_x / _z say where
function wl_push(wildlife_st: mut WildlifeState, a: WildAnimal, s: WildSpecies, x: float, z: float) -> bool {
wildlife_st.wl_ask_x = x
wildlife_st.wl_ask_z = z
let k = Math.clamp(float(s.size), 0.0, 2.0)
let r = (0.2 + 0.2 * k) * s.scale * a.scale
let hit = WildlifeWorld.push(x, z, r, a.y + 0.1, a.y + (0.5 + 0.7 * k) * s.scale * a.scale)
wildlife_st.wl_out_x = x
wildlife_st.wl_out_z = z
if hit {
wildlife_st.wl_out_x = WildlifeWorld.pushed_x()
wildlife_st.wl_out_z = WildlifeWorld.pushed_z()
}
return hit
}
function wl_pick_target(wildlife_st: WildlifeState, a: WildAnimal, radius: float) -> void {
let ang = wl_rnd(wildlife_st) * WL_TAU
let d = wl_rnd(wildlife_st) * radius

View file

@ -16,6 +16,9 @@ export state WildlifeTestsFakeState {
fk_born: int = 0
fk_lures: []WildLure = null
fk_gone: int = 0 # a uid the port says is no longer there
fk_trunk: bool = false # a trunk 1 m round at (0.3, -20)
fk_out_x: float = 0.0
fk_out_z: float = 0.0
}
function fk_setup(wildlife_st: mut WildlifeState, wildlife_tests_fake_st: mut WildlifeTestsFakeState) -> void {
@ -27,6 +30,7 @@ function fk_setup(wildlife_st: mut WildlifeState, wildlife_tests_fake_st: mut Wi
wildlife_tests_fake_st.fk_px[0] = 5000.0
wildlife_tests_fake_st.fk_pz[0] = 5000.0
wildlife_tests_fake_st.fk_lures = new []WildLure
wildlife_tests_fake_st.fk_trunk = false
wildlife_clear(wildlife_st)
wildlife_species_clear(wildlife_st)
fk_species(wildlife_st)
@ -108,3 +112,16 @@ function fk_spot(a: WildAnimal, water: bool, s: WildSpot) -> bool {
s.water = water
return true
}
# the trunk, when it stands: a body inside it is pushed straight out from its axis
function fk_push(wildlife_tests_fake_st: mut WildlifeTestsFakeState, x: float, z: float, r: float, y0: float, y1: float) -> bool {
let dx = x - 0.3
let dz = z + 20.0
let d = Math.sqrt(dx * dx + dz * dz)
if not wildlife_tests_fake_st.fk_trunk or d > 1.0 + r or d < 0.0001 { return false }
wildlife_tests_fake_st.fk_out_x = 0.3 + dx / d * (1.0 + r)
wildlife_tests_fake_st.fk_out_z = -20.0 + dz / d * (1.0 + r)
return true
}
function fk_pushed_x(wildlife_tests_fake_st: WildlifeTestsFakeState) -> float { return wildlife_tests_fake_st.fk_out_x }
function fk_pushed_z(wildlife_tests_fake_st: WildlifeTestsFakeState) -> float { return wildlife_tests_fake_st.fk_out_z }

View file

@ -10,6 +10,9 @@ program WildlifeTest {
water: fn fk_water
forest: fn fk_forest
sight: fn fk_sight
push: fn fk_push
pushed_x: fn fk_pushed_x
pushed_z: fn fk_pushed_z
players: fn fk_players
player_on: fn fk_player_on
player_x: fn fk_x
@ -177,6 +180,25 @@ program WildlifeTest {
expect(wildlife_seen(wildlife_st, 0))
}
test "a walker slides round a trunk rather than through it" (wildlife_st: mut WildlifeState, wildlife_tests_fake_st: mut WildlifeTestsFakeState) {
fk_setup(wildlife_st, wildlife_tests_fake_st)
let a = deer_at(wildlife_st, 0.0, 0.0)
wildlife_tests_fake_st.fk_trunk = true
player_at(wildlife_tests_fake_st, 60.0, 0.0)
wildlife_tests_fake_st.fk_blocked = true
a.state = WILD_WANDER
a.tx = 0.0
a.tz = -100.0
a.timer = 100.0
var closest = 100.0
for k in 0 .. 800 {
wildlife_tick(wildlife_st, 0.05, 0.0)
closest = Math.min(closest, Math.sqrt((a.x - 0.3) * (a.x - 0.3) + (a.z + 20.0) * (a.z + 20.0)))
}
expect(closest > 1.35)
expect(a.z < -25.0)
}
test "the record and the found ranges come back from the save section" (wildlife_st: mut WildlifeState, wildlife_tests_fake_st: mut WildlifeTestsFakeState) {
fk_setup(wildlife_st, wildlife_tests_fake_st)
wildlife_spawn(wildlife_st, 0.0, 0.0)

View file

@ -1,7 +1,7 @@
# want.ludic - thirst and hunger over sixty send it to the water or the forage the game offers for
# it (a hungry one on its way turns to food put down nearer); it drinks or grazes there
function wl_seek(wildlife_st: WildlifeState, a: WildAnimal, dt: float) -> bool {
function wl_seek(wildlife_st: mut WildlifeState, a: WildAnimal, dt: float) -> bool {
if a.state == WILD_GO_FEED and wl_attraction(wildlife_st, a) and wildlife_st.wl_best.kind == WILD_FEED { wl_take_lure(wildlife_st, a) }
if a.state != WILD_GO_DRINK and a.state != WILD_GO_FEED { return false }
if a.spot == 0 or not WildlifeWorld.present(a.spot) {