Implement player tackling system with collision detection

Added a realistic tackling system where players can physically contest for the puck:
- Player-player collisions trigger tackle calculations based on tackling vs balance attributes
- Tackle success uses skill-based formula with configurable modifier
- Successful tackles can steal puck or knock it loose (60% chance)
- Cooldown system prevents rapid successive tackles (1s between attempts)
- Tackled players experience significant momentum loss on successful tackles

Also improved player movement physics:
- Added smooth acceleration/deceleration with ease-out curves
- Players now gradually build up speed (10 m/s²) and brake quickly (20 m/s²)
- More realistic stopping behavior when reaching target positions

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pierre Wessman 2025-10-02 10:50:35 +02:00
parent 9e81619637
commit c81e500e18
4 changed files with 194 additions and 48 deletions

View File

@ -36,6 +36,8 @@ export const GOAL_DECELERATION_RATE = 0.9; // Speed multiplier reduction after g
// Movement constants // Movement constants
export const MOVEMENT_STOP_THRESHOLD = 0.1; // meters - stop moving when this close to target export const MOVEMENT_STOP_THRESHOLD = 0.1; // meters - stop moving when this close to target
export const PLAYER_ACCELERATION = 10; // m/s² - how quickly player reaches max speed
export const PLAYER_DECELERATION = 20; // m/s² - how quickly player stops
// Puck constants // Puck constants
export const PUCK_RADIUS = 0.2; // meters (regulation hockey puck is ~7.6cm diameter, make it larger to be visible) export const PUCK_RADIUS = 0.2; // meters (regulation hockey puck is ~7.6cm diameter, make it larger to be visible)
@ -56,3 +58,8 @@ export const CENTER_DOT_RADIUS = 5; // pixels
export const SHOOTING_RANGE = 10; // meters - max distance to attempt shot export const SHOOTING_RANGE = 10; // meters - max distance to attempt shot
export const SHOOTING_ANGLE_THRESHOLD = Math.PI / 4; // radians (45 degrees) export const SHOOTING_ANGLE_THRESHOLD = Math.PI / 4; // radians (45 degrees)
export const GOALIE_RANGE = 3; // meters - how far goalie moves from center export const GOALIE_RANGE = 3; // meters - how far goalie moves from center
// Tackle constants
export const TACKLE_SUCCESS_MODIFIER = 1; // Multiplier for tackle success calculation (balancing)
export const TACKLE_PUCK_LOOSE_CHANCE = 0.6; // Chance puck becomes loose after tackle
export const TACKLE_COOLDOWN = 1000; // ms - time between tackles for same player

View File

