Files
libstormgames/combat.py
2025-09-16 01:21:20 -04:00

637 lines
21 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Combat system for Storm Games.
Provides weapon and projectile systems with cooldowns, hit detection,
stat bonuses, and flexible configuration options.
"""
import time
import math
from typing import Dict, Any, Optional, Tuple, Union, Callable, List
import copy
class Weapon:
"""Generic weapon system with cooldown, range, and stat bonuses.
Features:
- Cooldown-based attack system
- Hit detection with attack duration
- Configurable stat bonuses (speed, jump, etc.)
- Serialization support for save/load
- Factory methods for common weapon types
- Sound integration (optional)
Example usage:
# Create a sword
sword = Weapon("Iron Sword", damage=10, range_value=2, cooldown=500)
# Add stat bonuses
sword.stat_bonuses = {"speed": 1.1, "critical_chance": 0.05}
# Use in combat
if sword.can_attack():
if sword.can_hit_target(enemy_pos, player_pos, facing_right):
damage_dealt = sword.attack()
"""
def __init__(
self,
name: str,
damage: Union[int, float],
range_value: Union[int, float],
attack_sound: Optional[str] = None,
hit_sound: Optional[str] = None,
cooldown: int = 500,
attack_duration: int = 200,
stat_bonuses: Optional[Dict[str, Union[int, float]]] = None
):
"""Initialize weapon.
Args:
name: Display name of the weapon
damage: Base damage value
range_value: Attack range
attack_sound: Sound key for attack (optional)
hit_sound: Sound key for successful hit (optional)
cooldown: Milliseconds between attacks
attack_duration: Milliseconds the attack window stays open
stat_bonuses: Dictionary of stat multipliers/bonuses
"""
self.name = name
self.damage = damage
self.range_value = range_value
self.attack_sound = attack_sound
self.hit_sound = hit_sound
self.cooldown = cooldown
self.attack_duration = attack_duration
self.stat_bonuses = stat_bonuses or {}
# Attack state tracking
self.last_attack_time = 0
self.attack_start_time = 0
self.is_attacking = False
self.hit_targets = set() # Track what we've hit during current attack
def can_attack(self) -> bool:
"""Check if weapon can currently attack (not on cooldown).
Returns:
True if weapon can attack, False if on cooldown
"""
current_time = time.time() * 1000 # Convert to milliseconds
return current_time - self.last_attack_time >= self.cooldown
def attack(self) -> Union[int, float]:
"""Initiate an attack.
Returns:
Damage value if attack is successful, 0 if on cooldown
"""
if not self.can_attack():
return 0
current_time = time.time() * 1000
self.last_attack_time = current_time
self.attack_start_time = current_time
self.is_attacking = True
self.hit_targets.clear()
return self.damage
def is_attack_active(self) -> bool:
"""Check if the attack window is currently active.
Returns:
True if within attack duration, False otherwise
"""
if not self.is_attacking:
return False
current_time = time.time() * 1000
elapsed = current_time - self.attack_start_time
if elapsed > self.attack_duration:
self.is_attacking = False
return False
return True
def can_hit_target(
self,
target_pos: Union[Tuple[float, float], float],
attacker_pos: Union[Tuple[float, float], float],
facing_right: bool = True,
target_id: Any = None
) -> bool:
"""Check if target is within range and can be hit.
Args:
target_pos: Position of target (x, y) or just x for 1D
attacker_pos: Position of attacker (x, y) or just x for 1D
facing_right: Direction attacker is facing (for 1D games)
target_id: Optional identifier to prevent multiple hits
Returns:
True if target can be hit, False otherwise
"""
# Check if attack is active
if not self.is_attack_active():
return False
# Check if we've already hit this target
if target_id is not None and target_id in self.hit_targets:
return False
# Calculate distance
if isinstance(target_pos, (tuple, list)) and isinstance(attacker_pos, (tuple, list)):
# 2D distance calculation
distance = math.sqrt(
(target_pos[0] - attacker_pos[0])**2 +
(target_pos[1] - attacker_pos[1])**2
)
else:
# 1D distance calculation
target_x = target_pos if isinstance(target_pos, (int, float)) else target_pos[0]
attacker_x = attacker_pos if isinstance(attacker_pos, (int, float)) else attacker_pos[0]
distance = abs(target_x - attacker_x)
# For 1D, also check direction
if facing_right and target_x <= attacker_x:
return False
elif not facing_right and target_x >= attacker_x:
return False
return distance <= self.range_value
def hit_target(self, target_id: Any = None) -> Union[int, float]:
"""Mark a target as hit and return damage.
Args:
target_id: Optional identifier for the target
Returns:
Damage value if hit is valid, 0 if target already hit
"""
if target_id is not None:
if target_id in self.hit_targets:
return 0
self.hit_targets.add(target_id)
return self.damage
def apply_stat_bonuses(self, base_stats: Dict[str, Union[int, float]]) -> Dict[str, Union[int, float]]:
"""Apply weapon's stat bonuses to base stats.
Args:
base_stats: Dictionary of base stat values
Returns:
Dictionary with stat bonuses applied
"""
modified_stats = base_stats.copy()
for stat_name, bonus in self.stat_bonuses.items():
if stat_name in modified_stats:
if isinstance(bonus, (int, float)) and bonus > 0:
if bonus > 1:
# Multiplicative bonus (e.g., 1.2 = 20% increase)
modified_stats[stat_name] *= bonus
else:
# Additive bonus (e.g., 0.05 = +5%)
modified_stats[stat_name] += bonus
return modified_stats
def to_dict(self) -> Dict[str, Any]:
"""Serialize weapon to dictionary for saving.
Returns:
Dictionary representation of weapon
"""
return {
"name": self.name,
"damage": self.damage,
"range_value": self.range_value,
"attack_sound": self.attack_sound,
"hit_sound": self.hit_sound,
"cooldown": self.cooldown,
"attack_duration": self.attack_duration,
"stat_bonuses": self.stat_bonuses.copy()
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Weapon':
"""Create weapon from dictionary data.
Args:
data: Dictionary containing weapon data
Returns:
New Weapon instance
"""
return cls(
name=data.get("name", "Unknown Weapon"),
damage=data.get("damage", 1),
range_value=data.get("range_value", 1),
attack_sound=data.get("attack_sound"),
hit_sound=data.get("hit_sound"),
cooldown=data.get("cooldown", 500),
attack_duration=data.get("attack_duration", 200),
stat_bonuses=data.get("stat_bonuses", {})
)
@classmethod
def create_from_config(cls, config_dict: Dict[str, Any]) -> 'Weapon':
"""Create weapon from configuration dictionary.
Args:
config_dict: Configuration dictionary
Returns:
New Weapon instance
"""
return cls.from_dict(config_dict)
# Factory methods for common weapon types
@classmethod
def create_sword(cls, name: str = "Sword", damage: Union[int, float] = 10) -> 'Weapon':
"""Create a standard sword weapon.
Args:
name: Name of the sword
damage: Damage value
Returns:
Sword weapon instance
"""
return cls(
name=name,
damage=damage,
range_value=2,
attack_sound="sword_swing",
hit_sound="sword_hit",
cooldown=800,
attack_duration=300
)
@classmethod
def create_dagger(cls, name: str = "Dagger", damage: Union[int, float] = 6) -> 'Weapon':
"""Create a fast dagger weapon.
Args:
name: Name of the dagger
damage: Damage value
Returns:
Dagger weapon instance
"""
return cls(
name=name,
damage=damage,
range_value=1,
attack_sound="dagger_stab",
hit_sound="dagger_hit",
cooldown=400,
attack_duration=150,
stat_bonuses={"speed": 1.1}
)
@classmethod
def create_staff(cls, name: str = "Staff", damage: Union[int, float] = 8) -> 'Weapon':
"""Create a magical staff weapon.
Args:
name: Name of the staff
damage: Damage value
Returns:
Staff weapon instance
"""
return cls(
name=name,
damage=damage,
range_value=3,
attack_sound="staff_cast",
hit_sound="magic_hit",
cooldown=1200,
attack_duration=400
)
def __str__(self) -> str:
"""String representation of weapon."""
return f"{self.name} (Damage: {self.damage}, Range: {self.range_value})"
def __repr__(self) -> str:
"""Detailed string representation."""
return f"Weapon(name='{self.name}', damage={self.damage}, range={self.range_value})"
class Projectile:
"""Generic projectile system for ranged combat.
Features:
- Position-based movement with direction vectors
- Range limiting and collision detection
- Configurable damage, speed, and behavior
- 1D and 2D movement support
- Hit callbacks for custom effects
Example usage:
# Create an arrow
arrow = Projectile("arrow", (10, 5), (1, 0), speed=0.3, damage=8)
# Update in game loop
while arrow.is_active():
arrow.update()
# Check collision with target
if arrow.check_collision(target_pos, target_size):
damage = arrow.hit()
break
"""
def __init__(
self,
projectile_type: str,
start_pos: Union[Tuple[float, float], float],
direction: Union[Tuple[float, float], float],
speed: float = 0.2,
damage: Union[int, float] = 5,
max_range: Union[int, float] = 12,
on_hit_callback: Optional[Callable] = None
):
"""Initialize projectile.
Args:
projectile_type: Type identifier for the projectile
start_pos: Starting position (x, y) or just x for 1D
direction: Direction vector (dx, dy) or just direction for 1D
speed: Movement speed per update
damage: Damage value
max_range: Maximum travel distance
on_hit_callback: Optional function called when projectile hits
"""
self.projectile_type = projectile_type
self.damage = damage
self.max_range = max_range
self.speed = speed
self.on_hit_callback = on_hit_callback
# Position tracking
if isinstance(start_pos, (tuple, list)):
self.position = list(start_pos)
self.start_position = list(start_pos)
self.is_2d = True
else:
self.position = float(start_pos)
self.start_position = float(start_pos)
self.is_2d = False
# Direction handling
if self.is_2d and isinstance(direction, (tuple, list)):
self.direction = list(direction)
# Normalize direction vector
magnitude = math.sqrt(direction[0]**2 + direction[1]**2)
if magnitude > 0:
self.direction = [direction[0]/magnitude, direction[1]/magnitude]
elif not self.is_2d:
self.direction = float(direction)
else:
raise ValueError("Direction format must match position format (1D or 2D)")
# State tracking
self.active = True
self.has_hit = False
self.distance_traveled = 0.0
def update(self) -> bool:
"""Update projectile position.
Returns:
True if projectile is still active, False if it should be removed
"""
if not self.active:
return False
# Move projectile
if self.is_2d:
old_pos = self.position.copy()
self.position[0] += self.direction[0] * self.speed
self.position[1] += self.direction[1] * self.speed
# Calculate distance traveled
dx = self.position[0] - old_pos[0]
dy = self.position[1] - old_pos[1]
self.distance_traveled += math.sqrt(dx**2 + dy**2)
else:
old_pos = self.position
self.position += self.direction * self.speed
self.distance_traveled += abs(self.position - old_pos)
# Check if projectile has exceeded range
if self.distance_traveled >= self.max_range:
self.active = False
return self.active
def check_collision(
self,
target_pos: Union[Tuple[float, float], float],
target_size: Union[Tuple[float, float], float] = 1.0
) -> bool:
"""Check if projectile collides with a target.
Args:
target_pos: Target position (x, y) or just x for 1D
target_size: Target size (width, height) or just width for 1D
Returns:
True if collision detected, False otherwise
"""
if not self.active or self.has_hit:
return False
if self.is_2d and isinstance(target_pos, (tuple, list)):
# 2D collision detection (simple rectangle/circle)
target_width = target_size[0] if isinstance(target_size, (tuple, list)) else target_size
target_height = target_size[1] if isinstance(target_size, (tuple, list)) else target_size
# Simple bounding box collision
distance_x = abs(self.position[0] - target_pos[0])
distance_y = abs(self.position[1] - target_pos[1])
return distance_x <= target_width / 2 and distance_y <= target_height / 2
else:
# 1D collision detection
target_x = target_pos if isinstance(target_pos, (int, float)) else target_pos[0]
proj_x = self.position if isinstance(self.position, (int, float)) else self.position[0]
target_width = target_size if isinstance(target_size, (int, float)) else target_size[0]
return abs(proj_x - target_x) <= target_width / 2
def hit(self) -> Union[int, float]:
"""Mark projectile as having hit a target.
Returns:
Damage value of the projectile
"""
if self.has_hit:
return 0
self.has_hit = True
self.active = False
# Call hit callback if provided
if self.on_hit_callback:
try:
self.on_hit_callback(self)
except Exception as e:
print(f"Warning: Hit callback failed: {e}")
return self.damage
def is_active(self) -> bool:
"""Check if projectile is still active.
Returns:
True if projectile is active, False otherwise
"""
return self.active and not self.has_hit
def get_position(self) -> Union[Tuple[float, float], float]:
"""Get current position of projectile.
Returns:
Current position (x, y) or just x for 1D
"""
if self.is_2d:
return tuple(self.position)
else:
return self.position
def get_distance_traveled(self) -> float:
"""Get total distance traveled by projectile.
Returns:
Distance traveled from start position
"""
return self.distance_traveled
def to_dict(self) -> Dict[str, Any]:
"""Serialize projectile to dictionary.
Returns:
Dictionary representation of projectile
"""
return {
"projectile_type": self.projectile_type,
"position": self.position,
"start_position": self.start_position,
"direction": self.direction,
"speed": self.speed,
"damage": self.damage,
"max_range": self.max_range,
"distance_traveled": self.distance_traveled,
"active": self.active,
"has_hit": self.has_hit,
"is_2d": self.is_2d
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Projectile':
"""Create projectile from dictionary data.
Args:
data: Dictionary containing projectile data
Returns:
New Projectile instance
"""
projectile = cls(
projectile_type=data.get("projectile_type", "projectile"),
start_pos=data.get("start_position", (0, 0)),
direction=data.get("direction", (1, 0)),
speed=data.get("speed", 0.2),
damage=data.get("damage", 5),
max_range=data.get("max_range", 12)
)
# Restore state
projectile.position = data.get("position", projectile.position)
projectile.distance_traveled = data.get("distance_traveled", 0.0)
projectile.active = data.get("active", True)
projectile.has_hit = data.get("has_hit", False)
return projectile
# Factory methods for common projectile types
@classmethod
def create_arrow(cls, start_pos: Union[Tuple[float, float], float], direction: Union[Tuple[float, float], float]) -> 'Projectile':
"""Create a standard arrow projectile.
Args:
start_pos: Starting position
direction: Direction vector
Returns:
Arrow projectile instance
"""
return cls(
projectile_type="arrow",
start_pos=start_pos,
direction=direction,
speed=0.4,
damage=7,
max_range=15
)
@classmethod
def create_fireball(cls, start_pos: Union[Tuple[float, float], float], direction: Union[Tuple[float, float], float]) -> 'Projectile':
"""Create a fireball projectile with area damage.
Args:
start_pos: Starting position
direction: Direction vector
Returns:
Fireball projectile instance
"""
return cls(
projectile_type="fireball",
start_pos=start_pos,
direction=direction,
speed=0.3,
damage=12,
max_range=20
)
@classmethod
def create_bullet(cls, start_pos: Union[Tuple[float, float], float], direction: Union[Tuple[float, float], float]) -> 'Projectile':
"""Create a fast bullet projectile.
Args:
start_pos: Starting position
direction: Direction vector
Returns:
Bullet projectile instance
"""
return cls(
projectile_type="bullet",
start_pos=start_pos,
direction=direction,
speed=0.8,
damage=5,
max_range=25
)
def __str__(self) -> str:
"""String representation of projectile."""
return f"{self.projectile_type} at {self.position} (Damage: {self.damage})"
def __repr__(self) -> str:
"""Detailed string representation."""
return f"Projectile(type='{self.projectile_type}', pos={self.position}, damage={self.damage})"