feat(packages): ludic.character - a third-person walking body: walk and run with their acceleration, a jump and gravity, a step within CHAR_STEP taken and a slope above it measured over a fixed probe, wading that shelves into a swim, the dive and the breath, sitting, one safe put-down spot, holds by reason, being carried, and the orbit camera with first-person eyes; ground, body and stick through ports; landed, jumped, footfall, too steep, the water's facts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 08:26:31 +03:00
parent 9e713bc39c
commit b24c523fa4
20 changed files with 1282 additions and 0 deletions

View file

@ -14,6 +14,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| --- | --- |
| [ludic.base](ludic.base/README.md) | the shared vocabulary: Tick and phases, `Queue<T>`, rng streams, the save tree, the system runner (`Systems`, an open registry) |
| [ludic.anim](ludic.anim/README.md) | glTF animation clips sampled and cross-faded onto a render3d Skin (uses `ludic_render3d`) |
| [ludic.character](ludic.character/README.md) | a third-person walking body: gait, steps and slopes, a jump, wading, swimming, sitting, a safe put-down, the orbit camera |
| [ludic.clock](ludic.clock/README.md) | the hour, the day, the moon and a calendar of seasons |
| [ludic.crafting](ludic.crafting/README.md) | recipes: what goes in and out, where and how long, making, refunding, hold-to-make |
| [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) |

View file

@ -0,0 +1,84 @@
# ludic.character
One walking body under a player's stick, seen from over its shoulder: the gait's walk and run with
their acceleration, a jump with gravity, the step-versus-slope rule, wading that shelves into a swim,
diving on a breath, sitting, one answer to "where can this body be put down", holds, being carried,
and the orbit camera with its first-person eyes. Uses [`ludic.base`](../ludic.base/README.md) and
nothing else.
```ludic
import "ludic.character"
```
The package never draws. It moves a body and works out where a camera goes; the game poses a figure
from the queries (`body_phase()`, `body_sit_t()`, `body_swim_phase()`, ...) and hands `body_eye_*`
to its renderer. What happened is a fact (`char_facts()`); the game says the words, plays the
sounds and charges the energy.
## The rules it keeps
- **A pure query of the world stays pure.** `CharacterGround.height` is the world's ground and never
the body's; what the feet can stand on is `char_stand_at(x, z, from_y)`, and only the movement asks.
- **A step up and a slope up are different refusals** (`char_slope_blocks`). Within `CHAR_STEP`
(0.55 m) of the feet is a step, taken whatever its gradient; above it the boots
(`slope_limit`, a rise per metre) decide over `CHAR_SLOPE_PROBE` (0.6 m), never over the frame's
own step - so 30 Hz and 240 Hz refuse at the same place.
- **Where a body is put down is one question with one answer** (`char_safe_spot`): clear of every
collider, no deeper than asked, within `CHAR_DROP` of the height left, on ground it can walk off.
- **A collider has a height**: `push` takes the body's span (y0, y1), and `top_at` reports a top it
could step onto.
- Real time, not frames: every rate is multiplied by `dt`.
## The ports
```ludic
export port CharacterGround {
height: fn(float, float) -> float # the world's ground, pure (required)
top_at: fn(float, float, float, float, float, float) -> float # (x, z, r, from_y, step, base)
bed: fn(float, float) -> float # under water (unbound: height)
water: fn(float, float) -> float # the surface (unbound: never wet)
push: fn(float, float, float, float, float) -> bool # (x, z, r, y0, y1) out of the colliders
pushed_x: fn() -> float # ... to here
pushed_z: fn() -> float
slope: fn(float, float) -> float # 0 flat .. 1 wall (for sitting)
slope_limit: fn() -> float # the boots: rise a metre (1.05)
current: fn(float, float, float, float) -> float # (x, z, fx, fz): a stroke's share lost
}
export port CharacterBody {
pace, sneaking, breath, riding, ride(ix, iz, run, dt), holding, hold_x, hold_z, eyes_ok
}
export port CharacterInput {
move_x, move_z, run, jump, look_yaw, look_pitch, zoom, look_always
}
```
Only `height` is required; unbound, the rest is a dry plain with no colliders, a fresh body and a
still stick.
## API
| | |
| --- | --- |
| `char_update(ix, iz, run, jump, dyaw, dpitch, dt)` | a frame from explicit input; `char_tick(dt)` reads `CharacterInput` (nothing while held) |
| `char_teleport(x, z, yaw)`, `char_stand_on`, `char_push_to`, `char_face`, `char_halt`, `char_go_limp`, `char_resume_swim(dive, depth, breath)`, `char_map_changed()` | placing and stopping the body |
| `char_config(walk, run)`, `char_allow_run(on)` | 3.2 and 6.8 m/s by default; a spent body cannot run or jump |
| `char_sit(x, z, yaw, mode)`, `char_sit_here() -> CHAR_SAT / _STOOD / _NOT_RIDING / _NOT_AIRBORNE / _NOT_STEEP / _NOT_WET`, `char_stand()` | sitting (`CHAR_SIT_SEAT`, `CHAR_SIT_GROUND`) |
| `char_hold(reason)`, `char_release(reason)` (`CHAR_HOLD_SCREEN`, `CHAR_HOLD_STAGE`) | holds by reason: two holders cannot release each other |
| `char_board(x, z, yaw)`, `char_ride_at(x, y, z, yaw, speed)`, `char_step_off(x, z)` | carried by something (`CharacterBody.riding`/`ride`) |
| `char_safe_spot(x, z, from_y, max_depth) -> bool`, `body_safe_x()`, `body_safe_z()` | somewhere to put a body down |
| `char_stand_at(x, z, from_y)`, `char_slope_blocks(nh, fx, fz)` | the step rule, for a game's own movers |
| `char_camera(dt)`, `char_camera_third(dt)`, `char_look(yaw, pitch)`, `char_look_pitch`, `char_orbit(dist)`, `char_lock_orbit`, `char_set_fps`, `char_fps_toggle() -> CHAR_EYES_*` | the camera; read it back with `body_eye_x/y/z/yaw/pitch()` and `body_eyes_now()` |
| `body_x()`, `body_y()`, `body_z()`, `body_yaw()`, `body_speed()`, `body_moving()`, `body_run()`, `body_phase()`, `body_time()`, `body_water()`, `body_airborne()`, `body_vy()`, `body_landing()`, `body_steep()`, `body_step()`, `body_sitting()`, `body_sit_mode()`, `body_sit_t()`, `body_swimming()`, `body_diving()`, `body_depth()`, `body_breath()`, `body_breath_max()`, `body_floating()`, `body_swim_phase()`, `body_can_run()`, `body_held()`, `body_fps()`, `body_cam_yaw()`, `body_cam_pitch()`, `body_cam_dist()` | the queries |
| `char_wrap(a)`, `char_toward(cur, target, rate)`, `char_ease(t)` | the helpers a pose needs |
| `char_facts() -> Queue<CharacterFact>` | `{ what, x, y, z, v }`: `CHAR_LANDED`, `_JUMPED`, `_FOOTFALL`, `_TOO_STEEP` (once per refusal), `_STARTED_SWIMMING`, `_LEFT_WATER`, `_OUT_OF_AIR` (v: seconds), `_CURRENT` (every 12 s) |
| `char_reset()`, `char_system() -> System` | `"character"`, `PH_INPUT`, nothing saved: where a body stands is the game's to write |
## Tests
```bash
ludic test packages/ludic.character
```
A fake world of a meadow, a 45 cm deck, a cliff, a gentle rise, a boulder and a shelving lake: the
step and the cliff at 30, 60 and 240 Hz, speeds and the sneak, wading into a swim and out, the dive
and the breath, the jump, the safe spot, sitting and its refusals, holds, riding and the camera.

