diff --git a/package-lock.json b/package-lock.json index a21628cf..39e0ed33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b1cf5f41..2c251630 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release-version.json b/release-version.json index 5b7fb97b..434de420 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.22", - "versionCode": 1022 + "versionName": "0.1.23", + "versionCode": 1023 } diff --git a/src/scene/MobPopulation.tsx b/src/scene/MobPopulation.tsx index 30afe6f4..dc041cb2 100644 --- a/src/scene/MobPopulation.tsx +++ b/src/scene/MobPopulation.tsx @@ -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 = 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(); let preparedCreatureTemplateRoots = new Set(); 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 ( + + + + + ); +} + function ProxyMob({ definition, instanceId, @@ -960,21 +1011,7 @@ function ProxyMob({ {showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? ( - - - - + ) : null} {boss && !dead ? ( diff --git a/src/scene/WailingNaralexEvent.tsx b/src/scene/WailingNaralexEvent.tsx index 71c2aa17..37b10065 100644 --- a/src/scene/WailingNaralexEvent.tsx +++ b/src/scene/WailingNaralexEvent.tsx @@ -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() { - +
Disciple of Naralex {phase === "locked" ? Defeat the four Fanglords : null} @@ -56,7 +56,7 @@ export function WailingNaralexEvent() { {phase === "cleansing" ? Channeling Serpentine Cleansing… : null} {phase === "complete" ? Naralex has awakened : null}
- +
); } diff --git a/src/scene/WorldNpcPrompt.test.ts b/src/scene/WorldNpcPrompt.test.ts new file mode 100644 index 00000000..fcc6e315 --- /dev/null +++ b/src/scene/WorldNpcPrompt.test.ts @@ -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("(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 ( + + {visible ? ( + + {children} + + ) : null} + + ); +} diff --git a/src/scene/mobAggroRangeVisibility.test.ts b/src/scene/mobAggroRangeVisibility.test.ts new file mode 100644 index 00000000..8b1f2e52 --- /dev/null +++ b/src/scene/mobAggroRangeVisibility.test.ts @@ -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); + }); +}); diff --git a/src/scene/mobAggroRangeVisibility.ts b/src/scene/mobAggroRangeVisibility.ts new file mode 100644 index 00000000..d69b2de3 --- /dev/null +++ b/src/scene/mobAggroRangeVisibility.ts @@ -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); +}