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