From eaddad413486a4396039ba2f7d84e39a642b3707 Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 5 Sep 2026 22:15:48 -0400 Subject: [PATCH] Release Healer Man 0.1.18 --- package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/content/prepare-android-bootstrap.mjs | 4 + scripts/content/prepare-web-bootstrap.mjs | 4 + scripts/content/publish-content.mjs | 3 + scripts/content/publish-content.test.mjs | 2 + src/ArenaRuntime.tsx | 74 ++++++++++++------- src/content/contentManager.ts | 2 + src/game/arenaCamera.test.ts | 19 +++++ src/game/arenaCamera.ts | 16 ++++ src/scene/useGameGLTF.ts | 15 ++-- 12 files changed, 107 insertions(+), 42 deletions(-) create mode 100644 src/game/arenaCamera.test.ts create mode 100644 src/game/arenaCamera.ts diff --git a/package-lock.json b/package-lock.json index 2f04effc..44d109d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.17", + "version": "0.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.17", + "version": "0.1.18", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 1d567a6a..40fde697 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.17", + "version": "0.1.18", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 54d3ea0c..c9c0c1a7 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.17", - "versionCode": 1017 + "versionName": "0.1.18", + "versionCode": 1018 } diff --git a/scripts/content/prepare-android-bootstrap.mjs b/scripts/content/prepare-android-bootstrap.mjs index 1b1262a6..c9fb1c38 100644 --- a/scripts/content/prepare-android-bootstrap.mjs +++ b/scripts/content/prepare-android-bootstrap.mjs @@ -32,6 +32,10 @@ const sources = [ directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), logicalPrefix: "assets/creatures/wailing-caverns", }, + { + directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"), + logicalPrefix: "assets/game/arenas", + }, { directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"), logicalPrefix: "assets/game/dungeons/wailing-caverns", diff --git a/scripts/content/prepare-web-bootstrap.mjs b/scripts/content/prepare-web-bootstrap.mjs index a1dfbf5d..aad5d347 100644 --- a/scripts/content/prepare-web-bootstrap.mjs +++ b/scripts/content/prepare-web-bootstrap.mjs @@ -33,6 +33,10 @@ const sources = [ directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), logicalPrefix: "assets/creatures/wailing-caverns", }, + { + directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"), + logicalPrefix: "assets/game/arenas", + }, { directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"), logicalPrefix: "assets/game/dungeons/wailing-caverns", diff --git a/scripts/content/publish-content.mjs b/scripts/content/publish-content.mjs index 2de52653..bab9983f 100644 --- a/scripts/content/publish-content.mjs +++ b/scripts/content/publish-content.mjs @@ -20,6 +20,7 @@ export const BUNDLED_ANDROID_PACKS = Object.freeze([ "core:equipment", "core:character-basic", "core:runtime", + "core:arenas", "dungeon:wailing-caverns", ]); @@ -79,6 +80,7 @@ function packLabel(packId) { if (packId === "core:equipment") return "Starter Equipment"; if (packId === "core:character-basic") return "Basic Human Character Model"; if (packId === "core:runtime") return "Game Runtime"; + if (packId === "core:arenas") return "Rated Arenas"; if (packId === "core:combat-media") return "Original Spell Effects & Sounds"; return name; } @@ -105,6 +107,7 @@ export function contentPackForPath(logicalPath) { if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) { return `dungeon:${segments[3]}`; } + if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas"; if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) { return `manastorm:${segments[3]}`; } diff --git a/scripts/content/publish-content.test.mjs b/scripts/content/publish-content.test.mjs index 411e4581..6dc005f0 100644 --- a/scripts/content/publish-content.test.mjs +++ b/scripts/content/publish-content.test.mjs @@ -20,6 +20,8 @@ test("assigns stable player-facing packs from logical asset paths", () => { assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1"); + assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas"); + assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true); assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media"); assert.equal( contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"), diff --git a/src/ArenaRuntime.tsx b/src/ArenaRuntime.tsx index 399749d5..3bff3c47 100644 --- a/src/ArenaRuntime.tsx +++ b/src/ArenaRuntime.tsx @@ -8,6 +8,7 @@ import { ProceduralAvatar } from "./avatar/ProceduralAvatar"; import { classById } from "./app/characterCatalog"; import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment"; import { useArenaStore } from "./game/arenaStore"; +import { arenaCameraPose } from "./game/arenaCamera"; import { arenaStageById } from "./game/arenaStages"; import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes"; import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF"; @@ -36,7 +37,7 @@ function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onRea const visual = stage.assets.find((asset) => asset.id === "visual")?.url; return ( <> - + {visual ? : } ); @@ -55,26 +56,49 @@ function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) { return ; } -function ProceduralStage({ stage }: { stage: ArenaStageManifest }) { +function ProceduralStage({ stage, floorOnly = false }: { stage: ArenaStageManifest; floorOnly?: boolean }) { const width = stage.bounds.max[0] - stage.bounds.min[0]; const depth = stage.bounds.max[2] - stage.bounds.min[2]; const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2; const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2; const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175; + const wallHeight = 2.4; return ( - + - {stage.lineOfSight.map((object) => ( - - {object.shape === "cylinder" - ? - : } - - - ))} + {!floorOnly && ( + <> + + {[ + [centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6], + [centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6], + [stage.bounds.min[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth], + [stage.bounds.max[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth], + ].map(([x, y, z, sizeX, sizeY, sizeZ], index) => ( + + + + + ))} + {stage.teamSpawns.map((spawn, index) => ( + + + + + ))} + {stage.lineOfSight.map((object) => ( + + {object.shape === "cylinder" + ? + : } + + + ))} + + )} ); } @@ -165,23 +189,18 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) { const target = useMemo(() => new Vector3(), []); const desired = useMemo(() => new Vector3(), []); + const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]); useFrame(({ camera }, delta) => { if (!actor) return; - const spawn = stage.teamSpawns[actor.team]; - const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2; - const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2; - const directionX = centerX - spawn[0]; - const directionZ = centerZ - spawn[2]; - const length = Math.max(0.001, Math.hypot(directionX, directionZ)); - target.set(actor.position[0], actor.position[1] + 1.2, actor.position[2]); - desired.set(actor.position[0] - directionX / length * 5, actor.position[1] + 7.5, actor.position[2] - directionZ / length * 5); + target.set(...pose.target); + desired.set(...pose.position); camera.position.lerp(desired, Math.min(1, delta * 5)); camera.lookAt(target); }); return null; } -function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType; movement: RefObject<[number, number]>; onStageReady: () => void; onStageError: (error: unknown) => void }) { +function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) { return ( - } - onError={onStageError} - > - }> - + {detailedAsset ? ( + + }> + + ) : } {actors.map((actor) => )} @@ -331,9 +348,10 @@ export default function ArenaRuntime() { if (!snapshot || !stage) return
HMEntering arenaWaiting for the authoritative match snapshot...
; const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null; + const detailedAsset = stageFailure?.stageId !== stage.id; return (
-
+
{stage.name}{snapshot.bracket} ยท {snapshot.ruleset.toUpperCase()}Dampening {Math.round(snapshot.dampening * 100)}% diff --git a/src/content/contentManager.ts b/src/content/contentManager.ts index cba5fa99..c42ea493 100644 --- a/src/content/contentManager.ts +++ b/src/content/contentManager.ts @@ -16,6 +16,7 @@ const STATIC_ANDROID_BUNDLED_PACKS = new Set([ "core:equipment", "core:character-basic", "core:runtime", + "core:arenas", "dungeon:wailing-caverns", ]); const STATIC_BROWSER_BUNDLED_PACKS = new Set([ @@ -23,6 +24,7 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([ "core:equipment", "core:character-basic", "core:runtime", + "core:arenas", "dungeon:wailing-caverns", ]); diff --git a/src/game/arenaCamera.test.ts b/src/game/arenaCamera.test.ts new file mode 100644 index 00000000..5b8337c3 --- /dev/null +++ b/src/game/arenaCamera.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { arenaCameraPose } from "./arenaCamera"; +import { ARENA_STAGES } from "./arenaStages"; + +describe("arena camera placement", () => { + it("uses a finite overview anchor aimed at the playable center of every arena", () => { + for (const stage of ARENA_STAGES) { + for (const team of [0, 1] as const) { + const { position, target } = arenaCameraPose(stage, team); + expect(position).toEqual(stage.spectatorPositions[team]); + expect(position[0], `${stage.id} team ${team} x`).toBeGreaterThan(stage.bounds.min[0]); + expect(position[0], `${stage.id} team ${team} x`).toBeLessThan(stage.bounds.max[0]); + expect(position[2], `${stage.id} team ${team} z`).toBeGreaterThan(stage.bounds.min[2]); + expect(position[2], `${stage.id} team ${team} z`).toBeLessThan(stage.bounds.max[2]); + expect(target.every(Number.isFinite)).toBe(true); + } + } + }); +}); diff --git a/src/game/arenaCamera.ts b/src/game/arenaCamera.ts new file mode 100644 index 00000000..258f3b1c --- /dev/null +++ b/src/game/arenaCamera.ts @@ -0,0 +1,16 @@ +import type { ArenaStageManifest } from "./arenaTypes"; + +export interface ArenaCameraPose { + readonly position: [number, number, number]; + readonly target: [number, number, number]; +} + +export function arenaCameraPose(stage: ArenaStageManifest, team: 0 | 1): ArenaCameraPose { + const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2; + const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2; + const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])); + return { + position: [...stage.spectatorPositions[team]], + target: [centerX, floorY + 1.2, centerZ], + }; +} diff --git a/src/scene/useGameGLTF.ts b/src/scene/useGameGLTF.ts index e6dbdac2..db6930da 100644 --- a/src/scene/useGameGLTF.ts +++ b/src/scene/useGameGLTF.ts @@ -1,6 +1,6 @@ import { useGLTF } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; -import { useCallback, useEffect, type ReactNode } from "react"; +import { useCallback, type ReactNode } from "react"; import type { WebGLRenderer } from "three"; import { KTX2Loader, type GLTFLoader } from "three-stdlib"; import { resolveContentUrl } from "../content/contentManager"; @@ -20,15 +20,12 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader { return loader; } -/** Owns the decoder worker pool for one Canvas and releases it on renderer teardown. */ +/** + * Marks the part of a Canvas that shares its GLTF decoder. KTX2Loader instances + * intentionally live with their weakly referenced renderer: disposing one from + * React cleanup can abort a GLB that is still decoding or cached by useGLTF. + */ export function GameGltfLoaderLifecycle({ children }: { readonly children: ReactNode }) { - const renderer = useThree((state) => state.gl); - useEffect(() => () => { - const loader = ktx2Loaders.get(renderer); - if (!loader) return; - loader.dispose(); - ktx2Loaders.delete(renderer); - }, [renderer]); return children; }