diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a1a7ea3..c635d58 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(git add:*)", "Bash(git commit:*)", - "Bash(git push:*)" + "Bash(git push:*)", + "Bash(pnpm run:*)" ], "deny": [], "ask": [] diff --git a/src/config/constants.ts b/src/config/constants.ts index ab1ba92..31fed27 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -73,6 +73,7 @@ export const GOALIE_RANGE = 3; // meters - how far goalie // Collision avoidance constants export const NEAR_PLAYER_DETECTION_RADIUS = 4; // meters - radius to detect nearby players (threats, teammates, etc.) +export const NEAR_PLAYER_DETECTION_ANGLE = Math.PI; // radians (180 degrees) - field of view for detection (half-circle in front) export const EVASION_ANGLE = Math.PI / 3; // radians (60 degrees) - angle to turn when evading // Tackle constants diff --git a/src/entities/Player.ts b/src/entities/Player.ts index 68ee4f9..61fddc1 100644 --- a/src/entities/Player.ts +++ b/src/entities/Player.ts @@ -12,7 +12,8 @@ import { TACKLE_FALL_DURATION, PLAYER_ACCELERATION, PLAYER_DECELERATION, - NEAR_PLAYER_DETECTION_RADIUS + NEAR_PLAYER_DETECTION_RADIUS, + NEAR_PLAYER_DETECTION_ANGLE } from '../config/constants'; import { CoordinateUtils } from '../utils/coordinates'; import { MathUtils } from '../utils/math'; @@ -62,6 +63,12 @@ export class Player extends Phaser.GameObjects.Container { // Direction indicator private directionIndicator!: Phaser.GameObjects.Graphics; + // Nearby player detection (physics-based) + public detectionSensor?: Phaser.GameObjects.Zone; + public nearbyPlayers: Set = new Set(); + public nearbyOpponents: Set = new Set(); + public nearbyTeammates: Set = new Set(); + constructor( scene: Phaser.Scene, id: string, @@ -288,6 +295,9 @@ export class Player extends Phaser.GameObjects.Container { this.gameX = gamePos.x; this.gameY = gamePos.y; + // Update detection sensor position + this.updateSensorPosition(); + // Update debug visualizations this.updateDebugVisuals(); } @@ -325,6 +335,74 @@ export class Player extends Phaser.GameObjects.Container { return this.currentSpeed; } + /** + * Create physics-based detection sensor for nearby players + * Call this after physics body is set up in GameScene.addPlayer() + */ + public createDetectionSensor() { + // Create invisible zone for physics detection (no visual representation) + const sensorRadius = NEAR_PLAYER_DETECTION_RADIUS * SCALE; + const sensorDiameter = sensorRadius * 2; + this.detectionSensor = this.scene.add.zone(0, 0, sensorDiameter, sensorDiameter); + + // Add physics to sensor + this.scene.physics.add.existing(this.detectionSensor); + const sensorBody = this.detectionSensor.body as Phaser.Physics.Arcade.Body; + sensorBody.setCircle(sensorRadius); + + // Make it a sensor (no collision, only overlap detection) + sensorBody.enable = true; + sensorBody.setAllowGravity(false); + + // Disable debug rendering for this physics body + sensorBody.debugShowBody = false; + + // Position sensor at player location + this.updateSensorPosition(); + } + + /** + * Update sensor position to match player position + * Called every frame in update() + */ + private updateSensorPosition() { + if (!this.detectionSensor) return; + + const screenPos = CoordinateUtils.gameToScreen(this.scene, this.gameX, this.gameY); + this.detectionSensor.setPosition(screenPos.x, screenPos.y); + } + + /** + * Check if a player is within detection field of view (angle filtering) + * Returns true if player is within the detection arc in front + */ + public isPlayerInDetectionRange(other: Player): boolean { + if (!this.nearbyPlayers.has(other)) return false; + + // Calculate angle from this player to the other player + const angleToOther = Math.atan2(other.gameY - this.gameY, other.gameX - this.gameX); + + // Calculate angle difference from current facing direction + const angleDiff = MathUtils.angleDifference(this.currentAngle, angleToOther); + + // Check if within field of view (e.g., 180° = Math.PI for half-circle in front) + return Math.abs(angleDiff) <= NEAR_PLAYER_DETECTION_ANGLE / 2; + } + + /** + * Get all opponents within detection range (filtered by angle) + */ + public getDetectedOpponents(): Player[] { + return Array.from(this.nearbyOpponents).filter(p => this.isPlayerInDetectionRange(p)); + } + + /** + * Get all teammates within detection range (filtered by angle) + */ + public getDetectedTeammates(): Player[] { + return Array.from(this.nearbyTeammates).filter(p => this.isPlayerInDetectionRange(p)); + } + /** * Update debug visualizations (target position, path line, and detection circle) */ @@ -361,8 +439,31 @@ export class Player extends Phaser.GameObjects.Container { targetScreen.y + markerSize ); - // Draw near player detection circle (always visible in DEBUG mode) - this.debugDetectionCircle.lineStyle(2, 0xff00ff, 0.3); // Magenta circle with lower opacity - this.debugDetectionCircle.strokeCircle(playerScreen.x, playerScreen.y, NEAR_PLAYER_DETECTION_RADIUS * SCALE); + // Draw near player detection arc (field of view as a filled wedge) + const halfFOV = NEAR_PLAYER_DETECTION_ANGLE / 2; + + // Convert game angle to screen angle (negate because screen Y is inverted) + const screenAngle = -this.currentAngle; + const startAngle = screenAngle - halfFOV; + const endAngle = screenAngle + halfFOV; + + // Draw filled wedge showing field of view + this.debugDetectionCircle.fillStyle(0xff00ff, 0.1); // Magenta with low alpha + this.debugDetectionCircle.lineStyle(2, 0xff00ff, 0.4); // Magenta border + + this.debugDetectionCircle.beginPath(); + this.debugDetectionCircle.moveTo(playerScreen.x, playerScreen.y); // Start at player center + this.debugDetectionCircle.arc( + playerScreen.x, + playerScreen.y, + NEAR_PLAYER_DETECTION_RADIUS * SCALE, + startAngle, + endAngle, + false + ); + this.debugDetectionCircle.lineTo(playerScreen.x, playerScreen.y); // Close the wedge + this.debugDetectionCircle.closePath(); + this.debugDetectionCircle.fillPath(); + this.debugDetectionCircle.strokePath(); } } diff --git a/src/game/GameScene.ts b/src/game/GameScene.ts index 310ffad..d53122e 100644 --- a/src/game/GameScene.ts +++ b/src/game/GameScene.ts @@ -132,10 +132,61 @@ export class GameScene extends Phaser.Scene { this.playerGroup.add(player); this.behaviorTrees.set(player.id, new BehaviorTree(player)); + // Create detection sensor for this player + player.createDetectionSensor(); + + // Setup detection sensor overlaps with other players + this.setupPlayerDetection(player); + // Add collision with all goal posts this.physics.add.collider(player, this.goalPostsGroup); } + /** + * Setup physics-based detection sensor overlaps for a player + */ + setupPlayerDetection(player: Player) { + if (!player.detectionSensor) return; + + // Setup overlap detection with all other players + this.physics.add.overlap( + player.detectionSensor, + this.playerGroup, + (_sensor, otherPlayer) => { + const other = otherPlayer as unknown as Player; + + // Don't detect self + if (other.id === player.id) return; + + // Add to nearby players set + player.nearbyPlayers.add(other); + + // Categorize by team + if (other.team === player.team) { + player.nearbyTeammates.add(other); + } else { + player.nearbyOpponents.add(other); + } + } + ); + + // Important: Clear nearby players when they leave the sensor + // We need to check every frame and remove players no longer overlapping + this.events.on('update', () => { + if (!player.detectionSensor) return; + + // Check each nearby player to see if still overlapping + player.nearbyPlayers.forEach(other => { + const overlapping = this.physics.overlap(player.detectionSensor!, other); + if (!overlapping) { + player.nearbyPlayers.delete(other); + player.nearbyOpponents.delete(other); + player.nearbyTeammates.delete(other); + } + }); + }); + } + /** * Remove a player from the scene (can be called at any time for debugging) */ @@ -165,7 +216,7 @@ export class GameScene extends Phaser.Scene { private createPuck() { // Initialize puck at center ice (0, 0 in game coordinates) - this.puck = new Puck(this, 5, 0); + this.puck = new Puck(this, 5, 5); // Add collisions between puck and all goal posts with custom bounce handler this.physics.add.collider(this.puck, this.goalPostsGroup, this.handlePuckPostBounce, undefined, this); diff --git a/src/systems/behaviors/offensive/PuckCarrierBehavior.ts b/src/systems/behaviors/offensive/PuckCarrierBehavior.ts index f4535b4..73a3da2 100644 --- a/src/systems/behaviors/offensive/PuckCarrierBehavior.ts +++ b/src/systems/behaviors/offensive/PuckCarrierBehavior.ts @@ -7,7 +7,6 @@ import { SHOOTING_ANGLE_THRESHOLD, CLOSE_RANGE_DISTANCE, CLOSE_RANGE_ANGLE_THRESHOLD, - NEAR_PLAYER_DETECTION_RADIUS, EVASION_ANGLE, DEBUG } from '../../../config/constants'; @@ -92,18 +91,19 @@ export class PuckCarrierBehavior extends BehaviorNode { } /** - * Detect opponents blocking the path to goal + * Detect opponents blocking the path to goal using physics-based detection * Returns evasion target if threat detected, null otherwise * * The player will maintain evasion as long as an opponent is: - * 1. Within the near player detection radius (close proximity) + * 1. Within the near player detection zone (physics sensor) + * 2. Within the field of view (angle-based filtering) * * Once an evasion direction is chosen, it persists until the opponent * leaves the threat zone to prevent direction flipping. */ private detectImmediateThreat( player: Player, - gameState: GameState, + _gameState: GameState, opponentGoalX: number ): { approachAngle: number; evasionTargetX: number; evasionTargetY: number; evasionAngle: number } | null { // Get or create debug graphics for this player @@ -126,7 +126,7 @@ export class PuckCarrierBehavior extends BehaviorNode { const goalY = 0; const toGoalAngle = Math.atan2(goalY - player.gameY, opponentGoalX - player.gameX); - // Draw debug line to goal (circle is now rendered by Player class) + // Draw debug line to goal (arc is now rendered by Player class) if (DEBUG && debugGraphics) { const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); const goalScreen = CoordinateUtils.gameToScreen(player.scene, opponentGoalX, goalY); @@ -134,131 +134,119 @@ export class PuckCarrierBehavior extends BehaviorNode { debugGraphics.lineBetween(playerScreen.x, playerScreen.y, goalScreen.x, goalScreen.y); } - // Find all opponents - const opponents = gameState.allPlayers.filter((p: Player) => p.team !== player.team); + // Get detected opponents (physics-based with angle filtering) + const detectedOpponents = player.getDetectedOpponents(); // Check if we have any active evasions that are still valid for (const [opponentId, evasion] of playerEvasions.entries()) { - const opponent = opponents.find(opp => opp.id === opponentId); + const opponent = detectedOpponents.find(opp => opp.id === opponentId); if (opponent) { - const distanceToOpponent = MathUtils.distance(player.gameX, player.gameY, opponent.gameX, opponent.gameY); + // Opponent still detected in threat zone, maintain the evasion + const evasionAngle = evasion.side === 'left' ? toGoalAngle + EVASION_ANGLE : toGoalAngle - EVASION_ANGLE; + const evasionDistance = 20; + const evasionTargetX = player.gameX + Math.cos(evasionAngle) * evasionDistance; + const evasionTargetY = player.gameY + Math.sin(evasionAngle) * evasionDistance; - // If opponent still in threat zone, maintain the evasion - if (distanceToOpponent <= NEAR_PLAYER_DETECTION_RADIUS) { - const evasionAngle = evasion.side === 'left' ? toGoalAngle + EVASION_ANGLE : toGoalAngle - EVASION_ANGLE; - const evasionDistance = 20; - const evasionTargetX = player.gameX + Math.cos(evasionAngle) * evasionDistance; - const evasionTargetY = player.gameY + Math.sin(evasionAngle) * evasionDistance; + // Debug: Draw active evasion + if (DEBUG && debugGraphics) { + const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); + const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY); + const evasionTargetScreen = CoordinateUtils.gameToScreen(player.scene, evasionTargetX, evasionTargetY); - // Debug: Draw active evasion - if (DEBUG && debugGraphics) { - const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); - const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY); - const evasionTargetScreen = CoordinateUtils.gameToScreen(player.scene, evasionTargetX, evasionTargetY); + // Highlight the opponent being evaded + debugGraphics.lineStyle(3, 0xff0000, 0.8); // Red circle around threat + debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 15); - // Highlight the opponent being evaded - debugGraphics.lineStyle(3, 0xff0000, 0.8); // Red circle around threat - debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 15); + // Draw evasion direction arrow + debugGraphics.lineStyle(3, 0xffff00, 0.8); // Yellow arrow for evasion + debugGraphics.lineBetween(playerScreen.x, playerScreen.y, evasionTargetScreen.x, evasionTargetScreen.y); - // Draw evasion direction arrow - debugGraphics.lineStyle(3, 0xffff00, 0.8); // Yellow arrow for evasion - debugGraphics.lineBetween(playerScreen.x, playerScreen.y, evasionTargetScreen.x, evasionTargetScreen.y); - - // Draw evasion side label - const labelText = `EVADE ${evasion.side.toUpperCase()}`; - const label = player.scene.add.text(playerScreen.x + 20, playerScreen.y - 20, labelText, { - fontSize: '10px', - color: '#ffff00', - backgroundColor: '#000000' - }); - player.scene.time.delayedCall(50, () => label.destroy()); - } - - return { - approachAngle: Math.atan2(opponent.gameY - player.gameY, opponent.gameX - player.gameX), - evasionTargetX, - evasionTargetY, - evasionAngle - }; - } else { - // Opponent left threat zone, clear the evasion - playerEvasions.delete(opponentId); + // Draw evasion side label + const labelText = `EVADE ${evasion.side.toUpperCase()}`; + const label = player.scene.add.text(playerScreen.x + 20, playerScreen.y - 20, labelText, { + fontSize: '10px', + color: '#ffff00', + backgroundColor: '#000000' + }); + player.scene.time.delayedCall(50, () => label.destroy()); } + + return { + approachAngle: Math.atan2(opponent.gameY - player.gameY, opponent.gameX - player.gameX), + evasionTargetX, + evasionTargetY, + evasionAngle + }; } else { - // Opponent no longer exists, clear the evasion + // Opponent left threat zone, clear the evasion playerEvasions.delete(opponentId); } } // No active evasions, check for new threats - for (const opponent of opponents) { - const distanceToOpponent = MathUtils.distance(player.gameX, player.gameY, opponent.gameX, opponent.gameY); - - // Debug: Draw all opponents in threat zone - if (DEBUG && debugGraphics && distanceToOpponent <= NEAR_PLAYER_DETECTION_RADIUS) { + for (const opponent of detectedOpponents) { + // Debug: Draw all detected opponents + if (DEBUG && debugGraphics) { const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY); debugGraphics.lineStyle(2, 0xffa500, 0.6); // Orange circle debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 12); } - // Check if opponent is within threat zone - if (distanceToOpponent <= NEAR_PLAYER_DETECTION_RADIUS) { - // Calculate angle from player to opponent - const angleToOpponent = Math.atan2( - opponent.gameY - player.gameY, - opponent.gameX - player.gameX - ); + // Calculate angle from player to opponent + const angleToOpponent = Math.atan2( + opponent.gameY - player.gameY, + opponent.gameX - player.gameX + ); - // Calculate how aligned the opponent is with our goal direction - const angleDiff = MathUtils.angleDifference(toGoalAngle, angleToOpponent); + // Calculate how aligned the opponent is with our goal direction + const angleDiff = MathUtils.angleDifference(toGoalAngle, angleToOpponent); - // Debug: Show angle difference + // Debug: Show angle difference + if (DEBUG && debugGraphics) { + const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); + const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY); + debugGraphics.lineStyle(1, 0xffffff, 0.4); + debugGraphics.lineBetween(playerScreen.x, playerScreen.y, opponentScreen.x, opponentScreen.y); + } + + // If opponent is roughly in front of us (within 60 degrees of goal direction) + if (Math.abs(angleDiff) < Math.PI / 3) { + // Calculate which side the opponent is on relative to our path + const crossProduct = Math.sin(angleToOpponent - toGoalAngle); + const evasionSide: 'left' | 'right' = crossProduct > 0 ? 'left' : 'right'; + + // Store this evasion for consistency + playerEvasions.set(opponent.id, { angle: toGoalAngle, side: evasionSide }); + + // Calculate evasion angle + const evasionAngle = evasionSide === 'left' ? toGoalAngle + EVASION_ANGLE : toGoalAngle - EVASION_ANGLE; + + // Calculate evasion target point far ahead (20 meters) + const evasionDistance = 20; + const evasionTargetX = player.gameX + Math.cos(evasionAngle) * evasionDistance; + const evasionTargetY = player.gameY + Math.sin(evasionAngle) * evasionDistance; + + // Debug: Draw new threat detection if (DEBUG && debugGraphics) { const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); + const evasionTargetScreen = CoordinateUtils.gameToScreen(player.scene, evasionTargetX, evasionTargetY); const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY); - debugGraphics.lineStyle(1, 0xffffff, 0.4); - debugGraphics.lineBetween(playerScreen.x, playerScreen.y, opponentScreen.x, opponentScreen.y); + + // Highlight new threat + debugGraphics.lineStyle(4, 0xff0000, 1); // Bright red for new threat + debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 15); + + // Draw new evasion direction + debugGraphics.lineStyle(3, 0x00ff00, 0.8); // Green arrow for new evasion + debugGraphics.lineBetween(playerScreen.x, playerScreen.y, evasionTargetScreen.x, evasionTargetScreen.y); } - // If opponent is roughly in front of us (within 60 degrees of goal direction) - if (Math.abs(angleDiff) < Math.PI / 3) { - // Calculate which side the opponent is on relative to our path - const crossProduct = Math.sin(angleToOpponent - toGoalAngle); - const evasionSide: 'left' | 'right' = crossProduct > 0 ? 'left' : 'right'; - - // Store this evasion for consistency - playerEvasions.set(opponent.id, { angle: toGoalAngle, side: evasionSide }); - - // Calculate evasion angle - const evasionAngle = evasionSide === 'left' ? toGoalAngle + EVASION_ANGLE : toGoalAngle - EVASION_ANGLE; - - // Calculate evasion target point far ahead (20 meters) - const evasionDistance = 20; - const evasionTargetX = player.gameX + Math.cos(evasionAngle) * evasionDistance; - const evasionTargetY = player.gameY + Math.sin(evasionAngle) * evasionDistance; - - // Debug: Draw new threat detection - if (DEBUG && debugGraphics) { - const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); - const evasionTargetScreen = CoordinateUtils.gameToScreen(player.scene, evasionTargetX, evasionTargetY); - const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY); - - // Highlight new threat - debugGraphics.lineStyle(4, 0xff0000, 1); // Bright red for new threat - debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 15); - - // Draw new evasion direction - debugGraphics.lineStyle(3, 0x00ff00, 0.8); // Green arrow for new evasion - debugGraphics.lineBetween(playerScreen.x, playerScreen.y, evasionTargetScreen.x, evasionTargetScreen.y); - } - - return { - approachAngle: angleToOpponent, - evasionTargetX, - evasionTargetY, - evasionAngle - }; - } + return { + approachAngle: angleToOpponent, + evasionTargetX, + evasionTargetY, + evasionAngle + }; } }