RedpointManager.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  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, string[]>();
  20. private readonly parents = new Map<string, string[]>();
  21. private readonly listeners = new Map<string, 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 = [];
  51. this.children.set(main, mainChildren);
  52. }
  53. if (mainChildren.includes(child)) continue;
  54. mainChildren.push(child);
  55. let childParents = this.parents.get(child);
  56. if (!childParents) {
  57. childParents = [];
  58. this.parents.set(child, childParents);
  59. }
  60. childParents.push(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 = [];
  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.push(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 (let index = bucket.length - 1; index >= 0; --index) {
  121. const record = bucket[index];
  122. if (filterByListener && record.listener !== listener) continue;
  123. if (filterByTarget && record.target !== target) continue;
  124. bucket.splice(index, 1);
  125. }
  126. if (bucket.length === 0) this.listeners.delete(action);
  127. }
  128. /** 移除 on 时使用同一 target 添加的所有监听(跨所有 action)。 */
  129. offTarget(target: unknown): void {
  130. for (const [action, bucket] of this.listeners) {
  131. for (let index = bucket.length - 1; index >= 0; --index) {
  132. if (bucket[index].target === target) bucket.splice(index, 1);
  133. }
  134. if (bucket.length === 0) this.listeners.delete(action);
  135. }
  136. }
  137. /** 与 Cocos EventTarget 命名保持兼容。 */
  138. targetOff(target: unknown): void {
  139. this.offTarget(target);
  140. }
  141. /** 清空所有数量、分组关系和监听。 */
  142. clear(): void {
  143. this.sourceCounts.clear();
  144. this.counts.clear();
  145. this.children.clear();
  146. this.parents.clear();
  147. this.listeners.clear();
  148. this.refreshQueue.length = 0;
  149. }
  150. /** 刷新 action 及其所有祖先;所有数量先稳定,再统一派发通知。 */
  151. private refresh(action: string): void {
  152. this.refreshQueue.push(action);
  153. if (this.refreshing) return;
  154. this.refreshing = true;
  155. try {
  156. while (this.refreshQueue.length > 0) {
  157. this.performRefresh(this.refreshQueue.shift()!);
  158. }
  159. } finally {
  160. this.refreshing = false;
  161. }
  162. }
  163. private performRefresh(action: string): void {
  164. const affected = this.collectAffected(action);
  165. const memo = new Map<string, number>();
  166. const changed: string[] = [];
  167. for (const current of affected) {
  168. const nextCount = this.resolveCount(current, memo, []);
  169. const previousCount = this.counts.get(current) ?? 0;
  170. this.counts.set(current, nextCount);
  171. if (nextCount !== previousCount) changed.push(current);
  172. }
  173. for (const current of changed) {
  174. this.emit(current, this.counts.get(current) ?? 0);
  175. }
  176. }
  177. /** 按 action -> 直接父级 -> 更高父级的顺序收集,保证通知顺序可预期。 */
  178. private collectAffected(action: string): string[] {
  179. const result: string[] = [];
  180. const visited: string[] = [];
  181. const pending = [action];
  182. while (pending.length > 0) {
  183. const current = pending.shift()!;
  184. if (visited.includes(current)) continue;
  185. visited.push(current);
  186. result.push(current);
  187. for (const parent of this.parents.get(current) ?? []) pending.push(parent);
  188. }
  189. return result;
  190. }
  191. private resolveCount(action: string, memo: Map<string, number>, visiting: string[]): number {
  192. const cached = memo.get(action);
  193. if (cached !== undefined) return cached;
  194. const actionChildren = this.children.get(action);
  195. if (!actionChildren || actionChildren.length === 0) {
  196. const count = this.sourceCounts.get(action) ?? 0;
  197. memo.set(action, count);
  198. return count;
  199. }
  200. // group 已阻止循环;这里保留保护,避免异常数据导致无限递归。
  201. if (visiting.includes(action)) return 0;
  202. visiting.push(action);
  203. let count = 0;
  204. for (const child of actionChildren) {
  205. count += this.resolveCount(child, memo, visiting);
  206. }
  207. visiting.pop();
  208. memo.set(action, count);
  209. return count;
  210. }
  211. private hasDescendant(action: string, expected: string): boolean {
  212. const visited: string[] = [];
  213. const pending = [action];
  214. while (pending.length > 0) {
  215. const current = pending.pop()!;
  216. if (current === expected) return true;
  217. if (visited.includes(current)) continue;
  218. visited.push(current);
  219. for (const child of this.children.get(current) ?? []) pending.push(child);
  220. }
  221. return false;
  222. }
  223. private emit(action: string, count: number): void {
  224. const bucket = this.listeners.get(action);
  225. if (!bucket) return;
  226. for (const record of [...bucket]) {
  227. if (!this.dispatch(action, record, count)) {
  228. const index = bucket.indexOf(record);
  229. if (index >= 0) bucket.splice(index, 1);
  230. }
  231. }
  232. if (bucket.length === 0) this.listeners.delete(action);
  233. }
  234. /** 返回 false 表示监听对象已失效,应自动清理。 */
  235. private dispatch(action: string, record: ListenerRecord, count: number): boolean {
  236. if (record.listener instanceof Node) {
  237. if (!record.listener.isValid) return false;
  238. record.listener.active = count > 0;
  239. return true;
  240. }
  241. try {
  242. record.listener.call(record.target, count, action);
  243. } catch (error) {
  244. console.error(`[RedpointManager] listener failed: ${action}`, error, record);
  245. }
  246. return true;
  247. }
  248. }