RedpointManager.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. import { Node } from 'cc';
  2. export type RedpointCallback = (count: number, action: string) => void;
  3. export type RedpointListener = Node | RedpointCallback;
  4. interface ListenerRecord {
  5. listener: RedpointListener;
  6. target?: unknown;
  7. }
  8. /**
  9. * 红点数据及红点间聚合关系管理器。
  10. *
  11. * 未分组的红点数量由 update 直接设置;分组后的父红点数量始终等于
  12. * 所有直接子红点的数量之和,并会沿父级关系自动向上传播。
  13. */
  14. export class RedpointManager {
  15. /** update 写入的原始数量。父红点一旦分组,其显示数量不再使用该值。 */
  16. private readonly sourceCounts = new Map<string, number>();
  17. /** 对外可见的最终数量(包含分组汇总结果)。 */
  18. private readonly counts = new Map<string, number>();
  19. private readonly children = new Map<string, Set<string>>();
  20. private readonly parents = new Map<string, Set<string>>();
  21. private readonly listeners = new Map<string, Set<ListenerRecord>>();
  22. private readonly refreshQueue: string[] = [];
  23. private refreshing = false;
  24. /**
  25. * 更新某个红点的数量。
  26. * 负数及非有限数会按 0 处理;父红点的最终数量仍由其子红点决定。
  27. */
  28. update(action: string, count: number): void {
  29. const normalizedCount = Number.isFinite(count) ? Math.max(0, count) : 0;
  30. this.sourceCounts.set(action, normalizedCount);
  31. this.refresh(action);
  32. }
  33. /** 获取红点当前的最终数量。 */
  34. getCount(action: string): number {
  35. return this.counts.get(action) ?? 0;
  36. }
  37. /**
  38. * 绑定红点组关系。重复调用会在已有关系上追加(重复项会被忽略)。
  39. * main 的数量等于其所有 sub 的数量之和,支持多级分组。
  40. */
  41. group(main: string, ...sub: string[]): void {
  42. let mainChildren = this.children.get(main);
  43. let changed = false;
  44. for (const child of sub) {
  45. if (child === main || this.hasDescendant(child, main)) {
  46. console.warn(`[RedpointManager] ignored circular group relation: ${main} -> ${child}`);
  47. continue;
  48. }
  49. if (!mainChildren) {
  50. mainChildren = new Set<string>();
  51. this.children.set(main, mainChildren);
  52. }
  53. if (mainChildren.has(child)) continue;
  54. mainChildren.add(child);
  55. let childParents = this.parents.get(child);
  56. if (!childParents) {
  57. childParents = new Set<string>();
  58. this.parents.set(child, childParents);
  59. }
  60. childParents.add(main);
  61. changed = true;
  62. }
  63. if (changed) this.refresh(main);
  64. }
  65. /**
  66. * 添加红点监听。
  67. * - 绑定 Node 时,数量大于 0 显示,等于 0 隐藏。
  68. * - 绑定回调时,回调参数为 (count, action),并使用 target 作为 this。
  69. *
  70. * 监听添加后会立即同步一次当前状态;相同 listener + target 不会重复添加。
  71. */
  72. on(action: string, listener: Node, target?: unknown): void;
  73. on(action: string, listener: RedpointCallback, target?: unknown): void;
  74. on(action: string, listener: RedpointListener, target?: unknown): void {
  75. if (!(listener instanceof Node) && typeof listener !== 'function') {
  76. console.error(`[RedpointManager] ignored invalid listener: ${action}`);
  77. return;
  78. }
  79. if (listener instanceof Node && !listener.isValid) return;
  80. // Node 未显式传 owner 时以自身作为 target,便于 offTarget(node) 清理。
  81. const owner = arguments.length >= 3
  82. ? target
  83. : (listener instanceof Node ? listener : undefined);
  84. let bucket = this.listeners.get(action);
  85. if (!bucket) {
  86. bucket = new Set<ListenerRecord>();
  87. this.listeners.set(action, bucket);
  88. }
  89. for (const record of bucket) {
  90. if (record.listener === listener && record.target === owner) return;
  91. }
  92. const record: ListenerRecord = { listener, target: owner };
  93. bucket.add(record);
  94. this.dispatch(action, record, this.getCount(action));
  95. }
  96. /**
  97. * 移除监听。
  98. * - off(action):移除该红点的全部监听。
  99. * - off(action, listener):移除该 listener 在该红点上的全部监听。
  100. * - off(action, listener, target):只移除完全匹配的监听。
  101. * - off(action, undefined, target):移除 target 在该红点上的全部监听。
  102. * - off(target):移除该 target 在所有红点上的全部监听。
  103. */
  104. off(target: object): void;
  105. off(action: string, listener?: RedpointListener, target?: unknown): void;
  106. off(actionOrTarget: string | object, listener?: RedpointListener, target?: unknown): void {
  107. if (typeof actionOrTarget !== 'string') {
  108. this.offTarget(actionOrTarget);
  109. return;
  110. }
  111. const action = actionOrTarget;
  112. const bucket = this.listeners.get(action);
  113. if (!bucket) return;
  114. const filterByListener = listener !== undefined;
  115. const filterByTarget = arguments.length >= 3;
  116. if (!filterByListener && !filterByTarget) {
  117. this.listeners.delete(action);
  118. return;
  119. }
  120. for (const record of bucket) {
  121. if (filterByListener && record.listener !== listener) continue;
  122. if (filterByTarget && record.target !== target) continue;
  123. bucket.delete(record);
  124. }
  125. if (bucket.size === 0) this.listeners.delete(action);
  126. }
  127. /** 移除 on 时使用同一 target 添加的所有监听(跨所有 action)。 */
  128. offTarget(target: unknown): void {
  129. for (const [action, bucket] of this.listeners) {
  130. for (const record of bucket) {
  131. if (record.target === target) bucket.delete(record);
  132. }
  133. if (bucket.size === 0) this.listeners.delete(action);
  134. }
  135. }
  136. /** 与 Cocos EventTarget 命名保持兼容。 */
  137. targetOff(target: unknown): void {
  138. this.offTarget(target);
  139. }
  140. /** 清空所有数量、分组关系和监听。 */
  141. clear(): void {
  142. this.sourceCounts.clear();
  143. this.counts.clear();
  144. this.children.clear();
  145. this.parents.clear();
  146. this.listeners.clear();
  147. this.refreshQueue.length = 0;
  148. }
  149. /** 刷新 action 及其所有祖先;所有数量先稳定,再统一派发通知。 */
  150. private refresh(action: string): void {
  151. this.refreshQueue.push(action);
  152. if (this.refreshing) return;
  153. this.refreshing = true;
  154. try {
  155. while (this.refreshQueue.length > 0) {
  156. this.performRefresh(this.refreshQueue.shift()!);
  157. }
  158. } finally {
  159. this.refreshing = false;
  160. }
  161. }
  162. private performRefresh(action: string): void {
  163. const affected = this.collectAffected(action);
  164. const memo = new Map<string, number>();
  165. const changed: string[] = [];
  166. for (const current of affected) {
  167. const nextCount = this.resolveCount(current, memo, new Set<string>());
  168. const previousCount = this.counts.get(current) ?? 0;
  169. this.counts.set(current, nextCount);
  170. if (nextCount !== previousCount) changed.push(current);
  171. }
  172. for (const current of changed) {
  173. this.emit(current, this.counts.get(current) ?? 0);
  174. }
  175. }
  176. /** 按 action -> 直接父级 -> 更高父级的顺序收集,保证通知顺序可预期。 */
  177. private collectAffected(action: string): string[] {
  178. const result: string[] = [];
  179. const visited = new Set<string>();
  180. const pending = [action];
  181. while (pending.length > 0) {
  182. const current = pending.shift()!;
  183. if (visited.has(current)) continue;
  184. visited.add(current);
  185. result.push(current);
  186. for (const parent of this.parents.get(current) ?? []) pending.push(parent);
  187. }
  188. return result;
  189. }
  190. private resolveCount(action: string, memo: Map<string, number>, visiting: Set<string>): number {
  191. const cached = memo.get(action);
  192. if (cached !== undefined) return cached;
  193. const actionChildren = this.children.get(action);
  194. if (!actionChildren || actionChildren.size === 0) {
  195. const count = this.sourceCounts.get(action) ?? 0;
  196. memo.set(action, count);
  197. return count;
  198. }
  199. // group 已阻止循环;这里保留保护,避免异常数据导致无限递归。
  200. if (visiting.has(action)) return 0;
  201. visiting.add(action);
  202. let count = 0;
  203. for (const child of actionChildren) {
  204. count += this.resolveCount(child, memo, visiting);
  205. }
  206. visiting.delete(action);
  207. memo.set(action, count);
  208. return count;
  209. }
  210. private hasDescendant(action: string, expected: string): boolean {
  211. const visited = new Set<string>();
  212. const pending = [action];
  213. while (pending.length > 0) {
  214. const current = pending.pop()!;
  215. if (current === expected) return true;
  216. if (visited.has(current)) continue;
  217. visited.add(current);
  218. for (const child of this.children.get(current) ?? []) pending.push(child);
  219. }
  220. return false;
  221. }
  222. private emit(action: string, count: number): void {
  223. const bucket = this.listeners.get(action);
  224. if (!bucket) return;
  225. for (const record of [...bucket]) {
  226. if (!this.dispatch(action, record, count)) bucket.delete(record);
  227. }
  228. if (bucket.size === 0) this.listeners.delete(action);
  229. }
  230. /** 返回 false 表示监听对象已失效,应自动清理。 */
  231. private dispatch(action: string, record: ListenerRecord, count: number): boolean {
  232. if (record.listener instanceof Node) {
  233. if (!record.listener.isValid) return false;
  234. record.listener.active = count > 0;
  235. return true;
  236. }
  237. try {
  238. record.listener.call(record.target, count, action);
  239. } catch (error) {
  240. console.error(`[RedpointManager] listener failed: ${action}`, error, record);
  241. }
  242. return true;
  243. }
  244. }