| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- 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<string, unknown>;
- 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<HTMLElement>,
- options?: UseEnergyStoneOptions
- ) => {
- const eventCallback = options?.onEvent;
- const resolveStoneSize = options?.resolveStoneSize;
- const [stones, setStones] = useState<EnergyStoneView[]>([]);
- const [containerSize, setContainerSize] = useState(FALLBACK_SIZE);
- const stonesRef = useRef<EnergyStoneView[]>([]);
- const timersRef = useRef<Map<string, { fade?: number; remove?: number }>>(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,
- };
- };
|