tried optimizing the game

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

View File

@@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="MultiplayerCoop" 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") config/features=PackedStringArray("4.6", "Forward Plus")
run/max_fps=60 run/max_fps=60
boot_splash/bg_color=Color(0.09375, 0.09375, 0.09375, 1) 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" GameState="*res://scripts/game_state.gd"
NetworkManager="*res://scripts/network_manager.gd" NetworkManager="*res://scripts/network_manager.gd"
LogManager="*res://scripts/log_manager.gd" LogManager="*res://scripts/log_manager.gd"
AppearanceTextureCache="*res://scripts/appearance_texture_cache.gd"
[display] [display]
@@ -91,6 +92,10 @@ inventory={
] ]
} }
[input_devices]
pointing/emulate_mouse_from_touch=false
[layer_names] [layer_names]
2d_physics/layer_1="Player" 2d_physics/layer_1="Player"

View File

@@ -2,7 +2,6 @@
[ext_resource type="Script" uid="uid://bibyqdhticm5i" path="res://scripts/attack_web_shot.gd" id="1_script"] [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="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"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_web"]
size = Vector2(8, 8) size = Vector2(8, 8)
@@ -23,5 +22,4 @@ frame = 568
shape = SubResource("RectangleShape2D_web") shape = SubResource("RectangleShape2D_web")
[node name="SfxWebbed" type="AudioStreamPlayer2D" parent="." unique_id=817117841] [node name="SfxWebbed" type="AudioStreamPlayer2D" parent="." unique_id=817117841]
stream = ExtResource("3_webbed")
bus = &"Sfx" bus = &"Sfx"

View File

@@ -218,7 +218,6 @@ script = ExtResource("5")
[node name="CanvasModulate" type="CanvasModulate" parent="." unique_id=948490815] [node name="CanvasModulate" type="CanvasModulate" parent="." unique_id=948490815]
light_mask = 1048575 light_mask = 1048575
visibility_layer = 1048575 visibility_layer = 1048575
color = Color(0.69140625, 0.69140625, 0.69140625, 1)
[node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465] [node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465]
stream = ExtResource("6_6c6v5") stream = ExtResource("6_6c6v5")

77
src/scenes/loader.tscn Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://drgacstnmn67x

View File

@@ -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 # Flight duration: 50% charge = 0.5s, 100% charge = 2.5s
max_flight_duration = lerp(0.5, 2.5, (charge_percentage - 0.5) * 2.0) 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. # Called every frame. 'delta' is the e lapsed time since the previous frame.
func _process(delta: float) -> void: func _process(delta: float) -> void:

View File

@@ -46,12 +46,16 @@ const FALLOUT_SINK_DURATION: float = 0.5
@onready var fuse_light = $FuseLight @onready var fuse_light = $FuseLight
@onready var explosion_light = $ExplosionLight @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) # Damage area (larger than collision)
var damage_area_shape: CircleShape2D = null var damage_area_shape: CircleShape2D = null
const TILE_SIZE: int = 16 const TILE_SIZE: int = 16
const TILE_STRIDE: int = 17 # 16 + separation 1 const TILE_STRIDE: int = 17 # 16 + separation 1
var _explosion_tile_particle_scene: PackedScene = null
func _ready(): func _ready():
# Set collision layer to 2 (interactable objects) so it can be grabbed # 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) body.take_damage(final_damage, attacker_pos)
print("Bomb hit interactable: ", body.name, " for ", final_damage, " damage!") 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(): func _spawn_explosion_tile_particles():
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world: if not game_world:
return return
var layer = game_world.get_node_or_null("Environment/DungeonLayer0") var layer = game_world.get_node_or_null("Environment/DungeonLayer0")
if not layer or not layer is TileMapLayer: if not layer or not layer is TileMapLayer:
return return
var parent = get_parent()
if not _explosion_tile_particle_scene: if not parent:
_explosion_tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene parent = game_world.get_node_or_null("Entities")
if not _explosion_tile_particle_scene: if not parent:
return
var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D
if not tex:
return return
var center = global_position var center = global_position
@@ -438,60 +439,54 @@ func _spawn_explosion_tile_particles():
var center_cell = layer.local_to_map(layer_pos) var center_cell = layer.local_to_map(layer_pos)
var half_cells = ceili(r / float(TILE_SIZE)) + 1 var half_cells = ceili(r / float(TILE_SIZE)) + 1
var parent = get_parent() # Collect candidate tiles (with world pos and atlas) then cap count tile selection is cheap
if not parent: var candidates: Array = []
parent = game_world.get_node_or_null("Entities")
if not parent:
return
for gx in range(center_cell.x - half_cells, center_cell.x + half_cells + 1): 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): for gy in range(center_cell.y - half_cells, center_cell.y + half_cells + 1):
var cell = Vector2i(gx, gy) var cell = Vector2i(gx, gy)
if layer.get_cell_source_id(cell) < 0: if layer.get_cell_source_id(cell) < 0:
continue continue
var atlas = layer.get_cell_atlas_coords(cell)
var world = layer.map_to_local(cell) + layer.global_position var world = layer.map_to_local(cell) + layer.global_position
if world.distance_to(center) > r: if world.distance_to(center) > r:
continue continue
var bx = atlas.x * TILE_STRIDE var atlas = layer.get_cell_atlas_coords(cell)
var by = atlas.y * TILE_STRIDE candidates.append({ "world": world, "atlas": atlas })
var h = 8.0 # TILE_SIZE / 2
var regions = [ # Share layer material (no duplicate per particle) same tint for all
Rect2(bx, by, h, h), var shared_material = layer.material if (layer.material is ShaderMaterial) else null
Rect2(bx + h, by, h, h),
Rect2(bx, by + h, h, h), var spawned = 0
Rect2(bx + h, by + h, h, h) for c in candidates:
] if spawned >= MAX_EXPLOSION_TILE_PARTICLES:
# Direction from explosion center to this tile (outward) particles fly away from bomb break
var to_tile = world - center var bx = c.atlas.x * TILE_STRIDE
var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU) var by = c.atlas.y * TILE_STRIDE
# Reduced particles: 1 piece per tile instead of 2 (use index 0) var h = 8.0
var i = 0 var region = Rect2(bx, by, h, h)
var p = _explosion_tile_particle_scene.instantiate() as CharacterBody2D var world = c.world
var spr = p.get_node_or_null("Sprite2D") as Sprite2D var to_tile = world - center
if not spr: var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU)
p.queue_free()
continue var p = _EXPLOSION_TILE_PARTICLE_SCENE.instantiate() as CharacterBody2D
spr.texture = tex var spr = p.get_node_or_null("Sprite2D") as Sprite2D
spr.region_enabled = true if not spr:
spr.region_rect = regions[i] p.queue_free()
continue
# CRITICAL: Apply level's material and colorization to tile particles spr.texture = _DUNGEON_TILESET_TEXTURE
# Get the material from the tilemap layer and duplicate it spr.region_enabled = true
# Duplicating ShaderMaterial copies all shader parameters (colorization, tint, ambient, etc.) spr.region_rect = region
if layer.material and layer.material is ShaderMaterial: if shared_material:
var layer_material = layer.material as ShaderMaterial spr.material = shared_material
var particle_material = layer_material.duplicate() as ShaderMaterial
spr.material = particle_material p.global_position = world
var speed = randf_range(280.0, 420.0)
p.global_position = world var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
var speed = randf_range(280.0, 420.0) # Much faster - fly around more p.velocity = d.normalized() * speed
var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4)) p.angular_velocity = randf_range(-14.0, 14.0)
p.velocity = d.normalized() * speed p.position_z = 0.0
p.angular_velocity = randf_range(-14.0, 14.0) p.velocity_z = randf_range(100.0, 180.0)
p.position_z = 0.0 parent.add_child(p)
p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down spawned += 1
parent.add_child(p)
func _cause_screenshake(): func _cause_screenshake():
# Calculate screenshake based on distance from local players # 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) by_player.character_stats.character_changed.emit(by_player.character_stats)
# Show "+1 Bomb" above player # 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):
if floating_text_scene and by_player and is_instance_valid(by_player): var ft = _FLOATING_TEXT_SCENE.instantiate()
var ft = floating_text_scene.instantiate()
var parent = by_player.get_parent() var parent = by_player.get_parent()
if parent: if parent:
parent.add_child(ft) parent.add_child(ft)
@@ -587,8 +581,6 @@ func on_grabbed(by_player):
if has_node("SfxPickup"): if has_node("SfxPickup"):
$SfxPickup.play() $SfxPickup.play()
print(by_player.name, " collected bomb!")
# Sync removal to other clients so bomb doesn't keep exploding on their sessions # 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 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(): if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree():

View File

@@ -134,7 +134,7 @@ func _impact_on_player(player: Node2D) -> void:
state = "hit_player" state = "hit_player"
hit_player = player hit_player = player
netted_timer = 0.0 netted_timer = 0.0
if sfx_webbed: if sfx_webbed and sfx_webbed.stream:
sfx_webbed.play() sfx_webbed.play()
# Netted: cannot move? User said "player gets stuck" - so disable movement and main weapon # Netted: cannot move? User said "player gets stuck" - so disable movement and main weapon
if player.has_method("_web_net_apply"): if player.has_method("_web_net_apply"):

View File