View file

@ -0,0 +1,70 @@
# camera.ludic - the orbit: a pivot at the chest easing after the body, the camera on its orbit
# behind and a share of the distance to the right (so the body sits left of the crosshair), lifted
# out of the ground and aimed back at the pivot. Or the eyes, with the gait's bob on them.
function chr_eyes_allowed() -> bool { return not CharacterBody.riding() and not chr_sit and CharacterBody.eyes_ok() }
function chr_shoulder() -> float { return Math.clamp(chr_cam_dist * 0.30, 0.6, 1.9) }
export function char_camera(dt: float) -> void {
if chr_fps and chr_eyes_allowed() {
chr_camera_eyes(dt)
return
}
char_camera_third(dt)
}
# over the shoulder, whatever the player plays in (a figure looked at from inside its head is not)
export function char_camera_third(dt: float) -> void {
chr_eyes_now = false
let k = Math.min(1.0, 10.0 * dt)
var ty = chr_y + 1.45
if chr_swim { ty = chr_y + 0.9 }
chr_piv_x = Math.lerp(chr_piv_x, chr_x, k)
chr_piv_y = Math.lerp(chr_piv_y, ty, k)
chr_piv_z = Math.lerp(chr_piv_z, chr_z, k)
let cyw = Math.cos(chr_cam_yaw)
let syw = Math.sin(chr_cam_yaw)
let cpt = Math.cos(chr_cam_pitch)
let fx = -(syw * cpt)
let fy = Math.sin(chr_cam_pitch)
let fz = -(cyw * cpt)
let ax = chr_piv_x + cyw * chr_shoulder()
let az = chr_piv_z - syw * chr_shoulder()
let cx = ax - fx * chr_cam_dist
var cyy = chr_piv_y - fy * chr_cam_dist
let cz = az - fz * chr_cam_dist
cyy = Math.max(cyy, CharacterGround.bed(cx, cz) + 0.55)
# at the top of the water the camera is held clear of it; under it, it follows the dive
if chr_swim and not chr_dive and cyy < chr_water + 0.35 { cyy = chr_water + 0.35 }
let mid_g = CharacterGround.bed((cx + ax) * 0.5, (cz + az) * 0.5) + 0.4
let mid_y = (cyy + chr_piv_y) * 0.5
if mid_y < mid_g { cyy = cyy + (mid_g - mid_y) * 2.0 }
chr_eye_x = cx
chr_eye_y = cyy
chr_eye_z = cz
let ddx = ax - cx
let ddz = az - cz
chr_eye_yaw = Math.atan2(-ddx, -ddz)
chr_eye_pitch = Math.atan2(chr_piv_y - cyy, Math.sqrt(ddx * ddx + ddz * ddz))
}
# the eyes: the head's height, the walk's bob, and the look the player is already holding
function chr_camera_eyes(dt: float) -> void {
chr_eyes_now = true
var eye = 1.62
if chr_swim { eye = 0.42 }
if chr_dive { eye = 0.30 }
let moving = Math.clamp(chr_speed / 5.0, 0.0, 1.0)
let want = 0.035 * moving * (1.0 - Math.cos(chr_phase * 2.0))
chr_fps_bob = Math.lerp(chr_fps_bob, want, Math.min(1.0, 12.0 * dt))
# never inside the ground on a steep step
let ey = Math.max(chr_y + eye + chr_fps_bob, CharacterGround.bed(chr_x, chr_z) + 0.30)
chr_piv_x = chr_x
chr_piv_y = ey
chr_piv_z = chr_z
chr_eye_x = chr_x
chr_eye_y = ey
chr_eye_z = chr_z
chr_eye_yaw = chr_cam_yaw
chr_eye_pitch = chr_cam_pitch
}

View file

@ -0,0 +1,53 @@
# ground.ludic - what the feet stand on, and the one rule for a step versus a slope
# The ground at (x, z) for feet at from_y, a collider's top included when it is a step up. Only
# the body's own movement asks this: the ground itself (CharacterGround.height) stays pure, or
# the height ratchets on itself and the body ends up on things it never climbed.
export function char_stand_at(x: float, z: float, from_y: float) -> float {
let base = CharacterGround.height(x, z)
return CharacterGround.top_at(x, z, 0.35, Math.max(from_y, base), CHAR_STEP, base)
}
# A step up and a slope up are different refusals. Within CHAR_STEP of the feet is a step, taken
# whatever the gradient into it (a 45 cm deck arrives in one frame); above that the boots decide,
# measured over CHAR_SLOPE_PROBE, so the frame rate never changes the answer.
export function char_slope_blocks(nh: float, fx: float, fz: float) -> bool {
if nh - chr_y > CHAR_STEP { return true }
let sx = chr_x + fx * CHAR_SLOPE_PROBE
let sz = chr_z + fz * CHAR_SLOPE_PROBE
return CharacterGround.height(sx, sz) - chr_y > CHAR_SLOPE_PROBE * CharacterGround.slope_limit()
}
# a body of radius r at (x, z) from y0 to y1, out of every collider: the resolved point is chr_out_*
var chr_out_x: float = 0.0
var chr_out_z: float = 0.0
function chr_push(x: float, z: float, r: float, y0: float, y1: float) -> bool {
chr_ask_x = x
chr_ask_z = z
let hit = CharacterGround.push(x, z, r, y0, y1)
chr_out_x = x
chr_out_z = z
if hit {
chr_out_x = CharacterGround.pushed_x()
chr_out_z = CharacterGround.pushed_z()
}
return hit
}
function chr_bed_depth(x: float, z: float) -> float { return CharacterGround.water(x, z) - CharacterGround.bed(x, z) }
export function char_wrap(a: float) -> float {
var d = a
while d > chr_pi { d = d - 2.0 * chr_pi }
while d < -chr_pi { d = d + 2.0 * chr_pi }
return d
}
export function char_toward(cur: float, target: float, rate: float) -> float {
return cur + Math.clamp(target - cur, -rate, rate)
}
# did the gait's phase step over `mark` this frame? (it wraps at 2 pi)
function chr_cross(a: float, b: float, mark: float) -> bool {
if b < a { return a > mark or b < mark }
return a < mark and not (b < mark)
}

View file

@ -0,0 +1,21 @@
# ludic.character - one body under a player's stick: the game says where the ground, the water and
# the colliders are and what the stick asks; the package moves the body and its camera and says
# what happened. It never draws: the game poses a figure and points its camera from the queries.
module ludic_character uses ludic_base
numbers float
import "ludic.base"
import "rules.ludic"
import "ports.ludic"
import "state.ludic"
import "queries.ludic"
import "ground.ludic"
import "safe.ludic"
import "water.ludic"
import "swim.ludic"
import "walk.ludic"
import "steer.ludic"
import "update.ludic"
import "sit.ludic"
import "verbs.ludic"
import "camera.ludic"
import "system.ludic"

View file

@ -0,0 +1,6 @@
# ludic.character - a third-person walking body: the gait's speeds, steps and slopes, a jump,
# wading, swimming and diving, sitting, a safe place to be put down, and the orbit camera.
# Uses ludic.base and nothing else. See README.md.
package "ludic.character"
version "0.1.0"
kind source

View file