@ -7,7 +7,10 @@ import {
SPEED_SCALE_FACTOR, SPEED_SCALE_FACTOR,
MOVEMENT_STOP_THRESHOLD, MOVEMENT_STOP_THRESHOLD,
GOAL_DECELERATION_RATE, GOAL_DECELERATION_RATE,
DEBUG DEBUG,
TACKLE_COOLDOWN,
PLAYER_ACCELERATION,
PLAYER_DECELERATION
} from '../config/constants'; } from '../config/constants';
import { CoordinateUtils } from '../utils/coordinates'; import { CoordinateUtils } from '../utils/coordinates';
import { MathUtils } from '../utils/math'; import { MathUtils } from '../utils/math';
@ -39,6 +42,12 @@ export class Player extends Phaser.GameObjects.Container {
// Rotation (angle in radians, 0 = facing right) // Rotation (angle in radians, 0 = facing right)
private currentAngle: number = 0; private currentAngle: number = 0;
// Current speed (m/s in game coordinates)
private currentSpeed: number = 0;
// Tackle cooldown tracking
private lastTackleTime: number = 0;
// Debug visualizations // Debug visualizations
private debugTargetGraphics?: Phaser.GameObjects.Graphics; private debugTargetGraphics?: Phaser.GameObjects.Graphics;
private debugLineGraphics?: Phaser.GameObjects.Graphics; private debugLineGraphics?: Phaser.GameObjects.Graphics;
@ -189,15 +198,30 @@ export class Player extends Phaser.GameObjects.Container {
* Update player movement each frame * Update player movement each frame
*/ */
public update(delta: number) { public update(delta: number) {
const deltaSeconds = delta / 1000;
// Calculate distance to target // Calculate distance to target
const distance = MathUtils.distance(this.gameX, this.gameY, this.targetX, this.targetY); const distance = MathUtils.distance(this.gameX, this.gameY, this.targetX, this.targetY);
// If close enough to target, stop // 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) { 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); this.body.setVelocity(0, 0);
return; 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 dx = this.targetX - this.gameX;
const dy = this.targetY - this.gameY; const dy = this.targetY - this.gameY;
@ -205,11 +229,8 @@ export class Player extends Phaser.GameObjects.Container {
const targetAngle = Math.atan2(dy, dx); const targetAngle = Math.atan2(dy, dx);
// Smoothly rotate toward target angle // Smoothly rotate toward target angle
// Scale rotation speed inversely with current velocity (faster = wider turns) // Scale rotation speed inversely with current speed (faster = wider turns)
const deltaSeconds = delta / 1000; const speedRatio = maxSpeedMS > 0 ? this.currentSpeed / maxSpeedMS : 0;
const currentSpeed = Math.sqrt(this.body.velocity.x ** 2 + this.body.velocity.y ** 2);
const maxSpeed = (this.attributes.speed / SPEED_SCALE_FACTOR) * SCALE;
const speedRatio = maxSpeed > 0 ? currentSpeed / maxSpeed : 0;
const turnPenalty = 1 - (speedRatio * 0.7); // At max speed, rotation is 30% of base const turnPenalty = 1 - (speedRatio * 0.7); // At max speed, rotation is 30% of base
const maxRotation = PLAYER_ROTATION_SPEED * turnPenalty * deltaSeconds; const maxRotation = PLAYER_ROTATION_SPEED * turnPenalty * deltaSeconds;
@ -223,19 +244,28 @@ export class Player extends Phaser.GameObjects.Container {
// Normalize current angle // Normalize current angle
this.currentAngle = MathUtils.normalizeAngle(this.currentAngle); this.currentAngle = MathUtils.normalizeAngle(this.currentAngle);
// Update visual rotation (convert to degrees, subtract 90 because 0 angle is up in Phaser) // 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); this.setRotation(-this.currentAngle);
// Calculate velocity based on current facing direction and speed attribute
// speed attribute (0-100) maps to actual m/s (e.g., 80 -> 8 m/s)
const baseSpeed = (this.attributes.speed / SPEED_SCALE_FACTOR) * SCALE; // Convert to pixels/s
const speed = baseSpeed * this.speedMultiplier; // Apply speed multiplier
// Move in the direction the player is currently facing // Move in the direction the player is currently facing
const velX = Math.cos(this.currentAngle) * speed; const speedPixels = this.currentSpeed * SCALE; // Convert m/s to pixels/s
const velY = Math.sin(this.currentAngle) * speed; const velX = Math.cos(this.currentAngle) * speedPixels;
const velY = Math.sin(this.currentAngle) * speedPixels;
this.body.setVelocity(velX, -velY); // Negative Y because screen coords this.body.setVelocity(velX, -velY); // Negative Y because screen coords
}
// Update game position based on physics body center // Update game position based on physics body center
const bodyX = this.body.x + this.body.width / 2; const bodyX = this.body.x + this.body.width / 2;
@ -248,6 +278,21 @@ export class Player extends Phaser.GameObjects.Container {
this.updateDebugVisuals(); 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();
}
/** /**
* Update debug visualizations (target position and path line) * Update debug visualizations (target position and path line)
*/ */

View File

@ -15,7 +15,9 @@ import {
MOVEMENT_STOP_THRESHOLD, MOVEMENT_STOP_THRESHOLD,
PUCK_CARRY_DISTANCE, PUCK_CARRY_DISTANCE,
PUCK_PICKUP_RADIUS, PUCK_PICKUP_RADIUS,
SHOT_SPEED SHOT_SPEED,
TACKLE_SUCCESS_MODIFIER,
TACKLE_PUCK_LOOSE_CHANCE
} from '../config/constants'; } from '../config/constants';
import { Goal } from './Goal'; import { Goal } from './Goal';
import { Puck } from '../entities/Puck'; import { Puck } from '../entities/Puck';
@ -50,7 +52,7 @@ export class GameScene extends Phaser.Scene {
'C', 'C',
-10, -10,
-5, -5,
{ speed: 80, skill: 75 } { speed: 80, skill: 75, tackling: 70, balance: 75 }
); );
// Create one away defender at (15, 0) - right side for defending // Create one away defender at (15, 0) - right side for defending
@ -61,10 +63,15 @@ export class GameScene extends Phaser.Scene {
'LD', 'LD',
15, 15,
0, 0,
{ speed: 75, skill: 70 } { speed: 75, skill: 70, tackling: 85, balance: 80 }
); );
this.players.push(homeCenter, awayDefender); this.players.push(homeCenter, awayDefender);
// Setup player-player collisions
this.physics.add.collider(homeCenter, awayDefender, (obj1, obj2) => {
this.handlePlayerCollision(obj1 as Player, obj2 as Player);
});
} }
private setupEventListeners() { private setupEventListeners() {
@ -254,4 +261,89 @@ export class GameScene extends Phaser.Scene {
this.puck.body.setVelocity(dirX * shotSpeed, -dirY * shotSpeed); 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) {
// Calculate tackle success based on tackling vs balance
// Formula: (tackling / (tackling + balance)) * modifier
const tacklerSkill = tackler.attributes.tackling;
const tackledBalance = tackled.attributes.balance;
const successChance = (tacklerSkill / (tacklerSkill + tackledBalance)) * TACKLE_SUCCESS_MODIFIER;
const success = Math.random() < successChance;
console.log(
`[Tackle] ${tackler.id} (tackling: ${tacklerSkill}) tackles ${tackled.id} (balance: ${tackledBalance}) - ${success ? 'SUCCESS' : 'FAILED'} (${(successChance * 100).toFixed(1)}%)`
);
// 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);
}
}
// Apply physical impact to tackled player (significantly slow them down)
tackled.body.setVelocity(
tackled.body.velocity.x * 0.1,
tackled.body.velocity.y * 0.1
);
}
}
} }

View File

@ -27,6 +27,8 @@ export type PuckState = 'loose' | 'carried' | 'passing' | 'shot';
export interface PlayerAttributes { export interface PlayerAttributes {
speed: number; // 0-100: movement speed speed: number; // 0-100: movement speed
skill: number; // 0-100: pass/shot accuracy, decision quality skill: number; // 0-100: pass/shot accuracy, decision quality
tackling: number; // 0-100: ability to execute successful tackles
balance: number; // 0-100: ability to resist being tackled
} }
/** /**