From cc24409d87dcef31994a65dad29923b2fc5f16e4 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 20:35:13 +0300 Subject: [PATCH] ludic.wildlife: painted habitats and ranges through the port (habitat_in, range_at), the rule where none is painted Co-Authored-By: Claude Opus 5.5 --- changes/wildlife-painted.md | 7 ++ packages/ludic.wildlife/README.md | 10 ++- packages/ludic.wildlife/habitat.ludic | 14 +++- packages/ludic.wildlife/index.ludic | 1 + packages/ludic.wildlife/port.ludic | 5 ++ packages/ludic.wildlife/ranges.ludic | 33 ++------ packages/ludic.wildlife/spawn.ludic | 4 +- packages/ludic.wildlife/species.ludic | 1 + .../ludic.wildlife/tests/painted_test.ludic | 82 +++++++++++++++++++ packages/ludic.wildlife/winter.ludic | 45 ++++++++++ 10 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 changes/wildlife-painted.md create mode 100644 packages/ludic.wildlife/tests/painted_test.ludic create mode 100644 packages/ludic.wildlife/winter.ludic diff --git a/changes/wildlife-painted.md b/changes/wildlife-painted.md new file mode 100644 index 00000000..12936eec --- /dev/null +++ b/changes/wildlife-painted.md @@ -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. diff --git a/packages/ludic.wildlife/README.md b/packages/ludic.wildlife/README.md index 67cf0306..6652fdee 100644 --- a/packages/ludic.wildlife/README.md +++ b/packages/ludic.wildlife/README.md @@ -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 | diff --git a/packages/ludic.wildlife/habitat.ludic b/packages/ludic.wildlife/habitat.ludic index 229444e4..35c0e669 100644 --- a/packages/ludic.wildlife/habitat.ludic +++ b/packages/ludic.wildlife/habitat.ludic @@ -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 diff --git a/packages/ludic.wildlife/index.ludic b/packages/ludic.wildlife/index.ludic index cab30c54..30b9a1d2 100644 --- a/packages/ludic.wildlife/index.ludic +++ b/packages/ludic.wildlife/index.ludic @@ -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" diff --git a/packages/ludic.wildlife/port.ludic b/packages/ludic.wildlife/port.ludic index 34bd1198..d0798b6f 100644 --- a/packages/ludic.wildlife/port.ludic +++ b/packages/ludic.wildlife/port.ludic @@ -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 { } diff --git a/packages/ludic.wildlife/ranges.ludic b/packages/ludic.wildlife/ranges.ludic index 587ebf99..f31422c6 100644 --- a/packages/ludic.wildlife/ranges.ludic +++ b/packages/ludic.wildlife/ranges.ludic @@ -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) diff --git a/packages/ludic.wildlife/spawn.ludic b/packages/ludic.wildlife/spawn.ludic index b3403462..0174e0ea 100644 --- a/packages/ludic.wildlife/spawn.ludic +++ b/packages/ludic.wildlife/spawn.ludic @@ -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 { diff --git a/packages/ludic.wildlife/species.ludic b/packages/ludic.wildlife/species.ludic index a30524de..027f5760 100644 --- a/packages/ludic.wildlife/species.ludic +++ b/packages/ludic.wildlife/species.ludic @@ -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 diff --git a/packages/ludic.wildlife/tests/painted_test.ludic b/packages/ludic.wildlife/tests/painted_test.ludic new file mode 100644 index 00000000..1106e5c8 --- /dev/null +++ b/packages/ludic.wildlife/tests/painted_test.ludic @@ -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)) + } +} diff --git a/packages/ludic.wildlife/winter.ludic b/packages/ludic.wildlife/winter.ludic new file mode 100644 index 00000000..b707aea3 --- /dev/null +++ b/packages/ludic.wildlife/winter.ludic @@ -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 +}