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": [ "allow": [
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(git push:*)" "Bash(git push:*)",
"Bash(pnpm run:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -73,6 +73,7 @@ export const GOALIE_RANGE = 3; // meters - how far goalie
// Collision avoidance constants // Collision avoidance constants
export const NEAR_PLAYER_DETECTION_RADIUS = 4; // meters - radius to detect nearby players (threats, teammates, etc.) 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 export const EVASION_ANGLE = Math.PI / 3; // radians (60 degrees) - angle to turn when evading
// Tackle constants // Tackle constants

View File

@ -12,7 +12,8 @@ import {
TACKLE_FALL_DURATION, TACKLE_FALL_DURATION,
PLAYER_ACCELERATION, PLAYER_ACCELERATION,
PLAYER_DECELERATION, PLAYER_DECELERATION,
NEAR_PLAYER_DETECTION_RADIUS NEAR_PLAYER_DETECTION_RADIUS,
NEAR_PLAYER_DETECTION_ANGLE
} 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';
@ -62,6 +63,12 @@ export class Player extends Phaser.GameObjects.Container {
// Direction indicator // Direction indicator
private directionIndicator!: Phaser.GameObjects.Graphics; 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( constructor(
scene: Phaser.Scene, scene: Phaser.Scene,
id: string, id: string,
@ -288,6 +295,9 @@ export class Player extends Phaser.GameObjects.Container {
this.gameX = gamePos.x; this.gameX = gamePos.x;
this.gameY = gamePos.y; this.gameY = gamePos.y;
// Update detection sensor position
this.updateSensorPosition();
// Update debug visualizations // Update debug visualizations
this.updateDebugVisuals(); this.updateDebugVisuals();
} }
@ -325,6 +335,74 @@ export class Player extends Phaser.GameObjects.Container {
return this.currentSpeed; 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) * Update debug visualizations (target position, path line, and detection circle)
*/ */
@ -361,8 +439,31 @@ export class Player extends Phaser.GameObjects.Container {
targetScreen.y + markerSize targetScreen.y + markerSize
); );
// Draw near player detection circle (always visible in DEBUG mode) // Draw near player detection arc (field of view as a filled wedge)
this.debugDetectionCircle.lineStyle(2, 0xff00ff, 0.3); // Magenta circle with lower opacity const halfFOV = NEAR_PLAYER_DETECTION_ANGLE / 2;
this.debugDetectionCircle.strokeCircle(playerScreen.x, playerScreen.y, NEAR_PLAYER_DETECTION_RADIUS * SCALE);
// 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.playerGroup.add(player);
this.behaviorTrees.set(player.id, new BehaviorTree(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 // Add collision with all goal posts
this.physics.add.collider(player, this.goalPostsGroup); 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) * 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() { private createPuck() {
// Initialize puck at center ice (0, 0 in game coordinates) // 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 // Add collisions between puck and all goal posts with custom bounce handler
this.physics.add.collider(this.puck, this.goalPostsGroup, this.handlePuckPostBounce, undefined, this); this.physics.add.collider(this.puck, this.goalPostsGroup, this.handlePuckPostBounce, undefined, this);

View File

@ -7,7 +7,6 @@ import {
SHOOTING_ANGLE_THRESHOLD, SHOOTING_ANGLE_THRESHOLD,
CLOSE_RANGE_DISTANCE, CLOSE_RANGE_DISTANCE,
CLOSE_RANGE_ANGLE_THRESHOLD, CLOSE_RANGE_ANGLE_THRESHOLD,
NEAR_PLAYER_DETECTION_RADIUS,
EVASION_ANGLE, EVASION_ANGLE,
DEBUG DEBUG
} from '../../../config/constants'; } 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 * Returns evasion target if threat detected, null otherwise
* *
* The player will maintain evasion as long as an opponent is: * 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 * Once an evasion direction is chosen, it persists until the opponent
* leaves the threat zone to prevent direction flipping. * leaves the threat zone to prevent direction flipping.
*/ */
private detectImmediateThreat( private detectImmediateThreat(
player: Player, player: Player,
gameState: GameState, _gameState: GameState,
opponentGoalX: number opponentGoalX: number
): { approachAngle: number; evasionTargetX: number; evasionTargetY: number; evasionAngle: number } | null { ): { approachAngle: number; evasionTargetX: number; evasionTargetY: number; evasionAngle: number } | null {
// Get or create debug graphics for this player // Get or create debug graphics for this player
@ -126,7 +126,7 @@ export class PuckCarrierBehavior extends BehaviorNode {
const goalY = 0; const goalY = 0;
const toGoalAngle = Math.atan2(goalY - player.gameY, opponentGoalX - player.gameX); 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) { if (DEBUG && debugGraphics) {
const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY);
const goalScreen = CoordinateUtils.gameToScreen(player.scene, opponentGoalX, goalY); 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); debugGraphics.lineBetween(playerScreen.x, playerScreen.y, goalScreen.x, goalScreen.y);
} }
// Find all opponents // Get detected opponents (physics-based with angle filtering)
const opponents = gameState.allPlayers.filter((p: Player) => p.team !== player.team); const detectedOpponents = player.getDetectedOpponents();
// Check if we have any active evasions that are still valid // Check if we have any active evasions that are still valid
for (const [opponentId, evasion] of playerEvasions.entries()) { 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) { 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 // Debug: Draw active evasion
if (distanceToOpponent <= NEAR_PLAYER_DETECTION_RADIUS) { if (DEBUG && debugGraphics) {
const evasionAngle = evasion.side === 'left' ? toGoalAngle + EVASION_ANGLE : toGoalAngle - EVASION_ANGLE; const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY);
const evasionDistance = 20; const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY);
const evasionTargetX = player.gameX + Math.cos(evasionAngle) * evasionDistance; const evasionTargetScreen = CoordinateUtils.gameToScreen(player.scene, evasionTargetX, evasionTargetY);
const evasionTargetY = player.gameY + Math.sin(evasionAngle) * evasionDistance;
// Debug: Draw active evasion // Highlight the opponent being evaded
if (DEBUG && debugGraphics) { debugGraphics.lineStyle(3, 0xff0000, 0.8); // Red circle around threat
const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 15);
const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY);
const evasionTargetScreen = CoordinateUtils.gameToScreen(player.scene, evasionTargetX, evasionTargetY);
// Highlight the opponent being evaded // Draw evasion direction arrow
debugGraphics.lineStyle(3, 0xff0000, 0.8); // Red circle around threat debugGraphics.lineStyle(3, 0xffff00, 0.8); // Yellow arrow for evasion
debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 15); debugGraphics.lineBetween(playerScreen.x, playerScreen.y, evasionTargetScreen.x, evasionTargetScreen.y);
// Draw evasion direction arrow // Draw evasion side label
debugGraphics.lineStyle(3, 0xffff00, 0.8); // Yellow arrow for evasion const labelText = `EVADE ${evasion.side.toUpperCase()}`;
debugGraphics.lineBetween(playerScreen.x, playerScreen.y, evasionTargetScreen.x, evasionTargetScreen.y); const label = player.scene.add.text(playerScreen.x + 20, playerScreen.y - 20, labelText, {
fontSize: '10px',
// Draw evasion side label color: '#ffff00',
const labelText = `EVADE ${evasion.side.toUpperCase()}`; backgroundColor: '#000000'
const label = player.scene.add.text(playerScreen.x + 20, playerScreen.y - 20, labelText, { });
fontSize: '10px', player.scene.time.delayedCall(50, () => label.destroy());
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);
} }
return {
approachAngle: Math.atan2(opponent.gameY - player.gameY, opponent.gameX - player.gameX),
evasionTargetX,
evasionTargetY,
evasionAngle
};
} else { } else {
// Opponent no longer exists, clear the evasion // Opponent left threat zone, clear the evasion
playerEvasions.delete(opponentId); playerEvasions.delete(opponentId);
} }
} }
// No active evasions, check for new threats // No active evasions, check for new threats
for (const opponent of opponents) { for (const opponent of detectedOpponents) {
const distanceToOpponent = MathUtils.distance(player.gameX, player.gameY, opponent.gameX, opponent.gameY); // Debug: Draw all detected opponents
if (DEBUG && debugGraphics) {
// Debug: Draw all opponents in threat zone
if (DEBUG && debugGraphics && distanceToOpponent <= NEAR_PLAYER_DETECTION_RADIUS) {
const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY); const opponentScreen = CoordinateUtils.gameToScreen(player.scene, opponent.gameX, opponent.gameY);
debugGraphics.lineStyle(2, 0xffa500, 0.6); // Orange circle debugGraphics.lineStyle(2, 0xffa500, 0.6); // Orange circle
debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 12); debugGraphics.strokeCircle(opponentScreen.x, opponentScreen.y, 12);
} }
// Check if opponent is within threat zone // Calculate angle from player to opponent
if (distanceToOpponent <= NEAR_PLAYER_DETECTION_RADIUS) { const angleToOpponent = Math.atan2(
// Calculate angle from player to opponent opponent.gameY - player.gameY,
const angleToOpponent = Math.atan2( opponent.gameX - player.gameX
opponent.gameY - player.gameY, );
opponent.gameX - player.gameX
);
// Calculate how aligned the opponent is with our goal direction // Calculate how aligned the opponent is with our goal direction
const angleDiff = MathUtils.angleDifference(toGoalAngle, angleToOpponent); 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) { if (DEBUG && debugGraphics) {
const playerScreen = CoordinateUtils.gameToScreen(player.scene, player.gameX, player.gameY); 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); 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) return {
if (Math.abs(angleDiff) < Math.PI / 3) { approachAngle: angleToOpponent,
// Calculate which side the opponent is on relative to our path evasionTargetX,
const crossProduct = Math.sin(angleToOpponent - toGoalAngle); evasionTargetY,
const evasionSide: 'left' | 'right' = crossProduct > 0 ? 'left' : 'right'; evasionAngle
};
// 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
};
}
} }
} }