Release Healer Man 0.1.35
This commit is contained in:
1 parent
46c272f889
commit
40d9f346d8
13 files changed
+218
-118
No files matched your search
@@ -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.
|
||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.34",
|
"versionName": "0.1.35",
|
||||||
"versionCode": 1034
|
"versionCode": 1035
|
||||||
}
|
}
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user