From e7204b92d24cc83c2d3c723a01f3b2072a7942a7 Mon Sep 17 00:00:00 2001 From: Elrinth Date: Sun, 25 Jan 2026 18:28:30 +0100 Subject: [PATCH] made some adjustments to brights of rooms --- src/scenes/game_world.tscn | 2 +- src/scenes/player.tscn | 6 +- src/scripts/enemy_base.gd | 3 +- src/scripts/game_world.gd | 172 ++++++++-- src/scripts/off_screen_indicators.gd | 3 +- src/scripts/player.gd | 472 ++++++++++++++++----------- src/scripts/staff_projectile.gd | 12 +- src/scripts/sword_projectile.gd | 12 +- src/scripts/sword_slash.gd | 13 +- 9 files changed, 476 insertions(+), 219 deletions(-) diff --git a/src/scenes/game_world.tscn b/src/scenes/game_world.tscn index d1a7cc7..f5d7047 100644 --- a/src/scenes/game_world.tscn +++ b/src/scenes/game_world.tscn @@ -106,7 +106,7 @@ script = ExtResource("5") [node name="CanvasModulate" type="CanvasModulate" parent="." unique_id=948490815] light_mask = 1048575 visibility_layer = 1048575 -color = Color(0.4140625, 0.4140625, 0.4140625, 1) +color = Color(0.69140625, 0.69140625, 0.69140625, 1) [node name="AudioStreamPlayer2D" type="AudioStreamPlayer2D" parent="." unique_id=1141138343] stream = ExtResource("6_6c6v5") diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 0c79300..042ae1f 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -573,12 +573,12 @@ scale = Vector2(1.984375, 2.0937502) texture = SubResource("GradientTexture2D_wnwbv") [node name="ConeLight" type="PointLight2D" parent="." unique_id=120780131] -blend_mode = 2 +blend_mode = 0 shadow_enabled = true [node name="PointLight2D" type="PointLight2D" parent="." unique_id=1250823818] position = Vector2(-1, 0) -blend_mode = 2 +blend_mode = 0 shadow_enabled = true texture = SubResource("GradientTexture2D_f1ej7") @@ -745,7 +745,7 @@ panning_strength = 1.11 visible = false rotation = 3.1869712 energy = 0.13 -blend_mode = 2 +blend_mode = 0 shadow_enabled = true max_distance = 100.0 diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index dfa34cb..22762d7 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -478,7 +478,8 @@ func _show_damage_number(amount: float, from_position: Vector2, is_critical: boo damage_label.color = Color.GRAY else: damage_label.label = str(int(amount)) - damage_label.color = Color.ORANGE if is_critical else Color.RED + damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) # Bright orange / bright red + damage_label.z_index = 5 # Calculate direction from attacker (slight upward variation) var direction_from_attacker = (global_position - from_position).normalized() diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 04d85fb..280bd26 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -63,6 +63,15 @@ var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1) var cached_corridor_allowed_room_ids: Dictionary = {} var was_in_corridor: bool = false # Track previous corridor state to detect transitions var last_corridor_fog_update: float = 0.0 # Time of last fog update in corridor + +# Torch-based CanvasModulate: only recalc on room/corridor transition, lerp over time +var _torch_darken_initialized: bool = false +var _torch_darken_in_room_last: bool = false +var _torch_darken_last_room_id: String = "" +var _torch_darken_target_scale: float = 1.0 +var _torch_darken_current_scale: float = 1.0 +const _TORCH_DARKEN_LERP_SPEED: float = 4.0 +const _TORCH_DARKEN_MIN_SCALE: float = 0.15 # Never go below this; allows player light to punch through var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen) var combined_seen: PackedInt32Array = PackedInt32Array() var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored @@ -1127,7 +1136,7 @@ func _show_loot_floating_text(player: Node, text: String, color: Color, item_tex floating_text.global_position = Vector2(player.global_position.x, player.global_position.y - 20) floating_text.setup(text, color, 0.5, 0.5, item_texture, sprite_hframes, sprite_vframes, sprite_frame) -func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool): +func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool, is_revive: bool = false): var target: Node = null for p in get_tree().get_nodes_in_group("player"): if p.name == target_name and is_instance_valid(p): @@ -1137,28 +1146,38 @@ func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display return var me = multiplayer.get_unique_id() var tid = target.get_multiplayer_authority() - if me == tid and target.has_method("heal") and amount_to_apply > 0: - target.heal(amount_to_apply, allow_overheal) - var entities = get_node_or_null("Entities") - var parent = entities if entities else target.get_parent() - if not parent: - return - var eff_scene = load("res://scenes/healing_effect.tscn") as PackedScene - if eff_scene: - var eff = eff_scene.instantiate() - parent.add_child(eff) - eff.global_position = target.global_position - if eff.has_method("setup"): - eff.setup(target) - var prefix = "" - if is_crit and is_overheal: - prefix = "CRIT OVERHEAL! " - elif is_crit: - prefix = "CRIT! " - elif is_overheal: - prefix = "OVERHEAL! " - var heal_text = prefix + "+" + str(display_amount) + " HP" - _show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0) + if is_revive: + if me == tid and target.has_method("_revive_from_heal"): + target._revive_from_heal(display_amount) + elif tid != 0 and target.has_method("_revive_from_heal"): + target._revive_from_heal.rpc_id(tid, display_amount) + else: + if me == tid and target.has_method("heal") and amount_to_apply > 0: + target.heal(amount_to_apply, allow_overheal) + # When revive, target's authority already spawned effect+text in _revive_from_heal; skip to avoid double spawn + var skip_spawn = is_revive and me == tid + if not skip_spawn: + var entities = get_node_or_null("Entities") + var parent = entities if entities else target.get_parent() + if not parent: + pass + else: + var eff_scene = load("res://scenes/healing_effect.tscn") as PackedScene + if eff_scene: + var eff = eff_scene.instantiate() + parent.add_child(eff) + eff.global_position = target.global_position + if eff.has_method("setup"): + eff.setup(target) + var prefix = "" + if is_crit and is_overheal: + prefix = "CRIT OVERHEAL! " + elif is_crit: + prefix = "CRIT! " + elif is_overheal: + prefix = "OVERHEAL! " + var heal_text = prefix + "+" + str(display_amount) + " HP" + _show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0) @rpc("authority", "unreliable") func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int): @@ -1645,6 +1664,7 @@ func _process(delta): # Update camera to follow local players _update_camera() _update_fog_of_war(delta) + _update_canvas_modulate_by_torches() # Periodic cleanup of disconnected peers (server only) if multiplayer.is_server() and multiplayer.has_multiplayer_peer(): @@ -2124,7 +2144,7 @@ func _is_walkable_tile(tile_center: Vector2) -> bool: var v = grid[tile_x][tile_y] return v == 1 or v == 2 or v == 3 -func _get_adjacent_valid_spell_tile_centers(center_world_pos: Vector2, player_pos: Vector2) -> Array: +func _get_adjacent_valid_spell_tile_centers(center_world_pos: Vector2, _player_pos: Vector2) -> Array: var out: Array = [] if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("grid"): return out @@ -2172,6 +2192,12 @@ func _init_fog_of_war(): cached_corridor_allowed_room_ids.clear() was_in_corridor = false last_corridor_fog_update = 0.0 + # Reset torch-based darkening state for new level + _torch_darken_initialized = false + _torch_darken_in_room_last = false + _torch_darken_last_room_id = "" + _torch_darken_target_scale = 1.0 + _torch_darken_current_scale = 1.0 func _update_fog_of_war(delta: float) -> void: if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"): @@ -2450,6 +2476,104 @@ func _get_room_index_for_tile(tile: Vector2i) -> int: return i return -1 +func _count_torches_in_room(room: Dictionary) -> int: + if dungeon_data.is_empty() or not dungeon_data.has("torches"): + return 0 + var tile_size := 16 + var min_x: float = room.x * tile_size + var min_y: float = room.y * tile_size + var max_x: float = (room.x + room.w) * tile_size + var max_y: float = (room.y + room.h) * tile_size + var count := 0 + for t in dungeon_data.torches: + var pos: Vector2 = t.get("position", Vector2.ZERO) + if pos.x >= min_x and pos.x <= max_x and pos.y >= min_y and pos.y <= max_y: + count += 1 + return count + +func _median_torch_scale_from_rooms(rooms: Array) -> float: + if rooms.is_empty(): + return 0.0 + var counts: Array = [] + for r in rooms: + counts.append(_count_torches_in_room(r)) + counts.sort() + var n := counts.size() + var mid := int(n / 2.0) + var median: float + if n % 2 == 1: + median = float(counts[mid]) + else: + median = (float(counts[mid - 1]) + float(counts[mid])) * 0.5 + median = clampf(median, 0.0, 4.0) + return median / 4.0 + +func _update_canvas_modulate_by_torches() -> void: + if dungeon_data.is_empty() or not dungeon_data.has("torches"): + return + var cm = get_node_or_null("CanvasModulate") + if not cm or not is_instance_valid(cm): + return + var local_list = player_manager.get_local_players() if player_manager else [] + if local_list.is_empty() or not local_list[0]: + return + var p = local_list[0] + var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE)) + var current_room = _find_room_at_tile(p_tile) + var in_room := not current_room.is_empty() + var room_id := "" + if in_room: + room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h) + var transition := not _torch_darken_initialized + if _torch_darken_initialized: + if in_room != _torch_darken_in_room_last: + transition = true + elif in_room and room_id != _torch_darken_last_room_id: + transition = true + if transition: + _torch_darken_initialized = true + _torch_darken_in_room_last = in_room + _torch_darken_last_room_id = room_id + if in_room: + var tc = clampi(_count_torches_in_room(current_room), 0, 4) + _torch_darken_target_scale = tc / 4.0 + else: + _torch_darken_target_scale = _median_torch_scale_from_rooms(cached_corridor_rooms) + var delta := get_process_delta_time() + _torch_darken_current_scale = lerpf(_torch_darken_current_scale, _torch_darken_target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0)) + var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE) + cm.color = Color(s, s, s) + +func _reapply_torch_darkening() -> void: + if dungeon_data.is_empty() or not dungeon_data.has("torches"): + return + var cm = get_node_or_null("CanvasModulate") + if not cm or not is_instance_valid(cm): + return + var local_list = player_manager.get_local_players() if player_manager else [] + if local_list.is_empty() or not local_list[0]: + return + var p = local_list[0] + var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE)) + var current_room = _find_room_at_tile(p_tile) + var in_room := not current_room.is_empty() + var target: float + if in_room: + var tc = clampi(_count_torches_in_room(current_room), 0, 4) + target = tc / 4.0 + else: + target = _median_torch_scale_from_rooms(cached_corridor_rooms) + var t := maxf(target, _TORCH_DARKEN_MIN_SCALE) + _torch_darken_target_scale = target + _torch_darken_current_scale = target + var room_id := "" + if in_room: + room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h) + _torch_darken_initialized = true + _torch_darken_in_room_last = in_room + _torch_darken_last_room_id = room_id + cm.color = Color(t, t, t) + func _get_view_angle_weight(view_dir: Vector2, ray_dir: Vector2) -> float: if view_dir.length() < 0.1 or ray_dir.length() < 0.1: return 1.0 diff --git a/src/scripts/off_screen_indicators.gd b/src/scripts/off_screen_indicators.gd index 3bab46e..6496052 100644 --- a/src/scripts/off_screen_indicators.gd +++ b/src/scripts/off_screen_indicators.gd @@ -24,7 +24,7 @@ var _indicators: Array = [] # { position: Vector2, color: Color, name: String, var _font: Font = null func _ready() -> void: - _update_size() + call_deferred("_update_size") if get_viewport(): get_viewport().size_changed.connect(_update_size) _game_world = get_tree().get_first_node_in_group("game_world") @@ -38,6 +38,7 @@ func _update_size() -> void: if not vp: return var rect = vp.get_visible_rect() + set_anchors_preset(Control.PRESET_FULL_RECT) position = rect.position size = rect.size custom_minimum_size = rect.size diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 4f358b2..21c708c 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -44,6 +44,7 @@ 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 is_reviving: bool = false # True when holding grab on a corpse and charging revive var revive_charge: float = 0.0 +var was_reviving_last_frame: bool = false const REVIVE_DURATION: float = 2.0 # Seconds holding grab to revive var last_movement_direction = Vector2.DOWN # Track last direction for placing objects var push_axis = Vector2.ZERO # Locked axis for pushing/pulling @@ -1782,165 +1783,180 @@ func _physics_process(delta): else: spell_charge_tint_pulse_time = 0.0 + # Revive charge visuals - same as healing (healing_charging on AnimationIncantation) + if is_reviving: + was_reviving_last_frame = true + if has_node("AnimationIncantation") and not is_charging_spell: + $AnimationIncantation.play("healing_charging") + elif was_reviving_last_frame: + was_reviving_last_frame = false + _stop_spell_charge_incantation() + if is_local_player and is_multiplayer_authority(): - # Skip all input and logic if dead + # When dead: only corpse knockback friction + sync; no input or other logic if is_dead: - return - - # Handle knockback timer (always handle knockback, even when controls are disabled) - if is_knocked_back: - knockback_time += delta - if knockback_time >= knockback_duration: - is_knocked_back = false - knockback_time = 0.0 - - # Update movement lock timer (for bow release) - if movement_lock_timer > 0.0: - movement_lock_timer -= delta - if movement_lock_timer <= 0.0: - movement_lock_timer = 0.0 - - # Update direction lock timer (for attacks) - if direction_lock_timer > 0.0: - direction_lock_timer -= 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 - - 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: - var charge_time = (Time.get_ticks_msec() / 1000.0) - bow_charge_start_time - # Smooth curve: charge from 0.2s to 1.0s - var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s + if is_knocked_back: + knockback_time += delta + if knockback_time >= knockback_duration: + is_knocked_back = false + knockback_time = 0.0 + else: + velocity = velocity.lerp(Vector2.ZERO, delta * 8.0) + else: + # Handle knockback timer (always handle knockback, even when controls are disabled) + if is_knocked_back: + knockback_time += delta + if knockback_time >= knockback_duration: + is_knocked_back = false + knockback_time = 0.0 - # Update tint pulse timer when fully charged - if charge_progress >= 1.0: - # Use fast pulse speed when fully charged - bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged - _apply_bow_charge_tint() + # Update movement lock timer (for bow release) + if movement_lock_timer > 0.0: + movement_lock_timer -= delta + if movement_lock_timer <= 0.0: + movement_lock_timer = 0.0 + + # Update direction lock timer (for attacks) + if direction_lock_timer > 0.0: + direction_lock_timer -= 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 + + 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: + var charge_time = (Time.get_ticks_msec() / 1000.0) - bow_charge_start_time + # Smooth curve: charge from 0.2s to 1.0s + var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s + + # Update tint pulse timer when fully charged + if charge_progress >= 1.0: + # Use fast pulse speed when fully charged + bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged + _apply_bow_charge_tint() + else: + bow_charge_tint_pulse_time = 0.0 + _clear_bow_charge_tint() else: + # Reset pulse timer when not charging bow_charge_tint_pulse_time = 0.0 _clear_bow_charge_tint() - else: - # Reset pulse timer when not charging - bow_charge_tint_pulse_time = 0.0 - _clear_bow_charge_tint() - - # Update burn debuff (works on both authority and clients) - if burn_debuff_timer > 0.0: - burn_debuff_timer -= delta - # Only deal damage on authority (where we have authority) - if is_multiplayer_authority(): - burn_damage_timer += delta + # Update burn debuff (works on both authority and clients) + if burn_debuff_timer > 0.0: + burn_debuff_timer -= delta - # Deal burn damage every second (no knockback) - if burn_damage_timer >= 1.0: + # Only deal damage on authority (where we have authority) + if is_multiplayer_authority(): + burn_damage_timer += delta + + # Deal burn damage every second (no knockback) + if burn_damage_timer >= 1.0: + burn_damage_timer = 0.0 + # Deal burn damage directly (no knockback, no animation) + if character_stats: + var old_hp = character_stats.hp + character_stats.modify_health(-burn_debuff_damage_per_second) + if character_stats.hp <= 0: + character_stats.no_health.emit() + character_stats.character_changed.emit(character_stats) + var actual_damage = old_hp - character_stats.hp + print(name, " takes ", actual_damage, " burn damage! Health: ", character_stats.hp, "/", character_stats.maxhp) + # Show damage number for burn damage + _show_damage_number(actual_damage, global_position, false, false, false) # Show burn damage number + # Sync burn damage visual to other clients + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_damage", [actual_damage, global_position]) + + # Animate burn visual if it's a sprite (works on both authority and clients) + if burn_debuff_visual and is_instance_valid(burn_debuff_visual): + if burn_debuff_visual is Sprite2D: + var sprite = burn_debuff_visual as Sprite2D + var anim_timer = sprite.get_meta("burn_animation_timer", 0.0) + anim_timer += delta + if anim_timer >= 0.1: # ~10 FPS + anim_timer = 0.0 + var frame = sprite.get_meta("burn_animation_frame", 0) + frame = (frame + 1) % 16 + sprite.frame = frame + sprite.set_meta("burn_animation_frame", frame) + sprite.set_meta("burn_animation_timer", anim_timer) + + # Remove burn debuff when timer expires (works on both authority and clients) + if burn_debuff_timer <= 0.0: + burn_debuff_timer = 0.0 burn_damage_timer = 0.0 - # Deal burn damage directly (no knockback, no animation) - if character_stats: - var old_hp = character_stats.hp - character_stats.modify_health(-burn_debuff_damage_per_second) - if character_stats.hp <= 0: - character_stats.no_health.emit() - character_stats.character_changed.emit(character_stats) - var actual_damage = old_hp - character_stats.hp - print(name, " takes ", actual_damage, " burn damage! Health: ", character_stats.hp, "/", character_stats.maxhp) - # Show damage number for burn damage - _show_damage_number(actual_damage, global_position, false, false, false) # Show burn damage number - # Sync burn damage visual to other clients - if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - _rpc_to_ready_peers("_sync_damage", [actual_damage, global_position]) + _remove_burn_debuff() - # Animate burn visual if it's a sprite (works on both authority and clients) - if burn_debuff_visual and is_instance_valid(burn_debuff_visual): - if burn_debuff_visual is Sprite2D: - var sprite = burn_debuff_visual as Sprite2D - var anim_timer = sprite.get_meta("burn_animation_timer", 0.0) - anim_timer += delta - if anim_timer >= 0.1: # ~10 FPS - anim_timer = 0.0 - var frame = sprite.get_meta("burn_animation_frame", 0) - frame = (frame + 1) % 16 - sprite.frame = frame - sprite.set_meta("burn_animation_frame", frame) - sprite.set_meta("burn_animation_timer", anim_timer) + # Skip input if controls are disabled (e.g., when inventory is open) + # But still allow knockback to continue (handled above) + var skip_input = controls_disabled + if controls_disabled: + if not is_knocked_back: + # Immediately stop movement when controls are disabled (e.g., inventory opened) + velocity = Vector2.ZERO + # Reset animation to IDLE if not in a special state + if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": + if is_lifting: + _set_animation("IDLE_HOLD") + elif is_pushing: + _set_animation("IDLE_PUSH") + else: + _set_animation("IDLE") - # Remove burn debuff when timer expires (works on both authority and clients) - if burn_debuff_timer <= 0.0: - burn_debuff_timer = 0.0 - burn_damage_timer = 0.0 - _remove_burn_debuff() - - # Skip input if controls are disabled (e.g., when inventory is open) - # But still allow knockback to continue (handled above) - var skip_input = controls_disabled - if controls_disabled: - if not is_knocked_back: - # Immediately stop movement when controls are disabled (e.g., inventory opened) - velocity = Vector2.ZERO - # Reset animation to IDLE if not in a special state - if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": - if is_lifting: - _set_animation("IDLE_HOLD") - elif is_pushing: - _set_animation("IDLE_PUSH") - else: - _set_animation("IDLE") - - # Check if being held by someone - var being_held_by_someone = false - for other_player in get_tree().get_nodes_in_group("player"): - if other_player != self and other_player.held_object == self: - being_held_by_someone = true - being_held_by = other_player - 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) - elif not is_airborne and not skip_input: - # Normal input handling (only if controls are not disabled) - struggle_time = 0.0 # Reset struggle timer - struggle_direction = Vector2.ZERO - _handle_input() - _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 - - # Update held object positions - if is_lifting: - _update_lifted_object() - elif is_pushing: - _update_pushed_object() - - # Sync position, direction, and animation to other clients (unreliable broadcast) + # Check if being held by someone + var being_held_by_someone = false + for other_player in get_tree().get_nodes_in_group("player"): + if other_player != self and other_player.held_object == self: + being_held_by_someone = true + being_held_by = other_player + 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) + elif not is_airborne and not skip_input: + # Normal input handling (only if controls are not disabled) + struggle_time = 0.0 # Reset struggle timer + struggle_direction = Vector2.ZERO + _handle_input() + _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 + + # Update held object positions + if is_lifting: + _update_lifted_object() + elif is_pushing: + _update_pushed_object() + + # Sync position, direction, and animation to other clients (unreliable broadcast) # Only send RPC if we're in the scene tree and ready to send RPCs (prevents errors when player hasn't spawned on all clients yet) # On server, also wait for all clients to be ready if multiplayer.has_multiplayer_peer() and is_inside_tree() and can_send_rpcs: @@ -4035,7 +4051,7 @@ func _cast_heal_spell(target: Node): var dungeon_seed: int = 0 if gw and "dungeon_seed" in gw: dungeon_seed = gw.dungeon_seed - var seed_val = dungeon_seed + hash(target.name) + int(global_position.x) * 31 + int(global_position.y) * 17 + int(Time.get_ticks_msec() / 50) + var seed_val = dungeon_seed + hash(target.name) + int(global_position.x) * 31 + int(global_position.y) * 17 + int(Time.get_ticks_msec() / 50.0) var rng = RandomNumberGenerator.new() rng.seed = seed_val @@ -4052,30 +4068,41 @@ func _cast_heal_spell(target: Node): if is_crit: amount = floor(amount * 2.0) - var overheal_chance_pct = 1.0 + lck_val * 0.3 - var can_overheal = target.character_stats and target.character_stats.hp <= target.character_stats.maxhp - var is_overheal = can_overheal and rng.randf() * 100.0 < overheal_chance_pct - + var is_revive = "is_dead" in target and target.is_dead var display_amount = int(amount) var actual_heal = amount var allow_overheal = false - if is_overheal: - allow_overheal = true + var is_overheal = false + + if is_revive: + # Tome revive: no overheal, full amount revives + actual_heal = amount else: - var cap = 0.0 - if target.character_stats: - cap = target.character_stats.maxhp - target.character_stats.hp - actual_heal = min(amount, max(0.0, cap)) + var overheal_chance_pct = 1.0 + lck_val * 0.3 + var can_overheal = target.character_stats and target.character_stats.hp <= target.character_stats.maxhp + is_overheal = can_overheal and rng.randf() * 100.0 < overheal_chance_pct + if is_overheal: + allow_overheal = true + else: + var cap = 0.0 + if target.character_stats: + cap = target.character_stats.maxhp - target.character_stats.hp + actual_heal = min(amount, max(0.0, cap)) var me = multiplayer.get_unique_id() var tid = target.get_multiplayer_authority() - if me == tid and actual_heal > 0: - target.heal(actual_heal, allow_overheal) + if is_revive: + if me == tid: + target._revive_from_heal(display_amount) + elif multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + target._revive_from_heal.rpc_id(tid, display_amount) + else: + if me == tid and actual_heal > 0: + target.heal(actual_heal, allow_overheal) _spawn_heal_effect_and_text(target, display_amount, is_crit, is_overheal) - if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - if gw and gw.has_method("_apply_heal_spell_sync"): - _rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, actual_heal, display_amount, is_crit, is_overheal, allow_overheal]) - print(name, " cast heal on ", target.name, " for ", display_amount, " HP", " (actual: ", actual_heal, ", crit: ", is_crit, ", overheal: ", is_overheal, ")") + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree() and gw and gw.has_method("_apply_heal_spell_sync"): + _rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, actual_heal, display_amount, is_crit, is_overheal, allow_overheal, is_revive]) + print(name, " cast heal on ", target.name, " for ", display_amount, " HP", " (actual: ", actual_heal, ", crit: ", is_crit, ", overheal: ", is_overheal, ", revive: ", is_revive, ")") func _spawn_heal_effect_and_text(target: Node, display_amount: int, is_crit: bool, is_overheal: bool): if not target or not is_instance_valid(target): @@ -4107,12 +4134,12 @@ func _spawn_heal_effect_and_text(target: Node, display_amount: int, is_crit: boo ft.setup(heal_text, Color.GREEN, 0.5, 0.5, null, 1, 1, 0) @rpc("any_peer", "reliable") -func _sync_heal_spell_via_gw(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool): +func _sync_heal_spell_via_gw(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool, is_revive: bool = false): 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_to_apply, display_amount, is_crit, is_overheal, allow_overheal) + gw._apply_heal_spell_sync(target_name, amount_to_apply, display_amount, is_crit, is_overheal, allow_overheal, is_revive) func _is_healing_spell() -> bool: if not character_stats or not character_stats.equipment.has("offhand"): @@ -4138,8 +4165,6 @@ func _get_heal_target() -> Node: 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 @@ -5826,19 +5851,7 @@ func _die(): # Wait for DIE animation to complete (200+200+200+800 = 1400ms = 1.4s) await get_tree().create_timer(1.4).timeout - # Fade out over 0.5 seconds (fade all sprite layers) - var fade_tween = create_tween() - 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_shield, sprite_shield_holding, sprite_weapon, shadow]: - if sprite_layer: - fade_tween.tween_property(sprite_layer, "modulate:a", 0.0, 0.5) - - # Wait for fade to finish - await fade_tween.finished - - # Force holder to drop us NOW (after fade, before respawn) + # Force holder to drop us NOW (before respawn wait) # Search for any player holding us (don't rely on being_held_by) print(name, " searching for anyone holding us...") var found_holder = false @@ -5999,7 +6012,9 @@ func _respawn(): global_position = new_respawn_pos respawn_point = new_respawn_pos # Update respawn point for next time - # Play idle animation + # Clear concussion and play idle animation (server) + if status_anim and status_anim.has_animation("idle"): + status_anim.play("idle") _set_animation("IDLE") # Sync respawn over network (only authority sends) @@ -6049,7 +6064,10 @@ func _do_revive(corpse: Node): if character_stats: character_stats.hp = max(1, character_stats.hp - half_hp) character_stats.character_changed.emit(character_stats) + # Show -X HP on reviver (we "took" that much to revive) + _show_revive_cost_number(half_hp) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_revive_cost", [half_hp]) corpse._revive_from_player.rpc_id(corpse.get_multiplayer_authority(), half_hp) else: corpse._revive_from_player(half_hp) @@ -6074,6 +6092,42 @@ func _revive_from_player(hp_amount: int): if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") _set_animation("IDLE") + # Same healing effect as Tome of Healing (green frames, pulse, +X HP) + _spawn_heal_effect_and_text(self, hp_amount, false, false) + # Clear concussion on all clients (authority already did above; broadcast for others) + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) + +@rpc("any_peer", "reliable") +func _sync_revived_clear_concussion(_player_name: String): + # Received on each peer's copy of the revived player; clear our concussion (server + clients) + var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim and status_anim.has_animation("idle"): + status_anim.play("idle") + +@rpc("any_peer", "reliable") +func _revive_from_heal(hp_amount: int): + if not is_dead: + return + was_revived = true + is_dead = false + is_processing_death = false + if character_stats: + character_stats.hp = float(hp_amount) + else: + current_health = float(hp_amount) + for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, + sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, + sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: + if sprite_layer: + sprite_layer.modulate.a = 1.0 + var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim and status_anim.has_animation("idle"): + status_anim.play("idle") + _set_animation("IDLE") + _spawn_heal_effect_and_text(self, hp_amount, false, false) + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + _rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) @rpc("any_peer", "reliable") func _sync_death(): @@ -6098,11 +6152,16 @@ 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_shield, sprite_shield_holding, sprite_weapon, shadow]: + sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, + sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: if sprite_layer: sprite_layer.modulate.a = 1.0 + # Clear concussion on clients (AnimationPlayerStatus -> idle) + var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim and status_anim.has_animation("idle"): + status_anim.play("idle") + _set_animation("IDLE") func add_coins(amount: int): @@ -6440,7 +6499,8 @@ func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = 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 + damage_label.color = Color(1.0, 0.6, 0.2) if is_crit else Color(1.0, 0.35, 0.35) # Bright orange / bright red + damage_label.z_index = 5 # Calculate direction from attacker (slight upward variation) var direction_from_attacker = (global_position - from_position).normalized() @@ -6462,6 +6522,32 @@ func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = get_tree().current_scene.add_child(damage_label) damage_label.global_position = global_position + Vector2(0, -16) +func _show_revive_cost_number(amount: int): + var damage_number_scene = preload("res://scenes/damage_number.tscn") + if not damage_number_scene: + return + var damage_label = damage_number_scene.instantiate() + if not damage_label: + return + damage_label.label = "-" + str(amount) + " HP" + damage_label.color = Color(1.0, 0.35, 0.35) + damage_label.z_index = 5 + damage_label.direction = Vector2(0, -1) + var game_world = get_tree().get_first_node_in_group("game_world") + var parent = game_world.get_node_or_null("Entities") if game_world else get_parent() + if parent: + parent.add_child(damage_label) + damage_label.global_position = global_position + Vector2(0, -16) + else: + get_tree().current_scene.add_child(damage_label) + damage_label.global_position = global_position + Vector2(0, -16) + +@rpc("any_peer", "reliable") +func _sync_revive_cost(amount: int): + if is_multiplayer_authority(): + return + _show_revive_cost_number(amount) + func _on_level_up_stats(stats_increased: Array): # Show floating text for level up - "LEVEL UP!" and stat increases # Use damage_number scene with damage_numbers font @@ -6526,6 +6612,20 @@ func _on_level_up_stats(stats_increased: Array): stat_text.global_position = global_position + Vector2(0, base_y_offset) base_y_offset -= y_spacing +@rpc("any_peer", "reliable") +func rpc_apply_corpse_knockback(dir_x: float, dir_y: float, force: float): + if not is_multiplayer_authority(): + return + if not is_dead: + return + var d = Vector2(dir_x, dir_y) + if d.length_squared() < 0.01: + return + d = d.normalized() + velocity = d * force + is_knocked_back = true + knockback_time = 0.0 + @rpc("any_peer", "reliable") 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 diff --git a/src/scripts/staff_projectile.gd b/src/scripts/staff_projectile.gd index 8f5c2cd..1eeb129 100644 --- a/src/scripts/staff_projectile.gd +++ b/src/scripts/staff_projectile.gd @@ -232,4 +232,14 @@ func _on_body_entered(body): # Push the hit target away slightly (only for non-enemies) if body is CharacterBody2D and not body.is_in_group("enemy"): var knockback_dir = (body.global_position - global_position).normalized() - body.velocity = knockback_dir * 200.0 + if body.is_in_group("player") and "is_dead" in body and body.is_dead: + const CORPSE_KNOCKBACK: float = 50.0 + var pid = body.get_multiplayer_authority() + if pid == multiplayer.get_unique_id(): + body.rpc_apply_corpse_knockback(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + elif pid != 0: + body.rpc_apply_corpse_knockback.rpc_id(pid, knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + else: + body.rpc_apply_corpse_knockback.rpc(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + else: + body.velocity = knockback_dir * 200.0 diff --git a/src/scripts/sword_projectile.gd b/src/scripts/sword_projectile.gd index 4bf6da1..806b6a2 100644 --- a/src/scripts/sword_projectile.gd +++ b/src/scripts/sword_projectile.gd @@ -207,4 +207,14 @@ func _on_body_entered(body): # Push the hit target away slightly (only for non-enemies) if body is CharacterBody2D and not body.is_in_group("enemy"): var knockback_dir = (body.global_position - global_position).normalized() - body.velocity = knockback_dir * 200.0 + if body.is_in_group("player") and "is_dead" in body and body.is_dead: + const CORPSE_KNOCKBACK: float = 50.0 + var pid = body.get_multiplayer_authority() + if pid == multiplayer.get_unique_id(): + body.rpc_apply_corpse_knockback(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + elif pid != 0: + body.rpc_apply_corpse_knockback.rpc_id(pid, knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + else: + body.rpc_apply_corpse_knockback.rpc(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + else: + body.velocity = knockback_dir * 200.0 diff --git a/src/scripts/sword_slash.gd b/src/scripts/sword_slash.gd index ca3fe62..9a9f503 100644 --- a/src/scripts/sword_slash.gd +++ b/src/scripts/sword_slash.gd @@ -80,4 +80,15 @@ func _on_body_entered(body): # Push the hit target away slightly if body is CharacterBody2D: var knockback_dir = (body.global_position - global_position).normalized() - body.velocity = knockback_dir * 200.0 + if body.is_in_group("player") and "is_dead" in body and body.is_dead: + # Corpse: reduced force, sync to victim's client so they see the push + const CORPSE_KNOCKBACK: float = 50.0 + var pid = body.get_multiplayer_authority() + if pid == multiplayer.get_unique_id(): + body.rpc_apply_corpse_knockback(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + elif pid != 0: + body.rpc_apply_corpse_knockback.rpc_id(pid, knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + else: + body.rpc_apply_corpse_knockback.rpc(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) + else: + body.velocity = knockback_dir * 200.0