@@ -21,8 +21,8 @@ var current_fly_target: Vector2 = Vector2.ZERO
var fly_target_timer: float = 0.0 var fly_target_timer: float = 0.0
const FLY_TARGET_DURATION: float = 2.8 # Re-pick target every ~3 seconds 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 # Web shot attack: preloaded so first shot doesn't stall (important for WebAssembly)
var web_shot_scene: PackedScene = null const WEB_SHOT_SCENE: PackedScene = preload("res://scenes/attack_web_shot.tscn")
var attack_state: String = "idle" # "idle" | "charging_web" var attack_state: String = "idle" # "idle" | "charging_web"
var web_attack_timer: float = 0.0 var web_attack_timer: float = 0.0
const WEB_CHARGE_TIME: float = 0.9 # Vibrate then fire all 3 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), # left
Vector2(1, 0) # right Vector2(1, 0) # right
] ]
# Spider spawn # Spider spawn: preloaded for smooth first spawn
var enemy_spider_scene: PackedScene = null const ENEMY_SPIDER_SCENE: PackedScene = preload("res://scenes/enemy_spider.tscn")
var spawned_spiders: Array = [] var spawned_spiders: Array = []
const SPIDER_SPAWN_COUNT: int = 3 const SPIDER_SPAWN_COUNT: int = 3
const SPIDER_SPAWN_COOLDOWN: float = 18.0 # First spawn after ~18s; same fallback as web for test scenes 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) set_meta("is_boss", true)
# Flying: no gravity, stay above ground (like bats) # Flying: no gravity, stay above ground (like bats)
position_z = 20.0 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) # Start idle (not activated)
velocity = Vector2.ZERO velocity = Vector2.ZERO
if anim_player: 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 cooldown (only spawn if previous 3 are defeated)
spider_spawn_timer += delta spider_spawn_timer += delta
_clean_defeated_spiders() _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() _spawn_spiders()
spider_spawn_timer = 0.0 spider_spawn_timer = 0.0
# Attack state machine # Attack state machine
@@ -192,7 +187,7 @@ func _physics_process(delta: float) -> void:
return return
if attack_state == "idle": if attack_state == "idle":
web_attack_timer += delta 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 web_attack_timer = 0.0
if _get_boss_rng().randf() < 0.5: if _get_boss_rng().randf() < 0.5:
attack_state = "charging_web" attack_state = "charging_web"
@@ -285,7 +280,7 @@ func _update_facing(dir: Vector2) -> void:
func _Fire_three_nets_at_once() -> 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 return
if sfx_web_shot: if sfx_web_shot:
sfx_web_shot.play() sfx_web_shot.play()
@@ -312,7 +307,7 @@ func _Fire_three_nets_at_once() -> void:
var dir: Vector2 = WEB_DIRECTIONS[indices[i]] var dir: Vector2 = WEB_DIRECTIONS[indices[i]]
var target_pos = global_position + dir * WEB_FIRE_DISTANCE var target_pos = global_position + dir * WEB_FIRE_DISTANCE
target_positions.append(target_pos) target_positions.append(target_pos)
var shot = web_shot_scene.instantiate() var shot = WEB_SHOT_SCENE.instantiate()
shot.global_position = global_position shot.global_position = global_position
if shot.has_method("set_target"): if shot.has_method("set_target"):
shot.set_target(target_pos) shot.set_target(target_pos)
@@ -369,7 +364,7 @@ func _get_spider_spawn_positions_in_room() -> Array:
func _spawn_spiders() -> void: 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 return
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
var positions: Array = _get_spider_spawn_positions_in_room() var positions: Array = _get_spider_spawn_positions_in_room()
@@ -390,7 +385,7 @@ func _spawn_spiders() -> void:
if not parent_node: if not parent_node:
return return
for i in range(SPIDER_SPAWN_COUNT): for i in range(SPIDER_SPAWN_COUNT):
var spider = enemy_spider_scene.instantiate() var spider = ENEMY_SPIDER_SCENE.instantiate()
spider.global_position = positions[i] spider.global_position = positions[i]
parent_node.add_child(spider) parent_node.add_child(spider)
spawned_spiders.append(spider) spawned_spiders.append(spider)

View File

@@ -667,12 +667,9 @@ func equip_item(iItem: Item, insert_index: int = -1):
can_stack = true can_stack = true
if can_stack: if can_stack:
# Stack quantities together
equipped_item.quantity += iItem.quantity equipped_item.quantity += iItem.quantity
# Remove the item from inventory since we merged it
if item_index >= 0: if item_index >= 0:
self.inventory.remove_at(item_index) self.inventory.remove_at(item_index)
print("Stacked ", iItem.quantity, " ", iItem.item_name, " with equipped (new total: ", equipped_item.quantity, ")")
else: else:
# Normal equip (swap items) # Normal equip (swap items)
match iItem.equipment_type: match iItem.equipment_type:

View File

@@ -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_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_h = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size)
var boss_room = { var boss_room = {
"x": int((map_size.x - boss_w) / 2), "x": int((map_size.x - boss_w) / 2.0),
"y": int((map_size.y - boss_h) / 2), "y": int((map_size.y - boss_h) / 2.0),
"w": boss_w, "w": boss_w,
"h": boss_h, "h": boss_h,
"modifiers": [] "modifiers": []

View File

@@ -42,6 +42,14 @@ const FALLOUT_SINK_DURATION: float = 0.5
const FALLOUT_CENTER_THRESHOLD: float = 2.0 const FALLOUT_CENTER_THRESHOLD: float = 2.0
const FALLOUT_LOOT_DELAY: float = 0.3 # Seconds after fallout death before spawning loot 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 # Animation
enum Direction {DOWN = 0, LEFT = 1, RIGHT = 2, UP = 3, DOWN_LEFT = 4, DOWN_RIGHT = 5, UP_LEFT = 6, UP_RIGHT = 7} 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 var current_direction: Direction = Direction.DOWN
@@ -88,9 +96,7 @@ func _ready():
fall_sfx = AudioStreamPlayer2D.new() fall_sfx = AudioStreamPlayer2D.new()
fall_sfx.name = "SfxFallout" fall_sfx.name = "SfxFallout"
add_child(fall_sfx) add_child(fall_sfx)
var stream = load("res://assets/audio/sfx/z3/LA_Enemy_Fall.wav") as AudioStream fall_sfx.stream = _fall_sfx_stream
if stream:
fall_sfx.stream = stream
fall_sfx.attenuation = 7.0 fall_sfx.attenuation = 7.0
fall_sfx.panning_strength = 1.14 fall_sfx.panning_strength = 1.14
fall_sfx.bus = "Sfx" fall_sfx.bus = "Sfx"
@@ -173,12 +179,12 @@ func _physics_process(delta):
_ai_behavior(delta) _ai_behavior(delta)
# All ground enemies: avoid stepping onto fallout when moving under their own control (not when knocked back) # 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: 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 _cached_gw == null or not is_instance_valid(_cached_gw):
if game_world and game_world.has_method("_is_position_on_fallout_tile"): _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 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 velocity = Vector2.ZERO
# Move # Move
@@ -753,22 +759,18 @@ func _create_burn_debuff_visual():
if burn_debuff_visual and is_instance_valid(burn_debuff_visual): if burn_debuff_visual and is_instance_valid(burn_debuff_visual):
burn_debuff_visual.queue_free() burn_debuff_visual.queue_free()
# Load burn debuff scene # Use preloaded burn debuff scene
var burn_debuff_scene = load("res://scenes/debuff_burn.tscn") if _debuff_burn_scene:
if burn_debuff_scene: burn_debuff_visual = _debuff_burn_scene.instantiate()
burn_debuff_visual = burn_debuff_scene.instantiate()
add_child(burn_debuff_visual) add_child(burn_debuff_visual)
# Position on enemy (centered)
burn_debuff_visual.position = Vector2(0, 0) 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) LogManager.log(str(name) + " created burn debuff visual", LogManager.CATEGORY_ENEMY)
else: else:
# Fallback: create simple sprite if scene doesn't exist if _burn_texture:
var burn_texture = load("res://assets/gfx/fx/burn.png")
if burn_texture:
var burn_sprite = Sprite2D.new() var burn_sprite = Sprite2D.new()
burn_sprite.name = "BurnDebuffSprite" burn_sprite.name = "BurnDebuffSprite"
burn_sprite.texture = burn_texture burn_sprite.texture = _burn_texture
burn_sprite.hframes = 4 burn_sprite.hframes = 4
burn_sprite.vframes = 4 burn_sprite.vframes = 4
burn_sprite.frame = 0 burn_sprite.frame = 0
@@ -815,14 +817,13 @@ func _die():
is_dead = true is_dead = true
LogManager.log(str(name) + " died!", LogManager.CATEGORY_ENEMY) 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 # Track defeated enemy for syncing to new clients
if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and gw:
var game_world = get_tree().get_first_node_in_group("game_world") var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
if game_world: if enemy_index >= 0:
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 gw.defeated_enemies[enemy_index] = true
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)
# Credit kill to the player who dealt the fatal damage # Credit kill to the player who dealt the fatal damage
if killer_player and is_instance_valid(killer_player) and killer_player.character_stats: 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) 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 # Show floating EXP text at enemy position and sync to all clients
if is_multiplayer_authority(): if is_multiplayer_authority() and gw and gw.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
# Show locally first
_show_exp_number(exp_per_player, global_position) _show_exp_number(exp_per_player, global_position)
# Sync to all clients via game_world gw._sync_exp_text_at_position.rpc(exp_per_player, global_position)
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)
# Spawn loot (immediately, or after 0.3s if died from fallout so it appears after sink) # Spawn loot (immediately, or after 0.3s if died from fallout so it appears after sink)
if died_from_fallout: if died_from_fallout:
@@ -883,16 +880,12 @@ func _die():
_spawn_loot() _spawn_loot()
# Sync death to all clients (only server sends RPC) # 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() and gw and gw.has_method("_sync_enemy_death"):
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree():
var enemy_name = name var enemy_name = name
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 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") gw._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index])
if game_world and game_world.has_method("_sync_enemy_death"): elif multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree():
game_world._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index]) _sync_death.rpc()
else:
# Fallback: try direct RPC (may fail if node path doesn't match)
_sync_death.rpc()
# Play death animation (override in subclasses) # Play death animation (override in subclasses)
_play_death_animation() _play_death_animation()

View File

@@ -27,7 +27,8 @@ const TILE_STRIDE: int = 17 # 16 + separation 1
@onready var interest_area: Area2D = $PlayerInterestArea @onready var interest_area: Area2D = $PlayerInterestArea
@onready var anim_player: AnimationPlayer = $AnimationPlayer @onready var anim_player: AnimationPlayer = $AnimationPlayer
@onready var hand_sprite: Sprite2D = $Sprite2D @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") 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: if not hand_sprite or not hand_sprite.texture:
return 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() var parent = get_parent()
if not parent: if not parent:
var game_world = get_tree().get_first_node_in_group("game_world") var game_world = get_tree().get_first_node_in_group("game_world")
@@ -185,7 +181,7 @@ func _spawn_hand_pieces():
# Spawn 4 pieces # Spawn 4 pieces
for i in range(4): 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 var piece_sprite = p.get_node_or_null("Sprite2D") as Sprite2D
if not piece_sprite: if not piece_sprite:
p.queue_free() p.queue_free()
@@ -290,18 +286,17 @@ func _detect_hand(detecting_player: Node) -> void:
func _remove_detected_effect() -> 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") 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"): if game_world and game_world.has_method("remove_detected_effect_at_position"):
game_world.remove_detected_effect_at_position(global_position) game_world.remove_detected_effect_at_position(global_position)
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
game_world._sync_remove_detected_effect_at_position.rpc(global_position.x, global_position.y) 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: func _on_emerge_area_body_entered(body: Node2D) -> void:
@@ -357,6 +352,10 @@ func _on_grab_player_area_body_entered(body: Node2D) -> void:
return return
if "is_dead" in body and body.is_dead: if "is_dead" in body and body.is_dead:
return 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: if "grabbed_by_enemy_hand" in body and body.grabbed_by_enemy_hand != null:
return return
if grabbed_player != null: if grabbed_player != null:
@@ -442,15 +441,6 @@ func _spawn_emerge_tile_particles():
if not layer or not layer is TileMapLayer: if not layer or not layer is TileMapLayer:
return 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 center = global_position
var layer_pos = center - layer.global_position var layer_pos = center - layer.global_position
var center_cell = layer.local_to_map(layer_pos) 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 # Spawn 2-3 particles from the tile, flying outward in random directions
var num_particles = randi_range(2, 3) var num_particles = randi_range(2, 3)
for i in range(num_particles): 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 var spr = p.get_node_or_null("Sprite2D") as Sprite2D
if not spr: if not spr:
p.queue_free() p.queue_free()
continue continue
spr.texture = tex spr.texture = _DUNGEON_TILESET_TEXTURE
spr.region_enabled = true spr.region_enabled = true
# Randomly pick one of the 4 tile quadrants # Randomly pick one of the 4 tile quadrants
var region_idx = randi() % 4 var region_idx = randi() % 4

