diff --git a/src/project.godot b/src/project.godot index 06bad13..334c8f0 100644 --- a/src/project.godot +++ b/src/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="MultiplayerCoop" -run/main_scene="res://scenes/main_menu.tscn" +run/main_scene="res://scenes/loader.tscn" config/features=PackedStringArray("4.6", "Forward Plus") run/max_fps=60 boot_splash/bg_color=Color(0.09375, 0.09375, 0.09375, 1) @@ -26,6 +26,7 @@ buses/default_bus_layout="uid://psistrevppd1" GameState="*res://scripts/game_state.gd" NetworkManager="*res://scripts/network_manager.gd" LogManager="*res://scripts/log_manager.gd" +AppearanceTextureCache="*res://scripts/appearance_texture_cache.gd" [display] @@ -91,6 +92,10 @@ inventory={ ] } +[input_devices] + +pointing/emulate_mouse_from_touch=false + [layer_names] 2d_physics/layer_1="Player" diff --git a/src/scenes/attack_web_shot.tscn b/src/scenes/attack_web_shot.tscn index 864e17d..fa28289 100644 --- a/src/scenes/attack_web_shot.tscn +++ b/src/scenes/attack_web_shot.tscn @@ -2,7 +2,6 @@ [ext_resource type="Script" uid="uid://bibyqdhticm5i" path="res://scripts/attack_web_shot.gd" id="1_script"] [ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"] -[ext_resource type="AudioStreamWAV" path="res://assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav" id="3_webbed"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_web"] size = Vector2(8, 8) @@ -23,5 +22,4 @@ frame = 568 shape = SubResource("RectangleShape2D_web") [node name="SfxWebbed" type="AudioStreamPlayer2D" parent="." unique_id=817117841] -stream = ExtResource("3_webbed") bus = &"Sfx" diff --git a/src/scenes/game_world.tscn b/src/scenes/game_world.tscn index 1499ea0..282357a 100644 --- a/src/scenes/game_world.tscn +++ b/src/scenes/game_world.tscn @@ -218,7 +218,6 @@ script = ExtResource("5") [node name="CanvasModulate" type="CanvasModulate" parent="." unique_id=948490815] light_mask = 1048575 visibility_layer = 1048575 -color = Color(0.69140625, 0.69140625, 0.69140625, 1) [node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465] stream = ExtResource("6_6c6v5") diff --git a/src/scenes/loader.tscn b/src/scenes/loader.tscn new file mode 100644 index 0000000..de9f55c --- /dev/null +++ b/src/scenes/loader.tscn @@ -0,0 +1,77 @@ +[gd_scene format=3 uid="uid://b8loader01"] + +[ext_resource type="Script" path="res://scripts/loader.gd" id="1_script"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bg"] +bg_color = Color(0.08, 0.08, 0.1, 0.98) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[node name="Loader" type="Node"] +script = ExtResource("1_script") + +[node name="CanvasLayer" type="CanvasLayer" parent="."] +layer = 100 + +[node name="ColorRect" type="ColorRect" parent="CanvasLayer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.06, 0.06, 0.08, 1) + +[node name="MarginContainer" type="MarginContainer" parent="CanvasLayer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 80.0 +offset_top = 80.0 +offset_right = -80.0 +offset_bottom = -80.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 40 +theme_override_constants/margin_top = 40 +theme_override_constants/margin_right = 40 +theme_override_constants/margin_bottom = 40 + +[node name="VBox" type="VBoxContainer" parent="CanvasLayer/MarginContainer"] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -200.0 +offset_top = -60.0 +offset_right = 200.0 +offset_bottom = 60.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 24 + +[node name="TitleLabel" type="Label" parent="CanvasLayer/MarginContainer/VBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.85, 0.85, 0.9, 1) +theme_override_font_sizes/font_size = 28 +text = "Loading..." +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="ProgressBar" type="ProgressBar" parent="CanvasLayer/MarginContainer/VBox"] +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 +max_value = 100.0 +show_percentage = false +value = 0.0 + +[node name="StatusLabel" type="Label" parent="CanvasLayer/MarginContainer/VBox"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.6, 0.6, 0.7, 1) +theme_override_font_sizes/font_size = 16 +text = "Preparing..." +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/src/scripts/appearance_texture_cache.gd b/src/scripts/appearance_texture_cache.gd new file mode 100644 index 0000000..944d723 --- /dev/null +++ b/src/scripts/appearance_texture_cache.gd @@ -0,0 +1,28 @@ +extends Node + +# Autoload: preloads all equipment and character appearance textures at startup +# so equip_item / _apply_appearance_to_sprites don't spike (load() is expensive). + +var _cache: Dictionary = {} # path -> Texture2D + +func get_texture(path: String) -> Texture2D: + if path.is_empty(): + return null + if _cache.has(path): + return _cache[path] as Texture2D + if not ResourceLoader.exists(path): + return null + var t = load(path) as Texture2D + if t: + _cache[path] = t + return t + +func preload_all() -> void: + var paths: Array = ItemDatabase.get_all_equipment_and_appearance_paths() + for path in paths: + if path is String and path != "" and not _cache.has(path): + if not ResourceLoader.exists(path): + continue + var t = load(path) as Texture2D + if t: + _cache[path] = t diff --git a/src/scripts/appearance_texture_cache.gd.uid b/src/scripts/appearance_texture_cache.gd.uid new file mode 100644 index 0000000..bda83ba --- /dev/null +++ b/src/scripts/appearance_texture_cache.gd.uid @@ -0,0 +1 @@ +uid://drgacstnmn67x diff --git a/src/scripts/attack_arrow.gd b/src/scripts/attack_arrow.gd index 6c2373e..bd48df2 100644 --- a/src/scripts/attack_arrow.gd +++ b/src/scripts/attack_arrow.gd @@ -84,8 +84,6 @@ func shoot(shoot_direction: Vector2, start_pos: Vector2, owner_player: Node = nu # Flight duration: 50% charge = 0.5s, 100% charge = 2.5s max_flight_duration = lerp(0.5, 2.5, (charge_percentage - 0.5) * 2.0) - - print("Arrow shot at ", charge_percentage * 100, "% charge (speed: ", speed, ", flight duration: ", max_flight_duration, "s)") # Called every frame. 'delta' is the e lapsed time since the previous frame. func _process(delta: float) -> void: diff --git a/src/scripts/attack_bomb.gd b/src/scripts/attack_bomb.gd index dc4a5d2..2bb28a5 100644 --- a/src/scripts/attack_bomb.gd +++ b/src/scripts/attack_bomb.gd @@ -46,12 +46,16 @@ const FALLOUT_SINK_DURATION: float = 0.5 @onready var fuse_light = $FuseLight @onready var explosion_light = $ExplosionLight +# Preloaded to avoid load() stalls in-game and on WebAssembly +const _EXPLOSION_TILE_PARTICLE_SCENE: PackedScene = preload("res://scenes/explosion_tile_particle.tscn") +const _DUNGEON_TILESET_TEXTURE: Texture2D = preload("res://assets/gfx/RPG DUNGEON VOL 3.png") +const _FLOATING_TEXT_SCENE: PackedScene = preload("res://scenes/floating_text.tscn") + # Damage area (larger than collision) var damage_area_shape: CircleShape2D = null const TILE_SIZE: int = 16 const TILE_STRIDE: int = 17 # 16 + separation 1 -var _explosion_tile_particle_scene: PackedScene = null func _ready(): # Set collision layer to 2 (interactable objects) so it can be grabbed @@ -414,22 +418,19 @@ func _deal_explosion_damage(): body.take_damage(final_damage, attacker_pos) print("Bomb hit interactable: ", body.name, " for ", final_damage, " damage!") +const MAX_EXPLOSION_TILE_PARTICLES: int = 24 # Cap so one bomb doesn't spawn 50+ (cheap selection by distance) + func _spawn_explosion_tile_particles(): var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: return - var layer = game_world.get_node_or_null("Environment/DungeonLayer0") if not layer or not layer is TileMapLayer: return - - if not _explosion_tile_particle_scene: - _explosion_tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene - if not _explosion_tile_particle_scene: - return - - var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D - if not tex: + var parent = get_parent() + if not parent: + parent = game_world.get_node_or_null("Entities") + if not parent: return var center = global_position @@ -438,60 +439,54 @@ func _spawn_explosion_tile_particles(): var center_cell = layer.local_to_map(layer_pos) var half_cells = ceili(r / float(TILE_SIZE)) + 1 - var parent = get_parent() - if not parent: - parent = game_world.get_node_or_null("Entities") - if not parent: - return - + # Collect candidate tiles (with world pos and atlas) then cap count – tile selection is cheap + var candidates: Array = [] for gx in range(center_cell.x - half_cells, center_cell.x + half_cells + 1): for gy in range(center_cell.y - half_cells, center_cell.y + half_cells + 1): var cell = Vector2i(gx, gy) if layer.get_cell_source_id(cell) < 0: continue - var atlas = layer.get_cell_atlas_coords(cell) var world = layer.map_to_local(cell) + layer.global_position if world.distance_to(center) > r: continue - var bx = atlas.x * TILE_STRIDE - var by = atlas.y * TILE_STRIDE - var h = 8.0 # TILE_SIZE / 2 - var regions = [ - Rect2(bx, by, h, h), - Rect2(bx + h, by, h, h), - Rect2(bx, by + h, h, h), - Rect2(bx + h, by + h, h, h) - ] - # Direction from explosion center to this tile (outward) – particles fly away from bomb - var to_tile = world - center - var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU) - # Reduced particles: 1 piece per tile instead of 2 (use index 0) - var i = 0 - var p = _explosion_tile_particle_scene.instantiate() as CharacterBody2D - var spr = p.get_node_or_null("Sprite2D") as Sprite2D - if not spr: - p.queue_free() - continue - spr.texture = tex - spr.region_enabled = true - spr.region_rect = regions[i] - - # CRITICAL: Apply level's material and colorization to tile particles - # Get the material from the tilemap layer and duplicate it - # Duplicating ShaderMaterial copies all shader parameters (colorization, tint, ambient, etc.) - if layer.material and layer.material is ShaderMaterial: - var layer_material = layer.material as ShaderMaterial - var particle_material = layer_material.duplicate() as ShaderMaterial - spr.material = particle_material - - p.global_position = world - var speed = randf_range(280.0, 420.0) # Much faster - fly around more - var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4)) - p.velocity = d.normalized() * speed - p.angular_velocity = randf_range(-14.0, 14.0) - p.position_z = 0.0 - p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down - parent.add_child(p) + var atlas = layer.get_cell_atlas_coords(cell) + candidates.append({ "world": world, "atlas": atlas }) + + # Share layer material (no duplicate per particle) – same tint for all + var shared_material = layer.material if (layer.material is ShaderMaterial) else null + + var spawned = 0 + for c in candidates: + if spawned >= MAX_EXPLOSION_TILE_PARTICLES: + break + var bx = c.atlas.x * TILE_STRIDE + var by = c.atlas.y * TILE_STRIDE + var h = 8.0 + var region = Rect2(bx, by, h, h) + var world = c.world + var to_tile = world - center + var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU) + + var p = _EXPLOSION_TILE_PARTICLE_SCENE.instantiate() as CharacterBody2D + var spr = p.get_node_or_null("Sprite2D") as Sprite2D + if not spr: + p.queue_free() + continue + spr.texture = _DUNGEON_TILESET_TEXTURE + spr.region_enabled = true + spr.region_rect = region + if shared_material: + spr.material = shared_material + + p.global_position = world + var speed = randf_range(280.0, 420.0) + var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4)) + p.velocity = d.normalized() * speed + p.angular_velocity = randf_range(-14.0, 14.0) + p.position_z = 0.0 + p.velocity_z = randf_range(100.0, 180.0) + parent.add_child(p) + spawned += 1 func _cause_screenshake(): # Calculate screenshake based on distance from local players @@ -574,9 +569,8 @@ func on_grabbed(by_player): by_player.character_stats.character_changed.emit(by_player.character_stats) # Show "+1 Bomb" above player - var floating_text_scene = load("res://scenes/floating_text.tscn") as PackedScene - if floating_text_scene and by_player and is_instance_valid(by_player): - var ft = floating_text_scene.instantiate() + if _FLOATING_TEXT_SCENE and by_player and is_instance_valid(by_player): + var ft = _FLOATING_TEXT_SCENE.instantiate() var parent = by_player.get_parent() if parent: parent.add_child(ft) @@ -587,8 +581,6 @@ func on_grabbed(by_player): if has_node("SfxPickup"): $SfxPickup.play() - print(by_player.name, " collected bomb!") - # Sync removal to other clients so bomb doesn't keep exploding on their sessions if multiplayer.has_multiplayer_peer() and by_player and is_instance_valid(by_player): if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): diff --git a/src/scripts/attack_web_shot.gd b/src/scripts/attack_web_shot.gd index 78d0554..2159347 100644 --- a/src/scripts/attack_web_shot.gd +++ b/src/scripts/attack_web_shot.gd @@ -134,7 +134,7 @@ func _impact_on_player(player: Node2D) -> void: state = "hit_player" hit_player = player netted_timer = 0.0 - if sfx_webbed: + if sfx_webbed and sfx_webbed.stream: sfx_webbed.play() # Netted: cannot move? User said "player gets stuck" - so disable movement and main weapon if player.has_method("_web_net_apply"): diff --git a/src/scripts/boss_spider_bat.gd b/src/scripts/boss_spider_bat.gd index b7a853c..e598b84 100644 --- a/src/scripts/boss_spider_bat.gd +++ b/src/scripts/boss_spider_bat.gd @@ -21,8 +21,8 @@ var current_fly_target: Vector2 = Vector2.ZERO var fly_target_timer: float = 0.0 const FLY_TARGET_DURATION: float = 2.8 # Re-pick target every ~3 seconds -# Web shot attack: only down, down_left, down_right, left, right; fire all 3 at once -var web_shot_scene: PackedScene = null +# Web shot attack: preloaded so first shot doesn't stall (important for WebAssembly) +const WEB_SHOT_SCENE: PackedScene = preload("res://scenes/attack_web_shot.tscn") var attack_state: String = "idle" # "idle" | "charging_web" var web_attack_timer: float = 0.0 const WEB_CHARGE_TIME: float = 0.9 # Vibrate then fire all 3 @@ -35,8 +35,8 @@ const WEB_DIRECTIONS: Array = [ Vector2(-1, 0), # left Vector2(1, 0) # right ] -# Spider spawn -var enemy_spider_scene: PackedScene = null +# Spider spawn: preloaded for smooth first spawn +const ENEMY_SPIDER_SCENE: PackedScene = preload("res://scenes/enemy_spider.tscn") var spawned_spiders: Array = [] const SPIDER_SPAWN_COUNT: int = 3 const SPIDER_SPAWN_COOLDOWN: float = 18.0 # First spawn after ~18s; same fallback as web for test scenes @@ -78,11 +78,6 @@ func _ready() -> void: set_meta("is_boss", true) # Flying: no gravity, stay above ground (like bats) position_z = 20.0 - # Load web shot and spider scenes - if ResourceLoader.exists("res://scenes/attack_web_shot.tscn"): - web_shot_scene = load("res://scenes/attack_web_shot.tscn") as PackedScene - if ResourceLoader.exists("res://scenes/enemy_spider.tscn"): - enemy_spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene # Start idle (not activated) velocity = Vector2.ZERO if anim_player: @@ -168,7 +163,7 @@ func _physics_process(delta: float) -> void: # Spider spawn cooldown (only spawn if previous 3 are defeated) spider_spawn_timer += delta _clean_defeated_spiders() - if spider_spawn_timer >= SPIDER_SPAWN_COOLDOWN and spawned_spiders.is_empty() and enemy_spider_scene: + if spider_spawn_timer >= SPIDER_SPAWN_COOLDOWN and spawned_spiders.is_empty() and ENEMY_SPIDER_SCENE: _spawn_spiders() spider_spawn_timer = 0.0 # Attack state machine @@ -192,7 +187,7 @@ func _physics_process(delta: float) -> void: return if attack_state == "idle": web_attack_timer += delta - if web_attack_timer >= 2.5 and web_shot_scene: + if web_attack_timer >= 2.5 and WEB_SHOT_SCENE: web_attack_timer = 0.0 if _get_boss_rng().randf() < 0.5: attack_state = "charging_web" @@ -285,7 +280,7 @@ func _update_facing(dir: Vector2) -> void: func _Fire_three_nets_at_once() -> void: - if not web_shot_scene or not is_inside_tree(): + if not WEB_SHOT_SCENE or not is_inside_tree(): return if sfx_web_shot: sfx_web_shot.play() @@ -312,7 +307,7 @@ func _Fire_three_nets_at_once() -> void: var dir: Vector2 = WEB_DIRECTIONS[indices[i]] var target_pos = global_position + dir * WEB_FIRE_DISTANCE target_positions.append(target_pos) - var shot = web_shot_scene.instantiate() + var shot = WEB_SHOT_SCENE.instantiate() shot.global_position = global_position if shot.has_method("set_target"): shot.set_target(target_pos) @@ -369,7 +364,7 @@ func _get_spider_spawn_positions_in_room() -> Array: func _spawn_spiders() -> void: - if not enemy_spider_scene or not is_inside_tree(): + if not ENEMY_SPIDER_SCENE or not is_inside_tree(): return var game_world = get_tree().get_first_node_in_group("game_world") var positions: Array = _get_spider_spawn_positions_in_room() @@ -390,7 +385,7 @@ func _spawn_spiders() -> void: if not parent_node: return for i in range(SPIDER_SPAWN_COUNT): - var spider = enemy_spider_scene.instantiate() + var spider = ENEMY_SPIDER_SCENE.instantiate() spider.global_position = positions[i] parent_node.add_child(spider) spawned_spiders.append(spider) diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index 7e84971..d437aab 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -667,12 +667,9 @@ func equip_item(iItem: Item, insert_index: int = -1): can_stack = true if can_stack: - # Stack quantities together equipped_item.quantity += iItem.quantity - # Remove the item from inventory since we merged it if item_index >= 0: self.inventory.remove_at(item_index) - print("Stacked ", iItem.quantity, " ", iItem.item_name, " with equipped (new total: ", equipped_item.quantity, ")") else: # Normal equip (swap items) match iItem.equipment_type: diff --git a/src/scripts/dungeon_generator.gd b/src/scripts/dungeon_generator.gd index f2a67b1..51ccf6e 100644 --- a/src/scripts/dungeon_generator.gd +++ b/src/scripts/dungeon_generator.gd @@ -154,8 +154,8 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) - var boss_w = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size) var boss_h = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size) var boss_room = { - "x": int((map_size.x - boss_w) / 2), - "y": int((map_size.y - boss_h) / 2), + "x": int((map_size.x - boss_w) / 2.0), + "y": int((map_size.y - boss_h) / 2.0), "w": boss_w, "h": boss_h, "modifiers": [] diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index bc4142b..823ad98 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -42,6 +42,14 @@ const FALLOUT_SINK_DURATION: float = 0.5 const FALLOUT_CENTER_THRESHOLD: float = 2.0 const FALLOUT_LOOT_DELAY: float = 0.3 # Seconds after fallout death before spawning loot +# Cache game_world for _physics_process hot path (avoids get_first_node_in_group per enemy per frame) +var _cached_gw: Node = null + +# Preloaded resources (avoids load() stalls in-game and on WebAssembly) +var _fall_sfx_stream: AudioStream = preload("res://assets/audio/sfx/z3/LA_Enemy_Fall.wav") as AudioStream +var _debuff_burn_scene: PackedScene = preload("res://scenes/debuff_burn.tscn") as PackedScene +var _burn_texture: Texture2D = preload("res://assets/gfx/fx/burn.png") as Texture2D + # Animation enum Direction {DOWN = 0, LEFT = 1, RIGHT = 2, UP = 3, DOWN_LEFT = 4, DOWN_RIGHT = 5, UP_LEFT = 6, UP_RIGHT = 7} var current_direction: Direction = Direction.DOWN @@ -88,9 +96,7 @@ func _ready(): fall_sfx = AudioStreamPlayer2D.new() fall_sfx.name = "SfxFallout" add_child(fall_sfx) - var stream = load("res://assets/audio/sfx/z3/LA_Enemy_Fall.wav") as AudioStream - if stream: - fall_sfx.stream = stream + fall_sfx.stream = _fall_sfx_stream fall_sfx.attenuation = 7.0 fall_sfx.panning_strength = 1.14 fall_sfx.bus = "Sfx" @@ -173,12 +179,12 @@ func _physics_process(delta): _ai_behavior(delta) # All ground enemies: avoid stepping onto fallout when moving under their own control (not when knocked back) - # Skip avoidance during knockback so thrown objects (barrel, box) can knock enemies into fallout tiles if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and not is_knocked_back and velocity.length_squared() > 1.0: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_is_position_on_fallout_tile"): + if _cached_gw == null or not is_instance_valid(_cached_gw): + _cached_gw = get_tree().get_first_node_in_group("game_world") + if _cached_gw and _cached_gw.has_method("_is_position_on_fallout_tile"): var step = velocity.normalized() * 18.0 - if game_world._is_position_on_fallout_tile(global_position + step): + if _cached_gw._is_position_on_fallout_tile(global_position + step): velocity = Vector2.ZERO # Move @@ -753,22 +759,18 @@ func _create_burn_debuff_visual(): if burn_debuff_visual and is_instance_valid(burn_debuff_visual): burn_debuff_visual.queue_free() - # Load burn debuff scene - var burn_debuff_scene = load("res://scenes/debuff_burn.tscn") - if burn_debuff_scene: - burn_debuff_visual = burn_debuff_scene.instantiate() + # Use preloaded burn debuff scene + if _debuff_burn_scene: + burn_debuff_visual = _debuff_burn_scene.instantiate() add_child(burn_debuff_visual) - # Position on enemy (centered) burn_debuff_visual.position = Vector2(0, 0) - burn_debuff_visual.z_index = 5 # Above enemy sprite + burn_debuff_visual.z_index = 5 LogManager.log(str(name) + " created burn debuff visual", LogManager.CATEGORY_ENEMY) else: - # Fallback: create simple sprite if scene doesn't exist - var burn_texture = load("res://assets/gfx/fx/burn.png") - if burn_texture: + if _burn_texture: var burn_sprite = Sprite2D.new() burn_sprite.name = "BurnDebuffSprite" - burn_sprite.texture = burn_texture + burn_sprite.texture = _burn_texture burn_sprite.hframes = 4 burn_sprite.vframes = 4 burn_sprite.frame = 0 @@ -815,14 +817,13 @@ func _die(): is_dead = true LogManager.log(str(name) + " died!", LogManager.CATEGORY_ENEMY) + var gw = get_tree().get_first_node_in_group("game_world") if is_inside_tree() else null + # Track defeated enemy for syncing to new clients - if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world: - var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 - if enemy_index >= 0: - game_world.defeated_enemies[enemy_index] = true - LogManager.log("Enemy: Tracked defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_ENEMY) + if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and gw: + var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 + if enemy_index >= 0: + gw.defeated_enemies[enemy_index] = true # Credit kill to the player who dealt the fatal damage if killer_player and is_instance_valid(killer_player) and killer_player.character_stats: @@ -865,13 +866,9 @@ func _die(): player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp) # Show floating EXP text at enemy position and sync to all clients - if is_multiplayer_authority(): - # Show locally first + if is_multiplayer_authority() and gw and gw.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer(): _show_exp_number(exp_per_player, global_position) - # Sync to all clients via game_world - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer(): - game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position) + gw._sync_exp_text_at_position.rpc(exp_per_player, global_position) # Spawn loot (immediately, or after 0.3s if died from fallout so it appears after sink) if died_from_fallout: @@ -883,16 +880,12 @@ func _die(): _spawn_loot() # Sync death to all clients (only server sends RPC) - # Use game_world to route death sync instead of direct RPC to avoid node path issues - if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree(): + if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree() and gw and gw.has_method("_sync_enemy_death"): var enemy_name = name var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_enemy_death"): - game_world._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index]) - else: - # Fallback: try direct RPC (may fail if node path doesn't match) - _sync_death.rpc() + gw._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index]) + elif multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree(): + _sync_death.rpc() # Play death animation (override in subclasses) _play_death_animation() diff --git a/src/scripts/enemy_hand.gd b/src/scripts/enemy_hand.gd index d4d4fb8..35393c1 100644 --- a/src/scripts/enemy_hand.gd +++ b/src/scripts/enemy_hand.gd @@ -27,7 +27,8 @@ const TILE_STRIDE: int = 17 # 16 + separation 1 @onready var interest_area: Area2D = $PlayerInterestArea @onready var anim_player: AnimationPlayer = $AnimationPlayer @onready var hand_sprite: Sprite2D = $Sprite2D -var _tile_particle_scene: PackedScene = null +const _TILE_PARTICLE_SCENE: PackedScene = preload("res://scenes/explosion_tile_particle.tscn") +const _DUNGEON_TILESET_TEXTURE: Texture2D = preload("res://assets/gfx/RPG DUNGEON VOL 3.png") var blood_scene: PackedScene = preload("res://scenes/blood_clot.tscn") @@ -133,11 +134,6 @@ func _spawn_hand_pieces(): if not hand_sprite or not hand_sprite.texture: return - if not _tile_particle_scene: - _tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene - if not _tile_particle_scene: - return - var parent = get_parent() if not parent: var game_world = get_tree().get_first_node_in_group("game_world") @@ -185,7 +181,7 @@ func _spawn_hand_pieces(): # Spawn 4 pieces for i in range(4): - var p = _tile_particle_scene.instantiate() as CharacterBody2D + var p = _TILE_PARTICLE_SCENE.instantiate() as CharacterBody2D var piece_sprite = p.get_node_or_null("Sprite2D") as Sprite2D if not piece_sprite: p.queue_free() @@ -290,18 +286,17 @@ func _detect_hand(detecting_player: Node) -> void: func _remove_detected_effect() -> void: - # Effect is under Entities (not our child) so it stays visible while we're hidden; remove by position and sync + # When effect was spawned with parent_node_name = our name, it's our child; remove it first. + for c in get_children(): + if c.name == "DetectedEffect" or (c.get_class() == "Node2D" and c.has_method("setup")): + c.queue_free() + return + # Otherwise effect is under Entities (e.g. no parent); remove by position and sync var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("remove_detected_effect_at_position"): game_world.remove_detected_effect_at_position(global_position) if multiplayer.has_multiplayer_peer(): game_world._sync_remove_detected_effect_at_position.rpc(global_position.x, global_position.y) - return - # Fallback: effect might be our child (legacy) - for c in get_children(): - if c.name == "DetectedEffect": - c.queue_free() - return func _on_emerge_area_body_entered(body: Node2D) -> void: @@ -357,6 +352,10 @@ func _on_grab_player_area_body_entered(body: Node2D) -> void: return if "is_dead" in body and body.is_dead: return + # Don't grab players who are mid-air (thrown) + var pz: float = body.position_z if "position_z" in body else 0.0 + if pz > 0.0: + return if "grabbed_by_enemy_hand" in body and body.grabbed_by_enemy_hand != null: return if grabbed_player != null: @@ -442,15 +441,6 @@ func _spawn_emerge_tile_particles(): if not layer or not layer is TileMapLayer: return - if not _tile_particle_scene: - _tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene - if not _tile_particle_scene: - return - - var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D - if not tex: - return - var center = global_position var layer_pos = center - layer.global_position var center_cell = layer.local_to_map(layer_pos) @@ -482,12 +472,12 @@ func _spawn_emerge_tile_particles(): # Spawn 2-3 particles from the tile, flying outward in random directions var num_particles = randi_range(2, 3) for i in range(num_particles): - var p = _tile_particle_scene.instantiate() as CharacterBody2D + var p = _TILE_PARTICLE_SCENE.instantiate() as CharacterBody2D var spr = p.get_node_or_null("Sprite2D") as Sprite2D if not spr: p.queue_free() continue - spr.texture = tex + spr.texture = _DUNGEON_TILESET_TEXTURE spr.region_enabled = true # Randomly pick one of the 4 tile quadrants var region_idx = randi() % 4 diff --git a/src/scripts/enemy_humanoid.gd b/src/scripts/enemy_humanoid.gd index fa4b81a..fd2cfbf 100644 --- a/src/scripts/enemy_humanoid.gd +++ b/src/scripts/enemy_humanoid.gd @@ -1048,13 +1048,14 @@ func _physics_process(delta): can_attack = true is_attacking = false - # Sync position and animation to clients (only server sends; humanoid uses game_world RPC) + # Sync position and animation to clients (only server sends; reuse _cached_gw from base avoidance) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority(): - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_enemy_position"): + if _cached_gw == null or not is_instance_valid(_cached_gw): + _cached_gw = get_tree().get_first_node_in_group("game_world") + if _cached_gw and _cached_gw.has_method("_sync_enemy_position"): var enemy_name = name var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 - game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1]) + _cached_gw._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1]) else: _sync_position(position, velocity, position_z, current_direction, 0, current_animation, current_frame) @@ -1640,11 +1641,35 @@ func _throw_held_bomb(): print(name, " threw bomb from above head!") func _cleanup_held_bomb(): - # Clean up held bomb object if it exists + # Clean up held bomb object if it exists (e.g. target lost while alive) if held_bomb_object and is_instance_valid(held_bomb_object): held_bomb_object.queue_free() held_bomb_object = null +func _drop_held_bomb(): + # When enemy dies with a bomb in hand: drop it on the ground so players can pick it up (don't destroy it) + if not held_bomb_object or not is_instance_valid(held_bomb_object): + held_bomb_object = null + return + var bomb = held_bomb_object + held_bomb_object = null + bomb.global_position = global_position + # Re-enable collision so players can grab it (layer 2 = interactable, mask 1 = players) + bomb.set_collision_layer_value(2, true) + bomb.set_collision_mask_value(1, true) + bomb.set_collision_mask_value(2, true) + # Ensure it's on ground and grabbable + if "position_z" in bomb: + bomb.position_z = 0.0 + if "velocity_z" in bomb: + bomb.velocity_z = 0.0 + if "is_airborne" in bomb: + bomb.is_airborne = false + +func _die(): + _drop_held_bomb() + super._die() + func _casting_spell_behavior(delta): state_timer -= delta velocity = Vector2.ZERO @@ -2201,8 +2226,9 @@ func _play_death_animation(): _update_animation(0.0) LogManager.log(str(name) + " (client) forced immediate animation update after setting DIE", LogManager.CATEGORY_ENEMY) - # Play death sound effect - if sfx_die: + # Play death sound effect and blood (blood_scene is preloaded; add_child 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) @@ -2210,12 +2236,12 @@ func _play_death_animation(): 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() # Wait for death animation to finish (same duration as player: 200+200+200+800 = 1400ms = 1.4s) diff --git a/src/scripts/fire.gd b/src/scripts/fire.gd index 61233ad..edb5ee4 100644 --- a/src/scripts/fire.gd +++ b/src/scripts/fire.gd @@ -10,5 +10,5 @@ func _ready() -> void: # Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(delta: float) -> void: +func _process(_delta: float) -> void: pass diff --git a/src/scripts/fog_of_war.gd b/src/scripts/fog_of_war.gd index a2325b9..4e2823b 100644 --- a/src/scripts/fog_of_war.gd +++ b/src/scripts/fog_of_war.gd @@ -9,38 +9,75 @@ var fog_color_seen: Color = Color(0, 0, 0, 0.85) var debug_lines: Array = [] var debug_enabled: bool = false +# Half-resolution texture: 1 pixel per 2x2 tiles = 4x fewer writes, much faster +var _fog_image: Image +var _fog_texture: ImageTexture + func setup(new_map_size: Vector2i, new_tile_size: int = 16) -> void: map_size = new_map_size tile_size = new_tile_size + var hw = (map_size.x + 1) >> 1 + var hh = (map_size.y + 1) >> 1 + _fog_image = Image.create(hw, hh, false, Image.FORMAT_RGBA8) + _fog_texture = ImageTexture.create_from_image(_fog_image) -func set_maps(new_explored_map: PackedInt32Array, new_visible_map: PackedInt32Array) -> void: +# phase 0 or 1: update only that half of rows so cost is spread over 2 frames +func set_maps(new_explored_map: PackedInt32Array, new_visible_map: PackedInt32Array, phase: int = 0) -> void: explored_map = new_explored_map visible_map = new_visible_map + _update_fog_texture(phase) queue_redraw() +func _update_fog_texture(phase: int = 0) -> void: + if map_size == Vector2i.ZERO or explored_map.is_empty() or visible_map.size() < explored_map.size(): + return + var hw = (map_size.x + 1) >> 1 + var hh = (map_size.y + 1) >> 1 + if _fog_image == null or _fog_image.get_width() != hw or _fog_image.get_height() != hh: + _fog_image = Image.create(hw, hh, false, Image.FORMAT_RGBA8) + _fog_texture = ImageTexture.create_from_image(_fog_image) + var stride = map_size.x + # Update only half the rows this frame (phase 0: top half, phase 1: bottom half) + var fy_start = (phase % 2) * (hh >> 1) + var fy_end = mini(fy_start + (hh >> 1), hh) + for fy in range(fy_start, fy_end): + for fx in range(hw): + var tx0 = fx * 2 + var ty0 = fy * 2 + var vis = 0 + var seen = 0 + for dy in range(2): + for dx in range(2): + var tx = tx0 + dx + var ty = ty0 + dy + if tx < map_size.x and ty < map_size.y: + var idx = tx + ty * stride + if idx < visible_map.size() and visible_map[idx] == 1: + vis = 1 + if idx < explored_map.size() and explored_map[idx] != 0: + seen = 1 + var col: Color + if vis == 1: + col = Color(0, 0, 0, 0) + elif seen == 0: + col = fog_color_unseen + else: + col = fog_color_seen # seen but not visible + _fog_image.set_pixel(fx, fy, col) + _fog_texture.update(_fog_image) + func set_debug_lines(lines: Array, enabled: bool) -> void: debug_lines = lines debug_enabled = enabled queue_redraw() func _draw() -> void: - if map_size == Vector2i.ZERO or explored_map.is_empty() or visible_map.is_empty(): + if map_size == Vector2i.ZERO or explored_map.is_empty(): return - - for x in range(map_size.x): - for y in range(map_size.y): - var idx = x + y * map_size.x - if idx >= explored_map.size() or idx >= visible_map.size(): - continue - var pos = Vector2(x * tile_size, y * tile_size) - if visible_map[idx] == 1: - continue - if explored_map[idx] == 0: - draw_rect(Rect2(pos, Vector2(tile_size, tile_size)), fog_color_unseen, true) - else: - draw_rect(Rect2(pos, Vector2(tile_size, tile_size)), fog_color_seen, true) - + if _fog_texture: + var rect = Rect2(Vector2.ZERO, Vector2(map_size.x * tile_size, map_size.y * tile_size)) + draw_texture_rect(_fog_texture, rect, false) if debug_enabled: for line in debug_lines: if line is Array and line.size() == 2: - draw_line(line[0], line[1], Color(0, 1, 0, 0.4), 1.0) \ No newline at end of file + draw_line(line[0], line[1], Color(0, 1, 0, 0.4), 1.0) diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index ec5f892..156ce3b 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -41,10 +41,12 @@ const FOG_VIEW_RANGE_TILES: float = 10.0 const FOG_BACK_RANGE_TILES: float = 3.0 const FOG_RAY_STEP: float = 0.5 const FOG_RAY_ANGLE_STEP: int = 10 -const FOG_UPDATE_INTERVAL: float = 0.1 -const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag +const FOG_UPDATE_INTERVAL: float = 0.25 # Run less often to avoid spikes (was 0.1) +const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.06 # Update often in corridors so vision feels correct const FOG_DEBUG_DRAW: bool = false +const FOG_SIMPLE_MODE: bool = true # Whole room / corridor+rooms visible (no raycast) var fog_update_timer: float = 0.0 +var fog_visual_tick: int = 0 # Stagger: fog texture one tick, minimap next, to spread cost var peer_cleanup_timer: float = 0.0 const PEER_CLEANUP_INTERVAL: float = 0.5 # Check every 0.5 seconds @@ -67,6 +69,8 @@ const CLIENT_BUFFER_CHECK_INTERVAL: float = 0.1 # Check client buffers every 100 const CLIENT_BUFFER_SKIP_THRESHOLD: int = 1024 * 256 # Skip sending if buffer > 256KB (lower threshold to catch earlier) const CLIENT_BUFFER_SKIP_DURATION: float = 3.0 # Skip sending for 3 seconds if buffer is full (longer duration) var fog_node: Node2D = null +var fog_tile_to_room_index: PackedInt32Array = PackedInt32Array() # -1 = corridor, else room index. O(1) lookup. +var _cached_closed_door_tiles: Dictionary = {} # "x,y" -> true. Rebuilt each fog update to avoid iterating Entities in _is_visibility_blocking_tile. var cached_corridor_mask: PackedInt32Array = PackedInt32Array() var cached_corridor_rooms: Array = [] var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1) @@ -81,7 +85,7 @@ 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.52 # Floor brightness so it's never insanely dark; same for all players +const _TORCH_DARKEN_MIN_SCALE: float = 0.05 # Floor brightness so it's never insanely dark; same for all players var _synced_darkness_scale: float = 1.0 # Server syncs this to clients so host and joiner see same darkness var _last_synced_darkness_sent: float = -1.0 # Server: last value we sent var _darkness_sync_timer: float = 0.0 # Server: throttle sync RPCs @@ -121,6 +125,9 @@ var dungeon_sync_in_progress: Dictionary = {} # peer_id -> bool # Fallout tiles (quicksand): last safe tile center per player (for respawn after falling in) var last_safe_position_by_player: Dictionary = {} # player node path or name -> Vector2 +# Cache: 1 = fallout tile, 0 = not. Index = x + y * map_size.x. Avoids get_cell_tile_data() every physics frame. +var _fallout_tile_cache: PackedByteArray = PackedByteArray() +var _fallout_cache_map_size: Vector2i = Vector2i.ZERO # Cracked floor: stand too long -> tile breaks and becomes fallout var cracked_stand_timers: Dictionary = {} # "player_key|tx|ty" -> float (seconds on that tile) @@ -128,8 +135,10 @@ const CRACKED_STAND_DURATION: float = 0.9 # Seconds standing on cracked tile bef const CRACKED_TILE_ATLAS: Vector2i = Vector2i(15, 16) # Cracked floor: normally invisible; once per game per tile a player can roll perception when close to reveal const CRACKED_DETECTION_RADIUS: float = 99.0 # Same as trap detection (pixels) +const CRACKED_DETECTION_INTERVAL: float = 0.2 # Only run detection every 0.2s to reduce cost var cracked_revealed_tiles: Dictionary = {} # "x,y" -> true var cracked_detection_attempts: Dictionary = {} # "peer_id|x|y" -> true +var cracked_detection_timer: float = 0.0 # Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator const _FALLOUT_CENTER = Vector2i(10, 12) const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11) @@ -195,9 +204,6 @@ var pending_chest_opens: Dictionary = {} # chest_name -> {loot_type: String, pla func _ready(): # Add to group for easy access add_to_group("game_world") - # Apply any boss spider spawns that arrived before we were in tree (joiner fix) - if network_manager and network_manager.has_method("apply_pending_boss_spider_spawns"): - network_manager.apply_pending_boss_spider_spawns(self) # Connect network signals if network_manager: @@ -519,16 +525,7 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_ # Wait a bit after dungeon sync to ensure spawners are spawned first call_deferred("_sync_existing_enemies_to_client", peer_id) # Sync existing boss-spawned spiders (so joiner sees them if they connected after spawn) - # Send immediately and again after delays so joiner 100% gets it (handles RPC loss or early processing) call_deferred("_sync_existing_boss_spiders_to_client", peer_id) - get_tree().create_timer(1.5).timeout.connect(func(): - if is_inside_tree() and multiplayer.is_server(): - _sync_existing_boss_spiders_to_client(peer_id) - ) - get_tree().create_timer(3.0).timeout.connect(func(): - if is_inside_tree() and multiplayer.is_server(): - _sync_existing_boss_spiders_to_client(peer_id) - ) # Sync existing chest open states to the new client # Wait a bit after dungeon sync to ensure objects are spawned first @@ -1169,15 +1166,12 @@ func request_spawn_boss_spiders(positions: Array) -> Array: # Called from NetworkManager.spawn_boss_spiders_client on clients (relay ensures joiner receives RPC) func _do_client_spawn_boss_spiders(p1x: float, p1y: float, p2x: float, p2y: float, p3x: float, p3y: float, idx0: int, idx1: int, idx2: int) -> void: - var entities_node = get_node_or_null("Entities") - if not entities_node: - # Joiner: Entities not ready yet; queue so we apply when dungeon is ready - if not multiplayer.is_server() and network_manager: - network_manager.pending_boss_spider_spawns.append({"p1x": p1x, "p1y": p1y, "p2x": p2x, "p2y": p2y, "p3x": p3x, "p3y": p3y, "idx0": idx0, "idx1": idx1, "idx2": idx2}) - return var spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene if not spider_scene: return + var entities_node = get_node_or_null("Entities") + if not entities_node: + return var positions = [Vector2(p1x, p1y), Vector2(p2x, p2y), Vector2(p3x, p3y)] var indices = [idx0, idx1, idx2] for i in range(3): @@ -1444,14 +1438,6 @@ func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display var heal_text = prefix + "+" + str(display_amount) + " HP" _show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0) -# Server-only: send enemy position to ALL peers (including joiner who may not be in client_gameworld_ready yet). -# Boss-spawned spiders and other enemies need position sync to reach joiners; _rpc_to_ready_peers can miss them. -func _broadcast_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) -> void: - if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): - return - for peer_id in multiplayer.get_peers(): - _sync_enemy_position.rpc_id(peer_id, enemy_name, enemy_index, pos, vel, z_pos, dir, frame, anim, frame_num, state_value) - @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): # Clients receive enemy position updates from server @@ -1561,14 +1547,6 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index: enemy = child break - # Fallback: boss may have different name on client; find by is_boss if exactly one - if enemy == null and enemy_index >= 0: - var boss_candidates = [] - for child in entities_node.get_children(): - if child.is_in_group("enemy") and child.has_meta("is_boss") and child.get_meta("is_boss"): - boss_candidates.append(child) - if boss_candidates.size() == 1: - enemy = boss_candidates[0] if enemy and enemy.has_method("_sync_damage_visual"): # Call the enemy's _sync_damage_visual method directly (not via RPC) @@ -1576,8 +1554,7 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou else: # Enemy not found - might already be freed or never spawned # This is okay, just log it - if enemy == null: - print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index) + print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index) @rpc("authority", "reliable") func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool): @@ -2110,10 +2087,6 @@ func _process(delta): if use_mouse_control: _update_mouse_cursor(delta) - # Client: apply any pending boss spider spawns (in case RPC arrived when we weren't findable) - if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"): - network_manager.apply_pending_boss_spider_spawns(self) - # Check tab visibility for buffer overflow protection (clients only) if not multiplayer.is_server(): var is_tab_visible = _check_tab_visibility() @@ -2672,21 +2645,16 @@ func _update_mouse_cursor(delta: float): if "mouse_control_active" in player: player.mouse_control_active = false +const GRID_TILE_SIZE: int = 16 + func get_grid_locked_cursor_position() -> Vector2: - # Get the grid-locked cursor world position for spell casting - if not dungeon_tilemap_layer: + # Grid-locked cursor for spell casting. Uses simple math (no TileMap) to avoid 13ms get_cell_source_id cost. + if not camera: return Vector2.ZERO - - var _mouse_pos = get_viewport().get_mouse_position() - var world_pos = camera.get_global_mouse_position() - - var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) - var tile_data = dungeon_tilemap_layer.get_cell_source_id(tile_pos) - if tile_data >= 0: # Valid tile - # Return tile center world position - return dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position - - return Vector2.ZERO # No valid grid position + var world_pos: Vector2 = camera.get_global_mouse_position() + var tx := int(floorf(world_pos.x / float(GRID_TILE_SIZE))) + var ty := int(floorf(world_pos.y / float(GRID_TILE_SIZE))) + return Vector2(tx * GRID_TILE_SIZE + GRID_TILE_SIZE / 2.0, ty * GRID_TILE_SIZE + GRID_TILE_SIZE / 2.0) func _get_valid_spell_target_position(target_world_pos: Vector2) -> Vector2: # Get valid spell target position (closest valid floor tile, or in front of wall if blocked) @@ -2827,8 +2795,18 @@ const FALLOUT_TERRAIN_VALUE: int = -1 func _is_position_on_fallout_tile(world_pos: Vector2) -> bool: if not dungeon_tilemap_layer: return false - var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) - var td = dungeon_tilemap_layer.get_cell_tile_data(tile_pos) + # Use cache to avoid get_cell_tile_data() every frame (was causing 40ms+ physics spikes) + if _fallout_tile_cache.size() > 0 and _fallout_cache_map_size.x > 0 and _fallout_cache_map_size.y > 0: + var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) + var tx := clampi(tile_pos.x, 0, _fallout_cache_map_size.x - 1) + var ty := clampi(tile_pos.y, 0, _fallout_cache_map_size.y - 1) + var idx := tx + ty * _fallout_cache_map_size.x + if idx >= 0 and idx < _fallout_tile_cache.size(): + return _fallout_tile_cache[idx] != 0 + return false + # Fallback when cache not built yet + var fp = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) + var td = dungeon_tilemap_layer.get_cell_tile_data(fp) if not td: return false if not td.get_custom_data("terrain") is int: @@ -2892,9 +2870,13 @@ func _is_position_on_cracked_tile(world_pos: Vector2) -> bool: return td.get_custom_data("terrain") == -2 func _try_cracked_floor_detection() -> void: - # When a player gets close to an unrevealed cracked tile, they get one perception roll per tile per game (like traps) if dungeon_data.is_empty() or not dungeon_data.has("cracked_tile_grid") or not dungeon_tilemap_layer_cracked: return + cracked_detection_timer += get_process_delta_time() + if cracked_detection_timer < CRACKED_DETECTION_INTERVAL: + return + cracked_detection_timer = 0.0 + var cracked_tile_grid = dungeon_data.cracked_tile_grid var map_size: Vector2i = dungeon_data.map_size var switch_tiles: Dictionary = {} @@ -2907,6 +2889,7 @@ func _try_cracked_floor_detection() -> void: var players: Array = get_tree().get_nodes_in_group("player") if players.is_empty() and player_manager: players = player_manager.get_local_players() + var radius_tiles: int = int(ceil(CRACKED_DETECTION_RADIUS / 16.0)) # ~7 tiles for player in players: if not is_instance_valid(player) or not player.is_in_group("player"): continue @@ -2915,10 +2898,18 @@ func _try_cracked_floor_detection() -> void: var qa = player.get_node("QuicksandArea") if is_instance_valid(qa): pos = qa.global_position + var pt_x: int = int(pos.x / 16) + var pt_y: int = int(pos.y / 16) + var x_min: int = maxi(0, pt_x - radius_tiles) + var x_max: int = mini(map_size.x - 1, pt_x + radius_tiles) + var y_min: int = maxi(0, pt_y - radius_tiles) + var y_max: int = mini(map_size.y - 1, pt_y + radius_tiles) var peer_id = player.get_multiplayer_authority() if "get_multiplayer_authority" in player else 0 - for x in range(map_size.x): - for y in range(map_size.y): - if x >= cracked_tile_grid.size() or y >= cracked_tile_grid[x].size(): + for x in range(x_min, x_max + 1): + if x >= cracked_tile_grid.size(): + continue + for y in range(y_min, y_max + 1): + if y >= (cracked_tile_grid[x] as Array).size(): continue if not cracked_tile_grid[x][y]: continue @@ -3035,16 +3026,13 @@ func spawn_detected_effect_at(world_pos: Vector2, parent_node_name: String = "", # Spawn the "detected" effect at position; sync to all players. # effect_type: "chest" (blue, 169-179), "trap" (purple, 274-284), "enemy" (red, 484-494). # If parent_node_name is set (e.g. trap/enemy hand name), effect is added as child of that node so it can be removed when disarmed/emerged. - # For "enemy" type we always add to Entities so the effect stays visible (animating) while the hidden enemy has modulate.a = 0. var scene = load("res://scenes/detected_effect.tscn") as PackedScene if not scene: return var entities_node = get_node_or_null("Entities") if not entities_node: entities_node = self - var parent = null - if parent_node_name and effect_type != "enemy": - parent = entities_node.get_node_or_null(parent_node_name) + var parent = entities_node.get_node_or_null(parent_node_name) if parent_node_name else null var effect = scene.instantiate() if parent: parent.add_child(effect) @@ -3081,9 +3069,7 @@ func _sync_spawn_detected_effect(px: float, py: float, parent_node_name: String var entities_node = get_node_or_null("Entities") if not entities_node: entities_node = self - var parent = null - if parent_node_name and effect_type != "enemy": - parent = entities_node.get_node_or_null(parent_node_name) + var parent = entities_node.get_node_or_null(parent_node_name) if parent_node_name else null var effect = scene.instantiate() if parent: parent.add_child(effect) @@ -3128,6 +3114,8 @@ func _break_cracked_tile(tile_x: int, tile_y: int) -> void: # Replace floor with fallout on main layer var fallout_tile = _get_fallout_tile_for_floor_at(tile_x, tile_y) dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile) + if _fallout_tile_cache.size() > 0 and tile_x >= 0 and tile_y >= 0 and tile_x < _fallout_cache_map_size.x and tile_y < _fallout_cache_map_size.y: + _fallout_tile_cache[tile_x + tile_y * _fallout_cache_map_size.x] = 1 if dungeon_tilemap_layer_decorated: dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y)) # Tile center for whoosh/effect removal: use same layer as spawn (cracked) so position matches @@ -3160,6 +3148,8 @@ func _sync_cracked_tile_broke(tile_x: int, tile_y: int, fallout_atlas_x: int, fa var fallout_tile = Vector2i(fallout_atlas_x, fallout_atlas_y) if dungeon_tilemap_layer: dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile) + if _fallout_tile_cache.size() > 0 and tile_x >= 0 and tile_y >= 0 and tile_x < _fallout_cache_map_size.x and tile_y < _fallout_cache_map_size.y: + _fallout_tile_cache[tile_x + tile_y * _fallout_cache_map_size.x] = 1 if dungeon_tilemap_layer_decorated: dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y)) if dungeon_tilemap_layer_cracked: @@ -3292,6 +3282,9 @@ func _update_fog_of_war(delta: float) -> void: if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"): return + _ensure_fog_tile_to_room_index() + _rebuild_closed_door_tiles_cache() + # Determine if we're in a corridor and use appropriate update interval var in_corridor = false if player_manager.get_local_players().size() > 0 and player_manager.get_local_players()[0]: @@ -3307,20 +3300,8 @@ func _update_fog_of_war(delta: float) -> void: cached_corridor_player_tile = Vector2i(-1, -1) cached_corridor_allowed_room_ids.clear() was_in_corridor = in_corridor - print("GameWorld: Corridor state changed - was_in_corridor: ", !in_corridor, " -> in_corridor: ", in_corridor) - - # In corridors: only update when entering/exiting or when player moves significantly - # Skip expensive updates if we're stationary in a corridor - if in_corridor and not corridor_state_changed: - # Check if player moved significantly (more than 1 tile) - var current_player_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE)) - var player_moved = cached_corridor_player_tile.distance_to(current_player_tile) > 1 - - # Only update if player moved significantly OR enough time has passed (much longer interval) - var time_since_last_update = Time.get_ticks_msec() / 1000.0 - last_corridor_fog_update - if not player_moved and time_since_last_update < 1.0: # Skip if stationary and updated recently - return # Skip expensive fog update - player is stationary in corridor + # Use shorter interval in corridors so vision updates feel responsive var update_interval = FOG_UPDATE_INTERVAL_CORRIDOR if in_corridor else FOG_UPDATE_INTERVAL fog_update_timer += delta if fog_update_timer < update_interval: @@ -3328,146 +3309,92 @@ func _update_fog_of_war(delta: float) -> void: fog_update_timer = 0.0 var map_size = dungeon_data.map_size - combined_seen = _create_seen_array(map_size) - if explored_map.is_empty(): - explored_map = _create_seen_array(map_size) + var total = map_size.x * map_size.y + if combined_seen.size() != total: + combined_seen.resize(total) + for i in range(total): + combined_seen[i] = 0 + if explored_map.is_empty() or explored_map.size() != total: + explored_map.resize(total) + for i in range(total): + explored_map[i] = 0 var local_player_list = player_manager.get_local_players() fog_debug_lines.clear() + + # Build combined_seen positively only: set 1 where visible. No full-map "clear other rooms" loops. for player in local_player_list: if not player or not is_instance_valid(player): continue - if not seen_by_player.has(player.name): - seen_by_player[player.name] = _create_seen_array(map_size) - var seen_map = seen_by_player[player.name] - _update_seen_for_player(player, seen_map) - _combine_seen_maps(combined_seen, seen_map) - _combine_seen_maps(explored_map, seen_map) - - # Mask visibility to current room only (hide other rooms even if previously seen) - var current_room = {} - if local_player_list.size() > 0 and local_player_list[0]: - var p_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE)) - current_room = _find_room_at_tile(p_tile) + var p_tile = Vector2i(int(player.global_position.x / FOG_TILE_SIZE), int(player.global_position.y / FOG_TILE_SIZE)) + var current_room = _find_room_at_tile(p_tile) if not current_room.is_empty(): _mark_room_explored(current_room) _mark_room_visible(current_room) - # Only hide other rooms; keep corridor visible when we can see into it (no closed door in way) - for y in range(map_size.y): - for x in range(map_size.x): - var idx = x + y * map_size.x - if idx < 0 or idx >= combined_seen.size(): - continue - var tile_room = _find_room_at_tile(Vector2i(x, y)) - if tile_room.is_empty(): - continue # Corridor: keep (already revealed by raycast if visible) - if tile_room.x != current_room.x or tile_room.y != current_room.y or tile_room.w != current_room.w or tile_room.h != current_room.h: - combined_seen[idx] = 0 # Other room: hide else: - # In corridors (no room), only show tiles connected to the corridor component - # AND explicitly clear combined_seen for all tiles in rooms that aren't connected - var corridor_player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE)) - - # Cache corridor data - only rebuild if player moved more than 1 tile - var should_rebuild_corridor = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(corridor_player_tile) > 1 - - var corridor_mask: PackedInt32Array - var corridor_rooms: Array - var allowed_room_ids: Dictionary - - if should_rebuild_corridor: - # Rebuild corridor mask and rooms (expensive operation) + var corridor_player_tile = p_tile + var should_rebuild = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(corridor_player_tile) > 1 + if should_rebuild: cached_corridor_mask = _build_corridor_mask(corridor_player_tile) cached_corridor_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, corridor_player_tile) cached_corridor_player_tile = corridor_player_tile - # Build a set of allowed room IDs for fast lookup - cached_corridor_allowed_room_ids = {} - for room in cached_corridor_rooms: - var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h) - cached_corridor_allowed_room_ids[room_id] = true - # Use the rebuilt data - corridor_mask = cached_corridor_mask - corridor_rooms = cached_corridor_rooms - allowed_room_ids = cached_corridor_allowed_room_ids - else: - # Use cached data (much faster!) - corridor_mask = cached_corridor_mask - corridor_rooms = cached_corridor_rooms - allowed_room_ids = cached_corridor_allowed_room_ids - - # Check explored rooms and mark them visible - for room in corridor_rooms: - # If this room was previously explored, mark the entire room (including outer walls) as visible - var was_explored = false - for x in range(room.x - 2, room.x + room.w + 2): - for y in range(room.y - 2, room.y + room.h + 2): - if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y: - continue - var idx = x + y * map_size.x - if idx >= 0 and idx < explored_map.size() and explored_map[idx] == 1: - was_explored = true - break - if was_explored: - break - if was_explored: - _mark_room_visible(room) - # Explicitly clear combined_seen for ANY tile not in corridor mask or allowed rooms - # OPTIMIZATION: Only do expensive per-tile checks when corridor state changed or player moved significantly - var needs_tile_clear = corridor_state_changed or should_rebuild_corridor - if needs_tile_clear: - for y in range(map_size.y): - for x in range(map_size.x): - var idx = x + y * map_size.x - if idx < 0 or idx >= combined_seen.size(): - continue - var tile_in_corridor = idx < corridor_mask.size() and corridor_mask[idx] == 1 - # Check if this tile is in a room, and if so, is it an allowed room? - var tile_room = _find_room_at_tile(Vector2i(x, y)) - var in_allowed_room = false - if not tile_room.is_empty(): - var room_id = str(tile_room.x) + "," + str(tile_room.y) + "," + str(tile_room.w) + "," + str(tile_room.h) - in_allowed_room = allowed_room_ids.has(room_id) - # Clear combined_seen for any tile not in corridor or allowed rooms - if not tile_in_corridor and not in_allowed_room: - combined_seen[idx] = 0 - - # Update last corridor fog update time + for idx in range(cached_corridor_mask.size()): + if cached_corridor_mask[idx] == 1: + combined_seen[idx] = 1 + for room in cached_corridor_rooms: + _mark_room_in_seen_map(combined_seen, room) last_corridor_fog_update = Time.get_ticks_msec() / 1000.0 - if fog_node.has_method("set_maps"): - fog_node.set_maps(explored_map, combined_seen) - if fog_node.has_method("set_debug_lines"): - fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW) + # Merge visible into explored (single pass) + for i in range(total): + if combined_seen[i] != 0: + explored_map[i] = 1 + + # Stagger heavy texture updates: fog one tick, minimap next, to avoid one big spike var player_tile := Vector2i(-1, -1) if local_player_list.size() > 0 and local_player_list[0]: player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE)) - var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw") - if minimap_draw and minimap_draw.has_method("set_maps") and dungeon_data.has("grid"): - var exit_tile := Vector2i(-1, -1) - var exit_discovered := false - if dungeon_data.has("stairs") and not dungeon_data.stairs.is_empty(): - var s = dungeon_data.stairs - if s.has("x") and s.has("y") and s.has("w") and s.has("h"): - exit_tile = Vector2i(s.x + s.w / 2, s.y + s.h / 2) - for dx in range(int(s.w)): - for dy in range(int(s.h)): - var tx: int = int(s.x) + dx - var ty: int = int(s.y) + dy - if tx >= 0 and ty >= 0 and tx < map_size.x and ty < map_size.y: - var idx: int = tx + ty * map_size.x - if idx >= 0 and idx < explored_map.size() and explored_map[idx] != 0: - exit_discovered = true - break - if exit_discovered: - break - var other_player_tiles: Array = [] - var all_players = get_tree().get_nodes_in_group("player") - for p in all_players: - if not is_instance_valid(p) or p in local_player_list: - continue - var pt = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE)) - other_player_tiles.append(pt) - minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered, other_player_tiles) + + if fog_node.has_method("set_debug_lines"): + fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW) + + # Fog texture: pass phase so fog updates only half the rows per tick (spread over 2 frames). Minimap every 2nd tick. + if fog_node.has_method("set_maps"): + fog_node.set_maps(explored_map, combined_seen, fog_visual_tick % 2) + + fog_visual_tick += 1 + if fog_visual_tick % 2 == 1: + var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw") + if minimap_draw and minimap_draw.has_method("set_maps") and dungeon_data.has("grid"): + var exit_tile := Vector2i(-1, -1) + var exit_discovered := false + if dungeon_data.has("stairs") and not dungeon_data.stairs.is_empty(): + var s = dungeon_data.stairs + if s.has("x") and s.has("y") and s.has("w") and s.has("h"): + exit_tile = Vector2i(s.x + s.w / 2, s.y + s.h / 2) + for dx in range(int(s.w)): + for dy in range(int(s.h)): + var tx: int = int(s.x) + dx + var ty: int = int(s.y) + dy + if tx >= 0 and ty >= 0 and tx < map_size.x and ty < map_size.y: + var idx: int = tx + ty * map_size.x + if idx >= 0 and idx < explored_map.size() and explored_map[idx] != 0: + exit_discovered = true + break + if exit_discovered: + break + var other_player_tiles: Array = [] + var all_players = get_tree().get_nodes_in_group("player") + for p in all_players: + if not is_instance_valid(p) or p in local_player_list: + continue + other_player_tiles.append(Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))) + # Build list of currently visible tile coords so minimap only updates those (smooth, no 1.8ms spike) + var visible_tiles: Array = [] + for i in range(combined_seen.size()): + if combined_seen[i] != 0: + visible_tiles.append(Vector2i(i % map_size.x, i / map_size.x)) + minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered, other_player_tiles, dungeon_data.get("rooms", []), visible_tiles) func _create_seen_array(map_size: Vector2i) -> PackedInt32Array: var size = map_size.x * map_size.y @@ -3549,6 +3476,32 @@ func _mark_seen(seen_map: PackedInt32Array, tile: Vector2i) -> void: if idx >= 0 and idx < seen_map.size(): seen_map[idx] = 1 +func _mark_room_in_seen_map(seen_map: PackedInt32Array, room: Dictionary) -> void: + if room.is_empty(): + return + var map_size = dungeon_data.map_size + for x in range(room.x - 2, room.x + room.w + 2): + for y in range(room.y - 2, room.y + room.h + 2): + if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y: + continue + var idx = x + y * map_size.x + if idx >= 0 and idx < seen_map.size(): + seen_map[idx] = 1 + +func _update_seen_for_player_simple(player: Node, seen_map: PackedInt32Array) -> void: + var p_tile = Vector2i(int(player.global_position.x / FOG_TILE_SIZE), int(player.global_position.y / FOG_TILE_SIZE)) + var current_room = _find_room_at_tile(p_tile) + if not current_room.is_empty(): + _mark_room_in_seen_map(seen_map, current_room) + return + var corridor_mask = _build_corridor_mask(p_tile) + var allowed_rooms = _get_rooms_connected_to_corridor(corridor_mask, p_tile) + for idx in range(corridor_mask.size()): + if corridor_mask[idx] == 1: + seen_map[idx] = 1 + for room in allowed_rooms: + _mark_room_in_seen_map(seen_map, room) + func _is_tile_in_room_or_walls(tile: Vector2i, room: Dictionary) -> bool: if room.is_empty(): return false @@ -3581,13 +3534,47 @@ func _mark_room_visible(room: Dictionary) -> void: if idx >= 0 and idx < combined_seen.size(): combined_seen[idx] = 1 +func _ensure_fog_tile_to_room_index() -> void: + var map_size = dungeon_data.map_size + var total = map_size.x * map_size.y + if fog_tile_to_room_index.size() == total: + return + fog_tile_to_room_index.resize(total) + for i in range(total): + fog_tile_to_room_index[i] = -1 + var rooms = dungeon_data.rooms + for room_idx in range(rooms.size()): + var room = rooms[room_idx] + for x in range(room.x, room.x + room.w): + for y in range(room.y, room.y + room.h): + if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y: + fog_tile_to_room_index[x + y * map_size.x] = room_idx + +func _rebuild_closed_door_tiles_cache() -> void: + _cached_closed_door_tiles.clear() + var entities_node = get_node_or_null("Entities") + if not entities_node: + return + for child in entities_node.get_children(): + if child.is_in_group("blocking_door") and "is_closed" in child and child.is_closed: + var tx = int(child.global_position.x / FOG_TILE_SIZE) + var ty = int(child.global_position.y / FOG_TILE_SIZE) + _cached_closed_door_tiles[str(tx) + "," + str(ty)] = true + func _find_room_at_tile(tile: Vector2i) -> Dictionary: if dungeon_data.is_empty() or not dungeon_data.has("rooms"): return {} - for room in dungeon_data.rooms: - if tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h: - return room - return {} + var map_size = dungeon_data.map_size + if tile.x < 0 or tile.y < 0 or tile.x >= map_size.x or tile.y >= map_size.y: + return {} + _ensure_fog_tile_to_room_index() + var idx = tile.x + tile.y * map_size.x + if idx < 0 or idx >= fog_tile_to_room_index.size(): + return {} + var ri: int = fog_tile_to_room_index[idx] + if ri < 0: + return {} + return dungeon_data.rooms[ri] func _get_room_index_for_tile(tile: Vector2i) -> int: if dungeon_data.is_empty() or not dungeon_data.has("rooms"): @@ -3777,20 +3764,9 @@ func _get_player_view_dir(player: Node) -> Vector2: return Vector2.RIGHT func _is_visibility_blocking_tile(tile: Vector2i) -> bool: - # Walls block vision if dungeon_data.grid[tile.x][tile.y] == 0: return true - - # Closed doors block vision - var entities_node = get_node_or_null("Entities") - if entities_node: - for door in entities_node.get_children(): - if door.is_in_group("blocking_door"): - if "is_closed" in door and door.is_closed: - var door_tile = Vector2i(int(door.global_position.x / FOG_TILE_SIZE), int(door.global_position.y / FOG_TILE_SIZE)) - if door_tile == tile: - return true - return false + return _cached_closed_door_tiles.get(str(tile.x) + "," + str(tile.y), false) func _build_corridor_mask(start_tile: Vector2i) -> PackedInt32Array: var map_size = dungeon_data.map_size @@ -3982,6 +3958,9 @@ func _generate_dungeon(): # Spawn room triggers _spawn_room_triggers() + # One-time warmup: instantiate each attack/projectile scene once so first enemy use doesn't stall (smooth in-game and WebAssembly) + _warmup_attack_scenes() + # Wait a frame to ensure enemies and objects are properly in scene tree before syncing await get_tree().process_frame @@ -4432,6 +4411,28 @@ func _render_dungeon(): # Randomize dungeon color scheme (seed-based) _apply_dungeon_color_scheme() + # Build fallout lookup cache so _is_position_on_fallout_tile is O(1) per call (avoids get_cell_tile_data in physics) + _build_fallout_tile_cache() + +func _build_fallout_tile_cache() -> void: + _fallout_tile_cache.resize(0) + _fallout_cache_map_size = Vector2i.ZERO + if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("map_size"): + return + var map_size: Vector2i = dungeon_data.map_size + var total := map_size.x * map_size.y + if total <= 0: + return + _fallout_cache_map_size = map_size + _fallout_tile_cache.resize(total) + for i in range(total): + _fallout_tile_cache[i] = 0 + for y in range(map_size.y): + for x in range(map_size.x): + var tile_pos = Vector2i(x, y) + var td = dungeon_tilemap_layer.get_cell_tile_data(tile_pos) + if td and td.get_custom_data("terrain") is int and int(td.get_custom_data("terrain")) == FALLOUT_TERRAIN_VALUE: + _fallout_tile_cache[x + y * map_size.x] = 1 func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = true): # Update player manager spawn points based on a room @@ -5334,7 +5335,7 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h print("GameWorld: Client - Spawning room triggers...") _spawn_room_triggers() print("GameWorld: Client - Room triggers spawned") - + _warmup_attack_scenes() # Wait a frame to ensure all enemies and objects are properly added to scene tree and initialized await get_tree().process_frame await get_tree().process_frame # Wait an extra frame to ensure enemies are fully ready @@ -5802,12 +5803,7 @@ func _reassemble_dungeon_blob(): print("GameWorld: Client - Spawning room triggers from blob...") _spawn_room_triggers() print("GameWorld: Client - Room triggers spawned") - - # CRITICAL for joiner: apply any boss spider spawns that arrived before dungeon was ready - if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"): - network_manager.apply_pending_boss_spider_spawns(self) - LogManager.log("GameWorld: Client applied pending boss spider spawns after dungeon blob", LogManager.CATEGORY_DUNGEON) - + _warmup_attack_scenes() # Apply door states (from metadata) - after doors are spawned if pending_door_states.size() > 0: print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...") @@ -6040,10 +6036,6 @@ func _sync_dungeon_entities(non_essential_data: Dictionary): # Spawn blocking doors and room triggers if not already spawned _spawn_blocking_doors() _spawn_room_triggers() - - # Joiner: apply any boss spider spawns that arrived before we had Entities ready - if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"): - network_manager.apply_pending_boss_spider_spawns(self) func _fix_player_appearance_after_dungeon_sync(): # Re-randomize appearance for all players that were spawned before dungeon_seed was received @@ -7602,13 +7594,23 @@ func _clear_level(): # Clear dungeon data (but keep it for now until new one is generated) # dungeon_data = {} # Don't clear yet, wait for new generation - # Clear fog of war + # Clear fog of war and minimap explored state so level 2 doesn't show level 1's explored rooms seen_by_player.clear() combined_seen = PackedInt32Array() + explored_map = PackedInt32Array() if fog_node and is_instance_valid(fog_node): fog_node.queue_free() fog_node = null - + fog_tile_to_room_index.resize(0) + _cached_closed_door_tiles.clear() + _fallout_tile_cache.resize(0) + _fallout_cache_map_size = Vector2i.ZERO + + # Clear minimap so it rebuilds for the new level + var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw") + if minimap_draw and minimap_draw.has_method("clear_for_new_level"): + minimap_draw.clear_for_new_level() + LogManager.log("GameWorld: Previous level cleared", LogManager.CATEGORY_DUNGEON) func _hide_all_players(): @@ -8905,26 +8907,6 @@ func _sync_exp_text_at_player(amount: float, player_peer_id: int): if player and is_instance_valid(player): _show_exp_number_at_player(amount, player) -# Show damage number at world position (e.g. when attacker hits boss so they always see the number) -func show_damage_number_at_position(world_pos: Vector2, amount: float, is_critical: bool = false) -> void: - 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(int(amount)) - damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35) - damage_label.z_index = 50 - damage_label.direction = Vector2(0, -1) - var entities_node = get_node_or_null("Entities") - if entities_node: - entities_node.add_child(damage_label) - damage_label.global_position = world_pos + Vector2(0, -16) - else: - get_tree().current_scene.add_child(damage_label) - damage_label.global_position = world_pos + Vector2(0, -16) - func _show_exp_number_at_position(amount: float, exp_pos: Vector2): # Show EXP number (green, using dmg_numbers.png font) at a specific position var damage_number_scene = preload("res://scenes/damage_number.tscn") @@ -10415,18 +10397,51 @@ func _spawn_room_triggers(): LogManager.log("GameWorld: Added room trigger " + str(trigger.name) + " for room (" + str(room.x) + ", " + str(room.y) + ") - " + str(triggers_spawned) + "/" + str(rooms.size()), LogManager.CATEGORY_DUNGEON) LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON) - - # Explicitly connect every blocking door to its room trigger (ensures boss room and all puzzle room doors close on enter) - _connect_all_doors_to_room_triggers() -func _connect_all_doors_to_room_triggers(): +func _warmup_attack_scenes() -> void: + # Instantiate each attack/projectile scene once so first use by enemies doesn't cause a frame spike (important for WebAssembly). var entities_node = get_node_or_null("Entities") if not entities_node: return - for child in entities_node.get_children(): - if (child.is_in_group("blocking_door") or (child.name and child.name.begins_with("BlockingDoor_"))) and is_instance_valid(child): - if child.get("blocking_room") and not child.blocking_room.is_empty(): - _connect_door_to_room_trigger(child) + var scenes = [ + preload("res://scenes/attack_arrow.tscn"), + preload("res://scenes/attack_bomb.tscn"), + preload("res://scenes/sword_projectile.tscn"), + preload("res://scenes/attack_spell_flame.tscn"), + preload("res://scenes/attack_spell_frostspike.tscn"), + preload("res://scenes/attack_web_shot.tscn"), + preload("res://scenes/enemy_spider.tscn"), + preload("res://scenes/debuff_burn.tscn"), + preload("res://scenes/explosion_tile_particle.tscn"), + preload("res://scenes/floating_text.tscn"), + ] + for s in scenes: + var n = s.instantiate() + entities_node.add_child(n) + n.queue_free() + # Warmup push/drag: first time pushing triggers drag SFX and particles; preload by playing once then stopping + var push_scene = preload("res://scenes/interactable_object.tscn") as PackedScene + if push_scene: + var push_obj = push_scene.instantiate() + entities_node.add_child(push_obj) + if push_obj.has_method("play_drag_sound"): + push_obj.play_drag_sound() + if push_obj.has_method("stop_drag_sound"): + push_obj.stop_drag_sound() + push_obj.queue_free() + # Warmup physics queries (first use can spike - push uses intersect_shape) + var space = get_world_2d().direct_space_state + var pq = PhysicsPointQueryParameters2D.new() + pq.position = Vector2(-10000, -10000) + pq.collision_mask = 1 + space.intersect_point(pq) + var rect = RectangleShape2D.new() + rect.size = Vector2(16, 16) + var sq = PhysicsShapeQueryParameters2D.new() + sq.shape = rect + sq.transform = Transform2D(0, Vector2(-10000, -10000)) + sq.collision_mask = 1 + space.intersect_shape(sq) func _place_key_in_room(room: Dictionary): # Place a key in the specified room (as loot) @@ -10505,8 +10520,8 @@ func _connect_door_to_room_trigger(door: Node): if trigger_room and not trigger_room.is_empty() and \ trigger_room.x == blocking_room.x and trigger_room.y == blocking_room.y and \ trigger_room.w == blocking_room.w and trigger_room.h == blocking_room.h: - # Connect door to trigger (avoid duplicate if room_trigger._find_room_entities already added it) + # Connect door to trigger door.room_trigger_area = trigger - if door not in trigger.doors_in_room: - trigger.doors_in_room.append(door) + # Add door to trigger's doors list (doors_in_room is a variable in room_trigger.gd) + trigger.doors_in_room.append(door) break diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index 7b1e4bc..65e9276 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -1,6 +1,8 @@ extends CharacterBody2D var tileParticleScene = preload("res://scenes/tile_particle.tscn") +# Preload so _convert_to_bomb_projectile doesn't spike (~25ms load() midgame) +const _ATTACK_BOMB_SCENE: PackedScene = preload("res://scenes/attack_bomb.tscn") # Interactable Object - Can be grabbed, pushed, pulled, lifted, and thrown @@ -56,6 +58,9 @@ var falling_into_fallout: bool = false var fallout_sink_progress: float = 1.0 const FALLOUT_SINK_DURATION: float = 0.4 +# Cache game_world for _physics_process hot path (avoids get_first_node_in_group per object per frame) +var _cached_gw: Node = null + func _ready(): # Make sure it's on the interactable layer collision_layer = 2 # Layer 2 for objects @@ -178,8 +183,9 @@ func _physics_process(delta): if not is_frozen: # Fallout: sink and disappear when on ground (not held, not airborne). Pillars must never sink. if not is_airborne and position_z <= 0.0 and object_type != "Pillar": - var gw = get_tree().get_first_node_in_group("game_world") - if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position): + if _cached_gw == null or not is_instance_valid(_cached_gw): + _cached_gw = get_tree().get_first_node_in_group("game_world") + if _cached_gw and _cached_gw.has_method("_is_position_on_fallout_tile") and _cached_gw._is_position_on_fallout_tile(global_position): if not falling_into_fallout: falling_into_fallout = true fallout_sink_progress = 1.0 @@ -661,57 +667,34 @@ func on_thrown(by_player, force: Vector2): position_z = 2.5 velocity_z = 100.0 # Scaled down for 1x scale - print(name, " thrown with velocity ", throw_velocity) - func _convert_to_bomb_projectile(by_player, force: Vector2): - # Convert bomb object to projectile bomb when thrown - var attack_bomb_scene = load("res://scenes/attack_bomb.tscn") - if not attack_bomb_scene: - push_error("ERROR: Could not load attack_bomb scene!") - return - - # Only authority can spawn bombs + # Defer so we don't change physics state (add_child / collision) while inside a physics callback (e.g. trap damage -> take_damage -> throw) if not is_multiplayer_authority(): return - - # Store current position before freeing - var current_pos = global_position - - # Spawn bomb projectile at current position - var bomb = attack_bomb_scene.instantiate() - bomb.name = "ThrownBomb_" + name - get_parent().add_child(bomb) - bomb.global_position = current_pos # Use current position, not target - - # Set multiplayer authority + call_deferred("_convert_to_bomb_projectile_deferred", global_position, get_parent(), name, by_player, force) + +func _convert_to_bomb_projectile_deferred(current_pos: Vector2, parent_node: Node, obj_name: String, by_player, force: Vector2): + if not is_instance_valid(parent_node) or not is_instance_valid(by_player): + return + var bomb = _ATTACK_BOMB_SCENE.instantiate() + bomb.name = "ThrownBomb_" + obj_name + parent_node.add_child(bomb) + bomb.global_position = current_pos if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(by_player.get_multiplayer_authority()) - - # Setup bomb with throw physics (pass force as throw_velocity) - # The bomb will use throw_velocity for movement - bomb.setup(current_pos, by_player, force, true) # true = is_thrown, force is throw_velocity - - # Make sure bomb sprite is visible + bomb.setup(current_pos, by_player, force, true) if bomb.has_node("Sprite2D"): bomb.get_node("Sprite2D").visible = true - - # Sync bomb throw to other clients if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority(): if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): - # Player threw: sync via player RPC (pass our name so they can free the lifted bomb) - by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force]) + by_player._rpc_to_ready_peers("_sync_throw_bomb", [obj_name, current_pos, force]) elif by_player.is_in_group("enemy"): - # Enemy threw: sync via game_world so joiners see the bomb var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_rpc_to_ready_peers"): var enemy_index = by_player.get_meta("enemy_index") if by_player.has_meta("enemy_index") else -1 var bomb_name = "EnemyBomb_" + by_player.name + "_" + str(Time.get_ticks_msec()) game_world._rpc_to_ready_peers("_sync_enemy_throw_bomb", [bomb_name, by_player.name, enemy_index, current_pos, force]) - - # Remove the interactable object queue_free() - - print("Bomb object converted to projectile and thrown!") @rpc("authority", "unreliable") func _sync_box_state(pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool): diff --git a/src/scripts/item_database.gd b/src/scripts/item_database.gd index 22935c6..ce18bb8 100644 --- a/src/scripts/item_database.gd +++ b/src/scripts/item_database.gd @@ -1886,6 +1886,58 @@ static func _register_item(item_id: String, item_data: Dictionary): item_data["item_id"] = item_id item_definitions[item_id] = item_data +# Collect all unique texture paths used for equipment and character appearance (for preloading to avoid equip spikes) +static func get_all_equipment_and_appearance_paths() -> Array: + _initialize() + var paths: Array = [] + var seen: Dictionary = {} + for item_id in item_definitions: + var data = item_definitions[item_id] + if data.has("equipmentPath"): + var p = data["equipmentPath"] + if p is String and p != "" and not seen.get(p, false): + seen[p] = true + paths.append(p) + # Character appearance paths (skin, hair, eyes, etc.) - same set as used by CharacterStats/player + for i in range(1, 8): + var p = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human" + str(i) + ".png" + if not seen.get(p, false): + seen[p] = true + paths.append(p) + var facial_bases = ["Beardstyle1", "Beardstyle2", "Mustache1"] + for name in facial_bases: + var p = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Facial Hairstyles/" + name + "White.png" + if not seen.get(p, false): + seen[p] = true + paths.append(p) + for i in range(1, 5): + var p = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/FHairstyle" + str(i) + "White.png" + if not seen.get(p, false): + seen[p] = true + paths.append(p) + for i in range(1, 9): # MHairstyle1..8 (iType 5..12 in setHair) + var p = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/MHairstyle" + str(i) + "White.png" + if not seen.get(p, false): + seen[p] = true + paths.append(p) + var eyelash_paths = ["res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/FEyelash1.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/FEyelash2.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/FEyelash3.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/MEyelash1.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/MEyelash2.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/NEyelash1.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/NEyelash2.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/NEyelash3.png"] + for p in eyelash_paths: + if not seen.get(p, false): + seen[p] = true + paths.append(p) + var eye_names = ["Black", "Blue", "Cyan", "DarkBlue", "DarkCyan", "DarkLime", "DarkRed", "FullBlack", "FullWhite", "Gray", "LightLime", "Orange", "Red", "Yellow"] + for name in eye_names: + var p = "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eye Color/Eyecolor" + name + ".png" + if not seen.get(p, false): + seen[p] = true + paths.append(p) + for i in range(1, 8): + var p = "res://assets/gfx/Puny-Characters/Layer 7 - Add-ons/Elf Add-ons/ElfEars" + str(i) + ".png" + if not seen.get(p, false): + seen[p] = true + paths.append(p) + return paths + # Create an Item instance from an item ID static func create_item(item_id: String) -> Item: _initialize() diff --git a/src/scripts/loader.gd b/src/scripts/loader.gd new file mode 100644 index 0000000..396acd9 --- /dev/null +++ b/src/scripts/loader.gd @@ -0,0 +1,119 @@ +extends Node + +# Loader: runs heavy first-time warmup (preload, instantiate, physics, etc.) with a progress bar +# so the game doesn't stutter when e.g. first bomb throw, first push, etc. happen. +# When done, switches to main menu. + +@onready var progress_bar: ProgressBar = $CanvasLayer/MarginContainer/VBox/ProgressBar +@onready var status_label: Label = $CanvasLayer/MarginContainer/VBox/StatusLabel +@onready var title_label: Label = $CanvasLayer/MarginContainer/VBox/TitleLabel + +var _steps: Array = [] # [{"label": str, "callable": Callable}, ...] +var _current_step: int = 0 + +func _ready() -> void: + _build_steps() + _run_loading_steps() + +func _build_steps() -> void: + # Order matters: do the heaviest / most likely to stutter first (e.g. bomb convert path) + _steps.clear() + _steps.append({"label": "Preloading bomb & projectiles...", "callable": Callable(self, "_warmup_bomb_and_projectiles")}) + _steps.append({"label": "Preloading attacks & effects...", "callable": Callable(self, "_warmup_attacks_and_effects")}) + _steps.append({"label": "Preloading enemies & objects...", "callable": Callable(self, "_warmup_enemies_and_objects")}) + _steps.append({"label": "Preloading equipment & appearance...", "callable": Callable(self, "_warmup_appearance_textures")}) + _steps.append({"label": "Preloading UI & feedback...", "callable": Callable(self, "_warmup_ui_and_feedback")}) + _steps.append({"label": "Warming physics...", "callable": Callable(self, "_warmup_physics")}) + _steps.append({"label": "Finalizing...", "callable": Callable(self, "_warmup_final")}) + +func _set_progress(ratio: float, text: String = "") -> void: + if progress_bar != null: + progress_bar.value = ratio * 100.0 + if status_label != null and text != "": + status_label.text = text + +func _run_loading_steps() -> void: + var total := _steps.size() + for i in range(total): + _current_step = i + var step = _steps[i] + _set_progress(float(i) / float(total), step.label) + await get_tree().process_frame + if step.callable.is_valid(): + step.callable.call() + await get_tree().process_frame + _set_progress(1.0, "Ready!") + await get_tree().create_timer(0.3).timeout + get_tree().change_scene_to_file("res://scenes/main_menu.tscn") + +# --- Warmup steps (each runs in one "tick"; heavy work so first use in-game doesn't stutter) --- + +func _warmup_bomb_and_projectiles() -> void: + # _convert_to_bomb_projectile uses load() first time -> 32ms; warm by loading + instantiating + var attack_bomb = load("res://scenes/attack_bomb.tscn") as PackedScene + if attack_bomb: + var n = attack_bomb.instantiate() + add_child(n) + if n.has_method("setup"): + n.setup(Vector2.ZERO, null, Vector2.ZERO, false) + n.queue_free() + for path in ["res://scenes/attack_arrow.tscn", "res://scenes/sword_projectile.tscn", "res://scenes/attack_spell_flame.tscn", "res://scenes/attack_spell_frostspike.tscn", "res://scenes/attack_bomb.tscn"]: + var s = load(path) as PackedScene + if s: + var n = s.instantiate() + add_child(n) + n.queue_free() + +func _warmup_attacks_and_effects() -> void: + for path in ["res://scenes/attack_web_shot.tscn", "res://scenes/attack_punch.tscn", "res://scenes/attack_axe_swing.tscn", "res://scenes/attack_staff.tscn", "res://scenes/explosion_tile_particle.tscn", "res://scenes/floating_text.tscn", "res://scenes/debuff_burn.tscn", "res://scenes/healing_effect.tscn", "res://scenes/detected_effect.tscn"]: + var s = load(path) as PackedScene + if s: + var n = s.instantiate() + add_child(n) + n.queue_free() + +func _warmup_enemies_and_objects() -> void: + for path in ["res://scenes/enemy_spider.tscn", "res://scenes/enemy_humanoid.tscn", "res://scenes/enemy_hand.tscn", "res://scenes/enemy_slime.tscn", "res://scenes/enemy_rat.tscn", "res://scenes/enemy_bat.tscn", "res://scenes/boss_spider_bat.tscn", "res://scenes/interactable_object.tscn", "res://scenes/loot.tscn", "res://scenes/blood_clot.tscn"]: + var s = load(path) as PackedScene + if s: + var n = s.instantiate() + add_child(n) + if n.has_method("play_drag_sound"): + n.play_drag_sound() + if n.has_method("stop_drag_sound"): + n.stop_drag_sound() + n.queue_free() + +func _warmup_appearance_textures() -> void: + var cache = get_node_or_null("/root/AppearanceTextureCache") + if cache and cache.has_method("preload_all"): + cache.preload_all() + +func _warmup_ui_and_feedback() -> void: + for path in ["res://scenes/door.tscn", "res://scenes/trap.tscn", "res://scenes/damage_number.tscn", "res://scenes/smoke_puff.tscn", "res://scenes/fire.tscn", "res://scenes/ingame_hud.tscn", "res://scenes/minimap.tscn"]: + var s = load(path) as PackedScene + if s: + var n = s.instantiate() + add_child(n) + n.queue_free() + +func _warmup_physics() -> void: + var vp = get_viewport() + var w2d = vp.get_world_2d() if vp else null + var space = w2d.direct_space_state if w2d else null + if space: + var pq = PhysicsPointQueryParameters2D.new() + pq.position = Vector2(-10000, -10000) + pq.collision_mask = 1 + space.intersect_point(pq) + var rect = RectangleShape2D.new() + rect.size = Vector2(16, 16) + var sq = PhysicsShapeQueryParameters2D.new() + sq.shape = rect + sq.transform = Transform2D(0, Vector2(-10000, -10000)) + sq.collision_mask = 1 + space.intersect_shape(sq) + +func _warmup_final() -> void: + # One more frame so any queued frees complete + pass diff --git a/src/scripts/loader.gd.uid b/src/scripts/loader.gd.uid new file mode 100644 index 0000000..a61f016 --- /dev/null +++ b/src/scripts/loader.gd.uid @@ -0,0 +1 @@ +uid://d043ergi5kef1 diff --git a/src/scripts/loot.gd b/src/scripts/loot.gd index 24c55d8..7ad4e8c 100644 --- a/src/scripts/loot.gd +++ b/src/scripts/loot.gd @@ -1,6 +1,9 @@ extends CharacterBody2D # Loot Item - Coins and food items that drop from enemies +# Preload common textures so first pickup doesn't spike (load() was ~55ms) +const _TEX_COIN: Texture2D = preload("res://assets/gfx/pickups/gold_coin.png") +const _TEX_ITEMS: Texture2D = preload("res://assets/gfx/pickups/items_n_shit.png") enum LootType { COIN, @@ -117,39 +120,35 @@ func _setup_sprite(): match loot_type: LootType.COIN: - # Load coin texture - var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") - if coin_texture and sprite: + if _TEX_COIN and sprite: + var coin_texture = _TEX_COIN sprite.texture = coin_texture sprite.hframes = 6 sprite.vframes = 1 sprite.frame = 0 - print("Coin sprite setup: texture=", coin_texture != null, " hframes=", sprite.hframes, " vframes=", sprite.vframes, " frame=", sprite.frame) - else: - print("ERROR: Coin texture or sprite is null! texture=", coin_texture, " sprite=", sprite) LootType.APPLE: - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + var items_texture = _TEX_ITEMS if items_texture: sprite.texture = items_texture sprite.hframes = 20 sprite.vframes = 14 sprite.frame = (8 * 20) + 10 LootType.BANANA: - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + var items_texture = _TEX_ITEMS if items_texture: sprite.texture = items_texture sprite.hframes = 20 sprite.vframes = 14 sprite.frame = (8 * 20) + 11 LootType.CHERRY: - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + var items_texture = _TEX_ITEMS if items_texture: sprite.texture = items_texture sprite.hframes = 20 sprite.vframes = 14 sprite.frame = (8 * 20) + 12 LootType.KEY: - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") + var items_texture = _TEX_ITEMS if items_texture: sprite.texture = items_texture sprite.hframes = 20 @@ -492,10 +491,7 @@ func _on_pickup_area_body_entered(body): var player_peer_id = body.get_multiplayer_authority() if player_peer_id == dropped_by_peer_id and time_since_drop < 5.0: # Player can't pick up their own dropped item for 5 seconds - print("Loot: Player ", body.name, " cannot pick up item they dropped (", time_since_drop, "s ago, need 5s cooldown)") return - - print("Loot: Pickup area entered by player: ", body.name, " is_local: ", body.is_local_player if "is_local_player" in body else "unknown", " is_server: ", multiplayer.is_server()) _pickup(body) func _pickup(player: Node): @@ -503,14 +499,8 @@ func _pickup(player: Node): return # Prevent multiple pickups if collected: - print("Loot: Already collected, ignoring pickup") return - var player_auth_str = "N/A" - if "get_multiplayer_authority" in player: - player_auth_str = str(player.get_multiplayer_authority()) - print("Loot: _pickup called by player: ", player.name, " is_server: ", multiplayer.is_server(), " has_peer: ", multiplayer.has_multiplayer_peer(), " player_authority: ", player_auth_str) - # In multiplayer, only process on server or if player has authority # If client player picks it up, send RPC to server if multiplayer.has_multiplayer_peer(): @@ -519,7 +509,6 @@ func _pickup(player: Node): if player.is_multiplayer_authority(): # This is the local player, send request to server var player_peer_id = player.get_multiplayer_authority() - print("Loot: Client sending pickup request to server for player peer_id: ", player_peer_id) # Route through game_world to avoid node path issues var game_world = get_tree().get_first_node_in_group("game_world") var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 @@ -528,8 +517,6 @@ func _pickup(player: Node): else: # Fallback: try direct RPC rpc_id(1, "_request_pickup", player_peer_id) - else: - print("Loot: Client player does not have authority, cannot pickup") return else: # Server: If player doesn't have authority, this is a client player @@ -559,17 +546,10 @@ func _process_pickup_on_server(player: Node): pickup_area.set_deferred("monitorable", false) # Sync removal to all clients FIRST (before processing pickup) - # This ensures clients remove the loot even if host processes it - # Use game_world to route removal sync instead of direct RPC to avoid node path issues - if multiplayer.has_multiplayer_peer() and is_inside_tree(): + var gw = get_tree().get_first_node_in_group("game_world") if is_inside_tree() else null + if multiplayer.has_multiplayer_peer() and is_inside_tree() and gw and gw.has_method("_sync_loot_remove"): var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_loot_remove"): - print("Loot: Server syncing removal of loot id=", loot_id, " at ", global_position) - game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position]) - else: - # If GameWorld isn't ready, skip removal sync to avoid node path RPC errors - print("Loot: GameWorld not ready, skipping removal sync for loot id=", loot_id) + gw._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position]) match loot_type: LootType.COIN: @@ -579,13 +559,10 @@ func _process_pickup_on_server(player: Node): if player.has_method("add_coins"): player.add_coins(coin_value) # Show floating text with item graphic and text - var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") - _show_floating_text(player, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0.5, 0.5, coin_texture, 6, 1, 0) + _show_floating_text(player, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0.5, 0.5, _TEX_COIN, 6, 1, 0) # Sync floating text to client via GameWorld to avoid loot node path RPCs - if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0, player.get_multiplayer_authority()) + if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"): + gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0, player.get_multiplayer_authority()) self.visible = false @@ -621,15 +598,11 @@ func _process_pickup_on_server(player: Node): player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) # Show floating text with item name (uppercase) - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var display_text = "APPLE" var text_color = Color.GREEN - _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10) - # Sync floating text to client via GameWorld to avoid loot node path RPCs - if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 10, player.get_multiplayer_authority()) + _show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 10) + if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"): + gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 10, player.get_multiplayer_authority()) self.visible = false @@ -665,15 +638,11 @@ func _process_pickup_on_server(player: Node): player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) # Show floating text with item name (uppercase) - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var display_text = "BANANA" var text_color = Color.GREEN - _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11) - # Sync floating text to client via GameWorld to avoid loot node path RPCs - if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 11, player.get_multiplayer_authority()) + _show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 11) + if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"): + gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 11, player.get_multiplayer_authority()) self.visible = false @@ -709,15 +678,11 @@ func _process_pickup_on_server(player: Node): player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) # Show floating text with item name (uppercase) - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var display_text = "CHERRY" var text_color = Color.GREEN - _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12) - # Sync floating text to client via GameWorld to avoid loot node path RPCs - if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 12, player.get_multiplayer_authority()) + _show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 12) + if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"): + gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 12, player.get_multiplayer_authority()) self.visible = false @@ -732,13 +697,9 @@ func _process_pickup_on_server(player: Node): if player.has_method("add_key"): player.add_key(1) # Show floating text with item graphic and text - var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") - _show_floating_text(player, "+1 KEY", Color.YELLOW, 0.5, 0.5, items_texture, 20, 14, (13 * 20) + 10) - # Sync floating text to client via GameWorld to avoid loot node path RPCs - if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, (13 * 20) + 10, player.get_multiplayer_authority()) + _show_floating_text(player, "+1 KEY", Color.YELLOW, 0.5, 0.5, _TEX_ITEMS, 20, 14, (13 * 20) + 10) + if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"): + gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, (13 * 20) + 10, player.get_multiplayer_authority()) self.visible = false @@ -786,8 +747,8 @@ func _process_pickup_on_server(player: Node): if player.has_method("_apply_inventory_and_equipment_from_server"): player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) - # Show floating text with item name (uppercase) - var items_texture = load(item.spritePath) + # Show floating text with item name (uppercase) (item.spritePath is dynamic so load; Godot caches after first) + var items_texture: Texture2D = load(item.spritePath) as Texture2D var display_text = item.item_name.to_upper() # Always uppercase var text_color = Color.WHITE @@ -798,12 +759,8 @@ func _process_pickup_on_server(player: Node): text_color = Color.GREEN # Green for consumables _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, item.spriteFrames.x, item.spriteFrames.y, item.spriteFrame, item) - - # Sync floating text to client via GameWorld to avoid loot node path RPCs - if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: - var game_world = get_tree().get_first_node_in_group("game_world") - if game_world and game_world.has_method("_sync_loot_floating_text"): - game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, item.spriteFrame, player.get_multiplayer_authority()) + if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"): + gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, item.spriteFrame, player.get_multiplayer_authority()) self.visible = false @@ -945,18 +902,11 @@ func _sync_show_floating_text(loot_type_value: int, text: String, color_value: C match loot_type_value: LootType.COIN: - item_texture = load("res://assets/gfx/pickups/gold_coin.png") + item_texture = _TEX_COIN sprite_hframes = 6 sprite_vframes = 1 - LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.KEY: - item_texture = load("res://assets/gfx/pickups/items_n_shit.png") - sprite_hframes = 20 - sprite_vframes = 14 - LootType.ITEM: - # Item instance - use item's sprite path - # Note: item data is not available on client in this sync, so we use default - # The actual item sprite is set when the loot is created - item_texture = load("res://assets/gfx/pickups/items_n_shit.png") + LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.KEY, LootType.ITEM: + item_texture = _TEX_ITEMS sprite_hframes = 20 sprite_vframes = 14 diff --git a/src/scripts/minimap.gd b/src/scripts/minimap.gd index c05a704..47689a8 100644 --- a/src/scripts/minimap.gd +++ b/src/scripts/minimap.gd @@ -1,7 +1,6 @@ extends Control -# Minimap: shows explored dungeon tiles (uses same "explored" data as fog of war). -# Populates as the player explores. Drawn in upper-right corner. +# Minimap: shows explored dungeon by room. Room textures are pre-built once; we only toggle visibility. const MINIMAP_WIDTH: int = 128 const MINIMAP_HEIGHT: int = 96 @@ -9,19 +8,76 @@ const COLOR_UNEXPLORED: Color = Color(0.08, 0.08, 0.1) const COLOR_WALL: Color = Color(0.22, 0.22, 0.26) const COLOR_FLOOR: Color = Color(0.38, 0.38, 0.44) const COLOR_PLAYER: Color = Color(1.0, 0.35, 0.35) -const COLOR_OTHER_PLAYER: Color = Color(0.35, 0.6, 1.0) # Blue for other players +const COLOR_OTHER_PLAYER: Color = Color(0.35, 0.6, 1.0) const COLOR_EXIT: Color = Color(1.0, 1.0, 1.0) var _map_size: Vector2i = Vector2i.ZERO var _explored_map: PackedInt32Array = PackedInt32Array() -var _grid: Array = [] # 2D grid [x][y]: 0=wall, 1=floor, 2=door, 3=corridor +var _grid: Array = [] var _player_tile: Vector2i = Vector2i(-1, -1) -var _other_player_tiles: Array = [] # Array of Vector2i for other players +var _other_player_tiles: Array = [] var _exit_tile: Vector2i = Vector2i(-1, -1) var _exit_discovered: bool = false +# Pre-built room tiles: one TextureRect per room, visible = true when room is explored +var _room_rects: Array = [] # [{ "rect": TextureRect, "room": Dictionary }, ...] +var _background_rect: ColorRect = null +var _room_textures_built: bool = false +# Corridor layer: half-res image for tiles not in any room; updated each set_maps +var _corridor_rect: TextureRect = null +var _corridor_image: Image = null +var _corridor_texture: ImageTexture = null +# Precomputed: 1 = tile is inside any room, 0 = corridor/door/wall outside rooms. Index: x + y * _map_size.x +var _room_mask: PackedByteArray = PackedByteArray() +# Spread corridor texture over 4 frames when doing full update +var _corridor_band_index: int = 0 +const CORRIDOR_BANDS: int = 4 +const CORRIDOR_ROWS_PER_BAND: int = 24 # MINIMAP_HEIGHT 96 / 4 +# When non-empty, we only update corridor pixels for these tile coords (vision-based; smooth, no spike) +var _visible_tiles: Array = [] +# Overlay draws player/exit on top of room/corridor layers (child with z_index 10) +var _overlay_control: Control = null -func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, player_tile: Vector2i = Vector2i(-1, -1), exit_tile: Vector2i = Vector2i(-1, -1), exit_discovered: bool = false, other_player_tiles: Array = []) -> void: +func get_overlay_data() -> Dictionary: + return { + "map_size": _map_size, + "player_tile": _player_tile, + "exit_tile": _exit_tile, + "exit_discovered": _exit_discovered, + "other_player_tiles": _other_player_tiles + } + +func clear_for_new_level() -> void: + # Tear down room/corridor layers so next set_maps() rebuilds for the new dungeon + var to_remove := [] + for child in get_children(): + if child != _overlay_control: + to_remove.append(child) + for c in to_remove: + c.queue_free() + _room_rects.clear() + _room_mask.resize(0) + _room_textures_built = false + _background_rect = null + _corridor_rect = null + _corridor_image = null + _corridor_texture = null + _minimap_image = null + _minimap_texture = null + _map_size = Vector2i.ZERO + _explored_map = PackedInt32Array() + _grid = [] + _player_tile = Vector2i(-1, -1) + _other_player_tiles = [] + _exit_tile = Vector2i(-1, -1) + _exit_discovered = false + _corridor_band_index = 0 + _visible_tiles = [] + queue_redraw() + if _overlay_control: + _overlay_control.queue_redraw() + +func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, player_tile: Vector2i = Vector2i(-1, -1), exit_tile: Vector2i = Vector2i(-1, -1), exit_discovered: bool = false, other_player_tiles: Array = [], rooms: Array = [], visible_tiles: Array = []) -> void: _explored_map = explored_map _map_size = map_size _grid = grid @@ -29,63 +85,244 @@ func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, p _other_player_tiles = other_player_tiles _exit_tile = exit_tile _exit_discovered = exit_discovered + _visible_tiles = visible_tiles + + # One-time: build a texture per room (pre-made), all initially hidden + if rooms.size() > 0 and _map_size.x > 0 and _map_size.y > 0 and not _room_textures_built: + _build_room_textures(rooms) + _room_textures_built = true + + if _room_rects.size() > 0: + # Only update visibility from explored state (no Image work) + for entry in _room_rects: + var room: Dictionary = entry.room + entry.rect.visible = _is_room_explored(room) + # Corridor layer: tiles not in any room (explored = floor, else unexplored) + _update_corridor_texture() + else: + # Fallback: no rooms passed, use single full texture (legacy path) + _update_minimap_texture() queue_redraw() + if _overlay_control: + _overlay_control.queue_redraw() func _ready() -> void: custom_minimum_size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT) mouse_filter = Control.MOUSE_FILTER_IGNORE + # Overlay draws player/exit on top so they are not covered by room/corridor TextureRects + _overlay_control = Control.new() + _overlay_control.name = "MinimapOverlay" + _overlay_control.set_script(load("res://scripts/minimap_overlay.gd") as GDScript) + add_child(_overlay_control) func _process(_delta: float) -> void: if _exit_discovered: queue_redraw() + if _overlay_control: + _overlay_control.queue_redraw() -func _draw() -> void: +func _build_room_textures(rooms: Array) -> void: + # Background: full minimap in unexplored color + _background_rect = ColorRect.new() + _background_rect.name = "MinimapBackground" + _background_rect.set_anchors_preset(Control.PRESET_FULL_RECT) + _background_rect.color = COLOR_UNEXPLORED + _background_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_background_rect) + _background_rect.z_index = -1 + + # Room mask: 1 if tile is in any room, 0 otherwise (O(1) lookup in corridor update) + var mask_size := _map_size.x * _map_size.y + _room_mask.resize(mask_size) + for i in range(mask_size): + _room_mask[i] = 0 + for room in rooms: + if room.is_empty() or not room.has("x") or not room.has("y") or not room.has("w") or not room.has("h"): + continue + for y in range(room.y, room.y + room.h): + for x in range(room.x, room.x + room.w): + if x >= 0 and x < _map_size.x and y >= 0 and y < _map_size.y: + _room_mask[x + y * _map_size.x] = 1 + + # Corridor layer: full minimap res so 1-tile-wide corridors are visible + _corridor_image = Image.create(MINIMAP_WIDTH, MINIMAP_HEIGHT, false, Image.FORMAT_RGBA8) + _corridor_image.fill(Color(0, 0, 0, 0)) + _corridor_texture = ImageTexture.create_from_image(_corridor_image) + _corridor_rect = TextureRect.new() + _corridor_rect.name = "MinimapCorridors" + _corridor_rect.set_anchors_preset(Control.PRESET_TOP_LEFT) + _corridor_rect.position = Vector2.ZERO + _corridor_rect.size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT) + _corridor_rect.texture = _corridor_texture + _corridor_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _corridor_rect.stretch_mode = TextureRect.STRETCH_SCALE + _corridor_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_corridor_rect) + _corridor_rect.z_index = 0 + + var scale_x: float = float(MINIMAP_WIDTH) / float(_map_size.x) + var scale_y: float = float(MINIMAP_HEIGHT) / float(_map_size.y) + + for room in rooms: + if room.is_empty() or not room.has("x") or not room.has("y") or not room.has("w") or not room.has("h"): + continue + var rx: int = room.x + var ry: int = room.y + var rw: int = room.w + var rh: int = room.h + # Room size in minimap pixels (at least 1x1) + var pw: int = maxi(1, int(rw * scale_x)) + var ph: int = maxi(1, int(rh * scale_y)) + var img := Image.create(pw, ph, false, Image.FORMAT_RGBA8) + for py in range(ph): + for px in range(pw): + var tx: int = rx + int(px * rw / float(pw)) + var ty: int = ry + int(py * rh / float(ph)) + tx = clampi(tx, 0, _map_size.x - 1) + ty = clampi(ty, 0, _map_size.y - 1) + var g: int = 0 + if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty: + var row = _grid[tx] as Array + g = int(row[ty]) + var col: Color = COLOR_WALL if g == 0 else COLOR_FLOOR + img.set_pixel(px, py, col) + var tex := ImageTexture.create_from_image(img) + var rect := TextureRect.new() + rect.texture = tex + rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + rect.stretch_mode = TextureRect.STRETCH_SCALE + rect.position = Vector2(rx * scale_x, ry * scale_y) + rect.size = Vector2(pw, ph) + rect.visible = false + rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(rect) + rect.z_index = 1 + _room_rects.append({ "rect": rect, "room": room }) + + +func _is_room_explored(room: Dictionary) -> bool: + if _explored_map.is_empty() or room.is_empty(): + return false + var stride: int = _map_size.x + for y in range(room.y, room.y + room.h): + for x in range(room.x, room.x + room.w): + if x < 0 or y < 0 or x >= _map_size.x or y >= _map_size.y: + continue + var idx: int = x + y * stride + if idx >= 0 and idx < _explored_map.size() and _explored_map[idx] != 0: + return true + return false + + +func _update_corridor_texture() -> void: + if _corridor_rect == null or _corridor_image == null or _map_size.x <= 0 or _map_size.y <= 0 or _room_mask.is_empty(): + return + var stride := _map_size.x + var cw := float(MINIMAP_WIDTH) + var ch := float(MINIMAP_HEIGHT) + # Vision-based: only update pixels for tiles the player currently sees (smooth, ~200–500 set_pixels instead of 12k) + if _visible_tiles.size() > 0: + for t in _visible_tiles: + var tx: int = t.x if t is Vector2i else int(t.x) + var ty: int = t.y if t is Vector2i else int(t.y) + if tx < 0 or ty < 0 or tx >= _map_size.x or ty >= _map_size.y: + continue + var mask_idx := tx + ty * stride + if mask_idx >= _room_mask.size() or _room_mask[mask_idx] != 0: + continue + var px := int(tx * cw / float(_map_size.x)) + var py := int(ty * ch / float(_map_size.y)) + px = clampi(px, 0, MINIMAP_WIDTH - 1) + py = clampi(py, 0, MINIMAP_HEIGHT - 1) + var g: int = 0 + if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty: + var row = _grid[tx] as Array + g = int(row[ty]) + if g == 0: + _corridor_image.set_pixel(px, py, COLOR_UNEXPLORED) + else: + var col: Color = COLOR_FLOOR if (mask_idx < _explored_map.size() and _explored_map[mask_idx] != 0) else COLOR_UNEXPLORED + _corridor_image.set_pixel(px, py, col) + _corridor_texture.update(_corridor_image) + return + # Fallback: no visible list (e.g. first frame) – update 1/4 of rows per call to avoid spike + var py_start := _corridor_band_index * CORRIDOR_ROWS_PER_BAND + var py_end := mini(py_start + CORRIDOR_ROWS_PER_BAND, MINIMAP_HEIGHT) + _corridor_band_index = (_corridor_band_index + 1) % CORRIDOR_BANDS + for py in range(py_start, py_end): + for px in range(MINIMAP_WIDTH): + var tx := int(px * _map_size.x / cw) + var ty := int(py * _map_size.y / ch) + tx = clampi(tx, 0, _map_size.x - 1) + ty = clampi(ty, 0, _map_size.y - 1) + var mask_idx := tx + ty * stride + if mask_idx < _room_mask.size() and _room_mask[mask_idx] != 0: + _corridor_image.set_pixel(px, py, Color(0, 0, 0, 0)) + continue + var g: int = 0 + if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty: + var row = _grid[tx] as Array + g = int(row[ty]) + if g == 0: + _corridor_image.set_pixel(px, py, COLOR_UNEXPLORED) + else: + var idx := mask_idx + var col: Color = COLOR_FLOOR if (idx < _explored_map.size() and _explored_map[idx] != 0) else COLOR_UNEXPLORED + _corridor_image.set_pixel(px, py, col) + _corridor_texture.update(_corridor_image) + + +func _update_minimap_texture() -> void: + # Legacy: single full minimap texture (used when no rooms passed) + if _background_rect == null: + _background_rect = ColorRect.new() + _background_rect.name = "MinimapBackground" + _background_rect.set_anchors_preset(Control.PRESET_FULL_RECT) + _background_rect.size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT) + _background_rect.color = COLOR_UNEXPLORED + _background_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_background_rect) + _background_rect.z_index = -1 + # If we already have room rects, visibility is updated above; no full texture + if _room_rects.size() > 0: + return + # Build/update single full image (fallback path) if _map_size.x <= 0 or _map_size.y <= 0 or _explored_map.is_empty(): return + if not _minimap_image or _minimap_image.get_width() != MINIMAP_WIDTH or _minimap_image.get_height() != MINIMAP_HEIGHT: + _minimap_image = Image.create(MINIMAP_WIDTH, MINIMAP_HEIGHT, false, Image.FORMAT_RGBA8) + _minimap_texture = ImageTexture.create_from_image(_minimap_image) var bw := float(MINIMAP_WIDTH) var bh := float(MINIMAP_HEIGHT) - var tw := bw / float(_map_size.x) - var th := bh / float(_map_size.y) - for x in range(_map_size.x): - for y in range(_map_size.y): - var idx := x + y * _map_size.x - if idx < 0 or idx >= _explored_map.size(): - continue - var explored := _explored_map[idx] != 0 - var px := float(x) * tw - var py := float(y) * th - var rect := Rect2(px, py, tw, th) + for py in range(MINIMAP_HEIGHT): + for px in range(MINIMAP_WIDTH): + var tx := int(px * _map_size.x / bw) + var ty := int(py * _map_size.y / bh) + tx = clampi(tx, 0, _map_size.x - 1) + ty = clampi(ty, 0, _map_size.y - 1) + var idx := tx + ty * _map_size.x var col: Color - if not explored: + if idx >= _explored_map.size() or _explored_map[idx] == 0: col = COLOR_UNEXPLORED else: var g: int = 0 - if _grid.size() > x and _grid[x] is Array and (_grid[x] as Array).size() > y: - var row = _grid[x] as Array - g = int(row[y]) - if g == 0: - col = COLOR_WALL - else: - col = COLOR_FLOOR - draw_rect(rect, col, true) - if _player_tile.x >= 0 and _player_tile.y >= 0 and _player_tile.x < _map_size.x and _player_tile.y < _map_size.y: - var px := float(_player_tile.x) * tw + tw * 0.5 - var py := float(_player_tile.y) * th + th * 0.5 - var r := maxf(2.0, minf(tw, th) * 0.4) - draw_circle(Vector2(px, py), r, COLOR_PLAYER) - for other_tile in _other_player_tiles: - if other_tile is Vector2i and other_tile.x >= 0 and other_tile.y >= 0 and other_tile.x < _map_size.x and other_tile.y < _map_size.y: - var ox := float(other_tile.x) * tw + tw * 0.5 - var oy := float(other_tile.y) * th + th * 0.5 - var or_ := maxf(1.5, minf(tw, th) * 0.32) - draw_circle(Vector2(ox, oy), or_, COLOR_OTHER_PLAYER) - if _exit_discovered and _exit_tile.x >= 0 and _exit_tile.y >= 0 and _exit_tile.x < _map_size.x and _exit_tile.y < _map_size.y: - var ex := float(_exit_tile.x) * tw + tw * 0.5 - var ey := float(_exit_tile.y) * th + th * 0.5 - var er := maxf(2.0, minf(tw, th) * 0.35) - var blink := sin(Time.get_ticks_msec() * 0.004) * 0.5 + 0.5 - var exit_col := Color(COLOR_EXIT.r, COLOR_EXIT.g, COLOR_EXIT.b, 0.45 + 0.55 * blink) - draw_circle(Vector2(ex, ey), er, exit_col) + if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty: + var row = _grid[tx] as Array + g = int(row[ty]) + col = COLOR_WALL if g == 0 else COLOR_FLOOR + _minimap_image.set_pixel(px, py, col) + _minimap_texture.update(_minimap_image) + +var _minimap_image: Image +var _minimap_texture: ImageTexture + +func _draw() -> void: + if _map_size.x <= 0 or _map_size.y <= 0: + return + # If using room rects, base is drawn by children; player/exit drawn by _overlay_control on top + if _room_rects.size() == 0 and _minimap_texture: + draw_texture_rect(_minimap_texture, Rect2(Vector2.ZERO, Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT)), false) + # Player/exit/other-players are drawn by MinimapOverlay child (z_index 10) so they appear on top diff --git a/src/scripts/minimap_overlay.gd b/src/scripts/minimap_overlay.gd new file mode 100644 index 0000000..c762cb4 --- /dev/null +++ b/src/scripts/minimap_overlay.gd @@ -0,0 +1,49 @@ +extends Control + +# Draws player/exit/other-players on top of the minimap (so they are not covered by room/corridor layers). + +const MINIMAP_WIDTH: int = 128 +const MINIMAP_HEIGHT: int = 96 +const COLOR_PLAYER: Color = Color(1.0, 0.35, 0.35) +const COLOR_OTHER_PLAYER: Color = Color(0.35, 0.6, 1.0) +const COLOR_EXIT: Color = Color(1.0, 1.0, 1.0) + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + z_index = 10 + +func _draw() -> void: + var parent_node = get_parent() + if not parent_node or not parent_node.has_method("get_overlay_data"): + return + var d: Dictionary = parent_node.get_overlay_data() + var map_size: Vector2i = d.get("map_size", Vector2i.ZERO) + if map_size.x <= 0 or map_size.y <= 0: + return + var bw := float(MINIMAP_WIDTH) + var bh := float(MINIMAP_HEIGHT) + var tw := bw / float(map_size.x) + var th := bh / float(map_size.y) + var player_tile: Vector2i = d.get("player_tile", Vector2i(-1, -1)) + if player_tile.x >= 0 and player_tile.y >= 0 and player_tile.x < map_size.x and player_tile.y < map_size.y: + var px := float(player_tile.x) * tw + tw * 0.5 + var py := float(player_tile.y) * th + th * 0.5 + var r := maxf(2.0, minf(tw, th) * 0.4) + draw_circle(Vector2(px, py), r, COLOR_PLAYER) + var other_tiles: Array = d.get("other_player_tiles", []) + for other_tile in other_tiles: + if other_tile is Vector2i and other_tile.x >= 0 and other_tile.y >= 0 and other_tile.x < map_size.x and other_tile.y < map_size.y: + var ox := float(other_tile.x) * tw + tw * 0.5 + var oy := float(other_tile.y) * th + th * 0.5 + var or_ := maxf(1.5, minf(tw, th) * 0.32) + draw_circle(Vector2(ox, oy), or_, COLOR_OTHER_PLAYER) + var exit_discovered: bool = d.get("exit_discovered", false) + var exit_tile: Vector2i = d.get("exit_tile", Vector2i(-1, -1)) + if exit_discovered and exit_tile.x >= 0 and exit_tile.y >= 0 and exit_tile.x < map_size.x and exit_tile.y < map_size.y: + var ex := float(exit_tile.x) * tw + tw * 0.5 + var ey := float(exit_tile.y) * th + th * 0.5 + var er := maxf(2.0, minf(tw, th) * 0.35) + var blink := sin(Time.get_ticks_msec() * 0.004) * 0.5 + 0.5 + var exit_col := Color(COLOR_EXIT.r, COLOR_EXIT.g, COLOR_EXIT.b, 0.45 + 0.55 * blink) + draw_circle(Vector2(ex, ey), er, exit_col) diff --git a/src/scripts/minimap_overlay.gd.uid b/src/scripts/minimap_overlay.gd.uid new file mode 100644 index 0000000..396e6b2 --- /dev/null +++ b/src/scripts/minimap_overlay.gd.uid @@ -0,0 +1 @@ +uid://oemu7i1agdew diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 6d78583..20955d6 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -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 diff --git a/src/scripts/smoke_puff.gd b/src/scripts/smoke_puff.gd index 18911bc..189c0cf 100644 --- a/src/scripts/smoke_puff.gd +++ b/src/scripts/smoke_puff.gd @@ -16,71 +16,38 @@ var puff_type: int = 0 # 0 or 1 for first or second row var move_direction: Vector2 = Vector2.ZERO # Direction to move in func _ready(): - # Add to group for easy cleanup add_to_group("smoke_puff") - - # Wait for sprite to be ready (ensure @onready variable is set) - await get_tree().process_frame - - # Verify sprite exists if not sprite: - push_error("SmokePuff: ERROR - Sprite2D not found! Check that scene has Sprite2D child node.") queue_free() return - - # Randomly choose puff type puff_type = randi() % 2 - - # Randomly choose movement direction (random angle, slow movement) - var random_angle = randf() * TAU # 0 to 2*PI - move_direction = Vector2(cos(random_angle), sin(random_angle)) - - # Set initial frame + move_direction = Vector2(cos(randf() * TAU), sin(randf() * TAU)) sprite.frame = puff_type * total_frames current_frame = 0 - - print("SmokePuff: Starting animation, sprite: ", sprite, ", frame: ", sprite.frame, ", move_direction: ", move_direction) - - # Start animation animate_puff() func animate_puff(): - # Verify sprite still exists if not sprite: - push_error("SmokePuff: ERROR - Sprite is null during animation!") queue_free() return - - # Calculate frame animation timing - var frame_interval = 1.0 / animation_speed # Time per frame + var frame_interval = 1.0 / animation_speed var frame_animation_duration = float(total_frames) * frame_interval - - # Set initial frame sprite.frame = puff_type * total_frames current_frame = 0 frame_timer = 0.0 - - # Start movement tween var move_distance = move_speed * move_duration var target_position = global_position + move_direction * move_distance var move_tween = create_tween() if move_tween: move_tween.tween_property(self, "global_position", target_position, move_duration) - - # After animation completes, fade out and remove var total_animation_time = max(frame_animation_duration, move_duration) await get_tree().create_timer(total_animation_time).timeout - - # Fade out if sprite: - print("SmokePuff: Starting fade out...") var fade_tween = create_tween() if fade_tween: fade_tween.tween_property(sprite, "modulate:a", 0.0, fade_duration) await fade_tween.finished - - print("SmokePuff: Animation complete, removing...") - queue_free() + queue_free() func _process(delta): # Handle frame animation in _process() for more reliable timing @@ -95,7 +62,5 @@ func _process(delta): if frame_timer >= frame_interval: frame_timer = 0.0 current_frame += 1 - if current_frame < total_frames: sprite.frame = puff_type * total_frames + current_frame - print("SmokePuff: Frame ", current_frame, " -> ", sprite.frame)