Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/combat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 12 additions & 7 deletions src/core/combat.phel
Original file line number Diff line number Diff line change
@@ -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])
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/core/engine.phel
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/core/map.phel
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 21 additions & 13 deletions src/core/physics.phel
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <COLOUR> 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
Expand Down
Loading