Release v0.1.4 2026-07-12

This commit is contained in:
Warren H
2026-07-12 13:49:46 -04:00
parent b48b3a4f8f
commit bef71d391a
803 changed files with 3800 additions and 358322 deletions
+289 -5
View File
@@ -2,17 +2,31 @@ 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 { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
import { useGameStore } from "../../game/store";
import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
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[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const EMPTY_POOL_TELEGRAPHS: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
const SOAK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const WARD_ARROW_DIRECTIONS = [0, Math.PI / 2, Math.PI, Math.PI * 1.5] as const;
const INWARD_ARROW_SHAPE = new THREE.Shape()
.moveTo(-0.13, -0.32)
.lineTo(0.13, -0.32)
.lineTo(0.13, 0.04)
.lineTo(0.29, 0.04)
.lineTo(0, 0.38)
.lineTo(-0.29, 0.04)
.lineTo(-0.13, 0.04)
.lineTo(-0.13, -0.32);
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
@@ -212,12 +226,12 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
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]} />
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_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]} />
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
@@ -276,6 +290,274 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
}
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const wardGroup = useRef<THREE.Group>(null);
const ghostGroup = useRef<THREE.Group>(null);
const outerColumn = useRef<THREE.MeshBasicMaterial>(null);
const innerColumn = useRef<THREE.MeshBasicMaterial>(null);
const groundRing = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
const current = motionAt(useGameStore.getState(), bossIndex);
const siphon = current?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
const active = useGameStore.getState().phase === "combat" && siphon;
if (!active || !siphon) return;
const pulse = (Math.sin(clock.elapsedTime * 5.5) + 1) * 0.5;
if (wardGroup.current) {
wardGroup.current.position.set(siphon.wardPosition[0], 0, siphon.wardPosition[1]);
wardGroup.current.rotation.y += 0.003;
}
if (ghostGroup.current) {
ghostGroup.current.position.set(siphon.ghostPosition[0], 0.85 + Math.sin(clock.elapsedTime * 4.2) * 0.12, siphon.ghostPosition[1]);
ghostGroup.current.rotation.y += 0.018;
}
if (outerColumn.current) outerColumn.current.opacity = 0.15 + pulse * 0.09;
if (innerColumn.current) innerColumn.current.opacity = 0.2 + pulse * 0.16;
if (groundRing.current) groundRing.current.opacity = 0.62 + pulse * 0.28;
});
const siphon = motion?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
if (!siphon || phase !== "combat") return null;
return (
<>
<group ref={wardGroup} position={[siphon.wardPosition[0], 0, siphon.wardPosition[1]]}>
<mesh position={[0, 2.45, 0]}>
<cylinderGeometry args={[0.78, 1.05, 4.9, 28, 1, true]} />
<meshBasicMaterial ref={outerColumn} color="#f8c94e" transparent opacity={0.2} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 2.45, 0]}>
<cylinderGeometry args={[0.38, 0.58, 4.9, 24, 1, true]} />
<meshBasicMaterial ref={innerColumn} color="#fff0a3" transparent opacity={0.32} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 0.045, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[siphon.wardRadius - 0.13, siphon.wardRadius, 40]} />
<meshBasicMaterial ref={groundRing} color="#ffe47d" transparent opacity={0.9} depthWrite={false} />
</mesh>
{WARD_ARROW_DIRECTIONS.map((angle) => (
<group
key={angle}
position={[Math.sin(angle) * 1.58, 0.062, Math.cos(angle) * 1.58]}
rotation={[0, angle, 0]}
>
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial color="#54ff90" transparent opacity={1} depthWrite={false} depthTest={false} />
</mesh>
</group>
))}
</group>
<group ref={ghostGroup} position={[siphon.ghostPosition[0], 0.85, siphon.ghostPosition[1]]}>
<mesh>
<sphereGeometry args={[0.34, 16, 12]} />
<meshBasicMaterial color="#b48aff" transparent opacity={0.68} depthWrite={false} />
</mesh>
<mesh position={[0, -0.52, 0]} scale={[0.82, 1.45, 0.82]}>
<coneGeometry args={[0.34, 0.78, 12, 1, true]} />
<meshBasicMaterial color="#8d62d2" transparent opacity={0.36} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.48, 0.026, 8, 24]} />
<meshBasicMaterial color="#e1c4ff" transparent opacity={0.72} depthWrite={false} />
</mesh>
</group>
</>
);
}
function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySymbolId; size?: number; opacity?: number }) {
const color = MEMORY_SYMBOLS[symbol].color;
if (symbol === "cross") {
return (
<group position={[0, 0.012, 0]}>
<mesh>
<boxGeometry args={[size * 0.22, 0.035, size]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
<mesh>
<boxGeometry args={[size, 0.035, size * 0.22]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
</group>
);
}
return (
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, symbol === "square" ? Math.PI / 4 : -Math.PI / 2]}>
<ringGeometry args={[size * 0.34, size * 0.54, symbol === "circle" ? 36 : symbol === "triangle" ? 3 : 4]} />
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
</mesh>
);
}
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
const color = MEMORY_SYMBOLS[tile.symbol].color;
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
const gridOffsets = [-0.48, 0, 0.48] as const;
return (
<group position={[tile.center[0], 0.075, tile.center[1]]}>
<mesh>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<group key={side}>
<mesh position={[side * halfSize, 0.03, 0]}>
<boxGeometry args={[0.08, 0.06, MEMORY_SEQUENCE.tileSize + 0.08]} />
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
</mesh>
<mesh position={[0, 0.03, side * halfSize]}>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize + 0.08, 0.06, 0.08]} />
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
</mesh>
</group>
))}
{gridOffsets.map((offset) => (
<group key={offset}>
<mesh position={[0, 0.026, offset * MEMORY_SEQUENCE.tileSize]}>
<boxGeometry args={[MEMORY_SEQUENCE.tileSize * 0.84, 0.018, 0.026]} />
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
</mesh>
<mesh position={[offset * MEMORY_SEQUENCE.tileSize, 0.027, 0]}>
<boxGeometry args={[0.026, 0.018, MEMORY_SEQUENCE.tileSize * 0.84]} />
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
</mesh>
</group>
))}
<MemorySymbolMark symbol={tile.symbol} size={0.78} opacity={1} />
</group>
);
}
function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph: PoolTelegraph; bossPosition?: [number, number]; time: number }) {
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined) return null;
const showingSequence = time < telegraph.inputStartsAt;
const flashIndex = Math.min(
telegraph.sequence.length - 1,
Math.max(0, Math.floor((time - telegraph.activatesAt) / MEMORY_SEQUENCE.flashDuration)),
);
const flashSymbol = telegraph.sequence[flashIndex];
const source = bossPosition ?? telegraph.center;
return (
<>
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
{showingSequence && (
<group position={[source[0], 2.5, source[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.88, 32]} />
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
</mesh>
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.8, 0.9, 32]} />
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
</mesh>
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
</group>
)}
</>
);
}
function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: string; bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const time = useGameStore((state) => state.time);
const telegraph = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId));
const bossPosition = useGameStore((state) => motionAt(state, bossIndex)?.position);
const fillMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!fillMaterial.current) return;
const current = motionAt(useGameStore.getState(), bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId);
if (!current) return;
const active = useGameStore.getState().time >= current.activatesAt;
fillMaterial.current.opacity = active ? 0.46 : 0.14 + (Math.sin(clock.elapsedTime * 10) + 1) * 0.08;
});
if (!telegraph || phase !== "combat") return null;
if (telegraph.kind === "memory") return <MemorySequenceIndicator telegraph={telegraph} bossPosition={bossPosition} time={time} />;
if (telegraph.kind === "soul-siphon") return null;
const active = time >= telegraph.activatesAt;
const color = telegraph.kind === "soak" ? "#57e8ff"
: telegraph.kind === "spread" ? "#e869ff"
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
const dx = telegraph.end[0] - telegraph.start[0];
const dz = telegraph.end[1] - telegraph.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
return (
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
);
}
const innerRadius = telegraph.innerRadius ?? 0;
return (
<group position={[telegraph.center[0], 0.07, telegraph.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
{telegraph.kind === "donut"
? <ringGeometry args={[innerRadius, telegraph.radius, 56]} />
: <circleGeometry args={[telegraph.radius, 48]} />}
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[telegraph.radius - 0.1, telegraph.radius + 0.04, 48]} />
<meshBasicMaterial color={color} transparent opacity={0.94} depthWrite={false} />
</mesh>
{telegraph.kind === "donut" && (
<mesh position={[0, 0.021, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[innerRadius - 0.05, innerRadius + 0.05, 40]} />
<meshBasicMaterial color="#f9efcf" transparent opacity={0.8} depthWrite={false} />
</mesh>
)}
{telegraph.kind === "spread" && (
<>
<mesh position={[0, 0.028, 0]}>
<boxGeometry args={[telegraph.radius * 1.35, 0.032, 0.07]} />
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
</mesh>
<mesh position={[0, 0.03, 0]}>
<boxGeometry args={[0.07, 0.032, telegraph.radius * 1.35]} />
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
</mesh>
</>
)}
{telegraph.kind === "soak" && SOAK_DIRECTIONS.map((angle, index) => (
<group key={index} rotation={[0, angle, 0]} position={[0, 0.035, 0]}>
<mesh position={[0, 0, -telegraph.radius + 0.32]} rotation={[-Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.16, 0.34, 3]} />
<meshBasicMaterial color="#c9fbff" transparent opacity={0.96} />
</mesh>
</group>
))}
</group>
);
}
export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const telegraphs = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs ?? EMPTY_POOL_TELEGRAPHS);
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
}
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator,
SlashLaneIndicators,
@@ -283,6 +565,8 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
BindingWebIndicator,
BreathConeIndicator,
CircleHazardIndicators,
SoulSiphonIndicator,
PooledMechanicIndicators,
];
export function BossMechanicIndicators() {