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",
|
"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
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.22",
|
"versionName": "0.1.23",
|
||||||
"versionCode": 1022
|
"versionCode": 1023
|
||||||
}
|
}
|
||||||
+52
-15
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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