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:
parent
9e81619637
commit
c81e500e18
@ -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
|
||||||
|
|||||||
@ -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,54 +198,75 @@ 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) {
|
||||||
this.body.setVelocity(0, 0);
|
// Decelerate
|
||||||
return;
|
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
|
// 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;
|
||||||
const bodyY = this.body.y + this.body.height / 2;
|
const bodyY = this.body.y + this.body.height / 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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,8 +25,10 @@ export type PuckState = 'loose' | 'carried' | 'passing' | 'shot';
|
|||||||
* Player attributes (0-100 scale)
|
* Player attributes (0-100 scale)
|
||||||
*/
|
*/
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user