Merge commit 'cc24409' into lang/foundations

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 20:55:24 +03:00
commit aba5d9e679
10 changed files with 170 additions and 32 deletions

View file

@ -0,0 +1,7 @@
bump: minor
type: feature
**`ludic.wildlife`: a map may paint where a species lives.** Two new `WildlifeWorld` members, both with
defaults that change nothing: `habitat_in(sp, x, z)` answers 1 inside a species' painted habitat, 0
outside and -1 where the map paints none (the kind's rule stands), and `range_at(sp, k, r)` fills the
map's k-th painted range, which is set out as painted and draws no dice. Spots, ranges, fills and
newcomers keep to the paint (`wildlife_habitat_of`, `wildlife_find_spot_of`), never under water.

View file

@ -39,6 +39,8 @@ export port WildlifeWorld { # every member has a default: a flat dry mead
taught: fn() -> bool # feeding is being taught: food carries 175 m, feeders mind less
about: fn(WildAnimal) -> int # WILD_ABOUT, WILD_AWAY (lying up) or WILD_GONE (a legend's hours ended)
ranges, per_range: fn(int) -> int # a species' ranges on this map, and how many live in each
habitat_in: fn(sp, x, z) -> int # the map's painted habitat: 1 in, 0 out, -1 none painted
range_at: fn(sp, k, WildRange) -> bool # the map's k-th painted range (x, z, r; r 0 = the species')
spot: fn(WildAnimal, bool, WildSpot) -> bool # water (true) or forage for it, filled in
lures: fn() -> int # how many things to ask `lure` about
lure: fn(int, WildLure) -> bool # the i-th, filled in, when it is food, a lure or an empty snare
@ -58,6 +60,12 @@ target took - so binding them changes where an animal walks and never the order
no answer (the defaults) it walks straight and turns 75 degrees off water and a climb, as before.
Maroon Lake binds both to its navmesh, walked by the species' habitat's taste.
**A map may paint where a species lives.** Where `habitat_in` answers 0 or 1, a species' spots,
ranges and newcomers keep inside its painted areas (never under water) instead of its kind's rule;
where it answers -1 the rule stands. A range `range_at` answers is set out as painted, drawing no
dice; the ones it does not answer are placed by the dice as before. The package never reads the
map's tables: the game answers both from its Areas.
## API
| | |
@ -67,7 +75,7 @@ Maroon Lake binds both to its navmesh, walked by the species' habitat's taste.
| `wildlife_new(sp, x, z, legend)`, `wildlife_make(sp, x, z, legend, yaw, variant, scale, nid)`, `wildlife_remove(a)`, `wildlife_clear()` | making one off the package's dice or exactly as given (a guest's copy), and taking one away |
| `wildlife_spawn(x0, z0)`, `wildlife_morning() -> int`, `wildlife_repopulate() -> int`, `wildlife_seed(n)` | a valley filled; each morning the ranges move with the season and each range under its target gets one back, out of every player's sight |
| `WildRange`, `wildlife_ranges()`, `wildlife_range(i)`, `wildlife_range_dist(r, x, z)`, `wildlife_range_alive(i)`, `wildlife_ranges_visit(x, z)`, `wildlife_range_found_near(sp, x, z)`, `wildlife_ranges_migrate()` | ranges, found by standing in them |
| `wildlife_habitat_ok(hab, x, z)`, `wildlife_find_spot(hab, x0, z0, d0, d1)`, `wildlife_spot_x()`, `wildlife_spot_z()` | the right ground, and a spot of it |
| `wildlife_habitat_ok(hab, x, z)`, `wildlife_habitat_of(sp, hab, x, z)`, `wildlife_find_spot(hab, x0, z0, d0, d1)`, `wildlife_find_spot_of(sp, hab, ...)`, `wildlife_spot_x()`, `wildlife_spot_z()` | the right ground, and a spot of it |
| `wildlife_tick(dt, gh)`, `wildlife_tick_ground(a, dt, gh)`, `wildlife_tick_bird(a, dt)`, `wildlife_stride(a, dt)`, `wildlife_hold(on)` | the step; `pinned` on an animal leaves it where it stands |
| `wildlife_alert_rate(a, p, d)`, `wildlife_alert_decay(a, p)`, `wildlife_downwind(a, p)`, `wildlife_can_see(a, p)` | what it makes of player p |
| `wildlife_in_snare(uid)`, `wildlife_release(a)` | a snare's catch |

View file

@ -16,14 +16,26 @@ export function wildlife_habitat_ok(hab: int, x: float, z: float) -> bool {
return s > 0.25 or h > 80.0
}
# a species' ground: its painted habitat where the map has one (never under water), else its kind's rule
export function wildlife_habitat_of(sp: int, hab: int, x: float, z: float) -> bool {
let painted = WildlifeWorld.habitat_in(sp, x, z)
if painted < 0 { return wildlife_habitat_ok(hab, x, z) }
return painted == 1 and WildlifeWorld.ground(x, z) >= WildlifeWorld.water(x, z) + 0.8
}
# a spot of `hab` d0..d1 metres from (x0, z0); where it is, is wildlife_spot_x / _z
export function wildlife_find_spot(wildlife_st: mut WildlifeState, hab: int, x0: float, z0: float, d0: float, d1: float) -> bool {
return wildlife_find_spot_of(wildlife_st, -1, hab, x0, z0, d0, d1)
}
# the same for a species (sp >= 0): inside its painted habitat when the map has one
export function wildlife_find_spot_of(wildlife_st: mut WildlifeState, sp: int, hab: int, x0: float, z0: float, d0: float, d1: float) -> bool {
for t in 0 .. 60 {
let ang = wl_rnd(wildlife_st) * WL_TAU
let d = d0 + wl_rnd(wildlife_st) * (d1 - d0)
let x = x0 + Math.cos(ang) * d
let z = z0 + Math.sin(ang) * d
if wildlife_habitat_ok(hab, x, z) {
if (sp < 0 and wildlife_habitat_ok(hab, x, z)) or (sp >= 0 and wildlife_habitat_of(sp, hab, x, z)) {
wildlife_st.wl_px = x
wildlife_st.wl_pz = z
return true

View file

@ -17,6 +17,7 @@ import "guest.ludic"
import "record.ludic"
import "habitat.ludic"
import "ranges.ludic"
import "winter.ludic"
import "migrate.ludic"
import "senses.ludic"
import "steer.ludic"

View file

@ -38,6 +38,9 @@ export port WildlifeWorld {
taught: fn() -> bool = fn wl_no0 # food carries further, feeders mind less
about: fn(WildAnimal) -> int = fn wl_about # WILD_ABOUT, _AWAY or _GONE
ranges: fn(int) -> int = fn wl_two # ranges a species gets on this map
# (sp, x, z): the map's painted habitat for a species: 1 inside, 0 outside, -1 none painted (the rule)
habitat_in: fn(int, float, float) -> int = fn wl_unpainted
range_at: fn(int, int, WildRange) -> bool = fn wl_no_range # (sp, k): the k-th painted range, x, z, r
active: fn(float, float) -> bool = fn wl_yes2 # simulated here: outside, an animal is frozen
per_range: fn(int) -> int = fn wl_three # and how many live in each
spot: fn(WildAnimal, bool, WildSpot) -> bool = fn wl_no_spot # water (true) or forage for it
@ -78,6 +81,8 @@ function wl_nine() -> float { return 9.0 }
function wl_about(a: WildAnimal) -> int { return WILD_ABOUT }
function wl_two(sp: int) -> int { return 2 }
function wl_three(sp: int) -> int { return 3 }
function wl_unpainted(sp: int, x: float, z: float) -> int { return -1 }
function wl_no_range(sp: int, k: int, r: WildRange) -> bool { return false }
function wl_no_spot(a: WildAnimal, water: bool, s: WildSpot) -> bool { return false }
function wl_no_lure(i: int, l: WildLure) -> bool { return false }
function wl_nothing(a: WildAnimal) -> void { }

View file

@ -25,7 +25,7 @@ function wl_range_clashes(wildlife_st: WildlifeState, sp: int, x: float, z: floa
function wl_range_try(wildlife_st: mut WildlifeState, sp: int, x0: float, z0: float) -> bool {
let s = wl_sp(wildlife_st, sp)
for tries in 0 .. 80 {
if not wildlife_find_spot(wildlife_st, s.habitat, x0, z0, 120.0, 700.0) { continue }
if not wildlife_find_spot_of(wildlife_st, sp, s.habitat, x0, z0, 120.0, 700.0) { continue }
let dx = wildlife_st.wl_px - x0
let dz = wildlife_st.wl_pz - z0
if Math.sqrt(dx * dx + dz * dz) < s.keep_away or wl_range_clashes(wildlife_st, sp, wildlife_st.wl_px, wildlife_st.wl_pz) { continue }
@ -48,39 +48,16 @@ export function wildlife_ranges_setup(wildlife_st: mut WildlifeState, x0: float,
wildlife_seed(wildlife_st, 613)
wildlife_st.wl_ranges = new []WildRange
for sp in 0 .. wildlife_species_count(wildlife_st) {
for k in 0 .. WildlifeWorld.ranges(sp) { if not wl_range_try(wildlife_st, sp, x0, z0) { break } }
for k in 0 .. WildlifeWorld.ranges(sp) {
if wl_range_painted(wildlife_st, sp, k) { continue }
if not wl_range_try(wildlife_st, sp, x0, z0) { break }
}
}
wl_found_apply(wildlife_st)
let rs = wildlife_ranges(wildlife_st)
for i in 0 .. len(rs) { WildlifeWorld.placed(rs[i], i) }
}
# downhill, toward the timber, never into the water: shelter is worth 25 m of height
function wl_pick_winter(r: WildRange) -> void {
let up = WildlifeWorld.ground(r.sx, r.sz) - WildlifeWorld.water(r.sx, r.sz)
let reach = Math.min(60.0 + up * 6.0, 520.0)
r.wx = r.sx
r.wz = r.sz
var best = up
for k in 0 .. 24 {
let ang = float(k) * Math.deg_to_rad(15.0)
for step in 1 .. 5 {
let d = reach * (float(step) / 4.0)
let px = r.sx + Math.cos(ang) * d
let pz = r.sz + Math.sin(ang) * d
let h = WildlifeWorld.ground(px, pz) - WildlifeWorld.water(px, pz)
if h < 3.0 or WildlifeWorld.slope(px, pz) > 0.6 { continue }
var score = h
if WildlifeWorld.forest(px, pz) { score = score - 25.0 }
if score < best {
best = score
r.wx = px
r.wz = pz
}
}
}
}
# a player stood at (x, z): any range they are inside is found
export function wildlife_ranges_visit(wildlife_st: WildlifeState, x: float, z: float) -> void {
let rs = wildlife_ranges(wildlife_st)

View file

@ -14,7 +14,7 @@ function wl_fill(wildlife_st: mut WildlifeState, i: int, want: int) -> int {
let r = wildlife_range(wildlife_st, i)
var made = 0
for k in 0 .. want {
if not wildlife_find_spot(wildlife_st, wl_sp(wildlife_st, r.sp).habitat, r.x, r.z, 0.0, r.r) { continue }
if not wildlife_find_spot_of(wildlife_st, r.sp, wl_sp(wildlife_st, r.sp).habitat, r.x, r.z, 0.0, r.r) { continue }
let a = wildlife_new(wildlife_st, r.sp, wildlife_st.wl_px, wildlife_st.wl_pz, false)
if a != null {
a.rg = i
@ -38,7 +38,7 @@ export function wildlife_repopulate(wildlife_st: mut WildlifeState) -> int {
let want = WildlifeWorld.per_range(rs[i].sp)
if want == 0 or wildlife_range_alive(wildlife_st, i) >= want { continue }
for t in 0 .. 12 {
if not wildlife_find_spot(wildlife_st, wl_sp(wildlife_st, rs[i].sp).habitat, rs[i].x, rs[i].z, 0.0, rs[i].r) { continue }
if not wildlife_find_spot_of(wildlife_st, rs[i].sp, wl_sp(wildlife_st, rs[i].sp).habitat, rs[i].x, rs[i].z, 0.0, rs[i].r) { continue }
if wl_near(wildlife_st, wildlife_st.wl_px, wildlife_st.wl_pz) < 200.0 { continue }
let a = wildlife_new(wildlife_st, rs[i].sp, wildlife_st.wl_px, wildlife_st.wl_pz, false)
if a != null {

View file

@ -44,6 +44,7 @@ export state WildlifeState {
wl_pz: float = 0.0
wl_ask: WildLure = new WildLure
wl_best: WildLure = new WildLure
wl_ask_rg: WildRange = new WildRange # what WildlifeWorld.range_at fills, copied only when it answers
wl_seed_any: bool = false
wl_seed_t: float = 0.0
wl_ids: int = 0

View file

@ -0,0 +1,82 @@
# painted_test.ludic - a map that paints a species' habitat and a range: its ranges and animals keep
# inside it, the painted range stands where it was painted, and a species with none keeps its rule
import "ludic.wildlife"
import "ludic.base"
import "fake"
program WildPaintedTest {
numbers float
bind WildlifeWorld {
ground: fn fk_ground
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
player_y: fn fk_y
player_z: fn fk_z
hidden: fn fk_is_hidden
noise: fn fk_noise_of
scent: fn fk_scent_of
stillness: fn fk_still_of
wind: fn fk_wind_of
wind_dir: fn fk_wind_to
winterness: fn fk_winter_of
about: fn fk_about_of
per_range: fn fk_one
lures: fn fk_count
lure: fn fk_lure
present: fn fk_present
born: fn fk_on_born
spot: fn fk_spot
habitat_in: fn pt_habitat
range_at: fn pt_range
}
# the deer's habitat is the square x 500..700, z -100..100; its first range is painted at (600, 0)
function pt_habitat(sp: int, x: float, z: float) -> int {
if sp != 0 { return -1 }
if x >= 500.0 and x <= 700.0 and z >= -100.0 and z <= 100.0 { return 1 }
return 0
}
function pt_range(sp: int, k: int, r: WildRange) -> bool {
if sp != 0 or k != 0 { return false }
r.x = 600.0
r.z = 0.0
r.r = 80.0
return true
}
function pt_inside(x: float, z: float) -> bool { return x >= 500.0 and x <= 700.0 and z >= -100.0 and z <= 100.0 }
test "a painted range stands where it was painted, and every deer range and deer keeps to the paint" (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)
let rs = wildlife_ranges(wildlife_st)
var first = -1
for i in 0 .. len(rs) { if first < 0 and rs[i].sp == 0 { first = i } }
expect(first >= 0)
expect_eq(rs[first].sx, 600.0)
expect_eq(rs[first].r, 80.0)
for i in 0 .. len(rs) { if rs[i].sp == 0 { expect(pt_inside(rs[i].sx, rs[i].sz)) } }
let all = wildlife_all(wildlife_st)
var deer = 0
for i in 0 .. len(all) {
if all[i].sp == 0 {
deer += 1
expect(pt_inside(all[i].x, all[i].z))
}
}
expect(deer >= 1)
}
test "a species with nothing painted keeps its kind's rule" (wildlife_st: mut WildlifeState, wildlife_tests_fake_st: mut WildlifeTestsFakeState) {
fk_setup(wildlife_st, wildlife_tests_fake_st)
expect(wildlife_habitat_of(1, WILD_FOREST, 0.0, 500.0))
expect(not wildlife_habitat_of(1, WILD_FOREST, 0.0, 0.0))
expect(not wildlife_habitat_of(0, WILD_MEADOW, 0.0, 0.0))
expect(wildlife_habitat_of(0, WILD_MEADOW, 550.0, 0.0))
}
}

View file

@ -0,0 +1,45 @@
# winter.ludic - where a range winters, and a range the map paints rather than the dice
# downhill, toward the timber, never into the water: shelter is worth 25 m of height
function wl_pick_winter(r: WildRange) -> void {
let up = WildlifeWorld.ground(r.sx, r.sz) - WildlifeWorld.water(r.sx, r.sz)
let reach = Math.min(60.0 + up * 6.0, 520.0)
r.wx = r.sx
r.wz = r.sz
var best = up
for k in 0 .. 24 {
let ang = float(k) * Math.deg_to_rad(15.0)
for step in 1 .. 5 {
let d = reach * (float(step) / 4.0)
let px = r.sx + Math.cos(ang) * d
let pz = r.sz + Math.sin(ang) * d
let h = WildlifeWorld.ground(px, pz) - WildlifeWorld.water(px, pz)
if h < 3.0 or WildlifeWorld.slope(px, pz) > 0.6 { continue }
var score = h
if WildlifeWorld.forest(px, pz) { score = score - 25.0 }
if score < best {
best = score
r.wx = px
r.wz = pz
}
}
}
}
# the map's own k-th range for a species, when it paints one: no dice drawn
function wl_range_painted(wildlife_st: mut WildlifeState, sp: int, k: int) -> bool {
let q = wildlife_st.wl_ask_rg
q.r = 0.0
if not WildlifeWorld.range_at(sp, k, q) { return false }
let r = new WildRange
r.sp = sp
r.x = q.x
r.z = q.z
r.r = q.r
if r.r <= 0.0 { r.r = wl_sp(wildlife_st, sp).range }
r.sx = r.x
r.sz = r.z
wl_pick_winter(r)
push(wildlife_ranges(wildlife_st), r)
return true
}