消消方块阵换皮表情
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

637 lines
22 KiB

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<string>();
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<void> {
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;
}
}