diff --git a/packages/README.md b/packages/README.md index 67120cae..49ac8c60 100644 --- a/packages/README.md +++ b/packages/README.md @@ -28,6 +28,7 @@ section. The rules are in [ludic.base](ludic.base/README.md). | [ludic.inventory](ludic.inventory/README.md) | a pack: a count per kind, with room the game decides | | [ludic.minimap](ludic.minimap/README.md) | a map's logic: the explored fog over a grid and its land share, the player's marks, and the view (world to map and back, zoom, pan, the camp, you, focus) | | [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse | +| [ludic.npc](ludic.npc/README.md) | the other people in a place: a routine by the hour, walking round what is in the way, facing a player who comes near, lines as data, a guest's copy | | [ludic.photo](ludic.photo/README.md) | a camera's photographs: what is in the frame, a grade on size, framing, light and the moment, the roll, its worth, the best of each subject | | [ludic.save](ludic.save/README.md) | versioned save files: a migration chain the game declares, torn writes told apart, a backup, a newer file refused and read-only | | [ludic.settings](ludic.settings/README.md) | a game's settings as data: one store, a fact per change, ranges, a safe set | diff --git a/packages/ludic.npc/README.md b/packages/ludic.npc/README.md new file mode 100644 index 00000000..8f43868c --- /dev/null +++ b/packages/ludic.npc/README.md @@ -0,0 +1,114 @@ +# ludic.npc + +The other people in a place: each with a routine by the hour rather than an intelligence - where +they start, where they go and what they do there, when they walk out - walking between spots and +round whatever is in the way, never into the water, turning to face a player who comes near, and +saying one line at a time from a table of lines. Uses [`ludic.base`](../ludic.base/README.md) and +nothing else. + +```ludic +import "ludic.npc" +``` + +The package owns the people's state, the census, the walk, the acts, the lines, its own dice and a +guest's copy of a host's people. It never draws: the port says a person was `born` (make an actor, +key it by the person's `slot`, or `id` across machines) and `gone`, and the game reads `x`, `y`, +`z`, `yaw`, `act`, `stalk` and `phase` (2.6 a metre walked) every frame to pose it. Clips are the +game's too (`NpcAct.clip` is a hint: `"walk"`, `"idle"`, or `""` for a pose of its own). + +## Four tables the game fills + +- **`NpcKinds`** - a kind of person: `weight` in the census draw (`night`: the only kinds drawn + after dark), whether it `stays` the night, `leave_lo` .. `leave_hi` (the hour it walks out), + `notice` (m it turns to face a player), `start` (where a new one goes: call + `npc_start_near(p, x, z, r0, r1)`), `plan` (the first word on what next; true: decided) and + `steps`, its routine. +- **`NpcStep`** (inside a kind) - in the hours `from` .. `to` (round midnight when `from > to`), + `weight` of the draw, go to `spot` (`near` .. `far` m, `n` the spot's own number) and do `act` + for `lo` .. `hi` seconds. A step whose spot cannot be found gives way to the next one in the table. +- **`NpcSpots`** - where a step goes; `find(p, step)` sends the person (`npc_go` / `npc_do`, or + `npc_step_go`) or says it cannot. The package's own: `here`, `home`, `near_home`, `near_here`, + `shore_home`, `shore_here` (facing the water) and `wander`; a game adds its own (a sign, an animal). +- **`NpcActs`** - what somebody does; the package's `walk` (`NPCA_WALK`) and `idle` come first. + `turns` (false: keeps its eyes on its work), `clip`, and `tick(p, dt)` (the game's while it lasts; + true: decided). +- **`NpcNames`** (`name`, `body`) and **`NpcLines`** - see below. + +A kind's `.lres` may name functions (`start: fn my_start`), and a step list is `[{ ... }, ...]`. + +## The rules it keeps + +1. **A routine, not a mind.** A person plans on arriving and when its act runs out: the kind's + `plan`, then a weighted draw among its steps for this hour, then standing a while. +2. **Nobody walks into the lake or up a scramble**: a step into water (0.4 m) or up more than 1.4x + the step is refused; eight steps round are tried, and a target that gets no closer for twenty + seconds is given up (three times: a few steps anywhere walkable). +3. **Nobody stops where the place says not to** (`NpcWorld.allowed`: a player's camp). They may + walk through; an act begun there walks out first. +4. **They face whoever came over to talk**, within the kind's `notice` - unless creeping or at an + act that does not `turns` - and go back to their errand when the player walks off. A player + coming within notice is a `Greeted` fact, once, until they have gone half as far again. +5. **The census is counted, not tallied**, on the half hour: up to `NpcWorld.want()` (and never + past `npc_config`'s cap), plus `NpcWorld.extra()` whatever the crowd. +6. **They leave only out of every player's sight**: after their hour at 120 m, at night (by 22:00, + except those who `stay`) at 70 m, and a crowd over what is wanted at 150 m - never while out + for something (`goal >= 0`). +7. **No name twice**: not another person's, not one the place keeps (`NpcWorld.taken`), not one + that left lately. +8. **Posts** (`npc_place`: a vendor) never walk, are never counted and never leave; they turn to a + player within notice and back to their stance. + +## Lines as data + +``` +hush { prio: 5, act: NPCA_PHOTO, fill: true } # the story's words (NpcTalk.say) +sighting { prio: 4, chance: 30, fill: true } # considered three times in ten +water { prio: 3, act: NPCA_WATER, text: "Boil it, even up here." } +hello { prio: 1, text: "Morning.", choices: [{ text: "Any fish?", line: "fish", code: 7 }] } +``` + +The highest `prio` with anything to say wins, each line of it in table order given its `chance`; +a `fill` line's words are `NpcTalk.say(p, line)`, and "" means it has nothing true to say now. +`npc_talk` picks one (a `Talked` fact); `npc_choose` answers a choice with the line it names (a +`Talked` fact carrying the choice's `code`). A vendor's greeting is `NpcTalk.greeting(p)`. + +## The ports + +```ludic +export port NpcWorld { # every member has a default: a flat dry field at noon, one player, nobody wanted + ground, water, slope: fn(float, float) -> float + allowed: fn(float, float) -> bool # may a person stop here + hour: fn() -> float, day: fn() -> int # the clock + players: fn() -> int, player_on: fn(int) -> bool, player_x / player_z: fn(int) -> float + want: fn() -> int, extra: fn() -> int # the census: how many, and a kind brought out regardless (-1) + origin_x / origin_z: fn() -> float # where anyone goes when nowhere else will do + next_id: fn() -> int, body_of: fn(int) -> int # an id across machines; a look seed's body + taken: fn(string) -> bool # a name the place keeps + born / gone: fn(NpcPerson) -> void # draw it, stop drawing it +} +export port NpcTalk { say: fn(NpcPerson, int) -> string, greeting: fn(NpcPerson) -> string } +``` + +## API + +| | | +| --- | --- | +| `NpcPerson`, `npc_walkers()`, `npc_posts()`, `npc_at(slot)`, `npc_post(i)`, `npc_by_id(id)`, `npc_count()`, `npc_max()` | the people (`goal`, `subj`, `note`, `note_h`, `flags` are the game's) | +| `npc_config(max)`, `npc_seed(n)`, `npc_rnd()`, `npc_between(lo, hi)`, `npc_secs(lo, hi)` | the cap and the package's own dice | +| `npc_tick(dt)`, `npc_posts_tick(dt)`, `npc_census_now()`, `npc_hold(on)`, `npc_reset()`, `npc_clear()` | a step where the place is run (or of the posts alone), the census at once, held still, a new trip, a new place | +| `npc_spawn(kind)`, `npc_place(kind, x, z, yaw, name, look)`, `npc_retire(p)`, `npc_start_near(p, x, z, r0, r1)`, `npc_set_at(p, x, z)` | making and leaving | +| `npc_go(p, x, z, act, secs)`, `npc_do(p, act, secs)`, `npc_face(p, x, z)`, `npc_face_ahead(p)`, `npc_set_home_face(p, x, z)`, `npc_plan(p)`, `npc_steps(p, steps)`, `npc_step_go(p, step, x, z)`, `npc_wander(p)` | the routine | +| `npc_walk(p, dt)`, `npc_act(p, dt)`, `npc_notice(p)` | a person's step | +| `npc_walkable(x, z)`, `npc_line_ok(ax, az, bx, bz)`, `npc_spot_near(x, z, d0, d1)`, `npc_toward(ax, az, bx, bz, d)`, `npc_shore(x, z, r)`, `npc_px()`, `npc_pz()`, `npc_wx()`, `npc_wz()` | the ground | +| `npc_near(x, z)`, `npc_near_i()`, `npc_d(...)`, `npc_wrap(a)` | the nearest player, distances, angles | +| `npc_pick_name(body)`, `npc_name_used(n)`, `npc_name_clashes()` | names | +| `npc_talk(p, player)`, `npc_line_for(p)`, `npc_words(p, line)`, `npc_said()`, `npc_choose(p, line, c, player)`, `npc_talked_today(p)`, `npc_greeting(p)` | talk | +| `npc_mirror(on)`, `npc_mirroring()`, `npc_put(slot, id, kind, look, seed, name, hx, hz)`, `npc_heard(slot, x, y, z, yaw, speed, act, stalk)`, `npc_heard_lately(p)`, `npc_mirror_tick(dt)` | a guest's copy of the host's people | +| `npc_facts() -> Queue` | `NPC_F_GREETED` (`player`), `NPC_F_TALKED` (`player`, `line`, `choice`), `NPC_F_ARRIVED` (`act`) | +| `npc_system() -> System` | `"npc"`, `PH_SIMULATE`, a reset and no tick; nothing saved | + +## Tests + +```bash +ludic test packages/ludic.npc +``` diff --git a/packages/ludic.npc/act.ludic b/packages/ludic.npc/act.ludic new file mode 100644 index 00000000..ec73b214 --- /dev/null +++ b/packages/ludic.npc/act.ludic @@ -0,0 +1,47 @@ +# act.ludic - doing the act: turned toward what it is about - or toward whoever came over to talk, +# within the kind's notice - the game's own tick for the act, and a new plan when its time is up +export function npc_act(p: NpcPerson, dt: float) -> void { + p.act_t = p.act_t - dt + var wx = p.fx + var wz = p.fz + if p.fixed { + wx = p.x - Math.sin(p.stance) * 10.0 + wz = p.z - Math.cos(p.stance) * 10.0 + } + if not p.stalk and np_turns(p.act) and npc_near(p.x, p.z) < NpcKinds[p.kind].notice { + let i = npc_near_i() + wx = NpcWorld.player_x(i) + wz = NpcWorld.player_z(i) + } + var r = 2.0 * dt + if p.fixed { r = 2.2 * dt } + p.yaw = p.yaw + Math.clamp(npc_wrap(Math.atan2(-(wx - p.x), -(wz - p.z)) - p.yaw), -r, r) + if p.fixed { return } + if p.act >= 0 and p.act < NPCA_COUNT and NpcActs[p.act].tick != null { + let tick = NpcActs[p.act].tick + if tick(p, dt) { return } + } + if p.act_t < 0.0 { npc_plan(p) } +} + +function np_turns(act: int) -> bool { + if act < 0 or act >= NPCA_COUNT { return true } + return NpcActs[act].turns +} + +# a player who came within notice since the last look is greeted - once, until they walk off +export function npc_notice(p: NpcPerson) -> void { + let reach = NpcKinds[p.kind].notice + for i in 0 .. NpcWorld.players() { + if i >= 30 { break } + let bit = 1 << i + var d = 1000000.0 + if NpcWorld.player_on(i) { d = npc_d(p.x, p.z, NpcWorld.player_x(i), NpcWorld.player_z(i)) } + if d < reach and (p.near & bit) == 0 { + p.near = p.near | bit + np_fact(NPC_F_GREETED, p, i) + } + if d > reach * 1.5 and (p.near & bit) != 0 { p.near = p.near - bit } + } +} + diff --git a/packages/ludic.npc/census.ludic b/packages/ludic.npc/census.ludic new file mode 100644 index 00000000..aada41e2 --- /dev/null +++ b/packages/ludic.npc/census.ludic @@ -0,0 +1,120 @@ +# census.ludic - making one (a slot reused before a new one, a look, a name, a start on its own +# ground out of the players' sight where that can be had), a post's fixed person, and leaving +export function npc_spawn(kind: int) -> NpcPerson { + np_ensure() + if np_n >= np_max or kind < 0 or kind >= NPCK_COUNT { return null } + var p: NpcPerson = null + for i in 0 .. len(np_walkers) { + if not np_walkers[i].active { + p = np_walkers[i] + break + } + } + if p == null { + if len(np_walkers) >= np_max { return null } + p = new NpcPerson + p.slot = len(np_walkers) + push(np_walkers, p) + } + np_fresh(p, kind, npc_between(1, 99999)) + p.name = npc_pick_name(p.body) + let k = NpcKinds[kind] + p.leg = npc_between(0, 3) + p.seed = npc_rnd() * 40.0 + p.out_h = k.leave_lo + npc_rnd() * (k.leave_hi - k.leave_lo) + p.yaw = npc_rnd() * NP_TAU + if k.start != null { + let start = k.start + start(p) + } else { npc_start_near(p, NpcWorld.origin_x(), NpcWorld.origin_z(), 6.0, 30.0) } + p.active = true + np_n += 1 + NpcWorld.born(p) + npc_plan(p) + return p +} + +function np_fresh(p: NpcPerson, kind: int, look: int) -> void { + p.kind = kind + p.id = NpcWorld.next_id() + p.look = look + p.body = NpcWorld.body_of(look) + p.fixed = false + p.said = -1 + p.near = 0 + p.goal = -1 + p.subj = -1 + p.note = -1 + p.note_h = 0.0 + p.flags = 0 + p.stalk = false + p.stall = 0.0 + p.stall_max = 0.0 + p.fails = 0 + p.phase = 0.0 + p.home_face = false + p.heard = false + p.act = NPCA_IDLE + p.act_t = 0.0 + p.speed = 0.0 + p.via_on = false +} + +# its own ground: a spot r0 .. r1 from (cx, cz), tried a few times for one no player is within 60 m +# of, else near the place's origin; that is home, where it stands and faces +export function npc_start_near(p: NpcPerson, cx: float, cz: float, r0: float, r1: float) -> void { + var x = NpcWorld.origin_x() + var z = NpcWorld.origin_z() + var ok = false + for t in 0 .. 8 { + if not npc_spot_near(cx, cz, r0, r1) { continue } + x = np_px + z = np_pz + ok = true + if npc_near(x, z) > 60.0 { break } + } + if not ok and npc_spot_near(x, z, 10.0, 45.0) { + x = np_px + z = np_pz + } + npc_set_at(p, x, z) +} + +# put it down at (x, z) as its home, stood still +export function npc_set_at(p: NpcPerson, x: float, z: float) -> void { + p.x = x + p.z = z + p.y = NpcWorld.ground(x, z) + p.hx = x + p.hz = z + p.tx = x + p.tz = z +} + +# somebody fixed at a post (a vendor behind the counter): never walks, never counted, never leaves, +# turns to a player within its kind's notice and back to `yaw` when they go +export function npc_place(kind: int, x: float, z: float, yaw: float, name: string, look: int) -> NpcPerson { + np_ensure() + let p = new NpcPerson + p.slot = len(np_posts) + np_fresh(p, kind, look) + p.fixed = true + p.name = name + p.seed = float(p.slot) * 0.9 + p.stance = yaw + p.yaw = yaw + npc_set_at(p, x, z) + p.active = true + push(np_posts, p) + NpcWorld.born(p) + npc_do(p, NPCA_IDLE, 3600.0) + return p +} + +export function npc_retire(p: NpcPerson) -> void { + if not p.active { return } + p.active = false + if not p.fixed and np_n > 0 { np_n -= 1 } + np_left(p.name) + NpcWorld.gone(p) +} diff --git a/packages/ludic.npc/defs.ludic b/packages/ludic.npc/defs.ludic new file mode 100644 index 00000000..905fee0b --- /dev/null +++ b/packages/ludic.npc/defs.ludic @@ -0,0 +1,139 @@ +# defs.ludic - a person, and the four tables a game fills: kinds (with their routine), spots (where +# a routine step goes), acts (what somebody does on arriving) and names +# One person. `goal`, `subj`, `note`, `note_h` and `flags` are the game's to use in its own plans +# (what it is out for, what it is watching, what it did and when); the package only reads `goal`. +export property NpcPerson { + slot: int = -1 + id: int = 0 # across machines, new each time a slot is reused + kind: int = 0 + fixed: bool = false # at a post (a vendor): never walks, never counted, never leaves + active: bool = false + name: string = "" + body: int = 0 # the game's (a model), from the look + look: int = 0 # the seed its look came from + seed: float = 0.0 # a phase of its own, so a crowd does not breathe in step + 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: 2.6 a metre walked + act: int = 0 # NPCA_* + act_t: float = 0.0 # seconds left of it + next_act: int = 0 # what it does on arriving, and for how long + next_t: float = 0.0 + tx: float = 0.0 # where it is walking to + tz: float = 0.0 + fx: float = 0.0 # what it faces while it does it + fz: float = 0.0 + hx: float = 0.0 # its own ground + hz: float = 0.0 + hfx: float = 0.0 # what it faces sitting at home (a camper's fire), when home_face + hfz: float = 0.0 + home_face: bool = false + stance: float = 0.0 # a post's heading, turned back to when nobody is near + leg: int = 0 # which stop of a round it is on + out_h: float = 0.0 # the hour it leaves + said: int = -1 # the day it last spoke to a player + near: int = 0 # the players it has noticed, a bit each + stalk: bool = false # creeping: slow, and it does not turn to a player + goal: int = -1 + subj: int = -1 + note: int = -1 + note_h: float = 0.0 + flags: int = 0 + best: float = 0.0 # the closest it has got to where it is walking + prog_t: float = 0.0 # seconds without getting any closer + via_on: bool = false # a step round whatever is in the way + vx: float = 0.0 + vz: float = 0.0 + detours: int = 0 + fails: int = 0 + stall: float = 0.0 # seconds walking without moving + stall_max: float = 0.0 + nx: float = 0.0 # a guest's copy: where the host last said it was + ny: float = 0.0 + nz: float = 0.0 + nyaw: float = 0.0 + heard: bool = false + heard_t: float = 0.0 +} + +# a step of a kind's routine: in the hours from..to (round midnight when from > to), `weight` of +# the draw, go to `spot` (NpcSpots, `near` .. `far` metres; `n` the spot's own number) and `act` +# for lo .. hi seconds +export property NpcStep { + from: float = 0.0 + to: float = 24.0 + weight: int = 1 + spot: int = 0 + act: int = 1 + lo: int = 10 + hi: int = 30 + near: float = 0.0 + far: float = 30.0 + n: int = 0 +} + +# a kind of person: how often the census draws it (`night`: the only kinds it draws after dark), +# whether it `stays` the night, when it leaves, how far off it notices a player; `start` puts a +# new one down (npc_start_near), `plan` has the first word on what it does next (true: decided) +export property NpcKind { + key: string = "" + weight: int = 1 + night: bool = false + stays: bool = false + leave_lo: float = 16.0 + leave_hi: float = 21.0 + notice: float = 10.0 + start: fn(NpcPerson) -> void = null + plan: fn(NpcPerson) -> bool = null + steps: []NpcStep = null +} + +export open registry NpcKinds of NpcKind as NPCK + +# where a step goes: `find` sends the person there (npc_go / npc_do) or says it cannot +export property NpcSpot { + key: string = "" + find: fn(NpcPerson, NpcStep) -> bool = null +} + +export open registry NpcSpots of NpcSpot as NPCS + +# what somebody does: `turns` to face a player who comes near, `clip` a hint for the drawing +# ("idle", "walk", or "" for the game's own pose), `tick` the game's while it lasts (true: decided) +export property NpcAct { + key: string = "" + turns: bool = true + clip: string = "" + tick: fn(NpcPerson, float) -> bool = null +} + +export open registry NpcActs of NpcAct as NPCA +def NpcActs walk { clip: "walk" } +def NpcActs idle { clip: "idle" } + +# a name somebody can have; `body` the game's (a pool per model), so a Nora is drawn as a woman +export property NpcName { + key: string = "" + name: string = "" + body: int = 0 +} + +export open registry NpcNames of NpcName as NPCN + +export const NPC_F_GREETED: int = 0 # a player came within notice +export const NPC_F_TALKED: int = 1 # a player talked (line, choice -1 or the choice taken) +export const NPC_F_ARRIVED: int = 2 # got where it was walking, and began the act + +export property NpcFact { + what: int = 0 + person: int = -1 # its id + slot: int = -1 + fixed: bool = false + player: int = -1 + line: int = -1 + choice: int = -1 + act: int = -1 +} diff --git a/packages/ludic.npc/ground.ludic b/packages/ludic.npc/ground.ludic new file mode 100644 index 00000000..e5f0a13e --- /dev/null +++ b/packages/ludic.npc/ground.ludic @@ -0,0 +1,101 @@ +# ground.ludic - where a person can stand and walk: dry, gentle, allowed; a straight walk with no +# water and no scramble on it; a spot in a ring, a spot toward something, and the nearest shore. +# A finder that succeeds leaves its answer in npc_px / npc_pz (and the water in npc_wx / npc_wz). +var np_px: float = 0.0 +var np_pz: float = 0.0 +var np_wx: float = 0.0 +var np_wz: float = 0.0 + +export function npc_px() -> float { return np_px } +export function npc_pz() -> float { return np_pz } +export function npc_wx() -> float { return np_wx } +export function npc_wz() -> float { return np_wz } + +export function npc_walkable(x: float, z: float) -> bool { + if not NpcWorld.allowed(x, z) { return false } + if NpcWorld.ground(x, z) < NpcWorld.water(x, z) + 0.6 { return false } + return NpcWorld.slope(x, z) < 0.75 +} + +export function npc_line_ok(ax: float, az: float, bx: float, bz: float) -> bool { + let d = npc_d(ax, az, bx, bz) + var n = int(d / 1.5) + if n < 1 { n = 1 } + var prev = NpcWorld.ground(ax, az) + for i in 1 .. n + 1 { + let t = float(i) / float(n) + let x = ax + (bx - ax) * t + let z = az + (bz - az) * t + let h = NpcWorld.ground(x, z) + if h < NpcWorld.water(x, z) + 0.5 { return false } + if h - prev > d / float(n) * 1.3 { return false } + prev = h + } + return true +} + +function np_found(x: float, z: float) -> bool { + np_px = x + np_pz = z + return true +} + +# somewhere to stand d0 .. d1 from a point +export function npc_spot_near(x0: float, z0: float, d0: float, d1: float) -> bool { + for t in 0 .. 40 { + let ang = npc_rnd() * NP_TAU + let d = d0 + npc_rnd() * (d1 - d0) + let x = x0 + Math.cos(ang) * d + let z = z0 + Math.sin(ang) * d + if npc_walkable(x, z) { return np_found(x, z) } + } + return false +} + +# a place to stand d from (ax, az), on the side (bx, bz) is on +export function npc_toward(ax: float, az: float, bx: float, bz: float, d: float) -> bool { + let dd = Math.max(npc_d(ax, az, bx, bz), 0.5) + let x = ax + (bx - ax) / dd * d + let z = az + (bz - az) / dd * d + if npc_walkable(x, z) { return np_found(x, z) } + for k in 0 .. 12 { + let ang = Math.atan2(bz - az, bx - ax) + float((k + 1) / 2 * (1 - 2 * (k % 2))) * 0.25 + let x2 = ax + Math.cos(ang) * d + let z2 = az + Math.sin(ang) * d + if npc_walkable(x2, z2) { return np_found(x2, z2) } + } + return npc_spot_near(x, z, 0.0, 14.0) +} + +# the nearest bit of shore within rmax: where to stand, and the water in front of it +export function npc_shore(x0: float, z0: float, rmax: float) -> bool { + var found = false + var bd = rmax + for k in 0 .. 16 { + let ang = float(k) * (NP_TAU / 16.0) + var lx = x0 + var lz = z0 + var ld = -100.0 + var d = 2.0 + while d < bd { + let x = x0 + Math.cos(ang) * d + let z = z0 + Math.sin(ang) * d + if NpcWorld.ground(x, z) < NpcWorld.water(x, z) { + if ld > 0.0 and d - ld < 12.0 { + bd = d + found = np_found(lx, lz) + np_wx = x + np_wz = z + } + break + } + if npc_walkable(x, z) { + lx = x + lz = z + ld = d + } + d = d + 2.0 + } + } + return found +} diff --git a/packages/ludic.npc/index.ludic b/packages/ludic.npc/index.ludic new file mode 100644 index 00000000..ee478ac6 --- /dev/null +++ b/packages/ludic.npc/index.ludic @@ -0,0 +1,19 @@ +# ludic.npc - people with a routine rather than an intelligence: kinds, spots and acts as open +# registries, a census by the hour, the walk, the acts, the lines, and a guest's copy of a host's +module ludic_npc uses ludic_base +numbers float +import "ludic.base" +import "defs.ludic" +import "person.ludic" +import "port.ludic" +import "ground.ludic" +import "spots.ludic" +import "routine.ludic" +import "walk.ludic" +import "act.ludic" +import "names.ludic" +import "census.ludic" +import "tick.ludic" +import "mirror.ludic" +import "talk.ludic" +import "system.ludic" diff --git a/packages/ludic.npc/mirror.ludic b/packages/ludic.npc/mirror.ludic new file mode 100644 index 00000000..a7bf613c --- /dev/null +++ b/packages/ludic.npc/mirror.ludic @@ -0,0 +1,78 @@ +# mirror.ludic - a guest draws the host's people: no routine and no census here, a slot filled or +# emptied as the host says, and each one eased toward where it was last heard to be +var np_clock: float = 0.0 + +# on: this machine's own walkers leave, and only the host's are drawn until it is turned off +export function npc_mirror(on: bool) -> void { + np_ensure() + if np_mirror == on { return } + np_mirror = on + for i in 0 .. len(np_walkers) { npc_retire(np_walkers[i]) } + if not on { npc_census_now() } +} + +export function npc_mirroring() -> bool { return np_mirror } + +# the host's slot: somebody new in it (born), or nobody (gone) +export function npc_put(slot: int, id: int, kind: int, look: int, seed: float, name: string, hx: float, hz: float) -> NpcPerson { + np_ensure() + if slot < 0 or slot >= np_max or kind < 0 or kind >= NPCK_COUNT { return null } + while len(np_walkers) <= slot { + let q = new NpcPerson + q.slot = len(np_walkers) + push(np_walkers, q) + } + let p = np_walkers[slot] + if p.active and p.id == id { return p } + if p.active { npc_retire(p) } + np_fresh(p, kind, look) + p.id = id + p.seed = seed + p.name = name + npc_set_at(p, hx, hz) + p.active = true + np_n += 1 + NpcWorld.born(p) + return p +} + +# where the host says it is now, and what it is doing +export function npc_heard(slot: int, x: float, y: float, z: float, yaw: float, speed: float, act: int, stalk: bool) -> void { + let p = npc_at(slot) + if p == null or not p.active { return } + if not p.heard { + p.x = x + p.y = y + p.z = z + p.yaw = yaw + } + p.heard = true + p.heard_t = np_clock + p.nx = x + p.ny = y + p.nz = z + p.nyaw = yaw + p.speed = speed + p.act = act + p.stalk = stalk +} + +# heard from in the last six seconds: drawn; not, and it is not drawn until it is again +export function npc_heard_lately(p: NpcPerson) -> bool { return p.heard and np_clock - p.heard_t <= 6.0 } + +export function npc_mirror_tick(dt: float) -> void { + np_ensure() + np_clock = np_clock + dt + let k = Math.min(1.0, dt * 8.0) + for i in 0 .. len(np_walkers) { + let p = np_walkers[i] + if not p.active or not npc_heard_lately(p) { continue } + let ox = p.x + let oz = p.z + p.x = p.x + (p.nx - p.x) * k + p.y = p.y + (p.ny - p.y) * k + p.z = p.z + (p.nz - p.z) * k + p.yaw = p.yaw + npc_wrap(p.nyaw - p.yaw) * k + if p.act == NPCA_WALK { p.phase = p.phase + npc_d(ox, oz, p.x, p.z) * 2.6 } + } +} diff --git a/packages/ludic.npc/names.ludic b/packages/ludic.npc/names.ludic new file mode 100644 index 00000000..30059694 --- /dev/null +++ b/packages/ludic.npc/names.ludic @@ -0,0 +1,49 @@ +# names.ludic - a name nobody else in the place is using: not another person, not one the place +# keeps for itself (NpcWorld.taken: a vendor), and not somebody who left a little while ago +var np_recent: []string = null # the last few names that left + +export function npc_name_used(n: string) -> bool { + if NpcWorld.taken(n) { return true } + let all = npc_walkers() + for i in 0 .. len(all) { if all[i].active and all[i].name == n { return true } } + let posts = npc_posts() + for i in 0 .. len(posts) { if posts[i].active and posts[i].name == n { return true } } + if np_recent != null { for i in 0 .. len(np_recent) { if np_recent[i] == n { return true } } } + return false +} + +# a free name from the body's pool, from a place in it the dice choose ("" when the pool is empty) +export function npc_pick_name(body: int) -> string { + let pool = new []string + for i in 0 .. NPCN_COUNT { if NpcNames[i].body == body { push(pool, NpcNames[i].name) } } + let n = len(pool) + if n == 0 { return "" } + let start = npc_between(0, n - 1) + for k in 0 .. n { + let name = pool[(start + k) % n] + if not npc_name_used(name) { return name } + } + return pool[start] +} + +function np_left(name: string) -> void { + if np_recent == null { np_recent = new []string } + push(np_recent, name) + if len(np_recent) <= 8 { return } + let keep = new []string + for i in len(np_recent) - 8 .. len(np_recent) { push(keep, np_recent[i]) } + np_recent = keep +} + +# how many pairs share a name, the place's own names included +export function npc_name_clashes() -> int { + var dup = 0 + let all = npc_walkers() + for i in 0 .. len(all) { + let p = all[i] + if not p.active { continue } + if NpcWorld.taken(p.name) { dup += 1 } + for k in i + 1 .. len(all) { if all[k].active and all[k].name == p.name { dup += 1 } } + } + return dup +} diff --git a/packages/ludic.npc/package.ludic b/packages/ludic.npc/package.ludic new file mode 100644 index 00000000..f331efd2 --- /dev/null +++ b/packages/ludic.npc/package.ludic @@ -0,0 +1,5 @@ +# ludic.npc - the other people in a place: a routine by the hour, walking between spots and round +# what is in the way, facing a player who comes near, and what they say as data. Uses ludic.base. +package "ludic.npc" +version "0.1.0" +kind source diff --git a/packages/ludic.npc/person.ludic b/packages/ludic.npc/person.ludic new file mode 100644 index 00000000..43e5c1a2 --- /dev/null +++ b/packages/ludic.npc/person.ludic @@ -0,0 +1,109 @@ +# person.ludic - the people: walkers in slots the census fills and empties, and the fixed ones at +# their posts; the package's own dice and its facts +var np_walkers: []NpcPerson = null +var np_posts: []NpcPerson = null +var np_max: int = 10 # walkers at most +var np_n: int = 0 # walkers about +var np_dice: Rng = null +var np_facts: Queue = null +var np_next_h: float = 0.0 # the hour the census is next looked at +var np_hold: bool = false # held where they stand (a shot of their poses) +var np_mirror: bool = false # a guest drawing a host's: no routine runs here +const NP_PI: float = 3.1415927 +const NP_TAU: float = 6.2831853 + +export function npc_facts() -> Queue { + if np_facts == null { np_facts = queue_new("npc.facts") } + return np_facts +} + +function np_fact(what: int, p: NpcPerson, player: int) -> NpcFact { + let f = new NpcFact + f.what = what + f.person = p.id + f.slot = p.slot + f.fixed = p.fixed + f.player = player + f.act = p.act + q_push(npc_facts(), f) + return f +} + +function np_ensure() -> void { + if np_walkers == null { np_walkers = new []NpcPerson } + if np_posts == null { np_posts = new []NpcPerson } + if np_dice == null { np_dice = rng_new(20260925) } +} + +# how many walkers the place can hold at once +export function npc_config(max: int) -> void { np_max = max } + +export function npc_seed(n: int) -> void { + np_ensure() + rng_seed(np_dice, n) +} + +# 0 .. 1, and whole seconds lo .. hi, off the package's dice +export function npc_rnd() -> float { + np_ensure() + return rng_float(np_dice) +} + +export function npc_between(lo: int, hi: int) -> int { + np_ensure() + return rng_between(np_dice, lo, hi) +} + +export function npc_secs(lo: int, hi: int) -> float { return float(npc_between(lo, np_hi(lo, hi))) } + +export function npc_walkers() -> []NpcPerson { + np_ensure() + return np_walkers +} + +export function npc_posts() -> []NpcPerson { + np_ensure() + return np_posts +} + +export function npc_count() -> int { return np_n } + +export function npc_max() -> int { return np_max } + +# a walker's slot or a post, and anyone by id +export function npc_at(slot: int) -> NpcPerson { + np_ensure() + if slot < 0 or slot >= len(np_walkers) { return null } + return np_walkers[slot] +} + +export function npc_post(i: int) -> NpcPerson { + np_ensure() + if i < 0 or i >= len(np_posts) { return null } + return np_posts[i] +} + +export function npc_by_id(id: int) -> NpcPerson { + np_ensure() + for i in 0 .. len(np_walkers) { if np_walkers[i].active and np_walkers[i].id == id { return np_walkers[i] } } + for i in 0 .. len(np_posts) { if np_posts[i].active and np_posts[i].id == id { return np_posts[i] } } + return null +} + +export function npc_d(ax: float, az: float, bx: float, bz: float) -> float { + let dx = ax - bx + let dz = az - bz + return Math.sqrt(dx * dx + dz * dz) +} + +export function npc_wrap(a: float) -> float { + var r = a + while r > NP_PI { r = r - NP_TAU } + while r < -NP_PI { r = r + NP_TAU } + return r +} + +function np_hi(lo: int, hi: int) -> int { + if hi < lo { return lo } + return hi +} diff --git a/packages/ludic.npc/port.ludic b/packages/ludic.npc/port.ludic new file mode 100644 index 00000000..e9a582ea --- /dev/null +++ b/packages/ludic.npc/port.ludic @@ -0,0 +1,71 @@ +# port.ludic - what the people ask the place. Unbound: a flat dry field at noon on day one, one +# player at the origin, nobody wanted, every name free, and nothing drawn +export port NpcWorld { + ground: fn(float, float) -> float = fn nw_zero2 + water: fn(float, float) -> float = fn nw_dry # the water's surface there + slope: fn(float, float) -> float = fn nw_zero2 + allowed: fn(float, float) -> bool = fn nw_yes2 # a person may stop here (not the player's camp) + hour: fn() -> float = fn nw_noon + day: fn() -> int = fn nw_day_one + players: fn() -> int = fn nw_one # player slots + player_on: fn(int) -> bool = fn nw_first + player_x: fn(int) -> float = fn nw_zero1 + player_z: fn(int) -> float = fn nw_zero1 + want: fn() -> int = fn nw_none # walkers the place wants at this hour + extra: fn() -> int = fn nw_no_kind # a kind to bring out whatever the crowd (-1) + origin_x: fn() -> float = fn nw_zero0 # where anyone is put when nowhere else will do + origin_z: fn() -> float = fn nw_zero0 + next_id: fn() -> int = fn nw_next_id + body_of: fn(int) -> int = fn nw_body_of # a look seed's body + taken: fn(string) -> bool = fn nw_free # a name the place already uses + born: fn(NpcPerson) -> void = fn nw_nothing # a new one (or a slot reused): draw it + gone: fn(NpcPerson) -> void = fn nw_nothing # left: stop drawing it +} + +# what the story says through them: the words of a line whose `fill` is set ("" when it has nothing +# to say now), and a greeting (a vendor's, when talked to) +export port NpcTalk { + say: fn(NpcPerson, int) -> string = fn nw_silent + greeting: fn(NpcPerson) -> string = fn nw_hello +} + +function nw_zero0() -> float { return 0.0 } +function nw_zero1(p: int) -> float { return 0.0 } +function nw_zero2(x: float, z: float) -> float { return 0.0 } +function nw_dry(x: float, z: float) -> float { return -1000.0 } +function nw_yes2(x: float, z: float) -> bool { return true } +function nw_noon() -> float { return 12.0 } +function nw_day_one() -> int { return 1 } +function nw_one() -> int { return 1 } +function nw_first(p: int) -> bool { return p == 0 } +function nw_none() -> int { return 0 } +function nw_no_kind() -> int { return -1 } +function nw_body_of(look: int) -> int { return look % 2 } +function nw_free(n: string) -> bool { return false } +function nw_nothing(p: NpcPerson) -> void { } +function nw_silent(p: NpcPerson, line: int) -> string { return "" } +function nw_hello(p: NpcPerson) -> string { return "" } +var nw_ids: int = 0 +function nw_next_id() -> int { + nw_ids += 1 + return nw_ids +} + +# the nearest player on and their distance (npc_near_i: -1 when nobody is on) +var np_near_i: int = -1 + +export function npc_near(x: float, z: float) -> float { + var bd = 1000000.0 + np_near_i = -1 + for i in 0 .. NpcWorld.players() { + if not NpcWorld.player_on(i) { continue } + let d = npc_d(x, z, NpcWorld.player_x(i), NpcWorld.player_z(i)) + if d < bd { + bd = d + np_near_i = i + } + } + return bd +} + +export function npc_near_i() -> int { return np_near_i } diff --git a/packages/ludic.npc/routine.ludic b/packages/ludic.npc/routine.ludic new file mode 100644 index 00000000..9a983037 --- /dev/null +++ b/packages/ludic.npc/routine.ludic @@ -0,0 +1,106 @@ +# routine.ludic - going somewhere to do something, doing it here, and deciding what is next: the +# kind's own plan first, then a weighted draw among its steps for this hour, falling through to the +# next step whose spot can be found, and standing a while when none can +export function npc_go(p: NpcPerson, x: float, z: float, act: int, dur: float) -> void { + p.tx = x + p.tz = z + p.next_act = act + p.next_t = dur + p.act = NPCA_WALK + p.via_on = false + p.detours = 0 + p.prog_t = 0.0 + p.best = npc_d(p.x, p.z, x, z) + p.fx = x + (x - p.x) + p.fz = z + (z - p.z) + p.speed = 1.1 + npc_rnd() * 0.4 +} + +# whatever it is, it is not done where the place says nobody stops: walk out of there first +export function npc_do(p: NpcPerson, act: int, dur: float) -> void { + if act != NPCA_WALK and not p.fixed and not NpcWorld.allowed(p.x, p.z) { + if npc_spot_near(p.x, p.z, 20.0, 60.0) { + let fx = p.fx + let fz = p.fz + npc_go(p, np_px, np_pz, act, dur) + if act != NPCA_IDLE { + p.fx = fx + p.fz = fz + } + return + } + } + p.act = act + p.act_t = dur + p.speed = 0.0 +} + +export function npc_face(p: NpcPerson, x: float, z: float) -> void { + p.fx = x + p.fz = z +} + +export function npc_face_ahead(p: NpcPerson) -> void { + p.fx = p.x - Math.sin(p.yaw) * 10.0 + p.fz = p.z - Math.cos(p.yaw) * 10.0 +} + +# where it faces sitting at home: its own fire when it has one, else out ahead from home +export function npc_set_home_face(p: NpcPerson, x: float, z: float) -> void { + p.hfx = x + p.hfz = z + p.home_face = true +} + +function np_in_hours(s: NpcStep, h: float) -> bool { + if s.from <= s.to { return h >= s.from and h < s.to } + return h >= s.from or h < s.to +} + +# what next: the kind's plan, then its steps for this hour +export function npc_plan(p: NpcPerson) -> void { + p.stalk = false + if p.fixed { + npc_do(p, NPCA_IDLE, 3600.0) + return + } + let k = NpcKinds[p.kind] + if k.plan != null { + let plan = k.plan + if plan(p) { return } + } + if k.steps != null and npc_steps(p, k.steps) { return } + npc_face_ahead(p) + npc_do(p, NPCA_IDLE, 10.0) +} + +# a weighted draw among the steps whose hours hold now; a step whose spot cannot be found +# gives way to the next one in the table, round to the start +export function npc_steps(p: NpcPerson, steps: []NpcStep) -> bool { + let h = NpcWorld.hour() + var total = 0 + for i in 0 .. len(steps) { if np_in_hours(steps[i], h) { total += np_pos(steps[i].weight) } } + if total <= 0 { return false } + var r = npc_between(0, total - 1) + var first = -1 + for i in 0 .. len(steps) { + if not np_in_hours(steps[i], h) { continue } + r -= np_pos(steps[i].weight) + if r < 0 { + first = i + break + } + } + for k in 0 .. len(steps) { + let s = steps[(first + k) % len(steps)] + if not np_in_hours(s, h) or s.spot < 0 or s.spot >= NPCS_COUNT { continue } + let find = NpcSpots[s.spot].find + if find != null and find(p, s) { return true } + } + return false +} + +function np_pos(n: int) -> int { + if n < 0 { return 0 } + return n +} diff --git a/packages/ludic.npc/spots.ludic b/packages/ludic.npc/spots.ludic new file mode 100644 index 00000000..424de009 --- /dev/null +++ b/packages/ludic.npc/spots.ludic @@ -0,0 +1,80 @@ +# spots.ludic - the spots every place has: here, home, somewhere near home or near here, the shore +# near either, and a few steps anywhere that can be walked to. A game adds its own (a sign, an +# animal) as `def NpcSpots`; each sends the person with npc_go / npc_do and says whether it could. +def NpcSpots here { find: fn np_spot_here } +def NpcSpots home { find: fn np_spot_home } +def NpcSpots near_home { find: fn np_spot_near_home } +def NpcSpots near_here { find: fn np_spot_near_here } +def NpcSpots shore_home { find: fn np_spot_shore_home } +def NpcSpots shore_here { find: fn np_spot_shore_here } +def NpcSpots wander { find: fn np_spot_wander } + +# the step's act for the step's time, at (x, z) +export function npc_step_go(p: NpcPerson, s: NpcStep, x: float, z: float) -> void { + npc_go(p, x, z, s.act, npc_secs(s.lo, s.hi)) +} + +function np_spot_here(p: NpcPerson, s: NpcStep) -> bool { + npc_face_ahead(p) + npc_do(p, s.act, npc_secs(s.lo, s.hi)) + return true +} + +function np_spot_home(p: NpcPerson, s: NpcStep) -> bool { + var fx = p.hx - Math.sin(p.yaw) * 5.0 + var fz = p.hz - Math.cos(p.yaw) * 5.0 + if p.home_face { + fx = p.hfx + fz = p.hfz + } + if npc_d(p.x, p.z, p.hx, p.hz) < 2.0 { npc_do(p, s.act, npc_secs(s.lo, s.hi)) } else { npc_step_go(p, s, p.hx, p.hz) } + npc_face(p, fx, fz) + return true +} + +function np_spot_near_home(p: NpcPerson, s: NpcStep) -> bool { + if not npc_spot_near(p.hx, p.hz, s.near, s.far) { return false } + npc_step_go(p, s, np_px, np_pz) + return true +} + +function np_spot_near_here(p: NpcPerson, s: NpcStep) -> bool { + if not npc_spot_near(p.x, p.z, s.near, s.far) { return false } + npc_step_go(p, s, np_px, np_pz) + return true +} + +# at the water's edge, facing out over it +function np_spot_shore(p: NpcPerson, s: NpcStep, x: float, z: float) -> bool { + if not npc_shore(x, z, s.far) { return false } + let wx = np_wx + let wz = np_wz + npc_step_go(p, s, np_px, np_pz) + npc_face(p, wx, wz) + return true +} + +function np_spot_shore_home(p: NpcPerson, s: NpcStep) -> bool { return np_spot_shore(p, s, p.hx, p.hz) } + +function np_spot_shore_here(p: NpcPerson, s: NpcStep) -> bool { return np_spot_shore(p, s, p.x, p.z) } + +function np_spot_wander(p: NpcPerson, s: NpcStep) -> bool { + npc_wander(p) + return true +} + +# a few steps somewhere that can actually be walked to, after being stuck +export function npc_wander(p: NpcPerson) -> void { + for t in 0 .. 16 { + let ang = npc_rnd() * NP_TAU + let d = 8.0 + npc_rnd() * 18.0 + let x = p.x + Math.cos(ang) * d + let z = p.z + Math.sin(ang) * d + if npc_walkable(x, z) and npc_line_ok(p.x, p.z, x, z) { + npc_go(p, x, z, NPCA_IDLE, npc_secs(5, 12)) + return + } + } + npc_face_ahead(p) + npc_do(p, NPCA_IDLE, 8.0) +} diff --git a/packages/ludic.npc/system.ludic b/packages/ludic.npc/system.ludic new file mode 100644 index 00000000..721fe7b8 --- /dev/null +++ b/packages/ludic.npc/system.ludic @@ -0,0 +1,8 @@ +# system.ludic - the people as a system: PH_SIMULATE with a reset and no tick (the game steps them +# with npc_tick where it runs the place, and npc_mirror_tick where it only draws a host's); a trip +# saves none of them - who is about is the hour's, not the trip's +export function npc_system() -> System { + let s = system_new("npc", PH_SIMULATE) + s.reset = fn npc_reset + return s +} diff --git a/packages/ludic.npc/talk.ludic b/packages/ludic.npc/talk.ludic new file mode 100644 index 00000000..a977a357 --- /dev/null +++ b/packages/ludic.npc/talk.ludic @@ -0,0 +1,96 @@ +# talk.ludic - what somebody says, as data: lines by kind and act, the highest `prio` that has +# anything to say winning, each line in its turn given its `chance`; a line's words are its `text`, +# or the story's (NpcTalk.say) when it `fill`s; a line may offer choices, each answered by a line +export property NpcChoice { + text: string = "" + line: string = "" # the key of the line that answers it ("" ends the talk) + code: int = -1 # the game's, carried by the Talked fact +} + +export property NpcLine { + key: string = "" + kind: int = -1 # NPCK_* (-1: anyone) + act: int = -1 # NPCA_* (-1: whatever they are doing) + prio: int = 0 + chance: int = 100 # percent: how often this line is even considered + text: string = "" + fill: bool = false + choices: []NpcChoice = null +} + +export open registry NpcLines of NpcLine as NPCL + +var np_said: string = "" + +function np_line_fits(p: NpcPerson, l: NpcLine) -> bool { + return (l.kind < 0 or l.kind == p.kind) and (l.act < 0 or l.act == p.act) +} + +# its words now ("" when it has nothing to say) +export function npc_words(p: NpcPerson, line: int) -> string { + if line < 0 or line >= NPCL_COUNT { return "" } + if NpcLines[line].fill { return NpcTalk.say(p, line) } + return NpcLines[line].text +} + +# the line it would say now (-1: none), its words kept for npc_said() +export function npc_line_for(p: NpcPerson) -> int { + np_said = "" + var top = -1000000 + for i in 0 .. NPCL_COUNT { if NpcLines[i].prio > top { top = NpcLines[i].prio } } + var prio = top + while prio > -1000000 { + var lower = -1000000 + for i in 0 .. NPCL_COUNT { + let l = NpcLines[i] + if l.prio < prio and l.prio > lower { lower = l.prio } + if l.prio != prio or not np_line_fits(p, l) { continue } + if l.chance < 100 and npc_between(0, 99) >= l.chance { continue } + let w = npc_words(p, i) + if len(w) > 0 { + np_said = w + return i + } + } + prio = lower + } + return -1 +} + +export function npc_said() -> string { return np_said } + +# a player talks to them: the line (-1: nothing to say), its words in npc_said(), a Talked fact, +# and `said` marked for the day +export function npc_talk(p: NpcPerson, player: int) -> int { + let line = npc_line_for(p) + let f = np_fact(NPC_F_TALKED, p, player) + f.line = line + p.said = NpcWorld.day() + return line +} + +# the answer to choice c of a line: the line it leads to (-1: the talk is over), its words in +# npc_said(), and a Talked fact carrying the choice's code +export function npc_choose(p: NpcPerson, line: int, c: int, player: int) -> int { + np_said = "" + if line < 0 or line >= NPCL_COUNT { return -1 } + let cs = NpcLines[line].choices + if cs == null or c < 0 or c >= len(cs) { return -1 } + let next = npc_lines_find(cs[c].line) + let f = np_fact(NPC_F_TALKED, p, player) + f.line = next + f.choice = cs[c].code + if next >= 0 { np_said = npc_words(p, next) } + return next +} + +function npc_lines_find(key: string) -> int { + if len(key) == 0 { return -1 } + return npclines_find(key) +} + +# spoken to already today +export function npc_talked_today(p: NpcPerson) -> bool { return p.said == NpcWorld.day() } + +# a greeting (a vendor's): the story's words for this person +export function npc_greeting(p: NpcPerson) -> string { return NpcTalk.greeting(p) } diff --git a/packages/ludic.npc/tests/fake/index.ludic b/packages/ludic.npc/tests/fake/index.ludic new file mode 100644 index 00000000..c335a945 --- /dev/null +++ b/packages/ludic.npc/tests/fake/index.ludic @@ -0,0 +1,61 @@ +# fake - a place for the people: dry flat ground east of x = -50 and a lake west of it, a camp at +# (40, 0) nobody may stop in, two players, and what the port was asked to draw +numbers float +var fk_hour: float = 12.0 +var fk_day: int = 1 +var fk_want: int = 0 +var fk_extra: int = -1 +var fk_px: []float = null +var fk_pz: []float = null +var fk_on: []bool = null +var fk_born: int = 0 +var fk_gone: int = 0 + +function fk_setup() -> void { + fk_hour = 12.0 + fk_day = 1 + fk_want = 0 + fk_extra = -1 + fk_px = [500.0, 900.0] + fk_pz = [500.0, 900.0] + fk_on = [true, false] + fk_born = 0 + fk_gone = 0 + npc_clear() + npc_reset() + npc_seed(7) + npc_config(10) +} + +function fk_ground(x: float, z: float) -> float { return 0.0 } +function fk_water(x: float, z: float) -> float { + if x < -50.0 { return 1.0 } + return -100.0 +} +function fk_allowed(x: float, z: float) -> bool { return npc_d(x, z, 40.0, 0.0) > 15.0 } +function fk_hour_now() -> float { return fk_hour } +function fk_day_now() -> int { return fk_day } +function fk_players() -> int { return 2 } +function fk_player_on(i: int) -> bool { return fk_on[i] } +function fk_x(i: int) -> float { return fk_px[i] } +function fk_z(i: int) -> float { return fk_pz[i] } +function fk_wanted() -> int { return fk_want } +function fk_extra_kind() -> int { return fk_extra } +function fk_taken(n: string) -> bool { return n == "Ada" } +function fk_on_born(p: NpcPerson) -> void { fk_born += 1 } +function fk_on_gone(p: NpcPerson) -> void { fk_gone += 1 } + +function fk_player_at(x: float, z: float) -> void { + fk_px[0] = x + fk_pz[0] = z +} + +# seconds of the place, in twentieths +function fk_run(secs: float) -> void { for k in 0 .. int(secs * 20.0) { npc_tick(0.05) } } + +function fk_count(what: int) -> int { + let fs = q_drain(npc_facts()) + var n = 0 + for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } } + return n +} diff --git a/packages/ludic.npc/tests/kinds.lres b/packages/ludic.npc/tests/kinds.lres new file mode 100644 index 00000000..2c54af89 --- /dev/null +++ b/packages/ludic.npc/tests/kinds.lres @@ -0,0 +1,19 @@ +# kinds.lres - the test's kinds: a camper who fishes the shore by day and sits at home by night, a +# hiker who wanders, and a vendor at a post the census never draws +camper { + weight: 1, night: true, stays: true + steps: [ + { from: 21.0, to: 5.0, spot: NPCS_HOME, act: NPCA_SIT, lo: 60, hi: 150 }, + { from: 5.0, to: 21.0, spot: NPCS_SHORE_HERE, act: NPCA_FISH, far: 80.0, lo: 60, hi: 90 } + ] +} +hiker { + weight: 3 + start: fn tk_start + steps: [ + { weight: 2, spot: NPCS_NEAR_HERE, act: NPCA_IDLE, near: 5.0, far: 20.0 }, + { weight: 1, spot: NPCS_HERE, act: NPCA_SIT }, + { weight: 1, spot: NPCS_WANDER, act: NPCA_IDLE } + ] +} +vendor { weight: 0, notice: 15.0 } diff --git a/packages/ludic.npc/tests/lines.lres b/packages/ludic.npc/tests/lines.lres new file mode 100644 index 00000000..11bf8b68 --- /dev/null +++ b/packages/ludic.npc/tests/lines.lres @@ -0,0 +1,6 @@ +# lines.lres - the test's lines: a fisher's hush, the story's news when it has any, a greeting with +# two choices, and the answer to the first +hush { act: NPCA_FISH, prio: 3, text: "Shh." } +news { prio: 2, fill: true } +hello { prio: 1, text: "Morning.", choices: [{ text: "Any fish?", line: "fish_no", code: 7 }, { text: "Bye.", line: "" }] } +fish_no { prio: -1, kind: NPCK_CAMPER, text: "Not today." } diff --git a/packages/ludic.npc/tests/names.lres b/packages/ludic.npc/tests/names.lres new file mode 100644 index 00000000..4c5a0234 --- /dev/null +++ b/packages/ludic.npc/tests/names.lres @@ -0,0 +1,15 @@ +# names.lres - two pools of seven, one per body, and one the place keeps (Ada) that is never given +nora { name: "Nora", body: 1 } +wren { name: "Wren", body: 1 } +ada { name: "Ada", body: 1 } +dev { name: "Dev", body: 0 } +cal { name: "Cal", body: 0 } +tom { name: "Tom", body: 0 } +ines { name: "Ines", body: 1 } +ruth { name: "Ruth", body: 1 } +pia { name: "Pia", body: 1 } +bea { name: "Bea", body: 1 } +sol { name: "Sol", body: 0 } +gus { name: "Gus", body: 0 } +ozan { name: "Ozan", body: 0 } +rafe { name: "Rafe", body: 0 } diff --git a/packages/ludic.npc/tests/npc_test.ludic b/packages/ludic.npc/tests/npc_test.ludic new file mode 100644 index 00000000..760619d4 --- /dev/null +++ b/packages/ludic.npc/tests/npc_test.ludic @@ -0,0 +1,230 @@ +# npc_test.ludic - the census by the hour and by weight, unique names, walking to a spot and never +# into the water, a routine's steps by the hour, turning to a player and greeting them once, leaving +# out of sight, lines and choices, a post, and a guest's copy +import "ludic.npc" +import "ludic.base" +import "fake" +program NpcTest { + numbers float + bind NpcWorld { + ground: fn fk_ground + water: fn fk_water + allowed: fn fk_allowed + hour: fn fk_hour_now + day: fn fk_day_now + players: fn fk_players + player_on: fn fk_player_on + player_x: fn fk_x + player_z: fn fk_z + want: fn fk_wanted + extra: fn fk_extra_kind + taken: fn fk_taken + born: fn fk_on_born + gone: fn fk_on_gone + } + bind NpcTalk { say: fn tk_say, greeting: fn tk_greeting } + + def NpcActs sit { } + def NpcActs fish { turns: false } + def NpcKinds from "kinds.lres" + def NpcNames from "names.lres" + def NpcLines from "lines.lres" + + var news_on: bool = false + function tk_say(p: NpcPerson, line: int) -> string { + if news_on { return `{p.name} saw a bear.` } + return "" + } + function tk_greeting(p: NpcPerson) -> string { return "What'll it be?" } + function tk_start(p: NpcPerson) -> void { npc_start_near(p, 0.0, 0.0, 0.0, 10.0) } + + function one(kind: int) -> NpcPerson { + fk_setup() + fk_want = 1 + let p = npc_spawn(kind) + npc_set_at(p, 0.0, 0.0) + return p + } + + test "the tables: the package's acts and spots, and the game's kinds, names and lines" { + expect_eq(NPCA_WALK, 0) + expect_eq(NPCA_IDLE, 1) + expect_eq(npcspots_find("shore_here"), NPCS_SHORE_HERE) + expect_eq(NPCK_COUNT, 3) + expect(NpcKinds[NPCK_CAMPER].stays) + expect_eq(len(NpcKinds[NPCK_HIKER].steps), 3) + expect_eq(NPCN_COUNT, 14) + } + + test "the census fills to what the place wants, by weight, and after dark only who stays out" { + fk_setup() + fk_want = 4 + npc_tick(0.05) + expect_eq(npc_count(), 4) + expect_eq(fk_born, 4) + expect_eq(npc_name_clashes(), 0) + for i in 0 .. len(npc_walkers()) { expect(npc_walkers()[i].kind != NPCK_VENDOR) } + fk_setup() + fk_want = 3 + fk_hour = 23.0 + npc_tick(0.05) + expect_eq(npc_count(), 3) + for i in 0 .. len(npc_walkers()) { expect_eq(npc_walkers()[i].kind, NPCK_CAMPER) } + } + + test "no name twice, and never one the place keeps" { + fk_setup() + fk_want = 6 + npc_tick(0.05) + expect_eq(npc_count(), 6) + expect_eq(npc_name_clashes(), 0) + for i in 0 .. 6 { expect(npc_walkers()[i].name != "Ada") } + } + + test "it walks to where it is going and begins there, facing what it came for" { + let p = one(NPCK_HIKER) + npc_go(p, 20.0, 0.0, NPCA_SIT, 30.0) + npc_face(p, 30.0, 0.0) + fk_run(30.0) + expect_eq(p.act, NPCA_SIT) + expect(npc_d(p.x, p.z, 20.0, 0.0) < 1.3) + expect(p.phase > 20.0) + expect_eq(fk_count(NPC_F_ARRIVED), 1) + expect_near(p.fx, 30.0, 0.01) + } + + test "nobody walks into the lake" { + let p = one(NPCK_HIKER) + npc_go(p, -80.0, 0.0, NPCA_SIT, 30.0) + var west = 0.0 + for k in 0 .. 1200 { + npc_tick(0.05) + west = Math.min(west, p.x) + } + expect(west > -50.5) + } + + test "a step's spot is where its hours say: the shore by day, home by night" { + let p = one(NPCK_CAMPER) + npc_set_at(p, -20.0, 0.0) + fk_hour = 12.0 + npc_plan(p) + expect_eq(p.act, NPCA_WALK) + expect_eq(p.next_act, NPCA_FISH) + expect(p.fx < -50.0) + fk_hour = 23.0 + npc_plan(p) + expect_eq(p.act, NPCA_SIT) + } + + test "an act is not begun where the place says nobody stops" { + let p = one(NPCK_HIKER) + npc_set_at(p, 40.0, 0.0) + npc_do(p, NPCA_SIT, 30.0) + expect_eq(p.act, NPCA_WALK) + expect(fk_allowed(p.tx, p.tz)) + } + + test "it turns to a player who comes near, greets them once, and again after they have gone" { + let p = one(NPCK_HIKER) + npc_do(p, NPCA_IDLE, 100.0) + p.yaw = 0.0 + fk_player_at(0.0, 0.0 - 5.0) + fk_run(2.0) + expect(Math.abs(p.yaw) < 0.05) + fk_player_at(5.0, 0.0) + fk_run(3.0) + expect(Math.abs(npc_wrap(p.yaw + 1.5707963)) < 0.05) + fk_run(1.0) + expect_eq(fk_count(NPC_F_GREETED), 1) + fk_player_at(40.0, 0.0) + fk_run(0.5) + fk_player_at(5.0, 0.0) + fk_run(0.5) + expect_eq(fk_count(NPC_F_GREETED), 1) + } + + test "a fisher keeps its eyes on the water, whoever is near" { + let p = one(NPCK_HIKER) + npc_do(p, NPCA_FISH, 100.0) + p.yaw = 0.0 + npc_face(p, 0.0, -10.0) + fk_player_at(5.0, 0.0) + fk_run(2.0) + expect(Math.abs(p.yaw) < 0.05) + } + + test "the day ends out of sight, and not in it" { + let p = one(NPCK_HIKER) + npc_do(p, NPCA_IDLE, 1000.0) + fk_hour = 22.5 + fk_player_at(20.0, 0.0) + fk_run(0.5) + expect(p.active) + fk_player_at(500.0, 0.0) + fk_run(0.5) + expect(not p.active) + expect_eq(fk_gone, 1) + let c = one(NPCK_CAMPER) + npc_do(c, NPCA_IDLE, 1000.0) + fk_hour = 22.5 + fk_run(0.5) + expect(c.active) + } + + test "lines: the highest that has something to say, the story's words, and choices" { + let p = one(NPCK_CAMPER) + npc_do(p, NPCA_FISH, 100.0) + expect_eq(npc_talk(p, 0), NPCL_HUSH) + expect(npc_said() == "Shh.") + expect(npc_talked_today(p)) + npc_do(p, NPCA_IDLE, 100.0) + expect_eq(npc_line_for(p), NPCL_HELLO) + news_on = true + expect_eq(npc_line_for(p), NPCL_NEWS) + expect(npc_said() == `{p.name} saw a bear.`) + news_on = false + expect_eq(npc_choose(p, NPCL_HELLO, 0, 0), NPCL_FISH_NO) + expect(npc_said() == "Not today.") + expect_eq(npc_choose(p, NPCL_HELLO, 1, 0), -1) + let fs = q_drain(npc_facts()) + expect_eq(len(fs), 3) + expect_eq(fs[1].choice, 7) + expect(npc_greeting(p) == "What'll it be?") + } + + test "a post stands where it was put, is not counted, and turns back when nobody is near" { + fk_setup() + let v = npc_place(NPCK_VENDOR, 0.0, 0.0, 0.0, "Mateo", 5) + expect(v.fixed) + expect_eq(npc_count(), 0) + fk_player_at(8.0, 0.0) + fk_run(3.0) + expect(Math.abs(npc_wrap(v.yaw + 1.5707963)) < 0.05) + fk_player_at(300.0, 0.0) + fk_run(3.0) + expect(Math.abs(v.yaw) < 0.05) + expect(npc_d(v.x, v.z, 0.0, 0.0) < 0.001) + expect(npc_by_id(v.id).name == "Mateo") + } + + test "a guest's copy: the host's slot put, heard and eased toward, and not drawn once it goes quiet" { + fk_setup() + fk_want = 2 + npc_tick(0.05) + npc_mirror(true) + expect_eq(npc_count(), 0) + let p = npc_put(3, 99, NPCK_HIKER, 12, 1.5, "Wren", 10.0, 10.0) + expect(p.active) + expect_eq(npc_count(), 1) + npc_heard(3, 10.0, 0.0, 10.0, 0.0, 1.2, NPCA_WALK, false) + npc_heard(3, 12.0, 0.0, 10.0, 0.0, 1.2, NPCA_WALK, false) + for k in 0 .. 40 { npc_mirror_tick(0.05) } + expect_near(p.x, 12.0, 0.01) + expect(p.phase > 0.0) + npc_tick(0.05) + expect_near(p.x, 12.0, 0.01) + for k in 0 .. 140 { npc_mirror_tick(0.05) } + expect(not npc_heard_lately(p)) + } +} diff --git a/packages/ludic.npc/tick.ludic b/packages/ludic.npc/tick.ludic new file mode 100644 index 00000000..d04b4fa4 --- /dev/null +++ b/packages/ludic.npc/tick.ludic @@ -0,0 +1,100 @@ +# tick.ludic - a step of everyone, on the machine that runs the place: the census on the half hour, +# the day's end (only ever out of every player's sight), the walk or the act, and who noticed whom +export function npc_tick(dt: float) -> void { + np_ensure() + for i in 0 .. len(np_posts) { if np_posts[i].active { np_step_post(np_posts[i], dt) } } + if np_hold or np_mirror { return } + np_census() + for i in 0 .. len(np_walkers) { + let p = np_walkers[i] + if not p.active or np_leaves(p) { continue } + if p.act == NPCA_WALK { npc_walk(p, dt) } else { npc_act(p, dt) } + npc_notice(p) + } +} + +# the posts alone: a machine that does not run the place still turns its vendors to whoever comes +export function npc_posts_tick(dt: float) -> void { + np_ensure() + for i in 0 .. len(np_posts) { if np_posts[i].active { np_step_post(np_posts[i], dt) } } +} + +function np_step_post(p: NpcPerson, dt: float) -> void { + npc_act(p, dt) + npc_notice(p) +} + +# counted, not tallied, so it cannot drift; the census fills to what the place wants from the kinds +# by weight (after dark, only the kinds that stay out at night), and brings out an extra when asked +function np_census() -> void { + let h = NpcWorld.hour() + if Math.abs(h - np_next_h) <= 0.5 { return } + np_next_h = h + np_n = 0 + for i in 0 .. len(np_walkers) { if np_walkers[i].active { np_n += 1 } } + let want = Math.min(NpcWorld.want(), np_max) + while np_n < want { + let k = np_draw_kind(np_is_night(h)) + if k < 0 or npc_spawn(k) == null { break } + } + let extra = NpcWorld.extra() + if extra >= 0 and np_n < np_max { npc_spawn(extra) } +} + +function np_is_night(h: float) -> bool { return h >= 21.0 or h < 5.0 } + +function np_draw_kind(night: bool) -> int { + var total = 0 + for k in 0 .. NPCK_COUNT { if np_drawn(k, night) { total += NpcKinds[k].weight } } + if total <= 0 { return -1 } + var r = npc_between(0, total - 1) + for k in 0 .. NPCK_COUNT { + if not np_drawn(k, night) { continue } + r -= NpcKinds[k].weight + if r < 0 { return k } + } + return -1 +} + +function np_drawn(k: int, night: bool) -> bool { return NpcKinds[k].weight > 0 and (NpcKinds[k].night or not night) } + +# Its day ends: somebody who does not stay walks out after its hour, and by ten at night nobody is +# out but those who stay; a crowd over what the place wants thins. Only ever where no player sees +# it go, and never while it is out for something (`goal`). +function np_leaves(p: NpcPerson) -> bool { + if p.goal >= 0 { return false } + let away = npc_near(p.x, p.z) + let h = NpcWorld.hour() + let k = NpcKinds[p.kind] + var go = false + if not k.stays and h > p.out_h and away > 120.0 { go = true } + if not k.stays and (h > 22.0 or h < 4.0) and away > 70.0 { go = true } + if np_n > NpcWorld.want() and away > 150.0 { go = true } + if go { npc_retire(p) } + return go +} + +# the census looks again at the next tick +export function npc_census_now() -> void { np_next_h = -10.0 } + +# held where they stand: no routine and no census (a shot of the poses) +export function npc_hold(on: bool) -> void { np_hold = on } + +# everyone gone, as a new trip starts; the posts are the place's and stay +export function npc_reset() -> void { + np_ensure() + for i in 0 .. len(np_walkers) { npc_retire(np_walkers[i]) } + np_n = 0 + np_next_h = 0.0 + np_hold = false + np_recent = new []string + q_clear(npc_facts()) +} + +# forgotten altogether, walkers and posts (the place itself was replaced) +export function npc_clear() -> void { + np_walkers = new []NpcPerson + np_posts = new []NpcPerson + np_n = 0 + np_next_h = 0.0 +} diff --git a/packages/ludic.npc/walk.ludic b/packages/ludic.npc/walk.ludic new file mode 100644 index 00000000..5cd2ad4e --- /dev/null +++ b/packages/ludic.npc/walk.ludic @@ -0,0 +1,90 @@ +# walk.ludic - walking to the target: turn toward it, step, never into the water and never up a +# scramble - step round it instead - and give up on a target that gets no closer for twenty seconds +export function npc_walk(p: NpcPerson, dt: float) -> void { + var gx = p.tx + var gz = p.tz + if p.via_on { + gx = p.vx + gz = p.vz + } + let dx = gx - p.x + let dz = gz - p.z + if Math.sqrt(dx * dx + dz * dz) < 1.2 { + np_arrive(p) + return + } + let dg = npc_d(p.x, p.z, p.tx, p.tz) + if dg < p.best - 1.0 { + p.best = dg + p.prog_t = 0.0 + } else { p.prog_t = p.prog_t + dt } + if p.prog_t > 20.0 { + np_stuck(p) + return + } + var sp = p.speed + if sp < 0.1 { sp = 1.2 } + if p.stalk { sp = Math.min(sp, 0.7) } + let want = Math.atan2(-dx, -dz) + let r = 3.0 * dt + p.yaw = p.yaw + Math.clamp(npc_wrap(want - p.yaw), -r, r) + let step = sp * dt + let nx = p.x - Math.sin(p.yaw) * step + let nz = p.z - Math.cos(p.yaw) * step + let nh = NpcWorld.ground(nx, nz) + if nh < NpcWorld.water(nx, nz) + 0.4 or nh - p.y > step * 1.4 { + p.stall = p.stall + dt + if p.stall > p.stall_max { p.stall_max = p.stall } + if p.detours < 10 and np_detour(p, want) { + p.detours += 1 + return + } + np_stuck(p) + return + } + p.x = nx + p.z = nz + p.y = nh + p.stall = 0.0 + p.phase = p.phase + step * 2.6 +} + +# there: a step round something ends, or the errand begins, facing what it came for +function np_arrive(p: NpcPerson) -> void { + if p.via_on { + p.via_on = false + return + } + let fx = p.fx + let fz = p.fz + npc_do(p, p.next_act, p.next_t) + p.fx = fx + p.fz = fz + p.stall = 0.0 + if p.act != NPCA_WALK { np_fact(NPC_F_ARRIVED, p, -1) } +} + +function np_detour(p: NpcPerson, want: float) -> bool { + for k in 0 .. 8 { + let ang = want + float((k / 2 + 1) * (1 - 2 * (k % 2))) * 0.6 + let x = p.x - Math.sin(ang) * 6.0 + let z = p.z - Math.cos(ang) * 6.0 + if npc_line_ok(p.x, p.z, x, z) { + p.via_on = true + p.vx = x + p.vz = z + p.yaw = ang + return true + } + } + return false +} + +function np_stuck(p: NpcPerson) -> void { + p.fails += 1 + p.via_on = false + if p.fails >= 3 { + p.fails = 0 + npc_wander(p) + } else { npc_plan(p) } +}