@ -0,0 +1,62 @@
# ports.ludic - what the body asks the game. Unbound, the world is a dry plain with no colliders,
# the boots manage 1.05 m a metre, the body is fresh and nothing holds or carries it.
export port CharacterGround {
height: fn(float, float) -> float # the world's own ground: pure, never the body's
# (x, z, r, from_y, step, base): the top of a collider within `step` above from_y, else base
top_at: fn(float, float, float, float, float, float) -> float = fn chr__top_plain
bed: fn(float, float) -> float = fn chr__bed_plain # the bed under water (unbound: the ground)
water: fn(float, float) -> float = fn chr__dry # the surface over a point
# (x, z, r, y0, y1): true when a collider takes a body of radius r there; pushed_x / _z say where to
push: fn(float, float, float, float, float) -> bool = fn chr__no_push
pushed_x: fn() -> float = fn chr__asked_x
pushed_z: fn() -> float = fn chr__asked_z
slope: fn(float, float) -> float = fn chr__slope_plain # 0 flat .. 1 a wall
slope_limit: fn() -> float = fn chr__boots # the rise a metre the boots take
current: fn(float, float, float, float) -> float = fn chr__still # (x, z, fx, fz): 0..1 of a stroke lost
}
export port CharacterBody {
pace: fn() -> float = fn chr__one # the walk and run, times (gear, a tired body)
sneaking: fn() -> bool = fn chr__no
breath: fn() -> float = fn chr__one # the lungs, times
riding: fn() -> bool = fn chr__no # something else carries the body
ride: fn(float, float, bool, float) -> void = fn chr__not_carried # (ix, iz, run, dt) while riding
holding: fn() -> bool = fn chr__no # at work on something: face it
hold_x: fn() -> float = fn chr__zero
hold_z: fn() -> float = fn chr__zero
eyes_ok: fn() -> bool = fn chr__yes # first person may be used now
}
# the stick, once a frame: move is in the camera's frame (+z ahead), look is radians this frame
export port CharacterInput {
move_x: fn() -> float = fn chr__zero
move_z: fn() -> float = fn chr__zero
run: fn() -> bool = fn chr__no
jump: fn() -> bool = fn chr__no
look_yaw: fn() -> float = fn chr__zero
look_pitch: fn() -> float = fn chr__zero
zoom: fn() -> float = fn chr__zero # wheel notches, in toward the body
look_always: fn() -> bool = fn chr__no # the look is held: never settle behind
}
var chr_ask_x: float = 0.0
var chr_ask_z: float = 0.0
function chr__top_plain(x: float, z: float, r: float, from_y: float, step: float, base: float) -> float { return base }
function chr__bed_plain(x: float, z: float) -> float { return CharacterGround.height(x, z) }
function chr__dry(x: float, z: float) -> float { return -10000.0 }
function chr__no_push(x: float, z: float, r: float, y0: float, y1: float) -> bool { return false }
function chr__asked_x() -> float { return chr_ask_x }
function chr__asked_z() -> float { return chr_ask_z }
function chr__slope_plain(x: float, z: float) -> float {
let e = 2.0
let dx = CharacterGround.height(x + e, z) - CharacterGround.height(x - e, z)
let dz = CharacterGround.height(x, z + e) - CharacterGround.height(x, z - e)
return 1.0 - 4.0 / Math.sqrt(dx * dx + dz * dz + 16.0)
}
function chr__boots() -> float { return 1.05 }
function chr__still(x: float, z: float, fx: float, fz: float) -> float { return 0.0 }
function chr__one() -> float { return 1.0 }
function chr__zero() -> float { return 0.0 }
function chr__no() -> bool { return false }
function chr__yes() -> bool { return true }
function chr__not_carried(ix: float, iz: float, run: bool, dt: float) -> void { }

View file

@ -0,0 +1,48 @@
# queries.ludic - what the rest of a game may ASK about the body: the only way out of its state
export function body_x() -> float { return chr_x }
export function body_y() -> float { return chr_y }
export function body_z() -> float { return chr_z }
export function body_yaw() -> float { return chr_yaw }
export function body_speed() -> float { return chr_speed }
export function body_moving() -> float { return chr_move }
export function body_run() -> float { return chr_run }
export function body_phase() -> float { return chr_phase }
export function body_time() -> float { return chr_time }
export function body_water() -> float { return chr_water }
export function body_airborne() -> bool { return chr_air }
export function body_vy() -> float { return chr_vy }
export function body_landing() -> float { return chr_land }
export function body_steep() -> float { return chr_steep }
export function body_step() -> float { return chr_step }
export function body_sitting() -> bool { return chr_sit }
export function body_sit_mode() -> int { return chr_sit_mode }
export function body_sit_t() -> float { return chr_sit_t }
export function body_swimming() -> bool { return chr_swim }
export function body_diving() -> bool { return chr_dive }
export function body_depth() -> float { return chr_depth }
export function body_breath() -> float { return chr_breath }
export function body_floating() -> float { return chr_float }
export function body_swim_phase() -> float { return chr_swim_phase }
export function body_can_run() -> bool { return chr_can_run }
export function body_walk_speed() -> float { return chr_walk_speed }
export function body_run_speed() -> float { return chr_run_speed }
export function body_held() -> bool { return chr_holds != 0 }
export function body_fps() -> bool { return chr_fps }
export function body_cam_yaw() -> float { return chr_cam_yaw }
export function body_cam_pitch() -> float { return chr_cam_pitch }
export function body_cam_dist() -> float { return chr_cam_dist }
# the camera the last char_camera worked out, for the game to hand its renderer
export function body_eye_x() -> float { return chr_eye_x }
export function body_eye_y() -> float { return chr_eye_y }
export function body_eye_z() -> float { return chr_eye_z }
export function body_eye_yaw() -> float { return chr_eye_yaw }
export function body_eye_pitch() -> float { return chr_eye_pitch }
export function body_eyes_now() -> bool { return chr_eyes_now }
# where char_safe_spot found room, after it answered true
export function body_safe_x() -> float { return chr_safe_x }
export function body_safe_z() -> float { return chr_safe_z }
# the air a full breath holds, with the lungs the game gives
export function body_breath_max() -> float { return CHAR_BREATH_MAX * CharacterBody.breath() }

View file

@ -0,0 +1,48 @@
# rules.ludic - the numbers that are the body's rules, and what it reports as facts
export const CHAR_STEP: float = 0.55 # a rise within this of the feet is a step, whatever its gradient
export const CHAR_BODY: float = 1.8 # feet to head
# a slope is measured over this, never over the frame's own step: 60 Hz and 240 Hz refuse alike
export const CHAR_SLOPE_PROBE: float = 0.6
export const CHAR_BREATH_MAX: float = 45.0 # seconds of air under the surface
export const CHAR_WADE_MAX: float = 1.35 # a bed deeper than this under the surface is a swim
export const CHAR_STAND_UP: float = 1.15 # and shallower than this is ground again
export const CHAR_DROP: float = 1.5 # how far below the height left a put-down may be
export const CHAR_SIT_SEAT: int = 0 # a log, a stump, a bench
export const CHAR_SIT_GROUND: int = 1 # cross-legged in the grass
# who is holding the body still; two holders cannot release each other's hold
export const CHAR_HOLD_SCREEN: int = 1
export const CHAR_HOLD_STAGE: int = 2
# what sitting down where you stand answered
export const CHAR_SAT: int = 0
export const CHAR_STOOD: int = 1
export const CHAR_NOT_RIDING: int = 2
export const CHAR_NOT_AIRBORNE: int = 3
export const CHAR_NOT_STEEP: int = 4
export const CHAR_NOT_WET: int = 5
# what the first-person toggle answered
export const CHAR_EYES_ON: int = 0
export const CHAR_EYES_OFF: int = 1
export const CHAR_EYES_SITTING: int = 2
export const CHAR_EYES_RIDING: int = 3
# the facts
export const CHAR_LANDED: int = 0 # v: the speed carried out of the air
export const CHAR_JUMPED: int = 1
export const CHAR_FOOTFALL: int = 2 # a boot came down
export const CHAR_TOO_STEEP: int = 3 # a walk refused, once per refusal
export const CHAR_STARTED_SWIMMING: int = 4
export const CHAR_LEFT_WATER: int = 5
export const CHAR_OUT_OF_AIR: int = 6 # v: the seconds of it this frame
export const CHAR_CURRENT: int = 7 # the water at the edge takes the stroke; said every 12 s
export property CharacterFact {
what: int = 0
x: float = 0.0
y: float = 0.0
z: float = 0.0
v: float = 0.0
}