View File

@@ -1048,13 +1048,14 @@ func _physics_process(delta):
can_attack = true can_attack = true
is_attacking = false 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(): if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
var game_world = get_tree().get_first_node_in_group("game_world") if _cached_gw == null or not is_instance_valid(_cached_gw):
if game_world and game_world.has_method("_sync_enemy_position"): _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_name = name
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1 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: else:
_sync_position(position, velocity, position_z, current_direction, 0, current_animation, current_frame) _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!") print(name, " threw bomb from above head!")
func _cleanup_held_bomb(): 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): if held_bomb_object and is_instance_valid(held_bomb_object):
held_bomb_object.queue_free() held_bomb_object.queue_free()
held_bomb_object = null 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): func _casting_spell_behavior(delta):
state_timer -= delta state_timer -= delta
velocity = Vector2.ZERO velocity = Vector2.ZERO
@@ -2201,8 +2226,9 @@ func _play_death_animation():
_update_animation(0.0) _update_animation(0.0)
LogManager.log(str(name) + " (client) forced immediate animation update after setting DIE", LogManager.CATEGORY_ENEMY) LogManager.log(str(name) + " (client) forced immediate animation update after setting DIE", LogManager.CATEGORY_ENEMY)
# Play death sound effect # Play death sound effect and blood (blood_scene is preloaded; add_child cheaper than 12x call_deferred)
if sfx_die: var death_parent = get_parent()
if sfx_die and death_parent:
for i in 12: for i in 12:
var angle = randf_range(0, TAU) var angle = randf_range(0, TAU)
var speed = randf_range(50, 100) var speed = randf_range(50, 100)
@@ -2210,12 +2236,12 @@ func _play_death_animation():
var b = blood_scene.instantiate() as CharacterBody2D var b = blood_scene.instantiate() as CharacterBody2D
b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2)) b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2))
b.global_position = global_position b.global_position = global_position
# Set initial velocities from the synchronized data
var direction = Vector2.from_angle(angle) var direction = Vector2.from_angle(angle)
b.velocity = direction * speed b.velocity = direction * speed
b.velocityZ = initial_velocityZ 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() sfx_die.play()
# Wait for death animation to finish (same duration as player: 200+200+200+800 = 1400ms = 1.4s) # Wait for death animation to finish (same duration as player: 200+200+200+800 = 1400ms = 1.4s)

View File

@@ -10,5 +10,5 @@ func _ready() -> void:
# Called every frame. 'delta' is the elapsed time since the previous frame. # Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void: func _process(_delta: float) -> void:
pass pass

View File

@@ -9,38 +9,75 @@ var fog_color_seen: Color = Color(0, 0, 0, 0.85)
var debug_lines: Array = [] var debug_lines: Array = []
var debug_enabled: bool = false 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: func setup(new_map_size: Vector2i, new_tile_size: int = 16) -> void:
map_size = new_map_size map_size = new_map_size
tile_size = new_tile_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 explored_map = new_explored_map
visible_map = new_visible_map visible_map = new_visible_map
_update_fog_texture(phase)
queue_redraw() 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: func set_debug_lines(lines: Array, enabled: bool) -> void:
debug_lines = lines debug_lines = lines
debug_enabled = enabled debug_enabled = enabled
queue_redraw() queue_redraw()
func _draw() -> void: 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 return
if _fog_texture:
for x in range(map_size.x): var rect = Rect2(Vector2.ZERO, Vector2(map_size.x * tile_size, map_size.y * tile_size))
for y in range(map_size.y): draw_texture_rect(_fog_texture, rect, false)
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 debug_enabled: if debug_enabled:
for line in debug_lines: for line in debug_lines:
if line is Array and line.size() == 2: if line is Array and line.size() == 2:
draw_line(line[0], line[1], Color(0, 1, 0, 0.4), 1.0) draw_line(line[0], line[1], Color(0, 1, 0, 0.4), 1.0)

View File

