Release Healer Man 0.1.18
This commit is contained in:
1 parent
fc8a7a722a
commit
eaddad4134
12 files changed
+107
-42
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.17",
|
||||
"versionCode": 1017
|
||||
"versionName": "0.1.18",
|
||||
"versionCode": 1018
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+46
-28
@@ -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 (
|
||||
<>
|
||||
<ProceduralStage stage={stage} />
|
||||
<ProceduralStage stage={stage} floorOnly />
|
||||
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
|
||||
</>
|
||||
);
|
||||
@@ -55,26 +56,49 @@ function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
|
||||
return <Clone object={gltf.scene} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<group>
|
||||
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
|
||||
<boxGeometry args={[width, 0.25, depth]} />
|
||||
<meshStandardMaterial color="#364038" roughness={0.92} />
|
||||
<meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
|
||||
</mesh>
|
||||
{stage.lineOfSight.map((object) => (
|
||||
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
|
||||
{object.shape === "cylinder"
|
||||
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
||||
: <boxGeometry args={object.size as [number, number, number]} />}
|
||||
<meshStandardMaterial color="#4e5146" roughness={0.78} />
|
||||
</mesh>
|
||||
))}
|
||||
{!floorOnly && (
|
||||
<>
|
||||
<gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
|
||||
{[
|
||||
[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) => (
|
||||
<mesh key={`boundary-${index}`} position={[x, y, z]} receiveShadow>
|
||||
<boxGeometry args={[sizeX, sizeY, sizeZ]} />
|
||||
<meshStandardMaterial color="#26342d" roughness={0.88} />
|
||||
</mesh>
|
||||
))}
|
||||
{stage.teamSpawns.map((spawn, index) => (
|
||||
<mesh key={`spawn-${index}`} position={[spawn[0], floorY + 0.2, spawn[2]]} receiveShadow>
|
||||
<cylinderGeometry args={[3.4, 3.4, 0.18, 32]} />
|
||||
<meshStandardMaterial color={index === 0 ? "#338dcc" : "#bd4944"} emissive={index === 0 ? "#123d5b" : "#541c1a"} emissiveIntensity={0.7} />
|
||||
</mesh>
|
||||
))}
|
||||
{stage.lineOfSight.map((object) => (
|
||||
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
|
||||
{object.shape === "cylinder"
|
||||
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
||||
: <boxGeometry args={object.size as [number, number, number]} />}
|
||||
<meshStandardMaterial color="#6a6f5e" roughness={0.78} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof selectedCharacter>; 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<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) {
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
@@ -194,13 +213,11 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
|
||||
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
|
||||
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
|
||||
<GameGltfLoaderLifecycle>
|
||||
<ArenaStageErrorBoundary
|
||||
key={stage.id}
|
||||
fallback={<ProceduralStage stage={stage} />}
|
||||
onError={onStageError}
|
||||
>
|
||||
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
|
||||
</ArenaStageErrorBoundary>
|
||||
{detailedAsset ? (
|
||||
<ArenaStageErrorBoundary key={stage.id} fallback={null} onError={onStageError}>
|
||||
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
|
||||
</ArenaStageErrorBoundary>
|
||||
) : <ProceduralStage stage={stage} />}
|
||||
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
|
||||
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
|
||||
<ArenaCamera actor={localActor} stage={stage} />
|
||||
@@ -331,9 +348,10 @@ export default function ArenaRuntime() {
|
||||
|
||||
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
|
||||
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
|
||||
const detailedAsset = stageFailure?.stageId !== stage.id;
|
||||
return (
|
||||
<main className="arena-runtime">
|
||||
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
|
||||
<div className="arena-world" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
|
||||
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
|
||||
<header className="arena-match-header">
|
||||
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user