updated the collision for jumps and terrain

This commit is contained in:
phenom
2026-08-16 18:07:34 -04:00
parent c00f3211ea
commit e39aebf3a1
5 changed files with 138 additions and 18 deletions
+24
View File
@@ -1269,3 +1269,27 @@ Vite's existing large-chunk advisory remains informational.
- Verification: 71 focused catalog/store/repository/dungeon/ability/party tests - Verification: 71 focused catalog/store/repository/dungeon/ability/party tests
passed, all 28 avatar tests passed, and `npm run build` completed passed, all 28 avatar tests passed, and `npm run build` completed
successfully. 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.
+37
View File
@@ -1,13 +1,16 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
PLAYER_GROUND_MIN_NORMAL_Y, PLAYER_GROUND_MIN_NORMAL_Y,
PLAYER_AIRBORNE_PRESENTATION_DELAY_MS,
PLAYER_JUMP_BUFFER_MS, PLAYER_JUMP_BUFFER_MS,
PLAYER_JUMP_COYOTE_MS, PLAYER_JUMP_COYOTE_MS,
bufferPlayerJump, bufferPlayerJump,
cancelBufferedPlayerJump, cancelBufferedPlayerJump,
characterVerticalMotion, characterVerticalMotion,
createCharacterVerticalPresentationTiming,
createPlayerJumpTiming, createPlayerJumpTiming,
isWalkableGroundHit, isWalkableGroundHit,
updateCharacterVerticalPresentation,
updatePlayerJumpTiming, updatePlayerJumpTiming,
} from "./playerJump"; } from "./playerJump";
@@ -63,4 +66,38 @@ describe("player jump timing", () => {
expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing"); expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing");
expect(characterVerticalMotion(true, 0, 150, 150)).toBe("grounded"); 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");
});
}); });
+51 -1
View File
@@ -3,7 +3,8 @@ export const PLAYER_JUMP_COYOTE_MS = 100;
export const PLAYER_JUMP_BUFFER_MS = 120; export const PLAYER_JUMP_BUFFER_MS = 120;
export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80; export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80;
export const PLAYER_LANDING_PRESENTATION_MS = 180; 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 const PLAYER_GROUND_MIN_NORMAL_Y = 0.55;
export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing"; export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing";
@@ -15,6 +16,12 @@ export interface PlayerJumpTiming {
lockedUntilLanding: boolean; lockedUntilLanding: boolean;
} }
export interface CharacterVerticalPresentationTiming {
airborneStartedAtMs: number;
airbornePresented: boolean;
landingUntilMs: number;
}
export interface GroundProbeHit { export interface GroundProbeHit {
readonly timeOfImpact: number; readonly timeOfImpact: number;
readonly normal: { readonly y: 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 { export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void {
if (!Number.isFinite(requestedAtMs)) return; if (!Number.isFinite(requestedAtMs)) return;
timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS; timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS;
@@ -92,3 +107,38 @@ export function characterVerticalMotion(
if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded"; if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded";
return verticalVelocity > 0.15 ? "rising" : "falling"; 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);
}
+16 -3
View File
@@ -81,6 +81,7 @@ const BREADCRUMB_REACHED_DISTANCE = 0.04;
const PARTY_COLLISION_RADIUS = 0.34; const PARTY_COLLISION_RADIUS = 0.34;
const PARTY_COLLISION_HALF_HEIGHT = 0.48; const PARTY_COLLISION_HALF_HEIGHT = 0.48;
const PARTY_COLLISION_CENTER_HEIGHT = PARTY_COLLISION_RADIUS + PARTY_COLLISION_HALF_HEIGHT + 0.04; 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_OBJECTIVE_APPROACH_SNAP_DISTANCE = 6;
const PARTY_FLOOR_SAMPLE_SPACING = 0.6; const PARTY_FLOOR_SAMPLE_SPACING = 0.6;
const PARTY_FLOOR_RAY_HEIGHT = 1.25; const PARTY_FLOOR_RAY_HEIGHT = 1.25;
@@ -291,7 +292,12 @@ function PartyActors({
const resetRevision = useGameStore((state) => state.resetRevision); const resetRevision = useGameStore((state) => state.resetRevision);
const { rapier, world } = useRapier(); const { rapier, world } = useRapier();
const navigationCapsule = useMemo( 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], [rapier],
); );
const navigationRotation = useMemo(() => new Quaternion(), []); const navigationRotation = useMemo(() => new Quaternion(), []);
@@ -319,13 +325,20 @@ function PartyActors({
const dz = end[2] - start[2]; const dz = end[2] - start[2];
const distance = Math.hypot(dx, dy, dz); const distance = Math.hypot(dx, dy, dz);
if (!Number.isFinite(distance)) return false; 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 const queryFlags = rapier.QueryFilterFlags.EXCLUDE_SENSORS
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC; | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC;
const obstacle = world.castShape( const obstacle = world.castShape(
{ {
x: start[0], 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], z: start[2],
}, },
navigationRotation, navigationRotation,
+10 -14
View File
@@ -41,12 +41,12 @@ import {
import { import {
PLAYER_GROUND_PROBE_DISTANCE, PLAYER_GROUND_PROBE_DISTANCE,
PLAYER_JUMP_VELOCITY, PLAYER_JUMP_VELOCITY,
PLAYER_LANDING_PRESENTATION_MS,
bufferPlayerJump, bufferPlayerJump,
cancelBufferedPlayerJump, cancelBufferedPlayerJump,
characterVerticalMotion, createCharacterVerticalPresentationTiming,
createPlayerJumpTiming, createPlayerJumpTiming,
isWalkableGroundHit, isWalkableGroundHit,
updateCharacterVerticalPresentation,
updatePlayerJumpTiming, updatePlayerJumpTiming,
type CharacterVerticalMotion, type CharacterVerticalMotion,
} from "../game/playerJump"; } from "../game/playerJump";
@@ -123,16 +123,14 @@ function PlayerController({
[rapier], [rapier],
); );
const jumpTimingRef = useRef(createPlayerJumpTiming()); const jumpTimingRef = useRef(createPlayerJumpTiming());
const wasGroundedRef = useRef(true); const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming());
const landingUntilRef = useRef(Number.NEGATIVE_INFINITY);
useEffect(() => { useEffect(() => {
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current); if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
lastReported.current = [...spawn.footPosition]; lastReported.current = [...spawn.footPosition];
clearJumpRequest(); clearJumpRequest();
jumpTimingRef.current = createPlayerJumpTiming(performance.now()); jumpTimingRef.current = createPlayerJumpTiming(performance.now());
wasGroundedRef.current = true; verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming();
landingUntilRef.current = Number.NEGATIVE_INFINITY;
verticalMotionRef.current = "grounded"; verticalMotionRef.current = "grounded";
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]); }, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
@@ -189,11 +187,12 @@ function PlayerController({
if (gameplayBlocked) { if (gameplayBlocked) {
cancelBufferedPlayerJump(jumpTimingRef.current); cancelBufferedPlayerJump(jumpTimingRef.current);
movingRef.current = false; movingRef.current = false;
verticalMotionRef.current = characterVerticalMotion( verticalMotionRef.current = updateCharacterVerticalPresentation(
verticalPresentationTimingRef.current,
grounded, grounded,
velocity.y, velocity.y,
nowMs, nowMs,
landingUntilRef.current, false,
); );
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true); body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
return; return;
@@ -208,15 +207,12 @@ function PlayerController({
); );
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y; const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
if (shouldJump) grounded = false; if (shouldJump) grounded = false;
if (grounded && !wasGroundedRef.current) { verticalMotionRef.current = updateCharacterVerticalPresentation(
landingUntilRef.current = nowMs + PLAYER_LANDING_PRESENTATION_MS; verticalPresentationTimingRef.current,
}
wasGroundedRef.current = grounded;
verticalMotionRef.current = characterVerticalMotion(
grounded, grounded,
verticalVelocity, verticalVelocity,
nowMs, nowMs,
landingUntilRef.current, shouldJump,
); );
const [worldX, worldZ] = cameraRelativeMovement( const [worldX, worldZ] = cameraRelativeMovement(