import { _decorator, BlockInputEvents, Color, Component, director, Director, isValid, Label, Node, Sorting2D, sp, Tween, tween, UIOpacity, UITransform, v3, Vec2, Vec3, type EventTouch } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import type { ComposedUnit } from 'db://assets/mx/module/merge'; import { Weapon2TileType } from 'db://assets/mx/module/merge'; import { GEvent } from 'db://assets/mx/module/event/GEvent'; import { GBundle, GPath } from '../../game/ConfigRes'; import { TableNames } from '../../game/ConfigTableData'; import { BattleMapSpec } from '../map/BattleMapSpec'; import { PEACH_TREE_WEAPON2_ID, SHOVEL_WEAPON2_ID } from '../bag/BagPrepConfig'; import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider'; import { hasCarriedTreasureShovel } from '../../manager/battleBuff/BattleBuffPassiveSkillUtil'; import { textIconName } from '../config/BattleHeroTable'; import type { DeploySync } from '../runtime/DeploySync'; import type { TileEntitySnapshot, TileLocation, TowerRuntimeSnapshot } from '../combat/TowerQueryTypes'; import type { BattleBagSession } from '../bag/BattleBagSession'; import { TowerQueryService } from '../combat/TowerQueryService'; import { Skill } from '../skill/Skill'; import { hideGridUpgradeSpine, playGridUpgradeSpineDeferred, COMPOSE_ANIM } from './GridUpgradeSpineEffect'; import { YingCampAnim } from './YingCampAnim'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { HERO_GLYPH_RESOURCES, isHeroActorWeapon1, YING_WEAPON1_ID } from '../heros/HeroActorRes'; import { aiPlaceCampForPlayer } from '../ai/runtime/AiCampArea'; import { HeroActorHost } from '../heros/HeroActorHost'; import { ClientErrorReporter } from '../../../mx/tools/ClientErrorReporter'; import { applyGlyphSpineSkin, GLYPH_ACTIVE_SKIN_BUCKETS, GLYPH_SKIN_CAMP_ALLY, GLYPH_SKIN_CAMP_ENEMY, glyphSpineHasSkin, resolveActiveGlyphSkin, } from './GlyphSpineSkin'; import 'db://assets/mx/extension/NodeDragExt'; export type { TileEntitySnapshot, TileLocation, TileBindRecord, TowerConfigSnapshot, TowerCombatStats, TowerRuntimeSnapshot, } from '../combat/TowerQueryTypes'; export { TowerQueryService } from '../combat/TowerQueryService'; /** @deprecated 使用 TileEntitySnapshot */ export type TextGridEntityData = TileEntitySnapshot; /** @deprecated 使用 TileLocation */ export type TextGridLocation = TileLocation; /** @deprecated 使用 TileBindRecord */ export type TextGridTileRecord = import('../combat/TowerQueryTypes').TileBindRecord; const { ccclass } = _decorator; /** 铲开挖 Spine 节点名 */ const SHOVEL_DIG_ANIM_NODE = '铲子开挖动画'; /** 铲开挖 Spine 动画名(与 prefab / 两把铲子皮肤.json 一致) */ const SHOVEL_DIG_ANIM_NAME = 'animation'; /** 铲开挖时长 fallback(Spine 不可读时) */ const SHOVEL_DIG_DURATION_FALLBACK_SEC = 1.2; /** 降级期间等级字色(#bf3f48) */ const LEVEL_DOWN_LV_COLOR = new Color(0xbf, 0x3f, 0x48, 255); /** 字块 buff 特效 Sorting2D 层(与 textGrid.prefab spine/canUpEff 一致) */ const TEXT_GRID_BUFF_FX_SORT_LAYER = 3; /** 字形 skin Sorting2D order(与 textGrid.prefab 内 spine 升级特效一致) */ const TEXT_GRID_GLYPH_SORT_ORDER = 3; /** 字块下方 buff 特效 order(须低于字形) */ const TEXT_GRID_BUFF_FX_UNDER_ORDER = 2; /** 字块上方 buff 特效 order(须高于 canUpEff=4) */ const TEXT_GRID_BUFF_FX_OVER_ORDER = 5; /** 主动 buff 特效挂层 */ export type TextGridBattleBuffFxLayer = 'over' | 'under'; /** 普通铲开挖皮肤 */ const SHOVEL_DIG_SKIN_IRON = '铁铲子'; /** 掘宝铲开挖皮肤 */ const SHOVEL_DIG_SKIN_GOLD = '金铲子'; /** 蟠桃树专用表现节点 */ const PEACH_TREE_NODE = '桃'; /** 蟠桃树 Spine 节点名 */ const PEACH_TREE_ANIM_NODE = '桃树'; /** 蟠桃树 CD 前半循环动画 */ const PEACH_TREE_ANIM_IDLE1 = '桃字1'; /** 蟠桃树 CD 后半循环动画 */ const PEACH_TREE_ANIM_IDLE2 = '桃字2'; /** 蟠桃树产桃单次动画 */ const PEACH_TREE_ANIM_SPAWN = '桃字3'; /** 字块脚下阴影节点 */ const SHADOW_NODE = 'ebg'; /** 营预制体底框,损毁时与 ebg 一样保持世界旋转与世界坐标 */ const YING_FRAME_NODE = '营底框'; /** ebg / 营底框未旋转时的局部坐标 */ interface RuinDecorRest { /** 节点 */ n: Node; /** 局部 x */ x: number; /** 局部 y */ y: number; /** 局部 z */ z: number; } /** 字块显示颜色(hex,不含 #) */ export enum TextColor { Black = '000000', Yellow = 'F8D874', Pink = 'FF69B4', } /** 等级标签 UI 状态 */ export interface TextGridLevelLabelState { active: boolean; text: string; } /** 字块视图/UI 状态 */ export interface TextGridViewState { nodeActive: boolean; draggable: boolean; skinReady: boolean; displayText: string; textColorHex: string | null; levelLabel: TextGridLevelLabelState; } /** 地图格语义(仅 location=map) */ export interface TextGridMapCellState { gridIndex: number; row: number; col: number; isCanWalk: boolean; isCanPutText: boolean; isCanPutGrid: boolean; isCanBeDig: boolean; isCanRemove: boolean; isBestPlace: boolean; isFreePlace: boolean; } /** Skill 表现组件状态 */ export interface TextGridSkillState { present: boolean; isActive: boolean; } /** getData():战斗层实体 + 视图 + 实时 runtime */ export interface TextGridData extends TileEntitySnapshot { view: TextGridViewState; mapCell: TextGridMapCellState | null; skill: TextGridSkillState | null; /** 场上实时(AttributeSystem;未部署为 null) */ runtime: TowerRuntimeSnapshot | null; } /** * 背包/地图字块视图(textGrid 预制体)。 * 五行字:不显示 lv;按 Hero(herotype=Text.type, level=当前显示等级).number 打开同名节点,并切金/木/水/火/土皮。 * 铲:显示「铲」节点;蟠桃树(102):显示「桃/桃树」Spine。 */ @ccclass('TextGrid') export class TextGrid extends Component { private static readonly CLICK_MOVE_THRESHOLD_SQ = 10 * 10; weapon2Id = 0; weapon1Id = 0; level = 1; location: TileLocation | null = null; private _skinNode: Node | null = null; /** 铲专用表现节点(非文字皮肤) */ private _shovelNode: Node | null = null; /** 蟠桃树专用表现节点(非文字 skin) */ private _peachNode: Node | null = null; /** 蟠桃树 Spine 节点 */ private _peachTreeAnimNode: Node | null = null; /** 蟠桃树当前循环 Spine 动画名 */ private _peachTreeLoopAnim: string | null = null; /** 蟠桃树产桃单次动画播放中 */ private _peachTreeSpawnPlaying = false; /** 本轮 CD 开始时普攻 OnTick 剩余秒数(首部署常为 interval/2) */ private _peachTreeCdCycleStartTimer = 0; /** 已同步 CD 周期的实体 id */ private _peachTreeCdSyncEntityId: EntityId | null = null; /** 铲开挖 Spine 节点 */ private _shovelDigAnimNode: Node | null = null; /** 铲开挖动画完成回调 */ private _shovelDigComplete: (() => void) | null = null; /** 铲开挖延迟起播世代(prepareForPool 时递增以作废) */ private _shovelDigScheduleGen = 0; /** 当前挂起的铲开挖延迟世代 */ private _shovelDigPendingGen = 0; /** 当前 Text.icon 皮肤名 */ private _skinIconName: string | null = null; /** 字块脚下阴影(ebg) */ private _shadowNode: Node | null = null; private _dragSurface: Node | null = null; private _originParent: Node | null = null; private _originPos = { x: 0, y: 0, z: 0 }; private _labelLv: Label | null = null; /** 当前五行数字皮节点(子节点名 = Hero.number) */ private _numberNode: Node | null = null; private _clickStart: Vec2 | null = null; private _entity: TileEntitySnapshot | null = null; private _dragFollow = true; /** 地图字块点击(查看攻击范围等) */ private _mapClickHandler: ((grid: TextGrid) => void) | null = null; /** 拖拽悬停选中(绿色) */ private _isSelectStatus = false; /** 拖拽悬停不可放置(红色,textGrid/redSelect) */ private _isRedSelectStatus = false; selectNode: Node | null = null; redSelectNode: Node | null = null; /** 降级期间等级字变色 */ private _levelDown = false; /** 牛魔王损毁:字形倒下 */ private _ruin = false; /** 字形倒下 tween 进行中 */ private _ruinFalling = false; /** 正在倒下的字形节点(回收后须作废 tween) */ private _ruinFallNode: Node | null = null; /** ebg / 营底框未旋转时的局部坐标 */ private readonly _decorRest: RuinDecorRest[] = []; /** 等级字原始颜色 */ private _lvColorOrig: Color | null = null; /** 可升级提示箭头(预制体默认隐藏) */ private _canUpEff: Node | null = null; private _canUpEffBaseX = 0; private _canUpEffBaseY = 0; private _spineBaseX = 0; private _spineBaseY = 0; /** 拖动时可合成目标:每秒抖动一次 */ private _mergeShakeOn = false; private _mergeShakeBase = { x: 0, y: 0, z: 0 }; /** 营字激活展示(营.prefab,含阵营等级皮) */ private _yingCampDisplay: Node | null = null; /** 正面 buff 特效节点(怪_攻击buff) */ private _positiveBuffFx: Node | null = null; /** 未组合组合字「睡觉」特效 */ private _sleepFx: Node | null = null; /** 混乱(debuff_8)特效 */ private _confusionFx: Node | null = null; /** attachBattleBuffFx 挂载的节点(施法幻魂镜等) */ private readonly _attachedBattleBuffFx: Node[] = []; /** * AI 无头地图轻量模式:只刷等级/散字皮/营,禁止 ensure/destroy HeroActor *(避免与 DeploySync 双管并在物理步进中改节点导致 Box2D 崩)。 */ private _aiMapLite = false; protected onLoad(): void { this._skinNode = this.node.getChildByName('skin'); this._shadowNode = this.node.getChildByName(SHADOW_NODE); this._shovelNode = this.node.getChildByName('铲'); if (this._shovelNode?.isValid) this._shovelNode.active = false; this._peachNode = this.node.getChildByName(PEACH_TREE_NODE); if (this._peachNode?.isValid) this._peachNode.active = false; this._labelLv = this.node.getChildByName('lv')?.getComponent(Label) ?? null; if (this._labelLv?.node?.isValid) this._labelLv.node.active = false; this.selectNode = this.node.getChildByName('select'); this.redSelectNode = this.node.getChildByName('redSelect'); this._updateSelect(); this._canUpEff = this.node.getChildByName('canUpEff'); if (this._canUpEff?.isValid) { this._canUpEffBaseX = this._canUpEff.position.x; this._canUpEffBaseY = this._canUpEff.position.y; this._canUpEff.active = false; } const spineNode = this.node.getChildByName('spine'); if (spineNode?.isValid) { this._spineBaseX = spineNode.position.x; this._spineBaseY = spineNode.position.y; } hideGridUpgradeSpine(this.node); } /** 背包合成/升级成功时播放 spine「升级效果」,播完自动隐藏 */ playUpgradeEffect(): void { this._resetComposeSpinePosition(); playGridUpgradeSpineDeferred(this.node); } /** 多字英雄组合成功时播放「合成效果」 */ playComposeEffect(): void { const offset = this._resolveComposeSpineOffset(); const spineNode = this.node.getChildByName('spine'); if (spineNode?.isValid && (offset.x !== 0 || offset.y !== 0)) { spineNode.setPosition( this._spineBaseX + offset.x, this._spineBaseY + offset.y, spineNode.position.z, ); } playGridUpgradeSpineDeferred(this.node, COMPOSE_ANIM, () => this._resetComposeSpinePosition()); } /** * 挂载主动 buff 特效到字块根节点并校正层级。 * over:置于字形之上(Sorting2D + sibling 置顶);under:置于 skin/spine 之下。 * 多格组合字时锚点对齐占格几何中心(与 spine 偏移一致)。 */ attachBattleBuffFx(fx: Node, layer: TextGridBattleBuffFxLayer): void { if (!fx?.isValid || !this.node?.isValid) return; fx.parent = this.node; const anchor = this._resolveComposeSpineOffset(); fx.setPosition(anchor.x, anchor.y, 0); fx.layer = this.node.layer; fx.active = true; this._ensureGlyphSkinSorting(); const ref = this.node.getChildByName('skin') ?? this.node.getChildByName('spine'); if (layer === 'under') { if (ref?.isValid) { fx.setSiblingIndex(ref.getSiblingIndex()); } else { fx.setSiblingIndex(0); } this._applyBattleBuffFxSorting(fx, TEXT_GRID_BUFF_FX_UNDER_ORDER); return; } fx.setSiblingIndex(this.node.children.length - 1); this._applyBattleBuffFxSorting(fx, TEXT_GRID_BUFF_FX_OVER_ORDER); this._ensureDragSurface(); this._attachedBattleBuffFx.push(fx); } /** * 回收单个 attachBattleBuffFx 节点(Presenter 中断施法时调用)。 * @param fx 特效节点 */ releaseAttachedBattleBuffFx(fx: Node): void { const idx = this._attachedBattleBuffFx.indexOf(fx); if (idx >= 0) this._attachedBattleBuffFx.splice(idx, 1); if (!fx?.isValid) return; Tween.stopAllByTarget(fx); if (fx['resKey']) gg.res.putNode(fx); else fx.destroy(); } /** * 按节点名回收字块上的主动 buff 特效(含未登记的残留鬼手)。 * @param name 预制体节点名 */ releaseAttachedBattleBuffFxByName(name: string): void { if (!name) return; const tracked = this._attachedBattleBuffFx.slice(); for (let i = 0; i < tracked.length; i++) { const fx = tracked[i]; if (fx?.name === name) this.releaseAttachedBattleBuffFx(fx); } const root = this.node; if (!root?.isValid) return; const children = root.children; for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; if (child?.isValid && child.name === name) this.releaseAttachedBattleBuffFx(child); } } /** 回收字块上由 attachBattleBuffFx 挂入的特效(回池/重建前) */ clearAttachedBattleBuffFx(): void { while (this._attachedBattleBuffFx.length > 0) { const fx = this._attachedBattleBuffFx[0]; this.releaseAttachedBattleBuffFx(fx); } } /** 主动 buff 特效锚点世界坐标(多格组合时为占格几何中心) */ resolveBattleBuffFxWorldPosition(): Vec3 { const anchor = this._resolveComposeSpineOffset(); const ut = this.node.getComponent(UITransform); if (ut) { return ut.convertToWorldSpaceAR(v3(anchor.x, anchor.y, 0)); } const p = this.node.worldPosition; return v3(p.x + anchor.x, p.y + anchor.y, p.z); } /** 字形 skin 补 Sorting2D,与 buff 特效 order 对齐(地图格原先无 Sorting2D 导致下层盖字) */ private _ensureGlyphSkinSorting(): void { const skin = this._skinNode?.isValid ? this._skinNode : this.node.getChildByName('skin'); if (!skin?.isValid) return; let sorting = skin.getComponent(Sorting2D); if (!sorting) sorting = skin.addComponent(Sorting2D); sorting.sortingLayer = TEXT_GRID_BUFF_FX_SORT_LAYER; sorting.sortingOrder = TEXT_GRID_GLYPH_SORT_ORDER; } /** 主动 buff 特效 Spine Sorting2D(地图格 skin 无 Sorting2D,仅 sibling 不足) */ private _applyBattleBuffFxSorting(fx: Node, order: number): void { for (const sk of fx.getComponentsInChildren(sp.Skeleton)) { if (!sk?.node?.isValid) continue; sk.node.layer = fx.layer; let sorting = sk.node.getComponent(Sorting2D); if (!sorting) sorting = sk.node.addComponent(Sorting2D); sorting.sortingLayer = TEXT_GRID_BUFF_FX_SORT_LAYER; sorting.sortingOrder = order; } for (const sort of fx.getComponents(Sorting2D)) { sort.sortingLayer = TEXT_GRID_BUFF_FX_SORT_LAYER; sort.sortingOrder = order; } } /** 显示正面 buff 特效(prefab/怪_攻击buff),挂在字块根节点 */ showPositiveBuffFx(): void { if (!this.node?.isValid) return; if (this._positiveBuffFx?.isValid) return; let node: Node | null = null; try { node = gg.res.getNode('prefab/', '怪_攻击buff', GBundle.BattleGameMonster); } catch (e) { console.log('[TextGrid] 怪_攻击buff 预制体加载失败', String(e)); return; } if (!node?.isValid) return; node.parent = this.node; node.setPosition(0, 0, 0); node.active = true; node.setSiblingIndex(this.node.children.length - 1); this._positiveBuffFx = node; for (const sk of node.getComponentsInChildren(sp.Skeleton)) { sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); const anim = (sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation || 'animation'; sk.setAnimation(0, anim, true); } this._ensureDragSurface(); } clearPositiveBuffFx(): void { if (this._positiveBuffFx?.isValid) { gg.res.putNode(this._positiveBuffFx); } this._positiveBuffFx = null; } /** * 未组合成英雄的组合字:显示「睡觉」特效;组合/离场时回收。 */ setSleepFx(visible: boolean): void { if (!visible) { this.clearSleepFx(); return; } if (!this.node?.isValid) return; if (this._sleepFx?.isValid) { this._sleepFx.active = true; this._sleepFx.setSiblingIndex(this.node.children.length - 1); this._ensureDragSurface(); return; } let node: Node | null = null; try { node = gg.res.getNode('prefab/', '睡觉', GBundle.BattleGameMonster); } catch { return; } if (!node?.isValid) return; node.parent = this.node; node.setPosition(0, 0, 0); node.active = true; node.setSiblingIndex(this.node.children.length - 1); this._sleepFx = node; for (const sk of node.getComponentsInChildren(sp.Skeleton)) { sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); const anim = (sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation || 'animation'; sk.setAnimation(0, anim, true); } this._ensureDragSurface(); } clearSleepFx(): void { if (this._sleepFx?.isValid) { gg.res.putNode(this._sleepFx); } this._sleepFx = null; } /** * 白无常 / 顺风耳混乱:格子上挂「混乱效果」。 * @param visible 是否显示 */ setConfusionFx(visible: boolean): void { if (!visible) { this.clearConfusionFx(); return; } if (!this.node?.isValid) return; if (this._confusionFx?.isValid) { this._confusionFx.active = true; this._confusionFx.setSiblingIndex(this.node.children.length - 1); this._ensureDragSurface(); return; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), '混乱效果', GBundle.BattleHero); } catch (err) { console.log('[TextGrid] 混乱效果 getNode 失败', String(err)); return; } if (!node?.isValid) { console.log('[TextGrid] 混乱效果预制体未加载'); return; } node.parent = this.node; node.setPosition(0, BattleMapSpec.CELL_SIZE * 0.25, 0); node.active = true; node.layer = this.node.layer; node.setSiblingIndex(this.node.children.length - 1); this._confusionFx = node; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[TextGrid] 混乱效果缺少 Skeleton'); this.clearConfusionFx(); return; } const anim = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation || 'animation').trim(); sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); if (!BattleSpinePlayback.play(sk, anim, { loop: true })) { console.log('[TextGrid] 混乱效果无动画', anim); this.clearConfusionFx(); return; } this._ensureDragSurface(); } /** 回收混乱特效 */ clearConfusionFx(): void { const node = this._confusionFx; this._confusionFx = null; if (!node?.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); gg.res.putNode(node); } private _resetComposeSpinePosition(): void { const spineNode = this.node.getChildByName('spine'); if (!spineNode?.isValid) return; spineNode.setPosition(this._spineBaseX, this._spineBaseY, spineNode.position.z); } /** 多格组合:spine 偏移到占格几何中心(水平偏 X,竖直偏 Y) */ private _resolveComposeSpineOffset(): { x: number; y: number } { const composition = this._getComposition(); if (!composition || composition.gridIndices.length < 2) { return { x: 0, y: 0 }; } const loc = this.location; if (loc?.kind !== 'map') return { x: 0, y: 0 }; const map = gg.game.CurentBattle?.Map; const hostUt = this.node.getComponent(UITransform); if (!map || !hostUt) return { x: 0, y: 0 }; let sx = 0; let sy = 0; let n = 0; for (const idx of composition.gridIndices) { const mg = map.getMapGridByIndex(idx); if (!mg?.node?.isValid) continue; const p = mg.node.worldPosition; sx += p.x; sy += p.y; n++; } if (n === 0) return { x: 0, y: 0 }; const centerLocal = hostUt.convertToNodeSpaceAR(new Vec3(sx / n, sy / n, 0)); if (composition.direction === 'horizontal') { return { x: centerLocal.x, y: 0 }; } if (composition.direction === 'vertical') { return { x: 0, y: centerLocal.y }; } return { x: centerLocal.x, y: centerLocal.y }; } /** * 可合成/可升级提示:显示时上下浮动待机;隐藏时停动画并复位。 * 铲不可合成,强制不显示。 */ setCanUpgradeHint(visible: boolean): void { const n = this._canUpEff; if (!n?.isValid) return; Tween.stopAllByTarget(n); if (!visible || this._isShovelTile() || this._isPeachTreeTile()) { n.active = false; n.setPosition(this._canUpEffBaseX, this._canUpEffBaseY, n.position.z); return; } n.active = true; const x = this._canUpEffBaseX; const y = this._canUpEffBaseY; const z = n.position.z; n.setPosition(x, y, z); tween(n) .repeatForever( tween(n) .to(0.45, { position: v3(x, y + 10, z) }, { easing: 'sineInOut' }) .to(0.45, { position: v3(x, y, z) }, { easing: 'sineInOut' }), ) .start(); } /** * 拖动时可合成对应目标:每秒左右抖动一下(动 skin/铲节点,避免与背包 Layout 抢根节点坐标)。 */ setMergeTargetShake(enabled: boolean): void { const n = this._presentationRoot(); if (!n?.isValid) return; Tween.stopAllByTarget(n); if (this._mergeShakeOn) { n.setPosition(this._mergeShakeBase.x, this._mergeShakeBase.y, this._mergeShakeBase.z); } this._mergeShakeOn = enabled; if (!enabled) return; const p = n.position; this._mergeShakeBase = { x: p.x, y: p.y, z: p.z }; const { x, y, z } = this._mergeShakeBase; const amp = 6; tween(n) .repeatForever( tween(n) .to(0.05, { position: v3(x + amp, y, z) }) .to(0.05, { position: v3(x - amp, y, z) }) .to(0.05, { position: v3(x + amp * 0.5, y, z) }) .to(0.05, { position: v3(x, y, z) }) .delay(0.8), ) .start(); } /** 拖拽悬停目标时显示 select 节点(绿色) */ get isSelectStatus(): boolean { return this._isSelectStatus; } set isSelectStatus(value: boolean) { this._isSelectStatus = value; if (value) this._isRedSelectStatus = false; this._updateSelect(); } /** 拖拽悬停不可放置格时显示 redSelect 节点(红色) */ get isRedSelectStatus(): boolean { return this._isRedSelectStatus; } set isRedSelectStatus(value: boolean) { this._isRedSelectStatus = value; if (value) this._isSelectStatus = false; this._updateSelect(); } protected onEnable(): void { this._bindClickSurface(); } protected onDisable(): void { this._unbindClickSurface(); } get dragNode(): Node { this._ensureDragSurface(); return this._dragSurface!; } /** 停止根节点 tween 并还原 scale/angle(入场 squash 动画被打断时会残留) */ resetNodeTransform(): void { const node = this.node; if (!node?.isValid) return; Tween.stopAllByTarget(node); node.setScale(1, 1, 1); node.angle = 0; if (this._cellRuined()) this._holdRuinXform(); } /** 根节点归零到父格/槽中心(合成升级、setData 时用;勿在 restoreOrigin 之后调用) */ resetLocalPosition(): void { const node = this.node; if (!node?.isValid) return; Tween.stopAllByTarget(node); node.setPosition(0, 0, 0); } /** 还原 skin/铲 节点位移 */ resetSkinPresentation(): void { this.setMergeTargetShake(false); this._stopYingSummonPulse(); const n = this._presentationRoot(); if (!n?.isValid) return; Tween.stopAllByTarget(n); n.setPosition(0, 0, 0); n.setScale(1, 1, 1); n.angle = 0; if (this._cellRuined()) this._holdRuinXform(); } /** 营展示 Spine(出怪读草字头) */ getYingCampSkeleton(): sp.Skeleton | null { return this._getYingCampAnim(); } /** 播出怪「攻击」(草字头开合),结束后回待机 */ playYingSummonAttack(): void { const anim = this._getYingCampAnim(); if (!anim?.isValid) { console.log('[TextGrid] 营攻击无 Spine'); return; } YingCampAnim.playAttack(anim); } /** 营 CD 到点:放大缩小抖动 */ playYingSummonPulse(): void { const n = this._yingCampDisplay?.isValid && this._yingCampDisplay.active ? this._yingCampDisplay : this._presentationRoot(); if (!n?.isValid) return; Tween.stopAllByTarget(n); const sx = n.scale.x; const sy = n.scale.y; const sz = n.scale.z; tween(n) .to(0.07, { scale: v3(sx * 1.18, sy * 1.18, sz) }) .to(0.07, { scale: v3(sx * 0.88, sy * 0.88, sz) }) .to(0.07, { scale: v3(sx * 1.1, sy * 1.1, sz) }) .to(0.07, { scale: v3(sx, sy, sz) }) .start(); } private _stopYingSummonPulse(): void { const nodes: Node[] = []; if (this._yingCampDisplay?.isValid) nodes.push(this._yingCampDisplay); const root = this._presentationRoot(); if (root?.isValid && nodes.indexOf(root) < 0) nodes.push(root); for (let i = 0; i < nodes.length; i++) { Tween.stopAllByTarget(nodes[i]); nodes[i].setScale(1, 1, 1); } } /** 当前字块是否为营(功能字,走组合字阵营等级皮) */ private _isYingWeaponTile(): boolean { const comp = this._getComposition(); if (comp?.weapon1Id === YING_WEAPON1_ID) return true; return this.weapon1Id === YING_WEAPON1_ID; } /** 营格所在地图阵营 → 皮肤字首位(1 己方 / 2 敌方) */ private _resolveYingGlyphCamp(): number { return this._isEnemyCampMapCell() ? GLYPH_SKIN_CAMP_ENEMY : GLYPH_SKIN_CAMP_ALLY; } /** 当前地图格是否敌方放置区(本机看对面 CampAreaID,与出怪口无关) */ private _isEnemyCampMapCell(): boolean { const loc = this.location; if (loc?.kind !== 'map') return false; const grid = gg.game.CurentBattle?.Map?.getMapGridByIndex(loc.gridIndex); const campAreaId = (grid?.CampAreaID ?? 0) | 0; const localId = gg.game.CurentBattle?.player?.playerId ?? 0; const enemyPlayerId = localId === 0 ? 1 : 0; return campAreaId === aiPlaceCampForPlayer(enemyPlayerId); } /** 营字:上场后挂营预制体并按阵营+等级切肤 */ private _syncYingCampState(active: boolean): void { if (!active) { this._releaseYingCampDisplay(); this._updateLevelLabel(); if (this._skinNode?.isValid) { this._skinNode.active = true; this.resetSkinPresentation(); if (this._skinIconName) this._applySkinSpineIcon(this._skinIconName); } return; } this._ensureYingCampDisplay(); if (!this._yingCampDisplay?.isValid) return; this._yingCampDisplay.active = true; this._setGlyphFaceActive(false); const anim = this._getYingCampAnim(); if (!anim?.isValid) { console.log('[TextGrid] 营展示 Spine 未就绪', this.weapon2Id); return; } const camp = this._resolveYingGlyphCamp(); const level = this._resolveDisplayLevel(); const preferred = resolveActiveGlyphSkin(level, camp); const extras = GLYPH_ACTIVE_SKIN_BUCKETS .map((b) => `皮肤字${camp}_${b}`) .filter((name) => name !== preferred); if (!applyGlyphSpineSkin(anim, preferred, extras)) { console.log('[TextGrid] 营阵营等级皮未能应用', preferred, level, camp); } if (anim.skeletonData) ensureSpineSkeletonDataRuntime(anim.skeletonData); anim.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); const runtime = anim.skeletonData?.getRuntimeData?.(true); if (runtime?.findAnimation?.('待机')) { anim.setAnimation(0, '待机', true); } } private _ensureYingCampDisplay(): void { if (this._yingCampDisplay?.isValid) return; const node = gg.res.getNode(GPath.HeroPrefab(), '营', GBundle.BattleHero); if (!node?.isValid) { console.log('[TextGrid] 营预制体未加载'); return; } if (!this.node?.isValid) { gg.res.putNode(node); return; } node.parent = this.node; node.setPosition(0, 0, 0); node.setScale(1, 1, 1); node.active = true; this._yingCampDisplay = node; this._ensureDragSurface(); this._forwardChildTouchToSurface(); } private _releaseYingCampDisplay(): void { const node = this._yingCampDisplay; this._yingCampDisplay = null; if (node?.isValid) { Tween.stopAllByTarget(node); node.angle = 0; this._resetDecorOn(node); gg.res.putNode(node); } } private _getYingCampAnim(): sp.Skeleton | null { const root = this._yingCampDisplay; if (!root?.isValid) return null; const animNode = root.getChildByName('动画'); return animNode?.getComponent(sp.Skeleton) ?? root.getComponent(sp.Skeleton) ?? root.getComponentInChildren(sp.Skeleton); } /** 绑定文字实体完整数据(来自 TowerQueryService.buildTileEntity) */ setData(data: TileEntitySnapshot): void { const prevLoc = this.location; this._invalidateHeroActorRetry(); this._releaseYingCampDisplay(); this.resetNodeTransform(); this.resetSkinPresentation(); this._entity = data; this.weapon2Id = Number(data.tile.id); this.weapon1Id = Number(data.weapon1Id) || 0; this.level = data.level; this.location = data.location; // 仅当场上真实占格节点收回背包时才拆英雄;对象池幽灵节点带旧 location 不得误伤 if (prevLoc?.kind === 'map' && data.location.kind === 'bag' && this._isLiveMapOccupant(prevLoc.gridIndex)) { HeroActorHost.instance.destroyAtGrid(prevLoc.gridIndex); } if (this._isElementNumberTile()) { this._hideShovelPresentation(); this._hidePeachTreePresentation(); } else if (this._isShovelTile()) { this._hidePeachTreePresentation(); this._hideNumberNodes(); this._applyShovelPresentation(); } else if (this._isPeachTreeTile()) { this._hideShovelPresentation(); this._hideNumberNodes(); this._applyPeachTreePresentation(); } else { this._hideShovelPresentation(); this._hidePeachTreePresentation(); this._hideNumberNodes(); this._ensureSkinNode(); const iconName = textIconName(data.tile); this._skinIconName = null; this._applySkinIcon(iconName); } this._finishSkinPresentation(); this._updateLevelLabel(); this.hideEntranceTrail(); this._ensureDragSurface(); this._forwardChildTouchToSurface(); } getEntityData(): TileEntitySnapshot | null { if (!this.location && !this._entity) return null; if (this._aiMapLite && this._entity) return this._entity; return this._queryEntity(); } setLocation(loc: TileLocation | null): void { this.location = loc; } /** 完整快照:实体走战斗层查询,视图层只补 UI 字段 */ getData(deploySync?: DeploySync | null): TextGridData { const entity = this._aiMapLite && this._entity ? this._entity : this._queryEntity(); const runtime = entity.location.kind === 'map' && !this._aiMapLite ? TowerQueryService.instance.getRuntimeByGridIndex(entity.location.gridIndex, undefined, deploySync ?? null) : null; return { ...entity, view: this._buildViewState(), mapCell: this._buildMapCellState(), skill: this._buildSkillState(), runtime, }; } setMapClickHandler(handler: ((grid: TextGrid) => void) | null): void { this._mapClickHandler = handler; } onClickText(e: EventTouch): void { const data = this.getData(); const range = data.runtime?.stats.range ?? data.tower?.stats.range; if (this.location?.kind === 'map' || this.location?.kind === 'bag') { if (this._mapClickHandler) { e.propagationStopped = true; this._mapClickHandler(this); } else { console.log('[TextGrid] mapClickHandler 未绑定', this.weapon2Id, this.location); } } } getSkinNode(): Node | null { return this._skinNode?.isValid ? this._skinNode : null; } rememberOrigin(): void { this._originParent = this.node.parent; const p = this.node.position; this._originPos = { x: p.x, y: p.y, z: p.z }; } restoreOrigin(): void { if (this._originParent?.isValid) { this.node.parent = this._originParent; } this.node.setPosition(this._originPos.x, this._originPos.y, this._originPos.z); this.resetNodeTransform(); } /** 入场落地后恢复可见性:五行数字皮优先,铲只显铲,字按 HeroActor 态同步 */ syncPresentationVisibility(): void { if (!this.isValid) return; if (this._isElementNumberTile()) { this._hideShovelPresentation(); this._hidePeachTreePresentation(); this._updateLevelLabel(); this._syncSkillActiveState(); return; } if (this._isShovelTile()) { this._hideNumberNodes(); this._applyShovelPresentation(); return; } if (this._isPeachTreeTile()) { this._applyPeachTreePresentation(); return; } this._hideShovelPresentation(); this._hidePeachTreePresentation(); this._syncSkillActiveState(); } /** * 拖动/交换落地后恢复字形可见。 * putNode 会把子树 Skeleton.enabled 关掉;地图原先跳过,交换后只剩 ebg 阴影和 lv。 * 已成将格 / 上场营只对账藏/显皮,不把格子皮强行打开(避免叠在 heroTemplate / 营.prefab 上)。 * @returns 是否曾发现异常并修复 */ ensureBagGlyphVisible(): boolean { if (!this.isValid || !this.node?.isValid) return false; let repaired = false; if (!this.node.active) { this.node.active = true; repaired = true; } const uo = this.node.getComponent(UIOpacity); if (uo?.isValid && uo.opacity < 255) { uo.opacity = 255; repaired = true; } this.node.setScale(1, 1, 1); this.node.setPosition(0, 0, 0); this.resetNodeTransform(); this.resetSkinPresentation(); if (this._isElementNumberTile()) { this._hideShovelPresentation(); this._hidePeachTreePresentation(); this._updateLevelLabel(); const sk = this._numberNode?.getComponent(sp.Skeleton) ?? null; if (sk?.isValid && !sk.enabled) { sk.enabled = true; repaired = true; } this._syncShadow(); } else if (this._isShovelTile()) { this._hideNumberNodes(); this._applyShovelPresentation(); } else if (this._isPeachTreeTile()) { this._applyPeachTreePresentation(); } else if (this._isHeroActorComposeCell() || this._isYingWeaponTile()) { // 成将/营只对账:覆盖层已挂上则藏皮。禁止强行开皮叠在 heroTemplate / 营.prefab 上。 this._syncSkillActiveState(); } else { this._hideShovelPresentation(); this._hidePeachTreePresentation(); this._ensureSkinNode(); const skin = this._skinNode; if (skin?.isValid && !skin.active) { skin.active = true; repaired = true; } const sk = this._getSkinSkeleton(); if (sk?.isValid) { if (!sk.enabled) { sk.enabled = true; repaired = true; } sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); if (this._skinIconName) { this._applySkinSpineIcon(this._skinIconName); } } this._syncShadow(); } if (repaired) { console.log( '[TextGrid] ensureBagGlyphVisible 修复字形', this.weapon2Id, this.location, ); } return repaired; } /** * AI 无头地图:轻量表现(等级标 + 散字/营),不碰 HeroActor 生命周期。 * @param on 是否开启 */ setAiMapLite(on: boolean): void { this._aiMapLite = !!on; } /** 松手飞入目标格前:组合/英雄皮可能被 HeroActor 隐藏,先恢复可见再播弧线 */ preparePlaceFlightVisual(): void { if (this._isElementNumberTile()) { this._hideShovelPresentation(); this._hidePeachTreePresentation(); this._updateLevelLabel(); return; } if (this._isShovelTile()) { this._hideNumberNodes(); if (this._shovelNode?.isValid) this._shovelNode.active = true; return; } if (this._isPeachTreeTile()) { if (this._peachNode?.isValid) this._peachNode.active = true; return; } if (this._isYingWeaponTile() && this._yingCampDisplay?.isValid) { this._yingCampDisplay.active = true; if (this._skinNode?.isValid) this._skinNode.active = false; return; } if (this._skinNode?.isValid) this._skinNode.active = true; } /** 落子/交换落地后:按 HeroActor 状态收起格子皮,避免与英雄字形叠字 */ finishPlaceFlightVisual(): void { this._syncSkillActiveState(); } /** * 回节点池前:停入场/拖拽残留,清选中与透明度。 * 否则下次取出会带着旋转角、绿色 select 框进背包。 */ prepareForPool(): void { if (!this.isValid) return; this._shovelDigScheduleGen++; this._shovelDigComplete = null; this._releaseYingCampDisplay(); this.clearPositiveBuffFx(); this.clearSleepFx(); this.clearConfusionFx(); this.clearAttachedBattleBuffFx(); this.resetNodeTransform(); this.resetSkinPresentation(); this._skinIconName = null; this._hideNumberNodes(); this._hideLevelLabel(); hideGridUpgradeSpine(this.node); this._hideShovelDigAnim(); this._hidePeachTreePresentation(); this.hideEntranceTrail(); this.isSelectStatus = false; this.isRedSelectStatus = false; this.setCanUpgradeHint(false); this.setLevelDownStyle(false); this.setRuinStyle(false, true); this.enableDrag(false); this.setMapClickHandler(null); this.setDragFollow(true); this.setAiMapLite(false); this.location = null; this._entity = null; this.weapon2Id = 0; this.weapon1Id = 0; this.level = 1; this.node.name = 'textGrid'; const uo = this.node.getComponent(UIOpacity); if (uo) uo.opacity = 255; this.node.setPosition(0, 0, 0); } enableDrag(enabled: boolean): void { if (!this.isValid || !this.node?.isValid) return; this._ensureDragSurface(); const surface = this._dragSurface!; surface.off(Node.DragEvent.DRAG_MOVE, this._syncDragMove, this); surface.off(Node.DragEvent.DRAG_END, this._resetDragSurface, this); if (enabled) { surface.on(Node.DragEvent.DRAG_MOVE, this._syncDragMove, this); surface.on(Node.DragEvent.DRAG_END, this._resetDragSurface, this); } surface.draggable = enabled; const canClick = this.location?.kind === 'map' || this.location?.kind === 'bag'; const block = surface.getComponent(BlockInputEvents); if (block) { // 禁拖拽时仍保留点击(查看攻击范围 / 武器详情) block.enabled = enabled || canClick; } if (canClick) { this._bindClickSurface(); } } setDragFollow(enabled: boolean): void { this._dragFollow = enabled; if (!enabled) this._resetDragSurface(); } onDestroy(): void { this.clearPositiveBuffFx(); this.clearSleepFx(); this.clearConfusionFx(); this.clearAttachedBattleBuffFx(); if (this._canUpEff?.isValid) { Tween.stopAllByTarget(this._canUpEff); } if (this._mergeShakeOn) { this.setMergeTargetShake(false); } this.resetSkinPresentation(); if (this._dragSurface?.isValid) { this._dragSurface.removeDragEvent?.(); this._dragSurface.destroy(); } this._dragSurface = null; this._skinIconName = null; this.node.removeDragEvent?.(); } private _queryEntity(): TileEntitySnapshot { const location = this.location ?? this._entity?.location; if (!location) return this._entity!; const composition = this._findComposition() ?? this._entity?.composition ?? null; const level = composition?.level ?? this.level; return TowerQueryService.instance.buildTileEntity( location, { weapon2Id: this.weapon2Id, weapon1Id: this.weapon1Id > 0 ? this.weapon1Id : undefined, level, source: this._entity?.source ?? undefined, }, { composition, player: gg.game.localPlayer }, ) ?? this._entity!; } private _findComposition(): ComposedUnit | null { const loc = this.location; if (loc?.kind !== 'map') return null; const local = gg.game.localPlayer?.compositions.find(c => c.gridIndices.includes(loc.gridIndex)); if (local) return local; const remote = gg.game.CurentBattle?.remotePlayer?.compositions.find( c => c.gridIndices.includes(loc.gridIndex), ); return remote ?? null; } /** 优先用 MergeSession 当前组合;快照仅在 live 尚未覆盖本格时回落 */ private _getComposition(): ComposedUnit | null { const live = this._findComposition(); if (live) return live; const loc = this.location; const fromEntity = this._entity?.composition; if (fromEntity && loc?.kind === 'map' && this._compHasGrid(fromEntity, loc.gridIndex)) { return fromEntity; } return null; } /** 组合是否仍占该格 */ private _compHasGrid(composition: ComposedUnit, gridIndex: number): boolean { const idx = composition.gridIndices; for (let i = 0; i < idx.length; i++) { if (idx[i] === gridIndex) return true; } return false; } private _buildViewState(): TextGridViewState { const lvNode = this._labelLv?.node; const skinReady = this._isShovelTile() ? !!this._shovelNode?.isValid : this._isPeachTreeTile() ? !!this._peachNode?.isValid : !!this._getSkinSkeleton(); return { nodeActive: this.node.active, draggable: this._dragSurface?.draggable ?? false, skinReady, displayText: '', textColorHex: null, levelLabel: { active: lvNode?.active ?? false, text: this._labelLv?.string ?? '' }, }; } private _buildMapCellState(): TextGridMapCellState | null { const loc = this.location; if (loc?.kind !== 'map') return null; const grid = gg.game.CurentBattle?.Map?.getMapGridByIndex(loc.gridIndex); if (!grid) return null; return { gridIndex: loc.gridIndex, row: grid.Row, col: grid.Col, isCanWalk: grid.isCanWalk, isCanPutText: grid.isCanPutText, isCanPutGrid: grid.isCanPutGrid, isCanBeDig: grid.isCanBeDig, isCanRemove: grid.isCanRemove, isBestPlace: grid.isBestPlace, isFreePlace: grid.isFreePlace, }; } private _buildSkillState(): TextGridSkillState | null { if (this._isShovelTile()) return { present: false, isActive: false }; const loc = this.location; if (loc?.kind === 'map') { const entityId = TowerQueryService.instance.getRuntimeByGridIndex(loc.gridIndex)?.entityId; if (entityId != null) { const actor = HeroActorHost.instance.getActor(entityId); if (actor?.isValid) { return { present: true, isActive: actor.isActive }; } } } const skin = this._skinNode?.isValid ? this._skinNode : null; if (!skin) return null; const skill = skin.getComponent(Skill) ?? skin.getComponentInChildren(Skill); return skill ? { present: true, isActive: skill.isActive } : { present: false, isActive: false }; } private _bindClickSurface(): void { this._ensureDragSurface(); const surface = this._dragSurface; if (!surface?.isValid) return; this._unbindClickSurface(); surface.on(Node.EventType.TOUCH_START, this._onSurfaceTouchStart, this); surface.on(Node.EventType.TOUCH_END, this._onSurfaceTouchEnd, this); surface.on(Node.EventType.TOUCH_CANCEL, this._onSurfaceTouchCancel, this); } private _unbindClickSurface(): void { const surface = this._dragSurface; if (!surface?.isValid) return; surface.off(Node.EventType.TOUCH_START, this._onSurfaceTouchStart, this); surface.off(Node.EventType.TOUCH_END, this._onSurfaceTouchEnd, this); surface.off(Node.EventType.TOUCH_CANCEL, this._onSurfaceTouchCancel, this); } private _onSurfaceTouchStart = (e: EventTouch): void => { if (!this._clickStart) this._clickStart = new Vec2(); this._clickStart.set(e.getUILocation()); }; private _onSurfaceTouchEnd = (e: EventTouch): void => { if (!this._clickStart) return; const end = e.getUILocation(); const dx = end.x - this._clickStart.x; const dy = end.y - this._clickStart.y; this._clickStart = null; if (dx * dx + dy * dy > TextGrid.CLICK_MOVE_THRESHOLD_SQ) return; const surface = this._dragSurface as (Node & { _dragging?: boolean }) | null; if (surface?._dragging) return; this.onClickText(e); }; private _onSurfaceTouchCancel = (): void => { this._clickStart = null; }; private _syncDragMove = (): void => { if (!this._dragFollow) { this._resetDragSurface(); return; } const surface = this._dragSurface; if (!surface?.isValid) return; const lp = surface.position; if (lp.x === 0 && lp.y === 0) return; const np = this.node.position; this.node.setPosition(np.x + lp.x, np.y + lp.y, np.z + lp.z); surface.setPosition(0, 0, 0); }; private _resetDragSurface = (): void => { this._dragSurface?.setPosition(0, 0, 0); }; /** 拖拽悬停 select / redSelect */ private _updateSelect(): void { if (this.selectNode && isValid(this.selectNode)) { this.selectNode.active = this._isSelectStatus; } if (this.redSelectNode && isValid(this.redSelectNode)) { this.redSelectNode.active = this._isRedSelectStatus; } } /** * 龟丞相降级:只改等级字色。 * @param on 是否处于降级期间 */ setLevelDownStyle(on: boolean): void { if (!this.isValid) return; this._levelDown = !!on; if (this._levelDown) this._applyLevelDownLvColor(); else this._restoreLevelDownLvColor(); } /** 等级字改为降级色 */ private _applyLevelDownLvColor(): void { if (!this._labelLv?.isValid) { this._labelLv = this.node.getChildByName('lv')?.getComponent(Label) ?? null; } const lab = this._labelLv; if (!lab) return; if (!this._lvColorOrig) this._lvColorOrig = lab.color.clone(); lab.color = LEVEL_DOWN_LV_COLOR.clone(); } /** * 牛魔王损毁:只倒本格字形(HeroActor / skin / 营预制体),格子根不转。 * ebg、营底框保持世界旋转与世界坐标不变。 * 是否倒下跟当前格/槽字块 ruined,禁止拧到另一座同名英雄。 * @param on 是否处于损毁失效 * @param fromPool 回池强制清本格节点;字块仍 ruined 时不得扶正场上 HeroActor */ setRuinStyle(on: boolean, fromPool = false): void { if (!this.isValid) return; this._snapAngle(this.node, 0); if (fromPool && !on) { this._ruin = false; this._clearRuinXform(true); return; } const tileRuined = this._cellRuined(); const next = tileRuined || !!on; if (!on && tileRuined) { if (this._ruin) this._holdRuinXform(); else { this._ruin = true; this._playRuinFall(); } return; } if (this._ruin === next) { if (next) this._holdRuinXform(); else this._clearLeftoverRuinAngle(); return; } this._ruin = next; if (this._ruin) this._playRuinFall(); else this._clearRuinXform(false); } /** * 损毁期间每帧钉死本格字形 -90°,并把 ebg / 营底框钉回世界角与世界坐标。 */ lateUpdate(): void { if (!this._ruin && !this._cellRuined()) return; this._holdRuinXform(); } /** 可见字形:只取本格 HeroActor / 营预制体 / 格子 skin */ private _ruinTarget(): Node | null { const loc = this.location; if (loc?.kind === 'map') { const n = HeroActorHost.instance.getDisplayAtGrid(loc.gridIndex); if (n?.isValid) return n; } const ying = this._yingCampDisplay; if (ying?.isValid && ying.active) return ying; const skin = this._presentationRoot(); return skin?.isValid ? skin : null; } /** * 钉死节点角度。 * @param n 节点 * @param angle 角度 */ private _snapAngle(n: Node | null, angle: number): void { if (!n?.isValid) return; Tween.stopAllByTarget(n); n.angle = angle; } /** * 当前格/槽字块是否仍损毁(交换后以数据为准,避免拧到新占格的英雄皮)。 * 敌方 AI 字读对手 bag,禁止误用本机格子。 */ private _cellRuined(): boolean { const loc = this.location; const bag = this._ownerBagSession(); if (!loc || !bag) return this._ruin; if (loc.kind === 'map') return !!bag.merge.map.getTile(loc.gridIndex)?.ruined; return !!bag.merge.bag.getSlot(loc.slotIndex)?.ruined; } /** * 字块所属玩家的 bag(敌方读 remotePlayer)。 */ private _ownerBagSession(): BattleBagSession | null { const battle = gg.game.CurentBattle; if (!battle) return null; if (this._aiMapLite || this._isEnemyCampMapCell()) { return battle.remotePlayer?.bagSession ?? null; } return battle.player?.bagSession ?? null; } /** 已倒下时保持字形 -90°;格子根保持 0;底框/阴影世界角与世界坐标保持不变 */ private _holdRuinXform(): void { this._snapAngle(this.node, 0); if (!this._cellRuined()) return; const n = this._ruinTarget(); if (this._ruinFalling && !this._isRuinFallLive(n)) { this._ruinFalling = false; this._ruinFallNode = null; } if (!this._ruinFalling && n?.isValid && n.angle !== -90) this._snapAngle(n, -90); this._lockRuinDecorUpright(); } /** * 倒下 tween 是否仍在 0°~-90° 之间。 * @param n 当前本格字形 */ private _isRuinFallLive(n: Node | null): boolean { const fall = this._ruinFallNode; if (!fall?.isValid || fall !== n) return false; return Math.abs(fall.angle) > 1 && Math.abs(fall.angle + 90) > 1; } /** 字形倒下 */ private _playRuinFall(): void { this._snapAngle(this.node, 0); const n = this._ruinTarget(); if (!n?.isValid) return; Tween.stopAllByTarget(n); this._ruinFalling = true; this._ruinFallNode = n; tween(n).to(0.2, { angle: -90 }).call(this._onRuinFallEnd, this).start(); this._lockRuinDecorUpright(); } /** 倒下 tween 结束:钉死字形 -90° */ private _onRuinFallEnd(): void { this._ruinFalling = false; this._ruinFallNode = null; if (!this.isValid || !this._ruin) return; const n = this._ruinTarget(); if (n?.isValid) n.angle = -90; this._lockRuinDecorUpright(); } /** * 字形回正,格子根与底框/阴影也回正。 * @param fromPool 回池:字块仍 ruined 时不得扶正场上 HeroActor */ private _clearRuinXform(fromPool = false): void { this._ruinFalling = false; this._ruinFallNode = null; this._snapAngle(this.node, 0); this._snapAngle(this._presentationRoot(), 0); this._snapAngle(this._yingCampDisplay, 0); const skipHero = fromPool && this._cellRuined(); if (!skipHero && !this._cellRuined()) this._snapAngle(this._heroDisplayOnThisCell(), 0); this._resetRuinDecorLocal(skipHero); } /** * ebg、营底框相对父节点反向旋转,并钉在父节点未旋转时的世界坐标上。 */ private _lockRuinDecorUpright(): void { this._lockDecorOn(this._ruinTarget()); this._lockDecorOn(this.node); } /** * 锁定 host 下 ebg / 营底框。 * @param host 倒下的字形根或格子根 */ private _lockDecorOn(host: Node | null): void { if (!host?.isValid) return; this._applyUprightWorld(host.getChildByName(SHADOW_NODE)); this._applyUprightWorld(host.getChildByName(YING_FRAME_NODE)); } /** * 局部角 = -父角;世界坐标 = 父世界坐标 + 未旋转局部偏移。 * @param n ebg 或 营底框 */ private _applyUprightWorld(n: Node | null): void { if (!n?.isValid) return; const parent = n.parent; if (!parent?.isValid) { n.angle = 0; return; } const rest = this._ensureDecorRest(n); n.angle = -parent.angle; const wp = parent.worldPosition; const ws = parent.worldScale; n.setWorldPosition(wp.x + rest.x * ws.x, wp.y + rest.y * ws.y, wp.z + rest.z); } /** * 记下底框/阴影未倒下时的局部坐标。 * @param n ebg 或 营底框 */ private _ensureDecorRest(n: Node): RuinDecorRest { const list = this._decorRest; for (let i = 0; i < list.length; i++) { if (list[i].n === n) return list[i]; } const p = n.position; const rest: RuinDecorRest = { n, x: p.x, y: p.y, z: p.z }; const parent = n.parent; if (parent?.isValid && Math.abs(parent.angle) > 1 && Math.abs(n.angle + parent.angle) < 1) { const rad = parent.angle * Math.PI / 180; const c = Math.cos(rad); const s = Math.sin(rad); rest.x = p.x * c - p.y * s; rest.y = p.x * s + p.y * c; } list.push(rest); return rest; } /** * 解除损毁时把底框/阴影局部角与坐标清回未倒下。 * @param skipHero 回池且字块仍 ruined:不要动场上 HeroActor 的 ebg */ private _resetRuinDecorLocal(skipHero = false): void { this._resetDecorOn(this._presentationRoot()); this._resetDecorOn(this._yingCampDisplay); this._resetDecorOn(this.node); if (!skipHero) this._resetDecorOn(this._heroDisplayOnThisCell()); } /** * 本格 HeroActor 可见字形。 */ private _heroDisplayOnThisCell(): Node | null { const loc = this.location; if (loc?.kind !== 'map') return null; return HeroActorHost.instance.getDisplayAtGrid(loc.gridIndex); } /** * 回正 host 下 ebg / 营底框。 * @param host 字形根或格子根 */ private _resetDecorOn(host: Node | null): void { if (!host?.isValid) return; this._restoreDecor(host.getChildByName(SHADOW_NODE)); this._restoreDecor(host.getChildByName(YING_FRAME_NODE)); } /** * 恢复单节点休息位姿并丢掉缓存。 * @param n ebg 或 营底框 */ private _restoreDecor(n: Node | null): void { if (!n?.isValid) return; const list = this._decorRest; for (let i = list.length - 1; i >= 0; i--) { if (list[i].n !== n) continue; n.setPosition(list[i].x, list[i].y, list[i].z); list.splice(i, 1); break; } this._snapAngle(n, 0); } /** * 未损毁时清掉残留 -90°(交换后英雄皮可能被旧格误拧)。 * 本格字块仍 ruined 则不许扶正。 */ private _clearLeftoverRuinAngle(): void { if (this._cellRuined()) { this._holdRuinXform(); return; } this._snapIfFallen(this._ruinTarget()); this._snapIfFallen(this._presentationRoot()); this._snapIfFallen(this._yingCampDisplay); this._snapIfFallen(this._heroDisplayOnThisCell()); this._resetRuinDecorLocal(false); } /** * 仅当节点处于损毁角时回正,避免每帧打断其它 tween。 * @param n 节点 */ private _snapIfFallen(n: Node | null): void { if (!n?.isValid) return; if (Math.abs(n.angle + 90) > 1) return; this._snapAngle(n, 0); } /** 恢复等级字色 */ private _restoreLevelDownLvColor(): void { const lab = this._labelLv; if (!lab?.isValid) return; if (this._lvColorOrig) lab.color = this._lvColorOrig; } private _ensureSkinNode(): void { if (!this._skinNode?.isValid) { this._skinNode = this.node.getChildByName('skin'); } } private _ensureShovelNode(): void { if (!this._shovelNode?.isValid) { this._shovelNode = this.node.getChildByName('铲'); } } private _ensureShovelDigAnimNode(): Node | null { this._ensureShovelNode(); if (!this._shovelDigAnimNode?.isValid) { this._shovelDigAnimNode = this._shovelNode?.getChildByName(SHOVEL_DIG_ANIM_NODE) ?? null; } return this._shovelDigAnimNode; } /** 设置铲开挖 Spine 皮肤 */ private _applyShovelDigSkin(skel: sp.Skeleton, skinName: string): void { const sd = skel.skeletonData; if (!sd?.isValid) { console.log('[TextGrid] 铲开挖 skeletonData 无效', skinName); return; } ensureSpineSkeletonDataRuntime(sd); const runtime = sd.getRuntimeData?.(true) ?? sd.getRuntimeData?.() ?? null; const skin = runtime?.findSkin?.(skinName) ?? null; if (!skin) { console.log('[TextGrid] 铲开挖皮肤不存在', skinName, skel.node?.name); return; } skel.setSkin(skinName); skel.setSlotsToSetupPose(); } /** 铲开挖应使用的 Spine 皮肤:仅 functiontype=10 为金铲,否则铁铲 */ private _resolveShovelDigSkinName(): string { return hasCarriedTreasureShovel() ? SHOVEL_DIG_SKIN_GOLD : SHOVEL_DIG_SKIN_IRON; } private _hideShovelDigAnim(): void { const digNode = this._shovelDigAnimNode ?? this._shovelNode?.getChildByName(SHOVEL_DIG_ANIM_NODE); if (!digNode?.isValid) return; const skel = digNode.getComponent(sp.Skeleton) ?? digNode.getComponentInChildren(sp.Skeleton); if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel); digNode.active = false; } /** 当前字块是否为铲(不走文字皮肤) */ isShovelTile(): boolean { return this._isShovelTile(); } /** 当前字块是否为蟠桃树(Text.id=102,走桃/桃树节点) */ isPeachTreeTile(): boolean { return this._isPeachTreeTile(); } /** * 铲开挖 animation 逻辑时长(秒):Spine 原始时长 / 预制体 timeScale。 * 与 BattleSpinePlayback.play 的 onComplete 延迟一致,供 dig 遮罩并行渐隐。 */ resolveShovelDigDurationSec(): number { const digNode = this._ensureShovelDigAnimNode(); if (!digNode?.isValid) return SHOVEL_DIG_DURATION_FALLBACK_SEC; const skel = digNode.getComponent(sp.Skeleton) ?? digNode.getComponentInChildren(sp.Skeleton); if (!skel?.isValid) return SHOVEL_DIG_DURATION_FALLBACK_SEC; const userScale = this._resolveShovelDigUserTimeScale(skel); const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(skel, SHOVEL_DIG_ANIM_NAME, userScale); if (delay == null || !(delay > 0)) return SHOVEL_DIG_DURATION_FALLBACK_SEC; return delay; } /** * 铲拖到可开挖格:播 animation,按被动 buff 切换铁铲/金铲皮肤。 * @param onComplete 动画播完回调(可选;不阻塞格子上开效果) */ playShovelDigAction(onComplete?: () => void): void { if (!this._isShovelTile()) { onComplete?.(); return; } this._shovelDigComplete = onComplete ?? null; this._applyShovelPresentation(); const parent = this.node.parent; if (parent?.isValid) { this.node.setSiblingIndex(parent.children.length - 1); } this._shovelDigPendingGen = ++this._shovelDigScheduleGen; director.once(Director.EVENT_AFTER_UPDATE, this._onShovelDigDeferredStart, this); } /** 铲开挖:reparent 后下一帧起播 */ private _onShovelDigDeferredStart(): void { if (this._shovelDigPendingGen !== this._shovelDigScheduleGen) return; this._startShovelDigSpine(); } /** 下一帧起播铲开挖 Spine(避免 reparent 同帧 Skeleton 未就绪) */ private _startShovelDigSpine(): void { const onComplete = this._shovelDigComplete; this._shovelDigComplete = null; if (!this.isValid) { onComplete?.(); return; } this._ensureShovelNode(); const shovel = this._shovelNode; if (!shovel?.isValid) { console.log('[TextGrid] 铲开挖缺少铲节点', this.weapon2Id); onComplete?.(); return; } const goldIcon = shovel.getChildByName('金铲子'); const ironIcon = shovel.getChildByName('铲子'); if (goldIcon?.isValid) goldIcon.active = false; if (ironIcon?.isValid) ironIcon.active = false; const digNode = this._ensureShovelDigAnimNode(); if (!digNode?.isValid) { console.log('[TextGrid] 铲开挖动画节点缺失', this.weapon2Id); onComplete?.(); return; } const skel = digNode.getComponent(sp.Skeleton) ?? digNode.getComponentInChildren(sp.Skeleton); if (!skel?.isValid) { console.log('[TextGrid] 铲开挖动画缺少 sp.Skeleton', this.weapon2Id); onComplete?.(); return; } const skin = this._resolveShovelDigSkinName(); shovel.active = true; digNode.active = true; digNode.setSiblingIndex(shovel.children.length - 1); skel.enabled = true; skel.paused = false; BattleSpinePlayback.resetTracks(skel); this._applyShovelDigSkin(skel, skin); const userScale = this._resolveShovelDigUserTimeScale(skel); const ok = BattleSpinePlayback.play(skel, SHOVEL_DIG_ANIM_NAME, { loop: false, timeScale: userScale, onComplete: this._onShovelDigSpineComplete.bind(this, digNode, onComplete), }); if (!ok) { this._onShovelDigSpineComplete(digNode, onComplete); } } /** 铲开挖 Spine 预制体 timeScale(≤0 时按 1) */ private _resolveShovelDigUserTimeScale(skel: sp.Skeleton): number { const scale = Number(skel.timeScale); return scale > 0 ? scale : 1; } /** 铲开挖 Spine 播完 */ private _onShovelDigSpineComplete(digNode: Node, onComplete: (() => void) | null): void { if (digNode?.isValid) digNode.active = false; onComplete?.(); } /** 当前字块是否为铲(内部) */ private _isShovelTile(): boolean { const entityType = this._entity?.tileType as Weapon2TileType | undefined; if (entityType === Weapon2TileType.Shovel) return true; if (this.weapon2Id === SHOVEL_WEAPON2_ID) return true; WeaponComposeConfigProvider.instance.load(); return WeaponComposeConfigProvider.instance.getWeapon2Type(this.weapon2Id) === Weapon2TileType.Shovel; } /** 当前字块是否为蟠桃树(内部) */ private _isPeachTreeTile(): boolean { return this.weapon2Id === PEACH_TREE_WEAPON2_ID; } /** 抖动/复位用的表现根:五行数字皮、铲、蟠桃树,其余用 skin */ private _presentationRoot(): Node { if (this._isElementNumberTile() && this._numberNode?.isValid) return this._numberNode; if (this._isShovelTile() && this._shovelNode?.isValid) return this._shovelNode; if (this._isPeachTreeTile() && this._peachNode?.isValid) return this._peachNode; if (this._skinNode?.isValid) return this._skinNode; return this.node; } /** * 阴影:铲/桃/英雄皮已挂上时隐藏;单字上场等英雄预制体期间仍显示。 */ private _syncShadow(): void { if (!this._shadowNode?.isValid) { this._shadowNode = this.node.getChildByName(SHADOW_NODE); } const node = this._shadowNode; if (!node?.isValid) return; const shovelFace = this._isShovelTile() && !this._isElementNumberTile(); node.active = !shovelFace && !this._isPeachTreeTile() && !this._shouldHideSkinForHeroActor(); } /** 铲:隐藏 skin,显示预制体「铲」节点(铁铲/金铲静态图) */ private _applyShovelPresentation(): void { this._ensureShovelNode(); this._hideShovelDigAnim(); this._skinIconName = null; this._hideNumberNodes(); if (this._skinNode?.isValid) { Tween.stopAllByTarget(this._skinNode); this._skinNode.setPosition(0, 0, 0); this._skinNode.active = false; } const shovel = this._shovelNode; if (!shovel?.isValid) { console.error('[TextGrid] 铲节点缺失', this.weapon2Id); return; } shovel.active = true; shovel.setPosition(0, 0, 0); const gold = shovel.getChildByName('金铲子'); const normal = shovel.getChildByName('铲子'); const treasure = hasCarriedTreasureShovel(); if (gold?.isValid) gold.active = treasure; if (normal?.isValid) { normal.active = !treasure; } else if (!treasure) { console.log('[TextGrid] 铲子子节点缺失', this.weapon2Id); } this._applySpecialTilePresentationLayer(shovel); this._ensureDragSurface(); this._forwardChildTouchToSurface(); this._syncShadow(); } /** 非铲:隐藏「铲」节点 */ private _hideShovelPresentation(): void { this._ensureShovelNode(); this._hideShovelDigAnim(); if (this._shovelNode?.isValid) this._shovelNode.active = false; } private _ensurePeachNode(): void { if (!this._peachNode?.isValid) { this._peachNode = this.node.getChildByName(PEACH_TREE_NODE); } } private _ensurePeachTreeAnimNode(): Node | null { this._ensurePeachNode(); if (!this._peachTreeAnimNode?.isValid) { this._peachTreeAnimNode = this._peachNode?.getChildByName(PEACH_TREE_ANIM_NODE) ?? null; } return this._peachTreeAnimNode; } /** 蟠桃树:隐藏 skin/铲,显示「桃/桃树」并按技能 CD 播 Spine */ private _applyPeachTreePresentation(): void { this._ensurePeachNode(); this._skinIconName = null; this._hideNumberNodes(); if (this._skinNode?.isValid) { Tween.stopAllByTarget(this._skinNode); this._skinNode.setPosition(0, 0, 0); this._skinNode.active = false; } const peach = this._peachNode; if (!peach?.isValid) { console.error('[TextGrid] 桃节点缺失', this.weapon2Id); return; } peach.active = true; peach.setPosition(0, 0, 0); this._applySpecialTilePresentationLayer(peach); if (!this._peachTreeSpawnPlaying) { this._peachTreeLoopAnim = null; } if (this.location?.kind === 'map') { this._peachTreeCdCycleStartTimer = 0; this._peachTreeCdSyncEntityId = null; } this.tickPeachTreeCdPresentation(); this._ensureDragSurface(); this._forwardChildTouchToSurface(); this._syncShadow(); } /** * 帧更新:CD 前半桃字1、后半桃字2 循环(仅已部署地图格)。 * @param deploySync 敌方 DeploySync(aiMapLite 时由外部传入) * @param mapRuntime 敌方格 runtime(aiMapLite 时由外部解析) */ tickPeachTreeCdPresentation(deploySync?: DeploySync | null, mapRuntime?: TowerRuntimeSnapshot | null): void { if (!this._isPeachTreeTile() || !this._peachNode?.isValid || !this._peachNode.active) return; if (this._peachTreeSpawnPlaying) return; if (this.location?.kind !== 'map') { this._applyPeachTreeLoopAnim(PEACH_TREE_ANIM_IDLE1); return; } const battle = gg.game.CurentBattle; const session = battle?.session; if (!session) { this._applyPeachTreeLoopAnim(PEACH_TREE_ANIM_IDLE1); return; } const runtime = mapRuntime ?? (!this._aiMapLite ? TowerQueryService.instance.getRuntimeByGridIndex(this.location.gridIndex, session, deploySync ?? null) : null); if (!runtime) { this._applyPeachTreeLoopAnim(PEACH_TREE_ANIM_IDLE1); return; } const entityId = runtime.entityId; if (this._peachTreeCdSyncEntityId !== entityId) { this._peachTreeCdSyncEntityId = entityId; this._resetPeachTreeCdCycleTracking(); } const tick = session.abilities.getInterruptibleNormalTickSnapshot(entityId); if (!tick || !(tick.interval > 0)) { console.log('[TextGrid] 蟠桃树普攻 OnTick 缺失', entityId, this.weapon2Id); this._applyPeachTreeLoopAnim(PEACH_TREE_ANIM_IDLE1); return; } this._syncPeachTreeCdCycleStart(tick.timer); const half = this._peachTreeCdCycleStartTimer * 0.5; const loopAnim = tick.timer > half ? PEACH_TREE_ANIM_IDLE1 : PEACH_TREE_ANIM_IDLE2; this._applyPeachTreeLoopAnim(loopAnim); } /** 产桃时播桃字3 一次,结束后回到桃字1 循环 */ playPeachTreeSpawnPresentation(): void { if (!this._isPeachTreeTile() || !this._peachNode?.isValid || !this._peachNode.active) return; const treeNode = this._ensurePeachTreeAnimNode(); const skel = this._resolvePeachTreeSkeleton(treeNode); if (!treeNode?.isValid || !skel) return; this._peachTreeSpawnPlaying = true; this._peachTreeLoopAnim = null; treeNode.active = true; skel.enabled = true; skel.paused = false; BattleSpinePlayback.resetTracks(skel); const ok = BattleSpinePlayback.play(skel, PEACH_TREE_ANIM_SPAWN, { loop: false, onComplete: this._onPeachTreeSpawnAnimComplete.bind(this), }); if (!ok) { this._onPeachTreeSpawnAnimComplete(); } } /** * 铲/蟠桃树专用层:与「铲」同 sibling,Spine 不走 Sorting2D(与铲 Sprite 一致)。 * 地图格上整颗 textGrid 置顶,避免被 mapGrid 子节点遮挡。 */ private _applySpecialTilePresentationLayer(presentationNode: Node): void { this._ensureShovelNode(); const ref = this._shovelNode; if (ref?.isValid && ref.parent === this.node && presentationNode.parent === this.node) { presentationNode.setSiblingIndex(ref.getSiblingIndex()); } this._disablePresentationSorting2D(presentationNode); this._raiseMapTextGridDrawOrder(); } /** 专用表现子树禁用 Sorting2D,按 sibling 与铲图同级绘制 */ private _disablePresentationSorting2D(root: Node): void { for (const sort of root.getComponentsInChildren(Sorting2D)) { sort.enabled = false; } } /** 地图字块:textGrid 置于 mapGrid 子节点最上层(与铲开挖 reparent 一致) */ private _raiseMapTextGridDrawOrder(): void { if (this.location?.kind !== 'map') return; const parent = this.node.parent; if (!parent?.isValid) return; this.node.setSiblingIndex(parent.children.length - 1); } /** 非蟠桃树:隐藏「桃」节点 */ private _hidePeachTreePresentation(): void { this._resetPeachTreeCdAnimState(); this._ensurePeachNode(); if (this._peachNode?.isValid) this._peachNode.active = false; const treeNode = this._peachTreeAnimNode ?? this._peachNode?.getChildByName(PEACH_TREE_ANIM_NODE); if (treeNode?.isValid) { const skel = treeNode.getComponent(sp.Skeleton) ?? treeNode.getComponentInChildren(sp.Skeleton); if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel); treeNode.active = false; } } /** 重置蟠桃树 CD 动画状态 */ private _resetPeachTreeCdAnimState(): void { this._peachTreeLoopAnim = null; this._peachTreeSpawnPlaying = false; this._peachTreeCdCycleStartTimer = 0; this._peachTreeCdSyncEntityId = null; } /** 重置本轮 CD 切分基准(下次 tick 按当前 timer 重新记录起点) */ private _resetPeachTreeCdCycleTracking(): void { this._peachTreeCdCycleStartTimer = 0; } /** * 记录本轮 CD 起点:timer 回满(产桃后)或首次同步时更新。 * @param currentTimer 普攻 OnTick 剩余秒数 */ private _syncPeachTreeCdCycleStart(currentTimer: number): void { if (!(currentTimer > 0)) return; if (!(this._peachTreeCdCycleStartTimer > 0) || currentTimer > this._peachTreeCdCycleStartTimer) { this._peachTreeCdCycleStartTimer = currentTimer; } } /** 解析蟠桃树 Spine 组件 */ private _resolvePeachTreeSkeleton(treeNode: Node | null): sp.Skeleton | null { if (!treeNode?.isValid) { console.log('[TextGrid] 桃树节点缺失', this.weapon2Id); return null; } treeNode.setPosition(0, 0, 0); const skel = treeNode.getComponent(sp.Skeleton) ?? treeNode.getComponentInChildren(sp.Skeleton); if (!skel?.isValid) { console.log('[TextGrid] 桃树缺少 sp.Skeleton', this.weapon2Id); return null; } if (skel.skeletonData) ensureSpineSkeletonDataRuntime(skel.skeletonData); return skel; } /** 循环播蟠桃树 Spine(同动画不重复起播) */ private _applyPeachTreeLoopAnim(anim: string): void { if (this._peachTreeSpawnPlaying) return; if (this._peachTreeLoopAnim === anim) return; const treeNode = this._ensurePeachTreeAnimNode(); const skel = this._resolvePeachTreeSkeleton(treeNode); if (!treeNode?.isValid || !skel) return; treeNode.active = true; skel.enabled = true; skel.paused = false; BattleSpinePlayback.resetTracks(skel); const ok = BattleSpinePlayback.play(skel, anim, { loop: true }); if (ok) { this._peachTreeLoopAnim = anim; } } /** 桃字3 播完:回到桃字1 循环 */ private _onPeachTreeSpawnAnimComplete(): void { this._peachTreeSpawnPlaying = false; this._peachTreeLoopAnim = null; this._resetPeachTreeCdCycleTracking(); this._applyPeachTreeLoopAnim(PEACH_TREE_ANIM_IDLE1); } private _getSkinSkeleton(): sp.Skeleton | null { return this._skinNode?.getComponent(sp.Skeleton) ?? null; } /** * 出字入场拖尾:普通字 trail1、金色组合字 trail2、铲 trail3。 * 仅入场飞行时打开,落地后须 hideEntranceTrail。 */ showEntranceTrail(): void { const type = (this._entity?.tileType as Weapon2TileType | undefined) ?? WeaponComposeConfigProvider.instance.getWeapon2Type(this.weapon2Id) as Weapon2TileType; this._applyTrailByTileType(type); } hideEntranceTrail(): void { if (!this.isValid || !this.node?.isValid) return; const trail1 = this.node.getChildByName('trail1'); const trail2 = this.node.getChildByName('trail2'); const trail3 = this.node.getChildByName('trail3'); if (trail1?.isValid) trail1.active = false; if (trail2?.isValid) trail2.active = false; if (trail3?.isValid) trail3.active = false; } /** * 入场拖尾:普通字 trail1、金色组合字 trail2、铲 trail3。 */ private _applyTrailByTileType(type: Weapon2TileType): void { const trail1 = this.node.getChildByName('trail1'); const trail2 = this.node.getChildByName('trail2'); const trail3 = this.node.getChildByName('trail3'); const use1 = type === Weapon2TileType.Single; const use2 = type === Weapon2TileType.Combo; const use3 = type === Weapon2TileType.Shovel; if (trail1?.isValid) trail1.active = use1; if (trail2?.isValid) trail2.active = use2; if (trail3?.isValid) trail3.active = use3; } /** 关掉 lv。五行字改由数字节点显示 Hero.number */ private _hideLevelLabel(): void { if (!this._labelLv?.isValid) { this._labelLv = this.node.getChildByName('lv')?.getComponent(Label) ?? null; } if (this._labelLv?.node?.isValid) this._labelLv.node.active = false; } /** * 五行字:Text.type 为 1–5(金木水火土)。 * 与旧 Weapon2 类型(单字/组合/格子/铲)不是同一套编号。 */ private _isElementNumberTile(): boolean { const type = Math.floor(Number(this._entity?.tile?.type)); return type >= 1 && type <= 5; } /** 子节点名是否为纯数字(对应 Hero.number) */ private _isNumberFaceName(name: string): boolean { if (!name) return false; let n = 0; for (let i = 0; i < name.length; i++) { const c = name.charCodeAt(i); if (c < 48 || c > 57) return false; n = n * 10 + (c - 48); } return n > 0; } /** Hero.herotype → 数字 Spine 皮肤名 */ private _elementSkinName(herotype: number): string { switch (herotype) { case 1: return '金'; case 2: return '木'; case 3: return '水'; case 4: return '火'; case 5: return '土'; default: return ''; } } /** 按 herotype 与显示等级查 Hero。显示等级含段位加值与合成升级,不是 Text.level */ private _heroByTypeLevel(herotype: number, level: number): ITableHero | null { const heroes = gg.data.table.getTableList(TableNames.Hero) ?? []; for (let i = 0; i < heroes.length; i++) { const hero = heroes[i]; if (Math.floor(Number(hero.herotype)) !== herotype) continue; if (Math.floor(Number(hero.level)) !== level) continue; return hero; } return null; } /** 关掉全部数字皮节点 */ private _hideNumberNodes(): void { if (!this.node?.isValid) return; const children = this.node.children; for (let i = 0; i < children.length; i++) { const child = children[i]; if (!child?.isValid || !this._isNumberFaceName(child.name)) continue; child.active = false; } this._numberNode = null; } /** 旧 skin 与当前数字皮一起显隐 */ private _setGlyphFaceActive(active: boolean): void { if (this._skinNode?.isValid) this._skinNode.active = active; if (!this._numberNode?.isValid) return; this._numberNode.active = active; if (!active) return; const sk = this._numberNode.getComponent(sp.Skeleton); if (sk?.isValid) sk.enabled = true; } /** * 打开 Hero.number 同名节点并切五行皮。 * @param show 是否可见(成将/营盖住时仍选中节点但保持关闭) */ private _applyNumberGlyph(show: boolean): void { const tile = this._entity?.tile; if (!tile) { this._hideNumberNodes(); return; } const herotype = Math.floor(Number(tile.type)); const level = this._resolveDisplayLevel(); const hero = this._heroByTypeLevel(herotype, level); if (!hero) { console.log('[TextGrid] 无英雄编号', this.weapon2Id, 'type=', herotype, 'level=', level); this._hideNumberNodes(); return; } const num = Math.floor(Number(hero.number)); if (!(num > 0)) { console.log('[TextGrid] Hero.number 无效', hero.id, hero.number); this._hideNumberNodes(); return; } const node = this.node.getChildByName(String(num)); if (!node?.isValid || !this._isNumberFaceName(node.name)) { console.log('[TextGrid] 数字节点缺失', num, 'hero=', hero.id); this._hideNumberNodes(); return; } const skinName = this._elementSkinName(herotype); if (!skinName) { console.log('[TextGrid] 五行皮肤未定义', herotype, 'hero=', hero.id); this._hideNumberNodes(); return; } this._hideNumberNodes(); this._numberNode = node; node.active = true; this._playElementSkin(node, skinName); node.active = show; } /** 数字节点切五行皮并播待机 */ private _playElementSkin(node: Node, skinName: string): void { const sk = node.getComponent(sp.Skeleton); if (!sk?.isValid) { console.log('[TextGrid] 数字节点缺少 Skeleton', node.name, this.weapon2Id); return; } sk.enabled = true; sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); const sd = sk.skeletonData; if (!sd?.isValid) { console.log('[TextGrid] 数字节点 skeletonData 无效', node.name, this.weapon2Id); return; } ensureSpineSkeletonDataRuntime(sd); if (!glyphSpineHasSkin(sk, skinName)) { console.log('[TextGrid] 数字皮不存在', skinName, node.name, this.weapon2Id); return; } sk.setSkin(skinName); sk.setSlotsToSetupPose(); const runtime = sd.getRuntimeData?.(true); if (runtime?.findAnimation?.('待机')) { sk.setAnimation(0, '待机', true); } } /** 隐藏 lv,并按当前显示等级刷新五行数字皮 */ private _updateLevelLabel(): void { this._hideLevelLabel(); if (!this._isElementNumberTile()) { this._hideNumberNodes(); return; } const yingCover = !!this._yingCampDisplay?.isValid && this._yingCampDisplay.active; this._applyNumberGlyph(!this._shouldHideGridSkin() && !yingCover); } /** 显示等级:以 setData 写入的 level 为准(与 tile / 组合数据同步) */ private _resolveDisplayLevel(): number { return this.level; } /** 地图字块等级变更后刷新数字皮(免全量 setData) */ applyMapLevel(level: number): void { if (!this.isValid) return; const lv = Math.max(1, Math.floor(level) || 1); this.level = lv; if (this._entity) { const comp = this._entity.composition ? { ...this._entity.composition, level: lv } : null; this._entity = { ...this._entity, level: lv, composition: comp }; } this._updateLevelLabel(); if (this._isPeachTreeTile()) { // 蟠桃树落下 tween 可能被合成打断停在偏上,升级时归中并重刷表现 this.resetLocalPosition(); this.resetSkinPresentation(); this._applyPeachTreePresentation(); } this._syncSkillActiveState(); } private _ensureDragSurface(): void { const { width, height } = this._resolveDragSurfaceSize(); if (this._dragSurface?.isValid) { this._dragSurface.getComponent(UITransform)?.setContentSize(width, height); this._dragSurface.setSiblingIndex(this.node.children.length - 1); return; } const surface = new Node('__dragSurface'); surface.parent = this.node; surface.setPosition(0, 0, 0); surface.addComponent(UITransform).setContentSize(width, height); if (!surface.getComponent(BlockInputEvents)) surface.addComponent(BlockInputEvents); surface.setSiblingIndex(this.node.children.length - 1); this._dragSurface = surface; this._bindClickSurface(); } private _resolveDragSurfaceSize(): { width: number; height: number } { const cell = BattleMapSpec.CELL_SIZE; const nodeUt = this.node.getComponent(UITransform); if (nodeUt && nodeUt.contentSize.width > 0 && nodeUt.contentSize.width <= cell * 2) { return { width: nodeUt.contentSize.width, height: nodeUt.contentSize.height }; } return { width: cell, height: cell }; } private _forwardChildTouchToSurface(): void { const surface = this._dragSurface; if (!surface) return; const relay = (e: EventTouch) => { e.preventSwallow = true; surface.emit(e.type, e); }; for (const child of this.node.children) { if (child === surface) continue; child.off(Node.EventType.TOUCH_START, relay, this); child.off(Node.EventType.TOUCH_MOVE, relay, this); child.off(Node.EventType.TOUCH_END, relay, this); child.off(Node.EventType.TOUCH_CANCEL, relay, this); child.on(Node.EventType.TOUCH_START, relay, this); child.on(Node.EventType.TOUCH_MOVE, relay, this); child.on(Node.EventType.TOUCH_END, relay, this); child.on(Node.EventType.TOUCH_CANCEL, relay, this); } } /** 按 Text.icon 刷新 skin 节点 Spine(普通字块;铲走 _applyShovelPresentation) */ private _applySkinIcon(iconName: string): void { const name = this._normalizeIconName(iconName); if (!name) return; this._ensureSkinNode(); const skinNode = this._skinNode; if (!skinNode?.isValid) { console.error('[TextGrid] skin 节点缺失', name); return; } if (this._skinIconName === name) { if (!this._shouldHideGridSkin()) { skinNode.active = true; } this.resetSkinPresentation(); const sk = this._getSkinSkeleton(); if (sk) sk.enabled = true; this._applySkinSpineIcon(name); return; } this._skinIconName = name; if (!this._shouldHideGridSkin()) { skinNode.active = true; } const sk = this._getSkinSkeleton(); if (sk) sk.enabled = true; this.resetSkinPresentation(); this._applySkinSpineIcon(name); this._ensureDragSurface(); this._forwardChildTouchToSurface(); } /** skin 节点 Spine 切 Text.icon 并播待机 */ private _applySkinSpineIcon(iconName: string): void { const sk = this._getSkinSkeleton(); if (!sk?.isValid) { console.log('[TextGrid] skin 节点缺少 sp.Skeleton', iconName, this.weapon2Id); return; } sk.enabled = true; // 池化复用后强制 REALTIME,避免 SHARED/PRIVATE 缓存与换肤叠出空网格 sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); if (!applyGlyphSpineSkin(sk, iconName)) { console.log('[TextGrid] Spine 皮肤未能应用', iconName, this.weapon2Id); } if (sk.skeletonData) ensureSpineSkeletonDataRuntime(sk.skeletonData); const runtime = sk.skeletonData?.getRuntimeData?.(true); if (runtime?.findAnimation?.('待机')) { sk.setAnimation(0, '待机', true); } } private _normalizeIconName(icon: unknown): string | null { if (typeof icon === 'string') { const name = icon.trim(); return name || null; } return null; } /** 同步英雄表现:隐藏格子皮 / 显示 HeroActor */ syncHeroPresentation(): void { this._finishSkinPresentation(); } private _finishSkinPresentation(): void { this._heroActorRetry = 0; this._syncSkillActiveState(); GEvent.Ins.emit(GEvent.UpdateBestPlace); } /** 本格属于已成将的 HeroActor 组合(不论英雄皮是否已挂上) */ private _isHeroActorComposeCell(): boolean { const composition = this._getComposition(); if (composition == null || !isHeroActorWeapon1(composition.weapon1Id)) return false; const loc = this.location; if (loc?.kind !== 'map') return false; return this._compHasGrid(composition, loc.gridIndex); } /** 本格已编入 HeroActor 且英雄字形已挂上:藏格子皮。预制体失败/异步未到时仍显示原单字 */ private _shouldHideSkinForHeroActor(): boolean { if (!this._isHeroActorComposeCell()) return false; const composition = this._getComposition(); return composition != null && this._isHeroDisplayOnThisCell(composition); } /** 格子皮应藏:HeroActor 已盖住,或上场营已挂 营.prefab */ private _shouldHideGridSkin(): boolean { if (this._shouldHideSkinForHeroActor()) return true; return this._isYingWeaponTile() && this._isMapComposedAttackUnit(); } /** 英雄皮是否盖在当前地图格(交换拆开后必须为 false) */ private _isHeroDisplayOnThisCell(composition: ComposedUnit): boolean { const loc = this.location; if (loc?.kind !== 'map') return false; return HeroActorHost.instance.isDisplayVisibleAtGrid(composition, loc.gridIndex); } /** 地图字块已组合为攻击单位 → 激活;拆开或未组合 → 关闭;组合字切换 Spine 皮肤 */ private _syncSkillActiveState(): void { this._syncShadow(); if (this._isPeachTreeTile()) return; if (this._isShovelTile() && !this._isElementNumberTile()) return; const composition = this._getComposition(); const active = this._isMapComposedAttackUnit(); if (this._aiMapLite) { this._syncAiMapLitePresentation(composition, active); return; } if (this._isYingWeaponTile()) { this._syncYingCampState(active); return; } // 正面 buff 特效由 CocosTowerPositiveBuffPresenter 按 AttributeComp 同步,勿在此清 const heroActor = composition != null && isHeroActorWeapon1(composition.weapon1Id); const heroGlyph = this._skinIconName != null && HERO_GLYPH_RESOURCES.has(this._skinIconName); if (heroActor || heroGlyph) { this._syncHeroActorState(heroActor ? composition : null, active); return; } const skin = this._skinNode?.isValid ? this._skinNode : null; const skill = skin?.getComponent(Skill) ?? skin?.getComponentInChildren(Skill) ?? null; if (!skill) { this._setGlyphFaceActive(!this._shouldHideSkinForHeroActor()); return; } const w2Type = WeaponComposeConfigProvider.instance.getWeapon2Type(this.weapon2Id) as Weapon2TileType; const entityType = this._entity?.tileType as Weapon2TileType | undefined; const isCombo = w2Type === Weapon2TileType.Combo || entityType === Weapon2TileType.Combo || (composition?.gridIndices.length ?? 0) >= 2; // 激活态未变:升级/重建只刷组合字等级皮,勿 abort(嫦娥领域等) if (skill.isActive === active) { if (isCombo && active) { skill.applyComposeTextSkin(true, this._resolveDisplayLevel()); } else if (!active) { this.resetSkinPresentation(); // 背包/未上场:强制待机(预制体 defaultAnimation 可能是「攻击」) if (isCombo) { skill.applyComposeTextSkin(false, this._resolveDisplayLevel()); } skill.onIdle(); } return; } const activating = active && !skill.isActive; const deactivating = !active && skill.isActive; // 仅失活时打断;首次激活时 PresentationCue 可能同帧已开始,abort 会 cancel 掉首刀 if (deactivating) { skill.abortPresentation(); this.resetSkinPresentation(); } skill.onSetActiveAtk(active); if (isCombo) { skill.applyComposeTextSkin(active, this._resolveDisplayLevel()); } if (deactivating || !active) { skill.onIdle(); } else if (!activating || !skill.isPresentationBusy()) { skill.onIdle(); } } /** HeroActor 对账重试(DeploySync 可能晚于 TextGrid.setData) */ private _heroActorRetry = 0; /** 递增作废未触发的 HeroActor 重试回调(换字/变铲后勿再开 skin) */ private _heroActorRetryToken = 0; /** * AI 地图轻量对账:营走阵营皮;HeroActor 格只藏/显格子皮;散字只套 icon。 * @param composition 合成体 * @param active 是否已组成攻击单位 */ private _syncAiMapLitePresentation(composition: ComposedUnit | null, active: boolean): void { if (this._isYingWeaponTile()) { this._syncYingCampState(active); this._updateLevelLabel(); return; } const heroActor = composition != null && isHeroActorWeapon1(composition.weapon1Id); if (heroActor) { if (this._skinNode?.isValid) this._skinNode.active = !this._shouldHideSkinForHeroActor(); this._updateLevelLabel(); return; } if (this._skinNode?.isValid) this._skinNode.active = true; if (this._skinIconName) this._applySkinSpineIcon(this._skinIconName); this._updateLevelLabel(); } /** setData 换字时作废挂起的 HeroActor 对账 */ private _invalidateHeroActorRetry(): void { this._heroActorRetryToken++; this._heroActorRetry = 0; } private _syncHeroActorState(composition: ComposedUnit | null, active: boolean): void { if (this._isPeachTreeTile()) return; if (this._isShovelTile() && !this._isElementNumberTile()) return; const loc = this.location; if (active && composition) { const map = gg.game.CurentBattle?.Map; let entityId: number | null = null; if (map) { entityId = loc?.kind === 'map' ? TowerQueryService.instance.getRuntimeByGridIndex(loc.gridIndex)?.entityId ?? null : null; const enemyCamp = this._isEnemyCampMapCell(); HeroActorHost.instance.ensureVisual(composition, map, entityId, enemyCamp); } const useHeroActor = isHeroActorWeapon1(composition.weapon1Id); const displayReady = this._isHeroDisplayOnThisCell(composition); this._setGlyphFaceActive(!this._shouldHideSkinForHeroActor()); // 皮可见 ≠ 已挂 Skill:entityId 晚到时必须继续对账,否则有皮无武器、不攻击 const skillReady = !useHeroActor || loc?.kind !== 'map' || (entityId != null && !!HeroActorHost.instance.getActor(entityId)); if (!displayReady || !skillReady) { const delay = this._heroActorRetry < 12 ? 0 : (this._heroActorRetry < 48 ? 0.08 : 0.25); if (this._heroActorRetry < 140) { this._heroActorRetry++; const token = this._heroActorRetryToken; this.scheduleOnce(() => { if (!this.isValid || token !== this._heroActorRetryToken) return; if (this._isPeachTreeTile()) return; if (this._isShovelTile() && !this._isElementNumberTile()) return; this._syncHeroActorState(composition, active); }, delay); } else if (useHeroActor) { ClientErrorReporter.report( 'TextGrid_HeroActorNotReady', `w1=${composition.weapon1Id}|display=${displayReady}|entity=${entityId}`, ); } } else { this._heroActorRetry = 0; } this._syncShadow(); return; } this._heroActorRetry = 0; this._setGlyphFaceActive(true); this.resetSkinPresentation(); this._resetGlyphSkinToBagIdle(); // 仅当地图格曾属于已激活组合时才拆 HeroActor;散落组合字(如单独「空」)勿误伤邻格 if (loc?.kind === 'map' && this._shouldDestroyHeroAtDeactivate(loc.gridIndex)) { HeroActorHost.instance.destroyAtGrid(loc.gridIndex); } this._syncShadow(); } /** 本节点是否仍挂在该地图格上(对象池幽灵 / 飞行节点不算) */ private _isLiveMapOccupant(gridIndex: number): boolean { const map = gg.game.CurentBattle?.Map; const parent = map?.getMapGridByIndex?.(gridIndex)?.node; return !!parent?.isValid && this.node?.parent === parent; } /** * 失活时是否应销毁该格 HeroActor。 * 组合仍在场时邻格快照未带上 composition 不得拆整座 Actor。 */ private _shouldDestroyHeroAtDeactivate(gridIndex: number): boolean { if (!this._isLiveMapOccupant(gridIndex)) return false; if (!HeroActorHost.instance.hasActorAtGrid(gridIndex)) return false; if (this._findComposition()) return false; return true; } /** 收回背包:恢复 Text.icon 待机皮 */ private _resetGlyphSkinToBagIdle(): void { if (!this._skinIconName) return; this._applySkinSpineIcon(this._skinIconName); } /** 当前字块在地图上且属于有效组合(含单格 Weapon1) */ private _isMapComposedAttackUnit(): boolean { const loc = this.location; if (loc?.kind !== 'map') return false; return this._getComposition() != null; } }