Compare commits

..

No commits in common. "c81e500e185369cf4013bd09df08456715aad4a1" and "a3ffc9491603c032909cdd2ac87df1a8351cfd79" have entirely different histories.

6 changed files with 57 additions and 302 deletions

View File

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

View File

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

@ -6,11 +6,7 @@ import {
PLAYER_RADIUS_SKATER, PLAYER_RADIUS_SKATER,
SPEED_SCALE_FACTOR, SPEED_SCALE_FACTOR,
MOVEMENT_STOP_THRESHOLD, MOVEMENT_STOP_THRESHOLD,
GOAL_DECELERATION_RATE, GOAL_DECELERATION_RATE
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';
@ -42,19 +38,6 @@ 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
private debugTargetGraphics?: Phaser.GameObjects.Graphics;
private debugLineGraphics?: Phaser.GameObjects.Graphics;
// Direction indicator
private directionIndicator!: Phaser.GameObjects.Graphics;
constructor( constructor(
scene: Phaser.Scene, scene: Phaser.Scene,
id: string, id: string,
@ -79,10 +62,6 @@ export class Player extends Phaser.GameObjects.Container {
this.attributes = attributes; this.attributes = attributes;
this.state = 'defensive'; 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 // Listen for goal events
this.scene.events.on('goal', this.onGoal, this); this.scene.events.on('goal', this.onGoal, this);
@ -99,12 +78,6 @@ export class Player extends Phaser.GameObjects.Container {
this.body.setCollideWorldBounds(true); this.body.setCollideWorldBounds(true);
this.createSprite(); this.createSprite();
// Create debug graphics if DEBUG is enabled
if (DEBUG) {
this.debugTargetGraphics = scene.add.graphics();
this.debugLineGraphics = scene.add.graphics();
}
} }
private createSprite() { private createSprite() {
@ -132,28 +105,7 @@ export class Player extends Phaser.GameObjects.Container {
}); });
label.setOrigin(0.5, 0.5); label.setOrigin(0.5, 0.5);
// Create direction indicator (small line showing facing direction) this.add([graphics, label]);
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
} }
/** /**
@ -198,134 +150,54 @@ 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);
// Calculate max speed based on player attributes (in m/s) // If close enough to target, stop
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.body.setVelocity(0, 0);
this.currentSpeed = Math.max(0, this.currentSpeed - PLAYER_DECELERATION * deltaSeconds); return;
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 // 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;
const gamePos = CoordinateUtils.screenToGame(this.scene, bodyX, bodyY); const gamePos = CoordinateUtils.screenToGame(this.scene, bodyX, bodyY);
this.gameX = gamePos.x; this.gameX = gamePos.x;
this.gameY = gamePos.y; 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,9 +15,7 @@ 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';
@ -51,27 +49,11 @@ export class GameScene extends Phaser.Scene {
'home', 'home',
'C', 'C',
-10, -10,
-5, -10,
{ speed: 80, skill: 75, tackling: 70, balance: 75 } { speed: 80, skill: 75 }
); );
// Create one away defender at (15, 0) - right side for defending this.players.push(homeCenter);
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() { private setupEventListeners() {
@ -261,89 +243,4 @@ 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,20 +27,17 @@ export class DefensiveBehavior extends BehaviorNode {
if (puck.state !== 'carried') return false; if (puck.state !== 'carried') return false;
if (puck.carrier === player.id) return false; if (puck.carrier === player.id) return false;
// Check if carrier is opponent (different team) // TODO: Check if carrier is opponent (requires team roster access)
return puck.carrierTeam !== player.team; // For now, just fail - will implement in Phase 6
return false;
}), }),
// Chase the puck carrier // Defensive action (future implementation)
new Action((player, gameState) => { new Action((player) => ({
const { puck } = gameState; type: 'idle',
targetX: player.gameX,
return { targetY: player.gameY
type: 'chase_puck', }))
targetX: puck.gameX,
targetY: puck.gameY
};
})
]); ]);
} }

View File

@ -25,10 +25,8 @@ 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
} }
/** /**