import type { EntityId } from 'db://assets/mx/module/ecs'; import { createMembershipSet, membershipAdd, membershipFromArray, membershipDedupeArray, mapKeysToArray, type MembershipSet, type ReadonlyMembershipSet } from '../../../mx/tools/MembershipSet'; import { MergeEventType, type ComposedUnit, type MergeEvent, type MergeEventSubscription, type MergeSession, } from 'db://assets/mx/module/merge'; import { composeKey } from '../runtime/ComposeUnitKey'; import { Node, Sprite, Tween } from 'cc'; import type { BattleRuntime } from '../runtime/BattleRuntime'; import type { CocosBagBoardView } from '../view/CocosBagBoardView'; import type { TextGrid } from '../view/TextGrid'; import type { CharmTarget } from './MechanismServices'; interface CharmEntry { composeKey: string; entityId: EntityId; gridIndices: readonly number[]; durationSec: number; remainingSec: number; /** 魅惑进度/飞入表现根;幻魂镜等纯动画技能可为 null */ fxRoot: Node | null; progressSprite: Sprite | null; } /** 魅惑状态:禁攻、禁拖、进度条;升级或计时结束解除 */ export class CharmController { private _runtime: BattleRuntime | null = null; private _bagView: CocosBagBoardView | null = null; private _mergeSession: MergeSession | null = null; private _entries = new Map(); private _entityToKey = new Map(); private _mergeListener: ((e: MergeEvent) => void) | null = null; /** 魅惑条目被释放时(合成变阵 / 升级 / 超时等),供幻魂镜表现收尾 */ private _onEntryReleased: ((composeKey: string) => void) | null = null; bindRuntime(runtime: BattleRuntime): void { this.unbind(); this._runtime = runtime; } bindBagView(view: CocosBagBoardView | null): void { this._bagView = view; this._syncDragState(); } /** 弹窗暂停等打断:收尾背包/地图拖动(遮罩常吞掉 DRAG_END) */ cancelBagDrag(): void { this._bagView?.cancelActiveDrag(); } /** 暂停恢复后:按当前相位重开拖拽,并覆盖魅惑禁拖格 */ refreshBagDragEnabled(): void { this._bagView?.refreshDragEnabled(); this._syncDragState(); } /** * 魅惑被释放时回调(composeKey);用于幻魂镜等与合成生命周期对齐。 * @param handler 回调;null 清除 */ setEntryReleasedHandler(handler: ((composeKey: string) => void) | null): void { this._onEntryReleased = handler; } bindMergeSession(session: MergeSession | null): void { this._detachMergeListeners(); this._mergeSession = session; if (!session) { this._mergeListener = null; return; } this._mergeListener = (event) => { if (event.type === MergeEventType.MapUpgraded) { this.releaseByGridIndex(event.gridIndex, 'upgraded'); return; } if (event.type === MergeEventType.ComposeChanged) { this.reconcileComposeChanged(event.compositions); } }; const listener = this._mergeListener as (e: MergeEvent) => void; session.on(MergeEventType.MapUpgraded as unknown as MergeEventSubscription, listener); session.on(MergeEventType.ComposeChanged as unknown as MergeEventSubscription, listener); } /** * 合成变阵后:composeKey 失效或实体已死的魅惑须解除(成将/换位不发 MapUpgraded)。 * @param compositions 当前合成体列表 */ reconcileComposeChanged(compositions: readonly ComposedUnit[]): void { const validKeys = createMembershipSet(); for (let i = 0; i < compositions.length; i++) { const c = compositions[i]; if (c.gridIndices.length >= 2) { membershipAdd(validKeys, composeKey(c)); } } const runtime = this._runtime; const keys = mapKeysToArray(this._entries); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const entry = this._entries.get(key); if (!entry) continue; if (!validKeys.has(key)) { this._releaseEntry(key, 'compose'); continue; } if (runtime && !runtime.session.world.isAlive(entry.entityId)) { this._releaseEntry(key, 'compose'); } } } unbind(): void { this.clear(); this.bindMergeSession(null); this._onEntryReleased = null; this._runtime = null; this._bagView = null; } private _detachMergeListeners(): void { if (!this._mergeSession || !this._mergeListener) return; const listener = this._mergeListener as (e: MergeEvent) => void; this._mergeSession.off(MergeEventType.MapUpgraded as unknown as MergeEventSubscription, listener); this._mergeSession.off(MergeEventType.ComposeChanged as unknown as MergeEventSubscription, listener); } clear(): void { const keys = mapKeysToArray(this._entries); for (let i = 0; i < keys.length; i++) { this._releaseEntry(keys[i], 'clear'); } } tick(dt: number): void { if (this._entries.size === 0) return; const expired: string[] = []; for (const [key, entry] of this._entries) { entry.remainingSec -= dt; this._syncCharmProgress(entry); if (entry.remainingSec <= 0) { expired.push(key); } } for (const key of expired) { this._releaseEntry(key, 'timeout'); } } isEntityCharmed(entityId: EntityId): boolean { return this._entityToKey.has(entityId); } isGridCharmed(gridIndex: number): boolean { for (const entry of this._entries.values()) { if (entry.gridIndices.includes(gridIndex)) return true; } return false; } isComposeKeyCharmed(composeKey: string): boolean { return this._entries.has(composeKey); } getCharmedComposeKeys(): ReadonlyMembershipSet { return membershipFromArray(mapKeysToArray(this._entries)); } applyCharm(target: CharmTarget, durationSec: number, fxRoot: Node | null): void { if (this.isComposeKeyCharmed(target.composeKey)) { this._releaseEntry(target.composeKey, 'reapply'); } const runtime = this._runtime; if (!runtime) return; const session = runtime.session; session.presentation.cancelByCaster(target.entityId); if (fxRoot?.isValid) { fxRoot.name = 'charmFx'; fxRoot.active = true; this._prepareCharmFx(fxRoot); const anchorGrid = this._resolveAnchorGrid(target.anchorGridIndex); const world = fxRoot.worldPosition.clone(); if (anchorGrid?.node?.isValid) { fxRoot.parent = anchorGrid.node; fxRoot.setWorldPosition(world); } const hitNode = fxRoot.getChildByName('击中效果'); if (hitNode?.isValid) { hitNode.active = true; } } const entry: CharmEntry = { composeKey: target.composeKey, entityId: target.entityId, gridIndices: target.gridIndices, durationSec: Math.max(0.001, durationSec), remainingSec: Math.max(0.001, durationSec), fxRoot: fxRoot?.isValid ? fxRoot : null, progressSprite: fxRoot?.isValid ? _resolveCharmProgressSprite(fxRoot) : null, }; if (entry.progressSprite) { _ensureCharmProgressSprite(entry.progressSprite); Tween.stopAllByTarget(entry.progressSprite); } this._entries.set(target.composeKey, entry); this._entityToKey.set(target.entityId, target.composeKey); this._syncCharmProgress(entry); this._syncDragState(); } releaseByGridIndex(gridIndex: number, reason: 'upgraded' | 'timeout' | 'clear' | 'reapply' = 'upgraded'): void { for (const [key, entry] of this._entries) { if (entry.gridIndices.includes(gridIndex)) { this._releaseEntry(key, reason); return; } } } private _prepareCharmFx(fxRoot: Node): void { const flyNode = fxRoot.getChildByName('fly'); if (flyNode?.isValid) flyNode.active = false; const progressNode = fxRoot.getChildByName('progressTemplate'); if (progressNode?.isValid) progressNode.active = true; } private _resolveAnchorGrid(gridIndex: number): TextGrid | null { return this._bagView?.getMapGrids().get(gridIndex) ?? null; } private _releaseEntry(key: string, _reason: 'upgraded' | 'timeout' | 'clear' | 'reapply' | 'compose'): void { const entry = this._entries.get(key); if (!entry) return; this._entries.delete(key); this._entityToKey.delete(entry.entityId); if (entry.progressSprite?.isValid) { Tween.stopAllByTarget(entry.progressSprite); } if (entry.fxRoot?.isValid) { entry.fxRoot.destroy(); } this._syncDragState(); try { this._onEntryReleased?.(key); } catch (e) { console.warn('[CharmController] entryReleased handler', String(e)); } } private _syncDragState(): void { const view = this._bagView; if (!view) return; view.refreshMapDragEnabled(); for (const grid of view.getMapGrids().values()) { if (!grid?.isValid || grid.location?.kind !== 'map') continue; if (this.isGridCharmed(grid.location.gridIndex)) { grid.enableDrag(false); } } } /** 战斗 tick 驱动:remainingSec 递减 + fillRange 同步(不依赖 Component.update) */ private _syncCharmProgress(entry: CharmEntry): void { if (!entry.fxRoot?.isValid || entry.durationSec <= 0) return; let sprite = entry.progressSprite; if (!sprite?.isValid) { sprite = _resolveCharmProgressSprite(entry.fxRoot); entry.progressSprite = sprite; } if (!sprite?.isValid) return; _ensureCharmProgressSprite(sprite); sprite.fillRange = Math.max(0, Math.min(1, entry.remainingSec / entry.durationSec)); } } function _ensureCharmProgressSprite(sprite: Sprite): void { sprite.type = Sprite.Type.FILLED; sprite.fillType = Sprite.FillType.RADIAL; sprite.fillCenter.set(0.5, 0.5); sprite.fillStart = 0; } function _resolveCharmProgressSprite(fxRoot: Node): Sprite | null { const tpl = fxRoot.getChildByName('progressTemplate'); if (!tpl?.isValid) return null; const progressNode = _findNamedNode(tpl, 'progress'); const named = progressNode?.getComponent(Sprite) ?? null; if (named) return named; for (const sp of tpl.getComponentsInChildren(Sprite)) { if (sp.node.name === 'progress') return sp; } for (const sp of tpl.getComponentsInChildren(Sprite)) { if (sp.type === Sprite.Type.FILLED) return sp; } return null; } function _findNamedNode(root: Node, name: string): Node | null { if (root.name === name) return root; for (const child of root.children) { const found = _findNamedNode(child, name); if (found) return found; } return null; }