tried optimizing the game

This commit is contained in:
2026-02-07 01:58:30 +01:00
parent fa7e969363
commit e167451e03
28 changed files with 1233 additions and 752 deletions

View File

@@ -128,6 +128,16 @@ var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projecti
var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch
var attack_axe_swing_scene = preload("res://scenes/attack_axe_swing.tscn") # Axe swing (orbits)
var blood_scene = preload("res://scenes/blood_clot.tscn")
# Preload for _create_bomb_object so placing a bomb doesn't spike
const _INTERACTABLE_OBJECT_SCENE: PackedScene = preload("res://scenes/interactable_object.tscn")
# Cache appearance texture paths so _apply_appearance_to_sprites() doesn't load() every time (avoids ~29ms spike on equipment change)
const _APPEARANCE_TEXTURE_CACHE_MAX: int = 48
var _appearance_texture_cache: Dictionary = {}
var _appearance_texture_cache_order: Array = [] # FIFO keys for eviction
# Lazy cache for spell SFX (avoids has_node + $ every frame in _physics_process)
var _sfx_spell_incantation: Node = null
# Simulated Z-axis for height (when thrown)
var position_z: float = 0.0
@@ -1136,14 +1146,35 @@ func _setup_player_appearance_preserve_race():
print("Player ", name, " appearance re-initialized (preserved race=", selected_race, ")")
func _get_appearance_texture(path: String) -> Texture2D:
if path.is_empty():
return null
# Use global preloaded cache first (avoids 13ms+ spike on equip)
var global_cache = get_node_or_null("/root/AppearanceTextureCache")
if global_cache and global_cache.has_method("get_texture"):
var tex = global_cache.get_texture(path)
if tex:
return tex
# Fallback: local cache for paths not in preload (e.g. future content)
if _appearance_texture_cache.has(path):
return _appearance_texture_cache[path] as Texture2D
var t = load(path) as Texture2D
if t:
if _appearance_texture_cache_order.size() >= _APPEARANCE_TEXTURE_CACHE_MAX:
var old_key = _appearance_texture_cache_order.pop_front()
_appearance_texture_cache.erase(old_key)
_appearance_texture_cache[path] = t
_appearance_texture_cache_order.append(path)
return t
func _apply_appearance_to_sprites():
# Apply character_stats appearance to sprite layers
# Apply character_stats appearance to sprite layers (uses texture cache to avoid load() spikes)
if not character_stats:
return
# Body/Skin
if sprite_body and character_stats.skin != "":
var body_texture = load(character_stats.skin)
var body_texture = _get_appearance_texture(character_stats.skin)
if body_texture:
sprite_body.texture = body_texture
sprite_body.hframes = 35
@@ -1155,7 +1186,7 @@ func _apply_appearance_to_sprites():
var equipped_boots = character_stats.equipment["boots"]
# Only render boots if it's actually boots equipment (not a weapon or other type)
if equipped_boots and equipped_boots.equipment_type == Item.EquipmentType.BOOTS and equipped_boots.equipmentPath != "":
var boots_texture = load(equipped_boots.equipmentPath)
var boots_texture = _get_appearance_texture(equipped_boots.equipmentPath)
if boots_texture:
sprite_boots.texture = boots_texture
sprite_boots.hframes = 35
@@ -1174,7 +1205,7 @@ func _apply_appearance_to_sprites():
var equipped_armour = character_stats.equipment["armour"]
# Only render armour if it's actually armour equipment (not a weapon)
if equipped_armour and equipped_armour.equipment_type == Item.EquipmentType.ARMOUR and equipped_armour.equipmentPath != "":
var armour_texture = load(equipped_armour.equipmentPath)
var armour_texture = _get_appearance_texture(equipped_armour.equipmentPath)
if armour_texture:
sprite_armour.texture = armour_texture
sprite_armour.hframes = 35
@@ -1191,7 +1222,7 @@ func _apply_appearance_to_sprites():
# Facial Hair
if sprite_facial_hair:
if character_stats.facial_hair != "":
var facial_hair_texture = load(character_stats.facial_hair)
var facial_hair_texture = _get_appearance_texture(character_stats.facial_hair)
if facial_hair_texture:
sprite_facial_hair.texture = facial_hair_texture
sprite_facial_hair.hframes = 35
@@ -1214,7 +1245,7 @@ func _apply_appearance_to_sprites():
# Hair
if sprite_hair:
if character_stats.hairstyle != "":
var hair_texture = load(character_stats.hairstyle)
var hair_texture = _get_appearance_texture(character_stats.hairstyle)
if hair_texture:
sprite_hair.texture = hair_texture
sprite_hair.hframes = 35
@@ -1237,7 +1268,7 @@ func _apply_appearance_to_sprites():
# Eyes
if sprite_eyes:
if character_stats.eyes != "":
var eyes_texture = load(character_stats.eyes)
var eyes_texture = _get_appearance_texture(character_stats.eyes)
if eyes_texture:
sprite_eyes.texture = eyes_texture
sprite_eyes.hframes = 35
@@ -1256,7 +1287,7 @@ func _apply_appearance_to_sprites():
# Eyelashes
if sprite_eyelashes:
if character_stats.eye_lashes != "":
var eyelash_texture = load(character_stats.eye_lashes)
var eyelash_texture = _get_appearance_texture(character_stats.eye_lashes)
if eyelash_texture:
sprite_eyelashes.texture = eyelash_texture
sprite_eyelashes.hframes = 35
@@ -1275,7 +1306,7 @@ func _apply_appearance_to_sprites():
# Addons (ears, etc.)
if sprite_addons:
if character_stats.add_on != "":
var addon_texture = load(character_stats.add_on)
var addon_texture = _get_appearance_texture(character_stats.add_on)
if addon_texture:
sprite_addons.texture = addon_texture
sprite_addons.hframes = 35
@@ -1289,7 +1320,7 @@ func _apply_appearance_to_sprites():
if sprite_headgear:
var equipped_headgear = character_stats.equipment["headgear"]
if equipped_headgear and equipped_headgear.equipmentPath != "":
var headgear_texture = load(equipped_headgear.equipmentPath)
var headgear_texture = _get_appearance_texture(equipped_headgear.equipmentPath)
if headgear_texture:
sprite_headgear.texture = headgear_texture
sprite_headgear.hframes = 35
@@ -1413,8 +1444,6 @@ func _on_character_changed(_char: CharacterStats):
_rpc_to_ready_peers("_sync_equipment", [equipment_data])
# ALWAYS sync race and base stats to all clients (for proper display)
# This ensures new clients get appearance data even if they connect after initial setup
print("Player ", name, " (authority) SENDING _sync_race_and_stats to all peers: race='", character_stats.race, "'")
_rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()])
# Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players
@@ -1965,6 +1994,8 @@ func _physics_process(delta):
# Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
if is_charging_spell:
if _sfx_spell_incantation == null:
_sfx_spell_incantation = get_node_or_null("SfxSpellIncantation")
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
@@ -1973,14 +2004,14 @@ func _physics_process(delta):
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()
if not spell_incantation_played and _sfx_spell_incantation:
_sfx_spell_incantation.play()
spell_incantation_played = true
else:
spell_charge_tint_pulse_time = 0.0
_clear_spell_charge_tint()
if has_node("SfxSpellIncantation"):
$SfxSpellIncantation.stop()
if _sfx_spell_incantation:
_sfx_spell_incantation.stop()
spell_incantation_played = false
else:
spell_charge_tint_pulse_time = 0.0
@@ -2661,11 +2692,6 @@ func _handle_interactions():
grab_just_released = Input.is_action_just_released("grab") or (not mouse_right_pressed and was_mouse_right_pressed)
was_mouse_right_pressed = mouse_right_pressed
# DEBUG: Log button states if there's a conflict
if grab_just_pressed and grab_just_released:
print("DEBUG: WARNING - Both grab_just_pressed and grab_just_released are true!")
if grab_just_released and grab_button_down:
print("DEBUG: WARNING - grab_just_released=true but grab_button_down=true!")
else:
# Gamepad input
var button_currently_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_A)
@@ -2677,10 +2703,11 @@ func _handle_interactions():
else:
grab_just_released = false
# Update is_shielding: hold grab with shield in offhand and nothing to grab/lift
# One overlap query per frame; reuse for shield check and spell block below
var nearby_grabbable_body = _get_nearby_grabbable()
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)
and nearby_grabbable_body == null and not is_disarming)
if would_shield and shield_block_cooldown_timer > 0.0:
is_shielding = false
if has_node("SfxDenyActivateShield"):
@@ -2716,8 +2743,8 @@ func _handle_interactions():
print(name, " cancelled bow charge")
# Check for trap disarm FIRST (Dwarf only) - PRIORITY: disarm takes priority over spell casting
if character_stats and character_stats.race == "Dwarf":
# Check for trap disarm FIRST (Dwarf only) - only when grab involved to avoid get_nodes_in_group every frame
if character_stats and character_stats.race == "Dwarf" and (grab_just_pressed or grab_just_released or grab_button_down):
var nearby_trap = _get_nearby_disarmable_trap()
if nearby_trap:
# Check if we're currently disarming this trap
@@ -2738,18 +2765,14 @@ func _handle_interactions():
$SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
print(name, " cancelled spell charge to start disarming")
# Start disarming
is_disarming = true
nearby_trap.disarming_player = self
nearby_trap.disarm_progress = 0.0
print(name, " (Dwarf) started disarming trap")
elif grab_just_released and currently_disarming:
# Cancel disarm if released early
is_disarming = false
nearby_trap._cancel_disarm()
print(name, " (Dwarf) cancelled disarm")
elif not currently_disarming:
# Not disarming anymore - reset flag
is_disarming = false
@@ -2780,29 +2803,9 @@ func _handle_interactions():
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)
# Healing: allow charge even without target (don't disable charge when hovering enemy/wall/etc.)
# But prefer to have a target (player or enemy) when possible
var can_start_charge = is_heal or has_valid_target
# Check if there's a grabbable object nearby - prioritize grabbing over spell casting
var nearby_grabbable = null
if grab_area:
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:
var distance = position.distance_to(body.position)
if distance < grab_range:
nearby_grabbable = body
break
# Reuse grabbable from single query above (avoids second get_overlapping_bodies)
var nearby_grabbable = nearby_grabbable_body
if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object:
# Check if player has enough mana before starting to charge
@@ -2816,10 +2819,8 @@ func _handle_interactions():
has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost
if not has_enough_mana:
# Not enough mana - show message to local player only
if is_local_player:
_show_not_enough_mana_text()
print(name, " cannot start charging spell - not enough mana")
just_grabbed_this_frame = false
return
@@ -2833,7 +2834,6 @@ func _handle_interactions():
$SfxSpellCharge.play()
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_start.rpc()
print(name, " started charging spell (", current_spell_element, ")")
just_grabbed_this_frame = false
return
elif grab_just_released and is_charging_spell:
@@ -2852,7 +2852,6 @@ func _handle_interactions():
$SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
print(name, " cancelled spell (released too quickly)")
just_grabbed_this_frame = false
return
@@ -2882,8 +2881,6 @@ func _handle_interactions():
else:
_cast_heal_spell(heal_target)
else:
# Not enough mana - cancel spell
print(name, " cannot cast spell - not enough mana")
is_charging_spell = false
current_spell_element = "fire"
spell_incantation_played = false
@@ -2909,7 +2906,6 @@ func _handle_interactions():
if has_node("SfxSpellCharge"):
$SfxSpellCharge.stop()
else:
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
@@ -2923,7 +2919,6 @@ func _handle_interactions():
$SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc()
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
elif is_charging_spell and (is_lifting or held_object or ((not is_heal) and not has_valid_target)):
@@ -2966,7 +2961,8 @@ func _handle_interactions():
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 body.get("position_z", 0.0) <= 0.0:
is_grabbable = true
if is_grabbable:
var distance = position.distance_to(body.position)
@@ -3331,9 +3327,10 @@ func _has_shield_in_offhand() -> bool:
var off = character_stats.equipment["offhand"]
return off != null and "shield" in off.item_name.to_lower()
func _has_nearby_grabbable() -> bool:
func _get_nearby_grabbable() -> Node:
# Single overlap query; call once per frame and reuse result (avoids 2x get_overlapping_bodies in _handle_interactions)
if not grab_area:
return false
return null
var bodies = grab_area.get_overlapping_bodies()
for body in bodies:
if body == self:
@@ -3343,10 +3340,14 @@ func _has_nearby_grabbable() -> bool:
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 body.get("position_z", 0.0) <= 0.0:
is_grabbable = true
if is_grabbable and position.distance_to(body.position) < grab_range:
return true
return false
return body
return null
func _has_nearby_grabbable() -> bool:
return _get_nearby_grabbable() != null
func _update_shield_visibility() -> void:
if not sprite_shield or not sprite_shield_holding:
@@ -3382,9 +3383,10 @@ func _try_grab():
if body.has_method("can_be_grabbed"):
if body.can_be_grabbed():
is_grabbable = true
# Also allow grabbing other players
# Also allow grabbing other players (not when they're mid-air / thrown)
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D):
is_grabbable = true
if body.get("position_z", 0.0) <= 0.0:
is_grabbable = true
if is_grabbable:
var distance = position.distance_to(body.position)
@@ -3823,8 +3825,6 @@ func _throw_object():
var obj_name = _get_object_name_for_sync(thrown_obj)
_rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name])
print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force)
func _force_throw_held_object(direction: Vector2):
if not held_object or not is_lifting:
return
@@ -4059,12 +4059,9 @@ func _place_down_object():
if placed_obj.has_method("on_released"):
placed_obj.on_released(self)
# Sync place down over network
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(placed_obj)
_rpc_to_ready_peers("_sync_place_down", [obj_name, place_pos])
print("Placed down ", placed_obj.name, " at ", place_pos)
func _perform_attack():
if not can_attack or is_attacking or spawn_landing or netted_by_web:
@@ -4129,7 +4126,6 @@ func _perform_attack():
var is_crit = randf() < crit_chance
if is_crit:
final_damage *= 2.0 # Critical strikes deal 2x damage
print(name, " CRITICAL STRIKE! (LCK: ", character_stats.baseStats.lck + character_stats.get_pass("lck"), ")")
# Round to 1 decimal place
final_damage = round(final_damage * 10.0) / 10.0
@@ -4170,22 +4166,17 @@ func _perform_attack():
$SfxBowShoot.play()
# Consume one arrow
arrows.quantity -= 1
var remaining = arrows.quantity
if arrows.quantity <= 0:
# Remove arrows if quantity reaches 0
character_stats.equipment["offhand"] = null
if character_stats:
character_stats.character_changed.emit(character_stats)
else:
# Update equipment to reflect quantity change
if character_stats:
character_stats.character_changed.emit(character_stats)
print(name, " shot arrow! Arrows remaining: ", remaining)
else:
# 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
@@ -4202,10 +4193,8 @@ func _perform_attack():
# Store crit status for visual feedback
if is_crit:
projectile.set_meta("is_crit", true)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_direction * 6.0
projectile.global_position = global_position + spawn_offset
print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")")
elif is_axe:
# Axe swing - stays on player, plays directional animation
if attack_axe_swing_scene and equipped_weapon:
@@ -4214,7 +4203,6 @@ func _perform_attack():
get_parent().add_child(axe_swing)
axe_swing.setup(attack_direction, self, -1.0, equipped_weapon)
axe_swing.global_position = global_position
print(name, " axe swing! Damage: ", final_damage)
elif is_unarmed:
# Unarmed punch - low STR-based damage (enemy armour mitigates via take_damage)
if attack_punch_scene:
@@ -4228,7 +4216,6 @@ func _perform_attack():
get_parent().add_child(punch)
punch.setup(attack_direction, self, punch_damage)
punch.global_position = global_position + attack_direction * 12.0
print(name, " punched! Damage: ", punch_damage)
else:
# Spawn sword projectile for non-bow/staff/axe weapons
if sword_projectile_scene:
@@ -4239,10 +4226,8 @@ func _perform_attack():
# Store crit status for visual feedback
if is_crit:
projectile.set_meta("is_crit", true)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_direction * 6.0 # 10 pixels in front
var spawn_offset = attack_direction * 6.0
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 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():
@@ -4334,21 +4319,13 @@ func _create_bomb_object():
if character_stats:
character_stats.character_changed.emit(character_stats)
# Load interactable object scene
var interactable_object_scene = load("res://scenes/interactable_object.tscn")
if not interactable_object_scene:
push_error("ERROR: Could not load interactable_object scene!")
return
# Spawn bomb object at player position
var entities_node = get_parent()
if not entities_node:
entities_node = get_tree().get_first_node_in_group("game_world").get_node_or_null("Entities")
if not entities_node:
push_error("ERROR: Could not find Entities node!")
return
var bomb_obj = interactable_object_scene.instantiate()
var bomb_obj = _INTERACTABLE_OBJECT_SCENE.instantiate()
bomb_obj.name = "BombObject_" + str(Time.get_ticks_msec())
bomb_obj.global_position = global_position + (facing_direction_vector * 8.0) # Spawn slightly in front
@@ -6597,7 +6574,6 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
func _die():
# Already processing death - prevent multiple concurrent death sequences
if is_processing_death:
print(name, " already processing death, ignoring duplicate call")
return
is_processing_death = true # Set IMMEDIATELY to block duplicates
@@ -6641,20 +6617,19 @@ func _die():
var obj_name = _get_object_name_for_sync(released_obj)
_rpc_to_ready_peers("_sync_release", [obj_name])
print(name, " released ", released_obj.name, " on death")
pass # released on death
else:
is_lifting = false
is_pushing = false
print(name, " died!")
# Show concussion status effect above head
var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus")
if status_anim and status_anim.has_animation("concussion"):
status_anim.play("concussion")
# Play death sound effect
if sfx_die:
# Play death sound effect and spawn blood (preloaded blood_scene; add_child is cheaper than 12x call_deferred)
var death_parent = get_parent()
if sfx_die and death_parent:
for i in 12:
var angle = randf_range(0, TAU)
var speed = randf_range(50, 100)
@@ -6662,12 +6637,12 @@ func _die():
var b = blood_scene.instantiate() as CharacterBody2D
b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2))
b.global_position = global_position
# Set initial velocities from the synchronized data
var direction = Vector2.from_angle(angle)
b.velocity = direction * speed
b.velocityZ = initial_velocityZ
get_parent().call_deferred("add_child", b)
death_parent.add_child(b)
sfx_die.play()
elif sfx_die:
sfx_die.play()
# Play DIE animation
@@ -6682,11 +6657,9 @@ func _die():
# 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
for other_player in get_tree().get_nodes_in_group("player"):
if other_player != self and other_player.held_object == self:
print(name, " FOUND holder: ", other_player.name, "! Clearing locally and syncing via RPC")
# Clear LOCALLY first
other_player.held_object = null