- 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>
721 lines
24 KiB
TypeScript
721 lines
24 KiB
TypeScript
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();
|
||
}
|
||
}
|
||
}
|