Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef, type ComponentType } from "react";
|
||||
import * as THREE from "three";
|
||||
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
|
||||
import { CINDER_BREATH } from "../../game/bosses/cindermaw";
|
||||
import { useGameStore } from "../../game/store";
|
||||
|
||||
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
||||
const EMPTY_HAZARDS: never[] = [];
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function motionAt(state: GameStoreState, bossIndex: number) {
|
||||
return bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion;
|
||||
}
|
||||
|
||||
export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
|
||||
const motion = motionAt(useGameStore.getState(), bossIndex);
|
||||
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
if (!warningMaterial.current) return;
|
||||
warningMaterial.current.opacity = motionAt(useGameStore.getState(), bossIndex)?.mode === "charging"
|
||||
? 0.34
|
||||
: 0.16 + (Math.sin(clock.elapsedTime * 9) + 1) * 0.09;
|
||||
});
|
||||
if (!motion || phase !== "combat" || (motionMode !== "telegraph" && motionMode !== "charging")) return null;
|
||||
|
||||
const dx = motion.chargeEnd[0] - motion.chargeStart[0];
|
||||
const dz = motion.chargeEnd[1] - motion.chargeStart[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
const midpoint: [number, number, number] = [
|
||||
(motion.chargeStart[0] + motion.chargeEnd[0]) / 2,
|
||||
0.045,
|
||||
(motion.chargeStart[1] + motion.chargeEnd[1]) / 2,
|
||||
];
|
||||
const color = motionMode === "charging" ? "#ffb04a" : "#ff4f37";
|
||||
const laneWidth = BULL_CHARGE.hitRadius * 2;
|
||||
return (
|
||||
<>
|
||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[laneWidth, length]} />
|
||||
<meshBasicMaterial ref={warningMaterial} color={color} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[-BULL_CHARGE.hitRadius, 0.018, 0]}>
|
||||
<boxGeometry args={[0.055, 0.025, length]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[BULL_CHARGE.hitRadius, 0.018, 0]}>
|
||||
<boxGeometry args={[0.055, 0.025, length]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
{CHARGE_MARKERS.map((index) => (
|
||||
<mesh key={index} position={[0, 0.025, -length / 2 + ((index + 0.55) / 7) * length]}>
|
||||
<boxGeometry args={[BULL_CHARGE.hitRadius, 0.028, 0.12]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.62} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
<mesh position={[motion.chargeEnd[0], 0.055, motion.chargeEnd[1]]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[1.05, BULL_CHARGE.hitRadius, 36]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} depthWrite={false} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
const motion = motionAt(useGameStore.getState(), bossIndex);
|
||||
if (!motion) return;
|
||||
if (group.current) group.current.position.set(motion.pounceCenter[0], 0.052, motion.pounceCenter[1]);
|
||||
if (!warningMaterial.current) return;
|
||||
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
|
||||
});
|
||||
if (phase !== "combat" || motionMode !== "stacking") return null;
|
||||
|
||||
const radius = BULL_POUNCE.stackRadius;
|
||||
return (
|
||||
<group ref={group}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[radius, 48]} />
|
||||
<meshBasicMaterial ref={warningMaterial} color="#ff2f37" transparent opacity={0.2} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[radius - 0.12, radius + 0.02, 48]} />
|
||||
<meshBasicMaterial color="#ff4450" transparent opacity={0.95} depthWrite={false} />
|
||||
</mesh>
|
||||
{STACK_DIRECTIONS.map((angle, index) => (
|
||||
<group
|
||||
key={index}
|
||||
position={[Math.sin(angle) * (radius - 0.32), 0.045, Math.cos(angle) * (radius - 0.32)]}
|
||||
rotation={[0, angle, 0]}
|
||||
>
|
||||
<mesh position={[0, 0, -0.18]}>
|
||||
<boxGeometry args={[0.09, 0.035, 0.38]} />
|
||||
<meshBasicMaterial color="#ff7278" />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, -0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<coneGeometry args={[0.19, 0.38, 4]} />
|
||||
<meshBasicMaterial color="#ff7278" />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motion = useGameStore((state) => motionAt(state, bossIndex));
|
||||
const positions = useGameStore((state) => state.partyPositions);
|
||||
if (!motion || phase !== "combat" || motion.mode !== "tethering" || motion.tetherIds.length !== 2) return null;
|
||||
const [firstId, secondId] = motion.tetherIds;
|
||||
const first = positions[firstId];
|
||||
const second = positions[secondId];
|
||||
const dx = second[0] - first[0];
|
||||
const dz = second[1] - first[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
const stretched = length >= motion.tetherBreakDistance * 0.8;
|
||||
const color = stretched ? "#f2b7ff" : "#ae52ef";
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[(first[0] + second[0]) / 2, 0.12, (first[1] + second[1]) / 2]} rotation={[0, angle, 0]}>
|
||||
<boxGeometry args={[0.1, 0.07, length]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
{[first, second].map((position, index) => (
|
||||
<mesh key={index} position={[position[0], 0.06, position[1]]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.68, 32]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motion = useGameStore((state) => motionAt(state, bossIndex));
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
if (material.current) material.current.opacity = 0.2 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.07;
|
||||
});
|
||||
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
|
||||
const color = motion.mode === "breath_sweeping" ? "#ff7b2e" : "#ffb04f";
|
||||
return (
|
||||
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - CINDER_BREATH.halfAngle]}>
|
||||
<circleGeometry args={[CINDER_BREATH.range, 64, 0, CINDER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, CINDER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[CINDER_BREATH.range * 0.47, CINDER_BREATH.range * 0.49, 48, 1, -CINDER_BREATH.halfAngle, CINDER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; bossIndex: number }) {
|
||||
const hazards = useGameStore((state) => motionAt(state, bossIndex)?.hazards ?? EMPTY_HAZARDS);
|
||||
const hazard = hazards.find((entry) => entry.id === hazardId);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
if (material.current) material.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 7) + 1) * 0.06;
|
||||
});
|
||||
if (!hazard) return null;
|
||||
const active = time >= hazard.activatesAt;
|
||||
const venom = hazard.kind === "venom_pool";
|
||||
const color = venom ? "#a94ee6" : active ? "#ff642d" : "#ffc14d";
|
||||
return (
|
||||
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[hazard.radius, 40]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={active ? 0.24 : 0.16} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[hazard.radius - 0.1, hazard.radius + 0.04, 40]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
{!active && (
|
||||
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.22, 0.34, 24]} />
|
||||
<meshBasicMaterial color="#fff0b0" transparent opacity={0.95} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const hazards = useGameStore((state) => motionAt(state, bossIndex)?.hazards ?? EMPTY_HAZARDS);
|
||||
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
|
||||
}
|
||||
|
||||
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
||||
ChargeLaneIndicator,
|
||||
PounceStackIndicator,
|
||||
BindingWebIndicator,
|
||||
BreathConeIndicator,
|
||||
CircleHazardIndicators,
|
||||
];
|
||||
|
||||
export function BossMechanicIndicators() {
|
||||
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: bossCount }, (_, bossIndex) => (
|
||||
<group key={bossIndex}>
|
||||
{BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
|
||||
</group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user