Restructure behavior tree for scalability

- Separate base behavior nodes into BehaviorNodes.ts (fixes circular dependency)
- Organize behaviors by role (goalie) and context (offensive/defensive/transition)
- Add modular behavior subtrees:
  - GoalieBehavior: Track puck position near net
  - SkaterBehavior: Route to context-specific behaviors
  - PuckCarrierBehavior: Shoot/carry logic (Phase 4)
  - OffensiveSupportBehavior: Placeholder for Phase 5
  - DefensiveBehavior: Placeholder for Phase 6
  - TransitionBehavior: Chase loose puck
- Maintain backward compatibility with static evaluatePlayer()

🤖 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-02 09:42:20 +02:00
parent 1504dba13e
commit a3ffc94916
8 changed files with 448 additions and 125 deletions

View File

@ -0,0 +1,86 @@
import type { Player } from '../entities/Player';
import type { GameState, PlayerAction } from '../types/game';
/**
* Base class for all behavior tree nodes
*/
export enum BehaviorStatus {
SUCCESS = 'SUCCESS',
FAILURE = 'FAILURE',
RUNNING = 'RUNNING',
}
export abstract class BehaviorNode {
abstract tick(player: Player, gameState: GameState): BehaviorStatus | PlayerAction;
}
/**
* Selector node - Returns SUCCESS on first child that succeeds (OR logic)
* Used for choosing between alternative behaviors
*/
export class Selector extends BehaviorNode {
constructor(private children: BehaviorNode[]) {
super();
}
tick(player: Player, gameState: GameState): BehaviorStatus | PlayerAction {
for (const child of this.children) {
const result = child.tick(player, gameState);
// If child returns an action or non-FAILURE status, return it
if (result !== BehaviorStatus.FAILURE) {
return result;
}
}
return BehaviorStatus.FAILURE;
}
}
/**
* Sequence node - Returns SUCCESS only if all children succeed (AND logic)
* Used for chaining conditions and actions
*/
export class Sequence extends BehaviorNode {
constructor(private children: BehaviorNode[]) {
super();
}
tick(player: Player, gameState: GameState): BehaviorStatus | PlayerAction {
let lastResult: BehaviorStatus | PlayerAction = BehaviorStatus.SUCCESS;
for (const child of this.children) {
const result = child.tick(player, gameState);
if (result !== BehaviorStatus.SUCCESS) {
return result; // Return FAILURE, RUNNING, or PlayerAction
}
lastResult = result;
}
return lastResult;
}
}
/**
* Condition node - Evaluates a predicate function
*/
export class Condition extends BehaviorNode {
constructor(private predicate: (player: Player, gameState: GameState) => boolean) {
super();
}
tick(player: Player, gameState: GameState): BehaviorStatus {
return this.predicate(player, gameState) ? BehaviorStatus.SUCCESS : BehaviorStatus.FAILURE;
}
}
/**
* Action node - Executes a behavior function and returns a PlayerAction
*/
export class Action extends BehaviorNode {
constructor(
private action: (player: Player, gameState: GameState) => PlayerAction
) {
super();
}
tick(player: Player, gameState: GameState): PlayerAction {
return this.action(player, gameState);
}
}

View File

