From b24c523fa4cae9b23dc2029dbc279df9226606fc Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 08:26:31 +0300 Subject: [PATCH] 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 --- packages/README.md | 1 + packages/ludic.character/README.md | 84 ++++++ packages/ludic.character/camera.ludic | 70 +++++ packages/ludic.character/ground.ludic | 53 ++++ packages/ludic.character/index.ludic | 21 ++ packages/ludic.character/package.ludic | 6 + packages/ludic.character/ports.ludic | 62 ++++ packages/ludic.character/queries.ludic | 48 ++++ packages/ludic.character/rules.ludic | 48 ++++ packages/ludic.character/safe.ludic | 39 +++ packages/ludic.character/sit.ludic | 59 ++++ packages/ludic.character/state.ludic | 77 +++++ packages/ludic.character/steer.ludic | 61 ++++ packages/ludic.character/swim.ludic | 22 ++ packages/ludic.character/system.ludic | 24 ++ .../tests/character_test.ludic | 272 ++++++++++++++++++ packages/ludic.character/update.ludic | 68 +++++ packages/ludic.character/verbs.ludic | 125 ++++++++ packages/ludic.character/walk.ludic | 69 +++++ packages/ludic.character/water.ludic | 73 +++++ 20 files changed, 1282 insertions(+) create mode 100644 packages/ludic.character/README.md create mode 100644 packages/ludic.character/camera.ludic create mode 100644 packages/ludic.character/ground.ludic create mode 100644 packages/ludic.character/index.ludic create mode 100644 packages/ludic.character/package.ludic create mode 100644 packages/ludic.character/ports.ludic create mode 100644 packages/ludic.character/queries.ludic create mode 100644 packages/ludic.character/rules.ludic create mode 100644 packages/ludic.character/safe.ludic create mode 100644 packages/ludic.character/sit.ludic create mode 100644 packages/ludic.character/state.ludic create mode 100644 packages/ludic.character/steer.ludic create mode 100644 packages/ludic.character/swim.ludic create mode 100644 packages/ludic.character/system.ludic create mode 100644 packages/ludic.character/tests/character_test.ludic create mode 100644 packages/ludic.character/update.ludic create mode 100644 packages/ludic.character/verbs.ludic create mode 100644 packages/ludic.character/walk.ludic create mode 100644 packages/ludic.character/water.ludic diff --git a/packages/README.md b/packages/README.md index 76b20b48..6a1411bf 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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`, 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) | diff --git a/packages/ludic.character/README.md b/packages/ludic.character/README.md new file mode 100644 index 00000000..56f6a8ca --- /dev/null +++ b/packages/ludic.character/README.md @@ -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` | `{ 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. diff --git a/packages/ludic.character/camera.ludic b/packages/ludic.character/camera.ludic new file mode 100644 index 00000000..78fb890b --- /dev/null +++ b/packages/ludic.character/camera.ludic @@ -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 +} diff --git a/packages/ludic.character/ground.ludic b/packages/ludic.character/ground.ludic new file mode 100644 index 00000000..b7907339 --- /dev/null +++ b/packages/ludic.character/ground.ludic @@ -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) +} diff --git a/packages/ludic.character/index.ludic b/packages/ludic.character/index.ludic new file mode 100644 index 00000000..b145db18 --- /dev/null +++ b/packages/ludic.character/index.ludic @@ -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" diff --git a/packages/ludic.character/package.ludic b/packages/ludic.character/package.ludic new file mode 100644 index 00000000..0db0a512 --- /dev/null +++ b/packages/ludic.character/package.ludic @@ -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 diff --git a/packages/ludic.character/ports.ludic b/packages/ludic.character/ports.ludic new file mode 100644 index 00000000..806c20c1 --- /dev/null +++ b/packages/ludic.character/ports.ludic @@ -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 { } diff --git a/packages/ludic.character/queries.ludic b/packages/ludic.character/queries.ludic new file mode 100644 index 00000000..9a43fcfb --- /dev/null +++ b/packages/ludic.character/queries.ludic @@ -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() } diff --git a/packages/ludic.character/rules.ludic b/packages/ludic.character/rules.ludic new file mode 100644 index 00000000..d630e4db --- /dev/null +++ b/packages/ludic.character/rules.ludic @@ -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 +} diff --git a/packages/ludic.character/safe.ludic b/packages/ludic.character/safe.ludic new file mode 100644 index 00000000..a1540944 --- /dev/null +++ b/packages/ludic.character/safe.ludic @@ -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 +} diff --git a/packages/ludic.character/sit.ludic b/packages/ludic.character/sit.ludic new file mode 100644 index 00000000..2f786111 --- /dev/null +++ b/packages/ludic.character/sit.ludic @@ -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) +} diff --git a/packages/ludic.character/state.ludic b/packages/ludic.character/state.ludic new file mode 100644 index 00000000..f4b414b5 --- /dev/null +++ b/packages/ludic.character/state.ludic @@ -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 = null + +export function char_facts() -> Queue { + 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) +} diff --git a/packages/ludic.character/steer.ludic b/packages/ludic.character/steer.ludic new file mode 100644 index 00000000..9c9e160b --- /dev/null +++ b/packages/ludic.character/steer.ludic @@ -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) +} diff --git a/packages/ludic.character/swim.ludic b/packages/ludic.character/swim.ludic new file mode 100644 index 00000000..770341ef --- /dev/null +++ b/packages/ludic.character/swim.ludic @@ -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() } +} diff --git a/packages/ludic.character/system.ludic b/packages/ludic.character/system.ludic new file mode 100644 index 00000000..6d4c7476 --- /dev/null +++ b/packages/ludic.character/system.ludic @@ -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 +} diff --git a/packages/ludic.character/tests/character_test.ludic b/packages/ludic.character/tests/character_test.ludic new file mode 100644 index 00000000..6f2a530f --- /dev/null +++ b/packages/ludic.character/tests/character_test.ludic @@ -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) + } +} diff --git a/packages/ludic.character/update.ludic b/packages/ludic.character/update.ludic new file mode 100644 index 00000000..861a1b0e --- /dev/null +++ b/packages/ludic.character/update.ludic @@ -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) +} diff --git a/packages/ludic.character/verbs.ludic b/packages/ludic.character/verbs.ludic new file mode 100644 index 00000000..e560018f --- /dev/null +++ b/packages/ludic.character/verbs.ludic @@ -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) +} diff --git a/packages/ludic.character/walk.ludic b/packages/ludic.character/walk.ludic new file mode 100644 index 00000000..39d33882 --- /dev/null +++ b/packages/ludic.character/walk.ludic @@ -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 +} diff --git a/packages/ludic.character/water.ludic b/packages/ludic.character/water.ludic new file mode 100644 index 00000000..e4a650d8 --- /dev/null +++ b/packages/ludic.character/water.ludic @@ -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 +}