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 = 1; this.lastPlayerTouch = null; this.lastTeamTouch = null; this.bounceCount = 0; this.trail = []; this.maxTrailLength = 10; // Collision cooldown tracking this.goalieCollisionCooldowns = new Map(); // Maps player ID to cooldown end time } update(deltaTime, gameState, players) { this.updatePosition(deltaTime); this.checkBoardCollisions(gameState); this.checkPlayerCollisions(players, gameState); this.checkPuckPossession(players); this.updateTrail(); // Check for goals continuously, not just on board collisions if (this.isInGoal(gameState)) { this.handleGoal(gameState); } } updatePosition(deltaTime) { this.velocity = Physics.applyFriction(this.velocity, this.friction, deltaTime); this.position = this.position.add(this.velocity.multiply(deltaTime)); } checkPuckPossession(players) { const puckCarrier = players.find(player => player.state.hasPuck); if (!puckCarrier) return; // Check if any opponent is trying to steal the puck const opponents = players.filter(p => p.team !== puckCarrier.team && p.role !== 'G'); const nearbyOpponent = opponents.find(opponent => { const distanceToCarrier = opponent.position.distance(puckCarrier.position); const distanceToPuck = opponent.position.distance(this.position); return distanceToCarrier < 25 && distanceToPuck < 20; }); if (nearbyOpponent) { // Opponent is close enough to potentially steal the puck const stealChance = 0.005; // 0.5% chance per frame (roughly 30% per second at 60fps) if (Math.random() < stealChance) { puckCarrier.state.hasPuck = false; nearbyOpponent.state.hasPuck = true; this.lastPlayerTouch = nearbyOpponent; this.lastTeamTouch = nearbyOpponent.team; return; } } // Sticky puck logic - keep puck attached to carrier const stickDistance = 15; // Distance puck stays from player const directionToPlayer = puckCarrier.position.subtract(this.position).normalize(); // Position puck slightly in front of player based on their movement direction let puckOffset; if (puckCarrier.velocity.magnitude() > 10) { // When moving, position puck in front of player puckOffset = puckCarrier.velocity.normalize().multiply(stickDistance); } else { // When stationary, keep puck close puckOffset = directionToPlayer.multiply(-stickDistance); } this.position = puckCarrier.position.add(puckOffset); this.velocity = puckCarrier.velocity.multiply(1.0); // Match player velocity } 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); }); } } isGoalieOnCooldown(goalie) { const playerId = goalie.id || `${goalie.team}-${goalie.role}`; const cooldownEnd = this.goalieCollisionCooldowns.get(playerId); if (cooldownEnd && Date.now() < cooldownEnd) { return true; } // Clean up expired cooldowns if (cooldownEnd && Date.now() >= cooldownEnd) { this.goalieCollisionCooldowns.delete(playerId); } return false; } setGoalieCooldown(goalie, durationMs = 500) { const playerId = goalie.id || `${goalie.team}-${goalie.role}`; this.goalieCollisionCooldowns.set(playerId, Date.now() + durationMs); } checkBoardCollisions(gameState) { const rink = gameState.rink; let collision = false; // Check goal wall collisions first collision = this.checkGoalWallCollisions(gameState) || collision; // Check regular board collisions (but skip goal areas) 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); } } checkGoalWallCollisions(gameState) { const rink = gameState.rink; const goalY = rink.centerY; const goalHeight = rink.goalHeight; const goalDepth = 25; const goalXOffset = gameState.renderer?.goalXOffset || 80; let collision = false; // Left goal walls const leftGoalRight = goalXOffset; const leftGoalLeft = goalXOffset - goalDepth; const leftGoalTop = goalY - goalHeight; const leftGoalBottom = goalY + goalHeight; // Right goal walls const rightGoalLeft = rink.width - goalXOffset; const rightGoalRight = rink.width - goalXOffset + goalDepth; const rightGoalTop = goalY - goalHeight; const rightGoalBottom = goalY + goalHeight; // Check left goal back wall collision if (this.position.x - this.radius <= leftGoalLeft && this.position.y >= leftGoalTop && this.position.y <= leftGoalBottom) { this.velocity.x = Math.abs(this.velocity.x); // Bounce away from wall this.position.x = leftGoalLeft + this.radius; collision = true; } // Check right goal back wall collision if (this.position.x + this.radius >= rightGoalRight && this.position.y >= rightGoalTop && this.position.y <= rightGoalBottom) { this.velocity.x = -Math.abs(this.velocity.x); // Bounce away from wall this.position.x = rightGoalRight - this.radius; collision = true; } // Check left goal side walls and front posts if (this.position.x >= leftGoalLeft && this.position.x <= leftGoalRight) { // Top side wall if (this.position.y - this.radius <= leftGoalTop && this.position.y >= leftGoalTop - 20) { this.velocity.y = Math.abs(this.velocity.y); this.position.y = leftGoalTop + this.radius; collision = true; } // Bottom side wall if (this.position.y + this.radius >= leftGoalBottom && this.position.y <= leftGoalBottom + 20) { this.velocity.y = -Math.abs(this.velocity.y); this.position.y = leftGoalBottom - this.radius; collision = true; } } // Check left goal front posts (prevent entering from sides) if (this.position.x + this.radius >= leftGoalRight && this.position.x <= leftGoalRight + 10) { // Top post if (this.position.y >= leftGoalTop - 10 && this.position.y <= leftGoalTop + 10) { this.velocity.x = -Math.abs(this.velocity.x); this.position.x = leftGoalRight - this.radius; collision = true; } // Bottom post if (this.position.y >= leftGoalBottom - 10 && this.position.y <= leftGoalBottom + 10) { this.velocity.x = -Math.abs(this.velocity.x); this.position.x = leftGoalRight - this.radius; collision = true; } } // Check right goal side walls if (this.position.x >= rightGoalLeft && this.position.x <= rightGoalRight) { // Top side wall if (this.position.y - this.radius <= rightGoalTop && this.position.y >= rightGoalTop - 20) { this.velocity.y = Math.abs(this.velocity.y); this.position.y = rightGoalTop + this.radius; collision = true; } // Bottom side wall if (this.position.y + this.radius >= rightGoalBottom && this.position.y <= rightGoalBottom + 20) { this.velocity.y = -Math.abs(this.velocity.y); this.position.y = rightGoalBottom - this.radius; collision = true; } } // Check right goal front posts (prevent entering from sides) if (this.position.x - this.radius <= rightGoalLeft && this.position.x >= rightGoalLeft - 10) { // Top post if (this.position.y >= rightGoalTop - 10 && this.position.y <= rightGoalTop + 10) { this.velocity.x = Math.abs(this.velocity.x); this.position.x = rightGoalLeft + this.radius; collision = true; } // Bottom post if (this.position.y >= rightGoalBottom - 10 && this.position.y <= rightGoalBottom + 10) { this.velocity.x = Math.abs(this.velocity.x); this.position.x = rightGoalLeft + this.radius; collision = true; } } return collision; } isInGoal(gameState) { const rink = gameState.rink; const goalY = rink.centerY; const goalHeight = rink.goalHeight; const goalDepth = 25; const goalXOffset = gameState.renderer?.goalXOffset || 80; // Check if puck is in the vertical range of the goals if (this.position.y >= goalY - goalHeight && this.position.y <= goalY + goalHeight) { // Left goal (light red area) const leftGoalRight = goalXOffset; const leftGoalLeft = goalXOffset - goalDepth; // Right goal (light red area) const rightGoalLeft = rink.width - goalXOffset; const rightGoalRight = rink.width - goalXOffset + goalDepth; // Check if puck is inside either light red goal area if ((this.position.x >= leftGoalLeft && this.position.x <= leftGoalRight) || (this.position.x >= rightGoalLeft && this.position.x <= rightGoalRight)) { return true; } } return false; } handleGoal(gameState) { // Determine which goal was scored in and validate the direction let scoringTeam = null; const rink = gameState.rink; const goalY = rink.centerY; const goalHeight = rink.goalHeight; const goalDepth = 25; const goalXOffset = gameState.renderer?.goalXOffset || 80; // Left goal (light red area) const leftGoalRight = goalXOffset; const leftGoalLeft = goalXOffset - goalDepth; // Right goal (light red area) const rightGoalLeft = rink.width - goalXOffset; const rightGoalRight = rink.width - goalXOffset + goalDepth; if (this.position.x >= leftGoalLeft && this.position.x <= leftGoalRight) { // Puck is in the LEFT goal (home team's goal) // Only count as goal if puck came from the right side (positive x velocity) if (this.velocity.x > 0 || (this.lastTeamTouch === 'away')) { scoringTeam = 'away'; // Away team scored on home team's goal } } else if (this.position.x >= rightGoalLeft && this.position.x <= rightGoalRight) { // Puck is in the RIGHT goal (away team's goal) // Only count as goal if puck came from the left side (negative x velocity) if (this.velocity.x < 0 || (this.lastTeamTouch === 'home')) { scoringTeam = 'home'; // Home team scored on away team's goal } } // Only award goal if it was a valid scoring direction if (scoringTeam) { gameState.addGoal(scoringTeam); gameState.emit('goal-scored', { team: scoringTeam, scorer: this.lastPlayerTouch, position: this.position.copy() }); } // Reset puck position regardless of whether goal was valid this.reset(gameState.rink.centerX, gameState.rink.centerY); } checkPlayerCollisions(players, gameState) { let closestPlayer = null; let closestDistance = Infinity; players.forEach(player => { // Skip goalies who are on collision cooldown if (player.role === 'G' && this.isGoalieOnCooldown(player)) { 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, players); } } handlePlayerCollision(player, gameState, players) { 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; // Handle goalies differently - check for save BEFORE applying collision physics if (player.role === 'G' && this.velocity.magnitude() > 50) { // Store original velocity before collision const originalVelocity = this.velocity.copy(); // Apply collision physics first const totalMass = this.mass + player.mass; const impulse = 2 * speed / totalMass; this.velocity = this.velocity.subtract(direction.multiply(impulse * player.mass * this.restitution)); // Now check if goalie makes the save this.handleGoalieSave(player, gameState, originalVelocity); } else if (player.role !== 'G') { // Regular player collision - apply physics and pickup puck const totalMass = this.mass + player.mass; const impulse = 2 * speed / totalMass; this.velocity = this.velocity.subtract(direction.multiply(impulse * player.mass * this.restitution)); this.pickupPuck(player, gameState, players); } } } pickupPuck(player, gameState, players) { 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, originalVelocity = null) { // Use original velocity for save calculation if provided, otherwise current velocity const velocityForCalculation = originalVelocity || this.velocity; const saveChance = goalie.attributes.defense / 100; const shotSpeed = velocityForCalculation.magnitude(); const difficulty = Math.min(1, shotSpeed / 500); if (Math.random() < saveChance * (1 - difficulty * 0.5)) { console.log("Successful save") // Successful save - reduce puck velocity significantly this.velocity = this.velocity.multiply(-0.3); gameState.addSave(goalie.team); gameState.emit('save', { goalie: goalie.name, team: goalie.team, difficulty: difficulty }); } else { console.log("Failed save - setting collision cooldown") // Failed save - puck continues with reduced velocity (already bounced from collision) // The collision physics have already been applied, so the puck will continue toward the goal this.velocity = this.velocity.multiply(0.8); // Slightly reduce speed but let it continue // Set collision cooldown to prevent immediate re-collision with goalie this.setGoalieCooldown(goalie, 500); // 500ms cooldown } } shoot(direction, power) { this.velocity = direction.normalize().multiply(power); this.bounceCount = 0; this.trail = []; } pass(target, power = 500) { 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 = []; // Clear all goalie collision cooldowns this.goalieCollisionCooldowns.clear(); } faceoffDrop(winningTeam, location, participants) { // Position puck at faceoff location this.position = new Vector2(location.x, location.y); this.velocity = new Vector2(0, 0); // Clear any existing puck possession Object.values(participants).forEach(player => { if (player) player.state.hasPuck = false; }); const winner = participants[winningTeam]; if (winner) { // Puck moves backward toward the winning team's end const direction = winningTeam === 'home' ? new Vector2(-1, (Math.random() - 0.5) * 0.3) : // Home team shoots left to right, so backward is left new Vector2(1, (Math.random() - 0.5) * 0.3); // Away team shoots right to left, so backward is right // Initial puck movement from faceoff this.velocity = direction.normalize().multiply(80 + Math.random() * 40); this.lastPlayerTouch = winner; this.lastTeamTouch = winningTeam; // Winner doesn't immediately have possession - they have to chase the puck setTimeout(() => { // Only give possession if the puck is still near the winner if (winner.position.distance(this.position) < 30) { winner.state.hasPuck = true; } }, 200); } } // Legacy method for compatibility 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; // 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(); } }