feat(packages): ludic.npc - the other people in a place: kinds, spots, acts, names and lines as open registries (a kind's routine is steps by the hour - a spot, an act, how long - with its own start and plan as function values, readable from .lres), a census on the half hour by weight (after dark only who stays out) with an extra the place asks for, walking round what is in the way and never into the water or up a scramble, no act where the place says nobody stops, facing a player within notice and back to the errand, leaving only out of every player's sight, unique names, posts that never walk (a vendor), lines by prio and chance with the story's words through a port and choices, a guest's copy of a host's people; greeted, talked and arrived as facts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 09:59:07 +03:00
parent 73a376ac6c
commit 029a1ebba2
23 changed files with 1664 additions and 0 deletions

View file

@ -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 |

View file

@ -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<NpcFact>` | `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
```

View file

@ -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 }
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"

View file

@ -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 }
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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<NpcFact> = 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<NpcFact> {
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
}

View file

@ -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 }

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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) }

View file

@ -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
}

View file

@ -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 }

View file

@ -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." }

View file

@ -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 }

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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) }
}