@@ -41,10 +41,12 @@ const FOG_VIEW_RANGE_TILES: float = 10.0
const FOG_BACK_RANGE_TILES: float = 3.0 const FOG_BACK_RANGE_TILES: float = 3.0
const FOG_RAY_STEP: float = 0.5 const FOG_RAY_STEP: float = 0.5
const FOG_RAY_ANGLE_STEP: int = 10 const FOG_RAY_ANGLE_STEP: int = 10
const FOG_UPDATE_INTERVAL: float = 0.1 const FOG_UPDATE_INTERVAL: float = 0.25 # Run less often to avoid spikes (was 0.1)
const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.06 # Update often in corridors so vision feels correct
const FOG_DEBUG_DRAW: bool = false 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_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 var peer_cleanup_timer: float = 0.0
const PEER_CLEANUP_INTERVAL: float = 0.5 # Check every 0.5 seconds 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_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) 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_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_mask: PackedInt32Array = PackedInt32Array()
var cached_corridor_rooms: Array = [] var cached_corridor_rooms: Array = []
var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1) 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_target_scale: float = 1.0
var _torch_darken_current_scale: float = 1.0 var _torch_darken_current_scale: float = 1.0
const _TORCH_DARKEN_LERP_SPEED: float = 4.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 _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 _last_synced_darkness_sent: float = -1.0 # Server: last value we sent
var _darkness_sync_timer: float = 0.0 # Server: throttle sync RPCs 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) # 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 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 # Cracked floor: stand too long -> tile breaks and becomes fallout
var cracked_stand_timers: Dictionary = {} # "player_key|tx|ty" -> float (seconds on that tile) 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) 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 # 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_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_revealed_tiles: Dictionary = {} # "x,y" -> true
var cracked_detection_attempts: Dictionary = {} # "peer_id|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 # Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator
const _FALLOUT_CENTER = Vector2i(10, 12) const _FALLOUT_CENTER = Vector2i(10, 12)
const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11) 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(): func _ready():
# Add to group for easy access # Add to group for easy access
add_to_group("game_world") 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 # Connect network signals
if network_manager: 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 # Wait a bit after dungeon sync to ensure spawners are spawned first
call_deferred("_sync_existing_enemies_to_client", peer_id) call_deferred("_sync_existing_enemies_to_client", peer_id)
# Sync existing boss-spawned spiders (so joiner sees them if they connected after spawn) # 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) 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 # Sync existing chest open states to the new client
# Wait a bit after dungeon sync to ensure objects are spawned first # 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) # 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: 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 var spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
if not spider_scene: if not spider_scene:
return 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 positions = [Vector2(p1x, p1y), Vector2(p2x, p2y), Vector2(p3x, p3y)]
var indices = [idx0, idx1, idx2] var indices = [idx0, idx1, idx2]
for i in range(3): 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" var heal_text = prefix + "+" + str(display_amount) + " HP"
_show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0) _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") @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): 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 # 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: elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
enemy = child enemy = child
break 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"): if enemy and enemy.has_method("_sync_damage_visual"):
# Call the enemy's _sync_damage_visual method directly (not via RPC) # 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: else:
# Enemy not found - might already be freed or never spawned # Enemy not found - might already be freed or never spawned
# This is okay, just log it # 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") @rpc("authority", "reliable")
func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool): func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool):
@@ -2110,10 +2087,6 @@ func _process(delta):
if use_mouse_control: if use_mouse_control:
_update_mouse_cursor(delta) _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) # Check tab visibility for buffer overflow protection (clients only)
if not multiplayer.is_server(): if not multiplayer.is_server():
var is_tab_visible = _check_tab_visibility() var is_tab_visible = _check_tab_visibility()
@@ -2672,21 +2645,16 @@ func _update_mouse_cursor(delta: float):
if "mouse_control_active" in player: if "mouse_control_active" in player:
player.mouse_control_active = false player.mouse_control_active = false
const GRID_TILE_SIZE: int = 16
func get_grid_locked_cursor_position() -> Vector2: func get_grid_locked_cursor_position() -> Vector2:
# Get the grid-locked cursor world position for spell casting # Grid-locked cursor for spell casting. Uses simple math (no TileMap) to avoid 13ms get_cell_source_id cost.
if not dungeon_tilemap_layer: if not camera:
return Vector2.ZERO return Vector2.ZERO
var world_pos: Vector2 = camera.get_global_mouse_position()
var _mouse_pos = get_viewport().get_mouse_position() var tx := int(floorf(world_pos.x / float(GRID_TILE_SIZE)))
var world_pos = camera.get_global_mouse_position() 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)
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
func _get_valid_spell_target_position(target_world_pos: Vector2) -> Vector2: 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) # 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: func _is_position_on_fallout_tile(world_pos: Vector2) -> bool:
if not dungeon_tilemap_layer: if not dungeon_tilemap_layer:
return false return false
var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) # Use cache to avoid get_cell_tile_data() every frame (was causing 40ms+ physics spikes)
var td = dungeon_tilemap_layer.get_cell_tile_data(tile_pos) 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: if not td:
return false return false
if not td.get_custom_data("terrain") is int: 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 return td.get_custom_data("terrain") == -2
func _try_cracked_floor_detection() -> void: 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: if dungeon_data.is_empty() or not dungeon_data.has("cracked_tile_grid") or not dungeon_tilemap_layer_cracked:
return 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 cracked_tile_grid = dungeon_data.cracked_tile_grid
var map_size: Vector2i = dungeon_data.map_size var map_size: Vector2i = dungeon_data.map_size
var switch_tiles: Dictionary = {} var switch_tiles: Dictionary = {}
@@ -2907,6 +2889,7 @@ func _try_cracked_floor_detection() -> void:
var players: Array = get_tree().get_nodes_in_group("player") var players: Array = get_tree().get_nodes_in_group("player")
if players.is_empty() and player_manager: if players.is_empty() and player_manager:
players = player_manager.get_local_players() players = player_manager.get_local_players()
var radius_tiles: int = int(ceil(CRACKED_DETECTION_RADIUS / 16.0)) # ~7 tiles
for player in players: for player in players:
if not is_instance_valid(player) or not player.is_in_group("player"): if not is_instance_valid(player) or not player.is_in_group("player"):
continue continue
@@ -2915,10 +2898,18 @@ func _try_cracked_floor_detection() -> void:
var qa = player.get_node("QuicksandArea") var qa = player.get_node("QuicksandArea")
if is_instance_valid(qa): if is_instance_valid(qa):
pos = qa.global_position 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 var peer_id = player.get_multiplayer_authority() if "get_multiplayer_authority" in player else 0
for x in range(map_size.x): for x in range(x_min, x_max + 1):
for y in range(map_size.y): if x >= cracked_tile_grid.size():
if x >= cracked_tile_grid.size() or y >= cracked_tile_grid[x].size(): continue
for y in range(y_min, y_max + 1):
if y >= (cracked_tile_grid[x] as Array).size():
continue continue
if not cracked_tile_grid[x][y]: if not cracked_tile_grid[x][y]:
continue 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. # Spawn the "detected" effect at position; sync to all players.
# effect_type: "chest" (blue, 169-179), "trap" (purple, 274-284), "enemy" (red, 484-494). # 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. # 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 var scene = load("res://scenes/detected_effect.tscn") as PackedScene
if not scene: if not scene:
return return
var entities_node = get_node_or_null("Entities") var entities_node = get_node_or_null("Entities")
if not entities_node: if not entities_node:
entities_node = self entities_node = self
var parent = null var parent = entities_node.get_node_or_null(parent_node_name) if parent_node_name else null
if parent_node_name and effect_type != "enemy":
parent = entities_node.get_node_or_null(parent_node_name)
var effect = scene.instantiate() var effect = scene.instantiate()
if parent: if parent:
parent.add_child(effect) 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") var entities_node = get_node_or_null("Entities")
if not entities_node: if not entities_node:
entities_node = self entities_node = self
var parent = null var parent = entities_node.get_node_or_null(parent_node_name) if parent_node_name else null
if parent_node_name and effect_type != "enemy":
parent = entities_node.get_node_or_null(parent_node_name)
var effect = scene.instantiate() var effect = scene.instantiate()
if parent: if parent:
parent.add_child(effect) 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 # Replace floor with fallout on main layer
var fallout_tile = _get_fallout_tile_for_floor_at(tile_x, tile_y) 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) 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: if dungeon_tilemap_layer_decorated:
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y)) 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 # 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) var fallout_tile = Vector2i(fallout_atlas_x, fallout_atlas_y)
if dungeon_tilemap_layer: if dungeon_tilemap_layer:
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile) 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: if dungeon_tilemap_layer_decorated:
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y)) dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
if dungeon_tilemap_layer_cracked: 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"): if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
return return
_ensure_fog_tile_to_room_index()
_rebuild_closed_door_tiles_cache()
# Determine if we're in a corridor and use appropriate update interval # Determine if we're in a corridor and use appropriate update interval
var in_corridor = false var in_corridor = false
if player_manager.get_local_players().size() > 0 and player_manager.get_local_players()[0]: 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_player_tile = Vector2i(-1, -1)
cached_corridor_allowed_room_ids.clear() cached_corridor_allowed_room_ids.clear()
was_in_corridor = in_corridor 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 var update_interval = FOG_UPDATE_INTERVAL_CORRIDOR if in_corridor else FOG_UPDATE_INTERVAL
fog_update_timer += delta fog_update_timer += delta
if fog_update_timer < update_interval: if fog_update_timer < update_interval:
@@ -3328,146 +3309,92 @@ func _update_fog_of_war(delta: float) -> void:
fog_update_timer = 0.0 fog_update_timer = 0.0
var map_size = dungeon_data.map_size var map_size = dungeon_data.map_size
combined_seen = _create_seen_array(map_size) var total = map_size.x * map_size.y
if explored_map.is_empty(): if combined_seen.size() != total:
explored_map = _create_seen_array(map_size) 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() var local_player_list = player_manager.get_local_players()
fog_debug_lines.clear() 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: for player in local_player_list:
if not player or not is_instance_valid(player): if not player or not is_instance_valid(player):
continue continue
if not seen_by_player.has(player.name): var p_tile = Vector2i(int(player.global_position.x / FOG_TILE_SIZE), int(player.global_position.y / FOG_TILE_SIZE))
seen_by_player[player.name] = _create_seen_array(map_size) var current_room = _find_room_at_tile(p_tile)
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)
if not current_room.is_empty(): if not current_room.is_empty():
_mark_room_explored(current_room) _mark_room_explored(current_room)
_mark_room_visible(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: else:
# In corridors (no room), only show tiles connected to the corridor component var corridor_player_tile = p_tile
# AND explicitly clear combined_seen for all tiles in rooms that aren't connected var should_rebuild = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(corridor_player_tile) > 1
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)) if should_rebuild:
# 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)
cached_corridor_mask = _build_corridor_mask(corridor_player_tile) 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_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, corridor_player_tile)
cached_corridor_player_tile = corridor_player_tile cached_corridor_player_tile = corridor_player_tile
# Build a set of allowed room IDs for fast lookup for idx in range(cached_corridor_mask.size()):
cached_corridor_allowed_room_ids = {} if cached_corridor_mask[idx] == 1:
for room in cached_corridor_rooms: combined_seen[idx] = 1
var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h) for room in cached_corridor_rooms:
cached_corridor_allowed_room_ids[room_id] = true _mark_room_in_seen_map(combined_seen, room)
# 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
last_corridor_fog_update = Time.get_ticks_msec() / 1000.0 last_corridor_fog_update = Time.get_ticks_msec() / 1000.0
if fog_node.has_method("set_maps"): # Merge visible into explored (single pass)
fog_node.set_maps(explored_map, combined_seen) for i in range(total):
if fog_node.has_method("set_debug_lines"): if combined_seen[i] != 0:
fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW) explored_map[i] = 1
# Stagger heavy texture updates: fog one tick, minimap next, to avoid one big spike
var player_tile := Vector2i(-1, -1) var player_tile := Vector2i(-1, -1)
if local_player_list.size() > 0 and local_player_list[0]: 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)) 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"): if fog_node.has_method("set_debug_lines"):
var exit_tile := Vector2i(-1, -1) fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW)
var exit_discovered := false
if dungeon_data.has("stairs") and not dungeon_data.stairs.is_empty(): # Fog texture: pass phase so fog updates only half the rows per tick (spread over 2 frames). Minimap every 2nd tick.
var s = dungeon_data.stairs if fog_node.has_method("set_maps"):
if s.has("x") and s.has("y") and s.has("w") and s.has("h"): fog_node.set_maps(explored_map, combined_seen, fog_visual_tick % 2)
exit_tile = Vector2i(s.x + s.w / 2, s.y + s.h / 2)
for dx in range(int(s.w)): fog_visual_tick += 1
for dy in range(int(s.h)): if fog_visual_tick % 2 == 1:
var tx: int = int(s.x) + dx var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw")
var ty: int = int(s.y) + dy if minimap_draw and minimap_draw.has_method("set_maps") and dungeon_data.has("grid"):
if tx >= 0 and ty >= 0 and tx < map_size.x and ty < map_size.y: var exit_tile := Vector2i(-1, -1)
var idx: int = tx + ty * map_size.x var exit_discovered := false
if idx >= 0 and idx < explored_map.size() and explored_map[idx] != 0: if dungeon_data.has("stairs") and not dungeon_data.stairs.is_empty():
exit_discovered = true var s = dungeon_data.stairs
break if s.has("x") and s.has("y") and s.has("w") and s.has("h"):
if exit_discovered: exit_tile = Vector2i(s.x + s.w / 2, s.y + s.h / 2)
break for dx in range(int(s.w)):
var other_player_tiles: Array = [] for dy in range(int(s.h)):
var all_players = get_tree().get_nodes_in_group("player") var tx: int = int(s.x) + dx
for p in all_players: var ty: int = int(s.y) + dy
if not is_instance_valid(p) or p in local_player_list: if tx >= 0 and ty >= 0 and tx < map_size.x and ty < map_size.y:
continue var idx: int = tx + ty * map_size.x
var pt = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE)) if idx >= 0 and idx < explored_map.size() and explored_map[idx] != 0:
other_player_tiles.append(pt) exit_discovered = true
minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered, other_player_tiles) 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: func _create_seen_array(map_size: Vector2i) -> PackedInt32Array:
var size = map_size.x * map_size.y 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(): if idx >= 0 and idx < seen_map.size():
seen_map[idx] = 1 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: func _is_tile_in_room_or_walls(tile: Vector2i, room: Dictionary) -> bool:
if room.is_empty(): if room.is_empty():
return false return false
@@ -3581,13 +3534,47 @@ func _mark_room_visible(room: Dictionary) -> void:
if idx >= 0 and idx < combined_seen.size(): if idx >= 0 and idx < combined_seen.size():
combined_seen[idx] = 1 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: func _find_room_at_tile(tile: Vector2i) -> Dictionary:
if dungeon_data.is_empty() or not dungeon_data.has("rooms"): if dungeon_data.is_empty() or not dungeon_data.has("rooms"):
return {} return {}
for room in dungeon_data.rooms: var map_size = dungeon_data.map_size
if tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h: if tile.x < 0 or tile.y < 0 or tile.x >= map_size.x or tile.y >= map_size.y:
return room return {}
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: func _get_room_index_for_tile(tile: Vector2i) -> int:
if dungeon_data.is_empty() or not dungeon_data.has("rooms"): 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 return Vector2.RIGHT
func _is_visibility_blocking_tile(tile: Vector2i) -> bool: func _is_visibility_blocking_tile(tile: Vector2i) -> bool:
# Walls block vision
if dungeon_data.grid[tile.x][tile.y] == 0: if dungeon_data.grid[tile.x][tile.y] == 0:
return true return true
return _cached_closed_door_tiles.get(str(tile.x) + "," + str(tile.y), false)
# 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
func _build_corridor_mask(start_tile: Vector2i) -> PackedInt32Array: func _build_corridor_mask(start_tile: Vector2i) -> PackedInt32Array:
var map_size = dungeon_data.map_size var map_size = dungeon_data.map_size
@@ -3982,6 +3958,9 @@ func _generate_dungeon():
# Spawn room triggers # Spawn room triggers
_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 # Wait a frame to ensure enemies and objects are properly in scene tree before syncing
await get_tree().process_frame await get_tree().process_frame
@@ -4432,6 +4411,28 @@ func _render_dungeon():
# Randomize dungeon color scheme (seed-based) # Randomize dungeon color scheme (seed-based)
_apply_dungeon_color_scheme() _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): func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = true):
# Update player manager spawn points based on a room # 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...") print("GameWorld: Client - Spawning room triggers...")
_spawn_room_triggers() _spawn_room_triggers()
print("GameWorld: Client - Room triggers spawned") 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 # 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
await get_tree().process_frame # Wait an extra frame to ensure enemies are fully ready 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...") print("GameWorld: Client - Spawning room triggers from blob...")
_spawn_room_triggers() _spawn_room_triggers()
print("GameWorld: Client - Room triggers spawned") print("GameWorld: Client - Room triggers spawned")
_warmup_attack_scenes()
# 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)
# Apply door states (from metadata) - after doors are spawned # Apply door states (from metadata) - after doors are spawned
if pending_door_states.size() > 0: if pending_door_states.size() > 0:
print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...") 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 and room triggers if not already spawned
_spawn_blocking_doors() _spawn_blocking_doors()
_spawn_room_triggers() _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(): func _fix_player_appearance_after_dungeon_sync():
# Re-randomize appearance for all players that were spawned before dungeon_seed was received # 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) # Clear dungeon data (but keep it for now until new one is generated)
# dungeon_data = {} # Don't clear yet, wait for new generation # 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() seen_by_player.clear()
combined_seen = PackedInt32Array() combined_seen = PackedInt32Array()
explored_map = PackedInt32Array()
if fog_node and is_instance_valid(fog_node): if fog_node and is_instance_valid(fog_node):
fog_node.queue_free() fog_node.queue_free()
fog_node = null 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) LogManager.log("GameWorld: Previous level cleared", LogManager.CATEGORY_DUNGEON)
func _hide_all_players(): 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): if player and is_instance_valid(player):
_show_exp_number_at_player(amount, 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): func _show_exp_number_at_position(amount: float, exp_pos: Vector2):
# Show EXP number (green, using dmg_numbers.png font) at a specific position # Show EXP number (green, using dmg_numbers.png font) at a specific position
var damage_number_scene = preload("res://scenes/damage_number.tscn") 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: 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) 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") var entities_node = get_node_or_null("Entities")
if not entities_node: if not entities_node:
return return
for child in entities_node.get_children(): var scenes = [
if (child.is_in_group("blocking_door") or (child.name and child.name.begins_with("BlockingDoor_"))) and is_instance_valid(child): preload("res://scenes/attack_arrow.tscn"),
if child.get("blocking_room") and not child.blocking_room.is_empty(): preload("res://scenes/attack_bomb.tscn"),
_connect_door_to_room_trigger(child) 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): func _place_key_in_room(room: Dictionary):
# Place a key in the specified room (as loot) # 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 \ 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.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: 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 door.room_trigger_area = trigger
if door not in trigger.doors_in_room: # Add door to trigger's doors list (doors_in_room is a variable in room_trigger.gd)
trigger.doors_in_room.append(door) trigger.doors_in_room.append(door)
break break

View File

@@ -1,6 +1,8 @@
extends CharacterBody2D extends CharacterBody2D
var tileParticleScene = preload("res://scenes/tile_particle.tscn") 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 # 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 var fallout_sink_progress: float = 1.0
const FALLOUT_SINK_DURATION: float = 0.4 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(): func _ready():
# Make sure it's on the interactable layer # Make sure it's on the interactable layer
collision_layer = 2 # Layer 2 for objects collision_layer = 2 # Layer 2 for objects
@@ -178,8 +183,9 @@ func _physics_process(delta):
if not is_frozen: if not is_frozen:
# Fallout: sink and disappear when on ground (not held, not airborne). Pillars must never sink. # 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": 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 _cached_gw == null or not is_instance_valid(_cached_gw):
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position): _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: if not falling_into_fallout:
falling_into_fallout = true falling_into_fallout = true
fallout_sink_progress = 1.0 fallout_sink_progress = 1.0
@@ -661,57 +667,34 @@ func on_thrown(by_player, force: Vector2):
position_z = 2.5 position_z = 2.5
velocity_z = 100.0 # Scaled down for 1x scale 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): func _convert_to_bomb_projectile(by_player, force: Vector2):
# Convert bomb object to projectile bomb when thrown # Defer so we don't change physics state (add_child / collision) while inside a physics callback (e.g. trap damage -> take_damage -> throw)
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
if not is_multiplayer_authority(): if not is_multiplayer_authority():
return return
call_deferred("_convert_to_bomb_projectile_deferred", global_position, get_parent(), name, by_player, force)
# Store current position before freeing
var current_pos = global_position 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):
# Spawn bomb projectile at current position return
var bomb = attack_bomb_scene.instantiate() var bomb = _ATTACK_BOMB_SCENE.instantiate()
bomb.name = "ThrownBomb_" + name bomb.name = "ThrownBomb_" + obj_name
get_parent().add_child(bomb) parent_node.add_child(bomb)
bomb.global_position = current_pos # Use current position, not target bomb.global_position = current_pos
# Set multiplayer authority
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
bomb.set_multiplayer_authority(by_player.get_multiplayer_authority()) bomb.set_multiplayer_authority(by_player.get_multiplayer_authority())
bomb.setup(current_pos, by_player, force, true)
# 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
if bomb.has_node("Sprite2D"): if bomb.has_node("Sprite2D"):
bomb.get_node("Sprite2D").visible = true bomb.get_node("Sprite2D").visible = true
# Sync bomb throw to other clients
if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority(): 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(): 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", [obj_name, current_pos, force])
by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force])
elif by_player.is_in_group("enemy"): 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") var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_rpc_to_ready_peers"): 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 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()) 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]) 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() queue_free()
print("Bomb object converted to projectile and thrown!")
@rpc("authority", "unreliable") @rpc("authority", "unreliable")
func _sync_box_state(pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool): func _sync_box_state(pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool):

View File

@@ -1886,6 +1886,58 @@ static func _register_item(item_id: String, item_data: Dictionary):
item_data["item_id"] = item_id item_data["item_id"] = item_id
item_definitions[item_id] = item_data 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 # Create an Item instance from an item ID
static func create_item(item_id: String) -> Item: static func create_item(item_id: String) -> Item:
_initialize() _initialize()

119
src/scripts/loader.gd Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
uid://d043ergi5kef1

View File