View file

@ -0,0 +1,39 @@
# safe.ludic - somewhere a body can be put down: the one answer every dismount, every stand-up and
# every landing asks. The wanted spot if it will do, else the nearest ring out that is clear of every
# collider, no deeper than max_depth, within CHAR_DROP of the height being left, and on ground
# gentle enough to walk off - a spot you can stand on but cannot leave is not a safe spot.
function chr_safe_ok(x: float, z: float, from_y: float, max_depth: float) -> bool {
let g = CharacterGround.height(x, z)
if Math.abs(g - from_y) > CHAR_DROP { return false }
if CharacterGround.water(x, z) - g > max_depth { return false }
if chr_push(x, z, 0.4, g, g + CHAR_BODY) { return false }
var lo = g
var hi = g
for k in 0 .. 4 {
let a = float(k) * (chr_pi * 0.5)
let h = CharacterGround.height(x + Math.sin(a) * CHAR_SLOPE_PROBE, z + Math.cos(a) * CHAR_SLOPE_PROBE)
lo = Math.min(lo, h)
hi = Math.max(hi, h)
}
return not (hi - lo > CHAR_SLOPE_PROBE * 2.0)
}
export function char_safe_spot(x: float, z: float, from_y: float, max_depth: float) -> bool {
chr_safe_x = x
chr_safe_z = z
if chr_safe_ok(x, z, from_y, max_depth) { return true }
for r in 0 .. 9 {
let rad = 0.8 + float(r) * 1.1
for k in 0 .. 16 {
let a = float(k) * (chr_pi / 8.0)
let px = x + Math.sin(a) * rad
let pz = z + Math.cos(a) * rad
if chr_safe_ok(px, pz, from_y, max_depth) {
chr_safe_x = px
chr_safe_z = pz
return true
}
}
}
return false
}

View file

@ -0,0 +1,59 @@
# sit.ludic - sitting: on a seat's point, or on the ground where the body stands. The pose either
# side is a still one, so the sit's own weight (body_sit_t) eases 0 -> 1 over a quarter second and
# the body walks the last step to the seat over the same time rather than jumping there.
export function char_sit(x: float, z: float, yaw: float, mode: int) -> void {
chr_sit = true
chr_sit_mode = mode
chr_speed = 0.0
chr_move = 0.0
chr_run = 0.0
chr_sit_x = x
chr_sit_z = z
chr_sit_yaw = yaw
chr_water = CharacterGround.water(chr_x, chr_z)
}
function chr_sit_tick(dt: float) -> void {
var want = 0.0
if chr_sit { want = 1.0 }
chr_sit_t = char_toward(chr_sit_t, want, 4.0 * dt)
if not chr_sit { return }
let k = Math.min(1.0, 8.0 * dt)
chr_x = Math.lerp(chr_x, chr_sit_x, k)
chr_z = Math.lerp(chr_z, chr_sit_z, k)
chr_yaw = chr_yaw + char_wrap(chr_sit_yaw - chr_yaw) * k
chr_y = CharacterGround.height(chr_x, chr_z)
chr_water = CharacterGround.water(chr_x, chr_z)
}
# Sitting down where the body stands: not astride something, not in the air, not on a slope,
# not in the water. Asked again while sitting, it stands up. The answer is a CHAR_* for the game
# to word.
export function char_sit_here() -> int {
if chr_sit {
char_stand()
return CHAR_STOOD
}
if CharacterBody.riding() { return CHAR_NOT_RIDING }
if chr_air { return CHAR_NOT_AIRBORNE }
if CharacterGround.slope(chr_x, chr_z) > 0.35 { return CHAR_NOT_STEEP }
if chr_y < chr_water + 0.9 { return CHAR_NOT_WET }
char_sit(chr_x, chr_z, chr_yaw, CHAR_SIT_GROUND)
return CHAR_SAT
}
# standing up out of a seat put down against a boulder or a bank must not leave the body inside it
export function char_stand() -> void {
chr_sit = false
if char_safe_spot(chr_x, chr_z, chr_y, 0.0) {
chr_x = chr_safe_x
chr_z = chr_safe_z
chr_y = CharacterGround.height(chr_x, chr_z)
}
}
# smoothstep, so a pose slows into the seat rather than arriving at full speed
export function char_ease(t: float) -> float {
let c = Math.clamp(t, 0.0, 1.0)
return c * c * (3.0 - 2.0 * c)
}

View file

@ -0,0 +1,77 @@
# state.ludic - the body, its camera, and the queue of what happened to it
const chr_pi: float = 3.14159265
var chr_x: float = 0.0
var chr_y: float = 0.0
var chr_z: float = 0.0
var chr_yaw: float = 0.0 # facing, radians; 0 faces -z
var chr_speed: float = 0.0 # m/s along the facing
var chr_phase: float = 0.0 # the gait: one cycle is two steps, advanced by ground covered
var chr_move: float = 0.0 # 0 standing .. 1 moving
var chr_run: float = 0.0 # 0 walking .. 1 running
var chr_time: float = 0.0
var chr_water: float = -10000.0 # the water line under the body, every update
var chr_walk_speed: float = 3.2
var chr_run_speed: float = 6.8
var chr_vy: float = 0.0
var chr_air: bool = false
var chr_air_vx: float = 0.0
var chr_air_vz: float = 0.0
var chr_land: float = 0.0 # seconds of the landing's crouch left
var chr_steep: float = 0.0 # seconds of "that was too steep" left to say
var chr_can_run: bool = true
var chr_step: float = 0.0 # metres covered this frame
var chr_sit: bool = false
var chr_sit_mode: int = 0
var chr_sit_t: float = 0.0 # 0 standing .. 1 sat, eased over a quarter second
var chr_sit_x: float = 0.0 # the seat's point, walked to over the blend
var chr_sit_z: float = 0.0
var chr_sit_yaw: float = 0.0
var chr_swim: bool = false
var chr_dive: bool = false
var chr_depth: float = 0.0 # metres below the surface
var chr_breath: float = 0.0
var chr_float: float = 0.0 # 0 swimming .. 1 floating
var chr_swim_phase: float = 0.0
var chr_edge_said: float = 0.0
var chr_holds: int = CHAR_HOLD_SCREEN
# the camera: an orbit round a pivot at the chest, or the eyes
var chr_cam_yaw: float = 0.0
var chr_cam_pitch: float = -0.157
var chr_cam_dist: float = 4.2
var chr_lock: bool = false # the orbit never settles behind on its own
var chr_fps: bool = false
var chr_fps_bob: float = 0.0
var chr_piv_x: float = 0.0
var chr_piv_y: float = 0.0
var chr_piv_z: float = 0.0
var chr_eye_x: float = 0.0 # where the camera is and where it looks, once computed
var chr_eye_y: float = 0.0
var chr_eye_z: float = 0.0
var chr_eye_yaw: float = 0.0
var chr_eye_pitch: float = 0.0
var chr_eyes_now: bool = false # the last camera was the eyes: the figure is hidden
var chr_safe_x: float = 0.0
var chr_safe_z: float = 0.0
var chr_fact_q: Queue<CharacterFact> = null
export function char_facts() -> Queue<CharacterFact> {
if chr_fact_q == null { chr_fact_q = queue_new("character.facts") }
return chr_fact_q
}
function chr_say(what: int, v: float) -> void {
let f = new CharacterFact
f.what = what
f.x = chr_x
f.y = chr_y
f.z = chr_z
f.v = v
q_push(char_facts(), f)
}

