Compare commits

...

3 Commits

Author SHA1 Message Date
Pierre Wessman
c81e500e18 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>
2025-10-02 10:50:35 +02:00
Pierre Wessman
9e81619637 Implement defensive chase behavior and increase rotation speed
- Add defensive AI: players now chase puck carriers on opposing team
- Update DefensiveBehavior to check opponent possession and target puck position
- Increase player rotation speed from 3 to 10 rad/s for more responsive turning
- Update Claude settings permissions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 10:20:00 +02:00
Pierre Wessman
ff97b227c2 Add player enhancements and debug features
- Add away team defender (LD) for testing behaviors
- Add direction indicator (white line) showing player facing direction
- Initialize player facing angle based on team (home faces right, away faces left)
- Implement speed-based turning physics (faster = wider turns, rotation drops to 30% at max speed)
- Add debug visualizations for player targets (line and X marker) when DEBUG is true
- Increase rotation speed constant from 1 to 3 rad/s for more responsive turning

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 10:08:00 +02:00
6 changed files with 302 additions and 57 deletions

View File

@ -3,7 +3,9 @@
"allow": [
"Bash(find:*)",
"Bash(cat:*)",
"Bash(pnpm run:*)"
"Bash(pnpm run:*)",
"Bash(mkdir:*)",
"Bash(git log:*)"
],
"deny": [],
"ask": []

View File

@ -25,17 +25,19 @@ export const COLOR_GOAL_CREASE = 0x87ceeb;
// Game settings
export const FPS = 60;
export const DEBUG = true;
export const DEBUG = false;
// Player constants
export const PLAYER_RADIUS_GOALIE = 12; // pixels
export const PLAYER_RADIUS_SKATER = 10; // pixels
export const PLAYER_ROTATION_SPEED = 1; // radians per second
export const PLAYER_ROTATION_SPEED = 10; // radians per second
export const SPEED_SCALE_FACTOR = 10; // speed attribute (0-100) / 10 = m/s
export const GOAL_DECELERATION_RATE = 0.9; // Speed multiplier reduction after goal
// 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

View File

@ -6,7 +6,11 @@ import {
PLAYER_RADIUS_SKATER,
SPEED_SCALE_FACTOR,
MOVEMENT_STOP_THRESHOLD,
GOAL_DECELERATION_RATE
GOAL_DECELERATION_RATE,
DEBUG,
TACKLE_COOLDOWN,
PLAYER_ACCELERATION,
PLAYER_DECELERATION
} from '../config/constants';
import { CoordinateUtils } from '../utils/coordinates';
import { MathUtils } from '../utils/math';
@ -38,6 +42,19 @@ 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;
// Direction indicator
private directionIndicator!: Phaser.GameObjects.Graphics;
constructor(
scene: Phaser.Scene,
id: string,
@ -62,6 +79,10 @@ export class Player extends Phaser.GameObjects.Container {
this.attributes = attributes;
this.state = 'defensive';
// Initialize facing direction based on team
// Home team (left side) faces right, Away team (right side) faces left
this.currentAngle = this.team === 'home' ? 0 : Math.PI;
// Listen for goal events
this.scene.events.on('goal', this.onGoal, this);
@ -78,6 +99,12 @@ export class Player extends Phaser.GameObjects.Container {
this.body.setCollideWorldBounds(true);
this.createSprite();
// Create debug graphics if DEBUG is enabled
if (DEBUG) {
this.debugTargetGraphics = scene.add.graphics();
this.debugLineGraphics = scene.add.graphics();
}
}
private createSprite() {
@ -105,7 +132,28 @@ export class Player extends Phaser.GameObjects.Container {
});
label.setOrigin(0.5, 0.5);
this.add([graphics, label]);
// Create direction indicator (small line showing facing direction)
this.directionIndicator = this.scene.add.graphics();
this.updateDirectionIndicator();
this.add([graphics, label, this.directionIndicator]);
}
/**
* Update the direction indicator to show current facing angle
*/
private updateDirectionIndicator() {
this.directionIndicator.clear();
const radius = this.playerPosition === 'G' ? PLAYER_RADIUS_GOALIE : PLAYER_RADIUS_SKATER;
// Draw line pointing right (container rotation will orient it correctly)
// Line goes from behind center to near the edge (0=center, 1=edge)
const startX = -radius * -0.5; // Start
const endX = radius * 1.0; // End
this.directionIndicator.lineStyle(3, 0x999999, 1);
this.directionIndicator.lineBetween(startX, 0, endX, 0); // Horizontal line, rotation handled by container
}
/**
@ -150,54 +198,134 @@ 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
const deltaSeconds = delta / 1000;
const maxRotation = PLAYER_ROTATION_SPEED * 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;
const gamePos = CoordinateUtils.screenToGame(this.scene, bodyX, bodyY);
this.gameX = gamePos.x;
this.gameY = gamePos.y;
// Update debug visualizations
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)
*/
private updateDebugVisuals() {
if (!DEBUG || !this.debugTargetGraphics || !this.debugLineGraphics) return;
// Convert target position to screen coordinates
const targetScreen = CoordinateUtils.gameToScreen(this.scene, this.targetX, this.targetY);
const playerScreen = CoordinateUtils.gameToScreen(this.scene, this.gameX, this.gameY);
// Clear previous debug graphics
this.debugTargetGraphics.clear();
this.debugLineGraphics.clear();
// Draw line from player to target
const lineColor = this.team === 'home' ? 0x0000ff : 0xff0000;
this.debugLineGraphics.lineStyle(1, lineColor, 0.5);
this.debugLineGraphics.lineBetween(playerScreen.x, playerScreen.y, targetScreen.x, targetScreen.y);
// Draw target marker (X)
const markerSize = 5;
this.debugTargetGraphics.lineStyle(2, lineColor, 0.8);
this.debugTargetGraphics.lineBetween(
targetScreen.x - markerSize,
targetScreen.y - markerSize,
targetScreen.x + markerSize,
targetScreen.y + markerSize
);
this.debugTargetGraphics.lineBetween(
targetScreen.x + markerSize,
targetScreen.y - markerSize,
targetScreen.x - markerSize,
targetScreen.y + markerSize
);
}
}

View File

@ -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';
@ -49,11 +51,27 @@ export class GameScene extends Phaser.Scene {
'home',
'C',
-10,
-10,
{ speed: 80, skill: 75 }
-5,
{ speed: 80, skill: 75, tackling: 70, balance: 75 }
);
this.players.push(homeCenter);
// Create one away defender at (15, 0) - right side for defending
const awayDefender = new Player(
this,
'away-LD',
'away',
'LD',
15,
0,
{ 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() {
@ -243,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
);
}
}
}

View File

@ -27,17 +27,20 @@ export class DefensiveBehavior extends BehaviorNode {
if (puck.state !== 'carried') return false;
if (puck.carrier === player.id) return false;
// TODO: Check if carrier is opponent (requires team roster access)
// For now, just fail - will implement in Phase 6
return false;
// Check if carrier is opponent (different team)
return puck.carrierTeam !== player.team;
}),
// Defensive action (future implementation)
new Action((player) => ({
type: 'idle',
targetX: player.gameX,
targetY: player.gameY
}))
// Chase the puck carrier
new Action((player, gameState) => {
const { puck } = gameState;
return {
type: 'chase_puck',
targetX: puck.gameX,
targetY: puck.gameY
};
})
]);
}

View File

@ -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
}
/**