BallInstance.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { _decorator, Component, Node, Vec2, Vec3, RigidBody2D, CircleCollider2D, find, UITransform } from 'cc';
  2. const { ccclass, property } = _decorator;
  3. @ccclass('BallInstance')
  4. export class BallInstance extends Component {
  5. @property({ tooltip: '基础速度,用于防围困时的冲量大小参考' })
  6. public baseSpeed: number = 60;
  7. @property({ tooltip: '目标速度(不在此组件内强制维持,用于参考)' })
  8. public currentSpeed: number = 60;
  9. @property({ tooltip: '是否启用振荡检测与增强防围困' })
  10. public enableOscillationDetection: boolean = true;
  11. @property({ tooltip: '振荡检测时间窗口(秒)' })
  12. public oscillationTimeWindow: number = 3.0;
  13. @property({ tooltip: '方向改变阈值(次)' })
  14. public directionChangeThreshold: number = 4;
  15. @property({ tooltip: '位置历史记录大小' })
  16. public positionHistorySize: number = 20;
  17. @property({ tooltip: '振荡距离阈值(像素)' })
  18. public oscillationDistanceThreshold: number = 100;
  19. @property({ tooltip: '测试模式(启用日志)' })
  20. public testMode: boolean = true;
  21. // —— 位置检查(实例级调度) ——
  22. @property({ tooltip: '启用位置检查与救援(实例级)' })
  23. public enableBallPositionCheck: boolean = true;
  24. @property({ tooltip: '位置检查间隔(秒)(实例级)' })
  25. public positionCheckInterval: number = 2.0;
  26. private _positionCheckElapsed: number = 0;
  27. // —— 安全区域与安全放置配置(由控制器注入) ——
  28. @property({ tooltip: '与边缘保持的最小距离' })
  29. public edgeOffset: number = 16;
  30. @property({ tooltip: '与方块保持的最小安全距离' })
  31. public safeDistance: number = 36;
  32. @property({ tooltip: '球半径(用于避让计算)' })
  33. public ballRadius: number = 12;
  34. @property({ tooltip: '放置尝试最大次数' })
  35. public maxAttempts: number = 24;
  36. @property({ tooltip: '位置检查边界扩展(像素)' })
  37. public positionCheckBoundaryExtension: number = 24;
  38. @property({ type: Node, tooltip: '已放置方块容器(Canvas/GameLevelUI/GameArea/PlacedBlocks)' })
  39. public placedBlocksContainer: Node | null = null;
  40. private posHistory: Vec2[] = [];
  41. private dirHistory: Vec2[] = [];
  42. private lastDirectionChange: number = 0;
  43. private directionChangeCount: number = 0;
  44. private oscillationAxis: 'horizontal' | 'vertical' | 'none' = 'none';
  45. private oscillationStartTime: number = 0;
  46. update(dt: number) {
  47. if (!this.enableOscillationDetection) return;
  48. const ball = this.node;
  49. if (!ball || !ball.isValid) return;
  50. const rigidBody = ball.getComponent(RigidBody2D);
  51. if (!rigidBody) return;
  52. const currentTime = Date.now() / 1000;
  53. const pos = ball.getWorldPosition();
  54. const vel = rigidBody.linearVelocity;
  55. const dir = new Vec2(vel.x, vel.y).normalize();
  56. // 初始化
  57. if (this.posHistory.length === 0) {
  58. this.lastDirectionChange = currentTime;
  59. this.directionChangeCount = 0;
  60. this.oscillationAxis = 'none';
  61. this.oscillationStartTime = 0;
  62. }
  63. // 位置历史
  64. this.posHistory.push(new Vec2(pos.x, pos.y));
  65. if (this.posHistory.length > this.positionHistorySize) {
  66. this.posHistory.shift();
  67. }
  68. // 方向历史
  69. this.dirHistory.push(dir.clone());
  70. if (this.dirHistory.length > this.positionHistorySize) {
  71. this.dirHistory.shift();
  72. }
  73. // 检测方向改变(点积 < 0 认为显著改变)
  74. if (this.dirHistory.length >= 2) {
  75. const prev = this.dirHistory[this.dirHistory.length - 2];
  76. const dot = Vec2.dot(prev, dir);
  77. if (dot < 0) {
  78. this.directionChangeCount++;
  79. this.lastDirectionChange = currentTime;
  80. if (this.testMode) {
  81. // console.log(`[BallInstance] 方向改变,累计: ${this.directionChangeCount}`);
  82. }
  83. }
  84. }
  85. // 时间窗口外重置
  86. const timeSinceLast = currentTime - this.lastDirectionChange;
  87. if (timeSinceLast > this.oscillationTimeWindow) {
  88. this.directionChangeCount = 0;
  89. this.oscillationAxis = 'none';
  90. return;
  91. }
  92. // 达到方向改变阈值,分析振荡轴向并触发
  93. if (this.directionChangeCount >= this.directionChangeThreshold) {
  94. const axis = this.analyzeOscillationAxis(this.posHistory);
  95. if (axis !== 'none' && this.oscillationAxis === 'none') {
  96. this.oscillationAxis = axis;
  97. this.oscillationStartTime = currentTime;
  98. if (this.testMode) {
  99. console.log(`[BallInstance] 检测到${axis === 'horizontal' ? '水平' : '垂直'}振荡,触发增强防围困`);
  100. }
  101. this.triggerEnhancedAntiTrap(axis);
  102. }
  103. }
  104. // 实例级位置检查调度
  105. if (this.enableBallPositionCheck) {
  106. this._positionCheckElapsed += dt;
  107. if (this._positionCheckElapsed >= this.positionCheckInterval) {
  108. this.checkAndRescueIfNeeded();
  109. this._positionCheckElapsed = 0;
  110. }
  111. }
  112. }
  113. private analyzeOscillationAxis(history: Vec2[]): 'horizontal' | 'vertical' | 'none' {
  114. if (!history || history.length < 10) return 'none';
  115. const recent = history.slice(-10);
  116. let minX = recent[0].x, maxX = recent[0].x;
  117. let minY = recent[0].y, maxY = recent[0].y;
  118. for (const p of recent) {
  119. minX = Math.min(minX, p.x);
  120. maxX = Math.max(maxX, p.x);
  121. minY = Math.min(minY, p.y);
  122. maxY = Math.max(maxY, p.y);
  123. }
  124. const xRange = maxX - minX;
  125. const yRange = maxY - minY;
  126. if (xRange > this.oscillationDistanceThreshold && yRange < this.oscillationDistanceThreshold / 2) {
  127. return 'horizontal';
  128. }
  129. if (yRange > this.oscillationDistanceThreshold && xRange < this.oscillationDistanceThreshold / 2) {
  130. return 'vertical';
  131. }
  132. return 'none';
  133. }
  134. private triggerEnhancedAntiTrap(axis: 'horizontal' | 'vertical') {
  135. const rigidBody = this.node.getComponent(RigidBody2D);
  136. if (!rigidBody) return;
  137. if (axis === 'horizontal') {
  138. const verticalImpulse = (Math.random() - 0.5) * this.baseSpeed * 0.8;
  139. const newVel = new Vec2(rigidBody.linearVelocity.x * 0.3, verticalImpulse);
  140. rigidBody.linearVelocity = newVel;
  141. if (this.testMode) {
  142. console.log(`[BallInstance] 水平振荡 -> 垂直冲量: ${verticalImpulse.toFixed(2)}`);
  143. }
  144. } else {
  145. const horizontalImpulse = (Math.random() - 0.5) * this.baseSpeed * 0.8;
  146. const newVel = new Vec2(horizontalImpulse, rigidBody.linearVelocity.y * 0.3);
  147. rigidBody.linearVelocity = newVel;
  148. if (this.testMode) {
  149. console.log(`[BallInstance] 垂直振荡 -> 水平冲量: ${horizontalImpulse.toFixed(2)}`);
  150. }
  151. }
  152. }
  153. // ---------- Safe Area Logic (migrated) ----------
  154. private getGameBounds(): { left: number; right: number; top: number; bottom: number; gameArea: Node; ui: UITransform } | null {
  155. const gameArea = find('Canvas/GameLevelUI/GameArea');
  156. if (!gameArea) return null;
  157. const ui = gameArea.getComponent(UITransform);
  158. if (!ui) return null;
  159. const wpos = gameArea.worldPosition;
  160. const halfW = ui.width / 2;
  161. const halfH = ui.height / 2;
  162. return {
  163. left: wpos.x - halfW,
  164. right: wpos.x + halfW,
  165. bottom: wpos.y - halfH,
  166. top: wpos.y + halfH,
  167. gameArea,
  168. ui,
  169. };
  170. }
  171. public checkInBounds(): boolean {
  172. const bounds = this.getGameBounds();
  173. if (!bounds) return true;
  174. const ext = this.positionCheckBoundaryExtension;
  175. const pos = this.node.worldPosition;
  176. const inX = pos.x >= (bounds.left - ext) && pos.x <= (bounds.right + ext);
  177. const inY = pos.y >= (bounds.bottom - ext) && pos.y <= (bounds.top + ext);
  178. return inX && inY;
  179. }
  180. public rescueToGameArea(): void {
  181. const bounds = this.getGameBounds();
  182. if (!bounds) return;
  183. this.positionSafely(bounds);
  184. const rigidBody = this.node.getComponent(RigidBody2D);
  185. if (rigidBody) {
  186. const angle = Math.random() * Math.PI * 2;
  187. const dir = new Vec2(Math.cos(angle), Math.sin(angle)).normalize();
  188. rigidBody.linearVelocity = new Vec2(dir.x * this.currentSpeed, dir.y * this.currentSpeed);
  189. }
  190. }
  191. public checkAndRescueIfNeeded(): boolean {
  192. if (this.checkInBounds()) return false;
  193. this.rescueToGameArea();
  194. return true;
  195. }
  196. /**
  197. * 对外公开:在游戏区域内安全随机摆放(考虑方块避让)
  198. */
  199. public placeRandomlyInGameArea(): void {
  200. const bounds = this.getGameBounds();
  201. if (!bounds) return;
  202. this.positionSafely(bounds);
  203. }
  204. private positionSafely(bounds: { left: number; right: number; top: number; bottom: number; gameArea: Node; ui: UITransform; }): void {
  205. const minX = bounds.left + this.ballRadius + this.edgeOffset;
  206. const maxX = bounds.right - this.ballRadius - this.edgeOffset;
  207. const minY = bounds.bottom + this.ballRadius + this.edgeOffset;
  208. const maxY = bounds.top - this.ballRadius - this.edgeOffset;
  209. if (!this.placedBlocksContainer || !this.placedBlocksContainer.isValid) {
  210. this.setRandomPositionDefault(minX, maxX, minY, maxY, bounds);
  211. return;
  212. }
  213. const placedBlocks: Node[] = [];
  214. for (const child of this.placedBlocksContainer.children) {
  215. if (child.name.includes('Block') || child.getChildByName('B1')) {
  216. placedBlocks.push(child);
  217. }
  218. }
  219. if (placedBlocks.length === 0) {
  220. this.setRandomPositionDefault(minX, maxX, minY, maxY, bounds);
  221. return;
  222. }
  223. let attempts = 0;
  224. const maxAttempts = this.maxAttempts;
  225. while (attempts < maxAttempts) {
  226. const rx = Math.random() * (maxX - minX) + minX;
  227. const ry = Math.random() * (maxY - minY) + minY;
  228. const worldPos = new Vec3(rx, ry, 0);
  229. let overlapping = false;
  230. for (const block of placedBlocks) {
  231. const bpos = block.worldPosition;
  232. const dx = rx - bpos.x;
  233. const dy = ry - bpos.y;
  234. const dist = Math.sqrt(dx * dx + dy * dy);
  235. const bt = block.getComponent(UITransform);
  236. const bsize = bt ? Math.max(bt.width, bt.height) / 2 : this.safeDistance;
  237. if (dist < this.ballRadius + bsize + this.safeDistance) {
  238. overlapping = true;
  239. break;
  240. }
  241. }
  242. if (!overlapping) {
  243. const local = bounds.ui.convertToNodeSpaceAR(worldPos);
  244. this.node.setPosition(local);
  245. return;
  246. }
  247. attempts++;
  248. }
  249. // 兜底:底部中心
  250. const cx = (bounds.left + bounds.right) / 2;
  251. const cy = bounds.bottom + this.ballRadius + this.safeDistance;
  252. const fallback = new Vec3(cx, cy, 0);
  253. this.node.setPosition(bounds.ui.convertToNodeSpaceAR(fallback));
  254. }
  255. private setRandomPositionDefault(minX: number, maxX: number, minY: number, maxY: number, bounds: { ui: UITransform }): void {
  256. const rx = Math.random() * (maxX - minX) + minX;
  257. const ry = Math.random() * (maxY - minY) + minY;
  258. const worldPos = new Vec3(rx, ry, 0);
  259. this.node.setPosition(bounds.ui.convertToNodeSpaceAR(worldPos));
  260. }
  261. }