game.test.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143
  1. import { describe, it, expect, beforeEach, vi } from 'vitest';
  2. import fc from 'fast-check';
  3. import { Game } from '../src/Game.js';
  4. import { Ball } from '../src/entities/Ball.js';
  5. import { GameState, GameMode, BALL_SPEED, BALL_RADIUS, LAUNCH_INTERVAL, GRID_COLS, GRID_GAP, TOP_PADDING_ROWS, FIXED_TIMESTEP } from '../src/constants.js';
  6. /**
  7. * Create a minimal mock canvas for testing
  8. */
  9. function createMockCanvas() {
  10. const canvas = {
  11. width: 375,
  12. height: 667,
  13. style: { width: '', height: '' },
  14. parentElement: { clientWidth: 375, clientHeight: 667 },
  15. getContext: () => ({
  16. save: vi.fn(),
  17. restore: vi.fn(),
  18. scale: vi.fn(),
  19. setTransform: vi.fn(),
  20. clearRect: vi.fn(),
  21. fillRect: vi.fn(),
  22. fillText: vi.fn(),
  23. beginPath: vi.fn(),
  24. arc: vi.fn(),
  25. fill: vi.fn(),
  26. stroke: vi.fn(),
  27. moveTo: vi.fn(),
  28. lineTo: vi.fn(),
  29. setLineDash: vi.fn(),
  30. translate: vi.fn(),
  31. fillStyle: '',
  32. strokeStyle: '',
  33. lineWidth: 1,
  34. font: '',
  35. textAlign: '',
  36. textBaseline: '',
  37. globalAlpha: 1,
  38. }),
  39. addEventListener: vi.fn(),
  40. removeEventListener: vi.fn(),
  41. getBoundingClientRect: () => ({ left: 0, top: 0, width: 375, height: 667 }),
  42. };
  43. return canvas;
  44. }
  45. describe('Game', () => {
  46. let canvas;
  47. let game;
  48. beforeEach(() => {
  49. // Mock window for devicePixelRatio
  50. if (typeof globalThis.window === 'undefined') {
  51. globalThis.window = { devicePixelRatio: 1 };
  52. } else {
  53. globalThis.window.devicePixelRatio = 1;
  54. }
  55. // Mock performance.now for animations
  56. if (typeof globalThis.performance === 'undefined') {
  57. globalThis.performance = { now: () => Date.now() };
  58. }
  59. canvas = createMockCanvas();
  60. game = new Game(canvas, GameMode.CLASSIC);
  61. });
  62. describe('constructor', () => {
  63. it('initializes with correct default state', () => {
  64. expect(game.state).toBe(GameState.AIMING);
  65. expect(game.round).toBe(1);
  66. expect(game.ballCount).toBe(1);
  67. expect(game.pendingBalls).toBe(0);
  68. expect(game.balls).toEqual([]);
  69. expect(game.mode).toBe(GameMode.CLASSIC);
  70. });
  71. it('calculates blockSize from canvas width', () => {
  72. const expectedBlockSize = (375 - GRID_GAP * (GRID_COLS + 1)) / GRID_COLS;
  73. expect(game.blockSize).toBeCloseTo(expectedBlockSize, 2);
  74. });
  75. it('sets board dimensions', () => {
  76. expect(game.boardWidth).toBe(375);
  77. expect(game.boardHeight).toBe(667);
  78. });
  79. it('creates subsystems', () => {
  80. expect(game.renderer).toBeDefined();
  81. expect(game.inputHandler).toBeDefined();
  82. expect(game.boardManager).toBeDefined();
  83. });
  84. });
  85. describe('start()', () => {
  86. it('generates first row of blocks', () => {
  87. game.start();
  88. const blocks = game.boardManager.getBlocks();
  89. expect(blocks.length).toBeGreaterThan(0);
  90. });
  91. it('enables input after start', () => {
  92. game.inputHandler.disable();
  93. game.start();
  94. // InputHandler._enabled should be true after start
  95. expect(game.inputHandler._enabled).toBe(true);
  96. });
  97. });
  98. describe('update() - state machine', () => {
  99. it('does nothing in AIMING state', () => {
  100. game.state = GameState.AIMING;
  101. game.update(16);
  102. expect(game.state).toBe(GameState.AIMING);
  103. });
  104. it('does nothing in GAME_OVER state', () => {
  105. game.state = GameState.GAME_OVER;
  106. game.update(16);
  107. expect(game.state).toBe(GameState.GAME_OVER);
  108. });
  109. it('calls nextRound in ROUND_END state', () => {
  110. game.start();
  111. game.state = GameState.ROUND_END;
  112. const initialRound = game.round;
  113. game.update(16);
  114. // After nextRound, round should increment and state should change
  115. expect(game.round).toBe(initialRound + 1);
  116. // State should be AIMING, SLIDING_DOWN, or GAME_OVER
  117. expect([GameState.AIMING, GameState.SLIDING_DOWN, GameState.GAME_OVER]).toContain(game.state);
  118. });
  119. });
  120. describe('launch logic', () => {
  121. it('transitions from LAUNCHING to RUNNING after all balls launched', () => {
  122. game.start();
  123. game.launchAngle = 90;
  124. game.state = GameState.LAUNCHING;
  125. game.launchIndex = 0;
  126. game.launchTimer = 0;
  127. game.ballCount = 1;
  128. game.balls = [];
  129. // Need enough fixed-timestep ticks for launchTimer to exceed LAUNCH_INTERVAL
  130. const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
  131. game.update(ticksNeeded * FIXED_TIMESTEP);
  132. // After launching 1 ball, should transition to RUNNING
  133. expect(game.state).toBe(GameState.RUNNING);
  134. expect(game.balls.length).toBe(1);
  135. });
  136. it('launches balls at correct intervals', () => {
  137. game.start();
  138. game.launchAngle = 90;
  139. game.state = GameState.LAUNCHING;
  140. game.launchIndex = 0;
  141. game.launchTimer = 0;
  142. game.ballCount = 3;
  143. game.balls = [];
  144. // Enough ticks for first ball launch
  145. const ticksForOneLaunch = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
  146. game.update(ticksForOneLaunch * FIXED_TIMESTEP);
  147. expect(game.balls.length).toBe(1);
  148. // Another interval for second ball
  149. game.update(ticksForOneLaunch * FIXED_TIMESTEP);
  150. expect(game.balls.length).toBe(2);
  151. });
  152. it('sets correct velocity on launched balls', () => {
  153. game.start();
  154. game.launchAngle = 90; // straight up
  155. game.state = GameState.LAUNCHING;
  156. game.launchIndex = 0;
  157. game.launchTimer = 0;
  158. game.ballCount = 1;
  159. game.balls = [];
  160. const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
  161. game.update(ticksNeeded * FIXED_TIMESTEP);
  162. const ball = game.balls[0];
  163. const rad = 90 * Math.PI / 180;
  164. expect(ball.vx).toBeCloseTo(BALL_SPEED * Math.cos(rad), 5);
  165. expect(ball.vy).toBeCloseTo(-BALL_SPEED * Math.sin(rad), 5);
  166. });
  167. });
  168. describe('running state - round end detection', () => {
  169. it('transitions to ROUND_END when all balls are inactive', () => {
  170. game.start();
  171. game.state = GameState.RUNNING;
  172. // Create a ball that is already inactive
  173. const { Ball } = require('../src/entities/Ball.js');
  174. const ball = new Ball(100, 600, BALL_RADIUS);
  175. ball.active = false;
  176. game.balls = [ball];
  177. // Need at least one fixed-timestep tick
  178. game.update(FIXED_TIMESTEP);
  179. expect(game.state).toBe(GameState.ROUND_END);
  180. });
  181. it('stays in RUNNING when some balls are still active', () => {
  182. game.start();
  183. game.state = GameState.RUNNING;
  184. const { Ball } = require('../src/entities/Ball.js');
  185. const ball1 = new Ball(100, 300, BALL_RADIUS);
  186. ball1.vx = 0;
  187. ball1.vy = 5;
  188. ball1.active = true;
  189. const ball2 = new Ball(100, 600, BALL_RADIUS);
  190. ball2.active = false;
  191. game.balls = [ball1, ball2];
  192. game.update(16);
  193. expect(game.state).toBe(GameState.RUNNING);
  194. });
  195. });
  196. describe('nextRound()', () => {
  197. it('adds pending balls to ball count', () => {
  198. game.start();
  199. game.pendingBalls = 3;
  200. game.ballCount = 2;
  201. game.nextRound();
  202. expect(game.ballCount).toBe(5);
  203. expect(game.pendingBalls).toBe(0);
  204. });
  205. it('increments round number', () => {
  206. game.start();
  207. const initialRound = game.round;
  208. game.nextRound();
  209. expect(game.round).toBe(initialRound + 1);
  210. });
  211. it('transitions to AIMING state', () => {
  212. game.start();
  213. game.state = GameState.ROUND_END;
  214. game.nextRound();
  215. // nextRound now transitions to SLIDING_DOWN first
  216. if (game.state === GameState.SLIDING_DOWN) {
  217. // Force slide animation to complete
  218. game._slideStartTime = performance.now() - game._slideDuration - 1;
  219. game._updateSlideDown();
  220. }
  221. // Should be AIMING unless game over
  222. if (game.state !== GameState.GAME_OVER) {
  223. expect(game.state).toBe(GameState.AIMING);
  224. }
  225. });
  226. it('enables input after round transition', () => {
  227. game.start();
  228. game.inputHandler.disable();
  229. game.nextRound();
  230. // Force slide animation to complete
  231. if (game.state === GameState.SLIDING_DOWN) {
  232. game._slideStartTime = performance.now() - game._slideDuration - 1;
  233. game._updateSlideDown();
  234. }
  235. if (game.state !== GameState.GAME_OVER) {
  236. expect(game.inputHandler._enabled).toBe(true);
  237. }
  238. });
  239. });
  240. describe('addPendingBall()', () => {
  241. it('increments pending balls', () => {
  242. expect(game.pendingBalls).toBe(0);
  243. game.addPendingBall();
  244. expect(game.pendingBalls).toBe(1);
  245. game.addPendingBall();
  246. expect(game.pendingBalls).toBe(2);
  247. });
  248. });
  249. describe('clearRow() and clearColumn()', () => {
  250. it('delegates clearRow to boardManager', () => {
  251. game.start();
  252. const spy = vi.spyOn(game.boardManager, 'clearRow').mockReturnValue([]);
  253. game.clearRow(2);
  254. expect(spy).toHaveBeenCalledWith(2);
  255. });
  256. it('delegates clearColumn to boardManager', () => {
  257. game.start();
  258. const spy = vi.spyOn(game.boardManager, 'clearColumn').mockReturnValue([]);
  259. game.clearColumn(3);
  260. expect(spy).toHaveBeenCalledWith(3);
  261. });
  262. });
  263. describe('restart()', () => {
  264. it('resets game state to initial values', () => {
  265. game.start();
  266. game.round = 5;
  267. game.ballCount = 10;
  268. game.pendingBalls = 3;
  269. game.state = GameState.GAME_OVER;
  270. game.restart();
  271. expect(game.state).toBe(GameState.AIMING);
  272. expect(game.round).toBe(1);
  273. expect(game.ballCount).toBe(1);
  274. expect(game.pendingBalls).toBe(0);
  275. expect(game.balls).toEqual([]);
  276. });
  277. it('generates new blocks after restart', () => {
  278. game.start();
  279. game.restart();
  280. const blocks = game.boardManager.getBlocks();
  281. expect(blocks.length).toBeGreaterThan(0);
  282. });
  283. });
  284. describe('gameOver()', () => {
  285. it('sets state to GAME_OVER', () => {
  286. game.gameOver();
  287. expect(game.state).toBe(GameState.GAME_OVER);
  288. });
  289. });
  290. describe('render()', () => {
  291. it('does not throw when rendering in AIMING state', () => {
  292. game.start();
  293. game.state = GameState.AIMING;
  294. expect(() => game.render()).not.toThrow();
  295. });
  296. it('does not throw when rendering in GAME_OVER state', () => {
  297. game.start();
  298. game.state = GameState.GAME_OVER;
  299. expect(() => game.render()).not.toThrow();
  300. });
  301. it('does not throw when rendering with active balls', () => {
  302. game.start();
  303. game.state = GameState.RUNNING;
  304. const { Ball } = require('../src/entities/Ball.js');
  305. const ball = new Ball(100, 300, BALL_RADIUS);
  306. ball.active = true;
  307. game.balls = [ball];
  308. expect(() => game.render()).not.toThrow();
  309. });
  310. });
  311. describe('input disabled during RUNNING', () => {
  312. it('disables input when launching starts', () => {
  313. game.start();
  314. game.inputHandler.enable();
  315. // Simulate _startLaunch
  316. game._startLaunch();
  317. expect(game.inputHandler._enabled).toBe(false);
  318. });
  319. it('re-enables input after round ends', () => {
  320. game.start();
  321. game.inputHandler.disable();
  322. game.nextRound();
  323. // Force slide animation to complete
  324. if (game.state === GameState.SLIDING_DOWN) {
  325. game._slideStartTime = performance.now() - game._slideDuration - 1;
  326. game._updateSlideDown();
  327. }
  328. if (game.state !== GameState.GAME_OVER) {
  329. expect(game.inputHandler._enabled).toBe(true);
  330. }
  331. });
  332. });
  333. describe('collision handling', () => {
  334. it('removes destroyed blocks during physics step', () => {
  335. game.start();
  336. // Add a block with count=1 that will be destroyed on hit
  337. const { Block } = require('../src/entities/Block.js');
  338. const block = new Block(3, 3, 1, game.blockSize);
  339. game.boardManager.blocks = [block];
  340. // Create a ball above the block, moving downward toward it
  341. const { Ball } = require('../src/entities/Ball.js');
  342. const blockRect = block.getRect();
  343. const ball = new Ball(
  344. blockRect.x + blockRect.width / 2,
  345. blockRect.y - BALL_RADIUS - 2,
  346. BALL_RADIUS
  347. );
  348. ball.vx = 0;
  349. ball.vy = BALL_SPEED;
  350. ball.active = true;
  351. game.balls = [ball];
  352. game.state = GameState.RUNNING;
  353. game._stepPhysics();
  354. // Block should be destroyed and removed
  355. expect(game.boardManager.getBlocks().length).toBe(0);
  356. });
  357. });
  358. /**
  359. * Feature: ball-block-breaker, Property 4: 球到底部停止
  360. * **Validates: Requirements 3.3**
  361. *
  362. * 对于任意小球,当其 y 坐标到达或超过 Game_Board 底部时,
  363. * 该球应被标记为非活跃状态(active = false)。
  364. */
  365. describe('Property 4: 球到底部停止', () => {
  366. it('balls at or beyond the bottom become inactive after update', () => {
  367. fc.assert(
  368. fc.property(
  369. // Generate a y position at or beyond the bottom of the board
  370. fc.float({ min: 0, max: 200, noNaN: true, noDefaultInfinity: true }),
  371. // Generate an x position within the board
  372. fc.float({ min: BALL_RADIUS + 1, max: 375 - BALL_RADIUS - 1, noNaN: true, noDefaultInfinity: true }),
  373. // Generate a downward vy (positive = moving down)
  374. fc.float({ min: 1, max: BALL_SPEED, noNaN: true, noDefaultInfinity: true }),
  375. (yOffset, x, vy) => {
  376. const boardHeight = game.boardHeight;
  377. // Place ball at or past the bottom boundary
  378. const y = boardHeight - BALL_RADIUS + yOffset;
  379. const ball = new Ball(x, y, BALL_RADIUS);
  380. ball.vx = 0;
  381. ball.vy = vy;
  382. ball.active = true;
  383. game.state = GameState.RUNNING;
  384. game.balls = [ball];
  385. // Need at least one fixed-timestep tick
  386. game.update(FIXED_TIMESTEP);
  387. // Ball that was at or beyond the bottom should now be inactive
  388. expect(ball.active).toBe(false);
  389. }
  390. ),
  391. { numRuns: 100 }
  392. );
  393. });
  394. });
  395. });
  396. /**
  397. * Feature: ball-block-breaker, Property 5: 所有球到底部则回合结束
  398. * **Validates: Requirements 3.4**
  399. *
  400. * 对于任意球列表,当且仅当所有球的 active 状态均为 false 时,
  401. * 回合应结束(状态转为 ROUND_END)。
  402. */
  403. describe('Property 5: 所有球到底部则回合结束', () => {
  404. let canvas;
  405. let game;
  406. beforeEach(() => {
  407. if (typeof globalThis.window === 'undefined') {
  408. globalThis.window = { devicePixelRatio: 1 };
  409. } else {
  410. globalThis.window.devicePixelRatio = 1;
  411. }
  412. if (typeof globalThis.performance === 'undefined') {
  413. globalThis.performance = { now: () => Date.now() };
  414. }
  415. canvas = createMockCanvas();
  416. game = new Game(canvas, GameMode.CLASSIC);
  417. game.start();
  418. });
  419. it('transitions to ROUND_END when all balls are inactive', () => {
  420. fc.assert(
  421. fc.property(
  422. fc.integer({ min: 1, max: 20 }),
  423. (numBalls) => {
  424. game.state = GameState.RUNNING;
  425. // Create numBalls balls, all inactive
  426. game.balls = Array.from({ length: numBalls }, (_, i) => {
  427. const ball = new Ball(50 + i * 10, game.boardHeight - BALL_RADIUS, BALL_RADIUS);
  428. ball.vx = 0;
  429. ball.vy = 0;
  430. ball.active = false;
  431. return ball;
  432. });
  433. game._updateRunning(16);
  434. expect(game.state).toBe(GameState.ROUND_END);
  435. }
  436. ),
  437. { numRuns: 100 }
  438. );
  439. });
  440. it('stays in RUNNING when at least one ball is still active', () => {
  441. fc.assert(
  442. fc.property(
  443. fc.integer({ min: 1, max: 20 }),
  444. fc.integer({ min: 0, max: 19 }),
  445. (numBalls, activeIndexRaw) => {
  446. const activeIndex = activeIndexRaw % numBalls;
  447. game.state = GameState.RUNNING;
  448. // Create numBalls balls, all inactive except one at safe position
  449. game.balls = Array.from({ length: numBalls }, (_, i) => {
  450. const ball = new Ball(50 + i * 10, 300, BALL_RADIUS);
  451. ball.vx = 0;
  452. ball.vy = 0;
  453. ball.active = (i === activeIndex);
  454. return ball;
  455. });
  456. game._updateRunning(16);
  457. expect(game.state).toBe(GameState.RUNNING);
  458. }
  459. ),
  460. { numRuns: 100 }
  461. );
  462. });
  463. });
  464. /**
  465. * Feature: ball-block-breaker, Property 13: BallItem碰撞效果
  466. * **Validates: Requirements 6.1, 6.3**
  467. *
  468. * 对于任意游戏状态,当小球触碰 BallItem 后,下一轮的球数应比当前多1,
  469. * 且该 BallItem 应被标记为已收集(从面板移除)。
  470. */
  471. describe('Property 13: BallItem碰撞效果', () => {
  472. let canvas;
  473. let game;
  474. beforeEach(() => {
  475. if (typeof globalThis.window === 'undefined') {
  476. globalThis.window = { devicePixelRatio: 1 };
  477. } else {
  478. globalThis.window.devicePixelRatio = 1;
  479. }
  480. if (typeof globalThis.performance === 'undefined') {
  481. globalThis.performance = { now: () => Date.now() };
  482. }
  483. canvas = createMockCanvas();
  484. game = new Game(canvas, GameMode.CLASSIC);
  485. game.start();
  486. });
  487. it('collecting a BallItem increases pendingBalls by 1 and removes the item', () => {
  488. const { BallItem } = require('../src/entities/Item.js');
  489. fc.assert(
  490. fc.property(
  491. // Random grid position for the BallItem
  492. fc.integer({ min: 0, max: GRID_COLS - 1 }),
  493. fc.integer({ min: 1, max: 5 }),
  494. // Random initial pendingBalls count
  495. fc.integer({ min: 0, max: 10 }),
  496. (gridX, gridY, initialPending) => {
  497. // Set up game state
  498. game.state = GameState.RUNNING;
  499. game.pendingBalls = initialPending;
  500. // Create a BallItem at the given grid position
  501. const ballItem = new BallItem(gridX, gridY, game.blockSize);
  502. // Clear existing items and add only our BallItem
  503. game.boardManager.blocks = [];
  504. game.boardManager.items = [ballItem];
  505. // Position a ball overlapping the BallItem (guaranteed collision)
  506. const itemRect = ballItem.getRect();
  507. const ball = new Ball(
  508. itemRect.x + itemRect.width / 2,
  509. itemRect.y + itemRect.height / 2,
  510. BALL_RADIUS
  511. );
  512. ball.vx = 0;
  513. ball.vy = -0.001;
  514. ball.active = true;
  515. game.balls = [ball];
  516. // Run physics step (handles item collisions)
  517. game._stepPhysics();
  518. // pendingBalls should have increased by exactly 1
  519. expect(game.pendingBalls).toBe(initialPending + 1);
  520. // BallItem should be removed from items array
  521. expect(game.boardManager.getItems().length).toBe(0);
  522. // BallItem should be marked as collected
  523. expect(ballItem.collected).toBe(true);
  524. }
  525. ),
  526. { numRuns: 100 }
  527. );
  528. });
  529. });
  530. /**
  531. * Feature: ball-block-breaker, Property 15: LineClearItem碰撞效果
  532. * **Validates: Requirements 6.4, 6.5**
  533. *
  534. * 对于任意方块布局和 LineClearItem 位置,触发后该行或列的所有方块应被移除,
  535. * 且 LineClearItem 本身不被移除。
  536. */
  537. describe('Property 15: LineClearItem碰撞效果', () => {
  538. let canvas;
  539. let game;
  540. beforeEach(() => {
  541. if (typeof globalThis.window === 'undefined') {
  542. globalThis.window = { devicePixelRatio: 1 };
  543. } else {
  544. globalThis.window.devicePixelRatio = 1;
  545. }
  546. if (typeof globalThis.performance === 'undefined') {
  547. globalThis.performance = { now: () => Date.now() };
  548. }
  549. canvas = createMockCanvas();
  550. game = new Game(canvas, GameMode.ENHANCED);
  551. game.start();
  552. });
  553. it('collecting a LineClearItem decrements count of blocks in its row or column by 1', () => {
  554. const { LineClearItem } = require('../src/entities/Item.js');
  555. const { Block } = require('../src/entities/Block.js');
  556. fc.assert(
  557. fc.property(
  558. // LineClearItem grid position
  559. fc.integer({ min: 0, max: GRID_COLS - 1 }),
  560. fc.integer({ min: 1, max: 5 }),
  561. // Direction
  562. fc.constantFrom('horizontal', 'vertical'),
  563. // Generate blocks that won't overlap with the ball position
  564. fc.array(
  565. fc.record({
  566. gridX: fc.integer({ min: 0, max: GRID_COLS - 1 }),
  567. gridY: fc.integer({ min: 0, max: 7 }),
  568. count: fc.integer({ min: 2, max: 20 })
  569. }),
  570. { minLength: 1, maxLength: 20 }
  571. ),
  572. (itemGridX, itemGridY, direction, blockDefs) => {
  573. game.state = GameState.RUNNING;
  574. // Create LineClearItem with explicit direction
  575. const lineClearItem = new LineClearItem(itemGridX, itemGridY, game.blockSize, direction);
  576. // Filter out blocks that would overlap with the item's grid cell
  577. // or are adjacent (ball physics might hit nearby blocks)
  578. const safeBlockDefs = blockDefs.filter(def =>
  579. !(def.gridX === itemGridX && def.gridY === itemGridY) &&
  580. Math.abs(def.gridY - itemGridY) > 1 &&
  581. Math.abs(def.gridX - itemGridX) > 1
  582. );
  583. if (safeBlockDefs.length === 0) return; // skip if no safe blocks
  584. // Create blocks from definitions
  585. const blocks = safeBlockDefs.map(def =>
  586. new Block(def.gridX, def.gridY, def.count, game.blockSize)
  587. );
  588. const originalCounts = blocks.map(b => b.count);
  589. // Set up board
  590. game.boardManager.blocks = [...blocks];
  591. game.boardManager.items = [lineClearItem];
  592. // Position a ball overlapping the LineClearItem for guaranteed collision
  593. const itemRect = lineClearItem.getRect();
  594. const ball = new Ball(
  595. itemRect.x + itemRect.width / 2,
  596. itemRect.y + itemRect.height / 2,
  597. BALL_RADIUS
  598. );
  599. ball.vx = 0;
  600. ball.vy = -0.001;
  601. ball.active = true;
  602. game.balls = [ball];
  603. // Run physics step (handles item collisions)
  604. game._stepPhysics();
  605. // LineClearItem should be marked as collected
  606. expect(lineClearItem.collected).toBe(true);
  607. // LineClearItem should NOT be removed (stays on board)
  608. expect(game.boardManager.getItems()).toContain(lineClearItem);
  609. // Check that affected blocks had count decremented by 1
  610. // (blocks not overlapping with ball, so only clearRow/clearColumn hit them)
  611. for (let i = 0; i < blocks.length; i++) {
  612. const block = blocks[i];
  613. const wasAffected = direction === 'horizontal'
  614. ? block.gridY === itemGridY
  615. : block.gridX === itemGridX;
  616. if (wasAffected && !block.isDestroyed()) {
  617. expect(block.count).toBe(originalCounts[i] - 1);
  618. } else if (!wasAffected) {
  619. expect(block.count).toBe(originalCounts[i]);
  620. }
  621. }
  622. }
  623. ),
  624. { numRuns: 100 }
  625. );
  626. });
  627. });
  628. /**
  629. * Feature: ball-block-breaker, Property 18: 运行中忽略输入
  630. * **Validates: Requirements 9.3**
  631. *
  632. * 对于任意游戏状态为 RUNNING 或 LAUNCHING 时,输入事件不应改变游戏的瞄准角度或触发新的发射。
  633. */
  634. describe('Property 18: 运行中忽略输入', () => {
  635. let canvas;
  636. let game;
  637. beforeEach(() => {
  638. if (typeof globalThis.window === 'undefined') {
  639. globalThis.window = { devicePixelRatio: 1 };
  640. } else {
  641. globalThis.window.devicePixelRatio = 1;
  642. }
  643. if (typeof globalThis.performance === 'undefined') {
  644. globalThis.performance = { now: () => Date.now() };
  645. }
  646. canvas = createMockCanvas();
  647. game = new Game(canvas, GameMode.CLASSIC);
  648. game.start();
  649. });
  650. it('input callbacks do not change aimAngle or trigger launch in RUNNING/LAUNCHING state', () => {
  651. fc.assert(
  652. fc.property(
  653. // Random state: RUNNING or LAUNCHING
  654. fc.constantFrom(GameState.RUNNING, GameState.LAUNCHING),
  655. // Random initial aimAngle
  656. fc.float({ min: 15, max: 165, noNaN: true, noDefaultInfinity: true }),
  657. // Random angle from input event
  658. fc.float({ min: -360, max: 360, noNaN: true, noDefaultInfinity: true }),
  659. (state, initialAimAngle, inputAngle) => {
  660. // Set game to the target state
  661. game.state = state;
  662. game.aimAngle = initialAimAngle;
  663. // Record state before input
  664. const aimAngleBefore = game.aimAngle;
  665. const stateBefore = game.state;
  666. const ballsBefore = game.balls.length;
  667. const eventData = { x: 100, y: 100, angle: inputAngle };
  668. // Trigger all three input callbacks directly
  669. game.inputHandler._onAimStart(eventData);
  670. game.inputHandler._onAimMove(eventData);
  671. game.inputHandler._onAimEnd(eventData);
  672. // aimAngle must not change
  673. expect(game.aimAngle).toBe(aimAngleBefore);
  674. // State must not change (no launch triggered)
  675. expect(game.state).toBe(stateBefore);
  676. // No new balls should be created
  677. expect(game.balls.length).toBe(ballsBefore);
  678. }
  679. ),
  680. { numRuns: 100 }
  681. );
  682. });
  683. });
  684. /**
  685. * Game Over UI Tests
  686. * **Validates: Requirements 7.2, 7.3**
  687. *
  688. * Tests for the game over overlay: showing the overlay with round number,
  689. * restart button functionality, and hiding the overlay on restart.
  690. */
  691. describe('Game Over UI (Requirements 7.2, 7.3)', () => {
  692. let canvas;
  693. let game;
  694. let overlay;
  695. let scoreEl;
  696. let restartBtn;
  697. function createMockCanvasLocal() {
  698. return {
  699. width: 375,
  700. height: 667,
  701. style: { width: '', height: '' },
  702. parentElement: { clientWidth: 375, clientHeight: 667 },
  703. getContext: () => ({
  704. save: vi.fn(),
  705. restore: vi.fn(),
  706. scale: vi.fn(),
  707. setTransform: vi.fn(),
  708. clearRect: vi.fn(),
  709. fillRect: vi.fn(),
  710. fillText: vi.fn(),
  711. beginPath: vi.fn(),
  712. arc: vi.fn(),
  713. fill: vi.fn(),
  714. stroke: vi.fn(),
  715. moveTo: vi.fn(),
  716. lineTo: vi.fn(),
  717. setLineDash: vi.fn(),
  718. translate: vi.fn(),
  719. fillStyle: '',
  720. strokeStyle: '',
  721. lineWidth: 1,
  722. font: '',
  723. textAlign: '',
  724. textBaseline: '',
  725. globalAlpha: 1,
  726. }),
  727. addEventListener: vi.fn(),
  728. removeEventListener: vi.fn(),
  729. getBoundingClientRect: () => ({ left: 0, top: 0, width: 375, height: 667 }),
  730. };
  731. }
  732. beforeEach(() => {
  733. if (typeof globalThis.window === 'undefined') {
  734. globalThis.window = { devicePixelRatio: 1 };
  735. } else {
  736. globalThis.window.devicePixelRatio = 1;
  737. }
  738. if (typeof globalThis.performance === 'undefined') {
  739. globalThis.performance = { now: () => Date.now() };
  740. }
  741. canvas = createMockCanvasLocal();
  742. game = new Game(canvas, GameMode.CLASSIC);
  743. // Create mock DOM elements for game over UI
  744. overlay = { style: { display: 'none' } };
  745. scoreEl = { textContent: '' };
  746. restartBtn = {
  747. _listeners: [],
  748. addEventListener(event, handler) {
  749. this._listeners.push({ event, handler });
  750. },
  751. };
  752. game.setGameOverUI(overlay, scoreEl, restartBtn);
  753. game.start();
  754. });
  755. describe('setGameOverUI()', () => {
  756. it('stores references to DOM elements', () => {
  757. expect(game.gameOverOverlay).toBe(overlay);
  758. expect(game.gameOverScoreEl).toBe(scoreEl);
  759. expect(game.gameOverRestartBtn).toBe(restartBtn);
  760. });
  761. it('binds click handler to restart button', () => {
  762. expect(restartBtn._listeners.length).toBe(1);
  763. expect(restartBtn._listeners[0].event).toBe('click');
  764. });
  765. });
  766. describe('gameOver()', () => {
  767. it('sets state to GAME_OVER', () => {
  768. game.round = 5;
  769. game.gameOver();
  770. expect(game.state).toBe(GameState.GAME_OVER);
  771. });
  772. it('displays the current round number in the score element', () => {
  773. game.round = 7;
  774. game.gameOver();
  775. expect(scoreEl.textContent).toBe('第 7 轮');
  776. });
  777. it('shows the game over overlay', () => {
  778. game.gameOver();
  779. expect(overlay.style.display).toBe('flex');
  780. });
  781. it('works without UI elements set (no errors)', () => {
  782. const game2 = new Game(createMockCanvasLocal(), GameMode.CLASSIC);
  783. expect(() => game2.gameOver()).not.toThrow();
  784. expect(game2.state).toBe(GameState.GAME_OVER);
  785. });
  786. });
  787. describe('restart via button', () => {
  788. it('clicking restart button resets game state', () => {
  789. game.round = 10;
  790. game.gameOver();
  791. // Simulate clicking the restart button
  792. const clickHandler = restartBtn._listeners.find(l => l.event === 'click').handler;
  793. clickHandler();
  794. expect(game.state).toBe(GameState.AIMING);
  795. expect(game.round).toBe(1);
  796. expect(game.ballCount).toBe(1);
  797. });
  798. it('clicking restart button hides the overlay', () => {
  799. game.gameOver();
  800. expect(overlay.style.display).toBe('flex');
  801. const clickHandler = restartBtn._listeners.find(l => l.event === 'click').handler;
  802. clickHandler();
  803. expect(overlay.style.display).toBe('none');
  804. });
  805. });
  806. describe('nextRound triggers gameOver with UI', () => {
  807. it('shows overlay when blocks reach bottom during nextRound', () => {
  808. // Force checkGameOver to return true
  809. vi.spyOn(game.boardManager, 'checkGameOver').mockReturnValue(true);
  810. game.round = 12;
  811. game.nextRound();
  812. // Force slide animation to complete
  813. if (game.state === GameState.SLIDING_DOWN) {
  814. game._slideStartTime = performance.now() - game._slideDuration - 1;
  815. game._updateSlideDown();
  816. }
  817. // round increments before gameOver is called
  818. expect(game.state).toBe(GameState.GAME_OVER);
  819. expect(overlay.style.display).toBe('flex');
  820. expect(scoreEl.textContent).toBe('第 13 轮');
  821. });
  822. });
  823. });
  824. /**
  825. * 集成单元测试 - 任务 11.2
  826. * 测试模式选择后游戏正确初始化,以及完整回合流程
  827. * **Validates: Requirements 1.1-1.4, 3.4, 5.1, 5.2**
  828. */
  829. describe('Integration: Mode selection and game initialization', () => {
  830. beforeEach(() => {
  831. if (typeof globalThis.window === 'undefined') {
  832. globalThis.window = { devicePixelRatio: 1 };
  833. } else {
  834. globalThis.window.devicePixelRatio = 1;
  835. }
  836. if (typeof globalThis.performance === 'undefined') {
  837. globalThis.performance = { now: () => Date.now() };
  838. }
  839. });
  840. it('CLASSIC mode initializes correctly (state=AIMING, round=1, ballCount=1)', () => {
  841. const canvas = createMockCanvas();
  842. const game = new Game(canvas, GameMode.CLASSIC);
  843. expect(game.mode).toBe(GameMode.CLASSIC);
  844. expect(game.state).toBe(GameState.AIMING);
  845. expect(game.round).toBe(1);
  846. expect(game.ballCount).toBe(1);
  847. });
  848. it('ENHANCED mode initializes correctly (state=AIMING, round=1, ballCount=1)', () => {
  849. const canvas = createMockCanvas();
  850. const game = new Game(canvas, GameMode.ENHANCED);
  851. expect(game.mode).toBe(GameMode.ENHANCED);
  852. expect(game.state).toBe(GameState.AIMING);
  853. expect(game.round).toBe(1);
  854. expect(game.ballCount).toBe(1);
  855. });
  856. it('start() generates blocks on the board', () => {
  857. const canvas = createMockCanvas();
  858. const game = new Game(canvas, GameMode.CLASSIC);
  859. game.start();
  860. const blocks = game.boardManager.getBlocks();
  861. expect(blocks.length).toBeGreaterThan(0);
  862. // All initial blocks should be at TOP_PADDING_ROWS
  863. for (const block of blocks) {
  864. expect(block.gridY).toBe(TOP_PADDING_ROWS);
  865. }
  866. });
  867. it('start() in ENHANCED mode generates blocks on the board', () => {
  868. const canvas = createMockCanvas();
  869. const game = new Game(canvas, GameMode.ENHANCED);
  870. game.start();
  871. const blocks = game.boardManager.getBlocks();
  872. expect(blocks.length).toBeGreaterThan(0);
  873. });
  874. });
  875. describe('Integration: Full round flow (launch → collision → round end → new row)', () => {
  876. let canvas;
  877. let game;
  878. beforeEach(() => {
  879. if (typeof globalThis.window === 'undefined') {
  880. globalThis.window = { devicePixelRatio: 1 };
  881. } else {
  882. globalThis.window.devicePixelRatio = 1;
  883. }
  884. if (typeof globalThis.performance === 'undefined') {
  885. globalThis.performance = { now: () => Date.now() };
  886. }
  887. canvas = createMockCanvas();
  888. game = new Game(canvas, GameMode.CLASSIC);
  889. game.start();
  890. });
  891. it('transitions AIMING → LAUNCHING → RUNNING when launching', () => {
  892. expect(game.state).toBe(GameState.AIMING);
  893. // Simulate launch
  894. game.launchAngle = 90;
  895. game.state = GameState.LAUNCHING;
  896. game.launchIndex = 0;
  897. game.launchTimer = 0;
  898. game.balls = [];
  899. // Provide enough fixed-timestep ticks to launch all balls (ballCount=1)
  900. const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
  901. game.update(ticksNeeded * FIXED_TIMESTEP);
  902. expect(game.state).toBe(GameState.RUNNING);
  903. expect(game.balls.length).toBe(1);
  904. expect(game.balls[0].active).toBe(true);
  905. });
  906. it('transitions RUNNING → ROUND_END when all balls become inactive', () => {
  907. game.state = GameState.RUNNING;
  908. // Create a ball that is already inactive (simulating it reached bottom)
  909. const ball = new Ball(game.boardWidth / 2, game.boardHeight - BALL_RADIUS);
  910. ball.active = false;
  911. game.balls = [ball];
  912. game.update(FIXED_TIMESTEP);
  913. expect(game.state).toBe(GameState.ROUND_END);
  914. });
  915. it('ROUND_END triggers nextRound: round increments, new blocks generated, blocks moved down', () => {
  916. // Record initial blocks
  917. const initialBlocks = game.boardManager.getBlocks();
  918. const initialBlockCount = initialBlocks.length;
  919. const initialGridYs = initialBlocks.map(b => b.gridY);
  920. expect(game.round).toBe(1);
  921. // Trigger round end
  922. game.state = GameState.ROUND_END;
  923. game.update(16);
  924. // Round should have incremented (nextRound increments after generating)
  925. if (game.state !== GameState.GAME_OVER) {
  926. expect(game.round).toBe(2);
  927. // Complete slide animation if in SLIDING_DOWN
  928. if (game.state === GameState.SLIDING_DOWN) {
  929. game._slideStartTime = performance.now() - game._slideDuration - 1;
  930. game._updateSlideDown();
  931. }
  932. expect(game.state).toBe(GameState.AIMING);
  933. // Blocks should have been moved down and new ones generated
  934. const newBlocks = game.boardManager.getBlocks();
  935. expect(newBlocks.length).toBeGreaterThan(0);
  936. // There should be blocks at TOP_PADDING_ROWS (newly generated)
  937. const topRowBlocks = newBlocks.filter(b => b.gridY === TOP_PADDING_ROWS);
  938. expect(topRowBlocks.length).toBeGreaterThan(0);
  939. // Original blocks should have moved down by 1 row
  940. const movedBlocks = newBlocks.filter(b => b.gridY > 0);
  941. expect(movedBlocks.length).toBeGreaterThanOrEqual(initialBlockCount);
  942. }
  943. });
  944. it('complete flow: launch → all balls inactive → round end → new round', () => {
  945. const round1Blocks = [...game.boardManager.getBlocks()];
  946. expect(game.round).toBe(1);
  947. // Step 1: Start launching
  948. game.launchAngle = 90;
  949. game.state = GameState.LAUNCHING;
  950. game.launchIndex = 0;
  951. game.launchTimer = 0;
  952. game.balls = [];
  953. // Step 2: Launch all balls
  954. const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
  955. game.update(ticksNeeded * FIXED_TIMESTEP);
  956. expect(game.state).toBe(GameState.RUNNING);
  957. // Step 3: Set all balls to inactive (simulate reaching bottom)
  958. for (const ball of game.balls) {
  959. ball.active = false;
  960. }
  961. // Step 4: Update triggers ROUND_END detection
  962. game.update(FIXED_TIMESTEP);
  963. expect(game.state).toBe(GameState.ROUND_END);
  964. // Step 5: Update triggers nextRound
  965. game.update(16);
  966. if (game.state !== GameState.GAME_OVER) {
  967. expect(game.round).toBe(2);
  968. // Complete slide animation if in SLIDING_DOWN
  969. if (game.state === GameState.SLIDING_DOWN) {
  970. game._slideStartTime = performance.now() - game._slideDuration - 1;
  971. game._updateSlideDown();
  972. }
  973. expect(game.state).toBe(GameState.AIMING);
  974. // New blocks should exist at TOP_PADDING_ROWS
  975. const blocks = game.boardManager.getBlocks();
  976. const topRowBlocks = blocks.filter(b => b.gridY === TOP_PADDING_ROWS);
  977. expect(topRowBlocks.length).toBeGreaterThan(0);
  978. // Original round 1 blocks should now be at TOP_PADDING_ROWS + 1 (moved down)
  979. const row1Blocks = blocks.filter(b => b.gridY === TOP_PADDING_ROWS + 1);
  980. expect(row1Blocks.length).toBeGreaterThanOrEqual(round1Blocks.length);
  981. }
  982. });
  983. });