Pierre Wessman 549ac400e0 Add minimum speed requirement for tackle execution
- 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>
2025-10-02 12:59:35 +02:00

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