forked from phenom/healer-man
Release Healer Man 0.1.23
This commit is contained in:
1 parent
7f6001723a
commit
c4346751a9
9 files changed
+205
-23
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.23",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.22",
|
||||
"versionCode": 1022
|
||||
"versionName": "0.1.23",
|
||||
"versionCode": 1023
|
||||
}
|
||||
+52
-15
@@ -43,6 +43,7 @@ import {
|
||||
registerMobRuntime,
|
||||
updateMobRuntimePosition,
|
||||
} from "../game/mobRuntimeRegistry";
|
||||
import { combatHasLineOfSight } from "../game/combatSpatial";
|
||||
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
|
||||
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
|
||||
import { stepFearedMobAway } from "../game/mobMotion";
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
} from "./mobAnimationCadence";
|
||||
import { useGameGLTF } from "./useGameGLTF";
|
||||
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
|
||||
|
||||
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
|
||||
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
|
||||
@@ -119,6 +121,7 @@ function mobTargetPosition(
|
||||
return getPartyRuntimePosition(targetActorId) ?? playerPosition;
|
||||
}
|
||||
const PROXIMITY_CHECK_INTERVAL = 0.3;
|
||||
const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25;
|
||||
let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>();
|
||||
let preparedCreatureTemplateRoots = new Set<Object3D>();
|
||||
registerDungeonRuntimeReset(() => {
|
||||
@@ -812,6 +815,54 @@ function usePopulationProximity(points: readonly MobVector3[]): boolean {
|
||||
return nearby;
|
||||
}
|
||||
|
||||
function MobAggroRangeRing({
|
||||
instanceId,
|
||||
aggroRange,
|
||||
boss,
|
||||
}: {
|
||||
readonly instanceId: string;
|
||||
readonly aggroRange: number;
|
||||
readonly boss: boolean;
|
||||
}) {
|
||||
const visibleRef = useRef(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const elapsedRef = useRef(AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
elapsedRef.current += delta;
|
||||
if (elapsedRef.current < AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL) return;
|
||||
elapsedRef.current = 0;
|
||||
const playerPosition = useGameStore.getState().playerPosition;
|
||||
const mobPosition = getMobRuntimeTransform(instanceId)?.position;
|
||||
const nextVisible = Boolean(mobPosition && isMobAggroRangeVisible(
|
||||
playerPosition,
|
||||
mobPosition,
|
||||
combatHasLineOfSight,
|
||||
));
|
||||
if (nextVisible === visibleRef.current) return;
|
||||
visibleRef.current = nextVisible;
|
||||
setVisible(nextVisible);
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh
|
||||
name={`mob-aggro-range-${instanceId}`}
|
||||
position={[0, 0.045, 0]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
renderOrder={2}
|
||||
visible={visible}
|
||||
>
|
||||
<torusGeometry args={[aggroRange, boss ? 0.075 : 0.05, 4, 56]} />
|
||||
<meshBasicMaterial
|
||||
color={boss ? "#e59a55" : "#c96b55"}
|
||||
transparent
|
||||
opacity={boss ? 0.62 : 0.5}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyMob({
|
||||
definition,
|
||||
instanceId,
|
||||
@@ -960,21 +1011,7 @@ function ProxyMob({
|
||||
</AnimatedMobBody>
|
||||
|
||||
{showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? (
|
||||
<mesh
|
||||
name={`mob-aggro-range-${instanceId}`}
|
||||
position={[0, 0.045, 0]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
renderOrder={2}
|
||||
>
|
||||
<torusGeometry args={[mob.aggroRange, boss ? 0.075 : 0.05, 4, 56]} />
|
||||
<meshBasicMaterial
|
||||
color={boss ? "#e59a55" : "#c96b55"}
|
||||
transparent
|
||||
opacity={boss ? 0.62 : 0.5}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
<MobAggroRangeRing instanceId={instanceId} aggroRange={mob.aggroRange} boss={boss} />
|
||||
) : null}
|
||||
|
||||
{boss && !dead ? (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef } from "react";
|
||||
import type { Group } from "three";
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
useWailingEncounterStore,
|
||||
wailingDisciplePosition,
|
||||
} from "../game/wailingCavernsEncounter";
|
||||
import { WorldNpcPrompt } from "./WorldNpcPrompt";
|
||||
|
||||
export function WailingNaralexEvent() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -38,7 +38,7 @@ export function WailingNaralexEvent() {
|
||||
<sphereGeometry args={[0.3, 12, 8]} />
|
||||
<meshStandardMaterial color="#b9d67a" roughness={0.7} />
|
||||
</mesh>
|
||||
<Html center position={[0, 2.45, 0]} zIndexRange={[8, 0]}>
|
||||
<WorldNpcPrompt position={[0, 2.45, 0]}>
|
||||
<div className="naralex-event-prompt">
|
||||
<strong>Disciple of Naralex</strong>
|
||||
{phase === "locked" ? <small>Defeat the four Fanglords</small> : null}
|
||||
@@ -56,7 +56,7 @@ export function WailingNaralexEvent() {
|
||||
{phase === "cleansing" ? <small>Channeling Serpentine Cleansing…</small> : null}
|
||||
{phase === "complete" ? <small>Naralex has awakened</small> : null}
|
||||
</div>
|
||||
</Html>
|
||||
</WorldNpcPrompt>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WORLD_NPC_PROMPT_MAX_DISTANCE,
|
||||
worldNpcPromptIsInRange,
|
||||
} from "./WorldNpcPrompt";
|
||||
|
||||
describe("world NPC prompts", () => {
|
||||
it("limits prompts to nearby players", () => {
|
||||
expect(worldNpcPromptIsInRange([0, 0, 0], [0, 0, WORLD_NPC_PROMPT_MAX_DISTANCE])).toBe(true);
|
||||
expect(worldNpcPromptIsInRange([0, 0, 0], [0, 0, WORLD_NPC_PROMPT_MAX_DISTANCE + 0.01])).toBe(false);
|
||||
});
|
||||
|
||||
it("uses geometry occlusion and is the Naralex prompt path", () => {
|
||||
const promptSource = readFileSync(new URL("./WorldNpcPrompt.tsx", import.meta.url), "utf8");
|
||||
const naralexSource = readFileSync(new URL("./WailingNaralexEvent.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(promptSource).toContain('occlude="raycast"');
|
||||
expect(naralexSource).toContain("<WorldNpcPrompt");
|
||||
expect(naralexSource).not.toContain("<Html");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { Group, Vector3 } from "three";
|
||||
import type { MobVector3 } from "../game/mobPopulation";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export const WORLD_NPC_PROMPT_MAX_DISTANCE = 22;
|
||||
|
||||
const VISIBILITY_CHECK_INTERVAL_SECONDS = 0.1;
|
||||
const VISIBILITY_HYSTERESIS = 2;
|
||||
|
||||
export function worldNpcPromptIsInRange(
|
||||
viewerPosition: MobVector3,
|
||||
promptPosition: MobVector3,
|
||||
maxDistance = WORLD_NPC_PROMPT_MAX_DISTANCE,
|
||||
): boolean {
|
||||
if (!Number.isFinite(maxDistance) || maxDistance < 0) return false;
|
||||
const dx = viewerPosition[0] - promptPosition[0];
|
||||
const dy = viewerPosition[1] - promptPosition[1];
|
||||
const dz = viewerPosition[2] - promptPosition[2];
|
||||
return dx * dx + dy * dy + dz * dz <= maxDistance * maxDistance;
|
||||
}
|
||||
|
||||
export function WorldNpcPrompt({
|
||||
children,
|
||||
position = [0, 0, 0],
|
||||
maxDistance = WORLD_NPC_PROMPT_MAX_DISTANCE,
|
||||
zIndexRange = [8, 0],
|
||||
}: {
|
||||
readonly children: ReactNode;
|
||||
readonly position?: MobVector3;
|
||||
readonly maxDistance?: number;
|
||||
readonly zIndexRange?: readonly [number, number];
|
||||
}) {
|
||||
const anchorRef = useRef<Group>(null);
|
||||
const worldPositionRef = useRef(new Vector3());
|
||||
const elapsedRef = useRef(VISIBILITY_CHECK_INTERVAL_SECONDS);
|
||||
const visibleRef = useRef(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
elapsedRef.current += delta;
|
||||
if (elapsedRef.current < VISIBILITY_CHECK_INTERVAL_SECONDS) return;
|
||||
elapsedRef.current = 0;
|
||||
if (!anchorRef.current) return;
|
||||
|
||||
anchorRef.current.getWorldPosition(worldPositionRef.current);
|
||||
const worldPosition = worldPositionRef.current;
|
||||
const distanceLimit = maxDistance + (visibleRef.current ? VISIBILITY_HYSTERESIS : 0);
|
||||
const nextVisible = worldNpcPromptIsInRange(
|
||||
useGameStore.getState().playerPosition,
|
||||
[worldPosition.x, worldPosition.y, worldPosition.z],
|
||||
distanceLimit,
|
||||
);
|
||||
if (nextVisible === visibleRef.current) return;
|
||||
visibleRef.current = nextVisible;
|
||||
setVisible(nextVisible);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={anchorRef} position={[position[0], position[1], position[2]]}>
|
||||
{visible ? (
|
||||
<Html center occlude="raycast" zIndexRange={[zIndexRange[0], zIndexRange[1]]}>
|
||||
{children}
|
||||
</Html>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isMobAggroRangeVisible,
|
||||
MOB_AGGRO_RANGE_VISIBILITY_DISTANCE,
|
||||
} from "./mobAggroRangeVisibility";
|
||||
|
||||
describe("mob aggro range visibility", () => {
|
||||
it("shows an in-range mob only when the player has line of sight", () => {
|
||||
const visible = vi.fn(() => true);
|
||||
const blocked = vi.fn(() => false);
|
||||
|
||||
expect(isMobAggroRangeVisible([0, 0, 0], [20, 0, 0], visible)).toBe(true);
|
||||
expect(isMobAggroRangeVisible([0, 0, 0], [20, 0, 0], blocked)).toBe(false);
|
||||
expect(visible).toHaveBeenCalledWith([0, 0, 0], [20, 0, 0]);
|
||||
expect(blocked).toHaveBeenCalledWith([0, 0, 0], [20, 0, 0]);
|
||||
});
|
||||
|
||||
it("hides distant mobs without performing a line-of-sight query", () => {
|
||||
const hasLineOfSight = vi.fn(() => true);
|
||||
|
||||
expect(isMobAggroRangeVisible(
|
||||
[0, 0, 0],
|
||||
[MOB_AGGRO_RANGE_VISIBILITY_DISTANCE + 0.01, 0, 0],
|
||||
hasLineOfSight,
|
||||
)).toBe(false);
|
||||
expect(hasLineOfSight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes mobs exactly on the visibility boundary", () => {
|
||||
expect(isMobAggroRangeVisible(
|
||||
[0, 0, 0],
|
||||
[0, 0, MOB_AGGRO_RANGE_VISIBILITY_DISTANCE],
|
||||
() => true,
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MobVector3 } from "../game/mobPopulation";
|
||||
|
||||
export const MOB_AGGRO_RANGE_VISIBILITY_DISTANCE = 40;
|
||||
|
||||
export function isMobAggroRangeVisible(
|
||||
playerPosition: MobVector3,
|
||||
mobPosition: MobVector3,
|
||||
hasLineOfSight: (from: MobVector3, to: MobVector3) => boolean,
|
||||
maximumDistance = MOB_AGGRO_RANGE_VISIBILITY_DISTANCE,
|
||||
): boolean {
|
||||
if (!Number.isFinite(maximumDistance) || maximumDistance < 0) return false;
|
||||
const dx = mobPosition[0] - playerPosition[0];
|
||||
const dy = mobPosition[1] - playerPosition[1];
|
||||
const dz = mobPosition[2] - playerPosition[2];
|
||||
if (dx * dx + dy * dy + dz * dz > maximumDistance * maximumDistance) return false;
|
||||
return hasLineOfSight(playerPosition, mobPosition);
|
||||
}
|
||||
Reference in new issue
Block a user