tried optimizing the game
This commit is contained in:
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="MultiplayerCoop"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
run/main_scene="res://scenes/loader.tscn"
|
||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||
run/max_fps=60
|
||||
boot_splash/bg_color=Color(0.09375, 0.09375, 0.09375, 1)
|
||||
@@ -26,6 +26,7 @@ buses/default_bus_layout="uid://psistrevppd1"
|
||||
GameState="*res://scripts/game_state.gd"
|
||||
NetworkManager="*res://scripts/network_manager.gd"
|
||||
LogManager="*res://scripts/log_manager.gd"
|
||||
AppearanceTextureCache="*res://scripts/appearance_texture_cache.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -91,6 +92,10 @@ inventory={
|
||||
]
|
||||
}
|
||||
|
||||
[input_devices]
|
||||
|
||||
pointing/emulate_mouse_from_touch=false
|
||||
|
||||
[layer_names]
|
||||
|
||||
2d_physics/layer_1="Player"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
[ext_resource type="Script" uid="uid://bibyqdhticm5i" path="res://scripts/attack_web_shot.gd" id="1_script"]
|
||||
[ext_resource type="Texture2D" uid="uid://bf158atxi7ucy" path="res://assets/gfx/fx/shade_spell_effects.png" id="2_tex"]
|
||||
[ext_resource type="AudioStreamWAV" path="res://assets/audio/sfx/enemies/boss/spider_bat_boss/webbed.wav" id="3_webbed"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_web"]
|
||||
size = Vector2(8, 8)
|
||||
@@ -23,5 +22,4 @@ frame = 568
|
||||
shape = SubResource("RectangleShape2D_web")
|
||||
|
||||
[node name="SfxWebbed" type="AudioStreamPlayer2D" parent="." unique_id=817117841]
|
||||
stream = ExtResource("3_webbed")
|
||||
bus = &"Sfx"
|
||||
|
||||
@@ -218,7 +218,6 @@ script = ExtResource("5")
|
||||
[node name="CanvasModulate" type="CanvasModulate" parent="." unique_id=948490815]
|
||||
light_mask = 1048575
|
||||
visibility_layer = 1048575
|
||||
color = Color(0.69140625, 0.69140625, 0.69140625, 1)
|
||||
|
||||
[node name="SfxWinds" type="AudioStreamPlayer" parent="." unique_id=1563020465]
|
||||
stream = ExtResource("6_6c6v5")
|
||||
|
||||
77
src/scenes/loader.tscn
Normal file
77
src/scenes/loader.tscn
Normal 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
|
||||
28
src/scripts/appearance_texture_cache.gd
Normal file
28
src/scripts/appearance_texture_cache.gd
Normal 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
|
||||
1
src/scripts/appearance_texture_cache.gd.uid
Normal file
1
src/scripts/appearance_texture_cache.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://drgacstnmn67x
|
||||
@@ -84,8 +84,6 @@ func shoot(shoot_direction: Vector2, start_pos: Vector2, owner_player: Node = nu
|
||||
|
||||
# Flight duration: 50% charge = 0.5s, 100% charge = 2.5s
|
||||
max_flight_duration = lerp(0.5, 2.5, (charge_percentage - 0.5) * 2.0)
|
||||
|
||||
print("Arrow shot at ", charge_percentage * 100, "% charge (speed: ", speed, ", flight duration: ", max_flight_duration, "s)")
|
||||
|
||||
# Called every frame. 'delta' is the e lapsed time since the previous frame.
|
||||
func _process(delta: float) -> void:
|
||||
|
||||
@@ -46,12 +46,16 @@ const FALLOUT_SINK_DURATION: float = 0.5
|
||||
@onready var fuse_light = $FuseLight
|
||||
@onready var explosion_light = $ExplosionLight
|
||||
|
||||
# Preloaded to avoid load() stalls in-game and on WebAssembly
|
||||
const _EXPLOSION_TILE_PARTICLE_SCENE: PackedScene = preload("res://scenes/explosion_tile_particle.tscn")
|
||||
const _DUNGEON_TILESET_TEXTURE: Texture2D = preload("res://assets/gfx/RPG DUNGEON VOL 3.png")
|
||||
const _FLOATING_TEXT_SCENE: PackedScene = preload("res://scenes/floating_text.tscn")
|
||||
|
||||
# Damage area (larger than collision)
|
||||
var damage_area_shape: CircleShape2D = null
|
||||
|
||||
const TILE_SIZE: int = 16
|
||||
const TILE_STRIDE: int = 17 # 16 + separation 1
|
||||
var _explosion_tile_particle_scene: PackedScene = null
|
||||
|
||||
func _ready():
|
||||
# Set collision layer to 2 (interactable objects) so it can be grabbed
|
||||
@@ -414,22 +418,19 @@ func _deal_explosion_damage():
|
||||
body.take_damage(final_damage, attacker_pos)
|
||||
print("Bomb hit interactable: ", body.name, " for ", final_damage, " damage!")
|
||||
|
||||
const MAX_EXPLOSION_TILE_PARTICLES: int = 24 # Cap so one bomb doesn't spawn 50+ (cheap selection by distance)
|
||||
|
||||
func _spawn_explosion_tile_particles():
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if not game_world:
|
||||
return
|
||||
|
||||
var layer = game_world.get_node_or_null("Environment/DungeonLayer0")
|
||||
if not layer or not layer is TileMapLayer:
|
||||
return
|
||||
|
||||
if not _explosion_tile_particle_scene:
|
||||
_explosion_tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene
|
||||
if not _explosion_tile_particle_scene:
|
||||
return
|
||||
|
||||
var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D
|
||||
if not tex:
|
||||
var parent = get_parent()
|
||||
if not parent:
|
||||
parent = game_world.get_node_or_null("Entities")
|
||||
if not parent:
|
||||
return
|
||||
|
||||
var center = global_position
|
||||
@@ -438,60 +439,54 @@ func _spawn_explosion_tile_particles():
|
||||
var center_cell = layer.local_to_map(layer_pos)
|
||||
var half_cells = ceili(r / float(TILE_SIZE)) + 1
|
||||
|
||||
var parent = get_parent()
|
||||
if not parent:
|
||||
parent = game_world.get_node_or_null("Entities")
|
||||
if not parent:
|
||||
return
|
||||
|
||||
# Collect candidate tiles (with world pos and atlas) then cap count – tile selection is cheap
|
||||
var candidates: Array = []
|
||||
for gx in range(center_cell.x - half_cells, center_cell.x + half_cells + 1):
|
||||
for gy in range(center_cell.y - half_cells, center_cell.y + half_cells + 1):
|
||||
var cell = Vector2i(gx, gy)
|
||||
if layer.get_cell_source_id(cell) < 0:
|
||||
continue
|
||||
var atlas = layer.get_cell_atlas_coords(cell)
|
||||
var world = layer.map_to_local(cell) + layer.global_position
|
||||
if world.distance_to(center) > r:
|
||||
continue
|
||||
var bx = atlas.x * TILE_STRIDE
|
||||
var by = atlas.y * TILE_STRIDE
|
||||
var h = 8.0 # TILE_SIZE / 2
|
||||
var regions = [
|
||||
Rect2(bx, by, h, h),
|
||||
Rect2(bx + h, by, h, h),
|
||||
Rect2(bx, by + h, h, h),
|
||||
Rect2(bx + h, by + h, h, h)
|
||||
]
|
||||
# Direction from explosion center to this tile (outward) – particles fly away from bomb
|
||||
var to_tile = world - center
|
||||
var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU)
|
||||
# Reduced particles: 1 piece per tile instead of 2 (use index 0)
|
||||
var i = 0
|
||||
var p = _explosion_tile_particle_scene.instantiate() as CharacterBody2D
|
||||
var spr = p.get_node_or_null("Sprite2D") as Sprite2D
|
||||
if not spr:
|
||||
p.queue_free()
|
||||
continue
|
||||
spr.texture = tex
|
||||
spr.region_enabled = true
|
||||
spr.region_rect = regions[i]
|
||||
|
||||
# CRITICAL: Apply level's material and colorization to tile particles
|
||||
# Get the material from the tilemap layer and duplicate it
|
||||
# Duplicating ShaderMaterial copies all shader parameters (colorization, tint, ambient, etc.)
|
||||
if layer.material and layer.material is ShaderMaterial:
|
||||
var layer_material = layer.material as ShaderMaterial
|
||||
var particle_material = layer_material.duplicate() as ShaderMaterial
|
||||
spr.material = particle_material
|
||||
|
||||
p.global_position = world
|
||||
var speed = randf_range(280.0, 420.0) # Much faster - fly around more
|
||||
var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
|
||||
p.velocity = d.normalized() * speed
|
||||
p.angular_velocity = randf_range(-14.0, 14.0)
|
||||
p.position_z = 0.0
|
||||
p.velocity_z = randf_range(100.0, 180.0) # Upward burst, then gravity brings them down
|
||||
parent.add_child(p)
|
||||
var atlas = layer.get_cell_atlas_coords(cell)
|
||||
candidates.append({ "world": world, "atlas": atlas })
|
||||
|
||||
# Share layer material (no duplicate per particle) – same tint for all
|
||||
var shared_material = layer.material if (layer.material is ShaderMaterial) else null
|
||||
|
||||
var spawned = 0
|
||||
for c in candidates:
|
||||
if spawned >= MAX_EXPLOSION_TILE_PARTICLES:
|
||||
break
|
||||
var bx = c.atlas.x * TILE_STRIDE
|
||||
var by = c.atlas.y * TILE_STRIDE
|
||||
var h = 8.0
|
||||
var region = Rect2(bx, by, h, h)
|
||||
var world = c.world
|
||||
var to_tile = world - center
|
||||
var outward = to_tile.normalized() if to_tile.length() > 1.0 else Vector2.RIGHT.rotated(randf() * TAU)
|
||||
|
||||
var p = _EXPLOSION_TILE_PARTICLE_SCENE.instantiate() as CharacterBody2D
|
||||
var spr = p.get_node_or_null("Sprite2D") as Sprite2D
|
||||
if not spr:
|
||||
p.queue_free()
|
||||
continue
|
||||
spr.texture = _DUNGEON_TILESET_TEXTURE
|
||||
spr.region_enabled = true
|
||||
spr.region_rect = region
|
||||
if shared_material:
|
||||
spr.material = shared_material
|
||||
|
||||
p.global_position = world
|
||||
var speed = randf_range(280.0, 420.0)
|
||||
var d = outward + Vector2(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
|
||||
p.velocity = d.normalized() * speed
|
||||
p.angular_velocity = randf_range(-14.0, 14.0)
|
||||
p.position_z = 0.0
|
||||
p.velocity_z = randf_range(100.0, 180.0)
|
||||
parent.add_child(p)
|
||||
spawned += 1
|
||||
|
||||
func _cause_screenshake():
|
||||
# Calculate screenshake based on distance from local players
|
||||
@@ -574,9 +569,8 @@ func on_grabbed(by_player):
|
||||
by_player.character_stats.character_changed.emit(by_player.character_stats)
|
||||
|
||||
# Show "+1 Bomb" above player
|
||||
var floating_text_scene = load("res://scenes/floating_text.tscn") as PackedScene
|
||||
if floating_text_scene and by_player and is_instance_valid(by_player):
|
||||
var ft = floating_text_scene.instantiate()
|
||||
if _FLOATING_TEXT_SCENE and by_player and is_instance_valid(by_player):
|
||||
var ft = _FLOATING_TEXT_SCENE.instantiate()
|
||||
var parent = by_player.get_parent()
|
||||
if parent:
|
||||
parent.add_child(ft)
|
||||
@@ -587,8 +581,6 @@ func on_grabbed(by_player):
|
||||
if has_node("SfxPickup"):
|
||||
$SfxPickup.play()
|
||||
|
||||
print(by_player.name, " collected bomb!")
|
||||
|
||||
# Sync removal to other clients so bomb doesn't keep exploding on their sessions
|
||||
if multiplayer.has_multiplayer_peer() and by_player and is_instance_valid(by_player):
|
||||
if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree():
|
||||
|
||||
@@ -134,7 +134,7 @@ func _impact_on_player(player: Node2D) -> void:
|
||||
state = "hit_player"
|
||||
hit_player = player
|
||||
netted_timer = 0.0
|
||||
if sfx_webbed:
|
||||
if sfx_webbed and sfx_webbed.stream:
|
||||
sfx_webbed.play()
|
||||
# Netted: cannot move? User said "player gets stuck" - so disable movement and main weapon
|
||||
if player.has_method("_web_net_apply"):
|
||||
|
||||
@@ -21,8 +21,8 @@ var current_fly_target: Vector2 = Vector2.ZERO
|
||||
var fly_target_timer: float = 0.0
|
||||
const FLY_TARGET_DURATION: float = 2.8 # Re-pick target every ~3 seconds
|
||||
|
||||
# Web shot attack: only down, down_left, down_right, left, right; fire all 3 at once
|
||||
var web_shot_scene: PackedScene = null
|
||||
# Web shot attack: preloaded so first shot doesn't stall (important for WebAssembly)
|
||||
const WEB_SHOT_SCENE: PackedScene = preload("res://scenes/attack_web_shot.tscn")
|
||||
var attack_state: String = "idle" # "idle" | "charging_web"
|
||||
var web_attack_timer: float = 0.0
|
||||
const WEB_CHARGE_TIME: float = 0.9 # Vibrate then fire all 3
|
||||
@@ -35,8 +35,8 @@ const WEB_DIRECTIONS: Array = [
|
||||
Vector2(-1, 0), # left
|
||||
Vector2(1, 0) # right
|
||||
]
|
||||
# Spider spawn
|
||||
var enemy_spider_scene: PackedScene = null
|
||||
# Spider spawn: preloaded for smooth first spawn
|
||||
const ENEMY_SPIDER_SCENE: PackedScene = preload("res://scenes/enemy_spider.tscn")
|
||||
var spawned_spiders: Array = []
|
||||
const SPIDER_SPAWN_COUNT: int = 3
|
||||
const SPIDER_SPAWN_COOLDOWN: float = 18.0 # First spawn after ~18s; same fallback as web for test scenes
|
||||
@@ -78,11 +78,6 @@ func _ready() -> void:
|
||||
set_meta("is_boss", true)
|
||||
# Flying: no gravity, stay above ground (like bats)
|
||||
position_z = 20.0
|
||||
# Load web shot and spider scenes
|
||||
if ResourceLoader.exists("res://scenes/attack_web_shot.tscn"):
|
||||
web_shot_scene = load("res://scenes/attack_web_shot.tscn") as PackedScene
|
||||
if ResourceLoader.exists("res://scenes/enemy_spider.tscn"):
|
||||
enemy_spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
|
||||
# Start idle (not activated)
|
||||
velocity = Vector2.ZERO
|
||||
if anim_player:
|
||||
@@ -168,7 +163,7 @@ func _physics_process(delta: float) -> void:
|
||||
# Spider spawn cooldown (only spawn if previous 3 are defeated)
|
||||
spider_spawn_timer += delta
|
||||
_clean_defeated_spiders()
|
||||
if spider_spawn_timer >= SPIDER_SPAWN_COOLDOWN and spawned_spiders.is_empty() and enemy_spider_scene:
|
||||
if spider_spawn_timer >= SPIDER_SPAWN_COOLDOWN and spawned_spiders.is_empty() and ENEMY_SPIDER_SCENE:
|
||||
_spawn_spiders()
|
||||
spider_spawn_timer = 0.0
|
||||
# Attack state machine
|
||||
@@ -192,7 +187,7 @@ func _physics_process(delta: float) -> void:
|
||||
return
|
||||
if attack_state == "idle":
|
||||
web_attack_timer += delta
|
||||
if web_attack_timer >= 2.5 and web_shot_scene:
|
||||
if web_attack_timer >= 2.5 and WEB_SHOT_SCENE:
|
||||
web_attack_timer = 0.0
|
||||
if _get_boss_rng().randf() < 0.5:
|
||||
attack_state = "charging_web"
|
||||
@@ -285,7 +280,7 @@ func _update_facing(dir: Vector2) -> void:
|
||||
|
||||
|
||||
func _Fire_three_nets_at_once() -> void:
|
||||
if not web_shot_scene or not is_inside_tree():
|
||||
if not WEB_SHOT_SCENE or not is_inside_tree():
|
||||
return
|
||||
if sfx_web_shot:
|
||||
sfx_web_shot.play()
|
||||
@@ -312,7 +307,7 @@ func _Fire_three_nets_at_once() -> void:
|
||||
var dir: Vector2 = WEB_DIRECTIONS[indices[i]]
|
||||
var target_pos = global_position + dir * WEB_FIRE_DISTANCE
|
||||
target_positions.append(target_pos)
|
||||
var shot = web_shot_scene.instantiate()
|
||||
var shot = WEB_SHOT_SCENE.instantiate()
|
||||
shot.global_position = global_position
|
||||
if shot.has_method("set_target"):
|
||||
shot.set_target(target_pos)
|
||||
@@ -369,7 +364,7 @@ func _get_spider_spawn_positions_in_room() -> Array:
|
||||
|
||||
|
||||
func _spawn_spiders() -> void:
|
||||
if not enemy_spider_scene or not is_inside_tree():
|
||||
if not ENEMY_SPIDER_SCENE or not is_inside_tree():
|
||||
return
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
var positions: Array = _get_spider_spawn_positions_in_room()
|
||||
@@ -390,7 +385,7 @@ func _spawn_spiders() -> void:
|
||||
if not parent_node:
|
||||
return
|
||||
for i in range(SPIDER_SPAWN_COUNT):
|
||||
var spider = enemy_spider_scene.instantiate()
|
||||
var spider = ENEMY_SPIDER_SCENE.instantiate()
|
||||
spider.global_position = positions[i]
|
||||
parent_node.add_child(spider)
|
||||
spawned_spiders.append(spider)
|
||||
|
||||
@@ -667,12 +667,9 @@ func equip_item(iItem: Item, insert_index: int = -1):
|
||||
can_stack = true
|
||||
|
||||
if can_stack:
|
||||
# Stack quantities together
|
||||
equipped_item.quantity += iItem.quantity
|
||||
# Remove the item from inventory since we merged it
|
||||
if item_index >= 0:
|
||||
self.inventory.remove_at(item_index)
|
||||
print("Stacked ", iItem.quantity, " ", iItem.item_name, " with equipped (new total: ", equipped_item.quantity, ")")
|
||||
else:
|
||||
# Normal equip (swap items)
|
||||
match iItem.equipment_type:
|
||||
|
||||
@@ -154,8 +154,8 @@ func generate_dungeon(map_size: Vector2i, seed_value: int = 0, level: int = 1) -
|
||||
var boss_w = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size)
|
||||
var boss_h = rng.randi_range(LEVEL4_BOSS_ROOM_MIN_SIZE, level4_boss_room_size)
|
||||
var boss_room = {
|
||||
"x": int((map_size.x - boss_w) / 2),
|
||||
"y": int((map_size.y - boss_h) / 2),
|
||||
"x": int((map_size.x - boss_w) / 2.0),
|
||||
"y": int((map_size.y - boss_h) / 2.0),
|
||||
"w": boss_w,
|
||||
"h": boss_h,
|
||||
"modifiers": []
|
||||
|
||||
@@ -42,6 +42,14 @@ const FALLOUT_SINK_DURATION: float = 0.5
|
||||
const FALLOUT_CENTER_THRESHOLD: float = 2.0
|
||||
const FALLOUT_LOOT_DELAY: float = 0.3 # Seconds after fallout death before spawning loot
|
||||
|
||||
# Cache game_world for _physics_process hot path (avoids get_first_node_in_group per enemy per frame)
|
||||
var _cached_gw: Node = null
|
||||
|
||||
# Preloaded resources (avoids load() stalls in-game and on WebAssembly)
|
||||
var _fall_sfx_stream: AudioStream = preload("res://assets/audio/sfx/z3/LA_Enemy_Fall.wav") as AudioStream
|
||||
var _debuff_burn_scene: PackedScene = preload("res://scenes/debuff_burn.tscn") as PackedScene
|
||||
var _burn_texture: Texture2D = preload("res://assets/gfx/fx/burn.png") as Texture2D
|
||||
|
||||
# Animation
|
||||
enum Direction {DOWN = 0, LEFT = 1, RIGHT = 2, UP = 3, DOWN_LEFT = 4, DOWN_RIGHT = 5, UP_LEFT = 6, UP_RIGHT = 7}
|
||||
var current_direction: Direction = Direction.DOWN
|
||||
@@ -88,9 +96,7 @@ func _ready():
|
||||
fall_sfx = AudioStreamPlayer2D.new()
|
||||
fall_sfx.name = "SfxFallout"
|
||||
add_child(fall_sfx)
|
||||
var stream = load("res://assets/audio/sfx/z3/LA_Enemy_Fall.wav") as AudioStream
|
||||
if stream:
|
||||
fall_sfx.stream = stream
|
||||
fall_sfx.stream = _fall_sfx_stream
|
||||
fall_sfx.attenuation = 7.0
|
||||
fall_sfx.panning_strength = 1.14
|
||||
fall_sfx.bus = "Sfx"
|
||||
@@ -173,12 +179,12 @@ func _physics_process(delta):
|
||||
_ai_behavior(delta)
|
||||
|
||||
# All ground enemies: avoid stepping onto fallout when moving under their own control (not when knocked back)
|
||||
# Skip avoidance during knockback so thrown objects (barrel, box) can knock enemies into fallout tiles
|
||||
if position_z <= 0.0 and not fallout_state and not fallout_defeat_started and not is_knocked_back and velocity.length_squared() > 1.0:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_is_position_on_fallout_tile"):
|
||||
if _cached_gw == null or not is_instance_valid(_cached_gw):
|
||||
_cached_gw = get_tree().get_first_node_in_group("game_world")
|
||||
if _cached_gw and _cached_gw.has_method("_is_position_on_fallout_tile"):
|
||||
var step = velocity.normalized() * 18.0
|
||||
if game_world._is_position_on_fallout_tile(global_position + step):
|
||||
if _cached_gw._is_position_on_fallout_tile(global_position + step):
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
# Move
|
||||
@@ -753,22 +759,18 @@ func _create_burn_debuff_visual():
|
||||
if burn_debuff_visual and is_instance_valid(burn_debuff_visual):
|
||||
burn_debuff_visual.queue_free()
|
||||
|
||||
# Load burn debuff scene
|
||||
var burn_debuff_scene = load("res://scenes/debuff_burn.tscn")
|
||||
if burn_debuff_scene:
|
||||
burn_debuff_visual = burn_debuff_scene.instantiate()
|
||||
# Use preloaded burn debuff scene
|
||||
if _debuff_burn_scene:
|
||||
burn_debuff_visual = _debuff_burn_scene.instantiate()
|
||||
add_child(burn_debuff_visual)
|
||||
# Position on enemy (centered)
|
||||
burn_debuff_visual.position = Vector2(0, 0)
|
||||
burn_debuff_visual.z_index = 5 # Above enemy sprite
|
||||
burn_debuff_visual.z_index = 5
|
||||
LogManager.log(str(name) + " created burn debuff visual", LogManager.CATEGORY_ENEMY)
|
||||
else:
|
||||
# Fallback: create simple sprite if scene doesn't exist
|
||||
var burn_texture = load("res://assets/gfx/fx/burn.png")
|
||||
if burn_texture:
|
||||
if _burn_texture:
|
||||
var burn_sprite = Sprite2D.new()
|
||||
burn_sprite.name = "BurnDebuffSprite"
|
||||
burn_sprite.texture = burn_texture
|
||||
burn_sprite.texture = _burn_texture
|
||||
burn_sprite.hframes = 4
|
||||
burn_sprite.vframes = 4
|
||||
burn_sprite.frame = 0
|
||||
@@ -815,14 +817,13 @@ func _die():
|
||||
is_dead = true
|
||||
LogManager.log(str(name) + " died!", LogManager.CATEGORY_ENEMY)
|
||||
|
||||
var gw = get_tree().get_first_node_in_group("game_world") if is_inside_tree() else null
|
||||
|
||||
# Track defeated enemy for syncing to new clients
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world:
|
||||
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
|
||||
if enemy_index >= 0:
|
||||
game_world.defeated_enemies[enemy_index] = true
|
||||
LogManager.log("Enemy: Tracked defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_ENEMY)
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and gw:
|
||||
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
|
||||
if enemy_index >= 0:
|
||||
gw.defeated_enemies[enemy_index] = true
|
||||
|
||||
# Credit kill to the player who dealt the fatal damage
|
||||
if killer_player and is_instance_valid(killer_player) and killer_player.character_stats:
|
||||
@@ -865,13 +866,9 @@ func _die():
|
||||
player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp)
|
||||
|
||||
# Show floating EXP text at enemy position and sync to all clients
|
||||
if is_multiplayer_authority():
|
||||
# Show locally first
|
||||
if is_multiplayer_authority() and gw and gw.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
|
||||
_show_exp_number(exp_per_player, global_position)
|
||||
# Sync to all clients via game_world
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
|
||||
game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position)
|
||||
gw._sync_exp_text_at_position.rpc(exp_per_player, global_position)
|
||||
|
||||
# Spawn loot (immediately, or after 0.3s if died from fallout so it appears after sink)
|
||||
if died_from_fallout:
|
||||
@@ -883,16 +880,12 @@ func _die():
|
||||
_spawn_loot()
|
||||
|
||||
# Sync death to all clients (only server sends RPC)
|
||||
# Use game_world to route death sync instead of direct RPC to avoid node path issues
|
||||
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree():
|
||||
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree() and gw and gw.has_method("_sync_enemy_death"):
|
||||
var enemy_name = name
|
||||
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_enemy_death"):
|
||||
game_world._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index])
|
||||
else:
|
||||
# Fallback: try direct RPC (may fail if node path doesn't match)
|
||||
_sync_death.rpc()
|
||||
gw._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index])
|
||||
elif multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree():
|
||||
_sync_death.rpc()
|
||||
|
||||
# Play death animation (override in subclasses)
|
||||
_play_death_animation()
|
||||
|
||||
@@ -27,7 +27,8 @@ const TILE_STRIDE: int = 17 # 16 + separation 1
|
||||
@onready var interest_area: Area2D = $PlayerInterestArea
|
||||
@onready var anim_player: AnimationPlayer = $AnimationPlayer
|
||||
@onready var hand_sprite: Sprite2D = $Sprite2D
|
||||
var _tile_particle_scene: PackedScene = null
|
||||
const _TILE_PARTICLE_SCENE: PackedScene = preload("res://scenes/explosion_tile_particle.tscn")
|
||||
const _DUNGEON_TILESET_TEXTURE: Texture2D = preload("res://assets/gfx/RPG DUNGEON VOL 3.png")
|
||||
var blood_scene: PackedScene = preload("res://scenes/blood_clot.tscn")
|
||||
|
||||
|
||||
@@ -133,11 +134,6 @@ func _spawn_hand_pieces():
|
||||
if not hand_sprite or not hand_sprite.texture:
|
||||
return
|
||||
|
||||
if not _tile_particle_scene:
|
||||
_tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene
|
||||
if not _tile_particle_scene:
|
||||
return
|
||||
|
||||
var parent = get_parent()
|
||||
if not parent:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
@@ -185,7 +181,7 @@ func _spawn_hand_pieces():
|
||||
|
||||
# Spawn 4 pieces
|
||||
for i in range(4):
|
||||
var p = _tile_particle_scene.instantiate() as CharacterBody2D
|
||||
var p = _TILE_PARTICLE_SCENE.instantiate() as CharacterBody2D
|
||||
var piece_sprite = p.get_node_or_null("Sprite2D") as Sprite2D
|
||||
if not piece_sprite:
|
||||
p.queue_free()
|
||||
@@ -290,18 +286,17 @@ func _detect_hand(detecting_player: Node) -> void:
|
||||
|
||||
|
||||
func _remove_detected_effect() -> void:
|
||||
# Effect is under Entities (not our child) so it stays visible while we're hidden; remove by position and sync
|
||||
# When effect was spawned with parent_node_name = our name, it's our child; remove it first.
|
||||
for c in get_children():
|
||||
if c.name == "DetectedEffect" or (c.get_class() == "Node2D" and c.has_method("setup")):
|
||||
c.queue_free()
|
||||
return
|
||||
# Otherwise effect is under Entities (e.g. no parent); remove by position and sync
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("remove_detected_effect_at_position"):
|
||||
game_world.remove_detected_effect_at_position(global_position)
|
||||
if multiplayer.has_multiplayer_peer():
|
||||
game_world._sync_remove_detected_effect_at_position.rpc(global_position.x, global_position.y)
|
||||
return
|
||||
# Fallback: effect might be our child (legacy)
|
||||
for c in get_children():
|
||||
if c.name == "DetectedEffect":
|
||||
c.queue_free()
|
||||
return
|
||||
|
||||
|
||||
func _on_emerge_area_body_entered(body: Node2D) -> void:
|
||||
@@ -357,6 +352,10 @@ func _on_grab_player_area_body_entered(body: Node2D) -> void:
|
||||
return
|
||||
if "is_dead" in body and body.is_dead:
|
||||
return
|
||||
# Don't grab players who are mid-air (thrown)
|
||||
var pz: float = body.position_z if "position_z" in body else 0.0
|
||||
if pz > 0.0:
|
||||
return
|
||||
if "grabbed_by_enemy_hand" in body and body.grabbed_by_enemy_hand != null:
|
||||
return
|
||||
if grabbed_player != null:
|
||||
@@ -442,15 +441,6 @@ func _spawn_emerge_tile_particles():
|
||||
if not layer or not layer is TileMapLayer:
|
||||
return
|
||||
|
||||
if not _tile_particle_scene:
|
||||
_tile_particle_scene = load("res://scenes/explosion_tile_particle.tscn") as PackedScene
|
||||
if not _tile_particle_scene:
|
||||
return
|
||||
|
||||
var tex = load("res://assets/gfx/RPG DUNGEON VOL 3.png") as Texture2D
|
||||
if not tex:
|
||||
return
|
||||
|
||||
var center = global_position
|
||||
var layer_pos = center - layer.global_position
|
||||
var center_cell = layer.local_to_map(layer_pos)
|
||||
@@ -482,12 +472,12 @@ func _spawn_emerge_tile_particles():
|
||||
# Spawn 2-3 particles from the tile, flying outward in random directions
|
||||
var num_particles = randi_range(2, 3)
|
||||
for i in range(num_particles):
|
||||
var p = _tile_particle_scene.instantiate() as CharacterBody2D
|
||||
var p = _TILE_PARTICLE_SCENE.instantiate() as CharacterBody2D
|
||||
var spr = p.get_node_or_null("Sprite2D") as Sprite2D
|
||||
if not spr:
|
||||
p.queue_free()
|
||||
continue
|
||||
spr.texture = tex
|
||||
spr.texture = _DUNGEON_TILESET_TEXTURE
|
||||
spr.region_enabled = true
|
||||
# Randomly pick one of the 4 tile quadrants
|
||||
var region_idx = randi() % 4
|
||||
|
||||
@@ -1048,13 +1048,14 @@ func _physics_process(delta):
|
||||
can_attack = true
|
||||
is_attacking = false
|
||||
|
||||
# Sync position and animation to clients (only server sends; humanoid uses game_world RPC)
|
||||
# Sync position and animation to clients (only server sends; reuse _cached_gw from base avoidance)
|
||||
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_enemy_position"):
|
||||
if _cached_gw == null or not is_instance_valid(_cached_gw):
|
||||
_cached_gw = get_tree().get_first_node_in_group("game_world")
|
||||
if _cached_gw and _cached_gw.has_method("_sync_enemy_position"):
|
||||
var enemy_name = name
|
||||
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
|
||||
game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1])
|
||||
_cached_gw._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, 0, current_animation, current_frame, -1])
|
||||
else:
|
||||
_sync_position(position, velocity, position_z, current_direction, 0, current_animation, current_frame)
|
||||
|
||||
@@ -1640,11 +1641,35 @@ func _throw_held_bomb():
|
||||
print(name, " threw bomb from above head!")
|
||||
|
||||
func _cleanup_held_bomb():
|
||||
# Clean up held bomb object if it exists
|
||||
# Clean up held bomb object if it exists (e.g. target lost while alive)
|
||||
if held_bomb_object and is_instance_valid(held_bomb_object):
|
||||
held_bomb_object.queue_free()
|
||||
held_bomb_object = null
|
||||
|
||||
func _drop_held_bomb():
|
||||
# When enemy dies with a bomb in hand: drop it on the ground so players can pick it up (don't destroy it)
|
||||
if not held_bomb_object or not is_instance_valid(held_bomb_object):
|
||||
held_bomb_object = null
|
||||
return
|
||||
var bomb = held_bomb_object
|
||||
held_bomb_object = null
|
||||
bomb.global_position = global_position
|
||||
# Re-enable collision so players can grab it (layer 2 = interactable, mask 1 = players)
|
||||
bomb.set_collision_layer_value(2, true)
|
||||
bomb.set_collision_mask_value(1, true)
|
||||
bomb.set_collision_mask_value(2, true)
|
||||
# Ensure it's on ground and grabbable
|
||||
if "position_z" in bomb:
|
||||
bomb.position_z = 0.0
|
||||
if "velocity_z" in bomb:
|
||||
bomb.velocity_z = 0.0
|
||||
if "is_airborne" in bomb:
|
||||
bomb.is_airborne = false
|
||||
|
||||
func _die():
|
||||
_drop_held_bomb()
|
||||
super._die()
|
||||
|
||||
func _casting_spell_behavior(delta):
|
||||
state_timer -= delta
|
||||
velocity = Vector2.ZERO
|
||||
@@ -2201,8 +2226,9 @@ func _play_death_animation():
|
||||
_update_animation(0.0)
|
||||
LogManager.log(str(name) + " (client) forced immediate animation update after setting DIE", LogManager.CATEGORY_ENEMY)
|
||||
|
||||
# Play death sound effect
|
||||
if sfx_die:
|
||||
# Play death sound effect and blood (blood_scene is preloaded; add_child cheaper than 12x call_deferred)
|
||||
var death_parent = get_parent()
|
||||
if sfx_die and death_parent:
|
||||
for i in 12:
|
||||
var angle = randf_range(0, TAU)
|
||||
var speed = randf_range(50, 100)
|
||||
@@ -2210,12 +2236,12 @@ func _play_death_animation():
|
||||
var b = blood_scene.instantiate() as CharacterBody2D
|
||||
b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2))
|
||||
b.global_position = global_position
|
||||
|
||||
# Set initial velocities from the synchronized data
|
||||
var direction = Vector2.from_angle(angle)
|
||||
b.velocity = direction * speed
|
||||
b.velocityZ = initial_velocityZ
|
||||
get_parent().call_deferred("add_child", b)
|
||||
death_parent.add_child(b)
|
||||
sfx_die.play()
|
||||
elif sfx_die:
|
||||
sfx_die.play()
|
||||
|
||||
# Wait for death animation to finish (same duration as player: 200+200+200+800 = 1400ms = 1.4s)
|
||||
|
||||
@@ -10,5 +10,5 @@ func _ready() -> void:
|
||||
|
||||
|
||||
# Called every frame. 'delta' is the elapsed time since the previous frame.
|
||||
func _process(delta: float) -> void:
|
||||
func _process(_delta: float) -> void:
|
||||
pass
|
||||
|
||||
@@ -9,38 +9,75 @@ var fog_color_seen: Color = Color(0, 0, 0, 0.85)
|
||||
var debug_lines: Array = []
|
||||
var debug_enabled: bool = false
|
||||
|
||||
# Half-resolution texture: 1 pixel per 2x2 tiles = 4x fewer writes, much faster
|
||||
var _fog_image: Image
|
||||
var _fog_texture: ImageTexture
|
||||
|
||||
func setup(new_map_size: Vector2i, new_tile_size: int = 16) -> void:
|
||||
map_size = new_map_size
|
||||
tile_size = new_tile_size
|
||||
var hw = (map_size.x + 1) >> 1
|
||||
var hh = (map_size.y + 1) >> 1
|
||||
_fog_image = Image.create(hw, hh, false, Image.FORMAT_RGBA8)
|
||||
_fog_texture = ImageTexture.create_from_image(_fog_image)
|
||||
|
||||
func set_maps(new_explored_map: PackedInt32Array, new_visible_map: PackedInt32Array) -> void:
|
||||
# phase 0 or 1: update only that half of rows so cost is spread over 2 frames
|
||||
func set_maps(new_explored_map: PackedInt32Array, new_visible_map: PackedInt32Array, phase: int = 0) -> void:
|
||||
explored_map = new_explored_map
|
||||
visible_map = new_visible_map
|
||||
_update_fog_texture(phase)
|
||||
queue_redraw()
|
||||
|
||||
func _update_fog_texture(phase: int = 0) -> void:
|
||||
if map_size == Vector2i.ZERO or explored_map.is_empty() or visible_map.size() < explored_map.size():
|
||||
return
|
||||
var hw = (map_size.x + 1) >> 1
|
||||
var hh = (map_size.y + 1) >> 1
|
||||
if _fog_image == null or _fog_image.get_width() != hw or _fog_image.get_height() != hh:
|
||||
_fog_image = Image.create(hw, hh, false, Image.FORMAT_RGBA8)
|
||||
_fog_texture = ImageTexture.create_from_image(_fog_image)
|
||||
var stride = map_size.x
|
||||
# Update only half the rows this frame (phase 0: top half, phase 1: bottom half)
|
||||
var fy_start = (phase % 2) * (hh >> 1)
|
||||
var fy_end = mini(fy_start + (hh >> 1), hh)
|
||||
for fy in range(fy_start, fy_end):
|
||||
for fx in range(hw):
|
||||
var tx0 = fx * 2
|
||||
var ty0 = fy * 2
|
||||
var vis = 0
|
||||
var seen = 0
|
||||
for dy in range(2):
|
||||
for dx in range(2):
|
||||
var tx = tx0 + dx
|
||||
var ty = ty0 + dy
|
||||
if tx < map_size.x and ty < map_size.y:
|
||||
var idx = tx + ty * stride
|
||||
if idx < visible_map.size() and visible_map[idx] == 1:
|
||||
vis = 1
|
||||
if idx < explored_map.size() and explored_map[idx] != 0:
|
||||
seen = 1
|
||||
var col: Color
|
||||
if vis == 1:
|
||||
col = Color(0, 0, 0, 0)
|
||||
elif seen == 0:
|
||||
col = fog_color_unseen
|
||||
else:
|
||||
col = fog_color_seen # seen but not visible
|
||||
_fog_image.set_pixel(fx, fy, col)
|
||||
_fog_texture.update(_fog_image)
|
||||
|
||||
func set_debug_lines(lines: Array, enabled: bool) -> void:
|
||||
debug_lines = lines
|
||||
debug_enabled = enabled
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
if map_size == Vector2i.ZERO or explored_map.is_empty() or visible_map.is_empty():
|
||||
if map_size == Vector2i.ZERO or explored_map.is_empty():
|
||||
return
|
||||
|
||||
for x in range(map_size.x):
|
||||
for y in range(map_size.y):
|
||||
var idx = x + y * map_size.x
|
||||
if idx >= explored_map.size() or idx >= visible_map.size():
|
||||
continue
|
||||
var pos = Vector2(x * tile_size, y * tile_size)
|
||||
if visible_map[idx] == 1:
|
||||
continue
|
||||
if explored_map[idx] == 0:
|
||||
draw_rect(Rect2(pos, Vector2(tile_size, tile_size)), fog_color_unseen, true)
|
||||
else:
|
||||
draw_rect(Rect2(pos, Vector2(tile_size, tile_size)), fog_color_seen, true)
|
||||
|
||||
if _fog_texture:
|
||||
var rect = Rect2(Vector2.ZERO, Vector2(map_size.x * tile_size, map_size.y * tile_size))
|
||||
draw_texture_rect(_fog_texture, rect, false)
|
||||
if debug_enabled:
|
||||
for line in debug_lines:
|
||||
if line is Array and line.size() == 2:
|
||||
draw_line(line[0], line[1], Color(0, 1, 0, 0.4), 1.0)
|
||||
draw_line(line[0], line[1], Color(0, 1, 0, 0.4), 1.0)
|
||||
|
||||
@@ -41,10 +41,12 @@ const FOG_VIEW_RANGE_TILES: float = 10.0
|
||||
const FOG_BACK_RANGE_TILES: float = 3.0
|
||||
const FOG_RAY_STEP: float = 0.5
|
||||
const FOG_RAY_ANGLE_STEP: int = 10
|
||||
const FOG_UPDATE_INTERVAL: float = 0.1
|
||||
const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag
|
||||
const FOG_UPDATE_INTERVAL: float = 0.25 # Run less often to avoid spikes (was 0.1)
|
||||
const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.06 # Update often in corridors so vision feels correct
|
||||
const FOG_DEBUG_DRAW: bool = false
|
||||
const FOG_SIMPLE_MODE: bool = true # Whole room / corridor+rooms visible (no raycast)
|
||||
var fog_update_timer: float = 0.0
|
||||
var fog_visual_tick: int = 0 # Stagger: fog texture one tick, minimap next, to spread cost
|
||||
var peer_cleanup_timer: float = 0.0
|
||||
const PEER_CLEANUP_INTERVAL: float = 0.5 # Check every 0.5 seconds
|
||||
|
||||
@@ -67,6 +69,8 @@ const CLIENT_BUFFER_CHECK_INTERVAL: float = 0.1 # Check client buffers every 100
|
||||
const CLIENT_BUFFER_SKIP_THRESHOLD: int = 1024 * 256 # Skip sending if buffer > 256KB (lower threshold to catch earlier)
|
||||
const CLIENT_BUFFER_SKIP_DURATION: float = 3.0 # Skip sending for 3 seconds if buffer is full (longer duration)
|
||||
var fog_node: Node2D = null
|
||||
var fog_tile_to_room_index: PackedInt32Array = PackedInt32Array() # -1 = corridor, else room index. O(1) lookup.
|
||||
var _cached_closed_door_tiles: Dictionary = {} # "x,y" -> true. Rebuilt each fog update to avoid iterating Entities in _is_visibility_blocking_tile.
|
||||
var cached_corridor_mask: PackedInt32Array = PackedInt32Array()
|
||||
var cached_corridor_rooms: Array = []
|
||||
var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1)
|
||||
@@ -81,7 +85,7 @@ var _torch_darken_last_room_id: String = ""
|
||||
var _torch_darken_target_scale: float = 1.0
|
||||
var _torch_darken_current_scale: float = 1.0
|
||||
const _TORCH_DARKEN_LERP_SPEED: float = 4.0
|
||||
const _TORCH_DARKEN_MIN_SCALE: float = 0.52 # Floor brightness so it's never insanely dark; same for all players
|
||||
const _TORCH_DARKEN_MIN_SCALE: float = 0.05 # Floor brightness so it's never insanely dark; same for all players
|
||||
var _synced_darkness_scale: float = 1.0 # Server syncs this to clients so host and joiner see same darkness
|
||||
var _last_synced_darkness_sent: float = -1.0 # Server: last value we sent
|
||||
var _darkness_sync_timer: float = 0.0 # Server: throttle sync RPCs
|
||||
@@ -121,6 +125,9 @@ var dungeon_sync_in_progress: Dictionary = {} # peer_id -> bool
|
||||
|
||||
# Fallout tiles (quicksand): last safe tile center per player (for respawn after falling in)
|
||||
var last_safe_position_by_player: Dictionary = {} # player node path or name -> Vector2
|
||||
# Cache: 1 = fallout tile, 0 = not. Index = x + y * map_size.x. Avoids get_cell_tile_data() every physics frame.
|
||||
var _fallout_tile_cache: PackedByteArray = PackedByteArray()
|
||||
var _fallout_cache_map_size: Vector2i = Vector2i.ZERO
|
||||
|
||||
# Cracked floor: stand too long -> tile breaks and becomes fallout
|
||||
var cracked_stand_timers: Dictionary = {} # "player_key|tx|ty" -> float (seconds on that tile)
|
||||
@@ -128,8 +135,10 @@ const CRACKED_STAND_DURATION: float = 0.9 # Seconds standing on cracked tile bef
|
||||
const CRACKED_TILE_ATLAS: Vector2i = Vector2i(15, 16)
|
||||
# Cracked floor: normally invisible; once per game per tile a player can roll perception when close to reveal
|
||||
const CRACKED_DETECTION_RADIUS: float = 99.0 # Same as trap detection (pixels)
|
||||
const CRACKED_DETECTION_INTERVAL: float = 0.2 # Only run detection every 0.2s to reduce cost
|
||||
var cracked_revealed_tiles: Dictionary = {} # "x,y" -> true
|
||||
var cracked_detection_attempts: Dictionary = {} # "peer_id|x|y" -> true
|
||||
var cracked_detection_timer: float = 0.0
|
||||
# Fallout tile atlas coords (for replacing floor when cracked tile breaks) - match dungeon_generator
|
||||
const _FALLOUT_CENTER = Vector2i(10, 12)
|
||||
const _FALLOUT_INNER_UP_LEFT = Vector2i(9, 11)
|
||||
@@ -195,9 +204,6 @@ var pending_chest_opens: Dictionary = {} # chest_name -> {loot_type: String, pla
|
||||
func _ready():
|
||||
# Add to group for easy access
|
||||
add_to_group("game_world")
|
||||
# Apply any boss spider spawns that arrived before we were in tree (joiner fix)
|
||||
if network_manager and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||
network_manager.apply_pending_boss_spider_spawns(self)
|
||||
|
||||
# Connect network signals
|
||||
if network_manager:
|
||||
@@ -519,16 +525,7 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
|
||||
# Wait a bit after dungeon sync to ensure spawners are spawned first
|
||||
call_deferred("_sync_existing_enemies_to_client", peer_id)
|
||||
# Sync existing boss-spawned spiders (so joiner sees them if they connected after spawn)
|
||||
# Send immediately and again after delays so joiner 100% gets it (handles RPC loss or early processing)
|
||||
call_deferred("_sync_existing_boss_spiders_to_client", peer_id)
|
||||
get_tree().create_timer(1.5).timeout.connect(func():
|
||||
if is_inside_tree() and multiplayer.is_server():
|
||||
_sync_existing_boss_spiders_to_client(peer_id)
|
||||
)
|
||||
get_tree().create_timer(3.0).timeout.connect(func():
|
||||
if is_inside_tree() and multiplayer.is_server():
|
||||
_sync_existing_boss_spiders_to_client(peer_id)
|
||||
)
|
||||
|
||||
# Sync existing chest open states to the new client
|
||||
# Wait a bit after dungeon sync to ensure objects are spawned first
|
||||
@@ -1169,15 +1166,12 @@ func request_spawn_boss_spiders(positions: Array) -> Array:
|
||||
|
||||
# Called from NetworkManager.spawn_boss_spiders_client on clients (relay ensures joiner receives RPC)
|
||||
func _do_client_spawn_boss_spiders(p1x: float, p1y: float, p2x: float, p2y: float, p3x: float, p3y: float, idx0: int, idx1: int, idx2: int) -> void:
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if not entities_node:
|
||||
# Joiner: Entities not ready yet; queue so we apply when dungeon is ready
|
||||
if not multiplayer.is_server() and network_manager:
|
||||
network_manager.pending_boss_spider_spawns.append({"p1x": p1x, "p1y": p1y, "p2x": p2x, "p2y": p2y, "p3x": p3x, "p3y": p3y, "idx0": idx0, "idx1": idx1, "idx2": idx2})
|
||||
return
|
||||
var spider_scene = load("res://scenes/enemy_spider.tscn") as PackedScene
|
||||
if not spider_scene:
|
||||
return
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if not entities_node:
|
||||
return
|
||||
var positions = [Vector2(p1x, p1y), Vector2(p2x, p2y), Vector2(p3x, p3y)]
|
||||
var indices = [idx0, idx1, idx2]
|
||||
for i in range(3):
|
||||
@@ -1444,14 +1438,6 @@ func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display
|
||||
var heal_text = prefix + "+" + str(display_amount) + " HP"
|
||||
_show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0)
|
||||
|
||||
# Server-only: send enemy position to ALL peers (including joiner who may not be in client_gameworld_ready yet).
|
||||
# Boss-spawned spiders and other enemies need position sync to reach joiners; _rpc_to_ready_peers can miss them.
|
||||
func _broadcast_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int) -> void:
|
||||
if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
|
||||
return
|
||||
for peer_id in multiplayer.get_peers():
|
||||
_sync_enemy_position.rpc_id(peer_id, enemy_name, enemy_index, pos, vel, z_pos, dir, frame, anim, frame_num, state_value)
|
||||
|
||||
@rpc("authority", "unreliable")
|
||||
func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int):
|
||||
# Clients receive enemy position updates from server
|
||||
@@ -1561,14 +1547,6 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou
|
||||
elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
|
||||
enemy = child
|
||||
break
|
||||
# Fallback: boss may have different name on client; find by is_boss if exactly one
|
||||
if enemy == null and enemy_index >= 0:
|
||||
var boss_candidates = []
|
||||
for child in entities_node.get_children():
|
||||
if child.is_in_group("enemy") and child.has_meta("is_boss") and child.get_meta("is_boss"):
|
||||
boss_candidates.append(child)
|
||||
if boss_candidates.size() == 1:
|
||||
enemy = boss_candidates[0]
|
||||
|
||||
if enemy and enemy.has_method("_sync_damage_visual"):
|
||||
# Call the enemy's _sync_damage_visual method directly (not via RPC)
|
||||
@@ -1576,8 +1554,7 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou
|
||||
else:
|
||||
# Enemy not found - might already be freed or never spawned
|
||||
# This is okay, just log it
|
||||
if enemy == null:
|
||||
print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index)
|
||||
print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index)
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool):
|
||||
@@ -2110,10 +2087,6 @@ func _process(delta):
|
||||
if use_mouse_control:
|
||||
_update_mouse_cursor(delta)
|
||||
|
||||
# Client: apply any pending boss spider spawns (in case RPC arrived when we weren't findable)
|
||||
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||
network_manager.apply_pending_boss_spider_spawns(self)
|
||||
|
||||
# Check tab visibility for buffer overflow protection (clients only)
|
||||
if not multiplayer.is_server():
|
||||
var is_tab_visible = _check_tab_visibility()
|
||||
@@ -2672,21 +2645,16 @@ func _update_mouse_cursor(delta: float):
|
||||
if "mouse_control_active" in player:
|
||||
player.mouse_control_active = false
|
||||
|
||||
const GRID_TILE_SIZE: int = 16
|
||||
|
||||
func get_grid_locked_cursor_position() -> Vector2:
|
||||
# Get the grid-locked cursor world position for spell casting
|
||||
if not dungeon_tilemap_layer:
|
||||
# Grid-locked cursor for spell casting. Uses simple math (no TileMap) to avoid 13ms get_cell_source_id cost.
|
||||
if not camera:
|
||||
return Vector2.ZERO
|
||||
|
||||
var _mouse_pos = get_viewport().get_mouse_position()
|
||||
var world_pos = camera.get_global_mouse_position()
|
||||
|
||||
var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
|
||||
var tile_data = dungeon_tilemap_layer.get_cell_source_id(tile_pos)
|
||||
if tile_data >= 0: # Valid tile
|
||||
# Return tile center world position
|
||||
return dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position
|
||||
|
||||
return Vector2.ZERO # No valid grid position
|
||||
var world_pos: Vector2 = camera.get_global_mouse_position()
|
||||
var tx := int(floorf(world_pos.x / float(GRID_TILE_SIZE)))
|
||||
var ty := int(floorf(world_pos.y / float(GRID_TILE_SIZE)))
|
||||
return Vector2(tx * GRID_TILE_SIZE + GRID_TILE_SIZE / 2.0, ty * GRID_TILE_SIZE + GRID_TILE_SIZE / 2.0)
|
||||
|
||||
func _get_valid_spell_target_position(target_world_pos: Vector2) -> Vector2:
|
||||
# Get valid spell target position (closest valid floor tile, or in front of wall if blocked)
|
||||
@@ -2827,8 +2795,18 @@ const FALLOUT_TERRAIN_VALUE: int = -1
|
||||
func _is_position_on_fallout_tile(world_pos: Vector2) -> bool:
|
||||
if not dungeon_tilemap_layer:
|
||||
return false
|
||||
var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
|
||||
var td = dungeon_tilemap_layer.get_cell_tile_data(tile_pos)
|
||||
# Use cache to avoid get_cell_tile_data() every frame (was causing 40ms+ physics spikes)
|
||||
if _fallout_tile_cache.size() > 0 and _fallout_cache_map_size.x > 0 and _fallout_cache_map_size.y > 0:
|
||||
var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
|
||||
var tx := clampi(tile_pos.x, 0, _fallout_cache_map_size.x - 1)
|
||||
var ty := clampi(tile_pos.y, 0, _fallout_cache_map_size.y - 1)
|
||||
var idx := tx + ty * _fallout_cache_map_size.x
|
||||
if idx >= 0 and idx < _fallout_tile_cache.size():
|
||||
return _fallout_tile_cache[idx] != 0
|
||||
return false
|
||||
# Fallback when cache not built yet
|
||||
var fp = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
|
||||
var td = dungeon_tilemap_layer.get_cell_tile_data(fp)
|
||||
if not td:
|
||||
return false
|
||||
if not td.get_custom_data("terrain") is int:
|
||||
@@ -2892,9 +2870,13 @@ func _is_position_on_cracked_tile(world_pos: Vector2) -> bool:
|
||||
return td.get_custom_data("terrain") == -2
|
||||
|
||||
func _try_cracked_floor_detection() -> void:
|
||||
# When a player gets close to an unrevealed cracked tile, they get one perception roll per tile per game (like traps)
|
||||
if dungeon_data.is_empty() or not dungeon_data.has("cracked_tile_grid") or not dungeon_tilemap_layer_cracked:
|
||||
return
|
||||
cracked_detection_timer += get_process_delta_time()
|
||||
if cracked_detection_timer < CRACKED_DETECTION_INTERVAL:
|
||||
return
|
||||
cracked_detection_timer = 0.0
|
||||
|
||||
var cracked_tile_grid = dungeon_data.cracked_tile_grid
|
||||
var map_size: Vector2i = dungeon_data.map_size
|
||||
var switch_tiles: Dictionary = {}
|
||||
@@ -2907,6 +2889,7 @@ func _try_cracked_floor_detection() -> void:
|
||||
var players: Array = get_tree().get_nodes_in_group("player")
|
||||
if players.is_empty() and player_manager:
|
||||
players = player_manager.get_local_players()
|
||||
var radius_tiles: int = int(ceil(CRACKED_DETECTION_RADIUS / 16.0)) # ~7 tiles
|
||||
for player in players:
|
||||
if not is_instance_valid(player) or not player.is_in_group("player"):
|
||||
continue
|
||||
@@ -2915,10 +2898,18 @@ func _try_cracked_floor_detection() -> void:
|
||||
var qa = player.get_node("QuicksandArea")
|
||||
if is_instance_valid(qa):
|
||||
pos = qa.global_position
|
||||
var pt_x: int = int(pos.x / 16)
|
||||
var pt_y: int = int(pos.y / 16)
|
||||
var x_min: int = maxi(0, pt_x - radius_tiles)
|
||||
var x_max: int = mini(map_size.x - 1, pt_x + radius_tiles)
|
||||
var y_min: int = maxi(0, pt_y - radius_tiles)
|
||||
var y_max: int = mini(map_size.y - 1, pt_y + radius_tiles)
|
||||
var peer_id = player.get_multiplayer_authority() if "get_multiplayer_authority" in player else 0
|
||||
for x in range(map_size.x):
|
||||
for y in range(map_size.y):
|
||||
if x >= cracked_tile_grid.size() or y >= cracked_tile_grid[x].size():
|
||||
for x in range(x_min, x_max + 1):
|
||||
if x >= cracked_tile_grid.size():
|
||||
continue
|
||||
for y in range(y_min, y_max + 1):
|
||||
if y >= (cracked_tile_grid[x] as Array).size():
|
||||
continue
|
||||
if not cracked_tile_grid[x][y]:
|
||||
continue
|
||||
@@ -3035,16 +3026,13 @@ func spawn_detected_effect_at(world_pos: Vector2, parent_node_name: String = "",
|
||||
# Spawn the "detected" effect at position; sync to all players.
|
||||
# effect_type: "chest" (blue, 169-179), "trap" (purple, 274-284), "enemy" (red, 484-494).
|
||||
# If parent_node_name is set (e.g. trap/enemy hand name), effect is added as child of that node so it can be removed when disarmed/emerged.
|
||||
# For "enemy" type we always add to Entities so the effect stays visible (animating) while the hidden enemy has modulate.a = 0.
|
||||
var scene = load("res://scenes/detected_effect.tscn") as PackedScene
|
||||
if not scene:
|
||||
return
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if not entities_node:
|
||||
entities_node = self
|
||||
var parent = null
|
||||
if parent_node_name and effect_type != "enemy":
|
||||
parent = entities_node.get_node_or_null(parent_node_name)
|
||||
var parent = entities_node.get_node_or_null(parent_node_name) if parent_node_name else null
|
||||
var effect = scene.instantiate()
|
||||
if parent:
|
||||
parent.add_child(effect)
|
||||
@@ -3081,9 +3069,7 @@ func _sync_spawn_detected_effect(px: float, py: float, parent_node_name: String
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if not entities_node:
|
||||
entities_node = self
|
||||
var parent = null
|
||||
if parent_node_name and effect_type != "enemy":
|
||||
parent = entities_node.get_node_or_null(parent_node_name)
|
||||
var parent = entities_node.get_node_or_null(parent_node_name) if parent_node_name else null
|
||||
var effect = scene.instantiate()
|
||||
if parent:
|
||||
parent.add_child(effect)
|
||||
@@ -3128,6 +3114,8 @@ func _break_cracked_tile(tile_x: int, tile_y: int) -> void:
|
||||
# Replace floor with fallout on main layer
|
||||
var fallout_tile = _get_fallout_tile_for_floor_at(tile_x, tile_y)
|
||||
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile)
|
||||
if _fallout_tile_cache.size() > 0 and tile_x >= 0 and tile_y >= 0 and tile_x < _fallout_cache_map_size.x and tile_y < _fallout_cache_map_size.y:
|
||||
_fallout_tile_cache[tile_x + tile_y * _fallout_cache_map_size.x] = 1
|
||||
if dungeon_tilemap_layer_decorated:
|
||||
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
|
||||
# Tile center for whoosh/effect removal: use same layer as spawn (cracked) so position matches
|
||||
@@ -3160,6 +3148,8 @@ func _sync_cracked_tile_broke(tile_x: int, tile_y: int, fallout_atlas_x: int, fa
|
||||
var fallout_tile = Vector2i(fallout_atlas_x, fallout_atlas_y)
|
||||
if dungeon_tilemap_layer:
|
||||
dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, fallout_tile)
|
||||
if _fallout_tile_cache.size() > 0 and tile_x >= 0 and tile_y >= 0 and tile_x < _fallout_cache_map_size.x and tile_y < _fallout_cache_map_size.y:
|
||||
_fallout_tile_cache[tile_x + tile_y * _fallout_cache_map_size.x] = 1
|
||||
if dungeon_tilemap_layer_decorated:
|
||||
dungeon_tilemap_layer_decorated.erase_cell(Vector2i(tile_x, tile_y))
|
||||
if dungeon_tilemap_layer_cracked:
|
||||
@@ -3292,6 +3282,9 @@ func _update_fog_of_war(delta: float) -> void:
|
||||
if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
|
||||
return
|
||||
|
||||
_ensure_fog_tile_to_room_index()
|
||||
_rebuild_closed_door_tiles_cache()
|
||||
|
||||
# Determine if we're in a corridor and use appropriate update interval
|
||||
var in_corridor = false
|
||||
if player_manager.get_local_players().size() > 0 and player_manager.get_local_players()[0]:
|
||||
@@ -3307,20 +3300,8 @@ func _update_fog_of_war(delta: float) -> void:
|
||||
cached_corridor_player_tile = Vector2i(-1, -1)
|
||||
cached_corridor_allowed_room_ids.clear()
|
||||
was_in_corridor = in_corridor
|
||||
print("GameWorld: Corridor state changed - was_in_corridor: ", !in_corridor, " -> in_corridor: ", in_corridor)
|
||||
|
||||
# In corridors: only update when entering/exiting or when player moves significantly
|
||||
# Skip expensive updates if we're stationary in a corridor
|
||||
if in_corridor and not corridor_state_changed:
|
||||
# Check if player moved significantly (more than 1 tile)
|
||||
var current_player_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE))
|
||||
var player_moved = cached_corridor_player_tile.distance_to(current_player_tile) > 1
|
||||
|
||||
# Only update if player moved significantly OR enough time has passed (much longer interval)
|
||||
var time_since_last_update = Time.get_ticks_msec() / 1000.0 - last_corridor_fog_update
|
||||
if not player_moved and time_since_last_update < 1.0: # Skip if stationary and updated recently
|
||||
return # Skip expensive fog update - player is stationary in corridor
|
||||
|
||||
# Use shorter interval in corridors so vision updates feel responsive
|
||||
var update_interval = FOG_UPDATE_INTERVAL_CORRIDOR if in_corridor else FOG_UPDATE_INTERVAL
|
||||
fog_update_timer += delta
|
||||
if fog_update_timer < update_interval:
|
||||
@@ -3328,146 +3309,92 @@ func _update_fog_of_war(delta: float) -> void:
|
||||
fog_update_timer = 0.0
|
||||
|
||||
var map_size = dungeon_data.map_size
|
||||
combined_seen = _create_seen_array(map_size)
|
||||
if explored_map.is_empty():
|
||||
explored_map = _create_seen_array(map_size)
|
||||
var total = map_size.x * map_size.y
|
||||
if combined_seen.size() != total:
|
||||
combined_seen.resize(total)
|
||||
for i in range(total):
|
||||
combined_seen[i] = 0
|
||||
if explored_map.is_empty() or explored_map.size() != total:
|
||||
explored_map.resize(total)
|
||||
for i in range(total):
|
||||
explored_map[i] = 0
|
||||
|
||||
var local_player_list = player_manager.get_local_players()
|
||||
fog_debug_lines.clear()
|
||||
|
||||
# Build combined_seen positively only: set 1 where visible. No full-map "clear other rooms" loops.
|
||||
for player in local_player_list:
|
||||
if not player or not is_instance_valid(player):
|
||||
continue
|
||||
if not seen_by_player.has(player.name):
|
||||
seen_by_player[player.name] = _create_seen_array(map_size)
|
||||
var seen_map = seen_by_player[player.name]
|
||||
_update_seen_for_player(player, seen_map)
|
||||
_combine_seen_maps(combined_seen, seen_map)
|
||||
_combine_seen_maps(explored_map, seen_map)
|
||||
|
||||
# Mask visibility to current room only (hide other rooms even if previously seen)
|
||||
var current_room = {}
|
||||
if local_player_list.size() > 0 and local_player_list[0]:
|
||||
var p_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE))
|
||||
current_room = _find_room_at_tile(p_tile)
|
||||
var p_tile = Vector2i(int(player.global_position.x / FOG_TILE_SIZE), int(player.global_position.y / FOG_TILE_SIZE))
|
||||
var current_room = _find_room_at_tile(p_tile)
|
||||
if not current_room.is_empty():
|
||||
_mark_room_explored(current_room)
|
||||
_mark_room_visible(current_room)
|
||||
# Only hide other rooms; keep corridor visible when we can see into it (no closed door in way)
|
||||
for y in range(map_size.y):
|
||||
for x in range(map_size.x):
|
||||
var idx = x + y * map_size.x
|
||||
if idx < 0 or idx >= combined_seen.size():
|
||||
continue
|
||||
var tile_room = _find_room_at_tile(Vector2i(x, y))
|
||||
if tile_room.is_empty():
|
||||
continue # Corridor: keep (already revealed by raycast if visible)
|
||||
if tile_room.x != current_room.x or tile_room.y != current_room.y or tile_room.w != current_room.w or tile_room.h != current_room.h:
|
||||
combined_seen[idx] = 0 # Other room: hide
|
||||
else:
|
||||
# In corridors (no room), only show tiles connected to the corridor component
|
||||
# AND explicitly clear combined_seen for all tiles in rooms that aren't connected
|
||||
var corridor_player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE))
|
||||
|
||||
# Cache corridor data - only rebuild if player moved more than 1 tile
|
||||
var should_rebuild_corridor = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(corridor_player_tile) > 1
|
||||
|
||||
var corridor_mask: PackedInt32Array
|
||||
var corridor_rooms: Array
|
||||
var allowed_room_ids: Dictionary
|
||||
|
||||
if should_rebuild_corridor:
|
||||
# Rebuild corridor mask and rooms (expensive operation)
|
||||
var corridor_player_tile = p_tile
|
||||
var should_rebuild = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(corridor_player_tile) > 1
|
||||
if should_rebuild:
|
||||
cached_corridor_mask = _build_corridor_mask(corridor_player_tile)
|
||||
cached_corridor_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, corridor_player_tile)
|
||||
cached_corridor_player_tile = corridor_player_tile
|
||||
# Build a set of allowed room IDs for fast lookup
|
||||
cached_corridor_allowed_room_ids = {}
|
||||
for room in cached_corridor_rooms:
|
||||
var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h)
|
||||
cached_corridor_allowed_room_ids[room_id] = true
|
||||
# Use the rebuilt data
|
||||
corridor_mask = cached_corridor_mask
|
||||
corridor_rooms = cached_corridor_rooms
|
||||
allowed_room_ids = cached_corridor_allowed_room_ids
|
||||
else:
|
||||
# Use cached data (much faster!)
|
||||
corridor_mask = cached_corridor_mask
|
||||
corridor_rooms = cached_corridor_rooms
|
||||
allowed_room_ids = cached_corridor_allowed_room_ids
|
||||
|
||||
# Check explored rooms and mark them visible
|
||||
for room in corridor_rooms:
|
||||
# If this room was previously explored, mark the entire room (including outer walls) as visible
|
||||
var was_explored = false
|
||||
for x in range(room.x - 2, room.x + room.w + 2):
|
||||
for y in range(room.y - 2, room.y + room.h + 2):
|
||||
if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y:
|
||||
continue
|
||||
var idx = x + y * map_size.x
|
||||
if idx >= 0 and idx < explored_map.size() and explored_map[idx] == 1:
|
||||
was_explored = true
|
||||
break
|
||||
if was_explored:
|
||||
break
|
||||
if was_explored:
|
||||
_mark_room_visible(room)
|
||||
# Explicitly clear combined_seen for ANY tile not in corridor mask or allowed rooms
|
||||
# OPTIMIZATION: Only do expensive per-tile checks when corridor state changed or player moved significantly
|
||||
var needs_tile_clear = corridor_state_changed or should_rebuild_corridor
|
||||
if needs_tile_clear:
|
||||
for y in range(map_size.y):
|
||||
for x in range(map_size.x):
|
||||
var idx = x + y * map_size.x
|
||||
if idx < 0 or idx >= combined_seen.size():
|
||||
continue
|
||||
var tile_in_corridor = idx < corridor_mask.size() and corridor_mask[idx] == 1
|
||||
# Check if this tile is in a room, and if so, is it an allowed room?
|
||||
var tile_room = _find_room_at_tile(Vector2i(x, y))
|
||||
var in_allowed_room = false
|
||||
if not tile_room.is_empty():
|
||||
var room_id = str(tile_room.x) + "," + str(tile_room.y) + "," + str(tile_room.w) + "," + str(tile_room.h)
|
||||
in_allowed_room = allowed_room_ids.has(room_id)
|
||||
# Clear combined_seen for any tile not in corridor or allowed rooms
|
||||
if not tile_in_corridor and not in_allowed_room:
|
||||
combined_seen[idx] = 0
|
||||
|
||||
# Update last corridor fog update time
|
||||
for idx in range(cached_corridor_mask.size()):
|
||||
if cached_corridor_mask[idx] == 1:
|
||||
combined_seen[idx] = 1
|
||||
for room in cached_corridor_rooms:
|
||||
_mark_room_in_seen_map(combined_seen, room)
|
||||
last_corridor_fog_update = Time.get_ticks_msec() / 1000.0
|
||||
|
||||
if fog_node.has_method("set_maps"):
|
||||
fog_node.set_maps(explored_map, combined_seen)
|
||||
if fog_node.has_method("set_debug_lines"):
|
||||
fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW)
|
||||
# Merge visible into explored (single pass)
|
||||
for i in range(total):
|
||||
if combined_seen[i] != 0:
|
||||
explored_map[i] = 1
|
||||
|
||||
# Stagger heavy texture updates: fog one tick, minimap next, to avoid one big spike
|
||||
var player_tile := Vector2i(-1, -1)
|
||||
if local_player_list.size() > 0 and local_player_list[0]:
|
||||
player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE))
|
||||
var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw")
|
||||
if minimap_draw and minimap_draw.has_method("set_maps") and dungeon_data.has("grid"):
|
||||
var exit_tile := Vector2i(-1, -1)
|
||||
var exit_discovered := false
|
||||
if dungeon_data.has("stairs") and not dungeon_data.stairs.is_empty():
|
||||
var s = dungeon_data.stairs
|
||||
if s.has("x") and s.has("y") and s.has("w") and s.has("h"):
|
||||
exit_tile = Vector2i(s.x + s.w / 2, s.y + s.h / 2)
|
||||
for dx in range(int(s.w)):
|
||||
for dy in range(int(s.h)):
|
||||
var tx: int = int(s.x) + dx
|
||||
var ty: int = int(s.y) + dy
|
||||
if tx >= 0 and ty >= 0 and tx < map_size.x and ty < map_size.y:
|
||||
var idx: int = tx + ty * map_size.x
|
||||
if idx >= 0 and idx < explored_map.size() and explored_map[idx] != 0:
|
||||
exit_discovered = true
|
||||
break
|
||||
if exit_discovered:
|
||||
break
|
||||
var other_player_tiles: Array = []
|
||||
var all_players = get_tree().get_nodes_in_group("player")
|
||||
for p in all_players:
|
||||
if not is_instance_valid(p) or p in local_player_list:
|
||||
continue
|
||||
var pt = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
|
||||
other_player_tiles.append(pt)
|
||||
minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered, other_player_tiles)
|
||||
|
||||
if fog_node.has_method("set_debug_lines"):
|
||||
fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW)
|
||||
|
||||
# Fog texture: pass phase so fog updates only half the rows per tick (spread over 2 frames). Minimap every 2nd tick.
|
||||
if fog_node.has_method("set_maps"):
|
||||
fog_node.set_maps(explored_map, combined_seen, fog_visual_tick % 2)
|
||||
|
||||
fog_visual_tick += 1
|
||||
if fog_visual_tick % 2 == 1:
|
||||
var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw")
|
||||
if minimap_draw and minimap_draw.has_method("set_maps") and dungeon_data.has("grid"):
|
||||
var exit_tile := Vector2i(-1, -1)
|
||||
var exit_discovered := false
|
||||
if dungeon_data.has("stairs") and not dungeon_data.stairs.is_empty():
|
||||
var s = dungeon_data.stairs
|
||||
if s.has("x") and s.has("y") and s.has("w") and s.has("h"):
|
||||
exit_tile = Vector2i(s.x + s.w / 2, s.y + s.h / 2)
|
||||
for dx in range(int(s.w)):
|
||||
for dy in range(int(s.h)):
|
||||
var tx: int = int(s.x) + dx
|
||||
var ty: int = int(s.y) + dy
|
||||
if tx >= 0 and ty >= 0 and tx < map_size.x and ty < map_size.y:
|
||||
var idx: int = tx + ty * map_size.x
|
||||
if idx >= 0 and idx < explored_map.size() and explored_map[idx] != 0:
|
||||
exit_discovered = true
|
||||
break
|
||||
if exit_discovered:
|
||||
break
|
||||
var other_player_tiles: Array = []
|
||||
var all_players = get_tree().get_nodes_in_group("player")
|
||||
for p in all_players:
|
||||
if not is_instance_valid(p) or p in local_player_list:
|
||||
continue
|
||||
other_player_tiles.append(Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE)))
|
||||
# Build list of currently visible tile coords so minimap only updates those (smooth, no 1.8ms spike)
|
||||
var visible_tiles: Array = []
|
||||
for i in range(combined_seen.size()):
|
||||
if combined_seen[i] != 0:
|
||||
visible_tiles.append(Vector2i(i % map_size.x, i / map_size.x))
|
||||
minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered, other_player_tiles, dungeon_data.get("rooms", []), visible_tiles)
|
||||
|
||||
func _create_seen_array(map_size: Vector2i) -> PackedInt32Array:
|
||||
var size = map_size.x * map_size.y
|
||||
@@ -3549,6 +3476,32 @@ func _mark_seen(seen_map: PackedInt32Array, tile: Vector2i) -> void:
|
||||
if idx >= 0 and idx < seen_map.size():
|
||||
seen_map[idx] = 1
|
||||
|
||||
func _mark_room_in_seen_map(seen_map: PackedInt32Array, room: Dictionary) -> void:
|
||||
if room.is_empty():
|
||||
return
|
||||
var map_size = dungeon_data.map_size
|
||||
for x in range(room.x - 2, room.x + room.w + 2):
|
||||
for y in range(room.y - 2, room.y + room.h + 2):
|
||||
if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y:
|
||||
continue
|
||||
var idx = x + y * map_size.x
|
||||
if idx >= 0 and idx < seen_map.size():
|
||||
seen_map[idx] = 1
|
||||
|
||||
func _update_seen_for_player_simple(player: Node, seen_map: PackedInt32Array) -> void:
|
||||
var p_tile = Vector2i(int(player.global_position.x / FOG_TILE_SIZE), int(player.global_position.y / FOG_TILE_SIZE))
|
||||
var current_room = _find_room_at_tile(p_tile)
|
||||
if not current_room.is_empty():
|
||||
_mark_room_in_seen_map(seen_map, current_room)
|
||||
return
|
||||
var corridor_mask = _build_corridor_mask(p_tile)
|
||||
var allowed_rooms = _get_rooms_connected_to_corridor(corridor_mask, p_tile)
|
||||
for idx in range(corridor_mask.size()):
|
||||
if corridor_mask[idx] == 1:
|
||||
seen_map[idx] = 1
|
||||
for room in allowed_rooms:
|
||||
_mark_room_in_seen_map(seen_map, room)
|
||||
|
||||
func _is_tile_in_room_or_walls(tile: Vector2i, room: Dictionary) -> bool:
|
||||
if room.is_empty():
|
||||
return false
|
||||
@@ -3581,13 +3534,47 @@ func _mark_room_visible(room: Dictionary) -> void:
|
||||
if idx >= 0 and idx < combined_seen.size():
|
||||
combined_seen[idx] = 1
|
||||
|
||||
func _ensure_fog_tile_to_room_index() -> void:
|
||||
var map_size = dungeon_data.map_size
|
||||
var total = map_size.x * map_size.y
|
||||
if fog_tile_to_room_index.size() == total:
|
||||
return
|
||||
fog_tile_to_room_index.resize(total)
|
||||
for i in range(total):
|
||||
fog_tile_to_room_index[i] = -1
|
||||
var rooms = dungeon_data.rooms
|
||||
for room_idx in range(rooms.size()):
|
||||
var room = rooms[room_idx]
|
||||
for x in range(room.x, room.x + room.w):
|
||||
for y in range(room.y, room.y + room.h):
|
||||
if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
|
||||
fog_tile_to_room_index[x + y * map_size.x] = room_idx
|
||||
|
||||
func _rebuild_closed_door_tiles_cache() -> void:
|
||||
_cached_closed_door_tiles.clear()
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if not entities_node:
|
||||
return
|
||||
for child in entities_node.get_children():
|
||||
if child.is_in_group("blocking_door") and "is_closed" in child and child.is_closed:
|
||||
var tx = int(child.global_position.x / FOG_TILE_SIZE)
|
||||
var ty = int(child.global_position.y / FOG_TILE_SIZE)
|
||||
_cached_closed_door_tiles[str(tx) + "," + str(ty)] = true
|
||||
|
||||
func _find_room_at_tile(tile: Vector2i) -> Dictionary:
|
||||
if dungeon_data.is_empty() or not dungeon_data.has("rooms"):
|
||||
return {}
|
||||
for room in dungeon_data.rooms:
|
||||
if tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h:
|
||||
return room
|
||||
return {}
|
||||
var map_size = dungeon_data.map_size
|
||||
if tile.x < 0 or tile.y < 0 or tile.x >= map_size.x or tile.y >= map_size.y:
|
||||
return {}
|
||||
_ensure_fog_tile_to_room_index()
|
||||
var idx = tile.x + tile.y * map_size.x
|
||||
if idx < 0 or idx >= fog_tile_to_room_index.size():
|
||||
return {}
|
||||
var ri: int = fog_tile_to_room_index[idx]
|
||||
if ri < 0:
|
||||
return {}
|
||||
return dungeon_data.rooms[ri]
|
||||
|
||||
func _get_room_index_for_tile(tile: Vector2i) -> int:
|
||||
if dungeon_data.is_empty() or not dungeon_data.has("rooms"):
|
||||
@@ -3777,20 +3764,9 @@ func _get_player_view_dir(player: Node) -> Vector2:
|
||||
return Vector2.RIGHT
|
||||
|
||||
func _is_visibility_blocking_tile(tile: Vector2i) -> bool:
|
||||
# Walls block vision
|
||||
if dungeon_data.grid[tile.x][tile.y] == 0:
|
||||
return true
|
||||
|
||||
# Closed doors block vision
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if entities_node:
|
||||
for door in entities_node.get_children():
|
||||
if door.is_in_group("blocking_door"):
|
||||
if "is_closed" in door and door.is_closed:
|
||||
var door_tile = Vector2i(int(door.global_position.x / FOG_TILE_SIZE), int(door.global_position.y / FOG_TILE_SIZE))
|
||||
if door_tile == tile:
|
||||
return true
|
||||
return false
|
||||
return _cached_closed_door_tiles.get(str(tile.x) + "," + str(tile.y), false)
|
||||
|
||||
func _build_corridor_mask(start_tile: Vector2i) -> PackedInt32Array:
|
||||
var map_size = dungeon_data.map_size
|
||||
@@ -3982,6 +3958,9 @@ func _generate_dungeon():
|
||||
# Spawn room triggers
|
||||
_spawn_room_triggers()
|
||||
|
||||
# One-time warmup: instantiate each attack/projectile scene once so first enemy use doesn't stall (smooth in-game and WebAssembly)
|
||||
_warmup_attack_scenes()
|
||||
|
||||
# Wait a frame to ensure enemies and objects are properly in scene tree before syncing
|
||||
await get_tree().process_frame
|
||||
|
||||
@@ -4432,6 +4411,28 @@ func _render_dungeon():
|
||||
|
||||
# Randomize dungeon color scheme (seed-based)
|
||||
_apply_dungeon_color_scheme()
|
||||
# Build fallout lookup cache so _is_position_on_fallout_tile is O(1) per call (avoids get_cell_tile_data in physics)
|
||||
_build_fallout_tile_cache()
|
||||
|
||||
func _build_fallout_tile_cache() -> void:
|
||||
_fallout_tile_cache.resize(0)
|
||||
_fallout_cache_map_size = Vector2i.ZERO
|
||||
if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
|
||||
return
|
||||
var map_size: Vector2i = dungeon_data.map_size
|
||||
var total := map_size.x * map_size.y
|
||||
if total <= 0:
|
||||
return
|
||||
_fallout_cache_map_size = map_size
|
||||
_fallout_tile_cache.resize(total)
|
||||
for i in range(total):
|
||||
_fallout_tile_cache[i] = 0
|
||||
for y in range(map_size.y):
|
||||
for x in range(map_size.x):
|
||||
var tile_pos = Vector2i(x, y)
|
||||
var td = dungeon_tilemap_layer.get_cell_tile_data(tile_pos)
|
||||
if td and td.get_custom_data("terrain") is int and int(td.get_custom_data("terrain")) == FALLOUT_TERRAIN_VALUE:
|
||||
_fallout_tile_cache[x + y * map_size.x] = 1
|
||||
|
||||
func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = true):
|
||||
# Update player manager spawn points based on a room
|
||||
@@ -5334,7 +5335,7 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h
|
||||
print("GameWorld: Client - Spawning room triggers...")
|
||||
_spawn_room_triggers()
|
||||
print("GameWorld: Client - Room triggers spawned")
|
||||
|
||||
_warmup_attack_scenes()
|
||||
# Wait a frame to ensure all enemies and objects are properly added to scene tree and initialized
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame # Wait an extra frame to ensure enemies are fully ready
|
||||
@@ -5802,12 +5803,7 @@ func _reassemble_dungeon_blob():
|
||||
print("GameWorld: Client - Spawning room triggers from blob...")
|
||||
_spawn_room_triggers()
|
||||
print("GameWorld: Client - Room triggers spawned")
|
||||
|
||||
# CRITICAL for joiner: apply any boss spider spawns that arrived before dungeon was ready
|
||||
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||
network_manager.apply_pending_boss_spider_spawns(self)
|
||||
LogManager.log("GameWorld: Client applied pending boss spider spawns after dungeon blob", LogManager.CATEGORY_DUNGEON)
|
||||
|
||||
_warmup_attack_scenes()
|
||||
# Apply door states (from metadata) - after doors are spawned
|
||||
if pending_door_states.size() > 0:
|
||||
print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...")
|
||||
@@ -6040,10 +6036,6 @@ func _sync_dungeon_entities(non_essential_data: Dictionary):
|
||||
# Spawn blocking doors and room triggers if not already spawned
|
||||
_spawn_blocking_doors()
|
||||
_spawn_room_triggers()
|
||||
|
||||
# Joiner: apply any boss spider spawns that arrived before we had Entities ready
|
||||
if not multiplayer.is_server() and network_manager and network_manager.pending_boss_spider_spawns.size() > 0 and network_manager.has_method("apply_pending_boss_spider_spawns"):
|
||||
network_manager.apply_pending_boss_spider_spawns(self)
|
||||
|
||||
func _fix_player_appearance_after_dungeon_sync():
|
||||
# Re-randomize appearance for all players that were spawned before dungeon_seed was received
|
||||
@@ -7602,13 +7594,23 @@ func _clear_level():
|
||||
# Clear dungeon data (but keep it for now until new one is generated)
|
||||
# dungeon_data = {} # Don't clear yet, wait for new generation
|
||||
|
||||
# Clear fog of war
|
||||
# Clear fog of war and minimap explored state so level 2 doesn't show level 1's explored rooms
|
||||
seen_by_player.clear()
|
||||
combined_seen = PackedInt32Array()
|
||||
explored_map = PackedInt32Array()
|
||||
if fog_node and is_instance_valid(fog_node):
|
||||
fog_node.queue_free()
|
||||
fog_node = null
|
||||
|
||||
fog_tile_to_room_index.resize(0)
|
||||
_cached_closed_door_tiles.clear()
|
||||
_fallout_tile_cache.resize(0)
|
||||
_fallout_cache_map_size = Vector2i.ZERO
|
||||
|
||||
# Clear minimap so it rebuilds for the new level
|
||||
var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw")
|
||||
if minimap_draw and minimap_draw.has_method("clear_for_new_level"):
|
||||
minimap_draw.clear_for_new_level()
|
||||
|
||||
LogManager.log("GameWorld: Previous level cleared", LogManager.CATEGORY_DUNGEON)
|
||||
|
||||
func _hide_all_players():
|
||||
@@ -8905,26 +8907,6 @@ func _sync_exp_text_at_player(amount: float, player_peer_id: int):
|
||||
if player and is_instance_valid(player):
|
||||
_show_exp_number_at_player(amount, player)
|
||||
|
||||
# Show damage number at world position (e.g. when attacker hits boss so they always see the number)
|
||||
func show_damage_number_at_position(world_pos: Vector2, amount: float, is_critical: bool = false) -> void:
|
||||
var damage_number_scene = preload("res://scenes/damage_number.tscn")
|
||||
if not damage_number_scene:
|
||||
return
|
||||
var damage_label = damage_number_scene.instantiate()
|
||||
if not damage_label:
|
||||
return
|
||||
damage_label.label = str(int(amount))
|
||||
damage_label.color = Color(1.0, 0.6, 0.2) if is_critical else Color(1.0, 0.35, 0.35)
|
||||
damage_label.z_index = 50
|
||||
damage_label.direction = Vector2(0, -1)
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if entities_node:
|
||||
entities_node.add_child(damage_label)
|
||||
damage_label.global_position = world_pos + Vector2(0, -16)
|
||||
else:
|
||||
get_tree().current_scene.add_child(damage_label)
|
||||
damage_label.global_position = world_pos + Vector2(0, -16)
|
||||
|
||||
func _show_exp_number_at_position(amount: float, exp_pos: Vector2):
|
||||
# Show EXP number (green, using dmg_numbers.png font) at a specific position
|
||||
var damage_number_scene = preload("res://scenes/damage_number.tscn")
|
||||
@@ -10415,18 +10397,51 @@ func _spawn_room_triggers():
|
||||
LogManager.log("GameWorld: Added room trigger " + str(trigger.name) + " for room (" + str(room.x) + ", " + str(room.y) + ") - " + str(triggers_spawned) + "/" + str(rooms.size()), LogManager.CATEGORY_DUNGEON)
|
||||
|
||||
LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON)
|
||||
|
||||
# Explicitly connect every blocking door to its room trigger (ensures boss room and all puzzle room doors close on enter)
|
||||
_connect_all_doors_to_room_triggers()
|
||||
|
||||
func _connect_all_doors_to_room_triggers():
|
||||
func _warmup_attack_scenes() -> void:
|
||||
# Instantiate each attack/projectile scene once so first use by enemies doesn't cause a frame spike (important for WebAssembly).
|
||||
var entities_node = get_node_or_null("Entities")
|
||||
if not entities_node:
|
||||
return
|
||||
for child in entities_node.get_children():
|
||||
if (child.is_in_group("blocking_door") or (child.name and child.name.begins_with("BlockingDoor_"))) and is_instance_valid(child):
|
||||
if child.get("blocking_room") and not child.blocking_room.is_empty():
|
||||
_connect_door_to_room_trigger(child)
|
||||
var scenes = [
|
||||
preload("res://scenes/attack_arrow.tscn"),
|
||||
preload("res://scenes/attack_bomb.tscn"),
|
||||
preload("res://scenes/sword_projectile.tscn"),
|
||||
preload("res://scenes/attack_spell_flame.tscn"),
|
||||
preload("res://scenes/attack_spell_frostspike.tscn"),
|
||||
preload("res://scenes/attack_web_shot.tscn"),
|
||||
preload("res://scenes/enemy_spider.tscn"),
|
||||
preload("res://scenes/debuff_burn.tscn"),
|
||||
preload("res://scenes/explosion_tile_particle.tscn"),
|
||||
preload("res://scenes/floating_text.tscn"),
|
||||
]
|
||||
for s in scenes:
|
||||
var n = s.instantiate()
|
||||
entities_node.add_child(n)
|
||||
n.queue_free()
|
||||
# Warmup push/drag: first time pushing triggers drag SFX and particles; preload by playing once then stopping
|
||||
var push_scene = preload("res://scenes/interactable_object.tscn") as PackedScene
|
||||
if push_scene:
|
||||
var push_obj = push_scene.instantiate()
|
||||
entities_node.add_child(push_obj)
|
||||
if push_obj.has_method("play_drag_sound"):
|
||||
push_obj.play_drag_sound()
|
||||
if push_obj.has_method("stop_drag_sound"):
|
||||
push_obj.stop_drag_sound()
|
||||
push_obj.queue_free()
|
||||
# Warmup physics queries (first use can spike - push uses intersect_shape)
|
||||
var space = get_world_2d().direct_space_state
|
||||
var pq = PhysicsPointQueryParameters2D.new()
|
||||
pq.position = Vector2(-10000, -10000)
|
||||
pq.collision_mask = 1
|
||||
space.intersect_point(pq)
|
||||
var rect = RectangleShape2D.new()
|
||||
rect.size = Vector2(16, 16)
|
||||
var sq = PhysicsShapeQueryParameters2D.new()
|
||||
sq.shape = rect
|
||||
sq.transform = Transform2D(0, Vector2(-10000, -10000))
|
||||
sq.collision_mask = 1
|
||||
space.intersect_shape(sq)
|
||||
|
||||
func _place_key_in_room(room: Dictionary):
|
||||
# Place a key in the specified room (as loot)
|
||||
@@ -10505,8 +10520,8 @@ func _connect_door_to_room_trigger(door: Node):
|
||||
if trigger_room and not trigger_room.is_empty() and \
|
||||
trigger_room.x == blocking_room.x and trigger_room.y == blocking_room.y and \
|
||||
trigger_room.w == blocking_room.w and trigger_room.h == blocking_room.h:
|
||||
# Connect door to trigger (avoid duplicate if room_trigger._find_room_entities already added it)
|
||||
# Connect door to trigger
|
||||
door.room_trigger_area = trigger
|
||||
if door not in trigger.doors_in_room:
|
||||
trigger.doors_in_room.append(door)
|
||||
# Add door to trigger's doors list (doors_in_room is a variable in room_trigger.gd)
|
||||
trigger.doors_in_room.append(door)
|
||||
break
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
var tileParticleScene = preload("res://scenes/tile_particle.tscn")
|
||||
# Preload so _convert_to_bomb_projectile doesn't spike (~25ms load() midgame)
|
||||
const _ATTACK_BOMB_SCENE: PackedScene = preload("res://scenes/attack_bomb.tscn")
|
||||
|
||||
# Interactable Object - Can be grabbed, pushed, pulled, lifted, and thrown
|
||||
|
||||
@@ -56,6 +58,9 @@ var falling_into_fallout: bool = false
|
||||
var fallout_sink_progress: float = 1.0
|
||||
const FALLOUT_SINK_DURATION: float = 0.4
|
||||
|
||||
# Cache game_world for _physics_process hot path (avoids get_first_node_in_group per object per frame)
|
||||
var _cached_gw: Node = null
|
||||
|
||||
func _ready():
|
||||
# Make sure it's on the interactable layer
|
||||
collision_layer = 2 # Layer 2 for objects
|
||||
@@ -178,8 +183,9 @@ func _physics_process(delta):
|
||||
if not is_frozen:
|
||||
# Fallout: sink and disappear when on ground (not held, not airborne). Pillars must never sink.
|
||||
if not is_airborne and position_z <= 0.0 and object_type != "Pillar":
|
||||
var gw = get_tree().get_first_node_in_group("game_world")
|
||||
if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(global_position):
|
||||
if _cached_gw == null or not is_instance_valid(_cached_gw):
|
||||
_cached_gw = get_tree().get_first_node_in_group("game_world")
|
||||
if _cached_gw and _cached_gw.has_method("_is_position_on_fallout_tile") and _cached_gw._is_position_on_fallout_tile(global_position):
|
||||
if not falling_into_fallout:
|
||||
falling_into_fallout = true
|
||||
fallout_sink_progress = 1.0
|
||||
@@ -661,57 +667,34 @@ func on_thrown(by_player, force: Vector2):
|
||||
position_z = 2.5
|
||||
velocity_z = 100.0 # Scaled down for 1x scale
|
||||
|
||||
print(name, " thrown with velocity ", throw_velocity)
|
||||
|
||||
func _convert_to_bomb_projectile(by_player, force: Vector2):
|
||||
# Convert bomb object to projectile bomb when thrown
|
||||
var attack_bomb_scene = load("res://scenes/attack_bomb.tscn")
|
||||
if not attack_bomb_scene:
|
||||
push_error("ERROR: Could not load attack_bomb scene!")
|
||||
return
|
||||
|
||||
# Only authority can spawn bombs
|
||||
# Defer so we don't change physics state (add_child / collision) while inside a physics callback (e.g. trap damage -> take_damage -> throw)
|
||||
if not is_multiplayer_authority():
|
||||
return
|
||||
|
||||
# Store current position before freeing
|
||||
var current_pos = global_position
|
||||
|
||||
# Spawn bomb projectile at current position
|
||||
var bomb = attack_bomb_scene.instantiate()
|
||||
bomb.name = "ThrownBomb_" + name
|
||||
get_parent().add_child(bomb)
|
||||
bomb.global_position = current_pos # Use current position, not target
|
||||
|
||||
# Set multiplayer authority
|
||||
call_deferred("_convert_to_bomb_projectile_deferred", global_position, get_parent(), name, by_player, force)
|
||||
|
||||
func _convert_to_bomb_projectile_deferred(current_pos: Vector2, parent_node: Node, obj_name: String, by_player, force: Vector2):
|
||||
if not is_instance_valid(parent_node) or not is_instance_valid(by_player):
|
||||
return
|
||||
var bomb = _ATTACK_BOMB_SCENE.instantiate()
|
||||
bomb.name = "ThrownBomb_" + obj_name
|
||||
parent_node.add_child(bomb)
|
||||
bomb.global_position = current_pos
|
||||
if multiplayer.has_multiplayer_peer():
|
||||
bomb.set_multiplayer_authority(by_player.get_multiplayer_authority())
|
||||
|
||||
# Setup bomb with throw physics (pass force as throw_velocity)
|
||||
# The bomb will use throw_velocity for movement
|
||||
bomb.setup(current_pos, by_player, force, true) # true = is_thrown, force is throw_velocity
|
||||
|
||||
# Make sure bomb sprite is visible
|
||||
bomb.setup(current_pos, by_player, force, true)
|
||||
if bomb.has_node("Sprite2D"):
|
||||
bomb.get_node("Sprite2D").visible = true
|
||||
|
||||
# Sync bomb throw to other clients
|
||||
if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority():
|
||||
if by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree():
|
||||
# Player threw: sync via player RPC (pass our name so they can free the lifted bomb)
|
||||
by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force])
|
||||
by_player._rpc_to_ready_peers("_sync_throw_bomb", [obj_name, current_pos, force])
|
||||
elif by_player.is_in_group("enemy"):
|
||||
# Enemy threw: sync via game_world so joiners see the bomb
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_rpc_to_ready_peers"):
|
||||
var enemy_index = by_player.get_meta("enemy_index") if by_player.has_meta("enemy_index") else -1
|
||||
var bomb_name = "EnemyBomb_" + by_player.name + "_" + str(Time.get_ticks_msec())
|
||||
game_world._rpc_to_ready_peers("_sync_enemy_throw_bomb", [bomb_name, by_player.name, enemy_index, current_pos, force])
|
||||
|
||||
# Remove the interactable object
|
||||
queue_free()
|
||||
|
||||
print("Bomb object converted to projectile and thrown!")
|
||||
|
||||
@rpc("authority", "unreliable")
|
||||
func _sync_box_state(pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool):
|
||||
|
||||
@@ -1886,6 +1886,58 @@ static func _register_item(item_id: String, item_data: Dictionary):
|
||||
item_data["item_id"] = item_id
|
||||
item_definitions[item_id] = item_data
|
||||
|
||||
# Collect all unique texture paths used for equipment and character appearance (for preloading to avoid equip spikes)
|
||||
static func get_all_equipment_and_appearance_paths() -> Array:
|
||||
_initialize()
|
||||
var paths: Array = []
|
||||
var seen: Dictionary = {}
|
||||
for item_id in item_definitions:
|
||||
var data = item_definitions[item_id]
|
||||
if data.has("equipmentPath"):
|
||||
var p = data["equipmentPath"]
|
||||
if p is String and p != "" and not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
# Character appearance paths (skin, hair, eyes, etc.) - same set as used by CharacterStats/player
|
||||
for i in range(1, 8):
|
||||
var p = "res://assets/gfx/Puny-Characters/Layer 0 - Skins/Human" + str(i) + ".png"
|
||||
if not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
var facial_bases = ["Beardstyle1", "Beardstyle2", "Mustache1"]
|
||||
for name in facial_bases:
|
||||
var p = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Facial Hairstyles/" + name + "White.png"
|
||||
if not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
for i in range(1, 5):
|
||||
var p = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/FHairstyle" + str(i) + "White.png"
|
||||
if not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
for i in range(1, 9): # MHairstyle1..8 (iType 5..12 in setHair)
|
||||
var p = "res://assets/gfx/Puny-Characters/Layer 4 - Hairstyle/Hairstyles/MHairstyle" + str(i) + "White.png"
|
||||
if not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
var eyelash_paths = ["res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/FEyelash1.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/FEyelash2.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/FEyelash3.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/MEyelash1.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/MEyelash2.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/NEyelash1.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/NEyelash2.png", "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eyelashes/NEyelash3.png"]
|
||||
for p in eyelash_paths:
|
||||
if not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
var eye_names = ["Black", "Blue", "Cyan", "DarkBlue", "DarkCyan", "DarkLime", "DarkRed", "FullBlack", "FullWhite", "Gray", "LightLime", "Orange", "Red", "Yellow"]
|
||||
for name in eye_names:
|
||||
var p = "res://assets/gfx/Puny-Characters/Layer 5 - Eyes/Eye Color/Eyecolor" + name + ".png"
|
||||
if not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
for i in range(1, 8):
|
||||
var p = "res://assets/gfx/Puny-Characters/Layer 7 - Add-ons/Elf Add-ons/ElfEars" + str(i) + ".png"
|
||||
if not seen.get(p, false):
|
||||
seen[p] = true
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
# Create an Item instance from an item ID
|
||||
static func create_item(item_id: String) -> Item:
|
||||
_initialize()
|
||||
|
||||
119
src/scripts/loader.gd
Normal file
119
src/scripts/loader.gd
Normal 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
|
||||
1
src/scripts/loader.gd.uid
Normal file
1
src/scripts/loader.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d043ergi5kef1
|
||||
@@ -1,6 +1,9 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# Loot Item - Coins and food items that drop from enemies
|
||||
# Preload common textures so first pickup doesn't spike (load() was ~55ms)
|
||||
const _TEX_COIN: Texture2D = preload("res://assets/gfx/pickups/gold_coin.png")
|
||||
const _TEX_ITEMS: Texture2D = preload("res://assets/gfx/pickups/items_n_shit.png")
|
||||
|
||||
enum LootType {
|
||||
COIN,
|
||||
@@ -117,39 +120,35 @@ func _setup_sprite():
|
||||
|
||||
match loot_type:
|
||||
LootType.COIN:
|
||||
# Load coin texture
|
||||
var coin_texture = load("res://assets/gfx/pickups/gold_coin.png")
|
||||
if coin_texture and sprite:
|
||||
if _TEX_COIN and sprite:
|
||||
var coin_texture = _TEX_COIN
|
||||
sprite.texture = coin_texture
|
||||
sprite.hframes = 6
|
||||
sprite.vframes = 1
|
||||
sprite.frame = 0
|
||||
print("Coin sprite setup: texture=", coin_texture != null, " hframes=", sprite.hframes, " vframes=", sprite.vframes, " frame=", sprite.frame)
|
||||
else:
|
||||
print("ERROR: Coin texture or sprite is null! texture=", coin_texture, " sprite=", sprite)
|
||||
LootType.APPLE:
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
var items_texture = _TEX_ITEMS
|
||||
if items_texture:
|
||||
sprite.texture = items_texture
|
||||
sprite.hframes = 20
|
||||
sprite.vframes = 14
|
||||
sprite.frame = (8 * 20) + 10
|
||||
LootType.BANANA:
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
var items_texture = _TEX_ITEMS
|
||||
if items_texture:
|
||||
sprite.texture = items_texture
|
||||
sprite.hframes = 20
|
||||
sprite.vframes = 14
|
||||
sprite.frame = (8 * 20) + 11
|
||||
LootType.CHERRY:
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
var items_texture = _TEX_ITEMS
|
||||
if items_texture:
|
||||
sprite.texture = items_texture
|
||||
sprite.hframes = 20
|
||||
sprite.vframes = 14
|
||||
sprite.frame = (8 * 20) + 12
|
||||
LootType.KEY:
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
var items_texture = _TEX_ITEMS
|
||||
if items_texture:
|
||||
sprite.texture = items_texture
|
||||
sprite.hframes = 20
|
||||
@@ -492,10 +491,7 @@ func _on_pickup_area_body_entered(body):
|
||||
var player_peer_id = body.get_multiplayer_authority()
|
||||
if player_peer_id == dropped_by_peer_id and time_since_drop < 5.0:
|
||||
# Player can't pick up their own dropped item for 5 seconds
|
||||
print("Loot: Player ", body.name, " cannot pick up item they dropped (", time_since_drop, "s ago, need 5s cooldown)")
|
||||
return
|
||||
|
||||
print("Loot: Pickup area entered by player: ", body.name, " is_local: ", body.is_local_player if "is_local_player" in body else "unknown", " is_server: ", multiplayer.is_server())
|
||||
_pickup(body)
|
||||
|
||||
func _pickup(player: Node):
|
||||
@@ -503,14 +499,8 @@ func _pickup(player: Node):
|
||||
return
|
||||
# Prevent multiple pickups
|
||||
if collected:
|
||||
print("Loot: Already collected, ignoring pickup")
|
||||
return
|
||||
|
||||
var player_auth_str = "N/A"
|
||||
if "get_multiplayer_authority" in player:
|
||||
player_auth_str = str(player.get_multiplayer_authority())
|
||||
print("Loot: _pickup called by player: ", player.name, " is_server: ", multiplayer.is_server(), " has_peer: ", multiplayer.has_multiplayer_peer(), " player_authority: ", player_auth_str)
|
||||
|
||||
# In multiplayer, only process on server or if player has authority
|
||||
# If client player picks it up, send RPC to server
|
||||
if multiplayer.has_multiplayer_peer():
|
||||
@@ -519,7 +509,6 @@ func _pickup(player: Node):
|
||||
if player.is_multiplayer_authority():
|
||||
# This is the local player, send request to server
|
||||
var player_peer_id = player.get_multiplayer_authority()
|
||||
print("Loot: Client sending pickup request to server for player peer_id: ", player_peer_id)
|
||||
# Route through game_world to avoid node path issues
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
|
||||
@@ -528,8 +517,6 @@ func _pickup(player: Node):
|
||||
else:
|
||||
# Fallback: try direct RPC
|
||||
rpc_id(1, "_request_pickup", player_peer_id)
|
||||
else:
|
||||
print("Loot: Client player does not have authority, cannot pickup")
|
||||
return
|
||||
else:
|
||||
# Server: If player doesn't have authority, this is a client player
|
||||
@@ -559,17 +546,10 @@ func _process_pickup_on_server(player: Node):
|
||||
pickup_area.set_deferred("monitorable", false)
|
||||
|
||||
# Sync removal to all clients FIRST (before processing pickup)
|
||||
# This ensures clients remove the loot even if host processes it
|
||||
# Use game_world to route removal sync instead of direct RPC to avoid node path issues
|
||||
if multiplayer.has_multiplayer_peer() and is_inside_tree():
|
||||
var gw = get_tree().get_first_node_in_group("game_world") if is_inside_tree() else null
|
||||
if multiplayer.has_multiplayer_peer() and is_inside_tree() and gw and gw.has_method("_sync_loot_remove"):
|
||||
var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_loot_remove"):
|
||||
print("Loot: Server syncing removal of loot id=", loot_id, " at ", global_position)
|
||||
game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position])
|
||||
else:
|
||||
# If GameWorld isn't ready, skip removal sync to avoid node path RPC errors
|
||||
print("Loot: GameWorld not ready, skipping removal sync for loot id=", loot_id)
|
||||
gw._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position])
|
||||
|
||||
match loot_type:
|
||||
LootType.COIN:
|
||||
@@ -579,13 +559,10 @@ func _process_pickup_on_server(player: Node):
|
||||
if player.has_method("add_coins"):
|
||||
player.add_coins(coin_value)
|
||||
# Show floating text with item graphic and text
|
||||
var coin_texture = load("res://assets/gfx/pickups/gold_coin.png")
|
||||
_show_floating_text(player, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0.5, 0.5, coin_texture, 6, 1, 0)
|
||||
_show_floating_text(player, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0.5, 0.5, _TEX_COIN, 6, 1, 0)
|
||||
# Sync floating text to client via GameWorld to avoid loot node path RPCs
|
||||
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_loot_floating_text"):
|
||||
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0, player.get_multiplayer_authority())
|
||||
if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
|
||||
gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0, player.get_multiplayer_authority())
|
||||
|
||||
self.visible = false
|
||||
|
||||
@@ -621,15 +598,11 @@ func _process_pickup_on_server(player: Node):
|
||||
player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
|
||||
|
||||
# Show floating text with item name (uppercase)
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
var display_text = "APPLE"
|
||||
var text_color = Color.GREEN
|
||||
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10)
|
||||
# Sync floating text to client via GameWorld to avoid loot node path RPCs
|
||||
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_loot_floating_text"):
|
||||
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 10, player.get_multiplayer_authority())
|
||||
_show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 10)
|
||||
if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
|
||||
gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 10, player.get_multiplayer_authority())
|
||||
|
||||
self.visible = false
|
||||
|
||||
@@ -665,15 +638,11 @@ func _process_pickup_on_server(player: Node):
|
||||
player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
|
||||
|
||||
# Show floating text with item name (uppercase)
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
var display_text = "BANANA"
|
||||
var text_color = Color.GREEN
|
||||
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11)
|
||||
# Sync floating text to client via GameWorld to avoid loot node path RPCs
|
||||
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_loot_floating_text"):
|
||||
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 11, player.get_multiplayer_authority())
|
||||
_show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 11)
|
||||
if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
|
||||
gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 11, player.get_multiplayer_authority())
|
||||
|
||||
self.visible = false
|
||||
|
||||
@@ -709,15 +678,11 @@ func _process_pickup_on_server(player: Node):
|
||||
player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
|
||||
|
||||
# Show floating text with item name (uppercase)
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
var display_text = "CHERRY"
|
||||
var text_color = Color.GREEN
|
||||
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12)
|
||||
# Sync floating text to client via GameWorld to avoid loot node path RPCs
|
||||
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_loot_floating_text"):
|
||||
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 12, player.get_multiplayer_authority())
|
||||
_show_floating_text(player, display_text, text_color, 0.5, 0.5, _TEX_ITEMS, 20, 14, (8 * 20) + 12)
|
||||
if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
|
||||
gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, (8 * 20) + 12, player.get_multiplayer_authority())
|
||||
|
||||
self.visible = false
|
||||
|
||||
@@ -732,13 +697,9 @@ func _process_pickup_on_server(player: Node):
|
||||
if player.has_method("add_key"):
|
||||
player.add_key(1)
|
||||
# Show floating text with item graphic and text
|
||||
var items_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
_show_floating_text(player, "+1 KEY", Color.YELLOW, 0.5, 0.5, items_texture, 20, 14, (13 * 20) + 10)
|
||||
# Sync floating text to client via GameWorld to avoid loot node path RPCs
|
||||
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_loot_floating_text"):
|
||||
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, (13 * 20) + 10, player.get_multiplayer_authority())
|
||||
_show_floating_text(player, "+1 KEY", Color.YELLOW, 0.5, 0.5, _TEX_ITEMS, 20, 14, (13 * 20) + 10)
|
||||
if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
|
||||
gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, (13 * 20) + 10, player.get_multiplayer_authority())
|
||||
|
||||
self.visible = false
|
||||
|
||||
@@ -786,8 +747,8 @@ func _process_pickup_on_server(player: Node):
|
||||
if player.has_method("_apply_inventory_and_equipment_from_server"):
|
||||
player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data)
|
||||
|
||||
# Show floating text with item name (uppercase)
|
||||
var items_texture = load(item.spritePath)
|
||||
# Show floating text with item name (uppercase) (item.spritePath is dynamic so load; Godot caches after first)
|
||||
var items_texture: Texture2D = load(item.spritePath) as Texture2D
|
||||
var display_text = item.item_name.to_upper() # Always uppercase
|
||||
var text_color = Color.WHITE
|
||||
|
||||
@@ -798,12 +759,8 @@ func _process_pickup_on_server(player: Node):
|
||||
text_color = Color.GREEN # Green for consumables
|
||||
|
||||
_show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, item.spriteFrames.x, item.spriteFrames.y, item.spriteFrame, item)
|
||||
|
||||
# Sync floating text to client via GameWorld to avoid loot node path RPCs
|
||||
if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1:
|
||||
var game_world = get_tree().get_first_node_in_group("game_world")
|
||||
if game_world and game_world.has_method("_sync_loot_floating_text"):
|
||||
game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, item.spriteFrame, player.get_multiplayer_authority())
|
||||
if gw and multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1 and gw.has_method("_sync_loot_floating_text"):
|
||||
gw._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, item.spriteFrame, player.get_multiplayer_authority())
|
||||
|
||||
self.visible = false
|
||||
|
||||
@@ -945,18 +902,11 @@ func _sync_show_floating_text(loot_type_value: int, text: String, color_value: C
|
||||
|
||||
match loot_type_value:
|
||||
LootType.COIN:
|
||||
item_texture = load("res://assets/gfx/pickups/gold_coin.png")
|
||||
item_texture = _TEX_COIN
|
||||
sprite_hframes = 6
|
||||
sprite_vframes = 1
|
||||
LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.KEY:
|
||||
item_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
sprite_hframes = 20
|
||||
sprite_vframes = 14
|
||||
LootType.ITEM:
|
||||
# Item instance - use item's sprite path
|
||||
# Note: item data is not available on client in this sync, so we use default
|
||||
# The actual item sprite is set when the loot is created
|
||||
item_texture = load("res://assets/gfx/pickups/items_n_shit.png")
|
||||
LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.KEY, LootType.ITEM:
|
||||
item_texture = _TEX_ITEMS
|
||||
sprite_hframes = 20
|
||||
sprite_vframes = 14
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
extends Control
|
||||
|
||||
# Minimap: shows explored dungeon tiles (uses same "explored" data as fog of war).
|
||||
# Populates as the player explores. Drawn in upper-right corner.
|
||||
# Minimap: shows explored dungeon by room. Room textures are pre-built once; we only toggle visibility.
|
||||
|
||||
const MINIMAP_WIDTH: int = 128
|
||||
const MINIMAP_HEIGHT: int = 96
|
||||
@@ -9,19 +8,76 @@ const COLOR_UNEXPLORED: Color = Color(0.08, 0.08, 0.1)
|
||||
const COLOR_WALL: Color = Color(0.22, 0.22, 0.26)
|
||||
const COLOR_FLOOR: Color = Color(0.38, 0.38, 0.44)
|
||||
const COLOR_PLAYER: Color = Color(1.0, 0.35, 0.35)
|
||||
const COLOR_OTHER_PLAYER: Color = Color(0.35, 0.6, 1.0) # Blue for other players
|
||||
const COLOR_OTHER_PLAYER: Color = Color(0.35, 0.6, 1.0)
|
||||
const COLOR_EXIT: Color = Color(1.0, 1.0, 1.0)
|
||||
|
||||
var _map_size: Vector2i = Vector2i.ZERO
|
||||
var _explored_map: PackedInt32Array = PackedInt32Array()
|
||||
var _grid: Array = [] # 2D grid [x][y]: 0=wall, 1=floor, 2=door, 3=corridor
|
||||
var _grid: Array = []
|
||||
var _player_tile: Vector2i = Vector2i(-1, -1)
|
||||
var _other_player_tiles: Array = [] # Array of Vector2i for other players
|
||||
var _other_player_tiles: Array = []
|
||||
var _exit_tile: Vector2i = Vector2i(-1, -1)
|
||||
var _exit_discovered: bool = false
|
||||
|
||||
# Pre-built room tiles: one TextureRect per room, visible = true when room is explored
|
||||
var _room_rects: Array = [] # [{ "rect": TextureRect, "room": Dictionary }, ...]
|
||||
var _background_rect: ColorRect = null
|
||||
var _room_textures_built: bool = false
|
||||
# Corridor layer: half-res image for tiles not in any room; updated each set_maps
|
||||
var _corridor_rect: TextureRect = null
|
||||
var _corridor_image: Image = null
|
||||
var _corridor_texture: ImageTexture = null
|
||||
# Precomputed: 1 = tile is inside any room, 0 = corridor/door/wall outside rooms. Index: x + y * _map_size.x
|
||||
var _room_mask: PackedByteArray = PackedByteArray()
|
||||
# Spread corridor texture over 4 frames when doing full update
|
||||
var _corridor_band_index: int = 0
|
||||
const CORRIDOR_BANDS: int = 4
|
||||
const CORRIDOR_ROWS_PER_BAND: int = 24 # MINIMAP_HEIGHT 96 / 4
|
||||
# When non-empty, we only update corridor pixels for these tile coords (vision-based; smooth, no spike)
|
||||
var _visible_tiles: Array = []
|
||||
# Overlay draws player/exit on top of room/corridor layers (child with z_index 10)
|
||||
var _overlay_control: Control = null
|
||||
|
||||
func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, player_tile: Vector2i = Vector2i(-1, -1), exit_tile: Vector2i = Vector2i(-1, -1), exit_discovered: bool = false, other_player_tiles: Array = []) -> void:
|
||||
func get_overlay_data() -> Dictionary:
|
||||
return {
|
||||
"map_size": _map_size,
|
||||
"player_tile": _player_tile,
|
||||
"exit_tile": _exit_tile,
|
||||
"exit_discovered": _exit_discovered,
|
||||
"other_player_tiles": _other_player_tiles
|
||||
}
|
||||
|
||||
func clear_for_new_level() -> void:
|
||||
# Tear down room/corridor layers so next set_maps() rebuilds for the new dungeon
|
||||
var to_remove := []
|
||||
for child in get_children():
|
||||
if child != _overlay_control:
|
||||
to_remove.append(child)
|
||||
for c in to_remove:
|
||||
c.queue_free()
|
||||
_room_rects.clear()
|
||||
_room_mask.resize(0)
|
||||
_room_textures_built = false
|
||||
_background_rect = null
|
||||
_corridor_rect = null
|
||||
_corridor_image = null
|
||||
_corridor_texture = null
|
||||
_minimap_image = null
|
||||
_minimap_texture = null
|
||||
_map_size = Vector2i.ZERO
|
||||
_explored_map = PackedInt32Array()
|
||||
_grid = []
|
||||
_player_tile = Vector2i(-1, -1)
|
||||
_other_player_tiles = []
|
||||
_exit_tile = Vector2i(-1, -1)
|
||||
_exit_discovered = false
|
||||
_corridor_band_index = 0
|
||||
_visible_tiles = []
|
||||
queue_redraw()
|
||||
if _overlay_control:
|
||||
_overlay_control.queue_redraw()
|
||||
|
||||
func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, player_tile: Vector2i = Vector2i(-1, -1), exit_tile: Vector2i = Vector2i(-1, -1), exit_discovered: bool = false, other_player_tiles: Array = [], rooms: Array = [], visible_tiles: Array = []) -> void:
|
||||
_explored_map = explored_map
|
||||
_map_size = map_size
|
||||
_grid = grid
|
||||
@@ -29,63 +85,244 @@ func set_maps(explored_map: PackedInt32Array, map_size: Vector2i, grid: Array, p
|
||||
_other_player_tiles = other_player_tiles
|
||||
_exit_tile = exit_tile
|
||||
_exit_discovered = exit_discovered
|
||||
_visible_tiles = visible_tiles
|
||||
|
||||
# One-time: build a texture per room (pre-made), all initially hidden
|
||||
if rooms.size() > 0 and _map_size.x > 0 and _map_size.y > 0 and not _room_textures_built:
|
||||
_build_room_textures(rooms)
|
||||
_room_textures_built = true
|
||||
|
||||
if _room_rects.size() > 0:
|
||||
# Only update visibility from explored state (no Image work)
|
||||
for entry in _room_rects:
|
||||
var room: Dictionary = entry.room
|
||||
entry.rect.visible = _is_room_explored(room)
|
||||
# Corridor layer: tiles not in any room (explored = floor, else unexplored)
|
||||
_update_corridor_texture()
|
||||
else:
|
||||
# Fallback: no rooms passed, use single full texture (legacy path)
|
||||
_update_minimap_texture()
|
||||
queue_redraw()
|
||||
if _overlay_control:
|
||||
_overlay_control.queue_redraw()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
# Overlay draws player/exit on top so they are not covered by room/corridor TextureRects
|
||||
_overlay_control = Control.new()
|
||||
_overlay_control.name = "MinimapOverlay"
|
||||
_overlay_control.set_script(load("res://scripts/minimap_overlay.gd") as GDScript)
|
||||
add_child(_overlay_control)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _exit_discovered:
|
||||
queue_redraw()
|
||||
if _overlay_control:
|
||||
_overlay_control.queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
func _build_room_textures(rooms: Array) -> void:
|
||||
# Background: full minimap in unexplored color
|
||||
_background_rect = ColorRect.new()
|
||||
_background_rect.name = "MinimapBackground"
|
||||
_background_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_background_rect.color = COLOR_UNEXPLORED
|
||||
_background_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_background_rect)
|
||||
_background_rect.z_index = -1
|
||||
|
||||
# Room mask: 1 if tile is in any room, 0 otherwise (O(1) lookup in corridor update)
|
||||
var mask_size := _map_size.x * _map_size.y
|
||||
_room_mask.resize(mask_size)
|
||||
for i in range(mask_size):
|
||||
_room_mask[i] = 0
|
||||
for room in rooms:
|
||||
if room.is_empty() or not room.has("x") or not room.has("y") or not room.has("w") or not room.has("h"):
|
||||
continue
|
||||
for y in range(room.y, room.y + room.h):
|
||||
for x in range(room.x, room.x + room.w):
|
||||
if x >= 0 and x < _map_size.x and y >= 0 and y < _map_size.y:
|
||||
_room_mask[x + y * _map_size.x] = 1
|
||||
|
||||
# Corridor layer: full minimap res so 1-tile-wide corridors are visible
|
||||
_corridor_image = Image.create(MINIMAP_WIDTH, MINIMAP_HEIGHT, false, Image.FORMAT_RGBA8)
|
||||
_corridor_image.fill(Color(0, 0, 0, 0))
|
||||
_corridor_texture = ImageTexture.create_from_image(_corridor_image)
|
||||
_corridor_rect = TextureRect.new()
|
||||
_corridor_rect.name = "MinimapCorridors"
|
||||
_corridor_rect.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
_corridor_rect.position = Vector2.ZERO
|
||||
_corridor_rect.size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT)
|
||||
_corridor_rect.texture = _corridor_texture
|
||||
_corridor_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_corridor_rect.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
_corridor_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_corridor_rect)
|
||||
_corridor_rect.z_index = 0
|
||||
|
||||
var scale_x: float = float(MINIMAP_WIDTH) / float(_map_size.x)
|
||||
var scale_y: float = float(MINIMAP_HEIGHT) / float(_map_size.y)
|
||||
|
||||
for room in rooms:
|
||||
if room.is_empty() or not room.has("x") or not room.has("y") or not room.has("w") or not room.has("h"):
|
||||
continue
|
||||
var rx: int = room.x
|
||||
var ry: int = room.y
|
||||
var rw: int = room.w
|
||||
var rh: int = room.h
|
||||
# Room size in minimap pixels (at least 1x1)
|
||||
var pw: int = maxi(1, int(rw * scale_x))
|
||||
var ph: int = maxi(1, int(rh * scale_y))
|
||||
var img := Image.create(pw, ph, false, Image.FORMAT_RGBA8)
|
||||
for py in range(ph):
|
||||
for px in range(pw):
|
||||
var tx: int = rx + int(px * rw / float(pw))
|
||||
var ty: int = ry + int(py * rh / float(ph))
|
||||
tx = clampi(tx, 0, _map_size.x - 1)
|
||||
ty = clampi(ty, 0, _map_size.y - 1)
|
||||
var g: int = 0
|
||||
if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty:
|
||||
var row = _grid[tx] as Array
|
||||
g = int(row[ty])
|
||||
var col: Color = COLOR_WALL if g == 0 else COLOR_FLOOR
|
||||
img.set_pixel(px, py, col)
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
var rect := TextureRect.new()
|
||||
rect.texture = tex
|
||||
rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
rect.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
rect.position = Vector2(rx * scale_x, ry * scale_y)
|
||||
rect.size = Vector2(pw, ph)
|
||||
rect.visible = false
|
||||
rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(rect)
|
||||
rect.z_index = 1
|
||||
_room_rects.append({ "rect": rect, "room": room })
|
||||
|
||||
|
||||
func _is_room_explored(room: Dictionary) -> bool:
|
||||
if _explored_map.is_empty() or room.is_empty():
|
||||
return false
|
||||
var stride: int = _map_size.x
|
||||
for y in range(room.y, room.y + room.h):
|
||||
for x in range(room.x, room.x + room.w):
|
||||
if x < 0 or y < 0 or x >= _map_size.x or y >= _map_size.y:
|
||||
continue
|
||||
var idx: int = x + y * stride
|
||||
if idx >= 0 and idx < _explored_map.size() and _explored_map[idx] != 0:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _update_corridor_texture() -> void:
|
||||
if _corridor_rect == null or _corridor_image == null or _map_size.x <= 0 or _map_size.y <= 0 or _room_mask.is_empty():
|
||||
return
|
||||
var stride := _map_size.x
|
||||
var cw := float(MINIMAP_WIDTH)
|
||||
var ch := float(MINIMAP_HEIGHT)
|
||||
# Vision-based: only update pixels for tiles the player currently sees (smooth, ~200–500 set_pixels instead of 12k)
|
||||
if _visible_tiles.size() > 0:
|
||||
for t in _visible_tiles:
|
||||
var tx: int = t.x if t is Vector2i else int(t.x)
|
||||
var ty: int = t.y if t is Vector2i else int(t.y)
|
||||
if tx < 0 or ty < 0 or tx >= _map_size.x or ty >= _map_size.y:
|
||||
continue
|
||||
var mask_idx := tx + ty * stride
|
||||
if mask_idx >= _room_mask.size() or _room_mask[mask_idx] != 0:
|
||||
continue
|
||||
var px := int(tx * cw / float(_map_size.x))
|
||||
var py := int(ty * ch / float(_map_size.y))
|
||||
px = clampi(px, 0, MINIMAP_WIDTH - 1)
|
||||
py = clampi(py, 0, MINIMAP_HEIGHT - 1)
|
||||
var g: int = 0
|
||||
if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty:
|
||||
var row = _grid[tx] as Array
|
||||
g = int(row[ty])
|
||||
if g == 0:
|
||||
_corridor_image.set_pixel(px, py, COLOR_UNEXPLORED)
|
||||
else:
|
||||
var col: Color = COLOR_FLOOR if (mask_idx < _explored_map.size() and _explored_map[mask_idx] != 0) else COLOR_UNEXPLORED
|
||||
_corridor_image.set_pixel(px, py, col)
|
||||
_corridor_texture.update(_corridor_image)
|
||||
return
|
||||
# Fallback: no visible list (e.g. first frame) – update 1/4 of rows per call to avoid spike
|
||||
var py_start := _corridor_band_index * CORRIDOR_ROWS_PER_BAND
|
||||
var py_end := mini(py_start + CORRIDOR_ROWS_PER_BAND, MINIMAP_HEIGHT)
|
||||
_corridor_band_index = (_corridor_band_index + 1) % CORRIDOR_BANDS
|
||||
for py in range(py_start, py_end):
|
||||
for px in range(MINIMAP_WIDTH):
|
||||
var tx := int(px * _map_size.x / cw)
|
||||
var ty := int(py * _map_size.y / ch)
|
||||
tx = clampi(tx, 0, _map_size.x - 1)
|
||||
ty = clampi(ty, 0, _map_size.y - 1)
|
||||
var mask_idx := tx + ty * stride
|
||||
if mask_idx < _room_mask.size() and _room_mask[mask_idx] != 0:
|
||||
_corridor_image.set_pixel(px, py, Color(0, 0, 0, 0))
|
||||
continue
|
||||
var g: int = 0
|
||||
if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty:
|
||||
var row = _grid[tx] as Array
|
||||
g = int(row[ty])
|
||||
if g == 0:
|
||||
_corridor_image.set_pixel(px, py, COLOR_UNEXPLORED)
|
||||
else:
|
||||
var idx := mask_idx
|
||||
var col: Color = COLOR_FLOOR if (idx < _explored_map.size() and _explored_map[idx] != 0) else COLOR_UNEXPLORED
|
||||
_corridor_image.set_pixel(px, py, col)
|
||||
_corridor_texture.update(_corridor_image)
|
||||
|
||||
|
||||
func _update_minimap_texture() -> void:
|
||||
# Legacy: single full minimap texture (used when no rooms passed)
|
||||
if _background_rect == null:
|
||||
_background_rect = ColorRect.new()
|
||||
_background_rect.name = "MinimapBackground"
|
||||
_background_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_background_rect.size = Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT)
|
||||
_background_rect.color = COLOR_UNEXPLORED
|
||||
_background_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_background_rect)
|
||||
_background_rect.z_index = -1
|
||||
# If we already have room rects, visibility is updated above; no full texture
|
||||
if _room_rects.size() > 0:
|
||||
return
|
||||
# Build/update single full image (fallback path)
|
||||
if _map_size.x <= 0 or _map_size.y <= 0 or _explored_map.is_empty():
|
||||
return
|
||||
if not _minimap_image or _minimap_image.get_width() != MINIMAP_WIDTH or _minimap_image.get_height() != MINIMAP_HEIGHT:
|
||||
_minimap_image = Image.create(MINIMAP_WIDTH, MINIMAP_HEIGHT, false, Image.FORMAT_RGBA8)
|
||||
_minimap_texture = ImageTexture.create_from_image(_minimap_image)
|
||||
var bw := float(MINIMAP_WIDTH)
|
||||
var bh := float(MINIMAP_HEIGHT)
|
||||
var tw := bw / float(_map_size.x)
|
||||
var th := bh / float(_map_size.y)
|
||||
for x in range(_map_size.x):
|
||||
for y in range(_map_size.y):
|
||||
var idx := x + y * _map_size.x
|
||||
if idx < 0 or idx >= _explored_map.size():
|
||||
continue
|
||||
var explored := _explored_map[idx] != 0
|
||||
var px := float(x) * tw
|
||||
var py := float(y) * th
|
||||
var rect := Rect2(px, py, tw, th)
|
||||
for py in range(MINIMAP_HEIGHT):
|
||||
for px in range(MINIMAP_WIDTH):
|
||||
var tx := int(px * _map_size.x / bw)
|
||||
var ty := int(py * _map_size.y / bh)
|
||||
tx = clampi(tx, 0, _map_size.x - 1)
|
||||
ty = clampi(ty, 0, _map_size.y - 1)
|
||||
var idx := tx + ty * _map_size.x
|
||||
var col: Color
|
||||
if not explored:
|
||||
if idx >= _explored_map.size() or _explored_map[idx] == 0:
|
||||
col = COLOR_UNEXPLORED
|
||||
else:
|
||||
var g: int = 0
|
||||
if _grid.size() > x and _grid[x] is Array and (_grid[x] as Array).size() > y:
|
||||
var row = _grid[x] as Array
|
||||
g = int(row[y])
|
||||
if g == 0:
|
||||
col = COLOR_WALL
|
||||
else:
|
||||
col = COLOR_FLOOR
|
||||
draw_rect(rect, col, true)
|
||||
if _player_tile.x >= 0 and _player_tile.y >= 0 and _player_tile.x < _map_size.x and _player_tile.y < _map_size.y:
|
||||
var px := float(_player_tile.x) * tw + tw * 0.5
|
||||
var py := float(_player_tile.y) * th + th * 0.5
|
||||
var r := maxf(2.0, minf(tw, th) * 0.4)
|
||||
draw_circle(Vector2(px, py), r, COLOR_PLAYER)
|
||||
for other_tile in _other_player_tiles:
|
||||
if other_tile is Vector2i and other_tile.x >= 0 and other_tile.y >= 0 and other_tile.x < _map_size.x and other_tile.y < _map_size.y:
|
||||
var ox := float(other_tile.x) * tw + tw * 0.5
|
||||
var oy := float(other_tile.y) * th + th * 0.5
|
||||
var or_ := maxf(1.5, minf(tw, th) * 0.32)
|
||||
draw_circle(Vector2(ox, oy), or_, COLOR_OTHER_PLAYER)
|
||||
if _exit_discovered and _exit_tile.x >= 0 and _exit_tile.y >= 0 and _exit_tile.x < _map_size.x and _exit_tile.y < _map_size.y:
|
||||
var ex := float(_exit_tile.x) * tw + tw * 0.5
|
||||
var ey := float(_exit_tile.y) * th + th * 0.5
|
||||
var er := maxf(2.0, minf(tw, th) * 0.35)
|
||||
var blink := sin(Time.get_ticks_msec() * 0.004) * 0.5 + 0.5
|
||||
var exit_col := Color(COLOR_EXIT.r, COLOR_EXIT.g, COLOR_EXIT.b, 0.45 + 0.55 * blink)
|
||||
draw_circle(Vector2(ex, ey), er, exit_col)
|
||||
if _grid.size() > tx and _grid[tx] is Array and (_grid[tx] as Array).size() > ty:
|
||||
var row = _grid[tx] as Array
|
||||
g = int(row[ty])
|
||||
col = COLOR_WALL if g == 0 else COLOR_FLOOR
|
||||
_minimap_image.set_pixel(px, py, col)
|
||||
_minimap_texture.update(_minimap_image)
|
||||
|
||||
var _minimap_image: Image
|
||||
var _minimap_texture: ImageTexture
|
||||
|
||||
func _draw() -> void:
|
||||
if _map_size.x <= 0 or _map_size.y <= 0:
|
||||
return
|
||||
# If using room rects, base is drawn by children; player/exit drawn by _overlay_control on top
|
||||
if _room_rects.size() == 0 and _minimap_texture:
|
||||
draw_texture_rect(_minimap_texture, Rect2(Vector2.ZERO, Vector2(MINIMAP_WIDTH, MINIMAP_HEIGHT)), false)
|
||||
# Player/exit/other-players are drawn by MinimapOverlay child (z_index 10) so they appear on top
|
||||
|
||||
49
src/scripts/minimap_overlay.gd
Normal file
49
src/scripts/minimap_overlay.gd
Normal 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)
|
||||
1
src/scripts/minimap_overlay.gd.uid
Normal file
1
src/scripts/minimap_overlay.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://oemu7i1agdew
|
||||
@@ -128,6 +128,16 @@ var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projecti
|
||||
var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch
|
||||
var attack_axe_swing_scene = preload("res://scenes/attack_axe_swing.tscn") # Axe swing (orbits)
|
||||
var blood_scene = preload("res://scenes/blood_clot.tscn")
|
||||
# Preload for _create_bomb_object so placing a bomb doesn't spike
|
||||
const _INTERACTABLE_OBJECT_SCENE: PackedScene = preload("res://scenes/interactable_object.tscn")
|
||||
|
||||
# Cache appearance texture paths so _apply_appearance_to_sprites() doesn't load() every time (avoids ~29ms spike on equipment change)
|
||||
const _APPEARANCE_TEXTURE_CACHE_MAX: int = 48
|
||||
var _appearance_texture_cache: Dictionary = {}
|
||||
var _appearance_texture_cache_order: Array = [] # FIFO keys for eviction
|
||||
|
||||
# Lazy cache for spell SFX (avoids has_node + $ every frame in _physics_process)
|
||||
var _sfx_spell_incantation: Node = null
|
||||
|
||||
# Simulated Z-axis for height (when thrown)
|
||||
var position_z: float = 0.0
|
||||
@@ -1136,14 +1146,35 @@ func _setup_player_appearance_preserve_race():
|
||||
|
||||
print("Player ", name, " appearance re-initialized (preserved race=", selected_race, ")")
|
||||
|
||||
func _get_appearance_texture(path: String) -> Texture2D:
|
||||
if path.is_empty():
|
||||
return null
|
||||
# Use global preloaded cache first (avoids 13ms+ spike on equip)
|
||||
var global_cache = get_node_or_null("/root/AppearanceTextureCache")
|
||||
if global_cache and global_cache.has_method("get_texture"):
|
||||
var tex = global_cache.get_texture(path)
|
||||
if tex:
|
||||
return tex
|
||||
# Fallback: local cache for paths not in preload (e.g. future content)
|
||||
if _appearance_texture_cache.has(path):
|
||||
return _appearance_texture_cache[path] as Texture2D
|
||||
var t = load(path) as Texture2D
|
||||
if t:
|
||||
if _appearance_texture_cache_order.size() >= _APPEARANCE_TEXTURE_CACHE_MAX:
|
||||
var old_key = _appearance_texture_cache_order.pop_front()
|
||||
_appearance_texture_cache.erase(old_key)
|
||||
_appearance_texture_cache[path] = t
|
||||
_appearance_texture_cache_order.append(path)
|
||||
return t
|
||||
|
||||
func _apply_appearance_to_sprites():
|
||||
# Apply character_stats appearance to sprite layers
|
||||
# Apply character_stats appearance to sprite layers (uses texture cache to avoid load() spikes)
|
||||
if not character_stats:
|
||||
return
|
||||
|
||||
# Body/Skin
|
||||
if sprite_body and character_stats.skin != "":
|
||||
var body_texture = load(character_stats.skin)
|
||||
var body_texture = _get_appearance_texture(character_stats.skin)
|
||||
if body_texture:
|
||||
sprite_body.texture = body_texture
|
||||
sprite_body.hframes = 35
|
||||
@@ -1155,7 +1186,7 @@ func _apply_appearance_to_sprites():
|
||||
var equipped_boots = character_stats.equipment["boots"]
|
||||
# Only render boots if it's actually boots equipment (not a weapon or other type)
|
||||
if equipped_boots and equipped_boots.equipment_type == Item.EquipmentType.BOOTS and equipped_boots.equipmentPath != "":
|
||||
var boots_texture = load(equipped_boots.equipmentPath)
|
||||
var boots_texture = _get_appearance_texture(equipped_boots.equipmentPath)
|
||||
if boots_texture:
|
||||
sprite_boots.texture = boots_texture
|
||||
sprite_boots.hframes = 35
|
||||
@@ -1174,7 +1205,7 @@ func _apply_appearance_to_sprites():
|
||||
var equipped_armour = character_stats.equipment["armour"]
|
||||
# Only render armour if it's actually armour equipment (not a weapon)
|
||||
if equipped_armour and equipped_armour.equipment_type == Item.EquipmentType.ARMOUR and equipped_armour.equipmentPath != "":
|
||||
var armour_texture = load(equipped_armour.equipmentPath)
|
||||
var armour_texture = _get_appearance_texture(equipped_armour.equipmentPath)
|
||||
if armour_texture:
|
||||
sprite_armour.texture = armour_texture
|
||||
sprite_armour.hframes = 35
|
||||
@@ -1191,7 +1222,7 @@ func _apply_appearance_to_sprites():
|
||||
# Facial Hair
|
||||
if sprite_facial_hair:
|
||||
if character_stats.facial_hair != "":
|
||||
var facial_hair_texture = load(character_stats.facial_hair)
|
||||
var facial_hair_texture = _get_appearance_texture(character_stats.facial_hair)
|
||||
if facial_hair_texture:
|
||||
sprite_facial_hair.texture = facial_hair_texture
|
||||
sprite_facial_hair.hframes = 35
|
||||
@@ -1214,7 +1245,7 @@ func _apply_appearance_to_sprites():
|
||||
# Hair
|
||||
if sprite_hair:
|
||||
if character_stats.hairstyle != "":
|
||||
var hair_texture = load(character_stats.hairstyle)
|
||||
var hair_texture = _get_appearance_texture(character_stats.hairstyle)
|
||||
if hair_texture:
|
||||
sprite_hair.texture = hair_texture
|
||||
sprite_hair.hframes = 35
|
||||
@@ -1237,7 +1268,7 @@ func _apply_appearance_to_sprites():
|
||||
# Eyes
|
||||
if sprite_eyes:
|
||||
if character_stats.eyes != "":
|
||||
var eyes_texture = load(character_stats.eyes)
|
||||
var eyes_texture = _get_appearance_texture(character_stats.eyes)
|
||||
if eyes_texture:
|
||||
sprite_eyes.texture = eyes_texture
|
||||
sprite_eyes.hframes = 35
|
||||
@@ -1256,7 +1287,7 @@ func _apply_appearance_to_sprites():
|
||||
# Eyelashes
|
||||
if sprite_eyelashes:
|
||||
if character_stats.eye_lashes != "":
|
||||
var eyelash_texture = load(character_stats.eye_lashes)
|
||||
var eyelash_texture = _get_appearance_texture(character_stats.eye_lashes)
|
||||
if eyelash_texture:
|
||||
sprite_eyelashes.texture = eyelash_texture
|
||||
sprite_eyelashes.hframes = 35
|
||||
@@ -1275,7 +1306,7 @@ func _apply_appearance_to_sprites():
|
||||
# Addons (ears, etc.)
|
||||
if sprite_addons:
|
||||
if character_stats.add_on != "":
|
||||
var addon_texture = load(character_stats.add_on)
|
||||
var addon_texture = _get_appearance_texture(character_stats.add_on)
|
||||
if addon_texture:
|
||||
sprite_addons.texture = addon_texture
|
||||
sprite_addons.hframes = 35
|
||||
@@ -1289,7 +1320,7 @@ func _apply_appearance_to_sprites():
|
||||
if sprite_headgear:
|
||||
var equipped_headgear = character_stats.equipment["headgear"]
|
||||
if equipped_headgear and equipped_headgear.equipmentPath != "":
|
||||
var headgear_texture = load(equipped_headgear.equipmentPath)
|
||||
var headgear_texture = _get_appearance_texture(equipped_headgear.equipmentPath)
|
||||
if headgear_texture:
|
||||
sprite_headgear.texture = headgear_texture
|
||||
sprite_headgear.hframes = 35
|
||||
@@ -1413,8 +1444,6 @@ func _on_character_changed(_char: CharacterStats):
|
||||
_rpc_to_ready_peers("_sync_equipment", [equipment_data])
|
||||
|
||||
# ALWAYS sync race and base stats to all clients (for proper display)
|
||||
# This ensures new clients get appearance data even if they connect after initial setup
|
||||
print("Player ", name, " (authority) SENDING _sync_race_and_stats to all peers: race='", character_stats.race, "'")
|
||||
_rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()])
|
||||
|
||||
# Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players
|
||||
@@ -1965,6 +1994,8 @@ func _physics_process(delta):
|
||||
|
||||
# Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse
|
||||
if is_charging_spell:
|
||||
if _sfx_spell_incantation == null:
|
||||
_sfx_spell_incantation = get_node_or_null("SfxSpellIncantation")
|
||||
var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time
|
||||
var charge_progress = clamp(charge_time / spell_charge_duration, 0.0, 1.0)
|
||||
spell_charge_particle_timer += delta
|
||||
@@ -1973,14 +2004,14 @@ func _physics_process(delta):
|
||||
if charge_progress >= 1.0:
|
||||
spell_charge_tint_pulse_time += delta * spell_charge_tint_pulse_speed_charged
|
||||
_apply_spell_charge_tint()
|
||||
if not spell_incantation_played and has_node("SfxSpellIncantation"):
|
||||
$SfxSpellIncantation.play()
|
||||
if not spell_incantation_played and _sfx_spell_incantation:
|
||||
_sfx_spell_incantation.play()
|
||||
spell_incantation_played = true
|
||||
else:
|
||||
spell_charge_tint_pulse_time = 0.0
|
||||
_clear_spell_charge_tint()
|
||||
if has_node("SfxSpellIncantation"):
|
||||
$SfxSpellIncantation.stop()
|
||||
if _sfx_spell_incantation:
|
||||
_sfx_spell_incantation.stop()
|
||||
spell_incantation_played = false
|
||||
else:
|
||||
spell_charge_tint_pulse_time = 0.0
|
||||
@@ -2661,11 +2692,6 @@ func _handle_interactions():
|
||||
grab_just_released = Input.is_action_just_released("grab") or (not mouse_right_pressed and was_mouse_right_pressed)
|
||||
was_mouse_right_pressed = mouse_right_pressed
|
||||
|
||||
# DEBUG: Log button states if there's a conflict
|
||||
if grab_just_pressed and grab_just_released:
|
||||
print("DEBUG: WARNING - Both grab_just_pressed and grab_just_released are true!")
|
||||
if grab_just_released and grab_button_down:
|
||||
print("DEBUG: WARNING - grab_just_released=true but grab_button_down=true!")
|
||||
else:
|
||||
# Gamepad input
|
||||
var button_currently_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_A)
|
||||
@@ -2677,10 +2703,11 @@ func _handle_interactions():
|
||||
else:
|
||||
grab_just_released = false
|
||||
|
||||
# Update is_shielding: hold grab with shield in offhand and nothing to grab/lift
|
||||
# One overlap query per frame; reuse for shield check and spell block below
|
||||
var nearby_grabbable_body = _get_nearby_grabbable()
|
||||
var would_shield = (not is_dead and grab_button_down and _has_shield_in_offhand() \
|
||||
and not held_object and not is_lifting and not is_pushing \
|
||||
and not _has_nearby_grabbable() and not is_disarming)
|
||||
and nearby_grabbable_body == null and not is_disarming)
|
||||
if would_shield and shield_block_cooldown_timer > 0.0:
|
||||
is_shielding = false
|
||||
if has_node("SfxDenyActivateShield"):
|
||||
@@ -2716,8 +2743,8 @@ func _handle_interactions():
|
||||
|
||||
print(name, " cancelled bow charge")
|
||||
|
||||
# Check for trap disarm FIRST (Dwarf only) - PRIORITY: disarm takes priority over spell casting
|
||||
if character_stats and character_stats.race == "Dwarf":
|
||||
# Check for trap disarm FIRST (Dwarf only) - only when grab involved to avoid get_nodes_in_group every frame
|
||||
if character_stats and character_stats.race == "Dwarf" and (grab_just_pressed or grab_just_released or grab_button_down):
|
||||
var nearby_trap = _get_nearby_disarmable_trap()
|
||||
if nearby_trap:
|
||||
# Check if we're currently disarming this trap
|
||||
@@ -2738,18 +2765,14 @@ func _handle_interactions():
|
||||
$SfxSpellIncantation.stop()
|
||||
if multiplayer.has_multiplayer_peer():
|
||||
_sync_spell_charge_end.rpc()
|
||||
print(name, " cancelled spell charge to start disarming")
|
||||
|
||||
# Start disarming
|
||||
is_disarming = true
|
||||
nearby_trap.disarming_player = self
|
||||
nearby_trap.disarm_progress = 0.0
|
||||
print(name, " (Dwarf) started disarming trap")
|
||||
elif grab_just_released and currently_disarming:
|
||||
# Cancel disarm if released early
|
||||
is_disarming = false
|
||||
nearby_trap._cancel_disarm()
|
||||
print(name, " (Dwarf) cancelled disarm")
|
||||
elif not currently_disarming:
|
||||
# Not disarming anymore - reset flag
|
||||
is_disarming = false
|
||||
@@ -2780,29 +2803,9 @@ func _handle_interactions():
|
||||
heal_target = _get_heal_target()
|
||||
|
||||
var has_valid_target = ((is_fire or is_frost) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null)
|
||||
# Healing: allow charge even without target (don't disable charge when hovering enemy/wall/etc.)
|
||||
# But prefer to have a target (player or enemy) when possible
|
||||
var can_start_charge = is_heal or has_valid_target
|
||||
|
||||
# Check if there's a grabbable object nearby - prioritize grabbing over spell casting
|
||||
var nearby_grabbable = null
|
||||
if grab_area:
|
||||
var bodies = grab_area.get_overlapping_bodies()
|
||||
for body in bodies:
|
||||
if body == self:
|
||||
continue
|
||||
var is_grabbable = false
|
||||
if body.has_method("can_be_grabbed"):
|
||||
if body.can_be_grabbed():
|
||||
is_grabbable = true
|
||||
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D):
|
||||
is_grabbable = true
|
||||
|
||||
if is_grabbable:
|
||||
var distance = position.distance_to(body.position)
|
||||
if distance < grab_range:
|
||||
nearby_grabbable = body
|
||||
break
|
||||
# Reuse grabbable from single query above (avoids second get_overlapping_bodies)
|
||||
var nearby_grabbable = nearby_grabbable_body
|
||||
|
||||
if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object:
|
||||
# Check if player has enough mana before starting to charge
|
||||
@@ -2816,10 +2819,8 @@ func _handle_interactions():
|
||||
has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost
|
||||
|
||||
if not has_enough_mana:
|
||||
# Not enough mana - show message to local player only
|
||||
if is_local_player:
|
||||
_show_not_enough_mana_text()
|
||||
print(name, " cannot start charging spell - not enough mana")
|
||||
just_grabbed_this_frame = false
|
||||
return
|
||||
|
||||
@@ -2833,7 +2834,6 @@ func _handle_interactions():
|
||||
$SfxSpellCharge.play()
|
||||
if multiplayer.has_multiplayer_peer():
|
||||
_sync_spell_charge_start.rpc()
|
||||
print(name, " started charging spell (", current_spell_element, ")")
|
||||
just_grabbed_this_frame = false
|
||||
return
|
||||
elif grab_just_released and is_charging_spell:
|
||||
@@ -2852,7 +2852,6 @@ func _handle_interactions():
|
||||
$SfxSpellIncantation.stop()
|
||||
if multiplayer.has_multiplayer_peer():
|
||||
_sync_spell_charge_end.rpc()
|
||||
print(name, " cancelled spell (released too quickly)")
|
||||
just_grabbed_this_frame = false
|
||||
return
|
||||
|
||||
@@ -2882,8 +2881,6 @@ func _handle_interactions():
|
||||
else:
|
||||
_cast_heal_spell(heal_target)
|
||||
else:
|
||||
# Not enough mana - cancel spell
|
||||
print(name, " cannot cast spell - not enough mana")
|
||||
is_charging_spell = false
|
||||
current_spell_element = "fire"
|
||||
spell_incantation_played = false
|
||||
@@ -2909,7 +2906,6 @@ func _handle_interactions():
|
||||
if has_node("SfxSpellCharge"):
|
||||
$SfxSpellCharge.stop()
|
||||
else:
|
||||
print(name, " spell not cast (charge: ", charge_time, "s, fully: ", is_fully_charged, ", target ok: ", has_valid_target, ")")
|
||||
is_charging_spell = false
|
||||
current_spell_element = "fire"
|
||||
spell_incantation_played = false
|
||||
@@ -2923,7 +2919,6 @@ func _handle_interactions():
|
||||
$SfxSpellIncantation.stop()
|
||||
if multiplayer.has_multiplayer_peer():
|
||||
_sync_spell_charge_end.rpc()
|
||||
print(name, " released spell (", "healing" if is_heal else ("frost" if is_frost else "fire"), ", fully: ", is_fully_charged, ")")
|
||||
just_grabbed_this_frame = false
|
||||
return
|
||||
elif is_charging_spell and (is_lifting or held_object or ((not is_heal) and not has_valid_target)):
|
||||
@@ -2966,7 +2961,8 @@ func _handle_interactions():
|
||||
if body.can_be_grabbed():
|
||||
is_grabbable = true
|
||||
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D):
|
||||
is_grabbable = true
|
||||
if body.get("position_z", 0.0) <= 0.0:
|
||||
is_grabbable = true
|
||||
|
||||
if is_grabbable:
|
||||
var distance = position.distance_to(body.position)
|
||||
@@ -3331,9 +3327,10 @@ func _has_shield_in_offhand() -> bool:
|
||||
var off = character_stats.equipment["offhand"]
|
||||
return off != null and "shield" in off.item_name.to_lower()
|
||||
|
||||
func _has_nearby_grabbable() -> bool:
|
||||
func _get_nearby_grabbable() -> Node:
|
||||
# Single overlap query; call once per frame and reuse result (avoids 2x get_overlapping_bodies in _handle_interactions)
|
||||
if not grab_area:
|
||||
return false
|
||||
return null
|
||||
var bodies = grab_area.get_overlapping_bodies()
|
||||
for body in bodies:
|
||||
if body == self:
|
||||
@@ -3343,10 +3340,14 @@ func _has_nearby_grabbable() -> bool:
|
||||
if body.can_be_grabbed():
|
||||
is_grabbable = true
|
||||
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D):
|
||||
is_grabbable = true
|
||||
if body.get("position_z", 0.0) <= 0.0:
|
||||
is_grabbable = true
|
||||
if is_grabbable and position.distance_to(body.position) < grab_range:
|
||||
return true
|
||||
return false
|
||||
return body
|
||||
return null
|
||||
|
||||
func _has_nearby_grabbable() -> bool:
|
||||
return _get_nearby_grabbable() != null
|
||||
|
||||
func _update_shield_visibility() -> void:
|
||||
if not sprite_shield or not sprite_shield_holding:
|
||||
@@ -3382,9 +3383,10 @@ func _try_grab():
|
||||
if body.has_method("can_be_grabbed"):
|
||||
if body.can_be_grabbed():
|
||||
is_grabbable = true
|
||||
# Also allow grabbing other players
|
||||
# Also allow grabbing other players (not when they're mid-air / thrown)
|
||||
elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D):
|
||||
is_grabbable = true
|
||||
if body.get("position_z", 0.0) <= 0.0:
|
||||
is_grabbable = true
|
||||
|
||||
if is_grabbable:
|
||||
var distance = position.distance_to(body.position)
|
||||
@@ -3823,8 +3825,6 @@ func _throw_object():
|
||||
var obj_name = _get_object_name_for_sync(thrown_obj)
|
||||
_rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name])
|
||||
|
||||
print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force)
|
||||
|
||||
func _force_throw_held_object(direction: Vector2):
|
||||
if not held_object or not is_lifting:
|
||||
return
|
||||
@@ -4059,12 +4059,9 @@ func _place_down_object():
|
||||
if placed_obj.has_method("on_released"):
|
||||
placed_obj.on_released(self)
|
||||
|
||||
# Sync place down over network
|
||||
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
|
||||
var obj_name = _get_object_name_for_sync(placed_obj)
|
||||
_rpc_to_ready_peers("_sync_place_down", [obj_name, place_pos])
|
||||
|
||||
print("Placed down ", placed_obj.name, " at ", place_pos)
|
||||
|
||||
func _perform_attack():
|
||||
if not can_attack or is_attacking or spawn_landing or netted_by_web:
|
||||
@@ -4129,7 +4126,6 @@ func _perform_attack():
|
||||
var is_crit = randf() < crit_chance
|
||||
if is_crit:
|
||||
final_damage *= 2.0 # Critical strikes deal 2x damage
|
||||
print(name, " CRITICAL STRIKE! (LCK: ", character_stats.baseStats.lck + character_stats.get_pass("lck"), ")")
|
||||
|
||||
# Round to 1 decimal place
|
||||
final_damage = round(final_damage * 10.0) / 10.0
|
||||
@@ -4170,22 +4166,17 @@ func _perform_attack():
|
||||
$SfxBowShoot.play()
|
||||
# Consume one arrow
|
||||
arrows.quantity -= 1
|
||||
var remaining = arrows.quantity
|
||||
if arrows.quantity <= 0:
|
||||
# Remove arrows if quantity reaches 0
|
||||
character_stats.equipment["offhand"] = null
|
||||
if character_stats:
|
||||
character_stats.character_changed.emit(character_stats)
|
||||
else:
|
||||
# Update equipment to reflect quantity change
|
||||
if character_stats:
|
||||
character_stats.character_changed.emit(character_stats)
|
||||
print(name, " shot arrow! Arrows remaining: ", remaining)
|
||||
else:
|
||||
# Play bow animation but no projectile - DO NOT sync attack (no arrow spawned)
|
||||
if has_node("SfxBowWithoutArrow"):
|
||||
$SfxBowWithoutArrow.play()
|
||||
print(name, " tried to shoot but has no arrows!")
|
||||
|
||||
# Track empty bow attempts; after 3, unequip bow and equip another weapon
|
||||
empty_bow_shot_attempts += 1
|
||||
@@ -4202,10 +4193,8 @@ func _perform_attack():
|
||||
# Store crit status for visual feedback
|
||||
if is_crit:
|
||||
projectile.set_meta("is_crit", true)
|
||||
# Spawn projectile a bit in front of the player
|
||||
var spawn_offset = attack_direction * 6.0
|
||||
projectile.global_position = global_position + spawn_offset
|
||||
print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")")
|
||||
elif is_axe:
|
||||
# Axe swing - stays on player, plays directional animation
|
||||
if attack_axe_swing_scene and equipped_weapon:
|
||||
@@ -4214,7 +4203,6 @@ func _perform_attack():
|
||||
get_parent().add_child(axe_swing)
|
||||
axe_swing.setup(attack_direction, self, -1.0, equipped_weapon)
|
||||
axe_swing.global_position = global_position
|
||||
print(name, " axe swing! Damage: ", final_damage)
|
||||
elif is_unarmed:
|
||||
# Unarmed punch - low STR-based damage (enemy armour mitigates via take_damage)
|
||||
if attack_punch_scene:
|
||||
@@ -4228,7 +4216,6 @@ func _perform_attack():
|
||||
get_parent().add_child(punch)
|
||||
punch.setup(attack_direction, self, punch_damage)
|
||||
punch.global_position = global_position + attack_direction * 12.0
|
||||
print(name, " punched! Damage: ", punch_damage)
|
||||
else:
|
||||
# Spawn sword projectile for non-bow/staff/axe weapons
|
||||
if sword_projectile_scene:
|
||||
@@ -4239,10 +4226,8 @@ func _perform_attack():
|
||||
# Store crit status for visual feedback
|
||||
if is_crit:
|
||||
projectile.set_meta("is_crit", true)
|
||||
# Spawn projectile a bit in front of the player
|
||||
var spawn_offset = attack_direction * 6.0 # 10 pixels in front
|
||||
var spawn_offset = attack_direction * 6.0
|
||||
projectile.global_position = global_position + spawn_offset
|
||||
print(name, " attacked with sword projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")")
|
||||
|
||||
# Sync attack over network only when we actually spawned a projectile
|
||||
if spawned_projectile_type != "" and multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
|
||||
@@ -4334,21 +4319,13 @@ func _create_bomb_object():
|
||||
if character_stats:
|
||||
character_stats.character_changed.emit(character_stats)
|
||||
|
||||
# Load interactable object scene
|
||||
var interactable_object_scene = load("res://scenes/interactable_object.tscn")
|
||||
if not interactable_object_scene:
|
||||
push_error("ERROR: Could not load interactable_object scene!")
|
||||
return
|
||||
|
||||
# Spawn bomb object at player position
|
||||
var entities_node = get_parent()
|
||||
if not entities_node:
|
||||
entities_node = get_tree().get_first_node_in_group("game_world").get_node_or_null("Entities")
|
||||
if not entities_node:
|
||||
push_error("ERROR: Could not find Entities node!")
|
||||
return
|
||||
|
||||
var bomb_obj = interactable_object_scene.instantiate()
|
||||
var bomb_obj = _INTERACTABLE_OBJECT_SCENE.instantiate()
|
||||
bomb_obj.name = "BombObject_" + str(Time.get_ticks_msec())
|
||||
bomb_obj.global_position = global_position + (facing_direction_vector * 8.0) # Spawn slightly in front
|
||||
|
||||
@@ -6597,7 +6574,6 @@ func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool
|
||||
func _die():
|
||||
# Already processing death - prevent multiple concurrent death sequences
|
||||
if is_processing_death:
|
||||
print(name, " already processing death, ignoring duplicate call")
|
||||
return
|
||||
|
||||
is_processing_death = true # Set IMMEDIATELY to block duplicates
|
||||
@@ -6641,20 +6617,19 @@ func _die():
|
||||
var obj_name = _get_object_name_for_sync(released_obj)
|
||||
_rpc_to_ready_peers("_sync_release", [obj_name])
|
||||
|
||||
print(name, " released ", released_obj.name, " on death")
|
||||
pass # released on death
|
||||
else:
|
||||
is_lifting = false
|
||||
is_pushing = false
|
||||
|
||||
print(name, " died!")
|
||||
|
||||
# Show concussion status effect above head
|
||||
var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus")
|
||||
if status_anim and status_anim.has_animation("concussion"):
|
||||
status_anim.play("concussion")
|
||||
|
||||
# Play death sound effect
|
||||
if sfx_die:
|
||||
# Play death sound effect and spawn blood (preloaded blood_scene; add_child is cheaper than 12x call_deferred)
|
||||
var death_parent = get_parent()
|
||||
if sfx_die and death_parent:
|
||||
for i in 12:
|
||||
var angle = randf_range(0, TAU)
|
||||
var speed = randf_range(50, 100)
|
||||
@@ -6662,12 +6637,12 @@ func _die():
|
||||
var b = blood_scene.instantiate() as CharacterBody2D
|
||||
b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2))
|
||||
b.global_position = global_position
|
||||
|
||||
# Set initial velocities from the synchronized data
|
||||
var direction = Vector2.from_angle(angle)
|
||||
b.velocity = direction * speed
|
||||
b.velocityZ = initial_velocityZ
|
||||
get_parent().call_deferred("add_child", b)
|
||||
death_parent.add_child(b)
|
||||
sfx_die.play()
|
||||
elif sfx_die:
|
||||
sfx_die.play()
|
||||
|
||||
# Play DIE animation
|
||||
@@ -6682,11 +6657,9 @@ func _die():
|
||||
|
||||
# Force holder to drop us NOW (before respawn wait)
|
||||
# Search for any player holding us (don't rely on being_held_by)
|
||||
print(name, " searching for anyone holding us...")
|
||||
var found_holder = false
|
||||
for other_player in get_tree().get_nodes_in_group("player"):
|
||||
if other_player != self and other_player.held_object == self:
|
||||
print(name, " FOUND holder: ", other_player.name, "! Clearing locally and syncing via RPC")
|
||||
|
||||
# Clear LOCALLY first
|
||||
other_player.held_object = null
|
||||
|
||||
@@ -16,71 +16,38 @@ var puff_type: int = 0 # 0 or 1 for first or second row
|
||||
var move_direction: Vector2 = Vector2.ZERO # Direction to move in
|
||||
|
||||
func _ready():
|
||||
# Add to group for easy cleanup
|
||||
add_to_group("smoke_puff")
|
||||
|
||||
# Wait for sprite to be ready (ensure @onready variable is set)
|
||||
await get_tree().process_frame
|
||||
|
||||
# Verify sprite exists
|
||||
if not sprite:
|
||||
push_error("SmokePuff: ERROR - Sprite2D not found! Check that scene has Sprite2D child node.")
|
||||
queue_free()
|
||||
return
|
||||
|
||||
# Randomly choose puff type
|
||||
puff_type = randi() % 2
|
||||
|
||||
# Randomly choose movement direction (random angle, slow movement)
|
||||
var random_angle = randf() * TAU # 0 to 2*PI
|
||||
move_direction = Vector2(cos(random_angle), sin(random_angle))
|
||||
|
||||
# Set initial frame
|
||||
move_direction = Vector2(cos(randf() * TAU), sin(randf() * TAU))
|
||||
sprite.frame = puff_type * total_frames
|
||||
current_frame = 0
|
||||
|
||||
print("SmokePuff: Starting animation, sprite: ", sprite, ", frame: ", sprite.frame, ", move_direction: ", move_direction)
|
||||
|
||||
# Start animation
|
||||
animate_puff()
|
||||
|
||||
func animate_puff():
|
||||
# Verify sprite still exists
|
||||
if not sprite:
|
||||
push_error("SmokePuff: ERROR - Sprite is null during animation!")
|
||||
queue_free()
|
||||
return
|
||||
|
||||
# Calculate frame animation timing
|
||||
var frame_interval = 1.0 / animation_speed # Time per frame
|
||||
var frame_interval = 1.0 / animation_speed
|
||||
var frame_animation_duration = float(total_frames) * frame_interval
|
||||
|
||||
# Set initial frame
|
||||
sprite.frame = puff_type * total_frames
|
||||
current_frame = 0
|
||||
frame_timer = 0.0
|
||||
|
||||
# Start movement tween
|
||||
var move_distance = move_speed * move_duration
|
||||
var target_position = global_position + move_direction * move_distance
|
||||
var move_tween = create_tween()
|
||||
if move_tween:
|
||||
move_tween.tween_property(self, "global_position", target_position, move_duration)
|
||||
|
||||
# After animation completes, fade out and remove
|
||||
var total_animation_time = max(frame_animation_duration, move_duration)
|
||||
await get_tree().create_timer(total_animation_time).timeout
|
||||
|
||||
# Fade out
|
||||
if sprite:
|
||||
print("SmokePuff: Starting fade out...")
|
||||
var fade_tween = create_tween()
|
||||
if fade_tween:
|
||||
fade_tween.tween_property(sprite, "modulate:a", 0.0, fade_duration)
|
||||
await fade_tween.finished
|
||||
|
||||
print("SmokePuff: Animation complete, removing...")
|
||||
queue_free()
|
||||
queue_free()
|
||||
|
||||
func _process(delta):
|
||||
# Handle frame animation in _process() for more reliable timing
|
||||
@@ -95,7 +62,5 @@ func _process(delta):
|
||||
if frame_timer >= frame_interval:
|
||||
frame_timer = 0.0
|
||||
current_frame += 1
|
||||
|
||||
if current_frame < total_frames:
|
||||
sprite.frame = puff_type * total_frames + current_frame
|
||||
print("SmokePuff: Frame ", current_frame, " -> ", sprite.frame)
|
||||
|
||||
Reference in New Issue
Block a user