Release v0.1.8 2026-07-13
This commit is contained in:
+119
-24
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user