synced stuff

This commit is contained in:
2026-01-22 02:15:47 +01:00
parent eaf86b39fa
commit 9b4135b175
7 changed files with 518 additions and 120 deletions

View File

@@ -402,6 +402,12 @@ func _ready():
can_send_rpcs = true
print("Player ", name, " is now ready to send RPCs (is_server: ", multiplayer.is_server(), ")")
# When we become ready to send RPCs, re-sync appearance to ensure new clients get it
# This handles the case where appearance was set up before new clients connected
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and character_stats and character_stats.race != "":
# Emit character_changed to trigger appearance sync for any newly connected clients
character_stats.character_changed.emit(character_stats)
func _duplicate_sprite_materials():
# Duplicate shader materials for sprites that use tint parameters
@@ -469,6 +475,12 @@ func _initialize_character_stats():
func _reinitialize_appearance_with_seed(_seed_value: int):
# Re-initialize appearance with the correct dungeon_seed
# This is called when a joiner receives dungeon_seed after players were already spawned
# CRITICAL: Only the authority should re-initialize appearance!
# Non-authority players will receive appearance via race/equipment sync
if not is_multiplayer_authority():
remove_meta("needs_appearance_reset") # Clear flag even if we skip
return # Non-authority will receive appearance via sync
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or game_world.dungeon_seed == 0:
return # Still no seed, skip
@@ -483,9 +495,8 @@ func _reinitialize_appearance_with_seed(_seed_value: int):
LogManager.log_error("Player " + str(name) + " _reinitialize_appearance_with_seed: character_stats is null!", LogManager.CATEGORY_GAMEPLAY)
return
# Save current state (race, stats, equipment) before re-initializing
# We need to preserve these because they might have been set correctly already
var saved_race = character_stats.race
# Save current state (stats, equipment) before re-initializing
# Race and appearance will be re-randomized with correct seed (deterministic)
var saved_stats = {
"str": character_stats.baseStats.str,
"dex": character_stats.baseStats.dex,
@@ -519,11 +530,12 @@ func _reinitialize_appearance_with_seed(_seed_value: int):
appearance_rng.seed = new_seed_value
# Re-run appearance setup with the correct seed
# This will re-randomize visual appearance (skin, hair, facial hair, eyes, etc.)
# This will re-randomize EVERYTHING including race, appearance, stats
# Since the seed is deterministic, this will match what was generated on other clients
_setup_player_appearance()
# Restore saved race, stats, and equipment (preserve them from before re-initialization)
character_stats.setRace(saved_race) # Restore original race
# Restore saved stats (but keep the newly randomized race/appearance from correct seed)
# The race/appearance from _setup_player_appearance() is now correct (deterministic seed)
character_stats.baseStats.str = saved_stats.str
character_stats.baseStats.dex = saved_stats.dex
character_stats.baseStats.int = saved_stats.int
@@ -540,7 +552,7 @@ func _reinitialize_appearance_with_seed(_seed_value: int):
character_stats.xp = saved_stats.exp # CharacterStats uses 'xp' not 'exp'
character_stats.level = saved_stats.level
# Restore equipment
# Restore equipment (but Elf starting equipment will be re-added by _setup_player_appearance)
for slot_name in saved_equipment.keys():
var item_data = saved_equipment[slot_name]
character_stats.equipment[slot_name] = Item.new(item_data) if item_data else null
@@ -762,6 +774,118 @@ func _setup_player_appearance():
print("Player ", name, " appearance set up: race=", character_stats.race)
func _setup_player_appearance_preserve_race():
# Same as _setup_player_appearance() but preserves existing race instead of randomizing it
# Ensure character_stats exists before setting appearance
if not character_stats:
LogManager.log_error("Player " + str(name) + " _setup_player_appearance_preserve_race: character_stats is null!", LogManager.CATEGORY_GAMEPLAY)
return
# Use existing race (don't randomize)
var selected_race = character_stats.race
if selected_race == "":
# Fallback: if no race set, randomize
var races = ["Dwarf", "Elf", "Human"]
selected_race = races[appearance_rng.randi() % races.size()]
character_stats.setRace(selected_race)
# Don't randomize stats - they should be synced separately
# Don't give starting equipment - that should be synced separately
# Randomize skin (human only for players)
var weights = [7, 6, 5, 4, 3, 2, 1]
var total_weight = 28
var random_value = appearance_rng.randi() % total_weight
var skin_index = 0
var cumulative = 0
for i in range(weights.size()):
cumulative += weights[i]
if random_value < cumulative:
skin_index = i
break
character_stats.setSkin(skin_index)
# Randomize hairstyle (0 = none, 1-12 = various styles)
var hair_style = appearance_rng.randi_range(0, 12)
character_stats.setHair(hair_style)
# Randomize hair color
var hair_colors = [
Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1),
Color(0.8, 0.6, 0.4), Color(0.6, 0.3, 0.1), Color(0.9, 0.7, 0.5),
Color(0.2, 0.2, 0.2), Color(0.5, 0.5, 0.5), Color(0.5, 0.8, 0.2),
Color(0.9, 0.5, 0.1), Color(0.8, 0.3, 0.9), Color(1.0, 0.9, 0.2),
Color(1.0, 0.5, 0.8), Color(0.9, 0.2, 0.2), Color(0.2, 0.9, 0.9),
Color(0.6, 0.2, 0.9), Color(0.9, 0.7, 0.2), Color(0.3, 0.9, 0.3),
Color(0.2, 0.2, 0.9), Color(0.9, 0.4, 0.6), Color(0.5, 0.2, 0.8),
Color(0.9, 0.6, 0.1)
]
character_stats.setHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()])
# Randomize facial hair based on race constraints
var facial_hair_style = 0
match selected_race:
"Dwarf":
facial_hair_style = appearance_rng.randi_range(1, 3)
"Elf":
facial_hair_style = 0
"Human":
facial_hair_style = 3 if appearance_rng.randf() < 0.5 else 0
character_stats.setFacialHair(facial_hair_style)
# Randomize facial hair color
if facial_hair_style > 0:
if appearance_rng.randf() < 0.3:
character_stats.setFacialHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()])
else:
character_stats.setFacialHairColor(character_stats.hair_color)
# Randomize eyes
var eye_style = appearance_rng.randi_range(1, 14)
character_stats.setEyes(eye_style)
# Randomize eye color
var white_color = Color(0.9, 0.9, 0.9)
var other_eye_colors = [
Color(0.1, 0.1, 0.1), Color(0.2, 0.3, 0.8), Color(0.3, 0.7, 0.9),
Color(0.5, 0.8, 0.2), Color(0.9, 0.5, 0.1), Color(0.8, 0.3, 0.9),
Color(1.0, 0.9, 0.2), Color(1.0, 0.5, 0.8), Color(0.9, 0.2, 0.2),
Color(0.2, 0.9, 0.9), Color(0.6, 0.2, 0.9), Color(0.9, 0.7, 0.2),
Color(0.3, 0.9, 0.3), Color(0.2, 0.2, 0.9), Color(0.9, 0.4, 0.6),
Color(0.5, 0.2, 0.8), Color(0.9, 0.6, 0.1)
]
if appearance_rng.randf() < 0.75:
character_stats.setEyeColor(white_color)
else:
character_stats.setEyeColor(other_eye_colors[appearance_rng.randi() % other_eye_colors.size()])
# Randomize eyelashes
var eyelash_style = appearance_rng.randi_range(0, 8)
character_stats.setEyeLashes(eyelash_style)
# Randomize eyelash color
var eyelash_colors = [
Color(0.1, 0.1, 0.1), Color(0.2, 0.2, 0.2), Color(0.3, 0.2, 0.15),
Color(0.4, 0.3, 0.2), Color(0.5, 0.8, 0.2), Color(0.9, 0.5, 0.1),
Color(0.8, 0.3, 0.9), Color(1.0, 0.9, 0.2), Color(1.0, 0.5, 0.8),
Color(0.9, 0.2, 0.2), Color(0.9, 0.9, 0.9), Color(0.6, 0.2, 0.9)
]
if eyelash_style > 0:
character_stats.setEyelashColor(eyelash_colors[appearance_rng.randi() % eyelash_colors.size()])
# Randomize ears/addons based on race
match selected_race:
"Elf":
var elf_ear_style = skin_index + 1
character_stats.setEars(elf_ear_style)
_:
character_stats.setEars(0)
# Apply appearance to sprite layers
_apply_appearance_to_sprites()
print("Player ", name, " appearance re-initialized (preserved race=", selected_race, ")")
func _apply_appearance_to_sprites():
# Apply character_stats appearance to sprite layers
if not character_stats:
@@ -1036,34 +1160,33 @@ func _on_character_changed(_char: CharacterStats):
equipment_data[slot_name] = null
_rpc_to_ready_peers("_sync_equipment", [equipment_data])
# 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
_rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()])
# Sync equipment and inventory to client (when server adds/removes items for a client player)
# This ensures joiners see items they pick up and equipment changes
# This must be checked separately from the authority-based sync because on the server,
# a joiner's player has authority set to their peer_id, not the server's unique_id
if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and can_send_rpcs and is_inside_tree():
var the_peer_id = get_multiplayer_authority()
# Only sync if this is a client player (not server's own player)
if the_peer_id != 0 and the_peer_id != multiplayer.get_unique_id():
# Sync equipment
var equipment_data = {}
for slot_name in character_stats.equipment.keys():
var item = character_stats.equipment[slot_name]
if item:
equipment_data[slot_name] = item.save()
else:
equipment_data[slot_name] = null
_sync_equipment.rpc_id(the_peer_id, equipment_data)
# Sync inventory
var inventory_data = []
for item in character_stats.inventory:
if item:
inventory_data.append(item.save())
_sync_inventory.rpc_id(the_peer_id, inventory_data)
print(name, " syncing equipment and inventory to client peer_id=", the_peer_id, " inventory_size=", inventory_data.size())
# Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players
# This ensures remote players have the exact same appearance as authority
var appearance_data = {
"skin": character_stats.skin,
"hairstyle": character_stats.hairstyle,
"hair_color": character_stats.hair_color.to_html(true),
"facial_hair": character_stats.facial_hair,
"facial_hair_color": character_stats.facial_hair_color.to_html(true),
"eyes": character_stats.eyes,
"eye_color": character_stats.eye_color.to_html(true),
"eye_lashes": character_stats.eye_lashes,
"eyelash_color": character_stats.eyelash_color.to_html(true),
"add_on": character_stats.add_on
}
_rpc_to_ready_peers("_sync_appearance", [appearance_data])
# Sync inventory changes to all clients
var inventory_data = []
for item in character_stats.inventory:
if item:
inventory_data.append(item.save())
_rpc_to_ready_peers("_sync_inventory", [inventory_data])
print(name, " synced equipment, race, and inventory to all clients. Inventory size: ", inventory_data.size())
func _get_player_color() -> Color:
# Legacy function - now returns white (no color tint)
@@ -1081,6 +1204,11 @@ func _is_player(obj) -> bool:
# Helper function to get consistent object name for network sync
func _get_object_name_for_sync(obj) -> String:
# Check if object is still valid
if not is_instance_valid(obj) or not obj.is_inside_tree():
# Object is invalid or not in tree - return empty string to skip sync
return ""
# For interactable objects, use the consistent name (InteractableObject_X)
if obj.name.begins_with("InteractableObject_"):
return obj.name
@@ -1091,6 +1219,11 @@ func _get_object_name_for_sync(obj) -> String:
if _is_player(obj):
return obj.name
# Last resort: use the node name (might be auto-generated like @CharacterBody2D@82)
# But only if it's a valid name (not auto-generated if possible)
if obj.name.begins_with("@") and obj.has_meta("object_index"):
# Try to use object_index instead of auto-generated name
var obj_index = obj.get_meta("object_index")
return "InteractableObject_%d" % obj_index
return obj.name
func _get_log_prefix() -> String:
@@ -1625,6 +1758,11 @@ func _physics_process(delta):
print("Player ", name, " (server) - all clients now ready! (waited ", time_since_last_ready, "s after last client)")
else:
print("Player ", name, " (server) - all clients now ready! (no ready times tracked)")
# Re-sync appearance to all clients now that they're all ready
# This ensures new clients get the correct appearance even if they connected after initial sync
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and character_stats and character_stats.race != "":
character_stats.character_changed.emit(character_stats)
# Sync position to all ready peers (clients and server)
# Only send if node is still valid and in tree
@@ -2057,6 +2195,11 @@ func _handle_interactions():
# Minimum charge time: 0.2 seconds, otherwise cancel
if charge_time < 0.2:
is_charging_bow = false
# CRITICAL: Sync bow charge end to other clients when cancelling
if multiplayer.has_multiplayer_peer():
_sync_bow_charge_end.rpc()
print(name, " cancelled arrow (released too quickly, need at least 0.2s)")
return
@@ -2189,9 +2332,11 @@ func _try_grab():
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
# Use consistent object name or index instead of path
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_initial_grab", [obj_name, grab_offset])
# Sync the grab state
_rpc_to_ready_peers("_sync_grab", [obj_name, is_lifting, push_axis])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_initial_grab", [obj_name, grab_offset])
# Sync the grab state
_rpc_to_ready_peers("_sync_grab", [obj_name, is_lifting, push_axis])
print("Grabbed: ", closest_body.name)
@@ -2240,7 +2385,9 @@ func _lift_object():
# Sync to network (non-blocking)
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(held_object)
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis])
print("Lifted: ", held_object.name)
$SfxLift.play()
@@ -2282,7 +2429,9 @@ func _start_pushing():
# Sync push state to network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_grab", [obj_name, false, push_axis]) # false = pushing, not lifting
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_grab", [obj_name, false, push_axis]) # false = pushing, not lifting
print("Pushing: ", held_object.name, " along axis: ", push_axis, " facing dir: ", push_direction_locked)
@@ -2816,7 +2965,9 @@ func _update_lifted_object():
# Sync held object position over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
func _update_pushed_object():
if held_object and is_instance_valid(held_object):
@@ -2898,7 +3049,9 @@ func _update_pushed_object():
# Sync position over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
# Send RPCs only to peers who are ready to receive them
func _rpc_to_ready_peers(method: String, args: Array = []):
@@ -4071,31 +4224,126 @@ func _sync_stats_update(kills_count: int, coins_count: int):
@rpc("any_peer", "reliable")
func _sync_race_and_stats(race: String, base_stats: Dictionary):
# Client receives race and base stats from authority player
if not is_multiplayer_authority():
# Accept initial sync (when race is empty), but reject changes if we're authority
print(name, " _sync_race_and_stats received: race=", race, " is_authority=", is_multiplayer_authority(), " current_race=", character_stats.race if character_stats else "null")
# CRITICAL: If we're the authority for this player, we should NOT process race syncs
# The authority player manages its own appearance and only syncs to others
if is_multiplayer_authority():
# Only allow initial sync if race is empty (first time setup)
if character_stats and character_stats.race != "":
# We're authority and already have a race set - ignore updates
print(name, " _sync_race_and_stats REJECTED (we're authority and already have race)")
return
if character_stats:
character_stats.race = race
character_stats.baseStats = base_stats
# For remote players, we don't re-initialize appearance here
# Instead, we wait for _sync_appearance RPC which contains the full appearance data
# This ensures remote players have the exact same appearance as authority
# Update race-specific appearance parts (ears)
var skin_index = 0
if character_stats.skin != "":
var regex = RegEx.new()
regex.compile("Human(\\d+)\\.png")
var result = regex.search(character_stats.skin)
if result:
skin_index = int(result.get_string(1)) - 1
match race:
"Elf":
var elf_ear_style = skin_index + 1
character_stats.setEars(elf_ear_style)
# Give Elf starting bow and arrows to remote players
# (Authority players get this in _setup_player_appearance)
# Check if equipment is missing - give it regardless of whether race changed
if not is_multiplayer_authority():
var needs_equipment = false
if character_stats.equipment["mainhand"] == null or character_stats.equipment["offhand"] == null:
needs_equipment = true
else:
# Check if mainhand is not a bow or offhand is not arrows
var mainhand = character_stats.equipment["mainhand"]
var offhand = character_stats.equipment["offhand"]
if not mainhand or mainhand.item_name != "short_bow":
needs_equipment = true
elif not offhand or offhand.item_name != "arrow" or offhand.quantity < 3:
needs_equipment = true
if needs_equipment:
var starting_bow = ItemDatabase.create_item("short_bow")
var starting_arrows = ItemDatabase.create_item("arrow")
if starting_bow and starting_arrows:
starting_arrows.quantity = 3
character_stats.equipment["mainhand"] = starting_bow
character_stats.equipment["offhand"] = starting_arrows
_apply_appearance_to_sprites()
print("Elf player ", name, " (remote) received short bow and 3 arrows via race sync")
_:
character_stats.setEars(0)
print(name, " race and stats synced: race=", race, " STR=", base_stats.str, " PER=", base_stats.per)
@rpc("any_peer", "reliable")
func _sync_appearance(appearance_data: Dictionary):
# Client receives full appearance data from authority player
# Apply it directly without any randomization
if is_multiplayer_authority():
# We're authority - ignore appearance syncs for ourselves
return
if not character_stats:
return
# Apply the synced appearance data directly
if appearance_data.has("skin"):
character_stats.skin = appearance_data["skin"]
if appearance_data.has("hairstyle"):
character_stats.hairstyle = appearance_data["hairstyle"]
if appearance_data.has("hair_color"):
character_stats.hair_color = Color(appearance_data["hair_color"])
if appearance_data.has("facial_hair"):
character_stats.facial_hair = appearance_data["facial_hair"]
if appearance_data.has("facial_hair_color"):
character_stats.facial_hair_color = Color(appearance_data["facial_hair_color"])
if appearance_data.has("eyes"):
character_stats.eyes = appearance_data["eyes"]
if appearance_data.has("eye_color"):
character_stats.eye_color = Color(appearance_data["eye_color"])
if appearance_data.has("eye_lashes"):
character_stats.eye_lashes = appearance_data["eye_lashes"]
if appearance_data.has("eyelash_color"):
character_stats.eyelash_color = Color(appearance_data["eyelash_color"])
if appearance_data.has("add_on"):
character_stats.add_on = appearance_data["add_on"]
# Apply appearance to sprites
_apply_appearance_to_sprites()
print(name, " appearance synced from authority")
@rpc("any_peer", "reliable")
func _sync_equipment(equipment_data: Dictionary):
# Client receives equipment update from server or other clients
# Update equipment to match server/other players
# Server also needs to accept equipment sync from clients (joiners) to update server's copy of joiner's player
if not character_stats:
return
# CRITICAL: Don't accept equipment syncs for our own player
# Each client manages their own equipment locally
if is_multiplayer_authority():
print(name, " ignoring equipment sync (I'm the authority)")
return
# CRITICAL: Don't accept equipment syncs for our own player AFTER initial setup
# Accept initial sync (when all equipment is null), but reject changes if we're authority
var has_any_equipment = false
for slot in character_stats.equipment.values():
if slot != null:
has_any_equipment = true
break
# On server, only accept if this is a client player (not server's own player)
if multiplayer.is_server():
var the_peer_id = get_multiplayer_authority()
# If this is the server's own player, ignore (server's own changes are handled differently)
if the_peer_id == 0 or the_peer_id == multiplayer.get_unique_id():
return
if is_multiplayer_authority() and has_any_equipment:
# We're authority and already have equipment - ignore updates
return
# Update equipment from data
for slot_name in equipment_data.keys():
@@ -4105,6 +4353,13 @@ func _sync_equipment(equipment_data: Dictionary):
else:
character_stats.equipment[slot_name] = null
# If we received equipment but don't have race yet, request race sync
# This handles the case where equipment sync arrives before race sync
if character_stats.race == "" and not is_multiplayer_authority():
# Request race sync from authority (they should send it, but if not, this ensures it)
# Actually, race should come via _sync_race_and_stats, so just wait for it
pass
# Update appearance
_apply_appearance_to_sprites()
print(name, " equipment synced: ", equipment_data.size(), " slots")
@@ -4113,19 +4368,15 @@ func _sync_equipment(equipment_data: Dictionary):
func _sync_inventory(inventory_data: Array):
# Client receives inventory update from server
# Update inventory to match server's inventory
# CRITICAL: Don't accept inventory syncs for our own player
# Each client manages their own inventory locally (same as equipment)
if is_multiplayer_authority():
print(name, " ignoring inventory sync (I'm the authority)")
# Accept initial sync (when inventory is empty), but reject changes if we're authority
if is_multiplayer_authority() and character_stats and character_stats.inventory.size() > 0:
# We're authority and already have items - ignore updates
return
if multiplayer.is_server():
return # Server ignores this (it's the sender)
if not character_stats:
return
# Clear and rebuild inventory from server data (only for OTHER players we're viewing)
# Clear and rebuild inventory from server data (only for OTHER players or initial sync)
character_stats.inventory.clear()
for item_data in inventory_data:
if item_data != null:
@@ -4133,7 +4384,7 @@ func _sync_inventory(inventory_data: Array):
# Emit character_changed to update UI
character_stats.character_changed.emit(character_stats)
print(name, " inventory synced from server: ", character_stats.inventory.size(), " items")
print(name, " inventory synced: ", character_stats.inventory.size(), " items")
func heal(amount: float):
if is_dead: