Release v0.1.9 2026-07-13
This commit is contained in:
@@ -3,3 +3,19 @@
|
||||
Only assets referenced by the game ship from this folder. They are copied from the ignored `game_assets` source library, retain their source-relative path, and are imported with Vite `new URL(...)` calls.
|
||||
|
||||
Use `pnpm assets:import <path-within-game_assets>` to add a source asset. Add `--replace` only when deliberately updating an existing runtime copy.
|
||||
|
||||
## KTX2/UASTC pilot
|
||||
|
||||
The tracked `*-uastc.glb` files are parallel optimized copies. Their original GLBs remain the rollback path and must not be replaced or deleted during this pilot.
|
||||
|
||||
KTX-Software `toktx` is an authoring dependency. Rebuild source copies with:
|
||||
|
||||
```sh
|
||||
TOKTX=/path/to/toktx pnpm assets:build-ktx2
|
||||
TOKTX=/path/to/toktx pnpm assets:build-dungeon-kit
|
||||
TOKTX=/path/to/toktx pnpm assets:build-gravehorn
|
||||
```
|
||||
|
||||
Import each generated path from `game_assets/` with `pnpm assets:import <path> --replace`. Builds and dev startup copy Three.js's matching Basis transcoder into the ignored `public/basis/` generated directory.
|
||||
|
||||
Force all original GLBs at runtime with `?legacyGameAssets=1`. Force them in an Android/browser build with `VITE_LEGACY_GAME_ASSETS=1 pnpm build`. `legacyDungeonAssets` and `VITE_LEGACY_DUNGEON_ASSETS=1` remain supported aliases.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
# Animated Triceratops Skeleton
|
||||
|
||||
- Creator: Zacxophone — https://sketchfab.com/Zacxophone
|
||||
- Source: https://sketchfab.com/3d-models/animated-triceratops-skeleton-06cb55f941d94dc8b95ac46f92d89e7c
|
||||
- License: CC0 1.0 Universal — https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
Runtime files named `gravehorn-triceratops*.glb` are optimized derivatives of this model.
|
||||
Binary file not shown.
+97
-17
@@ -1,15 +1,22 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import * as THREE from "three";
|
||||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
const ROOM_CENTER_Z = ARENA_CENTER[1];
|
||||
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href;
|
||||
const DUNGEON_MESH_NAMES = {
|
||||
pillar: "pillar_decorated",
|
||||
wall: "wall_pillar",
|
||||
torch: "torch_lit",
|
||||
} as const;
|
||||
|
||||
type ArenaFixture = {
|
||||
position: readonly [number, number, number];
|
||||
@@ -106,21 +113,19 @@ function firstMesh(scene: THREE.Object3D) {
|
||||
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||
}
|
||||
|
||||
function DungeonAssetInstances({
|
||||
url,
|
||||
function DungeonMeshInstances({
|
||||
mesh,
|
||||
fixtures,
|
||||
tint,
|
||||
opacity = 1,
|
||||
colors,
|
||||
}: {
|
||||
url: string;
|
||||
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||
fixtures: readonly ArenaFixture[];
|
||||
tint: string;
|
||||
opacity?: number;
|
||||
colors?: readonly THREE.Color[];
|
||||
}) {
|
||||
const gltf = useGLTF(url, false, true);
|
||||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||||
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||
const material = useMemo(() => {
|
||||
const next = sourceMaterial.clone();
|
||||
@@ -156,20 +161,77 @@ function DungeonAssetInstances({
|
||||
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
||||
}
|
||||
|
||||
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||
function LegacyDungeonAssetInstances({
|
||||
url,
|
||||
...props
|
||||
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
|
||||
const gltf = useGLTF(url, false, true);
|
||||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||||
return <DungeonMeshInstances mesh={mesh} {...props} />;
|
||||
}
|
||||
|
||||
function arenaWalls(room: BossRoomDefinition) {
|
||||
return ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||
...fixture,
|
||||
scaleY: room.wallHeight / 4,
|
||||
})), [room.wallHeight]);
|
||||
}));
|
||||
}
|
||||
|
||||
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
return (
|
||||
<group>
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function namedMesh(scene: THREE.Object3D, name: string) {
|
||||
const object = scene.getObjectByName(name);
|
||||
if (object instanceof THREE.Mesh) {
|
||||
return object as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||
}
|
||||
throw new Error(`Dungeon kit is missing mesh ${name}.`);
|
||||
}
|
||||
|
||||
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
const meshes = useMemo(() => ({
|
||||
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
|
||||
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
|
||||
torch: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.torch),
|
||||
}), [gltf.scene]);
|
||||
return (
|
||||
<group>
|
||||
<DungeonMeshInstances mesh={meshes.wall} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
<DungeonMeshInstances mesh={meshes.pillar} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||
<DungeonMeshInstances mesh={meshes.torch} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
class DungeonAssetErrorBoundary extends Component<{
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
}, { failed: boolean }> {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.warn("Optimized dungeon asset failed; using legacy GLBs.", error);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useRef<THREE.Group>(null);
|
||||
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
||||
@@ -205,14 +267,30 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
return (
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<ArenaArchitecture room={room} />
|
||||
<LegacyArenaArchitecture room={room} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
return (
|
||||
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<DungeonKitArchitecture room={room} />
|
||||
</Suspense>
|
||||
</DungeonAssetErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
return LEGACY_GAME_ASSETS_FORCED
|
||||
? <LegacyRoomWalls room={room} />
|
||||
: <OptimizedRoomWalls room={room} />;
|
||||
}
|
||||
|
||||
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||
const rays = useRef<THREE.InstancedMesh>(null);
|
||||
const pattern = ROOM_PATTERNS[room.floor];
|
||||
@@ -345,6 +423,8 @@ export function BossRoom() {
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
|
||||
import type { BossId } from "../game/types";
|
||||
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
function PortraitModel({ bossId }: { bossId: BossId }) {
|
||||
const gltf = useGLTF(bossVisualUrl(bossId), false, true);
|
||||
const gltf = useGameGLTF(bossVisualUrl(bossId, !LEGACY_GAME_ASSETS_FORCED));
|
||||
const model = useMemo(() => {
|
||||
const clone = cloneSkeleton(gltf.scene);
|
||||
clone.updateMatrixWorld(true);
|
||||
@@ -49,7 +49,9 @@ export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
|
||||
<ambientLight intensity={1.9} />
|
||||
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
|
||||
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
|
||||
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
|
||||
<GameAssetProvider>
|
||||
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
|
||||
</GameAssetProvider>
|
||||
</Canvas>
|
||||
<span aria-hidden="true">{boss.icon}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { KTX2Loader } from "three-stdlib";
|
||||
|
||||
const BASIS_TRANSCODER_PATH = `${import.meta.env.BASE_URL}basis/`;
|
||||
const query = typeof window === "undefined" ? null : new URLSearchParams(window.location.search);
|
||||
|
||||
export const LEGACY_GAME_ASSETS_FORCED = import.meta.env.VITE_LEGACY_GAME_ASSETS === "1"
|
||||
|| import.meta.env.VITE_LEGACY_DUNGEON_ASSETS === "1"
|
||||
|| query?.has("legacyGameAssets") === true
|
||||
|| query?.has("legacyDungeonAssets") === true;
|
||||
|
||||
const Ktx2LoaderContext = createContext<KTX2Loader | null>(null);
|
||||
|
||||
export function selectedGameAssetUrl(legacyUrl: string, optimizedUrl: string) {
|
||||
return LEGACY_GAME_ASSETS_FORCED ? legacyUrl : optimizedUrl;
|
||||
}
|
||||
|
||||
export function GameAssetProvider({ children }: { children: ReactNode }) {
|
||||
const gl = useThree((state) => state.gl);
|
||||
const [ktx2Loader] = useState(() => LEGACY_GAME_ASSETS_FORCED
|
||||
? null
|
||||
: new KTX2Loader().setTranscoderPath(BASIS_TRANSCODER_PATH).detectSupport(gl));
|
||||
|
||||
useEffect(() => () => {
|
||||
ktx2Loader?.dispose();
|
||||
}, [ktx2Loader]);
|
||||
|
||||
return <Ktx2LoaderContext.Provider value={ktx2Loader}>{children}</Ktx2LoaderContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGameGLTF(url: string) {
|
||||
const ktx2Loader = useContext(Ktx2LoaderContext);
|
||||
return useGLTF(
|
||||
url,
|
||||
false,
|
||||
true,
|
||||
(loader) => {
|
||||
if (ktx2Loader) loader.setKTX2Loader(ktx2Loader);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -30,15 +30,30 @@ import type { BossId, MemberId, PulseKind } from "../game/types";
|
||||
import { BossRoom } from "./BossRoom";
|
||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
|
||||
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
const PARTY_MODEL_URLS: Record<MemberId, string> = {
|
||||
const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
|
||||
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
|
||||
orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
|
||||
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
|
||||
};
|
||||
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
const PARTY_MODEL_OPTIMIZED_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid-uastc.glb", import.meta.url).href,
|
||||
brann: new URL("../assets/game/models/claudecraft/chars/players/knight-uastc.glb", import.meta.url).href,
|
||||
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger-uastc.glb", import.meta.url).href,
|
||||
orin: new URL("../assets/game/models/claudecraft/chars/players/mage-uastc.glb", import.meta.url).href,
|
||||
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue-uastc.glb", import.meta.url).href,
|
||||
};
|
||||
const PARTY_MODEL_URLS = Object.fromEntries(Object.keys(PARTY_MODEL_LEGACY_URLS).map((memberId) => [
|
||||
memberId,
|
||||
selectedGameAssetUrl(
|
||||
PARTY_MODEL_LEGACY_URLS[memberId as MemberId],
|
||||
PARTY_MODEL_OPTIMIZED_URLS[memberId as MemberId],
|
||||
),
|
||||
])) as Record<MemberId, string>;
|
||||
const PARTY_WEAPON_LEGACY_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
|
||||
brann: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
|
||||
@@ -54,6 +69,30 @@ const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
},
|
||||
};
|
||||
const PARTY_WEAPON_OPTIMIZED_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb", import.meta.url).href },
|
||||
brann: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/shield_badge-uastc.glb", import.meta.url).href,
|
||||
},
|
||||
nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb", import.meta.url).href },
|
||||
orin: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_wand-uastc.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb", import.meta.url).href,
|
||||
},
|
||||
vale: {
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
|
||||
},
|
||||
};
|
||||
const PARTY_WEAPON_URLS = Object.fromEntries(Object.keys(PARTY_WEAPON_LEGACY_URLS).map((memberId) => {
|
||||
const legacy = PARTY_WEAPON_LEGACY_URLS[memberId as MemberId];
|
||||
const optimized = PARTY_WEAPON_OPTIMIZED_URLS[memberId as MemberId];
|
||||
return [memberId, {
|
||||
right: selectedGameAssetUrl(legacy.right, optimized.right),
|
||||
left: legacy.left && optimized.left ? selectedGameAssetUrl(legacy.left, optimized.left) : undefined,
|
||||
}];
|
||||
})) as Record<MemberId, { right: string; left?: string }>;
|
||||
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
|
||||
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
|
||||
aelia: "2H_Melee_Attack_Chop",
|
||||
@@ -227,12 +266,12 @@ function PartyCharacterModel({
|
||||
animationState: MutableRefObject<ActorAnimationState>;
|
||||
animationTrigger: MutableRefObject<number>;
|
||||
}) {
|
||||
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const gltf = useGameGLTF(PARTY_MODEL_URLS[memberId]);
|
||||
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||
const grips = PARTY_WEAPON_GRIPS[memberId];
|
||||
const rightWeapon = useGLTF(loadout.right, false, true);
|
||||
const leftWeapon = useGLTF(loadout.left ?? loadout.right, false, true);
|
||||
const rightWeapon = useGameGLTF(loadout.right);
|
||||
const leftWeapon = useGameGLTF(loadout.left ?? loadout.right);
|
||||
const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
|
||||
const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
|
||||
const rightWeaponScene = useMemo(
|
||||
@@ -793,7 +832,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const defeated = bossHp <= 0;
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const light = useRef<THREE.PointLight>(null);
|
||||
const gltf = useGLTF(config.url, false, true);
|
||||
const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
|
||||
const gltf = useGameGLTF(assetUrl);
|
||||
const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
|
||||
const { actions } = useAnimations(gltf.animations, model);
|
||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
@@ -1184,23 +1224,27 @@ export function GameScene() {
|
||||
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
|
||||
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
|
||||
>
|
||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||
<BossRoom />
|
||||
<BossMechanicIndicators />
|
||||
<BarrierField />
|
||||
<TankAuraField />
|
||||
<Party />
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
<GameAssetProvider>
|
||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||
<BossRoom />
|
||||
<BossMechanicIndicators />
|
||||
<BarrierField />
|
||||
<TankAuraField />
|
||||
<Party />
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
</GameAssetProvider>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
||||
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||
useGLTF.preload(loadout.right, false, true);
|
||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||
if (LEGACY_GAME_ASSETS_FORCED) {
|
||||
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
||||
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||
useGLTF.preload(loadout.right, false, true);
|
||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals";
|
||||
describe("boss catalog", () => {
|
||||
it("derives the available roster from every catalog definition", () => {
|
||||
expect(AVAILABLE_BOSS_IDS).toEqual(Object.keys(BOSS_DEFINITIONS));
|
||||
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(27);
|
||||
expect(new Set(AVAILABLE_BOSS_IDS)).toHaveLength(28);
|
||||
});
|
||||
|
||||
it("assigns every boss to one compatible mechanic group", () => {
|
||||
@@ -66,6 +66,18 @@ describe("boss catalog", () => {
|
||||
expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].death).toBe("Dying");
|
||||
});
|
||||
|
||||
it("assigns Gravehorn to the underfilled burrow group with its optimized animation set", () => {
|
||||
expect(BOSS_GROUPS.find((group) => group.id === "burrow-eruption")?.bossIds).toContain("gravehorn-triceratops");
|
||||
expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"]).toMatchObject({
|
||||
idle: "Gravehorn|Idle",
|
||||
move: "Armature|Walk",
|
||||
attack: "Armature|Roar",
|
||||
special: "Armature|RiseUp",
|
||||
death: "Armature|Fall",
|
||||
});
|
||||
expect(ALTERNATE_BOSS_CONFIG["gravehorn-triceratops"].optimizedUrl).toContain("gravehorn-triceratops-uastc");
|
||||
});
|
||||
|
||||
it("uses original animated creatures instead of the retired chicken and frog visuals", () => {
|
||||
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"]).toMatchObject({
|
||||
idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death",
|
||||
|
||||
@@ -191,6 +191,10 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
|
||||
name: "Rimeclaw", title: "The Frozen Duel", icon: "✥", accent: "#75bfe8",
|
||||
summary: "Sidesteps between frost strikes before forming a lethal ice cross.", mechanicIds: ["elemental-beam", "guardian-cross", "memory-sequence"], maxHp: 500, archetype: "duelist",
|
||||
},
|
||||
"gravehorn-triceratops": {
|
||||
name: "Gravehorn", title: "The Fossil Wake", icon: "☠", accent: "#d8c79b",
|
||||
summary: "Burrows through fossil trails, erupts beneath the party, and releases a radial grave pulse.", mechanicIds: ["burrow-rush", "hourglass-eruption", "destruction-pulse"], maxHp: 540, archetype: "burrower",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -220,7 +224,7 @@ export const BOSS_GROUPS: readonly BossGroupDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "burrow-eruption", letter: "F", name: "Burrow / Eruption", coreMechanic: "Burrow and Eruption",
|
||||
bossIds: ["sandglass-scorpion", "mirelord-frog"], archetypes: ["burrower"],
|
||||
bossIds: ["sandglass-scorpion", "mirelord-frog", "gravehorn-triceratops"], archetypes: ["burrower"],
|
||||
},
|
||||
{
|
||||
id: "scuttle-burst", letter: "G", name: "Scuttle / Burst", coreMechanic: "Scuttle and Burst",
|
||||
|
||||
@@ -133,6 +133,9 @@ export const BOSS_ROOMS = {
|
||||
"rimeclaw-yeti": room("frozen-duel", "The Frozen Duel", "Frozen mirror palace", "frost", {
|
||||
background: "#07162a", fog: "#285a83", sky: "#8fd8ff", ground: "#030c19", floorColor: "#17476d", wallColor: "#2d6691", accent: "#74c8f4", accentSecondary: "#c1f6ff", wallHeight: 4.9,
|
||||
}),
|
||||
"gravehorn-triceratops": room("fossil-wake", "The Fossil Wake", "Buried ossuary", "reliquary", {
|
||||
background: "#100d08", fog: "#40382a", sky: "#d8c79b", ground: "#090704", floorColor: "#332b20", wallColor: "#4c4030", accent: "#d8c79b", accentSecondary: "#8eb9a6", wallHeight: 4.5,
|
||||
}),
|
||||
} as const satisfies Record<BossId, BossRoomDefinition>;
|
||||
|
||||
export function bossRoomFor(bossId: BossId): BossRoomDefinition {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type AlternateBossKind = Exclude<BossId, "bulldrome">;
|
||||
|
||||
export interface AlternateBossConfig {
|
||||
url: string;
|
||||
optimizedUrl?: string;
|
||||
scale: number;
|
||||
idle: string;
|
||||
move: string;
|
||||
@@ -21,6 +22,8 @@ const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-s
|
||||
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
|
||||
const BRASSBEAK_BASILISK_URL = new URL("../assets/game/models/original/bosses/brassbeak-basilisk/brassbeak-basilisk.glb", import.meta.url).href;
|
||||
const BOGBELL_MYCONID_URL = new URL("../assets/game/models/original/bosses/bogbell-myconid/bogbell-myconid.glb", import.meta.url).href;
|
||||
const GRAVEHORN_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops.glb", import.meta.url).href;
|
||||
const GRAVEHORN_OPTIMIZED_URL = new URL("../assets/game/models/sketchfab-opensource/gravehorn-triceratops-uastc.glb", import.meta.url).href;
|
||||
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
|
||||
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
|
||||
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
|
||||
@@ -34,6 +37,7 @@ const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
|
||||
| "crystal-bat-matriarch"
|
||||
| "cluckhorn-colossus"
|
||||
| "mirelord-frog"
|
||||
| "gravehorn-triceratops"
|
||||
>, string> = {
|
||||
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
|
||||
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
|
||||
@@ -84,8 +88,11 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
|
||||
"moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 },
|
||||
"frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 },
|
||||
"rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 },
|
||||
"gravehorn-triceratops": { url: GRAVEHORN_URL, optimizedUrl: GRAVEHORN_OPTIMIZED_URL, scale: 0.82, idle: "Gravehorn|Idle", move: "Armature|Walk", attack: "Armature|Roar", special: "Armature|RiseUp", death: "Armature|Fall", light: "#ffd9a0", rotationOffset: 0 },
|
||||
};
|
||||
|
||||
export function bossVisualUrl(bossId: BossId): string {
|
||||
return bossId === "bulldrome" ? BULL_URL : ALTERNATE_BOSS_CONFIG[bossId].url;
|
||||
export function bossVisualUrl(bossId: BossId, optimized = false): string {
|
||||
if (bossId === "bulldrome") return BULL_URL;
|
||||
const config = ALTERNATE_BOSS_CONFIG[bossId];
|
||||
return optimized ? config.optimizedUrl ?? config.url : config.url;
|
||||
}
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@ export type BossId =
|
||||
| "bristlequake-boar"
|
||||
| "moonfang-wolf"
|
||||
| "frostmaw-yeti"
|
||||
| "rimeclaw-yeti";
|
||||
| "rimeclaw-yeti"
|
||||
| "gravehorn-triceratops";
|
||||
export type BossMechanicId =
|
||||
| "basic-melee"
|
||||
| "bull-charge"
|
||||
|
||||
Reference in New Issue
Block a user