637 lines
21 KiB
Python
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})" |