import Phaser from 'phaser'; import { RINK_LENGTH, RINK_WIDTH, RINK_CORNER_RADIUS, SCALE, BLUE_LINE_OFFSET, GOAL_LINE_OFFSET, COLOR_ICE, COLOR_BOARDS, COLOR_RED_LINE, COLOR_BLUE_LINE, FACEOFF_CIRCLE_RADIUS, CENTER_DOT_RADIUS, MOVEMENT_STOP_THRESHOLD, PUCK_CARRY_DISTANCE, PUCK_PICKUP_RADIUS, SHOT_SPEED, TACKLE_SUCCESS_MODIFIER, TACKLE_PUCK_LOOSE_CHANCE, PLAYER_RADIUS_GOALIE, PLAYER_RADIUS_SKATER, TACKLE_MIN_SPEED, TACKLE_ANGLE_HEAD_ON_THRESHOLD, TACKLE_ANGLE_SIDE_THRESHOLD, TACKLE_ANGLE_BEHIND_THRESHOLD, TACKLE_ANGLE_HEAD_ON_MODIFIER, TACKLE_ANGLE_SIDE_MODIFIER, TACKLE_ANGLE_BEHIND_MODIFIER, TACKLE_ANGLE_OPPOSITE_MODIFIER, TACKLE_CLOSING_SPEED_WEAK, TACKLE_CLOSING_SPEED_SOLID, TACKLE_VELOCITY_MODIFIER_MIN, TACKLE_VELOCITY_MODIFIER_MAX, POST_BOUNCE_SPEED_REDUCTION, POST_BOUNCE_ANGLE_RANDOMNESS, PUCK_RECEPTION_BASE_CHANCE, PUCK_RECEPTION_SPEED_EASY, PUCK_RECEPTION_SPEED_HARD, PUCK_RECEPTION_CHECK_INTERVAL } from '../config/constants'; import { Goal } from './Goal'; import { Puck } from '../entities/Puck'; import { Player } from '../entities/Player'; import { BehaviorTree } from '../systems/BehaviorTree'; import { MathUtils } from '../utils/math'; export class GameScene extends Phaser.Scene { private leftGoal!: Goal; private rightGoal!: Goal; private puck!: Puck; private players: Player[] = []; private playerGroup!: Phaser.Physics.Arcade.Group; private goalPostsGroup!: Phaser.Physics.Arcade.StaticGroup; private behaviorTrees: Map = new Map(); // Track last reception attempt time for each player to avoid spamming attempts private lastReceptionAttempt: Map = new Map(); // Pause state private isPaused: boolean = false; private pauseButton!: Phaser.GameObjects.Text; // Goal reset timer private goalResetTimer: number = 0; private isWaitingForReset: boolean = false; constructor() { super({ key: 'GameScene' }); } create() { console.log('[GameScene] Creating scene...'); // Reset goal timer flags this.goalResetTimer = 0; this.isWaitingForReset = false; this.drawRink(); this.createGoals(); this.createPuck(); this.createPlayers(); this.setupEventListeners(); this.createPauseButton(); console.log('[GameScene] Scene created. Players:', this.players.length); } private createPlayers() { // Create physics group for all players this.playerGroup = this.physics.add.group({ collideWorldBounds: false }); // Create one home center at (-10, 0) - left side of center ice const homeCenter = new Player( this, 'home-C', 'home', 'C', -1, 0, { speed: 70, skill: 75, tackling: 70, balance: 75, handling: 80 } ); // Add players to tracking array and physics group this.addPlayer(homeCenter); // Commented out for testing single player // const awayDefender = new Player( // this, // 'away-LD', // 'away', // 'LD', // -15, // 0, // { speed: 80, skill: 70, tackling: 85, balance: 80, handling: 65 } // ); // this.addPlayer(awayDefender); // Setup player-player collisions for entire group this.physics.add.collider(this.playerGroup, this.playerGroup, (obj1, obj2) => { this.handlePlayerCollision(obj1 as Player, obj2 as Player); }); } /** * Add a player to the scene (can be called at any time for debugging) */ addPlayer(player: Player) { // Add to scene and enable physics this.add.existing(player); this.physics.add.existing(player); // Setup physics body player.body = player.body as Phaser.Physics.Arcade.Body; const radius = player.playerPosition === 'G' ? PLAYER_RADIUS_GOALIE : PLAYER_RADIUS_SKATER; player.body.setCircle(radius); player.body.setOffset(-radius, -radius); player.body.setCollideWorldBounds(true); // Add to tracking arrays and groups this.players.push(player); this.playerGroup.add(player); this.behaviorTrees.set(player.id, new BehaviorTree(player)); // Create detection sensor for this player player.createDetectionSensor(); // Setup detection sensor overlaps with other players this.setupPlayerDetection(player); // Add collision with all goal posts this.physics.add.collider(player, this.goalPostsGroup); } /** * Setup physics-based detection sensor overlaps for a player */ setupPlayerDetection(player: Player) { if (!player.detectionSensor) return; // Setup overlap detection with all other players this.physics.add.overlap( player.detectionSensor, this.playerGroup, (_sensor, otherPlayer) => { const other = otherPlayer as unknown as Player; // Don't detect self if (other.id === player.id) return; // Add to nearby players set player.nearbyPlayers.add(other); // Categorize by team if (other.team === player.team) { player.nearbyTeammates.add(other); } else { player.nearbyOpponents.add(other); } } ); // Important: Clear nearby players when they leave the sensor // We need to check every frame and remove players no longer overlapping this.events.on('update', () => { if (!player.detectionSensor) return; // Check each nearby player to see if still overlapping player.nearbyPlayers.forEach(other => { const overlapping = this.physics.overlap(player.detectionSensor!, other); if (!overlapping) { player.nearbyPlayers.delete(other); player.nearbyOpponents.delete(other); player.nearbyTeammates.delete(other); } }); }); } /** * Remove a player from the scene (can be called at any time for debugging) */ removePlayer(playerId: string) { const index = this.players.findIndex(p => p.id === playerId); if (index === -1) return; const player = this.players[index]; this.players.splice(index, 1); this.playerGroup.remove(player); this.behaviorTrees.delete(playerId); this.lastReceptionAttempt.delete(playerId); player.destroy(); } private setupEventListeners() { // Listen for goal events this.events.on('goal', (data: { team: string; goal: string }) => { console.log(`[GameScene] Goal scored by ${data.team} team in ${data.goal} goal`); // Stop the puck (caught by net) this.puck.body.setVelocity(0, 0); // Start 3-second countdown to reset this.goalResetTimer = 3000; this.isWaitingForReset = true; }); } private createPuck() { // Initialize puck at center ice (0, 0 in game coordinates) this.puck = new Puck(this, 5, 5); // Add collisions between puck and all goal posts with custom bounce handler this.physics.add.collider(this.puck, this.goalPostsGroup, this.handlePuckPostBounce, undefined, this); } private createGoals() { const centerX = (RINK_LENGTH * SCALE) / 2; const centerY = (RINK_WIDTH * SCALE) / 2; // Create static physics group for all goal posts this.goalPostsGroup = this.physics.add.staticGroup(); // Left goal (positioned at left goal line) const leftGoalX = centerX - (GOAL_LINE_OFFSET * SCALE); this.leftGoal = new Goal(this, leftGoalX, centerY, true); // Right goal (positioned at right goal line) const rightGoalX = centerX + (GOAL_LINE_OFFSET * SCALE); this.rightGoal = new Goal(this, rightGoalX, centerY, false); // Add all goal posts to the group this.goalPostsGroup.add(this.leftGoal.getLeftPost()); this.goalPostsGroup.add(this.leftGoal.getRightPost()); this.goalPostsGroup.add(this.leftGoal.getBackBar()); this.goalPostsGroup.add(this.rightGoal.getLeftPost()); this.goalPostsGroup.add(this.rightGoal.getRightPost()); this.goalPostsGroup.add(this.rightGoal.getBackBar()); } private drawRink() { const graphics = this.add.graphics(); // Calculate center of screen const centerX = (RINK_LENGTH * SCALE) / 2; const centerY = (RINK_WIDTH * SCALE) / 2; // Draw ice surface graphics.fillStyle(COLOR_ICE, 1); graphics.fillRoundedRect(2, 2, RINK_LENGTH * SCALE - 4, RINK_WIDTH * SCALE - 4, RINK_CORNER_RADIUS * SCALE); // Draw center red line (x = 0 in game coords, centerX in screen coords) graphics.lineStyle(3, COLOR_RED_LINE, 1); graphics.lineBetween(centerX, 0, centerX, RINK_WIDTH * SCALE); // Draw blue lines graphics.lineStyle(3, COLOR_BLUE_LINE, 1); // Left blue line const leftBlueLineX = centerX - (BLUE_LINE_OFFSET * SCALE); graphics.lineBetween(leftBlueLineX, 0, leftBlueLineX, RINK_WIDTH * SCALE); // Right blue line const rightBlueLineX = centerX + (BLUE_LINE_OFFSET * SCALE); graphics.lineBetween(rightBlueLineX, 0, rightBlueLineX, RINK_WIDTH * SCALE); // Draw goal lines graphics.lineStyle(3, 0xff0000, 1); const leftGoalLineX = centerX - (GOAL_LINE_OFFSET * SCALE); const rightGoalLineX = centerX + (GOAL_LINE_OFFSET * SCALE); graphics.lineBetween(leftGoalLineX, 4, leftGoalLineX, RINK_WIDTH * SCALE - 4); graphics.lineBetween(rightGoalLineX, 4, rightGoalLineX, RINK_WIDTH * SCALE - 4); // Draw center faceoff circle graphics.lineStyle(2, 0x0000ff, 1); graphics.strokeCircle(centerX, centerY, FACEOFF_CIRCLE_RADIUS * SCALE); // Draw center dot graphics.fillStyle(0x0000ff, 1); graphics.fillCircle(centerX, centerY, CENTER_DOT_RADIUS); // Draw boards (border) graphics.lineStyle(4, COLOR_BOARDS, 1); graphics.strokeRoundedRect(2, 2, RINK_LENGTH * SCALE - 4, RINK_WIDTH * SCALE - 4, RINK_CORNER_RADIUS * SCALE); } private createPauseButton() { const centerX = (RINK_LENGTH * SCALE) / 2; const rinkHeight = RINK_WIDTH * SCALE; // Create pause button at bottom center this.pauseButton = this.add.text(centerX, rinkHeight + 20, 'PAUSE', { fontSize: '20px', color: '#ffffff', backgroundColor: '#333333', padding: { x: 16, y: 8 } }) .setOrigin(0.5) .setInteractive({ useHandCursor: true }) .on('pointerdown', () => this.togglePause()); } private togglePause() { this.isPaused = !this.isPaused; if (this.isPaused) { // Pause physics and update button this.physics.pause(); this.pauseButton.setText('RESUME'); this.pauseButton.setBackgroundColor('#2d6a2d'); } else { // Resume physics and update button this.physics.resume(); this.pauseButton.setText('PAUSE'); this.pauseButton.setBackgroundColor('#333333'); } } update(_time: number, delta: number) { if (this.isPaused) return; // Handle goal reset timer if (this.isWaitingForReset) { this.goalResetTimer -= delta; if (this.goalResetTimer <= 0) { // Clean up before restart this.events.off('goal'); this.scene.restart(); } return; // Don't update anything while waiting for reset } this.updatePuck(); this.updatePlayers(delta); this.checkGoals(); } private updatePuck() { this.puck.update(); this.checkPuckPickup(); } private updatePlayers(delta: number) { // Build game state const gameState = { puck: this.puck, allPlayers: this.players }; // Update all players with behavior tree decisions this.players.forEach(player => { // Get cached behavior tree for this player const tree = this.behaviorTrees.get(player.id); if (!tree) return; // Evaluate behavior tree to get action const action = tree.tick(gameState); // Apply action to player if (action.type === 'shoot') { this.executeShot(player, action.targetX!, action.targetY!); } else if (action.type === 'move' || action.type === 'chase_puck' || action.type === 'skate_with_puck') { if (action.targetX !== undefined && action.targetY !== undefined) { player.setTarget(action.targetX, action.targetY); } } // Update player movement player.update(delta); // If player has puck, update puck position (in front of player) this.updatePuckCarrier(player); }); } private updatePuckCarrier(player: Player) { if (this.puck.carrier !== player.id) return; const distance = MathUtils.distance(player.gameX, player.gameY, player.targetX, player.targetY); if (distance > MOVEMENT_STOP_THRESHOLD) { // Place puck in front of player in direction of movement const dx = player.targetX - player.gameX; const dy = player.targetY - player.gameY; const dirX = dx / distance; const dirY = dy / distance; this.puck.setGamePosition( player.gameX + dirX * PUCK_CARRY_DISTANCE, player.gameY + dirY * PUCK_CARRY_DISTANCE ); } else { // If not moving, keep puck at player position this.puck.setGamePosition(player.gameX, player.gameY); } } private checkGoals() { this.leftGoal.checkGoal(this.puck); this.rightGoal.checkGoal(this.puck); } /** * Check if players can receive the puck (skill-based with puck speed factor) */ private checkPuckPickup() { if (this.puck.state !== 'loose') return; const currentTime = Date.now(); // Calculate puck speed in m/s const puckVelX = this.puck.body.velocity.x / SCALE; const puckVelY = this.puck.body.velocity.y / SCALE; const puckSpeed = Math.sqrt(puckVelX * puckVelX + puckVelY * puckVelY); // Check each player's distance to puck this.players.forEach(player => { const distance = MathUtils.distance(player.gameX, player.gameY, this.puck.gameX, this.puck.gameY); if (distance < PUCK_PICKUP_RADIUS) { // Check if enough time has passed since last attempt (prevents spam) const lastAttempt = this.lastReceptionAttempt.get(player.id) || 0; if (currentTime - lastAttempt < PUCK_RECEPTION_CHECK_INTERVAL) { return; // Too soon since last attempt } // Update last attempt time this.lastReceptionAttempt.set(player.id, currentTime); // Calculate reception success chance based on handling skill and puck speed const receptionChance = this.calculateReceptionChance(player.attributes.handling, puckSpeed); // Attempt to receive the puck if (Math.random() < receptionChance) { this.puck.setCarrier(player.id, player.team); console.log( `[Reception] ${player.id} received puck | ` + `Handling: ${player.attributes.handling} | ` + `Puck speed: ${puckSpeed.toFixed(1)} m/s | ` + `Success chance: ${(receptionChance * 100).toFixed(1)}%` ); } else { console.log( `[Reception] ${player.id} FAILED to receive puck | ` + `Handling: ${player.attributes.handling} | ` + `Puck speed: ${puckSpeed.toFixed(1)} m/s | ` + `Success chance: ${(receptionChance * 100).toFixed(1)}%` ); } } }); } /** * Calculate puck reception success chance based on handling skill and puck speed * @param handling - Player's handling skill (0-100) * @param puckSpeed - Puck speed in m/s * @returns Success chance (0-1) */ private calculateReceptionChance(handling: number, puckSpeed: number): number { // 1. Base chance from handling skill (handling / 100) // Scale it with the base chance constant const skillFactor = (handling / 100) * PUCK_RECEPTION_BASE_CHANCE; // 2. Speed modifier (easier for slow pucks, harder for fast ones) let speedModifier: number; if (puckSpeed <= PUCK_RECEPTION_SPEED_EASY) { // Easy reception - no penalty speedModifier = 1.0; } else if (puckSpeed >= PUCK_RECEPTION_SPEED_HARD) { // Very hard reception - 30% of normal chance speedModifier = 0.3; } else { // Linear interpolation between easy and hard thresholds const speedRange = PUCK_RECEPTION_SPEED_HARD - PUCK_RECEPTION_SPEED_EASY; const speedExcess = puckSpeed - PUCK_RECEPTION_SPEED_EASY; const speedRatio = speedExcess / speedRange; // Interpolate from 1.0 (easy) to 0.3 (hard) speedModifier = 1.0 - (speedRatio * 0.7); } // 3. Final reception chance return skillFactor * speedModifier; } /** * Handle puck bouncing off goal posts with randomized angle and speed reduction */ private handlePuckPostBounce() { // Get current puck velocity const vx = this.puck.body.velocity.x; const vy = this.puck.body.velocity.y; // Calculate current angle and speed const currentAngle = Math.atan2(vy, vx); const currentSpeed = Math.sqrt(vx * vx + vy * vy); // Add random variation to bounce angle (-0.3 to +0.3 radians, ~±17 degrees) const randomAngle = (Math.random() - 0.5) * 2 * POST_BOUNCE_ANGLE_RANDOMNESS; const newAngle = currentAngle + randomAngle; // Reduce speed by 30% const newSpeed = currentSpeed * POST_BOUNCE_SPEED_REDUCTION; // Apply new velocity this.puck.body.setVelocity( Math.cos(newAngle) * newSpeed, Math.sin(newAngle) * newSpeed ); console.log(`[Post bounce] Speed: ${(currentSpeed / SCALE).toFixed(1)} → ${(newSpeed / SCALE).toFixed(1)} m/s, Angle variation: ${(randomAngle * 180 / Math.PI).toFixed(1)}°`); } private executeShot(player: Player, targetX: number, targetY: number) { console.log(`${player.id} shoots toward (${targetX}, ${targetY})`); // Release puck from player control this.puck.setLoose(); // Calculate shot direction const distance = MathUtils.distance(player.gameX, player.gameY, targetX, targetY); if (distance > 0) { const dx = targetX - player.gameX; const dy = targetY - player.gameY; // Normalize direction const dirX = dx / distance; const dirY = dy / distance; const shotSpeed = SHOT_SPEED * SCALE; // Apply velocity to puck this.puck.body.setVelocity(dirX * shotSpeed, -dirY * shotSpeed); } } /** * Handle collision between two players - determines and executes tackle */ private handlePlayerCollision(player1: Player, player2: Player) { // Determine who is the tackler and who is being tackled let tackler: Player; let tackled: Player; // If one player has the puck, the other tackles if (this.puck.carrier === player1.id) { tackler = player2; tackled = player1; } else if (this.puck.carrier === player2.id) { tackler = player1; tackled = player2; } else { // Neither has puck - faster player tackles slower one const player1Speed = Math.sqrt(player1.body.velocity.x ** 2 + player1.body.velocity.y ** 2); const player2Speed = Math.sqrt(player2.body.velocity.x ** 2 + player2.body.velocity.y ** 2); if (player1Speed >= player2Speed) { tackler = player1; tackled = player2; } else { tackler = player2; tackled = player1; } } // Check if EITHER player is on cooldown (prevents double tackle execution) if (!tackler.canTackle() || !tackled.canTackle()) { return; } // Execute tackle and mark both players as having participated this.executeTackle(tackler, tackled); tackled.setTacklePerformed(); // Mark tackled player too to prevent instant counter-tackle } /** * Execute tackle using tackling skill vs balance skill */ private executeTackle(tackler: Player, tackled: Player) { // Check if tackler has sufficient speed to execute tackle const tacklerSpeed = tackler.getCurrentSpeed(); if (tacklerSpeed < TACKLE_MIN_SPEED) { console.log( `[Tackle] ${tackler.id} moving too slow (${tacklerSpeed.toFixed(1)} m/s < ${TACKLE_MIN_SPEED} m/s) - tackle impossible` ); return; // Tackle cannot occur } // 1. Calculate approach angle modifier // Get velocity vectors (using physics body velocity) const tacklerVelX = tackler.body.velocity.x / SCALE; // Convert to m/s const tacklerVelY = tackler.body.velocity.y / SCALE; const tackledVelX = tackled.body.velocity.x / SCALE; const tackledVelY = tackled.body.velocity.y / SCALE; // Calculate angle of attack: angle between tackler's velocity and direction to tackled player const dx = tackled.gameX - tackler.gameX; const dy = tackled.gameY - tackler.gameY; const angleToTarget = Math.atan2(dy, dx); const tacklerVelocityAngle = Math.atan2(tacklerVelY, tacklerVelX); // Angle difference (how aligned is tackler's movement with target direction) let approachAngle = Math.abs(angleToTarget - tacklerVelocityAngle); // Normalize to 0-PI range if (approachAngle > Math.PI) approachAngle = 2 * Math.PI - approachAngle; // Determine angle modifier based on approach angle let angleModifier: number; if (approachAngle <= TACKLE_ANGLE_HEAD_ON_THRESHOLD) { angleModifier = TACKLE_ANGLE_HEAD_ON_MODIFIER; // Head-on } else if (approachAngle <= TACKLE_ANGLE_SIDE_THRESHOLD) { angleModifier = TACKLE_ANGLE_SIDE_MODIFIER; // Side angle } else if (approachAngle <= TACKLE_ANGLE_BEHIND_THRESHOLD) { angleModifier = TACKLE_ANGLE_BEHIND_MODIFIER; // From behind } else { angleModifier = TACKLE_ANGLE_OPPOSITE_MODIFIER; // Opposite direction } // 2. Calculate relative velocity differential (closing speed) // Project velocities onto the line connecting the players const distSq = dx * dx + dy * dy; const dist = Math.sqrt(distSq); if (dist < 0.01) { // Players are on top of each other, skip velocity calculation return; } const dirX = dx / dist; // Unit vector toward tackled player const dirY = dy / dist; // Closing velocity = tackler's velocity toward target - tackled's velocity toward tackler const tacklerClosingVel = tacklerVelX * dirX + tacklerVelY * dirY; const tackledClosingVel = -(tackledVelX * dirX + tackledVelY * dirY); // Negative because measuring toward tackler const closingSpeed = tacklerClosingVel + tackledClosingVel; // Calculate velocity modifier based on closing speed // Map closing speed to modifier range let velocityModifier: number; if (closingSpeed <= TACKLE_CLOSING_SPEED_WEAK) { velocityModifier = TACKLE_VELOCITY_MODIFIER_MIN; } else if (closingSpeed >= TACKLE_CLOSING_SPEED_SOLID) { velocityModifier = TACKLE_VELOCITY_MODIFIER_MAX; } else { // Linear interpolation between weak and solid const ratio = (closingSpeed - TACKLE_CLOSING_SPEED_WEAK) / (TACKLE_CLOSING_SPEED_SOLID - TACKLE_CLOSING_SPEED_WEAK); velocityModifier = TACKLE_VELOCITY_MODIFIER_MIN + ratio * (TACKLE_VELOCITY_MODIFIER_MAX - TACKLE_VELOCITY_MODIFIER_MIN); } // 3. Calculate base tackle success based on tackling vs balance const tacklerSkill = tackler.attributes.tackling; const tackledBalance = tackled.attributes.balance; const baseSuccess = (tacklerSkill / (tacklerSkill + tackledBalance)) * TACKLE_SUCCESS_MODIFIER; // 4. Apply all modifiers const finalSuccessChance = baseSuccess * angleModifier * velocityModifier; const success = Math.random() < finalSuccessChance; console.log( `[Tackle] ${tackler.id} → ${tackled.id} | ` + `Base: ${(baseSuccess * 100).toFixed(1)}% | ` + `Angle: ${(approachAngle * 180 / Math.PI).toFixed(0)}° (×${angleModifier.toFixed(2)}) | ` + `Closing: ${closingSpeed.toFixed(1)} m/s (×${velocityModifier.toFixed(2)}) | ` + `Final: ${(finalSuccessChance * 100).toFixed(1)}% → ${success ? 'SUCCESS' : 'FAILED'}` ); // Mark tackle performed (cooldown starts) tackler.setTacklePerformed(); if (success) { // Successful tackle if (this.puck.carrier === tackled.id) { // Tackled player had puck - determine if it becomes loose if (Math.random() < TACKLE_PUCK_LOOSE_CHANCE) { console.log(`[Tackle] Puck knocked loose!`); this.puck.setLoose(); // Give puck some velocity in random direction const angle = Math.random() * Math.PI * 2; const speed = 3 * SCALE; this.puck.body.setVelocity(Math.cos(angle) * speed, Math.sin(angle) * speed); } else { // Tackler takes possession console.log(`[Tackle] ${tackler.id} steals the puck!`); this.puck.setCarrier(tackler.id, tackler.team); } } // Make tackled player fall tackled.fall(); } } }