init
This commit is contained in:
commit
0eb0574fbd
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
152
README.md
Normal file
152
README.md
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
# Hockey Manager - 2D Match Engine
|
||||||
|
|
||||||
|
A JavaScript-based 2D hockey match engine for web browsers featuring realistic physics, AI players, and complete hockey rules implementation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Game Engine
|
||||||
|
- **60 FPS game loop** with requestAnimationFrame
|
||||||
|
- **Physics system** with collision detection and realistic puck/player movement
|
||||||
|
- **Game state management** with time tracking, scoring, and statistics
|
||||||
|
- **Event-driven architecture** for game events and interactions
|
||||||
|
|
||||||
|
### Gameplay Features
|
||||||
|
- **12 players** (6 per team) with distinct roles (C, LW, RW, LD, RD, G)
|
||||||
|
- **Realistic hockey rules**: offsides, icing, penalties, power plays
|
||||||
|
- **Player AI behaviors**: puck handling, passing, shooting, checking, formations
|
||||||
|
- **Complete hockey rink** with proper dimensions, goals, creases, and faceoff dots
|
||||||
|
- **Live statistics** tracking shots, saves, hits, penalties, ice time
|
||||||
|
|
||||||
|
### Visual Features
|
||||||
|
- **2D Canvas rendering** with smooth animations
|
||||||
|
- **Hockey rink visualization** with proper lines, zones, and markings
|
||||||
|
- **Player sprites** with team colors and role indicators
|
||||||
|
- **Puck physics** with trail effects and realistic bouncing
|
||||||
|
- **Camera system** with zoom and follow capabilities
|
||||||
|
- **Particle effects** for goals, saves, and collisions
|
||||||
|
|
||||||
|
### Controls & UI
|
||||||
|
- **Real-time scoreboard** with period and clock display
|
||||||
|
- **Game statistics panel** showing shots, saves, and penalties
|
||||||
|
- **Interactive controls** for play/pause, speed adjustment, and reset
|
||||||
|
- **Keyboard shortcuts** for quick game control
|
||||||
|
- **Responsive design** that adapts to screen size
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
hockey-manager/
|
||||||
|
├── index.html # Main HTML entry point
|
||||||
|
├── css/
|
||||||
|
│ └── styles.css # Game styling and UI
|
||||||
|
├── src/
|
||||||
|
│ ├── engine/
|
||||||
|
│ │ ├── game-engine.js # Main game engine
|
||||||
|
│ │ ├── game-state.js # Game state management
|
||||||
|
│ │ └── main.js # Application initialization
|
||||||
|
│ ├── entities/
|
||||||
|
│ │ ├── player.js # Player entity with AI
|
||||||
|
│ │ └── puck.js # Puck physics and behavior
|
||||||
|
│ ├── systems/
|
||||||
|
│ │ ├── renderer.js # 2D rendering system
|
||||||
|
│ │ ├── physics-system.js # Physics calculations
|
||||||
|
│ │ ├── ai-system.js # AI formation and strategy
|
||||||
|
│ │ └── rules-system.js # Hockey rules enforcement
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── vector.js # 2D vector mathematics
|
||||||
|
│ └── physics.js # Physics utility functions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. **Open the game**: Simply open `index.html` in a modern web browser
|
||||||
|
2. **Start playing**: The game will automatically initialize and start
|
||||||
|
3. **Use controls**:
|
||||||
|
- **Space**: Pause/Resume
|
||||||
|
- **D**: Toggle debug mode
|
||||||
|
- **R**: Reset game
|
||||||
|
- **Mouse wheel**: Zoom in/out
|
||||||
|
- **F11**: Toggle fullscreen
|
||||||
|
|
||||||
|
## Controls Reference
|
||||||
|
|
||||||
|
### Keyboard Controls
|
||||||
|
- `SPACE` - Pause/Resume game
|
||||||
|
- `D` - Toggle debug information display
|
||||||
|
- `R` - Reset game to initial state
|
||||||
|
- `F11` - Toggle fullscreen mode
|
||||||
|
|
||||||
|
### Mouse Controls
|
||||||
|
- `Mouse Wheel` - Zoom camera in/out
|
||||||
|
- `Click buttons` - Use UI controls for game management
|
||||||
|
|
||||||
|
### UI Controls
|
||||||
|
- **Play/Pause** - Start/stop game simulation
|
||||||
|
- **Speed Control** - Adjust game speed (0.5x, 1x, 2x, 4x)
|
||||||
|
- **Reset Game** - Return to initial game state
|
||||||
|
|
||||||
|
## Game Mechanics
|
||||||
|
|
||||||
|
### Player AI
|
||||||
|
Each player has sophisticated AI that includes:
|
||||||
|
- **Role-based behavior** (forwards vs defensemen vs goalie)
|
||||||
|
- **Formation positioning** based on game situation
|
||||||
|
- **Puck awareness** and decision making
|
||||||
|
- **Teammate cooperation** for passing and positioning
|
||||||
|
- **Opponent pressure** and defensive reactions
|
||||||
|
|
||||||
|
### Physics System
|
||||||
|
- **Realistic puck physics** with momentum and friction
|
||||||
|
- **Player collision detection** and response
|
||||||
|
- **Board bouncing** with energy conservation
|
||||||
|
- **Goalie save mechanics** based on skill attributes
|
||||||
|
|
||||||
|
### Hockey Rules
|
||||||
|
- **Offside detection** when players cross lines ahead of puck
|
||||||
|
- **Icing calls** for long shots across multiple zones
|
||||||
|
- **Penalty system** for infractions like checking and interference
|
||||||
|
- **Power play situations** with player advantages
|
||||||
|
- **Faceoff mechanics** at center ice and zone dots
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Optimized 60 FPS rendering
|
||||||
|
- Efficient collision detection
|
||||||
|
- Smooth camera interpolation
|
||||||
|
- Responsive UI updates
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
- Modern browsers with HTML5 Canvas support
|
||||||
|
- Chrome, Firefox, Safari, Edge
|
||||||
|
- Mobile browsers (with touch controls)
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
The engine is highly modular and can be extended with:
|
||||||
|
- Custom player attributes and skills
|
||||||
|
- Different team formations and strategies
|
||||||
|
- Additional hockey rules and penalties
|
||||||
|
- Enhanced visual effects and animations
|
||||||
|
- Sound effects and music integration
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The codebase is organized into clean, modular components:
|
||||||
|
- **Entity-Component system** for game objects
|
||||||
|
- **Event-driven communication** between systems
|
||||||
|
- **Separation of concerns** (rendering, physics, AI, rules)
|
||||||
|
- **Utility libraries** for common operations
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements include:
|
||||||
|
- **Sound system** with hockey arena audio
|
||||||
|
- **Advanced AI** with machine learning behaviors
|
||||||
|
- **Multiplayer support** for online matches
|
||||||
|
- **Tournament mode** with brackets and playoffs
|
||||||
|
- **Player statistics** tracking across multiple games
|
||||||
|
- **Save/load functionality** for game states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This 2D hockey match engine provides a complete foundation for hockey management games, featuring realistic gameplay mechanics and professional-quality code architecture.*
|
||||||
132
css/styles.css
Normal file
132
css/styles.css
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: white;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-canvas {
|
||||||
|
border: 2px solid #333;
|
||||||
|
background: #2a2a2a;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-ui {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#score-board {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: fit-content;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#period {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clock {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-stats {
|
||||||
|
position: absolute;
|
||||||
|
top: 100px;
|
||||||
|
left: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#penalties {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #4a90e2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #357abd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-box {
|
||||||
|
background: #ff4444;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
56
index.html
Normal file
56
index.html
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Hockey Manager - 2D Match Engine</title>
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game-container">
|
||||||
|
<canvas id="game-canvas" width="1200" height="800"></canvas>
|
||||||
|
<div id="game-ui">
|
||||||
|
<div id="score-board">
|
||||||
|
<div class="team home">
|
||||||
|
<span class="team-name">Home</span>
|
||||||
|
<span class="score">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="time-display">
|
||||||
|
<span id="period">1st</span>
|
||||||
|
<span id="clock">20:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="team away">
|
||||||
|
<span class="team-name">Away</span>
|
||||||
|
<span class="score">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="game-stats">
|
||||||
|
<div id="shots">Shots: <span id="home-shots">0</span> - <span id="away-shots">0</span></div>
|
||||||
|
<div id="penalties">
|
||||||
|
<div id="home-penalties"></div>
|
||||||
|
<div id="away-penalties"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="controls">
|
||||||
|
<button id="play-pause">Play/Pause</button>
|
||||||
|
<button id="speed-control">Speed: 1x</button>
|
||||||
|
<button id="sound-toggle">Sound: ON</button>
|
||||||
|
<button id="reset-game">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="src/utils/vector.js"></script>
|
||||||
|
<script src="src/utils/physics.js"></script>
|
||||||
|
<script src="src/entities/player.js"></script>
|
||||||
|
<script src="src/entities/puck.js"></script>
|
||||||
|
<script src="src/systems/renderer.js"></script>
|
||||||
|
<script src="src/systems/physics-system.js"></script>
|
||||||
|
<script src="src/systems/ai-system.js"></script>
|
||||||
|
<script src="src/systems/rules-system.js"></script>
|
||||||
|
<script src="src/systems/audio-system.js"></script>
|
||||||
|
<script src="src/engine/game-state.js"></script>
|
||||||
|
<script src="src/engine/game-engine.js"></script>
|
||||||
|
<script src="src/engine/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
350
src/engine/game-engine.js
Normal file
350
src/engine/game-engine.js
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
class GameEngine {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.gameState = new GameState();
|
||||||
|
this.renderer = new Renderer(canvas);
|
||||||
|
this.audioSystem = new AudioSystem();
|
||||||
|
|
||||||
|
this.players = [];
|
||||||
|
this.puck = new Puck(500, 300);
|
||||||
|
|
||||||
|
this.lastTime = 0;
|
||||||
|
this.deltaTime = 0;
|
||||||
|
this.targetFPS = 60;
|
||||||
|
this.frameTime = 1000 / this.targetFPS;
|
||||||
|
|
||||||
|
this.isRunning = false;
|
||||||
|
this.effects = [];
|
||||||
|
|
||||||
|
this.setupPlayers();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupPlayers() {
|
||||||
|
const homeTeamPositions = [
|
||||||
|
{ role: 'G', x: 80, y: 300 },
|
||||||
|
{ role: 'LD', x: 200, y: 220 },
|
||||||
|
{ role: 'RD', x: 200, y: 380 },
|
||||||
|
{ role: 'LW', x: 350, y: 200 },
|
||||||
|
{ role: 'C', x: 400, y: 300 },
|
||||||
|
{ role: 'RW', x: 350, y: 400 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const awayTeamPositions = [
|
||||||
|
{ role: 'G', x: 920, y: 300 },
|
||||||
|
{ role: 'LD', x: 800, y: 220 },
|
||||||
|
{ role: 'RD', x: 800, y: 380 },
|
||||||
|
{ role: 'LW', x: 650, y: 200 },
|
||||||
|
{ role: 'C', x: 600, y: 300 },
|
||||||
|
{ role: 'RW', x: 650, y: 400 }
|
||||||
|
];
|
||||||
|
|
||||||
|
homeTeamPositions.forEach((pos, index) => {
|
||||||
|
const player = new Player(
|
||||||
|
`home_${index}`,
|
||||||
|
`Player ${index + 1}`,
|
||||||
|
'home',
|
||||||
|
pos.role,
|
||||||
|
pos.x,
|
||||||
|
pos.y
|
||||||
|
);
|
||||||
|
this.players.push(player);
|
||||||
|
});
|
||||||
|
|
||||||
|
awayTeamPositions.forEach((pos, index) => {
|
||||||
|
const player = new Player(
|
||||||
|
`away_${index}`,
|
||||||
|
`Player ${index + 7}`,
|
||||||
|
'away',
|
||||||
|
pos.role,
|
||||||
|
pos.x,
|
||||||
|
pos.y
|
||||||
|
);
|
||||||
|
this.players.push(player);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
this.gameState.on('goal', (data) => {
|
||||||
|
this.addEffect({
|
||||||
|
type: 'goal',
|
||||||
|
position: this.puck.position.copy(),
|
||||||
|
duration: 2000,
|
||||||
|
startTime: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audioSystem.onGoal(data.team);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.startFaceoff();
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gameState.on('save', (data) => {
|
||||||
|
this.addEffect({
|
||||||
|
type: 'save',
|
||||||
|
position: this.puck.position.copy(),
|
||||||
|
duration: 1000,
|
||||||
|
startTime: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audioSystem.onSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gameState.on('penalty', (data) => {
|
||||||
|
const penalizedPlayer = this.players.find(p => p.name === data.player);
|
||||||
|
if (penalizedPlayer) {
|
||||||
|
penalizedPlayer.state.penaltyTime = data.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioSystem.onPenalty();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gameState.on('period-end', () => {
|
||||||
|
this.audioSystem.onPeriodEnd();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupControls() {
|
||||||
|
document.getElementById('play-pause').addEventListener('click', () => {
|
||||||
|
this.gameState.togglePause();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('speed-control').addEventListener('click', (e) => {
|
||||||
|
const speeds = [0.5, 1, 2, 4];
|
||||||
|
const currentIndex = speeds.indexOf(this.gameState.gameSpeed);
|
||||||
|
const nextIndex = (currentIndex + 1) % speeds.length;
|
||||||
|
this.gameState.setSpeed(speeds[nextIndex]);
|
||||||
|
e.target.textContent = `Speed: ${speeds[nextIndex]}x`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sound-toggle').addEventListener('click', (e) => {
|
||||||
|
const enabled = this.audioSystem.toggle();
|
||||||
|
e.target.textContent = `Sound: ${enabled ? 'ON' : 'OFF'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reset-game').addEventListener('click', () => {
|
||||||
|
this.resetGame();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
this.gameState.togglePause();
|
||||||
|
break;
|
||||||
|
case 'd':
|
||||||
|
window.debugMode = !window.debugMode;
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
this.resetGame();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
this.renderer.setZoom(this.renderer.camera.zoom * zoomFactor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.isRunning = true;
|
||||||
|
this.lastTime = performance.now();
|
||||||
|
this.gameLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameLoop(currentTime = performance.now()) {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
|
||||||
|
this.deltaTime = (currentTime - this.lastTime) / 1000;
|
||||||
|
this.lastTime = currentTime;
|
||||||
|
|
||||||
|
this.deltaTime = Math.min(this.deltaTime, 1/30);
|
||||||
|
|
||||||
|
this.update(this.deltaTime);
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
requestAnimationFrame((time) => this.gameLoop(time));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime) {
|
||||||
|
if (this.gameState.isPaused) return;
|
||||||
|
|
||||||
|
this.gameState.updateTime(deltaTime);
|
||||||
|
|
||||||
|
this.players.forEach(player => {
|
||||||
|
player.update(deltaTime, this.gameState, this.puck, this.players);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.puck.update(deltaTime, this.gameState, this.players);
|
||||||
|
|
||||||
|
this.updateCollisions();
|
||||||
|
this.updateEffects(deltaTime);
|
||||||
|
|
||||||
|
this.renderer.updateCamera(this.puck.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCollisions() {
|
||||||
|
for (let i = 0; i < this.players.length; i++) {
|
||||||
|
for (let j = i + 1; j < this.players.length; j++) {
|
||||||
|
const player1 = this.players[i];
|
||||||
|
const player2 = this.players[j];
|
||||||
|
|
||||||
|
if (Physics.checkCircleCollision(
|
||||||
|
player1.position, player1.radius,
|
||||||
|
player2.position, player2.radius
|
||||||
|
)) {
|
||||||
|
Physics.resolveCircleCollision(player1, player2);
|
||||||
|
|
||||||
|
if (player1.team !== player2.team &&
|
||||||
|
(player1.velocity.magnitude() > 100 || player2.velocity.magnitude() > 100)) {
|
||||||
|
this.handlePlayerCollision(player1, player2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePlayerCollision(player1, player2) {
|
||||||
|
const speed1 = player1.velocity.magnitude();
|
||||||
|
const speed2 = player2.velocity.magnitude();
|
||||||
|
|
||||||
|
if (speed1 > 150 || speed2 > 150) {
|
||||||
|
this.addEffect({
|
||||||
|
type: 'hit',
|
||||||
|
position: player1.position.lerp(player2.position, 0.5),
|
||||||
|
duration: 500,
|
||||||
|
startTime: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Math.random() < 0.1) {
|
||||||
|
const penalizedPlayer = speed1 > speed2 ? player1 : player2;
|
||||||
|
this.gameState.addPenalty(
|
||||||
|
penalizedPlayer.team,
|
||||||
|
penalizedPlayer.name,
|
||||||
|
'Checking',
|
||||||
|
120
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.renderer.clear();
|
||||||
|
this.renderer.drawRink(this.gameState);
|
||||||
|
this.renderer.drawPlayers(this.players);
|
||||||
|
this.renderer.drawPuck(this.puck);
|
||||||
|
this.renderEffects();
|
||||||
|
this.renderer.drawUI(this.gameState);
|
||||||
|
this.renderer.drawDebugInfo(this.gameState, this.players, this.puck);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEffects() {
|
||||||
|
this.effects.forEach(effect => {
|
||||||
|
const elapsed = Date.now() - effect.startTime;
|
||||||
|
const progress = elapsed / effect.duration;
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
this.renderer.drawParticleEffect(effect.position, effect.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEffects(deltaTime) {
|
||||||
|
this.effects = this.effects.filter(effect => {
|
||||||
|
const elapsed = Date.now() - effect.startTime;
|
||||||
|
return elapsed < effect.duration;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addEffect(effect) {
|
||||||
|
this.effects.push(effect);
|
||||||
|
}
|
||||||
|
|
||||||
|
startFaceoff(position = null) {
|
||||||
|
if (!position) {
|
||||||
|
position = new Vector2(this.gameState.rink.centerX, this.gameState.rink.centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.puck.reset(position.x, position.y);
|
||||||
|
|
||||||
|
const nearbyPlayers = this.players.filter(player =>
|
||||||
|
player.position.distance(position) < 100
|
||||||
|
).sort((a, b) =>
|
||||||
|
a.position.distance(position) - b.position.distance(position)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nearbyPlayers.length >= 2) {
|
||||||
|
const player1 = nearbyPlayers[0];
|
||||||
|
const player2 = nearbyPlayers[1];
|
||||||
|
|
||||||
|
// Immediately start the faceoff
|
||||||
|
this.puck.faceoff(player1, player2);
|
||||||
|
} else {
|
||||||
|
// If no players nearby, give puck to closest player
|
||||||
|
const allPlayers = this.players.filter(p => p.role !== 'G');
|
||||||
|
if (allPlayers.length > 0) {
|
||||||
|
const closest = allPlayers.reduce((closest, player) =>
|
||||||
|
player.position.distance(position) < closest.position.distance(position) ? player : closest
|
||||||
|
);
|
||||||
|
closest.state.hasPuck = true;
|
||||||
|
this.puck.lastPlayerTouch = closest;
|
||||||
|
this.puck.lastTeamTouch = closest.team;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetGame() {
|
||||||
|
this.gameState.reset();
|
||||||
|
this.effects = [];
|
||||||
|
|
||||||
|
// Clear all player states first
|
||||||
|
this.players.forEach(player => {
|
||||||
|
player.position = player.homePosition.copy();
|
||||||
|
player.velocity = new Vector2(0, 0);
|
||||||
|
player.state.hasPuck = false;
|
||||||
|
player.state.energy = 100;
|
||||||
|
player.state.penaltyTime = 0;
|
||||||
|
player.aiState.lastAction = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset puck after players
|
||||||
|
this.puck.reset();
|
||||||
|
|
||||||
|
// Start faceoff after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.startFaceoff();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getGameData() {
|
||||||
|
return {
|
||||||
|
gameState: this.gameState.getGameState(),
|
||||||
|
players: this.players.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
team: p.team,
|
||||||
|
position: p.position,
|
||||||
|
role: p.role,
|
||||||
|
state: p.state
|
||||||
|
})),
|
||||||
|
puck: {
|
||||||
|
position: this.puck.position,
|
||||||
|
velocity: this.puck.velocity,
|
||||||
|
speed: this.puck.getSpeed()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGameData(data) {
|
||||||
|
// Implementation for loading saved game state
|
||||||
|
// This would restore the game from a saved state
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/engine/game-state.js
Normal file
208
src/engine/game-state.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
class GameState {
|
||||||
|
constructor() {
|
||||||
|
this.reset();
|
||||||
|
this.eventListeners = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.period = 1;
|
||||||
|
this.timeRemaining = 20 * 60; // 20 minutes in seconds
|
||||||
|
this.homeScore = 0;
|
||||||
|
this.awayScore = 0;
|
||||||
|
this.gameSpeed = 1;
|
||||||
|
this.isPaused = false;
|
||||||
|
this.gameOver = false;
|
||||||
|
|
||||||
|
this.stats = {
|
||||||
|
home: {
|
||||||
|
shots: 0,
|
||||||
|
saves: 0,
|
||||||
|
hits: 0,
|
||||||
|
penalties: [],
|
||||||
|
faceoffWins: 0
|
||||||
|
},
|
||||||
|
away: {
|
||||||
|
shots: 0,
|
||||||
|
saves: 0,
|
||||||
|
hits: 0,
|
||||||
|
penalties: [],
|
||||||
|
faceoffWins: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.gameEvents = [];
|
||||||
|
this.lastEventTime = 0;
|
||||||
|
|
||||||
|
this.rink = {
|
||||||
|
width: 1000,
|
||||||
|
height: 600,
|
||||||
|
centerX: 500,
|
||||||
|
centerY: 300,
|
||||||
|
goalWidth: 120,
|
||||||
|
goalHeight: 40,
|
||||||
|
corners: {
|
||||||
|
radius: 80
|
||||||
|
},
|
||||||
|
faceoffDots: [
|
||||||
|
{ x: 200, y: 150 }, // Home left
|
||||||
|
{ x: 200, y: 450 }, // Home right
|
||||||
|
{ x: 500, y: 300 }, // Center
|
||||||
|
{ x: 800, y: 150 }, // Away left
|
||||||
|
{ x: 800, y: 450 } // Away right
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.powerPlay = {
|
||||||
|
home: null,
|
||||||
|
away: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(seconds) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeriodName() {
|
||||||
|
switch (this.period) {
|
||||||
|
case 1: return '1st';
|
||||||
|
case 2: return '2nd';
|
||||||
|
case 3: return '3rd';
|
||||||
|
default: return 'OT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTime(deltaTime) {
|
||||||
|
if (this.isPaused || this.gameOver) return;
|
||||||
|
|
||||||
|
this.timeRemaining -= deltaTime * this.gameSpeed;
|
||||||
|
|
||||||
|
if (this.timeRemaining <= 0) {
|
||||||
|
this.timeRemaining = 0;
|
||||||
|
this.endPeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePenalties(deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
endPeriod() {
|
||||||
|
this.period++;
|
||||||
|
if (this.period > 3 && this.homeScore !== this.awayScore) {
|
||||||
|
this.gameOver = true;
|
||||||
|
this.emit('game-end', { winner: this.homeScore > this.awayScore ? 'home' : 'away' });
|
||||||
|
} else if (this.period <= 3) {
|
||||||
|
this.timeRemaining = 20 * 60;
|
||||||
|
this.emit('period-end', { period: this.period - 1 });
|
||||||
|
} else {
|
||||||
|
this.timeRemaining = 5 * 60; // Overtime
|
||||||
|
this.emit('overtime-start');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addGoal(team) {
|
||||||
|
if (team === 'home') {
|
||||||
|
this.homeScore++;
|
||||||
|
} else {
|
||||||
|
this.awayScore++;
|
||||||
|
}
|
||||||
|
this.addEvent(`GOAL - ${team.toUpperCase()}!`);
|
||||||
|
this.emit('goal', { team, homeScore: this.homeScore, awayScore: this.awayScore });
|
||||||
|
}
|
||||||
|
|
||||||
|
addShot(team) {
|
||||||
|
this.stats[team].shots++;
|
||||||
|
this.emit('shot', { team, shots: this.stats[team].shots });
|
||||||
|
}
|
||||||
|
|
||||||
|
addSave(team) {
|
||||||
|
this.stats[team].saves++;
|
||||||
|
this.emit('save', { team, saves: this.stats[team].saves });
|
||||||
|
}
|
||||||
|
|
||||||
|
addPenalty(team, player, type, duration = 120) {
|
||||||
|
const penalty = {
|
||||||
|
player,
|
||||||
|
type,
|
||||||
|
duration,
|
||||||
|
timeRemaining: duration
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stats[team].penalties.push(penalty);
|
||||||
|
this.addEvent(`PENALTY - ${team.toUpperCase()} ${player}: ${type}`);
|
||||||
|
|
||||||
|
const oppositeTeam = team === 'home' ? 'away' : 'home';
|
||||||
|
this.powerPlay[oppositeTeam] = Date.now() + (duration * 1000);
|
||||||
|
|
||||||
|
this.emit('penalty', { team, player, type, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePenalties(deltaTime) {
|
||||||
|
['home', 'away'].forEach(team => {
|
||||||
|
this.stats[team].penalties = this.stats[team].penalties.filter(penalty => {
|
||||||
|
penalty.timeRemaining -= deltaTime * this.gameSpeed;
|
||||||
|
if (penalty.timeRemaining <= 0) {
|
||||||
|
this.emit('penalty-expired', { team, penalty });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
['home', 'away'].forEach(team => {
|
||||||
|
if (this.powerPlay[team] && Date.now() > this.powerPlay[team]) {
|
||||||
|
this.powerPlay[team] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addEvent(description) {
|
||||||
|
const event = {
|
||||||
|
time: this.formatTime(20 * 60 - this.timeRemaining),
|
||||||
|
period: this.period,
|
||||||
|
description,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
this.gameEvents.unshift(event);
|
||||||
|
if (this.gameEvents.length > 50) {
|
||||||
|
this.gameEvents.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePause() {
|
||||||
|
this.isPaused = !this.isPaused;
|
||||||
|
this.emit('pause-toggle', { isPaused: this.isPaused });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpeed(speed) {
|
||||||
|
this.gameSpeed = Math.max(0.1, Math.min(5, speed));
|
||||||
|
this.emit('speed-change', { speed: this.gameSpeed });
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.eventListeners[event]) {
|
||||||
|
this.eventListeners[event] = [];
|
||||||
|
}
|
||||||
|
this.eventListeners[event].push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, data) {
|
||||||
|
if (this.eventListeners[event]) {
|
||||||
|
this.eventListeners[event].forEach(callback => callback(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getGameState() {
|
||||||
|
return {
|
||||||
|
period: this.period,
|
||||||
|
timeRemaining: this.timeRemaining,
|
||||||
|
homeScore: this.homeScore,
|
||||||
|
awayScore: this.awayScore,
|
||||||
|
stats: { ...this.stats },
|
||||||
|
isPaused: this.isPaused,
|
||||||
|
gameSpeed: this.gameSpeed,
|
||||||
|
gameOver: this.gameOver,
|
||||||
|
powerPlay: { ...this.powerPlay }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/engine/main.js
Normal file
163
src/engine/main.js
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
class HockeyManager {
|
||||||
|
constructor() {
|
||||||
|
this.gameEngine = null;
|
||||||
|
this.canvas = null;
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.canvas = document.getElementById('game-canvas');
|
||||||
|
if (!this.canvas) {
|
||||||
|
throw new Error('Canvas element not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupCanvas();
|
||||||
|
this.gameEngine = new GameEngine(this.canvas);
|
||||||
|
this.setupGlobalEvents();
|
||||||
|
|
||||||
|
console.log('Hockey Manager initialized successfully');
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Hockey Manager:', error);
|
||||||
|
this.showError('Failed to initialize game: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCanvas() {
|
||||||
|
const container = document.getElementById('game-container');
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
this.canvas.width = Math.min(1200, containerRect.width - 40);
|
||||||
|
this.canvas.height = Math.min(800, containerRect.height - 200);
|
||||||
|
|
||||||
|
this.canvas.style.width = this.canvas.width + 'px';
|
||||||
|
this.canvas.style.height = this.canvas.height + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGlobalEvents() {
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
this.handleResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
this.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
this.gameEngine.gameState.isPaused = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'F11') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggleFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize() {
|
||||||
|
if (!this.initialized) return;
|
||||||
|
|
||||||
|
clearTimeout(this.resizeTimeout);
|
||||||
|
this.resizeTimeout = setTimeout(() => {
|
||||||
|
this.setupCanvas();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(err => {
|
||||||
|
console.warn('Could not enable fullscreen:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.initialized) {
|
||||||
|
console.error('Game not initialized. Call init() first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gameEngine.start();
|
||||||
|
console.log('Hockey Manager started');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.gameEngine) {
|
||||||
|
this.gameEngine.stop();
|
||||||
|
console.log('Hockey Manager stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
this.stop();
|
||||||
|
// Clean up any resources, event listeners, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #ff4444;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
`;
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
document.body.appendChild(errorDiv);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (errorDiv.parentNode) {
|
||||||
|
errorDiv.parentNode.removeChild(errorDiv);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getGameEngine() {
|
||||||
|
return this.gameEngine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hockeyManager;
|
||||||
|
let players;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
hockeyManager = new HockeyManager();
|
||||||
|
await hockeyManager.init();
|
||||||
|
hockeyManager.start();
|
||||||
|
|
||||||
|
players = hockeyManager.gameEngine.players;
|
||||||
|
|
||||||
|
// Initial faceoff to start the game
|
||||||
|
setTimeout(() => {
|
||||||
|
hockeyManager.gameEngine.startFaceoff();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
console.log('Hockey Manager 2D Match Engine loaded successfully!');
|
||||||
|
console.log('Controls:');
|
||||||
|
console.log('- SPACE: Pause/Resume');
|
||||||
|
console.log('- D: Toggle debug mode');
|
||||||
|
console.log('- R: Reset game');
|
||||||
|
console.log('- Mouse wheel: Zoom in/out');
|
||||||
|
console.log('- F11: Toggle fullscreen');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start Hockey Manager:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.hockeyManager = hockeyManager;
|
||||||
355
src/entities/player.js
Normal file
355
src/entities/player.js
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
class Player {
|
||||||
|
constructor(id, name, team, position, x, y) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.team = team; // 'home' or 'away'
|
||||||
|
this.position = new Vector2(x, y);
|
||||||
|
this.velocity = new Vector2(0, 0);
|
||||||
|
this.targetPosition = new Vector2(x, y);
|
||||||
|
|
||||||
|
this.role = position; // 'LW', 'C', 'RW', 'LD', 'RD', 'G'
|
||||||
|
this.radius = position === 'G' ? 20 : 12;
|
||||||
|
this.mass = position === 'G' ? 2 : 1;
|
||||||
|
this.maxSpeed = position === 'G' ? 150 : 200;
|
||||||
|
this.acceleration = 800;
|
||||||
|
this.restitution = 0.8;
|
||||||
|
|
||||||
|
this.attributes = {
|
||||||
|
speed: Math.random() * 20 + 70,
|
||||||
|
shooting: Math.random() * 20 + 70,
|
||||||
|
passing: Math.random() * 20 + 70,
|
||||||
|
defense: Math.random() * 20 + 70,
|
||||||
|
checking: Math.random() * 20 + 70,
|
||||||
|
puckHandling: Math.random() * 20 + 70,
|
||||||
|
awareness: Math.random() * 20 + 70
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
hasPuck: false,
|
||||||
|
energy: 100,
|
||||||
|
checking: false,
|
||||||
|
penaltyTime: 0,
|
||||||
|
injured: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.aiState = {
|
||||||
|
target: null,
|
||||||
|
behavior: 'defensive',
|
||||||
|
lastAction: 0,
|
||||||
|
reactionTime: 50 + Math.random() * 100
|
||||||
|
};
|
||||||
|
|
||||||
|
this.homePosition = new Vector2(x, y);
|
||||||
|
this.angle = 0;
|
||||||
|
this.targetAngle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime, gameState, puck, players) {
|
||||||
|
if (this.state.penaltyTime > 0) {
|
||||||
|
this.state.penaltyTime -= deltaTime;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateEnergy(deltaTime);
|
||||||
|
this.updateMovement(deltaTime);
|
||||||
|
this.updateAngle(deltaTime);
|
||||||
|
|
||||||
|
if (this.role !== 'G') {
|
||||||
|
this.updateAI(gameState, puck, players);
|
||||||
|
} else {
|
||||||
|
this.updateGoalie(gameState, puck, players);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEnergy(deltaTime) {
|
||||||
|
const energyDrain = this.velocity.magnitude() / this.maxSpeed * 10 * deltaTime;
|
||||||
|
this.state.energy = Math.max(0, this.state.energy - energyDrain);
|
||||||
|
|
||||||
|
if (this.state.energy < 20) {
|
||||||
|
this.maxSpeed *= 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.velocity.magnitude() < 50) {
|
||||||
|
this.state.energy = Math.min(100, this.state.energy + 15 * deltaTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMovement(deltaTime) {
|
||||||
|
const direction = this.targetPosition.subtract(this.position).normalize();
|
||||||
|
const distance = this.position.distance(this.targetPosition);
|
||||||
|
|
||||||
|
if (distance > 10) {
|
||||||
|
const force = direction.multiply(this.acceleration * deltaTime);
|
||||||
|
this.velocity = this.velocity.add(force);
|
||||||
|
} else {
|
||||||
|
// Slow down when close to target
|
||||||
|
this.velocity = this.velocity.multiply(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedMultiplier = Math.min(1, this.state.energy / 100);
|
||||||
|
this.velocity = this.velocity.limit(this.maxSpeed * speedMultiplier);
|
||||||
|
|
||||||
|
// Reduced friction for more responsive movement
|
||||||
|
this.velocity = Physics.applyFriction(this.velocity, 2, deltaTime);
|
||||||
|
|
||||||
|
this.position = this.position.add(this.velocity.multiply(deltaTime));
|
||||||
|
|
||||||
|
this.keepInBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAngle(deltaTime) {
|
||||||
|
let angleDiff = this.targetAngle - this.angle;
|
||||||
|
angleDiff = Physics.wrapAngle(angleDiff);
|
||||||
|
|
||||||
|
const turnRate = 10;
|
||||||
|
if (Math.abs(angleDiff) > 0.1) {
|
||||||
|
this.angle += Math.sign(angleDiff) * turnRate * deltaTime;
|
||||||
|
this.angle = Physics.wrapAngle(this.angle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAI(gameState, puck, players) {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
if (currentTime - this.aiState.lastAction < this.aiState.reactionTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.aiState.lastAction = currentTime;
|
||||||
|
|
||||||
|
const distanceToPuck = this.position.distance(puck.position);
|
||||||
|
const teammates = players.filter(p => p.team === this.team && p.id !== this.id);
|
||||||
|
const opponents = players.filter(p => p.team !== this.team);
|
||||||
|
|
||||||
|
if (this.state.hasPuck) {
|
||||||
|
this.behaviorWithPuck(gameState, puck, teammates, opponents);
|
||||||
|
} else {
|
||||||
|
this.behaviorWithoutPuck(gameState, puck, teammates, opponents, distanceToPuck);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
behaviorWithPuck(gameState, puck, teammates, opponents) {
|
||||||
|
const enemyGoal = this.team === 'home' ?
|
||||||
|
new Vector2(gameState.rink.width - 50, gameState.rink.centerY) :
|
||||||
|
new Vector2(50, gameState.rink.centerY);
|
||||||
|
|
||||||
|
const nearestOpponent = this.findNearestPlayer(opponents);
|
||||||
|
const distanceToGoal = this.position.distance(enemyGoal);
|
||||||
|
|
||||||
|
if (distanceToGoal < 200 && Math.random() < 0.3) {
|
||||||
|
this.shoot(puck, enemyGoal);
|
||||||
|
} else if (nearestOpponent && this.position.distance(nearestOpponent.position) < 80) {
|
||||||
|
const bestTeammate = this.findBestPassTarget(teammates, opponents);
|
||||||
|
if (bestTeammate && Math.random() < 0.7) {
|
||||||
|
this.pass(puck, bestTeammate);
|
||||||
|
} else {
|
||||||
|
this.moveToPosition(enemyGoal);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.moveToPosition(enemyGoal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
behaviorWithoutPuck(gameState, puck, teammates, opponents, distanceToPuck) {
|
||||||
|
const puckOwner = opponents.find(p => p.state.hasPuck) || teammates.find(p => p.state.hasPuck);
|
||||||
|
|
||||||
|
if (!puckOwner && distanceToPuck < 200) {
|
||||||
|
this.chasePuck(puck);
|
||||||
|
} else if (puckOwner && puckOwner.team !== this.team) {
|
||||||
|
if (distanceToPuck < 150 && Math.random() < 0.2) {
|
||||||
|
this.checkPlayer(puckOwner);
|
||||||
|
} else {
|
||||||
|
this.defendPosition(gameState, puckOwner);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.moveToFormationPosition(gameState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGoalie(gameState, puck, players) {
|
||||||
|
const goal = this.team === 'home' ?
|
||||||
|
new Vector2(50, gameState.rink.centerY) :
|
||||||
|
new Vector2(gameState.rink.width - 50, gameState.rink.centerY);
|
||||||
|
|
||||||
|
const crease = {
|
||||||
|
x: goal.x - 30,
|
||||||
|
y: goal.y - 60,
|
||||||
|
width: 60,
|
||||||
|
height: 120
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.position.distance(puck.position) < 80) {
|
||||||
|
this.targetPosition = puck.position.lerp(goal, 0.3);
|
||||||
|
} else {
|
||||||
|
this.targetPosition = goal.add(new Vector2(
|
||||||
|
this.team === 'home' ? 20 : -20,
|
||||||
|
(puck.position.y - goal.y) * 0.3
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetPosition.x = Math.max(crease.x, Math.min(crease.x + crease.width, this.targetPosition.x));
|
||||||
|
this.targetPosition.y = Math.max(crease.y, Math.min(crease.y + crease.height, this.targetPosition.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
chasePuck(puck) {
|
||||||
|
this.moveToPosition(puck.position);
|
||||||
|
this.aiState.behavior = 'chasing';
|
||||||
|
}
|
||||||
|
|
||||||
|
shoot(puck, target) {
|
||||||
|
const direction = target.subtract(puck.position).normalize();
|
||||||
|
const power = this.attributes.shooting / 100 * 800;
|
||||||
|
const accuracy = this.attributes.shooting / 100;
|
||||||
|
|
||||||
|
const spread = (1 - accuracy) * 0.5;
|
||||||
|
const angle = direction.angle() + (Math.random() - 0.5) * spread;
|
||||||
|
|
||||||
|
puck.velocity = Vector2.fromAngle(angle, power);
|
||||||
|
this.state.hasPuck = false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pass(puck, target) {
|
||||||
|
const direction = target.position.subtract(puck.position).normalize();
|
||||||
|
const distance = puck.position.distance(target.position);
|
||||||
|
const power = Math.min(600, distance * 2);
|
||||||
|
|
||||||
|
puck.velocity = direction.multiply(power);
|
||||||
|
this.state.hasPuck = false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkPlayer(target) {
|
||||||
|
if (this.position.distance(target.position) < 30) {
|
||||||
|
target.velocity = target.velocity.add(
|
||||||
|
this.position.subtract(target.position).normalize().multiply(-200)
|
||||||
|
);
|
||||||
|
this.aiState.behavior = 'checking';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.moveToPosition(target.position);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToPosition(target) {
|
||||||
|
this.targetPosition = target.copy();
|
||||||
|
this.targetAngle = target.subtract(this.position).angle();
|
||||||
|
}
|
||||||
|
|
||||||
|
defendPosition(gameState, opponent) {
|
||||||
|
const ownGoal = this.team === 'home' ?
|
||||||
|
new Vector2(50, gameState.rink.centerY) :
|
||||||
|
new Vector2(gameState.rink.width - 50, gameState.rink.centerY);
|
||||||
|
|
||||||
|
const defendPoint = opponent.position.lerp(ownGoal, 0.6);
|
||||||
|
this.moveToPosition(defendPoint);
|
||||||
|
this.aiState.behavior = 'defending';
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToFormationPosition(gameState) {
|
||||||
|
this.moveToPosition(this.getFormationPosition(gameState));
|
||||||
|
this.aiState.behavior = 'formation';
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormationPosition(gameState) {
|
||||||
|
const side = this.team === 'home' ? -1 : 1;
|
||||||
|
const centerX = gameState.rink.centerX;
|
||||||
|
const centerY = gameState.rink.centerY;
|
||||||
|
|
||||||
|
switch (this.role) {
|
||||||
|
case 'C':
|
||||||
|
return new Vector2(centerX + side * 100, centerY);
|
||||||
|
case 'LW':
|
||||||
|
return new Vector2(centerX + side * 150, centerY - 100);
|
||||||
|
case 'RW':
|
||||||
|
return new Vector2(centerX + side * 150, centerY + 100);
|
||||||
|
case 'LD':
|
||||||
|
return new Vector2(centerX + side * 200, centerY - 80);
|
||||||
|
case 'RD':
|
||||||
|
return new Vector2(centerX + side * 200, centerY + 80);
|
||||||
|
default:
|
||||||
|
return this.homePosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findNearestPlayer(players) {
|
||||||
|
let nearest = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
players.forEach(player => {
|
||||||
|
const distance = this.position.distance(player.position);
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
nearest = player;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
findBestPassTarget(teammates, opponents) {
|
||||||
|
let bestTarget = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
|
||||||
|
teammates.forEach(teammate => {
|
||||||
|
const distance = this.position.distance(teammate.position);
|
||||||
|
if (distance < 50 || distance > 300) return;
|
||||||
|
|
||||||
|
let blocked = false;
|
||||||
|
opponents.forEach(opponent => {
|
||||||
|
const lineToTeammate = teammate.position.subtract(this.position);
|
||||||
|
const lineToOpponent = opponent.position.subtract(this.position);
|
||||||
|
const angle = Math.abs(lineToTeammate.angle() - lineToOpponent.angle());
|
||||||
|
|
||||||
|
if (angle < 0.3 && this.position.distance(opponent.position) < distance) {
|
||||||
|
blocked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blocked) {
|
||||||
|
const score = teammate.attributes.puckHandling / distance;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestTarget = teammate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bestTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
keepInBounds() {
|
||||||
|
this.position.x = Math.max(this.radius, Math.min(1000 - this.radius, this.position.x));
|
||||||
|
this.position.y = Math.max(this.radius, Math.min(600 - this.radius, this.position.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(this.position.x, this.position.y);
|
||||||
|
ctx.rotate(this.angle);
|
||||||
|
|
||||||
|
ctx.fillStyle = this.team === 'home' ? '#4a90e2' : '#e24a4a';
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, this.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (this.state.hasPuck) {
|
||||||
|
ctx.fillStyle = '#ffff00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, -this.radius - 5, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = '10px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(this.role, 0, 3);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
272
src/entities/puck.js
Normal file
272
src/entities/puck.js
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
class Puck {
|
||||||
|
constructor(x = 500, y = 300) {
|
||||||
|
this.position = new Vector2(x, y);
|
||||||
|
this.velocity = new Vector2(0, 0);
|
||||||
|
this.radius = 8;
|
||||||
|
this.mass = 0.5;
|
||||||
|
this.restitution = 0.9;
|
||||||
|
this.friction = 3;
|
||||||
|
|
||||||
|
this.lastPlayerTouch = null;
|
||||||
|
this.lastTeamTouch = null;
|
||||||
|
this.bounceCount = 0;
|
||||||
|
this.trail = [];
|
||||||
|
this.maxTrailLength = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime, gameState, players) {
|
||||||
|
this.updatePosition(deltaTime);
|
||||||
|
this.checkBoardCollisions(gameState);
|
||||||
|
this.checkPlayerCollisions(players, gameState);
|
||||||
|
this.updateTrail();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition(deltaTime) {
|
||||||
|
this.velocity = Physics.applyFriction(this.velocity, this.friction, deltaTime);
|
||||||
|
this.position = this.position.add(this.velocity.multiply(deltaTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTrail() {
|
||||||
|
if (this.velocity.magnitude() > 50) {
|
||||||
|
this.trail.unshift({
|
||||||
|
position: this.position.copy(),
|
||||||
|
alpha: 1.0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.trail.length > this.maxTrailLength) {
|
||||||
|
this.trail.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.trail.forEach((point, index) => {
|
||||||
|
point.alpha = 1 - (index / this.maxTrailLength);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkBoardCollisions(gameState) {
|
||||||
|
const rink = gameState.rink;
|
||||||
|
let collision = false;
|
||||||
|
|
||||||
|
if (this.position.x - this.radius <= 0 || this.position.x + this.radius >= rink.width) {
|
||||||
|
if (this.isInGoal(gameState)) {
|
||||||
|
this.handleGoal(gameState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.velocity.x *= -this.restitution;
|
||||||
|
this.position.x = Math.max(this.radius, Math.min(rink.width - this.radius, this.position.x));
|
||||||
|
collision = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.position.y - this.radius <= 0 || this.position.y + this.radius >= rink.height) {
|
||||||
|
this.velocity.y *= -this.restitution;
|
||||||
|
this.position.y = Math.max(this.radius, Math.min(rink.height - this.radius, this.position.y));
|
||||||
|
collision = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collision) {
|
||||||
|
this.bounceCount++;
|
||||||
|
this.velocity = this.velocity.multiply(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInGoal(gameState) {
|
||||||
|
const goalY = gameState.rink.centerY;
|
||||||
|
const goalHeight = gameState.rink.goalHeight;
|
||||||
|
|
||||||
|
if (this.position.y >= goalY - goalHeight && this.position.y <= goalY + goalHeight) {
|
||||||
|
if (this.position.x <= 20 || this.position.x >= gameState.rink.width - 20) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGoal(gameState) {
|
||||||
|
const team = this.position.x <= 20 ? 'away' : 'home';
|
||||||
|
gameState.addGoal(team);
|
||||||
|
|
||||||
|
this.reset(gameState.rink.centerX, gameState.rink.centerY);
|
||||||
|
|
||||||
|
gameState.emit('goal-scored', {
|
||||||
|
team,
|
||||||
|
scorer: this.lastPlayerTouch,
|
||||||
|
position: this.position.copy()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkPlayerCollisions(players, gameState) {
|
||||||
|
let closestPlayer = null;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
|
||||||
|
players.forEach(player => {
|
||||||
|
if (player.state.penaltyTime > 0) return;
|
||||||
|
|
||||||
|
const distance = this.position.distance(player.position);
|
||||||
|
const collisionDistance = this.radius + player.radius;
|
||||||
|
|
||||||
|
if (distance < collisionDistance) {
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestPlayer = player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (closestPlayer) {
|
||||||
|
this.handlePlayerCollision(closestPlayer, gameState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePlayerCollision(player, gameState) {
|
||||||
|
const distance = this.position.distance(player.position);
|
||||||
|
const minDistance = this.radius + player.radius;
|
||||||
|
|
||||||
|
if (distance < minDistance) {
|
||||||
|
const overlap = minDistance - distance;
|
||||||
|
const direction = this.position.subtract(player.position).normalize();
|
||||||
|
|
||||||
|
this.position = player.position.add(direction.multiply(minDistance));
|
||||||
|
|
||||||
|
const relativeVelocity = this.velocity.subtract(player.velocity);
|
||||||
|
const speed = relativeVelocity.dot(direction);
|
||||||
|
|
||||||
|
if (speed > 0) return;
|
||||||
|
|
||||||
|
const totalMass = this.mass + player.mass;
|
||||||
|
const impulse = 2 * speed / totalMass;
|
||||||
|
|
||||||
|
this.velocity = this.velocity.subtract(direction.multiply(impulse * player.mass * this.restitution));
|
||||||
|
|
||||||
|
if (this.velocity.magnitude() < 100 && player.role !== 'G') {
|
||||||
|
this.pickupPuck(player, gameState);
|
||||||
|
} else if (player.role === 'G' && this.velocity.magnitude() > 50) {
|
||||||
|
this.handleGoalieSave(player, gameState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pickupPuck(player, gameState) {
|
||||||
|
if (player.state.hasPuck) return;
|
||||||
|
|
||||||
|
players.forEach(p => p.state.hasPuck = false);
|
||||||
|
|
||||||
|
player.state.hasPuck = true;
|
||||||
|
this.lastPlayerTouch = player;
|
||||||
|
this.lastTeamTouch = player.team;
|
||||||
|
this.velocity = new Vector2(0, 0);
|
||||||
|
|
||||||
|
gameState.emit('puck-pickup', {
|
||||||
|
player: player.name,
|
||||||
|
team: player.team,
|
||||||
|
position: this.position.copy()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGoalieSave(goalie, gameState) {
|
||||||
|
const saveChance = goalie.attributes.defense / 100;
|
||||||
|
const shotSpeed = this.velocity.magnitude();
|
||||||
|
const difficulty = Math.min(1, shotSpeed / 500);
|
||||||
|
|
||||||
|
if (Math.random() < saveChance * (1 - difficulty * 0.5)) {
|
||||||
|
this.velocity = this.velocity.multiply(-0.3);
|
||||||
|
gameState.addSave(goalie.team);
|
||||||
|
|
||||||
|
gameState.emit('save', {
|
||||||
|
goalie: goalie.name,
|
||||||
|
team: goalie.team,
|
||||||
|
difficulty: difficulty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shoot(direction, power) {
|
||||||
|
this.velocity = direction.normalize().multiply(power);
|
||||||
|
this.bounceCount = 0;
|
||||||
|
this.trail = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
pass(target, power = 300) {
|
||||||
|
const direction = target.subtract(this.position).normalize();
|
||||||
|
this.velocity = direction.multiply(power);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(x = 500, y = 300) {
|
||||||
|
this.position = new Vector2(x, y);
|
||||||
|
this.velocity = new Vector2(0, 0);
|
||||||
|
this.lastPlayerTouch = null;
|
||||||
|
this.lastTeamTouch = null;
|
||||||
|
this.bounceCount = 0;
|
||||||
|
this.trail = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
faceoff(player1, player2) {
|
||||||
|
const centerPoint = player1.position.add(player2.position).divide(2);
|
||||||
|
this.position = centerPoint.copy();
|
||||||
|
this.velocity = new Vector2(0, 0);
|
||||||
|
|
||||||
|
const winner = Math.random() < 0.5 ? player1 : player2;
|
||||||
|
const loser = winner === player1 ? player2 : player1;
|
||||||
|
|
||||||
|
// Clear any existing puck possession
|
||||||
|
player1.state.hasPuck = false;
|
||||||
|
player2.state.hasPuck = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// Winner gets the puck with slight movement toward their goal
|
||||||
|
const direction = winner.team === 'home' ?
|
||||||
|
new Vector2(1, (Math.random() - 0.5) * 0.5) :
|
||||||
|
new Vector2(-1, (Math.random() - 0.5) * 0.5);
|
||||||
|
|
||||||
|
this.velocity = direction.normalize().multiply(100);
|
||||||
|
winner.state.hasPuck = true;
|
||||||
|
this.lastPlayerTouch = winner;
|
||||||
|
this.lastTeamTouch = winner.team;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return winner;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoose(players) {
|
||||||
|
return !players.some(player => player.state.hasPuck);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpeed() {
|
||||||
|
return this.velocity.magnitude();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
this.trail.forEach((point, index) => {
|
||||||
|
if (point.alpha > 0) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = point.alpha * 0.5;
|
||||||
|
ctx.fillStyle = '#ffff88';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(point.position.x, point.position.y, this.radius * (point.alpha * 0.5 + 0.5), 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.position.x, this.position.y, this.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (this.velocity.magnitude() > 20) {
|
||||||
|
ctx.strokeStyle = '#ffff88';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
const direction = this.velocity.normalize().multiply(-15);
|
||||||
|
ctx.moveTo(this.position.x, this.position.y);
|
||||||
|
ctx.lineTo(this.position.x + direction.x, this.position.y + direction.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
268
src/systems/ai-system.js
Normal file
268
src/systems/ai-system.js
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
class AISystem {
|
||||||
|
constructor() {
|
||||||
|
this.formations = {
|
||||||
|
offensive: {
|
||||||
|
'LW': { x: 0.7, y: 0.2 },
|
||||||
|
'C': { x: 0.75, y: 0.5 },
|
||||||
|
'RW': { x: 0.7, y: 0.8 },
|
||||||
|
'LD': { x: 0.4, y: 0.3 },
|
||||||
|
'RD': { x: 0.4, y: 0.7 }
|
||||||
|
},
|
||||||
|
defensive: {
|
||||||
|
'LW': { x: 0.3, y: 0.2 },
|
||||||
|
'C': { x: 0.4, y: 0.5 },
|
||||||
|
'RW': { x: 0.3, y: 0.8 },
|
||||||
|
'LD': { x: 0.25, y: 0.3 },
|
||||||
|
'RD': { x: 0.25, y: 0.7 }
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
'LW': { x: 0.5, y: 0.25 },
|
||||||
|
'C': { x: 0.5, y: 0.5 },
|
||||||
|
'RW': { x: 0.5, y: 0.75 },
|
||||||
|
'LD': { x: 0.35, y: 0.35 },
|
||||||
|
'RD': { x: 0.35, y: 0.65 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.strategyWeights = {
|
||||||
|
aggressive: { offense: 0.7, defense: 0.3 },
|
||||||
|
balanced: { offense: 0.5, defense: 0.5 },
|
||||||
|
defensive: { offense: 0.3, defense: 0.7 }
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentStrategy = 'balanced';
|
||||||
|
}
|
||||||
|
|
||||||
|
update(players, puck, gameState) {
|
||||||
|
const puckOwner = this.findPuckOwner(players);
|
||||||
|
const gameContext = this.analyzeGameContext(players, puck, gameState, puckOwner);
|
||||||
|
|
||||||
|
players.forEach(player => {
|
||||||
|
if (player.role !== 'G') {
|
||||||
|
this.updatePlayerAI(player, gameContext);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findPuckOwner(players) {
|
||||||
|
return players.find(player => player.state.hasPuck) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzeGameContext(players, puck, gameState, puckOwner) {
|
||||||
|
const context = {
|
||||||
|
puckOwner,
|
||||||
|
puckPosition: puck.position,
|
||||||
|
gameTime: gameState.timeRemaining,
|
||||||
|
period: gameState.period,
|
||||||
|
scoreGap: gameState.homeScore - gameState.awayScore,
|
||||||
|
powerPlay: gameState.powerPlay,
|
||||||
|
zone: this.determinePuckZone(puck, gameState)
|
||||||
|
};
|
||||||
|
|
||||||
|
context.formation = this.selectFormation(context);
|
||||||
|
context.urgency = this.calculateUrgency(context);
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
determinePuckZone(puck, gameState) {
|
||||||
|
const rinkWidth = gameState.rink.width;
|
||||||
|
const x = puck.position.x;
|
||||||
|
|
||||||
|
if (x < rinkWidth * 0.33) return 'defensive';
|
||||||
|
if (x > rinkWidth * 0.67) return 'offensive';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
selectFormation(context) {
|
||||||
|
if (context.powerPlay.home || context.powerPlay.away) {
|
||||||
|
return context.powerPlay.home ? 'offensive' : 'defensive';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (context.zone) {
|
||||||
|
case 'offensive':
|
||||||
|
return 'offensive';
|
||||||
|
case 'defensive':
|
||||||
|
return 'defensive';
|
||||||
|
default:
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateUrgency(context) {
|
||||||
|
let urgency = 0;
|
||||||
|
|
||||||
|
if (context.gameTime < 300) urgency += 0.3;
|
||||||
|
if (context.gameTime < 120) urgency += 0.3;
|
||||||
|
if (Math.abs(context.scoreGap) > 1) urgency += 0.2;
|
||||||
|
if (context.powerPlay.home || context.powerPlay.away) urgency += 0.4;
|
||||||
|
|
||||||
|
return Math.min(1, urgency);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayerAI(player, context) {
|
||||||
|
const teamSide = player.team === 'home' ? 1 : -1;
|
||||||
|
const formation = this.formations[context.formation];
|
||||||
|
const playerFormation = formation[player.role];
|
||||||
|
|
||||||
|
if (playerFormation) {
|
||||||
|
const formationPosition = this.calculateFormationPosition(
|
||||||
|
playerFormation,
|
||||||
|
context,
|
||||||
|
teamSide
|
||||||
|
);
|
||||||
|
|
||||||
|
player.aiState.formationTarget = formationPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePlayerBehavior(player, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateFormationPosition(formation, context, teamSide) {
|
||||||
|
const rink = { width: 1000, height: 600 };
|
||||||
|
const centerX = rink.width * 0.5;
|
||||||
|
const centerY = rink.height * 0.5;
|
||||||
|
|
||||||
|
let x, y;
|
||||||
|
|
||||||
|
if (teamSide === 1) {
|
||||||
|
x = formation.x * rink.width;
|
||||||
|
y = formation.y * rink.height;
|
||||||
|
} else {
|
||||||
|
x = (1 - formation.x) * rink.width;
|
||||||
|
y = formation.y * rink.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const puckInfluence = this.calculatePuckInfluence(context.puckPosition, new Vector2(x, y));
|
||||||
|
x += puckInfluence.x * 30;
|
||||||
|
y += puckInfluence.y * 30;
|
||||||
|
|
||||||
|
return new Vector2(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculatePuckInfluence(puckPos, playerPos) {
|
||||||
|
const direction = puckPos.subtract(playerPos).normalize();
|
||||||
|
const distance = puckPos.distance(playerPos);
|
||||||
|
const influence = Math.max(0, 1 - distance / 200);
|
||||||
|
|
||||||
|
return direction.multiply(influence);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayerBehavior(player, context) {
|
||||||
|
const distanceToPuck = player.position.distance(context.puckPosition);
|
||||||
|
const isNearPuck = distanceToPuck < 100;
|
||||||
|
|
||||||
|
if (player.state.hasPuck) {
|
||||||
|
player.aiState.behavior = 'puck_carrier';
|
||||||
|
this.executePuckCarrierBehavior(player, context);
|
||||||
|
} else if (context.puckOwner && context.puckOwner.team === player.team) {
|
||||||
|
player.aiState.behavior = 'support';
|
||||||
|
this.executeSupportBehavior(player, context);
|
||||||
|
} else if (context.puckOwner && context.puckOwner.team !== player.team) {
|
||||||
|
player.aiState.behavior = 'pressure';
|
||||||
|
this.executePressureBehavior(player, context);
|
||||||
|
} else if (isNearPuck) {
|
||||||
|
player.aiState.behavior = 'chase';
|
||||||
|
this.executeChaseBehavior(player, context);
|
||||||
|
} else {
|
||||||
|
player.aiState.behavior = 'formation';
|
||||||
|
this.executeFormationBehavior(player, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executePuckCarrierBehavior(player, context) {
|
||||||
|
const enemyGoal = player.team === 'home' ?
|
||||||
|
new Vector2(950, 300) : new Vector2(50, 300);
|
||||||
|
|
||||||
|
const distanceToGoal = player.position.distance(enemyGoal);
|
||||||
|
const urgency = context.urgency;
|
||||||
|
|
||||||
|
if (distanceToGoal < 200 && urgency > 0.5) {
|
||||||
|
player.aiState.action = 'shoot';
|
||||||
|
} else if (this.isUnderPressure(player, context)) {
|
||||||
|
player.aiState.action = 'pass';
|
||||||
|
} else {
|
||||||
|
player.aiState.action = 'advance';
|
||||||
|
player.targetPosition = enemyGoal.copy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executeSupportBehavior(player, context) {
|
||||||
|
const puckCarrier = context.puckOwner;
|
||||||
|
const supportPosition = this.calculateSupportPosition(player, puckCarrier, context);
|
||||||
|
|
||||||
|
player.targetPosition = supportPosition;
|
||||||
|
player.aiState.action = 'support';
|
||||||
|
}
|
||||||
|
|
||||||
|
executePressureBehavior(player, context) {
|
||||||
|
const opponent = context.puckOwner;
|
||||||
|
const pressurePosition = this.calculatePressurePosition(player, opponent, context);
|
||||||
|
|
||||||
|
player.targetPosition = pressurePosition;
|
||||||
|
player.aiState.action = 'pressure';
|
||||||
|
}
|
||||||
|
|
||||||
|
executeChaseBehavior(player, context) {
|
||||||
|
player.targetPosition = context.puckPosition.copy();
|
||||||
|
player.aiState.action = 'chase';
|
||||||
|
}
|
||||||
|
|
||||||
|
executeFormationBehavior(player, context) {
|
||||||
|
if (player.aiState.formationTarget) {
|
||||||
|
player.targetPosition = player.aiState.formationTarget.copy();
|
||||||
|
}
|
||||||
|
player.aiState.action = 'formation';
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnderPressure(player, context) {
|
||||||
|
// Check if opponent players are nearby and approaching
|
||||||
|
const nearbyOpponents = this.getNearbyOpponents(player, context, 80);
|
||||||
|
return nearbyOpponents.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNearbyOpponents(player, context, radius) {
|
||||||
|
// This would typically be passed from the game engine
|
||||||
|
// For now, return empty array as placeholder
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateSupportPosition(player, puckCarrier, context) {
|
||||||
|
const enemyGoal = player.team === 'home' ?
|
||||||
|
new Vector2(950, 300) : new Vector2(50, 300);
|
||||||
|
|
||||||
|
const basePosition = puckCarrier.position.lerp(enemyGoal, 0.7);
|
||||||
|
const offset = this.getRoleOffset(player.role);
|
||||||
|
|
||||||
|
return basePosition.add(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculatePressurePosition(player, opponent, context) {
|
||||||
|
const ownGoal = player.team === 'home' ?
|
||||||
|
new Vector2(50, 300) : new Vector2(950, 300);
|
||||||
|
|
||||||
|
return opponent.position.lerp(ownGoal, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoleOffset(role) {
|
||||||
|
const offsets = {
|
||||||
|
'LW': new Vector2(-50, -80),
|
||||||
|
'RW': new Vector2(-50, 80),
|
||||||
|
'C': new Vector2(0, 0),
|
||||||
|
'LD': new Vector2(-100, -40),
|
||||||
|
'RD': new Vector2(-100, 40)
|
||||||
|
};
|
||||||
|
|
||||||
|
return offsets[role] || new Vector2(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStrategy(strategy) {
|
||||||
|
if (this.strategyWeights[strategy]) {
|
||||||
|
this.currentStrategy = strategy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStrategy() {
|
||||||
|
return this.currentStrategy;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/systems/audio-system.js
Normal file
245
src/systems/audio-system.js
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
class AudioSystem {
|
||||||
|
constructor() {
|
||||||
|
this.audioContext = null;
|
||||||
|
this.sounds = {};
|
||||||
|
this.volume = 0.7;
|
||||||
|
this.enabled = true;
|
||||||
|
this.initialized = false;
|
||||||
|
|
||||||
|
this.initializeAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeAudio() {
|
||||||
|
try {
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.generateSounds();
|
||||||
|
this.initialized = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Audio initialization failed:', error);
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSounds() {
|
||||||
|
this.sounds = {
|
||||||
|
puckHit: this.createPuckHitSound(),
|
||||||
|
stick: this.createStickSound(),
|
||||||
|
goal: this.createGoalSound(),
|
||||||
|
save: this.createSaveSound(),
|
||||||
|
whistle: this.createWhistleSound(),
|
||||||
|
crowd: this.createCrowdSound(),
|
||||||
|
skate: this.createSkateSound(),
|
||||||
|
board: this.createBoardSound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createPuckHitSound() {
|
||||||
|
return this.createPercussiveSound(150, 0.1, 'sawtooth');
|
||||||
|
}
|
||||||
|
|
||||||
|
createStickSound() {
|
||||||
|
return this.createPercussiveSound(200, 0.05, 'square');
|
||||||
|
}
|
||||||
|
|
||||||
|
createGoalSound() {
|
||||||
|
return this.createMelodicSound([400, 500, 600], 0.8, 'sine');
|
||||||
|
}
|
||||||
|
|
||||||
|
createSaveSound() {
|
||||||
|
return this.createPercussiveSound(100, 0.3, 'triangle');
|
||||||
|
}
|
||||||
|
|
||||||
|
createWhistleSound() {
|
||||||
|
return this.createMelodicSound([800, 900], 0.5, 'sine');
|
||||||
|
}
|
||||||
|
|
||||||
|
createCrowdSound() {
|
||||||
|
return this.createNoiseSound(0.2, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkateSound() {
|
||||||
|
return this.createNoiseSound(0.1, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
createBoardSound() {
|
||||||
|
return this.createPercussiveSound(80, 0.2, 'triangle');
|
||||||
|
}
|
||||||
|
|
||||||
|
createPercussiveSound(frequency, duration, waveType = 'sine') {
|
||||||
|
return () => {
|
||||||
|
if (!this.enabled || !this.audioContext) return;
|
||||||
|
|
||||||
|
const oscillator = this.audioContext.createOscillator();
|
||||||
|
const gainNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.type = waveType;
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, this.audioContext.currentTime);
|
||||||
|
oscillator.frequency.exponentialRampToValueAtTime(
|
||||||
|
frequency * 0.5,
|
||||||
|
this.audioContext.currentTime + duration
|
||||||
|
);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(this.volume * 0.3, this.audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(
|
||||||
|
0.001,
|
||||||
|
this.audioContext.currentTime + duration
|
||||||
|
);
|
||||||
|
|
||||||
|
oscillator.start(this.audioContext.currentTime);
|
||||||
|
oscillator.stop(this.audioContext.currentTime + duration);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createMelodicSound(frequencies, duration, waveType = 'sine') {
|
||||||
|
return () => {
|
||||||
|
if (!this.enabled || !this.audioContext) return;
|
||||||
|
|
||||||
|
frequencies.forEach((freq, index) => {
|
||||||
|
const delay = index * 0.1;
|
||||||
|
const oscillator = this.audioContext.createOscillator();
|
||||||
|
const gainNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.type = waveType;
|
||||||
|
oscillator.frequency.setValueAtTime(freq, this.audioContext.currentTime + delay);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0, this.audioContext.currentTime + delay);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(
|
||||||
|
this.volume * 0.2,
|
||||||
|
this.audioContext.currentTime + delay + 0.05
|
||||||
|
);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(
|
||||||
|
0.001,
|
||||||
|
this.audioContext.currentTime + delay + duration
|
||||||
|
);
|
||||||
|
|
||||||
|
oscillator.start(this.audioContext.currentTime + delay);
|
||||||
|
oscillator.stop(this.audioContext.currentTime + delay + duration);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createNoiseSound(volume, duration) {
|
||||||
|
return () => {
|
||||||
|
if (!this.enabled || !this.audioContext) return;
|
||||||
|
|
||||||
|
const bufferSize = this.audioContext.sampleRate * duration;
|
||||||
|
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * volume * this.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this.audioContext.createBufferSource();
|
||||||
|
const gainNode = this.audioContext.createGain();
|
||||||
|
const filter = this.audioContext.createBiquadFilter();
|
||||||
|
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(filter);
|
||||||
|
filter.connect(gainNode);
|
||||||
|
gainNode.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(2000, this.audioContext.currentTime);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(1, this.audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.001, this.audioContext.currentTime + duration);
|
||||||
|
|
||||||
|
source.start(this.audioContext.currentTime);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
playSound(soundName, volume = 1) {
|
||||||
|
if (!this.enabled || !this.sounds[soundName]) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
this.sounds[soundName]();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to play sound:', soundName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPuckHit(velocity) {
|
||||||
|
const intensity = Math.min(1, velocity / 500);
|
||||||
|
if (intensity > 0.1) {
|
||||||
|
this.playSound('puckHit', intensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStickContact() {
|
||||||
|
this.playSound('stick');
|
||||||
|
}
|
||||||
|
|
||||||
|
onGoal(team) {
|
||||||
|
this.playSound('goal');
|
||||||
|
setTimeout(() => this.playSound('crowd'), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave() {
|
||||||
|
this.playSound('save');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPenalty() {
|
||||||
|
this.playSound('whistle');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPeriodEnd() {
|
||||||
|
this.playSound('whistle');
|
||||||
|
}
|
||||||
|
|
||||||
|
onBoardCollision(velocity) {
|
||||||
|
const intensity = Math.min(1, velocity / 300);
|
||||||
|
if (intensity > 0.2) {
|
||||||
|
this.playSound('board', intensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlayerMovement(speed) {
|
||||||
|
if (speed > 100 && Math.random() < 0.01) {
|
||||||
|
this.playSound('skate', 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume) {
|
||||||
|
this.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.enabled = !this.enabled;
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
resume() {
|
||||||
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
|
return this.audioContext.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend() {
|
||||||
|
if (this.audioContext && this.audioContext.state === 'running') {
|
||||||
|
return this.audioContext.suspend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
volume: this.volume,
|
||||||
|
initialized: this.initialized,
|
||||||
|
contextState: this.audioContext ? this.audioContext.state : 'not-initialized'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/systems/physics-system.js
Normal file
64
src/systems/physics-system.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
class PhysicsSystem {
|
||||||
|
constructor() {
|
||||||
|
this.gravity = new Vector2(0, 0);
|
||||||
|
this.airResistance = 0.99;
|
||||||
|
this.collisionIterations = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(entities, deltaTime) {
|
||||||
|
entities.forEach(entity => {
|
||||||
|
this.updateEntity(entity, deltaTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < this.collisionIterations; i++) {
|
||||||
|
this.resolveCollisions(entities);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEntity(entity, deltaTime) {
|
||||||
|
if (!entity.velocity || !entity.position) return;
|
||||||
|
|
||||||
|
entity.velocity = entity.velocity.add(this.gravity.multiply(deltaTime));
|
||||||
|
entity.velocity = entity.velocity.multiply(this.airResistance);
|
||||||
|
entity.position = entity.position.add(entity.velocity.multiply(deltaTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCollisions(entities) {
|
||||||
|
for (let i = 0; i < entities.length; i++) {
|
||||||
|
for (let j = i + 1; j < entities.length; j++) {
|
||||||
|
const entityA = entities[i];
|
||||||
|
const entityB = entities[j];
|
||||||
|
|
||||||
|
if (this.checkCollision(entityA, entityB)) {
|
||||||
|
this.resolveCollision(entityA, entityB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCollision(entityA, entityB) {
|
||||||
|
if (!entityA.radius || !entityB.radius) return false;
|
||||||
|
|
||||||
|
const distance = entityA.position.distance(entityB.position);
|
||||||
|
return distance < (entityA.radius + entityB.radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCollision(entityA, entityB) {
|
||||||
|
Physics.resolveCircleCollision(entityA, entityB);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyForce(entity, force) {
|
||||||
|
if (!entity.velocity) return;
|
||||||
|
|
||||||
|
const acceleration = force.divide(entity.mass || 1);
|
||||||
|
entity.velocity = entity.velocity.add(acceleration);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGravity(gravity) {
|
||||||
|
this.gravity = gravity;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAirResistance(resistance) {
|
||||||
|
this.airResistance = Math.max(0, Math.min(1, resistance));
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/systems/renderer.js
Normal file
324
src/systems/renderer.js
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
class Renderer {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.camera = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
zoom: 1,
|
||||||
|
target: null,
|
||||||
|
smoothing: 0.1
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setupCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCanvas() {
|
||||||
|
this.canvas.style.imageRendering = 'pixelated';
|
||||||
|
this.ctx.imageSmoothingEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRink(gameState) {
|
||||||
|
const rink = gameState.rink;
|
||||||
|
|
||||||
|
this.ctx.save();
|
||||||
|
this.applyCamera();
|
||||||
|
|
||||||
|
this.ctx.fillStyle = '#f8f8f8';
|
||||||
|
this.ctx.fillRect(0, 0, rink.width, rink.height);
|
||||||
|
|
||||||
|
this.drawRinkLines(rink);
|
||||||
|
this.drawGoals(rink);
|
||||||
|
this.drawFaceoffDots(rink);
|
||||||
|
this.drawCreases(rink);
|
||||||
|
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRinkLines(rink) {
|
||||||
|
this.ctx.strokeStyle = '#d32f2f';
|
||||||
|
this.ctx.lineWidth = 3;
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.rect(0, 0, rink.width, rink.height);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.strokeStyle = '#2196f3';
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(rink.centerX, 0);
|
||||||
|
this.ctx.lineTo(rink.centerX, rink.height);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
const zoneWidth = rink.width / 3;
|
||||||
|
this.ctx.strokeStyle = '#2196f3';
|
||||||
|
this.ctx.setLineDash([10, 5]);
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(zoneWidth, 0);
|
||||||
|
this.ctx.lineTo(zoneWidth, rink.height);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(rink.width - zoneWidth, 0);
|
||||||
|
this.ctx.lineTo(rink.width - zoneWidth, rink.height);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.setLineDash([]);
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(rink.centerX, rink.centerY, 100, 0, Math.PI * 2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGoals(rink) {
|
||||||
|
this.ctx.strokeStyle = '#d32f2f';
|
||||||
|
this.ctx.lineWidth = 4;
|
||||||
|
|
||||||
|
const goalY = rink.centerY;
|
||||||
|
const goalHeight = rink.goalHeight;
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(0, goalY - goalHeight);
|
||||||
|
this.ctx.lineTo(-10, goalY - goalHeight);
|
||||||
|
this.ctx.lineTo(-10, goalY + goalHeight);
|
||||||
|
this.ctx.lineTo(0, goalY + goalHeight);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(rink.width, goalY - goalHeight);
|
||||||
|
this.ctx.lineTo(rink.width + 10, goalY - goalHeight);
|
||||||
|
this.ctx.lineTo(rink.width + 10, goalY + goalHeight);
|
||||||
|
this.ctx.lineTo(rink.width, goalY + goalHeight);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.fillStyle = 'rgba(211, 47, 47, 0.1)';
|
||||||
|
this.ctx.fillRect(-10, goalY - goalHeight, 10, goalHeight * 2);
|
||||||
|
this.ctx.fillRect(rink.width, goalY - goalHeight, 10, goalHeight * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCreases(rink) {
|
||||||
|
this.ctx.strokeStyle = '#4fc3f7';
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.fillStyle = 'rgba(79, 195, 247, 0.1)';
|
||||||
|
|
||||||
|
const creaseRadius = 60;
|
||||||
|
const goalY = rink.centerY;
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(60, goalY, creaseRadius, -Math.PI/2, Math.PI/2);
|
||||||
|
this.ctx.fill();
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(rink.width - 60, goalY, creaseRadius, Math.PI/2, -Math.PI/2);
|
||||||
|
this.ctx.fill();
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFaceoffDots(rink) {
|
||||||
|
this.ctx.fillStyle = '#d32f2f';
|
||||||
|
|
||||||
|
rink.faceoffDots.forEach(dot => {
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(dot.x, dot.y, 8, 0, Math.PI * 2);
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
this.ctx.strokeStyle = '#d32f2f';
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(dot.x, dot.y, 30, 0, Math.PI * 2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPlayers(players) {
|
||||||
|
this.ctx.save();
|
||||||
|
this.applyCamera();
|
||||||
|
|
||||||
|
players.forEach(player => {
|
||||||
|
if (player.state.penaltyTime <= 0) {
|
||||||
|
player.render(this.ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPuck(puck) {
|
||||||
|
this.ctx.save();
|
||||||
|
this.applyCamera();
|
||||||
|
puck.render(this.ctx);
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawUI(gameState) {
|
||||||
|
this.updateScoreBoard(gameState);
|
||||||
|
this.updateGameStats(gameState);
|
||||||
|
this.updatePenalties(gameState);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScoreBoard(gameState) {
|
||||||
|
document.querySelector('.team.home .score').textContent = gameState.homeScore;
|
||||||
|
document.querySelector('.team.away .score').textContent = gameState.awayScore;
|
||||||
|
document.getElementById('period').textContent = gameState.getPeriodName();
|
||||||
|
document.getElementById('clock').textContent = gameState.formatTime(gameState.timeRemaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGameStats(gameState) {
|
||||||
|
document.getElementById('home-shots').textContent = gameState.stats.home.shots;
|
||||||
|
document.getElementById('away-shots').textContent = gameState.stats.away.shots;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePenalties(gameState) {
|
||||||
|
const homePenalties = document.getElementById('home-penalties');
|
||||||
|
const awayPenalties = document.getElementById('away-penalties');
|
||||||
|
|
||||||
|
homePenalties.innerHTML = '';
|
||||||
|
awayPenalties.innerHTML = '';
|
||||||
|
|
||||||
|
gameState.stats.home.penalties.forEach(penalty => {
|
||||||
|
const penaltyDiv = document.createElement('div');
|
||||||
|
penaltyDiv.className = 'penalty-box';
|
||||||
|
penaltyDiv.textContent = `${penalty.player}: ${penalty.type} (${Math.ceil(penalty.timeRemaining)}s)`;
|
||||||
|
homePenalties.appendChild(penaltyDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
gameState.stats.away.penalties.forEach(penalty => {
|
||||||
|
const penaltyDiv = document.createElement('div');
|
||||||
|
penaltyDiv.className = 'penalty-box';
|
||||||
|
penaltyDiv.textContent = `${penalty.player}: ${penalty.type} (${Math.ceil(penalty.timeRemaining)}s)`;
|
||||||
|
awayPenalties.appendChild(penaltyDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawParticleEffect(position, type, color = '#ffff00') {
|
||||||
|
this.ctx.save();
|
||||||
|
this.applyCamera();
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'goal':
|
||||||
|
this.drawGoalEffect(position);
|
||||||
|
break;
|
||||||
|
case 'hit':
|
||||||
|
this.drawHitEffect(position, color);
|
||||||
|
break;
|
||||||
|
case 'save':
|
||||||
|
this.drawSaveEffect(position);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGoalEffect(position) {
|
||||||
|
this.ctx.fillStyle = '#ffff00';
|
||||||
|
this.ctx.strokeStyle = '#ff8800';
|
||||||
|
this.ctx.lineWidth = 3;
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const angle = (i / 8) * Math.PI * 2;
|
||||||
|
const x = position.x + Math.cos(angle) * 30;
|
||||||
|
const y = position.y + Math.sin(angle) * 30;
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||||
|
this.ctx.fill();
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHitEffect(position, color) {
|
||||||
|
this.ctx.strokeStyle = color;
|
||||||
|
this.ctx.lineWidth = 4;
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (i / 6) * Math.PI * 2;
|
||||||
|
const startX = position.x + Math.cos(angle) * 10;
|
||||||
|
const startY = position.y + Math.sin(angle) * 10;
|
||||||
|
const endX = position.x + Math.cos(angle) * 25;
|
||||||
|
const endY = position.y + Math.sin(angle) * 25;
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(startX, startY);
|
||||||
|
this.ctx.lineTo(endX, endY);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSaveEffect(position) {
|
||||||
|
this.ctx.fillStyle = '#4fc3f7';
|
||||||
|
this.ctx.strokeStyle = '#0288d1';
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(position.x, position.y, 20, 0, Math.PI * 2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(position.x, position.y, 15, 0, Math.PI * 2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCamera(target) {
|
||||||
|
if (target) {
|
||||||
|
this.camera.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.camera.target) {
|
||||||
|
const targetX = this.canvas.width / 2 - this.camera.target.x * this.camera.zoom;
|
||||||
|
const targetY = this.canvas.height / 2 - this.camera.target.y * this.camera.zoom;
|
||||||
|
|
||||||
|
this.camera.x += (targetX - this.camera.x) * this.camera.smoothing;
|
||||||
|
this.camera.y += (targetY - this.camera.y) * this.camera.smoothing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCamera() {
|
||||||
|
this.ctx.translate(this.camera.x, this.camera.y);
|
||||||
|
this.ctx.scale(this.camera.zoom, this.camera.zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoom(zoom) {
|
||||||
|
this.camera.zoom = Math.max(0.5, Math.min(2.0, zoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
screenToWorld(screenPos) {
|
||||||
|
return new Vector2(
|
||||||
|
(screenPos.x - this.camera.x) / this.camera.zoom,
|
||||||
|
(screenPos.y - this.camera.y) / this.camera.zoom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
worldToScreen(worldPos) {
|
||||||
|
return new Vector2(
|
||||||
|
worldPos.x * this.camera.zoom + this.camera.x,
|
||||||
|
worldPos.y * this.camera.zoom + this.camera.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDebugInfo(gameState, players, puck) {
|
||||||
|
if (!window.debugMode) return;
|
||||||
|
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
this.ctx.fillRect(10, 10, 200, 150);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = '#fff';
|
||||||
|
this.ctx.font = '12px monospace';
|
||||||
|
this.ctx.fillText(`FPS: ${Math.round(1000 / 16)}`, 20, 30);
|
||||||
|
this.ctx.fillText(`Players: ${players.length}`, 20, 50);
|
||||||
|
this.ctx.fillText(`Puck Speed: ${Math.round(puck.velocity.magnitude())}`, 20, 70);
|
||||||
|
this.ctx.fillText(`Game Time: ${gameState.formatTime(gameState.timeRemaining)}`, 20, 90);
|
||||||
|
this.ctx.fillText(`Period: ${gameState.period}`, 20, 110);
|
||||||
|
this.ctx.fillText(`Paused: ${gameState.isPaused}`, 20, 130);
|
||||||
|
this.ctx.fillText(`Speed: ${gameState.gameSpeed}x`, 20, 150);
|
||||||
|
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/systems/rules-system.js
Normal file
256
src/systems/rules-system.js
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
class RulesSystem {
|
||||||
|
constructor(gameState) {
|
||||||
|
this.gameState = gameState;
|
||||||
|
this.lastOffsideCheck = 0;
|
||||||
|
this.lastIcingCheck = 0;
|
||||||
|
this.penaltyQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
update(players, puck, deltaTime) {
|
||||||
|
this.checkOffside(players, puck);
|
||||||
|
this.checkIcing(players, puck);
|
||||||
|
this.checkGoaltenderInterference(players, puck);
|
||||||
|
this.checkHighSticking(players, puck);
|
||||||
|
this.processPenaltyQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkOffside(players, puck) {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
if (currentTime - this.lastOffsideCheck < 500) return;
|
||||||
|
|
||||||
|
const puckOwner = players.find(p => p.state.hasPuck);
|
||||||
|
if (!puckOwner) return;
|
||||||
|
|
||||||
|
const centerLine = this.gameState.rink.centerX;
|
||||||
|
const offensiveZoneLine = puckOwner.team === 'home' ?
|
||||||
|
this.gameState.rink.width * 0.67 : this.gameState.rink.width * 0.33;
|
||||||
|
|
||||||
|
const puckInOffensiveZone = puckOwner.team === 'home' ?
|
||||||
|
puck.position.x > offensiveZoneLine :
|
||||||
|
puck.position.x < offensiveZoneLine;
|
||||||
|
|
||||||
|
if (puckInOffensiveZone) {
|
||||||
|
const teammates = players.filter(p =>
|
||||||
|
p.team === puckOwner.team &&
|
||||||
|
p.id !== puckOwner.id &&
|
||||||
|
p.role !== 'G'
|
||||||
|
);
|
||||||
|
|
||||||
|
const offsidePlayers = teammates.filter(teammate => {
|
||||||
|
const isAhead = puckOwner.team === 'home' ?
|
||||||
|
teammate.position.x > puck.position.x :
|
||||||
|
teammate.position.x < puck.position.x;
|
||||||
|
|
||||||
|
const inOffensiveZone = puckOwner.team === 'home' ?
|
||||||
|
teammate.position.x > offensiveZoneLine :
|
||||||
|
teammate.position.x < offensiveZoneLine;
|
||||||
|
|
||||||
|
return isAhead && inOffensiveZone;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (offsidePlayers.length > 0) {
|
||||||
|
this.callOffside(puckOwner.team, offsidePlayers);
|
||||||
|
this.lastOffsideCheck = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callOffside(team, players) {
|
||||||
|
this.gameState.addEvent(`OFFSIDE - ${team.toUpperCase()}`);
|
||||||
|
|
||||||
|
const faceoffPosition = this.gameState.rink.faceoffDots[
|
||||||
|
team === 'home' ? 1 : 3
|
||||||
|
];
|
||||||
|
|
||||||
|
this.gameState.emit('offside', {
|
||||||
|
team,
|
||||||
|
players: players.map(p => p.name),
|
||||||
|
faceoffPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scheduleFaceoff(faceoffPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIcing(players, puck) {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
if (currentTime - this.lastIcingCheck < 1000) return;
|
||||||
|
|
||||||
|
if (!puck.lastTeamTouch) return;
|
||||||
|
|
||||||
|
const shootingTeam = puck.lastTeamTouch;
|
||||||
|
const shootingZoneLine = shootingTeam === 'home' ?
|
||||||
|
this.gameState.rink.width * 0.33 : this.gameState.rink.width * 0.67;
|
||||||
|
|
||||||
|
const puckCrossedOwnZone = shootingTeam === 'home' ?
|
||||||
|
puck.position.x < shootingZoneLine :
|
||||||
|
puck.position.x > shootingZoneLine;
|
||||||
|
|
||||||
|
if (!puckCrossedOwnZone) return;
|
||||||
|
|
||||||
|
const goalLine = shootingTeam === 'home' ?
|
||||||
|
this.gameState.rink.width - 50 : 50;
|
||||||
|
|
||||||
|
const puckPastGoalLine = shootingTeam === 'home' ?
|
||||||
|
puck.position.x > goalLine :
|
||||||
|
puck.position.x < goalLine;
|
||||||
|
|
||||||
|
if (puckPastGoalLine) {
|
||||||
|
const shootingPlayers = players.filter(p => p.team === shootingTeam);
|
||||||
|
const opposingPlayers = players.filter(p => p.team !== shootingTeam);
|
||||||
|
|
||||||
|
const nearestShootingPlayer = this.findNearestPlayer(puck.position, shootingPlayers);
|
||||||
|
const nearestOpposingPlayer = this.findNearestPlayer(puck.position, opposingPlayers);
|
||||||
|
|
||||||
|
if (nearestOpposingPlayer &&
|
||||||
|
nearestOpposingPlayer.position.distance(puck.position) <
|
||||||
|
nearestShootingPlayer.position.distance(puck.position)) {
|
||||||
|
|
||||||
|
this.callIcing(shootingTeam);
|
||||||
|
this.lastIcingCheck = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callIcing(team) {
|
||||||
|
this.gameState.addEvent(`ICING - ${team.toUpperCase()}`);
|
||||||
|
|
||||||
|
const faceoffPosition = this.gameState.rink.faceoffDots[
|
||||||
|
team === 'home' ? 0 : 4
|
||||||
|
];
|
||||||
|
|
||||||
|
this.gameState.emit('icing', {
|
||||||
|
team,
|
||||||
|
faceoffPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scheduleFaceoff(faceoffPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkGoaltenderInterference(players, puck) {
|
||||||
|
const goalies = players.filter(p => p.role === 'G');
|
||||||
|
|
||||||
|
goalies.forEach(goalie => {
|
||||||
|
const crease = this.getCreaseArea(goalie.team);
|
||||||
|
const opponents = players.filter(p =>
|
||||||
|
p.team !== goalie.team &&
|
||||||
|
this.isPlayerInCrease(p, crease)
|
||||||
|
);
|
||||||
|
|
||||||
|
opponents.forEach(opponent => {
|
||||||
|
if (opponent.velocity.magnitude() > 100) {
|
||||||
|
this.callGoaltenderInterference(opponent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkHighSticking(players, puck) {
|
||||||
|
if (puck.position.y < 200) {
|
||||||
|
const lastTouch = puck.lastPlayerTouch;
|
||||||
|
if (lastTouch && Math.random() < 0.1) {
|
||||||
|
this.callHighSticking(lastTouch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlayerInCrease(player, crease) {
|
||||||
|
return player.position.x >= crease.x &&
|
||||||
|
player.position.x <= crease.x + crease.width &&
|
||||||
|
player.position.y >= crease.y &&
|
||||||
|
player.position.y <= crease.y + crease.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCreaseArea(team) {
|
||||||
|
const goalY = this.gameState.rink.centerY;
|
||||||
|
const goalX = team === 'home' ? 50 : this.gameState.rink.width - 50;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: goalX - 30,
|
||||||
|
y: goalY - 60,
|
||||||
|
width: 60,
|
||||||
|
height: 120
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
callGoaltenderInterference(player) {
|
||||||
|
this.queuePenalty(player.team, player.name, 'Goaltender Interference', 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
callHighSticking(player) {
|
||||||
|
this.queuePenalty(player.team, player.name, 'High Sticking', 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
queuePenalty(team, player, type, duration) {
|
||||||
|
this.penaltyQueue.push({
|
||||||
|
team,
|
||||||
|
player,
|
||||||
|
type,
|
||||||
|
duration,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processPenaltyQueue() {
|
||||||
|
if (this.penaltyQueue.length === 0) return;
|
||||||
|
|
||||||
|
const penalty = this.penaltyQueue.shift();
|
||||||
|
this.gameState.addPenalty(
|
||||||
|
penalty.team,
|
||||||
|
penalty.player,
|
||||||
|
penalty.type,
|
||||||
|
penalty.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleFaceoff(position) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.gameState.emit('faceoff', { position });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
findNearestPlayer(position, players) {
|
||||||
|
let nearest = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
players.forEach(player => {
|
||||||
|
const distance = player.position.distance(position);
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
nearest = player;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFighting(players) {
|
||||||
|
for (let i = 0; i < players.length; i++) {
|
||||||
|
for (let j = i + 1; j < players.length; j++) {
|
||||||
|
const player1 = players[i];
|
||||||
|
const player2 = players[j];
|
||||||
|
|
||||||
|
if (player1.team !== player2.team &&
|
||||||
|
player1.position.distance(player2.position) < 20 &&
|
||||||
|
player1.velocity.magnitude() > 150 &&
|
||||||
|
player2.velocity.magnitude() > 150 &&
|
||||||
|
Math.random() < 0.01) {
|
||||||
|
|
||||||
|
this.callFighting(player1, player2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callFighting(player1, player2) {
|
||||||
|
this.queuePenalty(player1.team, player1.name, 'Fighting', 300);
|
||||||
|
this.queuePenalty(player2.team, player2.name, 'Fighting', 300);
|
||||||
|
|
||||||
|
this.gameState.addEvent(`FIGHT - ${player1.name} vs ${player2.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.lastOffsideCheck = 0;
|
||||||
|
this.lastIcingCheck = 0;
|
||||||
|
this.penaltyQueue = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/utils/physics.js
Normal file
69
src/utils/physics.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
class Physics {
|
||||||
|
static checkCircleCollision(pos1, radius1, pos2, radius2) {
|
||||||
|
const distance = pos1.distance(pos2);
|
||||||
|
return distance < (radius1 + radius2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static resolveCircleCollision(obj1, obj2) {
|
||||||
|
const distance = obj1.position.distance(obj2.position);
|
||||||
|
const minDistance = obj1.radius + obj2.radius;
|
||||||
|
|
||||||
|
if (distance < minDistance) {
|
||||||
|
const overlap = minDistance - distance;
|
||||||
|
const direction = obj2.position.subtract(obj1.position).normalize();
|
||||||
|
|
||||||
|
const separation = direction.multiply(overlap * 0.5);
|
||||||
|
obj1.position = obj1.position.subtract(separation);
|
||||||
|
obj2.position = obj2.position.add(separation);
|
||||||
|
|
||||||
|
const relativeVelocity = obj2.velocity.subtract(obj1.velocity);
|
||||||
|
const speed = relativeVelocity.dot(direction);
|
||||||
|
|
||||||
|
if (speed > 0) return;
|
||||||
|
|
||||||
|
const restitution = Math.min(obj1.restitution || 0.8, obj2.restitution || 0.8);
|
||||||
|
const impulse = 2 * speed / (obj1.mass + obj2.mass);
|
||||||
|
|
||||||
|
obj1.velocity = obj1.velocity.add(direction.multiply(impulse * obj2.mass * restitution));
|
||||||
|
obj2.velocity = obj2.velocity.subtract(direction.multiply(impulse * obj1.mass * restitution));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static checkPointInRectangle(point, rect) {
|
||||||
|
return point.x >= rect.x &&
|
||||||
|
point.x <= rect.x + rect.width &&
|
||||||
|
point.y >= rect.y &&
|
||||||
|
point.y <= rect.y + rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
static checkCircleRectangleCollision(circle, rect) {
|
||||||
|
const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width));
|
||||||
|
const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height));
|
||||||
|
|
||||||
|
const distance = new Vector2(circle.x - closestX, circle.y - closestY).magnitude();
|
||||||
|
return distance < circle.radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
static applyFriction(velocity, friction, deltaTime) {
|
||||||
|
const speed = velocity.magnitude();
|
||||||
|
if (speed > 0) {
|
||||||
|
const frictionForce = speed * friction * deltaTime;
|
||||||
|
if (frictionForce >= speed) {
|
||||||
|
return new Vector2(0, 0);
|
||||||
|
} else {
|
||||||
|
return velocity.subtract(velocity.normalize().multiply(frictionForce));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return velocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
static wrapAngle(angle) {
|
||||||
|
while (angle > Math.PI) angle -= 2 * Math.PI;
|
||||||
|
while (angle < -Math.PI) angle += 2 * Math.PI;
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
static lerp(start, end, t) {
|
||||||
|
return start + (end - start) * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/utils/vector.js
Normal file
76
src/utils/vector.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
class Vector2 {
|
||||||
|
constructor(x = 0, y = 0) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(obj) {
|
||||||
|
return new Vector2(obj.x, obj.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
copy() {
|
||||||
|
return new Vector2(this.x, this.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(vector) {
|
||||||
|
return new Vector2(this.x + vector.x, this.y + vector.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(vector) {
|
||||||
|
return new Vector2(this.x - vector.x, this.y - vector.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
multiply(scalar) {
|
||||||
|
return new Vector2(this.x * scalar, this.y * scalar);
|
||||||
|
}
|
||||||
|
|
||||||
|
divide(scalar) {
|
||||||
|
return new Vector2(this.x / scalar, this.y / scalar);
|
||||||
|
}
|
||||||
|
|
||||||
|
magnitude() {
|
||||||
|
return Math.sqrt(this.x * this.x + this.y * this.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize() {
|
||||||
|
const mag = this.magnitude();
|
||||||
|
if (mag === 0) return new Vector2(0, 0);
|
||||||
|
return this.divide(mag);
|
||||||
|
}
|
||||||
|
|
||||||
|
distance(vector) {
|
||||||
|
const dx = this.x - vector.x;
|
||||||
|
const dy = this.y - vector.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
dot(vector) {
|
||||||
|
return this.x * vector.x + this.y * vector.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
angle() {
|
||||||
|
return Math.atan2(this.y, this.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromAngle(angle, magnitude = 1) {
|
||||||
|
return new Vector2(
|
||||||
|
Math.cos(angle) * magnitude,
|
||||||
|
Math.sin(angle) * magnitude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lerp(target, t) {
|
||||||
|
return new Vector2(
|
||||||
|
this.x + (target.x - this.x) * t,
|
||||||
|
this.y + (target.y - this.y) * t
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
limit(maxMagnitude) {
|
||||||
|
const mag = this.magnitude();
|
||||||
|
if (mag > maxMagnitude) {
|
||||||
|
return this.normalize().multiply(maxMagnitude);
|
||||||
|
}
|
||||||
|
return this.copy();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user