import { _decorator, Camera, Collider2D, Component, Contact2DType, Director, ERigidBody2DType, EventTouch, find, IPhysics2DContact, Mat4, Node, PhysicsSystem2D, PolygonCollider2D, Rect, RigidBody2D, Tween, tween, UIOpacity, UITransform, v3, Vec2, Vec3, director, screen, sp, view } from 'cc'; import { tetriNode } from '../tetriCard/tetriNode'; import { tetriFloorNode } from '../tetriCard/tetriFloorNode'; import { tetriWeaponCarrier } from '../tetriCard/tetriWeaponCarrier'; import { role } from '../tetriCard/role'; import { TetriType } from '../game/ConfigProjectData'; import { GEvent } from '../../mx/module/event/GEvent'; import { StatusType } from '../game/GameData'; import { TableNames } from '../game/ConfigTableData'; import { TetriStackDiagnostics } from './TetriStackDiagnostics'; import { BattlePerformance } from '../ui/BattleGame/BattlePerformance'; import { Physics2DGate } from '../game/Physics2DGate'; const { ccclass, property } = _decorator; /**俄罗斯方块地图, _gameRoot用于管理方块的摆放和移动 *方块在摆放的时候父节点是_gameRoot 方块在落地稳定之后,父节点会变成_tetrisTable * */ @ccclass('tetriMap') export class tetriMap extends Component { /**出怪口 */ public spawnPointParent: Node = null; /**最新一次放下去、正在自由下落的方块节点(由 createBattlePiece 新实例化后接管) */ public latestFallingNode: Node | null = null; /**记录 latestFallingNode 设置时间,用于避免“放下瞬间触发点击推力” */ private _latestFallingSetMs: number = 0; /**每次触摸带来的水平速度增量(手感参数) */ pushDeltaVelX: number = 1.2; /**最大水平速度(防止越推越快) */ @property({ displayName: '最大水平速度' }) maxVelX: number = 220; /**放下后多少秒内不响应推动(避免松手那一下把方块推飞) */ @property({ displayName: '放下推动冷却(秒)' }) pushCooldownSeconds: number = 0.2; /**安全硬上限:无论 Inspector 设多大,都不会超过这个速度 */ @property({ displayName: '安全最大水平速度' }) safeMaxVelX: number = 260; /**调试打印触摸推动信息 */ @property({ displayName: '调试触摸推动' }) debugTouchPush: boolean = false; /**下落重力系数(越小下落越慢) */ @property({ displayName: '下落重力系数' }) fallGravityScale: number = 1.15; /**下落线性阻尼(越大越慢/越“粘”) */ @property({ displayName: '下落线性阻尼' }) fallLinearDamping: number = 0.12; /**下落最大速度(绝对值,防止越落越快)。<=0 表示不限制 */ @property({ displayName: '下落最大速度' }) maxFallSpeedY: number = 900; /**普通块落稳后的线性阻尼(越大越“稳”,但仍可倒塌) */ @property({ displayName: '落稳线性阻尼' }) settledLinearDamping: number = 1.2; /**普通块落稳后的角阻尼(过大易把慢倾覆「粘」住再被冻 Static) */ @property({ displayName: '落稳角阻尼' }) settledAngularDamping: number = 0.45; /** * 物理帧之后对叠塔做轻度角速度收敛(默认关闭,避免整塔像冻住一样不能倒)。 * 需要减轻地墙翘角时再打开,并优先只开「角速度衰减」勿开「禁止旋转」。 */ @property({ displayName: 'LateUpdate 轻度收敛叠塔角速度' }) lateLockSettledStack: boolean = false; @property({ displayName: '…每帧角速度保留系数(越小转得越死)', range: [0, 1, 0.01], slide: true }) lateSettledAngVelRetain: number = 0.92; @property({ displayName: '…角速度低于此才尝试吸附 90°' }) lateSnapOnlyBelowAngVel: number = 12; @property({ displayName: '…吸附 90° 时与网格最大偏差(度)' }) lateSnapAngleToleranceDeg: number = 14; @property({ displayName: '…吸附到 90° 网格(仅小偏差+低速)' }) lateSnapSettledAngle90: boolean = false; /**勾选后落稳块每帧 fixedRotation=true,整塔将无法倒塌,仅特殊展示用 */ @property({ displayName: '…禁止旋转(慎用)' }) lateFixedRotationWhenSettled: boolean = false; /**易把将落未落整段锁死,默认关;仅在地墙翘角仍存在时尝试打开 */ @property({ displayName: '…也收敛将稳未稳的慢速块' }) lateClampSlowContacting: boolean = false; @property({ displayName: '…慢速阈值(合成速度)' }) lateClampSpeedThreshold: number = 18; @property({ displayName: '格子高度(px)' }) blockHeight: number = 36; /** * 松手放置时:按格子横向吸附落点中心 X(与引导条列宽同源)。 * 当前逻辑:仅当开关开启且关卡为第 1 章(doc.chapter === 1)时生效,其它章节始终不吸附。 */ @property({ displayName: '放置时横向吸附格子(仅第1章)' }) placeSnapHorizontalGrid: boolean = true; _groundTable: Node = null; _gameRoot: Node = null; _floorTaizi: Node = null; _tetrisTable: Node = null; _monsterTable: Node = null; _highLine: Node = null; _highLineUI: UITransform | null = null; _monsterTableUI: UITransform | null = null; /**白线目标本地 Y(GameRoot 本地坐标) */ private _highLineTargetLocalY: number = 0; /**HighLine 初始本地 Y(GameRoot 本地坐标) */ private _highLineInitY: number = 0; /**白线默认高出堆顶多少格(也用于无块时的初始高度) */ @property({ displayName: '白线偏移(格)' }) highLineOffsetBlocks: number = 10; /**白线最高只到离地面多少格 */ @property({ displayName: '白线最高(格)' }) highLineMaxBlocks: number = 23; /**白线到顶后,最高方块与白线保持的间距(格) */ @property({ displayName: '白线到顶后顶部间距(格)' }) highLineTopGapBlocks: number = 5; /**白线始终比堆顶高出的格子数 */ @property({ displayName: '白线堆顶间距(格)' }) highLineStackGapBlocks: number = 2; /**白线最低不低于离地面的格子数 */ @property({ displayName: '白线最小高度(格)' }) highLineMinBaseBlocks: number = 10; /**画布设计宽度(用于缩放时反向拉宽避免穿帮) */ private _designWidth: number = 0; /**周期刷新:用于方块倒塌时同步白线/缩放 */ private _recalcAcc: number = 0; @property({ displayName: '白线重算间隔(秒)' }) recalcInterval: number = 0.3; @property({ displayName: '白线平滑速度' }) highLineFollowSpeed: number = 10; /**缩放目标(镜头拉远;背景/地面 Sprite 用 1/scale 补偿铺满视口) */ private _scaleTarget: number = 1; /** 设计分辨率高度变化后等待布局再同步刚体 */ private _physicsStabilizeScheduled = false; private _lastDesignHeight = 0; /** 游戏相机(通过 UIManager 注入) */ private _gameCamera: Camera | null = null; /** 相机初始正交高度(s=1 对应值) */ private _cameraBaseOrthoHeight: number = 0; /** 开战时锁定的半屏设计高度;禁止用实时 gg.ui.height,否则改分辨率会拽歪镜头 */ private _designHalfHeight: number = 0; /** 相机目标参数(由 _updateHighLine 计算,update 中平滑逼近) */ private _cameraTargetOrthoHeight: number = 0; private _cameraTargetY: number = 0; private _cameraTargetZ: number = 1000; @property({ displayName: '镜头平滑速度' }) cameraFollowSpeed: number = 8; @property({ displayName: '镜头基准线上移比例' }) cameraBaselineLiftRatio: number = 0.2; /**避免重复注入 onSettled */ private _reparentSet = new Set(); /** 堆顶 world Y 缓存,落稳时增量更新,方块销毁时失效 */ private _cachedStackTopWorldYMax = 0; private _stackTopCacheDirty = true; /** 上次用于白线/镜头的已提交堆顶(塔顶未变则不移动白线) */ private _committedStackTopWorldYMax = Number.NaN; /** 离地 0 基准:开战时锁定的地面上沿,不跟地面 Sprite 反缩放漂移 */ private _groundHeightBaselineWorldY = Number.NaN; public invalidateStackTopCache(): void { this._stackTopCacheDirty = true; } private _floorSettledSet = new Set(); /**同一对方块在销毁前可能触发多次 BEGIN_CONTACT,用 key 去重避免重复升级 */ private _mergeOnceKeys = new Set(); timeHit: number = 0; /** 上次已切换到的喷泉档位 0~3(与血量分段一致),-1 表示尚未与城墙血量同步 */ private _lastFountainTier: number = -1; meinvAnimState: string = '待机'; /** * 尝试合并升级:优先保留已落稳的那块升级,销毁正在下落的那块。 * 不依赖 BEGIN_CONTACT 的 self/other 顺序(该顺序不稳定) */ private _tryMergeUpgrade(a: tetriNode, b: tetriNode): boolean { if (!a?.node?.isValid || !b?.node?.isValid) return false; if (a === b) return false; const aType = a.getTetriCubeType?.(); const bType = b.getTetriCubeType?.(); if (aType == null || bType == null) return false; if (aType !== bType) return false; //type也需要一样 const aType2 = a.getTetriType?.(); const bType2 = b.getTetriType?.(); if (aType2 == null || bType2 == null) return false; if (aType2 !== bType2) return false; //如果是藤曼不能升级 if (aType2 == TetriType.Tetris_Basevine || bType2 == TetriType.Tetris_Basevine) return false; // 选择升级目标:优先已落稳者;若同状态,则取 y 更低者做升级目标 let keep: tetriNode; let destroy: tetriNode; if (a.isSettled !== b.isSettled) { keep = a.isSettled ? a : b; destroy = a.isSettled ? b : a; } else { const ay = a.node.worldPosition.y; const by = b.node.worldPosition.y; keep = ay <= by ? a : b; destroy = ay <= by ? b : a; } if (!keep.canMerge()) return false; const aId = a.node.uuid; const bId = b.node.uuid; const key = aId < bId ? `${aId}|${bId}` : `${bId}|${aId}`; if (this._mergeOnceKeys.has(key)) return true; this._mergeOnceKeys.add(key); destroy.mergeDestory(); keep.addLevel(0.2); return true; } private _findInParents(start: Node | null, getter: (n: Node) => T | null): { comp: T | null; node: Node | null } { let cur: Node | null = start; while (cur) { const c = getter(cur); if (c) return { comp: c, node: cur }; if (cur === this._gameRoot) break; cur = cur.parent; } return { comp: null, node: null }; } private _isNodeSelfOrDescendantOf(node: Node | null, root: Node | null): boolean { if (!node || !root) return false; let cur: Node | null = node; while (cur) { if (cur === root) return true; cur = cur.parent; } return false; } protected onLoad(): void { this._initNodes(); } protected onDestroy(): void { this.detachPhysics(); } /** 离开战斗前主动注销物理监听,避免节点销毁后 Box2D 仍在 step 崩溃 */ detachPhysics(): void { PhysicsSystem2D.instance.off(Contact2DType.BEGIN_CONTACT, this._onPhysicsContact, this); PhysicsSystem2D.instance.off(Contact2DType.PRE_SOLVE, this._onPhysicsPreSolve, this); director.off(Director.EVENT_AFTER_PHYSICS, this._afterPhysicsFloorRigidClear, this); this._unbindViewportResize(); } onEnable(): void { //注册点击事件 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); //playerHit GEvent.Ins.on(GEvent.playerHit, this._onPlayerHit, this); GEvent.Ins.on(GEvent.UpdateWallHp, this._onUpdateWallHp, this); this._bindViewportResize(); } onDisable(): 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.playerHit, this._onPlayerHit, this); GEvent.Ins.off(GEvent.UpdateWallHp, this._onUpdateWallHp, this); this._unbindViewportResize(); } start() { this.recalcInterval = BattlePerformance.tetriRecalcIntervalSec(); // 确保外部在 start 之前读取时也已初始化,但这里再兜底一次 this._initNodes(); Physics2DGate.beginBattle(); console.log("2D Physics Enabled:", PhysicsSystem2D.instance.enable); PhysicsSystem2D.instance.on(Contact2DType.BEGIN_CONTACT, this._onPhysicsContact, this); // 地面节点在多数地图仅是 Sprite 铺底(碰撞在台子等节点);开战时归 1,堆高后再反缩放补画面 const ground = this._groundTable ?? this.node.find('地面'); if (ground?.isValid) { ground.setScale(1, 1, 1); const gut = ground.getComponent(UITransform); this._groundHeightBaselineWorldY = gut ? gut.getBoundingBoxToWorld().yMax : ground.worldPosition.y; } this._lastDesignHeight = view.getDesignResolutionSize().height; PhysicsSystem2D.instance.on(Contact2DType.PRE_SOLVE, this._onPhysicsPreSolve, this); director.on(Director.EVENT_AFTER_PHYSICS, this._afterPhysicsFloorRigidClear, this); this.initChapterTetrisTable() } private _initNodes() { if (this._gameRoot) return; this._gameRoot = find("GameRoot", this.node); if (!this._gameRoot) return; this._tetrisTable = find("TetrisTable", this._gameRoot); this._monsterTable = find("TetrisOutTable", this._gameRoot); this.spawnPointParent = this.node.getChildByName('出怪口'); this._groundTable = find("地面", this.node); // 白线节点:你的 prefab 里叫“高度线”,兼容旧名 HighLine this._highLine = find("高度线", this._gameRoot) ?? find("HighLine", this._gameRoot); this._highLineUI = this._highLine?.getComponent(UITransform); this._monsterTableUI = this._monsterTable?.getComponent(UITransform); this._floorTaizi = find("台子", this._gameRoot); // 记录 HighLine 初始位置 & 贴地对齐基准(用于缩放后不“飘”) this._highLineInitY = this._highLine?.position.y ?? 0; this._highLineTargetLocalY = this._highLineInitY; this._designWidth = this.node.getComponent(UITransform)?.contentSize.width ?? 0; //调整spawnPoint_1,spawnPoint_2,spawnPoint_3的x为-360 if (this.spawnPointParent.getChildByName('spawnPoint_1')) { this.spawnPointParent.getChildByName('spawnPoint_1').x = -360; this.spawnPointParent.getChildByName('spawnPoint_1').y = -356 } if (this.spawnPointParent.getChildByName('spawnPoint_2')) { this.spawnPointParent.getChildByName('spawnPoint_2').x = -360; this.spawnPointParent.getChildByName('spawnPoint_2').y = -401 } if (this.spawnPointParent.getChildByName('spawnPoint_3')) { this.spawnPointParent.getChildByName('spawnPoint_3').x = -360; this.spawnPointParent.getChildByName('spawnPoint_3').y = -401 } if (this.spawnPointParent.getChildByName('spawnPoint_4')) { this.spawnPointParent.getChildByName('spawnPoint_4').x = 360; this.spawnPointParent.getChildByName('spawnPoint_4').y = -356 } if (this.spawnPointParent.getChildByName('spawnPoint_5')) { this.spawnPointParent.getChildByName('spawnPoint_5').x = 360; this.spawnPointParent.getChildByName('spawnPoint_5').y = -401 } if (this.spawnPointParent.getChildByName('spawnPoint_6')) { this.spawnPointParent.getChildByName('spawnPoint_6').x = 360; this.spawnPointParent.getChildByName('spawnPoint_6').y = -401 } // 缩放方案改为相机:记录初始相机参数作为“1x”基准 // 地图当前挂在 UI Canvas 树下,实际渲染通常由 UICamera 负责; // 优先取 UICamera,拿不到再回退到 GameCamera。 const cam = gg.ui.GameCamera; if (cam?.isValid) { this._gameCamera = cam; this._cameraBaseOrthoHeight = cam.orthoHeight; this._designHalfHeight = gg.ui.height / 2; if (this._designHalfHeight <= 0) { this._designHalfHeight = cam.orthoHeight; } this._cameraTargetOrthoHeight = this._designHalfHeight; this._cameraTargetY = cam.node.position.y; this._cameraTargetZ = cam.node.position.z; this._syncGameCameraToUiCamera(true); } if (gg.data.doc.chapter == 1 && gg.data.getStatus(StatusType.IsShowPlayGuidePop) == 0) { if (!gg.game.IsPlayingGuideStory) { if (this.node.getChildByName('地基区域') && this.node.getChildByName('地基区域').isValid) { this.node.getChildByName('地基区域').active = true; } } } } _onPhysicsContact(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) { //console.log("tetriMap _onPhysicsContact", self.node.name, other.node.name); const nodeA = self.node; const nodeB = other.node; // Collider2D 可能挂在方块子节点上,需要向上找真正挂脚本的根节点 const aT = this._findInParents(nodeA, (n) => n.getComponent(tetriNode)); const bT = this._findInParents(nodeB, (n) => n.getComponent(tetriNode)); const aF = this._findInParents(nodeA, (n) => n.getComponent(tetriFloorNode)); const bF = this._findInParents(nodeB, (n) => n.getComponent(tetriFloorNode)); // 需求:方块碰到已堆叠支撑后,不再允许左右推动。 // 这里按“latestFallingNode 与支撑体首次接触”立刻清控制目标, // 支撑体包括:地板块 / 已落稳普通块 / 地面节点。 if (this.latestFallingNode?.isValid) { const aBelongLatest = this._isNodeSelfOrDescendantOf(nodeA, this.latestFallingNode); const bBelongLatest = this._isNodeSelfOrDescendantOf(nodeB, this.latestFallingNode); if (aBelongLatest || bBelongLatest) { const latestIsA = aBelongLatest; const otherNode = latestIsA ? nodeB : nodeA; const otherT = latestIsA ? bT.comp : aT.comp; const otherF = latestIsA ? bF.comp : aF.comp; const hitGround = this._isNodeSelfOrDescendantOf(otherNode, this._groundTable); const hitTaizi = this._isNodeSelfOrDescendantOf(otherNode, this._floorTaizi); const hitSupport = !!otherF || !!(otherT && otherT.isSettled) || hitGround || hitTaizi; if (hitSupport) { this.latestFallingNode = null; } } } // 合并升级优先:两个都是 tetriNode 且同类型且可合并时,优先保留已落稳者升级、销毁下落者 if (aT.comp && bT.comp) { if (this._tryMergeUpgrade(aT.comp, bT.comp)) return; } // tetriNode 与 tetriFloorNode 相撞:地板仍按「立刻变底板」处理;下落块只接入落稳检测,不走与普通动态块相撞的那套逻辑,避免和地板同时处理产生多余冲量感 const tetriAHitFloorB = !!(aT.comp && bF.comp); const tetriBHitFloorA = !!(bT.comp && aF.comp); // floor:一碰到任何东西就立刻站稳(与下落普通块相撞时是否立刻站稳见参数) if (aF.comp && aF.node) { this._handleFloorContact(aF.node, other.node, tetriBHitFloorA || aF.comp.isSimFalling, contact); } if (bF.comp && bF.node) { this._handleFloorContact(bF.node, self.node, tetriAHitFloorB || bF.comp.isSimFalling, contact); } // 碰撞分发:如果任意一方是 tetriNode,就把它当作“方块”处理(撞上地板块时走轻量分支) if (aT.comp) { if (tetriAHitFloorB) this._handleTetriContactWithFloor(aT.comp, other); else this._handleTetriContact(aT.comp, other, contact); } if (bT.comp) { if (tetriBHitFloorA) this._handleTetriContactWithFloor(bT.comp, self); else this._handleTetriContact(bT.comp, self, contact); } } /** * RigidBody2D 的 clear* 在部分版本的 d.ts 中未声明,运行时若存在则优先调用(与 3D RigidBody 同源能力探测)。 * 用于消除悬浮地板碰撞后 Box2D 写入的寄生速度与力。 */ private _rigidBody2DTryClearForces(rb: RigidBody2D): void { const fn = (rb as any).clearForces; if (typeof fn !== 'function') return; try { fn.call(rb); } catch { /* ignore */ } } private _rigidBody2DTryClearVelocity(rb: RigidBody2D): void { const fn = (rb as any).clearVelocity; if (typeof fn !== 'function') { rb.linearVelocity = new Vec2(0, 0); rb.angularVelocity = 0; return; } try { fn.call(rb); } catch { rb.linearVelocity = new Vec2(0, 0); rb.angularVelocity = 0; } } private _rigidBody2DTryClearState(rb: RigidBody2D): boolean { const fn = (rb as any).clearState; if (typeof fn !== 'function') return false; try { fn.call(rb); return true; } catch { return false; } } /** * 物理步已解算完毕后调用:清力/清速度,再恢复「允许向下落」与横向分量,专门压掉朝上回弹。 * @param tetri 若传入且仍处于 floorNarrowBridgePhyStepsLeft 窗口,额外压角速度以消「窄接缝」残留微弹。 */ private _rigidBody2DKillFloorParasiticMotion(rb: RigidBody2D, tetri: tetriNode | null = null): void { if (!rb?.isValid || rb.type !== ERigidBody2DType.Dynamic) return; const vx = rb.linearVelocity.x; const vy = rb.linearVelocity.y; const ang = rb.angularVelocity; const narrowExtra = !!(tetri?.floorNarrowBridgePhyStepsLeft && tetri.floorNarrowBridgePhyStepsLeft > 0); // 已落稳:只消上弹,绝不压角速度——贴无重力板棱角时仍需重力倾覆贴合(否则会「翘着停住」) if (tetri?.isSettled) { this._rigidBody2DTryClearForces(rb); if (vy > 0) rb.linearVelocity = new Vec2(vx, 0); return; } // 贴无重力墙慢倾倒时,解算常会留下很小的 vy+;若走下面的 clearVelocity+角速度×0.12,会把翻倒拖成十几秒 if (vy <= 6 && Math.abs(ang) < 42) { this._rigidBody2DTryClearForces(rb); rb.linearVelocity = new Vec2(vx, Math.min(0, vy)); if (rb.linearVelocity.y > 0) { rb.linearVelocity = new Vec2(rb.linearVelocity.x, 0); } if (narrowExtra) { let w = rb.angularVelocity; w *= 0.38; if (Math.abs(w) < 0.12) w = 0; rb.angularVelocity = w; const v2 = rb.linearVelocity; rb.linearVelocity = new Vec2(v2.x * 0.9, Math.min(0, v2.y)); } return; } this._rigidBody2DTryClearForces(rb); if (vy > 55 && this._rigidBody2DTryClearState(rb)) { rb.linearVelocity = new Vec2(vx, 0); rb.angularVelocity = ang * 0.1; return; } this._rigidBody2DTryClearVelocity(rb); rb.linearVelocity = new Vec2(vx, Math.min(0, vy)); rb.angularVelocity = ang * (narrowExtra ? 0.08 : 0.12); // 解算后仍可能留下极小向上分量,再钳一刀避免肉眼可见的“轻弹” if (rb.linearVelocity.y > 0) { rb.linearVelocity = new Vec2(rb.linearVelocity.x, 0); } } /** 每次物理模拟结束后:对仍处于「地板硬清除窗口」内的方块刚体做 clear*,必须在碰撞解算之后 */ private _afterPhysicsFloorRigidClear(): void { const seen = new Set(); const tickOne = (t: tetriNode | null) => { if (!t?.node?.isValid || seen.has(t)) return; if (t.floorRbHardClearPhyStepsLeft <= 0 && t.floorNarrowBridgePhyStepsLeft <= 0) return; seen.add(t); const rb = t.node.getComponent(RigidBody2D) ?? t.node.getComponentInChildren(RigidBody2D); if (rb?.isValid && rb.type === ERigidBody2DType.Dynamic) { // 窗口内每帧都压一次:单靠 vy 阈值会漏掉「本帧末 vy≈0、下一帧初又因分离冲量翘起」的节拍 this._rigidBody2DKillFloorParasiticMotion(rb, t); } if (t.floorRbHardClearPhyStepsLeft > 0) t.floorRbHardClearPhyStepsLeft--; if (t.floorNarrowBridgePhyStepsLeft > 0) t.floorNarrowBridgePhyStepsLeft--; }; let latestT: tetriNode | null = null; if (this.latestFallingNode?.isValid) { latestT = this.latestFallingNode.getComponent(tetriNode) ?? this.latestFallingNode.getComponentInChildren(tetriNode) ?? null; } else if (this.latestFallingNode && !this.latestFallingNode.isValid) { this.latestFallingNode = null; } tickOne(latestT); if (this._tetrisTable?.isValid) { const nodes = this._tetrisTable.getComponentsInChildren(tetriNode) ?? []; for (let i = 0; i < nodes.length; i++) tickOne(nodes[i]); } } /** 两流形点间距小于此(像素)仍视为「顶面一角」窄条,否则 Box2D 常给 2 点导致漏判 */ private static readonly _FLOOR_MANIFOLD_NARROW_PATCH_PX = 18; /** * floor 流形是否为弱支撑:侧棱/单点,或竖法线下两接触点极近(一角/窄棱)。 */ private _floorManifoldIsWeakSupport(contact: IPhysics2DContact): boolean { const wm = contact.getWorldManifold?.(); const ny = wm?.normal?.y ?? 0; const mostlyVertical = Math.abs(ny) >= 0.7; if (!mostlyVertical) return true; const wmPts = (wm as { points?: Vec2[] } | null | undefined)?.points; if (!Array.isArray(wmPts) || wmPts.length === 0) return false; if (wmPts.length === 1) return true; if (wmPts.length >= 2) { const dx = wmPts[0].x - wmPts[1].x; const dy = wmPts[0].y - wmPts[1].y; if (dx * dx + dy * dy < tetriMap._FLOOR_MANIFOLD_NARROW_PATCH_PX * tetriMap._FLOOR_MANIFOLD_NARROW_PATCH_PX) return true; } return false; } /** * floor/台子 × 下落块:与 floor 为弱支撑且已有落稳邻块托底时,视为「窄桥接」。 */ private _floorContactIsNarrowBridge(tetri: tetriNode, contact: IPhysics2DContact): boolean { const bridgeSettledTetri = !tetri.isSettled && tetri.hasSettledTetriBridgeSupport(); return bridgeSettledTetri && this._floorManifoldIsWeakSupport(contact); } /** * 窄桥接时禁用本步 floor 接触解算,避免棱角与邻块约束「撬起」一角。 * 顶面一角/窄条(见 _floorManifoldIsWeakSupport)在邻块托底时不再看 vy;侧棱在 vy 仍很大时暂保留接触防穿模。 */ private _preSolveMaybeDisableNarrowFloorContact(tetri: tetriNode, rb: RigidBody2D, contact: IPhysics2DContact): void { if (!rb?.isValid || rb.type !== ERigidBody2DType.Dynamic || tetri.isSettled) return; if (!this._floorContactIsNarrowBridge(tetri, contact)) return; const wm = contact.getWorldManifold?.(); const ny = wm?.normal?.y ?? 0; const mostlyVertical = Math.abs(ny) >= 0.7; // 仅「侧向弱法线」在仍较快下落时保留 floor 约束,防高速穿棱;顶面一角/窄条不再看 vy if (!mostlyVertical && rb.linearVelocity.y < -260) return; try { const se = (contact as { setEnabled?: (e: boolean) => void }).setEnabled; if (typeof se === 'function') se.call(contact, false); } catch { /* ignore */ } } /** * 无重力墙/台子顶面等「承载体」× tetri:PRE_SOLVE 共用逻辑。 * 贴竖墙时法线常偏水平(|ny| 小),原先角速度门槛过高会导致左右不对称的轻微翘起。 */ private _preSolveDampTetriOnFloorLikeSupport(tetri: tetriNode, rb: RigidBody2D, contact: IPhysics2DContact): void { if (!rb?.isValid || rb.type !== ERigidBody2DType.Dynamic) return; const wm = contact.getWorldManifold?.(); const ny = wm?.normal?.y ?? 0; const mostlyVertical = Math.abs(ny) >= 0.7; const hardClearWindow = 18; const narrowClearWindow = 34; const bridgeSettledTetri = !tetri.isSettled && tetri.hasSettledTetriBridgeSupport(); const narrowFloorBridge = this._floorContactIsNarrowBridge(tetri, contact); const clearWin = narrowFloorBridge ? narrowClearWindow : hardClearWindow; if (tetri.isSettled) { // 已落稳:只消竖直回弹,不压角速度、不续 hardClear——否则会在无重力板棱上「翘着钉死」无法贴合 const v = rb.linearVelocity; if (v.y > 0) rb.linearVelocity = new Vec2(v.x * 0.95, 0); return; } else if (mostlyVertical) { const v = rb.linearVelocity; const vyMul = narrowFloorBridge ? 0.16 : 0.45; if (v.y < 0) rb.linearVelocity = new Vec2(v.x * 0.82, v.y * vyMul); else if (v.y > 0) rb.linearVelocity = new Vec2(v.x * 0.88, 0); const w0 = rb.angularVelocity; const aw0 = Math.abs(w0); if (narrowFloorBridge) { if (aw0 > 6) rb.angularVelocity = w0 * 0.12; else if (aw0 > 0.15) rb.angularVelocity = w0 * 0.42; } else if (aw0 > 14) { rb.angularVelocity *= 0.38; } if (narrowFloorBridge) { tetri.floorNarrowBridgePhyStepsLeft = Math.max(tetri.floorNarrowBridgePhyStepsLeft, narrowClearWindow); } tetri.floorRbHardClearPhyStepsLeft = Math.max(tetri.floorRbHardClearPhyStepsLeft, clearWin); } else { // 法线偏水平:常见于「一块同时压在 floor 竖边/棱角 + 邻块顶面」接缝处;若不压 vy/角速度,解算易把靠 floor 一侧角顶起 const v = rb.linearVelocity; const vyMul = narrowFloorBridge ? 0.12 : (bridgeSettledTetri ? 0.30 : 0.38); if (v.y < 0) rb.linearVelocity = new Vec2(v.x * 0.82, v.y * vyMul); else if (v.y > 0) rb.linearVelocity = new Vec2(v.x * 0.88, 0); const w = rb.angularVelocity; const aw = Math.abs(w); if (narrowFloorBridge) { if (aw > 4) rb.angularVelocity = w * 0.08; else if (aw > 0.12) rb.angularVelocity = w * 0.35; } else if (aw > 12) { rb.angularVelocity = w * 0.22; } else if (aw > 5) { rb.angularVelocity = w * 0.42; } else if (aw > 0.35) { rb.angularVelocity = w * 0.72; } if (narrowFloorBridge) { tetri.floorNarrowBridgePhyStepsLeft = Math.max(tetri.floorNarrowBridgePhyStepsLeft, narrowClearWindow); } tetri.floorRbHardClearPhyStepsLeft = Math.max(tetri.floorRbHardClearPhyStepsLeft, clearWin); } } /** PRE_SOLVE:在物理解算前弱化“砸到已落稳块”的纵向冲量(实现“几乎不顶”) */ private _onPhysicsPreSolve(self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) { if (!contact) return; // 全局禁用回弹:该玩法不需要弹性碰撞,所有接触统一 restitution=0 try { contact.setRestitution(0); } catch { } const aFloor = this._findInParents(self.node, (n) => n.getComponent(tetriFloorNode)); const bFloor = this._findInParents(other.node, (n) => n.getComponent(tetriFloorNode)); const aTpair = this._findInParents(self.node, (n) => n.getComponent(tetriNode)); const bTpair = this._findInParents(other.node, (n) => n.getComponent(tetriNode)); // 无重力墙/底板 × 普通块:PRE_SOLVE 先衰减;物理解算后再用 clearVelocity/clearForces 收尾(见 EVENT_AFTER_PHYSICS) if ((aFloor.comp && bTpair.comp) || (bFloor.comp && aTpair.comp)) { const tetri = aFloor.comp ? bTpair.comp! : aTpair.comp!; const rb = tetri.node.getComponent(RigidBody2D) ?? tetri.node.getComponentInChildren(RigidBody2D); if (rb?.isValid && rb.type === ERigidBody2DType.Dynamic) { // 先关窄接缝接触再阻尼,避免本步已把 vy 压小后仍错过 setEnabled this._preSolveMaybeDisableNarrowFloorContact(tetri, rb, contact); this._preSolveDampTetriOnFloorLikeSupport(tetri, rb, contact); } return; } // 「台子」等静态顶面无 tetriFloor 脚本,原先不走上一分支,右侧/边缘更容易残留上顶与翘角 const aTaizi = !!(this._floorTaizi && this._isNodeSelfOrDescendantOf(self.node, this._floorTaizi)); const bTaizi = !!(this._floorTaizi && this._isNodeSelfOrDescendantOf(other.node, this._floorTaizi)); if ((aTaizi && bTpair.comp) || (bTaizi && aTpair.comp)) { const tetri = aTaizi ? bTpair.comp! : aTpair.comp!; const rb = tetri.node.getComponent(RigidBody2D) ?? tetri.node.getComponentInChildren(RigidBody2D); if (rb?.isValid && rb.type === ERigidBody2DType.Dynamic) { this._preSolveMaybeDisableNarrowFloorContact(tetri, rb, contact); this._preSolveDampTetriOnFloorLikeSupport(tetri, rb, contact); } return; } // Collider2D 可能挂在方块子节点上,需要向上找真正挂脚本的根节点 const aT = aTpair; const bT = bTpair; if (!aT.comp || !bT.comp) return; // 找到“下落者”和“已落稳者” let falling: tetriNode | null = null; let settled: tetriNode | null = null; let fallingIsA = false; if (!aT.comp.isSettled && bT.comp.isSettled) { falling = aT.comp; settled = bT.comp; fallingIsA = true; } else if (!bT.comp.isSettled && aT.comp.isSettled) { falling = bT.comp; settled = aT.comp; fallingIsA = false; } else { return; } const rb = falling.node.getComponent(RigidBody2D) ?? falling.node.getComponentInChildren(RigidBody2D); if (!rb || rb.type !== ERigidBody2DType.Dynamic) return; const v = rb.linearVelocity; if (v.y >= 0) return; // 只处理“正在下落砸下去”的情况 // 只对“近乎竖直的踩踏接触”处理,避免侧面擦碰也被禁用导致来回修正抖动 const wm = contact.getWorldManifold?.(); const ny = wm?.normal?.y ?? 0; // normal 为 A->B:我们只关心“竖直方向的接触”,并且下落者在上、已落稳者在下 if (Math.abs(ny) < 0.75) return; if (falling.node.worldPosition.y < settled.node.worldPosition.y) return; // 不再禁用接触解算(disabledOnce 会让“砸击力矩”消失,导致看起来该倒却不倒) // 在 PRE_SOLVE 仅衰减下落速度,不直接清零,保留倾倒所需的力矩 if (v.y < 0) { rb.linearVelocity = new Vec2(v.x, v.y * 0.35); } // 记录分离深度用于观察是否有穿透/重叠 const seps = wm?.separations ?? null; let minSep = 0; if (seps && seps.length) { minSep = seps.reduce((m, s) => (s < m ? s : m), 0); } // console.log( // `[PRE_SOLVE][noBump] falling=${falling.node.name} settled=${settled.node.name} ` + // `ny=${ny.toFixed(2)} minSep=${minSep.toFixed(3)} ` + // `v=(${v.x.toFixed(1)},${v.y.toFixed(1)}) angV=${rb.angularVelocity.toFixed(1)} ` + // `fixedRot=${rb.fixedRotation} g=${rb.gravityScale} linD=${rb.linearDamping} angD=${rb.angularDamping} type=${rb.type}` // ); // 不再手动改 worldPosition:交给物理接触约束做位置修正,避免偶发抖动/“钉住”副作用 } // 注意:这里不再用 UITransform 的 yMin/yMax “硬贴合”顶面, // 而是用 worldManifold.separations 做最小必要的上推纠正,减少不同形状/旋转下的抖动。 /**与普通 tetriFloorNode(墙/底板)相撞:怪物区仍生效;否则只挂落稳,不把对方当「另一块动态俄罗斯方块」做互撞处理 */ private _handleTetriContactWithFloor(t: tetriNode, other: Collider2D) { const hitMonster = !!(this._monsterTable && this._isNodeSelfOrDescendantOf(other.node, this._monsterTable)); const hitNamedOut = other.node?.name === 'TetrisOutTable'; const hitGround = !!(this._groundTable && this._isNodeSelfOrDescendantOf(other.node, this._groundTable)); const isKill = hitMonster || hitNamedOut || hitGround; if (isKill) { this._showFloorRedFlash(other.node); t.skill(); return; } if (t.isSettled) return; t.floorRbHardClearPhyStepsLeft = Math.max(t.floorRbHardClearPhyStepsLeft, 18); const otherF = this._findInParents(other.node, (n) => n.getComponent(tetriFloorNode)).comp; if (otherF?.isSettled && otherF.hasStackSupportStable()) { t.markStackSupportStable(); } // 地板块下一帧会切 Kinematic + 归位,容易在单点接触上被解算甩出角速度导致「翘起来」:先清角速度并暂时禁止旋转,落稳回调里会再打开 this._freezeTetriRotationForFloorContact(t.node); this._ensureReparentOnSettled(t); t.notifyContact(); } /**floor 接触瞬间仅抑制过大的角速度,不锁旋转,避免“该倒不倒” */ private _freezeTetriRotationForFloorContact(tetriRoot: Node | null) { if (!tetriRoot?.isValid) return; const rb = tetriRoot.getComponent(RigidBody2D) ?? tetriRoot.getComponentInChildren(RigidBody2D); if (!rb || rb.type !== ERigidBody2DType.Dynamic) return; rb.angularVelocity *= 0.35; rb.fixedRotation = false; } /** * 地板块落稳时 BEGIN_CONTACT 往往只与「最底下那块」配对,上层(如蓝 Z)仍会吃到冲量翘起。 * 在 floor 已变 Kinematic 钉住之后,对 TetrisTable 上所有普通块统一清角速度;已落稳的做 90° 网格吸附。 * 注意:pinFloor 当前未调用(601bdd7b 起为避免整塔冻死/吸附副作用而停用)。 */ private _stabilizeAllTetrisAfterFloorPinned() { if (!this._tetrisTable?.isValid) return; const snapDeg = 22.5; const visit = (n: Node) => { const t = n.getComponent(tetriNode); const rb = n.getComponent(RigidBody2D) ?? n.getComponentInChildren(RigidBody2D); if (t && rb?.type === ERigidBody2DType.Dynamic) { rb.angularVelocity = 0; if (t.isSettled) { const a = n.angle; const snapped = Math.round(a / 90) * 90; if (Math.abs(a - snapped) <= snapDeg) { n.angle = snapped; } } } const ch = n.children; for (let i = 0; i < ch.length; i++) visit(ch[i]); }; const roots = this._tetrisTable.children; for (let i = 0; i < roots.length; i++) visit(roots[i]); } /** 合并子树上已启用 PolygonCollider2D 的世界 AABB */ private _unionWorldAabbEnabledPolysUnder(root: Node | null): Rect | null { if (!root?.isValid) return null; const cols = root.getComponentsInChildren(PolygonCollider2D) ?? []; let out: Rect | null = null; for (const c of cols) { if (!c?.isValid || !c.enabled) continue; const r = c.worldAABB as Rect | undefined; if (!r) continue; if (!out) { out = new Rect(r.x, r.y, r.width, r.height); } else { const x1 = Math.min(out.x, r.x); const y1 = Math.min(out.y, r.y); const x2 = Math.max(out.x + out.width, r.x + r.width); const y2 = Math.max(out.y + out.height, r.y + r.height); out = new Rect(x1, y1, x2 - x1, y2 - y1); } } return out; } /** 地板碰撞体尚未 enabled 时,用 UITransform 包围盒估算影响区 */ private _unionWorldAabbFloorPrep(floorRoot: Node | null): Rect | null { if (!floorRoot?.isValid) return null; const fromEnabled = this._unionWorldAabbEnabledPolysUnder(floorRoot); if (fromEnabled) return fromEnabled; const uts = floorRoot.getComponentsInChildren(UITransform) ?? []; let out: Rect | null = null; for (const ut of uts) { if (!ut?.isValid) continue; const r = ut.getBoundingBoxToWorld(); if (!out) { out = new Rect(r.x, r.y, r.width, r.height); } else { const x1 = Math.min(out.x, r.x); const y1 = Math.min(out.y, r.y); const x2 = Math.max(out.x + out.width, r.x + r.width); const y2 = Math.max(out.y + out.height, r.y + r.height); out = new Rect(x1, y1, x2 - x1, y2 - y1); } } return out; } private _aabbOverlap2D(a: Rect, b: Rect): boolean { return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y); } private _floorInfluenceZone(floorRoot: Node | null, padUpBlocks: number, usePrepAabb: boolean): Rect | null { const fr = usePrepAabb ? this._unionWorldAabbFloorPrep(floorRoot) : this._unionWorldAabbEnabledPolysUnder(floorRoot); if (!fr) return null; const padX = 28; const padDown = 96; const padUp = Math.max(16, this.blockHeight * Math.max(0, padUpBlocks)); return new Rect(fr.x - padX, fr.y - padDown, fr.width + padX * 2, fr.height + padDown + padUp); } /** * 仅处理与地板影响区 AABB 重叠的 Dynamic 块:压寄生上顶 vy、衰减角速度。 * usePrepAabb=true 时地板碰撞体尚未进物理世界(pin 前预消冲)。 */ private _dampDynamicsNearFloorRegionImpl(floorRoot: Node | null, padUpBlocks: number, usePrepAabb: boolean): void { if (!floorRoot?.isValid || !this._tetrisTable?.isValid) return; const zone = this._floorInfluenceZone(floorRoot, padUpBlocks, usePrepAabb); if (!zone) return; const nodes = this._tetrisTable.getComponentsInChildren(tetriNode) ?? []; for (let i = 0; i < nodes.length; i++) { const t = nodes[i]; if (!t?.node?.isValid || t.node.getComponent(tetriFloorNode)) continue; if (this._isNodeSelfOrDescendantOf(t.node, floorRoot)) continue; const tr = this._unionWorldAabbEnabledPolysUnder(t.node); if (!tr || !this._aabbOverlap2D(zone, tr)) continue; const rb = t.node.getComponent(RigidBody2D) ?? t.node.getComponentInChildren(RigidBody2D); if (!rb?.isValid || rb.type !== ERigidBody2DType.Dynamic) continue; const v = rb.linearVelocity; if (v.y > 1e-4) { this._rigidBody2DKillFloorParasiticMotion(rb, t); } const w = rb.angularVelocity; if (Math.abs(w) > 1e-4) rb.angularVelocity *= 0.28; } } /** * pin 序列:预消冲 → 启用地板碰撞体 → 立即再消冲 → 下一帧物理解算后再消冲。 * 解决 cold-enable 静态碰撞体嵌入 Dynamic 塔导致整塔炸开。 */ private _runPinFloorColliderSequence(floorRoot: Node | null, floorComp: tetriFloorNode | null): void { if (!floorRoot?.isValid) return; const padUp = 3; this._dampDynamicsNearFloorRegionImpl(floorRoot, padUp, true); floorComp?.enablePhysicsColliders(); this._dampDynamicsNearFloorRegionImpl(floorRoot, padUp, false); this.scheduleOnce(() => { if (floorRoot?.isValid) { this._dampDynamicsNearFloorRegionImpl(floorRoot, padUp, false); } }, 0); } /** * floor 钉住(归档到 TetrisTable + 切刚体类型 + 收敛整塔角速度 + 刷新白线)。 * 说明:floor 现在可能由脚本模拟下落(无 Dynamic 物理过程),因此对外暴露一个入口供 tetriFloorNode 调用。 */ public pinFloor(selfNode: Node): void { if (!selfNode?.isValid) return; if (this._floorSettledSet.has(selfNode)) return; this._floorSettledSet.add(selfNode); // 仍延迟到下一帧,避免与同帧的物理/碰撞回调交织 this.scheduleOnce(() => { if (!selfNode?.isValid) return; if (this._tetrisTable?.isValid) { selfNode.setParent(this._tetrisTable, true); } // const rb = selfNode.getComponent(RigidBody2D) ?? selfNode.getComponentInChildren(RigidBody2D); // if (rb) { // rb.linearVelocity = new Vec2(0, 0); // rb.angularVelocity = 0; // rb.fixedRotation = false; // rb.gravityScale = 0; // rb.type = ERigidBody2DType.Static; // } const floor = selfNode.getComponent(tetriFloorNode); TetriStackDiagnostics.onPinFloor(selfNode.name); this._runPinFloorColliderSequence(selfNode, floor); // 与普通块落稳一致:先写入堆顶缓存,再算白线/镜头(否则缓存未脏会一直用旧高度) this._noteBlockStackTop(selfNode); this._updateHighLine(); this.scheduleOnce(() => { if (selfNode?.isValid) TetriStackDiagnostics.afterPinFloorSequence(); }, 0); try { floor?.onSettled?.(); } catch (e) { console.error(e); } }, 0); } /**地墙碰上即停(脚本层);刚体类型仍延后到下一帧改,避免在碰撞回调里直接改 type */ private _brakeFloorRigidbodyNow(floorRoot: Node | null) { if (!floorRoot?.isValid) return; const rb = floorRoot.getComponent(RigidBody2D) ?? floorRoot.getComponentInChildren(RigidBody2D); if (!rb) return; rb.linearVelocity = new Vec2(0, 0); rb.angularVelocity = 0; } /** * 是否因碰到「移除用地面」而应扣雷电爱心:地图「地面」子树、名为 TetrisOutTable 的落出区、 * 或根名为 TetrisOutTable 的怪物落出表子树。纯怪物区(非落出表)不扣。 */ private _shouldApplyLightningLoveOnKillContact(otherNode: Node | null): boolean { if (!otherNode?.isValid) return false; if (otherNode.name === 'TetrisOutTable') return true; if (this._groundTable && this._isNodeSelfOrDescendantOf(otherNode, this._groundTable)) return true; if (this._monsterTable?.name === 'TetrisOutTable' && this._monsterTable && this._isNodeSelfOrDescendantOf(otherNode, this._monsterTable)) { return true; } return false; } private _handleFloorContact(selfNode: Node, otherNode: Node, pinEvenIfHitByFallingTetri: boolean = false, contact: IPhysics2DContact | null = null) { if (this._floorSettledSet.has(selfNode)) return; // 关键:floor 在 Dynamic 状态与已存在方块发生重叠/接触时,Box2D 会用分离冲量把对方“挤走”。 // floor 的设计是“碰到就立刻钉住变底板”,因此这里直接禁用这次接触的物理解算,避免对方被顶开。 // 仍然保留回调逻辑(我们自己在下一帧把 floor 切 Kinematic:不受动态塔挤压位移,比 Static 更不易把底层块“挤开”)。 if (contact) { const otherT = this._findInParents(otherNode, (n) => n.getComponent(tetriNode)).comp; if (otherT) { try { (contact as any).setEnabled(false); } catch { } } } // 碰到怪物区 / 落出区 /「地面」移除区:直接销毁(地面与普通块、floor 脚本杀区一致) const hitMonster = !!(this._monsterTable && this._isNodeSelfOrDescendantOf(otherNode, this._monsterTable)); const hitNamedOut = otherNode?.name === 'TetrisOutTable'; const hitGround = !!(this._groundTable && this._isNodeSelfOrDescendantOf(otherNode, this._groundTable)); const isKill = hitMonster || hitNamedOut || hitGround; if (isKill) { this._showFloorRedFlash(otherNode); selfNode.getComponent(tetriFloorNode).skill(); return; } // 如果撞到的是“正在下落的普通块”,默认不立即站稳,避免被侧推时瞬间锁死;与 tetriNode 的「墙」相撞时允许立刻变底板 const otherT = this._findInParents(otherNode, (n) => n.getComponent(tetriNode)).comp; if (!pinEvenIfHitByFallingTetri && otherT && !otherT.isSettled) return; this._brakeFloorRigidbodyNow(selfNode); this.pinFloor(selfNode); } private _handleTetriContact(t: tetriNode, other: Collider2D, contact: IPhysics2DContact | null) { const hitMonster = !!(this._monsterTable && this._isNodeSelfOrDescendantOf(other.node, this._monsterTable)); const hitNamedOut = other.node?.name === 'TetrisOutTable'; const hitGround = !!(this._groundTable && this._isNodeSelfOrDescendantOf(other.node, this._groundTable)); const isKill = hitMonster || hitNamedOut || hitGround; if (isKill) { this._showFloorRedFlash(other.node); t.skill(); return; } if (t.isSettled) return; const otherT = this._findInParents(other.node, (n) => n.getComponent(tetriNode)).comp; if (this._floorTaizi && this._isNodeSelfOrDescendantOf(other.node, this._floorTaizi)) { t.floorRbHardClearPhyStepsLeft = Math.max(t.floorRbHardClearPhyStepsLeft, 18); t.markStackSupportStable(); } if (otherT && otherT.isSettled && otherT.hasStackSupportStable()) { t.markStackSupportStable(); } // 之前为了避免“两块同时下落互相触发落稳”这里会跳过未落稳对方。 // 但会导致一种卡死:A/B 初次接触时都未落稳 → 永远不再触发 BEGIN_CONTACT → 后续即使其中一块先稳定,另一块也收不到 notifyContact(),从而一直 isSettled=false(不发射武器)。 // 因此:只要发生了方块接触(无论对方是否已落稳),都允许进入落稳检测;真正 settle 仍由速度/角速度阈值 + 静止帧数控制,不会“空中瞬落”。 // 砸到“已落稳”的块时:仅衰减下落速度,不直接清零,保留更自然的翻倒力矩 if (otherT && otherT.isSettled) { if (BattlePerformance.shouldFreezeTetriPhysics() && otherT.isPhysicsFrozen) { otherT.deferUnfreezePhysicsForImpact(); } const rb = t.node.getComponent(RigidBody2D) ?? t.node.getComponentInChildren(RigidBody2D); if (rb && rb.type === ERigidBody2DType.Dynamic) { const v = rb.linearVelocity; if (v.y < 0) { rb.linearVelocity = new Vec2(v.x, v.y * 0.35); console.log("砸到已落稳块:仅衰减 vy,不清零,保留自然倾倒趋势"); } } } // 碰到地面、已落稳方块等 → 开始静止检测并注入 onSettled this._ensureReparentOnSettled(t); t.notifyContact(); } /**确保落稳后归档到稳定表 */ private _ensureReparentOnSettled(t: tetriNode) { if (!this._tetrisTable) return; if (this._reparentSet.has(t)) return; this._reparentSet.add(t); const prevOnSettled = t.onSettled; t.onSettled = () => { // 允许外部先挂钩(例如“第一块落稳后开始战斗”),这里做链式调用避免覆盖 try { prevOnSettled?.(); } catch (e) { console.error(e); } if (!t.node?.isValid || !this._tetrisTable?.isValid) return; // 注意:Box2D 不允许在碰撞回调里直接改 type(Dynamic/Static),必须延迟到下一帧 this.scheduleOnce(() => { if (!t.node?.isValid || !this._tetrisTable?.isValid) return; if (this.latestFallingNode === t.node) { this.latestFallingNode = null; } // 归档到稳定表 t.node.setParent(this._tetrisTable, true); GEvent.Ins.emit(GEvent.UpdateDps,0) // 普通 tetriNode:落稳后仍保持 Dynamic + 重力开启(后续叠加可倒塌) const rb = t.node.getComponent(RigidBody2D) ?? t.node.getComponentInChildren(RigidBody2D); if (rb) { // 不在落稳瞬间强行清零速度,避免“该倒却被站稳” rb.fixedRotation = false; rb.type = ERigidBody2DType.Dynamic; // 保留重力,阻尼降低到更自然可倾倒 rb.gravityScale = this.fallGravityScale; rb.linearDamping = this.settledLinearDamping; rb.angularDamping = this.settledAngularDamping; // 落稳后保持 Dynamic 解算,禁止 sleep,否则濒倾块会在力矩未平衡时睡着 rb.allowSleep = false; if (!BattlePerformance.isWeChatIos()) { rb.wakeUp(); } } this._noteBlockStackTop(t.node); this._updateHighLine(); }, 0); }; } /** 脚本下落的 floor 碰到「地面」等销毁区时调用(与 _showFloorRedFlash 一致,供 tetriFloorNode 使用) */ public showFloorRedFlash(lightningContactOther?: Node | null): void { this._showFloorRedFlash(lightningContactOther); } /**显示地面闪红;掉落方块统一入口。lightningContactOther:用于雷电副本爱心(碰移除地面/落出区等) */ private _showFloorRedFlash(lightningContactOther?: Node | null): void { gg.sdk.reportDY("inLevel", `章节${gg.game.CurentBattle.getReportDYChapterId()}_${gg.game.CurentBattle.CurentWaveNum}-掉落方块`) // if (this._shouldApplyLightningLoveOnKillContact(lightningContactOther ?? null)) { // gg.game?.CurentBattle?.tryApplyLightningLoveOnRemovalGround(); // } let eff = this.node.getChildByName("目标点").getChildByName("groundEff"); eff.active = true; let o = eff.getComponent(UIOpacity) || eff.addComponent(UIOpacity); Tween.stopAllByTarget(o); o.opacity = 0; tween(o) .to(0.1, { opacity: 255 }) .to(0.1, { opacity: 0 }) .to(0.1, { opacity: 255 }) .to(0.1, { opacity: 0 }) .to(0.1, { opacity: 255 }) .to(0.1, { opacity: 0 }) .call(() => { eff.active = false; }) .start(); // 方块销毁等发生在当前帧稍后,延迟一帧再重算堆叠/白线,与 UItetriGame 高度尺一致 this.scheduleOnce(() => this._updateHighLine(), 0); } protected update(dt: number): void { if (!this._gameRoot?.isValid) return; if (gg.game.IsPlayingGuideStory) { return } // 白线每帧平滑逼近目标,避免重算间隔导致“台阶跳动” if (this._highLine?.isValid) { const p = this._highLine.position; const speed = Math.max(0, this.highLineFollowSpeed); const alpha = speed > 0 ? (1 - Math.exp(-speed * dt)) : 1; const nextY = p.y + (this._highLineTargetLocalY - p.y) * alpha; this._highLine.setPosition(p.x, nextY, p.z); } // 方块可能会倒塌降低高度:周期性全表重算(避免 floor 漏记时缓存一直不脏) this._recalcAcc += dt; if (this._recalcAcc >= this.recalcInterval) { this._recalcAcc = 0; this.invalidateStackTopCache(); this._updateHighLine(); } // 下落速度钳制:重力加速度会让速度随时间变大(正常物理现象),此处按策划手感做上限 this._clampLatestFallingSpeed(); // this._gameCamera.orthoHeight = this._cameraBaseOrthoHeight / this._scaleTarget; // const camPos = this._gameCamera.node.position; // const targetY = gg.ui.height * (1 - this._scaleTarget) / 2; // const targetZ = 1000; // const followT = 0.2; // const smoothY = camPos.y + (targetY - camPos.y) * followT; // const smoothZ = camPos.z + (targetZ - camPos.z) * followT; // this._gameCamera.node.setPosition(camPos.x, smoothY, smoothZ); // 每帧平滑更新相机,避免 recalcInterval=0.1s 带来的“台阶感/卡顿感” if (this._gameCamera?.isValid && this._cameraTargetOrthoHeight > 0) { const alpha = 1 - Math.exp(-Math.max(0, this.cameraFollowSpeed) * dt); const curH = this._gameCamera.orthoHeight; this._gameCamera.orthoHeight = curH + (this._cameraTargetOrthoHeight - curH) * alpha; const camPos = this._gameCamera.node.position; const nextY = camPos.y + (this._cameraTargetY - camPos.y) * alpha; const nextZ = camPos.z + (this._cameraTargetZ - camPos.z) * alpha; this._gameCamera.node.setPosition(camPos.x, nextY, nextZ); // A 修正:背景 + 地面视觉都按镜头反缩放,避免堆高后底部露黑边穿帮。 // 「地面」多为 Sprite 铺底(锚点靠上),放大主要向下延伸,上沿支撑基准基本不动; // 真正承重碰撞在台子等节点,不跟这里缩放。 const inv = 1 / Math.max(0.05, this.getVisualZoomScale()); const bg = this.node.find('背景'); if (bg?.isValid) { bg.setScale(inv, inv, inv); } const ground = this._groundTable ?? this.node.find('地面'); if (ground?.isValid) { ground.setScale(inv, inv, inv); } } } private _applyHighLineScaleForZoom(zoomScale: number): void { if (!this._highLine?.isValid) return; const s = Math.max(0.05, Math.min(1, zoomScale)); // 相机拉远后白线会变短,按反比只补偿横向宽度 this._highLine.setScale(1 / s, 1, 1); } private _clampLatestFallingSpeed(): void { if (!this.latestFallingNode?.isValid) return; const maxY = this.maxFallSpeedY ?? 0; if (maxY <= 0) return; const rb = this.latestFallingNode.getComponent(RigidBody2D) ?? this.latestFallingNode.getComponentInChildren(RigidBody2D); if (!rb || rb.type !== ERigidBody2DType.Dynamic) return; const v = rb.linearVelocity; // 只限制“下落”(y<0)方向 if (v.y < -maxY) { rb.linearVelocity = new Vec2(v.x, -maxY); } } /** * B(收敛版): * - 仅「设计高度」变化时处理(750×1334 ↔ 750×1600) * - 黑边/窗口变化:不处理 * - 双相机:GameCamera ortho 跟随 UICamera * - 刚体:Canvas 父级缩放后须 syncPositionToPhysics(禁止关物理 / rb.enabled) */ private _bindViewportResize(): void { this._unbindViewportResize(); view.on('canvas-resize', this._onViewportResize, this); screen.on('window-resize', this._onViewportResize, this); } private _unbindViewportResize(): void { view.off('canvas-resize', this._onViewportResize, this); screen.off('window-resize', this._onViewportResize, this); } public stabilizeAfterViewportChange(): void { this._onViewportResize(); } private _onViewportResize(): void { if (!gg.game?.isBattleContextActive?.()) return; const designH = view.getDesignResolutionSize().height; if (!(designH > 0) || Math.abs(designH - this._lastDesignHeight) < 1) { return; } this._lastDesignHeight = designH; this._syncGameCameraToUiCamera(true); this._scheduleViewportStabilize(); } /** 设计高度变化后:GameCamera 与 UICamera 保持同一缩放比例,避免 GameRoot 横向偏移 */ private _syncGameCameraToUiCamera(snapNow = false): void { const uiCam = gg.ui?.UICamera; const gameCam = this._gameCamera?.isValid ? this._gameCamera : (gg.ui?.GameCamera ?? null); if (!uiCam?.isValid || !gameCam?.isValid) return; const uiOrtho = uiCam.orthoHeight; const lockedHalf = this._designHalfHeight; if (!(uiOrtho > 0) || !(lockedHalf > 0) || !(this._cameraBaseOrthoHeight > 0)) return; const zoom = Math.max(0.05, this._scaleTarget || 1); const gameToUi = this._cameraBaseOrthoHeight / lockedHalf; this._cameraTargetOrthoHeight = (uiOrtho * gameToUi) / zoom; this._cameraTargetY = (this._cameraTargetOrthoHeight - lockedHalf) / 2; if (snapNow) { gameCam.orthoHeight = this._cameraTargetOrthoHeight; const p = gameCam.node.position; gameCam.node.setPosition(p.x, this._cameraTargetY, this._cameraTargetZ); } } private _scheduleViewportStabilize(): void { if (this._physicsStabilizeScheduled) return; this._physicsStabilizeScheduled = true; // 等 Canvas/Widget 布局稳定后再对齐刚体 this.scheduleOnce(() => { this.scheduleOnce(() => { this._physicsStabilizeScheduled = false; this._resyncAllTetriRigidBodiesFromNodes(); this._syncGameCameraToUiCamera(true); role.refreshFollowOffsetsAfterViewportChange(); }, 0); }, 0); } /** * 分辨率变化后:节点世界坐标已变,刚体仍停在旧位 → 碰撞全乱。 * 无重力 floor 无 RigidBody2D,须单独冷重启 PolygonCollider2D。 */ private _resyncAllTetriRigidBodiesFromNodes(): void { const visited = new Set(); const pushRb = (rb: RigidBody2D) => { const body = (rb as { _body?: { syncPositionToPhysics?: (resetVel?: boolean) => void } })._body; body?.syncPositionToPhysics?.(false); }; const visit = (n: Node) => { if (!n?.isValid || visited.has(n)) return; visited.add(n); const floor = n.getComponent(tetriFloorNode); if (floor) { if (floor.isSettled) { floor.refreshPhysicsCollidersAfterViewportChange(); } return; } const tn = n.getComponent(tetriNode); const rb = n.getComponent(RigidBody2D) ?? n.getComponentInChildren(RigidBody2D); if (rb?.isValid) { if (tn) { const falling = !tn.isSettled && !tn.isPhysicsFrozen; tn.syncRigidBodyPoseFromNode(!falling); } else { pushRb(rb); rb.angularVelocity = 0; if (rb.type === ERigidBody2DType.Static || rb.type === ERigidBody2DType.Kinematic) { rb.linearVelocity = new Vec2(0, 0); } const cols = n.getComponentsInChildren(PolygonCollider2D) ?? []; for (let i = 0; i < cols.length; i++) { this._coldRefreshCollider2D(cols[i]); } } } else { const cols = n.getComponentsInChildren(PolygonCollider2D) ?? []; for (let i = 0; i < cols.length; i++) { this._coldRefreshCollider2D(cols[i]); } } const ch = n.children; for (let i = 0; i < ch.length; i++) visit(ch[i]); }; if (this._tetrisTable?.isValid) { for (const child of this._tetrisTable.children) visit(child); } if (this.latestFallingNode?.isValid) visit(this.latestFallingNode); if (this._floorTaizi?.isValid) visit(this._floorTaizi); if (this._groundTable?.isValid) visit(this._groundTable); this._refreshAllPinnedFloorsAfterViewport(); } private _coldRefreshCollider2D(col: PolygonCollider2D): void { if (!col?.isValid) return; const was = col.enabled; col.enabled = false; col.enabled = was; if (typeof col.apply === 'function') col.apply(); } /** 无重力板钉住后再冷启碰撞体,并消邻域竖直冲量,避免穿透/炸塔 */ private _refreshAllPinnedFloorsAfterViewport(): void { if (!this._tetrisTable?.isValid) return; const floors = this._tetrisTable.getComponentsInChildren(tetriFloorNode) ?? []; for (let i = 0; i < floors.length; i++) { const f = floors[i]; if (!f?.isSettled || !f.node?.isValid) continue; f.refreshPhysicsCollidersAfterViewportChange(); this._dampDynamicsNearFloorRegionImpl(f.node, 3, false); } } private _resizeUIForScale(curScale: number): void { if (this._designWidth <= 0) return; const w = this._designWidth / curScale; if (this._highLineUI?.isValid) { this._highLineUI.setContentSize(w, this._highLineUI.contentSize.height); } if (this._monsterTableUI?.isValid) { this._monsterTableUI.setContentSize(w, this._monsterTableUI.contentSize.height); } } /** * 物理模拟之后:可选地对叠塔做「轻度」角速度衰减 / 小角度吸附,避免先前每帧 fixedRotation+硬吸附把整塔冻死。 */ protected lateUpdate(_dt: number): void { if (!this.lateLockSettledStack || !this._tetrisTable?.isValid) return; const retain = Math.min(1, Math.max(0, this.lateSettledAngVelRetain)); const visit = (n: Node) => { const t = n.getComponent(tetriNode); const rb = n.getComponent(RigidBody2D) ?? n.getComponentInChildren(RigidBody2D); if (t && rb && rb.type === ERigidBody2DType.Dynamic) { let soften = false; if (t.isSettled) { soften = true; } else if ( this.lateClampSlowContacting && t.hasSettleContact && Math.hypot(rb.linearVelocity.x, rb.linearVelocity.y) < this.lateClampSpeedThreshold ) { soften = true; } if (soften) { rb.angularVelocity *= retain; if ( this.lateSnapSettledAngle90 && Math.abs(rb.angularVelocity) <= this.lateSnapOnlyBelowAngVel ) { const a = n.angle; const snapped = Math.round(a / 90) * 90; if (Math.abs(a - snapped) <= this.lateSnapAngleToleranceDeg) { n.angle = snapped; rb.angularVelocity = 0; } } if (this.lateFixedRotationWhenSettled && t.isSettled) { rb.fixedRotation = true; } } } const ch = n.children; for (let i = 0; i < ch.length; i++) visit(ch[i]); }; const roots = this._tetrisTable.children; for (let i = 0; i < roots.length; i++) visit(roots[i]); } private _pushLatestFallingByTouch(event: EventTouch): void { if (!this.latestFallingNode?.isValid) { this.latestFallingNode = null; return; } // 放下的一瞬间会触发 TOUCH_END,给个短冷却防止“刚生成就被推飞” if (this.pushCooldownSeconds > 0) { const now = Date.now(); if (now - this._latestFallingSetMs < this.pushCooldownSeconds * 1000) return; } // 按屏幕左右半区决定横移方向(与方块在屏幕左侧还是右侧无关)。 // 有游戏相机时:视口水平中线与触点同一屏幕 Y 映射到世界 X 再比较,避免镜头平移/缩放后误判。 const gameCamera = this._gameCamera?.isValid ? this._gameCamera : (gg.ui?.GameCamera ?? null); const sp = event.getLocation(); const vp = view.getViewportRect(); const midScreenX = vp.x + vp.width * 0.5; let dir: number; if (gameCamera?.isValid) { const touchWorldX = gameCamera.screenToWorld(new Vec3(sp.x, sp.y, 0)).x; const midWorldX = gameCamera.screenToWorld(new Vec3(midScreenX, sp.y, 0)).x; dir = touchWorldX < midWorldX ? -1 : 1; } else { dir = sp.x < midScreenX ? -1 : 1; } // floorNode:脚本模拟下落(无 Dynamic 刚体),触摸推动走 tetriFloorNode._simVx const floor = this.latestFallingNode.getComponent(tetriFloorNode) ?? this.latestFallingNode.getComponentInChildren(tetriFloorNode); if (floor?.isSimFalling) { // floor:点击即横移小步(与放置时的格子吸附无关;几何误挡由 tetriFloorNode 侧 AABB 粗测等修正) floor.nudgeSimX(dir, 6); return; } const rb = this.latestFallingNode.getComponent(RigidBody2D) ?? this.latestFallingNode.getComponentInChildren(RigidBody2D); if (!rb) return; // 只对 Dynamic 刚体施力 if (rb.type !== ERigidBody2DType.Dynamic) return; // 推动过程中禁止旋转(避免被冲量带出角速度) rb.fixedRotation = true; rb.angularVelocity = 0; // console.log( // `[TouchPush][START] node=${this.latestFallingNode.name} fixedRot=${rb.fixedRotation} ` + // `v=(${rb.linearVelocity.x.toFixed(1)},${rb.linearVelocity.y.toFixed(1)}) angV=${rb.angularVelocity.toFixed(1)} ` + // `g=${rb.gravityScale} linD=${rb.linearDamping} angD=${rb.angularDamping} type=${rb.type}` // ); const vel = rb.linearVelocity; // 只做“速度阶跃”,避免冲量把方块弹飞 const hardMax = Math.min(this.maxVelX, this.safeMaxVelX); const nextX = vel.x + dir * this.pushDeltaVelX; const clampedX = Math.max(-hardMax, Math.min(hardMax, nextX)); rb.linearVelocity = new Vec2(clampedX, vel.y); // if (this.debugTouchPush) { // const vel2 = rb.linearVelocity; // } } _onTouchStart(event: EventTouch): void { // 节点触摸链(部分机型/相机层级下可能收不到) this._pushLatestFallingByTouch(event); } /** 供 UI 层转发触摸:推动正在下落的方块 */ public handleExternalTouchStart(event: EventTouch): void { if (gg.game?.CurentBattle?.IsUseShovel) return; this._pushLatestFallingByTouch(event); } /** 供 UI 层转发触摸结束:结束推动状态 */ public handleExternalTouchEnd(event: EventTouch): void { this._onTouchEnd(event); } _onTouchEnd(event: EventTouch): void { //console.log("tetriMap onTouchEnd"); if (gg.game.CurentBattle.IsUseShovel) { // 锤子由全局监听处理,节点触摸链可能被遮罩截断 return; } if (!this.latestFallingNode?.isValid) { this.latestFallingNode = null; return; } // floorNode:松手清掉水平速度 const floor = this.latestFallingNode.getComponent(tetriFloorNode) ?? this.latestFallingNode.getComponentInChildren(tetriFloorNode); if (floor?.isSimFalling) { return; } // 左右加的力取消掉:松手后清掉横向速度,并恢复旋转控制(避免一直“横滑/锁死旋转”) const rb = this.latestFallingNode.getComponent(RigidBody2D) ?? this.latestFallingNode.getComponentInChildren(RigidBody2D); if (!rb) return; if (rb.type !== ERigidBody2DType.Dynamic) return; const vel = rb.linearVelocity; if (vel.x !== 0) { rb.linearVelocity = new Vec2(0, vel.y); } rb.fixedRotation = false; // console.log( // `[TouchPush][END] node=${this.latestFallingNode.name} fixedRot=${rb.fixedRotation} ` + // `v=(${rb.linearVelocity.x.toFixed(1)},${rb.linearVelocity.y.toFixed(1)}) angV=${rb.angularVelocity.toFixed(1)} ` + // `g=${rb.gravityScale} linD=${rb.linearDamping} angD=${rb.angularDamping} type=${rb.type}` // ); } /**由外部设置当前最新下落的方块(会同时刷新冷却计时) */ public setLatestFallingNode(node: Node | null) { this.latestFallingNode = node?.isValid ? node : null; this._latestFallingSetMs = Date.now(); if (this.latestFallingNode) { this.invalidateStackTopCache(); } } /**当前镜头对应的视觉缩放(1=正常,<1=镜头拉远后视觉变小);优先用实时 ortho,与画面一致 */ public getVisualZoomScale(): number { if (this._gameCamera?.isValid && this._cameraBaseOrthoHeight > 0) { const h = this._gameCamera.orthoHeight; if (h > 0) { return Math.max(0.05, Math.min(1, this._cameraBaseOrthoHeight / h)); } } return Math.max(0.05, Math.min(1, this._scaleTarget || 1)); } private _getRenderGameCamera(): Camera | null { const cam = this._gameCamera?.isValid ? this._gameCamera : (gg.ui?.GameCamera ?? null); return cam?.isValid ? cam : null; } private _getUiCamera(): Camera | null { const cam = gg.ui?.UICamera ?? null; return cam?.isValid ? cam : null; } /** * UI 世界坐标 → 游戏渲染相机世界坐标。 * 双相机:触摸在 UICamera,叠塔画面在 GameCamera(堆高改 ortho + 抬 Y)。 * 正交相机:game = gCam + (ui - uCam) * (gOrtho / uOrtho); * 旧公式误用 gCam 当 UI 中心,堆高后 Y 会偏飞。 */ public uiWorldToGameWorld(uiWorldX: number, uiWorldY: number, out?: Vec3): Vec3 { const result = out ?? new Vec3(); const gameCam = this._getRenderGameCamera(); const uiCam = this._getUiCamera(); if (gameCam && uiCam) { const uH = uiCam.orthoHeight; const gH = gameCam.orthoHeight; if (uH > 0 && gH > 0) { const scale = gH / uH; const gPos = gameCam.node.worldPosition; const uPos = uiCam.node.worldPosition; result.set( gPos.x + (uiWorldX - uPos.x) * scale, gPos.y + (uiWorldY - uPos.y) * scale, 0, ); return result; } // ortho 异常时走屏幕桥接,并用游戏平面深度校正 z const screen = new Vec3(); uiCam.worldToScreen(new Vec3(uiWorldX, uiWorldY, 0), screen); const ref = new Vec3(); gameCam.worldToScreen(new Vec3(0, 0, 0), ref); screen.z = ref.z; gameCam.screenToWorld(screen, result); return result; } const zoom = this.getVisualZoomScale(); if (!Number.isFinite(zoom) || zoom <= 0 || Math.abs(zoom - 1) < 0.0001) { result.set(uiWorldX, uiWorldY, 0); return result; } const gPos = gameCam?.node?.worldPosition; result.set( (gPos?.x ?? 0) + (uiWorldX - (gPos?.x ?? 0)) / zoom, (gPos?.y ?? 0) + (uiWorldY - (gPos?.y ?? 0)) / zoom, 0, ); return result; } /**把 UI 体系下的 worldX 换算到游戏相机渲染体系下的 worldX */ public uiWorldXToGameWorldX(uiWorldX: number): number { return this.uiWorldToGameWorld(uiWorldX, 0).x; } /**把 UI 体系下的 worldY 换算到游戏相机渲染体系下的 worldY */ public uiWorldYToGameWorldY(uiWorldY: number): number { return this.uiWorldToGameWorld(0, uiWorldY).y; } /** 游戏世界 → UI 世界(锤子特效等 UI 节点定位;与 uiWorldToGameWorld 互逆) */ public gameWorldToUiWorld(gameWorldX: number, gameWorldY: number, out?: Vec3): Vec3 { const result = out ?? new Vec3(); const gameCam = this._getRenderGameCamera(); const uiCam = this._getUiCamera(); if (gameCam && uiCam) { const uH = uiCam.orthoHeight; const gH = gameCam.orthoHeight; if (uH > 0 && gH > 0) { const scale = uH / gH; const gPos = gameCam.node.worldPosition; const uPos = uiCam.node.worldPosition; result.set( uPos.x + (gameWorldX - gPos.x) * scale, uPos.y + (gameWorldY - gPos.y) * scale, 0, ); return result; } const screen = new Vec3(); gameCam.worldToScreen(new Vec3(gameWorldX, gameWorldY, 0), screen); const ref = new Vec3(); uiCam.worldToScreen(new Vec3(0, 0, 0), ref); screen.z = ref.z; uiCam.screenToWorld(screen, result); return result; } result.set(gameWorldX, gameWorldY, 0); return result; } /** 子树内所有 PolygonCollider2D 在世界系的 X 最小/最大(与 tetriCardItem 引导宽度算法一致) */ private _getPolygonWorldMinMaxX(root: Node): { minX: number; maxX: number } | null { const polys = root.getComponentsInChildren(PolygonCollider2D); if (!polys?.length) return null; let minX = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; const wv = new Vec3(); const lv = new Vec3(); const wm = new Mat4(); for (const poly of polys) { const owner = poly.node; owner.getWorldMatrix(wm); const offset = (poly as any).offset as Vec2 | undefined; const ox = offset?.x ?? 0; const oy = offset?.y ?? 0; const pts = poly.points ?? []; for (const p of pts) { lv.set(p.x + ox, p.y + oy, 0); Vec3.transformMat4(wv, lv, wm); if (wv.x < minX) minX = wv.x; if (wv.x > maxX) maxX = wv.x; } } if (!Number.isFinite(minX) || !Number.isFinite(maxX)) return null; return { minX, maxX }; } /** 足迹按列宽折算后的半宽(世界坐标),与引导竖条宽度一致 */ private _getPieceFootprintHalfWidthWorld(pieceRoot: Node): number | null { const cell = Math.max(1, this.blockHeight); const mm = this._getPolygonWorldMinMaxX(pieceRoot); if (!mm) return null; const cols = Math.max(1, Math.round((mm.maxX - mm.minX) / cell)); return cols * cell * 0.5; } /** 从已有堆叠/台面采样格子相位,避免场景整体 X 偏移时仍按 world=0 对齐 */ private _sampleSupportMinLeftWorldX(): number | null { let best: number | null = null; const considerRoot = (table: Node | null) => { if (!table?.isValid) return; const mmRoot = this._getPolygonWorldMinMaxX(table); if (mmRoot && (best == null || mmRoot.minX < best)) best = mmRoot.minX; for (let i = 0; i < table.children.length; i++) { const ch = table.children[i]; if (!ch?.isValid) continue; const mm = this._getPolygonWorldMinMaxX(ch); if (mm && (best == null || mm.minX < best)) best = mm.minX; } }; considerRoot(this._tetrisTable); considerRoot(this._floorTaizi); return best; } private _getHorizontalGridPhaseWorldX(): number { const cell = Math.max(1, this.blockHeight); const ref = this._sampleSupportMinLeftWorldX(); if (ref == null || !Number.isFinite(ref)) return 0; let r = ref % cell; if (r < 0) r += cell; return r; } /** * 放置前吸附:使足迹左边缘落在 blockHeight 网格上(相位与场上已有块一致)。 */ public snapPlaceCenterWorldX(centerWorldX: number, pieceRoot: Node | null): number { if (!this.placeSnapHorizontalGrid || !pieceRoot?.isValid) return centerWorldX; if (gg?.data?.doc?.chapter !== 1) return centerWorldX; const halfW = this._getPieceFootprintHalfWidthWorld(pieceRoot); if (halfW == null || !(halfW > 0)) return centerWorldX; const cell = Math.max(1, this.blockHeight); const left = centerWorldX - halfW; const phase = this._getHorizontalGridPhaseWorldX(); const snappedLeft = phase + Math.round((left - phase) / cell) * cell; return snappedLeft + halfW; } /** 与白线几何遍历一致:写入 BattleCore 堆叠米数(供竞速段数等逻辑使用) */ private _syncBattleStackHeightMeters(stackTopWorldYMax: number, groundWorldYMax: number, curScale: number): void { const battle = gg.game?.CurentBattle; if (!battle || battle.TetraMap !== this) return; const scale = Number.isFinite(curScale) && curScale > 0 ? curScale : 1; const blockH = this.blockHeight > 0 ? this.blockHeight : 36; const heightWorld = Math.max(0, stackTopWorldYMax - groundWorldYMax); const metersRaw = heightWorld / (blockH * scale); battle.TetriStackHeightMeters = Math.max(0, Math.round(metersRaw)); battle.TetriStackHeightMetersRaw = metersRaw; } /** * 重算白线/镜头缩放目标,并同步 BattleCore 堆叠高度 + 通知 UI 高度尺。 * 无「高度线」节点时仍会更新堆叠数值与侧栏 UI。 */ public refreshHighLineFromStack(): void { this._updateHighLine(); } /** 离地 0 的世界 Y(开战锁定;地面视觉反缩放不影响高度尺/白线) */ private _getGroundHeightBaselineWorldY(): number { if (Number.isFinite(this._groundHeightBaselineWorldY)) return this._groundHeightBaselineWorldY; const ground = this._groundTable ?? this.node.find('地面'); if (!ground?.isValid) return 0; const gut = ground.getComponent(UITransform); return gut ? gut.getBoundingBoxToWorld().yMax : ground.worldPosition.y; } _updateHighLine(): void { const battle = gg.game?.CurentBattle; if (!this._tetrisTable?.isValid || !this._gameRoot?.isValid) { if (battle?.TetraMap === this) { battle.TetriStackHeightMeters = 0; battle.TetriStackHeightMetersRaw = 0; } GEvent.Ins.emit(GEvent.TetriStackHeightNeedRefresh); return; } const gameRootWorldY = this._gameRoot.worldPosition.y; const curScale = this._gameRoot.scale.x || 1; const groundWorldYMax = this._getGroundHeightBaselineWorldY(); const stackTopWorldYMax = this._resolveStackTopWorldYMax(groundWorldYMax); const stackTopChanged = !Number.isFinite(this._committedStackTopWorldYMax) || Math.abs(stackTopWorldYMax - this._committedStackTopWorldYMax) > 0.5; if (!stackTopChanged) { return; } this._committedStackTopWorldYMax = stackTopWorldYMax; this._syncBattleStackHeightMeters(stackTopWorldYMax, groundWorldYMax, curScale); GEvent.Ins.emit(GEvent.TetriStackHeightNeedRefresh); if (!this._highLine?.isValid) return; // 新规则:白线始终保持在“堆顶上方 2 格” // 规则按“格子数”但以地面 world 为基准(地面不缩放) const offsetWorld = this.blockHeight * this.highLineStackGapBlocks; const minBaseWorld = this.blockHeight * this.highLineMinBaseBlocks; // 白线希望的位置:堆顶之上 2 格;并且不低于离地 10 格(最小值) // 不再做顶部封顶,确保白线始终跟随到“堆顶+2格” const desiredWorldY = Math.max(stackTopWorldYMax + offsetWorld, groundWorldYMax + minBaseWorld); const clampedWorldY = desiredWorldY; // 目标 worldY -> GameRoot 本地 y(白线在 GameRoot 下,需换算) const newLocalY = (clampedWorldY - gameRootWorldY) / curScale; this._highLineTargetLocalY = newLocalY; // 缩放规则改为“看白线位置”: // 白线升到一定高度后即开始拉远,避免白线明显上移但镜头仍不变。 const lineBlocks = (clampedWorldY - groundWorldYMax) / this.blockHeight; const zoomStartBlocks = Math.max(this.highLineOffsetBlocks, this.highLineMaxBlocks - this.highLineTopGapBlocks); // 默认 max(10,18)=18 if (lineBlocks <= zoomStartBlocks) { this._scaleTarget = 1; } else { const s = zoomStartBlocks / lineBlocks; this._scaleTarget = Math.max(0.05, Math.min(1, s)); } if (this._gameCamera?.isValid && this._cameraBaseOrthoHeight > 0) { const halfH = this._designHalfHeight > 0 ? this._designHalfHeight : (gg.ui.height / 2); const zoom = Math.max(0.05, this._scaleTarget || 1); const uiOrtho = gg.ui?.UICamera?.orthoHeight; const gameToUi = this._cameraBaseOrthoHeight / Math.max(1, halfH); this._cameraTargetOrthoHeight = (uiOrtho > 0 ? uiOrtho * gameToUi : this._cameraBaseOrthoHeight) / zoom; this._cameraTargetY = (this._cameraTargetOrthoHeight - halfH) / 2; this._cameraTargetZ = 1000; } // 用当前相机缩放补偿白线宽度 this._applyHighLineScaleForZoom(this._scaleTarget); } private _worldYMaxOfTetrisChild(child: Node): number { let worldYMax = child.worldPosition.y; const uts = child.getComponentsInChildren(UITransform); if (uts?.length) { for (let i = 0; i < uts.length; i++) { const yMax = uts[i].getBoundingBoxToWorld().yMax; if (yMax > worldYMax) worldYMax = yMax; } } return worldYMax; } /** 单块落稳归档后:全量重算堆顶,避免增量缓存把下落中高度锁死 */ private _noteBlockStackTop(_child: Node): void { this.invalidateStackTopCache(); } private _resolveStackTopWorldYMax(groundWorldYMax: number): number { if (!this._stackTopCacheDirty && this._cachedStackTopWorldYMax >= groundWorldYMax) { return this._cachedStackTopWorldYMax; } let stackTopWorldYMax = groundWorldYMax; if (this._tetrisTable?.isValid) { for (const child of this._tetrisTable.children) { if (!this.shouldTetrisChildCountForStackHeight(child)) continue; const worldYMax = this._worldYMaxOfTetrisChild(child); if (worldYMax > stackTopWorldYMax) stackTopWorldYMax = worldYMax; } } this._cachedStackTopWorldYMax = stackTopWorldYMax; this._stackTopCacheDirty = false; return stackTopWorldYMax; } public getPathShortestTarget(startPos: Vec3): Node { const n1 = find('目标点/wallTarget1', this.node); return n1; } getTargetBox(): PolygonCollider2D { const box = find('目标点/wallTarget1/boxAttck', this.node).getComponent(PolygonCollider2D); return box; } /**遍历 _tetrisTable 的子节点,找到 weaponId 的方块(可能多个)*/ getTetriWeaponCarrier(weaponId: number): tetriWeaponCarrier[] { // 遍历 _tetrisTable 的子节点,找到 weaponId 的方块(可能多个) const out: tetriWeaponCarrier[] = []; if (!this._tetrisTable?.isValid) return out; if (!Number.isFinite(weaponId) || weaponId <= 0) return out; const findParentTetriNode = (start: Node | null): tetriNode | null => { let cur: Node | null = start; while (cur?.isValid) { const t = cur.getComponent(tetriNode); if (t) return t; cur = cur.parent; } return null; }; for (const root of this._tetrisTable.children) { if (!root?.isValid) continue; // 一个方块根下理论上只会有一个 carrier,但用 getComponentsInChildren 更鲁棒(prefab 层级变动不怕) const carriers = root.getComponentsInChildren(tetriWeaponCarrier) ?? []; if (!carriers.length) continue; for (const c of carriers) { if (!c?.node?.isValid) continue; // 找到 weaponId 对应的载体 if (c._curentWeaponId !== weaponId) continue; // 判断方块是否可以攻击:必须已落稳 & 节点存在 const t = findParentTetriNode(c.node); if (!t?.node?.isValid) continue; if (!t.shouldCountTowardWeaponStack()) continue; out.push(c); } } return out; } /** * 锤子/拆块后唤醒叠塔物理:把 Static 冻结块解冻为 Dynamic,避免支撑被拆后上方悬空。 * - 敲掉列正上方(水平 AABB 重叠):一律解冻,即使右侧仍有无重力砖托着——否则悬臂 Static 无法倾倒 * - 其他块:仅当下方没有「横向重叠的无重力板托底」时才解冻(避免穿板) * @param minWorldY 只处理不低于此世界 Y 的块(敲掉点略减一点容差) * @param excludeNode 即将销毁的节点(用于取敲掉列 AABB,且不参与托底判定) */ public wakeFrozenStackPhysics(minWorldY?: number, excludeNode?: Node | null): void { if (!this._tetrisTable?.isValid) return; // 销毁前先缓存敲掉块包围盒,延迟帧 exclude 已没了仍要用「敲掉列」判定悬臂倾覆 const removedAabb = this._captureNodeWorldAabb(excludeNode); this._wakeFrozenStackBySupport(minWorldY, excludeNode, removedAabb); this.scheduleOnce(() => { if (!this._tetrisTable?.isValid) return; this._wakeFrozenStackBySupport(minWorldY, null, removedAabb); }, 0); } private _captureNodeWorldAabb(node: Node | null | undefined): Rect | null { if (!node?.isValid) return null; return this._unionWorldAabbEnabledPolysUnder(node) ?? this._unionWorldAabbFloorPrep(node); } /** 两 AABB 水平是否重叠(可外扩 pad,覆盖擦边支撑) */ private _aabbHorizOverlap(a: Rect, b: Rect, padX: number = 0): boolean { return !(a.x + a.width < b.x - padX || b.x + b.width < a.x - padX); } /** * 敲点以上需要解冻的普通块。 */ private _wakeFrozenStackBySupport( minWorldY: number | undefined, excludeNode: Node | null | undefined, removedAabb: Rect | null, ): void { if (!this._tetrisTable?.isValid) return; const cut = minWorldY ?? Number.NEGATIVE_INFINITY; const padX = Math.max(12, (this.blockHeight || 36) * 0.35); const nodes = this._tetrisTable.getComponentsInChildren(tetriNode) ?? []; for (let i = 0; i < nodes.length; i++) { const t = nodes[i]; if (!t?.node?.isValid) continue; if (t.node.getComponent(tetriFloorNode)) continue; if (excludeNode && t.node === excludeNode) continue; const y = t.node.worldPosition.y; if (y < cut) continue; const blockAabb = this._unionWorldAabbEnabledPolysUnder(t.node) ?? this._unionWorldAabbFloorPrep(t.node); // 落在「被敲掉那一列」正上方:必须解冻(半边只剩普通块托着也要能倾倒) const inRemovedColumn = !!( removedAabb && blockAabb && this._aabbHorizOverlap(blockAabb, removedAabb, padX) ); if (!inRemovedColumn && this._hasNoGravityFloorSupportAboveCut(t.node, cut, excludeNode ?? null)) { continue; } if (t.isPhysicsFrozen) { // 锤子列:给足倾覆时间,避免水平块解冻后立刻又冻回 Static t.unfreezePhysicsForImpact(2200); continue; } const rb = t.node.getComponent(RigidBody2D) ?? t.node.getComponentInChildren(RigidBody2D); if (rb?.isValid && rb.type === ERigidBody2DType.Dynamic) { t.suppressSettledFreezeForMs(2200); } } } /** * 方块下方是否仍有已钉住无重力板托底(需水平 AABB 重叠,避免侧面砖台误判)。 */ private _hasNoGravityFloorSupportAboveCut(blockRoot: Node, cutY: number, excludeNode?: Node | null): boolean { const blockAabb = this._unionWorldAabbEnabledPolysUnder(blockRoot) ?? this._unionWorldAabbFloorPrep(blockRoot); if (!blockAabb) return false; const floors = this._tetrisTable.getComponentsInChildren(tetriFloorNode) ?? []; const skin = 10; const maxGap = 28; // 锤子敲空后的明显空隙不算「仍托着」 for (let i = 0; i < floors.length; i++) { const f = floors[i]; if (!f?.node?.isValid || f.node === excludeNode) continue; if (!f.isSettled) continue; const floorAabb = this._unionWorldAabbEnabledPolysUnder(f.node) ?? this._unionWorldAabbFloorPrep(f.node); if (!floorAabb) continue; const floorTop = floorAabb.y + floorAabb.height; const blockBottom = blockAabb.y; // 板顶面不高于敲点:对「敲掉中间后是否仍托住」无意义 if (floorTop <= cutY) continue; // 板须在块下方 if (floorTop > blockBottom + skin) continue; // 空隙过大 = 支撑已被敲掉 if (blockBottom - floorTop > maxGap) continue; // 水平无重叠则不是托底(侧面砖台不能挡中间悬空块解冻) if (!this._aabbHorizOverlap(blockAabb, floorAabb, 0)) continue; return true; } return false; } /** 堆顶世界 Y(供分层冻结判断活跃带) */ public getStackTopWorldYMax(): number { return this._resolveStackTopWorldYMax(this._getGroundHeightBaselineWorldY()); } private _worldYMinOfTetrisChild(child: Node): number { let worldYMin = child.worldPosition.y; const uts = child.getComponentsInChildren(UITransform); if (uts?.length) { for (let i = 0; i < uts.length; i++) { const yMin = uts[i].getBoundingBoxToWorld().yMin; if (yMin < worldYMin) worldYMin = yMin; } } return worldYMin; } //获取_tetrisTable的子节点数量 getTetriNodeCount(): number { if (!this._tetrisTable?.isValid) return 0; return this._tetrisTable.children.length; } /** 堆顶/高度统计:仅 TetrisTable 上已落稳归档的块 */ public shouldTetrisChildCountForStackHeight(child: Node | null): boolean { if (!child?.isValid || !child.active) return false; if (!this._tetrisTable?.isValid || child.parent !== this._tetrisTable) return false; const floor = child.getComponent(tetriFloorNode) ?? child.getComponentInChildren(tetriFloorNode); if (floor) return floor.isSettled && floor.hasStackSupportStable(); const tetri = child.getComponent(tetriNode) ?? child.getComponentInChildren(tetriNode); if (tetri) return tetri.shouldCountTowardStackHeight(); return false; } //初始章节TetrisTable上有方块添加对应的角色和武器 initChapterTetrisTable() { const battleCubeList = gg.data.table.getTableList(TableNames.BattleCube); if (this._tetrisTable?.isValid && this._tetrisTable.children.length > 0 && gg.data.doc.chapter == 1) { let tetriConf = battleCubeList.find(item => item.id == 10); //左边方块配置 let tetriConf2 = battleCubeList.find(item => item.id == 6); //右边方块配置 // console.log('tetriConf', tetriConf); // console.log('tetriConf2', tetriConf2); let Tetris_att_J = this._tetrisTable.getChildByName('Tetris_att_J'); const jNode = Tetris_att_J.getOrAddComponent(tetriNode); jNode.setConfig(tetriConf2); Tetris_att_J.getOrAddComponent(tetriWeaponCarrier).setConfig(tetriConf2, false, true); jNode.addWeapon(); let Tetris_att_L = this._tetrisTable.getChildByName('Tetris_att_L'); const lNode = Tetris_att_L.getOrAddComponent(tetriNode); lNode.setConfig(tetriConf); Tetris_att_L.getOrAddComponent(tetriWeaponCarrier).setConfig(tetriConf, false, true); lNode.addWeapon(); let Tetris_floor_7d = this._tetrisTable.getChildByName('Tetris_floor_7d'); let tetriConf3 = battleCubeList.find(item => item.id == 37); const floor7d = Tetris_floor_7d.getOrAddComponent(tetriFloorNode); floor7d.setConfig(tetriConf3); floor7d.markAwaitingScriptFallStart(); gg.game.IsPlayingGuideStory = true this.scheduleOnce(() => { const cam = gg.ui.GameCamera; //gg.ui.GameCamera.orthoHeight = gg.ui.GameCamera.orthoHeight -300 //播放镜头动画 if (cam?.isValid && cam.node?.isValid) { // 新手引导镜头:轻微拉近并下移,再回到原位 const baseH = cam.orthoHeight; const basePos = new Vec3(cam.node.position.x, cam.node.position.y, cam.node.position.z); const zoomInH = Math.max(50, baseH - 300); const yOffset = -120; // 防止同一目标上 tween 叠加 Tween.stopAllByTarget(cam); Tween.stopAllByTarget(cam.node); tween(cam) .to(1, { orthoHeight: zoomInH }, { easing: 'quadInOut' }) .call(() => { if(this.showQiPaoEffect){ this.showQiPaoEffect() } //创建两个引导怪物怪物被击杀不计算死亡数量 gg.game?.CurentBattle?.spawnMonster(101, 8, 2); gg.game?.CurentBattle?.spawnMonster(101, 7, 2); }) .delay(2.0) .call(() => { if(Tetris_att_J && Tetris_att_L && Tetris_att_J.isValid && Tetris_att_L.isValid){ Tetris_att_J.active = true; Tetris_att_L.active = true; Tetris_att_J.getComponent(RigidBody2D).enabled = true; Tetris_att_L.getComponent(RigidBody2D).enabled = true; Tetris_att_J.getOrAddComponent(tetriWeaponCarrier).setIsShootCan(true); Tetris_att_L.getOrAddComponent(tetriWeaponCarrier).setIsShootCan(true); } }) .delay(1.0) .call(()=>{ if(Tetris_floor_7d && Tetris_floor_7d.isValid){ Tetris_floor_7d.active = true; Tetris_floor_7d.getOrAddComponent(tetriFloorNode).beginPlacementFall(); } }) .start(); } }, 0.1) } else { // 再进第一章:引导预设块可能已隐藏但碰撞体仍在 _tetrisTable,会挡脚本下落;直接销毁 if (this._tetrisTable?.isValid) { gg.game.IsPlayingGuideStory = false this._tetrisTable.getChildByName('Tetris_att_J')?.destroy(); this._tetrisTable.getChildByName('Tetris_att_L')?.destroy(); this._tetrisTable.getChildByName('Tetris_floor_7d')?.destroy(); } } } showQiPaoEffect() { const wallTarget1 = find('目标点/wallTarget1', this.node); if(!wallTarget1 || !wallTarget1.isValid){ return; } let qipao = wallTarget1.getChildByName('气泡文字'); let meinv = wallTarget1.getChildByName('美女'); if (qipao?.isValid) { if(this.timeHit){ clearTimeout(this.timeHit); } meinv.getComponent(sp.Skeleton).setAnimation(0, '说话', true) this.meinvAnimState = '说话'; qipao.active = true; qipao.scale = v3(0, 0, 0); tween(qipao) .to(0.5, { scale: v3(0.55, 0.55, 0.55) }, { easing: 'quadInOut' }) .delay(3.0) .call(() => { this.hideQiPaoEffect(); this.showMeinvIdleEffect(); }) .start(); } } hideQiPaoEffect() { const wallTarget1 = find('目标点/wallTarget1', this.node); let qipao = wallTarget1.getChildByName('气泡文字'); if (qipao?.isValid) { qipao.active = false; } } /** * 获取高度线在与底部卡牌相同的 UI_2D 体系下、相对本 tetriMap 根节点的本地 Y。 * 白线由游戏相机(GameCamera)渲染,随 ortho/位移缩放;不能对「游戏世界坐标」直接 UICamera.convertToUINode, * 否则会按 UI 相机误投影,镜头拉远后 Y 会严重偏高。正确做法:GameCamera.worldToScreen → UICamera.screenToWorld → 转节点本地。 */ getHeightLinePosition(): number { if (!this.node?.isValid) return this._highLineTargetLocalY; const wp = this._highLine?.isValid ? this._highLine.worldPosition.clone() : (() => { const gr = this._gameRoot; if (!gr?.isValid) return null; const ls = gr.worldScale; return new Vec3( gr.worldPosition.x, gr.worldPosition.y + this._highLineTargetLocalY * ls.y, gr.worldPosition.z ); })(); if (!wp) return this._highLineTargetLocalY; const gameCam = this._gameCamera?.isValid ? this._gameCamera : (gg.ui?.GameCamera ?? null); const uiCam = gg.ui?.UICamera; const ut = this.node.getComponent(UITransform); if (!gameCam?.isValid || !uiCam?.isValid || !ut?.isValid) { return wp.y; } const screen = new Vec3(); gameCam.worldToScreen(wp, screen); const uiWorld = new Vec3(); uiCam.screenToWorld(screen, uiWorld); const local = new Vec3(); ut.convertToNodeSpaceAR(uiWorld, local); return local.y; } //美女待机 showMeinvIdleEffect() { if (this.meinvAnimState === '胜利') return; const wallTarget1 = find('目标点/wallTarget1', this.node); if(!wallTarget1 || !wallTarget1.isValid){ return; } let meinv = wallTarget1.getChildByName('美女'); if(meinv?.isValid){ this.meinvAnimState = '待机'; meinv.getComponent(sp.Skeleton).setAnimation(0, '待机', true) } } _onUpdateWallHp(){ //方块回血 this._updateWallFountainDisplay() } /** * 100%~75%:喷泉;75%~50%:喷泉2;50%~25%:喷泉3;25%~0%:喷泉4。 * 档位切换时:将要显示的一层从透明淡入,原先显示的一层同时淡出至 0 再隐藏(交叉淡入淡出)。 */ private _updateWallFountainDisplay() { const battle = gg.game?.CurentBattle; const wallTarget1 = find('目标点/wallTarget1', this.node); if (!battle || !wallTarget1?.isValid) return; const maxHp = Math.max(1, battle.WallMaxHp); const ratio = battle.CurentWallHp / maxHp; let tier = 3; if (ratio >= 0.75) tier = 0; else if (ratio >= 0.5) tier = 1; else if (ratio >= 0.25) tier = 2; const names = ['喷泉', '喷泉2', '喷泉3', '喷泉4']; const newNode = find(names[tier], wallTarget1); // 节点尚未挂好时不写 _lastFountainTier,避免「假同步」后永远不再刷新(偶现喷泉不显示) if (!newNode?.isValid) return; const duration = 2; // 首次进入战斗 / 重置后:直接按档位点亮,避免与其它初始化时序竞态 if (this._lastFountainTier < 0 || this._lastFountainTier > 3) { for (const name of names) { const n = find(name, wallTarget1); if (n?.isValid && n !== newNode) { Tween.stopAllByTarget(n); n.active = false; n.opacity = 0; } } Tween.stopAllByTarget(newNode); newNode.active = true; newNode.opacity = 255; this._lastFountainTier = tier; return; } // 档位不变:若当前档喷泉被误隐藏或透明度异常(tween 被 stop、异步回调顺序等),补一次淡入 if (tier === this._lastFountainTier) { if (newNode.active && newNode.opacity >= 64) return; newNode.active = true; Tween.stopAllByTarget(newNode); const from = newNode.opacity < 1 ? 0 : Math.min(newNode.opacity, 200); newNode.opacity = from; tween(newNode).to(0.35, { opacity: 255 }, { easing: 'quadOut' }).start(); return; } const oldNode = find(names[this._lastFountainTier], wallTarget1); if (oldNode?.isValid) { Tween.stopAllByTarget(oldNode); tween(oldNode).to(duration, { opacity: 0 }, { easing: 'quadIn' }).call(() => { if (oldNode?.isValid) oldNode.active = false; }).start(); } newNode.active = true; newNode.opacity = 100; Tween.stopAllByTarget(newNode); tween(newNode).to(duration, { opacity: 255 }, { easing: 'quadOut' }).start(); this._lastFountainTier = tier; } _onPlayerHit(){ if (gg.game?.CurentBattle?.IsGameOver) return; this.showMeinvBeHitEffect(); if(this.timeHit){ clearTimeout(this.timeHit); } // 喷泉状态:随城墙血量分档淡入淡出(受击时刷新) this._updateWallFountainDisplay(); this.timeHit = setTimeout(() => { this.showMeinvIdleEffect(); }, 2000); } //美女受击动画 showMeinvBeHitEffect() { if (this.meinvAnimState === '胜利') return; const wallTarget1 = find('目标点/wallTarget1', this.node); if(!wallTarget1 || !wallTarget1.isValid){ return; } let meinv = wallTarget1.getChildByName('美女'); if(meinv?.isValid && this.meinvAnimState != '受击'){ this.meinvAnimState = '受击'; meinv.getComponent(sp.Skeleton).setAnimation(0, '受击', true) } } //美女胜利动画 showMeinvWinEffect() { const wallTarget1 = find('目标点/wallTarget1', this.node); if(!wallTarget1 || !wallTarget1.isValid){ return; } if(this.timeHit){ clearTimeout(this.timeHit); } let meinv = wallTarget1.getChildByName('美女'); if(meinv?.isValid){ this.meinvAnimState = '胜利'; meinv.getComponent(sp.Skeleton).setAnimation(0, '胜利', true) } } //初始状态喷泉和美女 initState() { const wallTarget1 = find('目标点/wallTarget1', this.node); if (!wallTarget1 || !wallTarget1.isValid) { return; } this._lastFountainTier = -1; this.showMeinvIdleEffect(); this._updateWallFountainDisplay(); } }