@@ -1,6 +1,9 @@
extends CharacterBody2D extends CharacterBody2D
# Loot Item - Coins and food items that drop from enemies # 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 { enum LootType {
COIN, COIN,
@@ -117,39 +120,35 @@ func _setup_sprite():
match loot_type: match loot_type:
LootType.COIN: LootType.COIN:
# Load coin texture if _TEX_COIN and sprite:
var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") var coin_texture = _TEX_COIN
if coin_texture and sprite:
sprite.texture = coin_texture sprite.texture = coin_texture
sprite.hframes = 6 sprite.hframes = 6
sprite.vframes = 1 sprite.vframes = 1
sprite.frame = 0 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: LootType.APPLE:
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var items_texture = _TEX_ITEMS
if items_texture: if items_texture:
sprite.texture = items_texture sprite.texture = items_texture
sprite.hframes = 20 sprite.hframes = 20
sprite.vframes = 14 sprite.vframes = 14
sprite.frame = (8 * 20) + 10 sprite.frame = (8 * 20) + 10
LootType.BANANA: LootType.BANANA:
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var items_texture = _TEX_ITEMS
if items_texture: if items_texture:
sprite.texture = items_texture sprite.texture = items_texture
sprite.hframes = 20 sprite.hframes = 20
sprite.vframes = 14 sprite.vframes = 14
sprite.frame = (8 * 20) + 11 sprite.frame = (8 * 20) + 11
LootType.CHERRY: LootType.CHERRY:
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var items_texture = _TEX_ITEMS
if items_texture: if items_texture:
sprite.texture = items_texture sprite.texture = items_texture
sprite.hframes = 20 sprite.hframes = 20
sprite.vframes = 14 sprite.vframes = 14
sprite.frame = (8 * 20) + 12 sprite.frame = (8 * 20) + 12
LootType.KEY: LootType.KEY:
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") var items_texture = _TEX_ITEMS
if items_texture: if items_texture:
sprite.texture = items_texture sprite.texture = items_texture
sprite.hframes = 20 sprite.hframes = 20
@@ -492,10 +491,7 @@ func _on_pickup_area_body_entered(body):
var player_peer_id = body.get_multiplayer_authority() var player_peer_id = body.get_multiplayer_authority()
if player_peer_id == dropped_by_peer_id and time_since_drop < 5.0: 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 # 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 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) _pickup(body)
func _pickup(player: Node): func _pickup(player: Node):
@@ -503,14 +499,8 @@ func _pickup(player: Node):
return return
# Prevent multiple pickups # Prevent multiple pickups
if collected: if collected:
print("Loot: Already collected, ignoring pickup")
return 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 # In multiplayer, only process on server or if player has authority
# If client player picks it up, send RPC to server # If client player picks it up, send RPC to server
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
@@ -519,7 +509,6 @@ func _pickup(player: Node):
if player.is_multiplayer_authority(): if player.is_multiplayer_authority():
# This is the local player, send request to server # This is the local player, send request to server
var player_peer_id = player.get_multiplayer_authority() 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 # Route through game_world to avoid node path issues
var game_world = get_tree().get_first_node_in_group("game_world") 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 var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
@@ -528,8 +517,6 @@ func _pickup(player: Node):
else: else:
# Fallback: try direct RPC # Fallback: try direct RPC
rpc_id(1, "_request_pickup", player_peer_id) rpc_id(1, "_request_pickup", player_peer_id)
else:
print("Loot: Client player does not have authority, cannot pickup")
return return
else: else:
# Server: If player doesn't have authority, this is a client player # 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) pickup_area.set_deferred("monitorable", false)
# Sync removal to all clients FIRST (before processing pickup) # Sync removal to all clients FIRST (before processing pickup)
# This ensures clients remove the loot even if host processes it var gw = get_tree().get_first_node_in_group("game_world") if is_inside_tree() else null
# 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() and gw and gw.has_method("_sync_loot_remove"):
if multiplayer.has_multiplayer_peer() and is_inside_tree():
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 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") gw._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position])
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)
match loot_type: match loot_type:
LootType.COIN: LootType.COIN:
@@ -579,13 +559,10 @@ func _process_pickup_on_server(player: Node):
if player.has_method("add_coins"): if player.has_method("add_coins"):
player.add_coins(coin_value) player.add_coins(coin_value)
# Show floating text with item graphic and text # 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, _TEX_COIN, 6, 1, 0)
_show_floating_text(player, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0.5, 0.5, coin_texture, 6, 1, 0)
# Sync floating text to client via GameWorld to avoid loot node path RPCs # Sync floating text to client via GameWorld to avoid loot node path RPCs
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
var game_world = get_tree().get_first_node_in_group("game_world") 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())
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())
self.visible = false 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) player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
# Show floating text with item name (uppercase) # Show floating text with item name (uppercase)
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
var display_text = "APPLE" var display_text = "APPLE"
var text_color = Color.GREEN var text_color = Color.GREEN
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10) _show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 10)
# Sync floating text to client via GameWorld to avoid loot node path RPCs if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 10, player.get_multiplayer_authority())
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())
self.visible = false 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) player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
# Show floating text with item name (uppercase) # Show floating text with item name (uppercase)
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
var display_text = "BANANA" var display_text = "BANANA"
var text_color = Color.GREEN var text_color = Color.GREEN
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11) _show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 11)
# Sync floating text to client via GameWorld to avoid loot node path RPCs if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 11, player.get_multiplayer_authority())
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())
self.visible = false 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) player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
# Show floating text with item name (uppercase) # Show floating text with item name (uppercase)
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
var display_text = "CHERRY" var display_text = "CHERRY"
var text_color = Color.GREEN var text_color = Color.GREEN
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12) _show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 12)
# Sync floating text to client via GameWorld to avoid loot node path RPCs if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 12, player.get_multiplayer_authority())
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())
self.visible = false self.visible = false
@@ -732,13 +697,9 @@ func _process_pickup_on_server(player: Node):
if player.has_method("add_key"): if player.has_method("add_key"):
player.add_key(1) player.add_key(1)
# Show floating text with item graphic and text # 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, _TEX_ITEMS, 20, 14, (13 * 20) + 10)
_show_floating_text(player, "+1 KEY", Color.YELLOW, 0.5, 0.5, items_texture, 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"):
# Sync floating text to client via GameWorld to avoid loot node path RPCs gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, (13 * 20) + 10, player.get_multiplayer_authority())
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())
self.visible = false self.visible = false
@@ -786,8 +747,8 @@ func _process_pickup_on_server(player: Node):
if player.has_method("_apply_inventory_and_equipment_from_server"): 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) player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
# Show floating text with item name (uppercase) # Show floating text with item name (uppercase) (item.spritePath is dynamic so load; Godot caches after first)
var items_texture = load(item.spritePath) var items_texture: Texture2D = load(item.spritePath) as Texture2D
var display_text = item.item_name.to_upper() # Always uppercase var display_text = item.item_name.to_upper() # Always uppercase
var text_color = Color.WHITE var text_color = Color.WHITE
@@ -798,12 +759,8 @@ func _process_pickup_on_server(player: Node):
text_color = Color.GREEN # Green for consumables 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) _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, item.spriteFrames.x, item.spriteFrames.y, item.spriteFrame, item)
if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
# Sync floating text to client via GameWorld to avoid loot node path RPCs gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, item.spriteFrame, player.get_multiplayer_authority())
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())
self.visible = false 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: match loot_type_value:
LootType.COIN: LootType.COIN:
item_texture = load("res://assets/gfx/pickups/gold_coin.png") item_texture = _TEX_COIN
sprite_hframes = 6 sprite_hframes = 6
sprite_vframes = 1 sprite_vframes = 1
LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.KEY: LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.KEY, LootType.ITEM:
item_texture = load("res://assets/gfx/pickups/items_n_shit.png") item_texture = _TEX_ITEMS
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")
sprite_hframes = 20 sprite_hframes = 20
sprite_vframes = 14 sprite_vframes = 14

View File

@@ -1,7 +1,6 @@
extends Control extends Control
# Minimap: shows explored dungeon tiles (uses same "explored" data as fog of war). # Minimap: shows explored dungeon by room. Room textures are pre-built once; we only toggle visibility.
# Populates as the player explores. Drawn in upper-right corner.
const MINIMAP_WIDTH: int = 128 const MINIMAP_WIDTH: int = 128
const MINIMAP_HEIGHT: int = 96 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_WALL: Color = Color(0.22, 0.22, 0.26)
const COLOR_FLOOR: Color = Color(0.38, 0.38, 0.44) const COLOR_FLOOR: Color = Color(0.38, 0.38, 0.44)
const COLOR_PLAYER: Color = Color(1.0, 0.35, 0.35) 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) const COLOR_EXIT: Color = Color(1.0, 1.0, 1.0)
var _map_size: Vector2i = Vector2i.ZERO var _map_size: Vector2i = Vector2i.ZERO
var _explored_map: PackedInt32Array = PackedInt32Array() 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 _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_tile: Vector2i = Vector2i(-1, -1)
var _exit_discovered: bool = false 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 _explored_map = explored_map
_map_size = map_size _map_size = map_size
_grid = grid _grid = grid
@@ -29,63 +85,244 @@ func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, p
_other_player_tiles = other_player_tiles _other_player_tiles = other_player_tiles
_exit_tile = exit_tile _exit_tile = exit_tile
_exit_discovered = exit_discovered _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() queue_redraw()
if _overlay_control:
_overlay_control.queue_redraw()
func _ready() -> void: func _ready() -> void:
custom_minimum_size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT) custom_minimum_size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT)
mouse_filter = Control.MOUSE_FILTER_IGNORE 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: func _process(_delta: float) -> void:
if _exit_discovered: if _exit_discovered:
queue_redraw() 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, ~200500 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(): if _map_size.x <= 0 or _map_size.y <= 0 or _explored_map.is_empty():
return 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 bw := float(MINIMAP_WIDTH)
var bh := float(MINIMAP_HEIGHT) var bh := float(MINIMAP_HEIGHT)
var tw := bw / float(_map_size.x) for py in range(MINIMAP_HEIGHT):
var th := bh / float(_map_size.y) for px in range(MINIMAP_WIDTH):
for x in range(_map_size.x): var tx := int(px * _map_size.x / bw)
for y in range(_map_size.y): var ty := int(py * _map_size.y / bh)
var idx := x + y * _map_size.x tx = clampi(tx, 0, _map_size.x - 1)
if idx < 0 or idx >= _explored_map.size(): ty = clampi(ty, 0, _map_size.y - 1)
continue var idx := tx + ty * _map_size.x
var explored := _explored_map[idx] != 0
var px := float(x) * tw
var py := float(y) * th
var rect := Rect2(px, py, tw, th)
var col: Color var col: Color
if not explored: if idx >= _explored_map.size() or _explored_map[idx] == 0:
col = COLOR_UNEXPLORED col = COLOR_UNEXPLORED
else: else:
var g: int = 0 var g: int = 0
if _grid.size() > x and _grid[x] is Array and (_grid[x] as Array).size() > y: if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty:
var row = _grid[x] as Array var row = _grid[tx] as Array
g = int(row[y]) g = int(row[ty])
if g == 0: col = COLOR_WALL if g == 0 else COLOR_FLOOR
col = COLOR_WALL _minimap_image.set_pixel(px, py, col)
else: _minimap_texture.update(_minimap_image)
col = COLOR_FLOOR
draw_rect(rect, col, true) var _minimap_image: Image
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 _minimap_texture: ImageTexture
var px := float(_player_tile.x) * tw + tw * 0.5
var py := float(_player_tile.y) * th + th * 0.5 func _draw() -> void:
var r := maxf(2.0, minf(tw, th) * 0.4) if _map_size.x <= 0 or _map_size.y <= 0:
draw_circle(Vector2(px, py), r, COLOR_PLAYER) return
for other_tile in _other_player_tiles: # If using room rects, base is drawn by children; player/exit drawn by _overlay_control on top
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: if _room_rects.size() == 0 and _minimap_texture:
var ox := float(other_tile.x) * tw + tw * 0.5 draw_texture_rect(_minimap_texture, Rect2(Vector2.ZERO, Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT)), false)
var oy := float(other_tile.y) * th + th * 0.5 # Player/exit/other-players are drawn by MinimapOverlay child (z_index 10) so they appear on top
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)

View File

@@ -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)

View File

@@ -0,0 +1 @@
uid://oemu7i1agdew

View File

@@ -128,6 +128,16 @@ var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projecti
var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch var attack_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 attack_axe_swing_scene = preload("res://scenes/attack_axe_swing.tscn") # Axe swing (orbits)
var blood_scene = preload("res://scenes/blood_clot.tscn") 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) # Simulated Z-axis for height (when thrown)
var position_z: float = 0.0 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, ")") 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(): 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: if not character_stats:
return return
# Body/Skin # Body/Skin
if sprite_body and character_stats.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: if body_texture:
sprite_body.texture = body_texture sprite_body.texture = body_texture
sprite_body.hframes = 35 sprite_body.hframes = 35
@@ -1155,7 +1186,7 @@ func _apply_appearance_to_sprites():
var equipped_boots = character_stats.equipment["boots"] var equipped_boots = character_stats.equipment["boots"]
# Only render boots if it's actually boots equipment (not a weapon or other type) # 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 != "": 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: if boots_texture:
sprite_boots.texture = boots_texture sprite_boots.texture = boots_texture
sprite_boots.hframes = 35 sprite_boots.hframes = 35
@@ -1174,7 +1205,7 @@ func _apply_appearance_to_sprites():
var equipped_armour = character_stats.equipment["armour"] var equipped_armour = character_stats.equipment["armour"]
# Only render armour if it's actually armour equipment (not a weapon) # 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 != "": 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: if armour_texture:
sprite_armour.texture = armour_texture sprite_armour.texture = armour_texture
sprite_armour.hframes = 35 sprite_armour.hframes = 35
@@ -1191,7 +1222,7 @@ func _apply_appearance_to_sprites():
# Facial Hair # Facial Hair
if sprite_facial_hair: if sprite_facial_hair:
if character_stats.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: if facial_hair_texture:
sprite_facial_hair.texture = facial_hair_texture sprite_facial_hair.texture = facial_hair_texture
sprite_facial_hair.hframes = 35 sprite_facial_hair.hframes = 35
@@ -1214,7 +1245,7 @@ func _apply_appearance_to_sprites():
# Hair # Hair
if sprite_hair: if sprite_hair:
if character_stats.hairstyle != "": if character_stats.hairstyle != "":
var hair_texture = load(character_stats.hairstyle) var hair_texture = _get_appearance_texture(character_stats.hairstyle)
if hair_texture: if hair_texture:
sprite_hair.texture = hair_texture sprite_hair.texture = hair_texture
sprite_hair.hframes = 35 sprite_hair.hframes = 35
@@ -1237,7 +1268,7 @@ func _apply_appearance_to_sprites():
# Eyes # Eyes
if sprite_eyes: if sprite_eyes:
if character_stats.eyes != "": if character_stats.eyes != "":
var eyes_texture = load(character_stats.eyes) var eyes_texture = _get_appearance_texture(character_stats.eyes)
if eyes_texture: if eyes_texture:
sprite_eyes.texture = eyes_texture sprite_eyes.texture = eyes_texture
sprite_eyes.hframes = 35 sprite_eyes.hframes = 35
@@ -1256,7 +1287,7 @@ func _apply_appearance_to_sprites():
# Eyelashes # Eyelashes
if sprite_eyelashes: if sprite_eyelashes:
if character_stats.eye_lashes != "": 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: if eyelash_texture:
sprite_eyelashes.texture = eyelash_texture sprite_eyelashes.texture = eyelash_texture
sprite_eyelashes.hframes = 35 sprite_eyelashes.hframes = 35
@@ -1275,7 +1306,7 @@ func _apply_appearance_to_sprites():
# Addons (ears, etc.) # Addons (ears, etc.)
if sprite_addons: if sprite_addons:
if character_stats.add_on != "": 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: if addon_texture:
sprite_addons.texture = addon_texture sprite_addons.texture = addon_texture
sprite_addons.hframes = 35 sprite_addons.hframes = 35
@@ -1289,7 +1320,7 @@ func _apply_appearance_to_sprites():
if sprite_headgear: if sprite_headgear:
var equipped_headgear = character_stats.equipment["headgear"] var equipped_headgear = character_stats.equipment["headgear"]
if equipped_headgear and equipped_headgear.equipmentPath != "": 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: if headgear_texture:
sprite_headgear.texture = headgear_texture sprite_headgear.texture = headgear_texture
sprite_headgear.hframes = 35 sprite_headgear.hframes = 35
@@ -1413,8 +1444,6 @@ func _on_character_changed(_char: CharacterStats):
_rpc_to_ready_peers("_sync_equipment", [equipment_data]) _rpc_to_ready_peers("_sync_equipment", [equipment_data])
# ALWAYS sync race and base stats to all clients (for proper display) # 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()]) _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 # 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 # Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
if is_charging_spell: 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_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time
var charge_progress = clamp(charge_time / spell_charge_duration, 0.0, 1.0) var charge_progress = clamp(charge_time / spell_charge_duration, 0.0, 1.0)
spell_charge_particle_timer += delta spell_charge_particle_timer += delta
@@ -1973,14 +2004,14 @@ func _physics_process(delta):
if charge_progress >= 1.0: if charge_progress >= 1.0:
spell_charge_tint_pulse_time += delta * spell_charge_tint_pulse_speed_charged spell_charge_tint_pulse_time += delta * spell_charge_tint_pulse_speed_charged
_apply_spell_charge_tint() _apply_spell_charge_tint()
if not spell_incantation_played and has_node("SfxSpellIncantation"): if not spell_incantation_played and _sfx_spell_incantation:
$SfxSpellIncantation.play() _sfx_spell_incantation.play()
spell_incantation_played = true spell_incantation_played = true
else: else:
spell_charge_tint_pulse_time = 0.0 spell_charge_tint_pulse_time = 0.0
_clear_spell_charge_tint() _clear_spell_charge_tint()
if has_node("SfxSpellIncantation"): if _sfx_spell_incantation:
$SfxSpellIncantation.stop() _sfx_spell_incantation.stop()
spell_incantation_played = false spell_incantation_played = false
else: else:
spell_charge_tint_pulse_time = 0.0 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) 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 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: else:
# Gamepad input # Gamepad input
var button_currently_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_A) var button_currently_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_A)
@@ -2677,10 +2703,11 @@ func _handle_interactions():
else: else:
grab_just_released = false 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() \ 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 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: if would_shield and shield_block_cooldown_timer > 0.0:
is_shielding = false is_shielding = false
if has_node("SfxDenyActivateShield"): if has_node("SfxDenyActivateShield"):
@@ -2716,8 +2743,8 @@ func _handle_interactions():
print(name, " cancelled bow charge") print(name, " cancelled bow charge")
# Check for trap disarm FIRST (Dwarf only) - PRIORITY: disarm takes priority over spell casting # 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": 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() var nearby_trap = _get_nearby_disarmable_trap()
if nearby_trap: if nearby_trap:
# Check if we're currently disarming this trap # Check if we're currently disarming this trap
@@ -2738,18 +2765,14 @@ func _handle_interactions():
$SfxSpellIncantation.stop() $SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc() _sync_spell_charge_end.rpc()
print(name, " cancelled spell charge to start disarming")
# Start disarming # Start disarming
is_disarming = true is_disarming = true
nearby_trap.disarming_player = self nearby_trap.disarming_player = self
nearby_trap.disarm_progress = 0.0 nearby_trap.disarm_progress = 0.0
print(name, " (Dwarf) started disarming trap")
elif grab_just_released and currently_disarming: elif grab_just_released and currently_disarming:
# Cancel disarm if released early
is_disarming = false is_disarming = false
nearby_trap._cancel_disarm() nearby_trap._cancel_disarm()
print(name, " (Dwarf) cancelled disarm")
elif not currently_disarming: elif not currently_disarming:
# Not disarming anymore - reset flag # Not disarming anymore - reset flag
is_disarming = false is_disarming = false
@@ -2780,29 +2803,9 @@ func _handle_interactions():
heal_target = _get_heal_target() 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) 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 var can_start_charge = is_heal or has_valid_target
# Reuse grabbable from single query above (avoids second get_overlapping_bodies)
# Check if there's a grabbable object nearby - prioritize grabbing over spell casting var nearby_grabbable = nearby_grabbable_body
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
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: 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 # 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 has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost
if not has_enough_mana: if not has_enough_mana:
# Not enough mana - show message to local player only
if is_local_player: if is_local_player:
_show_not_enough_mana_text() _show_not_enough_mana_text()
print(name, " cannot start charging spell - not enough mana")
just_grabbed_this_frame = false just_grabbed_this_frame = false
return return
@@ -2833,7 +2834,6 @@ func _handle_interactions():
$SfxSpellCharge.play() $SfxSpellCharge.play()
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
_sync_spell_charge_start.rpc() _sync_spell_charge_start.rpc()
print(name, " started charging spell (", current_spell_element, ")")
just_grabbed_this_frame = false just_grabbed_this_frame = false
return return
elif grab_just_released and is_charging_spell: elif grab_just_released and is_charging_spell:
@@ -2852,7 +2852,6 @@ func _handle_interactions():
$SfxSpellIncantation.stop() $SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc() _sync_spell_charge_end.rpc()
print(name, " cancelled spell (released too quickly)")
just_grabbed_this_frame = false just_grabbed_this_frame = false
return return
@@ -2882,8 +2881,6 @@ func _handle_interactions():
else: else:
_cast_heal_spell(heal_target) _cast_heal_spell(heal_target)
else: else:
# Not enough mana - cancel spell
print(name, " cannot cast spell - not enough mana")
is_charging_spell = false is_charging_spell = false
current_spell_element = "fire" current_spell_element = "fire"
spell_incantation_played = false spell_incantation_played = false
@@ -2909,7 +2906,6 @@ func _handle_interactions():
if has_node("SfxSpellCharge"): if has_node("SfxSpellCharge"):
$SfxSpellCharge.stop() $SfxSpellCharge.stop()
else: else:
print(name, " spell not cast (charge: ", charge_time, "s, fully: ", is_fully_charged, ", target ok: ", has_valid_target, ")")
is_charging_spell = false is_charging_spell = false
current_spell_element = "fire" current_spell_element = "fire"
spell_incantation_played = false spell_incantation_played = false
@@ -2923,7 +2919,6 @@ func _handle_interactions():
$SfxSpellIncantation.stop() $SfxSpellIncantation.stop()
if multiplayer.has_multiplayer_peer(): if multiplayer.has_multiplayer_peer():
_sync_spell_charge_end.rpc() _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 just_grabbed_this_frame = false
return return
elif is_charging_spell and (is_lifting or held_object or ((not is_heal) and not has_valid_target)): 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(): if body.can_be_grabbed():
is_grabbable = true is_grabbable = true
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D): 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: if is_grabbable:
var distance = position.distance_to(body.position) var distance = position.distance_to(body.position)
@@ -3331,9 +3327,10 @@ func _has_shield_in_offhand() -> bool:
var off = character_stats.equipment["offhand"] var off = character_stats.equipment["offhand"]
return off != null and "shield" in off.item_name.to_lower() 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: if not grab_area:
return false return null
var bodies = grab_area.get_overlapping_bodies() var bodies = grab_area.get_overlapping_bodies()
for body in bodies: for body in bodies:
if body == self: if body == self:
@@ -3343,10 +3340,14 @@ func _has_nearby_grabbable() -> bool:
if body.can_be_grabbed(): if body.can_be_grabbed():
is_grabbable = true is_grabbable = true
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D): 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: if is_grabbable and position.distance_to(body.position) < grab_range:
return true return body
return false return null
func _has_nearby_grabbable() -> bool:
return _get_nearby_grabbable() != null
func _update_shield_visibility() -> void: func _update_shield_visibility() -> void:
if not sprite_shield or not sprite_shield_holding: 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.has_method("can_be_grabbed"):
if body.can_be_grabbed(): if body.can_be_grabbed():
is_grabbable = true 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): 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: if is_grabbable:
var distance = position.distance_to(body.position) var distance = position.distance_to(body.position)
@@ -3823,8 +3825,6 @@ func _throw_object():
var obj_name = _get_object_name_for_sync(thrown_obj) 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]) _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): func _force_throw_held_object(direction: Vector2):
if not held_object or not is_lifting: if not held_object or not is_lifting:
return return
@@ -4059,12 +4059,9 @@ func _place_down_object():
if placed_obj.has_method("on_released"): if placed_obj.has_method("on_released"):
placed_obj.on_released(self) 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(): 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) var obj_name = _get_object_name_for_sync(placed_obj)
_rpc_to_ready_peers("_sync_place_down", [obj_name, place_pos]) _rpc_to_ready_peers("_sync_place_down", [obj_name, place_pos])
print("Placed down ", placed_obj.name, " at ", place_pos)
func _perform_attack(): func _perform_attack():
if not can_attack or is_attacking or spawn_landing or netted_by_web: 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 var is_crit = randf() < crit_chance
if is_crit: if is_crit:
final_damage *= 2.0 # Critical strikes deal 2x damage 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 # Round to 1 decimal place
final_damage = round(final_damage * 10.0) / 10.0 final_damage = round(final_damage * 10.0) / 10.0
@@ -4170,22 +4166,17 @@ func _perform_attack():
$SfxBowShoot.play() $SfxBowShoot.play()
# Consume one arrow # Consume one arrow
arrows.quantity -= 1 arrows.quantity -= 1
var remaining = arrows.quantity
if arrows.quantity <= 0: if arrows.quantity <= 0:
# Remove arrows if quantity reaches 0 # Remove arrows if quantity reaches 0
character_stats.equipment["offhand"] = null character_stats.equipment["offhand"] = null
if character_stats: if character_stats:
character_stats.character_changed.emit(character_stats) character_stats.character_changed.emit(character_stats)
else: else:
# Update equipment to reflect quantity change
if character_stats: if character_stats:
character_stats.character_changed.emit(character_stats) character_stats.character_changed.emit(character_stats)
print(name, " shot arrow! Arrows remaining: ", remaining)
else: else:
# Play bow animation but no projectile - DO NOT sync attack (no arrow spawned)
if has_node("SfxBowWithoutArrow"): if has_node("SfxBowWithoutArrow"):
$SfxBowWithoutArrow.play() $SfxBowWithoutArrow.play()
print(name, " tried to shoot but has no arrows!")
# Track empty bow attempts; after 3, unequip bow and equip another weapon # Track empty bow attempts; after 3, unequip bow and equip another weapon
empty_bow_shot_attempts += 1 empty_bow_shot_attempts += 1
@@ -4202,10 +4193,8 @@ func _perform_attack():
# Store crit status for visual feedback # Store crit status for visual feedback
if is_crit: if is_crit:
projectile.set_meta("is_crit", true) projectile.set_meta("is_crit", true)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_direction * 6.0 var spawn_offset = attack_direction * 6.0
projectile.global_position = global_position + spawn_offset 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: elif is_axe:
# Axe swing - stays on player, plays directional animation # Axe swing - stays on player, plays directional animation
if attack_axe_swing_scene and equipped_weapon: if attack_axe_swing_scene and equipped_weapon:
@@ -4214,7 +4203,6 @@ func _perform_attack():
get_parent().add_child(axe_swing) get_parent().add_child(axe_swing)
axe_swing.setup(attack_direction, self, -1.0, equipped_weapon) axe_swing.setup(attack_direction, self, -1.0, equipped_weapon)
axe_swing.global_position = global_position axe_swing.global_position = global_position
print(name, " axe swing! Damage: ", final_damage)
elif is_unarmed: elif is_unarmed:
# Unarmed punch - low STR-based damage (enemy armour mitigates via take_damage) # Unarmed punch - low STR-based damage (enemy armour mitigates via take_damage)
if attack_punch_scene: if attack_punch_scene:
@@ -4228,7 +4216,6 @@ func _perform_attack():
get_parent().add_child(punch) get_parent().add_child(punch)
punch.setup(attack_direction, self, punch_damage) punch.setup(attack_direction, self, punch_damage)
punch.global_position = global_position + attack_direction * 12.0 punch.global_position = global_position + attack_direction * 12.0
print(name, " punched! Damage: ", punch_damage)
else: else:
# Spawn sword projectile for non-bow/staff/axe weapons # Spawn sword projectile for non-bow/staff/axe weapons
if sword_projectile_scene: if sword_projectile_scene:
@@ -4239,10 +4226,8 @@ func _perform_attack():
# Store crit status for visual feedback # Store crit status for visual feedback
if is_crit: if is_crit:
projectile.set_meta("is_crit", true) projectile.set_meta("is_crit", true)
# Spawn projectile a bit in front of the player var spawn_offset = attack_direction * 6.0
var spawn_offset = attack_direction * 6.0 # 10 pixels in front
projectile.global_position = global_position + spawn_offset 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 # 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(): 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: if character_stats:
character_stats.character_changed.emit(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() var entities_node = get_parent()
if not entities_node: if not entities_node:
entities_node = get_tree().get_first_node_in_group("game_world").get_node_or_null("Entities") entities_node = get_tree().get_first_node_in_group("game_world").get_node_or_null("Entities")
if not entities_node: if not entities_node:
push_error("ERROR: Could not find Entities node!")
return 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.name = "BombObject_" + str(Time.get_ticks_msec())
bomb_obj.global_position = global_position + (facing_direction_vector * 8.0) # Spawn slightly in front 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(): func _die():
# Already processing death - prevent multiple concurrent death sequences # Already processing death - prevent multiple concurrent death sequences
if is_processing_death: if is_processing_death:
print(name, " already processing death, ignoring duplicate call")
return return
is_processing_death = true # Set IMMEDIATELY to block duplicates 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) var obj_name = _get_object_name_for_sync(released_obj)
_rpc_to_ready_peers("_sync_release", [obj_name]) _rpc_to_ready_peers("_sync_release", [obj_name])
print(name, " released ", released_obj.name, " on death") pass # released on death
else: else:
is_lifting = false is_lifting = false
is_pushing = false is_pushing = false
print(name, " died!")
# Show concussion status effect above head # Show concussion status effect above head
var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus")
if status_anim and status_anim.has_animation("concussion"): if status_anim and status_anim.has_animation("concussion"):
status_anim.play("concussion") status_anim.play("concussion")
# Play death sound effect # Play death sound effect and spawn blood (preloaded blood_scene; add_child is cheaper than 12x call_deferred)
if sfx_die: var death_parent = get_parent()
if sfx_die and death_parent:
for i in 12: for i in 12:
var angle = randf_range(0, TAU) var angle = randf_range(0, TAU)
var speed = randf_range(50, 100) var speed = randf_range(50, 100)
@@ -6662,12 +6637,12 @@ func _die():
var b = blood_scene.instantiate() as CharacterBody2D var b = blood_scene.instantiate() as CharacterBody2D
b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2)) b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2))
b.global_position = global_position b.global_position = global_position
# Set initial velocities from the synchronized data
var direction = Vector2.from_angle(angle) var direction = Vector2.from_angle(angle)
b.velocity = direction * speed b.velocity = direction * speed
b.velocityZ = initial_velocityZ 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() sfx_die.play()
# Play DIE animation # Play DIE animation
@@ -6682,11 +6657,9 @@ func _die():
# Force holder to drop us NOW (before respawn wait) # Force holder to drop us NOW (before respawn wait)
# Search for any player holding us (don't rely on being_held_by) # Search for any player holding us (don't rely on being_held_by)
print(name, " searching for anyone holding us...")
var found_holder = false var found_holder = false
for other_player in get_tree().get_nodes_in_group("player"): for other_player in get_tree().get_nodes_in_group("player"):
if other_player != self and other_player.held_object == self: 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 # Clear LOCALLY first
other_player.held_object = null other_player.held_object = null

View File

@@ -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 var move_direction: Vector2 = Vector2.ZERO # Direction to move in
func _ready(): func _ready():
# Add to group for easy cleanup
add_to_group("smoke_puff") 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: if not sprite:
push_error("SmokePuff: ERROR - Sprite2D not found! Check that scene has Sprite2D child node.")
queue_free() queue_free()
return return
# Randomly choose puff type
puff_type = randi() % 2 puff_type = randi() % 2
move_direction = Vector2(cos(randf() * TAU), sin(randf() * TAU))
# 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
sprite.frame = puff_type * total_frames sprite.frame = puff_type * total_frames
current_frame = 0 current_frame = 0
print("SmokePuff: Starting animation, sprite: ", sprite, ", frame: ", sprite.frame, ", move_direction: ", move_direction)
# Start animation
animate_puff() animate_puff()
func animate_puff(): func animate_puff():
# Verify sprite still exists
if not sprite: if not sprite:
push_error("SmokePuff: ERROR - Sprite is null during animation!")
queue_free() queue_free()
return return
var frame_interval = 1.0 / animation_speed
# Calculate frame animation timing
var frame_interval = 1.0 / animation_speed # Time per frame
var frame_animation_duration = float(total_frames) * frame_interval var frame_animation_duration = float(total_frames) * frame_interval
# Set initial frame
sprite.frame = puff_type * total_frames sprite.frame = puff_type * total_frames
current_frame = 0 current_frame = 0
frame_timer = 0.0 frame_timer = 0.0
# Start movement tween
var move_distance = move_speed * move_duration var move_distance = move_speed * move_duration
var target_position = global_position + move_direction * move_distance var target_position = global_position + move_direction * move_distance
var move_tween = create_tween() var move_tween = create_tween()
if move_tween: if move_tween:
move_tween.tween_property(self, "global_position", target_position, move_duration) 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) var total_animation_time = max(frame_animation_duration, move_duration)
await get_tree().create_timer(total_animation_time).timeout await get_tree().create_timer(total_animation_time).timeout
# Fade out
if sprite: if sprite:
print("SmokePuff: Starting fade out...")
var fade_tween = create_tween() var fade_tween = create_tween()
if fade_tween: if fade_tween:
fade_tween.tween_property(sprite, "modulate:a", 0.0, fade_duration) fade_tween.tween_property(sprite, "modulate:a", 0.0, fade_duration)
await fade_tween.finished await fade_tween.finished
queue_free()
print("SmokePuff: Animation complete, removing...")
queue_free()
func _process(delta): func _process(delta):
# Handle frame animation in _process() for more reliable timing # Handle frame animation in _process() for more reliable timing
@@ -95,7 +62,5 @@ func _process(delta):
if frame_timer >= frame_interval: if frame_timer >= frame_interval:
frame_timer = 0.0 frame_timer = 0.0
current_frame += 1 current_frame += 1
if current_frame < total_frames: if current_frame < total_frames:
sprite.frame = puff_type * total_frames + current_frame sprite.frame = puff_type * total_frames + current_frame
print("SmokePuff: Frame ", current_frame, " -> ", sprite.frame)