import { _decorator, Collider2D, Component, ERigidBody2DType, EventTouch, Graphics, instantiate, Intersection2D, Node, PolygonCollider2D, RigidBody2D, tween, Tween, UIOpacity, UITransform, Vec2, Vec3, view, Color, Rect, Label, Sprite } from 'cc'; import { tetriMap } from '../tetriMap/tetriMap'; import { GEvent } from '../../mx/module/event/GEvent'; import { TetriType } from '../game/ConfigProjectData'; import { tetriNode } from './tetriNode'; import { tetriWeaponCarrier } from './tetriWeaponCarrier'; import { tetriAbility } from './tetriAbility'; import { tetriFloorNode } from './tetriFloorNode'; import { UIID } from '../game/ConfigRes'; import { StatusType } from '../game/GameData'; import MTools from '../../mx/tools/MTools'; import { BattleType } from '../manager/ChapterDataManager'; import { playTetriBlockSpine, TetriBlockSpineState } from './tetriBlockSpine'; const { ccclass, property } = _decorator; /***方块摆放的item节点, 放在tetraGame上的ui_card的子节点上,会自动根据方块的类型创建对应的方块节点 * 可以进行触摸移动 * * 点击卡牌后从模板克隆纯视觉预览;松手时再从模板实例化全新战场方块(与预览彻底分离,无物理开关竞态)。 * 根据类型不同,所以方块有两个脚本 * tetriCardItem: 自由下落的方块节点 * tetraFloorNode: 无重力方块,墙体。接触到其他方块会立马停住做为底板使用 * 方块会绑定武器和技能根据武器不同,调度进行发射子弹 * TetriType == 3||TetriType == 4 添加脚本tetriAbility脚本 自带脚本tetriNode * TetriType == 1 || TetriType == 5 添加脚本tetriWeaponCarrier脚本 自带脚本tetriNode * TetriType == 2 自带脚本tetriFloorNode */ @ccclass('tetriCardItem') export class tetriCardItem extends Component { /**本卡牌对应的方块配置(BattleCube 表) */ public config: ITableBattleCube | null = null; public setConfig(cfg: ITableBattleCube | null) { this.config = cfg; this.refreshCoinNum() } wenhaoBtnNode: Node | null = null; tetriNameLabel: Label | null = null; /**放置区域下边界节点(默认从 CurentBattle.TetraMap 获取,也可外部注入) */ highLine: Node | null = null; chooseMask: Node | null = null; liangkuangNode: Node | null = null; /**拖到该节点上松手则取消(chooseMask 的子节点:cancleNode) */ cancleNode: Node | null = null; /**真实下落/稳定的方块父节点(地图 GameRoot) */ physicsRoot: Node | null = null; /**绑定地图脚本(用于读取镜头缩放,做 x 坐标换算) */ private _boundMap: tetriMap | null = null; /**外部回调:松手且满足放置条件时触发;返回 true 表示战场已接管 battleNode */ onPlaceRequested: ((self: tetriCardItem, battleNode: Node, worldPos: Vec3) => boolean) | null = null; /**外部回调:卡牌被成功消耗(放置成功) */ onConsumed: (() => void) | null = null; // 原始状态(用于回弹复位) private _originPos: Vec3 = new Vec3(); private _originSiblingIdx: number = 0; // 方块子节点及其原始本地坐标(用于预览创建/销毁) private _pieceNode: Node | null = null; /***预览二号 */ private _pieceNodeCopyMove: Node | null = null; private _pieceOriginLocalPos: Vec3 = new Vec3(); private _holdScheduled: boolean = false; private _lastTouchEvent: EventTouch | null = null; private _startPos: Vec2 = new Vec2(); private _startTime: number = 0; private static readonly HOLD_SECONDS = 0; private _isDragging: boolean = false; private _snapTween: Tween | null = null; private _previewNode: Node | null = null; /** 预览二号:仅视觉对齐,跟手点上方偏移;不参与物理/掉落/角色武器 */ private _previewAlignNode: Node | null = null; /** 拿起方块时记录的 scale,拖拽期内乘镜头缩放;交还卡牌/下落前恢复 */ private _previewDragBaseScale: Vec3 | null = null; private _guideLine: Node | null = null; private _guideLineParent: Node | null = null; private _isOverCancel: boolean = false; private _cancelNodeBaseScale: Vec3 | null = null; /**钳制安全边距(像素),避免边缘出现少量溢出;可在编辑器里调 */ @property({ tooltip: '拖拽钳制的安全边距(px),越大越不容易“露出一点点”' }) public clampPaddingPx: number = 8; /** * 预览二号(对齐用):相对手指触点沿 UI 坐标 Y 轴向上偏移。 * 需求约 30cm 时请在编辑器按设计分辨率/DPI 换算为像素后填写。 */ @property({ tooltip: '预览二号:手指触点正上方偏移(UI 像素,与 getUILocation 同坐标系)' }) public alignPreviewOffsetAboveFingerPx: number = 400; public enableAlignPreview: boolean = false; // 位置钳制缓存(mask 本地坐标系):AABB 相对 previewRoot.position 的偏移量 private _clampRelAABB: { minX: number; minY: number; maxX: number; maxY: number } | null = null; // 引导区域边界缓存(基于 tetriMap 里的 GameRoot 子节点 UITransform 计算) private _chooseMaskTopY: number = 0; /**银币是否够放置方块 */ _affordable: boolean = true; /** world -> local */ private _worldToLocal(parent: Node, world: Vec3): Vec3 { const ut = parent.getComponent(UITransform); if (!ut) return world.clone(); const local = new Vec3(); ut.convertToNodeSpaceAR(world, local); return local; } private _setLayerRecursively(node: Node, layer: number) { node.layer = layer; for (let i = 0; i < node.children.length; i++) { this._setLayerRecursively(node.children[i], layer); } } /** * 拖拽预览专用:剥玩法脚本并移除刚体,仅保留渲染与 Polygon(重叠检测)。 * 预览节点用完即毁,永不进入战场,从设计上避免「关物理 → 再开物理」竞态。 */ private _stripPreviewForDrag(root: Node | null): void { if (!root?.isValid) return; this._stripAlignPreviewGameplay(root); for (const rb of root.getComponentsInChildren(RigidBody2D)) { rb.destroy(); } } /** 从卡牌模板实例化战场方块(与拖拽预览彻底分离) */ public createBattlePiece(): Node | null { if (!this._pieceNode?.isValid) return null; const node = instantiate(this._pieceNode); if (node?.isValid) { for (const wc of node.getComponentsInChildren(tetriWeaponCarrier)) { wc.resetWeaponForBattlePlace(); } } return node; } /** 递归销毁名为「角色」的子树(预览二号禁止挂角色/武器) */ private _destroyRoleNamedSubtrees(node: Node | null): void { if (!node?.isValid) return; const kids = node.children.slice(); for (const ch of kids) { if (ch.name === '角色') { ch.destroy(); } else { this._destroyRoleNamedSubtrees(ch); } } } /** * 预览:剥掉下落/碰撞玩法脚本,保留 tetriWeaponCarrier 仅作展示(禁止射击)。 */ private _stripAlignPreviewGameplay(root: Node | null): void { if (!root?.isValid) return; for (const c of root.getComponentsInChildren(tetriAbility)) { c.destroy(); } for (const c of root.getComponentsInChildren(tetriWeaponCarrier)) { c.setIsShootCan(false); } this._destroyRoleNamedSubtrees(root); for (const c of root.getComponentsInChildren(tetriNode)) { c.destroy(); } for (const c of root.getComponentsInChildren(tetriFloorNode)) { c.destroy(); } } private _destroyAlignPreview(): void { if (this._previewAlignNode?.isValid) { this._previewAlignNode.destroy(); } this._previewAlignNode = null; } private _setCardVisible(visible: boolean): void { const op = this.node.getComponent(UIOpacity) ?? this.node.addComponent(UIOpacity); op.opacity = visible ? 255 : 0; } /**外部可拿当前预览节点(用于放置成功时接管) */ public get previewNode(): Node | null { return this._previewNode; } public setChooseMask(mask: Node | null) { this.chooseMask = mask; } public setCancleNode(node: Node | null) { this.cancleNode = node; this._cancelNodeBaseScale = node ? node.scale.clone() : null; } /**允许外部注入 map(推荐在发牌后立刻调用一次) */ public bindMap(map: tetriMap | null) { if (!map) return; this._boundMap = map; this.physicsRoot = map._gameRoot ?? null; this.highLine = map._highLine ?? null; } /** * 拖拽主预览在 UI 坐标下跟手;白线副本/最终下落在游戏相机下渲染。 * 镜头拉远后,两者 x 需要按 zoom 做换算,否则会出现“偏离引导线”。 */ private _toGameWorldXFromUiWorldX(uiWorldX: number): number { const map = this._boundMap; if (map?.uiWorldXToGameWorldX) { return map.uiWorldXToGameWorldX(uiWorldX); } const zoom = map?.getVisualZoomScale?.() ?? 1; if (!Number.isFinite(zoom) || zoom <= 0 || Math.abs(zoom - 1) < 0.0001) return uiWorldX; const camX = (globalThis as any)?.gg?.ui?.GameCamera?.node?.worldPosition.x ?? 0; return camX + (uiWorldX - camX) / zoom; } private _toGameWorldYFromUiWorldY(uiWorldY: number): number { const map = this._boundMap; if (map?.uiWorldYToGameWorldY) { return map.uiWorldYToGameWorldY(uiWorldY); } const zoom = map?.getVisualZoomScale?.() ?? 1; if (!Number.isFinite(zoom) || zoom <= 0 || Math.abs(zoom - 1) < 0.0001) return uiWorldY; const camY = (globalThis as any)?.gg?.ui?.GameCamera?.node?.worldPosition.y ?? 0; return camY + (uiWorldY - camY) / zoom; } /**外部显式指定“卡牌里哪一个节点是方块”(强烈推荐在发牌时调用) */ public setPieceNode(node: Node | null) { this._pieceNode = node; if (this._pieceNode) { this._pieceOriginLocalPos = this._pieceNode.position.clone(); } } protected onLoad(): void { this._originPos = this.node.position.clone(); this._originSiblingIdx = this.node.getSiblingIndex(); this.liangkuangNode = this.node.getChildByName('亮框'); this.wenhaoBtnNode = this.node.getChildByName('btnwenhao'); this.tetriNameLabel = this.node.getChildByName('tetriName').getComponent(Label); // 兜底:如果外部没调用 setPieceNode,则尝试自动抓取 if (!this._pieceNode) { this._pieceNode = this.node.getChildByName('Tetris') ?? this.node.children[0] ?? null; if (this._pieceNode) { this._pieceOriginLocalPos = this._pieceNode.position.clone(); } } // 如果没有外部注入,尝试从当前战斗里取 tetriMap 绑定 try { const ggAny = (globalThis as any).gg; const map = ggAny?.game?.CurentBattle?.TetraMap as tetriMap | null; if (map) this.bindMap(map); } catch { } this.node.on(Node.EventType.TOUCH_START, this._onTouchStart, this); this.node.on(Node.EventType.TOUCH_MOVE, this._onTouchMove, this); this.node.on(Node.EventType.TOUCH_END, this._onTouchEnd, this); this.node.on(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this); GEvent.Ins.on(GEvent.showCoinFly, this.refreshCoinNum2, this); GEvent.Ins.on(GEvent.UpdateGameCoinNum, this.refreshCoinNum, this); GEvent.Ins.on(GEvent.UpdateCoin, this.refreshCoinNum, this); this.refreshCoinNum() } protected onDestroy(): void { this.node.off(Node.EventType.TOUCH_START, this._onTouchStart, this); this.node.off(Node.EventType.TOUCH_MOVE, this._onTouchMove, this); this.node.off(Node.EventType.TOUCH_END, this._onTouchEnd, this); this.node.off(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this); GEvent.Ins.off(GEvent.showCoinFly, this.refreshCoinNum2, this); GEvent.Ins.off(GEvent.UpdateGameCoinNum, this.refreshCoinNum, this); GEvent.Ins.off(GEvent.UpdateCoin, this.refreshCoinNum, this); this._destroyAlignPreview(); } private _onTouchStart(event: EventTouch): void { // 地图异步晚到时补绑,避免 highLine/layer 为空导致无 copy、无法掉落 if (!this._boundMap || !this.highLine?.isValid) { try { const map = (globalThis as any).gg?.game?.CurentBattle?.TetraMap as tetriMap | null; if (map) this.bindMap(map); } catch { } } if (!this._affordable) { // if (!MTools.beforeTimes(1000, "goldNotEnoughTimeStamp")) { gg.ui.showToast(gg.lang.get('银币不足')) } return } //触摸穿透 为了maplayer的可以监听到点击事件给下落的方块左右移动 //如果是使用锤子阶段就触摸穿透 if (gg.game.CurentBattle.IsUseShovel) { event.preventSwallow = true; event.propagationStopped = true; } this._snapTween?.stop(); this._snapTween = null; this._isDragging = true; this._startPos = event.getUILocation(); this._startTime = Date.now(); this._lastTouchEvent = event; this.scheduleOnce(() => { if (this._previewNode) { this._syncPreviewToTouch(event); } }, gg.game.CurentBattle.cancelTimeMs / 1000); // 置顶,避免被其他 UI 遮挡 if (this.node.parent) { this.node.setSiblingIndex(this.node.parent.children.length - 1); } if(gg.data.getStatus(StatusType.IsShowPlayGuidePop) == 0){ //打点 gg.sdk.reportDY("inLevel", `章节${gg.game.CurentBattle.getReportDYChapterId()}_${gg.game.CurentBattle.CurentWaveNum}-首次拖动方块1`); } // 按住 0.5s 后才真正“拿起方块”:卡牌透明 + 方块移到 GameRoot if (!this._holdScheduled) { this._holdScheduled = true; this.scheduleOnce(() => { this._holdScheduled = false; if (!this._isDragging) return; if (this._previewNode) return; this._createPreview(); if (!this._previewNode) return; this._setCardVisible(false); if (this._lastTouchEvent) this._syncPreviewToTouch(this._lastTouchEvent); if(gg.data.doc.chapter == 1){ GEvent.Ins.emit(GEvent.TetriGameTouchStart); } if(gg.data.getStatus(StatusType.IsShowPlayGuidePop) == 0){ //打点 gg.sdk.reportDY("inLevel", `章节${gg.game.CurentBattle.getReportDYChapterId()}_${gg.game.CurentBattle.CurentWaveNum}-首次拖动方块2`); } }, tetriCardItem.HOLD_SECONDS); } } //移动添加区域保护不能超过chooseMask包围的区域 private _onTouchMove(event: EventTouch): void { if (!this._isDragging) return; this._lastTouchEvent = event; if (this._previewNode) { this._syncPreviewToTouch(event); } } private _onTouchEnd(event: EventTouch): void { if (gg.game.CurentBattle.IsUseShovel) { event.preventSwallow = true; event.propagationStopped = true; } if (!this._isDragging) return; this._isDragging = false; this._lastTouchEvent = null; // 如果还没到“按住 0.5s 拿起”的时机,直接结束(不消耗、不改变卡牌) if (!this._previewNode) { this._setCardVisible(true); this.snapBack(); return; } // 拖到取消区松手:直接取消 if (this._isOverCancel) { this.cancelPlace(); return; } // 新需求:任何地方松手都放置;默认 x 取当前位置,y 固定为白线处开始掉落;isShowBlockDrop 时在手指释放处落点并做 Polygon 重叠校验 const pos = this._getDropStartWorldPos(); if (!pos) { this.cancelPlace(); return; } if (gg.game.isShowBlockDrop && this._previewPolygonsOverlapGameplayBlocksAt(pos)) { this.cancelPlace(); return; } if (this.onPlaceRequested) { const battleNode = this.createBattlePiece(); if (!battleNode) { this.cancelPlace(); return; } const placed = this.onPlaceRequested(this, battleNode, pos) === true; if (!placed) { if (battleNode.isValid) battleNode.destroy(); return; } } else { this.consume(); } } /**放置成功:销毁预览、扣银币、隐藏卡牌 */ public consume(): void { this._snapTween?.stop(); this._tearDownDragPreview(); if (gg.game.CurentBattle.BattleType !== BattleType.OrangeWeaponMode_Dian) { gg.game.CurentBattle.subCoinNum(this.config?.price ?? 0); } this.node.active = false; this.onConsumed?.(); if (gg.data.doc.chapter == 1) { GEvent.Ins.emit(GEvent.TetriGameTouchEnd); } } /**放置失败:销毁预览,卡牌回弹 */ public cancelPlace(): void { this._destroyPreview(); this._setCardVisible(true); this.snapBack(); if(gg.data.doc.chapter == 1){ GEvent.Ins.emit(GEvent.TetriGameTouchEnd); } } /**弹回原始位置 */ public snapBack(): void { if (this.node.parent) { this.node.setSiblingIndex(this._originSiblingIdx); } this._setCardVisible(true); this._snapTween = tween(this.node) .to(0.3, { position: this._originPos }, { easing: 'backOut' }) .start(); if(gg.data.doc.chapter == 1){ GEvent.Ins.emit(GEvent.TetriGameTouchEnd); } } private _isAboveLine(_event: EventTouch): boolean { if (!this.highLine || !this._previewNode) return false; const ut = this._previewNode.getComponent(UITransform); const halfH = ut ? ut.getBoundingBoxToWorld().height / 2 : 0; const bottomY = this._previewNode.worldPosition.y - halfH; return bottomY > this.highLine.worldPosition.y; } /** 预览二号:底边高于高度线(白线)时隐藏,移回线下方再显示 */ private _syncAlignPreviewVisibilityByHighLine(): void { if (!this.enableAlignPreview) return; if (!this._previewAlignNode?.isValid) return; if (!this.highLine?.isValid) { this._previewAlignNode.active = true; return; } const ut = this._previewAlignNode.getComponent(UITransform); const halfH = ut ? ut.getBoundingBoxToWorld().height / 2 : 0; const bottomY = this._previewAlignNode.worldPosition.y - halfH; const overHighLine = bottomY > this.highLine.worldPosition.y; this._previewAlignNode.active = !overHighLine; } private _isOverCancleNode(event: EventTouch): boolean { if (!this.cancleNode) return false; const ut = this.cancleNode.getComponent(UITransform); if (!ut) return false; const rect = ut.getBoundingBoxToWorld(); const p = event.getUILocation(); // 仅在 cancleNode 的矩形区域内才视为“取消” return p.x >= rect.xMin && p.x <= rect.xMax && p.y >= rect.yMin && p.y <= rect.yMax; } private _getDropStartWorldPos(): Vec3 | null { if (!this._previewNode) return null; const footprintNode = this._pieceNodeCopyMove?.isValid ? this._pieceNodeCopyMove : this._previewNode; const centerGameX = this._pieceNodeCopyMove?.isValid ? this._pieceNodeCopyMove.worldPosition.x : this._toGameWorldXFromUiWorldX(this._previewNode.worldPosition.x); let x = centerGameX; if (footprintNode?.isValid && this._boundMap?.snapPlaceCenterWorldX) { x = this._boundMap.snapPlaceCenterWorldX(centerGameX, footprintNode); } const z = this._previewNode.worldPosition.z; if (gg.game.isShowBlockDrop) { const y = this._toGameWorldYFromUiWorldY(this._previewNode.worldPosition.y); return new Vec3(x, y, z); } if (!this.highLine) return null; const ut = this._previewNode.getComponent(UITransform); const halfH = ut ? ut.getBoundingBoxToWorld().height / 2 : 0; // 下落起点固定为白线处(底边贴线)。即使拖拽到很高也只影响 X,不影响下落起点 Y。 const minCenterY = this.highLine.worldPosition.y + halfH; return new Vec3(x, minCenterY, z); } /**白线预览块固定 Y:白线 worldY + 方块半高(让底边贴线) */ private _getWhiteLineFixedWorldYFor(node: Node): number | null { if (!this.highLine) return null; const rect = this._getWorldRect(node); const halfH = rect ? rect.height / 2 : 0; return this.highLine.worldPosition.y + halfH; } /** PolygonCollider2D 顶点变换到世界 XY(与 enabled 无关,用于精确重叠判定) */ private _getPolygonColliderWorldPoints2D(poly: PolygonCollider2D): Vec2[] { const owner = poly.node; const m = owner.getWorldMatrix(); const offset = (poly as any).offset as Vec2 | undefined; const offX = offset?.x ?? 0; const offY = offset?.y ?? 0; const pts = poly.points ?? []; const wv = new Vec3(); const lv = new Vec3(); const out: Vec2[] = []; for (const p of pts) { lv.set(p.x + offX, p.y + offY, 0); Vec3.transformMat4(wv, lv, m); out.push(new Vec2(wv.x, wv.y)); } return out; } private _polygonHasEnoughVerts(pts: readonly Vec2[]): boolean { return (pts?.length ?? 0) >= 3; } /** 节点是否属于场上玩法方块(tetriNode / tetriFloorNode 子树) */ private _isGameplayBlockColliderNode(n: Node | null): boolean { let cur: Node | null = n; while (cur) { if (cur.getComponent(tetriNode) || cur.getComponent(tetriFloorNode)) return true; cur = cur.parent; } return false; } /** * 在指定世界落点下,预览块 Polygon 是否与场上已存在方块的启用 PolygonCollider2D 相交。 * 预览仍在 chooseMask 下,通过平移当前世界多边形到落点做判定。 */ private _previewPolygonsOverlapGameplayBlocksAt(dropWorldPos: Vec3): boolean { if (!this._previewNode?.isValid) return true; const gameRoot = this._boundMap?._gameRoot; if (!gameRoot?.isValid) return false; const root = this._previewNode; const dx = dropWorldPos.x - root.worldPosition.x; const dy = dropWorldPos.y - root.worldPosition.y; const previewPolys = root.getComponentsInChildren(PolygonCollider2D) ?? []; const shiftedPreview: Vec2[][] = []; for (const pp of previewPolys) { const w = this._getPolygonColliderWorldPoints2D(pp); if (!this._polygonHasEnoughVerts(w)) continue; const shifted: Vec2[] = []; for (const p of w) { shifted.push(new Vec2(p.x + dx, p.y + dy)); } shiftedPreview.push(shifted); } if (shiftedPreview.length === 0) return false; const others = gameRoot.getComponentsInChildren(PolygonCollider2D) ?? []; for (const op of others) { if (!op?.node?.isValid || !op.enabled) continue; if (!this._isGameplayBlockColliderNode(op.node)) continue; const ow = this._getPolygonColliderWorldPoints2D(op); if (!this._polygonHasEnoughVerts(ow)) continue; for (const sp of shiftedPreview) { if (Intersection2D.polygonPolygon(sp, ow)) { return true; } } } return false; } /**创建预览:从模板克隆纯视觉节点;模板 _pieceNode 始终留在卡牌上 */ private _createPreview(): void { if (!this._pieceNode || !this.chooseMask) return; this.chooseMask.active = true; this.cancleNode.active = true; const preview = instantiate(this._pieceNode); this.chooseMask.addChild(preview); this._setLayerRecursively(preview, this.chooseMask.layer); preview.active = true; preview.angle = this._pieceNode.angle; this._stripPreviewForDrag(preview); this._previewDragBaseScale = this._pieceNode.scale.clone(); this._previewNode = preview; if (this._pieceNodeCopyMove && this._pieceNodeCopyMove.isValid) { this._pieceNodeCopyMove.destroy(); this._pieceNodeCopyMove = null; } if (!gg.game.isShowBlockDrop) { this._pieceNodeCopyMove = instantiate(this._pieceNode); this._stripPreviewForDrag(this._pieceNodeCopyMove); this._pieceNodeCopyMove.angle = 0; this.chooseMask.addChild(this._pieceNodeCopyMove); const mapLayer = gg.game.CurentBattle.TetraMap?.node?.layer ?? this.chooseMask.layer; this._setLayerRecursively(this._pieceNodeCopyMove, mapLayer); this._pieceNodeCopyMove.active = true; const fixedY = this._getWhiteLineFixedWorldYFor(this._pieceNodeCopyMove); const startX = preview.worldPosition.x; const startZ = this._pieceNodeCopyMove.worldPosition.z; if (fixedY != null) { this._pieceNodeCopyMove.setWorldPosition(new Vec3(startX, fixedY, startZ)); } } else { this._pieceNodeCopyMove = null; } this._createAlignPreview(); this._playPreviewBlockSpine(TetriBlockSpineState.Falling); if (preview?.isValid && preview.parent === this.chooseMask) { preview.setSiblingIndex(this.chooseMask.children.length - 1); } this._createGuideLine(); } /** 预览二号:与主预览同形,位置为手指触点正上方(可配置像素偏移),仅对齐用 */ private _createAlignPreview(): void { this._destroyAlignPreview(); if (!this.enableAlignPreview) return; if (!this._pieceNode?.isValid || !this.chooseMask) return; const n = instantiate(this._pieceNode); this._stripAlignPreviewGameplay(n); this.chooseMask.addChild(n); this._setLayerRecursively(n, this.chooseMask.layer); n.active = true; n.angle = this._pieceNode.angle; this._stripPreviewForDrag(n); this._previewAlignNode = n; } /** 堆叠升高镜头拉远时与 tetriMap 一致,主预览与预览二号同比例缩小,贴近场上/白线副本观感 */ private _syncPreviewDragScaleForMapZoom(): void { if (!this._previewDragBaseScale || !this._previewNode?.isValid) return; const z = this._boundMap?.getVisualZoomScale?.() ?? 1; const b = this._previewDragBaseScale; const sx = b.x * z; const sy = b.y * z; const sz = b.z * z; this._previewNode.setScale(sx, sy, sz); if (this._previewAlignNode?.isValid) { this._previewAlignNode.setScale(sx, sy, sz); } } /** 结束拖拽:销毁预览克隆,模板方块始终留在卡牌上 */ private _tearDownDragPreview(): void { this._destroyGuideLine(); this._destroyAlignPreview(); if (this._pieceNodeCopyMove?.isValid) { this._pieceNodeCopyMove.destroy(); } this._pieceNodeCopyMove = null; if (this._previewNode?.isValid) { this._previewNode.destroy(); } this._previewNode = null; this._previewDragBaseScale = null; if (this._pieceNode?.isValid) { playTetriBlockSpine(this._pieceNode, TetriBlockSpineState.Shop); } if (this.chooseMask) this.chooseMask.active = false; if (this.cancleNode) this.cancleNode.active = false; this._restoreCancelNodeScale(); this._clampRelAABB = null; } private _destroyPreview(): void { this._tearDownDragPreview(); } /**拖入取消区时做一次轻微放大反馈 */ private _playCancelHoverFeedback(): void { if (!this.cancleNode?.isValid) return; if (!this._cancelNodeBaseScale) { this._cancelNodeBaseScale = this.cancleNode.scale.clone(); } const base = this._cancelNodeBaseScale; const bump = new Vec3(base.x * 1.08, base.y * 1.08, base.z); Tween.stopAllByTarget(this.cancleNode); this.cancleNode.setScale(base); tween(this.cancleNode) .to(0.08, { scale: bump }, { easing: 'quadOut' }) .to(0.12, { scale: base }, { easing: 'quadIn' }) .start(); } private _restoreCancelNodeScale(): void { if (!this.cancleNode?.isValid) return; if (!this._cancelNodeBaseScale) return; Tween.stopAllByTarget(this.cancleNode); this.cancleNode.setScale(this._cancelNodeBaseScale); } /**获取节点(含子节点)在世界坐标的合并包围盒(基于 UITransform) */ private _getWorldRectByUI(node: Node): Rect | null { const uts = node.getComponentsInChildren(UITransform); let out: Rect | null = null; for (const ut of uts) { const r = ut.getBoundingBoxToWorld(); if (!out) { out = new Rect(r.x, r.y, r.width, r.height); } else { const xMin = Math.min(out.xMin, r.xMin); const yMin = Math.min(out.yMin, r.yMin); const xMax = Math.max(out.xMax, r.xMax); const yMax = Math.max(out.yMax, r.yMax); out.x = xMin; out.y = yMin; out.width = xMax - xMin; out.height = yMax - yMin; } } return out; } /**获取节点(含子节点)在世界坐标的合并包围盒(优先 UITransform,否则用 2D 物理 Collider) */ private _getWorldRect(node: Node): Rect | null { const uiRect = this._getWorldRectByUI(node); if (uiRect) return uiRect; // 预览阶段我们会禁用 PolygonCollider2D,导致 worldAABB 可能不可用。 // 这里用 collider 的点位手算 AABB(即使 collider disabled 也能取到 points)。 const polyRect = this._getWorldRectByPolygonCollider(node); if (polyRect) return polyRect; const cols = node.getComponentsInChildren(Collider2D); let out: Rect | null = null; for (const col of cols) { const r: any = (col as any).worldAABB; if (!r) continue; // worldAABB 是 math.Rect(字段是 x/y/width/height + xMin/xMax/yMin/yMax getter) if (!out) { out = new Rect(r.x, r.y, r.width, r.height); } else { const xMin = Math.min(out.xMin, r.xMin); const yMin = Math.min(out.yMin, r.yMin); const xMax = Math.max(out.xMax, r.xMax); const yMax = Math.max(out.yMax, r.yMax); out.x = xMin; out.y = yMin; out.width = xMax - xMin; out.height = yMax - yMin; } } return out; } /**基于 PolygonCollider2D.points 手算世界 AABB(忽略 enabled 状态) */ private _getWorldRectByPolygonCollider(node: Node): Rect | null { const polys = node.getComponentsInChildren(PolygonCollider2D); if (!polys?.length) return null; let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; const wv = new Vec3(); const lv = new Vec3(); for (const poly of polys) { const owner = poly.node; const m = owner.getWorldMatrix(); const offset = (poly as any).offset as Vec2 | undefined; const offX = offset?.x ?? 0; const offY = offset?.y ?? 0; const pts = poly.points ?? []; for (const p of pts) { lv.set(p.x + offX, p.y + offY, 0); Vec3.transformMat4(wv, lv, m); if (wv.x < minX) minX = wv.x; if (wv.y < minY) minY = wv.y; if (wv.x > maxX) maxX = wv.x; if (wv.y > maxY) maxY = wv.y; } } if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) { return null; } return new Rect(minX, minY, maxX - minX, maxY - minY); } /**按 PolygonCollider2D 点位计算“横向占用格子列数”(不使用包围盒 API) */ private _getGridColumnCountByPolygon(node: Node, cellWorldWidth: number): number { const polys = node.getComponentsInChildren(PolygonCollider2D); if (!polys?.length) return 1; const cell = Math.max(1, cellWorldWidth); let minX = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; const wv = new Vec3(); const lv = new Vec3(); for (const poly of polys) { const owner = poly.node; const m = owner.getWorldMatrix(); const offset = (poly as any).offset as Vec2 | undefined; const offX = offset?.x ?? 0; const offY = offset?.y ?? 0; const pts = poly.points ?? []; for (const p of pts) { lv.set(p.x + offX, p.y + offY, 0); Vec3.transformMat4(wv, lv, m); if (wv.x < minX) minX = wv.x; if (wv.x > maxX) maxX = wv.x; } } if (!Number.isFinite(minX) || !Number.isFinite(maxX)) return 1; const cols = Math.round((maxX - minX) / cell); return Math.max(1, cols); } /**把“预览整体大小”合并成 mask 本地 AABB(UITransform + PolygonCollider2D) */ private _getPreviewLocalAABBInMask(maskUT: UITransform, previewRoot: Node): { minX: number; minY: number; maxX: number; maxY: number } | null { let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; const tmp = new Vec3(); const toLocal = (w: Vec3) => { maskUT.convertToNodeSpaceAR(w, tmp); if (tmp.x < minX) minX = tmp.x; if (tmp.y < minY) minY = tmp.y; if (tmp.x > maxX) maxX = tmp.x; if (tmp.y > maxY) maxY = tmp.y; }; // 1) UITransform 的 worldRect 四角投影到 mask 本地 const uts = previewRoot.getComponentsInChildren(UITransform); for (const ut of uts) { const r = ut.getBoundingBoxToWorld(); toLocal(new Vec3(r.xMin, r.yMin, 0)); toLocal(new Vec3(r.xMin, r.yMax, 0)); toLocal(new Vec3(r.xMax, r.yMin, 0)); toLocal(new Vec3(r.xMax, r.yMax, 0)); } // 2) PolygonCollider2D 的点(即使 disabled 也能取 points)投影到 mask 本地 const polys = previewRoot.getComponentsInChildren(PolygonCollider2D); const wv = new Vec3(); const lv = new Vec3(); for (const poly of polys) { const owner = poly.node; const m = owner.getWorldMatrix(); const offset = (poly as any).offset as Vec2 | undefined; const offX = offset?.x ?? 0; const offY = offset?.y ?? 0; const pts = poly.points ?? []; for (const p of pts) { lv.set(p.x + offX, p.y + offY, 0); Vec3.transformMat4(wv, lv, m); toLocal(wv); } } if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) return null; return { minX, minY, maxX, maxY }; } /**初始化位置钳制缓存:把当前预览整体 AABB 转成“相对 previewRoot.position 的偏移” */ private _ensureClampCache(maskUT: UITransform, previewRoot: Node): void { if (this._clampRelAABB) return; const aabb = this._getPreviewLocalAABBInMask(maskUT, previewRoot); if (!aabb) return; const p = previewRoot.position; // previewRoot 本身就在 chooseMask 下,本地坐标系一致 this._clampRelAABB = { minX: aabb.minX - p.x, minY: aabb.minY - p.y, maxX: aabb.maxX - p.x, maxY: aabb.maxY - p.y, }; } /**使用 position + 缓存 AABB 做钳制(不做每帧遍历/矩阵计算) */ private _clampPreviewByPosition(maskUT: UITransform, previewRoot: Node): void { this._ensureClampCache(maskUT, previewRoot); if (!this._clampRelAABB) return; const maskW = maskUT.contentSize.width; const maskH = maskUT.contentSize.height; const ax = maskUT.anchorPoint.x; const ay = maskUT.anchorPoint.y; const maskLocalMinX = -maskW * ax; const maskLocalMaxX = maskW * (1 - ax); const maskLocalMinY = -maskH * ay; const maskLocalMaxY = maskH * (1 - ay); const pad = this.clampPaddingPx ?? 0; const rel = this._clampRelAABB; const p = previewRoot.position; // 约束:p.x + rel.minX - pad >= maskMinX 且 p.x + rel.maxX + pad <= maskMaxX const minPX = maskLocalMinX - rel.minX + pad; const maxPX = maskLocalMaxX - rel.maxX - pad; const minPY = maskLocalMinY - rel.minY + pad; const maxPY = maskLocalMaxY - rel.maxY - pad; const clampedX = Math.min(Math.max(p.x, minPX), maxPX); const clampedY = Math.min(Math.max(p.y, minPY), maxPY); if (clampedX !== p.x || clampedY !== p.y) { previewRoot.setPosition(clampedX, clampedY, p.z); } } /** 白线预览块:每帧按当前高度线重算 Y(堆叠升高时白线会平滑上移) */ private _syncWhiteLinePreviewPosition(): void { if (gg.game.isShowBlockDrop) return; if (!this._pieceNodeCopyMove?.isValid || !this._previewNode?.isValid) return; const fixedY = this._getWhiteLineFixedWorldYFor(this._pieceNodeCopyMove); if (fixedY == null) return; const x = this._toGameWorldXFromUiWorldX(this._previewNode.worldPosition.x); const z = this._pieceNodeCopyMove.worldPosition.z; this._pieceNodeCopyMove.setWorldPosition(new Vec3(x, fixedY, z)); } protected lateUpdate(): void { if (!this._previewNode?.isValid) return; this._syncPreviewDragScaleForMapZoom(); this._syncWhiteLinePreviewPosition(); if (this._guideLine?.active) { this._updateGuideLine(); } } private _syncPreviewToTouch(event: EventTouch): void { if (!this._previewNode || !this.chooseMask) return; const uiPos = event.getUILocation(); const worldPos = new Vec3(uiPos.x, uiPos.y, 0); // 先按触摸点移动,再做 chooseMask 区域边界钳制(保证预览整体不超过 mask) const maskUT = this.chooseMask.getComponent(UITransform); if (maskUT) { const localPos = new Vec3(); maskUT.convertToNodeSpaceAR(worldPos, localPos); this._previewNode.setPosition(localPos); if (this._previewAlignNode?.isValid) { const alignWorld = new Vec3( uiPos.x, uiPos.y + (this.alignPreviewOffsetAboveFingerPx ?? 0), 0, ); const alignLocal = new Vec3(); maskUT.convertToNodeSpaceAR(alignWorld, alignLocal); this._previewAlignNode.setPosition(alignLocal); } } else { this._previewNode.setWorldPosition(worldPos); if (this._previewAlignNode?.isValid) { this._previewAlignNode.setWorldPosition( new Vec3( uiPos.x, uiPos.y + (this.alignPreviewOffsetAboveFingerPx ?? 0), this._previewAlignNode.worldPosition.z, ), ); } } // 边界保护:在 chooseMask 的本地坐标系中钳制(避免 UI 坐标/世界坐标不一致导致钳制失效) // if (maskUT) { // this._clampPreviewByPosition(maskUT, this._previewNode); // } this._syncPreviewDragScaleForMapZoom(); this._syncWhiteLinePreviewPosition(); let time = Date.now() - this._startTime; const wasOverCancel = this._isOverCancel; if (time < gg.game.CurentBattle.cancelTimeMs) { this._isOverCancel = true; } else { this._isOverCancel = this._isOverCancleNode(event); } if (!wasOverCancel && this._isOverCancel) { this._playCancelHoverFeedback(); } if (this._guideLine) { // 拖拽时始终显示引导区域(避免“没超过高线就完全不显示”看起来像没画出来) this._guideLine.active = true; this._updateGuideLine(); } this._syncAlignPreviewVisibilityByHighLine(); } private _createGuideLine(): void { if (!this.chooseMask) return; // guideLine 放在 chooseMask 的父节点(通常是 Canvas/UI 根),避免被 chooseMask 裁剪 this._guideLineParent = this.chooseMask.parent ?? this.chooseMask; // 垂直覆盖使用超大范围,保证镜头缩放后仍能完整贯穿屏幕 this._chooseMaskTopY = 10000; const node = new Node('GuideLine'); // 初始 layer 先跟 chooseMask,绘制时会再同步到目标预览层 node.layer = this.chooseMask.layer; node.addComponent(UITransform); node.addComponent(Graphics); this._guideLineParent.addChild(node); node.setSiblingIndex(this._guideLineParent.children.length - 1); this._guideLine = node; } private _updateGuideLine(): void { if (!this._guideLine || !this._previewNode) return; const g = this._guideLine.getComponent(Graphics)!; g.clear(); const parent = this._guideLineParent ?? this.chooseMask ?? this._previewNode.parent; if (!parent) return; // 引导线完全使用“白线副本”的世界包围盒(与真实落点同源)。 const targetNode = (this._pieceNodeCopyMove && this._pieceNodeCopyMove.isValid) ? this._pieceNodeCopyMove : this._previewNode; // 与目标节点保持同一 layer/camera,避免镜头缩放后屏幕投影不一致 if (this._guideLine.layer !== targetNode.layer) { this._guideLine.layer = targetNode.layer; } const centerWorld = targetNode.worldPosition; // 宽度按“方块实际占用格子列数”计算(列数 * 固定格宽),不使用包围盒。 const cellWorldWidth = this._boundMap?.blockHeight ?? 36; const rectCenterX = centerWorld.x; const cols = this._getGridColumnCountByPolygon(targetNode, cellWorldWidth); const snappedWorldWidth = cols * cellWorldWidth; const halfW = snappedWorldWidth * 0.5; let left = -halfW; let right = halfW; const leftWorldX = rectCenterX - halfW; const rightWorldX = rectCenterX + halfW; left = this._worldToLocal(parent, new Vec3(leftWorldX, centerWorld.y, 0)).x; right = this._worldToLocal(parent, new Vec3(rightWorldX, centerWorld.y, 0)).x; // 画线区域左右各 +1 像素(按 parent 的本地坐标系单位,即 UI 像素) // left -= 1; // right += 1; const topY = this._chooseMaskTopY || (view.getDesignResolutionSize().height / 2); // 新需求:线要很长,直接贯穿整个可视区域 const bottomY = -topY; // 拖到取消区:引导区域变红 g.fillColor = this._isOverCancel ? new Color(255, 0, 0, 50) : new Color(0, 255, 0, 50); g.rect(left, bottomY, right - left, topY - bottomY); g.fill(); g.strokeColor = this._isOverCancel ? new Color(255, 0, 0, 200) : new Color(0, 255, 0, 200); g.lineWidth = 3; g.moveTo(left, topY); g.lineTo(left, bottomY); g.stroke(); g.moveTo(right, topY); g.lineTo(right, bottomY); g.stroke(); } private _destroyGuideLine(): void { if (this._guideLine) { const line = this._guideLine; this._guideLine = null; line.destroy(); } } refreshCoinNum2(){ this.scheduleOnce(()=>{ if (gg.game.CurentBattle.BattleType === BattleType.OrangeWeaponMode_Dian) { this._affordable = true return; } let cost = this.node.getChildByName('cost').getComponent(Label) if (gg.game.CurentBattle.CurentCoinNum < this.config.price) { this._affordable = false cost.color = new Color(255, 0, 0) } else { this._affordable = true cost.color = new Color(255, 255, 255) } },1.5) } /**刷新金币*/ refreshCoinNum() { if (!this.config) { return } const lightning = gg.game.CurentBattle.BattleType === BattleType.OrangeWeaponMode_Dian; const yinbi = this.node.getChildByName('jinbi'); const costNode = this.node.getChildByName('cost'); if (lightning) { if (yinbi) yinbi.active = false; //if (costNode) costNode.active = false; const cost = costNode?.getComponent(Label); if (costNode && cost) { costNode.x = -30 cost.string = '免费'; } this._affordable = true; } else { if (yinbi) yinbi.active = true; if (costNode) costNode.active = true; const cost = costNode?.getComponent(Label); if (cost) { cost.string = `${this.config.price}`; if (gg.game.CurentBattle.CurentCoinNum < this.config.price) { this._affordable = false; cost.color = new Color(255, 0, 0); } else { this._affordable = true; cost.color = new Color(255, 255, 255); } } } if(!this.wenhaoBtnNode){ return } if(gg.game.CurentBattle.ChapterId == 1 || gg.game.CurentBattle.ChapterId == 2){ if(this.config.type != TetriType.Tetris_Baseattk){ this.wenhaoBtnNode.active = true this.tetriNameLabel.node.active = true this.setNameType(this.config.type) }else{ this.wenhaoBtnNode.active = false this.tetriNameLabel.node.active = false } }else{ this.wenhaoBtnNode.active = false this.tetriNameLabel.node.active = false } } click_wenhaoBtn(){ // let type = this.config.type if(type == TetriType.Tetris_Basefloor){ gg.ui.openPanel(UIID.TetriDoc, { data: 3}); }else{ gg.ui.openPanel(UIID.TetriDoc, { data: 4 }); } } setNameType(type: TetriType){ // Tetris_Basefloor // Tetris_BaseGold // Tetris_Baseheal // Tetris_Basevine if(type == TetriType.Tetris_Basefloor){ this.tetriNameLabel.string = '悬浮' }else if(type == TetriType.Tetris_BaseGold){ this.tetriNameLabel.string = '产币' }else if(type == TetriType.Tetris_Baseheal){ this.tetriNameLabel.string = '回血' }else if(type == TetriType.Tetris_Basevine){ this.tetriNameLabel.string = '绑定' } } /** 同步主预览、白线副本、对齐预览的方块表情 Spine */ private _playPreviewBlockSpine(state: TetriBlockSpineState): void { playTetriBlockSpine(this._pieceNode, state); playTetriBlockSpine(this._previewNode, state); playTetriBlockSpine(this._pieceNodeCopyMove, state); playTetriBlockSpine(this._previewAlignNode, state); } }