updated the collision for jumps and terrain
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user