Add physics-based player detection with field-of-view filtering

Replace distance-based opponent detection with physics sensors and
angle filtering for more realistic spatial awareness. Players now
detect nearby opponents/teammates through Phaser overlap zones with
180° forward field-of-view.

Key changes:
- Add detection sensor to Player with physics overlap tracking
- Add NEAR_PLAYER_DETECTION_ANGLE constant for FOV filtering
- Refactor PuckCarrierBehavior to use physics-based detection
- Add debug visualization showing detection arc (wedge shape)
- Fix TypeScript unused variable errors for production build

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pierre Wessman 2025-10-03 14:03:26 +02:00
parent c749308b49
commit ca6a444dec
5 changed files with 251 additions and 109 deletions

View File

@ -3,7 +3,8 @@
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
"Bash(git push:*)",
"Bash(pnpm run:*)"
],
"deny": [],
"ask": []

View File

@ -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

View File

@ -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<Player> = new Set();
public nearbyOpponents: Set<Player> = new Set();
public nearbyTeammates: Set<Player> = 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();
}
}

View File

@ -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);

View File

@ -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
};
}
}