diff --git a/src/systems/BehaviorNodes.ts b/src/systems/BehaviorNodes.ts new file mode 100644 index 0000000..48d6461 --- /dev/null +++ b/src/systems/BehaviorNodes.ts @@ -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); + } +} diff --git a/src/systems/BehaviorTree.ts b/src/systems/BehaviorTree.ts index 32e04c2..d62f415 100644 --- a/src/systems/BehaviorTree.ts +++ b/src/systems/BehaviorTree.ts @@ -1,136 +1,60 @@ 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 { 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 { + 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 { - const { puck } = 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; + const tree = new BehaviorTree(player); + return tree.tick(gameState); } } diff --git a/src/systems/behaviors/GoalieBehavior.ts b/src/systems/behaviors/GoalieBehavior.ts new file mode 100644 index 0000000..39eec2d --- /dev/null +++ b/src/systems/behaviors/GoalieBehavior.ts @@ -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 + }; + } +} diff --git a/src/systems/behaviors/SkaterBehavior.ts b/src/systems/behaviors/SkaterBehavior.ts new file mode 100644 index 0000000..3ac2ad5 --- /dev/null +++ b/src/systems/behaviors/SkaterBehavior.ts @@ -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; + } +} diff --git a/src/systems/behaviors/TransitionBehavior.ts b/src/systems/behaviors/TransitionBehavior.ts new file mode 100644 index 0000000..e5d766c --- /dev/null +++ b/src/systems/behaviors/TransitionBehavior.ts @@ -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; + } +} diff --git a/src/systems/behaviors/defensive/DefensiveBehavior.ts b/src/systems/behaviors/defensive/DefensiveBehavior.ts new file mode 100644 index 0000000..fa0a776 --- /dev/null +++ b/src/systems/behaviors/defensive/DefensiveBehavior.ts @@ -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); + } +} diff --git a/src/systems/behaviors/offensive/OffensiveSupportBehavior.ts b/src/systems/behaviors/offensive/OffensiveSupportBehavior.ts new file mode 100644 index 0000000..354b3cc --- /dev/null +++ b/src/systems/behaviors/offensive/OffensiveSupportBehavior.ts @@ -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); + } +} diff --git a/src/systems/behaviors/offensive/PuckCarrierBehavior.ts b/src/systems/behaviors/offensive/PuckCarrierBehavior.ts new file mode 100644 index 0000000..314f924 --- /dev/null +++ b/src/systems/behaviors/offensive/PuckCarrierBehavior.ts @@ -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; + } +}