You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
314 lines
11 KiB
314 lines
11 KiB
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<string, CharmEntry>();
|
|
private _entityToKey = new Map<EntityId, string>();
|
|
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<string>();
|
|
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<string> {
|
|
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;
|
|
}
|
|
|