View file

@ -0,0 +1,61 @@
# steer.ludic - turning to the stick and easing to the speed it asks; then the gait and the orbit
function chr_turn_to(dx: float, dz: float, rate: float) -> float {
let diff = char_wrap(Math.atan2(-dx, -dz) - chr_yaw)
chr_yaw = chr_yaw + Math.clamp(diff, -rate, rate)
return diff
}
function chr_steer(dx: float, dz: float, mag: float, run: bool, dt: float) -> void {
var target = 0.0
if mag > 0.05 and not chr_air {
let diff = chr_turn_to(dx, dz, Math.lerp(10.0, 5.0, chr_run) * dt)
let lined = Math.clamp((Math.cos(diff) + 0.3) / 1.3, 0.0, 1.0)
var top = chr_walk_speed * CharacterBody.pace()
if run { top = chr_run_speed * CharacterBody.pace() }
if CharacterBody.sneaking() { top = 1.3 }
target = mag * top * lined
} else if CharacterBody.holding() and not chr_air {
# a hold turns the body to the thing being worked, so the work is seen being done
chr_turn_to(CharacterBody.hold_x() - chr_x, CharacterBody.hold_z() - chr_z, 6.0 * dt)
}
# in the water the top speed and the inertia are the water's: a swim is half a walk
if chr_swim {
target = mag * 1.5
if run { target = mag * 2.4 }
if mag > 0.05 { chr_turn_to(dx, dz, 4.0 * dt) }
}
if not chr_air {
var acc = 12.0
if target > chr_speed { acc = 9.0 }
if chr_swim { acc = 4.5 }
chr_speed = char_toward(chr_speed, target, acc * dt)
}
}
# The gait's phase is distance over the stride, so the feet never slide; a boot comes down as the
# phase crosses 0 and again as it crosses pi, and that is when a footfall is said.
function chr_gait(dist: float, dyaw: float, dpitch: float, dt: float) -> void {
if chr_land > 0.0 { chr_land = chr_land - dt }
if chr_steep > 0.0 { chr_steep = chr_steep - dt }
if chr_swim { chr_swim_phase = (chr_swim_phase + dt * (1.6 + chr_speed * 0.9)) % (2.0 * chr_pi) }
let stride = Math.lerp(1.5, 2.7, chr_run)
let phase0 = chr_phase
chr_phase = (chr_phase + dist / stride * (2.0 * chr_pi)) % (2.0 * chr_pi)
if not chr_air and not chr_swim and not chr_sit and chr_speed > 0.4 {
if chr_cross(phase0, chr_phase, 0.0) or chr_cross(phase0, chr_phase, chr_pi) { chr_say(CHAR_FOOTFALL, chr_speed) }
}
var mv = 0.0
if chr_speed > 0.12 { mv = 1.0 }
chr_move = char_toward(chr_move, mv, 5.0 * dt)
chr_run = char_toward(chr_run, Math.clamp((chr_speed - 3.6) / 2.8, 0.0, 1.0), 3.0 * dt)
chr_look(dyaw, dpitch)
# the orbit settles slowly behind a walking body unless the player is holding the look
if dyaw == 0.0 and chr_speed > 0.4 and not chr_lock and not CharacterInput.look_always() {
chr_cam_yaw = chr_cam_yaw + char_wrap(chr_yaw - chr_cam_yaw) * Math.min(1.0, 1.1 * dt)
}
}
function chr_look(dyaw: float, dpitch: float) -> void {
chr_cam_yaw = chr_cam_yaw + dyaw
chr_cam_pitch = Math.clamp(chr_cam_pitch + dpitch, -0.96, 0.61)
}

View file

@ -0,0 +1,22 @@
# swim.ludic - afloat: buoyancy, the descent, the breath, and the shore stopping the body
function chr_swim_tick(dx: float, dz: float, jump: bool, dt: float) -> void {
# standing still out here is floating, not swimming
if Math.abs(dx) + Math.abs(dz) > 0.05 { chr_float = 0.0 } else { chr_float = Math.min(1.0, chr_float + dt / 1.5) }
chr_dive_tick(jump, dt)
chr_breath_tick(dt)
chr_current_tick(dt)
let step = chr_speed * dt
if step > 0.0 {
chr_push(chr_x - Math.sin(chr_yaw) * step, chr_z - Math.cos(chr_yaw) * step, 0.35, chr_y, chr_y + CHAR_BODY)
let mx = chr_out_x - chr_x
let mz = chr_out_z - chr_z
chr_step = Math.sqrt(mx * mx + mz * mz)
chr_x = chr_out_x
chr_z = chr_out_z
}
# the surface, with a slow bob so a floating body is not a statue on glass
let bob = 0.04 * Math.sin(chr_time * 3.8)
chr_y = chr_water - 0.30 + bob - chr_depth
# shallow enough to stand up in, and at the top rather than under it
if chr_bed_depth(chr_x, chr_z) < CHAR_STAND_UP and chr_depth < 0.2 { chr_leave_water() }
}

View file

@ -0,0 +1,24 @@
# system.ludic - the body as a system: reset, a tick from the input port, and nothing saved (where a
# body stands is the game's to write, with the rest of the player)
export function char_reset() -> void {
char_teleport(0.0, 0.0, 0.0)
chr_time = 0.0
chr_phase = 0.0
chr_breath = CHAR_BREATH_MAX
chr_can_run = true
chr_sit_t = 0.0
chr_steep = 0.0
chr_land = 0.0
chr_edge_said = 0.0
chr_fps = false
q_clear(char_facts())
}
function chr_sys_tick(t: Tick) -> void { char_tick(t.dt) }
export function char_system() -> System {
let s = system_new("character", PH_INPUT)
s.reset = fn char_reset
s.tick = fn chr_sys_tick
return s
}

View file

