import { useCallback, useEffect, useRef, useState } from 'react'; import type { EnergyStoneSpawnMessage } from '@/types'; const FALLBACK_SIZE = { width: 320, height: 260 }; const DEFAULT_STONE_SIZES: Record<'mission' | 'conversation', number> = { mission: 86, conversation: 68, }; interface StonePosition { xRatio: number; yRatio: number; } interface StoneSize { width: number; height: number; } interface EnergyStoneView { stoneId: string; stoneType: 'mission' | 'conversation'; position: StonePosition; ttlMs: number; metadata?: Record; fading: boolean; size: StoneSize; } export interface EnergyStoneEventPayload { ratioX: number; ratioY: number; width: number; height: number; } interface UseEnergyStoneOptions { onEvent?: (stoneId: string, event: 'click' | 'timeout', payload: EnergyStoneEventPayload) => void; resolveStoneSize?: (stoneType: 'mission' | 'conversation') => number; } interface StoneRect { left: number; right: number; top: number; bottom: number; } const createRectFromStone = ( stone: EnergyStoneView, container: { width: number; height: number } ): StoneRect => { const centerX = stone.position.xRatio * container.width; const centerY = stone.position.yRatio * container.height; const halfWidth = stone.size.width / 2; const halfHeight = stone.size.height / 2; return { left: centerX - halfWidth, right: centerX + halfWidth, top: centerY - halfHeight, bottom: centerY + halfHeight, }; }; const rectanglesOverlap = (a: StoneRect, b: StoneRect) => !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom); const computePosition = ( existing: EnergyStoneView[], container: { width: number; height: number }, stoneSize: StoneSize ): StonePosition => { const maxAttempts = 24; const width = container.width || FALLBACK_SIZE.width; const height = container.height || FALLBACK_SIZE.height; const halfWidth = stoneSize.width / 2; const halfHeight = stoneSize.height / 2; const horizontalMin = halfWidth; const horizontalMax = Math.max(horizontalMin, width - halfWidth); const verticalMin = Math.max(halfHeight, height * 0.05); const verticalMax = Math.max(verticalMin, Math.min(height * 0.5, height - halfHeight)); let candidate: StonePosition = { xRatio: 0.5, yRatio: 0.2 }; const hasOverlap = (rect: StoneRect) => existing.some((stone) => rectanglesOverlap(rect, createRectFromStone(stone, { width, height }))); // 1) 随机采样若干次,先尝试随机避让 for (let attempt = 0; attempt < maxAttempts; attempt++) { const usableWidth = Math.max(0, horizontalMax - horizontalMin); const usableHeight = Math.max(0, verticalMax - verticalMin); const centerX = usableWidth === 0 ? width / 2 : horizontalMin + Math.random() * usableWidth; const centerY = usableHeight === 0 ? height * 0.2 : verticalMin + Math.random() * usableHeight; const candidateRect: StoneRect = { left: centerX - halfWidth, right: centerX + halfWidth, top: centerY - halfHeight, bottom: centerY + halfHeight, }; candidate = { xRatio: width ? centerX / width : 0.5, yRatio: height ? centerY / height : 0.2, }; if (!hasOverlap(candidateRect)) { return candidate; } } // 2) 如果随机失败,使用网格扫描尝试找到第一个不重叠的点 const usableWidth = Math.max(0, horizontalMax - horizontalMin); const usableHeight = Math.max(0, verticalMax - verticalMin); const cols = Math.max(2, Math.floor(usableWidth / Math.max(halfWidth * 2, 1))); const rows = Math.max(2, Math.floor(usableHeight / Math.max(halfHeight * 2, 1))); const stepX = cols > 1 ? usableWidth / (cols - 1) : usableWidth; const stepY = rows > 1 ? usableHeight / (rows - 1) : usableHeight; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const centerX = horizontalMin + col * stepX; const centerY = verticalMin + row * stepY; const candidateRect: StoneRect = { left: centerX - halfWidth, right: centerX + halfWidth, top: centerY - halfHeight, bottom: centerY + halfHeight, }; if (!hasOverlap(candidateRect)) { return { xRatio: width ? centerX / width : 0.5, yRatio: height ? centerY / height : 0.2, }; } } } // 3) 实在找不到,就使用最后一次随机的候选(可能存在轻微重叠,但概率更低) return candidate; }; export const useEnergyStones = ( containerRef: React.RefObject, options?: UseEnergyStoneOptions ) => { const eventCallback = options?.onEvent; const resolveStoneSize = options?.resolveStoneSize; const [stones, setStones] = useState([]); const [containerSize, setContainerSize] = useState(FALLBACK_SIZE); const stonesRef = useRef([]); const timersRef = useRef>(new Map()); useEffect(() => { stonesRef.current = stones; }, [stones]); useEffect(() => { const node = containerRef.current; if (!node) { return; } const updateSize = () => { const rect = node.getBoundingClientRect(); if (rect.width && rect.height) { setContainerSize({ width: rect.width, height: rect.height }); } }; if (typeof ResizeObserver === 'undefined') { updateSize(); return; } const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry?.contentRect) { const { width, height } = entry.contentRect; if (width && height) { setContainerSize({ width, height }); } } }); observer.observe(node); updateSize(); return () => observer.disconnect(); }, [containerRef]); useEffect(() => { return () => { timersRef.current.forEach(({ fade, remove }) => { if (fade) { clearTimeout(fade); } if (remove) { clearTimeout(remove); } }); timersRef.current.clear(); }; }, []); const clearTimers = useCallback((stoneId: string) => { const timers = timersRef.current.get(stoneId); if (timers?.fade) { clearTimeout(timers.fade); } if (timers?.remove) { clearTimeout(timers.remove); } timersRef.current.delete(stoneId); }, []); const toEventPayload = useCallback( (stone: EnergyStoneView): EnergyStoneEventPayload => { const bounds = containerRef.current?.getBoundingClientRect(); const width = bounds?.width || containerSize.width; const height = bounds?.height || containerSize.height; return { ratioX: stone.position.xRatio, ratioY: stone.position.yRatio, width, height, }; }, [containerRef, containerSize] ); const scheduleLifecycle = useCallback( (stone: EnergyStoneView) => { // 超时后先设置 fading 状态,然后延迟移除 const fadeTimer = window.setTimeout(() => { setStones((prev) => prev.map((item) => item.stoneId === stone.stoneId ? { ...item, fading: true } : item ) ); }, stone.ttlMs - 1000); // 提前 1 秒开始动画 const removeTimer = window.setTimeout(() => { setStones((prev) => prev.filter((item) => item.stoneId !== stone.stoneId)); timersRef.current.delete(stone.stoneId); if (eventCallback) { eventCallback(stone.stoneId, 'timeout', toEventPayload(stone)); } }, stone.ttlMs); timersRef.current.set(stone.stoneId, { fade: fadeTimer, remove: removeTimer }); }, [eventCallback, toEventPayload] ); const spawnStone = useCallback( (message: EnergyStoneSpawnMessage) => { if (!containerRef.current) { return; } const exists = stonesRef.current.some((stone) => stone.stoneId === message.stoneId); if (exists) { return; } const bounds = containerRef.current.getBoundingClientRect(); const width = bounds.width || containerSize.width; const height = bounds.height || containerSize.height; const resolvedSize = resolveStoneSize?.(message.stoneType) ?? DEFAULT_STONE_SIZES[message.stoneType]; const stoneSize: StoneSize = { width: resolvedSize, height: resolvedSize, }; const position = computePosition(stonesRef.current, { width, height }, stoneSize); const ttlMs = Math.max(4000, (message.ttlSeconds ?? 20) * 1000); const stone: EnergyStoneView = { stoneId: message.stoneId, stoneType: message.stoneType, position, ttlMs, metadata: message.metadata ?? {}, fading: false, size: stoneSize, }; const nextStones = [...stonesRef.current, stone]; stonesRef.current = nextStones; setStones(nextStones); scheduleLifecycle(stone); }, [ containerRef, containerSize.height, containerSize.width, resolveStoneSize, scheduleLifecycle, ] ); const handleStoneClick = useCallback( (stoneId: string) => { const stone = stonesRef.current.find((item) => item.stoneId === stoneId); if (!stone) { return; } clearTimers(stoneId); // 先设置 fading 状态,触发动画 setStones((prev) => prev.map((item) => item.stoneId === stoneId ? { ...item, fading: true } : item ) ); // 延迟 2 秒后移除能量石(留足动画时间,避免过早移除) const removeTimer = window.setTimeout(() => { setStones((prev) => prev.filter((item) => item.stoneId !== stoneId)); timersRef.current.delete(stoneId); if (eventCallback) { eventCallback(stoneId, 'click', toEventPayload(stone)); } }, 2000); timersRef.current.set(stoneId, { remove: removeTimer }); }, [clearTimers, eventCallback, toEventPayload] ); return { stones, spawnStone, handleStoneClick, }; };