@ -1,136 +1,60 @@
import type { Player } from '../entities/Player'; import type { Player } from '../entities/Player';
import type { Puck } from '../entities/Puck';
import {
GOAL_LINE_OFFSET,
GOALIE_RANGE,
SHOOTING_RANGE,
SHOOTING_ANGLE_THRESHOLD
} from '../config/constants';
import { MathUtils } from '../utils/math';
import type { GameState, PlayerAction } from '../types/game'; import type { GameState, PlayerAction } from '../types/game';
import { BehaviorNode, Selector, Sequence, Condition } from './BehaviorNodes';
import { GoalieBehavior } from './behaviors/GoalieBehavior';
import { SkaterBehavior } from './behaviors/SkaterBehavior';
// Re-export base classes for convenience
export { BehaviorNode, BehaviorStatus, Selector, Sequence, Condition, Action } from './BehaviorNodes';
/** /**
* Simple behavior tree for player decision making * BehaviorTree system for AI decision-making
*
* This system uses a behavior tree pattern to make tactical decisions for players.
* The tree is evaluated every tick (60 FPS) and produces actions based on:
* - Game state (possession, positions, threats)
* - Player attributes (Hockey IQ, Skill, Speed)
* - Tactical context (offensive/defensive situation)
*
* The tree is structured by role (goalie vs skater) at the top level,
* then by game context (offensive/defensive/transition) for skaters.
*
* @example
* const tree = new BehaviorTree(player);
* const action = tree.tick(gameState); // Called every frame in GameScene.update()
*/ */
export class BehaviorTree { export class BehaviorTree {
private root: BehaviorNode;
constructor(private player: Player) {
// Root selector: Choose between goalie and skater behaviors based on position
this.root = new Selector([
// Goalie behavior (checks position internally)
new Sequence([
new Condition((p) => p.playerPosition === 'G'),
new GoalieBehavior()
]),
// Skater behavior (default for all other positions)
new SkaterBehavior()
]);
}
/** /**
* Evaluate player behavior and return action * Evaluates the behavior tree and returns the next action for the player
* Called every frame (60 FPS)
*/
public tick(gameState: GameState): PlayerAction {
const result = this.root.tick(this.player, gameState);
// Result should always be a PlayerAction from our structure
return result as PlayerAction;
}
/**
* Static helper to evaluate any player
* This maintains backward compatibility with existing code
*/ */
public static evaluatePlayer(player: Player, gameState: GameState): PlayerAction { public static evaluatePlayer(player: Player, gameState: GameState): PlayerAction {
const { puck } = gameState; const tree = new BehaviorTree(player);
return tree.tick(gameState);
// Check if player is goalie
if (player.playerPosition === 'G') {
return this.goalieLogic(player, puck);
}
// Check if player has puck
if (puck.carrier === player.id) {
return this.skateWithPuck(player, puck);
}
// Check if puck is loose
if (puck.state === 'loose') {
return this.chasePuck(player, puck);
}
// Default: idle (stay at current position)
return {
type: 'idle',
targetX: player.gameX,
targetY: player.gameY
};
}
/**
* Goalie stays near net and tracks puck Y position
*/
private static goalieLogic(player: Player, puck: Puck): PlayerAction {
// Stay near goal line
const goalX = player.team === 'home' ? -GOAL_LINE_OFFSET : GOAL_LINE_OFFSET;
// Track puck Y position (clamped to goal width)
const targetY = Math.max(-GOALIE_RANGE, Math.min(GOALIE_RANGE, puck.gameY));
return {
type: 'move',
targetX: goalX,
targetY: targetY
};
}
/**
* Chase loose puck
*/
private static chasePuck(_player: Player, puck: Puck): PlayerAction {
return {
type: 'chase_puck',
targetX: puck.gameX,
targetY: puck.gameY
};
}
/**
* Skate with puck toward opponent's goal
*/
private static skateWithPuck(player: Player, _puck: Puck): PlayerAction {
// Determine opponent's goal X position
const opponentGoalX = player.team === 'home' ? GOAL_LINE_OFFSET : -GOAL_LINE_OFFSET;
// Check if player has a good shot opportunity
if (this.hasGoodShot(player)) {
console.log(`${player.id} is shooting!`);
return {
type: 'shoot',
targetX: opponentGoalX,
targetY: 0 // Aim for center of net
};
}
// Skate toward opponent's net (slightly randomized Y to avoid going straight)
return {
type: 'skate_with_puck',
targetX: opponentGoalX,
targetY: 0
};
}
/**
* Evaluate if player has a good shooting opportunity
*/
private static hasGoodShot(player: Player): boolean {
// Determine opponent's goal position
const opponentGoalX = player.team === 'home' ? GOAL_LINE_OFFSET : -GOAL_LINE_OFFSET;
const goalY = 0;
// Calculate distance to goal
const distance = MathUtils.distance(player.gameX, player.gameY, opponentGoalX, goalY);
// Check if within shooting range
if (distance >= SHOOTING_RANGE) {
return false;
}
// Calculate angle to goal (in radians)
const dx = opponentGoalX - player.gameX;
const dy = goalY - player.gameY;
const angleToGoal = Math.atan2(dy, dx);
// Calculate player's direction of movement
const moveX = player.targetX - player.gameX;
const moveY = player.targetY - player.gameY;
const angleOfMovement = Math.atan2(moveY, moveX);
// Calculate angle difference
let angleDiff = Math.abs(angleToGoal - angleOfMovement);
// Normalize to 0-π
if (angleDiff > Math.PI) {
angleDiff = 2 * Math.PI - angleDiff;
}
// Good angle if within threshold
const reasonableAngle = angleDiff < SHOOTING_ANGLE_THRESHOLD;
return reasonableAngle;
} }
} }

View File

