| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- import { describe, it, expect } from 'vitest';
- import fc from 'fast-check';
- import {
- checkBallWallCollision,
- checkBallBlockCollision,
- checkBallItemCollision,
- resolveBallBlockCollision,
- reflectWall
- } from '../src/systems/Physics.js';
- import { Ball } from '../src/entities/Ball.js';
- import { Block } from '../src/entities/Block.js';
- import { BallItem, LineClearItem } from '../src/entities/Item.js';
- describe('Physics', () => {
- // ---- checkBallWallCollision ----
- describe('checkBallWallCollision', () => {
- it('returns "left" when ball touches left wall', () => {
- const ball = { x: 8, y: 100, radius: 8 };
- expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
- });
- it('returns "right" when ball touches right wall', () => {
- const ball = { x: 392, y: 100, radius: 8 };
- expect(checkBallWallCollision(ball, 400, 600)).toBe('right');
- });
- it('returns "top" when ball touches top wall', () => {
- const ball = { x: 200, y: 8, radius: 8 };
- expect(checkBallWallCollision(ball, 400, 600)).toBe('top');
- });
- it('returns null when ball is in the middle', () => {
- const ball = { x: 200, y: 300, radius: 8 };
- expect(checkBallWallCollision(ball, 400, 600)).toBeNull();
- });
- it('returns "left" when ball is past left wall', () => {
- const ball = { x: 3, y: 100, radius: 8 };
- expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
- });
- it('prioritizes left over top when in corner', () => {
- const ball = { x: 5, y: 5, radius: 8 };
- expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
- });
- });
- // ---- checkBallBlockCollision ----
- describe('checkBallBlockCollision', () => {
- it('returns hit:false when no overlap', () => {
- const ball = new Ball(50, 50, 8);
- const block = new Block(3, 3, 5, 40); // far away
- const result = checkBallBlockCollision(ball, block);
- expect(result.hit).toBe(false);
- expect(result.side).toBeNull();
- });
- it('detects top collision', () => {
- // Place ball just above the block, overlapping slightly from top
- const block = new Block(0, 1, 5, 40);
- const blockRect = block.getRect();
- // Ball center just above block top, overlapping by 2px
- const ball = new Ball(blockRect.x + 20, blockRect.y - 6, 8);
- const result = checkBallBlockCollision(ball, block);
- expect(result.hit).toBe(true);
- expect(result.side).toBe('top');
- });
- it('detects bottom collision', () => {
- const block = new Block(0, 1, 5, 40);
- const blockRect = block.getRect();
- // Ball center just below block bottom, overlapping by 2px
- const ball = new Ball(blockRect.x + 20, blockRect.y + blockRect.height + 6, 8);
- const result = checkBallBlockCollision(ball, block);
- expect(result.hit).toBe(true);
- expect(result.side).toBe('bottom');
- });
- it('detects left collision', () => {
- const block = new Block(2, 2, 5, 40);
- const blockRect = block.getRect();
- // Ball center just left of block, overlapping by 2px
- const ball = new Ball(blockRect.x - 6, blockRect.y + 20, 8);
- const result = checkBallBlockCollision(ball, block);
- expect(result.hit).toBe(true);
- expect(result.side).toBe('left');
- });
- it('detects right collision', () => {
- const block = new Block(2, 2, 5, 40);
- const blockRect = block.getRect();
- // Ball center just right of block, overlapping by 2px
- const ball = new Ball(blockRect.x + blockRect.width + 6, blockRect.y + 20, 8);
- const result = checkBallBlockCollision(ball, block);
- expect(result.hit).toBe(true);
- expect(result.side).toBe('right');
- });
- });
- // ---- checkBallItemCollision ----
- describe('checkBallItemCollision', () => {
- it('returns true when ball overlaps item', () => {
- const ball = new Ball(10, 10, 8);
- const item = new BallItem(0, 0, 40);
- expect(checkBallItemCollision(ball, item)).toBe(true);
- });
- it('returns false when ball does not overlap item', () => {
- const ball = new Ball(200, 200, 8);
- const item = new BallItem(0, 0, 40);
- expect(checkBallItemCollision(ball, item)).toBe(false);
- });
- // Feature: ball-block-breaker, Property 14: 道具碰撞不改变球方向
- // **Validates: Requirements 6.2**
- it('item collision does not change ball velocity', () => {
- fc.assert(
- fc.property(
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
- fc.constantFrom('ball', 'lineClear'),
- (vx, vy, itemType) => {
- // Place ball at item center so it overlaps
- const item = itemType === 'ball'
- ? new BallItem(0, 0, 40)
- : new LineClearItem(0, 0, 40);
- const itemRect = item.getRect();
- const ball = new Ball(
- itemRect.x + itemRect.width / 2,
- itemRect.y + itemRect.height / 2,
- 8
- );
- ball.vx = vx;
- ball.vy = vy;
- // Perform collision detection
- const collided = checkBallItemCollision(ball, item);
- // Collision should be detected
- expect(collided).toBe(true);
- // Ball velocity must remain unchanged
- expect(ball.vx).toBe(vx);
- expect(ball.vy).toBe(vy);
- }
- ),
- { numRuns: 100 }
- );
- });
- });
- // ---- resolveBallBlockCollision ----
- describe('resolveBallBlockCollision', () => {
- it('reflects y for top collision', () => {
- const ball = new Ball(100, 100, 8);
- ball.vx = 5;
- ball.vy = 3;
- resolveBallBlockCollision(ball, 'top');
- expect(ball.vx).toBe(5);
- expect(ball.vy).toBe(-3);
- });
- it('reflects y for bottom collision', () => {
- const ball = new Ball(100, 100, 8);
- ball.vx = 5;
- ball.vy = -3;
- resolveBallBlockCollision(ball, 'bottom');
- expect(ball.vx).toBe(5);
- expect(ball.vy).toBe(3);
- });
- it('reflects x for left collision', () => {
- const ball = new Ball(100, 100, 8);
- ball.vx = 5;
- ball.vy = 3;
- resolveBallBlockCollision(ball, 'left');
- expect(ball.vx).toBe(-5);
- expect(ball.vy).toBe(3);
- });
- it('reflects x for right collision', () => {
- const ball = new Ball(100, 100, 8);
- ball.vx = -5;
- ball.vy = 3;
- resolveBallBlockCollision(ball, 'right');
- expect(ball.vx).toBe(5);
- expect(ball.vy).toBe(3);
- });
- // Feature: ball-block-breaker, Property 8: 方块碰撞反弹正确性
- // **Validates: Requirements 4.3, 4.4, 4.5**
- it('block collision reflects correct axis and preserves speed magnitude', () => {
- fc.assert(
- fc.property(
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0),
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0),
- fc.constantFrom('top', 'bottom', 'left', 'right'),
- (vx, vy, side) => {
- const ball = new Ball(100, 100, 8);
- ball.vx = vx;
- ball.vy = vy;
- const originalSpeed = Math.sqrt(vx * vx + vy * vy);
- resolveBallBlockCollision(ball, side);
- if (side === 'top' || side === 'bottom') {
- // vy should be negated, vx unchanged
- expect(ball.vy).toBe(-vy);
- expect(ball.vx).toBe(vx);
- } else {
- // left or right: vx should be negated, vy unchanged
- expect(ball.vx).toBe(-vx);
- expect(ball.vy).toBe(vy);
- }
- // Speed magnitude preserved
- const newSpeed = Math.sqrt(ball.vx ** 2 + ball.vy ** 2);
- expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001);
- }
- ),
- { numRuns: 100 }
- );
- });
- });
- // ---- reflectWall ----
- describe('reflectWall', () => {
- it('negates vx for left wall', () => {
- const result = reflectWall({ vx: 5, vy: 3 }, 'left');
- expect(result).toEqual({ vx: -5, vy: 3 });
- });
- it('negates vx for right wall', () => {
- const result = reflectWall({ vx: -5, vy: 3 }, 'right');
- expect(result).toEqual({ vx: 5, vy: 3 });
- });
- it('negates vy for top wall', () => {
- const result = reflectWall({ vx: 5, vy: -3 }, 'top');
- expect(result).toEqual({ vx: 5, vy: 3 });
- });
- it('returns unchanged velocity for unknown wall', () => {
- const result = reflectWall({ vx: 5, vy: 3 }, 'bottom');
- expect(result).toEqual({ vx: 5, vy: 3 });
- });
- it('is a pure function - does not mutate input', () => {
- const velocity = { vx: 5, vy: 3 };
- reflectWall(velocity, 'left');
- expect(velocity).toEqual({ vx: 5, vy: 3 });
- });
- // Feature: ball-block-breaker, Property 3: 墙壁反射正确性
- // **Validates: Requirements 3.2**
- it('wall reflection preserves speed magnitude', () => {
- fc.assert(
- fc.property(
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
- fc.constantFrom('left', 'right', 'top'),
- (vx, vy, wall) => {
- const reflected = reflectWall({ vx, vy }, wall);
- const originalSpeed = Math.sqrt(vx * vx + vy * vy);
- const newSpeed = Math.sqrt(reflected.vx ** 2 + reflected.vy ** 2);
- expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001);
- }
- ),
- { numRuns: 100 }
- );
- });
- it('wall reflection negates correct component and preserves the other', () => {
- fc.assert(
- fc.property(
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
- fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
- fc.constantFrom('left', 'right', 'top'),
- (vx, vy, wall) => {
- const reflected = reflectWall({ vx, vy }, wall);
- if (wall === 'left' || wall === 'right') {
- expect(reflected.vx).toBe(-vx);
- expect(reflected.vy).toBe(vy);
- } else {
- // wall === 'top'
- expect(reflected.vx).toBe(vx);
- expect(reflected.vy).toBe(-vy);
- }
- }
- ),
- { numRuns: 100 }
- );
- });
- });
- });
|