import { _decorator, CCFloat, CircleCollider2D, Collider2D, Component, Contact2DType, Director, director, ERigidBody2DType, IPhysics2DContact, Node, Rect, RigidBody2D, sp, v2, v3, Vec2, Vec3, } from 'cc'; import { Monster } from './Monster'; import { tetriNode } from '../../tetriCard/tetriNode'; import { GBundle } from '../../game/ConfigRes'; import { tetriFloorNode } from '../../tetriCard/tetriFloorNode'; import { BattlePerformance } from './BattlePerformance'; const { ccclass, property } = _decorator; /** 相对原拱高再抬高 30%(控制点更高) */ const ARC_PEAK_HEIGHT_MULT = 1.3; /** * 与 settings/v2/packages/project.json 中 physics.collisionGroups 的 index 10「bossSkill」一致。 * 预制体里 CircleCollider2D 常为 1024,但 RigidBody2D 若误为 2(monster)则与 card 无碰撞,球会穿方块下落。 */ const PHYSICS_GROUP_BOSS_SKILL = 1 << 10; @ccclass('BossSkill') export class BossSkill extends Component { @property(Vec3) offset: Vec3 = v3(0, 0, 0); /** 抛物线飞行总时长(秒) */ @property(CCFloat) flyDuration: number = 0.75; /** * 拱高相对水平位移的比例,越大弧越高。 * 实际拱高 = max(arcMinHeight, 水平距离 * arcHeightRatio) */ @property(CCFloat) arcHeightRatio: number = 0.38; /** 拱高下限(像素),避免很近的目标弧太平 */ @property(CCFloat) arcMinHeight: number = 80; /** 是否根据速度方向旋转节点(2D 常用贴图朝上为默认朝向) */ @property orientToVelocity: boolean = true; /** 与 atan2 结果叠加的转角修正(度),贴图朝 +Y 时常用 -90 */ @property(CCFloat) velocityAngleOffsetDeg: number = -90; monster: Monster; /*爆炸延迟时间 **/ bombDelayTime: number = 0; /*冲击力 **/ bombForce: number = 0; /*爆炸范围 **/ bombRange: number = 0; targetPos: Vec3; isFly: boolean = false; private readonly _flyStart = new Vec3(); private readonly _flyEnd = new Vec3(); private _flyT = 0; private _arcPeak = 0; /** 抛物线结束后是否已切回动态刚体(避免重复调用) */ private _physicsRestored = false; /** 是否已排队延迟恢复刚体(避免物理回调内改 type 报错) */ private _restorePhysicsDeferredPending = false; collider: Collider2D; private _rigidBody: RigidBody2D | null = null; /** 上一帧世界坐标:用于 Kinematic 瞬移时的扫段检测,避免薄/悬浮块隧道效应导致 BEGIN_CONTACT 过晚 */ private readonly _flyPrevWorld = new Vec3(); /** 碰砖冲量:延后到本步物理解算之后执行(避免在 contact 回调里直接改冲量) */ private _brickImpulsePending = false; private _brickImpulseCollider: Collider2D | null = null; private _brickImpulseContact: IPhysics2DContact | null = null; protected onLoad(): void { this.collider = this.getComponent(Collider2D); this._rigidBody = this.node.getComponent(RigidBody2D); } protected onEnable(): void { if (this.collider) { this.collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); } } protected onDisable(): void { director.off(Director.EVENT_AFTER_PHYSICS, this._deferredRestoreDynamicPhysics, this); director.off(Director.EVENT_AFTER_PHYSICS, this._applyDeferredBrickImpulse, this); this.unschedule(this._deferredRestoreDynamicPhysics); this._restorePhysicsDeferredPending = false; this._brickImpulsePending = false; this._brickImpulseCollider = null; this._brickImpulseContact = null; if (this.collider) { this.collider.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); } } setData(monster: Monster, targetPos: Vec3) { this.monster = monster; this.bombDelayTime = Number(monster.Data.skillpar[0]); this.bombForce = Number(monster.Data.skillpar[1]); this.bombRange = Number(monster.Data.skillpar[2]); this.targetPos = v3(targetPos.x + this.offset.x, targetPos.y + this.offset.y, targetPos.z + this.offset.z); // 与方块、地板同属 GameRoot 子树,保证与 card/floor 的 Box2D 碰撞在同一世界里解算; // 挂在 ui_skilllayer 下时常见表现为穿透堆叠与地板。 this._reparentToGameRootForPhysics(); this.startFly(); } /** 挂到俄罗斯地图 GameRoot,并对齐渲染层,避免与 TetrisTable 物理隔离 */ private _reparentToGameRootForPhysics(): void { const gr = gg.game.CurentBattle?.TetraMap?._gameRoot; if (!gr?.isValid || this.node.parent === gr) { return; } this.node.setParent(gr, true); this.node.layer = gr.layer; } protected update(dt: number): void { if (!this.isFly) { return; } const dur = this.flyDuration > 1e-4 ? this.flyDuration : 1e-4; this._flyT += dt / dur; const t = Math.min(1, this._flyT); // 竖直抛物线叠加:t=0,1 时为 0,t=0.5 时最高 const arc = 4 * t * (1 - t) * this._arcPeak; const wx = this._flyStart.x + (this._flyEnd.x - this._flyStart.x) * t; const wy = this._flyStart.y + (this._flyEnd.y - this._flyStart.y) * t + arc; const wz = this._flyStart.z + (this._flyEnd.z - this._flyStart.z) * t; const px = this._flyPrevWorld.x; const py = this._flyPrevWorld.y; const pz = this._flyPrevWorld.z; const sweptHit = this._sweptHitTetriBrickBetween(px, py, pz, wx, wy, wz); if (sweptHit) { this._queueBrickImpulse(sweptHit, null); this._stopFlyAtCurrentWorldPos(); return; } this.node.setWorldPosition(wx, wy, wz); this._flyPrevWorld.set(wx, wy, wz); if (this.orientToVelocity && t < 1) { const dArc = 4 * this._arcPeak * (1 - 2 * t); const vx = this._flyEnd.x - this._flyStart.x; const vy = this._flyEnd.y - this._flyStart.y + dArc; this.node.angle = (Math.atan2(vy, vx) * 180) / Math.PI + this.velocityAngleOffsetDeg; } if (t >= 1) { this.node.setWorldPosition(this._flyEnd); this._flyPrevWorld.set(this._flyEnd); this.isFly = false; this._scheduleRestoreDynamicPhysicsForRoll(); } } /** 炸弹碰撞圆的世界半径(近似) */ private _getBombWorldRadius(): number { const c = this.collider; if (c instanceof CircleCollider2D) { const sx = Math.abs(c.node.worldScale.x); const sy = Math.abs(c.node.worldScale.y); const s = Math.max(sx, sy, 1e-4); return c.radius * s; } const aabb = c?.worldAABB; if (aabb) { return Math.max(aabb.width, aabb.height) * 0.5; } return 18; } private _circleIntersectsRect(cx: number, cy: number, r: number, rect: Rect): boolean { const closestX = Math.min(Math.max(cx, rect.xMin), rect.xMax); const closestY = Math.min(Math.max(cy, rect.yMin), rect.yMax); const dx = cx - closestX; const dy = cy - closestY; return dx * dx + dy * dy <= r * r; } /** * 本帧位移线段上是否穿过「场上俄罗斯块」的任一 2D 碰撞体 AABB(与物理 BEGIN_CONTACT 互补,避免 Kinematic 瞬移隧道)。 * 命中时把节点放在线段上首次命中的位置并返回该碰撞体;未命中返回 null。 */ private _sweptHitTetriBrickBetween( fx: number, fy: number, fz: number, tx: number, ty: number, tz: number, ): Collider2D | null { const table = gg.game.CurentBattle?.TetraMap?._tetrisTable; if (!table?.isValid) { return null; } const r = this._getBombWorldRadius(); const dx = tx - fx; const dy = ty - fy; const len = Math.sqrt(dx * dx + dy * dy); if (len < 0.5) { return null; } const stepPx = Math.max(r * 0.4, 5); const steps = Math.min(40, Math.max(4, Math.ceil(len / stepPx))); const roots = table.children; for (let si = 1; si <= steps; si++) { const u = si / steps; const cx = fx + dx * u; const cy = fy + dy * u; const cz = fz + (tz - fz) * u; for (let i = 0; i < roots.length; i++) { const root = roots[i]; if (!root?.isValid) { continue; } if (!root.getComponent(tetriNode) && !root.getComponent(tetriFloorNode)) { continue; } const cols = root.getComponentsInChildren(Collider2D) ?? []; for (let j = 0; j < cols.length; j++) { const col = cols[j]; if (!col?.enabled || !col.node?.isValid) { continue; } const aabb = col.worldAABB; if (!aabb) { continue; } if (this._circleIntersectsRect(cx, cy, r, aabb)) { this.node.setWorldPosition(cx, cy, cz); return col; } } } } return null; } startFly() { director.off(Director.EVENT_AFTER_PHYSICS, this._deferredRestoreDynamicPhysics, this); director.off(Director.EVENT_AFTER_PHYSICS, this._applyDeferredBrickImpulse, this); this.unschedule(this._deferredRestoreDynamicPhysics); this._brickImpulsePending = false; this._brickImpulseCollider = null; this._brickImpulseContact = null; this._physicsRestored = false; this._restorePhysicsDeferredPending = false; this._flyStart.set(this.node.worldPosition); this._flyEnd.set(this.targetPos); const dx = this._flyEnd.x - this._flyStart.x; const dy = this._flyEnd.y - this._flyStart.y; const dist = Math.sqrt(dx * dx + dy * dy); const basePeak = Math.max(this.arcMinHeight, dist * this.arcHeightRatio); this._arcPeak = basePeak * ARC_PEAK_HEIGHT_MULT; this._flyT = 0; this.isFly = true; this._reparentToGameRootForPhysics(); this._flyPrevWorld.set(this.node.worldPosition); this._configurePhysicsForOverlapOnly(); } /** * 飞行全程:Sensor 不产生挤压/冲量;Kinematic + 清速度,由脚本 setWorldPosition 驱动,避免“撞开”砖块。 */ private _configurePhysicsForOverlapOnly(): void { const rb = this._rigidBody ?? this.node.getComponent(RigidBody2D); if (rb) { rb.enabledContactListener = true; rb.type = ERigidBody2DType.Kinematic; rb.linearVelocity = v2(0, 0); rb.angularVelocity = 0; } if (this.collider) { this.collider.sensor = true; } this._syncBossSkillPhysicsGroup(); } /** 刚体与碰撞体统一为 bossSkill 分组,与 card / floor 的碰撞矩阵一致 */ private _syncBossSkillPhysicsGroup(): void { const rb = this._rigidBody ?? this.node.getComponent(RigidBody2D); const col = this.collider; if (rb) { rb.group = PHYSICS_GROUP_BOSS_SKILL; } if (col) { col.group = PHYSICS_GROUP_BOSS_SKILL; const apply = (col as unknown as { apply?: () => void }).apply; if (typeof apply === 'function') { try { apply.call(col); } catch { /* ignore */ } } } } /** * 抛物线结束后:恢复实体碰撞与动态刚体,受重力下落并允许滚动。 * BEGIN_CONTACT 内不能改 type:放到「当前物理步结束之后」再执行(避免 setType 断言)。 */ private _scheduleRestoreDynamicPhysicsForRoll(): void { if (this._physicsRestored || this._restorePhysicsDeferredPending) { return; } this._restorePhysicsDeferredPending = true; director.once(Director.EVENT_AFTER_PHYSICS, this._deferredRestoreDynamicPhysics, this); } private readonly _deferredRestoreDynamicPhysics = (): void => { this._restorePhysicsDeferredPending = false; if (!this.isValid || !this.node?.isValid || this._physicsRestored) { return; } this._physicsRestored = true; const rb = this._rigidBody ?? this.node.getComponent(RigidBody2D); if (this.collider) { this.collider.sensor = false; if (this.collider instanceof CircleCollider2D) { // 略提高摩擦,便于在方块顶面滚动而不是一直打滑 this.collider.friction = 0.55; } } this._syncBossSkillPhysicsGroup(); if (rb) { rb.enabledContactListener = true; rb.bullet = true; rb.type = ERigidBody2DType.Dynamic; rb.gravityScale = 1; rb.fixedRotation = false; rb.linearVelocity = v2(0, 0); rb.angularVelocity = 0; const anyRb = rb as unknown as { wakeUp?: () => void }; anyRb.wakeUp?.(); } const colApply = this.collider as unknown as { apply?: () => void }; if (typeof colApply?.apply === 'function') { try { colApply.apply.call(this.collider); } catch { /* ignore */ } } }; private _queueBrickImpulse(other: Collider2D, contact: IPhysics2DContact | null): void { if (this.bombForce <= 0 || !other?.isValid) { return; } this._brickImpulseCollider = other; this._brickImpulseContact = contact; if (!this._brickImpulsePending) { this._brickImpulsePending = true; director.once(Director.EVENT_AFTER_PHYSICS, this._applyDeferredBrickImpulse, this); } } private readonly _applyDeferredBrickImpulse = (): void => { this._brickImpulsePending = false; const col = this._brickImpulseCollider; const contact = this._brickImpulseContact; this._brickImpulseCollider = null; this._brickImpulseContact = null; if (!this.isValid || this.bombForce <= 0 || !col?.isValid) { return; } this._applyBombForceImpulse(col, contact); }; /** 从碰撞体节点向上查找砖块刚体(tetri 根上多为 RigidBody2D) */ private _findBrickRigidBody(otherCollider: Collider2D): RigidBody2D | null { let n: Node | null = otherCollider.node; while (n) { const rb = n.getComponent(RigidBody2D); if (rb) { return rb; } n = n.parent; } return null; } /** * 配表 bombForce:对动态砖块施加世界冲量(沿接触法线或炸弹→砖方向),Static/Kinematic 目标跳过。 */ private _applyBombForceImpulse(otherCollider: Collider2D, contact: IPhysics2DContact | null): void { const otherRb = this._findBrickRigidBody(otherCollider); if (!otherRb?.isValid || otherRb.type !== ERigidBody2DType.Dynamic) { return; } const bombPos = this.node.worldPosition; let nx = 0; let ny = 1; const wm = contact?.getWorldManifold?.() as { normal?: Vec2; points?: Vec2[] } | undefined; const nn = wm?.normal; if (nn && nn.x * nn.x + nn.y * nn.y > 1e-8) { nx = nn.x; ny = nn.y; } else { const aabb = otherCollider.worldAABB; const ox = aabb ? (aabb.xMin + aabb.xMax) * 0.5 : otherCollider.node.worldPosition.x; const oy = aabb ? (aabb.yMin + aabb.yMax) * 0.5 : otherCollider.node.worldPosition.y; nx = ox - bombPos.x; ny = oy - bombPos.y; const hl = Math.hypot(nx, ny); if (hl < 1e-4) { nx = 0; ny = 1; } else { nx /= hl; ny /= hl; } } const otherPos = otherCollider.node.worldPosition; const ox = otherPos.x - bombPos.x; const oy = otherPos.y - bombPos.y; if (nx * ox + ny * oy < 0) { nx = -nx; ny = -ny; } const len = Math.hypot(nx, ny) || 1; nx /= len; ny /= len; const pts = wm?.points; const px = pts?.length ? pts[0].x : otherPos.x; const py = pts?.length ? pts[0].y : otherPos.y; otherRb.applyLinearImpulse(v2(nx * this.bombForce, ny * this.bombForce), v2(px, py), true); } /** 碰撞体所在节点向上查找已落稳的俄罗斯方块(砖块) */ private _getSettledTetriBrickFromCollider(other: Collider2D) { let n: Node | null = other?.node; while (n) { const t = n.getComponent(tetriNode) || n.getComponent(tetriFloorNode); if (t ) { return t; } n = n.parent; } return null; } private _stopFlyAtCurrentWorldPos(): void { if (!this.isFly) { return; } this.isFly = false; this._flyT = 1; const wp = this.node.worldPosition; this.node.setWorldPosition(wp.x, wp.y, wp.z); this._scheduleRestoreDynamicPhysicsForRoll(); this.scheduleOnce(() => { this.playExplosionEffect(); }, this.bombDelayTime); } onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { if (!this.isFly) { return; } if (!this._getSettledTetriBrickFromCollider(otherCollider)) { return; } this._queueBrickImpulse(otherCollider, contact); this._stopFlyAtCurrentWorldPos(); } /**播放爆炸效果 */ playExplosionEffect() { const bs = this.getExplosionBlocks(); const destroyedStackRoots = new Set(); for (let i = 0; i < bs.length; i++) { const b = bs[i]; if (!b?.isValid) continue; const t = b.getComponent(tetriNode); if (t?.node?.isValid) { const stackRoot = t.getStackRootUnderTetrisTable(); const key = stackRoot?.uuid ?? t.node.uuid; if (destroyedStackRoots.has(key)) continue; destroyedStackRoots.add(key); t.destroyForRemoval('boss'); continue; } b.destroy(); } if (!BattlePerformance.rollShowCombatVfx(0.55)) { this.scheduleOnce(() => { if (this.node?.isValid) gg.res.putNode(this.node); }, 0.1); return; } void this._spawnExplosionVfx(); } private async _spawnExplosionVfx(): Promise { if (!this.node?.isValid) return; const parent = this.node.parent?.isValid ? this.node.parent : (gg.game?.CurentBattle?.TetraMap?._gameRoot ?? null); const wp = this.node.worldPosition.clone(); this.scheduleOnce(() => { if (this.node?.isValid) gg.res.putNode(this.node); }, 0.1); let n = await gg.res.getNodeAsync('prefab/爆炸/', '砖块爆炸', GBundle.BattleGameWeapon); if (!n?.isValid) return; if (parent?.isValid) { n.setParent(parent, true); } else { gg.res.putNode(n); return; } n.worldPosition = wp; const vfxMul = BattlePerformance.vfxScaleMul(); n.setScale(vfxMul, vfxMul, vfxMul); const sk = n.getComponent(sp.Skeleton); if (!sk) { gg.res.putNode(n); return; } sk.setAnimation(0, '爆炸', false); sk.setCompleteListener(() => { if (n?.isValid) gg.res.putNode(n); }); } /**获取爆炸范围内的所有堆叠的方块 */ getExplosionBlocks(): Node[] { const map = gg.game.CurentBattle?.TetraMap; const table = map?._tetrisTable; if (!table?.isValid) { return []; } const cell = Math.max(1, map.blockHeight ?? 36); const minOverlapArea = cell * cell; const cx = this.node.worldPosition.x; const cy = this.node.worldPosition.y; const r = this.bombRange; if (r <= 0) { return []; } const blocks: Node[] = []; const roots = table.children; for (let i = 0; i < roots.length; i++) { const root = roots[i]; if (!root?.isValid) { continue; } let overlapSum = 0; const cols = root.getComponentsInChildren(Collider2D) ?? []; for (let j = 0; j < cols.length; j++) { const col = cols[j]; if (!col?.enabled || !col.node?.isValid) { continue; } const aabb = col.worldAABB; if (!aabb) { continue; } overlapSum += this._estimateCircleRectOverlapArea(cx, cy, r, aabb); } if (overlapSum >= minOverlapArea) { blocks.push(root); } } return blocks; } /** * 估算圆盘与轴对齐矩形相交面积(像素²),在圆与矩形的公共包围子域内均匀采样。 */ private _estimateCircleRectOverlapArea(cx: number, cy: number, r: number, rect: Rect): number { const r2 = r * r; const x0 = Math.max(rect.xMin, cx - r); const x1 = Math.min(rect.xMax, cx + r); const y0 = Math.max(rect.yMin, cy - r); const y1 = Math.min(rect.yMax, cy + r); if (x1 <= x0 || y1 <= y0) { return 0; } const gw = x1 - x0; const gh = y1 - y0; const GRID = 14; let inside = 0; const inv = 1 / GRID; for (let i = 0; i < GRID; i++) { const px = x0 + (i + 0.5) * inv * gw; const dx = px - cx; const dx2 = dx * dx; for (let j = 0; j < GRID; j++) { const py = y0 + (j + 0.5) * inv * gh; const dy = py - cy; if (dx2 + dy * dy <= r2) { inside++; } } } return (inside / (GRID * GRID)) * gw * gh; } }