feat(turret): RED→GREEN — implement capped rotation tracking hull position (5 tests)
This commit is contained in:
78
src/game/entities/Turret.js
Normal file
78
src/game/entities/Turret.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// src/game/entities/Turret.js — Turret rotation entity
|
||||
|
||||
const TURRET_MAX_ROTATION = 15; // degrees per second
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Normalize an angle to [-180, 180] range.
|
||||
*/
|
||||
function normalizeAngle(deg) {
|
||||
return ((deg + 180) % 360 + 360) % 360 - 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turret — independent rotation, follows mouse cursor, capped at 15°/sec.
|
||||
* Body disabled (no physics). Position tracks hull center each frame.
|
||||
* World angle = hull.angle + turret.angle (additive — turret does NOT rotate
|
||||
* when only the hull turns).
|
||||
*/
|
||||
export class Turret {
|
||||
/**
|
||||
* @param {object} hull — Tank hull with { x, y, angle (deg) }
|
||||
* @param {number} x — initial x position
|
||||
* @param {number} y — initial y position
|
||||
*/
|
||||
constructor(hull, x, y) {
|
||||
this.hull = hull;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.angle = 0; // local turret angle, degrees
|
||||
this.rotation = 0; // radians
|
||||
this.active = true;
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update turret each frame: track hull position, rotate toward target.
|
||||
* Capped at TURRET_MAX_ROTATION °/sec.
|
||||
*
|
||||
* @param {number} delta — ms since last frame
|
||||
* @param {number} targetAngle — mouse cursor angle in degrees (world space)
|
||||
*/
|
||||
update(delta, targetAngle) {
|
||||
// Position: follow hull center
|
||||
this.x = this.hull.x;
|
||||
this.y = this.hull.y;
|
||||
|
||||
// Compute max rotation this frame (degrees)
|
||||
const maxDegrees = TURRET_MAX_ROTATION * (delta / 1000);
|
||||
|
||||
// Difference between current and target (normalized to [-180, 180])
|
||||
let diff = targetAngle - this.angle;
|
||||
diff = normalizeAngle(diff);
|
||||
|
||||
// Clamp rotation to max per frame
|
||||
if (Math.abs(diff) <= maxDegrees) {
|
||||
this.angle = targetAngle;
|
||||
} else {
|
||||
this.angle += Math.sign(diff) * maxDegrees;
|
||||
}
|
||||
|
||||
// Keep angle in [0, 360) range
|
||||
this.angle = ((this.angle % 360) + 360) % 360;
|
||||
this.rotation = this.angle * DEG_TO_RAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* World angle = hull angle + turret local angle.
|
||||
* @returns {number} degrees
|
||||
*/
|
||||
getWorldAngle() {
|
||||
return this.hull.angle + this.angle;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.active = false;
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
122
tests/turret.test.js
Normal file
122
tests/turret.test.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// tests/turret.test.js — Turret rotation tests (Slice 1.3)
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { Turret } from '../src/game/entities/Turret.js';
|
||||
|
||||
/**
|
||||
* Stub hull for testing — provides { x, y, angle }.
|
||||
*/
|
||||
function createHull(x = 320, y = 180, angle = 0) {
|
||||
return { x, y, angle, active: true };
|
||||
}
|
||||
|
||||
describe('Turret', () => {
|
||||
let hull;
|
||||
let turret;
|
||||
|
||||
beforeEach(() => {
|
||||
hull = createHull();
|
||||
turret = new Turret(hull, hull.x, hull.y);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (turret && turret.active) turret.destroy();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: Rotation cap — never exceeds 15 deg/sec
|
||||
// -----------------------------------------------------------------------
|
||||
test('turret rotation never exceeds 15 deg/sec', () => {
|
||||
turret.angle = 0;
|
||||
const delta = 33.33; // ~30fps, so 30 frames ≈ 1 second
|
||||
const targetAngle = 90;
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
turret.update(delta, targetAngle);
|
||||
}
|
||||
|
||||
// Should have rotated toward target
|
||||
expect(turret.angle).toBeGreaterThan(0);
|
||||
// Total rotation ≤ 15 degrees (cap over ~1 sec)
|
||||
expect(turret.angle).toBeLessThanOrEqual(15.01);
|
||||
// Should not overshoot target
|
||||
expect(turret.angle).toBeLessThanOrEqual(90);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: Centered on hull during movement
|
||||
// -----------------------------------------------------------------------
|
||||
test('turret remains centered on hull during hull movement', () => {
|
||||
hull.x = 100;
|
||||
hull.y = 100;
|
||||
turret.update(16.67, turret.angle);
|
||||
|
||||
expect(turret.x).toBe(100);
|
||||
expect(turret.y).toBe(100);
|
||||
|
||||
hull.x = 500;
|
||||
hull.y = 300;
|
||||
turret.update(16.67, turret.angle);
|
||||
|
||||
expect(turret.x).toBe(500);
|
||||
expect(turret.y).toBe(300);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: Turret local angle unchanged when only hull rotates
|
||||
// -----------------------------------------------------------------------
|
||||
test('turret does not change angle when only hull rotates', () => {
|
||||
turret.angle = 42;
|
||||
const saved = turret.angle;
|
||||
|
||||
hull.angle = 90;
|
||||
turret.update(16.67, 42); // target = current turret angle
|
||||
|
||||
// Local angle unchanged
|
||||
expect(turret.angle).toBe(saved);
|
||||
// World angle additive
|
||||
expect(turret.getWorldAngle()).toBe(90 + 42); // 132
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 4: Asymptotic approach to target
|
||||
// -----------------------------------------------------------------------
|
||||
test('turret angle approaches target mouse angle asymptotically', () => {
|
||||
turret.angle = 0;
|
||||
const delta = 16.67; // ~60fps
|
||||
const target = 40; // 40 degrees away — reachable at 15 deg/sec
|
||||
|
||||
const distances = [];
|
||||
for (let i = 0; i < 350; i++) {
|
||||
turret.update(delta, target);
|
||||
distances.push(Math.abs(turret.angle - target));
|
||||
}
|
||||
|
||||
// Monotonically decreasing
|
||||
for (let i = 1; i < distances.length; i++) {
|
||||
expect(distances[i]).toBeLessThanOrEqual(distances[i - 1] + 0.001);
|
||||
}
|
||||
|
||||
// Converges within 0.1 degrees
|
||||
expect(Math.abs(turret.angle - target)).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 5: World angle = hull.angle + turret.angle
|
||||
// -----------------------------------------------------------------------
|
||||
test('turret correctly computes world angle = hull.angle + turret.angle', () => {
|
||||
hull.angle = 30;
|
||||
turret.angle = 15;
|
||||
turret.update(16.67, 15);
|
||||
|
||||
expect(turret.getWorldAngle()).toBe(45);
|
||||
|
||||
hull.angle = 90;
|
||||
turret.update(16.67, 70); // rotate toward 70
|
||||
expect(turret.getWorldAngle()).toBe(90 + turret.angle);
|
||||
|
||||
hull.angle = 0;
|
||||
turret.angle = 45;
|
||||
turret.update(16.67, 45);
|
||||
expect(turret.getWorldAngle()).toBe(45);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const phaserDist = new URL('./node_modules/phaser/dist/phaser.js', import.meta.url).pathname;
|
||||
const srcDir = new URL('./src', import.meta.url).pathname;
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
@@ -8,7 +11,8 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
'@': srcDir,
|
||||
'phaser': phaserDist,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user