Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,906 @@
|
||||
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations, useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { MemberId, PulseKind } from "../game/types";
|
||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
|
||||
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
|
||||
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
|
||||
const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
|
||||
const PARTY_MODEL_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
|
||||
nia: new URL("../../game_assets/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
|
||||
orin: new URL("../../game_assets/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
|
||||
vale: new URL("../../game_assets/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
|
||||
};
|
||||
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
aelia: { right: new URL("../../game_assets/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
|
||||
brann: {
|
||||
right: new URL("../../game_assets/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
|
||||
left: new URL("../../game_assets/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
|
||||
},
|
||||
nia: { right: new URL("../../game_assets/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
|
||||
orin: {
|
||||
right: new URL("../../game_assets/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
|
||||
left: new URL("../../game_assets/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
|
||||
},
|
||||
vale: {
|
||||
right: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
left: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
},
|
||||
};
|
||||
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",
|
||||
brann: "1H_Melee_Attack_Chop",
|
||||
nia: "2H_Ranged_Shoot",
|
||||
orin: "Spellcast_Shoot",
|
||||
vale: "Dualwield_Melee_Attack_Chop",
|
||||
};
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||
return bossIndex === 0
|
||||
? { boss: state.boss, motion: state.bossMotion }
|
||||
: state.additionalBosses[bossIndex - 1];
|
||||
}
|
||||
|
||||
function targetBossMotion(state: GameStoreState) {
|
||||
if (state.boss.hp > 0) return state.bossMotion;
|
||||
return state.additionalBosses.find((entry) => entry.boss.hp > 0)?.motion ?? state.bossMotion;
|
||||
}
|
||||
|
||||
function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) {
|
||||
if (!instanceId || instanceId === `boss-0-${state.boss.id}`) return targetBossMotion(state);
|
||||
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
|
||||
}
|
||||
|
||||
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
|
||||
|
||||
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
|
||||
|
||||
const PARTY_WEAPON_GRIPS: Record<MemberId, { right: WeaponGrip; left?: WeaponGrip }> = {
|
||||
aelia: { right: "staff" },
|
||||
brann: { right: "sword", left: "prop" },
|
||||
nia: { right: "crossbow" },
|
||||
orin: { right: "wand", left: "prop" },
|
||||
vale: { right: "dagger", left: "dagger" },
|
||||
};
|
||||
|
||||
const VARIANT_GRIPS: Record<Exclude<WeaponGrip, "crossbow" | "prop">, { lift: number; maxHeight: number }> = {
|
||||
sword: { lift: 0.04, maxHeight: 2 },
|
||||
dagger: { lift: 0.04, maxHeight: 1.4 },
|
||||
staff: { lift: 0.18, maxHeight: 2.4 },
|
||||
wand: { lift: 0.04, maxHeight: 1.2 },
|
||||
};
|
||||
|
||||
function resolveRigNode(root: THREE.Object3D, authoredName: string) {
|
||||
return root.getObjectByName(authoredName)
|
||||
?? root.getObjectByName(authoredName.replace(/[[\].:/]/g, ""));
|
||||
}
|
||||
|
||||
function flattenWeaponScene(scene: THREE.Object3D) {
|
||||
if (scene.children.length !== 1) return scene;
|
||||
const holder = new THREE.Group();
|
||||
const child = scene.children[0];
|
||||
holder.scale.copy(child.scale);
|
||||
child.position.set(0, 0, 0);
|
||||
child.rotation.set(0, 0, 0);
|
||||
child.scale.set(1, 1, 1);
|
||||
scene.remove(child);
|
||||
holder.add(child);
|
||||
return holder;
|
||||
}
|
||||
|
||||
function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" | "l") {
|
||||
// Shields and spellbooks carry useful authored offsets, so keep their scene transform.
|
||||
if (grip === "prop") return scene;
|
||||
|
||||
const weapon = flattenWeaponScene(scene);
|
||||
if (grip === "crossbow") {
|
||||
weapon.position.set(0.3381, 0.058, 0);
|
||||
weapon.quaternion.set(0, 0.7071068, 0, 0.7071067);
|
||||
weapon.scale.setScalar(0.7204);
|
||||
return weapon;
|
||||
}
|
||||
|
||||
const { lift, maxHeight } = VARIANT_GRIPS[grip];
|
||||
const bounds = new THREE.Box3().setFromObject(weapon);
|
||||
const height = bounds.max.y - bounds.min.y;
|
||||
const scale = height > 0.001 ? Math.min(1, maxHeight / height) : 1;
|
||||
weapon.position.set(0, lift, 0);
|
||||
weapon.quaternion.set(0, side === "l" ? 0 : 1, 0, side === "l" ? 1 : 0);
|
||||
weapon.scale.setScalar(scale);
|
||||
return weapon;
|
||||
}
|
||||
|
||||
function PartyCharacterModel({
|
||||
memberId,
|
||||
animationState,
|
||||
}: {
|
||||
memberId: MemberId;
|
||||
animationState: MutableRefObject<ActorAnimationState>;
|
||||
}) {
|
||||
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
|
||||
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 rightHandSlot = resolveRigNode(actorScene, "handslot.r");
|
||||
const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
|
||||
const rightWeaponScene = useMemo(
|
||||
() => prepareHeldWeapon(rightWeapon.scene.clone(true), grips.right, "r"),
|
||||
[grips.right, rightWeapon.scene],
|
||||
);
|
||||
const leftWeaponScene = useMemo(
|
||||
() => loadout.left ? prepareHeldWeapon(leftWeapon.scene.clone(true), grips.left ?? grips.right, "l") : null,
|
||||
[grips.left, grips.right, leftWeapon.scene, loadout.left],
|
||||
);
|
||||
const { actions } = useAnimations(gltf.animations, actorScene);
|
||||
const activeClip = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
actorScene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
}, [actorScene]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const weaponScene of [rightWeaponScene, leftWeaponScene]) {
|
||||
if (!weaponScene) continue;
|
||||
weaponScene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
// Animated hand sockets can cross a mesh's original root-space frustum.
|
||||
object.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [leftWeaponScene, rightWeaponScene]);
|
||||
|
||||
useFrame(() => {
|
||||
const state = animationState.current;
|
||||
const clipName = state === "death"
|
||||
? "Death_A"
|
||||
: state === "hit"
|
||||
? "Hit_A"
|
||||
: state === "run"
|
||||
? "Running_A"
|
||||
: state === "walk"
|
||||
? "Walking_A"
|
||||
: state === "cast"
|
||||
? "Spellcasting"
|
||||
: state === "attack"
|
||||
? PARTY_ATTACK_CLIPS[memberId]
|
||||
: "Idle";
|
||||
if (activeClip.current === clipName) return;
|
||||
const next = actions[clipName];
|
||||
if (!next) return;
|
||||
if (activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1).fadeIn(0.16);
|
||||
if (state === "death" || state === "hit" || state === "attack" || state === "cast") {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = state === "death";
|
||||
} else {
|
||||
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
||||
next.clampWhenFinished = false;
|
||||
}
|
||||
next.play();
|
||||
activeClip.current = clipName;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<primitive object={actorScene} scale={PARTY_MODEL_SCALES[memberId]} />
|
||||
{rightHandSlot && createPortal(<primitive object={rightWeaponScene} />, rightHandSlot)}
|
||||
{leftWeaponScene && leftHandSlot && createPortal(<primitive object={leftWeaponScene} />, leftHandSlot)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Arena() {
|
||||
const columns = useMemo(() => {
|
||||
return Array.from({ length: 10 }, (_, index) => {
|
||||
const angle = (index / 10) * Math.PI * 2;
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, -0.45, -1]} receiveShadow>
|
||||
<cylinderGeometry args={[10.5, 11.2, 0.8, 48]} />
|
||||
<meshStandardMaterial color="#182420" roughness={0.92} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.015, -1]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<ringGeometry args={[4.8, 5.05, 64]} />
|
||||
<meshBasicMaterial color="#765c32" transparent opacity={0.55} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.01, -1]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[2.1, 48]} />
|
||||
<meshStandardMaterial color="#27342d" roughness={1} />
|
||||
</mesh>
|
||||
{columns.map(([x, z], index) => (
|
||||
<group key={index} position={[x, 0, z - 1]}>
|
||||
<mesh castShadow receiveShadow position={[0, 1.1, 0]}>
|
||||
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</mesh>
|
||||
<pointLight color={index % 2 ? "#dd7b38" : "#6fc9ba"} intensity={2.2} distance={5} position={[0, 2.7, 0]} />
|
||||
<mesh position={[0, 2.6, 0]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial color={index % 2 ? "#ff9a4f" : "#77ddce"} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const animationState = useRef<ActorAnimationState>("idle");
|
||||
useEffect(() => {
|
||||
const start = useGameStore.getState().partyPositions[memberId];
|
||||
group.current?.position.set(start[0], 0.025, start[1]);
|
||||
}, [memberId]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const target = state.partyPositions[memberId];
|
||||
const dx = target[0] - group.current.position.x;
|
||||
const dz = target[1] - group.current.position.z;
|
||||
const moving = Math.hypot(dx, dz) > 0.015;
|
||||
const movementBlend = 1 - Math.pow(0.002, delta);
|
||||
group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], movementBlend);
|
||||
group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], movementBlend);
|
||||
|
||||
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||
const knocked = member.knockedUntil > state.time;
|
||||
const visualAction = state.partyCombat.combatants[memberId].visualAction;
|
||||
const targetMotion = targetBossMotionByInstance(state, visualAction?.targetInstanceId);
|
||||
const attacking = state.phase === "combat"
|
||||
&& visualAction !== null
|
||||
&& visualAction.endsAt > state.time;
|
||||
animationState.current = member.hp <= 0
|
||||
? "death"
|
||||
: knocked
|
||||
? "hit"
|
||||
: attacking
|
||||
? "attack"
|
||||
: moving
|
||||
? memberId === "vale" ? "run" : "walk"
|
||||
: "idle";
|
||||
if (!knocked && member.hp > 0 && (state.phase === "combat" || moving)) {
|
||||
const faceBoss = state.phase === "combat";
|
||||
const facingX = faceBoss ? targetMotion.position[0] - group.current.position.x : dx;
|
||||
const facingZ = faceBoss ? targetMotion.position[1] - group.current.position.z : dz;
|
||||
const targetAngle = Math.atan2(facingX, facingZ);
|
||||
const angleDelta = Math.atan2(
|
||||
Math.sin(targetAngle - group.current.rotation.y),
|
||||
Math.cos(targetAngle - group.current.rotation.y),
|
||||
);
|
||||
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
|
||||
}
|
||||
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} rotation={[0, Math.PI, 0]}>
|
||||
<PartyCharacterModel memberId={memberId} animationState={animationState} />
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerCharacter() {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const animationState = useRef<ActorAnimationState>("idle");
|
||||
const keys = useRef(new Set<string>());
|
||||
const scenePulse = useGameStore((state) => state.scenePulse);
|
||||
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
|
||||
const setPlayerPosition = useGameStore((state) => state.setPlayerPosition);
|
||||
const { camera } = useThree();
|
||||
const broadcastTimer = useRef(0);
|
||||
const castingUntil = useRef(0);
|
||||
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const start = useGameStore.getState().partyPositions.aelia;
|
||||
group.current?.position.set(start[0], 0.025, start[1]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
|
||||
castingUntil.current = performance.now() + 700;
|
||||
}
|
||||
}, [scenePulse]);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
keys.current.add(key);
|
||||
if (event.repeat || !group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
if (state.phase !== "combat" || state.paused || state.activeCast || state.party[0].hp <= 0 || state.party[0].knockedUntil > state.time) return;
|
||||
const nudgeX = Number(key === "d") - Number(key === "a");
|
||||
const nudgeZ = Number(key === "s") - Number(key === "w");
|
||||
if (!nudgeX && !nudgeZ) return;
|
||||
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + nudgeX * 0.18, -7.2, 7.2);
|
||||
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + nudgeZ * 0.18, -4.8, 7.2);
|
||||
setPlayerPosition([group.current.position.x, group.current.position.z]);
|
||||
};
|
||||
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
|
||||
window.addEventListener("keydown", down);
|
||||
window.addEventListener("keyup", up);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", down);
|
||||
window.removeEventListener("keyup", up);
|
||||
};
|
||||
}, [setPlayerPosition]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!group.current) return;
|
||||
let inputX = 0;
|
||||
let inputZ = 0;
|
||||
const state = useGameStore.getState();
|
||||
const knocked = state.party[0].knockedUntil > state.time;
|
||||
const player = state.party[0];
|
||||
if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
|
||||
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
|
||||
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
|
||||
const gamepad = navigator.getGamepads?.()[0];
|
||||
if (gamepad) {
|
||||
inputX += Math.abs(gamepad.axes[0] ?? 0) > 0.18 ? gamepad.axes[0] : 0;
|
||||
inputZ += Math.abs(gamepad.axes[1] ?? 0) > 0.18 ? gamepad.axes[1] : 0;
|
||||
}
|
||||
}
|
||||
const length = Math.hypot(inputX, inputZ);
|
||||
if (length > 0.05) {
|
||||
const speed = 4.6 * delta / Math.max(1, length);
|
||||
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + inputX * speed, -7.2, 7.2);
|
||||
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + inputZ * speed, -4.8, 7.2);
|
||||
group.current.rotation.y = Math.atan2(inputX, inputZ);
|
||||
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
|
||||
const boss = targetBossMotion(state).position;
|
||||
const targetAngle = Math.atan2(boss[0] - group.current.position.x, boss[1] - group.current.position.z);
|
||||
const angleDelta = Math.atan2(
|
||||
Math.sin(targetAngle - group.current.rotation.y),
|
||||
Math.cos(targetAngle - group.current.rotation.y),
|
||||
);
|
||||
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
|
||||
}
|
||||
animationState.current = player.hp <= 0
|
||||
? "death"
|
||||
: knocked
|
||||
? "hit"
|
||||
: state.activeCast
|
||||
? "cast"
|
||||
: length > 0.05
|
||||
? "run"
|
||||
: performance.now() < castingUntil.current
|
||||
? "cast"
|
||||
: "idle";
|
||||
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
|
||||
|
||||
desiredCameraPosition.set(group.current.position.x * 0.45, 5.1, group.current.position.z + 7.7);
|
||||
camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta));
|
||||
camera.lookAt(group.current.position.x * 0.55, 0.65, group.current.position.z - 2.8);
|
||||
|
||||
broadcastTimer.current += delta;
|
||||
if (broadcastTimer.current > 0.15) {
|
||||
setPlayerPosition([group.current.position.x, group.current.position.z]);
|
||||
broadcastTimer.current = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} rotation={[0, Math.PI, 0]}>
|
||||
<PartyCharacterModel memberId="aelia" animationState={animationState} />
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
<meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
|
||||
</mesh>
|
||||
)}
|
||||
<pointLight color="#f7d873" intensity={0.8} distance={2.5} position={[0.45, 1.12, 0]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Party() {
|
||||
const party = useGameStore((state) => state.party);
|
||||
const selected = useGameStore((state) => state.selectedMemberId);
|
||||
return (
|
||||
<>
|
||||
<PlayerCharacter />
|
||||
{party.slice(1).map((member) => (
|
||||
<Character
|
||||
key={member.id}
|
||||
memberId={member.id as Exclude<MemberId, "aelia">}
|
||||
selected={selected === member.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PartyFallback() {
|
||||
const positions = useGameStore((state) => state.partyPositions);
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(positions) as MemberId[]).map((memberId) => (
|
||||
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
|
||||
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
|
||||
<meshStandardMaterial color="#79998c" roughness={0.8} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function BullBoss({ bossIndex }: { bossIndex: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
|
||||
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 gltf = useGLTF(BULL_URL, false, true);
|
||||
const bullScene = useMemo(() => cloneSkeleton(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]);
|
||||
|
||||
const clipName = phase === "victory" || defeated
|
||||
? "Death"
|
||||
: motionMode === "telegraph"
|
||||
? "Idle_Headlow"
|
||||
: motionMode === "pouncing"
|
||||
? "Gallop_Jump"
|
||||
: motionMode === "charging" || motionMode === "returning"
|
||||
? "Gallop"
|
||||
: motionMode === "stacking"
|
||||
? "Idle_Headlow"
|
||||
: "Idle";
|
||||
|
||||
useEffect(() => {
|
||||
const next = actions[clipName];
|
||||
if (!next) return;
|
||||
for (const action of Object.values(actions)) action?.fadeOut(0.18);
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(motionMode === "charging" ? 1.3 : 1).fadeIn(0.18).play();
|
||||
if (clipName === "Death") {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = true;
|
||||
} else {
|
||||
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
||||
}
|
||||
return () => { next.fadeOut(0.18); };
|
||||
}, [actions, clipName, motionMode]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const current = encounterBossAt(state, bossIndex);
|
||||
if (!current) return;
|
||||
const motion = current.motion;
|
||||
targetPosition.set(motion.position[0], 0.03, motion.position[1]);
|
||||
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
||||
|
||||
if (motion.mode === "stacking") {
|
||||
group.current.rotation.y += (Math.PI * 2 / 5) * delta;
|
||||
return;
|
||||
}
|
||||
|
||||
let facingX = state.partyPositions.brann[0] - motion.position[0];
|
||||
let facingZ = state.partyPositions.brann[1] - motion.position[1];
|
||||
if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
|
||||
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
|
||||
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
|
||||
} else if (motion.mode === "returning") {
|
||||
facingX = state.partyPositions.brann[0] + motion.formationOffsetX - motion.position[0];
|
||||
facingZ = state.partyPositions.brann[1] - 4.25 - motion.position[1];
|
||||
}
|
||||
if (Math.hypot(facingX, facingZ) > 0.01) {
|
||||
const targetAngle = Math.atan2(facingX, facingZ);
|
||||
const difference = Math.atan2(Math.sin(targetAngle - group.current.rotation.y), Math.cos(targetAngle - group.current.rotation.y));
|
||||
group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
|
||||
}
|
||||
});
|
||||
if (phase === "briefing") return null;
|
||||
return (
|
||||
<group ref={group}>
|
||||
<primitive object={bullScene} scale={0.81} />
|
||||
<pointLight color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
type AlternateBossKind = "vexa" | "cindermaw";
|
||||
|
||||
const ALTERNATE_BOSS_CONFIG = {
|
||||
vexa: {
|
||||
url: SPIDER_URL,
|
||||
scale: 0.022,
|
||||
idle: "Spider_Armature|warte_pose",
|
||||
move: "Spider_Armature|run_ani_vor",
|
||||
attack: "Spider_Armature|Attack",
|
||||
special: "Spider_Armature|Jump",
|
||||
death: "Spider_Armature|die",
|
||||
light: "#bb67ff",
|
||||
rotationOffset: Math.PI,
|
||||
},
|
||||
cindermaw: {
|
||||
url: DRAGON_URL,
|
||||
scale: 1.15,
|
||||
idle: "Flying_Idle",
|
||||
move: "Fast_Flying",
|
||||
attack: "Headbutt",
|
||||
special: "Punch",
|
||||
death: "Death",
|
||||
light: "#ff8742",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
|
||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
|
||||
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 gltf = useGLTF(config.url, false, true);
|
||||
const model = useMemo(() => cloneSkeleton(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;
|
||||
}
|
||||
});
|
||||
if (kind === "vexa") {
|
||||
const authoredHelperBox = model.getObjectByName("Box");
|
||||
if (authoredHelperBox) authoredHelperBox.visible = false;
|
||||
}
|
||||
}, [kind, model]);
|
||||
|
||||
const clipName = phase === "victory" || defeated
|
||||
? config.death
|
||||
: motionMode === "skyfall"
|
||||
? config.move
|
||||
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
|
||||
? config.special
|
||||
: motionMode === "tethering" || motionMode === "venom_cast"
|
||||
? config.attack
|
||||
: config.idle;
|
||||
|
||||
useEffect(() => {
|
||||
const next = actions[clipName];
|
||||
if (!next) return;
|
||||
for (const action of Object.values(actions)) action?.fadeOut(0.16);
|
||||
next.reset().setEffectiveWeight(1).fadeIn(0.16).play();
|
||||
if (phase === "victory" || defeated) {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = true;
|
||||
} else {
|
||||
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
||||
}
|
||||
return () => { next.fadeOut(0.16); };
|
||||
}, [actions, clipName, defeated, phase]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const current = encounterBossAt(state, bossIndex);
|
||||
if (!current) return;
|
||||
const motion = current.motion;
|
||||
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
|
||||
targetPosition.set(motion.position[0], airborne ? 3.2 : 0.03, motion.position[1]);
|
||||
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
||||
|
||||
let targetAngle = Math.atan2(
|
||||
state.partyPositions.brann[0] - motion.position[0],
|
||||
state.partyPositions.brann[1] - motion.position[1],
|
||||
);
|
||||
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
|
||||
targetAngle = motion.breathAngle;
|
||||
}
|
||||
const difference = Math.atan2(
|
||||
Math.sin(targetAngle - group.current.rotation.y),
|
||||
Math.cos(targetAngle - group.current.rotation.y),
|
||||
);
|
||||
group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
|
||||
});
|
||||
|
||||
if (phase === "briefing") return null;
|
||||
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]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function BarrierField() {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const fill = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const innerRing = useRef<THREE.MeshBasicMaterial>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const active = state.phase === "combat" && state.barrier.expiresAt > state.time;
|
||||
group.current.visible = active;
|
||||
if (!active) return;
|
||||
group.current.position.set(state.barrier.center[0], 0.058, state.barrier.center[1]);
|
||||
group.current.rotation.y = clock.elapsedTime * 0.08;
|
||||
if (fill.current) fill.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 2.6) + 1) * 0.035;
|
||||
if (innerRing.current) innerRing.current.opacity = 0.38 + (Math.sin(clock.elapsedTime * 3.2) + 1) * 0.12;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[3, 64]} />
|
||||
<meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[2.92, 3.05, 64]} />
|
||||
<meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[1.82, 1.9, 48]} />
|
||||
<meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
|
||||
</mesh>
|
||||
{Array.from({ length: 8 }, (_, index) => {
|
||||
const angle = (index / 8) * Math.PI * 2;
|
||||
return (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[Math.sin(angle) * 2.35, 0.02, Math.cos(angle) * 2.35]}
|
||||
rotation={[-Math.PI / 2, 0, angle]}
|
||||
>
|
||||
<ringGeometry args={[0.09, 0.16, 6]} />
|
||||
<meshBasicMaterial color="#ffe89a" transparent opacity={0.62} depthWrite={false} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function TankAuraField() {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
if (!group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && state.party[1].hp > 0;
|
||||
group.current.visible = active;
|
||||
if (!active) return;
|
||||
const brann = state.partyPositions.brann;
|
||||
group.current.position.set(brann[0], 0.06, brann[1]);
|
||||
group.current.rotation.y = clock.elapsedTime * -0.22;
|
||||
if (material.current) material.current.opacity = 0.13 + (Math.sin(clock.elapsedTime * 5) + 1) * 0.05;
|
||||
});
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[3, 48]} />
|
||||
<meshBasicMaterial ref={material} color="#67bfff" transparent opacity={0.18} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[2.88, 3.04, 48]} />
|
||||
<meshBasicMaterial color="#8fd5ff" transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
{Array.from({ length: 6 }, (_, index) => {
|
||||
const angle = (index / 6) * Math.PI * 2;
|
||||
return <mesh key={index} position={[Math.sin(angle) * 2.35, 0.025, Math.cos(angle) * 2.35]} rotation={[-Math.PI / 2, 0, angle]}>
|
||||
<ringGeometry args={[0.08, 0.15, 4]} />
|
||||
<meshBasicMaterial color="#d2efff" transparent opacity={0.8} depthWrite={false} />
|
||||
</mesh>;
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const start = useMemo(() => new THREE.Vector3(), []);
|
||||
const end = useMemo(() => new THREE.Vector3(), []);
|
||||
const current = useMemo(() => new THREE.Vector3(), []);
|
||||
const direction = useMemo(() => new THREE.Vector3(), []);
|
||||
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!group.current) return;
|
||||
const state = useGameStore.getState();
|
||||
const action = state.partyCombat.combatants[memberId].visualAction;
|
||||
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||
const rapid = action?.abilityId === "rapid_fire";
|
||||
const active = action !== null
|
||||
&& state.phase === "combat"
|
||||
&& member.hp > 0
|
||||
&& action.abilityId !== "overcharge"
|
||||
&& state.time >= action.startedAt
|
||||
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
|
||||
group.current.visible = active;
|
||||
if (!active || !action) return;
|
||||
|
||||
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
|
||||
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
|
||||
const progress = rapid
|
||||
? ((state.time - action.startedAt) % 0.4) / 0.4
|
||||
: Math.min(1, (state.time - action.startedAt) / projectileDuration);
|
||||
const source = state.partyPositions[memberId];
|
||||
const target = targetMotion.position;
|
||||
start.set(source[0], 1.18, source[1]);
|
||||
end.set(target[0], 1.12, target[1]);
|
||||
current.copy(start).lerp(end, progress);
|
||||
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
|
||||
group.current.position.copy(current);
|
||||
direction.subVectors(end, start).normalize();
|
||||
group.current.quaternion.setFromUnitVectors(up, direction);
|
||||
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
{memberId === "nia" ? (
|
||||
<>
|
||||
<mesh>
|
||||
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
|
||||
<meshBasicMaterial color="#d7b477" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.5, 0]}>
|
||||
<coneGeometry args={[0.085, 0.2, 6]} />
|
||||
<meshBasicMaterial color="#f1ddaa" />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.4, 0]}>
|
||||
<coneGeometry args={[0.1, 0.18, 4]} />
|
||||
<meshBasicMaterial color="#70cf8e" />
|
||||
</mesh>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.19, 12, 10]} />
|
||||
<meshBasicMaterial color="#bc8cff" />
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.25, 0.025, 6, 20]} />
|
||||
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} />
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RangedProjectiles() {
|
||||
return (
|
||||
<>
|
||||
<RangedProjectile memberId="nia" />
|
||||
<RangedProjectile memberId="orin" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BossActor() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const primaryBoss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
if (phase === "briefing") return null;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
return (
|
||||
<>{bosses.map((boss, bossIndex) => (
|
||||
<Suspense key={`${boss.id}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
|
||||
{boss.id === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={boss.id} bossIndex={bossIndex} />}
|
||||
</Suspense>
|
||||
))}</>
|
||||
);
|
||||
}
|
||||
|
||||
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
||||
const ring = useRef<THREE.Mesh>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const age = useRef(0);
|
||||
const isBossFx = kind === "boss";
|
||||
const state = useGameStore.getState();
|
||||
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
|
||||
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
|
||||
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
|
||||
useFrame((_, delta) => {
|
||||
age.current += delta;
|
||||
if (!ring.current || !material.current) return;
|
||||
const progress = Math.min(1, age.current / 0.7);
|
||||
ring.current.scale.setScalar(0.5 + progress * 3.6);
|
||||
material.current.opacity = (1 - progress) * 0.85;
|
||||
});
|
||||
return (
|
||||
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.38, 0.5, 32]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function CombatFx() {
|
||||
const pulse = useGameStore((state) => state.scenePulse);
|
||||
if (!pulse.id) return null;
|
||||
return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
|
||||
}
|
||||
|
||||
export function GameScene() {
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={[1, 1.5]}
|
||||
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
|
||||
gl={{ antialias: true, powerPreference: "high-performance" }}
|
||||
>
|
||||
<color attach="background" args={["#07110f"]} />
|
||||
<fog attach="fog" args={["#07110f", 17, 32]} />
|
||||
<hemisphereLight args={["#8ac4b5", "#15100b", 1.25]} />
|
||||
<directionalLight castShadow position={[5, 10, 8]} intensity={2.2} color="#ffe2a9" shadow-mapSize={[1024, 1024]} />
|
||||
<Arena />
|
||||
<BossMechanicIndicators />
|
||||
<BarrierField />
|
||||
<TankAuraField />
|
||||
<Suspense fallback={<PartyFallback />}>
|
||||
<Party />
|
||||
</Suspense>
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(BULL_URL, false, true);
|
||||
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
|
||||
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
|
||||
useGLTF.preload(loadout.right, false, true);
|
||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||
}
|
||||
Reference in New Issue
Block a user