From 9e7cbd88be753843d70e299ff5b98af299e80d67 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 4 Jul 2026 21:42:32 +0200 Subject: [PATCH] feat(physics+combat): window-gap passability + shoot-through Related to #388 --- CHANGELOG.md | 1 + docs/combat.md | 2 +- docs/map.md | 2 +- src/core/combat.phel | 19 ++++++++----- src/core/engine.phel | 52 ++++++++++++++++++++++++++++++++++++ src/core/map.phel | 20 ++++++++++++++ src/core/physics.phel | 34 ++++++++++++++--------- tests/core/engine-test.phel | 48 ++++++++++++++++++++++++++++++++- tests/core/physics-test.phel | 45 ++++++++++++++++++++++++++++++- 9 files changed, 199 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 364e4f3..ecc0601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ User-facing changes (`feat:`, `fix:`, `perf:`) belong under `## [Unreleased]` un ### Added +- Window-gap passability + shoot-through (issue #388, epic #375 Phase 3, the 4/4 that completes variable ceilings): a lowered ceiling now blocks movement and shots, not just the eye. Physics `try-move` gains a headroom gate - a cell is walk-through only when its vertical opening (`ceiling-z - floor-z`) clears `player-height`; a lintel that pinches the opening below that blocks walking while staying see-through (the renderer still paints the upper slice from #387). Combat routes its hitscan through a new `cast-shot-dist` (a gap-aware `cast-ray`): a floor cell whose ceiling has dropped to or below the muzzle height (`0.5 + player z`) stops the shot like a wall, a higher ceiling is shoot-through. `player-height` equals one `fz-step` (0.25) precisely so every flat-ceiling level stays neutral - even L2's fz-0.75 platform under a full cz-1.0 ceiling (opening 0.25) is still walkable and shootable - so behaviour is unchanged on all shipped levels (full suite green). Floor risers deliberately do not block shots (skim-over preserved, tiered-floor levels unchanged). New deterministic physics + combat unit tests pin the gates and their flat-ceiling neutrality. Enemy chase parity for the headroom gate is deferred (no shipped lowered-ceiling content to exercise it), consistent with the epic's other deferred enemy-elevation work. - Upper wall-slice painter for lowered ceilings (issue #387, epic #375 Phase 3): the renderer now draws the `:uspans` ceiling-drop stream from #386 - each column's ceiling drop paints a wall-shaded band hanging down from the ceiling top to the lowered ceiling plane, the visual mirror of #370's riser painter. `compute-upper-shades` projects each drop's `cz0`/`cz1` through `project-height` once per column and shades it like a wall; the per-cell paint stays a single aget (closure-tax rule preserved - render/engine hot-loop closure counts unchanged at 26/2), and the band wins over sky for its rows. `core/projection.phel` gains `ceiling-top-row-dist` (the ceiling mirror of #370's `tier-top-row-dist`, unit-tested, wired-and-ready for a future textured ceiling pass). Flat-ceiling worlds are byte-identical (`:uspans` empty, the upper lookup resolves nil through one aget - every shipped level unchanged, full suite + all golden hashes green); a synthetic lowered-ceiling room exercises the px1 + px2 upper-slice branches (differs from its full-ceiling twin + renders deterministically). Ceilings still don't block movement/shots (deferred to #388). - Upper-span cast for ceiling drops (issue #386, epic #375 Phase 3): the raycaster's DDA now tracks ceiling height (`:pcz` from #385) and emits a flat `:uspans` event stream where the ceiling DROPS between cells - the mirror of #369's floor risers, the visible upper wall slice. Same stride-8 shape as `:spans` but `cz1 < cz0`; a cell with raised floor + lowered ceiling is a window gap (emits both a floor span and an upper span). do-cast now selects one of THREE march variants per frame on the build-time `:fz-flat?` / `:cz-flat?` flags: both-flat -> legacy, tiered-floor -> floor-span march (#369), lowered-ceiling -> a combined march tracking both. The first two are byte-identical to before, so flat levels and tiered-floor levels (L2) are unchanged (all golden hashes + full suite green); only lowered-ceiling worlds run the combined march (~+53% cast, future-content cost - no shipped level has lowered ceilings). Render ignores `:uspans` until #387. - Textured tier-top floors (issue #384, epic #375): the raised-tier ground band (between a far wall's bottom and the nearer riser's top) previously painted a flat grey gradient; it now samples the floor stone texture, so a raised tier reads as textured ground consistent with the main floor. Wires `tier-top-row-dist` (built + unit-tested in #370, never used until now): the texel is sampled ONCE PER COLUMN in `compute-riser-shades` at the band's near edge (via the tier's own height `z1`) and fogged by that distance, so the per-cell paint stays a single aget (closure-tax rule) - the per-cell hot loop gains zero closures, and render cost is within noise (bench 5.8/6.8/8.6 ms at 80/120/180 cols, unchanged from the flat-gradient version). Flat levels are byte-identical (no risers -> no tier-top samples); only the tiered from-below / px2 golden frames change. Adds one per-column closure (the `^:pure` inline) on tiered frames only. diff --git a/docs/combat.md b/docs/combat.md index 6e6056c..6efc854 100644 --- a/docs/combat.md +++ b/docs/combat.md @@ -56,7 +56,7 @@ Drops push into `:hearts` / `:armors` / `:ammo-boxes` vectors (same as level spa Two-step scan per shot: -1. **Wall ray** along player facing via `cast-ray`, returns distance to first wall. +1. **Shot ray** along player facing via `cast-shot-dist`, returns distance to the first blocker. A blocker is a wall OR a window-gap lintel: a floor cell whose ceiling has dropped to or below the muzzle height `shot-z` (eye level, `0.5 + player z`) stops the shot like a wall, while a higher ceiling is shoot-through (issue #388). On a flat-ceiling level every ceiling is full-height (`cz 1.0 > shot-z`), so this reduces exactly to the old wall-only `cast-ray` and combat is behaviour-neutral. Floor risers do not block shots (the shot skims over a raised step as before), which keeps tiered-floor levels like L2 unchanged. 2. **Enemy scan**: project each alive enemy onto heading (dot product). Nearest one in front, closer than wall, within max-range, within hit-radius perpendicular, AND vertically on the sprite (see below) = hit. Flip `:alive false`, arm respawn timer (3-6s uniform). On hit: `play-shot-sfx` emits weapon report + kill cue (distance-attenuated). `on-shot-hit` bumps `:kills`, chains streak, pushes blood splatter, arms hit-stop if tough enemy dies, and stamps the crosshair hit-marker (see below). diff --git a/docs/map.md b/docs/map.md index dfa6397..2127d33 100644 --- a/docs/map.md +++ b/docs/map.md @@ -69,7 +69,7 @@ Layouts author lowered ceilings with lowercase chars: `a` / `b` / `c` parse as ` Note: each layout char sets EITHER floor height (`1`/`2`/`3`) OR ceiling height (`a`/`b`/`c`), not both, so no single char authors a window gap (raised `fz` + lowered `cz`) on one cell yet. A combined-authoring char is left for the #387 render / #388 physics sub-issues that first need it. -This is the data layer only (#385): cast / render / physics still assume ceiling `z = 1`, and every shipped level stays full-height, so behavior is unchanged. The cast (#386), render (#387), and physics/combat (#388) sub-issues make ceilings visible and walk-blocking. +Ceilings are now fully wired through the pipeline: the cast emits ceiling-drop `:uspans` (#386), the renderer paints the upper wall slice (#387), and physics + combat honor the gap (#388). Passability uses `headroom-clears?`: a cell is walk-through only when its opening `ceiling-z - floor-z` is at least `player-height` (one `fz-step`, 0.25), so a lowered lintel that pinches the opening blocks walking while staying see-through. Combat's `cast-shot-dist` stops a shot at a lintel whose ceiling has dropped to or below the muzzle height. `player-height` equals one `fz-step` precisely so every flat-ceiling level - including L2's fz-0.75 platform under a full cz-1.0 ceiling (opening 0.25) - stays walkable and shootable, i.e. behaviour is unchanged on all shipped levels. Enemy chase parity for the headroom gate is deferred (enemies still use the wall + step-up gate only), consistent with the epic's other deferred enemy-elevation work; no shipped level has lowered ceilings to exercise it. ## Random map generation diff --git a/src/core/combat.phel b/src/core/combat.phel index 3cf342d..faa5aae 100644 --- a/src/core/combat.phel +++ b/src/core/combat.phel @@ -1,5 +1,5 @@ (ns phel-doom.core.combat - (:require phel-doom.core.engine :refer [cast-ray fov-proj-dist]) + (:require phel-doom.core.engine :refer [cast-ray cast-shot-dist fov-proj-dist]) (:require phel-doom.core.projection :refer [pitch-rows]) (:require phel-doom.core.enemy :refer [push-back-enemy shoot beam-impact splash pierce spread-shoot]) (:require phel-doom.core.enemy-ai :refer [attacks? noise-wake]) @@ -578,13 +578,18 @@ (aim-angle-offset (:aim-col world) (:scene-cols world)))) (defn- cast-wall-dist - "Distance to the wall along the AIM direction (#324): an off-centre - reticle steers the wall probe too, so the shot stops at the wall the - reticle points at, not the one dead ahead. Centre / mouse-off aims down - `:angle`, identical to the pre-#324 cast." + "Distance the shot travels along the AIM direction (#324): an off-centre + reticle steers the probe too, so the shot stops at the wall the reticle + points at, not the one dead ahead. Centre / mouse-off aims down + `:angle`, identical to the pre-#324 cast. Uses `cast-shot-dist` (#388) + so a window-gap lintel (a floor cell whose ceiling dropped to/below the + muzzle height) stops the shot like a wall; on a flat-ceiling level every + ceiling is full-height, so this is identical to the plain wall cast." [world] - (let [p (:player world)] - (cast-ray (:pgrid world) (:x p) (:y p) (aim-angle world)))) + (let [p (:player world) + shot-z (php/+ 0.5 (or (:z p) 0.0))] + (cast-shot-dist (:pgrid world) (:pcz world) (:width world) + shot-z (:x p) (:y p) (aim-angle world)))) (defn- aim-svh "Scene viewport height (rows) the hit gate projects against. The game diff --git a/src/core/engine.phel b/src/core/engine.phel index f4c01c4..e56c713 100644 --- a/src/core/engine.phel +++ b/src/core/engine.phel @@ -166,6 +166,58 @@ (php/!== 0 hit) dist :else (recur cx cy' sidex (php/+ sidey deltay)))))))) +(defn ^:pure cast-shot-dist + "Like `cast-ray`, but a hitscan / projectile at muzzle height `shot-z` + (eye level, ~0.5) also stops at a window-gap lintel: a floor cell whose + ceiling has dropped to or below the shot line (`cz <= shot-z`) blocks + the shot exactly like a wall, while a higher ceiling is shoot-through + (issue #388, epic #375 Phase 3). `pcz` is the world's flat ceiling + array (indexed `y * mapw + x`, default 1.0); on a flat-ceiling level + every `cz` is 1.0 > `shot-z`, so this reduces to `cast-ray` and combat + is behaviour-neutral. Runs per shot (not per column), so it favours + clarity over the closure-tax micro-tuning of the frame cast." + [pgrid pcz ^int mapw ^float shot-z ^float px ^float py ^float angle] + (let [max-depth max-depth + dda-inf dda-inf + ^array grid pgrid + ^array cz pcz + dirx (php/cos angle) + diry (php/sin angle) + cx0 (php/intval (php/floor px)) + cy0 (php/intval (php/floor py)) + stepx (if (php/< dirx 0.0) -1 1) + stepy (if (php/< diry 0.0) -1 1) + deltax (if (php/=== dirx 0.0) dda-inf (php/abs (php// 1.0 dirx))) + deltay (if (php/=== diry 0.0) dda-inf (php/abs (php// 1.0 diry))) + sidex0 (if (php/< dirx 0.0) + (php/* (php/- px cx0) deltax) + (php/* (php/- (php/+ cx0 1.0) px) deltax)) + sidey0 (if (php/< diry 0.0) + (php/* (php/- py cy0) deltay) + (php/* (php/- (php/+ cy0 1.0) py) deltay))] + (loop [cx cx0 cy cy0 sidex sidex0 sidey sidey0] + (if (php/< sidex sidey) + (let [cx' (php/+ cx stepx) + dist sidex + ^array row (get grid cy) + hit (get row cx' 1)] + (cond + (php/> dist max-depth) max-depth + (php/!== 0 hit) dist + ;; window-gap lintel: ceiling of the entered cell drops to or + ;; below the shot line - the shot stops here like a wall. + (php/<= (get cz (php/+ (php/* cy mapw) cx') 1.0) shot-z) dist + :else (recur cx' cy (php/+ sidex deltax) sidey))) + (let [cy' (php/+ cy stepy) + dist sidey + ^array row (get grid cy') + hit (get row cx 1)] + (cond + (php/> dist max-depth) max-depth + (php/!== 0 hit) dist + (php/<= (get cz (php/+ (php/* cy' mapw) cx) 1.0) shot-z) dist + :else (recur cx cy' sidex (php/+ sidey deltay)))))))) + (defn- do-cast "Cast every column once; returns the parallel :dists / :hits / :sides / :hxs / :hys map plus the :spans riser-event stream (issue #369) and diff --git a/src/core/map.phel b/src/core/map.phel index 21bc58d..2b58192 100644 --- a/src/core/map.phel +++ b/src/core/map.phel @@ -149,6 +149,26 @@ 1.0 (or (get row x) 1.0))))) +(def player-height + "Minimum vertical clearance (`ceiling-z - floor-z`) a cell must offer + for the player to walk into it (issue #388, epic #375 Phase 3). Equals + one `fz-step`, so every flat-ceiling level stays walkable - even a + raised fz-0.75 tier under a full cz-1.0 ceiling has clearance 0.25 and + passes - and behaviour there is unchanged. Only a genuinely lowered + ceiling (a window-gap lintel) can pinch a cell's opening below this and + block walking while the gap stays see-through (the renderer still + paints the upper wall slice, #387)." + fz-step) + +(defn ^:pure headroom-clears? + "True when cell (x, y)'s vertical opening (`ceiling-z - floor-z`) is at + least `player-height` - the player fits under the ceiling (issue #388). + A lowered lintel that pinches the opening below `player-height` blocks + walking. Full-height ceilings on any shipped (<= fz-0.75) tier always + clear, so this gate is a no-op on flat-ceiling levels." + [fz-grid cz-grid x y] + (php/>= (php/- (ceiling-z cz-grid x y) (floor-z fz-grid x y)) player-height)) + (defn ^:pure wall? "True when (x, y) blocks player movement. Solid walls, unrevealed secrets, AND switch cells (either state) qualify; doors are diff --git a/src/core/physics.phel b/src/core/physics.phel index 878600e..d7e4dd8 100644 --- a/src/core/physics.phel +++ b/src/core/physics.phel @@ -1,7 +1,7 @@ (ns phel-doom.core.physics (:require phel-doom.core.rng :as rng) (:require phel-doom.core.state :refer [max-stamina move-player turn-player clamp-pitch]) - (:require phel-doom.core.map :refer [missing-key-for passable? wall? floor-z fz-step]) + (:require phel-doom.core.map :refer [missing-key-for passable? wall? floor-z fz-step headroom-clears?]) (:require phel-doom.core.weapons :refer [weapon-spec])) (def locked-bump-secs @@ -80,26 +80,34 @@ (defn try-move "Translate the player by (dx, dy) unless the destination cell holds a - wall, a locked door without the matching keycard, or a floor-height - rise bigger than one fz-step (issue #371 - a taller rise blocks exactly - like a wall). An allowed move snaps the player's :z to the destination - cell's fz, whether stepping up (stairs) or down (no lerp/fall - that is - later polish, not built here). Flat worlds keep every cell's fz at 0.0 - so this gate is a no-op there (byte-identical to pre-#371 behaviour). + wall, a locked door without the matching keycard, a floor-height rise + bigger than one fz-step (issue #371 - a taller rise blocks exactly like + a wall), or a window-gap lintel whose vertical opening (`ceiling-z - + floor-z`) is under `player-height` (issue #388 - too low to walk under, + though still see-through). An allowed move snaps the player's :z to the + destination cell's fz, whether stepping up (stairs) or down (no + lerp/fall - that is later polish, not built here). Flat-ceiling worlds + keep every opening at >= player-height so both height gates are no-ops + there (byte-identical to pre-#371/#388 behaviour). When a bump is caused specifically by a missing keycard, arm :locked-door-bump-secs + record the colour so the render layer can paint 'NEED KEY' over the 3D view, and enqueue a muted `:click` deny cue on the :sfx queue. Wall bumps are silent - only the locked-door case is informational." [world ^float dx ^float dy] - (let [{:keys [grid fz-grid player held-keys]} world - nx (+ (:x player) dx) - ny (+ (:y player) dy) - ix (php/intval nx) - iy (php/intval ny)] + (let [{:keys [grid fz-grid cz-grid player held-keys]} world + nx (+ (:x player) dx) + ny (+ (:y player) dy) + ix (php/intval nx) + iy (php/intval ny)] (cond (and (passable? grid held-keys ix iy) - (step-clears? fz-grid (or (:z player) 0.0) ix iy)) + (step-clears? fz-grid (or (:z player) 0.0) ix iy) + ;; Window-gap headroom (#388): a lowered lintel that pinches + ;; the opening below player-height blocks walking (but stays + ;; see-through). No-op on flat-ceiling levels (clearance >= 1 + ;; minus the tier fz, always >= player-height for shipped fz). + (headroom-clears? fz-grid cz-grid ix iy)) (assoc-in (move-player world dx dy) [:player :z] (floor-z fz-grid ix iy)) :else diff --git a/tests/core/engine-test.phel b/tests/core/engine-test.phel index 6d0ea76..465e893 100644 --- a/tests/core/engine-test.phel +++ b/tests/core/engine-test.phel @@ -2,7 +2,7 @@ (:require phel.test :refer [deftest is]) (:require phel-doom.core.map :refer [default-grid]) (:require phel-doom.core.state :refer [new-world new-player]) - (:require phel-doom.core.engine :refer [cast-ray cast-frame mark-visible-cells max-depth visited? visit-radius fov-proj-dist fov-clamp-width fov-max-deg proj-dist])) + (:require phel-doom.core.engine :refer [cast-ray cast-shot-dist cast-frame mark-visible-cells max-depth visited? visit-radius fov-proj-dist fov-clamp-width fov-max-deg proj-dist])) (def pgrid (to-php-array (map to-php-array default-grid))) @@ -53,6 +53,52 @@ (let [d (cast-ray pgrid 1.5 1.5 (- 0 (/ php/M_PI 2.0)))] (is (< d 1.0)))) +;; --------------------------------------------------------------------- +;; cast-shot-dist: window-gap shoot-through (issue #388, epic #375). A +;; hitscan/projectile travels at the muzzle height `shot-z` (eye level, +;; ~0.5) and passes through a floor cell UNLESS its ceiling drops to or +;; below the shot line (`cz <= shot-z`) - a lowered lintel stops the shot +;; just like a wall. Full-height ceilings never block, so every shipped +;; flat-ceiling level is behaviour-neutral (identical to `cast-ray`). +;; --------------------------------------------------------------------- + +;; 8-wide corridor open along y=1, walls top/bottom + far east at x=7. +(def shot-corridor + [[1 1 1 1 1 1 1 1] + [0 0 0 0 0 0 0 1] + [1 1 1 1 1 1 1 1]]) +(def shot-flat-fz (vec (repeat 3 (vec (repeat 8 0.0))))) +(defn- shot-world [cz-mid] + (new-world shot-corridor (new-player 1.5 1.5 0.0) shot-flat-fz + [(vec (repeat 8 1.0)) cz-mid (vec (repeat 8 1.0))])) + +(deftest test-cast-shot-dist-flat-ceiling-matches-cast-ray + ;; Full-height ceiling everywhere: the shot behaves exactly like the + ;; wall-only cast-ray - the neutrality contract for shipped levels. + (let [w (shot-world (vec (repeat 8 1.0))) + px 1.5 py 1.5 ang 0.0] + (is (= (cast-ray (:pgrid w) px py ang) + (cast-shot-dist (:pgrid w) (:pcz w) (:width w) 0.5 px py ang))))) + +(deftest test-cast-shot-dist-lowered-lintel-stops-shot-early + ;; Cell (4,1) ceiling drops to 0.2, below the 0.5 shot line: the shot + ;; stops at that lintel, nearer than the far wall the plain cast reaches. + (let [w (shot-world [1.0 1.0 1.0 1.0 0.2 1.0 1.0 1.0]) + px 1.5 py 1.5 ang 0.0 + wall (cast-ray (:pgrid w) px py ang) + shot (cast-shot-dist (:pgrid w) (:pcz w) (:width w) 0.5 px py ang)] + (is (< shot wall) "the lowered lintel stops the shot before the wall") + (is (< (php/abs (php/- shot 2.5)) 0.001) "stops at the x=4 lintel (~2.5 away)"))) + +(deftest test-cast-shot-dist-high-lintel-does-not-block + ;; Cell (4,1) ceiling at 0.75, ABOVE the 0.5 shot line: the shot clears + ;; it and travels on to the far wall, same as the flat-ceiling cast. + (let [w (shot-world [1.0 1.0 1.0 1.0 0.75 1.0 1.0 1.0]) + px 1.5 py 1.5 ang 0.0] + (is (= (cast-ray (:pgrid w) px py ang) + (cast-shot-dist (:pgrid w) (:pcz w) (:width w) 0.5 px py ang)) + "a lintel above the shot line is see-through and shoot-through"))) + ;; --------------------------------------------------------------------- ;; DDA correctness (issue #2): pin the hit details cast-frame surfaces ;; to render so the texture / side-shading pipeline keeps working. diff --git a/tests/core/physics-test.phel b/tests/core/physics-test.phel index 1112cbd..1a9c656 100644 --- a/tests/core/physics-test.phel +++ b/tests/core/physics-test.phel @@ -1,6 +1,6 @@ (ns phel-doom-tests.core.physics-test (:require phel.test :refer [deftest is]) - (:require phel-doom.core.map :refer [fz-step parse-layout]) + (:require phel-doom.core.map :refer [fz-step player-height parse-layout]) (:require phel-doom.core.level :refer [showcase-layout]) (:require phel-doom.core.state :refer [max-stamina new-world new-player]) (:require phel-doom.core.physics :refer [try-move apply-physics sprint-engage-threshold sprint-multiplier stamina-drain stamina-regen tick-heartbeat tick-stamina tick-blood-drops])) @@ -147,6 +147,49 @@ zs) "walking north from the spawn climbs 0 -> 0.25 -> 0.5 -> 0.75 then holds on the platform"))) +;; --------------------------------------------------------------------- +;; try-move + window-gap headroom (issue #388, epic #375 Phase 3): a cell +;; is walk-through only when its vertical opening `ceiling-z - floor-z` +;; clears `player-height`. A lowered lintel (small opening) blocks walking +;; but stays see-through (the renderer still paints the upper slice #387). +;; `player-height` equals one fz-step (0.25), so every shipped flat-ceiling +;; level - including L2's fz-0.75 platform under a full cz-1.0 ceiling +;; (opening 0.25) - stays walkable and behaviour is unchanged. +;; --------------------------------------------------------------------- + +(defn- headroom-world [mid-fz mid-cz] + (new-world step-corridor (new-player 1.5 1.5 0.0) + [[0.0 0.0 0.0 0.0 0.0] mid-fz [0.0 0.0 0.0 0.0 0.0]] + [[1.0 1.0 1.0 1.0 1.0] mid-cz [1.0 1.0 1.0 1.0 1.0]])) + +(deftest test-try-move-blocked-by-low-ceiling + ;; Cell (2,1): flat floor (fz 0, step-up gate passes) but the ceiling + ;; drops to 0.2 - an opening of 0.2 < player-height (0.25). The lintel + ;; is too low to walk under, so the move is blocked like a wall. + (let [w0 (headroom-world [0.0 0.0 0.0 0.0 0.0] [1.0 1.0 0.2 1.0 1.0]) + w (try-move w0 1.0 0.0) + p (:player w)] + (is (= 1.5 (:x p)) "low-ceiling cell blocks the move") + (is (= 0.0 (:z p)) "z stays put"))) + +(deftest test-try-move-allowed-when-opening-clears-player-height + ;; Same cell but the ceiling only drops to exactly player-height above + ;; the floor (opening == 0.25 >= player-height): the move is allowed. + (let [w0 (headroom-world [0.0 0.0 0.0 0.0 0.0] [1.0 1.0 player-height 1.0 1.0]) + w (try-move w0 1.0 0.0) + p (:player w)] + (is (= 2.5 (:x p)) "an opening of exactly player-height is walkable"))) + +(deftest test-try-move-flat-ceiling-tier-stays-walkable + ;; Behaviour-neutral guard: a raised tier (fz 0.25) under a full ceiling + ;; (cz 1.0) has opening 0.75, far above player-height - the headroom gate + ;; must NOT block it (it is the ordinary one-step climb #371 allows). + (let [w0 (headroom-world [0.0 0.0 fz-step 0.0 0.0] [1.0 1.0 1.0 1.0 1.0]) + w (try-move w0 1.0 0.0) + p (:player w)] + (is (= 2.5 (:x p)) "flat-ceiling raised tier is still walkable") + (is (= fz-step (:z p)) "z snaps to the tier fz"))) + ;; --------------------------------------------------------------------- ;; try-move + locked doors: arming the NEED KEY HUD cue. ;; ---------------------------------------------------------------------