@ -0,0 +1,32 @@
import { BehaviorNode } from '../BehaviorNodes';
import type { Player } from '../../entities/Player';
import type { GameState, PlayerAction } from '../../types/game';
import { GOAL_LINE_OFFSET, GOALIE_RANGE } from '../../config/constants';
/**
* Goalie-specific behavior tree
*
* Goalies have unique behaviors:
* - Stay near goal line
* - Track puck Y position
* - Challenge shooters (future)
* - Make saves (future)
*/
export class GoalieBehavior extends BehaviorNode {
tick(player: Player, gameState: GameState): PlayerAction {
// Stay near goal line
const goalX = player.team === 'home' ? -GOAL_LINE_OFFSET : GOAL_LINE_OFFSET;
// Track puck Y position (clamped to goalie range)
const targetY = Math.max(
-GOALIE_RANGE,
Math.min(GOALIE_RANGE, gameState.puck.gameY)
);
return {
type: 'move',
targetX: goalX,
targetY: targetY
};
}
}

View File

@ -0,0 +1,39 @@
import { BehaviorNode, Selector } from '../BehaviorNodes';
import type { Player } from '../../entities/Player';
import type { GameState, PlayerAction } from '../../types/game';
import { PuckCarrierBehavior } from './offensive/PuckCarrierBehavior';
import { OffensiveSupportBehavior } from './offensive/OffensiveSupportBehavior';
import { DefensiveBehavior } from './defensive/DefensiveBehavior';
import { TransitionBehavior } from './TransitionBehavior';
/**
* Skater behavior tree - Context-based decision making
*
* The selector evaluates children in priority order:
* 1. PuckCarrier - If this player has the puck (highest priority)
* 2. OffensiveSupport - If teammate has puck (support offense)
* 3. Defensive - If opponent has puck (play defense)
* 4. Transition - Loose puck or neutral situations (fallback)
*
* Each subtree handles its own conditions and returns FAILURE if not applicable,
* allowing the selector to try the next branch.
*/
export class SkaterBehavior extends BehaviorNode {
private tree: Selector;
constructor() {
super();
// Build context-based behavior tree
this.tree = new Selector([
new PuckCarrierBehavior(), // Has possession
new OffensiveSupportBehavior(), // Team has possession
new DefensiveBehavior(), // Opponent has possession
new TransitionBehavior(), // Loose puck/neutral (always succeeds as fallback)
]);
}
tick(player: Player, gameState: GameState): PlayerAction {
return this.tree.tick(player, gameState) as PlayerAction;
}
}

View File

@ -0,0 +1,46 @@
import { BehaviorNode, Selector, Action } from '../BehaviorNodes';
import type { Player } from '../../entities/Player';
import type { GameState, PlayerAction } from '../../types/game';
/**
* Transition behavior - Loose puck and neutral situations
*
* This behavior handles:
* 1. Chasing loose pucks
* 2. Neutral zone positioning
* 3. Faceoff positioning (future)
* 4. Line changes (future)
*
* This should always succeed as the fallback behavior
*/
export class TransitionBehavior extends BehaviorNode {
private tree: BehaviorNode;
constructor() {
super();
this.tree = new Selector([
// Chase loose puck
new Action((player, gameState) => {
if (gameState.puck.state === 'loose') {
return {
type: 'chase_puck',
targetX: gameState.puck.gameX,
targetY: gameState.puck.gameY
};
}
// If not loose puck, idle at current position (fallback)
return {
type: 'idle',
targetX: player.gameX,
targetY: player.gameY
};
})
]);
}
tick(player: Player, gameState: GameState): PlayerAction {
return this.tree.tick(player, gameState) as PlayerAction;
}
}

View File

@ -0,0 +1,47 @@
import { BehaviorNode, Sequence, Condition, Action, BehaviorStatus } from '../../BehaviorNodes';
import type { Player } from '../../../entities/Player';
import type { GameState, PlayerAction } from '../../../types/game';
/**
* Defensive behavior (Phase 6 - Future)
*
* When opponent has the puck, this player should:
* 1. Apply pressure to puck carrier
* 2. Block passing lanes
* 3. Cover open attackers
* 4. Backcheck to defensive zone
* 5. Position for shot blocks
*
* Currently: Placeholder that returns FAILURE to pass control to next behavior
*/
export class DefensiveBehavior extends BehaviorNode {
private tree: BehaviorNode;
constructor() {
super();
this.tree = new Sequence([
// Check if opponent has puck
new Condition((player, gameState) => {
const { puck } = gameState;
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;
}),
// Defensive action (future implementation)
new Action((player) => ({
type: 'idle',
targetX: player.gameX,
targetY: player.gameY
}))
]);
}
tick(player: Player, gameState: GameState): BehaviorStatus | PlayerAction {
return this.tree.tick(player, gameState);
}
}