@ -0,0 +1,272 @@
# character_test.ludic - a body on a fake world: flat meadow, a 45 cm deck, a gentle rise and a cliff,
# a boulder with a step on top, and a lake that shelves. The step against the slope at every frame
# rate, wading into a swim and out, the dive and the breath, the jump, sitting, holds, the safe
# spot, riding and the camera.
import "ludic.character"
import "ludic.base"
program CharacterTest {
numbers float
# the world: x in 4..8 is a deck 45 cm up; z < -10 a cliff; z > 10 a gentle rise; x > 20 the lake
function fk_height(x: float, z: float) -> float {
if x > 20.0 { return -(x - 20.0) * 0.5 }
if z < -10.0 { return (-10.0 - z) * 2.0 }
if z > 10.0 { return (z - 10.0) * 0.3 }
if x > 4.0 and x < 8.0 and z > -2.0 and z < 2.0 { return 0.45 }
return 0.0
}
function fk_water(x: float, z: float) -> float {
if x > 19.0 { return 0.0 }
return -10000.0
}
# a boulder at (0, 6), a metre round and 40 cm high, and a pillar at (-6, 0)
var px: float = 0.0
var pz: float = 0.0
function fk_ring(x: float, z: float, cx: float, cz: float, r: float) -> bool {
let dx = x - cx
let dz = z - cz
let d = Math.sqrt(dx * dx + dz * dz)
if d >= r { return false }
let k = r / Math.max(d, 0.001)
px = cx + dx * k
pz = cz + dz * k
return true
}
function fk_push(x: float, z: float, r: float, y0: float, y1: float) -> bool {
if y0 < 0.4 and fk_ring(x, z, 0.0, 6.0, 1.0 + r) { return true }
return fk_ring(x, z, -6.0, 0.0, 0.5 + r)
}
function fk_px() -> float { return px }
function fk_pz() -> float { return pz }
function fk_top(x: float, z: float, r: float, from_y: float, step: float, base: float) -> float {
let dx = x
let dz = z - 6.0
if dx * dx + dz * dz < 1.0 and 0.4 - from_y <= step { return 0.4 }
return base
}
var riding: bool = false
var rides: int = 0
var sneaking: bool = false
var pace: float = 1.0
var eyes: bool = true
function fk_riding() -> bool { return riding }
function fk_ride(ix: float, iz: float, run: bool, dt: float) -> void { rides += 1 }
function fk_sneak() -> bool { return sneaking }
function fk_pace() -> float { return pace }
function fk_eyes() -> bool { return eyes }
var mz: float = 0.0
function fk_move_z() -> float { return mz }
bind CharacterGround { height: fn fk_height, water: fn fk_water, push: fn fk_push, pushed_x: fn fk_px, pushed_z: fn fk_pz, top_at: fn fk_top }
bind CharacterBody { riding: fn fk_riding, ride: fn fk_ride, sneaking: fn fk_sneak, pace: fn fk_pace, eyes_ok: fn fk_eyes }
bind CharacterInput { move_z: fn fk_move_z }
function fresh(x: float, z: float, yaw: float) -> void {
char_reset()
char_release(CHAR_HOLD_SCREEN)
char_teleport(x, z, yaw)
}
function facts() -> []CharacterFact { return q_drain(char_facts()) }
function count(fs: []CharacterFact, what: int) -> int {
var n = 0
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
return n
}
# walk the stick straight ahead (the camera looks where the body faces) for `secs`
function walk(secs: float, hz: float, run: bool) -> void {
let n = int(secs * hz)
for i in 0 .. n { char_update(0.0, 1.0, run, false, 0.0, 0.0, 1.0 / hz) }
}
# yaw 0 faces -z; the heading of a walk toward +x is -pi/2
const EAST: float = -1.5707963
const SOUTH: float = 3.14159265
test "a 45 cm deck is a step at 60 Hz and at 240" {
fresh(1.0, 0.0, EAST)
walk(2.0, 60.0, false)
expect(body_x() > 5.0)
expect_near(body_y(), 0.45, 0.01)
fresh(1.0, 0.0, EAST)
walk(2.0, 240.0, false)
expect(body_x() > 5.0)
expect_near(body_y(), 0.45, 0.01)
expect_eq(count(facts(), CHAR_TOO_STEEP), 0)
}
test "a cliff is refused at the same place whatever the frame rate, and said once" {
fresh(0.0, -8.0, 0.0)
walk(3.0, 30.0, false)
let at30 = body_z()
expect(body_steep() > 0.0)
expect_eq(count(facts(), CHAR_TOO_STEEP), 1)
fresh(0.0, -8.0, 0.0)
walk(3.0, 240.0, false)
expect_near(body_z(), at30, 0.25)
expect(body_z() > -10.0)
expect(body_y() < 0.3)
}
test "a gentle rise is walked up" {
fresh(0.0, 8.0, SOUTH)
walk(3.0, 60.0, false)
expect(body_z() > 14.0)
expect(body_y() > 1.0)
}
test "the boots set the limit: a rise they cannot take is refused" {
fresh(0.0, 8.0, SOUTH)
walk(0.2, 60.0, false)
expect(not char_slope_blocks(body_y() + 0.1, 0.0, 1.0))
}
test "the walk reaches the walk speed, the run the run speed, a sneak its own" {
fresh(0.0, 0.0, SOUTH)
walk(2.0, 60.0, false)
expect_near(body_speed(), 3.2, 0.05)
fresh(0.0, 0.0, SOUTH)
walk(2.0, 60.0, true)
expect_near(body_speed(), 6.8, 0.05)
pace = 0.5
fresh(0.0, 0.0, SOUTH)
walk(2.0, 60.0, false)
expect_near(body_speed(), 1.6, 0.05)
pace = 1.0
sneaking = true
fresh(0.0, 0.0, SOUTH)
walk(2.0, 60.0, true)
expect_near(body_speed(), 1.3, 0.05)
}
test "a boulder is a wall to a body below its step and a step onto its top" {
fresh(0.0, 3.0, SOUTH)
walk(2.0, 60.0, false)
expect(body_z() < 5.1)
expect_near(char_stand_at(0.0, 6.0, 0.0), 0.4, 0.001)
expect_near(char_stand_at(3.0, 6.0, 0.0), 0.0, 0.001)
# and the world's own ground never includes it
expect_near(fk_height(0.0, 6.0), 0.0, 0.001)
}
test "wading shelves into a swim, and the shallows give the ground back" {
fresh(18.0, 0.0, EAST)
walk(3.0, 60.0, false)
let fs = facts()
expect(body_swimming())
expect_eq(count(fs, CHAR_STARTED_SWIMMING), 1)
expect_near(body_y(), -0.30, 0.06)
expect(body_breath() > 40.0)
char_face(-EAST)
char_look(-EAST, 0.0)
walk(8.0, 60.0, false)
expect(not body_swimming())
expect_eq(count(facts(), CHAR_LEFT_WATER), 1)
}
test "the jump held dives, the air runs out, and the body surfaces" {
fresh(30.0, 0.0, 0.0)
char_resume_swim(false, 0.0, 2.0)
for i in 0 .. 600 { char_update(0.0, 0.0, false, true, 0.0, 0.0, 1.0 / 60.0) }
let fs = facts()
expect(count(fs, CHAR_OUT_OF_AIR) > 0)
for i in 0 .. 300 { char_update(0.0, 0.0, false, false, 0.0, 0.0, 1.0 / 60.0) }
expect(body_depth() < 0.05)
expect(body_breath() > 10.0)
}
test "a jump leaves the ground, falls, lands, and says both" {
fresh(0.0, 0.0, 0.0)
char_update(0.0, 0.0, false, true, 0.0, 0.0, 1.0 / 60.0)
expect(body_airborne())
var guard = 0
while body_airborne() and guard < 200 {
char_update(0.0, 0.0, false, false, 0.0, 0.0, 1.0 / 60.0)
guard += 1
}
let fs = facts()
expect(not body_airborne())
expect_eq(count(fs, CHAR_JUMPED), 1)
expect_eq(count(fs, CHAR_LANDED), 1)
expect(guard > 30)
char_allow_run(false)
char_update(0.0, 0.0, false, true, 0.0, 0.0, 1.0 / 60.0)
expect(not body_airborne())
}
test "the safe spot clears a collider, keeps out of deep water, and allows a wade when asked" {
expect(char_safe_spot(-6.0, 0.0, 0.0, 0.0))
let dx = body_safe_x() + 6.0
let dz = body_safe_z()
expect(dx * dx + dz * dz > 0.81)
expect(not char_safe_spot(40.0, 0.0, 0.0, 0.0))
expect(char_safe_spot(20.5, 0.0, 0.0, 1.25))
expect(char_safe_spot(15.0, 0.0, 0.0, 0.0))
expect_near(body_safe_x(), 15.0, 0.001)
}
test "sitting where you stand, and the refusals" {
fresh(0.0, 0.0, 0.0)
expect_eq(char_sit_here(), CHAR_SAT)
for i in 0 .. 30 { char_update(0.0, 0.0, false, false, 0.0, 0.0, 1.0 / 60.0) }
expect(body_sitting())
expect_near(body_sit_t(), 1.0, 0.01)
expect_eq(char_sit_here(), CHAR_STOOD)
expect(not body_sitting())
fresh(0.0, -14.0, 0.0)
expect_eq(char_sit_here(), CHAR_NOT_STEEP)
fresh(24.0, 0.0, 0.0)
char_resume_swim(false, 0.0, 10.0)
expect_eq(char_sit_here(), CHAR_NOT_WET)
riding = true
fresh(0.0, 0.0, 0.0)
expect_eq(char_sit_here(), CHAR_NOT_RIDING)
riding = false
}
test "a hold stops the stick, and only its own reason lets it go" {
fresh(0.0, 0.0, 0.0)
mz = 1.0
char_hold(CHAR_HOLD_STAGE)
char_hold(CHAR_HOLD_SCREEN)
for i in 0 .. 60 { char_tick(1.0 / 60.0) }
expect_near(body_z(), 0.0, 0.001)
char_release(CHAR_HOLD_SCREEN)
char_release(CHAR_HOLD_SCREEN)
expect(body_held())
char_release(CHAR_HOLD_STAGE)
for i in 0 .. 60 { char_tick(1.0 / 60.0) }
expect(body_z() < -1.0)
mz = 0.0
}
test "riding hands the stick over and leaves the body where the ride puts it" {
fresh(0.0, 0.0, 0.0)
riding = true
rides = 0
char_board(3.0, 3.0, 0.0)
for i in 0 .. 10 { char_update(0.0, 1.0, false, false, 0.0, 0.0, 1.0 / 60.0) }
expect_eq(rides, 10)
char_ride_at(4.0, 1.2, 3.0, 0.0, 2.0)
expect_near(body_x(), 4.0, 0.001)
expect_near(body_y(), 1.2, 0.001)
riding = false
char_step_off(5.0, 0.0)
expect_near(body_y(), 0.45, 0.001)
}
test "the orbit sits behind and above the ground; the eyes at head height, refused when sat" {
fresh(0.0, 0.0, 0.0)
char_camera(1.0)
expect(body_eye_z() > 1.0)
expect(body_eye_y() > 0.55)
expect(not body_eyes_now())
expect_eq(char_fps_toggle(), CHAR_EYES_ON)
char_camera(1.0)
expect(body_eyes_now())
expect_near(body_eye_y(), 1.62, 0.05)
expect_eq(char_fps_toggle(), CHAR_EYES_OFF)
char_sit(0.0, 0.0, 0.0, CHAR_SIT_SEAT)
expect_eq(char_fps_toggle(), CHAR_EYES_SITTING)
}
}

