added more tomes

This commit is contained in:
2026-01-25 00:59:34 +01:00
parent 9ab4a13244
commit a95e22d2fa
79 changed files with 2429 additions and 337 deletions

View File

@@ -41,6 +41,7 @@ var just_grabbed_this_frame = false # Prevents immediate release bug - persists
var grab_released_while_lifting = false # Track if grab was released while lifting (for drop-on-second-press logic)
var grab_start_time = 0.0 # Time when we first grabbed (to detect quick tap)
var grab_tap_threshold = 0.2 # Seconds to distinguish tap from hold
var is_shielding: bool = false # True when holding grab with shield in offhand and nothing to grab/lift
var last_movement_direction = Vector2.DOWN # Track last direction for placing objects
var push_axis = Vector2.ZERO # Locked axis for pushing/pulling
var push_direction_locked: int = Direction.DOWN # Locked facing direction when pushing
@@ -86,6 +87,7 @@ var bow_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation
var bow_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse animation when fully charged
var original_sprite_tints: Dictionary = {} # Store original tint values for restoration
var spell_incantation_played: bool = false # Track if incantation sound has been played
var current_spell_element: String = "fire" # "fire" or "healing" for cursor/tint
var burn_debuff_timer: float = 0.0 # Timer for burn debuff
var burn_debuff_duration: float = 5.0 # Burn lasts 5 seconds
var burn_debuff_damage_per_second: float = 1.0 # 1 HP per second
@@ -96,6 +98,10 @@ 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 shield_block_cooldown_timer: float = 0.0 # After blocking, cannot block again until this reaches 0
var shield_block_cooldown_duration: float = 1.2 # Seconds before can block again
var shield_block_direction: Vector2 = Vector2.DOWN # Facing direction when we started blocking (locked)
var was_shielding_last_frame: bool = false # For detecting shield activate transition
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)
@@ -103,6 +109,8 @@ var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New
var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile
var attack_arrow_scene = preload("res://scenes/attack_arrow.tscn")
var flame_spell_scene = preload("res://scenes/attack_spell_flame.tscn") # Flame spell for Tome of Flames
var frostspike_spell_scene = preload("res://scenes/attack_spell_frostspike.tscn") # Frostspike for Tome of Frostspike
var healing_effect_scene = preload("res://scenes/healing_effect.tscn") # Visual effect for Tome of Healing
var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile
var blood_scene = preload("res://scenes/blood_clot.tscn")
@@ -135,6 +143,8 @@ var is_airborne: bool = false
@onready var sprite_eyelashes = $Sprite2DEyeLashes
@onready var sprite_addons = $Sprite2DAddons
@onready var sprite_headgear = $Sprite2DHeadgear
@onready var sprite_shield = $Sprite2DShield
@onready var sprite_shield_holding = $Sprite2DShieldHolding
@onready var sprite_weapon = $Sprite2DWeapon
@onready var cone_light = $ConeLight
@@ -478,6 +488,10 @@ func _duplicate_sprite_materials():
sprite_headgear.material = sprite_headgear.material.duplicate()
if sprite_weapon and sprite_weapon.material:
sprite_weapon.material = sprite_weapon.material.duplicate()
if sprite_shield and sprite_shield.material:
sprite_shield.material = sprite_shield.material.duplicate()
if sprite_shield_holding and sprite_shield_holding.material:
sprite_shield_holding.material = sprite_shield_holding.material.duplicate()
func _initialize_character_stats():
# Create character_stats if it doesn't exist
@@ -693,12 +707,15 @@ func _setup_player_appearance():
character_stats.equipment["offhand"] = starting_bomb
print("Dwarf player ", name, " spawned with 5 bombs")
# Give Human race starting spellbook (Tome of Flames)
# Give Human race (Wizard) starting spellbook (Tome of Flames) and Hat
if selected_race == "Human":
var starting_tome = ItemDatabase.create_item("tome_of_flames")
if starting_tome:
character_stats.equipment["offhand"] = starting_tome
print("Human player ", name, " spawned with Tome of Flames")
var starting_hat = ItemDatabase.create_item("hat")
if starting_hat:
character_stats.equipment["headgear"] = starting_hat
print("Human player ", name, " spawned with Tome of Flames and Hat")
# Randomize skin (human only for players)
# Weighted random: Human1 has highest chance, Human7 has lowest chance
@@ -1129,6 +1146,8 @@ func _apply_appearance_to_sprites():
_apply_weapon_color_replacements(sprite_weapon, equipped_weapon)
else:
_clear_weapon_color_replacements(sprite_weapon)
_update_shield_visibility()
# Appearance applied (verbose logging removed)
@@ -1458,6 +1477,10 @@ func _update_animation(delta):
sprite_addons.frame = frame_index
if sprite_headgear:
sprite_headgear.frame = frame_index
if sprite_shield:
sprite_shield.frame = frame_index
if sprite_shield_holding:
sprite_shield_holding.frame = frame_index
# Update weapon sprite - use BOW_STRING animation if charging bow
if sprite_weapon:
@@ -1511,6 +1534,10 @@ func _update_facing_from_mouse(mouse_direction: Vector2):
if is_pushing:
return
# Don't update if shielding (locked block direction)
if is_shielding:
return
# Don't update if direction is locked (during attack)
if direction_lock_timer > 0.0:
return
@@ -1699,7 +1726,7 @@ func _update_z_physics(delta):
# Apply to all sprite layers
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon]:
sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon]:
if sprite_layer:
sprite_layer.position.y = y_offset
if position_z > 0:
@@ -1729,6 +1756,28 @@ func _physics_process(delta):
if is_airborne:
_update_z_physics(delta)
# Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
if is_charging_spell:
var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time
var charge_progress = clamp(charge_time / spell_charge_duration, 0.0, 1.0)
spell_charge_particle_timer += delta
_update_spell_charge_particles(charge_progress)
_update_spell_charge_incantation(charge_progress)
if charge_progress >= 1.0:
spell_charge_tint_pulse_time += delta * spell_charge_tint_pulse_speed_charged
_apply_spell_charge_tint()
if not spell_incantation_played and has_node("SfxSpellIncantation"):
$SfxSpellIncantation.play()
spell_incantation_played = true
else:
spell_charge_tint_pulse_time = 0.0
_clear_spell_charge_tint()
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.stop()
spell_incantation_played = false
else:
spell_charge_tint_pulse_time = 0.0
if is_local_player and is_multiplayer_authority():
# Skip all input and logic if dead
if is_dead:
@@ -1759,37 +1808,10 @@ func _physics_process(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
var charge_progress = clamp(charge_time / spell_charge_duration, 0.0, 1.0)
# 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:
# Use much faster pulse speed when fully charged
spell_charge_tint_pulse_time += delta * spell_charge_tint_pulse_speed_charged
_apply_spell_charge_tint()
# Play incantation sound when fully charged (only once)
if not spell_incantation_played:
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.play()
spell_incantation_played = true
else:
spell_charge_tint_pulse_time = 0.0
_clear_spell_charge_tint()
# Stop incantation if not fully charged
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.stop()
spell_incantation_played = false
else:
# Reset pulse timer when not charging
spell_charge_tint_pulse_time = 0.0
if shield_block_cooldown_timer > 0.0:
shield_block_cooldown_timer -= delta
if shield_block_cooldown_timer <= 0.0:
shield_block_cooldown_timer = 0.0
# Update bow charge tint (when fully charged)
if is_charging_bow:
@@ -1881,9 +1903,15 @@ func _physics_process(delta):
break
if being_held_by_someone:
is_shielding = false
was_shielding_last_frame = false
_update_shield_visibility()
# Handle struggle mechanic
_handle_struggle(delta)
elif is_knocked_back:
is_shielding = false
was_shielding_last_frame = false
_update_shield_visibility()
# During knockback, no input control - just let velocity carry the player
# Apply friction to slow down knockback
velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
@@ -1895,6 +1923,9 @@ func _physics_process(delta):
_handle_movement(delta)
_handle_interactions()
else:
is_shielding = false
was_shielding_last_frame = false
_update_shield_visibility()
# Reset struggle when airborne
struggle_time = 0.0
struggle_direction = Vector2.ZERO
@@ -2100,8 +2131,10 @@ func _handle_input():
# Update full 360-degree facing direction for attacks (gamepad/keyboard input)
# Only update if mouse control is not active (i.e., mouse is outside window or using gamepad)
# Don't update if direction is locked (during attack)
if not is_pushing and (not mouse_control_active or input_device != -1) and direction_lock_timer <= 0.0:
# Don't update if direction is locked (during attack) or shielding
if is_shielding:
facing_direction_vector = shield_block_direction
elif not is_pushing and (not mouse_control_active or input_device != -1) and direction_lock_timer <= 0.0:
facing_direction_vector = input_vector.normalized()
elif direction_lock_timer > 0.0 and locked_facing_direction.length() > 0.0:
# Use locked direction during attack
@@ -2109,8 +2142,13 @@ func _handle_input():
# Update facing direction for animations (except when pushing - locked direction)
# Only update from movement input if mouse control is not active or using gamepad
# Don't update if direction is locked (during attack)
if not is_pushing and (not mouse_control_active or input_device != -1) and direction_lock_timer <= 0.0:
# Don't update if direction is locked (during attack) or shielding
if is_shielding:
var new_direction = _get_direction_from_vector(shield_block_direction) as Direction
if new_direction != current_direction:
current_direction = new_direction
_update_cone_light_rotation()
elif not is_pushing and (not mouse_control_active or input_device != -1) and direction_lock_timer <= 0.0:
var new_direction = _get_direction_from_vector(input_vector) as Direction
# Update direction and cone light rotation if changed
@@ -2164,6 +2202,12 @@ func _handle_input():
if push_direction_locked != current_direction:
current_direction = push_direction_locked as Direction
_update_cone_light_rotation()
elif is_shielding:
# Keep locked block direction when shielding and idle
var new_direction = _get_direction_from_vector(shield_block_direction) as Direction
if new_direction != current_direction:
current_direction = new_direction
_update_cone_light_rotation()
else:
if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL":
_set_animation("IDLE")
@@ -2192,6 +2236,7 @@ func _handle_input():
# Reduce speed by half when pushing/pulling
# Reduce speed by 50% when charging bow
# Reduce speed by 80% when charging spell (20% speed)
# Reduce speed to 60% when shielding
# Calculate speed with encumbrance penalty
var speed_multiplier = 1.0
if is_pushing:
@@ -2200,6 +2245,8 @@ func _handle_input():
speed_multiplier = 0.5
elif is_charging_spell:
speed_multiplier = 0.2 # 20% speed (80% reduction)
elif is_shielding:
speed_multiplier = 0.6 # 60% speed when blocking with shield
var base_speed = move_speed * speed_multiplier
var current_speed = base_speed
@@ -2262,6 +2309,25 @@ func _handle_interactions():
else:
grab_just_released = false
# Update is_shielding: hold grab with shield in offhand and nothing to grab/lift
var would_shield = (not is_dead and grab_button_down and _has_shield_in_offhand() \
and not held_object and not is_lifting and not is_pushing \
and not _has_nearby_grabbable() and not is_disarming)
if would_shield and shield_block_cooldown_timer > 0.0:
is_shielding = false
if has_node("SfxDenyActivateShield"):
$SfxDenyActivateShield.play()
elif would_shield:
if not was_shielding_last_frame:
shield_block_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized()
if has_node("SfxActivateShield"):
$SfxActivateShield.play()
is_shielding = true
else:
is_shielding = false
was_shielding_last_frame = is_shielding
_update_shield_visibility()
# Cancel bow charging if grab is pressed
if grab_just_pressed and is_charging_bow:
is_charging_bow = false
@@ -2275,17 +2341,23 @@ func _handle_interactions():
print(name, " cancelled bow charge")
# Check for spell casting (with Tome of Flames)
# Handle spell charging (Tome of Flames)
# Check for spell casting (Tome of Flames, Frostspike, or Healing)
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.SPELLBOOK:
if offhand_item.item_name == "Tome of Flames":
# Check for valid target position
var is_fire = offhand_item.item_name == "Tome of Flames"
var is_frost = offhand_item.item_name == "Tome of Frostspike"
var is_heal = offhand_item.item_name == "Tome of Healing"
if is_fire or is_frost or is_heal:
var game_world = get_tree().get_first_node_in_group("game_world")
var target_pos = Vector2.ZERO
if game_world and game_world.has_method("get_grid_locked_cursor_position"):
var heal_target: Node = null
if (is_fire or is_frost) and game_world and game_world.has_method("get_grid_locked_cursor_position"):
target_pos = game_world.get_grid_locked_cursor_position()
elif is_heal:
heal_target = _get_heal_target()
var has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null)
# Check if there's a grabbable object nearby - prioritize grabbing over spell casting
var nearby_grabbable = null
@@ -2307,133 +2379,95 @@ func _handle_interactions():
nearby_grabbable = body
break
# Only start charging spell if no grabbable object is nearby and not lifting/grabbing
if grab_just_pressed and not is_charging_spell and target_pos != Vector2.ZERO and not nearby_grabbable and not is_lifting and not held_object:
if grab_just_pressed and not is_charging_spell and has_valid_target and not nearby_grabbable and not is_lifting and not held_object:
is_charging_spell = true
current_spell_element = "healing" if is_heal else ("frost" if is_frost else "fire")
spell_charge_start_time = Time.get_ticks_msec() / 1000.0
spell_incantation_played = false # Reset flag when starting new charge
spell_incantation_played = false
_start_spell_charge_particles()
_start_spell_charge_incantation()
# Play spell charging sound (incantation plays when fully charged)
if has_node("SfxSpellCharge"):
$SfxSpellCharge.play()
# Sync spell charge start to other clients
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_start.rpc()
print(name, " started charging spell")
# Skip regular grab handling
print(name, " started charging spell (", current_spell_element, ")")
just_grabbed_this_frame = false
return
# Release spell
elif grab_just_released and is_charging_spell:
var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time
# Minimum charge time: 0.2 seconds, otherwise cancel
if charge_time < 0.2:
is_charging_spell = false
current_spell_element = "fire"
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
# Return to IDLE animation
_set_animation("IDLE")
# Stop spell charging sounds
if has_node("SfxSpellCharge"):
$SfxSpellCharge.stop()
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.stop()
# Sync spell charge end to other clients
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
print(name, " cancelled spell (released too quickly, need at least 0.2s)")
print(name, " cancelled spell (released too quickly)")
just_grabbed_this_frame = false
return
# Check if fully charged (1.0 seconds)
var is_fully_charged = charge_time >= spell_charge_duration
# Get target position again (in case it changed)
if game_world and game_world.has_method("get_grid_locked_cursor_position"):
if (is_fire or is_frost) and game_world and game_world.has_method("get_grid_locked_cursor_position"):
target_pos = game_world.get_grid_locked_cursor_position()
if is_heal:
heal_target = _get_heal_target()
has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null)
# Cast spell if fully charged (will find valid position if target is blocked)
if target_pos != Vector2.ZERO and is_fully_charged:
# Cast spell (will find closest valid position if target is blocked)
_cast_flame_spell(target_pos)
# Play FINISH_SPELL animation after casting
if has_valid_target and is_fully_charged:
if is_fire:
_cast_flame_spell(target_pos)
elif is_frost:
_cast_frostspike_spell(target_pos)
else:
_cast_heal_spell(heal_target)
_set_animation("FINISH_SPELL")
# Stop charging and clear tint (but let incantation sound finish)
is_charging_spell = false
current_spell_element = "fire"
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
_clear_spell_charge_tint()
if has_node("SfxSpellCharge"):
$SfxSpellCharge.stop()
# Don't stop SfxSpellIncantation - let it finish playing
# Sync spell charge end to other clients
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
else:
# Not fully charged or no target - just cancel without casting
print(name, " spell not cast (charge: ", charge_time, "s, fully charged: ", is_fully_charged, ", target: ", target_pos, ")")
# Stop charging and clear tint
print(name, " spell not cast (charge: ", charge_time, "s, fully: ", is_fully_charged, ", target ok: ", has_valid_target, ")")
is_charging_spell = false
current_spell_element = "fire"
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
_clear_spell_charge_tint()
_set_animation("IDLE")
# Stop spell charging sounds
if has_node("SfxSpellCharge"):
$SfxSpellCharge.stop()
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.stop()
# Sync spell charge end to other clients
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
print(name, " released spell (charge: ", charge_time, "s, fully charged: ", is_fully_charged, ")")
# Skip regular grab handling
print(name, " released spell (", "healing" if is_heal else ("frost" if is_frost else "fire"), ", fully: ", is_fully_charged, ")")
just_grabbed_this_frame = false
return
# Cancel if no target position available or if player starts lifting/grabbing
elif is_charging_spell and (target_pos == Vector2.ZERO or is_lifting or held_object):
elif is_charging_spell and (not has_valid_target or is_lifting or held_object):
is_charging_spell = false
current_spell_element = "fire"
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
# Return to IDLE animation
_set_animation("IDLE")
# Stop spell charging sounds
if has_node("SfxSpellCharge"):
$SfxSpellCharge.stop()
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.stop()
# Sync spell charge end to other clients
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
print(name, " spell charge cancelled (no target)")
# Check for trap disarm (Dwarf only)
@@ -2784,6 +2818,44 @@ func _get_nearby_disarmable_trap() -> Node:
return null
func _has_shield_in_offhand() -> bool:
if not character_stats or not character_stats.equipment.has("offhand"):
return false
var off = character_stats.equipment["offhand"]
return off != null and "shield" in off.item_name.to_lower()
func _has_nearby_grabbable() -> bool:
if not grab_area:
return false
var bodies = grab_area.get_overlapping_bodies()
for body in bodies:
if body == self:
continue
var is_grabbable = false
if body.has_method("can_be_grabbed"):
if body.can_be_grabbed():
is_grabbable = true
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D):
is_grabbable = true
if is_grabbable and position.distance_to(body.position) < grab_range:
return true
return false
func _update_shield_visibility() -> void:
if not sprite_shield or not sprite_shield_holding:
return
var has_shield = _has_shield_in_offhand()
if not has_shield:
sprite_shield.visible = false
sprite_shield_holding.visible = false
return
if is_shielding:
sprite_shield.visible = false
sprite_shield_holding.visible = true
else:
sprite_shield.visible = true
sprite_shield_holding.visible = false
func _try_grab():
if not grab_area:
return
@@ -3026,6 +3098,9 @@ func reset_grab_state():
grab_start_time = 0.0
grab_released_while_lifting = false
was_dragging_last_frame = false
is_shielding = false
was_shielding_last_frame = false
_update_shield_visibility()
# Reset to idle animation
if current_animation == "IDLE_HOLD" or current_animation == "RUN_HOLD" or current_animation == "LIFT" or current_animation == "IDLE_PUSH" or current_animation == "RUN_PUSH":
@@ -3411,6 +3486,7 @@ func _place_down_object():
if not attack_bomb_scene:
return
var bomb = attack_bomb_scene.instantiate()
bomb.name = "PlacedBomb_" + bomb_name
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
@@ -3813,8 +3889,10 @@ func _place_bomb(target_position: Vector2):
print(name, " cannot place bomb - no valid target position")
return
# Spawn bomb at target position
# Unique id for sync (collect/remove on other clients)
var bomb_id = "DirectBomb_%d_%d" % [Time.get_ticks_msec(), get_multiplayer_authority()]
var bomb = attack_bomb_scene.instantiate()
bomb.name = bomb_id
get_parent().add_child(bomb)
bomb.global_position = valid_target_pos
@@ -3827,7 +3905,7 @@ func _place_bomb(target_position: Vector2):
# Sync bomb spawn to other clients
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_place_bomb", [valid_target_pos])
_rpc_to_ready_peers("_sync_place_bomb", [bomb_id, valid_target_pos])
print(name, " placed bomb!")
@@ -3889,6 +3967,128 @@ func _sync_flame_spell(target_position: Vector2, spell_damage: float):
print(name, " (synced) spawned flame spell at ", target_position)
func _cast_frostspike_spell(target_position: Vector2):
if not frostspike_spell_scene:
return
if not is_multiplayer_authority():
return
var game_world = get_tree().get_first_node_in_group("game_world")
var valid_target_pos = target_position
if game_world and game_world.has_method("_get_valid_spell_target_position"):
var found_pos = game_world._get_valid_spell_target_position(target_position)
if found_pos != Vector2.ZERO:
valid_target_pos = found_pos
else:
print(name, " cannot cast frostspike - no valid target position")
return
var spell_damage = 15.0
if character_stats:
spell_damage = character_stats.damage * 0.75
var frost = frostspike_spell_scene.instantiate()
frost.setup(valid_target_pos, self, spell_damage, true)
get_parent().add_child(frost)
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_frostspike_spell", [valid_target_pos, spell_damage])
print(name, " cast frostspike at ", valid_target_pos)
@rpc("any_peer", "reliable")
func _sync_frostspike_spell(target_position: Vector2, spell_damage: float):
if is_multiplayer_authority():
return
if not frostspike_spell_scene:
return
var frost = frostspike_spell_scene.instantiate()
frost.setup(target_position, self, spell_damage, true)
get_parent().add_child(frost)
print(name, " (synced) spawned frostspike at ", target_position)
func _cast_heal_spell(target: Node):
if not target or not is_instance_valid(target):
return
if not character_stats:
return
var int_val = character_stats.baseStats.int + character_stats.get_pass("int")
var base_heal = 10.0
var amount = base_heal + int_val * 0.5
amount = max(1.0, floor(amount))
var cap = 0.0
if target.character_stats:
cap = target.character_stats.maxhp - target.character_stats.hp
amount = min(amount, max(0.0, cap))
if amount <= 0:
return
var me = multiplayer.get_unique_id()
var tid = target.get_multiplayer_authority()
if me == tid:
target.heal(amount)
_spawn_heal_effect_and_text(target, amount)
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_sync_heal_spell"):
_rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, amount])
print(name, " cast heal on ", target.name, " for ", int(amount), " HP")
func _spawn_heal_effect_and_text(target: Node, amount: float):
if not target or not is_instance_valid(target):
return
var game_world = get_tree().get_first_node_in_group("game_world")
var entities = game_world.get_node_or_null("Entities") if game_world else null
var parent = entities if entities else target.get_parent()
if not parent:
return
if healing_effect_scene:
var eff = healing_effect_scene.instantiate()
parent.add_child(eff)
eff.global_position = target.global_position
if eff.has_method("setup"):
eff.setup(target)
var floating_text_scene = preload("res://scenes/floating_text.tscn")
if floating_text_scene:
var ft = floating_text_scene.instantiate()
parent.add_child(ft)
ft.global_position = Vector2(target.global_position.x, target.global_position.y - 20)
ft.setup("+" + str(int(amount)) + " HP", Color.GREEN, 0.5, 0.5, null, 1, 1, 0)
@rpc("any_peer", "reliable")
func _sync_heal_spell_via_gw(target_name: String, amount: float):
if is_multiplayer_authority():
return
var gw = get_tree().get_first_node_in_group("game_world")
if gw and gw.has_method("_apply_heal_spell_sync"):
gw._apply_heal_spell_sync(target_name, amount)
func _is_healing_spell() -> bool:
if not character_stats or not character_stats.equipment.has("offhand"):
return false
var off = character_stats.equipment["offhand"]
return off != null and off.item_name == "Tome of Healing"
func _is_frost_spell() -> bool:
if not character_stats or not character_stats.equipment.has("offhand"):
return false
var off = character_stats.equipment["offhand"]
return off != null and off.item_name == "Tome of Frostspike"
func _get_heal_target() -> Node:
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.has_node("Camera2D"):
return null
var cam = game_world.get_node("Camera2D")
var mouse_world = cam.get_global_mouse_position()
const HEAL_RANGE: float = 56.0
var best: Node = null
var best_d: float = HEAL_RANGE
for p in get_tree().get_nodes_in_group("player"):
if not is_instance_valid(p):
continue
if "is_dead" in p and p.is_dead:
continue
var d = p.global_position.distance_to(mouse_world)
if d < best_d:
best_d = d
best = p
return best
func _can_cast_spell_at(target_position: Vector2) -> bool:
# Check if spell can be cast at target position
# Must be on floor tile and not blocked by walls
@@ -4024,17 +4224,25 @@ func _stop_spell_charge_particles():
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")
if _is_healing_spell():
$AnimationIncantation.play("healing_charging")
elif _is_frost_spell():
$AnimationIncantation.play("frost_charging")
else:
$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")
if _is_healing_spell():
$AnimationIncantation.play("healing_ready")
elif _is_frost_spell():
$AnimationIncantation.play("frost_ready")
else:
$AnimationIncantation.play("fire_ready")
spell_incantation_fire_ready_shown = true
func _stop_spell_charge_incantation():
@@ -4044,12 +4252,13 @@ func _stop_spell_charge_incantation():
$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
# IMPORTANT: Only apply to THIS player's sprites (not other players)
if not is_charging_spell:
return
var tint = spell_charge_tint
if _is_healing_spell():
tint = Color(0.25, 2.0, 0.3, 2.0) # Green pulse for healing
elif _is_frost_spell():
tint = Color(0.25, 0.5, 2.0, 2.0) # Blue pulse for frost
var sprites = [
{"sprite": sprite_body, "name": "body"},
{"sprite": sprite_boots, "name": "boots"},
@@ -4098,12 +4307,12 @@ func _apply_spell_charge_tint():
# Get original tint
var original_tint = original_sprite_tints.get(tint_key, Color.WHITE)
# Calculate fully charged tint (original * spell_charge_tint)
# Calculate fully charged tint (original * tint)
var full_charged_tint = Color(
original_tint.r * spell_charge_tint.r,
original_tint.g * spell_charge_tint.g,
original_tint.b * spell_charge_tint.b,
original_tint.a * spell_charge_tint.a
original_tint.r * tint.r,
original_tint.g * tint.g,
original_tint.b * tint.b,
original_tint.a * tint.a
)
# Interpolate between original and fully charged tint based on pulse
@@ -4285,15 +4494,13 @@ func _sync_spell_charge_start():
@rpc("any_peer", "reliable")
func _sync_spell_charge_end():
# Sync spell charge end to other clients
if not is_multiplayer_authority():
is_charging_spell = false
current_spell_element = "fire"
spell_incantation_played = false
_stop_spell_charge_particles()
_stop_spell_charge_incantation()
_clear_spell_charge_tint()
# Return to IDLE animation
_set_animation("IDLE")
# Stop spell charging sounds
@@ -4741,6 +4948,7 @@ func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2):
if not attack_bomb_scene:
return
var bomb = attack_bomb_scene.instantiate()
bomb.name = "PlacedBomb_" + bomb_name
get_parent().add_child(bomb)
bomb.global_position = place_pos
bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse lit
@@ -4749,20 +4957,17 @@ func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2):
print(name, " (synced) dropped bomb at ", place_pos)
@rpc("any_peer", "reliable")
func _sync_place_bomb(target_pos: Vector2):
func _sync_place_bomb(bomb_id: String, target_pos: Vector2):
# Sync bomb placement to other clients (Human/Elf)
if not is_multiplayer_authority():
if not attack_bomb_scene:
return
# Spawn bomb at target position
var bomb = attack_bomb_scene.instantiate()
bomb.name = bomb_id
get_parent().add_child(bomb)
bomb.global_position = target_pos
# Setup bomb without throw (placed directly)
bomb.setup(target_pos, self, Vector2.ZERO, false) # false = not thrown
print(name, " (synced) placed bomb at ", target_pos)
@rpc("any_peer", "reliable")
@@ -4778,6 +4983,7 @@ func _sync_throw_bomb(bomb_name: String, bomb_pos: Vector2, throw_force: Vector2
if not attack_bomb_scene:
return
var bomb = attack_bomb_scene.instantiate()
bomb.name = "ThrownBomb_" + bomb_name
get_parent().add_child(bomb)
bomb.global_position = bomb_pos
bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown
@@ -4785,6 +4991,16 @@ func _sync_throw_bomb(bomb_name: String, bomb_pos: Vector2, throw_force: Vector2
bomb.get_node("Sprite2D").visible = true
print(name, " (synced) threw bomb from ", bomb_pos)
@rpc("any_peer", "reliable")
func _sync_bomb_collected(bomb_name: String):
# Another peer collected this bomb remove our copy so it doesn't keep exploding
# Collector already removed and added to inventory locally; we just free our instance
var bombs = get_tree().get_nodes_in_group("attack_bomb")
for b in bombs:
if b.name == bomb_name and is_instance_valid(b):
b.queue_free()
return
@rpc("any_peer", "reliable")
func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_name: String):
# Sync throw to all clients (RPC sender already threw on their side)
@@ -5358,6 +5574,28 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
_rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, true]) # is_dodged = true
return # No damage taken, exit early
# Check for shield block (would have hit; enemy attack from blocked direction; no burn)
if not is_burn_damage and is_shielding and shield_block_cooldown_timer <= 0.0:
var dir_to_attacker = (attacker_position - global_position).normalized()
if dir_to_attacker.length() < 0.01:
dir_to_attacker = Vector2.RIGHT
var block_dir = shield_block_direction.normalized() if shield_block_direction.length() > 0.01 else Vector2.DOWN
var dot = block_dir.dot(dir_to_attacker)
if dot > 0.5: # Lenient: attacker in front (~60° cone)
# Blocked: no damage, small knockback, BLOCKED notification, cooldown
shield_block_cooldown_timer = shield_block_cooldown_duration
var direction_from_attacker = (global_position - attacker_position).normalized()
velocity = direction_from_attacker * 90.0 # Small knockback
is_knocked_back = true
knockback_time = 0.0
if has_node("SfxBlockWithShield"):
$SfxBlockWithShield.play()
_show_damage_number(0.0, attacker_position, false, false, false, true) # is_blocked = true
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, false, true])
print(name, " BLOCKED attack from direction ", dir_to_attacker)
return
# If taking damage while holding something, drop/throw immediately
if held_object:
if is_lifting:
@@ -5457,6 +5695,9 @@ func _die():
velocity = Vector2.ZERO
is_knocked_back = false
damage_direction_lock_timer = 0.0
is_shielding = false
was_shielding_last_frame = false
_update_shield_visibility()
# CRITICAL: Release any held object/player BEFORE dying to restore their collision layers
if held_object:
@@ -5528,7 +5769,7 @@ func _die():
fade_tween.set_parallel(true)
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon, shadow]:
sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]:
if sprite_layer:
fade_tween.tween_property(sprite_layer, "modulate:a", 0.0, 0.5)
@@ -5601,7 +5842,7 @@ func _respawn():
# Restore visibility (fade all sprite layers back in)
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon, shadow]:
sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]:
if sprite_layer:
sprite_layer.modulate.a = 1.0
@@ -5741,7 +5982,7 @@ func _sync_respawn(spawn_pos: Vector2):
# Restore visibility
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon, shadow]:
sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]:
if sprite_layer:
sprite_layer.modulate.a = 1.0
@@ -5867,7 +6108,7 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary):
"Human":
character_stats.setEars(0)
# Give Human starting spellbook (Tome of Flames) to remote players
# Give Human (Wizard) starting spellbook (Tome of Flames) and Hat to remote players
# (Authority players get this in _setup_player_appearance)
# Check if equipment is missing - give it regardless of whether race changed
if not is_multiplayer_authority():
@@ -5875,17 +6116,25 @@ func _sync_race_and_stats(race: String, base_stats: Dictionary):
if character_stats.equipment["offhand"] == null:
needs_equipment = true
else:
# Check if offhand is not Tome of Flames
var offhand = character_stats.equipment["offhand"]
if not offhand or offhand.item_name != "Tome of Flames":
needs_equipment = true
if character_stats.equipment["headgear"] == null:
needs_equipment = true
else:
var headgear = character_stats.equipment["headgear"]
if not headgear or headgear.item_name != "Hat":
needs_equipment = true
if needs_equipment:
var starting_tome = ItemDatabase.create_item("tome_of_flames")
if starting_tome:
character_stats.equipment["offhand"] = starting_tome
_apply_appearance_to_sprites()
print("Human player ", name, " (remote) received Tome of Flames via race sync")
var starting_hat = ItemDatabase.create_item("hat")
if starting_hat:
character_stats.equipment["headgear"] = starting_hat
_apply_appearance_to_sprites()
print("Human player ", name, " (remote) received Tome of Flames and Hat via race sync")
_:
character_stats.setEars(0)
@@ -5990,6 +6239,25 @@ func _sync_inventory(inventory_data: Array):
character_stats.character_changed.emit(character_stats)
print(name, " inventory synced: ", character_stats.inventory.size(), " items")
@rpc("any_peer", "reliable")
func _apply_inventory_and_equipment_from_server(inventory_data: Array, equipment_data: Dictionary):
# Joiner receives inventory+equipment push from server after loot pickup (or other server-driven change).
# Always apply no authority rejection. Used only when server adds items to a remote player.
if multiplayer.is_server():
return
if not character_stats:
return
character_stats.inventory.clear()
for item_data in inventory_data:
if item_data != null:
character_stats.inventory.append(Item.new(item_data))
for slot_name in character_stats.equipment.keys():
var item_data = equipment_data.get(slot_name, null)
character_stats.equipment[slot_name] = Item.new(item_data) if item_data != null else null
_apply_appearance_to_sprites()
character_stats.character_changed.emit(character_stats)
print(name, " inventory+equipment applied from server: ", character_stats.inventory.size(), " items")
func heal(amount: float):
if is_dead:
return
@@ -6030,9 +6298,9 @@ func _sync_keys(new_key_count: int):
keys = new_key_count
@rpc("authority", "reliable")
func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false):
func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false):
# Show damage number (red, using dmg_numbers.png font) above player
# Show even if amount is 0 for MISS/DODGED
# Show even if amount is 0 for MISS/DODGED/BLOCKED
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
@@ -6049,6 +6317,9 @@ func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool =
elif is_miss:
damage_label.label = "MISS"
damage_label.color = Color.GRAY
elif is_blocked:
damage_label.label = "BLOCKED"
damage_label.color = Color(0.4, 0.65, 1.0) # Light blue
else:
damage_label.label = str(int(amount))
damage_label.color = Color.ORANGE if is_crit else Color.RED
@@ -6138,13 +6409,23 @@ func _on_level_up_stats(stats_increased: Array):
base_y_offset -= y_spacing
@rpc("any_peer", "reliable")
func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false):
func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: bool = false):
# This RPC only syncs visual effects, not damage application
# (damage is already applied via rpc_take_damage)
if not is_multiplayer_authority():
# If dodged, only show dodge text, no other effects
if is_dodged:
_show_damage_number(0.0, attacker_position, false, false, true)
_show_damage_number(0.0, attacker_position, false, false, true, false)
return
# If blocked, show BLOCKED, small knockback, block sound; no damage effects
if is_blocked:
var block_knock_dir = (global_position - attacker_position).normalized()
velocity = block_knock_dir * 90.0
is_knocked_back = true
knockback_time = 0.0
if has_node("SfxBlockWithShield"):
$SfxBlockWithShield.play()
_show_damage_number(0.0, attacker_position, false, false, false, true)
return
# Play damage sound and effects (rate limited to prevent spam when tab becomes active)
@@ -6181,7 +6462,7 @@ func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = fa
tween.tween_property(sprite_body, "modulate", Color.WHITE, 0.1)
# Show damage number
_show_damage_number(_amount, attacker_position, is_crit, is_miss, false)
_show_damage_number(_amount, attacker_position, is_crit, is_miss, false, false)
func on_grabbed(by_player):
print(name, " grabbed by ", by_player.name)