added more spell effects, fixed bomb effects, allow to pickup bomb...

This commit is contained in:
2026-01-24 05:20:24 +01:00
parent b9e836d394
commit 9ab4a13244
18 changed files with 715 additions and 158 deletions

View File

@@ -73,8 +73,10 @@ var bow_charge_percentage: float = 1.0 # 0.5, 0.75, or 1.0 based on charge time
var is_charging_spell: bool = false # True when holding grab with spellbook
var spell_charge_start_time: float = 0.0
var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second)
var use_spell_charge_particles: bool = false # If true, use red_star particles; if false, use AnimationIncantation
var spell_charge_particles: Node2D = null # Particle system for charging
var spell_charge_particle_timer: float = 0.0 # Timer for spawning particles
var spell_incantation_fire_ready_shown: bool = false # Track when we've switched to fire_ready
var spell_charge_tint: Color = Color(2.0, 0.2, 0.2, 2.0) # Tint color when fully charged
var spell_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation
var spell_charge_tint_pulse_speed: float = 8.0 # Speed of tint pulse animation while charging
@@ -92,6 +94,10 @@ var burn_damage_timer: float = 0.0 # Timer for burn damage ticks
var movement_lock_timer: float = 0.0 # Lock movement when bow is released
var direction_lock_timer: float = 0.0 # Lock facing direction when attacking
var locked_facing_direction: Vector2 = Vector2.ZERO # Locked facing direction during attack
var damage_direction_lock_timer: float = 0.0 # Lock facing when taking damage (enemies/players)
var damage_direction_lock_duration: float = 0.3 # Duration to block direction change after damage
var empty_bow_shot_attempts: int = 0
var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync)
var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference)
var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New projectile version
var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile
@@ -1493,6 +1499,10 @@ func _get_direction_from_vector(vec: Vector2) -> int:
# Update facing direction from mouse position (called by GameWorld)
func _update_facing_from_mouse(mouse_direction: Vector2):
# Don't update facing when dead
if is_dead:
return
# Only update if using keyboard input (not gamepad)
if input_device != -1:
return
@@ -1505,6 +1515,10 @@ func _update_facing_from_mouse(mouse_direction: Vector2):
if direction_lock_timer > 0.0:
return
# Don't update if direction is locked (taking damage from enemies/players)
if damage_direction_lock_timer > 0.0:
return
# Mark that mouse control is active (prevents movement keys from overriding attack direction)
mouse_control_active = true
@@ -1739,6 +1753,12 @@ func _physics_process(delta):
if direction_lock_timer <= 0.0:
direction_lock_timer = 0.0
# Update damage direction lock timer (block facing change when taking damage)
if damage_direction_lock_timer > 0.0:
damage_direction_lock_timer -= delta
if damage_direction_lock_timer <= 0.0:
damage_direction_lock_timer = 0.0
# Update spell charging
if is_charging_spell:
var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time
@@ -1747,6 +1767,7 @@ func _physics_process(delta):
# Update particles (spawn and animate)
spell_charge_particle_timer += delta
_update_spell_charge_particles(charge_progress)
_update_spell_charge_incantation(charge_progress)
# Update tint pulse timer when fully charged
if charge_progress >= 1.0:
@@ -2292,6 +2313,7 @@ func _handle_interactions():
spell_charge_start_time = Time.get_ticks_msec() / 1000.0
spell_incantation_played = false # Reset flag when starting new charge
_start_spell_charge_particles()
_start_spell_charge_incantation()
# Play spell charging sound (incantation plays when fully charged)
if has_node("SfxSpellCharge"):
@@ -2314,6 +2336,7 @@ func _handle_interactions():
is_charging_spell = false
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
# Return to IDLE animation
@@ -2351,6 +2374,7 @@ func _handle_interactions():
is_charging_spell = false
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() # This will restore original tints
# Stop spell charging sound, but let incantation play to completion
@@ -2369,6 +2393,7 @@ func _handle_interactions():
is_charging_spell = false
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint() # This will restore original tints
# Return to IDLE animation
@@ -2393,6 +2418,7 @@ func _handle_interactions():
is_charging_spell = false
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
# Return to IDLE animation
@@ -2788,6 +2814,12 @@ func _try_grab():
closest_body = body
if closest_body:
# Placed bomb (attack_bomb with fuse): collect to inventory, don't lift
if "is_fused" in closest_body and "can_be_collected" in closest_body and "player_owner" in closest_body:
if closest_body.has_method("can_be_grabbed") and closest_body.can_be_grabbed() and closest_body.has_method("on_grabbed"):
closest_body.on_grabbed(self)
return
held_object = closest_body
# Store the initial positions - don't change the grabbed object's position yet!
initial_grab_position = closest_body.global_position
@@ -3363,9 +3395,31 @@ func _place_down_object():
if not held_object:
return
# Place object in front of player based on facing direction (mouse or movement)
var place_pos = _find_closest_place_pos(facing_direction_vector, held_object)
var placed_obj = held_object
var place_pos = _find_closest_place_pos(facing_direction_vector, placed_obj)
# Dwarf dropping bomb: place attack_bomb with fuse lit (explodes if not picked up in time)
if "object_type" in placed_obj and placed_obj.object_type == "Bomb":
if not _can_place_down_at(place_pos, placed_obj):
return
var bomb_name = placed_obj.name
held_object = null
grab_offset = Vector2.ZERO
is_lifting = false
is_pushing = false
placed_obj.queue_free()
if not attack_bomb_scene:
return
var bomb = attack_bomb_scene.instantiate()
get_parent().add_child(bomb)
bomb.global_position = place_pos
bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse starts in _deferred_ready
if multiplayer.has_multiplayer_peer():
bomb.set_multiplayer_authority(get_multiplayer_authority())
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_bomb_dropped", [bomb_name, place_pos])
print(name, " dropped bomb at ", place_pos, " (fuse lit)")
return
print("DEBUG: Attempting to place ", placed_obj.name if placed_obj else "null", " at ", place_pos, " (player at ", global_position, ", distance: ", global_position.distance_to(place_pos), ")")
@@ -3484,6 +3538,10 @@ func _perform_attack():
# Round to 1 decimal place
final_damage = round(final_damage * 10.0) / 10.0
# Track what we spawned so we only sync when we actually shot a projectile
var spawned_projectile_type: String = ""
var sync_arrow_name: String = "" # Stable name for arrow sync (multiplayer)
# Handle bow attacks - require arrows in off-hand
if is_bow:
# Check for arrows in off-hand
@@ -3493,12 +3551,19 @@ func _perform_attack():
if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION:
arrows = offhand_item
# Reset empty-bow counter when we have arrows
if arrows and arrows.quantity > 0:
empty_bow_shot_attempts = 0
# Only spawn arrow if we have arrows
if arrows and arrows.quantity > 0:
if attack_arrow_scene:
spawned_projectile_type = "arrow"
$SfxBowShoot.play()
var arrow_projectile = attack_arrow_scene.instantiate()
sync_arrow_name = "arrow_%d_%d" % [multiplayer.get_unique_id(), _arrow_spawn_seq]
_arrow_spawn_seq += 1
arrow_projectile.name = sync_arrow_name
get_parent().add_child(arrow_projectile)
# Spawn arrow 4 pixels in the direction player is looking
var arrow_spawn_pos = global_position + (attack_direction * 4.0)
@@ -3521,14 +3586,20 @@ func _perform_attack():
character_stats.character_changed.emit(character_stats)
print(name, " shot arrow! Arrows remaining: ", remaining)
else:
# Play bow animation but no projectile
# Play sound for trying to shoot without arrows
# Play bow animation but no projectile - DO NOT sync attack (no arrow spawned)
if has_node("SfxBowWithoutArrow"):
$SfxBowWithoutArrow.play()
print(name, " tried to shoot but has no arrows!")
# Track empty bow attempts; after 3, unequip bow and equip another weapon
empty_bow_shot_attempts += 1
if empty_bow_shot_attempts >= 3:
empty_bow_shot_attempts = 0
_unequip_bow_and_equip_other_weapon()
elif is_staff:
# Spawn staff projectile for staff weapons
if staff_projectile_scene and equipped_weapon:
spawned_projectile_type = "staff"
var projectile = staff_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_direction, self, final_damage, equipped_weapon)
@@ -3542,6 +3613,7 @@ func _perform_attack():
else:
# Spawn sword projectile for non-bow/staff weapons
if sword_projectile_scene:
spawned_projectile_type = "sword"
var projectile = sword_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_direction, self, final_damage)
@@ -3553,9 +3625,10 @@ func _perform_attack():
projectile.global_position = global_position + spawn_offset
print(name, " attacked with sword projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")")
# Sync attack over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage])
# Sync attack over network only when we actually spawned a projectile
if spawned_projectile_type != "" and multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var arrow_name_arg = sync_arrow_name if spawned_projectile_type == "arrow" else ""
_rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage, spawned_projectile_type, arrow_name_arg])
# Reset attack cooldown (instant if cooldown is 0)
if attack_cooldown > 0:
@@ -3564,6 +3637,64 @@ func _perform_attack():
can_attack = true
is_attacking = false
func _unequip_bow_and_equip_other_weapon():
# After 3 empty bow shots: unequip bow, equip another mainhand weapon if any, show messages
if not is_multiplayer_authority() or not character_stats:
return
var mainhand = character_stats.equipment.get("mainhand", null)
if not mainhand or mainhand.weapon_type != Item.WeaponType.BOW:
return
# Unequip bow (moves it to inventory)
character_stats.unequip_item(mainhand, true)
# Show "Bow unequipped" message
_show_equipment_message("Bow unequipped.")
# Find first mainhand weapon in inventory that is not a bow
var other_weapon = null
for i in range(character_stats.inventory.size()):
var it = character_stats.inventory[i]
if not it:
continue
if it.equipment_type != Item.EquipmentType.MAINHAND:
continue
if it.weapon_type == Item.WeaponType.BOW:
continue
other_weapon = it
break
if other_weapon:
character_stats.equip_item(other_weapon, -1)
_show_equipment_message("%s equipped." % other_weapon.item_name)
# Sync equipment/inventory to other clients (same as _on_character_changed)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var equipment_data = {}
for slot_name in character_stats.equipment.keys():
var item = character_stats.equipment[slot_name]
if item:
equipment_data[slot_name] = item.save()
else:
equipment_data[slot_name] = null
_rpc_to_ready_peers("_sync_equipment", [equipment_data])
var inventory_data = []
for item in character_stats.inventory:
if item:
inventory_data.append(item.save())
_rpc_to_ready_peers("_sync_inventory", [inventory_data])
_apply_appearance_to_sprites()
var other_name = other_weapon.item_name if other_weapon else "none"
print(name, " unequipped bow (no arrows x3); other weapon: ", other_name)
func _show_equipment_message(text: String):
# Local-only so the player who unequipped always sees it (host or client)
var chat_ui = get_tree().get_first_node_in_group("chat_ui")
if chat_ui and chat_ui.has_method("add_local_message"):
chat_ui.add_local_message("System", text)
func _create_bomb_object():
# Dwarf: Create interactable bomb object that can be lifted/thrown
if not is_multiplayer_authority():
@@ -3629,9 +3760,13 @@ func _create_bomb_object():
bomb_obj.on_grabbed(self)
# Immediately lift the bomb (Dwarf lifts it directly)
# Set is_lifting BEFORE calling _lift_object to prevent it from being reset
is_lifting = true
is_pushing = false
push_axis = _snap_to_8_directions(facing_direction_vector) if facing_direction_vector.length() > 0.1 else Vector2.DOWN
if "is_being_held" in bomb_obj:
bomb_obj.is_being_held = true
if "held_by_player" in bomb_obj:
bomb_obj.held_by_player = self
# Freeze the bomb
if "is_frozen" in bomb_obj:
@@ -3644,19 +3779,14 @@ func _create_bomb_object():
# Play lift animation
_set_animation("LIFT")
# Sync to network
# Sync bomb spawn to other clients so they see it when lifted, then sync grab state
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(bomb_obj)
var obj_name = bomb_obj.name
if obj_name != "" and is_instance_valid(bomb_obj) and bomb_obj.is_inside_tree():
_rpc_to_ready_peers("_sync_create_bomb_object", [obj_name, bomb_obj.global_position])
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting
print(name, " created bomb object! Remaining bombs: ", remaining)
# Sync grab to network
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(bomb_obj)
if obj_name != "":
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting
func _throw_bomb(_target_position: Vector2):
# Legacy function - kept for Human/Elf if needed, but Dwarf uses _create_bomb_object now
@@ -3787,10 +3917,9 @@ func _can_cast_spell_at(target_position: Vector2) -> bool:
if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y:
return false
# Check if it's a floor tile (grid value 1) or corridor (grid value 3)
# Allow casting on both floor and corridor tiles
# Check if it's floor (1), door (2), or corridor (3) - same as walkable
var grid_value = grid[tile_x][tile_y]
if grid_value != 1 and grid_value != 3:
if grid_value != 1 and grid_value != 2 and grid_value != 3:
return false
# Check if there's a wall between player and target using raycast
@@ -3811,7 +3940,9 @@ func _can_cast_spell_at(target_position: Vector2) -> bool:
return true
func _start_spell_charge_particles():
# Create particle system for spell charging
# Create particle system for spell charging (only if enabled)
if not use_spell_charge_particles:
return
if spell_charge_particles:
_stop_spell_charge_particles()
@@ -3821,8 +3952,8 @@ func _start_spell_charge_particles():
spell_charge_particle_timer = 0.0
func _update_spell_charge_particles(charge_progress: float):
# Update particle system based on charge progress
if not spell_charge_particles or not is_instance_valid(spell_charge_particles):
# Update particle system based on charge progress (skip if disabled)
if not use_spell_charge_particles or not spell_charge_particles or not is_instance_valid(spell_charge_particles):
return
var star_texture = load("res://assets/gfx/fx/magic/red_star.png")
@@ -3892,6 +4023,26 @@ func _stop_spell_charge_particles():
spell_charge_particles.queue_free()
spell_charge_particles = null
func _start_spell_charge_incantation():
# Play fire_charging on AnimationIncantation when starting spell charge
spell_incantation_fire_ready_shown = false
if has_node("AnimationIncantation"):
$AnimationIncantation.play("fire_charging")
func _update_spell_charge_incantation(charge_progress: float):
# Switch to fire_ready when fully charged (fire_charging already playing from start)
if not has_node("AnimationIncantation"):
return
if charge_progress >= 1.0 and not spell_incantation_fire_ready_shown:
$AnimationIncantation.play("fire_ready")
spell_incantation_fire_ready_shown = true
func _stop_spell_charge_incantation():
# Reset incantation when spell charge ends
spell_incantation_fire_ready_shown = false
if has_node("AnimationIncantation"):
$AnimationIncantation.play("idle")
func _apply_spell_charge_tint():
# Apply pulsing tint to all sprite layers when fully charged using shader parameters
# Pulse between original tint and spell charge tint
@@ -4129,6 +4280,7 @@ func _sync_spell_charge_start():
is_charging_spell = true
spell_charge_start_time = Time.get_ticks_msec() / 1000.0
_start_spell_charge_particles()
_start_spell_charge_incantation()
print(name, " (synced) started charging spell")
@rpc("any_peer", "reliable")
@@ -4138,6 +4290,7 @@ func _sync_spell_charge_end():
is_charging_spell = false
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
# Return to IDLE animation
@@ -4486,76 +4639,51 @@ func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, airborne: bo
shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3
@rpc("any_peer", "reliable")
func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float = 1.0):
# Sync attack to other clients
# Check if node still exists and is valid before processing
func _sync_attack(direction: int, attack_dir: Vector2, charge_percentage: float = 1.0, projectile_type: String = "sword", arrow_name: String = ""):
# Sync attack to other clients. Use projectile_type from authority (what was actually spawned),
# not equipment - fixes no-arrows and post-unequip bow desync.
if not is_inside_tree() or not is_instance_valid(self):
return
if not is_multiplayer_authority():
current_direction = direction as Direction
# Determine weapon type for animation and projectile
var equipped_weapon = null
var is_staff = false
var is_bow = false
if character_stats and character_stats.equipment.has("mainhand"):
equipped_weapon = character_stats.equipment["mainhand"]
if equipped_weapon:
if equipped_weapon.weapon_type == Item.WeaponType.STAFF:
is_staff = true
elif equipped_weapon.weapon_type == Item.WeaponType.BOW:
is_bow = true
# Set appropriate animation
if is_staff:
_set_animation("STAFF")
elif is_bow:
_set_animation("BOW")
else:
_set_animation("SWORD")
# Set animation from projectile_type (authority knows what they shot)
match projectile_type:
"staff":
_set_animation("STAFF")
"arrow":
_set_animation("BOW")
_:
_set_animation("SWORD")
# Delay before spawning projectile
await get_tree().create_timer(0.15).timeout
# Check again after delay - node might have been destroyed
if not is_inside_tree() or not is_instance_valid(self):
return
# Spawn appropriate projectile on client
if is_staff and staff_projectile_scene and equipped_weapon:
# Spawn only what authority actually spawned (ignore equipment)
if projectile_type == "staff" and staff_projectile_scene:
var equipped_weapon = character_stats.equipment.get("mainhand", null) if character_stats else null
var projectile = staff_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_dir, self, 20.0, equipped_weapon)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_dir * 10.0 # 10 pixels in front
projectile.setup(attack_dir, self, 20.0, equipped_weapon if equipped_weapon else null)
var spawn_offset = attack_dir * 10.0
projectile.global_position = global_position + spawn_offset
print(name, " performed synced staff attack!")
elif is_bow:
# For bow attacks, check if we have arrows (same logic as host)
var arrows = null
if character_stats and character_stats.equipment.has("offhand"):
var offhand_item = character_stats.equipment["offhand"]
if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION:
arrows = offhand_item
# Only spawn arrow if we have arrows (matches host behavior)
if arrows and arrows.quantity > 0:
if attack_arrow_scene:
var arrow_projectile = attack_arrow_scene.instantiate()
get_parent().add_child(arrow_projectile)
# Use charge percentage from sync (matches local player's arrow)
arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage)
print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)")
else:
# No arrows - just play animation, no projectile (matches host behavior)
print(name, " performed synced bow attack without arrows (no projectile)")
elif sword_projectile_scene:
elif projectile_type == "arrow" and attack_arrow_scene:
var arrow_projectile = attack_arrow_scene.instantiate()
if arrow_name != "":
arrow_projectile.name = arrow_name
get_parent().add_child(arrow_projectile)
arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage)
print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)")
elif (projectile_type == "sword" or projectile_type == "") and sword_projectile_scene:
var projectile = sword_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_dir, self)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_dir * 10.0 # 10 pixels in front
var spawn_offset = attack_dir * 10.0
projectile.global_position = global_position + spawn_offset
print(name, " performed synced attack!")
@@ -4575,9 +4703,54 @@ func _sync_bow_charge_end():
_clear_bow_charge_tint()
print(name, " (synced) ended charging bow")
@rpc("any_peer", "reliable")
func _sync_create_bomb_object(bomb_name: String, spawn_pos: Vector2):
# Sync Dwarf's lifted bomb spawn to other clients so they see it when held
if is_multiplayer_authority():
return
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world:
return
var entities_node = game_world.get_node_or_null("Entities")
if not entities_node:
return
if entities_node.get_node_or_null(bomb_name):
return # Already exists (e.g. duplicate RPC)
var interactable_scene = load("res://scenes/interactable_object.tscn") as PackedScene
if not interactable_scene:
return
var bomb_obj = interactable_scene.instantiate()
bomb_obj.name = bomb_name
bomb_obj.global_position = spawn_pos
if multiplayer.has_multiplayer_peer():
bomb_obj.set_multiplayer_authority(get_multiplayer_authority())
entities_node.add_child(bomb_obj)
bomb_obj.setup_bomb()
print(name, " (synced) created bomb object ", bomb_name, " at ", spawn_pos)
@rpc("any_peer", "reliable")
func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2):
# Sync Dwarf drop: free lifted bomb on clients, spawn attack_bomb with fuse lit
if not is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world")
var entities_node = game_world.get_node_or_null("Entities") if game_world else null
if entities_node and bomb_name.begins_with("BombObject_"):
var lifted = entities_node.get_node_or_null(bomb_name)
if lifted and is_instance_valid(lifted):
lifted.queue_free()
if not attack_bomb_scene:
return
var bomb = attack_bomb_scene.instantiate()
get_parent().add_child(bomb)
bomb.global_position = place_pos
bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse lit
if multiplayer.has_multiplayer_peer():
bomb.set_multiplayer_authority(get_multiplayer_authority())
print(name, " (synced) dropped bomb at ", place_pos)
@rpc("any_peer", "reliable")
func _sync_place_bomb(target_pos: Vector2):
# Sync bomb placement to other clients
# Sync bomb placement to other clients (Human/Elf)
if not is_multiplayer_authority():
if not attack_bomb_scene:
return
@@ -4593,24 +4766,23 @@ func _sync_place_bomb(target_pos: Vector2):
print(name, " (synced) placed bomb at ", target_pos)
@rpc("any_peer", "reliable")
func _sync_throw_bomb(bomb_pos: Vector2, throw_force: Vector2):
# Sync bomb throw to other clients
func _sync_throw_bomb(bomb_name: String, bomb_pos: Vector2, throw_force: Vector2):
# Sync bomb throw to other clients; free lifted bomb (BombObject_*) if it exists
if not is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world")
var entities_node = game_world.get_node_or_null("Entities") if game_world else null
if entities_node and bomb_name.begins_with("BombObject_"):
var lifted = entities_node.get_node_or_null(bomb_name)
if lifted and is_instance_valid(lifted):
lifted.queue_free()
if not attack_bomb_scene:
return
# Spawn bomb projectile at position
var bomb = attack_bomb_scene.instantiate()
get_parent().add_child(bomb)
bomb.global_position = bomb_pos
# Setup bomb with throw physics
bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown
# Make sure bomb sprite is visible
if bomb.has_node("Sprite2D"):
bomb.get_node("Sprite2D").visible = true
print(name, " (synced) threw bomb from ", bomb_pos)
@rpc("any_peer", "reliable")
@@ -5153,6 +5325,7 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
is_charging_spell = false
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
# Return to IDLE animation
@@ -5223,6 +5396,9 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
# Play damage animation
_set_animation("DAMAGE")
# Lock facing direction briefly so player can't change it while taking damage
damage_direction_lock_timer = damage_direction_lock_duration
# Only apply knockback if not burn damage
if not is_burn_damage:
# Calculate direction FROM attacker TO victim
@@ -5280,6 +5456,7 @@ func _die():
is_dead = true # Ensure flag is set
velocity = Vector2.ZERO
is_knocked_back = false
damage_direction_lock_timer = 0.0
# CRITICAL: Release any held object/player BEFORE dying to restore their collision layers
if held_object:
@@ -5417,6 +5594,7 @@ func _respawn():
velocity = Vector2.ZERO
is_knocked_back = false
is_airborne = false
damage_direction_lock_timer = 0.0
position_z = 0.0
velocity_z = 0.0