View file

@ -0,0 +1,68 @@
# update.ludic - a frame of the body: the stick in the camera's frame, the step, the gait, the camera
export function char_update(ix0: float, iz0: float, run0: bool, jump0: bool, dyaw: float, dpitch: float, dt: float) -> void {
var run = run0 and chr_can_run
var ix = ix0
var iz = iz0
var jump = jump0
chr_step = 0.0
chr_time = chr_time + dt
chr_water = CharacterGround.water(chr_x, chr_z)
chr_sit_tick(dt)
if CharacterBody.riding() {
chr_ride_update(ix, iz, run, dyaw, dpitch, dt)
return
}
if CharacterBody.sneaking() { run = false }
# sitting: any step stands up, and nothing else moves
if chr_sit and (Math.abs(ix) > 0.3 or Math.abs(iz) > 0.3 or jump) { chr_sit = false }
if chr_sit {
ix = 0.0
iz = 0.0
jump = false
}
# the stick in the camera's frame: forward on the ground is (-sin yaw, -cos yaw)
let cy = Math.cos(chr_cam_yaw)
let sy = Math.sin(chr_cam_yaw)
var dx = -sy * iz + cy * ix
var dz = -cy * iz - sy * ix
var mag = Math.sqrt(dx * dx + dz * dz)
if mag > 1.0 {
dx = dx / mag
dz = dz / mag
mag = 1.0
}
chr_steer(dx, dz, mag, run, dt)
let dist = chr_travel(dx, dz, -Math.sin(chr_yaw), -Math.cos(chr_yaw), jump, dt)
chr_gait(dist, dyaw, dpitch, dt)
char_camera(dt)
}
# something else carries the body (CharacterBody.ride moves it with char_ride_at); the orbit
# drifts behind whatever it is on
function chr_ride_update(ix: float, iz: float, run: bool, dyaw: float, dpitch: float, dt: float) -> void {
CharacterBody.ride(ix, iz, run, dt)
chr_look(dyaw, dpitch)
if dyaw == 0.0 and not CharacterInput.look_always() { chr_cam_yaw = chr_cam_yaw + char_wrap(chr_yaw - chr_cam_yaw) * Math.min(1.0, 1.5 * dt) }
char_camera(dt)
}
# a frame from the input port: the game's devices, read once, stepping the body
export function char_tick(dt: float) -> void {
var ix = 0.0
var iz = 0.0
var run = false
var jump = false
var dyaw = 0.0
var dpitch = 0.0
if chr_holds == 0 {
ix = CharacterInput.move_x()
iz = CharacterInput.move_z()
run = CharacterInput.run()
jump = CharacterInput.jump()
dyaw = CharacterInput.look_yaw()
dpitch = CharacterInput.look_pitch()
let wheel = CharacterInput.zoom()
if wheel != 0.0 { chr_cam_dist = Math.clamp(chr_cam_dist - wheel * 0.35, 1.8, 9.0) }
}
char_update(ix, iz, run, jump, dyaw, dpitch, dt)
}

View file

@ -0,0 +1,125 @@
# verbs.ludic - every change the rest of a game may make to the body
export function char_config(walk: float, run: float) -> void {
chr_walk_speed = walk
chr_run_speed = run
}
# put the body somewhere, standing, the camera behind it
export function char_teleport(x: float, z: float, yaw: float) -> void {
chr_x = x
chr_z = z
chr_yaw = yaw
chr_y = CharacterGround.height(x, z)
char_halt()
chr_sit = false
chr_swim = false
chr_dive = false
chr_depth = 0.0
chr_float = 0.0
# the water where the body lands, not where it was: a trip reloaded mid-swim reads it next
chr_water = CharacterGround.water(x, z)
chr_cam_yaw = yaw
chr_piv_x = chr_x
chr_piv_y = chr_y + 1.45
chr_piv_z = chr_z
char_camera(1.0)
}
# a screen holds the body still by a reason, so two holders cannot release each other's hold
export function char_hold(reason: int) -> void { chr_holds = chr_holds | reason }
export function char_release(reason: int) -> void { if (chr_holds & reason) != 0 { chr_holds = chr_holds - reason } }
# everything that carries the body stops: no fall, no stride, no run
export function char_halt() -> void {
chr_air = false
chr_vy = 0.0
chr_speed = 0.0
chr_move = 0.0
chr_run = 0.0
}
# out of strength in the water: the stroke stops and the body floats
export function char_go_limp() -> void {
chr_speed = 0.0
chr_float = 1.0
}
# set down somewhere without the teleport's reset (a swimmer turned back)
export function char_push_to(x: float, z: float) -> void {
chr_x = x
chr_z = z
chr_speed = 0.0
}
# stood on the ground at a point, facing a way
export function char_stand_on(x: float, z: float, yaw: float) -> void {
chr_x = x
chr_z = z
chr_yaw = yaw
chr_y = CharacterGround.height(x, z)
}
# a trip saved in the middle of the lake reloads in the middle of the lake, not on the bed
export function char_resume_swim(dive: bool, depth: float, breath: float) -> void {
chr_water = CharacterGround.water(chr_x, chr_z)
chr_swim = true
chr_dive = dive
chr_depth = depth
chr_breath = breath
chr_y = chr_water - 0.30 - chr_depth
}
# the map changed under the body: nothing about the old water may survive
export function char_map_changed() -> void {
chr_swim = false
chr_dive = false
chr_depth = 0.0
chr_water = CharacterGround.water(chr_x, chr_z)
}
export function char_face(yaw: float) -> void { chr_yaw = yaw }
export function char_allow_run(on: bool) -> void { chr_can_run = on }
export function char_look(yaw: float, pitch: float) -> void {
chr_cam_yaw = yaw
chr_cam_pitch = pitch
}
export function char_look_pitch(pitch: float) -> void { chr_cam_pitch = pitch }
export function char_orbit(dist: float) -> void { chr_cam_dist = Math.clamp(dist, 1.8, 9.0) }
export function char_lock_orbit(on: bool) -> void { chr_lock = on }
export function char_set_fps(on: bool) -> void { chr_fps = on }
# the eyes or the shoulder; refused while sitting or carried, and the answer says which
export function char_fps_toggle() -> int {
if not chr_fps and not chr_eyes_allowed() {
if chr_sit { return CHAR_EYES_SITTING }
return CHAR_EYES_RIDING
}
chr_fps = not chr_fps
if chr_fps { return CHAR_EYES_ON }
return CHAR_EYES_OFF
}
# something to ride: getting on, being carried, and stepping off onto a spot char_safe_spot found
export function char_board(x: float, z: float, yaw: float) -> void {
chr_sit = false
chr_x = x
chr_z = z
chr_yaw = yaw
chr_speed = 0.0
chr_move = 0.0
chr_run = 0.0
}
export function char_ride_at(x: float, y: float, z: float, yaw: float, speed: float) -> void {
chr_x = x
chr_y = y
chr_z = z
chr_yaw = yaw
chr_speed = speed
}
export function char_step_off(x: float, z: float) -> void {
chr_x = x
chr_z = z
chr_y = CharacterGround.height(x, z)
}

