Release v0.1.9 2026-07-13

This commit is contained in:
Warren H
2026-07-13 19:37:30 -04:00
parent 18c416d4d2
commit aa9f5eff20
35 changed files with 1064 additions and 48 deletions
+16
View File
@@ -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.
@@ -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.
+97 -17
View File
@@ -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);
}
+5 -3
View File
@@ -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>
+43
View File
@@ -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);
},
);
}
+65 -21
View File
@@ -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);
}
}
+13 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+3
View File
@@ -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 {
+9 -2
View File
@@ -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
View File
@@ -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"