hockey-manager-2/src/game/GameScene.ts
Pierre Wessman 29885d1992 Add pause-aware goal reset timer with proper scene cleanup
- Add 3-second countdown timer after goal that resets the scene
- Timer pauses when game is paused, respecting pause state
- Freeze all game updates (puck, players, goals) during countdown
- Properly reset timer flags in create() to fix post-restart state
- Add safety checks in Player and Puck update to handle destroyed entities
- Remove player goal deceleration behavior (superseded by scene freeze)
- Add destroy() method to Player for proper resource cleanup
- Clean up goal event listeners before scene restart

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 16:12:21 +02:00

721 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, BehaviorTree> = new Map();
// Track last reception attempt time for each player to avoid spamming attempts
private lastReceptionAttempt: Map<string, number> = 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();
}
}
}