From c81e500e185369cf4013bd09df08456715aad4a1 Mon Sep 17 00:00:00 2001 From: Pierre Wessman <4029607+pierrewessman@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:50:35 +0200 Subject: [PATCH] Implement player tackling system with collision detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/config/constants.ts | 7 +++ src/entities/Player.ts | 131 +++++++++++++++++++++++++++------------- src/game/GameScene.ts | 98 +++++++++++++++++++++++++++++- src/types/game.ts | 6 +- 4 files changed, 194 insertions(+), 48 deletions(-) diff --git a/src/config/constants.ts b/src/config/constants.ts index ca842ba..0c2cc3e 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -36,6 +36,8 @@ export const GOAL_DECELERATION_RATE = 0.9; // Speed multiplier reduction after g // Movement constants 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 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_ANGLE_THRESHOLD = Math.PI / 4; // radians (45 degrees) 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 diff --git a/src/entities/Player.ts b/src/entities/Player.ts index ee58b2e..bcca4bb 100644 --- a/src/entities/Player.ts +++ b/src/entities/Player.ts @@ -7,7 +7,10 @@ import { SPEED_SCALE_FACTOR, MOVEMENT_STOP_THRESHOLD, GOAL_DECELERATION_RATE, - DEBUG + DEBUG, + TACKLE_COOLDOWN, + PLAYER_ACCELERATION, + PLAYER_DECELERATION } from '../config/constants'; import { CoordinateUtils } from '../utils/coordinates'; import { MathUtils } from '../utils/math'; @@ -39,6 +42,12 @@ export class Player extends Phaser.GameObjects.Container { // 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; + // Debug visualizations private debugTargetGraphics?: Phaser.GameObjects.Graphics; private debugLineGraphics?: Phaser.GameObjects.Graphics; @@ -189,54 +198,75 @@ export class Player extends Phaser.GameObjects.Container { * Update player movement each frame */ public update(delta: number) { + const deltaSeconds = delta / 1000; + // Calculate distance to target 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) { - this.body.setVelocity(0, 0); - return; + // 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 } - 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 velocity (faster = wider turns) - const deltaSeconds = delta / 1000; - 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 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); - - // Update visual rotation (convert to degrees, subtract 90 because 0 angle is up in Phaser) - 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 - const velX = Math.cos(this.currentAngle) * speed; - const velY = Math.sin(this.currentAngle) * speed; - - 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; @@ -248,6 +278,21 @@ export class Player extends Phaser.GameObjects.Container { 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) */ diff --git a/src/game/GameScene.ts b/src/game/GameScene.ts index 1ddfe4f..86e4abd 100644 --- a/src/game/GameScene.ts +++ b/src/game/GameScene.ts @@ -15,7 +15,9 @@ import { MOVEMENT_STOP_THRESHOLD, PUCK_CARRY_DISTANCE, PUCK_PICKUP_RADIUS, - SHOT_SPEED + SHOT_SPEED, + TACKLE_SUCCESS_MODIFIER, + TACKLE_PUCK_LOOSE_CHANCE } from '../config/constants'; import { Goal } from './Goal'; import { Puck } from '../entities/Puck'; @@ -50,7 +52,7 @@ export class GameScene extends Phaser.Scene { 'C', -10, -5, - { speed: 80, skill: 75 } + { speed: 80, skill: 75, tackling: 70, balance: 75 } ); // Create one away defender at (15, 0) - right side for defending @@ -61,10 +63,15 @@ export class GameScene extends Phaser.Scene { 'LD', 15, 0, - { speed: 75, skill: 70 } + { speed: 75, skill: 70, tackling: 85, balance: 80 } ); 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() { @@ -254,4 +261,89 @@ export class GameScene extends Phaser.Scene { 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 + ); + } + } } diff --git a/src/types/game.ts b/src/types/game.ts index 235dad4..c952dbf 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -25,8 +25,10 @@ export type PuckState = 'loose' | 'carried' | 'passing' | 'shot'; * Player attributes (0-100 scale) */ export interface PlayerAttributes { - speed: number; // 0-100: movement speed - skill: number; // 0-100: pass/shot accuracy, decision quality + speed: number; // 0-100: movement speed + 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 } /**