Release v0.1.8 2026-07-13

This commit is contained in:
Warren H
2026-07-13 17:21:10 -04:00
parent 77fd434226
commit 18c416d4d2
29 changed files with 1120 additions and 159 deletions
+119 -24
View File
@@ -1,6 +1,6 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
@@ -29,7 +29,7 @@ import { useGameStore } from "../game/store";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
import { bossCanTrackTarget } from "./boss/bossDeathVisuals";
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
@@ -66,6 +66,85 @@ const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
interface BossFadeMaterial {
material: THREE.Material;
baseOpacity: number;
baseTransparent: boolean;
baseDepthWrite: boolean;
}
function createBossRenderModel(source: THREE.Object3D) {
const model = cloneSkeleton(source);
const materialClones = new Map<THREE.Material, THREE.Material>();
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.castShadow = true;
object.receiveShadow = true;
const cloneMaterial = (material: THREE.Material) => {
const existing = materialClones.get(material);
if (existing) return existing;
const clone = material.clone();
materialClones.set(material, clone);
return clone;
};
object.material = Array.isArray(object.material)
? object.material.map(cloneMaterial)
: cloneMaterial(object.material);
});
return {
model,
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
material,
baseOpacity: material.opacity,
baseTransparent: material.transparent,
baseDepthWrite: material.depthWrite,
})),
};
}
function applyBossOpacity(materials: readonly BossFadeMaterial[], opacity: number) {
const fading = opacity < 0.999;
for (const entry of materials) {
const transparent = entry.baseTransparent || fading;
if (entry.material.transparent !== transparent) {
entry.material.transparent = transparent;
entry.material.needsUpdate = true;
}
entry.material.opacity = entry.baseOpacity * opacity;
entry.material.depthWrite = fading ? false : entry.baseDepthWrite;
}
}
function useBossDeathFade(
group: RefObject<THREE.Group | null>,
light: RefObject<THREE.PointLight | null>,
materials: readonly BossFadeMaterial[],
defeated: boolean,
baseLightIntensity: number,
) {
const elapsed = useRef(0);
const lastOpacity = useRef(1);
useFrame((_, delta) => {
if (!defeated) {
elapsed.current = 0;
if (lastOpacity.current !== 1) {
lastOpacity.current = 1;
if (group.current) group.current.visible = true;
if (light.current) light.current.intensity = baseLightIntensity;
applyBossOpacity(materials, 1);
}
return;
}
elapsed.current += delta;
const opacity = bossDeathOpacity(elapsed.current);
if (opacity === lastOpacity.current) return;
lastOpacity.current = opacity;
if (group.current) group.current.visible = opacity > 0;
if (light.current) light.current.intensity = baseLightIntensity * opacity;
applyBossOpacity(materials, opacity);
});
}
function encounterBossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0
? { boss: state.boss, motion: state.bossMotion }
@@ -587,14 +666,34 @@ function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
function BossFallback({ bossIndex }: { bossIndex: number }) {
const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshStandardMaterial>(null);
const deathElapsed = useRef(0);
useFrame((_, delta) => {
const defeated = (boss?.hp ?? 1) <= 0;
deathElapsed.current = defeated ? deathElapsed.current + delta : 0;
const opacity = bossDeathOpacity(deathElapsed.current);
if (group.current) group.current.visible = opacity > 0;
if (material.current) {
const transparent = opacity < 0.999;
if (material.current.transparent !== transparent) {
material.current.transparent = transparent;
material.current.needsUpdate = true;
}
material.current.opacity = opacity;
material.current.depthWrite = opacity >= 0.999;
}
});
if (!boss || !motion) return null;
const position = motion.position;
const bossId = boss.id;
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
<group ref={group} position={[position[0], 1.1, position[1]]}>
<mesh castShadow>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial ref={material} color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
</group>
);
}
@@ -606,19 +705,17 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(null);
const gltf = useGLTF(BULL_URL, false, true);
const bullScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, bullScene);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
bullScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [bullScene]);
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
}, [fadeMaterials]);
useBossDeathFade(group, light, fadeMaterials, defeated, 2.8);
const clipName = phase === "victory" || defeated
? "Death"
@@ -675,7 +772,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
return (
<group ref={group}>
<primitive object={bullScene} scale={0.81} />
<pointLight color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
<pointLight ref={light} color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
</group>
);
}
@@ -695,19 +792,17 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(null);
const gltf = useGLTF(config.url, false, true);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
model.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [kind, model]);
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
}, [fadeMaterials]);
useBossDeathFade(group, light, fadeMaterials, defeated, 2.5);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
@@ -770,7 +865,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
return (
<group ref={group}>
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
<pointLight color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
<pointLight ref={light} color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
</group>
);
}