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.
2545 lines
97 KiB
2545 lines
97 KiB
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 { 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,
|
|
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 预制体)。
|
|
* 普通字:skin 节点按 Text.icon 切 Spine;铲:隐藏 skin,显示「铲」节点;
|
|
* 蟠桃树(102):显示「桃/桃树」Spine,按当前等级 Skill.cd 前半/后半循环桃字1/2,产桃播桃字3。
|
|
*/
|
|
@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;
|
|
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;
|
|
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();
|
|
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;
|
|
if (this._skinNode?.isValid) this._skinNode.active = 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._isShovelTile()) {
|
|
this._hidePeachTreePresentation();
|
|
this._applyShovelPresentation();
|
|
} else if (this._isPeachTreeTile()) {
|
|
this._hideShovelPresentation();
|
|
this._applyPeachTreePresentation();
|
|
} else {
|
|
this._hideShovelPresentation();
|
|
this._hidePeachTreePresentation();
|
|
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._isShovelTile()) {
|
|
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._isShovelTile()) {
|
|
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._isShovelTile()) {
|
|
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;
|
|
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._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;
|
|
node.active = !this._isShovelTile()
|
|
&& !this._isPeachTreeTile()
|
|
&& !this._shouldHideSkinForHeroActor();
|
|
}
|
|
|
|
/** 铲:隐藏 skin,显示预制体「铲」节点(铁铲/金铲静态图) */
|
|
private _applyShovelPresentation(): void {
|
|
this._ensureShovelNode();
|
|
this._hideShovelDigAnim();
|
|
this._skinIconName = null;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
private _updateLevelLabel(): void {
|
|
if (!this._labelLv?.isValid) {
|
|
this._labelLv = this.node.getChildByName('lv')?.getComponent(Label) ?? null;
|
|
}
|
|
if (!this._labelLv) return;
|
|
WeaponComposeConfigProvider.instance.load();
|
|
const provider = WeaponComposeConfigProvider.instance;
|
|
const w2Type = provider.getWeapon2Type(this.weapon2Id) as Weapon2TileType;
|
|
if (w2Type === Weapon2TileType.Grid || w2Type === Weapon2TileType.Shovel) {
|
|
if (!this._isPeachTreeTile()) {
|
|
this._labelLv.node.active = false;
|
|
return;
|
|
}
|
|
}
|
|
const level = this._resolveDisplayLevel();
|
|
if (w2Type === Weapon2TileType.Single || this._isPeachTreeTile()) {
|
|
this._labelLv.node.active = true;
|
|
this._labelLv.string = `${level}`;
|
|
if (this._levelDown) this._applyLevelDownLvColor();
|
|
return;
|
|
}
|
|
// type2 组合字:多格组合时仅最后一字显示等级
|
|
const composition = this._getComposition();
|
|
if (composition && composition.gridIndices.length >= 2
|
|
&& !provider.isSingleCellWeapon1(composition.weapon1Id)) {
|
|
const show = this._isLastTileInComposition(composition);
|
|
this._labelLv.node.active = show;
|
|
if (show) this._labelLv.string = `${level}`;
|
|
if (this._levelDown) this._applyLevelDownLvColor();
|
|
return;
|
|
}
|
|
const show = level > 1;
|
|
this._labelLv.node.active = show;
|
|
if (show) this._labelLv.string = `${level}`;
|
|
if (this._levelDown) this._applyLevelDownLvColor();
|
|
}
|
|
|
|
/** 组合方向上的最后一格(横→最右,竖→最下) */
|
|
private _isLastTileInComposition(composition: ComposedUnit): boolean {
|
|
const loc = this.location;
|
|
if (loc?.kind !== 'map') return false;
|
|
const lastIdx = composition.gridIndices[composition.gridIndices.length - 1];
|
|
return loc.gridIndex === lastIdx;
|
|
}
|
|
|
|
/** 显示等级:以 setData 写入的 level 为准(与 tile / 组合数据同步) */
|
|
private _resolveDisplayLevel(): number {
|
|
return this.level;
|
|
}
|
|
|
|
/** 地图字块等级变更后刷新 lv 与等级皮(免全量 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._isShovelTile() || this._isPeachTreeTile()) 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;
|
|
if (!skin) return;
|
|
const skill = skin.getComponent(Skill) ?? skin.getComponentInChildren(Skill);
|
|
if (!skill) {
|
|
skin.active = !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._isShovelTile() || this._isPeachTreeTile()) 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);
|
|
if (this._skinNode?.isValid) {
|
|
this._skinNode.active = !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._isShovelTile() || this._isPeachTreeTile()) 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;
|
|
if (this._skinNode?.isValid) {
|
|
this._skinNode.active = 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;
|
|
}
|
|
}
|
|
|