diff --git a/packages/ludic.wildlife/README.md b/packages/ludic.wildlife/README.md new file mode 100644 index 00000000..37e504ed --- /dev/null +++ b/packages/ludic.wildlife/README.md @@ -0,0 +1,77 @@ +# ludic.wildlife + +The animals of a valley: the ranges each species keeps to, and every animal's day - wander about +home, go down to water and forage when thirst and hunger climb, notice the players and decide what +to make of them, flee, charge, come to food and lures, walk into snares; birds circle, glide down, +peck and lift off. Uses [`ludic.base`](../ludic.base/README.md) and nothing else. + +```ludic +import "ludic.wildlife" +``` + +The package owns the animals' state, the steering, the senses, the alert, feeding and lures, +spawning within ranges, its own dice (an `Rng`, never `Random.*`) and its own save section. It +never draws anything: the game makes an actor when the port says an animal was `born` and keys its +drawing by the animal's `key`, and it never decides what anything is worth - a feeding, a catch, a +shove, a print are facts the game drains. + +## Species are data + +The game hands the table in, once: `wildlife_species_add(s)` per species, in its own order, so the +entry's place is the species number. A `WildSpecies` is `key`, `walk`, `run`, `flee` (its nerve, +m), `home` (how far it roams), `habitat` (`WILD_MEADOW`, `_FOREST`, `_SHORE`, `_CLIFF`), `tracks`, +`size` (0 small - a snare holds it), `diet` (`WILD_PLANTS`, `_FISH`, `_HAY`, `_SEED`), `flies`, +`tame`, `charges`, `forget` (alert points a second it lets go of), `land_shy`, `land_chance`, +`keep_away`, `range` (how wide one of its ranges is) and `scale`. + +## The port + +```ludic +export port WildlifeWorld { # every member has a default: a flat dry meadow at noon, one silent player + 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 + 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 + wariness, charge_reach: fn() -> float # every nerve times, how close a charger lets you come + 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 + 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 + present: fn(int) -> bool # a spot or lure by uid is still there + next_id: fn() -> int # an animal's id across machines + born: fn(WildAnimal) -> void # draw it + placed: fn(WildRange, int) -> void # a range was set out: put its water and forage down +} +``` + +## API + +| | | +| --- | --- | +| `wildlife_species_clear()`, `wildlife_species_add(s) -> int`, `wildlife_species(sp)`, `wildlife_species_count()` | the table | +| `WildAnimal`, `wildlife_all()`, `wildlife_count(sp)`, `wildlife_total()`, `wildlife_by_nid(nid)`, `wildlife_count_all()` | the animals; `state` is `WILD_IDLE` .. `WILD_LAND` | +| `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_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 | +| `wildlife_saw(sp) -> bool`, `wildlife_seen(sp)`, `wildlife_seen_mask()`, `wildlife_set_seen(m)`, `wildlife_mark_fed(sp)`, `wildlife_fed_mask()`, `wildlife_fed_any()`, `wildlife_set_fed_any(b)` | this player's record | +| `wildlife_put_away()`, `wildlife_bring_back()`, `wildlife_away()` | a machine drawing another's animals keeps its own aside | +| `wildlife_facts() -> Queue` | `WILD_SPOTTED`, `_FLED`, `_FED` (`lure`, `owner`, `diet`), `_CAUGHT` (`lure`, `owner`), `_CHARGED`, `_SHOVED` (`player`), `_PRINT`, `_SCAT`, `_BED`, `_WENT` | +| `wildlife_system() -> System` | `"wildlife"`, `PH_SIMULATE`, no tick (the game steps it where the world is owned): reset, and its save section - the record and the found ranges | + +The alert is net: what a player's sight, scent and noise add is weighed against what the animal +forgets every tick, so it SETTLES at a level the distance sets rather than climbing to 100 and +sticking there - `wildlife_test` holds where it settles, not only how it rises. + +## Tests + +```bash +ludic test packages/ludic.wildlife +``` diff --git a/packages/ludic.wildlife/animal.ludic b/packages/ludic.wildlife/animal.ludic new file mode 100644 index 00000000..a5f3ef4c --- /dev/null +++ b/packages/ludic.wildlife/animal.ludic @@ -0,0 +1,64 @@ +# animal.ludic - one animal: where it is and where it lives, what it is doing, what it wants, and +# what it makes of the players. The game draws it from these and keeps its own drawing by `key`. +export const WILD_IDLE: int = 0 +export const WILD_WANDER: int = 1 +export const WILD_FLEE: int = 2 +export const WILD_APPROACH: int = 3 # walking to food, a lure or a snare +export const WILD_EAT: int = 4 +export const WILD_TRAPPED: int = 5 +export const WILD_CHARGE: int = 6 +export const WILD_FLY: int = 7 +export const WILD_PERCH: int = 8 +export const WILD_GO_DRINK: int = 9 +export const WILD_GO_FEED: int = 10 +export const WILD_DRINK: int = 11 # drinking or grazing at its spot +export const WILD_ALERT: int = 12 # head up, turned at what it noticed +export const WILD_WARY: int = 13 # turned away, a step or two of distance taken +export const WILD_LAND: int = 14 # a bird gliding down to a spot + +export property WildAnimal { + key: int = 0 # unique for the run, never reused: the game's drawing is found by it + nid: int = 0 # its id across machines (the port's next_id) + sp: int = 0 + legend: bool = false + variant: int = 0 # 0..11: which coat, the game's to read + scale: float = 1.0 + alive: bool = true + shown: bool = true # about, and to be drawn + pinned: bool = false # the AI leaves it where it stands + x: float = 0.0 + y: float = 0.0 + z: float = 0.0 + yaw: float = 0.0 + speed: float = 0.0 + phase: float = 0.0 # the gait, 0..2 pi, from the ground covered + fly_h: float = 0.0 + bank: float = 0.0 + state: int = 0 + timer: float = 0.0 + rg: int = -1 # the range it belongs to + home_x: float = 0.0 + home_z: float = 0.0 + tx: float = 0.0 + tz: float = 0.0 + thirst: float = 0.0 # 0..100: over 60 it goes looking for water + hunger: float = 0.0 + scat_t: float = 0.0 # game minutes until it might leave sign + track_d: float = 0.0 + spot: int = 0 # the uid of the water or forage it walks to, 0 none + spot_x: float = 0.0 + spot_z: float = 0.0 + spot_water: bool = false + lure: int = 0 # the uid of the food, lure or snare it walks to, 0 none + lure_kind: int = 0 + lure_x: float = 0.0 + lure_z: float = 0.0 + lure_owner: int = -1 + calm: float = 0.0 # game minutes of tolerating you, after being fed + fed: int = 0 + alert: float = 0.0 # 0..100: what it makes of you + sus_x: float = 0.0 # where it thinks the trouble is + sus_z: float = 0.0 + spook: float = 0.0 # game minutes of extra wariness after being flushed + seen: float = 0.0 # the alert it has already reacted to +} diff --git a/packages/ludic.wildlife/birds.ludic b/packages/ludic.wildlife/birds.ludic new file mode 100644 index 00000000..5000b509 --- /dev/null +++ b/packages/ludic.wildlife/birds.ludic @@ -0,0 +1,105 @@ +# birds.ludic - a bird circles its home at its own height, comes down now and then (for seed from +# further off than for nothing), glides in rather than dropping, pecks and drinks, and lifts off +export function wildlife_tick_bird(a: WildAnimal, dt: float) -> void { + a.timer = a.timer - dt + let dh = wl_near(a.x, a.z) + if a.state == WILD_LAND { + wl_land(a, dh, dt) + return + } + if a.state == WILD_PERCH { + wl_perch(a, dh, dt) + return + } + wl_circle(a, dt) + let s = wl_sp(a.sp) + if wl_bird_seed(a) and dh > s.flee * 0.7 and wl_bird_spot_ok(wl_best.x, wl_best.z) { + let off = 1.0 + wl_rnd() * 2.0 + let oa = wl_rnd() * WL_TAU + wl_come_down(a, wl_best.x + Math.sin(oa) * off, wl_best.z + Math.cos(oa) * off) + return + } + if a.timer < 0.0 and dh > s.land_shy { + let sx = a.x + (wl_rnd() - 0.5) * 24.0 + let sz = a.z + (wl_rnd() - 0.5) * 24.0 + if wl_rnd() < s.land_chance and wl_bird_spot_ok(sx, sz) { + wl_come_down(a, sx, sz) + return + } + } + if a.timer < 0.0 { + a.timer = 30.0 + a.home_x = a.home_x + (wl_rnd() - 0.5) * 120.0 + a.home_z = a.home_z + (wl_rnd() - 0.5) * 120.0 + } +} + +function wl_come_down(a: WildAnimal, x: float, z: float) -> void { + a.tx = x + a.tz = z + a.state = WILD_LAND + a.timer = 30.0 +} + +# a circle about home, climbing to its height: the eagle wide and high, the jay low +function wl_circle(a: WildAnimal, dt: float) -> void { + let s = wl_sp(a.sp) + a.fly_h = a.fly_h + (8.0 + s.home * 0.12 - a.fly_h) * Math.min(1.0, 0.4 * dt) + let r = s.home * 0.5 + let ang = Math.atan2(a.z - a.home_z, a.x - a.home_x) + s.walk / r * dt + let nx = a.home_x + Math.cos(ang) * r + let nz = a.home_z + Math.sin(ang) * r + a.yaw = Math.atan2(-(nx - a.x), -(nz - a.z)) + a.x = nx + a.z = nz + a.speed = s.walk + a.y = WildlifeWorld.ground(a.x, a.z) + a.fly_h + a.bank = 0.35 +} + +function wl_take_off(a: WildAnimal, h: float) -> void { + a.state = WILD_FLY + a.timer = 20.0 + wl_rnd() * 40.0 + a.fly_h = Math.max(a.fly_h, h) +} + +function wl_land(a: WildAnimal, dh: float, dt: float) -> void { + let s = wl_sp(a.sp) + a.fly_h = Math.max(0.0, a.fly_h - dt * 3.2) + a.speed = s.walk * 0.8 + wl_face(a, a.tx, a.tz, 2.4, dt) + wl_step(a, dt) + a.y = WildlifeWorld.ground(a.x, a.z) + a.fly_h + a.bank = 0.2 * Math.clamp(a.fly_h / 4.0, 0.0, 1.0) + if dh < s.flee or a.timer < -20.0 { + wl_take_off(a, 0.0) + return + } + if a.fly_h < 0.15 and wl_at_target(a, 2.0) { + a.state = WILD_PERCH + a.fly_h = 0.0 + a.timer = 8.0 + wl_rnd() * 16.0 + a.bank = 0.0 + } +} + +# on the ground it pecks at seed or takes a drink at the water's edge; it leaves no droppings +function wl_perch(a: WildAnimal, dh: float, dt: float) -> void { + a.speed = 0.0 + a.y = WildlifeWorld.ground(a.x, a.z) + a.bank = 0.0 + if wl_bird_seed(a) and Math.sqrt((wl_best.x - a.x) * (wl_best.x - a.x) + (wl_best.z - a.z) * (wl_best.z - a.z)) < 3.0 { + a.hunger = Math.max(a.hunger - 30.0 * dt, 0.0) + a.timer = Math.max(a.timer, 1.0) + if wl_rnd() < 0.12 * dt and WildlifeWorld.present(wl_best.uid) { wl_ate(a, wl_best.uid, wl_best.owner, WILD_SEED) } + } else if wl_wet(a.x - Math.sin(a.yaw) * 2.0, a.z - Math.cos(a.yaw) * 2.0, 0.05) { + a.thirst = Math.max(a.thirst - 30.0 * dt, 0.0) + } + if a.timer < 0.0 or dh < wl_sp(a.sp).flee { wl_take_off(a, 0.5) } +} + +# somewhere a bird would put down: flat and dry +function wl_bird_spot_ok(x: float, z: float) -> bool { + if wl_wet(x, z, 0.05) { return false } + return not (WildlifeWorld.slope(x, z) > 0.30) +} diff --git a/packages/ludic.wildlife/facts.ludic b/packages/ludic.wildlife/facts.ludic new file mode 100644 index 00000000..883b6861 --- /dev/null +++ b/packages/ludic.wildlife/facts.ludic @@ -0,0 +1,50 @@ +# facts.ludic - what happened, oldest first, for the game to drain: it decides what a sighting is +# worth, who a fed animal credits, what a snare's catch does to the snare, and what a shove costs +export const WILD_SPOTTED: int = 0 # a species came within sight of player 0 for the first time +export const WILD_FLED: int = 1 # it was flushed and ran (from `player`) +export const WILD_FED: int = 2 # it ate food someone set out (`lure`, `owner`, `diet`) +export const WILD_CAUGHT: int = 3 # it walked into a snare (`lure`, `owner`) +export const WILD_CHARGED: int = 4 # it came at `player` +export const WILD_SHOVED: int = 5 # and reached them, from (x, z) +export const WILD_PRINT: int = 6 # a walker left a print at (x, z), heading `yaw` +export const WILD_SCAT: int = 7 # droppings, near enough to a player to be found +export const WILD_BED: int = 8 # a long sit left crushed grass +export const WILD_WENT: int = 9 # its hours ended and it went for good (a legend's) + +export property WildFact { + what: int = 0 + key: int = 0 + nid: int = 0 + sp: int = 0 + legend: bool = false + x: float = 0.0 + z: float = 0.0 + yaw: float = 0.0 + scale: float = 1.0 + player: int = 0 + lure: int = 0 + owner: int = -1 + diet: int = 0 +} + +var wl_facts: Queue = null + +export function wildlife_facts() -> Queue { + if wl_facts == null { wl_facts = queue_new("wildlife.facts") } + return wl_facts +} + +function wl_fact(what: int, a: WildAnimal) -> WildFact { + let f = new WildFact + f.what = what + f.key = a.key + f.nid = a.nid + f.sp = a.sp + f.legend = a.legend + f.x = a.x + f.z = a.z + f.yaw = a.yaw + f.scale = a.scale + q_push(wildlife_facts(), f) + return f +} diff --git a/packages/ludic.wildlife/ground.ludic b/packages/ludic.wildlife/ground.ludic new file mode 100644 index 00000000..10eb9962 --- /dev/null +++ b/packages/ludic.wildlife/ground.ludic @@ -0,0 +1,89 @@ +# ground.ludic - a walker's tick: its needs, the alert, then the first of charging, reacting, going +# to its spot, walking to something set out, eating, idling and wandering that has it this tick +export function wildlife_tick_ground(a: WildAnimal, dt: float, gh: float) -> void { + wl_needs(a, gh) + let dh = wl_near(a.x, a.z) + let np = wl_near_i + a.timer = a.timer - dt + if a.calm > 0.0 { a.calm = a.calm - gh * 60.0 } + if a.spook > 0.0 { a.spook = a.spook - gh * 60.0 } + wl_alert(a, np, dh, dt) + if a.state == WILD_TRAPPED { + a.speed = 0.0 + return + } + if wl_charge(a, np, dh, dt) { return } + wl_posture(a, np, dt) + if wl_react(a, np, dt) { return } + if wl_seek(a, dt) { return } + if wl_drink(a, dt, gh) { return } + if wl_approach(a, dt) { return } + if wl_eat(a) { return } + let s = wl_sp(a.sp) + if a.state == WILD_IDLE { + a.speed = 0.0 + if a.timer < 0.0 { + if a.timer > -6.0 and s.tracks and wl_rnd() < 0.06 { wl_fact(WILD_BED, a) } # a long sit leaves crushed grass + a.state = WILD_WANDER + a.timer = 6.0 + wl_rnd() * 10.0 + wl_clamp(a) + wl_pick_target(a, s.home) + } + return + } + wl_face(a, a.tx, a.tz, 2.0, dt) + a.speed = s.walk + wl_step(a, dt) + if wl_at_target(a, 1.5) or a.timer < 0.0 { + a.state = WILD_IDLE + a.timer = 3.0 + wl_rnd() * 8.0 + } +} + +# a charger close enough comes at the nearest player; true while it is charging +function wl_charge(a: WildAnimal, np: int, dh: float, dt: float) -> bool { + let s = wl_sp(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 + a.timer = 2.5 + wl_fact(WILD_CHARGED, a).player = np + } + if a.state != WILD_CHARGE { return false } + wl_face(a, WildlifeWorld.player_x(np), WildlifeWorld.player_z(np), 4.0, dt) + a.speed = s.run + wl_step(a, dt) + if dh < 2.6 { + wl_fact(WILD_SHOVED, a).player = np + wl_wander(a, 8.0) + a.calm = 30.0 + } + if a.timer < 0.0 { wl_wander(a, 6.0) } + return true +} + +# Four postures read off the alert. Staring is a REACTION, not a condition: each times out and +# hands the animal back to its business, and only a fresh rise above `seen` looks up again. +function wl_posture(a: WildAnimal, np: int, dt: float) -> void { + if wl_sp(a.sp).tame or a.state == WILD_EAT { return } + if a.alert > 95.0 { + if a.state != WILD_FLEE { + a.state = WILD_FLEE + a.timer = 3.0 + wl_rnd() * 3.0 + a.spook = 120.0 + wl_rnd() * 180.0 + a.alert = 70.0 + wl_fact(WILD_FLED, a).player = np + } + } else if a.alert > 60.0 { + if a.state != WILD_WARY and a.state != WILD_FLEE and a.alert > a.seen { + a.state = WILD_WARY + a.timer = 2.0 + wl_rnd() * 2.0 + } + } else if a.alert > 25.0 { + if a.state != WILD_ALERT and a.state != WILD_WARY and a.alert > a.seen { + a.state = WILD_ALERT + a.timer = 1.5 + wl_rnd() * 2.5 + } + } + if a.alert > a.seen { a.seen = a.alert } + a.seen = Math.max(a.seen - 4.0 * dt, 0.0) +} diff --git a/packages/ludic.wildlife/guest.ludic b/packages/ludic.wildlife/guest.ludic new file mode 100644 index 00000000..5f4e94fc --- /dev/null +++ b/packages/ludic.wildlife/guest.ludic @@ -0,0 +1,17 @@ +# guest.ludic - a machine that draws another's animals puts its own away whole, and they come back +# as they were when it stops; in between the list is the copies it is handed (wildlife_make) +var wl_own: []WildAnimal = null + +export function wildlife_put_away() -> void { + if wl_own != null { return } + wl_own = wildlife_all() + wl_list = new []WildAnimal +} + +export function wildlife_bring_back() -> void { + if wl_own == null { return } + wl_list = wl_own + wl_own = null +} + +export function wildlife_away() -> bool { return wl_own != null } diff --git a/packages/ludic.wildlife/habitat.ludic b/packages/ludic.wildlife/habitat.ludic new file mode 100644 index 00000000..0206d969 --- /dev/null +++ b/packages/ludic.wildlife/habitat.ludic @@ -0,0 +1,38 @@ +# habitat.ludic - the right kind of ground for a species, asked of the port's ground, water, slope +# and forest; and a spot of it at a distance from a point, off the package's dice +var wl_px: float = 0.0 +var wl_pz: float = 0.0 + +function wl_wet(x: float, z: float, depth: float) -> bool { + return WildlifeWorld.ground(x, z) < WildlifeWorld.water(x, z) - depth +} + +export function wildlife_habitat_ok(hab: int, x: float, z: float) -> bool { + let h = WildlifeWorld.ground(x, z) + let w = WildlifeWorld.water(x, z) + if h < w + 0.8 { return false } + let s = WildlifeWorld.slope(x, z) + if hab == WILD_MEADOW { return s < 0.28 and not WildlifeWorld.forest(x, z) } + if hab == WILD_FOREST { return s < 0.5 and WildlifeWorld.forest(x, z) } + if hab == WILD_SHORE { return h < w + 4.0 and s < 0.4 } + return s > 0.25 or h > 80.0 +} + +# a spot of `hab` d0..d1 metres from (x0, z0); where it is, is wildlife_spot_x / _z +export function wildlife_find_spot(hab: int, x0: float, z0: float, d0: float, d1: float) -> bool { + for t in 0 .. 60 { + let ang = wl_rnd() * WL_TAU + let d = d0 + wl_rnd() * (d1 - d0) + let x = x0 + Math.cos(ang) * d + let z = z0 + Math.sin(ang) * d + if wildlife_habitat_ok(hab, x, z) { + wl_px = x + wl_pz = z + return true + } + } + return false +} + +export function wildlife_spot_x() -> float { return wl_px } +export function wildlife_spot_z() -> float { return wl_pz } diff --git a/packages/ludic.wildlife/index.ludic b/packages/ludic.wildlife/index.ludic new file mode 100644 index 00000000..832aa192 --- /dev/null +++ b/packages/ludic.wildlife/index.ludic @@ -0,0 +1,27 @@ +# ludic.wildlife - species handed in by the game, ranges placed against its ground, and every animal's +# day: wander, graze, drink, notice the players, flee, come to food; what happened is a fact +module ludic_wildlife uses ludic_base +numbers float +import "ludic.base" +import "species.ludic" +import "animal.ludic" +import "places.ludic" +import "port.ludic" +import "facts.ludic" +import "store.ludic" +import "guest.ludic" +import "record.ludic" +import "habitat.ludic" +import "ranges.ludic" +import "migrate.ludic" +import "senses.ludic" +import "steer.ludic" +import "lures.ludic" +import "needs.ludic" +import "ground.ludic" +import "react.ludic" +import "want.ludic" +import "birds.ludic" +import "run.ludic" +import "spawn.ludic" +import "system.ludic" diff --git a/packages/ludic.wildlife/lures.ludic b/packages/ludic.wildlife/lures.ludic new file mode 100644 index 00000000..89dfaa3a --- /dev/null +++ b/packages/ludic.wildlife/lures.ludic @@ -0,0 +1,102 @@ +# lures.ludic - what an animal would walk to: food of its diet, a lure of its diet, a snare it is +# small enough for (half the time: a curiosity), within 70 m - 175 m for food while feeding is taught +var wl_ask: WildLure = null +var wl_best: WildLure = null +var wl_seed_any: bool = false +var wl_seed_t: float = 0.0 + +function wl_lure_scratch() -> void { + if wl_ask == null { wl_ask = new WildLure } + if wl_best == null { wl_best = new WildLure } +} + +function wl_copy_lure(from: WildLure, to: WildLure) -> void { + to.uid = from.uid + to.kind = from.kind + to.diet = from.diet + to.size = from.size + to.x = from.x + to.z = from.z + to.owner = from.owner +} + +function wl_draws(a: WildAnimal, l: WildLure) -> bool { + let s = wl_sp(a.sp) + if l.kind == WILD_SNARE { return s.size <= l.size and wl_rnd() > 0.5 } + return l.diet == s.diet and l.diet != WILD_SEED +} + +# the nearest thing set out that draws it, into `wl_best`; false when none does +function wl_attraction(a: WildAnimal) -> bool { + wl_lure_scratch() + var bd = 70.0 + var got = false + let taught = WildlifeWorld.taught() + for i in 0 .. WildlifeWorld.lures() { + if not WildlifeWorld.lure(i, wl_ask) or not wl_draws(a, wl_ask) { continue } + let dx = wl_ask.x - a.x + let dz = wl_ask.z - a.z + var d = Math.sqrt(dx * dx + dz * dz) + if taught and wl_ask.kind == WILD_FEED { d = d * 0.4 } + if d < bd { + bd = d + got = true + wl_copy_lure(wl_ask, wl_best) + } + } + return got +} + +function wl_take_lure(a: WildAnimal) -> void { + a.lure = wl_best.uid + a.lure_kind = wl_best.kind + a.lure_x = wl_best.x + a.lure_z = wl_best.z + a.lure_owner = wl_best.owner + a.state = WILD_APPROACH +} + +# Is there ANY seed down? Asked once a second for the whole flock rather than per bird per tick. +function wl_seed_scan(dt: float) -> void { + wl_seed_t = wl_seed_t - dt + if wl_seed_t > 0.0 { return } + wl_seed_t = 1.0 + wl_seed_any = false + wl_lure_scratch() + for i in 0 .. WildlifeWorld.lures() { + if WildlifeWorld.lure(i, wl_ask) and wl_ask.kind == WILD_FEED and wl_ask.diet == WILD_SEED { + wl_seed_any = true + return + } + } +} + +# the nearest seed within 120 m, into `wl_best`: the one thing that brings an animal TO you +function wl_bird_seed(a: WildAnimal) -> bool { + if not wl_seed_any { return false } + wl_lure_scratch() + var bd = 120.0 + var got = false + for i in 0 .. WildlifeWorld.lures() { + if not WildlifeWorld.lure(i, wl_ask) or wl_ask.kind != WILD_FEED or wl_ask.diet != WILD_SEED { continue } + let dx = wl_ask.x - a.x + let dz = wl_ask.z - a.z + let d = Math.sqrt(dx * dx + dz * dz) + if d < bd { + bd = d + got = true + wl_copy_lure(wl_ask, wl_best) + } + } + return got +} + +# it ate what was set out: calm for three game hours, and the game credits whoever left it +function wl_ate(a: WildAnimal, uid: int, owner: int, diet: int) -> void { + a.calm = 180.0 + a.fed += 1 + let f = wl_fact(WILD_FED, a) + f.lure = uid + f.owner = owner + f.diet = diet +} diff --git a/packages/ludic.wildlife/migrate.ludic b/packages/ludic.wildlife/migrate.ludic new file mode 100644 index 00000000..90167c00 --- /dev/null +++ b/packages/ludic.wildlife/migrate.ludic @@ -0,0 +1,66 @@ +# migrate.ludic - each morning the ranges slide toward where the season wants them, and the animals +# that live in them are dragged along rather than teleported; an animal keeps inside its own +export function wildlife_ranges_migrate() -> void { + let rs = wildlife_ranges() + let w = WildlifeWorld.winterness() + for i in 0 .. len(rs) { + rs[i].x = Math.lerp(rs[i].sx, rs[i].wx, w) + rs[i].z = Math.lerp(rs[i].sz, rs[i].wz, w) + } + let all = wildlife_all() + for i in 0 .. len(all) { + let a = all[i] + let r = wildlife_range(a.rg) + if r == null { continue } + wl_clamp(a) + if wildlife_range_dist(r, a.home_x, a.home_z) > r.r * 1.2 { + a.home_x = r.x + a.home_z = r.z + a.x = r.x + a.z = r.z + a.y = WildlifeWorld.ground(a.x, a.z) + } + } +} + +# its home kept inside its range +function wl_clamp(a: WildAnimal) -> void { + let r = wildlife_range(a.rg) + if r == null { return } + let d = wildlife_range_dist(r, a.home_x, a.home_z) + if d < r.r { return } + a.home_x = r.x + (a.home_x - r.x) / d * (r.r * 0.9) + a.home_z = r.z + (a.home_z - r.z) / d * (r.r * 0.9) +} + +# how many live in range `i`, legends apart +export function wildlife_range_alive(i: int) -> int { + var n = 0 + let all = wildlife_all() + for k in 0 .. len(all) { if all[k].alive and not all[k].legend and all[k].rg == i { n += 1 } } + return n +} + +# how many are alive in the whole valley, legends apart +export function wildlife_total() -> int { + var n = 0 + let all = wildlife_all() + for k in 0 .. len(all) { if all[k].alive and not all[k].legend { n += 1 } } + return n +} + +# the nearest range of `sp` a player has found, to (x, z), or -1 +export function wildlife_range_found_near(sp: int, x: float, z: float) -> int { + var best = -1 + var bd = 99999.0 + let rs = wildlife_ranges() + for i in 0 .. len(rs) { + if rs[i].sp != sp or not rs[i].found { continue } + let d = wildlife_range_dist(rs[i], x, z) + if d < bd { + bd = d + best = i + } + } + return best +} diff --git a/packages/ludic.wildlife/needs.ludic b/packages/ludic.wildlife/needs.ludic new file mode 100644 index 00000000..6667e8fc --- /dev/null +++ b/packages/ludic.wildlife/needs.ludic @@ -0,0 +1,22 @@ +# needs.ludic - thirst and hunger climb with the game hours (midday is thirsty work, a run costs more +# than a graze, dawn and dusk are when they go down to drink), and a walker leaves droppings +function wl_needs(a: WildAnimal, gh: float) -> void { + let h = WildlifeWorld.hour() + var thirst = 8.0 + var hunger = 5.0 + if h > 11.0 and h < 16.0 { thirst = 12.0 } + if a.state == WILD_FLEE or a.state == WILD_CHARGE { + thirst = thirst * 3.0 + hunger = hunger * 2.0 + } + if (h > 5.0 and h < 8.0) or (h > 18.0 and h < 21.0) { thirst = thirst * 1.8 } + a.thirst = Math.min(a.thirst + thirst * gh, 100.0) + a.hunger = Math.min(a.hunger + hunger * gh, 100.0) + if not wl_sp(a.sp).tracks { return } + a.scat_t = a.scat_t - gh * 60.0 + if a.scat_t > 0.0 { return } + a.scat_t = 120.0 + wl_rnd() * 300.0 + wl_near(a.x, a.z) + if wl_pdist(wl_near_i, a) > 500.0 { return } # further than 500 m from everyone: nobody would find it + wl_fact(WILD_SCAT, a) +} diff --git a/packages/ludic.wildlife/package.ludic b/packages/ludic.wildlife/package.ludic new file mode 100644 index 00000000..d3dad094 --- /dev/null +++ b/packages/ludic.wildlife/package.ludic @@ -0,0 +1,5 @@ +# ludic.wildlife - the animals of a valley: where they live, what they do all day, what they make of +# the players, and what they come to. Uses ludic.base and nothing else. +package "ludic.wildlife" +version "0.1.0" +kind source diff --git a/packages/ludic.wildlife/places.ludic b/packages/ludic.wildlife/places.ludic new file mode 100644 index 00000000..d3f05303 --- /dev/null +++ b/packages/ludic.wildlife/places.ludic @@ -0,0 +1,38 @@ +# places.ludic - the places an animal's day is made of: its range, the water and forage it walks +# to (the game's, found through the port), and food, lures and snares players set out +export const WILD_FEED: int = 0 # food put down: eaten, and the one who left it is credited +export const WILD_LURE: int = 1 # a lure: it comes, stands curious a while, and drifts off +export const WILD_SNARE: int = 2 # a snare: a small enough animal walking onto it is held + +# a disc a species keeps to, with an address for each end of the year: `x`, `z` is where it is +# today, the summer and winter centres lerped by the season (WildlifeWorld.winterness) +export property WildRange { + sp: int = 0 + x: float = 0.0 + z: float = 0.0 + r: float = 0.0 + found: bool = false # a player has been inside it + sx: float = 0.0 + sz: float = 0.0 + wx: float = 0.0 + wz: float = 0.0 +} + +# water or forage the game offers an animal (WildlifeWorld.spot fills one) +export property WildSpot { + uid: int = 0 + x: float = 0.0 + z: float = 0.0 + water: bool = false +} + +# something set out (WildlifeWorld.lure fills one): what it is, what eats it, where, whose +export property WildLure { + uid: int = 0 + kind: int = 0 # WILD_FEED, WILD_LURE, WILD_SNARE + diet: int = 0 # the diet it draws (WILD_PLANTS .. WILD_SEED) + size: int = 0 # a snare: the largest size it holds + x: float = 0.0 + z: float = 0.0 + owner: int = -1 # whoever set it out; the game decides what it means +} diff --git a/packages/ludic.wildlife/port.ludic b/packages/ludic.wildlife/port.ludic new file mode 100644 index 00000000..954217c1 --- /dev/null +++ b/packages/ludic.wildlife/port.ludic @@ -0,0 +1,73 @@ +# port.ludic - what the animals ask the world. Every member has a default: unbound, the ground is a +# flat dry meadow at 0 in the middle of a summer's day, one player stands at the origin making no +# sound, nothing is set out and nothing blocks a look. +export const WILD_MOVING: int = 0 # a player's stillness +export const WILD_STILL: int = 1 +export const WILD_SITTING: int = 2 + +export const WILD_ABOUT: int = 0 # what an animal is doing at this hour (WildlifeWorld.about) +export const WILD_AWAY: int = 1 # lying up: not drawn, not ticked +export const WILD_GONE: int = 2 # gone for good: a fact says so + +export port WildlifeWorld { + ground: fn(float, float) -> float = fn wl_zero2 + water: fn(float, float) -> float = fn wl_dry # the water's surface there + 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 + players: fn() -> int = fn wl_one # player slots + player_on: fn(int) -> bool = fn wl_first + player_x: fn(int) -> float = fn wl_zero1 + player_y: fn(int) -> float = fn wl_zero1 + player_z: fn(int) -> float = fn wl_zero1 + hidden: fn(int) -> bool = fn wl_no1 # in a hide: nothing sees them + noise: fn(int) -> float = fn wl_zero1 # 0..1 + scent: fn(int) -> float = fn wl_zero1 # 0..1 + stillness: fn(int) -> int = fn wl_moving # WILD_MOVING, _STILL, _SITTING + hour: fn() -> float = fn wl_noon + light: fn() -> float = fn wl_one_f # how well it sees now: 1 by day + winterness: fn() -> float = fn wl_zero0 # 0 full summer .. 1 full winter + wind: fn() -> float = fn wl_zero0 # 0..1 + wind_dir: fn() -> float = fn wl_zero0 # the yaw the wind blows toward + wariness: fn() -> float = fn wl_one_f # every nerve, times + charge_reach: fn() -> float = fn wl_nine # how close a charger lets you come, m + 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 + 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 + lures: fn() -> int = fn wl_none0 # how many things to ask `lure` about + lure: fn(int, WildLure) -> bool = fn wl_no_lure # the i-th, if it is one and set out + present: fn(int) -> bool = fn wl_yes1 # a spot or lure by uid is still there + next_id: fn() -> int = fn wl_next_id + born: fn(WildAnimal) -> void = fn wl_nothing # a new animal: draw it + placed: fn(WildRange, int) -> void = fn wl_range_nothing # a range was set out +} +function wl_zero0() -> float { return 0.0 } +function wl_zero1(p: int) -> float { return 0.0 } +function wl_zero2(x: float, z: float) -> float { return 0.0 } +function wl_dry(x: float, z: float) -> float { return -1000.0 } +function wl_no0() -> bool { return false } +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_one() -> int { return 1 } +function wl_none0() -> int { return 0 } +function wl_first(p: int) -> bool { return p == 0 } +function wl_moving(p: int) -> int { return WILD_MOVING } +function wl_noon() -> float { return 12.0 } +function wl_one_f() -> float { return 1.0 } +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_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 { } +function wl_range_nothing(r: WildRange, i: int) -> void { } +var wl_ids: int = 0 +function wl_next_id() -> int { + wl_ids += 1 + return wl_ids +} diff --git a/packages/ludic.wildlife/ranges.ludic b/packages/ludic.wildlife/ranges.ludic new file mode 100644 index 00000000..c830523b --- /dev/null +++ b/packages/ludic.wildlife/ranges.ludic @@ -0,0 +1,97 @@ +# ranges.ludic - a species keeps to discs placed once, against the ground, 120-700 m out from where +# the valley starts; each winters lower, toward the timber, as far down as it summered high +var wl_ranges: []WildRange = null +var wl_found: []int = null # found flags a load brought before the ranges were set out + +export function wildlife_ranges() -> []WildRange { + if wl_ranges == null { wl_ranges = new []WildRange } + return wl_ranges +} + +export function wildlife_range(i: int) -> WildRange { + if i < 0 or i >= len(wildlife_ranges()) { return null } + return wildlife_ranges()[i] +} + +export function wildlife_range_dist(r: WildRange, x: float, z: float) -> float { + let dx = r.x - x + let dz = r.z - z + return Math.sqrt(dx * dx + dz * dz) +} + +function wl_range_clashes(sp: int, x: float, z: float) -> bool { + let rs = wildlife_ranges() + for j in 0 .. len(rs) { if rs[j].sp == sp and wildlife_range_dist(rs[j], x, z) < wl_sp(sp).range * 1.6 { return true } } + return false +} + +function wl_range_try(sp: int, x0: float, z0: float) -> bool { + let s = wl_sp(sp) + for tries in 0 .. 80 { + if not wildlife_find_spot(s.habitat, x0, z0, 120.0, 700.0) { continue } + let dx = wl_px - x0 + let dz = wl_pz - z0 + if Math.sqrt(dx * dx + dz * dz) < s.keep_away or wl_range_clashes(sp, wl_px, wl_pz) { continue } + let r = new WildRange + r.sp = sp + r.x = wl_px + r.z = wl_pz + r.r = s.range + r.sx = wl_px + r.sz = wl_pz + wl_pick_winter(r) + push(wildlife_ranges(), r) + return true + } + return false +} + +# every species' ranges about (x0, z0), then the game told of each (WildlifeWorld.placed) +export function wildlife_ranges_setup(x0: float, z0: float) -> void { + wildlife_seed(613) + wl_ranges = new []WildRange + for sp in 0 .. wildlife_species_count() { + for k in 0 .. WildlifeWorld.ranges(sp) { if not wl_range_try(sp, x0, z0) { break } } + } + wl_found_apply() + let rs = wildlife_ranges() + 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(x: float, z: float) -> void { + let rs = wildlife_ranges() + for i in 0 .. len(rs) { if not rs[i].found and wildlife_range_dist(rs[i], x, z) < rs[i].r { rs[i].found = true } } +} + +function wl_found_apply() -> void { + if wl_found == null { return } + let rs = wildlife_ranges() + for i in 0 .. len(rs) { if i < len(wl_found) { rs[i].found = wl_found[i] != 0 } } +} diff --git a/packages/ludic.wildlife/react.ludic b/packages/ludic.wildlife/react.ludic new file mode 100644 index 00000000..2cdd3108 --- /dev/null +++ b/packages/ludic.wildlife/react.ludic @@ -0,0 +1,79 @@ +# 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(a: WildAnimal, np: int, dt: float) -> bool { + let s = wl_sp(a.sp) + if a.state == WILD_ALERT { + a.speed = 0.0 + wl_face(a, a.sus_x, a.sus_z, 2.2, dt) + if a.timer < 0.0 { wl_wander(a, 3.0 + wl_rnd() * 4.0) } + return true + } + if a.state == WILD_WARY { + wl_face(a, a.x + (a.x - a.sus_x), a.z + (a.z - a.sus_z), 2.6, dt) + a.speed = s.walk * 0.8 + wl_step(a, dt) + if a.timer < 0.0 { wl_wander(a, 3.0 + wl_rnd() * 4.0) } + return true + } + if a.state == WILD_FLEE { + wl_face(a, a.x + (a.x - WildlifeWorld.player_x(np)), a.z + (a.z - WildlifeWorld.player_z(np)), 5.0, dt) + a.speed = s.run + wl_step(a, dt) + if a.timer < 0.0 { + wl_rehome(a) + wl_wander(a, 5.0) + } + return true + } + return false +} + +# walking to (or reached) something set out; true while it is +function wl_approach(a: WildAnimal, dt: float) -> bool { + if (a.state == WILD_IDLE or a.state == WILD_WANDER) and wl_attraction(a) { wl_take_lure(a) } + if a.state != WILD_APPROACH { return false } + if a.lure == 0 or not WildlifeWorld.present(a.lure) { + a.lure = 0 + a.state = WILD_IDLE + a.timer = 2.0 + return true + } + wl_face(a, a.lure_x, a.lure_z, 3.0, dt) + a.speed = wl_sp(a.sp).walk + let dx = a.lure_x - a.x + let dz = a.lure_z - a.z + if Math.sqrt(dx * dx + dz * dz) < 1.3 { + a.speed = 0.0 + wl_arrive(a) + return true + } + wl_step(a, dt) + return true +} + +# at a snare it is held; at food it eats for 14 s; at a lure it stands curious for 9 +function wl_arrive(a: WildAnimal) -> void { + a.state = WILD_EAT + a.timer = 9.0 + if a.lure_kind == WILD_FEED { a.timer = 14.0 } + if a.lure_kind != WILD_SNARE { return } + a.state = WILD_TRAPPED + a.x = a.lure_x + a.z = a.lure_z + a.y = WildlifeWorld.ground(a.x, a.z) + let f = wl_fact(WILD_CAUGHT, a) + f.lure = a.lure + f.owner = a.lure_owner +} + +# eating what it came to; when it is done, food is credited and it wanders off +function wl_eat(a: WildAnimal) -> bool { + if a.state != WILD_EAT { return false } + a.speed = 0.0 + if a.timer < 0.0 { + if a.lure != 0 and a.lure_kind == WILD_FEED and WildlifeWorld.present(a.lure) { wl_ate(a, a.lure, a.lure_owner, wl_sp(a.sp).diet) } + a.lure = 0 + wl_wander(a, 4.0) + } + return true +} diff --git a/packages/ludic.wildlife/record.ludic b/packages/ludic.wildlife/record.ludic new file mode 100644 index 00000000..b30dfff9 --- /dev/null +++ b/packages/ludic.wildlife/record.ludic @@ -0,0 +1,34 @@ +# record.ludic - what this player has met: the species seen up close and the species fed, as masks +# by species number, and whether anything has been fed at all +var wl_seen: int = 0 +var wl_fed: int = 0 +var wl_fed_any: bool = false + +export function wildlife_seen(sp: int) -> bool { return (wl_seen & (1 << sp)) != 0 } +export function wildlife_seen_mask() -> int { return wl_seen } +export function wildlife_fed_mask() -> int { return wl_fed } +export function wildlife_fed_any() -> bool { return wl_fed_any } + +# seen up close (a guest's own pass, droppings read, a sighting here); true the first time +export function wildlife_saw(sp: int) -> bool { + if wildlife_seen(sp) { return false } + wl_seen = wl_seen | (1 << sp) + return true +} + +export function wildlife_set_seen(mask: int) -> void { wl_seen = mask } + +# a species fed, credited to this player +export function wildlife_mark_fed(sp: int) -> void { + wl_fed = wl_fed | (1 << sp) + wl_fed_any = true +} + +# nothing fed yet (a test that feeds again), or the flag as given +export function wildlife_set_fed_any(on: bool) -> void { wl_fed_any = on } + +function wl_record_reset() -> void { + wl_seen = 0 + wl_fed = 0 + wl_fed_any = false +} diff --git a/packages/ludic.wildlife/run.ludic b/packages/ludic.wildlife/run.ludic new file mode 100644 index 00000000..df037d85 --- /dev/null +++ b/packages/ludic.wildlife/run.ludic @@ -0,0 +1,58 @@ +# run.ludic - one step of every animal: the port says whether it is about at this hour; anything +# within 900 m of a player thinks; the gait follows the ground covered; player 0 sees what is near +export function wildlife_tick(dt: float, gh: float) -> void { + wl_seed_scan(dt) + let all = wildlife_all() + for i in 0 .. len(all) { wl_tick_one(all[i], dt, gh) } +} + +function wl_tick_one(a: WildAnimal, dt: float, gh: float) -> void { + if not a.alive { return } + let about = WildlifeWorld.about(a) + if about == WILD_AWAY { + a.shown = false + return + } + if about == WILD_GONE { + wildlife_remove(a) + wl_fact(WILD_WENT, a) + return + } + a.shown = true + let dh = wl_near(a.x, a.z) + if dh > 900.0 { return } + let s = wl_sp(a.sp) + if wl_hold { } + else if a.pinned { a.speed = 0.0 } + else if s.flies { wildlife_tick_bird(a, dt) } else { wildlife_tick_ground(a, dt, gh) } + var stride = 1.2 * a.scale + if s.flies { stride = 0.6 } + a.phase = (a.phase + a.speed * dt / stride * WL_TAU) % WL_TAU + if WildlifeWorld.player_on(0) and wl_pdist(0, a) < 60.0 and wildlife_saw(a.sp) { wl_fact(WILD_SPOTTED, a) } +} + +# the gait advanced by a copy the game moves itself (a guest's copy of the host's animal) +export function wildlife_stride(a: WildAnimal, dt: float) -> void { + var stride = 1.2 * a.scale + if wl_sp(a.sp).flies { stride = 0.6 } + a.phase = (a.phase + a.speed * dt / stride * WL_TAU) % WL_TAU +} + +# a snare's catch let go: it bolts, and it holds nothing against anyone +export function wildlife_release(a: WildAnimal) -> void { + if a == null { return } + a.state = WILD_FLEE + a.timer = 4.0 + a.calm = 0.0 + a.lure = 0 +} + +# the animal a snare holds, by the snare's uid +export function wildlife_in_snare(uid: int) -> WildAnimal { + let all = wildlife_all() + for i in 0 .. len(all) { + let a = all[i] + if a.alive and a.state == WILD_TRAPPED and a.lure == uid { return a } + } + return null +} diff --git a/packages/ludic.wildlife/senses.ludic b/packages/ludic.wildlife/senses.ludic new file mode 100644 index 00000000..554be6af --- /dev/null +++ b/packages/ludic.wildlife/senses.ludic @@ -0,0 +1,97 @@ +# senses.ludic - what an animal makes of one player: sight (a clear line, closer is worse, the dark +# helps), scent (downwind, further than sight), noise; the rise is NET of how fast it forgets, so +# an alert settles where the distance puts it instead of climbing to the top and sticking +var wl_near_i: int = 0 + +# the nearest player's distance; `wl_near_i` says who +function wl_near(x: float, z: float) -> float { + var best = 999999999999.0 + wl_near_i = 0 + for p in 0 .. WildlifeWorld.players() { + if not WildlifeWorld.player_on(p) { continue } + let d = wl_pdist2(p, x, z) + if d < best { + best = d + wl_near_i = p + } + } + return Math.sqrt(best) +} + +function wl_pdist2(p: int, x: float, z: float) -> float { + let dx = WildlifeWorld.player_x(p) - x + let dz = WildlifeWorld.player_z(p) - z + return dx * dx + dz * dz +} + +function wl_pdist(p: int, a: WildAnimal) -> float { return Math.sqrt(wl_pdist2(p, a.x, a.z)) } + +# -1 (it is upwind of player p: it cannot smell them) .. 1 (straight downwind, it gets everything) +export function wildlife_downwind(a: WildAnimal, p: int) -> float { + let dx = a.x - WildlifeWorld.player_x(p) + let dz = a.z - WildlifeWorld.player_z(p) + let d = Math.max(Math.sqrt(dx * dx + dz * dz), 0.01) + let dir = WildlifeWorld.wind_dir() + return dx / d * -Math.sin(dir) + dz / d * -Math.cos(dir) +} + +export function wildlife_can_see(a: WildAnimal, p: int) -> bool { + if WildlifeWorld.hidden(p) { return false } + return WildlifeWorld.sight(a.x, a.y + 0.9, a.z, WildlifeWorld.player_x(p), WildlifeWorld.player_y(p) + 0.9, WildlifeWorld.player_z(p)) +} + +# 1 at the animal, 0 at `reach` and beyond, squared: the far half of a range is almost free +function wl_near_sq(d: float, reach: float) -> float { + let n = Math.max(1.0 - d / reach, 0.0) + return n * n +} + +# alert points a second from player p at `dh` metres; 0 when nothing of them reaches it +export function wildlife_alert_rate(a: WildAnimal, p: int, dh: float) -> float { + let s = wl_sp(a.sp) + if s.tame or a.calm > 0.0 { return 0.0 } + if (a.state == WILD_APPROACH or a.state == WILD_EAT) and a.lure != 0 and a.lure_kind == WILD_FEED and dh > 5.0 and WildlifeWorld.taught() { return 0.0 } + var reach = s.flee * WildlifeWorld.wariness() + if a.spook > 0.0 { reach = reach * 1.25 } + var rate = 0.0 + if dh < reach * 1.9 and wildlife_can_see(a, p) { rate = rate + 55.0 * wl_near_sq(dh, reach * 1.9) * WildlifeWorld.light() } + let wind = wildlife_downwind(a, p) + let ws = WildlifeWorld.wind() + if wind > 0.0 and ws > 0.08 and dh < reach * 3.2 { + rate = rate + 30.0 * wl_near_sq(dh, reach * 3.2) * wind * (WildlifeWorld.scent(p) * Math.min(1.0, 0.4 + ws)) + } + let noise = WildlifeWorld.noise(p) + if noise > 0.12 and dh < reach * 1.5 * noise { rate = rate + 40.0 * wl_near_sq(dh, reach * 1.5 * noise) * noise } + return rate +} + +# how fast it forgets player p: standing still buys it, sitting down buys more +export function wildlife_alert_decay(a: WildAnimal, p: int) -> float { + var d = wl_sp(a.sp).forget + let still = WildlifeWorld.stillness(p) + if still == WILD_SITTING { d = d * 2.5 } else if still == WILD_STILL { d = d * 1.6 } + if a.spook > 0.0 { d = d * 0.7 } + return d +} + +# the alert rises and falls with the player who nets it the most, and it watches them +function wl_alert(a: WildAnimal, np: int, dh: float, dt: float) -> void { + var rise = wildlife_alert_rate(a, np, dh) + var fall = wildlife_alert_decay(a, np) + var who = np + for p in 0 .. WildlifeWorld.players() { + if p == np or not WildlifeWorld.player_on(p) { continue } + let r2 = wildlife_alert_rate(a, p, wl_pdist(p, a)) + let f2 = wildlife_alert_decay(a, p) + if r2 - f2 > rise - fall { + rise = r2 + fall = f2 + who = p + } + } + if rise > 0.05 { + a.sus_x = WildlifeWorld.player_x(who) + a.sus_z = WildlifeWorld.player_z(who) + } + a.alert = Math.clamp(a.alert + (rise - fall) * dt, 0.0, 100.0) +} diff --git a/packages/ludic.wildlife/spawn.ludic b/packages/ludic.wildlife/spawn.ludic new file mode 100644 index 00000000..c64f4a94 --- /dev/null +++ b/packages/ludic.wildlife/spawn.ludic @@ -0,0 +1,52 @@ +# spawn.ludic - a valley filled: ranges set out, then each range to its target on its own habitat. +# Each morning the ranges move with the season and every range under its target gets ONE back, +# out of every player's sight: a valley recovers, it does not refill. +export function wildlife_spawn(x0: float, z0: float) -> void { + wildlife_ranges_setup(x0, z0) + wildlife_seed(97) + let rs = wildlife_ranges() + for i in 0 .. len(rs) { wl_fill(i, WildlifeWorld.per_range(rs[i].sp)) } + wildlife_ranges_migrate() +} + +# up to `want` more in range i; returns how many went in +function wl_fill(i: int, want: int) -> int { + let r = wildlife_range(i) + var made = 0 + for k in 0 .. want { + if not wildlife_find_spot(wl_sp(r.sp).habitat, r.x, r.z, 0.0, r.r) { continue } + let a = wildlife_new(r.sp, wl_px, wl_pz, false) + if a != null { + a.rg = i + made += 1 + } + } + return made +} + +# each morning: the ranges slide with the season, and the valley gets back what it lost +export function wildlife_morning() -> int { + wildlife_ranges_migrate() + return wildlife_repopulate() +} + +# one newcomer for each range under its target, never within 200 m of a player; how many came +export function wildlife_repopulate() -> int { + let rs = wildlife_ranges() + var added = 0 + for i in 0 .. len(rs) { + let want = WildlifeWorld.per_range(rs[i].sp) + if want == 0 or wildlife_range_alive(i) >= want { continue } + for t in 0 .. 12 { + if not wildlife_find_spot(wl_sp(rs[i].sp).habitat, rs[i].x, rs[i].z, 0.0, rs[i].r) { continue } + if wl_near(wl_px, wl_pz) < 200.0 { continue } + let a = wildlife_new(rs[i].sp, wl_px, wl_pz, false) + if a != null { + a.rg = i + added += 1 + } + break + } + } + return added +} diff --git a/packages/ludic.wildlife/species.ludic b/packages/ludic.wildlife/species.ludic new file mode 100644 index 00000000..30b82c1f --- /dev/null +++ b/packages/ludic.wildlife/species.ludic @@ -0,0 +1,57 @@ +# species.ludic - what a kind of animal IS, as far as its day goes: the game hands the table in, +# one entry per species, and the entry's place in it is the species' number +export const WILD_MEADOW: int = 0 +export const WILD_FOREST: int = 1 +export const WILD_SHORE: int = 2 +export const WILD_CLIFF: int = 3 + +# what it eats, and so what food and which lures it comes to +export const WILD_PLANTS: int = 0 +export const WILD_FISH: int = 1 +export const WILD_HAY: int = 2 +export const WILD_SEED: int = 3 + +export property WildSpecies { + key: string = "" + walk: float = 1.0 # m/s + run: float = 1.0 # m/s + flee: float = 30.0 # its nerve: how far off a player starts to reach it, m + home: float = 60.0 # how far it roams about its home, m + habitat: int = 0 # WILD_MEADOW .. WILD_CLIFF + tracks: bool = false # a walker that leaves prints (and droppings, and beds) + size: int = 0 # 0 small (a snare holds it), 1 medium, 2 large + diet: int = 0 # WILD_PLANTS .. WILD_SEED + flies: bool = false # a bird: it circles, lands and lifts off + tame: bool = false # minds nobody (a horse) + charges: bool = false # comes at a player who gets too close (a bear) + forget: float = 2.5 # alert points a second it lets go of + land_shy: float = 80.0 # a bird: how close it comes down with somebody about, m + land_chance: float = 0.1 # and how readily it comes down at all + keep_away: float = 60.0 # how far its ranges keep from where the valley starts, m + range: float = 100.0 # how wide one of its ranges is, m + scale: float = 1.0 # its size, times +} + +var wl_species: []WildSpecies = null + +function wl_sp_all() -> []WildSpecies { + if wl_species == null { wl_species = new []WildSpecies } + return wl_species +} + +export function wildlife_species_clear() -> void { wl_species = new []WildSpecies } + +# the next species; returns its number +export function wildlife_species_add(s: WildSpecies) -> int { + push(wl_sp_all(), s) + return len(wl_sp_all()) - 1 +} + +export function wildlife_species_count() -> int { return len(wl_sp_all()) } + +export function wildlife_species(sp: int) -> WildSpecies { + if sp < 0 or sp >= len(wl_sp_all()) { return null } + return wl_sp_all()[sp] +} + +function wl_sp(sp: int) -> WildSpecies { return wl_sp_all()[sp] } diff --git a/packages/ludic.wildlife/steer.ludic b/packages/ludic.wildlife/steer.ludic new file mode 100644 index 00000000..510b84e7 --- /dev/null +++ b/packages/ludic.wildlife/steer.ludic @@ -0,0 +1,64 @@ +# steer.ludic - turning toward a point at a rate, a step along the facing that turns away from water +# and a climb, and a target somewhere about home; a walker leaves a print every 2.8 m +function wl_wrap(a: float) -> float { + var r = a + while r > 3.1415927 { r = r - WL_TAU } + while r < -3.1415927 { r = r + WL_TAU } + return r +} + +function wl_face(a: WildAnimal, tx: float, tz: float, rate: float, dt: float) -> void { + let want = Math.atan2(-(tx - a.x), -(tz - a.z)) + let r = rate * dt + a.yaw = a.yaw + Math.clamp(wl_wrap(want - a.yaw), -r, r) +} + +# true when it moved +function wl_step(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 + let nh = WildlifeWorld.ground(nx, nz) + if nh < WildlifeWorld.water(nx, nz) + 0.6 or nh - a.y > d * 1.2 { + a.yaw = a.yaw + Math.deg_to_rad(75.0) + a.timer = 0.0 + return false + } + a.x = nx + a.z = nz + a.y = nh + a.track_d = a.track_d + d + if wl_sp(a.sp).tracks and a.track_d > 2.8 { + a.track_d = 0.0 + wl_fact(WILD_PRINT, a) + } + return true +} + +function wl_pick_target(a: WildAnimal, radius: float) -> void { + let ang = wl_rnd() * WL_TAU + let d = wl_rnd() * radius + a.tx = a.home_x + Math.cos(ang) * d + a.tz = a.home_z + Math.sin(ang) * d +} + +function wl_at_target(a: WildAnimal, r: float) -> bool { + let dx = a.tx - a.x + let dz = a.tz - a.z + return dx * dx + dz * dz < r * r +} + +# back to wandering about home after `t` seconds of whatever it was doing +function wl_wander(a: WildAnimal, t: float) -> void { + a.state = WILD_WANDER + a.timer = t + wl_pick_target(a, wl_sp(a.sp).home) +} + +# home where it stands now, kept inside its range +function wl_rehome(a: WildAnimal) -> void { + a.home_x = a.x + a.home_z = a.z + wl_clamp(a) +} diff --git a/packages/ludic.wildlife/store.ludic b/packages/ludic.wildlife/store.ludic new file mode 100644 index 00000000..067b61db --- /dev/null +++ b/packages/ludic.wildlife/store.ludic @@ -0,0 +1,95 @@ +# store.ludic - the animals, their own dice, and the verbs that make and take one away +var wl_list: []WildAnimal = null +var wl_keys: int = 0 +var wl_dice: Rng = null +var wl_hold: bool = false # staged: nobody thinks, everybody keeps their speed +const WL_TAU: float = 6.2831853 + +export function wildlife_all() -> []WildAnimal { + if wl_list == null { wl_list = new []WildAnimal } + return wl_list +} + +export function wildlife_count_all() -> int { return len(wildlife_all()) } + +# a new valley: every animal forgotten, quietly (the game has already let its drawings go) +export function wildlife_clear() -> void { wl_list = new []WildAnimal } + +function wl_rng() -> Rng { + if wl_dice == null { wl_dice = rng_new(97) } + return wl_dice +} + +function wl_rnd() -> float { return rng_float(wl_rng()) } + +# the package's dice from a known place (a spawn, a test) +export function wildlife_seed(seed: int) -> void { rng_seed(wl_rng(), seed) } + +# an individual exactly as given: the host's own, or the host's on a guest +export function wildlife_make(sp: int, x: float, z: float, legend: bool, yaw: float, variant: int, scale: float, nid: int) -> WildAnimal { + let s = wildlife_species(sp) + if s == null { return null } + let a = new WildAnimal + wl_keys += 1 + a.key = wl_keys + a.nid = nid + a.sp = sp + a.x = x + a.z = z + a.home_x = x + a.home_z = z + a.tx = x + a.tz = z + a.y = WildlifeWorld.ground(x, z) + a.yaw = yaw + a.variant = variant + a.scale = scale + a.legend = legend + a.state = WILD_IDLE + push(wildlife_all(), a) + WildlifeWorld.born(a) + return a +} + +# a new individual of a species at (x, z), off the package's dice +export function wildlife_new(sp: int, x: float, z: float, legend: bool) -> WildAnimal { + let s = wildlife_species(sp) + if s == null { return null } + let yaw = wl_rnd() * WL_TAU + let variant = rng_between(wl_rng(), 0, 11) + let scale = s.scale * (0.88 + wl_rnd() * 0.24) + let a = wildlife_make(sp, x, z, legend, yaw, variant, scale, WildlifeWorld.next_id()) + a.thirst = wl_rnd() * 50.0 + a.hunger = wl_rnd() * 50.0 + a.scat_t = 40.0 + wl_rnd() * 180.0 + if s.flies { + a.state = WILD_FLY + a.fly_h = 10.0 + wl_rnd() * 20.0 + } + a.timer = wl_rnd() * 6.0 + return a +} + +# gone for good: not drawn, not ticked +export function wildlife_remove(a: WildAnimal) -> void { + if a == null { return } + a.alive = false + a.shown = false +} + +# staged for a picture: nobody thinks, and everybody keeps the speed they were given +export function wildlife_hold(on: bool) -> void { wl_hold = on } + +export function wildlife_by_nid(nid: int) -> WildAnimal { + let all = wildlife_all() + for i in 0 .. len(all) { if all[i].nid == nid { return all[i] } } + return null +} + +# how many of a species are alive, legends apart +export function wildlife_count(sp: int) -> int { + var n = 0 + let all = wildlife_all() + for i in 0 .. len(all) { if all[i].alive and all[i].sp == sp and not all[i].legend { n += 1 } } + return n +} diff --git a/packages/ludic.wildlife/system.ludic b/packages/ludic.wildlife/system.ludic new file mode 100644 index 00000000..0eb32983 --- /dev/null +++ b/packages/ludic.wildlife/system.ludic @@ -0,0 +1,44 @@ +# system.ludic - the wildlife as a system: its save section is this player's record (the species +# seen and fed) and which ranges they have found; the animals themselves are respawned with the +# valley, so a reset forgets the record and nothing else +export function wildlife_reset() -> void { + wl_record_reset() + wl_found = null + let rs = wildlife_ranges() + for i in 0 .. len(rs) { rs[i].found = false } + q_clear(wildlife_facts()) +} + +export function wildlife_save() -> Val { + let v = Value.object() + sv_put_int(v, "seen", wl_seen) + sv_put_int(v, "fed", wl_fed) + sv_put_bool(v, "fed_any", wl_fed_any) + let found = new []int + let rs = wildlife_ranges() + for i in 0 .. len(rs) { + var f = 0 + if rs[i].found { f = 1 } + push(found, f) + } + sv_put_ints(v, "found", found) + return v +} + +export function wildlife_load(v: Val, version: int) -> void { + wildlife_reset() + wl_seen = sv_int(v, "seen", 0) + wl_fed = sv_int(v, "fed", 0) + wl_fed_any = sv_bool(v, "fed_any", false) + wl_found = sv_ints(v, "found") + wl_found_apply() +} + +# PH_SIMULATE with no tick of its own: the game steps it (wildlife_tick) where the world is owned +export function wildlife_system() -> System { + let s = system_new("wildlife", PH_SIMULATE) + s.reset = fn wildlife_reset + s.save = fn wildlife_save + s.load = fn wildlife_load + return s +} diff --git a/packages/ludic.wildlife/tests/fake/index.ludic b/packages/ludic.wildlife/tests/fake/index.ludic new file mode 100644 index 00000000..50d45429 --- /dev/null +++ b/packages/ludic.wildlife/tests/fake/index.ludic @@ -0,0 +1,108 @@ +# fake.ludic - a toy valley for the tests: flat ground at 10 m with a lake west of x = -300 (its +# surface at 10.5), forest north of z = 400, up to two players, and a table of things set out +var fk_px: []float = null +var fk_pz: []float = null +var fk_on: []bool = null +var fk_noise: float = 0.0 +var fk_scent: float = 0.0 +var fk_still: int = 0 +var fk_hidden: bool = false +var fk_blocked: bool = false +var fk_wind: float = 0.0 +var fk_wind_dir: float = 0.0 +var fk_winter: float = 0.0 +var fk_about: int = 0 +var fk_born: int = 0 +var fk_lures: []WildLure = null +var fk_gone: int = 0 # a uid the port says is no longer there + +function fk_setup() -> void { + fk_px = floats(2) + fk_pz = floats(2) + fk_on = new []bool + push(fk_on, true) + push(fk_on, false) + fk_px[0] = 5000.0 + fk_pz[0] = 5000.0 + fk_lures = new []WildLure + wildlife_clear() + wildlife_species_clear() + fk_species() +} + +# 0 deer (meadow, tracks, medium), 1 bear (forest, charges), 2 hare (small), 3 jay (flies) +function fk_species() -> void { + let d = new WildSpecies + d.key = "deer" + d.walk = 1.1 + d.run = 7.0 + d.flee = 34.0 + d.tracks = true + d.size = 1 + d.range = 150.0 + d.keep_away = 120.0 + wildlife_species_add(d) + let b = new WildSpecies + b.key = "bear" + b.habitat = WILD_FOREST + b.charges = true + b.size = 2 + b.diet = WILD_FISH + b.forget = 1.6 + b.run = 6.0 + wildlife_species_add(b) + let h = new WildSpecies + h.key = "hare" + wildlife_species_add(h) + let j = new WildSpecies + j.key = "jay" + j.flies = true + j.walk = 4.0 + j.flee = 8.0 + j.land_shy = 0.0 + j.land_chance = 1.0 + wildlife_species_add(j) +} + +function fk_ground(x: float, z: float) -> float { return 10.0 } +function fk_water(x: float, z: float) -> float { + if x < -300.0 { return 10.5 } + return 0.0 +} +function fk_forest(x: float, z: float) -> bool { return z > 400.0 } +function fk_sight(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool { return not fk_blocked } +function fk_players() -> int { return 2 } +function fk_player_on(p: int) -> bool { return fk_on[p] } +function fk_x(p: int) -> float { return fk_px[p] } +function fk_y(p: int) -> float { return 10.0 } +function fk_z(p: int) -> float { return fk_pz[p] } +function fk_is_hidden(p: int) -> bool { return fk_hidden } +function fk_noise_of(p: int) -> float { return fk_noise } +function fk_scent_of(p: int) -> float { return fk_scent } +function fk_still_of(p: int) -> int { return fk_still } +function fk_wind_of() -> float { return fk_wind } +function fk_wind_to() -> float { return fk_wind_dir } +function fk_winter_of() -> float { return fk_winter } +function fk_about_of(a: WildAnimal) -> int { return fk_about } +function fk_one(sp: int) -> int { return 1 } +function fk_count() -> int { return len(fk_lures) } +function fk_lure(i: int, l: WildLure) -> bool { + let f = fk_lures[i] + l.uid = f.uid + l.kind = f.kind + l.diet = f.diet + l.size = f.size + l.x = f.x + l.z = f.z + l.owner = f.owner + return f.uid != fk_gone +} +function fk_present(uid: int) -> bool { return uid != fk_gone } +function fk_on_born(a: WildAnimal) -> void { fk_born += 1 } +function fk_spot(a: WildAnimal, water: bool, s: WildSpot) -> bool { + s.uid = 900 + s.x = a.x + 30.0 + s.z = a.z + s.water = water + return true +} diff --git a/packages/ludic.wildlife/tests/lures_test.ludic b/packages/ludic.wildlife/tests/lures_test.ludic new file mode 100644 index 00000000..4f5d7ab2 --- /dev/null +++ b/packages/ludic.wildlife/tests/lures_test.ludic @@ -0,0 +1,131 @@ +# lures_test.ludic - food of its diet draws an animal and feeding it is a fact with the one who left +# it; the wrong food does not; a snare holds a small animal; thirst finds water; a bird comes to seed +import "ludic.wildlife" +import "ludic.base" +import "fake" +program LuresTest { + numbers float + bind WildlifeWorld { + ground: fn fk_ground + water: fn fk_water + 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 + lures: fn fk_count + lure: fn fk_lure + present: fn fk_present + spot: fn fk_spot + } + + function facts() -> []WildFact { return q_drain(wildlife_facts()) } + function set_out(uid: int, kind: int, diet: int, x: float, z: float, owner: int) -> WildLure { + let l = new WildLure + l.uid = uid + l.kind = kind + l.diet = diet + l.x = x + l.z = z + l.owner = owner + push(fk_lures, l) + return l + } + function idle(sp: int, x: float, z: float) -> WildAnimal { + let a = wildlife_make(sp, x, z, false, 0.0, 0, 1.0, 7) + a.timer = 30.0 + return a + } + function go(a: WildAnimal, secs: float) -> void { + for k in 0 .. int(secs * 20.0) { wildlife_tick_ground(a, 0.05, 0.05 / 60.0) } + } + + test "food of its diet draws it in, it eats, and the fact names what and whose" { + fk_setup() + set_out(41, WILD_FEED, WILD_PLANTS, 20.0, 0.0, 3) + let a = idle(0, 0.0, 0.0) + go(a, 40.0) + let fs = facts() + var fed = 0 + for i in 0 .. len(fs) { + if fs[i].what == WILD_FED { + fed += 1 + expect_eq(fs[i].lure, 41) + expect_eq(fs[i].owner, 3) + } + } + expect_eq(fed, 1) + expect(a.calm > 0.0) + } + + test "food of another diet is left alone" { + fk_setup() + set_out(41, WILD_FEED, WILD_FISH, 20.0, 0.0, -1) + let a = idle(0, 0.0, 0.0) + go(a, 5.0) + expect(a.state != WILD_APPROACH) + expect_eq(a.lure, 0) + } + + test "food taken away while it walks is given up" { + fk_setup() + set_out(41, WILD_FEED, WILD_PLANTS, 50.0, 0.0, -1) + let a = idle(0, 0.0, 0.0) + go(a, 1.0) + expect_eq(a.state, WILD_APPROACH) + fk_gone = 41 + go(a, 0.2) + expect(a.state != WILD_APPROACH) + } + + test "a snare holds a small animal, the port can find it, and a release lets it go" { + fk_setup() + let l = set_out(55, WILD_SNARE, 0, 10.0, 0.0, -1) + l.size = 0 + let h = idle(2, 0.0, 0.0) + for k in 0 .. 40 { + if h.state == WILD_TRAPPED { break } + go(h, 2.0) + } + expect_eq(h.state, WILD_TRAPPED) + expect(wildlife_in_snare(55) == h) + let fs = facts() + var held = false + for i in 0 .. len(fs) { if fs[i].what == WILD_CAUGHT and fs[i].lure == 55 { held = true } } + expect(held) + wildlife_release(h) + expect_eq(h.state, WILD_FLEE) + expect(wildlife_in_snare(55) == null) + } + + test "a thirsty animal walks to the water the port offers, and drinks it down" { + fk_setup() + let a = idle(0, 0.0, 0.0) + a.thirst = 90.0 + a.state = WILD_WANDER + go(a, 0.1) + expect_eq(a.state, WILD_GO_DRINK) + expect_eq(a.spot, 900) + go(a, 40.0) + expect(a.thirst < 60.0) + } + + test "seed down brings a bird out of the sky and it takes it" { + fk_setup() + set_out(77, WILD_FEED, WILD_SEED, 30.0, 0.0, -1) + let j = wildlife_new(3, 0.0, 0.0, false) + fk_px[0] = 200.0 # within 900 m: an animal nobody is near does not think + fk_pz[0] = 0.0 + var landed = false + var ate = false + for k in 0 .. 4000 { + wildlife_tick(0.05, 0.0) + if j.state == WILD_PERCH { landed = true } + let fs = facts() + for i in 0 .. len(fs) { if fs[i].what == WILD_FED and fs[i].diet == WILD_SEED { ate = true } } + if ate { break } + } + expect(landed) + expect(ate) + } +} diff --git a/packages/ludic.wildlife/tests/wildlife_test.ludic b/packages/ludic.wildlife/tests/wildlife_test.ludic new file mode 100644 index 00000000..461c8786 --- /dev/null +++ b/packages/ludic.wildlife/tests/wildlife_test.ludic @@ -0,0 +1,211 @@ +# wildlife_test.ludic - species as data, a valley spawned against its ground, the alert (where it +# SETTLES, not only how it rises), cover, stillness and wind, the charge, the hours, prints and the save +import "ludic.wildlife" +import "ludic.base" +import "fake" +program WildlifeTest { + numbers float + bind WildlifeWorld { + ground: fn fk_ground + water: fn fk_water + forest: fn fk_forest + sight: fn fk_sight + 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 + } + + function facts() -> []WildFact { return q_drain(wildlife_facts()) } + function count(fs: []WildFact, what: int) -> int { + var n = 0 + for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } } + return n + } + function deer_at(x: float, z: float) -> WildAnimal { + let a = wildlife_make(0, x, z, false, 0.0, 0, 1.0, 1) + a.state = WILD_IDLE + a.timer = 30.0 + return a + } + function player_at(x: float, z: float) -> void { + fk_px[0] = x + fk_pz[0] = z + } + function go(a: WildAnimal, secs: float) -> void { + for k in 0 .. int(secs * 20.0) { wildlife_tick_ground(a, 0.05, 0.05 / 60.0) } + } + + test "the table handed in numbers the species, and a new animal is born through the port" { + fk_setup() + expect_eq(wildlife_species_count(), 4) + expect(wildlife_species(3).flies) + let a = wildlife_new(0, 10.0, 20.0, false) + expect_eq(fk_born, 1) + expect_near(a.y, 10.0, 0.001) + expect(a.variant >= 0 and a.variant <= 11) + expect(a.scale > 0.87 and a.scale < 1.13) + let j = wildlife_new(3, 0.0, 0.0, false) + expect_eq(j.state, WILD_FLY) + expect(wildlife_new(9, 0.0, 0.0, false) == null) + } + + test "a spawn sets ranges out on their own habitat, clear of the start, and fills them" { + fk_setup() + wildlife_spawn(0.0, 0.0) + let rs = wildlife_ranges() + expect(len(rs) >= 6) + for i in 0 .. len(rs) { + expect(wildlife_range_dist(rs[i], 0.0, 0.0) >= wildlife_species(rs[i].sp).keep_away) + if rs[i].sp == 1 { expect(rs[i].z > 400.0) } + expect(rs[i].sx > -300.0) + } + expect(wildlife_count(0) >= 1) + expect_eq(wildlife_count_all(), wildlife_count(0) + wildlife_count(1) + wildlife_count(2) + wildlife_count(3)) + } + + test "a player far off makes no difference that lasts: the alert settles under the unaware line" { + fk_setup() + let a = deer_at(0.0, 0.0) + player_at(55.0, 0.0) + fk_noise = 0.3 + go(a, 60.0) + expect(a.alert < 25.0) + expect(a.state != WILD_FLEE) + } + + test "close and in the open it is flushed, runs, and says so" { + fk_setup() + let a = deer_at(0.0, 0.0) + player_at(8.0, 0.0) + fk_noise = 0.8 + go(a, 10.0) + expect(a.spook > 0.0) + let fs = facts() + expect(count(fs, WILD_FLED) >= 1) + expect(a.x < 0.0) + } + + test "cover hides a player from sight, a hide from everything but the nose" { + fk_setup() + let a = deer_at(0.0, 0.0) + let open = wildlife_alert_rate(a, 0, 30.0) + player_at(30.0, 0.0) + let open2 = wildlife_alert_rate(a, 0, 30.0) + fk_blocked = true + let covered = wildlife_alert_rate(a, 0, 30.0) + fk_blocked = false + fk_hidden = true + let hidden = wildlife_alert_rate(a, 0, 30.0) + expect(open2 > 0.0) + expect(open >= open2) + expect_near(covered, 0.0, 0.0001) + expect_near(hidden, 0.0, 0.0001) + } + + test "sitting still makes it forget faster, and the wind carries scent only downwind" { + fk_setup() + let a = deer_at(0.0, 0.0) + let moving = wildlife_alert_decay(a, 0) + fk_still = WILD_SITTING + expect(wildlife_alert_decay(a, 0) > moving * 2.0) + player_at(0.0, -60.0) + fk_blocked = true + fk_scent = 1.0 + fk_wind = 0.5 + fk_wind_dir = 3.1415927 # blowing toward +z: from the player to the deer + expect(wildlife_downwind(a, 0) > 0.9) + expect(wildlife_alert_rate(a, 0, 60.0) > 0.0) + fk_wind_dir = 0.0 + expect_near(wildlife_alert_rate(a, 0, 60.0), 0.0, 0.0001) + } + + test "a charger comes at a player inside its reach, and a shove is a fact for the game" { + fk_setup() + let b = wildlife_make(1, 0.0, 0.0, false, 0.0, 0, 1.0, 2) + player_at(6.0, 0.0) + wildlife_tick_ground(b, 0.05, 0.001) + expect_eq(b.state, WILD_CHARGE) + go(b, 2.0) + let fs = facts() + expect_eq(count(fs, WILD_CHARGED), 1) + expect_eq(count(fs, WILD_SHOVED), 1) + expect(b.calm > 0.0) + } + + test "away is hidden and skipped, gone is removed and said" { + fk_setup() + let a = deer_at(0.0, 0.0) + player_at(10.0, 0.0) + fk_about = WILD_AWAY + wildlife_tick(0.05, 0.001) + expect(not a.shown and a.alive) + fk_about = WILD_GONE + wildlife_tick(0.05, 0.001) + expect(not a.alive) + expect_eq(count(facts(), WILD_WENT), 1) + } + + test "a walker leaves a print every 2.8 m, and player 0 spots a species once" { + fk_setup() + let a = deer_at(0.0, 0.0) + player_at(40.0, 0.0) + fk_blocked = true + a.state = WILD_WANDER + a.tx = 0.0 + a.tz = -100.0 + a.timer = 100.0 + for k in 0 .. 200 { wildlife_tick(0.05, 0.0) } + let fs = facts() + expect(count(fs, WILD_PRINT) >= 3) + expect_eq(count(fs, WILD_SPOTTED), 1) + expect(wildlife_seen(0)) + } + + test "the record and the found ranges come back from the save section" { + fk_setup() + wildlife_spawn(0.0, 0.0) + wildlife_mark_fed(2) + wildlife_saw(0) + let r = wildlife_ranges()[0] + wildlife_ranges_visit(r.x, r.z) + let v = wildlife_save() + wildlife_reset() + expect(not wildlife_fed_any() and not wildlife_ranges()[0].found) + wildlife_load(v, 1) + expect(wildlife_fed_any()) + expect_eq(wildlife_fed_mask(), 4) + expect(wildlife_seen(0)) + expect(wildlife_ranges()[0].found) + } + + test "a morning moves the ranges toward winter and brings one back to a range that lost its own" { + fk_setup() + wildlife_spawn(0.0, 0.0) + let r = wildlife_ranges()[0] + let x0 = r.x + let all = wildlife_all() + for i in 0 .. len(all) { if all[i].rg == 0 { wildlife_remove(all[i]) } } + fk_winter = 1.0 + let added = wildlife_morning() + expect(added >= 1) + expect_eq(wildlife_range_alive(0), 1) + expect_near(r.x, r.wx, 0.001) + expect(r.x != x0 or r.wx == r.sx) + } +} diff --git a/packages/ludic.wildlife/want.ludic b/packages/ludic.wildlife/want.ludic new file mode 100644 index 00000000..94ecc5b3 --- /dev/null +++ b/packages/ludic.wildlife/want.ludic @@ -0,0 +1,56 @@ +# 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 +var wl_spot_ask: WildSpot = null + +function wl_seek(a: WildAnimal, dt: float) -> bool { + if a.state == WILD_GO_FEED and wl_attraction(a) and wl_best.kind == WILD_FEED { wl_take_lure(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) { + a.spot = 0 + a.state = WILD_IDLE + a.timer = 2.0 + return true + } + wl_face(a, a.spot_x, a.spot_z, 2.0, dt) + a.speed = wl_sp(a.sp).walk + a.tx = a.spot_x + a.tz = a.spot_z + wl_step(a, dt) + if wl_at_target(a, 2.0) { + a.state = WILD_DRINK + a.timer = 6.0 + wl_rnd() * 10.0 + } + a.timer = a.timer - dt + if a.timer < -600.0 { + a.state = WILD_IDLE + a.timer = 3.0 + } + return true +} + +# true while it drinks or grazes, or when it has just set off +function wl_drink(a: WildAnimal, dt: float, gh: float) -> bool { + if a.state == WILD_DRINK { + a.speed = 0.0 + a.timer = a.timer - dt + if a.spot != 0 and a.spot_water { a.thirst = Math.max(a.thirst - 600.0 * gh, 0.0) } else { a.hunger = Math.max(a.hunger - 600.0 * gh, 0.0) } + if a.timer < 0.0 or Math.min(a.thirst, a.hunger) < 1.0 { + wl_rehome(a) + wl_wander(a, 6.0) + } + return true + } + if a.state != WILD_IDLE and a.state != WILD_WANDER { return false } + if not (a.thirst > 60.0 or a.hunger > 60.0) { return false } + if wl_spot_ask == null { wl_spot_ask = new WildSpot } + let water = a.thirst > a.hunger + if not WildlifeWorld.spot(a, water, wl_spot_ask) { return false } + a.spot = wl_spot_ask.uid + a.spot_x = wl_spot_ask.x + a.spot_z = wl_spot_ask.z + a.spot_water = wl_spot_ask.water + a.state = WILD_GO_DRINK + if not water { a.state = WILD_GO_FEED } + a.timer = 120.0 + return true +}