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