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.
2383 lines
108 KiB
2383 lines
108 KiB
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<tetriNode>();
|
|
/** 堆顶 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<Node>();
|
|
/**同一对方块在销毁前可能触发多次 BEGIN_CONTACT,用 key 去重避免重复升级 */
|
|
private _mergeOnceKeys = new Set<string>();
|
|
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<T>(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<tetriNode>();
|
|
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<Node>();
|
|
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<ITableBattleCube>(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();
|
|
}
|
|
} |