useEnergyStones.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import { useCallback, useEffect, useRef, useState } from 'react';
  2. import type { EnergyStoneSpawnMessage } from '@/types';
  3. const FALLBACK_SIZE = { width: 320, height: 260 };
  4. const DEFAULT_STONE_SIZES: Record<'mission' | 'conversation', number> = {
  5. mission: 86,
  6. conversation: 68,
  7. };
  8. interface StonePosition {
  9. xRatio: number;
  10. yRatio: number;
  11. }
  12. interface StoneSize {
  13. width: number;
  14. height: number;
  15. }
  16. interface EnergyStoneView {
  17. stoneId: string;
  18. stoneType: 'mission' | 'conversation';
  19. position: StonePosition;
  20. ttlMs: number;
  21. metadata?: Record<string, unknown>;
  22. fading: boolean;
  23. size: StoneSize;
  24. }
  25. export interface EnergyStoneEventPayload {
  26. ratioX: number;
  27. ratioY: number;
  28. width: number;
  29. height: number;
  30. }
  31. interface UseEnergyStoneOptions {
  32. onEvent?: (stoneId: string, event: 'click' | 'timeout', payload: EnergyStoneEventPayload) => void;
  33. resolveStoneSize?: (stoneType: 'mission' | 'conversation') => number;
  34. }
  35. interface StoneRect {
  36. left: number;
  37. right: number;
  38. top: number;
  39. bottom: number;
  40. }
  41. const createRectFromStone = (
  42. stone: EnergyStoneView,
  43. container: { width: number; height: number }
  44. ): StoneRect => {
  45. const centerX = stone.position.xRatio * container.width;
  46. const centerY = stone.position.yRatio * container.height;
  47. const halfWidth = stone.size.width / 2;
  48. const halfHeight = stone.size.height / 2;
  49. return {
  50. left: centerX - halfWidth,
  51. right: centerX + halfWidth,
  52. top: centerY - halfHeight,
  53. bottom: centerY + halfHeight,
  54. };
  55. };
  56. const rectanglesOverlap = (a: StoneRect, b: StoneRect) =>
  57. !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
  58. const computePosition = (
  59. existing: EnergyStoneView[],
  60. container: { width: number; height: number },
  61. stoneSize: StoneSize
  62. ): StonePosition => {
  63. const maxAttempts = 24;
  64. const width = container.width || FALLBACK_SIZE.width;
  65. const height = container.height || FALLBACK_SIZE.height;
  66. const halfWidth = stoneSize.width / 2;
  67. const halfHeight = stoneSize.height / 2;
  68. const horizontalMin = halfWidth;
  69. const horizontalMax = Math.max(horizontalMin, width - halfWidth);
  70. const verticalMin = Math.max(halfHeight, height * 0.05);
  71. const verticalMax = Math.max(verticalMin, Math.min(height * 0.5, height - halfHeight));
  72. let candidate: StonePosition = { xRatio: 0.5, yRatio: 0.2 };
  73. const hasOverlap = (rect: StoneRect) =>
  74. existing.some((stone) => rectanglesOverlap(rect, createRectFromStone(stone, { width, height })));
  75. // 1) 随机采样若干次,先尝试随机避让
  76. for (let attempt = 0; attempt < maxAttempts; attempt++) {
  77. const usableWidth = Math.max(0, horizontalMax - horizontalMin);
  78. const usableHeight = Math.max(0, verticalMax - verticalMin);
  79. const centerX =
  80. usableWidth === 0 ? width / 2 : horizontalMin + Math.random() * usableWidth;
  81. const centerY =
  82. usableHeight === 0 ? height * 0.2 : verticalMin + Math.random() * usableHeight;
  83. const candidateRect: StoneRect = {
  84. left: centerX - halfWidth,
  85. right: centerX + halfWidth,
  86. top: centerY - halfHeight,
  87. bottom: centerY + halfHeight,
  88. };
  89. candidate = {
  90. xRatio: width ? centerX / width : 0.5,
  91. yRatio: height ? centerY / height : 0.2,
  92. };
  93. if (!hasOverlap(candidateRect)) {
  94. return candidate;
  95. }
  96. }
  97. // 2) 如果随机失败,使用网格扫描尝试找到第一个不重叠的点
  98. const usableWidth = Math.max(0, horizontalMax - horizontalMin);
  99. const usableHeight = Math.max(0, verticalMax - verticalMin);
  100. const cols = Math.max(2, Math.floor(usableWidth / Math.max(halfWidth * 2, 1)));
  101. const rows = Math.max(2, Math.floor(usableHeight / Math.max(halfHeight * 2, 1)));
  102. const stepX = cols > 1 ? usableWidth / (cols - 1) : usableWidth;
  103. const stepY = rows > 1 ? usableHeight / (rows - 1) : usableHeight;
  104. for (let row = 0; row < rows; row++) {
  105. for (let col = 0; col < cols; col++) {
  106. const centerX = horizontalMin + col * stepX;
  107. const centerY = verticalMin + row * stepY;
  108. const candidateRect: StoneRect = {
  109. left: centerX - halfWidth,
  110. right: centerX + halfWidth,
  111. top: centerY - halfHeight,
  112. bottom: centerY + halfHeight,
  113. };
  114. if (!hasOverlap(candidateRect)) {
  115. return {
  116. xRatio: width ? centerX / width : 0.5,
  117. yRatio: height ? centerY / height : 0.2,
  118. };
  119. }
  120. }
  121. }
  122. // 3) 实在找不到,就使用最后一次随机的候选(可能存在轻微重叠,但概率更低)
  123. return candidate;
  124. };
  125. export const useEnergyStones = (
  126. containerRef: React.RefObject<HTMLElement>,
  127. options?: UseEnergyStoneOptions
  128. ) => {
  129. const eventCallback = options?.onEvent;
  130. const resolveStoneSize = options?.resolveStoneSize;
  131. const [stones, setStones] = useState<EnergyStoneView[]>([]);
  132. const [containerSize, setContainerSize] = useState(FALLBACK_SIZE);
  133. const stonesRef = useRef<EnergyStoneView[]>([]);
  134. const timersRef = useRef<Map<string, { fade?: number; remove?: number }>>(new Map());
  135. useEffect(() => {
  136. stonesRef.current = stones;
  137. }, [stones]);
  138. useEffect(() => {
  139. const node = containerRef.current;
  140. if (!node) {
  141. return;
  142. }
  143. const updateSize = () => {
  144. const rect = node.getBoundingClientRect();
  145. if (rect.width && rect.height) {
  146. setContainerSize({ width: rect.width, height: rect.height });
  147. }
  148. };
  149. if (typeof ResizeObserver === 'undefined') {
  150. updateSize();
  151. return;
  152. }
  153. const observer = new ResizeObserver((entries) => {
  154. const entry = entries[0];
  155. if (entry?.contentRect) {
  156. const { width, height } = entry.contentRect;
  157. if (width && height) {
  158. setContainerSize({ width, height });
  159. }
  160. }
  161. });
  162. observer.observe(node);
  163. updateSize();
  164. return () => observer.disconnect();
  165. }, [containerRef]);
  166. useEffect(() => {
  167. return () => {
  168. timersRef.current.forEach(({ fade, remove }) => {
  169. if (fade) {
  170. clearTimeout(fade);
  171. }
  172. if (remove) {
  173. clearTimeout(remove);
  174. }
  175. });
  176. timersRef.current.clear();
  177. };
  178. }, []);
  179. const clearTimers = useCallback((stoneId: string) => {
  180. const timers = timersRef.current.get(stoneId);
  181. if (timers?.fade) {
  182. clearTimeout(timers.fade);
  183. }
  184. if (timers?.remove) {
  185. clearTimeout(timers.remove);
  186. }
  187. timersRef.current.delete(stoneId);
  188. }, []);
  189. const toEventPayload = useCallback(
  190. (stone: EnergyStoneView): EnergyStoneEventPayload => {
  191. const bounds = containerRef.current?.getBoundingClientRect();
  192. const width = bounds?.width || containerSize.width;
  193. const height = bounds?.height || containerSize.height;
  194. return {
  195. ratioX: stone.position.xRatio,
  196. ratioY: stone.position.yRatio,
  197. width,
  198. height,
  199. };
  200. },
  201. [containerRef, containerSize]
  202. );
  203. const scheduleLifecycle = useCallback(
  204. (stone: EnergyStoneView) => {
  205. // 超时后先设置 fading 状态,然后延迟移除
  206. const fadeTimer = window.setTimeout(() => {
  207. setStones((prev) =>
  208. prev.map((item) =>
  209. item.stoneId === stone.stoneId ? { ...item, fading: true } : item
  210. )
  211. );
  212. }, stone.ttlMs - 1000); // 提前 1 秒开始动画
  213. const removeTimer = window.setTimeout(() => {
  214. setStones((prev) => prev.filter((item) => item.stoneId !== stone.stoneId));
  215. timersRef.current.delete(stone.stoneId);
  216. if (eventCallback) {
  217. eventCallback(stone.stoneId, 'timeout', toEventPayload(stone));
  218. }
  219. }, stone.ttlMs);
  220. timersRef.current.set(stone.stoneId, { fade: fadeTimer, remove: removeTimer });
  221. },
  222. [eventCallback, toEventPayload]
  223. );
  224. const spawnStone = useCallback(
  225. (message: EnergyStoneSpawnMessage) => {
  226. if (!containerRef.current) {
  227. return;
  228. }
  229. const exists = stonesRef.current.some((stone) => stone.stoneId === message.stoneId);
  230. if (exists) {
  231. return;
  232. }
  233. const bounds = containerRef.current.getBoundingClientRect();
  234. const width = bounds.width || containerSize.width;
  235. const height = bounds.height || containerSize.height;
  236. const resolvedSize =
  237. resolveStoneSize?.(message.stoneType) ?? DEFAULT_STONE_SIZES[message.stoneType];
  238. const stoneSize: StoneSize = {
  239. width: resolvedSize,
  240. height: resolvedSize,
  241. };
  242. const position = computePosition(stonesRef.current, { width, height }, stoneSize);
  243. const ttlMs = Math.max(4000, (message.ttlSeconds ?? 20) * 1000);
  244. const stone: EnergyStoneView = {
  245. stoneId: message.stoneId,
  246. stoneType: message.stoneType,
  247. position,
  248. ttlMs,
  249. metadata: message.metadata ?? {},
  250. fading: false,
  251. size: stoneSize,
  252. };
  253. const nextStones = [...stonesRef.current, stone];
  254. stonesRef.current = nextStones;
  255. setStones(nextStones);
  256. scheduleLifecycle(stone);
  257. },
  258. [
  259. containerRef,
  260. containerSize.height,
  261. containerSize.width,
  262. resolveStoneSize,
  263. scheduleLifecycle,
  264. ]
  265. );
  266. const handleStoneClick = useCallback(
  267. (stoneId: string) => {
  268. const stone = stonesRef.current.find((item) => item.stoneId === stoneId);
  269. if (!stone) {
  270. return;
  271. }
  272. clearTimers(stoneId);
  273. // 先设置 fading 状态,触发动画
  274. setStones((prev) =>
  275. prev.map((item) =>
  276. item.stoneId === stoneId ? { ...item, fading: true } : item
  277. )
  278. );
  279. // 延迟 2 秒后移除能量石(留足动画时间,避免过早移除)
  280. const removeTimer = window.setTimeout(() => {
  281. setStones((prev) => prev.filter((item) => item.stoneId !== stoneId));
  282. timersRef.current.delete(stoneId);
  283. if (eventCallback) {
  284. eventCallback(stoneId, 'click', toEventPayload(stone));
  285. }
  286. }, 2000);
  287. timersRef.current.set(stoneId, { remove: removeTimer });
  288. },
  289. [clearTimers, eventCallback, toEventPayload]
  290. );
  291. return {
  292. stones,
  293. spawnStone,
  294. handleStoneClick,
  295. };
  296. };