feat(turret): RED→GREEN — implement capped rotation tracking hull position (5 tests)

This commit is contained in:
2026-05-23 06:27:27 +00:00
parent 35db657b7a
commit ed53f4984f
3 changed files with 205 additions and 1 deletions

View 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
View 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);
});
});

View File

@@ -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,
},
},
});