View file

@ -0,0 +1,69 @@
# walk.ludic - the step itself: swimming, in the air, or on the ground; the distance, for the gait
function chr_travel(dx: float, dz: float, fx: float, fz: float, jump: bool, dt: float) -> float {
if chr_swim {
chr_swim_tick(dx, dz, jump, dt)
return chr_speed * dt
}
if chr_air { return chr_fly(dx, dz, dt) }
return chr_walk(fx, fz, jump, dt)
}
# ballistic: gravity, the takeoff's velocity carried, a little steering
function chr_fly(dx: float, dz: float, dt: float) -> float {
chr_vy = chr_vy - 14.0 * dt
chr_air_vx = chr_air_vx + dx * (4.0 * dt)
chr_air_vz = chr_air_vz + dz * (4.0 * dt)
chr_push(chr_x + chr_air_vx * dt, chr_z + chr_air_vz * dt, 0.35, chr_y, chr_y + CHAR_BODY)
chr_y = chr_y + chr_vy * dt
let ground = char_stand_at(chr_out_x, chr_out_z, chr_y)
chr_x = chr_out_x
chr_z = chr_out_z
if chr_y < ground and chr_vy < 0.0 {
chr_y = ground
chr_air = false
chr_land = 0.22
chr_speed = Math.sqrt(chr_air_vx * chr_air_vx + chr_air_vz * chr_air_vz)
chr_say(CHAR_LANDED, chr_speed)
}
return chr_speed * (dt * 0.4)
}
# on the ground: a step, unless a slope refuses it or the water takes the body; then a jump
function chr_walk(fx: float, fz: float, jump: bool, dt: float) -> float {
var dist = chr_speed * dt
if dist > 0.0 { dist = chr_walk_step(fx, fz, dist) } else { chr_y = CharacterGround.height(chr_x, chr_z) }
if jump and chr_can_run and chr_land < 0.05 {
chr_air = true
chr_vy = 5.0
chr_air_vx = fx * chr_speed
chr_air_vz = fz * chr_speed
chr_say(CHAR_JUMPED, 0.0)
}
return dist
}
function chr_walk_step(fx: float, fz: float, dist: float) -> float {
chr_push(chr_x + fx * dist, chr_z + fz * dist, 0.35, chr_y, chr_y + CHAR_BODY)
let nx = chr_out_x
let nz = chr_out_z
let nh = char_stand_at(nx, nz, chr_y)
if char_slope_blocks(nh, fx, fz) {
if not (chr_steep > 0.0) { chr_say(CHAR_TOO_STEEP, nh - chr_y) }
chr_steep = 1.2
chr_speed = 0.0
return 0.0
}
# the lake shelves: shin-deep is a wade, and past CHAR_WADE_MAX it is a swim. Entering owns
# the position and the height this frame, or the body sits on the bed for one frame.
if nh < CharacterGround.water(nx, nz) - CHAR_WADE_MAX {
chr_enter_water(nx, nz)
return dist
}
let mx = nx - chr_x
let mz = nz - chr_z
chr_step = Math.sqrt(mx * mx + mz * mz)
chr_x = nx
chr_z = nz
chr_y = nh
return dist
}

View file

@ -0,0 +1,73 @@
# water.ludic - one axis decides it: how far the bed is below the surface where the body stands.
# Under CHAR_WADE_MAX the walk wades; past it the feet leave the bottom. Coming out wants a
# shallower bed (CHAR_STAND_UP) than going in did, so a shelving shore does not flicker.
function chr_enter_water(x: float, z: float) -> void {
if chr_swim { return }
chr_swim = true
chr_dive = false
chr_depth = 0.0
chr_breath = body_breath_max()
chr_float = 0.0
chr_air = false
chr_vy = 0.0
chr_sit = false
chr_x = x
chr_z = z
chr_water = CharacterGround.water(x, z)
chr_y = chr_water - 0.30
chr_say(CHAR_STARTED_SWIMMING, 0.0)
}
function chr_leave_water() -> void {
if not chr_swim { return }
chr_swim = false
chr_dive = false
chr_depth = 0.0
chr_y = CharacterGround.height(chr_x, chr_z)
chr_speed = Math.min(chr_speed, 1.0)
chr_say(CHAR_LEFT_WATER, 0.0)
}
# the descent: the jump held pushes the body under, letting go floats it back up
function chr_dive_tick(jump: bool, dt: float) -> void {
let bed = Math.max(chr_bed_depth(chr_x, chr_z), 0.0)
let max_d = Math.min(6.0, Math.max(bed - 0.4, 0.0))
if jump and max_d > 0.2 {
chr_dive = true
chr_depth = Math.min(chr_depth + 1.2 * dt, max_d)
} else {
chr_depth = Math.max(chr_depth - 1.6 * dt, 0.0)
if chr_depth < 0.05 { chr_dive = false }
}
}
# air is spent only under the surface, and comes back fast at the top
function chr_breath_tick(dt: float) -> void {
if not (chr_depth > 0.55) {
chr_breath = Math.min(chr_breath + dt * 12.0, body_breath_max())
return
}
chr_breath = chr_breath - dt
if chr_breath < 0.0 {
chr_breath = 0.0
chr_dive = false
chr_depth = Math.max(chr_depth - 3.0 * dt, 0.0)
chr_say(CHAR_OUT_OF_AIR, dt)
}
}
# The edge of the world is a current, not a wall: it takes the outward part of a stroke, so
# turning round always works. Said once every twelve seconds while it is strong.
function chr_current_tick(dt: float) -> void {
let fx = -Math.sin(chr_yaw)
let fz = -Math.cos(chr_yaw)
let take = Math.clamp(CharacterGround.current(chr_x, chr_z, fx, fz), 0.0, 1.0)
if take > 0.0 {
chr_speed = chr_speed * (1.0 - take)
if take > 0.55 and chr_edge_said < 0.0 {
chr_edge_said = 12.0
chr_say(CHAR_CURRENT, take)
}
}
chr_edge_said = chr_edge_said - dt
}