From d2fb60df8d20e29109442b9b0205d7686ed5a789 Mon Sep 17 00:00:00 2001 From: Pierre Date: Wed, 1 Oct 2025 14:51:24 +0000 Subject: [PATCH] init player --- src/entities/Player.ts | 161 ++++++++++++++++++++++++++++++++++++ src/entities/Puck.ts | 24 ++++-- src/game/GameScene.ts | 118 +++++++++++++++++++++++++- src/game/Goal.ts | 2 +- src/systems/BehaviorTree.ts | 139 +++++++++++++++++++++++++++++++ 5 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 src/entities/Player.ts create mode 100644 src/systems/BehaviorTree.ts diff --git a/src/entities/Player.ts b/src/entities/Player.ts new file mode 100644 index 0000000..57e29b3 --- /dev/null +++ b/src/entities/Player.ts @@ -0,0 +1,161 @@ +import Phaser from 'phaser'; +import { SCALE } from '../config/constants'; + +export type PlayerPosition = 'LW' | 'C' | 'RW' | 'LD' | 'RD' | 'G'; +export type TeamSide = 'home' | 'away'; +export type PlayerState = 'offensive' | 'defensive'; + +export interface PlayerAttributes { + speed: number; // 0-100: movement speed + skill: number; // 0-100: pass/shot accuracy, decision quality +} + +export class Player extends Phaser.GameObjects.Container { + declare body: Phaser.Physics.Arcade.Body; + + // Player identity + public id: string; + public team: TeamSide; + public playerPosition: PlayerPosition; + + // Position in game coordinates (meters) + public gameX: number; + public gameY: number; + + // Target position (where player wants to move) + public targetX: number; + public targetY: number; + + // Player attributes + public attributes: PlayerAttributes; + + // Player state + public state: PlayerState; + + constructor( + scene: Phaser.Scene, + id: string, + team: TeamSide, + playerPosition: PlayerPosition, + gameX: number, + gameY: number, + attributes: PlayerAttributes + ) { + // Convert game coordinates to screen coordinates + const screenX = (scene.game.config.width as number) / 2 + gameX * SCALE; + const screenY = (scene.game.config.height as number) / 2 - gameY * SCALE; + + super(scene, screenX, screenY); + + this.id = id; + this.team = team; + this.playerPosition = playerPosition; + this.gameX = gameX; + this.gameY = gameY; + this.targetX = gameX; + this.targetY = gameY; + this.attributes = attributes; + this.state = 'defensive'; + + // Add to scene + scene.add.existing(this); + scene.physics.add.existing(this); + + this.body = this.body as Phaser.Physics.Arcade.Body; + + // Set physics body (circular, centered on container) + const radius = this.playerPosition === 'G' ? 12 : 10; + this.body.setCircle(radius); + this.body.setOffset(-radius, -radius); // Center the body on the container + this.body.setCollideWorldBounds(true); + + this.createSprite(); + } + + private createSprite() { + const graphics = this.scene.add.graphics(); + + // Determine color based on team + const color = this.team === 'home' ? 0x0000ff : 0xff0000; + + // Make goalie larger + const radius = this.playerPosition === 'G' ? 12 : 10; + + // Draw player circle + graphics.fillStyle(color, 1); + graphics.fillCircle(0, 0, radius); + + // Add white outline + graphics.lineStyle(2, 0xffffff, 1); + graphics.strokeCircle(0, 0, radius); + + // Add position label + const label = this.scene.add.text(0, 0, this.playerPosition, { + fontSize: '8px', + color: '#ffffff', + fontStyle: 'bold' + }); + label.setOrigin(0.5, 0.5); + + this.add([graphics, label]); + } + + /** + * Update player position in game coordinates (meters) + */ + public setGamePosition(gameX: number, gameY: number) { + this.gameX = gameX; + this.gameY = gameY; + + // Convert to screen coordinates + const centerX = (this.scene.game.config.width as number) / 2; + const centerY = (this.scene.game.config.height as number) / 2; + + this.setPosition( + centerX + gameX * SCALE, + centerY - gameY * SCALE + ); + } + + /** + * Set target position for movement + */ + public setTarget(targetX: number, targetY: number) { + this.targetX = targetX; + this.targetY = targetY; + } + + /** + * Update player movement each frame + */ + public update(_delta: number) { + // Calculate distance to target + const dx = this.targetX - this.gameX; + const dy = this.targetY - this.gameY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // If close enough to target, stop + if (distance < 0.1) { + this.body.setVelocity(0, 0); + return; + } + + // Calculate velocity based on speed attribute + // speed attribute (0-100) maps to actual m/s (e.g., 80 -> 8 m/s) + const speed = (this.attributes.speed / 10) * SCALE; // Convert to pixels/s + + // Normalize direction and apply speed + const dirX = dx / distance; + const dirY = dy / distance; + + this.body.setVelocity(dirX * speed, -dirY * speed); // Negative Y because screen coords + + // Update game position based on physics body center + const centerX = (this.scene.game.config.width as number) / 2; + const centerY = (this.scene.game.config.height as number) / 2; + const bodyX = this.body.x + this.body.width / 2; + const bodyY = this.body.y + this.body.height / 2; + this.gameX = (bodyX - centerX) / SCALE; + this.gameY = -(bodyY - centerY) / SCALE; + } +} diff --git a/src/entities/Puck.ts b/src/entities/Puck.ts index 3935348..083fc00 100644 --- a/src/entities/Puck.ts +++ b/src/entities/Puck.ts @@ -41,13 +41,8 @@ export class Puck extends Phaser.GameObjects.Container { // Set max velocity (allow up to x m/s) this.body.setMaxVelocity(50 * SCALE, 50 * SCALE); - // Set initial velocity (5 m/s in a random direction for testing) - const angle = Math.PI * .0; //Math.random() * Math.PI * 2; - const speed = 100 * SCALE; // 5 m/s converted to pixels/s - this.body.setVelocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - ); + // Set initial velocity to 0 (stationary) + this.body.setVelocity(0, 0); // Add bounce to keep it moving this.body.setBounce(1, 1); @@ -113,4 +108,19 @@ export class Puck extends Phaser.GameObjects.Container { public getGamePosition(): { x: number; y: number } { return { x: this.gameX, y: this.gameY }; } + + /** + * Update puck game coordinates based on physics body position + */ + public update() { + const centerX = (this.scene.game.config.width as number) / 2; + const centerY = (this.scene.game.config.height as number) / 2; + + // Use body center position (body.x/y is top-left, need to account for that) + const bodyX = this.body.x + this.body.width / 2; + const bodyY = this.body.y + this.body.height / 2; + + this.gameX = (bodyX - centerX) / SCALE; + this.gameY = -(bodyY - centerY) / SCALE; + } } diff --git a/src/game/GameScene.ts b/src/game/GameScene.ts index a2991ef..3defbbd 100644 --- a/src/game/GameScene.ts +++ b/src/game/GameScene.ts @@ -13,11 +13,14 @@ import { } from '../config/constants'; import { Goal } from './Goal'; import { Puck } from '../entities/Puck'; +import { Player } from '../entities/Player'; +import { BehaviorTree } from '../systems/BehaviorTree'; export class GameScene extends Phaser.Scene { private leftGoal!: Goal; private rightGoal!: Goal; private puck!: Puck; + private players: Player[] = []; constructor() { super({ key: 'GameScene' }); @@ -27,13 +30,33 @@ export class GameScene extends Phaser.Scene { this.drawRink(); this.createGoals(); this.createPuck(); + this.createPlayers(); this.setupEventListeners(); } + private createPlayers() { + // Create one home center at (-10, 0) - left side of center ice + const homeCenter = new Player( + this, + 'home-C', + 'home', + 'C', + -10, + 0, + { speed: 80, skill: 75 } + ); + + this.players.push(homeCenter); + } + private setupEventListeners() { // Listen for goal events this.events.on('goal', (data: { team: string; goal: string }) => { console.log(`[GameScene] Goal scored by ${data.team} team in ${data.goal} goal`); + + // Stop the puck (caught by net) + this.puck.body.setVelocity(0, 0); + // Future: update score, trigger celebration, reset to faceoff, etc. }); } @@ -110,9 +133,102 @@ export class GameScene extends Phaser.Scene { graphics.strokeRoundedRect(2, 2, RINK_LENGTH * SCALE - 4, RINK_WIDTH * SCALE - 4, RINK_CORNER_RADIUS * SCALE); } - update() { + update(_time: number, delta: number) { + // Update puck position + this.puck.update(); + + // Check for puck pickup + this.checkPuckPickup(); + // Check for goals this.leftGoal.checkGoal(this.puck); this.rightGoal.checkGoal(this.puck); + + // Build game state + const gameState = { + puck: this.puck, + allPlayers: this.players + }; + + // Update all players with behavior tree decisions + this.players.forEach(player => { + // Evaluate behavior tree to get action + const action = BehaviorTree.evaluatePlayer(player, gameState); + + // Apply action to player + if (action.type === 'shoot') { + // Execute shot + this.executeShot(player, action.targetX!, action.targetY!); + } else if (action.type === 'move' || action.type === 'chase_puck' || action.type === 'skate_with_puck') { + if (action.targetX !== undefined && action.targetY !== undefined) { + player.setTarget(action.targetX, action.targetY); + } + } + + // Update player movement + player.update(delta); + + // If player has puck, update puck position (in front of player) + if (this.puck.carrier === player.id) { + // Calculate direction player is moving + const dx = player.targetX - player.gameX; + const dy = player.targetY - player.gameY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0.1) { + // Place puck 1 meter in front of player in direction of movement + const dirX = dx / distance; + const dirY = dy / distance; + this.puck.setGamePosition( + player.gameX + dirX * 1.0, + player.gameY + dirY * 1.0 + ); + } else { + // If not moving, keep puck at player position + this.puck.setGamePosition(player.gameX, player.gameY); + } + } + }); + } + + private checkPuckPickup() { + if (this.puck.state !== 'loose') return; + + // Check each player's distance to puck + this.players.forEach(player => { + const dx = player.gameX - this.puck.gameX; + const dy = player.gameY - this.puck.gameY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Pickup radius: 1.5 meters + if (distance < 1.5) { + this.puck.setCarrier(player.id, player.team); + console.log(`${player.id} picked up the puck`); + } + }); + } + + private executeShot(player: Player, targetX: number, targetY: number) { + console.log(`${player.id} shoots toward (${targetX}, ${targetY})`); + + // Release puck from player control + this.puck.setLoose(); + + // Calculate shot direction + const dx = targetX - player.gameX; + const dy = targetY - player.gameY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0) { + // Normalize direction + const dirX = dx / distance; + const dirY = dy / distance; + + // Shot speed: 30 m/s + const shotSpeed = 30 * SCALE; + + // Apply velocity to puck + this.puck.body.setVelocity(dirX * shotSpeed, -dirY * shotSpeed); + } } } diff --git a/src/game/Goal.ts b/src/game/Goal.ts index a634c6d..c18b595 100644 --- a/src/game/Goal.ts +++ b/src/game/Goal.ts @@ -24,7 +24,7 @@ export class Goal extends Phaser.GameObjects.Container { const postThickness = 0.3 * SCALE; // Post thickness const goalWidth = GOAL_WIDTH * SCALE; // Width of goal opening (top to bottom) const goalDepth = GOAL_DEPTH * SCALE; // Depth extending into zone - const barThickness = 0.3 * SCALE; // Thicker bar to prevent high-speed puck tunneling + const barThickness = 0.4 * SCALE; // Thicker bar to prevent high-speed puck tunneling // Create graphics for visual representation const graphics = this.scene.add.graphics(); diff --git a/src/systems/BehaviorTree.ts b/src/systems/BehaviorTree.ts new file mode 100644 index 0000000..c5f10e4 --- /dev/null +++ b/src/systems/BehaviorTree.ts @@ -0,0 +1,139 @@ +import type { Player } from '../entities/Player'; +import type { Puck } from '../entities/Puck'; + +export interface GameState { + puck: Puck; + allPlayers: Player[]; +} + +export interface PlayerAction { + type: 'move' | 'chase_puck' | 'skate_with_puck' | 'shoot' | 'idle'; + targetX?: number; + targetY?: number; +} + +/** + * Simple behavior tree for player decision making + */ +export class BehaviorTree { + /** + * Evaluate player behavior and return action + */ + 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' ? -26 : 26; + + // Track puck Y position (clamped to goal width) + const targetY = Math.max(-3, Math.min(3, 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' ? 26 : -26; + + // 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' ? 26 : -26; + const goalY = 0; + + // Calculate distance to goal + const dx = opponentGoalX - player.gameX; + const dy = goalY - player.gameY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Check if within shooting range (< 10m) + if (distance >= 10) { + return false; + } + + // Calculate angle to goal (in radians) + 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 45 degrees (π/4 radians) of goal + const reasonableAngle = angleDiff < Math.PI / 4; + + return reasonableAngle; + } +}