diff --git a/level/resources/weapon_shield.tres b/level/resources/weapon_shield.tres index 06b7019..9dcf59c 100644 --- a/level/resources/weapon_shield.tres +++ b/level/resources/weapon_shield.tres @@ -11,6 +11,7 @@ hand_type = 1 damage = 8.0 attack_range = 2.5 attack_cooldown = 0.8 +knockback_force = 15.0 can_block = true block_reduction = 0.7 mesh_scene = ExtResource("2") diff --git a/level/resources/weapon_sword.tres b/level/resources/weapon_sword.tres index 17dad51..f42a824 100644 --- a/level/resources/weapon_sword.tres +++ b/level/resources/weapon_sword.tres @@ -10,6 +10,7 @@ description = "A simple iron sword. Good for close combat." damage = 15.0 attack_range = 3.5 attack_cooldown = 0.6 +knockback_force = 12.0 attack_animation = "Attack1" mesh_scene = ExtResource("2") pickup_radius = 1.5 diff --git a/level/scripts/base_unit.gd b/level/scripts/base_unit.gd index 9fd78bd..dc6742f 100644 --- a/level/scripts/base_unit.gd +++ b/level/scripts/base_unit.gd @@ -26,7 +26,7 @@ func _enter_tree(): ## Take damage from an attacker ## Should only be called on the server for authority @rpc("any_peer", "reliable") -func take_damage(amount: float, attacker_id: int = -1): +func take_damage(amount: float, attacker_id: int = -1, knockback: float = 0.0, attacker_pos: Vector3 = Vector3.ZERO): # Only server can process damage if not multiplayer.is_server(): return @@ -36,10 +36,14 @@ func take_damage(amount: float, attacker_id: int = -1): # Apply blocking reduction if applicable (duck typing - check if method exists) var final_damage = amount + var final_knockback = knockback + var was_blocked = false if has_method("get_block_reduction"): var block_reduction = call("get_block_reduction") if block_reduction > 0.0: final_damage = amount * (1.0 - block_reduction) + final_knockback = knockback * (1.0 - block_reduction) # Reduce knockback too + was_blocked = true print("Blocked! Damage reduced from ", amount, " to ", final_damage, " (", block_reduction * 100, "% reduction)") var old_health = current_health @@ -49,6 +53,18 @@ func take_damage(amount: float, attacker_id: int = -1): rpc("sync_health", current_health) health_changed.emit(old_health, current_health) + # Show damage number on all clients + rpc("_show_damage_number", final_damage, was_blocked) + + # Play hurt animation on all clients + rpc("_play_hurt_animation") + + # Apply knockback if applicable + if final_knockback > 0.0 and attacker_pos != Vector3.ZERO: + var knockback_dir = (global_position - attacker_pos).normalized() + knockback_dir.y = 0.3 # Slight upward component + rpc("_apply_knockback", knockback_dir * final_knockback) + if current_health <= 0: _die(attacker_id) @@ -123,3 +139,36 @@ func set_respawn_point(point: Vector3): ## Get health percentage (0.0 to 1.0) func get_health_percent() -> float: return current_health / max_health if max_health > 0 else 0.0 + +## Show floating damage number +@rpc("any_peer", "call_local", "reliable") +func _show_damage_number(damage: float, was_blocked: bool): + print("Spawning damage number: ", damage, " blocked: ", was_blocked, " on peer: ", multiplayer.get_unique_id()) + + # Create damage number label + var damage_label = Label3D.new() + var damage_script = load("res://level/scripts/damage_number.gd") + damage_label.set_script(damage_script) + + # Position above the character + damage_label.position = global_position + Vector3(0, 2.5, 0) + + # Add to scene (not as child of this node, so it doesn't get affected by player movement) + get_tree().get_current_scene().add_child(damage_label) + + # Set damage value and color + damage_label.call("set_damage", damage, was_blocked) + +## Apply knockback force +@rpc("any_peer", "call_local", "reliable") +func _apply_knockback(knockback_velocity: Vector3): + # Only applies to CharacterBody3D (players) + if self is CharacterBody3D: + velocity += knockback_velocity + print("Applied knockback: ", knockback_velocity) + +## Play hurt animation (override in subclasses) +@rpc("any_peer", "call_local", "reliable") +func _play_hurt_animation(): + # Subclasses should implement this + pass diff --git a/level/scripts/base_weapon.gd b/level/scripts/base_weapon.gd index 55daba8..bdbfec1 100644 --- a/level/scripts/base_weapon.gd +++ b/level/scripts/base_weapon.gd @@ -80,10 +80,22 @@ func _find_and_damage_targets(): # If we're the server, apply damage directly if multiplayer.is_server(): - owner_character._server_apply_damage(hit_body.name, weapon_data.damage, attacker_id) + owner_character._server_apply_damage( + hit_body.name, + weapon_data.damage, + attacker_id, + weapon_data.knockback_force, + owner_character.global_position + ) else: # Otherwise, request server to apply damage - owner_character.rpc_id(1, "_server_apply_damage", hit_body.name, weapon_data.damage, attacker_id) + owner_character.rpc_id(1, "_server_apply_damage", + hit_body.name, + weapon_data.damage, + attacker_id, + weapon_data.knockback_force, + owner_character.global_position + ) break # Only hit one target per attack ## Check if weapon can attack diff --git a/level/scripts/damage_number.gd b/level/scripts/damage_number.gd new file mode 100644 index 0000000..5367b51 --- /dev/null +++ b/level/scripts/damage_number.gd @@ -0,0 +1,86 @@ +extends Label3D +class_name DamageNumber + +## Floating damage number that animates upward and fades out + +@export var float_speed: float = 3.0 +@export var lifetime: float = 1.8 +@export var spread: float = 0.7 # Random horizontal spread + +var _elapsed: float = 0.0 +var _velocity: Vector3 = Vector3.ZERO +var _initial_scale: float = 1.5 + +func _ready(): + # Random horizontal spread + _velocity = Vector3( + randf_range(-spread, spread), + float_speed, + randf_range(-spread, spread) + ) + + # Billboard mode so it always faces camera + billboard = BaseMaterial3D.BILLBOARD_ENABLED + + # Start with full opacity + modulate.a = 1.0 + + # Larger initial scale for impact + scale = Vector3.ONE * 0.5 + +func _process(delta): + _elapsed += delta + + # Move upward with velocity + position += _velocity * delta + _velocity.y -= delta * 1.5 # Slight gravity effect + + # Fade out over lifetime (keep visible longer, then quick fade) + var fade_progress = _elapsed / lifetime + if fade_progress < 0.7: + modulate.a = 1.0 + else: + var fade_amount = (fade_progress - 0.7) / 0.3 + modulate.a = 1.0 - fade_amount + + # Pop animation - quick scale up, then gentle scale down + if fade_progress < 0.15: + # Quick pop up + var scale_progress = fade_progress / 0.15 + var target_scale = _initial_scale * (0.5 + (0.5 * scale_progress)) + scale = Vector3.ONE * target_scale + elif fade_progress < 0.3: + # Slight bounce/overshoot + var bounce_progress = (fade_progress - 0.15) / 0.15 + var overshoot = sin(bounce_progress * PI) * 0.15 + var target_scale = _initial_scale * (1.0 + overshoot) + scale = Vector3.ONE * target_scale + else: + # Gentle shrink + var shrink_progress = (fade_progress - 0.3) / 0.7 + var target_scale = _initial_scale * (1.0 - (shrink_progress * 0.3)) + scale = Vector3.ONE * target_scale + + # Delete when lifetime expired + if _elapsed >= lifetime: + queue_free() + +## Set the damage number text and color +func set_damage(amount: float, was_blocked: bool = false): + text = str(int(amount)) + + if was_blocked: + # Bright yellow/orange for blocked damage with strong contrast + modulate = Color(1.0, 0.9, 0.0) # Bright yellow + outline_modulate = Color(0.3, 0.15, 0.0) # Dark brown/black outline + else: + # Vibrant red for normal damage with black outline + modulate = Color(1.0, 0.15, 0.15) # Bright red + outline_modulate = Color(0.1, 0.0, 0.0) # Almost black outline + + # Much larger and bolder + outline_size = 16 + font_size = 72 + + # Add depth with larger outline + pixel_size = 0.002 # Slightly larger pixels for better visibility diff --git a/level/scripts/damage_number.gd.uid b/level/scripts/damage_number.gd.uid new file mode 100644 index 0000000..da949f8 --- /dev/null +++ b/level/scripts/damage_number.gd.uid @@ -0,0 +1 @@ +uid://c563plaqxfk5d diff --git a/level/scripts/player.gd b/level/scripts/player.gd index 49fb1bc..d9570c0 100644 --- a/level/scripts/player.gd +++ b/level/scripts/player.gd @@ -354,17 +354,17 @@ func _perform_attack(): if hit_body != self and hit_body is BaseUnit: var attacker_id = multiplayer.get_unique_id() - # If we're the server, apply damage directly + # If we're the server, apply damage directly (default unarmed knockback) if multiplayer.is_server(): - _server_apply_damage(hit_body.name, attack_damage, attacker_id) + _server_apply_damage(hit_body.name, attack_damage, attacker_id, 5.0, global_position) else: # Otherwise, request server to apply damage - rpc_id(1, "_server_apply_damage", hit_body.name, attack_damage, attacker_id) + rpc_id(1, "_server_apply_damage", hit_body.name, attack_damage, attacker_id, 5.0, global_position) break # Only hit one target per attack ## Server-side damage application @rpc("any_peer", "reliable") -func _server_apply_damage(target_name: String, damage: float, attacker_id: int): +func _server_apply_damage(target_name: String, damage: float, attacker_id: int, knockback: float = 0.0, attacker_pos: Vector3 = Vector3.ZERO): if not multiplayer.is_server(): return @@ -379,7 +379,7 @@ func _server_apply_damage(target_name: String, damage: float, attacker_id: int): var target = players_container.get_node(target_name) if target and target is BaseUnit: - target.take_damage(damage, attacker_id) + target.take_damage(damage, attacker_id, knockback, attacker_pos) ## Health display and callbacks func _create_health_ui(): @@ -520,6 +520,36 @@ func _reset_dash_rotation(rotation_value: float): if _body: _body.rotation.x = rotation_value +## Override hurt animation from BaseUnit +func _play_hurt_animation(): + if _body and _body.animation_player: + # Try to play a hurt/hit animation if it exists + if _body.animation_player.has_animation("Hurt"): + _body.animation_player.play("Hurt") + elif _body.animation_player.has_animation("Hit"): + _body.animation_player.play("Hit") + elif _body.animation_player.has_animation("TakeDamage"): + _body.animation_player.play("TakeDamage") + else: + # Fallback: briefly flash the character red + _flash_hurt() + +## Flash effect when no hurt animation exists +func _flash_hurt(): + if not _body: + return + + # Store original modulate + var original_modulate = _body.modulate + + # Flash red + _body.modulate = Color(1.5, 0.5, 0.5, 1.0) + + # Return to normal after a brief moment + await get_tree().create_timer(0.15).timeout + if _body: + _body.modulate = original_modulate + ## Weapon System func _setup_weapon_pickup_area(): # Create an Area3D to detect nearby weapons diff --git a/level/scripts/weapon_data.gd b/level/scripts/weapon_data.gd index 193e319..66eef42 100644 --- a/level/scripts/weapon_data.gd +++ b/level/scripts/weapon_data.gd @@ -16,6 +16,7 @@ enum Hand { MAIN_HAND, OFF_HAND, TWO_HAND } @export var attack_range: float = 3.0 @export var attack_cooldown: float = 0.5 @export var attack_animation: String = "Attack1" # Animation to play when attacking +@export var knockback_force: float = 8.0 # How much to push the target back @export_category("Defense Stats") @export var can_block: bool = false