- Players must be moving at least 2 m/s to execute a tackle - Add TACKLE_MIN_SPEED constant (2 m/s) in constants.ts - Add Player.getCurrentSpeed() method to expose current speed - Check tackler speed in executeTackle() before attempting tackle - Log speed in tackle debug output for tuning 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import Phaser from 'phaser';
|
|
import {
|
|
SCALE,
|
|
PLAYER_ROTATION_SPEED,
|
|
PLAYER_RADIUS_GOALIE,
|
|
PLAYER_RADIUS_SKATER,
|
|
SPEED_SCALE_FACTOR,
|
|
MOVEMENT_STOP_THRESHOLD,
|
|
GOAL_DECELERATION_RATE,
|
|
DEBUG,
|
|
TACKLE_COOLDOWN,
|
|
TACKLE_FALL_DURATION,
|
|
PLAYER_ACCELERATION,
|
|
PLAYER_DECELERATION
|
|
} from '../config/constants';
|
|
import { CoordinateUtils } from '../utils/coordinates';
|
|
import { MathUtils } from '../utils/math';
|
|
import type { PlayerPosition, TeamSide, PlayerState, PlayerAttributes } from '../types/game';
|
|
|
|
export class Player extends Phaser.GameObjects.Container {
|
|
declare body: Phaser.Physics.Arcade.Body;
|
|
|
|
// Player identity
|
|
public id: string;
|
|
public team: TeamSide;
|
|
public playerPosition: PlayerPosition;
|
|
|
|
// Position in game coordinates (meters)
|
|
public gameX: number;
|
|
public gameY: number;
|
|
|
|
// Target position (where player wants to move)
|
|
public targetX: number;
|
|
public targetY: number;
|
|
|
|
// Player attributes
|
|
public attributes: PlayerAttributes;
|
|
|
|
// Player state
|
|
public state: PlayerState;
|
|
private speedMultiplier: number = 1.0;
|
|
|
|
// Rotation (angle in radians, 0 = facing right)
|
|
private currentAngle: number = 0;
|
|
|
|
// Current speed (m/s in game coordinates)
|
|
private currentSpeed: number = 0;
|
|
|
|
// Tackle cooldown tracking
|
|
private lastTackleTime: number = 0;
|
|
|
|
// Fall state tracking (when tackled)
|
|
private isFallen: boolean = false;
|
|
private fallRecoveryTime: number = 0;
|
|
|
|
// Debug visualizations
|
|
private debugTargetGraphics?: Phaser.GameObjects.Graphics;
|
|
private debugLineGraphics?: Phaser.GameObjects.Graphics;
|
|
|
|
// Direction indicator
|
|
private directionIndicator!: Phaser.GameObjects.Graphics;
|
|
|
|
constructor(
|
|
scene: Phaser.Scene,
|
|
id: string,
|
|
team: TeamSide,
|
|
playerPosition: PlayerPosition,
|
|
gameX: number,
|
|
gameY: number,
|
|
attributes: PlayerAttributes
|
|
) {
|
|
// Convert game coordinates to screen coordinates
|
|
const screenPos = CoordinateUtils.gameToScreen(scene, gameX, gameY);
|
|
|
|
super(scene, screenPos.x, screenPos.y);
|
|
|
|
this.id = id;
|
|
this.team = team;
|
|
this.playerPosition = playerPosition;
|
|
this.gameX = gameX;
|
|
this.gameY = gameY;
|
|
this.targetX = gameX;
|
|
this.targetY = gameY;
|
|
this.attributes = attributes;
|
|
this.state = 'defensive';
|
|
|
|
// Initialize facing direction based on team
|
|
// Home team (left side) faces right, Away team (right side) faces left
|
|
this.currentAngle = this.team === 'home' ? 0 : Math.PI;
|
|
|
|
// Listen for goal events
|
|
this.scene.events.on('goal', this.onGoal, this);
|
|
|
|
// Add to scene
|
|
scene.add.existing(this);
|
|
scene.physics.add.existing(this);
|
|
|
|
this.body = this.body as Phaser.Physics.Arcade.Body;
|
|
|
|
// Set physics body (circular, centered on container)
|
|
const radius = this.playerPosition === 'G' ? PLAYER_RADIUS_GOALIE : PLAYER_RADIUS_SKATER;
|
|
this.body.setCircle(radius);
|
|
this.body.setOffset(-radius, -radius); // Center the body on the container
|
|
this.body.setCollideWorldBounds(true);
|
|
|
|
this.createSprite();
|
|
|
|
// Create debug graphics if DEBUG is enabled
|
|
if (DEBUG) {
|
|
this.debugTargetGraphics = scene.add.graphics();
|
|
this.debugLineGraphics = scene.add.graphics();
|
|
}
|
|
}
|
|
|
|
private createSprite() {
|
|
const graphics = this.scene.add.graphics();
|
|
|
|
// Determine color based on team
|
|
const color = this.team === 'home' ? 0x0000ff : 0xff0000;
|
|
|
|
// Make goalie larger
|
|
const radius = this.playerPosition === 'G' ? PLAYER_RADIUS_GOALIE : PLAYER_RADIUS_SKATER;
|
|
|
|
// Draw player circle
|
|
graphics.fillStyle(color, 1);
|
|
graphics.fillCircle(0, 0, radius);
|
|
|
|
// Add white outline
|
|
graphics.lineStyle(2, 0xffffff, 1);
|
|
graphics.strokeCircle(0, 0, radius);
|
|
|
|
// Add position label
|
|
const label = this.scene.add.text(0, 0, this.playerPosition, {
|
|
fontSize: '8px',
|
|
color: '#ffffff',
|
|
fontStyle: 'bold'
|
|
});
|
|
label.setOrigin(0.5, 0.5);
|
|
|
|
// Create direction indicator (small line showing facing direction)
|
|
this.directionIndicator = this.scene.add.graphics();
|
|
this.updateDirectionIndicator();
|
|
|
|
this.add([graphics, label, this.directionIndicator]);
|
|
}
|
|
|
|
/**
|
|
* Update the direction indicator to show current facing angle
|
|
*/
|
|
private updateDirectionIndicator() {
|
|
this.directionIndicator.clear();
|
|
|
|
const radius = this.playerPosition === 'G' ? PLAYER_RADIUS_GOALIE : PLAYER_RADIUS_SKATER;
|
|
|
|
// Draw line pointing right (container rotation will orient it correctly)
|
|
// Line goes from behind center to near the edge (0=center, 1=edge)
|
|
const startX = -radius * -0.5; // Start
|
|
const endX = radius * 1.0; // End
|
|
|
|
this.directionIndicator.lineStyle(3, 0x999999, 1);
|
|
this.directionIndicator.lineBetween(startX, 0, endX, 0); // Horizontal line, rotation handled by container
|
|
}
|
|
|
|
/**
|
|
* Update player position in game coordinates (meters)
|
|
*/
|
|
public setGamePosition(gameX: number, gameY: number) {
|
|
this.gameX = gameX;
|
|
this.gameY = gameY;
|
|
|
|
// Convert to screen coordinates
|
|
const screenPos = CoordinateUtils.gameToScreen(this.scene, gameX, gameY);
|
|
this.setPosition(screenPos.x, screenPos.y);
|
|
}
|
|
|
|
/**
|
|
* Set target position for movement
|
|
*/
|
|
public setTarget(targetX: number, targetY: number) {
|
|
this.targetX = targetX;
|
|
this.targetY = targetY;
|
|
}
|
|
|
|
/**
|
|
* Handle goal event
|
|
*/
|
|
private onGoal(_data: { team: string; goal: string }) {
|
|
// Gradually decelerate player after goal
|
|
const decelerate = () => {
|
|
this.speedMultiplier *= GOAL_DECELERATION_RATE;
|
|
|
|
if (this.speedMultiplier > 0.01) {
|
|
this.scene.time.delayedCall(50, decelerate);
|
|
} else {
|
|
this.speedMultiplier = 0;
|
|
}
|
|
};
|
|
|
|
decelerate();
|
|
}
|
|
|
|
/**
|
|
* Update player movement each frame
|
|
*/
|
|
public update(delta: number) {
|
|
const deltaSeconds = delta / 1000;
|
|
|
|
// Check if player is fallen and should stay still
|
|
if (this.isFallen) {
|
|
const currentTime = Date.now();
|
|
|
|
if (currentTime < this.fallRecoveryTime) {
|
|
// Player is still down - force zero velocity
|
|
this.currentSpeed = 0;
|
|
this.body.setVelocity(0, 0);
|
|
return;
|
|
} else {
|
|
// Recovery time elapsed - player can move again
|
|
this.isFallen = false;
|
|
}
|
|
}
|
|
|
|
// Calculate distance to target
|
|
const distance = MathUtils.distance(this.gameX, this.gameY, this.targetX, this.targetY);
|
|
|
|
// Calculate max speed based on player attributes (in m/s)
|
|
const maxSpeedMS = (this.attributes.speed / SPEED_SCALE_FACTOR) * this.speedMultiplier;
|
|
|
|
// If close enough to target, decelerate to stop
|
|
if (distance < MOVEMENT_STOP_THRESHOLD) {
|
|
// Decelerate
|
|
this.currentSpeed = Math.max(0, this.currentSpeed - PLAYER_DECELERATION * deltaSeconds);
|
|
|
|
if (this.currentSpeed < 0.1) {
|
|
this.currentSpeed = 0;
|
|
this.body.setVelocity(0, 0);
|
|
return;
|
|
}
|
|
|
|
// Continue moving in current direction while decelerating
|
|
const velX = Math.cos(this.currentAngle) * this.currentSpeed * SCALE;
|
|
const velY = Math.sin(this.currentAngle) * this.currentSpeed * SCALE;
|
|
this.body.setVelocity(velX, -velY);
|
|
} else {
|
|
const dx = this.targetX - this.gameX;
|
|
const dy = this.targetY - this.gameY;
|
|
|
|
// Calculate desired angle toward target
|
|
const targetAngle = Math.atan2(dy, dx);
|
|
|
|
// Smoothly rotate toward target angle
|
|
// Scale rotation speed inversely with current speed (faster = wider turns)
|
|
const speedRatio = maxSpeedMS > 0 ? this.currentSpeed / maxSpeedMS : 0;
|
|
const turnPenalty = 1 - (speedRatio * 0.7); // At max speed, rotation is 30% of base
|
|
const maxRotation = PLAYER_ROTATION_SPEED * turnPenalty * deltaSeconds;
|
|
|
|
// Calculate shortest angular difference
|
|
const angleDiff = MathUtils.angleDifference(this.currentAngle, targetAngle);
|
|
|
|
// Apply rotation with limit
|
|
const rotationStep = Math.sign(angleDiff) * Math.min(Math.abs(angleDiff), maxRotation);
|
|
this.currentAngle += rotationStep;
|
|
|
|
// Normalize current angle
|
|
this.currentAngle = MathUtils.normalizeAngle(this.currentAngle);
|
|
|
|
// Acceleration logic: accelerate toward max speed with ease-out curve
|
|
// This makes acceleration fast at first, then slows down as approaching max speed
|
|
if (this.currentSpeed < maxSpeedMS) {
|
|
const speedDiff = maxSpeedMS - this.currentSpeed;
|
|
const accelerationFactor = speedDiff / maxSpeedMS; // 1.0 at start, 0.0 at max speed
|
|
const dynamicAcceleration = PLAYER_ACCELERATION * (0.3 + accelerationFactor * 0.7); // Range: 30%-100% of base
|
|
this.currentSpeed = Math.min(maxSpeedMS, this.currentSpeed + dynamicAcceleration * deltaSeconds);
|
|
} else if (this.currentSpeed > maxSpeedMS) {
|
|
// Decelerate if max speed was reduced (e.g., after goal)
|
|
this.currentSpeed = Math.max(maxSpeedMS, this.currentSpeed - PLAYER_DECELERATION * deltaSeconds);
|
|
}
|
|
|
|
// Update visual rotation
|
|
this.setRotation(-this.currentAngle);
|
|
|
|
// Move in the direction the player is currently facing
|
|
const speedPixels = this.currentSpeed * SCALE; // Convert m/s to pixels/s
|
|
const velX = Math.cos(this.currentAngle) * speedPixels;
|
|
const velY = Math.sin(this.currentAngle) * speedPixels;
|
|
|
|
this.body.setVelocity(velX, -velY); // Negative Y because screen coords
|
|
}
|
|
|
|
// Update game position based on physics body center
|
|
const bodyX = this.body.x + this.body.width / 2;
|
|
const bodyY = this.body.y + this.body.height / 2;
|
|
const gamePos = CoordinateUtils.screenToGame(this.scene, bodyX, bodyY);
|
|
this.gameX = gamePos.x;
|
|
this.gameY = gamePos.y;
|
|
|
|
// Update debug visualizations
|
|
this.updateDebugVisuals();
|
|
}
|
|
|
|
/**
|
|
* Check if player can perform a tackle (cooldown expired)
|
|
*/
|
|
public canTackle(): boolean {
|
|
const currentTime = Date.now();
|
|
return (currentTime - this.lastTackleTime) >= TACKLE_COOLDOWN;
|
|
}
|
|
|
|
/**
|
|
* Mark that this player performed a tackle
|
|
*/
|
|
public setTacklePerformed() {
|
|
this.lastTackleTime = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Make player fall (when successfully tackled)
|
|
* Player decelerates to 0 and stays down for TACKLE_FALL_DURATION ms
|
|
*/
|
|
public fall() {
|
|
this.isFallen = true;
|
|
this.fallRecoveryTime = Date.now() + TACKLE_FALL_DURATION;
|
|
this.currentSpeed = 0;
|
|
this.body.setVelocity(0, 0);
|
|
}
|
|
|
|
/**
|
|
* Get current speed in m/s
|
|
*/
|
|
public getCurrentSpeed(): number {
|
|
return this.currentSpeed;
|
|
}
|
|
|
|
/**
|
|
* Update debug visualizations (target position and path line)
|
|
*/
|
|
private updateDebugVisuals() {
|
|
if (!DEBUG || !this.debugTargetGraphics || !this.debugLineGraphics) return;
|
|
|
|
// Convert target position to screen coordinates
|
|
const targetScreen = CoordinateUtils.gameToScreen(this.scene, this.targetX, this.targetY);
|
|
const playerScreen = CoordinateUtils.gameToScreen(this.scene, this.gameX, this.gameY);
|
|
|
|
// Clear previous debug graphics
|
|
this.debugTargetGraphics.clear();
|
|
this.debugLineGraphics.clear();
|
|
|
|
// Draw line from player to target
|
|
const lineColor = this.team === 'home' ? 0x0000ff : 0xff0000;
|
|
this.debugLineGraphics.lineStyle(1, lineColor, 0.5);
|
|
this.debugLineGraphics.lineBetween(playerScreen.x, playerScreen.y, targetScreen.x, targetScreen.y);
|
|
|
|
// Draw target marker (X)
|
|
const markerSize = 5;
|
|
this.debugTargetGraphics.lineStyle(2, lineColor, 0.8);
|
|
this.debugTargetGraphics.lineBetween(
|
|
targetScreen.x - markerSize,
|
|
targetScreen.y - markerSize,
|
|
targetScreen.x + markerSize,
|
|
targetScreen.y + markerSize
|
|
);
|
|
this.debugTargetGraphics.lineBetween(
|
|
targetScreen.x + markerSize,
|
|
targetScreen.y - markerSize,
|
|
targetScreen.x - markerSize,
|
|
targetScreen.y + markerSize
|
|
);
|
|
}
|
|
}
|