Update 3D game 2026-07-10 21:20

This commit is contained in:
Warren H
2026-07-10 21:20:17 -04:00
parent 141ec64963
commit e0be0458aa
720 changed files with 366857 additions and 0 deletions
@@ -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>
))}
</>
);
}