physics.test.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import { describe, it, expect } from 'vitest';
  2. import fc from 'fast-check';
  3. import {
  4. checkBallWallCollision,
  5. checkBallBlockCollision,
  6. checkBallItemCollision,
  7. resolveBallBlockCollision,
  8. reflectWall
  9. } from '../src/systems/Physics.js';
  10. import { Ball } from '../src/entities/Ball.js';
  11. import { Block } from '../src/entities/Block.js';
  12. import { BallItem, LineClearItem } from '../src/entities/Item.js';
  13. describe('Physics', () => {
  14. // ---- checkBallWallCollision ----
  15. describe('checkBallWallCollision', () => {
  16. it('returns "left" when ball touches left wall', () => {
  17. const ball = { x: 8, y: 100, radius: 8 };
  18. expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
  19. });
  20. it('returns "right" when ball touches right wall', () => {
  21. const ball = { x: 392, y: 100, radius: 8 };
  22. expect(checkBallWallCollision(ball, 400, 600)).toBe('right');
  23. });
  24. it('returns "top" when ball touches top wall', () => {
  25. const ball = { x: 200, y: 8, radius: 8 };
  26. expect(checkBallWallCollision(ball, 400, 600)).toBe('top');
  27. });
  28. it('returns null when ball is in the middle', () => {
  29. const ball = { x: 200, y: 300, radius: 8 };
  30. expect(checkBallWallCollision(ball, 400, 600)).toBeNull();
  31. });
  32. it('returns "left" when ball is past left wall', () => {
  33. const ball = { x: 3, y: 100, radius: 8 };
  34. expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
  35. });
  36. it('prioritizes left over top when in corner', () => {
  37. const ball = { x: 5, y: 5, radius: 8 };
  38. expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
  39. });
  40. });
  41. // ---- checkBallBlockCollision ----
  42. describe('checkBallBlockCollision', () => {
  43. it('returns hit:false when no overlap', () => {
  44. const ball = new Ball(50, 50, 8);
  45. const block = new Block(3, 3, 5, 40); // far away
  46. const result = checkBallBlockCollision(ball, block);
  47. expect(result.hit).toBe(false);
  48. expect(result.side).toBeNull();
  49. });
  50. it('detects top collision', () => {
  51. // Place ball just above the block, overlapping slightly from top
  52. const block = new Block(0, 1, 5, 40);
  53. const blockRect = block.getRect();
  54. // Ball center just above block top, overlapping by 2px
  55. const ball = new Ball(blockRect.x + 20, blockRect.y - 6, 8);
  56. const result = checkBallBlockCollision(ball, block);
  57. expect(result.hit).toBe(true);
  58. expect(result.side).toBe('top');
  59. });
  60. it('detects bottom collision', () => {
  61. const block = new Block(0, 1, 5, 40);
  62. const blockRect = block.getRect();
  63. // Ball center just below block bottom, overlapping by 2px
  64. const ball = new Ball(blockRect.x + 20, blockRect.y + blockRect.height + 6, 8);
  65. const result = checkBallBlockCollision(ball, block);
  66. expect(result.hit).toBe(true);
  67. expect(result.side).toBe('bottom');
  68. });
  69. it('detects left collision', () => {
  70. const block = new Block(2, 2, 5, 40);
  71. const blockRect = block.getRect();
  72. // Ball center just left of block, overlapping by 2px
  73. const ball = new Ball(blockRect.x - 6, blockRect.y + 20, 8);
  74. const result = checkBallBlockCollision(ball, block);
  75. expect(result.hit).toBe(true);
  76. expect(result.side).toBe('left');
  77. });
  78. it('detects right collision', () => {
  79. const block = new Block(2, 2, 5, 40);
  80. const blockRect = block.getRect();
  81. // Ball center just right of block, overlapping by 2px
  82. const ball = new Ball(blockRect.x + blockRect.width + 6, blockRect.y + 20, 8);
  83. const result = checkBallBlockCollision(ball, block);
  84. expect(result.hit).toBe(true);
  85. expect(result.side).toBe('right');
  86. });
  87. });
  88. // ---- checkBallItemCollision ----
  89. describe('checkBallItemCollision', () => {
  90. it('returns true when ball overlaps item', () => {
  91. const ball = new Ball(10, 10, 8);
  92. const item = new BallItem(0, 0, 40);
  93. expect(checkBallItemCollision(ball, item)).toBe(true);
  94. });
  95. it('returns false when ball does not overlap item', () => {
  96. const ball = new Ball(200, 200, 8);
  97. const item = new BallItem(0, 0, 40);
  98. expect(checkBallItemCollision(ball, item)).toBe(false);
  99. });
  100. // Feature: ball-block-breaker, Property 14: 道具碰撞不改变球方向
  101. // **Validates: Requirements 6.2**
  102. it('item collision does not change ball velocity', () => {
  103. fc.assert(
  104. fc.property(
  105. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
  106. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
  107. fc.constantFrom('ball', 'lineClear'),
  108. (vx, vy, itemType) => {
  109. // Place ball at item center so it overlaps
  110. const item = itemType === 'ball'
  111. ? new BallItem(0, 0, 40)
  112. : new LineClearItem(0, 0, 40);
  113. const itemRect = item.getRect();
  114. const ball = new Ball(
  115. itemRect.x + itemRect.width / 2,
  116. itemRect.y + itemRect.height / 2,
  117. 8
  118. );
  119. ball.vx = vx;
  120. ball.vy = vy;
  121. // Perform collision detection
  122. const collided = checkBallItemCollision(ball, item);
  123. // Collision should be detected
  124. expect(collided).toBe(true);
  125. // Ball velocity must remain unchanged
  126. expect(ball.vx).toBe(vx);
  127. expect(ball.vy).toBe(vy);
  128. }
  129. ),
  130. { numRuns: 100 }
  131. );
  132. });
  133. });
  134. // ---- resolveBallBlockCollision ----
  135. describe('resolveBallBlockCollision', () => {
  136. it('reflects y for top collision', () => {
  137. const ball = new Ball(100, 100, 8);
  138. ball.vx = 5;
  139. ball.vy = 3;
  140. resolveBallBlockCollision(ball, 'top');
  141. expect(ball.vx).toBe(5);
  142. expect(ball.vy).toBe(-3);
  143. });
  144. it('reflects y for bottom collision', () => {
  145. const ball = new Ball(100, 100, 8);
  146. ball.vx = 5;
  147. ball.vy = -3;
  148. resolveBallBlockCollision(ball, 'bottom');
  149. expect(ball.vx).toBe(5);
  150. expect(ball.vy).toBe(3);
  151. });
  152. it('reflects x for left collision', () => {
  153. const ball = new Ball(100, 100, 8);
  154. ball.vx = 5;
  155. ball.vy = 3;
  156. resolveBallBlockCollision(ball, 'left');
  157. expect(ball.vx).toBe(-5);
  158. expect(ball.vy).toBe(3);
  159. });
  160. it('reflects x for right collision', () => {
  161. const ball = new Ball(100, 100, 8);
  162. ball.vx = -5;
  163. ball.vy = 3;
  164. resolveBallBlockCollision(ball, 'right');
  165. expect(ball.vx).toBe(5);
  166. expect(ball.vy).toBe(3);
  167. });
  168. // Feature: ball-block-breaker, Property 8: 方块碰撞反弹正确性
  169. // **Validates: Requirements 4.3, 4.4, 4.5**
  170. it('block collision reflects correct axis and preserves speed magnitude', () => {
  171. fc.assert(
  172. fc.property(
  173. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0),
  174. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0),
  175. fc.constantFrom('top', 'bottom', 'left', 'right'),
  176. (vx, vy, side) => {
  177. const ball = new Ball(100, 100, 8);
  178. ball.vx = vx;
  179. ball.vy = vy;
  180. const originalSpeed = Math.sqrt(vx * vx + vy * vy);
  181. resolveBallBlockCollision(ball, side);
  182. if (side === 'top' || side === 'bottom') {
  183. // vy should be negated, vx unchanged
  184. expect(ball.vy).toBe(-vy);
  185. expect(ball.vx).toBe(vx);
  186. } else {
  187. // left or right: vx should be negated, vy unchanged
  188. expect(ball.vx).toBe(-vx);
  189. expect(ball.vy).toBe(vy);
  190. }
  191. // Speed magnitude preserved
  192. const newSpeed = Math.sqrt(ball.vx ** 2 + ball.vy ** 2);
  193. expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001);
  194. }
  195. ),
  196. { numRuns: 100 }
  197. );
  198. });
  199. });
  200. // ---- reflectWall ----
  201. describe('reflectWall', () => {
  202. it('negates vx for left wall', () => {
  203. const result = reflectWall({ vx: 5, vy: 3 }, 'left');
  204. expect(result).toEqual({ vx: -5, vy: 3 });
  205. });
  206. it('negates vx for right wall', () => {
  207. const result = reflectWall({ vx: -5, vy: 3 }, 'right');
  208. expect(result).toEqual({ vx: 5, vy: 3 });
  209. });
  210. it('negates vy for top wall', () => {
  211. const result = reflectWall({ vx: 5, vy: -3 }, 'top');
  212. expect(result).toEqual({ vx: 5, vy: 3 });
  213. });
  214. it('returns unchanged velocity for unknown wall', () => {
  215. const result = reflectWall({ vx: 5, vy: 3 }, 'bottom');
  216. expect(result).toEqual({ vx: 5, vy: 3 });
  217. });
  218. it('is a pure function - does not mutate input', () => {
  219. const velocity = { vx: 5, vy: 3 };
  220. reflectWall(velocity, 'left');
  221. expect(velocity).toEqual({ vx: 5, vy: 3 });
  222. });
  223. // Feature: ball-block-breaker, Property 3: 墙壁反射正确性
  224. // **Validates: Requirements 3.2**
  225. it('wall reflection preserves speed magnitude', () => {
  226. fc.assert(
  227. fc.property(
  228. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
  229. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
  230. fc.constantFrom('left', 'right', 'top'),
  231. (vx, vy, wall) => {
  232. const reflected = reflectWall({ vx, vy }, wall);
  233. const originalSpeed = Math.sqrt(vx * vx + vy * vy);
  234. const newSpeed = Math.sqrt(reflected.vx ** 2 + reflected.vy ** 2);
  235. expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001);
  236. }
  237. ),
  238. { numRuns: 100 }
  239. );
  240. });
  241. it('wall reflection negates correct component and preserves the other', () => {
  242. fc.assert(
  243. fc.property(
  244. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
  245. fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
  246. fc.constantFrom('left', 'right', 'top'),
  247. (vx, vy, wall) => {
  248. const reflected = reflectWall({ vx, vy }, wall);
  249. if (wall === 'left' || wall === 'right') {
  250. expect(reflected.vx).toBe(-vx);
  251. expect(reflected.vy).toBe(vy);
  252. } else {
  253. // wall === 'top'
  254. expect(reflected.vx).toBe(vx);
  255. expect(reflected.vy).toBe(-vy);
  256. }
  257. }
  258. ),
  259. { numRuns: 100 }
  260. );
  261. });
  262. });
  263. });