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:
parent
73a376ac6c
commit
029a1ebba2
23 changed files with 1664 additions and 0 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
114
packages/ludic.npc/README.md
Normal file
114
packages/ludic.npc/README.md
Normal 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
|
||||
```
|
||||
47
packages/ludic.npc/act.ludic
Normal file
47
packages/ludic.npc/act.ludic
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
||||
120
packages/ludic.npc/census.ludic
Normal file
120
packages/ludic.npc/census.ludic
Normal 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)
|
||||
}
|
||||
139
packages/ludic.npc/defs.ludic
Normal file
139
packages/ludic.npc/defs.ludic
Normal 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
|
||||
}
|
||||
101
packages/ludic.npc/ground.ludic
Normal file
101
packages/ludic.npc/ground.ludic
Normal 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
|
||||
}
|
||||
19
packages/ludic.npc/index.ludic
Normal file
19
packages/ludic.npc/index.ludic
Normal 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"
|
||||
78
packages/ludic.npc/mirror.ludic
Normal file
78
packages/ludic.npc/mirror.ludic
Normal 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 }
|
||||
}
|
||||
}
|
||||
49
packages/ludic.npc/names.ludic
Normal file
49
packages/ludic.npc/names.ludic
Normal 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
|
||||
}
|
||||
5
packages/ludic.npc/package.ludic
Normal file
5
packages/ludic.npc/package.ludic
Normal 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
|
||||
109
packages/ludic.npc/person.ludic
Normal file
109
packages/ludic.npc/person.ludic
Normal 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
|
||||
}
|
||||
71
packages/ludic.npc/port.ludic
Normal file
71
packages/ludic.npc/port.ludic
Normal 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 }
|
||||
106
packages/ludic.npc/routine.ludic
Normal file
106
packages/ludic.npc/routine.ludic
Normal 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
|
||||
}
|
||||
80
packages/ludic.npc/spots.ludic
Normal file
80
packages/ludic.npc/spots.ludic
Normal 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)
|
||||
}
|
||||
8
packages/ludic.npc/system.ludic
Normal file
8
packages/ludic.npc/system.ludic
Normal 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
|
||||
}
|
||||
96
packages/ludic.npc/talk.ludic
Normal file
96
packages/ludic.npc/talk.ludic
Normal 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) }
|
||||
61
packages/ludic.npc/tests/fake/index.ludic
Normal file
61
packages/ludic.npc/tests/fake/index.ludic
Normal 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
|
||||
}
|
||||
19
packages/ludic.npc/tests/kinds.lres
Normal file
19
packages/ludic.npc/tests/kinds.lres
Normal 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 }
|
||||
6
packages/ludic.npc/tests/lines.lres
Normal file
6
packages/ludic.npc/tests/lines.lres
Normal 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." }
|
||||
15
packages/ludic.npc/tests/names.lres
Normal file
15
packages/ludic.npc/tests/names.lres
Normal 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 }
|
||||
230
packages/ludic.npc/tests/npc_test.ludic
Normal file
230
packages/ludic.npc/tests/npc_test.ludic
Normal 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))
|
||||
}
|
||||
}
|
||||
100
packages/ludic.npc/tick.ludic
Normal file
100
packages/ludic.npc/tick.ludic
Normal 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
|
||||
}
|
||||
90
packages/ludic.npc/walk.ludic
Normal file
90
packages/ludic.npc/walk.ludic
Normal 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) }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue