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