Compare commits

..

No commits in common. "d17d5c594bcbc96aa6c46823cc801302d028e2ed" and "40d07ee68c98b1fa21d347d6e643c2d0246a000f" have entirely different histories.

11 changed files with 129 additions and 771 deletions

View File

@ -1,9 +1,7 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(cat:*)",
"Bash(pnpm run:*)"
"Bash(find:*)"
],
"deny": [],
"ask": []

View File

@ -1,452 +0,0 @@
# Code Review Findings
**Date:** 2025-10-02
**Review Type:** Major Review (Phase 2-3 completion)
**Reviewer:** Automated review based on [REVIEW.md](REVIEW.md) checklist
---
## Executive Summary
Overall code quality is **good** with solid architecture foundations. The codebase shows:
- ✅ Excellent TypeScript strict mode compliance
- ✅ Good file organization following planned structure
- ✅ Clean separation of concerns (entities, systems, config)
- ⚠️ Several DRY violations requiring attention
- ⚠️ Missing utility layer for common operations
- ⚠️ Some magic numbers not yet extracted to constants
**Priority Actions:**
1. **Critical**: Extract coordinate conversion utilities (high duplication risk)
2. **Important**: Create math utility module for distance calculations
3. **Important**: Extract remaining magic numbers to constants
4. **Nice-to-have**: Simplify screen coordinate calculations
---
## 1. Code Organization & Architecture ✅
### File Structure ✅
- [x] Files correctly organized by responsibility
- [x] Clear separation: entities/, systems/, config/, game/
- [x] No circular dependencies detected
- [x] Phaser code appropriately isolated
**Files reviewed:**
- [constants.ts](src/config/constants.ts) (31 lines)
- [main.ts](src/game/main.ts) (25 lines)
- [GameScene.ts](src/game/GameScene.ts) (234 lines) ⚠️ Largest file
- [Player.ts](src/entities/Player.ts) (211 lines)
- [Puck.ts](src/entities/Puck.ts) (126 lines)
- [Goal.ts](src/game/Goal.ts) (144 lines)
- [BehaviorTree.ts](src/systems/BehaviorTree.ts) (139 lines)
### Naming Conventions ✅
- [x] Classes use PascalCase (`Player`, `GameScene`, `BehaviorTree`)
- [x] Files match class names
- [x] Constants use SCREAMING_SNAKE_CASE (`RINK_WIDTH`, `SCALE`)
- [x] Variables/functions use camelCase
- [x] Meaningful names throughout
---
## 2. DRY (Don't Repeat Yourself) ⚠️
### Code Duplication Found
#### **CRITICAL: Coordinate Conversion (4 instances)**
**Pattern duplicated:**
```typescript
// Converting game coords → screen coords
const centerX = (this.scene.game.config.width as number) / 2;
const centerY = (this.scene.game.config.height as number) / 2;
const screenX = centerX + gameX * SCALE;
const screenY = centerY - gameY * SCALE;
```
**Found in:**
1. [Player.ts:49-50](src/entities/Player.ts#L49-50) - Constructor
2. [Player.ts:118-123](src/entities/Player.ts#L118-123) - `setGamePosition()`
3. [Puck.ts:20-21](src/entities/Puck.ts#L20-21) - Constructor
4. [Puck.ts:79-84](src/entities/Puck.ts#L79-84) - `setGamePosition()`
**Recommendation:** Extract to utility module
```typescript
// src/utils/coordinates.ts
export class CoordinateUtils {
static gameToScreen(scene: Phaser.Scene, gameX: number, gameY: number): { x: number; y: number } {
const centerX = (scene.game.config.width as number) / 2;
const centerY = (scene.game.config.height as number) / 2;
return {
x: centerX + gameX * SCALE,
y: centerY - gameY * SCALE
};
}
static screenToGame(scene: Phaser.Scene, screenX: number, screenY: number): { x: number; y: number } {
const centerX = (scene.game.config.width as number) / 2;
const centerY = (scene.game.config.height as number) / 2;
return {
x: (screenX - centerX) / SCALE,
y: -(screenY - centerY) / SCALE
};
}
}
```
---
#### **IMPORTANT: Distance Calculation (4 instances)**
**Pattern duplicated:**
```typescript
const dx = x2 - x1;
const dy = y2 - y1;
const distance = Math.sqrt(dx * dx + dy * dy);
```
**Found in:**
1. [GameScene.ts:174-176](src/game/GameScene.ts#L174-176) - Puck position with carrier
2. [GameScene.ts:199-201](src/game/GameScene.ts#L199-201) - Puck pickup check
3. [Player.ts:158-160](src/entities/Player.ts#L158-160) - Movement update
4. [BehaviorTree.ts:110-112](src/systems/BehaviorTree.ts#L110-112) - Shot evaluation
**Recommendation:** Extract to math utility
```typescript
// src/utils/math.ts
export class MathUtils {
static distance(x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
static distanceSquared(x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
return dx * dx + dy * dy; // Faster for comparisons
}
}
```
---
#### **IMPORTANT: Body Center Position Calculation (2 instances)**
**Pattern duplicated:**
```typescript
const bodyX = this.body.x + this.body.width / 2;
const bodyY = this.body.y + this.body.height / 2;
```
**Found in:**
1. [Player.ts:206-207](src/entities/Player.ts#L206-207)
2. [Puck.ts:120-121](src/entities/Puck.ts#L120-121)
**Recommendation:** Extract to physics utility or base class method
---
#### **Minor: Angle Normalization (2 instances)**
**Pattern duplicated:**
```typescript
// Normalize to [-PI, PI]
while (angle > Math.PI) angle -= Math.PI * 2;
while (angle < -Math.PI) angle += Math.PI * 2;
```
**Found in:**
1. [Player.ts:178-179](src/entities/Player.ts#L178-179)
2. [Player.ts:186-187](src/entities/Player.ts#L186-187)
**Recommendation:** Extract to math utility
```typescript
static normalizeAngle(angle: number): number {
while (angle > Math.PI) angle -= Math.PI * 2;
while (angle < -Math.PI) angle += Math.PI * 2;
return angle;
}
```
---
## 3. KISS (Keep It Simple) ⚠️
### Complexity Issues
#### **GameScene.update() - 56 lines** ([GameScene.ts:136-192](src/game/GameScene.ts#L136-192))
The main update loop handles too many responsibilities:
- Puck position updates
- Puck pickup checks
- Goal checks
- Game state building
- Player AI evaluation
- Player movement
- Puck carrier positioning
**Recommendation:** Extract methods for clarity
```typescript
update(_time: number, delta: number) {
this.updatePuck();
this.updatePlayers(delta);
this.checkGoals();
}
private updatePuck() { /* ... */ }
private updatePlayers(delta: number) { /* ... */ }
private checkGoals() { /* ... */ }
```
#### **Player.update() - Good complexity**
Movement logic is complex but well-structured and appropriately commented.
---
## 4. Type Safety ✅
### TypeScript Usage ✅
- [x] `strict: true` enabled in tsconfig.json
- [x] No `any` types detected (good use of type assertions: `as Phaser.Physics.Arcade.Body`)
- [x] Interfaces properly defined (`PlayerAttributes`, `GameState`, `PlayerAction`)
- [x] Union types used for fixed values (`PlayerPosition`, `TeamSide`, `PuckState`)
- [x] Return types explicit on public methods
- [x] Optional chaining not needed (no null issues)
### Type Definitions ✅
- [x] Shared types in appropriate locations
- [x] Entity state properly typed
- [x] No inline types duplicated
**Excellent type safety throughout!**
---
## 5. Performance ✅ ⚠️
### Update Loop Performance ⚠️
**Issues found:**
1. **GameScene.update() - forEach loop** ([GameScene.ts:154-191](src/game/GameScene.ts#L154-191))
- ✅ No object allocations in hot loop
- ⚠️ Creates gameState object every frame (minor)
- ⚠️ Multiple distance calculations per frame
2. **Player.update()** ([Player.ts:156-210](src/entities/Player.ts#L156-210))
- ✅ Early return when at target
- ✅ No allocations
- ✅ Efficient calculations
3. **Puck.update()** ([Puck.ts:115-125](src/entities/Puck.ts#L115-125))
- ✅ Minimal overhead
- ✅ No allocations
**Recommendations:**
- Consider caching `gameState` object and mutating properties instead of recreating
- Use `distanceSquared` for comparison checks (avoid `Math.sqrt` when possible)
### Phaser Best Practices ✅
- [x] Physics bodies properly configured
- [x] Sprites reused (no pooling needed yet - only 1 player)
- [x] Graphics created once in constructors
- [x] No texture reloading
---
## 6. Constants & Configuration ⚠️
### Centralization ⚠️
**Constants properly extracted:**
- ✅ `RINK_LENGTH`, `RINK_WIDTH`, `SCALE`
- ✅ `BLUE_LINE_OFFSET`, `GOAL_LINE_OFFSET`
- ✅ `GOAL_WIDTH`, `GOAL_DEPTH`
- ✅ `COLOR_*` constants
- ✅ `FPS`, `DEBUG`
- ✅ `PLAYER_ROTATION_SPEED`
**Magic numbers still in code:**
| Location | Value | Meaning | Should Be |
|----------|-------|---------|-----------|
| [Player.ts:74](src/entities/Player.ts#L74) | `12`, `10` | Player radius | `PLAYER_RADIUS_GOALIE`, `PLAYER_RADIUS_SKATER` |
| [Player.ts:89](src/entities/Player.ts#L89) | `12`, `10` | Player radius (duplicate) | Same as above |
| [Player.ts:194](src/entities/Player.ts#L194) | `/ 10` | Speed scale factor | `SPEED_SCALE_FACTOR` or comment explaining |
| [GameScene.ts:46](src/game/GameScene.ts#L46) | `80`, `75` | Player attributes | Consider named constants or config file |
| [GameScene.ts:125](src/game/GameScene.ts#L125) | `4.5` | Faceoff circle radius | `FACEOFF_CIRCLE_RADIUS` |
| [GameScene.ts:129](src/game/GameScene.ts#L129) | `5` | Center dot radius | `CENTER_DOT_RADIUS` |
| [GameScene.ts:178](src/game/GameScene.ts#L178) | `0.1` | Stop threshold | `MOVEMENT_STOP_THRESHOLD` |
| [GameScene.ts:184](src/game/GameScene.ts#L184) | `1.0` | Puck carry distance | `PUCK_CARRY_DISTANCE` |
| [GameScene.ts:204](src/game/GameScene.ts#L204) | `1.5` | Pickup radius | `PUCK_PICKUP_RADIUS` |
| [GameScene.ts:228](src/game/GameScene.ts#L228) | `30` | Shot speed | `SHOT_SPEED` |
| [Player.ts:141](src/entities/Player.ts#L141) | `0.9` | Deceleration rate | `GOAL_DECELERATION_RATE` |
| [Player.ts:163](src/entities/Player.ts#L163) | `0.1` | Stop threshold | `MOVEMENT_STOP_THRESHOLD` (duplicate!) |
| [Puck.ts:42](src/entities/Puck.ts#L42) | `50` | Max puck velocity | `MAX_PUCK_VELOCITY` |
| [Goal.ts:24](src/goal/Goal.ts#L24) | `0.3` | Post thickness | `GOAL_POST_THICKNESS` |
| [Goal.ts:27](src/goal/Goal.ts#L27) | `0.4` | Back bar thickness | `GOAL_BAR_THICKNESS` |
| [BehaviorTree.ts:53](src/systems/BehaviorTree.ts#L53) | `-26`, `26` | Goal X positions | `GOAL_LINE_OFFSET` (already exists!) |
| [BehaviorTree.ts:56](src/systems/BehaviorTree.ts#L56) | `-3`, `3` | Goalie Y clamp | `GOAL_WIDTH / 2` or `GOALIE_RANGE` |
| [BehaviorTree.ts:81](src/systems/BehaviorTree.ts#L81) | `26`, `-26` | Goal X positions | Duplicate of above |
| [BehaviorTree.ts:106](src/systems/BehaviorTree.ts#L106) | `26`, `-26` | Goal X positions | Duplicate again! |
| [BehaviorTree.ts:115](src/systems/BehaviorTree.ts#L115) | `10` | Shooting range | `SHOOTING_RANGE` |
| [BehaviorTree.ts:135](src/systems/BehaviorTree.ts#L135) | `Math.PI / 4` | Shooting angle | `SHOOTING_ANGLE_THRESHOLD` |
**CRITICAL:** Goal X position (`±26`) is hardcoded **3 times** in BehaviorTree.ts but `GOAL_LINE_OFFSET` constant already exists!
### Recommendations
**High Priority:**
```typescript
// Add to constants.ts
export const GOAL_LINE_OFFSET = 26; // Already exists - USE IT!
export const PLAYER_RADIUS_GOALIE = 12;
export const PLAYER_RADIUS_SKATER = 10;
export const MOVEMENT_STOP_THRESHOLD = 0.1; // meters
export const PUCK_PICKUP_RADIUS = 1.5; // meters
export const PUCK_CARRY_DISTANCE = 1.0; // meters in front of player
export const SHOT_SPEED = 30; // m/s
export const MAX_PUCK_VELOCITY = 50; // m/s
export const SHOOTING_RANGE = 10; // meters
export const SHOOTING_ANGLE_THRESHOLD = Math.PI / 4; // 45 degrees
```
---
## 7. Error Handling ✅
### Robustness ✅
- [x] Position validation via `setCollideWorldBounds(true)`
- [x] Division by zero checks in distance calculations (`if (distance > 0)`)
- [x] Null guards in Goal.checkGoal() (`if (!puckBody || !zoneBody) return`)
- [x] Angle normalization prevents overflow
### Debugging ✅
- [x] Meaningful console logs (`${player.id} picked up the puck`)
- [x] DEBUG constant for Phaser physics debug view
- [x] Visual debug zones in goals (green translucent)
**No issues found.**
---
## 8. Comments & Documentation ✅
### Code Comments ✅
- [x] Complex algorithms explained (Player rotation, angle normalization)
- [x] JSDoc on public methods (`setGamePosition`, `setTarget`, `update`)
- [x] Inline comments clarify coordinate systems
- [x] Physics body setup well-commented
### Project Documentation ✅
- [x] [CLAUDE.md](CLAUDE.md) accurately reflects current architecture
- [x] [PLAN.md](PLAN.md) shows phase progress
- [x] [NOTES.md](NOTES.md) exists (not reviewed in this pass)
- [x] README.md exists (assumed correct)
**No issues found.**
---
## 9. Testing Readiness ⚠️
### Testability ⚠️
- ✅ BehaviorTree has static methods (easy to unit test)
- ✅ Pure logic separated (distance calc, angle calc)
- ⚠️ Game logic tightly coupled to Phaser (Player, Puck extend Phaser classes)
- ⚠️ No dependency injection (scene passed directly)
- ⚠️ Update loops contain state mutation (harder to test)
**Recommendations:**
- Extract pure game logic to separate classes/functions
- Consider separating rendering from game state
- Add unit tests for BehaviorTree decisions
- Add integration tests for coordinate conversions
---
## 10. Git Hygiene ✅
Not reviewed in this pass (would require git history inspection).
**Assumed clean based on visible code quality.**
---
## Summary of Issues
### Critical (Fix Immediately)
1. **Coordinate conversion duplication** (4 instances) → Create `CoordinateUtils`
2. **Hardcoded goal positions in BehaviorTree** (3 instances) → Use existing `GOAL_LINE_OFFSET` constant
### Important (Fix Soon)
3. **Distance calculation duplication** (4 instances) → Create `MathUtils.distance()`
4. **22+ magic numbers not in constants** → Extract to [constants.ts](src/config/constants.ts)
5. **GameScene.update() too complex** → Extract into smaller methods
### Nice-to-Have (Improvements)
6. **Angle normalization duplication** → Extract utility function
7. **Body center calculation duplication** → Extract method
8. **GameState object recreation** → Consider object reuse for performance
9. **Testability** → Decouple game logic from Phaser rendering
---
## Refactoring Roadmap
### Step 1: Create Utilities (High Priority)
1. Create `src/utils/coordinates.ts` with conversion functions
2. Create `src/utils/math.ts` with distance/angle utilities
3. Update all files to use new utilities
4. Test coordinate conversions thoroughly
### Step 2: Extract Constants (High Priority)
1. Add all magic numbers to [constants.ts](src/config/constants.ts)
2. Update BehaviorTree to use `GOAL_LINE_OFFSET` instead of hardcoded `26`
3. Replace all numeric literals with named constants
4. Group constants logically (PLAYER_*, PUCK_*, SHOOTING_*)
### Step 3: Simplify GameScene (Medium Priority)
1. Extract `updatePuck()` method
2. Extract `updatePlayers()` method
3. Extract `checkGoals()` method
4. Consider `checkPuckPickup()` inline or as property
### Step 4: Performance Tweaks (Low Priority)
1. Cache gameState object (mutate instead of recreate)
2. Use `distanceSquared` where comparison only is needed
3. Profile with many players (12+) to identify bottlenecks
### Step 5: Testing Setup (Future)
1. Add Jest/Vitest configuration
2. Create unit tests for BehaviorTree
3. Create unit tests for utilities
4. Add integration tests for game flow
---
## Conclusion
**Overall Grade: B+**
The codebase is in excellent shape for this stage of development (Phase 2-3). Architecture is sound, TypeScript usage is exemplary, and code is generally clean and readable.
**Key Strengths:**
- Solid architecture with clear separation of concerns
- Excellent TypeScript strict mode compliance
- Good performance patterns in hot paths
- Clean, readable code with meaningful names
**Key Weaknesses:**
- DRY violations in coordinate/math operations (easily fixable)
- Too many magic numbers not yet extracted
- GameScene.update() could be cleaner
**Recommendation:** Address critical DRY issues and extract magic numbers before proceeding to Phase 4. This will make behavior tree tuning much easier when you need to adjust shooting ranges, speeds, and decision thresholds.
---
**Next Review:** After Phase 5 (Team offensive positioning system) or when player count increases to 12.

View File

@ -27,31 +27,5 @@ export const COLOR_GOAL_CREASE = 0x87ceeb;
export const FPS = 60;
export const DEBUG = true;
// Player constants
export const PLAYER_RADIUS_GOALIE = 12; // pixels
export const PLAYER_RADIUS_SKATER = 10; // pixels
export const PLAYER_ROTATION_SPEED = 1; // radians per second
export const SPEED_SCALE_FACTOR = 10; // speed attribute (0-100) / 10 = m/s
export const GOAL_DECELERATION_RATE = 0.9; // Speed multiplier reduction after goal
// Movement constants
export const MOVEMENT_STOP_THRESHOLD = 0.1; // meters - stop moving when this close to target
// Puck constants
export const PUCK_PICKUP_RADIUS = 1.5; // meters
export const PUCK_CARRY_DISTANCE = 1.0; // meters in front of player
export const MAX_PUCK_VELOCITY = 50; // m/s
export const SHOT_SPEED = 30; // m/s
// Goal constants
export const GOAL_POST_THICKNESS = 0.3; // meters
export const GOAL_BAR_THICKNESS = 0.4; // meters
// Rink visual constants
export const FACEOFF_CIRCLE_RADIUS = 4.5; // meters
export const CENTER_DOT_RADIUS = 5; // pixels
// AI/Behavior constants
export const SHOOTING_RANGE = 10; // meters - max distance to attempt shot
export const SHOOTING_ANGLE_THRESHOLD = Math.PI / 4; // radians (45 degrees)
export const GOALIE_RANGE = 3; // meters - how far goalie moves from center
// Player movement
export const PLAYER_ROTATION_SPEED = 1; // radians per second

View File

@ -1,16 +1,14 @@
import Phaser from 'phaser';
import {
SCALE,
PLAYER_ROTATION_SPEED,
PLAYER_RADIUS_GOALIE,
PLAYER_RADIUS_SKATER,
SPEED_SCALE_FACTOR,
MOVEMENT_STOP_THRESHOLD,
GOAL_DECELERATION_RATE
} from '../config/constants';
import { CoordinateUtils } from '../utils/coordinates';
import { MathUtils } from '../utils/math';
import type { PlayerPosition, TeamSide, PlayerState, PlayerAttributes } from '../types/game';
import { SCALE, PLAYER_ROTATION_SPEED } 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;
@ -48,9 +46,10 @@ export class Player extends Phaser.GameObjects.Container {
attributes: PlayerAttributes
) {
// Convert game coordinates to screen coordinates
const screenPos = CoordinateUtils.gameToScreen(scene, gameX, gameY);
const screenX = (scene.game.config.width as number) / 2 + gameX * SCALE;
const screenY = (scene.game.config.height as number) / 2 - gameY * SCALE;
super(scene, screenPos.x, screenPos.y);
super(scene, screenX, screenY);
this.id = id;
this.team = team;
@ -72,7 +71,7 @@ export class Player extends Phaser.GameObjects.Container {
this.body = this.body as Phaser.Physics.Arcade.Body;
// Set physics body (circular, centered on container)
const radius = this.playerPosition === 'G' ? PLAYER_RADIUS_GOALIE : PLAYER_RADIUS_SKATER;
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);
@ -87,7 +86,7 @@ export class Player extends Phaser.GameObjects.Container {
const color = this.team === 'home' ? 0x0000ff : 0xff0000;
// Make goalie larger
const radius = this.playerPosition === 'G' ? PLAYER_RADIUS_GOALIE : PLAYER_RADIUS_SKATER;
const radius = this.playerPosition === 'G' ? 12 : 10;
// Draw player circle
graphics.fillStyle(color, 1);
@ -116,8 +115,13 @@ export class Player extends Phaser.GameObjects.Container {
this.gameY = gameY;
// Convert to screen coordinates
const screenPos = CoordinateUtils.gameToScreen(this.scene, gameX, gameY);
this.setPosition(screenPos.x, screenPos.y);
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
);
}
/**
@ -134,7 +138,7 @@ export class Player extends Phaser.GameObjects.Container {
private onGoal(_data: { team: string; goal: string }) {
// Gradually decelerate player after goal
const decelerate = () => {
this.speedMultiplier *= GOAL_DECELERATION_RATE;
this.speedMultiplier *= 0.9;
if (this.speedMultiplier > 0.01) {
this.scene.time.delayedCall(50, decelerate);
@ -151,17 +155,16 @@ export class Player extends Phaser.GameObjects.Container {
*/
public update(delta: number) {
// Calculate distance to target
const distance = MathUtils.distance(this.gameX, this.gameY, this.targetX, this.targetY);
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 < MOVEMENT_STOP_THRESHOLD) {
if (distance < 0.1) {
this.body.setVelocity(0, 0);
return;
}
const dx = this.targetX - this.gameX;
const dy = this.targetY - this.gameY;
// Calculate desired angle toward target
const targetAngle = Math.atan2(dy, dx);
@ -170,21 +173,25 @@ export class Player extends Phaser.GameObjects.Container {
const maxRotation = PLAYER_ROTATION_SPEED * deltaSeconds;
// Calculate shortest angular difference
const angleDiff = MathUtils.angleDifference(this.currentAngle, targetAngle);
let angleDiff = targetAngle - this.currentAngle;
// Normalize to [-PI, PI]
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
// Apply rotation with limit
const rotationStep = Math.sign(angleDiff) * Math.min(Math.abs(angleDiff), maxRotation);
this.currentAngle += rotationStep;
// Normalize current angle
this.currentAngle = MathUtils.normalizeAngle(this.currentAngle);
while (this.currentAngle > Math.PI) this.currentAngle -= Math.PI * 2;
while (this.currentAngle < -Math.PI) this.currentAngle += Math.PI * 2;
// Update visual rotation (convert to degrees, subtract 90 because 0 angle is up in Phaser)
this.setRotation(-this.currentAngle);
// Calculate velocity based on current facing direction and speed attribute
// speed attribute (0-100) maps to actual m/s (e.g., 80 -> 8 m/s)
const baseSpeed = (this.attributes.speed / SPEED_SCALE_FACTOR) * SCALE; // Convert to pixels/s
const baseSpeed = (this.attributes.speed / 10) * SCALE; // Convert to pixels/s
const speed = baseSpeed * this.speedMultiplier; // Apply speed multiplier
// Move in the direction the player is currently facing
@ -194,10 +201,11 @@ export class Player extends Phaser.GameObjects.Container {
this.body.setVelocity(velX, -velY); // 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;
const gamePos = CoordinateUtils.screenToGame(this.scene, bodyX, bodyY);
this.gameX = gamePos.x;
this.gameY = gamePos.y;
this.gameX = (bodyX - centerX) / SCALE;
this.gameY = -(bodyY - centerY) / SCALE;
}
}

View File

@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { SCALE, MAX_PUCK_VELOCITY } from '../config/constants';
import { CoordinateUtils } from '../utils/coordinates';
import type { PuckState, TeamSide, Position } from '../types/game';
import { SCALE } from '../config/constants';
export type PuckState = 'loose' | 'carried' | 'passing' | 'shot';
export class Puck extends Phaser.GameObjects.Container {
declare body: Phaser.Physics.Arcade.Body;
@ -12,14 +12,15 @@ export class Puck extends Phaser.GameObjects.Container {
// Puck state
public state: PuckState;
public possession: TeamSide | null;
public possession: 'home' | 'away' | null;
public carrier: string | null; // PlayerID
constructor(scene: Phaser.Scene, gameX: number = 0, gameY: number = 0) {
// Convert game coordinates to screen coordinates
const screenPos = CoordinateUtils.gameToScreen(scene, gameX, gameY);
const screenX = (scene.game.config.width as number) / 2 + gameX * SCALE;
const screenY = (scene.game.config.height as number) / 2 - gameY * SCALE;
super(scene, screenPos.x, screenPos.y);
super(scene, screenX, screenY);
this.gameX = gameX;
this.gameY = gameY;
@ -38,7 +39,7 @@ export class Puck extends Phaser.GameObjects.Container {
this.body.setOffset(-5, -5); // Center the body on the container
// Set max velocity (allow up to x m/s)
this.body.setMaxVelocity(MAX_PUCK_VELOCITY * SCALE, MAX_PUCK_VELOCITY * SCALE);
this.body.setMaxVelocity(50 * SCALE, 50 * SCALE);
// Set initial velocity to 0 (stationary)
this.body.setVelocity(0, 0);
@ -75,14 +76,19 @@ export class Puck extends Phaser.GameObjects.Container {
this.gameY = gameY;
// Convert to screen coordinates
const screenPos = CoordinateUtils.gameToScreen(this.scene, gameX, gameY);
this.setPosition(screenPos.x, screenPos.y);
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 puck to be carried by a player
*/
public setCarrier(playerId: string, team: TeamSide) {
public setCarrier(playerId: string, team: 'home' | 'away') {
this.carrier = playerId;
this.possession = team;
this.state = 'carried';
@ -99,7 +105,7 @@ export class Puck extends Phaser.GameObjects.Container {
/**
* Get puck position in game coordinates
*/
public getGamePosition(): Position {
public getGamePosition(): { x: number; y: number } {
return { x: this.gameX, y: this.gameY };
}
@ -107,12 +113,14 @@ export class Puck extends Phaser.GameObjects.Container {
* 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;
const gamePos = CoordinateUtils.screenToGame(this.scene, bodyX, bodyY);
this.gameX = gamePos.x;
this.gameY = gamePos.y;
this.gameX = (bodyX - centerX) / SCALE;
this.gameY = -(bodyY - centerY) / SCALE;
}
}

View File

@ -9,19 +9,12 @@ import {
COLOR_ICE,
COLOR_BOARDS,
COLOR_RED_LINE,
COLOR_BLUE_LINE,
FACEOFF_CIRCLE_RADIUS,
CENTER_DOT_RADIUS,
MOVEMENT_STOP_THRESHOLD,
PUCK_CARRY_DISTANCE,
PUCK_PICKUP_RADIUS,
SHOT_SPEED
COLOR_BLUE_LINE
} from '../config/constants';
import { Goal } from './Goal';
import { Puck } from '../entities/Puck';
import { Player } from '../entities/Player';
import { BehaviorTree } from '../systems/BehaviorTree';
import { MathUtils } from '../utils/math';
export class GameScene extends Phaser.Scene {
private leftGoal!: Goal;
@ -129,11 +122,11 @@ export class GameScene extends Phaser.Scene {
// Draw center faceoff circle
graphics.lineStyle(2, 0x0000ff, 1);
graphics.strokeCircle(centerX, centerY, FACEOFF_CIRCLE_RADIUS * SCALE);
graphics.strokeCircle(centerX, centerY, 4.5 * SCALE);
// Draw center dot
graphics.fillStyle(0x0000ff, 1);
graphics.fillCircle(centerX, centerY, CENTER_DOT_RADIUS);
graphics.fillCircle(centerX, centerY, 5);
// Draw boards (border)
graphics.lineStyle(4, COLOR_BOARDS, 1);
@ -141,17 +134,16 @@ export class GameScene extends Phaser.Scene {
}
update(_time: number, delta: number) {
this.updatePuck();
this.updatePlayers(delta);
this.checkGoals();
}
private updatePuck() {
// Update puck position
this.puck.update();
this.checkPuckPickup();
}
private updatePlayers(delta: number) {
// 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,
@ -165,6 +157,7 @@ export class GameScene extends Phaser.Scene {
// 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) {
@ -176,44 +169,39 @@ export class GameScene extends Phaser.Scene {
player.update(delta);
// If player has puck, update puck position (in front of player)
this.updatePuckCarrier(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 updatePuckCarrier(player: Player) {
if (this.puck.carrier !== player.id) return;
const distance = MathUtils.distance(player.gameX, player.gameY, player.targetX, player.targetY);
if (distance > MOVEMENT_STOP_THRESHOLD) {
// Place puck in front of player in direction of movement
const dx = player.targetX - player.gameX;
const dy = player.targetY - player.gameY;
const dirX = dx / distance;
const dirY = dy / distance;
this.puck.setGamePosition(
player.gameX + dirX * PUCK_CARRY_DISTANCE,
player.gameY + dirY * PUCK_CARRY_DISTANCE
);
} else {
// If not moving, keep puck at player position
this.puck.setGamePosition(player.gameX, player.gameY);
}
}
private checkGoals() {
this.leftGoal.checkGoal(this.puck);
this.rightGoal.checkGoal(this.puck);
}
private checkPuckPickup() {
if (this.puck.state !== 'loose') return;
// Check each player's distance to puck
this.players.forEach(player => {
const distance = MathUtils.distance(player.gameX, player.gameY, this.puck.gameX, this.puck.gameY);
const dx = player.gameX - this.puck.gameX;
const dy = player.gameY - this.puck.gameY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < PUCK_PICKUP_RADIUS) {
// Pickup radius: 1.5 meters
if (distance < 1.5) {
this.puck.setCarrier(player.id, player.team);
console.log(`${player.id} picked up the puck`);
}
@ -227,17 +215,17 @@ export class GameScene extends Phaser.Scene {
this.puck.setLoose();
// Calculate shot direction
const distance = MathUtils.distance(player.gameX, player.gameY, targetX, targetY);
const dx = targetX - player.gameX;
const dy = targetY - player.gameY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
const dx = targetX - player.gameX;
const dy = targetY - player.gameY;
// Normalize direction
const dirX = dx / distance;
const dirY = dy / distance;
const shotSpeed = SHOT_SPEED * SCALE;
// Shot speed: 30 m/s
const shotSpeed = 30 * SCALE;
// Apply velocity to puck
this.puck.body.setVelocity(dirX * shotSpeed, -dirY * shotSpeed);

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import { SCALE, GOAL_WIDTH, GOAL_DEPTH, GOAL_POST_THICKNESS, GOAL_BAR_THICKNESS } from '../config/constants';
import { SCALE, GOAL_WIDTH, GOAL_DEPTH } from '../config/constants';
import type { Puck } from '../entities/Puck';
export class Goal extends Phaser.GameObjects.Container {
@ -21,10 +21,10 @@ export class Goal extends Phaser.GameObjects.Container {
}
private createGoalStructure() {
const postThickness = GOAL_POST_THICKNESS * SCALE;
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 = GOAL_BAR_THICKNESS * SCALE;
const barThickness = 0.4 * SCALE; // Thicker bar to prevent high-speed puck tunneling
// Create graphics for visual representation
const graphics = this.scene.add.graphics();

View File

@ -1,13 +1,16 @@
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';
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
@ -47,10 +50,10 @@ export class BehaviorTree {
*/
private static goalieLogic(player: Player, puck: Puck): PlayerAction {
// Stay near goal line
const goalX = player.team === 'home' ? -GOAL_LINE_OFFSET : GOAL_LINE_OFFSET;
const goalX = player.team === 'home' ? -26 : 26;
// Track puck Y position (clamped to goal width)
const targetY = Math.max(-GOALIE_RANGE, Math.min(GOALIE_RANGE, puck.gameY));
const targetY = Math.max(-3, Math.min(3, puck.gameY));
return {
type: 'move',
@ -75,7 +78,7 @@ export class BehaviorTree {
*/
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;
const opponentGoalX = player.team === 'home' ? 26 : -26;
// Check if player has a good shot opportunity
if (this.hasGoodShot(player)) {
@ -100,20 +103,20 @@ export class BehaviorTree {
*/
private static hasGoodShot(player: Player): boolean {
// Determine opponent's goal position
const opponentGoalX = player.team === 'home' ? GOAL_LINE_OFFSET : -GOAL_LINE_OFFSET;
const opponentGoalX = player.team === 'home' ? 26 : -26;
const goalY = 0;
// Calculate distance to goal
const distance = MathUtils.distance(player.gameX, player.gameY, opponentGoalX, goalY);
const dx = opponentGoalX - player.gameX;
const dy = goalY - player.gameY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Check if within shooting range
if (distance >= SHOOTING_RANGE) {
// Check if within shooting range (< 10m)
if (distance >= 10) {
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
@ -128,8 +131,8 @@ export class BehaviorTree {
angleDiff = 2 * Math.PI - angleDiff;
}
// Good angle if within threshold
const reasonableAngle = angleDiff < SHOOTING_ANGLE_THRESHOLD;
// Good angle if within 45 degrees (π/4 radians) of goal
const reasonableAngle = angleDiff < Math.PI / 4;
return reasonableAngle;
}

View File

@ -1,63 +0,0 @@
import type { Player } from '../entities/Player';
import type { Puck } from '../entities/Puck';
/**
* Player position on the ice
*/
export type PlayerPosition = 'LW' | 'C' | 'RW' | 'LD' | 'RD' | 'G';
/**
* Team side (home or away)
*/
export type TeamSide = 'home' | 'away';
/**
* Player tactical state
*/
export type PlayerState = 'offensive' | 'defensive';
/**
* Puck state in the game
*/
export type PuckState = 'loose' | 'carried' | 'passing' | 'shot';
/**
* Player attributes (0-100 scale)
*/
export interface PlayerAttributes {
speed: number; // 0-100: movement speed
skill: number; // 0-100: pass/shot accuracy, decision quality
}
/**
* Game state snapshot for AI decision making
*/
export interface GameState {
puck: Puck;
allPlayers: Player[];
}
/**
* Player action decided by behavior tree
*/
export interface PlayerAction {
type: 'move' | 'chase_puck' | 'skate_with_puck' | 'shoot' | 'idle';
targetX?: number;
targetY?: number;
}
/**
* Goal event data
*/
export interface GoalEvent {
team: TeamSide;
goal: 'left' | 'right';
}
/**
* Position in 2D space (game coordinates in meters)
*/
export interface Position {
x: number;
y: number;
}

View File

@ -1,51 +0,0 @@
import Phaser from 'phaser';
import { SCALE } from '../config/constants';
/**
* Utility class for converting between game coordinates (meters) and screen coordinates (pixels)
*/
export class CoordinateUtils {
/**
* Convert game coordinates (meters, centered) to screen coordinates (pixels)
* @param scene Phaser scene
* @param gameX X position in meters (0 = center)
* @param gameY Y position in meters (0 = center)
* @returns Screen coordinates {x, y} in pixels
*/
static gameToScreen(scene: Phaser.Scene, gameX: number, gameY: number): { x: number; y: number } {
const centerX = (scene.game.config.width as number) / 2;
const centerY = (scene.game.config.height as number) / 2;
return {
x: centerX + gameX * SCALE,
y: centerY - gameY * SCALE // Negative Y because screen coords increase downward
};
}
/**
* Convert screen coordinates (pixels) to game coordinates (meters, centered)
* @param scene Phaser scene
* @param screenX X position in pixels
* @param screenY Y position in pixels
* @returns Game coordinates {x, y} in meters
*/
static screenToGame(scene: Phaser.Scene, screenX: number, screenY: number): { x: number; y: number } {
const centerX = (scene.game.config.width as number) / 2;
const centerY = (scene.game.config.height as number) / 2;
return {
x: (screenX - centerX) / SCALE,
y: -(screenY - centerY) / SCALE // Negative Y to convert screen coords back to game coords
};
}
/**
* Get the center of the screen in pixels
* @param scene Phaser scene
* @returns Center coordinates {x, y} in pixels
*/
static getScreenCenter(scene: Phaser.Scene): { x: number; y: number } {
return {
x: (scene.game.config.width as number) / 2,
y: (scene.game.config.height as number) / 2
};
}
}

View File

@ -1,55 +0,0 @@
/**
* Utility class for common mathematical operations
*/
export class MathUtils {
/**
* Calculate distance between two points
* @param x1 First point X coordinate
* @param y1 First point Y coordinate
* @param x2 Second point X coordinate
* @param y2 Second point Y coordinate
* @returns Distance between points
*/
static distance(x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Calculate squared distance between two points (faster for comparisons)
* Use this when you only need to compare distances, not the actual value
* @param x1 First point X coordinate
* @param y1 First point Y coordinate
* @param x2 Second point X coordinate
* @param y2 Second point Y coordinate
* @returns Squared distance between points
*/
static distanceSquared(x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
return dx * dx + dy * dy;
}
/**
* Normalize an angle to the range [-PI, PI]
* @param angle Angle in radians
* @returns Normalized angle in radians
*/
static normalizeAngle(angle: number): number {
while (angle > Math.PI) angle -= Math.PI * 2;
while (angle < -Math.PI) angle += Math.PI * 2;
return angle;
}
/**
* Calculate the shortest angular difference between two angles
* @param from Starting angle in radians
* @param to Target angle in radians
* @returns Angular difference in range [-PI, PI]
*/
static angleDifference(from: number, to: number): number {
let diff = to - from;
return this.normalizeAngle(diff);
}
}