diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index aa732dba..bb81e790 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -1269,3 +1269,27 @@ Vite's existing large-chunk advisory remains informational. - Verification: 71 focused catalog/store/repository/dungeon/ability/party tests passed, all 28 avatar tests passed, and `npm run build` completed successfully. + +# 2026-08-16 — Deadmines party terrain following and vertical animation tolerance + +- Entered The Deadmines as the saved Human Priest and selected **Attack**. + The player crossed 43 m of uneven mine-floor and rail terrain; the tank and + a damage companion remained visibly beside the player instead of waiting for + the 12 m stuck-recall threshold. All four companion world labels remained in + the live scene, and no automatic party recall was observed. +- The character maintained ordinary ground locomotion over small collision + height changes. Focused timing coverage confirms that terrain gaps shorter + than 100 ms neither enter the falling state nor trigger a landing animation, + while intentional jumps and sustained falls still present immediately/after + the grace period and land once. +- The saved roster preview rendered the complete Human body and starter staff; + the in-dungeon view rendered the player silhouette, nearby companions, the + Deadmines environment, and HUD. +- Browser diagnostics contained no errors. The existing third-party + initialization deprecation warning remained non-blocking. +- Evidence: `playtest-artifacts/party-terrain-roster-2026-08-16.png`, + `playtest-artifacts/deadmines-party-terrain-2026-08-16.png`, and + `playtest-artifacts/deadmines-party-follow-view-2026-08-16.png`. +- Verification: 38 focused party/pathing/jump/animation tests passed, all 28 + avatar tests passed, and `npm run build` completed successfully with 954 + modules transformed. The existing large-chunk advisory remains informational. diff --git a/src/game/playerJump.test.ts b/src/game/playerJump.test.ts index be3bc8a7..49322d2f 100644 --- a/src/game/playerJump.test.ts +++ b/src/game/playerJump.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; import { PLAYER_GROUND_MIN_NORMAL_Y, + PLAYER_AIRBORNE_PRESENTATION_DELAY_MS, PLAYER_JUMP_BUFFER_MS, PLAYER_JUMP_COYOTE_MS, bufferPlayerJump, cancelBufferedPlayerJump, characterVerticalMotion, + createCharacterVerticalPresentationTiming, createPlayerJumpTiming, isWalkableGroundHit, + updateCharacterVerticalPresentation, updatePlayerJumpTiming, } from "./playerJump"; @@ -63,4 +66,38 @@ describe("player jump timing", () => { expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing"); expect(characterVerticalMotion(true, 0, 150, 150)).toBe("grounded"); }); + + it("hides brief terrain gaps without playing a landing animation", () => { + const timing = createCharacterVerticalPresentationTiming(); + expect(updateCharacterVerticalPresentation(timing, false, -0.1, 1_000, false)) + .toBe("grounded"); + expect(updateCharacterVerticalPresentation( + timing, + true, + 0, + 1_000 + PLAYER_AIRBORNE_PRESENTATION_DELAY_MS - 1, + false, + )).toBe("grounded"); + }); + + it("presents real falls and intentional jumps, then lands once", () => { + const falling = createCharacterVerticalPresentationTiming(); + expect(updateCharacterVerticalPresentation(falling, false, -1, 2_000, false)) + .toBe("grounded"); + expect(updateCharacterVerticalPresentation( + falling, + false, + -2, + 2_000 + PLAYER_AIRBORNE_PRESENTATION_DELAY_MS, + false, + )).toBe("falling"); + expect(updateCharacterVerticalPresentation(falling, true, 0, 2_200, false)) + .toBe("landing"); + expect(updateCharacterVerticalPresentation(falling, true, 0, 2_400, false)) + .toBe("grounded"); + + const jumping = createCharacterVerticalPresentationTiming(); + expect(updateCharacterVerticalPresentation(jumping, false, 7.2, 3_000, true)) + .toBe("rising"); + }); }); diff --git a/src/game/playerJump.ts b/src/game/playerJump.ts index 5b18647a..2998dd07 100644 --- a/src/game/playerJump.ts +++ b/src/game/playerJump.ts @@ -3,7 +3,8 @@ export const PLAYER_JUMP_COYOTE_MS = 100; export const PLAYER_JUMP_BUFFER_MS = 120; export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80; export const PLAYER_LANDING_PRESENTATION_MS = 180; -export const PLAYER_GROUND_PROBE_DISTANCE = 0.14; +export const PLAYER_AIRBORNE_PRESENTATION_DELAY_MS = 100; +export const PLAYER_GROUND_PROBE_DISTANCE = 0.22; export const PLAYER_GROUND_MIN_NORMAL_Y = 0.55; export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing"; @@ -15,6 +16,12 @@ export interface PlayerJumpTiming { lockedUntilLanding: boolean; } +export interface CharacterVerticalPresentationTiming { + airborneStartedAtMs: number; + airbornePresented: boolean; + landingUntilMs: number; +} + export interface GroundProbeHit { readonly timeOfImpact: number; readonly normal: { readonly y: number }; @@ -29,6 +36,14 @@ export function createPlayerJumpTiming(nowMs = Number.NEGATIVE_INFINITY): Player }; } +export function createCharacterVerticalPresentationTiming(): CharacterVerticalPresentationTiming { + return { + airborneStartedAtMs: Number.NEGATIVE_INFINITY, + airbornePresented: false, + landingUntilMs: Number.NEGATIVE_INFINITY, + }; +} + export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void { if (!Number.isFinite(requestedAtMs)) return; timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS; @@ -92,3 +107,38 @@ export function characterVerticalMotion( if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded"; return verticalVelocity > 0.15 ? "rising" : "falling"; } + +/** + * Keeps tiny terrain gaps from interrupting locomotion with a fall/land pair. + * An intentional jump is presented immediately; an incidental loss of ground + * must persist before it becomes visible, and only visible airtime may land. + */ +export function updateCharacterVerticalPresentation( + timing: CharacterVerticalPresentationTiming, + grounded: boolean, + verticalVelocity: number, + nowMs: number, + intentionalJump: boolean, +): CharacterVerticalMotion { + if (!Number.isFinite(nowMs)) { + return characterVerticalMotion(grounded, verticalVelocity, 0, Number.NEGATIVE_INFINITY); + } + + if (!grounded) { + if (!Number.isFinite(timing.airborneStartedAtMs)) timing.airborneStartedAtMs = nowMs; + if ( + intentionalJump + || nowMs - timing.airborneStartedAtMs >= PLAYER_AIRBORNE_PRESENTATION_DELAY_MS + ) timing.airbornePresented = true; + return timing.airbornePresented + ? characterVerticalMotion(false, verticalVelocity, nowMs, timing.landingUntilMs) + : "grounded"; + } + + if (timing.airbornePresented) { + timing.landingUntilMs = nowMs + PLAYER_LANDING_PRESENTATION_MS; + } + timing.airborneStartedAtMs = Number.NEGATIVE_INFINITY; + timing.airbornePresented = false; + return characterVerticalMotion(true, verticalVelocity, nowMs, timing.landingUntilMs); +} diff --git a/src/scene/PartyPopulation.tsx b/src/scene/PartyPopulation.tsx index 4953692a..cac36ce6 100644 --- a/src/scene/PartyPopulation.tsx +++ b/src/scene/PartyPopulation.tsx @@ -81,6 +81,7 @@ const BREADCRUMB_REACHED_DISTANCE = 0.04; const PARTY_COLLISION_RADIUS = 0.34; const PARTY_COLLISION_HALF_HEIGHT = 0.48; const PARTY_COLLISION_CENTER_HEIGHT = PARTY_COLLISION_RADIUS + PARTY_COLLISION_HALF_HEIGHT + 0.04; +const PARTY_WALKABLE_STEP_HEIGHT = 0.32; const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 6; const PARTY_FLOOR_SAMPLE_SPACING = 0.6; const PARTY_FLOOR_RAY_HEIGHT = 1.25; @@ -291,7 +292,12 @@ function PartyActors({ const resetRevision = useGameStore((state) => state.resetRevision); const { rapier, world } = useRapier(); const navigationCapsule = useMemo( - () => new rapier.Capsule(PARTY_COLLISION_HALF_HEIGHT, PARTY_COLLISION_RADIUS), + // Trim the lower leg band while keeping the capsule top at normal head + // height. This supplies step clearance without demanding extra ceiling. + () => new rapier.Capsule( + PARTY_COLLISION_HALF_HEIGHT - PARTY_WALKABLE_STEP_HEIGHT * 0.5, + PARTY_COLLISION_RADIUS, + ), [rapier], ); const navigationRotation = useMemo(() => new Quaternion(), []); @@ -319,13 +325,20 @@ function PartyActors({ const dz = end[2] - start[2]; const distance = Math.hypot(dx, dy, dz); if (!Number.isFinite(distance)) return false; - if (distance <= 0.04) return true; + const planarDistance = Math.hypot(dx, dz); + // Collision and navigation surfaces commonly disagree by a few centimeters. + // Treat a same-column height correction as grounding, not a blocked move. + if (planarDistance <= 0.04) { + return Math.abs(dy) <= PARTY_WALKABLE_STEP_HEIGHT; + } const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC; const obstacle = world.castShape( { x: start[0], - y: start[1] + PARTY_COLLISION_CENTER_HEIGHT, + // Raise only the trimmed lower band over walkable relief. The floor + // samples below still reject real ledges and excessive elevation. + y: start[1] + PARTY_COLLISION_CENTER_HEIGHT + PARTY_WALKABLE_STEP_HEIGHT * 0.5, z: start[2], }, navigationRotation, diff --git a/src/scene/PlayerRig.tsx b/src/scene/PlayerRig.tsx index ee1928e2..41364594 100644 --- a/src/scene/PlayerRig.tsx +++ b/src/scene/PlayerRig.tsx @@ -41,12 +41,12 @@ import { import { PLAYER_GROUND_PROBE_DISTANCE, PLAYER_JUMP_VELOCITY, - PLAYER_LANDING_PRESENTATION_MS, bufferPlayerJump, cancelBufferedPlayerJump, - characterVerticalMotion, + createCharacterVerticalPresentationTiming, createPlayerJumpTiming, isWalkableGroundHit, + updateCharacterVerticalPresentation, updatePlayerJumpTiming, type CharacterVerticalMotion, } from "../game/playerJump"; @@ -123,16 +123,14 @@ function PlayerController({ [rapier], ); const jumpTimingRef = useRef(createPlayerJumpTiming()); - const wasGroundedRef = useRef(true); - const landingUntilRef = useRef(Number.NEGATIVE_INFINITY); + const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming()); useEffect(() => { if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current); lastReported.current = [...spawn.footPosition]; clearJumpRequest(); jumpTimingRef.current = createPlayerJumpTiming(performance.now()); - wasGroundedRef.current = true; - landingUntilRef.current = Number.NEGATIVE_INFINITY; + verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming(); verticalMotionRef.current = "grounded"; }, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]); @@ -189,11 +187,12 @@ function PlayerController({ if (gameplayBlocked) { cancelBufferedPlayerJump(jumpTimingRef.current); movingRef.current = false; - verticalMotionRef.current = characterVerticalMotion( + verticalMotionRef.current = updateCharacterVerticalPresentation( + verticalPresentationTimingRef.current, grounded, velocity.y, nowMs, - landingUntilRef.current, + false, ); body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true); return; @@ -208,15 +207,12 @@ function PlayerController({ ); const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y; if (shouldJump) grounded = false; - if (grounded && !wasGroundedRef.current) { - landingUntilRef.current = nowMs + PLAYER_LANDING_PRESENTATION_MS; - } - wasGroundedRef.current = grounded; - verticalMotionRef.current = characterVerticalMotion( + verticalMotionRef.current = updateCharacterVerticalPresentation( + verticalPresentationTimingRef.current, grounded, verticalVelocity, nowMs, - landingUntilRef.current, + shouldJump, ); const [worldX, worldZ] = cameraRelativeMovement(