Compare commits

..
1 Commits
Author SHA1 Message Date
phenom 40d9f346d8 Release Healer Man 0.1.35 2026-09-12 16:02:15 -04:00
13 changed files with 218 additions and 118 deletions

No files matched your search

+43
View File
@@ -2305,3 +2305,46 @@ Vite's existing large-chunk advisory remains informational.
compression, and release-bundle verification passed. Existing build-size compression, and release-bundle verification passed. Existing build-size
advisories and deprecated physics initialization warnings remain. No hosted advisories and deprecated physics initialization warnings remain. No hosted
release was published. release was published.
## 2026-09-12 - Non-blocking players and combat actors
- Removed party soft separation and crowd-triggered combat repositioning.
Companions may overlap or cross one another without push-apart corrections
changing their routes. Normal follow formations and role-specific attack
ranges remain; a tank already in melee range does not move just because an
ally occupies the same position.
- Added a shared actor collision layer that interacts only with physical
terrain. The player capsule uses this layer. Player controller movement,
ground probes, camera sweeps, party direct/route validation, party grounding,
and combat ground-position queries use terrain-only filters. Actor body type
cannot turn an actor into a floor or movement obstruction. Party members,
remote players, mobs, and bosses currently render without physical colliders;
any future actor capsule must use the shared actor layer.
- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps
retain their terrain collision. Target-selection meshes, combat visibility,
damage, and attack-range checks were not removed.
- Ten new Rapier regressions cover overlapping actors with no contacts or
displacement, movement through fixed/kinematic/dynamic actors (including an
overlapped starting position), continued wall/floor collision, and ignoring
actors in ground, party-route, and camera queries. A terrain-layer control
confirms real actor/terrain contacts are still detected. Stockades shipping-
asset regressions now use the live player collision policy and still pass
down/up all three ramps without jumping.
- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with
Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster
and dungeon screenshots show character bodies, weapons, unchanged visible
stairs, and the existing warm lighting. Attack moved companions down from
the entrance and out of its view without ROUTE BLOCKED; the tank took aggro
and melee/caster combat events continued. Experience rose from 11,098 to
13,359. Stop followed by Recall returned the party to the nearby player.
Selecting Morvane and casting Power Word: Shield applied the buff successfully.
- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or
a manual traversal of both wings. Actor overlap and passage guarantees are
backed by the physics regressions; physical Android and multiplayer QA remain
open.
- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing
tests remained skipped. The final focused checks passed again after cleanup.
Production TypeScript/Vite build, texture/performance audits, static bundle
compression, and release-bundle verification passed. Existing chunk-size
advisories and deprecated physics initialization warnings remain. No hosted
release was published.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.34", "version": "0.1.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.34", "version": "0.1.35",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.34", "version": "0.1.35",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.34", "versionName": "0.1.35",
"versionCode": 1034 "versionCode": 1035
} }
+7 -11
View File
@@ -12,7 +12,6 @@ import {
partyNaturalFollowPosition, partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal, partyPersonalizedCombatMovementGoal,
partyPresentationY, partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck, partyShouldRecallForStuck,
partyShouldMoveToComfortTarget, partyShouldMoveToComfortTarget,
PARTY_STUCK_RECALL_DISTANCE, PARTY_STUCK_RECALL_DISTANCE,
@@ -102,7 +101,7 @@ describe("party world movement", () => {
} }
}); });
it("uses a stable combat bearing and separates overlapping allies", () => { it("uses a stable combat bearing without forcing in-range actors to reposition", () => {
const out: [number, number, number] = [0, 0, 0]; const out: [number, number, number] = [0, 0, 0];
expect(partyPersonalizedCombatMovementGoal( expect(partyPersonalizedCombatMovementGoal(
[20, 0, 0], [20, 0, 0],
@@ -110,23 +109,20 @@ describe("party world movement", () => {
true, true,
{ minimum: 10, preferred: 14, maximum: 18 }, { minimum: 10, preferred: 14, maximum: 18 },
Math.PI / 2, Math.PI / 2,
false,
out, out,
)).toBe("approach"); )).toBe("approach");
expect(out[0]).toBeCloseTo(14); expect(out[0]).toBeCloseTo(14);
expect(out[1]).toBe(0); expect(out[1]).toBe(0);
expect(out[2]).toBeCloseTo(0); expect(out[2]).toBeCloseTo(0);
const separated: [number, number, number] = [0, 0, 0]; expect(partyPersonalizedCombatMovementGoal(
expect(partySeparationGoal(
[1, 0, 1], [1, 0, 1],
[2, 0, 2], [0, 0, 0],
[[1, 0, 1]], true,
{ minimum: 0, preferred: 2.9, maximum: 3.15 },
Math.PI / 2, Math.PI / 2,
separated, out,
)).toBe(true); )).toBe("hold");
expect(separated[0]).toBeGreaterThan(2);
expect(separated[2]).toBeCloseTo(2);
}); });
it("recalls only distant allies that should be moving and stopped making progress", () => { it("recalls only distant allies that should be moving and stopped making progress", () => {
+3 -42
View File
@@ -19,7 +19,7 @@ export interface PartyBreadcrumbCursor {
breadcrumbId: number | null; breadcrumbId: number | null;
} }
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition"; export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export interface PartyMovementProfile { export interface PartyMovementProfile {
readonly followDistanceJitter: number; readonly followDistanceJitter: number;
@@ -427,44 +427,6 @@ export function personalizedPartyCombatRangeBand(
}; };
} }
/** Adds a bounded local repulsion so allies do not occupy the same point. */
export function partySeparationGoal(
current: PartyWorldPosition,
desired: PartyWorldPosition,
peers: readonly PartyWorldPosition[],
fallbackAngle: number,
out: MutablePartyWorldPosition,
separationDistance = 0.85,
maximumCorrection = 0.35,
): boolean {
let offsetX = 0;
let offsetZ = 0;
const safeSeparation = Math.max(EPSILON, separationDistance);
for (const peer of peers) {
const dx = current[0] - peer[0];
const dz = current[2] - peer[2];
const distance = Math.hypot(dx, dz);
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
const weight = (safeSeparation - distance) / safeSeparation;
if (distance <= EPSILON) {
offsetX += Math.sin(fallbackAngle) * weight;
offsetZ += Math.cos(fallbackAngle) * weight;
} else {
offsetX += dx / distance * weight;
offsetZ += dz / distance * weight;
}
}
const length = Math.hypot(offsetX, offsetZ);
out[0] = desired[0];
out[1] = desired[1];
out[2] = desired[2];
if (length <= EPSILON) return false;
const correction = Math.min(Math.max(0, maximumCorrection), length);
out[0] += offsetX / length * correction;
out[2] += offsetZ / length * correction;
return true;
}
/** /**
* Chooses an individual combat destination. Melee only closes distance; * Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band. * ranged attackers also back out when a target collapses their preferred band.
@@ -524,7 +486,6 @@ export function partyPersonalizedCombatMovementGoal(
targetVisible: boolean, targetVisible: boolean,
rangeBand: PartyCombatRangeLike, rangeBand: PartyCombatRangeLike,
preferredBearing: number, preferredBearing: number,
forceReposition: boolean,
out: MutablePartyWorldPosition, out: MutablePartyWorldPosition,
): PartyCombatMovementIntent { ): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal( const intent = partyCombatMovementGoal(
@@ -538,10 +499,10 @@ export function partyPersonalizedCombatMovementGoal(
Math.sin(preferredBearing), Math.sin(preferredBearing),
Math.cos(preferredBearing), Math.cos(preferredBearing),
); );
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent; if (!targetVisible || intent === "hold") return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred; out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1]; out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred; out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent === "hold" ? "reposition" : intent; return intent;
} }
+4 -3
View File
@@ -19,6 +19,7 @@ import { derivePartySpawnSlots } from "./partySpawnSlots";
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision"; import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal"; import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement"; import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision";
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb"; const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 }); const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
@@ -302,7 +303,7 @@ describe("Stormwind Stockade party navigation", () => {
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation( const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
player[0], player[1] + 0.84, player[2], player[0], player[1] + 0.84, player[2],
)); ));
world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body); world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
world.step(); world.step();
expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true); expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true); expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true);
@@ -321,7 +322,7 @@ describe("Stormwind Stockade party navigation", () => {
const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation( const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT, ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT,
)); ));
const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body); const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body);
world.step(); world.step();
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET)); const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
const progress = () => { const progress = () => {
@@ -338,7 +339,7 @@ describe("Stormwind Stockade party navigation", () => {
x: dx / length * direction * 4.8 / 60, x: dx / length * direction * 4.8 / 60,
y: -0.5 / 60, y: -0.5 / 60,
z: dz / length * direction * 4.8 / 60, z: dz / length * direction * 4.8 / 60,
}, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC); }, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
const position = body.translation(); const position = body.translation();
const move = controller.computedMovement(); const move = controller.computedMovement();
body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z }); body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z });
+5 -36
View File
@@ -38,7 +38,6 @@ import {
partyNaturalFollowPosition, partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal, partyPersonalizedCombatMovementGoal,
partyPresentationY, partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck, partyShouldRecallForStuck,
partyShouldMoveToComfortTarget, partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand, personalizedPartyCombatRangeBand,
@@ -84,12 +83,12 @@ import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro"; import { PLAYER_AGGRO_ID } from "../game/aggro";
import { hasDungeonLineOfSight } from "./dungeonLineOfSight"; import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import { import {
createPartyDirectSegmentValidator, createPartyDirectSegmentValidator,
createPartyRouteSegmentValidator, createPartyRouteSegmentValidator,
PARTY_FLOOR_RAY_DEPTH, PARTY_FLOOR_RAY_DEPTH,
PARTY_FLOOR_RAY_HEIGHT, PARTY_FLOOR_RAY_HEIGHT,
partyTerrainQueryFlags,
} from "./partyTraversal"; } from "./partyTraversal";
const PARTY_MOVE_SPEED = 5.25; const PARTY_MOVE_SPEED = 5.25;
@@ -300,7 +299,8 @@ function PartyActors({
ray, ray,
PARTY_FLOOR_RAY_DEPTH, PARTY_FLOOR_RAY_DEPTH,
true, true,
partyTerrainQueryFlags(rapier), rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
const floorY = floor ? ray.origin.y - floor.timeOfImpact : null; const floorY = floor ? ray.origin.y - floor.timeOfImpact : null;
return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION); return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION);
@@ -394,7 +394,8 @@ function PartyActors({
ray, ray,
20, 20,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null; return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null;
}, },
@@ -636,11 +637,9 @@ function PartyActors({
const nextPosition: MutablePartyWorldPosition = [0, 0, 0]; const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0]; const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0]; const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
const livingIndices = party.members const livingIndices = party.members
.map((member, index) => member.health > 0 ? index : -1) .map((member, index) => member.health > 0 ? index : -1)
.filter((index) => index >= 0); .filter((index) => index >= 0);
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => { const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex)); const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
const key = `${memberId}|${livingRank}`; const key = `${memberId}|${livingRank}`;
@@ -651,13 +650,6 @@ function PartyActors({
} }
return profile; return profile;
}; };
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
const peers: PartyWorldPosition[] = [];
for (const [id, position] of positionsRef.current) {
if (id !== memberId && livingIds.has(id)) peers.push(position);
}
return peers;
};
let leaderHasWaypoint = false; let leaderHasWaypoint = false;
let leaderMovementExpected = false; let leaderMovementExpected = false;
let leaderFollowingPlayer = false; let leaderFollowingPlayer = false;
@@ -712,15 +704,12 @@ function PartyActors({
}; };
combatAnchorsRef.current.set(leader.id, combatAnchor); combatAnchorsRef.current.set(leader.id, combatAnchor);
} }
const peers = peerPositionsFor(leader.id);
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal( const movementIntent = partyPersonalizedCombatMovementGoal(
leaderPosition, leaderPosition,
targetPosition, targetPosition,
hasStaticLineOfSight(leaderPosition, targetPosition), hasStaticLineOfSight(leaderPosition, targetPosition),
rangeBand, rangeBand,
combatAnchor.bearing, combatAnchor.bearing,
crowded,
leaderDestination, leaderDestination,
); );
if (movementIntent === "hold") { if (movementIntent === "hold") {
@@ -1039,15 +1028,12 @@ function PartyActors({
}; };
combatAnchorsRef.current.set(member.id, combatAnchor); combatAnchorsRef.current.set(member.id, combatAnchor);
} }
const peers = peerPositionsFor(member.id);
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal( const movementIntent = partyPersonalizedCombatMovementGoal(
current, current,
memberTargetPosition, memberTargetPosition,
canSeeActiveTarget, canSeeActiveTarget,
rangeBand, rangeBand,
combatAnchor.bearing, combatAnchor.bearing,
crowded,
desired, desired,
); );
movementExpected = movementIntent !== "hold"; movementExpected = movementIntent !== "hold";
@@ -1245,23 +1231,6 @@ function PartyActors({
} }
const movementProfile = movementProfileFor(member.id, index); const movementProfile = movementProfileFor(member.id, index);
if (hasDesired) {
const peers = peerPositionsFor(member.id);
if (
partySeparationGoal(
current,
desired,
peers,
movementProfile.swayPhase,
separatedDesired,
)
&& directSegmentAllowed(current, separatedDesired)
) {
desired[0] = separatedDesired[0];
desired[1] = separatedDesired[1];
desired[2] = separatedDesired[2];
}
}
const currentPlanarDistance = planarDistance(current, playerNavigation); const currentPlanarDistance = planarDistance(current, playerNavigation);
if (member.health > 0 && party.command !== "stop" && hasDesired) { if (member.health > 0 && party.command !== "stop" && hasDesired) {
+13 -7
View File
@@ -60,6 +60,7 @@ import {
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
const PLAYER_RADIUS = 0.36; const PLAYER_RADIUS = 0.36;
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48; const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
@@ -212,8 +213,8 @@ function PlayerController({
groundRay, groundRay,
groundProbeLength, groundProbeLength,
true, true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
undefined, ACTOR_TERRAIN_QUERY_GROUPS,
undefined, undefined,
body, body,
); );
@@ -267,7 +268,8 @@ function PlayerController({
y: verticalVelocity * movementDeltaSeconds, y: verticalVelocity * movementDeltaSeconds,
z: worldZ * MOVE_SPEED * movementDeltaSeconds, z: worldZ * MOVE_SPEED * movementDeltaSeconds,
}, },
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
const computedMovement = characterController.computedMovement(); const computedMovement = characterController.computedMovement();
const nextTranslation = { const nextTranslation = {
@@ -347,7 +349,11 @@ function PlayerController({
spawn.footPosition[2], spawn.footPosition[2],
]} ]}
> >
<CapsuleCollider args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]} friction={0} /> <CapsuleCollider
args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]}
friction={0}
collisionGroups={ACTOR_COLLISION_GROUPS}
/>
<group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}> <group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}>
<CharacterModel <CharacterModel
identity={identity} identity={identity}
@@ -408,7 +414,7 @@ function ThirdPersonCamera({
// Cast a small sphere instead of a single ray. This behaves like a camera // Cast a small sphere instead of a single ray. This behaves like a camera
// spring arm and keeps the whole near-camera volume out of walls, pillars, // spring arm and keeps the whole near-camera volume out of walls, pillars,
// and low ceilings. The player's own capsule is explicitly ignored. // and low ceilings. All actor capsules are ignored, including the player.
const hit = world.castShape( const hit = world.castShape(
target, target,
shapeRotation, shapeRotation,
@@ -417,8 +423,8 @@ function ThirdPersonCamera({
CAMERA_WALL_GAP, CAMERA_WALL_GAP,
fullDistance, fullDistance,
false, false,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, rapier.QueryFilterFlags.EXCLUDE_SENSORS,
undefined, ACTOR_TERRAIN_QUERY_GROUPS,
undefined, undefined,
body, body,
); );
+114
View File
@@ -0,0 +1,114 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "../game/playerMovement";
import { isWalkableGroundHit } from "../game/playerJump";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
const BODY_TYPES = ["fixed", "kinematic", "dynamic"] as const;
type BodyType = typeof BODY_TYPES[number];
beforeAll(async () => {
await RAPIER.init();
});
function actor(world: RAPIER.World, bodyType: BodyType, z = 0, groups = ACTOR_COLLISION_GROUPS) {
const description = bodyType === "fixed"
? RAPIER.RigidBodyDesc.fixed()
: bodyType === "kinematic"
? RAPIER.RigidBodyDesc.kinematicPositionBased()
: RAPIER.RigidBodyDesc.dynamic();
const body = world.createRigidBody(description.setTranslation(0, 0.84, z));
const collider = world.createCollider(
RAPIER.ColliderDesc.capsule(0.48, 0.36)
.setCollisionGroups(groups)
.setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL),
body,
);
return { body, collider };
}
function terrain(world: RAPIER.World, wall = false) {
const floor = world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 8)
.setTranslation(0, -0.1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
if (wall) world.createCollider(RAPIER.ColliderDesc.cuboid(5, 2, 0.1)
.setTranslation(0, 2, 2.5).setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS));
return floor;
}
describe("non-blocking combat actors", () => {
it.each(BODY_TYPES)("has no physical contacts with overlapping %s actors", (bodyType) => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const player = actor(world, "dynamic");
// Party members, mobs, bosses, and remote players use the same actor policy.
const others = ["party", "mob", "boss", "remote-player"].map(() => actor(world, bodyType));
world.step();
for (const other of others) {
world.contactPair(player.collider, other.collider, () => {
throw new Error("Actors must not produce physical contacts");
});
expect(other.body.translation().z).toBeCloseTo(0);
}
expect(player.body.translation().z).toBeCloseTo(0);
world.free();
});
it("detects contacts when an actor mistakenly uses the terrain layer", () => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const player = actor(world, "dynamic");
const obstacle = actor(world, "fixed", 0.4, DUNGEON_PHYSICAL_COLLISION_GROUPS);
world.step();
let contacts = 0;
world.contactPair(player.collider, obstacle.collider, (manifold) => { contacts += manifold.numContacts(); });
expect(contacts).toBeGreaterThan(0);
world.free();
});
it.each(BODY_TYPES)("walks through %s actors but still stops at a dungeon wall", (bodyType) => {
for (const hasWall of [false, true]) {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
terrain(world, hasWall);
actor(world, bodyType);
actor(world, bodyType, -2); // Start overlapped: no depenetration or detour.
const player = actor(world, "kinematic", -2);
const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET));
world.step();
for (let frame = 0; frame < 90; frame += 1) {
controller.computeColliderMovement(player.collider, { x: 0, y: -0.5 / 60, z: 4.8 / 60 },
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
const position = player.body.translation();
const movement = controller.computedMovement();
player.body.setNextKinematicTranslation({ x: position.x + movement.x, y: position.y + movement.y, z: position.z + movement.z });
world.step();
}
const position = player.body.translation();
if (hasWall) {
expect(position.z).toBeGreaterThan(1.8);
expect(position.z).toBeLessThan(2.1);
} else expect(position.z).toBeGreaterThan(5);
expect(position.x).toBeCloseTo(0);
expect(position.y).toBeCloseTo(0.84, 1);
world.removeCharacterController(controller);
world.free();
}
});
it.each(BODY_TYPES)("never treats a %s actor as ground, a route obstacle, or a camera wall", (bodyType) => {
const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
const floor = terrain(world);
actor(world, bodyType);
world.step();
const ground = world.castRayAndGetNormal(new RAPIER.Ray({ x: 0, y: 4, z: 0 }, { x: 0, y: -1, z: 0 }),
5, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS);
expect(ground?.collider.handle).toBe(floor.handle);
expect(ground?.timeOfImpact).toBeCloseTo(4);
expect(isWalkableGroundHit(ground, 5)).toBe(true);
expect(createPartyDirectSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
expect(createPartyRouteSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true);
expect(world.castShape({ x: 0, y: 0.9, z: -1.5 }, { x: 0, y: 0, z: 0, w: 1 },
{ x: 0, y: 0, z: 1 }, new RAPIER.Ball(0.24), 0.08, 3, false,
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS)).toBeNull();
world.free();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { interactionGroups } from "@react-three/rapier";
export const TERRAIN_COLLISION_GROUP = 0;
export const ACTOR_COLLISION_GROUP = 1;
// Players, companions, mobs, and bosses never block or push one another.
// Any actor capsule added in future must use this layer, not the terrain layer.
export const ACTOR_COLLISION_GROUPS = interactionGroups(
ACTOR_COLLISION_GROUP,
TERRAIN_COLLISION_GROUP,
);
// Movement, ground probes, and camera sweeps see only physical terrain,
// regardless of whether an actor's body is fixed, kinematic, or dynamic.
export const ACTOR_TERRAIN_QUERY_GROUPS = ACTOR_COLLISION_GROUPS;
+3 -2
View File
@@ -1,4 +1,5 @@
import { interactionGroups } from "@react-three/rapier"; import { interactionGroups } from "@react-three/rapier";
import { TERRAIN_COLLISION_GROUP } from "./actorCollision";
import type { import type {
QueryFilterFlags, QueryFilterFlags,
Ray, Ray,
@@ -17,9 +18,9 @@ interface RapierApi {
type WorldPosition = readonly [number, number, number]; type WorldPosition = readonly [number, number, number];
export const DUNGEON_LINE_OF_SIGHT_GROUP = 15; export const DUNGEON_LINE_OF_SIGHT_GROUP = 15;
export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0); export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(TERRAIN_COLLISION_GROUP);
export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP); export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP);
export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([0, DUNGEON_LINE_OF_SIGHT_GROUP]); export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([TERRAIN_COLLISION_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP]);
export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups( export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups(
DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP,
DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP,
+6 -12
View File
@@ -1,6 +1,7 @@
import type RAPIER from "@dimforge/rapier3d-compat"; import type RAPIER from "@dimforge/rapier3d-compat";
import type { World } from "@dimforge/rapier3d-compat"; import type { World } from "@dimforge/rapier3d-compat";
import type { PartyWorldPosition } from "../game/partyRuntimeRegistry"; import type { PartyWorldPosition } from "../game/partyRuntimeRegistry";
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
const COLLISION_RADIUS = 0.34; const COLLISION_RADIUS = 0.34;
const COLLISION_HALF_HEIGHT = 0.48; const COLLISION_HALF_HEIGHT = 0.48;
@@ -11,14 +12,6 @@ export const PARTY_FLOOR_RAY_DEPTH = 2;
const FLOOR_SAMPLE_SPACING = 0.6; const FLOOR_SAMPLE_SPACING = 0.6;
const MAX_FLOOR_DELTA = 0.55; const MAX_FLOOR_DELTA = 0.55;
export function partyTerrainQueryFlags(rapier: typeof RAPIER) {
// Actor capsules are not terrain. Crowd spacing is handled separately by
// party separation; counting the player here blocks recalls and replans.
return rapier.QueryFilterFlags.EXCLUDE_SENSORS
| rapier.QueryFilterFlags.EXCLUDE_DYNAMIC
| rapier.QueryFilterFlags.EXCLUDE_KINEMATIC;
}
/** Shared by live party routing and collision-backed traversal regressions. */ /** Shared by live party routing and collision-backed traversal regressions. */
export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) { export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) {
const capsule = new rapier.Capsule( const capsule = new rapier.Capsule(
@@ -26,7 +19,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
COLLISION_RADIUS, COLLISION_RADIUS,
); );
const rotation = { x: 0, y: 0, z: 0, w: 1 }; const rotation = { x: 0, y: 0, z: 0, w: 1 };
const flags = partyTerrainQueryFlags(rapier); const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => { return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
const dx = end[0] - start[0]; const dx = end[0] - start[0];
const dy = end[1] - start[1]; const dy = end[1] - start[1];
@@ -43,6 +36,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
distance, distance,
false, false,
flags, flags,
ACTOR_TERRAIN_QUERY_GROUPS,
); );
if (obstacle && obstacle.time_of_impact < distance - 0.04) return false; if (obstacle && obstacle.time_of_impact < distance - 0.04) return false;
const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING)); const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING));
@@ -54,7 +48,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
y: expectedY + PARTY_FLOOR_RAY_HEIGHT, y: expectedY + PARTY_FLOOR_RAY_HEIGHT,
z: start[2] + dz * progress, z: start[2] + dz * progress,
}, { x: 0, y: -1, z: 0 }); }, { x: 0, y: -1, z: 0 });
const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags); const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false; if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false;
} }
return true; return true;
@@ -63,7 +57,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world:
/** Triangle corridors need wall/overhang validation, not a second radius erosion. */ /** Triangle corridors need wall/overhang validation, not a second radius erosion. */
export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) { export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) {
const flags = partyTerrainQueryFlags(rapier); const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS;
return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => { return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => {
const dx = end[0] - start[0]; const dx = end[0] - start[0];
const dy = end[1] - start[1]; const dy = end[1] - start[1];
@@ -74,7 +68,7 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W
const ray = new rapier.Ray({ const ray = new rapier.Ray({
x: start[0], y: start[1] + 0.9, z: start[2], x: start[0], y: start[1] + 0.9, z: start[2],
}, { x: dx / distance, y: dy / distance, z: dz / distance }); }, { x: dx / distance, y: dy / distance, z: dz / distance });
const hit = world.castRay(ray, distance, true, flags); const hit = world.castRay(ray, distance, true, flags, ACTOR_TERRAIN_QUERY_GROUPS);
return !hit || hit.timeOfImpact >= distance - 0.04; return !hit || hit.timeOfImpact >= distance - 0.04;
}; };
} }