Release Healer Man 0.1.23

This commit is contained in:
phenom committed 2026-09-09 14:26:13 -04:00
1 parent 7f6001723a
commit c4346751a9
9 files changed
+205 -23

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.22", "version": "0.1.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.22", "version": "0.1.23",
"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.22", "version": "0.1.23",
"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.22", "versionName": "0.1.23",
"versionCode": 1022 "versionCode": 1023
} }
+52 -15
View File
@@ -43,6 +43,7 @@ import {
registerMobRuntime, registerMobRuntime,
updateMobRuntimePosition, updateMobRuntimePosition,
} from "../game/mobRuntimeRegistry"; } from "../game/mobRuntimeRegistry";
import { combatHasLineOfSight } from "../game/combatSpatial";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset"; import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling"; import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
import { stepFearedMobAway } from "../game/mobMotion"; import { stepFearedMobAway } from "../game/mobMotion";
@@ -76,6 +77,7 @@ import {
} from "./mobAnimationCadence"; } from "./mobAnimationCadence";
import { useGameGLTF } from "./useGameGLTF"; import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime"; import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
import { isMobAggroRangeVisible } from "./mobAggroRangeVisibility";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([ const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse", "stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -119,6 +121,7 @@ function mobTargetPosition(
return getPartyRuntimePosition(targetActorId) ?? playerPosition; return getPartyRuntimePosition(targetActorId) ?? playerPosition;
} }
const PROXIMITY_CHECK_INTERVAL = 0.3; const PROXIMITY_CHECK_INTERVAL = 0.3;
const AGGRO_RANGE_VISIBILITY_CHECK_INTERVAL = 0.25;
let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>(); let preparedCreatureTemplates = new WeakMap<Object3D, Object3D>();
let preparedCreatureTemplateRoots = new Set<Object3D>(); let preparedCreatureTemplateRoots = new Set<Object3D>();
registerDungeonRuntimeReset(() => { registerDungeonRuntimeReset(() => {
@@ -812,6 +815,54 @@ function usePopulationProximity(points: readonly MobVector3[]): boolean {
return nearby; 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({ function ProxyMob({
definition, definition,
instanceId, instanceId,
@@ -960,21 +1011,7 @@ function ProxyMob({
</AnimatedMobBody> </AnimatedMobBody>
{showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? ( {showAggroRanges && gameMode === "dungeon" && mob && !dead && !mob.engaged ? (
<mesh <MobAggroRangeRing instanceId={instanceId} aggroRange={mob.aggroRange} boss={boss} />
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>
) : null} ) : null}
{boss && !dead ? ( {boss && !dead ? (
+3 -3
View File
@@ -1,4 +1,3 @@
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { useRef } from "react"; import { useRef } from "react";
import type { Group } from "three"; import type { Group } from "three";
@@ -7,6 +6,7 @@ import {
useWailingEncounterStore, useWailingEncounterStore,
wailingDisciplePosition, wailingDisciplePosition,
} from "../game/wailingCavernsEncounter"; } from "../game/wailingCavernsEncounter";
import { WorldNpcPrompt } from "./WorldNpcPrompt";
export function WailingNaralexEvent() { export function WailingNaralexEvent() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -38,7 +38,7 @@ export function WailingNaralexEvent() {
<sphereGeometry args={[0.3, 12, 8]} /> <sphereGeometry args={[0.3, 12, 8]} />
<meshStandardMaterial color="#b9d67a" roughness={0.7} /> <meshStandardMaterial color="#b9d67a" roughness={0.7} />
</mesh> </mesh>
<Html center position={[0, 2.45, 0]} zIndexRange={[8, 0]}> <WorldNpcPrompt position={[0, 2.45, 0]}>
<div className="naralex-event-prompt"> <div className="naralex-event-prompt">
<strong>Disciple of Naralex</strong> <strong>Disciple of Naralex</strong>
{phase === "locked" ? <small>Defeat the four Fanglords</small> : null} {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 === "cleansing" ? <small>Channeling Serpentine Cleansing…</small> : null}
{phase === "complete" ? <small>Naralex has awakened</small> : null} {phase === "complete" ? <small>Naralex has awakened</small> : null}
</div> </div>
</Html> </WorldNpcPrompt>
</group> </group>
); );
} }
+22
View File
@@ -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");
});
});
+70
View File
@@ -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>
);
}
+36
View File
@@ -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);
});
});
+17
View File
@@ -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);
}