View File

@ -0,0 +1,46 @@
import { BehaviorNode, Sequence, Condition, Action, BehaviorStatus } from '../../BehaviorNodes';
import type { Player } from '../../../entities/Player';
import type { GameState, PlayerAction } from '../../../types/game';
/**
* Offensive support behavior (Phase 5 - Future)
*
* When a teammate has the puck, this player should:
* 1. Get open for a pass
* 2. Move to scoring areas
* 3. Screen the goalie
* 4. Provide passing options
*
* Currently: Placeholder that returns FAILURE to pass control to next behavior
*/
export class OffensiveSupportBehavior extends BehaviorNode {
private tree: BehaviorNode;
constructor() {
super();
this.tree = new Sequence([
// Check if teammate has puck (not this player, not opponent)
new Condition((player, gameState) => {
const { puck } = gameState;
if (puck.state !== 'carried') return false;
if (puck.carrier === player.id) return false;
// TODO: Check if carrier is teammate (requires team roster access)
// For now, just fail - will implement in Phase 5
return false;
}),
// Action to support offense (future implementation)
new Action((player) => ({
type: 'idle',
targetX: player.gameX,
targetY: player.gameY
}))
]);
}
tick(player: Player, gameState: GameState): BehaviorStatus | PlayerAction {
return this.tree.tick(player, gameState);
}
}

View File

@ -0,0 +1,103 @@
import { BehaviorNode, Sequence, Condition, Action, BehaviorStatus } from '../../BehaviorNodes';
import type { Player } from '../../../entities/Player';
import type { GameState, PlayerAction } from '../../../types/game';
import {
GOAL_LINE_OFFSET,
SHOOTING_RANGE,
SHOOTING_ANGLE_THRESHOLD
} from '../../../config/constants';
import { MathUtils } from '../../../utils/math';
/**
* Puck carrier behavior tree (Phase 4)
*
* Decision priority when carrying the puck:
* 1. Shoot if good opportunity
* 2. Pass to open teammate (future - Phase 4)
* 3. Carry puck toward net (default)
*
* Future enhancements:
* - Deke/stickhandle under pressure
* - One-timer pass opportunities
* - Look for trailer for pass
*/
export class PuckCarrierBehavior extends BehaviorNode {
private tree: BehaviorNode;
constructor() {
super();
// Build decision tree for puck carrier
this.tree = new Sequence([
// First check if this player has the puck
new Condition((player, gameState) => gameState.puck.carrier === player.id),
// Then choose action (in future, this will be a Selector with shoot/pass/carry options)
new Action((player, gameState) => this.decidePuckCarrierAction(player, gameState))
]);
}
tick(player: Player, gameState: GameState): BehaviorStatus | PlayerAction {
return this.tree.tick(player, gameState);
}
/**
* Decide what to do with the puck
* Currently: Shoot if good opportunity, otherwise carry toward net
*/
private decidePuckCarrierAction(player: Player, _gameState: GameState): PlayerAction {
const opponentGoalX = player.team === 'home' ? GOAL_LINE_OFFSET : -GOAL_LINE_OFFSET;
// Check for shooting opportunity
if (this.hasGoodShot(player, opponentGoalX)) {
console.log(`${player.id} is shooting!`);
return {
type: 'shoot',
targetX: opponentGoalX,
targetY: 0 // Aim for center of net
};
}
// Default: Carry puck toward opponent's net
return {
type: 'skate_with_puck',
targetX: opponentGoalX,
targetY: 0
};
}
/**
* Evaluate if player has a good shooting opportunity
*/
private hasGoodShot(player: Player, opponentGoalX: number): boolean {
const goalY = 0;
// Calculate distance to goal
const distance = MathUtils.distance(player.gameX, player.gameY, opponentGoalX, goalY);
// Check if within shooting range
if (distance >= SHOOTING_RANGE) {
return false;
}
// Calculate angle to goal (in radians)
const dx = opponentGoalX - player.gameX;
const dy = goalY - player.gameY;
const angleToGoal = Math.atan2(dy, dx);
// Calculate player's direction of movement
const moveX = player.targetX - player.gameX;
const moveY = player.targetY - player.gameY;
const angleOfMovement = Math.atan2(moveY, moveX);
// Calculate angle difference
let angleDiff = Math.abs(angleToGoal - angleOfMovement);
// Normalize to 0-π
if (angleDiff > Math.PI) {
angleDiff = 2 * Math.PI - angleDiff;
}
// Good angle if within threshold
return angleDiff < SHOOTING_ANGLE_THRESHOLD;
}
}