Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 64 additions & 34 deletions clients/seacat/src/game/managers/ProjectileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { Howl } from 'howler';
*/
export class ProjectileManager {
private readonly GRAVITY = 150; // px/s² (must match server)
private readonly LIFETIME = 2000; // ms
private readonly LIFETIME = 5000; // ms

constructor(
private scene: Phaser.Scene,
Expand All @@ -70,7 +70,7 @@ export class ProjectileManager {
},
private getOnShip: () => string | null,
private shipCommands: ShipCommands
) {}
) { }

/**
* Spawn a projectile from a cannon (c5x-ship-combat Phase 2)
Expand All @@ -85,8 +85,17 @@ export class ProjectileManager {
return;
}

console.log(`[GameScene] Spawning projectile ${id} at (${position.x.toFixed(1)}, ${position.y.toFixed(1)})`);
console.log(` Velocity: (${velocity.x.toFixed(1)}, ${velocity.y.toFixed(1)}) px/s`);
// Convert spawn position to ground coordinates (inverse isometric transform)
// Forward: screenX = groundX - groundY, screenY = (groundX + groundY) / 2 - heightZ
// Inverse: groundX = screenX/2 + screenY + heightZ, groundY = screenY - screenX/2 + heightZ
const spawnHeightZ = 0; // Cannons fire from deck level (water surface)
const spawnGroundX = position.x / 2 + position.y + spawnHeightZ;
const spawnGroundY = position.y - position.x / 2 + spawnHeightZ;

console.log(`[GameScene] Spawning projectile ${id} at screen(${position.x.toFixed(1)}, ${position.y.toFixed(1)})`);
console.log(` Ground position: (${spawnGroundX.toFixed(1)}, ${spawnGroundY.toFixed(1)}), height ${spawnHeightZ.toFixed(1)}`);
console.log(` Ground velocity: (${velocity.groundVx.toFixed(1)}, ${velocity.groundVy.toFixed(1)}) px/s`);
console.log(` Height velocity: ${velocity.heightVz.toFixed(1)} px/s`);

// Create cannonball sprite (black circle, 8px diameter)
const sprite = this.scene.add.circle(
Expand All @@ -98,11 +107,21 @@ export class ProjectileManager {
);
sprite.setDepth(100); // Above ships and players

// Store projectile
// Store projectile with 3D physics data
const projectile: Projectile = {
id,
sprite,
velocity: { ...velocity }, // Copy velocity

// Initialize ground position and velocity
groundX: spawnGroundX,
groundY: spawnGroundY,
groundVx: velocity.groundVx,
groundVy: velocity.groundVy,

// Initialize height position and velocity
heightZ: spawnHeightZ,
heightVz: velocity.heightVz,

spawnTime: timestamp,
sourceShip,
minFlightTime: 200, // 200ms grace period before water collision check (prevents instant despawn from deck-level shots)
Expand Down Expand Up @@ -141,12 +160,24 @@ export class ProjectileManager {
return;
}

// Apply gravity (downward acceleration)
proj.velocity.y += this.GRAVITY * deltaS;
// TRUE 3D PHYSICS (p2v-projectile-velocity Option 2)
// Update ground position (no gravity - only horizontal movement)
proj.groundX += proj.groundVx * deltaS;
proj.groundY += proj.groundVy * deltaS;

// Update height (with gravity - only affects vertical component)
// Sign convention: heightVz > 0 = upward, heightZ > 0 = above ground
// Gravity reduces upward velocity (decelerates when going up, accelerates when going down)
proj.heightVz -= this.GRAVITY * deltaS; // Gravity decreases heightVz
proj.heightZ += proj.heightVz * deltaS;

// Update position (Euler integration)
proj.sprite.x += proj.velocity.x * deltaS;
proj.sprite.y += proj.velocity.y * deltaS;
// Convert ground position + height to screen coordinates for rendering
// Isometric projection: screenX = groundX - groundY, screenY = (groundX + groundY) / 2 - heightZ
const screenX = proj.groundX - proj.groundY;
const screenY = (proj.groundX + proj.groundY) / 2 - proj.heightZ;

proj.sprite.x = screenX;
proj.sprite.y = screenY;

// Phase 2c: Add smoke trail effect (30% chance per frame ~18 puffs/sec at 60fps)
if (Math.random() < 0.3) {
Expand Down Expand Up @@ -175,6 +206,13 @@ export class ProjectileManager {
if (ship.id === proj.sourceShip) return; // Don't hit own ship
if (hitShip) return; // Already hit a ship this frame

// Only hit ships if projectile is at deck height (within threshold)
// This prevents high arcing shots from hitting ships they pass over
const DECK_HEIGHT_THRESHOLD = 30; // px tolerance for deck-level hits
if (Math.abs(proj.heightZ) > DECK_HEIGHT_THRESHOLD) {
return; // Projectile is too high or too low to hit ship
}

// Use existing OBB collision with generous hitbox
const hitboxPadding = 1.2; // 20% generous hitbox
const paddedBoundary = {
Expand Down Expand Up @@ -216,35 +254,27 @@ export class ProjectileManager {

if (hitShip) return; // Skip water check if we hit a ship

// Phase 2c: Check for water surface collision
// ONLY check if: (1) past grace period AND (2) descending
// Phase 2c: Check for water surface collision using 3D height
// ONLY check if: (1) past grace period AND (2) descending (heightVz < 0, since positive = upward)
// Grace period prevents instant despawn from deck-level downward shots
if (age > proj.minFlightTime && proj.velocity.y > 0) {
if (age > proj.minFlightTime && proj.heightVz < 0) {
const tilePos = this.map.worldToTileXY(proj.sprite.x, proj.sprite.y);
if (tilePos) {
const tile = this.groundLayer.getTileAt(Math.floor(tilePos.x), Math.floor(tilePos.y));

if (tile && tile.properties?.navigable === true) {
// Projectile is over water - calculate water surface Y coordinate
const worldPos = this.map.tileToWorldXY(Math.floor(tilePos.x), Math.floor(tilePos.y));
if (worldPos) {
// Water surface is at the tile's world Y position
// Add half tile visual height to get the center/surface of the water tile
const waterSurfaceY = worldPos.y + (TILE_VISUAL_HEIGHT / 2);

// Check if cannonball has hit the water (with small margin for cannonball radius)
if (proj.sprite.y >= waterSurfaceY - 5) {
// HIT WATER! Show splash and despawn
this.effectsRenderer.createWaterSplash(proj.sprite.x, proj.sprite.y);

// Phase 5: Play water splash sound (c5x-ship-combat)
this.sounds?.waterSplash?.play();

proj.sprite.destroy();
this.projectiles.delete(id);
console.log(`[GameScene] Projectile ${id} hit water at (${proj.sprite.x.toFixed(1)}, ${proj.sprite.y.toFixed(1)}). Total: ${this.projectiles.size}`);
return;
}
// Over water - check if height has reached water surface (heightZ <= 0)
if (proj.heightZ <= 0) {
// HIT WATER! Show splash and despawn
this.effectsRenderer.createWaterSplash(proj.sprite.x, proj.sprite.y);

// Phase 5: Play water splash sound (c5x-ship-combat)
this.sounds?.waterSplash?.play();

proj.sprite.destroy();
this.projectiles.delete(id);
console.log(`[GameScene] Projectile ${id} hit water at ground(${proj.groundX.toFixed(1)}, ${proj.groundY.toFixed(1)}), screen(${proj.sprite.x.toFixed(1)}, ${proj.sprite.y.toFixed(1)}). Total: ${this.projectiles.size}`);
return;
}
}
}
Expand Down
13 changes: 12 additions & 1 deletion clients/seacat/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,22 @@ export interface Ship {

/**
* Projectile entity managed by the game (c5x-ship-combat Phase 2)
* Updated to use 3D physics (p2v-projectile-velocity Option 2)
*/
export interface Projectile {
id: string;
sprite: Phaser.GameObjects.Arc;
velocity: { x: number; y: number };

// Ground position and velocity (horizontal movement on isometric map)
groundX: number;
groundY: number;
groundVx: number;
groundVy: number;

// Height position and velocity (vertical elevation in 3D space)
heightZ: number; // Height above ground (0 = water/ground level)
heightVz: number; // Vertical velocity (negative = upward)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Vertical Sign Convention: Up Is Down

Sign convention mismatch: the type comment says heightVz is "negative = upward" but the implementation in ProjectileManager.ts:169 and ShipServer.ts:709 treats positive values as upward. The water collision check at line 260 uses heightVz < 0 to detect descending projectiles, which contradicts the type definition. This inconsistency will cause confusion and potential bugs when the code is maintained or extended.

Fix in Cursor Fix in Web


spawnTime: number;
sourceShip: string;
minFlightTime: number; // Minimum ms before water collision can trigger (prevents instant despawn from deck-level firing)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 124 additions & 17 deletions spec/seacat/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Ships are movable platforms implemented as MEW participants backed by a ship ser

The game implements a hybrid coordinate system combining Cartesian physics with isometric rendering. **Server-side physics** use Cartesian coordinates (simple, proven approach used by industry-standard isometric games like StarCraft and Age of Empires). **Client-side rendering** uses isometric transforms for all visual elements, collision detection, and player movement input. This "cosmetic isometric" approach provides visual consistency while keeping physics simple.

**Projectile Physics (p2v-projectile-velocity):** Cannon projectiles use a **3D coordinate system** that separates ground movement (groundX, groundY) from height (heightZ). This ensures gravity only affects vertical elevation, not horizontal ground movement, producing uniform trajectories in all directions. The server calculates 3D velocity using inverse isometric transforms to convert screen-space fire angles to ground-space azimuth angles. Both client and server simulate physics using identical iterative Euler integration to prevent desync. See Milestone 9 Phase 2 for detailed implementation.

**Platform Coordinates:** The game also implements platform-relative coordinates to handle players on moving ships. World coordinates are absolute positions in the game world, while platform coordinates are relative offsets from a platform's origin point (used when standing on a ship). Each player's state includes a `platform_ref` field that is null when on solid ground or references a ship participant ID when aboard. When a player is on a ship, their world position is calculated as `ship.world_position + player.platform_offset` (using isometric rotation), recalculated every frame as the ship moves and rotates. Movement commands from players on ships are interpreted as relative movements within the ship's coordinate frame, with collision detection checking against the ship's deck boundaries (using isometric OBB) rather than world terrain. The rendering system uses isometric transforms to position players correctly on rotating platforms, ensuring players stay within visual ship bounds during rotation.

## Current Client Implementation
Expand Down Expand Up @@ -1039,20 +1041,118 @@ Add cannon-based ship combat enabling multiplayer PvP and cooperative multi-crew
- `ShipParticipant.ts` - Message handlers for player control messages
- `GameScene.ts` - Cannon rendering, input handling, visual feedback

### Phase 2: Projectile Physics ✅ COMPLETE
### Phase 2: Projectile Physics (p2v-projectile-velocity) ✅ COMPLETE

**True 3D Isometric Ballistics:**

Projectiles use a **3D coordinate system** that separates ground movement from height, ensuring uniform trajectories in all directions regardless of isometric projection.

**Coordinate Separation:**
- **Ground position** (groundX, groundY): Horizontal movement on the map plane
- **Height** (heightZ): Vertical elevation affected by gravity
- **Screen rendering**: Converts ground + height to isometric coordinates

**Physics Constants:**
- Gravity: 150 px/s² (only affects heightVz, not ground movement)
- Initial speed: 300 px/s (before decomposition into horizontal/vertical components)
- Deck height threshold: 30px (projectiles only hit ships at deck level)

**Velocity Calculation (Server):**

Server calculates 3D velocity in ground-space coordinates:

```typescript
// 1. Calculate fire direction (perpendicular to ship)
const perpendicular = shipRotation + (isPort ? -π/2 : π/2);
const fireAngle = perpendicular + cannon.aimAngle;

// 2. Decompose into horizontal and vertical components
const elevation = cannon.elevationAngle;
const horizontalSpeed = CANNON_SPEED * cos(elevation); // Ground-plane speed
const verticalComponent = CANNON_SPEED * sin(elevation); // Upward velocity

// 3. Convert screen angle to ground azimuth (inverse isometric transform)
const cos_fire = cos(fireAngle);
const sin_fire = sin(fireAngle);
const cos_azimuth = (cos_fire + 2 * sin_fire) / norm;
const sin_azimuth = (2 * sin_fire - cos_fire) / norm;

// 4. Calculate 3D velocity (includes ship velocity inheritance)
velocity = {
groundVx: horizontalSpeed * cos_azimuth + ship.velocity.x,
groundVy: horizontalSpeed * sin_azimuth + ship.velocity.y,
heightVz: verticalComponent // Positive = upward
};
```

**Physics Simulation (Client):**

Client simulates projectiles frame-by-frame at 60 FPS using iterative Euler integration:

```typescript
// Update ground position (NO gravity - horizontal only)
proj.groundX += proj.groundVx * deltaS;
proj.groundY += proj.groundVy * deltaS;

// Update height (WITH gravity - vertical only)
proj.heightVz -= GRAVITY * deltaS; // Gravity decreases upward velocity
proj.heightZ += proj.heightVz * deltaS;

**Ballistics System:**
- Gravity: 150 px/s² (downward acceleration)
- Initial velocity: ~300 px/s (based on elevation and aim angle)
- Velocity inheritance: Projectiles inherit ship's velocity vector
- Deterministic physics: Same equations on client and server
// Convert to screen coordinates for rendering
proj.sprite.x = proj.groundX - proj.groundY;
proj.sprite.y = (proj.groundX + proj.groundY) / 2 - proj.heightZ;
```

**Hit Validation (Server):**

Server validates hits by replaying the exact physics simulation:

```typescript
// Iterative Euler integration (matches client's frame-by-frame simulation)
const FRAME_TIME = 1 / 60; // 60 FPS
const numSteps = ceil(elapsed / FRAME_TIME);
const dt = elapsed / numSteps;

for (let i = 0; i < numSteps; i++) {
groundX += velocity.groundVx * dt;
groundY += velocity.groundVy * dt;
heightVz -= GRAVITY * dt;
heightZ += heightVz * dt;
}

// Height threshold check (prevents high-arc exploits)
if (abs(heightZ) > DECK_HEIGHT_THRESHOLD) {
return false; // Projectile too high or too low
}
```

**Key Benefits:**
- ✅ Uniform distances: All directions travel equal ground distance (~10-12 tiles)
- ✅ Physically accurate: Gravity only affects height, not ground movement
- ✅ Consistent trajectories: Same arc shape in all directions
- ✅ Cheat-resistant: Server validates using same physics as client
- ✅ Extensible: Enables future terrain elevation, multi-level maps

**Why Iterative Integration (Not Analytical)?**

Using the analytical ballistic formula `h = h₀ + v₀t - ½gt²` would diverge from the client's iterative Euler integration over time due to numerical precision differences. Server MUST use frame-by-frame simulation to match client exactly, preventing false hit rejections.

**Constants Synchronization:**

Critical that these constants stay synchronized between client and server:

| Constant | Value | Client Location | Server Location |
|----------|-------|-----------------|-----------------|
| GRAVITY | 150 px/s² | ProjectileManager.ts:54 | ShipServer.ts:805 |
| DECK_HEIGHT_THRESHOLD | 30 px | ProjectileManager.ts:211 | ShipServer.ts:806 |
| CANNON_SPEED | 300 px/s | - | ShipServer.ts:680 |

**Projectile Lifecycle:**
1. Spawn at cannon muzzle position (rotated with ship)
2. Server calculates initial velocity from aim/elevation angles
3. Server broadcasts `game/projectile_spawn` to all clients
4. All clients simulate identical physics (gravity + initial velocity)
5. Lifetime: 2 seconds client-side, 3 seconds server-side (1s grace for validation)
1. Spawn at cannon muzzle position (rotated with ship, converted to ground coordinates)
2. Server calculates 3D velocity from aim/elevation angles using inverse isometric transform
3. Server broadcasts `game/projectile_spawn` to all clients with Velocity3D
4. All clients simulate identical physics using iterative Euler integration
5. Lifetime: 2 seconds client-side, 5 seconds server-side (3s grace for validation)

**Visual Effects:**
- Cannonballs: Black circles (8px diameter, 4px radius)
Expand All @@ -1061,10 +1161,15 @@ Add cannon-based ship combat enabling multiplayer PvP and cooperative multi-crew
- Water splash: Blue particles on water impact (8 particles)

**Implementation:**
- `ShipServer.ts:596-717` - Projectile spawn logic, physics constants
- `GameScene.ts:759-796` - Projectile spawn handling
- `GameScene.ts:1606-1721` - Client-side physics simulation
- `GameScene.ts:800-863` - Visual effects (cannon blast, water splash, hit impact)
- Server velocity calculation: `ShipServer.ts:689-719`
- Server hit validation with physics replay: `ShipServer.ts:784-870`
- Client physics simulation: `ProjectileManager.ts:163-172`
- Client hit detection with height threshold: `ProjectileManager.ts:210-214`
- Unit tests (11 tests verifying physics sync): `ShipServer.test.ts`
- Visual effects: `EffectsRenderer.ts`

**Related Proposals:**
- `spec/seacat/proposals/p2v-projectile-velocity/` - Full specification and implementation details

### Phase 3: Damage & Health ✅ COMPLETE

Expand Down Expand Up @@ -1181,8 +1286,10 @@ Add cannon-based ship combat enabling multiplayer PvP and cooperative multi-crew
### Related Proposals

- `c5x-ship-combat`: Full specification with 5 implementation phases
- See detailed documentation in `spec/seacat/proposals/c5x-ship-combat/`
- Implementation plan: `spec/seacat/proposals/c5x-ship-combat/implementation.md`
- `p2v-projectile-velocity`: True 3D isometric projectile physics (Phase 2 implementation)
- See detailed documentation in:
- `spec/seacat/proposals/c5x-ship-combat/` - Overall combat system
- `spec/seacat/proposals/p2v-projectile-velocity/` - Physics implementation details

### Phase 5: Polish & Sound Effects ⚠️ PARTIAL

Expand Down
2 changes: 1 addition & 1 deletion spec/seacat/TODO.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# To Do

- [ ] Show control points above the ship sprite
- [ ] Fix the bug in cannon tragectories (when pointing south with cannon all the way up, immediately hits water on firing but should be up in the air for a while)
- [x] Fix the bug in cannon trajectories (when pointing south with cannon all the way up, immediately hits water on firing but should be up in the air for a while) - Fixed by removing ship Y-velocity inheritance from projectile calculations
- [ ] Cannon ball shadows

- [ ] controller support
Expand Down
Loading
Loading