Files
DungeonsOfKharadum/src/scripts/player.gd

4066 lines
149 KiB
GDScript3

extends CharacterBody2D
# Player Character - Top-down movement and interaction
# Character stats system
var character_stats: CharacterStats
var appearance_rng: RandomNumberGenerator # Deterministic RNG for appearance/stats
@export var move_speed: float = 80.0
@export var grab_range: float = 20.0
@export var throw_force: float = 150.0
@export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider)
# Network identity
var peer_id: int = 1
var local_player_index: int = 0
var is_local_player: bool = false
var can_send_rpcs: bool = false # Flag to prevent RPCs until player is fully initialized
var all_clients_ready: bool = false # Server only: true when all clients have notified they're ready
var all_clients_ready_time: float = 0.0 # Server only: time when all_clients_ready was set to true
var teleported_this_frame: bool = false # Flag to prevent position sync from overriding teleportation
# Input device (for local multiplayer)
var input_device: int = -1 # -1 for keyboard, 0+ for gamepad index
var virtual_joystick_input: Vector2 = Vector2.ZERO # Virtual joystick input from mobile controls
var was_mouse_right_pressed: bool = false # Track previous mouse right button state
var was_mouse_left_pressed: bool = false # Track previous mouse left button state
# Interaction
var held_object = null
var grab_offset = Vector2.ZERO
var grab_distance: float = 0.0 # Distance from player to object when grabbed (for placement)
var can_grab = true
var is_lifting = false # True when object is lifted above head
var is_pushing = false # True when holding button to push/pull
var grab_button_pressed_time = 0.0
var just_grabbed_this_frame = false # Prevents immediate release bug - persists across frames
var grab_start_time = 0.0 # Time when we first grabbed (to detect quick tap)
var grab_tap_threshold = 0.2 # Seconds to distinguish tap from hold
var last_movement_direction = Vector2.DOWN # Track last direction for placing objects
var push_axis = Vector2.ZERO # Locked axis for pushing/pulling
var push_direction_locked: int = Direction.DOWN # Locked facing direction when pushing
var initial_grab_position = Vector2.ZERO # Position of grabbed object when first grabbed
var initial_player_position = Vector2.ZERO # Position of player when first grabbed
var object_blocked_by_wall = false # True if pushed object is blocked by a wall
var was_dragging_last_frame = false # Track if we were dragging last frame to detect start/stop
# Level complete state
var controls_disabled: bool = false # True when player has reached exit and controls should be disabled
# Being held state
var being_held_by: Node = null
var struggle_time: float = 0.0
var struggle_threshold: float = 0.8 # Seconds to break free
var struggle_direction: Vector2 = Vector2.ZERO
# Knockback state
var is_knocked_back: bool = false
var knockback_time: float = 0.0
var knockback_duration: float = 0.3 # How long knockback lasts
# Attack/Combat
var can_attack: bool = true
var attack_cooldown: float = 0.0 # No cooldown - instant attacks!
var is_attacking: bool = false
var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference)
var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New projectile version
var staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile
var attack_arrow_scene = preload("res://scenes/attack_arrow.tscn")
var blood_scene = preload("res://scenes/blood_clot.tscn")
# Simulated Z-axis for height (when thrown)
var position_z: float = 0.0
var velocity_z: float = 0.0
var gravity_z: float = 500.0 # Gravity pulling down (scaled for 1x scale)
var is_airborne: bool = false
# Components
# @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites
@onready var shadow = $Shadow
@onready var collision_shape = $CollisionShape2D
@onready var grab_area = $GrabArea
@onready var interaction_indicator = $InteractionIndicator
# Audio
@onready var sfx_walk = $SfxWalk
@onready var timer_walk = $SfxWalk/TimerWalk
@onready var sfx_take_damage = $SfxTakeDamage
@onready var sfx_die = $SfxDie
# Character sprite layers
@onready var sprite_body = $Sprite2DBody
@onready var sprite_boots = $Sprite2DBoots
@onready var sprite_armour = $Sprite2DArmour
@onready var sprite_facial_hair = $Sprite2DFacialHair
@onready var sprite_hair = $Sprite2DHair
@onready var sprite_eyes = $Sprite2DEyes
@onready var sprite_eyelashes = $Sprite2DEyeLashes
@onready var sprite_addons = $Sprite2DAddons
@onready var sprite_headgear = $Sprite2DHeadgear
@onready var sprite_weapon = $Sprite2DWeapon
@onready var cone_light = $ConeLight
# Player stats (legacy - now using character_stats)
var max_health: float:
get:
return character_stats.maxhp if character_stats else 100.0
var current_health: float:
get:
return character_stats.hp if character_stats else 100.0
set(value):
if character_stats:
character_stats.hp = value
var is_dead: bool = false
var is_processing_death: bool = false # Prevent multiple death sequences
var respawn_point: Vector2 = Vector2.ZERO
var coins: int:
get:
return character_stats.coin if character_stats else 0
set(value):
if character_stats:
character_stats.coin = value
# Key inventory
var keys: int = 0 # Number of keys the player has
# Animation system
enum Direction {
DOWN = 0,
DOWN_RIGHT = 1,
RIGHT = 2,
UP_RIGHT = 3,
UP = 4,
UP_LEFT = 5,
LEFT = 6,
DOWN_LEFT = 7
}
const ANIMATIONS = {
"IDLE": {
"frames": [0, 1],
"frameDurations": [500, 500],
"loop": true,
"nextAnimation": null
},
"RUN": {
"frames": [3, 2, 3, 4],
"frameDurations": [140, 140, 140, 140],
"loop": true,
"nextAnimation": null
},
"SWORD": {
"frames": [5, 6, 7, 8],
"frameDurations": [40, 60, 90, 80],
"loop": false,
"nextAnimation": "IDLE"
},
"AXE": {
"frames": [5, 6, 7, 8],
"frameDurations": [50, 70, 100, 90],
"loop": false,
"nextAnimation": "IDLE"
},
"PUNCH": {
"frames": [16, 17, 18],
"frameDurations": [50, 70, 100],
"loop": false,
"nextAnimation": "IDLE"
},
"BOW": {
"frames": [9, 10, 11, 12],
"frameDurations": [80, 110, 110, 80],
"loop": false,
"nextAnimation": "IDLE"
},
"STAFF": {
"frames": [13, 14, 15],
"frameDurations": [100, 100, 300],
"loop": false,
"nextAnimation": "IDLE"
},
"THROW": {
"frames": [16, 17, 18],
"frameDurations": [80, 80, 300],
"loop": false,
"nextAnimation": "IDLE"
},
"CONJURE": {
"frames": [19],
"frameDurations": [400],
"loop": false,
"nextAnimation": "IDLE"
},
"DAMAGE": {
"frames": [20, 21],
"frameDurations": [150, 150],
"loop": false,
"nextAnimation": "IDLE"
},
"DIE": {
"frames": [21, 22, 23, 24],
"frameDurations": [200, 200, 200, 800],
"loop": false,
"nextAnimation": null
},
"IDLE_HOLD": {
"frames": [25],
"frameDurations": [500],
"loop": true,
"nextAnimation": null
},
"RUN_HOLD": {
"frames": [25, 26, 25, 27],
"frameDurations": [150, 150, 150, 150],
"loop": true,
"nextAnimation": null
},
"JUMP": {
"frames": [25, 26, 27, 28],
"frameDurations": [80, 80, 80, 800],
"loop": false,
"nextAnimation": "IDLE"
},
"LIFT": {
"frames": [19, 30],
"frameDurations": [70, 70],
"loop": false,
"nextAnimation": "IDLE_HOLD"
},
"IDLE_PUSH": {
"frames": [30],
"frameDurations": [10],
"loop": true,
"nextAnimation": null
},
"RUN_PUSH": {
"frames": [30, 29, 30, 31],
"frameDurations": [260, 260, 260, 260],
"loop": true,
"nextAnimation": null
}
}
var current_animation = "IDLE"
var current_frame = 0
var current_direction = Direction.DOWN
var time_since_last_frame = 0.0
func _ready():
# Add to player group for easy identification
add_to_group("player")
# Set respawn point to starting position
respawn_point = global_position
# Set up input device based on local player index
if is_local_player:
if local_player_index == 0:
input_device = -1 # Keyboard for first local player
else:
input_device = local_player_index - 1 # Gamepad for others
# Initialize character stats system
_initialize_character_stats()
# CRITICAL: Duplicate shader materials for hair/facial hair to prevent shared state
# If materials are shared between players, changing one affects all
_duplicate_sprite_materials()
# Set up player appearance (randomized based on stats)
_setup_player_appearance()
# Authority is set by player_manager after adding to scene
# Hide interaction indicator by default
if interaction_indicator:
interaction_indicator.visible = false
# Set up cone light blend mode, texture, initial rotation, and spread
if cone_light:
_update_cone_light_rotation()
_update_cone_light_spread() # This calls _create_cone_light_texture()
# Wait before allowing RPCs to ensure player is fully spawned on all clients
# This prevents "Node not found" errors when RPCs try to resolve node paths
if multiplayer.is_server():
# Server: wait for all clients to be ready
# First wait a bit for initial setup
await get_tree().process_frame
await get_tree().process_frame
# Notify server that this player is ready (if we're a client-controlled player)
# Actually, server players don't need to notify - only clients do
# But we need to wait for all clients to be ready
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
# Wait for all connected clients to be ready
var max_wait_time = 5.0 # Maximum wait time (5 seconds)
var check_interval = 0.1 # Check every 0.1 seconds
var waited = 0.0
var all_ready = false # Declare outside loop
while waited < max_wait_time:
all_ready = true
var connected_peers = multiplayer.get_peers()
# Check if all connected peers (except server) are ready
for connected_peer_id in connected_peers:
if connected_peer_id != multiplayer.get_unique_id(): # Skip server
if not game_world.clients_ready.has(connected_peer_id) or not game_world.clients_ready[connected_peer_id]:
all_ready = false
break
# If no peers, we can start sending RPCs (no clients to wait for)
# But we'll still check in _physics_process in case clients connect later
if connected_peers.size() == 0:
all_ready = true
# Note: We set all_ready = true, but if clients connect later,
# _reset_server_players_ready_flag will reset all_clients_ready
if all_ready:
break
await get_tree().create_timer(check_interval).timeout
waited += check_interval
if all_ready:
all_clients_ready = true
var connected_peers = multiplayer.get_peers() # Get peers again for logging
print("Player ", name, " (server) - all clients ready: ", game_world.clients_ready, " connected_peers: ", connected_peers)
else:
# Not all clients ready yet, but we'll keep checking
var connected_peers = multiplayer.get_peers() # Get peers again for logging
print("Player ", name, " (server) - waiting for clients, ready: ", game_world.clients_ready, " connected_peers: ", connected_peers)
# Set up a signal to re-check when clients become ready
# We'll check again in _physics_process
all_clients_ready = false
else:
# Client: wait until ALL players have been spawned before notifying server
# This ensures Player_1_0 and other players exist before server starts sending RPCs
await get_tree().process_frame
await get_tree().process_frame
await get_tree().process_frame
# Wait for all players to be spawned
var game_world = get_tree().get_first_node_in_group("game_world")
var network_manager = get_node("/root/NetworkManager")
if game_world and network_manager:
# Check if all players from players_info have been spawned
var max_wait = 3.0 # Maximum 3 seconds
var check_interval = 0.1
var waited = 0.0
var all_players_spawned = false
while waited < max_wait:
all_players_spawned = true
var player_manager = game_world.get_node_or_null("PlayerManager")
if player_manager:
# Check if all players from players_info exist
for check_peer_id in network_manager.players_info.keys():
var info = network_manager.players_info[check_peer_id]
for local_idx in range(info.local_player_count):
var unique_id = "%d_%d" % [check_peer_id, local_idx]
# Check if player exists in player_manager
if not player_manager.players.has(unique_id):
all_players_spawned = false
break
if all_players_spawned:
break
await get_tree().create_timer(check_interval).timeout
waited += check_interval
if all_players_spawned:
# Wait a bit more after all players are spawned to ensure they're fully in scene tree
await get_tree().create_timer(0.3).timeout
var my_peer_id = multiplayer.get_unique_id()
game_world._notify_client_ready.rpc_id(1, my_peer_id) # Send to server (peer 1)
print("Player ", name, " (client) - notified server we're ready (all players spawned)")
else:
print("Player ", name, " (client) - timed out waiting for all players, notifying anyway")
# Wait a bit even on timeout to ensure players are in scene tree
await get_tree().create_timer(0.3).timeout
var my_peer_id = multiplayer.get_unique_id()
game_world._notify_client_ready.rpc_id(1, my_peer_id) # Send anyway after timeout
can_send_rpcs = true
print("Player ", name, " is now ready to send RPCs (is_server: ", multiplayer.is_server(), ")")
func _duplicate_sprite_materials():
# Duplicate shader materials for sprites that use tint parameters
# This prevents shared material state between players
if sprite_hair and sprite_hair.material:
sprite_hair.material = sprite_hair.material.duplicate()
if sprite_facial_hair and sprite_facial_hair.material:
sprite_facial_hair.material = sprite_facial_hair.material.duplicate()
if sprite_eyes and sprite_eyes.material:
sprite_eyes.material = sprite_eyes.material.duplicate()
if sprite_eyelashes and sprite_eyelashes.material:
sprite_eyelashes.material = sprite_eyelashes.material.duplicate()
# Also duplicate materials for equipment sprites that use color replacements
if sprite_boots and sprite_boots.material:
sprite_boots.material = sprite_boots.material.duplicate()
if sprite_armour and sprite_armour.material:
sprite_armour.material = sprite_armour.material.duplicate()
if sprite_headgear and sprite_headgear.material:
sprite_headgear.material = sprite_headgear.material.duplicate()
if sprite_weapon and sprite_weapon.material:
sprite_weapon.material = sprite_weapon.material.duplicate()
func _initialize_character_stats():
# Create character_stats if it doesn't exist
if not character_stats:
character_stats = CharacterStats.new()
character_stats.character_type = "player"
character_stats.character_name = "Player_" + str(peer_id) + "_" + str(local_player_index)
# Initialize health/mana from stats
character_stats.hp = character_stats.maxhp
character_stats.mp = character_stats.maxmp
# Connect signals
if character_stats:
character_stats.level_up_stats.connect(_on_level_up_stats)
character_stats.character_changed.connect(_on_character_changed)
# Ensure equipment starts empty (players spawn bare)
character_stats.equipment["mainhand"] = null
character_stats.equipment["offhand"] = null
character_stats.equipment["headgear"] = null
character_stats.equipment["armour"] = null
character_stats.equipment["boots"] = null
character_stats.equipment["accessory"] = null
# Create deterministic RNG based on peer_id and local_index for sync across clients
# Add session-based randomness so appearance changes each game session
appearance_rng = RandomNumberGenerator.new()
var session_seed = 0
# Use dungeon seed if available (for multiplayer sync), otherwise use time for variety
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and "dungeon_seed" in game_world and game_world.dungeon_seed != 0:
session_seed = game_world.dungeon_seed
else:
session_seed = Time.get_ticks_msec() # Different each game session (for single-player)
# Mark that we need to re-initialize appearance when dungeon_seed becomes available
if multiplayer.has_multiplayer_peer():
set_meta("needs_appearance_reset", true)
var seed_value = hash(str(peer_id) + "_" + str(local_player_index) + "_" + str(session_seed))
appearance_rng.seed = seed_value
# Stats will be randomized AFTER race is set in _setup_player_appearance()
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
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
# Only re-initialize if this player was spawned before dungeon_seed was available
# Check if appearance needs to be reset (set in _initialize_character_stats)
if not has_meta("needs_appearance_reset"):
return # Appearance was already initialized with correct seed, skip
# Ensure character_stats exists before trying to modify appearance
if not character_stats:
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
var saved_stats = {
"str": character_stats.baseStats.str,
"dex": character_stats.baseStats.dex,
"int": character_stats.baseStats.int,
"end": character_stats.baseStats.end,
"wis": character_stats.baseStats.wis,
"cha": character_stats.baseStats.cha,
"lck": character_stats.baseStats.lck,
"hp": character_stats.hp,
"maxhp": character_stats.maxhp,
"mp": character_stats.mp,
"maxmp": character_stats.maxmp,
"kills": character_stats.kills,
"coin": character_stats.coin,
"exp": character_stats.xp, # CharacterStats uses 'xp' not 'exp'
"level": character_stats.level
}
# Deep copy equipment
var saved_equipment = {}
for slot_name in character_stats.equipment.keys():
var item = character_stats.equipment[slot_name]
saved_equipment[slot_name] = item.save() if item else null
# Save inventory
var saved_inventory = []
for item in character_stats.inventory:
saved_inventory.append(item.save() if item else null)
# Re-seed the RNG with the correct dungeon_seed
var session_seed = game_world.dungeon_seed
var new_seed_value = hash(str(peer_id) + "_" + str(local_player_index) + "_" + str(session_seed))
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.)
_setup_player_appearance()
# Restore saved race, stats, and equipment (preserve them from before re-initialization)
character_stats.setRace(saved_race) # Restore original race
character_stats.baseStats.str = saved_stats.str
character_stats.baseStats.dex = saved_stats.dex
character_stats.baseStats.int = saved_stats.int
character_stats.baseStats.end = saved_stats.end
character_stats.baseStats.wis = saved_stats.wis
character_stats.baseStats.cha = saved_stats.cha
character_stats.baseStats.lck = saved_stats.lck
character_stats.hp = saved_stats.hp
character_stats.maxhp = saved_stats.maxhp
character_stats.mp = saved_stats.mp
character_stats.maxmp = saved_stats.maxmp
character_stats.kills = saved_stats.kills
character_stats.coin = saved_stats.coin
character_stats.xp = saved_stats.exp # CharacterStats uses 'xp' not 'exp'
character_stats.level = saved_stats.level
# Restore equipment
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
# Restore inventory
character_stats.inventory.clear()
for item_data in saved_inventory:
if item_data:
character_stats.inventory.append(Item.new(item_data))
# Re-apply appearance to sprites to show the new visual appearance
_apply_appearance_to_sprites()
# Clear the flag so we don't re-initialize again
remove_meta("needs_appearance_reset")
LogManager.log("Player " + str(name) + " appearance re-initialized with dungeon_seed " + str(session_seed), LogManager.CATEGORY_GAMEPLAY)
func _randomize_stats():
# Randomize base stats within reasonable ranges
# Using deterministic RNG so all clients generate the same values
character_stats.baseStats.str = appearance_rng.randi_range(8, 12)
character_stats.baseStats.dex = appearance_rng.randi_range(8, 12)
character_stats.baseStats.int = appearance_rng.randi_range(8, 12)
character_stats.baseStats.end = appearance_rng.randi_range(8, 12)
character_stats.baseStats.wis = appearance_rng.randi_range(8, 12)
character_stats.baseStats.cha = appearance_rng.randi_range(8, 12)
character_stats.baseStats.lck = appearance_rng.randi_range(8, 12)
# Apply race-based stat modifiers
match character_stats.race:
"Dwarf":
# Dwarf: Higher STR, Medium DEX, lower INT, lower WIS, lower LCK
character_stats.baseStats.str += 3
character_stats.baseStats.int -= 2
character_stats.baseStats.wis -= 2
character_stats.baseStats.lck -= 2
"Elf":
# Elf: Medium STR, Higher DEX, lower INT, medium WIS, higher LCK
character_stats.baseStats.dex += 3
character_stats.baseStats.int -= 2
character_stats.baseStats.lck += 2
"Human":
# Human: Lower STR, lower DEX, higher INT, higher WIS, lower LCK
character_stats.baseStats.str -= 2
character_stats.baseStats.dex -= 2
character_stats.baseStats.int += 3
character_stats.baseStats.wis += 3
character_stats.baseStats.lck -= 2
# Stats randomized (verbose logging removed)
func _setup_player_appearance():
# Randomize appearance - players spawn "bare" (naked, no equipment)
# But with randomized hair, facial hair, eyes, etc.
# Ensure character_stats exists before setting appearance
if not character_stats:
LogManager.log_error("Player " + str(name) + " _setup_player_appearance: character_stats is null!", LogManager.CATEGORY_GAMEPLAY)
return
# Randomize race first (affects appearance constraints and stats)
var races = ["Dwarf", "Elf", "Human"]
var selected_race = races[appearance_rng.randi() % races.size()]
character_stats.setRace(selected_race)
# Randomize stats AFTER race is set (race affects stat modifiers)
_randomize_stats()
# Randomize skin (human only for players)
# Weighted random: Human1 has highest chance, Human7 has lowest chance
# Weights: Human1=7, Human2=6, Human3=5, Human4=4, Human5=3, Human6=2, Human7=1 (total=28)
var weights = [7, 6, 5, 4, 3, 2, 1] # Higher weight = higher chance
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 - vibrant and weird colors!
var hair_colors = [
Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1), # Brown
Color(0.8, 0.6, 0.4), # Blonde
Color(0.6, 0.3, 0.1), # Dark brown
Color(0.9, 0.7, 0.5), # Light blonde
Color(0.2, 0.2, 0.2), # Dark gray
Color(0.5, 0.5, 0.5), # Gray
Color(0.5, 0.8, 0.2), # Snot green
Color(0.9, 0.5, 0.1), # Orange
Color(0.8, 0.3, 0.9), # Purple
Color(1.0, 0.9, 0.2), # Yellow
Color(1.0, 0.5, 0.8), # Pink
Color(0.9, 0.2, 0.2), # Red
Color(0.2, 0.9, 0.9), # Bright cyan
Color(0.6, 0.2, 0.9), # Magenta
Color(0.9, 0.7, 0.2), # Gold
Color(0.3, 0.9, 0.3), # Bright green
Color(0.2, 0.2, 0.9), # Bright blue
Color(0.9, 0.4, 0.6), # Hot pink
Color(0.5, 0.2, 0.8), # Deep purple
Color(0.9, 0.6, 0.1) # Amber
]
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":
# Dwarf: must have mustache or beard (1-3, not 0)
facial_hair_style = appearance_rng.randi_range(1, 3)
"Elf":
# Elf: cannot have facial hair (always 0)
facial_hair_style = 0
"Human":
# Human: only mustache or no facial hair (0 or 3)
facial_hair_style = 3 if appearance_rng.randf() < 0.5 else 0
character_stats.setFacialHair(facial_hair_style)
# Randomize facial hair color (usually matches hair, but can be different)
if facial_hair_style > 0:
if appearance_rng.randf() < 0.3: # 30% chance for different color
character_stats.setFacialHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()])
else:
character_stats.setFacialHairColor(character_stats.hair_color)
# Randomize eyes (0 = none, 1-14 = various eye colors)
var eye_style = appearance_rng.randi_range(1, 14) # Always have eyes
character_stats.setEyes(eye_style)
# Randomize eye color - vibrant and weird colors!
# 75% chance for white, 25% chance for other colors
var white_color = Color(0.9, 0.9, 0.9) # White
var other_eye_colors = [
Color(0.1, 0.1, 0.1), # Black
Color(0.2, 0.3, 0.8), # Blue
Color(0.3, 0.7, 0.9), # Cyan
Color(0.5, 0.8, 0.2), # Snot green
Color(0.9, 0.5, 0.1), # Orange
Color(0.8, 0.3, 0.9), # Purple
Color(1.0, 0.9, 0.2), # Yellow
Color(1.0, 0.5, 0.8), # Pink
Color(0.9, 0.2, 0.2), # Red
Color(0.2, 0.9, 0.9), # Bright cyan
Color(0.6, 0.2, 0.9), # Magenta
Color(0.9, 0.7, 0.2), # Gold
Color(0.3, 0.9, 0.3), # Bright green
Color(0.2, 0.2, 0.9), # Bright blue
Color(0.9, 0.4, 0.6), # Hot pink
Color(0.5, 0.2, 0.8), # Deep purple
Color(0.9, 0.6, 0.1) # Amber
]
if appearance_rng.randf() < 0.75: # 75% chance for white
character_stats.setEyeColor(white_color)
else: # 25% chance for other colors
character_stats.setEyeColor(other_eye_colors[appearance_rng.randi() % other_eye_colors.size()])
# Randomize eyelashes (0 = none, 1-8 = various styles)
var eyelash_style = appearance_rng.randi_range(0, 8)
character_stats.setEyeLashes(eyelash_style)
# Randomize eyelash color - vibrant and weird colors too!
var eyelash_colors = [
Color(0.1, 0.1, 0.1), # Black
Color(0.2, 0.2, 0.2), # Dark gray
Color(0.3, 0.2, 0.15), # Dark brown
Color(0.4, 0.3, 0.2), # Brown
Color(0.5, 0.8, 0.2), # Snot green
Color(0.9, 0.5, 0.1), # Orange
Color(0.8, 0.3, 0.9), # Purple
Color(1.0, 0.9, 0.2), # Yellow
Color(1.0, 0.5, 0.8), # Pink
Color(0.9, 0.2, 0.2), # Red
Color(0.9, 0.9, 0.9), # White
Color(0.6, 0.2, 0.9) # Magenta
]
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":
# Elf: always gets elf ears (ElfEars1 to 7 based on skin number)
# skin_index is 0-6 (Human1-7), ear styles are 1-7 (ElfEars1-7)
var elf_ear_style = skin_index + 1 # Convert 0-6 to 1-7
character_stats.setEars(elf_ear_style)
_:
# Other races: no ears
character_stats.setEars(0)
# Apply appearance to sprite layers
_apply_appearance_to_sprites()
func _apply_appearance_to_sprites():
# Apply character_stats appearance to sprite layers
if not character_stats:
return
# Body/Skin
if sprite_body and character_stats.skin != "":
var body_texture = load(character_stats.skin)
if body_texture:
sprite_body.texture = body_texture
sprite_body.hframes = 35
sprite_body.vframes = 8
sprite_body.modulate = Color.WHITE # Remove old color tint
# Boots
if sprite_boots:
var equipped_boots = character_stats.equipment["boots"]
# Only render boots if it's actually boots equipment (not a weapon or other type)
if equipped_boots and equipped_boots.equipment_type == Item.EquipmentType.BOOTS and equipped_boots.equipmentPath != "":
var boots_texture = load(equipped_boots.equipmentPath)
if boots_texture:
sprite_boots.texture = boots_texture
sprite_boots.hframes = 35
sprite_boots.vframes = 8
# Apply color replacements if available
_apply_color_replacements(sprite_boots, equipped_boots)
else:
sprite_boots.texture = null
_clear_color_replacements(sprite_boots)
else:
sprite_boots.texture = null
_clear_color_replacements(sprite_boots)
# Armour
if sprite_armour:
var equipped_armour = character_stats.equipment["armour"]
# Only render armour if it's actually armour equipment (not a weapon)
if equipped_armour and equipped_armour.equipment_type == Item.EquipmentType.ARMOUR and equipped_armour.equipmentPath != "":
var armour_texture = load(equipped_armour.equipmentPath)
if armour_texture:
sprite_armour.texture = armour_texture
sprite_armour.hframes = 35
sprite_armour.vframes = 8
# Apply color replacements if available
_apply_color_replacements(sprite_armour, equipped_armour)
else:
sprite_armour.texture = null
_clear_color_replacements(sprite_armour)
else:
sprite_armour.texture = null
_clear_color_replacements(sprite_armour)
# Facial Hair
if sprite_facial_hair:
if character_stats.facial_hair != "":
var facial_hair_texture = load(character_stats.facial_hair)
if facial_hair_texture:
sprite_facial_hair.texture = facial_hair_texture
sprite_facial_hair.hframes = 35
sprite_facial_hair.vframes = 8
# Use shader tint parameter instead of modulate
# Only update color if it's valid (not uninitialized black with alpha 0)
# This prevents hair colors from changing when joiners connect and sync triggers character_changed
var facial_hair_color = character_stats.facial_hair_color
if facial_hair_color != Color(0, 0, 0, 0):
if sprite_facial_hair.material and sprite_facial_hair.material is ShaderMaterial:
sprite_facial_hair.material.set_shader_parameter("tint", Vector4(facial_hair_color.r, facial_hair_color.g, facial_hair_color.b, facial_hair_color.a))
else:
# Fallback to modulate if no shader material
sprite_facial_hair.modulate = facial_hair_color
else:
sprite_facial_hair.texture = null
else:
sprite_facial_hair.texture = null
# Hair
if sprite_hair:
if character_stats.hairstyle != "":
var hair_texture = load(character_stats.hairstyle)
if hair_texture:
sprite_hair.texture = hair_texture
sprite_hair.hframes = 35
sprite_hair.vframes = 8
# Use shader tint parameter instead of modulate
# Only update color if it's valid (not uninitialized black with alpha 0)
# This prevents hair colors from changing when joiners connect and sync triggers character_changed
var hair_color = character_stats.hair_color
if hair_color != Color(0, 0, 0, 0):
if sprite_hair.material and sprite_hair.material is ShaderMaterial:
sprite_hair.material.set_shader_parameter("tint", Vector4(hair_color.r, hair_color.g, hair_color.b, hair_color.a))
else:
# Fallback to modulate if no shader material
sprite_hair.modulate = hair_color
else:
sprite_hair.texture = null
else:
sprite_hair.texture = null
# Eyes
if sprite_eyes:
if character_stats.eyes != "":
var eyes_texture = load(character_stats.eyes)
if eyes_texture:
sprite_eyes.texture = eyes_texture
sprite_eyes.hframes = 35
sprite_eyes.vframes = 8
# Use shader tint parameter for eye color
if sprite_eyes.material and sprite_eyes.material is ShaderMaterial:
sprite_eyes.material.set_shader_parameter("tint", Vector4(character_stats.eye_color.r, character_stats.eye_color.g, character_stats.eye_color.b, character_stats.eye_color.a))
else:
# Fallback to modulate if no shader material
sprite_eyes.modulate = character_stats.eye_color
else:
sprite_eyes.texture = null
else:
sprite_eyes.texture = null
# Eyelashes
if sprite_eyelashes:
if character_stats.eye_lashes != "":
var eyelash_texture = load(character_stats.eye_lashes)
if eyelash_texture:
sprite_eyelashes.texture = eyelash_texture
sprite_eyelashes.hframes = 35
sprite_eyelashes.vframes = 8
# Use shader tint parameter for eyelash color
if sprite_eyelashes.material and sprite_eyelashes.material is ShaderMaterial:
sprite_eyelashes.material.set_shader_parameter("tint", Vector4(character_stats.eyelash_color.r, character_stats.eyelash_color.g, character_stats.eyelash_color.b, character_stats.eyelash_color.a))
else:
# Fallback to modulate if no shader material
sprite_eyelashes.modulate = character_stats.eyelash_color
else:
sprite_eyelashes.texture = null
else:
sprite_eyelashes.texture = null
# Addons (ears, etc.)
if sprite_addons:
if character_stats.add_on != "":
var addon_texture = load(character_stats.add_on)
if addon_texture:
sprite_addons.texture = addon_texture
sprite_addons.hframes = 35
sprite_addons.vframes = 8
else:
sprite_addons.texture = null
else:
sprite_addons.texture = null
# Headgear
if sprite_headgear:
var equipped_headgear = character_stats.equipment["headgear"]
if equipped_headgear and equipped_headgear.equipmentPath != "":
var headgear_texture = load(equipped_headgear.equipmentPath)
if headgear_texture:
sprite_headgear.texture = headgear_texture
sprite_headgear.hframes = 35
sprite_headgear.vframes = 8
# Apply color replacements if available
_apply_color_replacements(sprite_headgear, equipped_headgear)
else:
sprite_headgear.texture = null
_clear_color_replacements(sprite_headgear)
else:
sprite_headgear.texture = null
_clear_color_replacements(sprite_headgear)
# Weapon (Mainhand)
# NOTE: Weapons NEVER change the Sprite2DWeapon sprite...
# but they can apply color changes!!!
if sprite_weapon:
var equipped_weapon = null
if character_stats and character_stats.equipment.has("mainhand"):
equipped_weapon = character_stats.equipment["mainhand"]
if equipped_weapon and equipped_weapon.weapon_type == Item.WeaponType.STAFF and equipped_weapon.equipmentPath != "":
_apply_weapon_color_replacements(sprite_weapon, equipped_weapon)
else:
_clear_weapon_color_replacements(sprite_weapon)
# Appearance applied (verbose logging removed)
func _apply_color_replacements(sprite: Sprite2D, item: Item) -> void:
# Apply color replacements using shader parameters
if not sprite or not item:
return
if not sprite.material or not sprite.material is ShaderMaterial:
return
if not item.colorReplacements or item.colorReplacements.size() == 0:
return
var shader_material = sprite.material as ShaderMaterial
for index in range(item.colorReplacements.size()):
var color_replacement: Dictionary = item.colorReplacements[index]
if color_replacement.has("original") and color_replacement.has("replace"):
var original_color = color_replacement["original"] as Color
var replace_color = color_replacement["replace"] as Color
shader_material.set_shader_parameter("original_" + str(index), original_color)
shader_material.set_shader_parameter("replace_" + str(index), replace_color)
func _clear_color_replacements(sprite: Sprite2D) -> void:
# Clear color replacement shader parameters
if not sprite or not sprite.material or not sprite.material is ShaderMaterial:
return
var shader_material = sprite.material as ShaderMaterial
# Clear up to 10 replacement slots (should be enough)
for index in range(10):
shader_material.set_shader_parameter("original_" + str(index), Color(0, 0, 0, 0))
shader_material.set_shader_parameter("replace_" + str(index), Color(0, 0, 0, 0))
func _apply_weapon_color_replacements(sprite: Sprite2D, item: Item) -> void:
# Apply color replacements for staff colors only (RGB 209,142,54 and RGB 192,112,31)
if not sprite or not item:
return
if not sprite.material or not sprite.material is ShaderMaterial:
return
if not item.colorReplacements or item.colorReplacements.size() == 0:
return
var shader_material = sprite.material as ShaderMaterial
# Staff colors that should be replaced on the weapon sprite
var staff_colors = [
Color(209/255.0, 142/255.0, 54/255.0),
Color(192/255.0, 112/255.0, 31/255.0)
]
var replacement_index = 0
for color_replacement in item.colorReplacements:
if color_replacement.has("original") and color_replacement.has("replace"):
var original_color = color_replacement["original"] as Color
# Only apply replacements for staff colors
for staff_color in staff_colors:
# Check if this replacement matches a staff color (with some tolerance)
if _colors_similar(original_color, staff_color, 0.1):
var replace_color = color_replacement["replace"] as Color
shader_material.set_shader_parameter("original_" + str(replacement_index), original_color)
shader_material.set_shader_parameter("replace_" + str(replacement_index), replace_color)
replacement_index += 1
break # Found match, move to next replacement
func _clear_weapon_color_replacements(sprite: Sprite2D) -> void:
# Clear weapon color replacement shader parameters (same as regular clear)
_clear_color_replacements(sprite)
func _colors_similar(color1: Color, color2: Color, tolerance: float = 0.1) -> bool:
# Check if two colors are similar within tolerance
var r_diff = abs(color1.r - color2.r)
var g_diff = abs(color1.g - color2.g)
var b_diff = abs(color1.b - color2.b)
return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance
func _on_character_changed(_char: CharacterStats):
# Update appearance when character stats change (e.g., equipment)
# Only update appearance-related sprites (equipment, not hair/facial hair colors)
# Hair and facial hair colors should NEVER change after initial setup
_apply_appearance_to_sprites()
# Sync equipment changes to other clients (when authority player changes equipment)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
# Sync equipment to all clients
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() # Serialize item data
else:
equipment_data[slot_name] = null
_rpc_to_ready_peers("_sync_equipment", [equipment_data])
# 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())
func _get_player_color() -> Color:
# Legacy function - now returns white (no color tint)
return Color.WHITE
# Helper function to check if object is a box (interactable object)
func _is_box(obj) -> bool:
# Check if it's an interactable object by checking for specific properties
return "throw_velocity" in obj and "is_grabbable" in obj
# Helper function to check if object is a player
func _is_player(obj) -> bool:
# Check if it's a player by looking for player-specific properties
return obj.is_in_group("player") or ("is_local_player" in obj and "peer_id" in obj)
# Helper function to get consistent object name for network sync
func _get_object_name_for_sync(obj) -> String:
# For interactable objects, use the consistent name (InteractableObject_X)
if obj.name.begins_with("InteractableObject_"):
return obj.name
if obj.has_meta("object_index"):
var obj_index = obj.get_meta("object_index")
return "InteractableObject_%d" % obj_index
# For players, use their unique name
if _is_player(obj):
return obj.name
# Last resort: use the node name (might be auto-generated like @CharacterBody2D@82)
return obj.name
func _get_log_prefix() -> String:
if multiplayer.has_multiplayer_peer():
return "[H] " if multiplayer.is_server() else "[J] "
return ""
func _can_place_down_at(place_pos: Vector2, placed_obj: Node) -> bool:
var space_state = get_world_2d().direct_space_state
# Get the actual collision shape and its transform (including position offset)
var placed_shape = _get_collision_shape_for(placed_obj)
var placed_shape_transform = _get_collision_shape_transform(placed_obj, place_pos)
if not placed_shape:
# Fallback to 16x16
placed_shape = RectangleShape2D.new()
placed_shape.size = Vector2(16, 16)
placed_shape_transform = Transform2D(0.0, place_pos)
# Check if the placed object's collision shape would collide with anything
# This includes: walls, other objects, and players
var params = PhysicsShapeQueryParameters2D.new()
params.shape = placed_shape
params.transform = placed_shape_transform
params.collision_mask = 1 | 2 | 64 # Players (layer 1), objects (layer 2), walls (layer 7 = bit 6 = 64)
# CRITICAL: Exclude self, the object being placed, and make sure to exclude it properly
# The object might still be in the scene tree with collision disabled, so we need to exclude it
var exclude_list = [self]
if placed_obj and is_instance_valid(placed_obj):
exclude_list.append(placed_obj)
params.exclude = exclude_list
# Test the actual collision shape at the placement position
var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions
# Debug: Log what we found
if hits.size() > 0:
print("DEBUG: Placement blocked - found ", hits.size(), " collisions at ", place_pos)
for i in min(hits.size(), 3): # Log first 3 collisions
var hit = hits[i]
if hit.has("collider"):
print(" - Collision with: ", hit.collider, " (", hit.collider.name if hit.collider else "null", ")")
if hit.has("rid"):
print(" - RID: ", hit.rid)
# If any collisions found, placement is invalid
return hits.size() == 0
func _find_closest_place_pos(direction: Vector2, placed_obj: Node) -> Vector2:
var dir = direction.normalized()
if dir.length() < 0.1:
dir = last_movement_direction.normalized()
if dir.length() < 0.1:
dir = Vector2.RIGHT
# Use the stored grab distance if available, otherwise calculate a default
var target_distance = grab_distance
if target_distance <= 0.0:
# Fallback: calculate minimum distance if grab_distance wasn't stored
var player_extent = _get_collision_extent(self)
var obj_extent = _get_collision_extent(placed_obj)
target_distance = player_extent + obj_extent + 2.0
# Try placing at the exact grab distance first
var place_pos = global_position + dir * target_distance
if _can_place_down_at(place_pos, placed_obj):
return place_pos
# If exact distance doesn't work, search nearby positions
# Search slightly closer and further to find valid placement
var search_range = 10.0
var step = 0.5
var best_pos = place_pos
# Try closer positions first (prefer closer placement)
for offset in range(int(-search_range * 2), int(search_range * 2) + 1, int(step * 2)):
var test_dist = target_distance + (float(offset) / 2.0)
if test_dist < 2.0: # Don't get too close
continue
var test_pos = global_position + dir * test_dist
if _can_place_down_at(test_pos, placed_obj):
return test_pos # Return first valid position
return best_pos
func _get_collision_shape_for(node: Node) -> Shape2D:
if not node:
return null
var shape_node = node.get_node_or_null("CollisionShape2D")
if not shape_node:
shape_node = node.find_child("CollisionShape2D", true, false)
if shape_node and "shape" in shape_node:
return shape_node.shape
return null
func _get_collision_shape_transform(node: Node, world_pos: Vector2) -> Transform2D:
# Get the collision shape's local transform (position offset and rotation)
# and combine it with the world position
if not node:
return Transform2D(0.0, world_pos)
var shape_node = node.get_node_or_null("CollisionShape2D")
if not shape_node:
shape_node = node.find_child("CollisionShape2D", true, false)
if shape_node:
# Get the shape node's local position and rotation
var shape_local_pos = shape_node.position
var shape_rotation = shape_node.rotation
# Create transform: rotation first, then translation
# The shape's local position is relative to the node, so add it to world_pos
var shape_transform = Transform2D(shape_rotation, world_pos + shape_local_pos)
return shape_transform
# No shape node found, just use world position
return Transform2D(0.0, world_pos)
func _get_collision_extent(node: Node) -> float:
var shape = _get_collision_shape_for(node)
if shape is RectangleShape2D:
return max(shape.size.x, shape.size.y) * 0.5
if shape is CapsuleShape2D:
return shape.radius + shape.height * 0.5
if shape is CircleShape2D:
return shape.radius
if shape is ConvexPolygonShape2D:
var rect = shape.get_rect()
return max(rect.size.x, rect.size.y) * 0.5
# Fallback
return 8.0
func _update_animation(delta):
# Update animation frame timing
time_since_last_frame += delta
if time_since_last_frame >= ANIMATIONS[current_animation]["frameDurations"][current_frame] / 1000.0:
current_frame += 1
if current_frame >= len(ANIMATIONS[current_animation]["frames"]):
current_frame -= 1 # Prevent out of bounds
if ANIMATIONS[current_animation]["loop"]:
current_frame = 0
if ANIMATIONS[current_animation]["nextAnimation"] != null:
current_frame = 0
current_animation = ANIMATIONS[current_animation]["nextAnimation"]
time_since_last_frame = 0.0
# Calculate frame index
var frame_index = current_direction * 35 + ANIMATIONS[current_animation]["frames"][current_frame]
# Update all sprite layers
if sprite_body:
sprite_body.frame = frame_index
if sprite_boots:
sprite_boots.frame = frame_index
if sprite_armour:
sprite_armour.frame = frame_index
if sprite_facial_hair:
sprite_facial_hair.frame = frame_index
if sprite_hair:
sprite_hair.frame = frame_index
if sprite_eyes:
sprite_eyes.frame = frame_index
if sprite_eyelashes:
sprite_eyelashes.frame = frame_index
if sprite_addons:
sprite_addons.frame = frame_index
if sprite_headgear:
sprite_headgear.frame = frame_index
if sprite_weapon:
sprite_weapon.frame = frame_index
func _get_direction_from_vector(vec: Vector2) -> int:
if vec.length() < 0.1:
return current_direction
var angle = vec.angle()
var deg = rad_to_deg(angle)
# Normalize to 0-360
if deg < 0:
deg += 360
# Map to 8 directions
if deg >= 337.5 or deg < 22.5:
return Direction.RIGHT
elif deg >= 22.5 and deg < 67.5:
return Direction.DOWN_RIGHT
elif deg >= 67.5 and deg < 112.5:
return Direction.DOWN
elif deg >= 112.5 and deg < 157.5:
return Direction.DOWN_LEFT
elif deg >= 157.5 and deg < 202.5:
return Direction.LEFT
elif deg >= 202.5 and deg < 247.5:
return Direction.UP_LEFT
elif deg >= 247.5 and deg < 292.5:
return Direction.UP
else: # 292.5 to 337.5
return Direction.UP_RIGHT
# Update facing direction from mouse position (called by GameWorld)
func _update_facing_from_mouse(mouse_direction: Vector2):
# Only update if using keyboard input (not gamepad)
if input_device != -1:
return
# Don't update if pushing (locked direction)
if is_pushing:
return
var new_direction = _get_direction_from_vector(mouse_direction) as Direction
# Update direction and cone light rotation if changed
if new_direction != current_direction:
current_direction = new_direction
_update_cone_light_rotation()
func _set_animation(anim_name: String):
if current_animation != anim_name:
current_animation = anim_name
current_frame = 0
time_since_last_frame = 0.0
# Convert Direction enum to angle in radians for light rotation
func _direction_to_angle(direction: int) -> float:
match direction:
Direction.DOWN:
return PI / 2.0 # 90 degrees
Direction.DOWN_RIGHT:
return PI / 4.0 # 45 degrees
Direction.RIGHT:
return 0.0 # 0 degrees
Direction.UP_RIGHT:
return -PI / 4.0 # -45 degrees
Direction.UP:
return -PI / 2.0 # -90 degrees
Direction.UP_LEFT:
return -3.0 * PI / 4.0 # -135 degrees
Direction.LEFT:
return PI # 180 degrees
Direction.DOWN_LEFT:
return 3.0 * PI / 4.0 # 135 degrees
_:
return PI / 2.0 # Default to DOWN
# Update cone light rotation based on player's facing direction
func _update_cone_light_rotation():
if cone_light:
cone_light.rotation = _direction_to_angle(current_direction)+(PI/2)
# Create a cone-shaped light texture programmatically
# Creates a directional cone texture that extends forward and fades to the sides
func _create_cone_light_texture():
if not cone_light:
return
# Create a square texture (recommended size for lights)
var texture_size = 256
var image = Image.create(texture_size, texture_size, false, Image.FORMAT_RGBA8)
var center = Vector2(texture_size / 2.0, texture_size / 2.0)
var max_distance = texture_size / 2.0
# Cone parameters (these control the shape)
var cone_angle_rad = deg_to_rad(cone_light_angle) # Convert to radians
var half_cone = cone_angle_rad / 2.0
var forward_dir = Vector2(0, -1) # Pointing up in texture space (will be rotated by light rotation)
for x in range(texture_size):
for y in range(texture_size):
var pos = Vector2(x, y)
var offset = pos - center
var distance = offset.length()
if distance > 0.0:
# Normalize offset to get direction
var dir = offset / distance
# Calculate angle from forward direction
# forward_dir is (0, -1) which has angle -PI/2
# We want to find the angle difference
var pixel_angle = dir.angle() # Angle of pixel direction
var forward_angle = forward_dir.angle() # Angle of forward direction (-PI/2)
# Calculate angle difference (wrapped to -PI to PI)
var angle_diff = pixel_angle - forward_angle
# Normalize to -PI to PI range
angle_diff = fmod(angle_diff + PI, 2.0 * PI) - PI
var abs_angle_diff = abs(angle_diff)
# Check if within cone angle (hard edge - no smooth falloff)
if abs_angle_diff <= half_cone:
# Within cone - calculate brightness
var normalized_distance = distance / max_distance
# Fade based on distance (from center) - keep distance falloff
# Hard edge for angle (pixely) - no smoothstep on angle
var distance_factor = 1.0 - smoothstep(0.0, 1.0, normalized_distance)
var alpha = distance_factor # Hard edge on angle, smooth fade on distance
var color = Color(1.0, 1.0, 1.0, alpha)
image.set_pixel(x, y, color)
else:
# Outside cone - transparent (hard edge)
image.set_pixel(x, y, Color.TRANSPARENT)
else:
# Center point - full brightness
image.set_pixel(x, y, Color.WHITE)
# Create ImageTexture from the image
var texture = ImageTexture.create_from_image(image)
cone_light.texture = texture
# Update cone light spread/angle
# Recreates the texture with the new angle to properly show the cone shape
func _update_cone_light_spread():
if cone_light:
# Recreate the texture with the new angle
_create_cone_light_texture()
# Set the cone light angle (in degrees) and update the light
func set_cone_light_angle(angle_degrees: float):
cone_light_angle = angle_degrees
_update_cone_light_spread()
# Helper function to snap direction to 8-way directions
func _snap_to_8_directions(direction: Vector2) -> Vector2:
if direction.length() < 0.1:
return Vector2.DOWN
# 8 cardinal and diagonal directions
var directions = [
Vector2(0, -1), # Up
Vector2(1, -1).normalized(), # Up-Right
Vector2(1, 0), # Right
Vector2(1, 1).normalized(), # Down-Right
Vector2(0, 1), # Down
Vector2(-1, 1).normalized(), # Down-Left
Vector2(-1, 0), # Left
Vector2(-1, -1).normalized() # Up-Left
]
# Find closest direction
var best_dir = directions[0]
var best_dot = direction.normalized().dot(best_dir)
for dir in directions:
var dot = direction.normalized().dot(dir)
if dot > best_dot:
best_dot = dot
best_dir = dir
return best_dir
func _update_z_physics(delta):
# Apply gravity
velocity_z -= gravity_z * delta
# Update Z position
position_z += velocity_z * delta
# Check if landed
if position_z <= 0.0:
position_z = 0.0
velocity_z = 0.0
is_airborne = false
# Stop horizontal movement on landing (with some friction)
velocity = velocity * 0.3
# Visual effect when landing (removed - using layered sprites now)
print(name, " landed!")
# Update visual offset based on height for all sprite layers
var y_offset = - position_z * 0.5 # Visual height is half of z for perspective
var height_scale = 1.0 # Base scale
if position_z > 0:
height_scale = 1.0 * (1.0 - (position_z / 50.0) * 0.2) # Scaled down for smaller Z values
height_scale = max(0.8, height_scale)
# Apply to all sprite layers
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon]:
if sprite_layer:
sprite_layer.position.y = y_offset
if position_z > 0:
sprite_layer.scale = Vector2.ONE * height_scale
else:
# Spring back to normal when landed
sprite_layer.scale = sprite_layer.scale.lerp(Vector2.ONE, delta * 10.0)
# Update shadow based on height
if shadow:
if position_z > 0:
var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 # Scaled down for smaller Z values
shadow.scale = Vector2.ONE * max(0.5, shadow_scale)
shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3
else:
shadow.scale = Vector2.ONE
shadow.modulate.a = 0.5
func _physics_process(delta):
# Reset teleport flag at start of frame
teleported_this_frame = false
# Update animations
_update_animation(delta)
# Update Z-axis physics (height simulation)
if is_airborne:
_update_z_physics(delta)
if is_local_player and is_multiplayer_authority():
# Skip all input and logic if dead
if is_dead:
return
# Handle knockback timer (always handle knockback, even when controls are disabled)
if is_knocked_back:
knockback_time += delta
if knockback_time >= knockback_duration:
is_knocked_back = false
knockback_time = 0.0
# Skip input if controls are disabled (e.g., when inventory is open)
# But still allow knockback to continue (handled above)
var skip_input = controls_disabled
if controls_disabled:
if not is_knocked_back:
# Immediately stop movement when controls are disabled (e.g., inventory opened)
velocity = Vector2.ZERO
# Reset animation to IDLE if not in a special state
if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF":
if is_lifting:
_set_animation("IDLE_HOLD")
elif is_pushing:
_set_animation("IDLE_PUSH")
else:
_set_animation("IDLE")
# Check if being held by someone
var being_held_by_someone = false
for other_player in get_tree().get_nodes_in_group("player"):
if other_player != self and other_player.held_object == self:
being_held_by_someone = true
being_held_by = other_player
break
if being_held_by_someone:
# Handle struggle mechanic
_handle_struggle(delta)
elif is_knocked_back:
# During knockback, no input control - just let velocity carry the player
# Apply friction to slow down knockback
velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
elif not is_airborne and not skip_input:
# Normal input handling (only if controls are not disabled)
struggle_time = 0.0 # Reset struggle timer
struggle_direction = Vector2.ZERO
_handle_input()
_handle_movement(delta)
_handle_interactions()
else:
# Reset struggle when airborne
struggle_time = 0.0
struggle_direction = Vector2.ZERO
# Update held object positions
if is_lifting:
_update_lifted_object()
elif is_pushing:
_update_pushed_object()
# Sync position, direction, and animation to other clients (unreliable broadcast)
# Only send RPC if we're in the scene tree and ready to send RPCs (prevents errors when player hasn't spawned on all clients yet)
# On server, also wait for all clients to be ready
if multiplayer.has_multiplayer_peer() and is_inside_tree() and can_send_rpcs:
# On server, continuously check if all clients are ready (in case new clients connect)
# Also add a small delay after clients notify they're ready to ensure they've spawned all players
if multiplayer.is_server() and not all_clients_ready:
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var connected_peers = multiplayer.get_peers()
var all_ready = true
var max_ready_time = 0.0 # Track when the last client became ready
for connected_peer_id in connected_peers:
if connected_peer_id != multiplayer.get_unique_id():
if not game_world.clients_ready.has(connected_peer_id) or not game_world.clients_ready[connected_peer_id]:
all_ready = false
break
else:
# Client is ready, check when they became ready
var ready_time_key = str(connected_peer_id) + "_ready_time"
if game_world.clients_ready.has(ready_time_key):
var ready_time = game_world.clients_ready[ready_time_key] as float
if ready_time > max_ready_time:
max_ready_time = ready_time
# If no peers, we're ready
if connected_peers.size() == 0:
all_ready = true
# If all clients are ready, wait a bit more to ensure they've spawned all players
if all_ready:
var current_time = Time.get_ticks_msec() / 1000.0
var time_since_last_ready = current_time - max_ready_time
# Wait at least 1.0 second after the last client became ready
# This gives time for all spawn RPCs to be processed and nodes to be registered in scene tree
if max_ready_time == 0.0 or time_since_last_ready >= 1.0:
if not all_clients_ready: # Only set once
all_clients_ready = true
all_clients_ready_time = current_time
if max_ready_time > 0.0:
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)")
# Sync position to all ready peers (clients and server)
# Only send if node is still valid and in tree
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree() and is_instance_valid(self):
_rpc_to_ready_peers("_sync_position", [position, velocity, position_z, is_airborne, current_direction, current_animation])
# Always move and slide to maintain horizontal velocity
# When airborne, velocity is set by throw and decreases with friction
move_and_slide()
# Apply air friction when airborne
if is_airborne:
velocity = velocity * 0.98 # Slight air resistance
# Handle walking sound effects (works for all players - server and clients)
# This checks velocity which is synced via RPC, so it works for remote players too
_handle_walking_sfx()
func _handle_input():
var input_vector = Vector2.ZERO
# Check for virtual joystick input first (mobile/touchscreen)
if virtual_joystick_input.length() > 0.01:
input_vector = virtual_joystick_input
elif input_device == -1:
# Keyboard input
input_vector.x = max(
Input.get_action_strength("move_right"),
Input.get_action_strength("ui_right")
) - max(
Input.get_action_strength("move_left"),
Input.get_action_strength("ui_left")
)
input_vector.y = max(
Input.get_action_strength("move_down"),
Input.get_action_strength("ui_down")
) - max(
Input.get_action_strength("move_up"),
Input.get_action_strength("ui_up")
)
else:
# Gamepad input
input_vector.x = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_X)
input_vector.y = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_Y)
# Normalize diagonal movement
if input_vector.length() > 1.0:
input_vector = input_vector.normalized()
# If pushing, lock movement to push axis
if is_pushing and push_axis.length() > 0.1:
# Project input onto the push axis
var dot = input_vector.dot(push_axis)
input_vector = push_axis * dot
# Check if moving would push object into a wall BEFORE allowing movement
if held_object and input_vector.length() > 0.1:
# Calculate where the object would be if we move
var push_speed = move_speed * 0.5 # Pushing speed
var proposed_movement = input_vector.normalized() * push_speed * get_process_delta_time()
var proposed_player_pos = global_position + proposed_movement
var proposed_player_movement = proposed_player_pos - initial_player_position
var proposed_movement_along_axis = proposed_player_movement.dot(push_axis) * push_axis
var proposed_object_pos = initial_grab_position + proposed_movement_along_axis
# Check if proposed object position would collide with walls
var space_state = get_world_2d().direct_space_state
var would_hit_wall = false
# Get collision shape from held object
var held_collision_shape = null
if held_object is CharacterBody2D:
for child in held_object.get_children():
if child is CollisionShape2D:
held_collision_shape = child
break
if held_collision_shape and held_collision_shape.shape:
# Use shape query
var query = PhysicsShapeQueryParameters2D.new()
query.shape = held_collision_shape.shape
# Account for collision shape offset
var shape_offset = held_collision_shape.position if held_collision_shape else Vector2.ZERO
query.transform = Transform2D(0, proposed_object_pos + shape_offset)
query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64)
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = [held_object.get_rid()]
var results = space_state.intersect_shape(query)
would_hit_wall = results.size() > 0
if would_hit_wall:
print("DEBUG: Would hit wall in _handle_input! Blocking movement. Results: ", results.size())
else:
# Fallback: point query
var query = PhysicsPointQueryParameters2D.new()
query.position = proposed_object_pos
query.collision_mask = 64
query.collide_with_areas = false
query.collide_with_bodies = true
if held_object is CharacterBody2D:
query.exclude = [held_object.get_rid()]
var results = space_state.intersect_point(query)
would_hit_wall = results.size() > 0
# If would hit wall and trying to push forward, block movement
if would_hit_wall:
var movement_direction = input_vector.normalized()
var push_direction = push_axis.normalized()
var dot_product = movement_direction.dot(push_direction)
if dot_product > 0.1: # Pushing forward, not pulling
# Block movement - would push object into wall
input_vector = Vector2.ZERO
object_blocked_by_wall = true
# Also check the flag from previous frame
if object_blocked_by_wall and held_object:
# Check if trying to move in the direction that's blocked
var movement_direction = input_vector.normalized()
var push_direction = push_axis.normalized()
# If moving in the same direction as push axis (pushing forward), block it
# Allow pulling away (opposite direction)
var dot_product = movement_direction.dot(push_direction)
if dot_product > 0.1:
# Trying to push forward into wall - block movement completely
input_vector = Vector2.ZERO
# Track last movement direction if moving
if input_vector.length() > 0.1:
last_movement_direction = input_vector.normalized()
# Update facing direction (except when pushing - locked direction)
# Note: Mouse control will override this if mouse is being used
var new_direction = current_direction
if not is_pushing:
new_direction = _get_direction_from_vector(input_vector) as Direction
else:
# Keep locked direction when pushing
new_direction = push_direction_locked as Direction
# Update direction and cone light rotation if changed
if new_direction != current_direction:
current_direction = new_direction
_update_cone_light_rotation()
# Set animation based on state
if is_lifting:
_set_animation("RUN_HOLD")
elif is_pushing:
_set_animation("RUN_PUSH")
elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF":
_set_animation("RUN")
else:
# Idle animations
if is_lifting:
if current_animation != "LIFT" and current_animation != "IDLE_HOLD":
_set_animation("IDLE_HOLD")
elif is_pushing:
_set_animation("IDLE_PUSH")
# Keep locked direction when pushing
if push_direction_locked != current_direction:
current_direction = push_direction_locked as Direction
_update_cone_light_rotation()
else:
if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF":
_set_animation("IDLE")
# Handle drag sound for interactable objects
var is_dragging_now = false
if held_object and is_pushing and not is_lifting:
# Player is pushing (not lifting) - check if moving
if input_vector.length() > 0.1 and not object_blocked_by_wall:
# Player is moving while pushing - this is dragging
is_dragging_now = true
# Continuously play drag sound while dragging (method checks if already playing)
if held_object.has_method("play_drag_sound"):
held_object.play_drag_sound()
# Stop drag sound when stopping or not dragging
if not is_dragging_now and was_dragging_last_frame:
# Stopped dragging - stop drag sound
if held_object and held_object.has_method("stop_drag_sound"):
held_object.stop_drag_sound()
# Update drag state for next frame
was_dragging_last_frame = is_dragging_now
# Reduce speed by half when pushing/pulling
# Calculate speed with encumbrance penalty
var base_speed = move_speed * (0.5 if is_pushing else 1.0)
var current_speed = base_speed
# Apply encumbrance penalty (1/4 speed if over-encumbered)
if character_stats and character_stats.is_over_encumbered():
current_speed = base_speed * 0.25
velocity = input_vector * current_speed
func _handle_movement(_delta):
# Simple top-down movement is handled by velocity set in _handle_input
pass
func _handle_walking_sfx():
# Check if player is moving (not dead, not airborne, velocity is significant)
var is_moving = velocity.length() > 0.1 and not is_dead and not is_airborne
if is_moving:
# Player is moving - play walking sound
if sfx_walk and timer_walk:
if not sfx_walk.playing and timer_walk.is_stopped():
timer_walk.start()
sfx_walk.play()
else:
# Player is not moving - stop walking sound
if sfx_walk and sfx_walk.playing:
sfx_walk.stop()
func _handle_interactions():
var grab_button_down = false
var grab_just_pressed = false
var grab_just_released = false
if input_device == -1:
# Keyboard or Mouse input
var mouse_right_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
grab_button_down = Input.is_action_pressed("grab") or mouse_right_pressed
grab_just_pressed = Input.is_action_just_pressed("grab") or (mouse_right_pressed and not 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
# DEBUG: Log button states if there's a conflict
if grab_just_pressed and grab_just_released:
print("DEBUG: WARNING - Both grab_just_pressed and grab_just_released are true!")
if grab_just_released and grab_button_down:
print("DEBUG: WARNING - grab_just_released=true but grab_button_down=true!")
else:
# Gamepad input
var button_currently_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_A)
grab_button_down = button_currently_pressed
grab_just_pressed = button_currently_pressed and grab_button_pressed_time == 0.0
# Check for release by tracking if button was down last frame but not now
if not button_currently_pressed and grab_button_pressed_time > 0.0:
grab_just_released = true
else:
grab_just_released = false
# Track how long grab button is held
if grab_button_down:
grab_button_pressed_time += get_process_delta_time()
else:
# Only reset timer when button is released (not just not pressed)
# This allows gamepad release detection to work correctly
if grab_just_released:
grab_button_pressed_time = 0.0
# Handle grab button press FIRST (before checking release)
# Note: just_grabbed_this_frame is reset at the END of this function
if grab_just_pressed and can_grab:
print("DEBUG: grab_just_pressed, can_grab=", can_grab, " held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down)
if not held_object:
# Try to grab something (but don't lift yet - wait for release to determine if it's a tap)
print("DEBUG: Calling _try_grab()")
_try_grab()
just_grabbed_this_frame = true
# Record when we grabbed to detect quick tap on release
grab_start_time = Time.get_ticks_msec() / 1000.0
print("DEBUG: After _try_grab(), held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down, " just_grabbed_this_frame=", just_grabbed_this_frame)
elif is_lifting:
# Already lifting - check if moving to throw, or just put down
var is_moving = velocity.length() > 10.0
if is_moving:
# Moving + tap E = throw
_throw_object()
else:
# Not moving + tap E = put down
_place_down_object()
# Handle grab button release
# CRITICAL: Don't process release if:
# 1. We just grabbed this frame (prevents immediate release bug) - THIS IS THE MOST IMPORTANT CHECK
# 2. Button is still down (shouldn't happen, but safety check)
# 3. grab_just_pressed is also true (same frame tap)
if grab_just_released and held_object:
# Check if we just grabbed (either this frame or recently)
# Use grab_start_time to determine if this was a quick tap
var time_since_grab = (Time.get_ticks_msec() / 1000.0) - grab_start_time
var was_recent_grab = time_since_grab <= grab_tap_threshold * 2.0 # Give some buffer
if just_grabbed_this_frame or (grab_start_time > 0.0 and was_recent_grab):
# Just grabbed - check if it was a quick tap (within threshold)
var was_quick_tap = time_since_grab <= grab_tap_threshold
print("DEBUG: Release after grab - was_quick_tap=", was_quick_tap, " time_since_grab=", time_since_grab, " threshold=", grab_tap_threshold, " is_pushing=", is_pushing)
if was_quick_tap:
# Quick tap - lift the object!
print("DEBUG: Quick tap detected - lifting object")
# Check if object can be lifted
var can_lift = true
if held_object.has_method("can_be_lifted"):
can_lift = held_object.can_be_lifted()
if can_lift:
_lift_object()
else:
# Can't lift - just release (stop pushing if we were pushing)
print("DEBUG: Can't lift this object - releasing")
if is_pushing:
_stop_pushing()
else:
_place_down_object()
else:
# Held too long - we were pushing/pulling, so just stop pushing (don't change position!)
print("DEBUG: Held too long (", time_since_grab, "s) - stopping push without changing position")
if is_pushing:
_stop_pushing()
else:
# Not pushing, just release
_stop_pushing() # Use stop_pushing for consistency (it handles position correctly)
# Reset the flag and start time now that we've processed the release
just_grabbed_this_frame = false
grab_start_time = 0.0
else:
var can_release = not grab_button_down and not grab_just_pressed
print("DEBUG: Release check - grab_just_released=", grab_just_released, " held_object=", held_object != null, " just_grabbed_this_frame=", just_grabbed_this_frame, " grab_button_down=", grab_button_down, " grab_just_pressed=", grab_just_pressed, " can_release=", can_release)
if can_release:
print("DEBUG: Processing release - is_lifting=", is_lifting, " is_pushing=", is_pushing)
# Button was just released - release the object
if is_lifting:
# If lifting, place down
print("DEBUG: Releasing lifted object")
_place_down_object()
elif is_pushing:
# If pushing, stop pushing
_stop_pushing()
else:
print("DEBUG: Release BLOCKED - grab_button_down=", grab_button_down, " grab_just_pressed=", grab_just_pressed)
# Update object position based on mode (only if button is still held)
if held_object and grab_button_down:
if is_lifting:
_update_lifted_object()
elif is_pushing:
_update_pushed_object()
else:
# Not lifting or pushing yet - start pushing/pulling immediately when holding E
# DO NOT lift while holding E - only lift on release if it's a quick tap!
# When holding E, we always push/pull (if pushable), regardless of whether it can be lifted
var can_push = true
if held_object.has_method("can_be_pushed"):
can_push = held_object.can_be_pushed()
if can_push and not is_pushing:
# Start pushing/pulling immediately when holding E
_start_pushing()
# Lift will only happen on release if it was a quick tap
# Handle attack input
var attack_just_pressed = false
if input_device == -1:
# Keyboard or Mouse
var mouse_left_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)
attack_just_pressed = Input.is_action_just_pressed("attack") or (mouse_left_pressed and not was_mouse_left_pressed)
was_mouse_left_pressed = mouse_left_pressed
else:
# Gamepad (X button)
attack_just_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X)
if attack_just_pressed and can_attack:
if is_lifting:
# Attack while lifting -> throw immediately (no movement required)
_force_throw_held_object(last_movement_direction)
elif not is_pushing:
_perform_attack()
# Note: just_grabbed_this_frame is reset when we block a release, or stays true if we grabbed this frame
# This ensures it persists to the next frame to block immediate release
func _try_grab():
if not grab_area:
return
var bodies = grab_area.get_overlapping_bodies()
var closest_body = null
var closest_distance = grab_range
for body in bodies:
if body == self:
continue
# Check if it's grabbable
var is_grabbable = false
# Check for objects with can_be_grabbed method
if body.has_method("can_be_grabbed"):
if body.can_be_grabbed():
is_grabbable = true
# Also allow grabbing other players
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 < closest_distance:
closest_distance = distance
closest_body = body
if closest_body:
held_object = closest_body
# Store the initial positions - don't change the grabbed object's position yet!
initial_grab_position = closest_body.global_position
initial_player_position = global_position
grab_offset = closest_body.position - position
# Store the distance from player to object when grabbed (for placement)
grab_distance = global_position.distance_to(closest_body.global_position)
# Calculate push axis from grab direction (but don't move the object yet)
var grab_direction = grab_offset.normalized()
if grab_direction.length() < 0.1:
grab_direction = last_movement_direction
push_axis = _snap_to_8_directions(grab_direction)
# Disable collision with players and other objects when grabbing
# But keep collision with walls (layer 7) enabled for pushing
if _is_box(closest_body):
# Objects are on layer 2
closest_body.set_collision_layer_value(2, false)
closest_body.set_collision_mask_value(1, false) # Disable collision with players
closest_body.set_collision_mask_value(2, false) # Disable collision with other objects
# IMPORTANT: Keep collision with walls (layer 7) enabled so we can detect wall collisions!
closest_body.set_collision_mask_value(7, true) # Enable collision with walls
elif _is_player(closest_body):
# Players are on layer 1
closest_body.set_collision_layer_value(1, false)
closest_body.set_collision_mask_value(1, false)
# IMPORTANT: Keep collision with walls (layer 7) enabled so we can detect wall collisions!
closest_body.set_collision_mask_value(7, true) # Enable collision with walls
# When grabbing, immediately try to lift if possible
_set_animation("IDLE")
is_pushing = false
is_lifting = false
# Notify the object it's being grabbed
if closest_body.has_method("on_grabbed"):
closest_body.on_grabbed(self)
# DON'T lift immediately - wait for release to determine if it's a tap or hold
# If it's a quick tap (release within grab_tap_threshold), we'll lift on release
# If it's held longer, we'll keep it grabbed (or push if can't lift)
# Sync initial grab to network
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])
print("Grabbed: ", closest_body.name)
func _lift_object():
print("DEBUG: _lift_object() called, held_object=", held_object != null)
if not held_object:
print("DEBUG: _lift_object() - no held_object, returning")
return
# Check if object can be lifted
# Players are always liftable (they don't have can_be_lifted method)
# Objects need to check can_be_lifted()
var can_lift = true
if held_object.has_method("can_be_lifted"):
can_lift = held_object.can_be_lifted()
print("DEBUG: _lift_object() - can_be_lifted() returned ", can_lift)
# If object doesn't have the method (like players), assume it can be lifted
# Only prevent lifting if the method exists AND returns false
if not can_lift:
# Can't lift this object, just push/pull it
print("DEBUG: _lift_object() - cannot lift, starting push instead")
_start_pushing()
return
print("DEBUG: _lift_object() - setting is_lifting=true, is_pushing=false")
is_lifting = true
is_pushing = false
# Freeze physics (collision already disabled in _try_grab)
if _is_box(held_object):
# Box: set frozen flag
if "is_frozen" in held_object:
held_object.is_frozen = true
elif _is_player(held_object):
# Player: use set_being_held
if held_object.has_method("set_being_held"):
held_object.set_being_held(true)
if held_object.has_method("on_lifted"):
held_object.on_lifted(self)
# Play lift animation (fast transition)
_set_animation("LIFT")
# 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])
print("Lifted: ", held_object.name)
$SfxLift.play()
func _start_pushing():
if not held_object:
return
# Check if object can be pushed
if held_object.has_method("can_be_pushed") and not held_object.can_be_pushed():
# Can't push this object (like chests) - just grab it but don't move it
is_pushing = false
is_lifting = false
return
is_pushing = true
is_lifting = false
# Lock to the direction we're facing when we start pushing
var initial_direction = grab_offset.normalized()
if initial_direction.length() < 0.1:
initial_direction = last_movement_direction.normalized()
# Snap to one of 8 directions
push_axis = _snap_to_8_directions(initial_direction)
# Lock the facing direction
push_direction_locked = _get_direction_from_vector(push_axis)
# Re-enable collision with walls (layer 7) for pushing, but keep collision with players/objects disabled
if _is_box(held_object):
# Re-enable collision with walls so we can detect wall collisions when pushing
held_object.set_collision_mask_value(7, true) # Enable collision with walls
# Box: unfreeze so it can be pushed
if "is_frozen" in held_object:
held_object.is_frozen = false
# 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
print("Pushing: ", held_object.name, " along axis: ", push_axis, " facing dir: ", push_direction_locked)
func _force_drop_held_object():
# Force drop any held object (used when level completes)
if held_object:
if is_lifting:
_place_down_object()
elif is_pushing:
_stop_pushing()
else:
# Just release
_stop_pushing()
func _stop_pushing():
if not held_object:
return
is_pushing = false
push_axis = Vector2.ZERO
# Stop drag sound when releasing object
if held_object and held_object.has_method("stop_drag_sound"):
held_object.stop_drag_sound()
was_dragging_last_frame = false # Reset drag state
# Store reference and CURRENT position - don't change it!
var released_obj = held_object
var released_obj_position = released_obj.global_position # Store exact position
# Sync release 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(released_obj)
_rpc_to_ready_peers("_sync_release", [obj_name])
# Release the object and re-enable collision completely
if _is_box(released_obj):
# Objects: back on layer 2
released_obj.set_collision_layer_value(2, true)
released_obj.set_collision_mask_value(1, true)
released_obj.set_collision_mask_value(2, true)
elif _is_player(released_obj):
# Players: back on layer 1
released_obj.set_collision_layer_value(1, true)
released_obj.set_collision_mask_value(1, true)
if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"):
released_obj.set_being_held(false)
# Ensure position stays exactly where it is - no movement on release!
# Do this AFTER calling on_released in case it tries to change position
if released_obj.has_method("on_released"):
released_obj.on_released(self)
# Force position to stay exactly where it was - no snapping or movement!
if released_obj is CharacterBody2D:
released_obj.global_position = released_obj_position
released_obj.velocity = Vector2.ZERO # Stop any velocity
held_object = null
grab_offset = Vector2.ZERO
grab_distance = 0.0
initial_grab_position = Vector2.ZERO
initial_player_position = Vector2.ZERO
print("Stopped pushing")
func _throw_object():
if not held_object or not is_lifting:
return
# Check if object can be thrown
if held_object.has_method("can_be_thrown") and not held_object.can_be_thrown():
# Can't throw this object, place it down instead
_place_down_object()
return
var throw_direction = velocity.normalized()
var is_moving = throw_direction.length() > 0.1
if not is_moving:
# If not moving, place down instead of throw
_place_down_object()
return
# Position object at player's position before throwing
var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front
# Store reference before clearing
var thrown_obj = held_object
# Clear state first (important!)
held_object = null
grab_offset = Vector2.ZERO
is_lifting = false
is_pushing = false
# Re-enable collision completely
if _is_box(thrown_obj):
# Box: set position and physics first
thrown_obj.global_position = throw_start_pos
# Set throw velocity for box (same force as player throw)
if "throw_velocity" in thrown_obj:
thrown_obj.throw_velocity = throw_direction * throw_force / thrown_obj.weight
if "is_frozen" in thrown_obj:
thrown_obj.is_frozen = false
# Make box airborne with same arc as players
if "is_airborne" in thrown_obj:
thrown_obj.is_airborne = true
thrown_obj.position_z = 2.5
thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale
# Call on_thrown if available
if thrown_obj.has_method("on_thrown"):
thrown_obj.on_thrown(self, throw_direction * throw_force)
# ⚡ Delay collision re-enable to prevent self-collision
await get_tree().create_timer(0.1).timeout
if thrown_obj and is_instance_valid(thrown_obj):
thrown_obj.set_collision_layer_value(2, true)
thrown_obj.set_collision_mask_value(1, true)
thrown_obj.set_collision_mask_value(2, true)
thrown_obj.set_collision_mask_value(7, true)
elif _is_player(thrown_obj):
# Player: set position and physics first
thrown_obj.global_position = throw_start_pos
# Set horizontal velocity for the arc
thrown_obj.velocity = throw_direction * throw_force * 0.8 # Slightly reduced for arc
# Make player airborne with Z velocity
if "is_airborne" in thrown_obj:
thrown_obj.is_airborne = true
thrown_obj.position_z = 2.5 # Start slightly off ground
thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale
if thrown_obj.has_method("set_being_held"):
thrown_obj.set_being_held(false)
# ⚡ Delay collision re-enable to prevent self-collision
await get_tree().create_timer(0.1).timeout
if thrown_obj and is_instance_valid(thrown_obj):
thrown_obj.set_collision_layer_value(1, true)
thrown_obj.set_collision_mask_value(1, true)
thrown_obj.set_collision_mask_value(7, true)
if thrown_obj.has_method("on_thrown"):
thrown_obj.on_thrown(self, throw_direction * throw_force)
# Play throw animation
_set_animation("THROW")
$SfxThrow.play()
# Sync throw 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(thrown_obj)
_rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name])
print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force)
func _force_throw_held_object(direction: Vector2):
if not held_object or not is_lifting:
return
# Check if object can be thrown
if held_object.has_method("can_be_thrown") and not held_object.can_be_thrown():
# Can't throw this object, place it down instead
_place_down_object()
return
var throw_direction = direction.normalized()
if throw_direction.length() < 0.1:
throw_direction = last_movement_direction.normalized()
if throw_direction.length() < 0.1:
throw_direction = Vector2.RIGHT
# Position object at player's position before throwing
var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front
# Store reference before clearing
var thrown_obj = held_object
# Clear state first (important!)
held_object = null
grab_offset = Vector2.ZERO
is_lifting = false
is_pushing = false
# Re-enable collision completely
if _is_box(thrown_obj):
# Box: set position and physics first
thrown_obj.global_position = throw_start_pos
# Set throw velocity for box (same force as player throw)
if "throw_velocity" in thrown_obj:
thrown_obj.throw_velocity = throw_direction * throw_force / thrown_obj.weight
if "is_frozen" in thrown_obj:
thrown_obj.is_frozen = false
# Make box airborne with same arc as players
if "is_airborne" in thrown_obj:
thrown_obj.is_airborne = true
thrown_obj.position_z = 2.5
thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale
# Call on_thrown if available
if thrown_obj.has_method("on_thrown"):
thrown_obj.on_thrown(self, throw_direction * throw_force)
# ⚡ Delay collision re-enable to prevent self-collision
await get_tree().create_timer(0.1).timeout
if thrown_obj and is_instance_valid(thrown_obj):
thrown_obj.set_collision_layer_value(2, true)
thrown_obj.set_collision_mask_value(1, true)
thrown_obj.set_collision_mask_value(2, true)
elif _is_player(thrown_obj):
# Player: set position and physics first
thrown_obj.global_position = throw_start_pos
# Set horizontal velocity for the arc
thrown_obj.velocity = throw_direction * throw_force * 0.8 # Slightly reduced for arc
# Make player airborne with Z velocity
if "is_airborne" in thrown_obj:
thrown_obj.is_airborne = true
thrown_obj.position_z = 2.5 # Start slightly off ground
thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale
if thrown_obj.has_method("set_being_held"):
thrown_obj.set_being_held(false)
# ⚡ Delay collision re-enable to prevent self-collision
await get_tree().create_timer(0.1).timeout
if thrown_obj and is_instance_valid(thrown_obj):
thrown_obj.set_collision_layer_value(1, true)
thrown_obj.set_collision_mask_value(1, true)
if thrown_obj.has_method("on_thrown"):
thrown_obj.on_thrown(self, throw_direction * throw_force)
# Play throw animation
_set_animation("THROW")
$SfxThrow.play()
# Sync throw 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(thrown_obj)
_rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name])
print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force)
func _place_down_object():
if not held_object:
return
# Place object in front of player based on last movement direction
var place_pos = _find_closest_place_pos(last_movement_direction, held_object)
var placed_obj = held_object
print("DEBUG: Attempting to place ", placed_obj.name if placed_obj else "null", " at ", place_pos, " (player at ", global_position, ", distance: ", global_position.distance_to(place_pos), ")")
if not _can_place_down_at(place_pos, placed_obj):
print("DEBUG: Place down blocked - space not free at ", place_pos)
return
# Clear state
held_object = null
grab_offset = Vector2.ZERO
is_lifting = false
is_pushing = false
# Re-enable collision completely and physics
placed_obj.global_position = place_pos
if _is_box(placed_obj):
# Box: back on layer 2
placed_obj.set_collision_layer_value(2, true)
placed_obj.set_collision_mask_value(1, true)
placed_obj.set_collision_mask_value(2, true)
# Stop movement and reset all state
if "throw_velocity" in placed_obj:
placed_obj.throw_velocity = Vector2.ZERO
if "is_frozen" in placed_obj:
placed_obj.is_frozen = false
if "is_being_held" in placed_obj:
placed_obj.is_being_held = false
if "held_by_player" in placed_obj:
placed_obj.held_by_player = null
if "is_airborne" in placed_obj:
placed_obj.is_airborne = false
if "position_z" in placed_obj:
placed_obj.position_z = 0.0
if "velocity_z" in placed_obj:
placed_obj.velocity_z = 0.0
elif _is_player(placed_obj):
# Player: back on layer 1
placed_obj.set_collision_layer_value(1, true)
placed_obj.set_collision_mask_value(1, true)
placed_obj.global_position = place_pos
placed_obj.velocity = Vector2.ZERO
if placed_obj.has_method("set_being_held"):
placed_obj.set_being_held(false)
if placed_obj.has_method("on_released"):
placed_obj.on_released(self)
# Sync place down over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(placed_obj)
_rpc_to_ready_peers("_sync_place_down", [obj_name, place_pos])
print("Placed down ", placed_obj.name, " at ", place_pos)
func _perform_attack():
if not can_attack or is_attacking:
return
can_attack = false
is_attacking = true
# Check what weapon is equipped
var equipped_weapon = null
if character_stats and character_stats.equipment.has("mainhand"):
equipped_weapon = character_stats.equipment["mainhand"]
var is_bow = false
var is_staff = false
if equipped_weapon:
if equipped_weapon.weapon_type == Item.WeaponType.BOW:
is_bow = true
elif equipped_weapon.weapon_type == Item.WeaponType.STAFF:
is_staff = true
# Play attack animation based on weapon
if is_bow:
_set_animation("BOW")
elif is_staff:
_set_animation("STAFF")
else:
_set_animation("SWORD")
# Calculate attack direction based on player's facing direction
var attack_direction = Vector2.ZERO
match current_direction:
Direction.RIGHT:
attack_direction = Vector2.RIGHT
Direction.DOWN_RIGHT:
attack_direction = Vector2(1, 1).normalized()
Direction.DOWN:
attack_direction = Vector2.DOWN
Direction.DOWN_LEFT:
attack_direction = Vector2(-1, 1).normalized()
Direction.LEFT:
attack_direction = Vector2.LEFT
Direction.UP_LEFT:
attack_direction = Vector2(-1, -1).normalized()
Direction.UP:
attack_direction = Vector2.UP
Direction.UP_RIGHT:
attack_direction = Vector2(1, -1).normalized()
# Delay before spawning projectile
await get_tree().create_timer(0.15).timeout
# Calculate damage from character_stats with randomization
var base_damage = 20.0 # Default damage
if character_stats:
base_damage = character_stats.damage
# D&D style randomization: ±20% variance
var damage_variance = 0.2
var damage_multiplier = 1.0 + randf_range(-damage_variance, damage_variance)
var final_damage = base_damage * damage_multiplier
# Critical strike chance (based on LCK stat)
var crit_chance = 0.0
if character_stats:
crit_chance = (character_stats.baseStats.lck + character_stats.get_pass("lck")) * 0.01 # 1% per LCK point
var is_crit = randf() < crit_chance
if is_crit:
final_damage *= 2.0 # Critical strikes deal 2x damage
print(name, " CRITICAL STRIKE! (LCK: ", character_stats.baseStats.lck + character_stats.get_pass("lck"), ")")
# Round to 1 decimal place
final_damage = round(final_damage * 10.0) / 10.0
# Handle bow attacks - require arrows in off-hand
if is_bow:
# Check for arrows in off-hand
var arrows = null
if character_stats and character_stats.equipment.has("offhand"):
var offhand_item = character_stats.equipment["offhand"]
if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION:
arrows = offhand_item
# Only spawn arrow if we have arrows
if arrows and arrows.quantity > 0:
if attack_arrow_scene:
var arrow_projectile = attack_arrow_scene.instantiate()
get_parent().add_child(arrow_projectile)
arrow_projectile.shoot(attack_direction, global_position, self)
# Play bow shoot sound
if has_node("SfxBowShoot"):
$SfxBowShoot.play()
# Consume one arrow
arrows.quantity -= 1
var remaining = arrows.quantity
if arrows.quantity <= 0:
# Remove arrows if quantity reaches 0
character_stats.equipment["offhand"] = null
if character_stats:
character_stats.character_changed.emit(character_stats)
else:
# Update equipment to reflect quantity change
if character_stats:
character_stats.character_changed.emit(character_stats)
print(name, " shot arrow! Arrows remaining: ", remaining)
else:
# Play bow animation but no projectile
# Play sound for trying to shoot without arrows
if has_node("SfxBowWithoutArrow"):
$SfxBowWithoutArrow.play()
print(name, " tried to shoot but has no arrows!")
elif is_staff:
# Spawn staff projectile for staff weapons
if staff_projectile_scene and equipped_weapon:
var projectile = staff_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_direction, self, final_damage, equipped_weapon)
# Store crit status for visual feedback
if is_crit:
projectile.set_meta("is_crit", true)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_direction * 6.0
projectile.global_position = global_position + spawn_offset
print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")")
else:
# Spawn sword projectile for non-bow/staff weapons
if sword_projectile_scene:
var projectile = sword_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_direction, self, final_damage)
# Store crit status for visual feedback
if is_crit:
projectile.set_meta("is_crit", true)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_direction * 6.0 # 10 pixels in front
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
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction])
# Reset attack cooldown (instant if cooldown is 0)
if attack_cooldown > 0:
await get_tree().create_timer(attack_cooldown).timeout
can_attack = true
is_attacking = false
func _update_lifted_object():
if held_object and is_instance_valid(held_object):
# Check if object is still being held (prevent updates after release)
if held_object.has_method("is_being_held") and not held_object.is_being_held:
held_object = null
return
# Object floats above player's head
var target_pos = position + Vector2(0, -12) # Above head
# Instant follow for local player, smooth for network sync
if is_local_player and is_multiplayer_authority():
held_object.global_position = target_pos # Instant!
else:
held_object.global_position = held_object.global_position.lerp(target_pos, 0.3)
# 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])
func _update_pushed_object():
if held_object and is_instance_valid(held_object):
# Check if object is still being held (prevent updates after release)
if held_object.has_method("is_being_held") and not held_object.is_being_held:
held_object = null
return
# Check if object can be pushed (chests shouldn't move)
if held_object.has_method("can_be_pushed") and not held_object.can_be_pushed():
# Object can't be pushed - don't update position
return
# Calculate how much the player has moved since grabbing
var player_movement = global_position - initial_player_position
# Project player movement onto the push axis (only move along locked axis)
var movement_along_axis = player_movement.dot(push_axis) * push_axis
# Calculate target position: initial position + movement along axis
var target_pos = initial_grab_position + movement_along_axis
# Check for wall collisions BEFORE moving
# Test if moving to target position would collide with walls
var space_state = get_world_2d().direct_space_state
var was_blocked = false
# Get collision shape from held object
var held_collision_shape = null
if held_object is CharacterBody2D:
for child in held_object.get_children():
if child is CollisionShape2D:
held_collision_shape = child
break
if held_collision_shape and held_collision_shape.shape:
# Use shape query to test collision
var query = PhysicsShapeQueryParameters2D.new()
query.shape = held_collision_shape.shape
# Account for collision shape offset
var shape_offset = held_collision_shape.position if held_collision_shape else Vector2.ZERO
query.transform = Transform2D(0, target_pos + shape_offset)
query.collision_mask = 1 | 2 | 64 # Players, objects, walls
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = [held_object.get_rid(), get_rid()] # Exclude the object and the holder
var results = space_state.intersect_shape(query)
was_blocked = results.size() > 0
if was_blocked:
print("DEBUG: Wall collision detected in _update_pushed_object! Results: ", results.size(), " target_pos: ", target_pos)
else:
# Fallback: use point query
var query = PhysicsPointQueryParameters2D.new()
query.position = target_pos
query.collision_mask = 1 | 2 | 64 # Players, objects, walls
query.collide_with_areas = false
query.collide_with_bodies = true
if held_object is CharacterBody2D:
query.exclude = [held_object.get_rid(), get_rid()]
var results = space_state.intersect_point(query)
was_blocked = results.size() > 0
# Update the flag for next frame's input handling
object_blocked_by_wall = was_blocked
# If we would hit a wall, don't move the object
if was_blocked:
# Would hit a wall - keep object at current position
# Don't update position at all
pass
else:
# No collision - move to target position
if is_local_player and is_multiplayer_authority():
held_object.global_position = target_pos
else:
held_object.global_position = held_object.global_position.lerp(target_pos, 0.5)
# 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])
# Send RPCs only to peers who are ready to receive them
func _rpc_to_ready_peers(method: String, args: Array = []):
if not multiplayer.has_multiplayer_peer():
return
var game_world = get_tree().get_first_node_in_group("game_world")
# Server can use the ready-peer helper for safe fanout
if multiplayer.is_server() and game_world and game_world.has_method("_rpc_node_to_ready_peers"):
game_world._rpc_node_to_ready_peers(self, method, args)
return
# Clients: only send to peers marked ready by server
if game_world and "clients_ready" in game_world:
# Get peers list once to avoid multiple calls
var peers = multiplayer.get_peers()
for target_peer_id in peers:
# Final check: verify peer is still in get_peers() right before sending
var current_peers = multiplayer.get_peers()
if target_peer_id not in current_peers:
continue
# Always allow sending to server (peer 1), but check connection first
if target_peer_id == 1:
# For WebRTC, verify connection before sending to server
if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
if not _is_peer_connected_for_rpc(target_peer_id):
continue
callv("rpc_id", [target_peer_id, method] + args)
continue
# Check if peer is ready and connected
if game_world.clients_ready.has(target_peer_id) and game_world.clients_ready[target_peer_id]:
# For WebRTC, verify connection before sending
if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
if not _is_peer_connected_for_rpc(target_peer_id):
continue
callv("rpc_id", [target_peer_id, method] + args)
else:
# Fallback: send to all peers (but still check connections for WebRTC)
if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
var peers = multiplayer.get_peers()
for target_peer_id in peers:
if _is_peer_connected_for_rpc(target_peer_id):
callv("rpc_id", [target_peer_id, method] + args)
else:
callv("rpc", [method] + args)
func _is_peer_connected_for_rpc(target_peer_id: int) -> bool:
"""Check if a peer is still connected and has open data channels before sending RPC"""
if not multiplayer.has_multiplayer_peer():
return false
# Check if peer is in get_peers()
var peers = multiplayer.get_peers()
if target_peer_id not in peers:
return false
# For WebRTC, check if data channels are actually open
if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
if not webrtc_peer.has_peer(target_peer_id):
return false
var peer_info = webrtc_peer.get_peer(target_peer_id)
if not peer_info:
return false
# Check if data channels are connected (this is the critical check)
var is_net_connected = peer_info.get("connected", false)
if not is_net_connected:
return false
# Also check connection state to be extra safe
var connection_obj = peer_info.get("connection")
if connection_obj != null and typeof(connection_obj) == TYPE_INT:
var connection_val = int(connection_obj)
# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED
return false
# Also verify channels array to ensure channels are actually open
# The "connected" flag might not update immediately when channels close
var channels = peer_info.get("channels", [])
if channels is Array and channels.size() > 0:
for channel in channels:
if channel != null:
# Check if channel has ready_state method
if channel.has_method("get_ready_state"):
var ready_state = channel.get_ready_state()
# WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
if ready_state != 1: # Not OPEN
return false
elif "ready_state" in channel:
var ready_state = channel.get("ready_state")
if ready_state != 1: # Not OPEN
return false
# Also check matchbox_client connection state for additional verification
var network_manager = get_node_or_null("/root/NetworkManager")
if network_manager and network_manager.matchbox_client:
var matchbox = network_manager.matchbox_client
if "peer_connections" in matchbox:
var pc = matchbox.peer_connections.get(target_peer_id)
if pc and pc.has_method("get_connection_state"):
var matchbox_conn_state = pc.get_connection_state()
# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED
return false
return true
# Network sync
@rpc("any_peer", "unreliable")
func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, airborne: bool = false, dir: int = 0, anim: String = "IDLE"):
# Check if node still exists and is valid before processing
if not is_inside_tree() or not is_instance_valid(self):
return
# Only update if we're not the authority (remote player)
if not is_multiplayer_authority():
position = pos
velocity = vel
position_z = z_pos
is_airborne = airborne
current_direction = dir as Direction
# Sync animation if different
if current_animation != anim:
_set_animation(anim)
# Update visual based on Z position (handled in _update_z_physics now)
# Update shadow based on Z position
if shadow and is_airborne:
var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 # Scaled down for smaller Z values
shadow.scale = Vector2.ONE * max(0.5, shadow_scale)
shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3
@rpc("any_peer", "reliable")
func _sync_attack(direction: int, attack_dir: Vector2):
# Sync attack to other clients
# Check if node still exists and is valid before processing
if not is_inside_tree() or not is_instance_valid(self):
return
if not is_multiplayer_authority():
current_direction = direction as Direction
# Determine weapon type for animation and projectile
var equipped_weapon = null
var is_staff = false
var is_bow = false
if character_stats and character_stats.equipment.has("mainhand"):
equipped_weapon = character_stats.equipment["mainhand"]
if equipped_weapon:
if equipped_weapon.weapon_type == Item.WeaponType.STAFF:
is_staff = true
elif equipped_weapon.weapon_type == Item.WeaponType.BOW:
is_bow = true
# Set appropriate animation
if is_staff:
_set_animation("STAFF")
elif is_bow:
_set_animation("BOW")
else:
_set_animation("SWORD")
# Delay before spawning projectile
await get_tree().create_timer(0.15).timeout
# Check again after delay - node might have been destroyed
if not is_inside_tree() or not is_instance_valid(self):
return
# Spawn appropriate projectile on client
if is_staff and staff_projectile_scene and equipped_weapon:
var projectile = staff_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_dir, self, 20.0, equipped_weapon)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_dir * 10.0 # 10 pixels in front
projectile.global_position = global_position + spawn_offset
print(name, " performed synced staff attack!")
elif is_bow:
# For bow attacks, check if we have arrows (same logic as host)
var arrows = null
if character_stats and character_stats.equipment.has("offhand"):
var offhand_item = character_stats.equipment["offhand"]
if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION:
arrows = offhand_item
# Only spawn arrow if we have arrows (matches host behavior)
if arrows and arrows.quantity > 0:
if attack_arrow_scene:
var arrow_projectile = attack_arrow_scene.instantiate()
get_parent().add_child(arrow_projectile)
arrow_projectile.shoot(attack_dir, global_position, self)
print(name, " performed synced bow attack with arrow!")
else:
# No arrows - just play animation, no projectile (matches host behavior)
print(name, " performed synced bow attack without arrows (no projectile)")
elif sword_projectile_scene:
var projectile = sword_projectile_scene.instantiate()
get_parent().add_child(projectile)
projectile.setup(attack_dir, self)
# Spawn projectile a bit in front of the player
var spawn_offset = attack_dir * 10.0 # 10 pixels in front
projectile.global_position = global_position + spawn_offset
print(name, " performed synced attack!")
@rpc("any_peer", "reliable")
func _sync_throw(obj_name: String, throw_pos: Vector2, force: Vector2, thrower_name: String):
# Sync throw to all clients (RPC sender already threw on their side)
# Check if node is still valid and in tree
if not is_inside_tree():
return
# Find object by name (consistent name like InteractableObject_X)
var obj = null
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.is_inside_tree():
return # GameWorld not ready yet
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
obj = entities_node.get_node_or_null(obj_name)
# Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup
if not obj and obj_name.begins_with("InteractableObject_") and entities_node:
var index_str = obj_name.substr(20) # Skip "InteractableObject_"
if index_str.is_valid_int():
var obj_index = index_str.to_int()
for child in entities_node.get_children():
if child.has_meta("object_index") and child.get_meta("object_index") == obj_index:
obj = child
break
var thrower = null
if entities_node:
thrower = entities_node.get_node_or_null(thrower_name)
print("_sync_throw received: ", obj_name, " found obj: ", obj != null, " thrower: ", thrower != null, " is_authority: ", is_multiplayer_authority())
if obj:
obj.global_position = throw_pos
var is_box = _is_box(obj)
var is_player = _is_player(obj)
print("Object type check - is_box: ", is_box, " is_player: ", is_player)
if is_box:
print("Syncing box throw on client! pos: ", throw_pos, " force: ", force)
# Reset held state and set thrower
if "is_being_held" in obj:
obj.is_being_held = false
if "held_by_player" in obj:
obj.held_by_player = null
if "thrown_by_player" in obj:
obj.thrown_by_player = thrower # Set who threw it
if "throw_velocity" in obj:
obj.throw_velocity = force / obj.weight
if "is_frozen" in obj:
obj.is_frozen = false
# Make box airborne with same arc as players
if "is_airborne" in obj:
obj.is_airborne = true
obj.position_z = 2.5
obj.velocity_z = 100.0 # Scaled down for 1x scale
print("Box is now airborne on client!")
# ⚡ Delay collision re-enable to prevent self-collision on clients
await get_tree().create_timer(0.1).timeout
if obj and is_instance_valid(obj):
obj.set_collision_layer_value(2, true)
obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true)
obj.set_collision_mask_value(7, true)
elif is_player:
print("Syncing player throw on client! pos: ", throw_pos, " force: ", force)
# Player: set physics first
obj.velocity = force * 0.8
if "is_airborne" in obj:
obj.is_airborne = true
obj.position_z = 2.5
obj.velocity_z = 100.0 # Scaled down for 1x scale
if obj.has_method("set_being_held"):
obj.set_being_held(false)
print("Player is now airborne on client!")
# ⚡ Delay collision re-enable to prevent self-collision on clients
await get_tree().create_timer(0.1).timeout
if obj and is_instance_valid(obj):
obj.set_collision_layer_value(1, true)
obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(7, true)
@rpc("any_peer", "reliable")
func _sync_initial_grab(obj_name: String, _offset: Vector2):
# Sync initial grab to other clients
# Check if node is still valid and in tree
if not is_inside_tree():
return
if not is_multiplayer_authority():
# Find object by name (consistent name like InteractableObject_X)
var obj = null
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.is_inside_tree():
return # GameWorld not ready yet
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
obj = entities_node.get_node_or_null(obj_name)
# Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup
if not obj and obj_name.begins_with("InteractableObject_"):
var index_str = obj_name.substr(20) # Skip "InteractableObject_"
if index_str.is_valid_int():
var obj_index = index_str.to_int()
for child in entities_node.get_children():
if child.has_meta("object_index") and child.get_meta("object_index") == obj_index:
obj = child
break
if obj:
# Disable collision for grabbed object
if _is_box(obj):
obj.set_collision_layer_value(2, false)
obj.set_collision_mask_value(1, false)
obj.set_collision_mask_value(2, false)
elif _is_player(obj):
obj.set_collision_layer_value(1, false)
obj.set_collision_mask_value(1, false)
print("Synced initial grab on client: ", obj_name)
@rpc("any_peer", "reliable")
func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO):
# Sync lift/push state to other clients
# Check if node is still valid and in tree
if not is_inside_tree():
return
if not is_multiplayer_authority():
# Find object by name (consistent name like InteractableObject_X)
var obj = null
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.is_inside_tree():
return # GameWorld not ready yet
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
obj = entities_node.get_node_or_null(obj_name)
# Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup
if not obj and obj_name.begins_with("InteractableObject_"):
var index_str = obj_name.substr(20) # Skip "InteractableObject_"
if index_str.is_valid_int():
var obj_index = index_str.to_int()
for child in entities_node.get_children():
if child.has_meta("object_index") and child.get_meta("object_index") == obj_index:
obj = child
break
if obj:
if is_lift:
# Lifting - completely disable collision
if _is_box(obj):
obj.set_collision_layer_value(2, false)
obj.set_collision_mask_value(1, false)
obj.set_collision_mask_value(2, false)
# Set box state
if "is_frozen" in obj:
obj.is_frozen = true
if "is_being_held" in obj:
obj.is_being_held = true
if "throw_velocity" in obj:
obj.throw_velocity = Vector2.ZERO
elif _is_player(obj):
obj.set_collision_layer_value(1, false)
obj.set_collision_mask_value(1, false)
if obj.has_method("set_being_held"):
obj.set_being_held(true)
else:
# Pushing - keep collision disabled but unfreeze
if _is_box(obj):
obj.set_collision_layer_value(2, false)
obj.set_collision_mask_value(1, false)
obj.set_collision_mask_value(2, false)
if "is_frozen" in obj:
obj.is_frozen = false
if "is_being_held" in obj:
obj.is_being_held = true
if "throw_velocity" in obj:
obj.throw_velocity = Vector2.ZERO
elif _is_player(obj):
obj.set_collision_layer_value(1, false)
obj.set_collision_mask_value(1, false)
if obj.has_method("set_being_held"):
obj.set_being_held(true)
print("Synced grab on client: lift=", is_lift, " axis=", axis)
@rpc("any_peer", "reliable")
func _sync_release(obj_name: String):
# Sync release to other clients
# Check if node is still valid and in tree
if not is_inside_tree():
return
if not is_multiplayer_authority():
# Find object by name (consistent name like InteractableObject_X)
var obj = null
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.is_inside_tree():
return # GameWorld not ready yet
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
obj = entities_node.get_node_or_null(obj_name)
# Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup
if not obj and obj_name.begins_with("InteractableObject_"):
var index_str = obj_name.substr(20) # Skip "InteractableObject_"
if index_str.is_valid_int():
var obj_index = index_str.to_int()
for child in entities_node.get_children():
if child.has_meta("object_index") and child.get_meta("object_index") == obj_index:
obj = child
break
if obj:
# Re-enable collision completely
if _is_box(obj):
obj.set_collision_layer_value(2, true)
obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true)
if "is_frozen" in obj:
obj.is_frozen = false
# CRITICAL: Clear is_being_held so object can be grabbed again and switches can detect it
if "is_being_held" in obj:
obj.is_being_held = false
if "held_by_player" in obj:
obj.held_by_player = null
elif _is_player(obj):
obj.set_collision_layer_value(1, true)
obj.set_collision_mask_value(1, true)
if obj.has_method("set_being_held"):
obj.set_being_held(false)
@rpc("any_peer", "reliable")
func _sync_place_down(obj_name: String, place_pos: Vector2):
# Sync placing down to other clients
# Check if node is still valid and in tree
if not is_inside_tree():
return
if not is_multiplayer_authority():
# Find object by name (consistent name like InteractableObject_X)
var obj = null
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.is_inside_tree():
return # GameWorld not ready yet
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
obj = entities_node.get_node_or_null(obj_name)
# Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup
if not obj and obj_name.begins_with("InteractableObject_"):
var index_str = obj_name.substr(20) # Skip "InteractableObject_"
if index_str.is_valid_int():
var obj_index = index_str.to_int()
for child in entities_node.get_children():
if child.has_meta("object_index") and child.get_meta("object_index") == obj_index:
obj = child
break
if obj:
obj.global_position = place_pos
# Re-enable collision completely
if _is_box(obj):
obj.set_collision_layer_value(2, true)
obj.set_collision_mask_value(1, true)
obj.set_collision_mask_value(2, true)
# Reset all state
if "throw_velocity" in obj:
obj.throw_velocity = Vector2.ZERO
if "is_frozen" in obj:
obj.is_frozen = false
if "is_being_held" in obj:
obj.is_being_held = false
if "held_by_player" in obj:
obj.held_by_player = null
if "is_airborne" in obj:
obj.is_airborne = false
if "position_z" in obj:
obj.position_z = 0.0
if "velocity_z" in obj:
obj.velocity_z = 0.0
elif _is_player(obj):
obj.set_collision_layer_value(1, true)
obj.set_collision_mask_value(1, true)
obj.velocity = Vector2.ZERO
if obj.has_method("set_being_held"):
obj.set_being_held(false)
@rpc("any_peer", "reliable")
func _sync_teleport_position(new_pos: Vector2):
# Sync teleport position from server to clients
# Server calls this to teleport any player (even if client has authority over their own player)
# Only update if we're not on the server (server already set position directly)
if not multiplayer.is_server():
global_position = new_pos
# Reset velocity to prevent player from moving back to old position
velocity = Vector2.ZERO
# Set flag to prevent position sync from overriding teleportation this frame
teleported_this_frame = true
print(name, " teleported to ", new_pos, " (synced from server, is_authority: ", is_multiplayer_authority(), ")")
@rpc("any_peer", "unreliable")
func _sync_held_object_pos(obj_name: String, pos: Vector2):
# Sync held object position to other clients
# Check if node is still valid and in tree
if not is_inside_tree():
return
if not is_multiplayer_authority():
# Find object by name (consistent name like InteractableObject_X)
var obj = null
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or not game_world.is_inside_tree():
return # GameWorld not ready yet
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
obj = entities_node.get_node_or_null(obj_name)
# Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup
if not obj and obj_name.begins_with("InteractableObject_"):
var index_str = obj_name.substr(20) # Skip "InteractableObject_"
if index_str.is_valid_int():
var obj_index = index_str.to_int()
for child in entities_node.get_children():
if child.has_meta("object_index") and child.get_meta("object_index") == obj_index:
obj = child
break
if obj:
# Don't update position if object is airborne (being thrown)
if "is_airborne" in obj and obj.is_airborne:
return
# Don't update position if object is not being held
if "is_being_held" in obj and not obj.is_being_held:
return
obj.global_position = pos
func can_be_grabbed() -> bool:
return true
func _handle_struggle(delta):
# Player is being held - check for struggle input
var input_dir = Vector2.ZERO
if input_device == -1: # Keyboard
input_dir.x = clamp(
Input.get_axis("move_left", "move_right")
+ Input.get_axis("ui_left", "ui_right"),
-1.0,
1.0
)
input_dir.y = clamp(
Input.get_axis("move_up", "move_down")
+ Input.get_axis("ui_up", "ui_down"),
-1.0,
1.0
)
else: # Gamepad
input_dir.x = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_X)
input_dir.y = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_Y)
# Check if player is trying to move
if input_dir.length() > 0.3:
# Player is struggling!
struggle_time += delta
struggle_direction = input_dir.normalized()
# Visual feedback - shake body sprite
if sprite_body:
sprite_body.position.x = randf_range(-2, 2)
# Break free after threshold
if struggle_time >= struggle_threshold:
print(name, " broke free!")
_break_free_from_holder()
else:
# Not struggling - reset timer
struggle_time = 0.0
struggle_direction = Vector2.ZERO
if sprite_body:
sprite_body.position.x = 0
func _break_free_from_holder():
if being_held_by and is_instance_valid(being_held_by):
# Force the holder to place us down in struggle direction
if being_held_by.has_method("_force_place_down"):
being_held_by._force_place_down(struggle_direction)
# Sync break free over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_break_free", [being_held_by.name, struggle_direction])
struggle_time = 0.0
struggle_direction = Vector2.ZERO
being_held_by = null
@rpc("any_peer", "reliable")
func _sync_break_free(holder_name: String, direction: Vector2):
var holder = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
holder = entities_node.get_node_or_null(holder_name)
if holder and holder.has_method("_force_place_down"):
holder._force_place_down(direction)
func _force_place_down(direction: Vector2):
# Forced to place down held object in specified direction
if held_object and is_lifting:
var place_pos = _find_closest_place_pos(direction, held_object)
var placed_obj = held_object
if not _can_place_down_at(place_pos, placed_obj):
print("DEBUG: Forced place down blocked - space not free")
return
# Clear state
held_object = null
grab_offset = Vector2.ZERO
is_lifting = false
is_pushing = false
# Re-enable collision and physics
placed_obj.global_position = place_pos
if _is_box(placed_obj):
placed_obj.set_collision_layer_value(2, true)
placed_obj.set_collision_mask_value(1, true)
placed_obj.set_collision_mask_value(2, true)
if "throw_velocity" in placed_obj:
placed_obj.throw_velocity = Vector2.ZERO
if "is_frozen" in placed_obj:
placed_obj.is_frozen = false
if "is_being_held" in placed_obj:
placed_obj.is_being_held = false
if "held_by_player" in placed_obj:
placed_obj.held_by_player = null
if "is_airborne" in placed_obj:
placed_obj.is_airborne = false
if "position_z" in placed_obj:
placed_obj.position_z = 0.0
if "velocity_z" in placed_obj:
placed_obj.velocity_z = 0.0
elif _is_player(placed_obj):
placed_obj.set_collision_layer_value(1, true)
placed_obj.set_collision_mask_value(1, true)
placed_obj.velocity = Vector2.ZERO
if placed_obj.has_method("set_being_held"):
placed_obj.set_being_held(false)
if placed_obj.has_method("on_released"):
placed_obj.on_released(self)
print("Forced to place down ", placed_obj.name)
func set_being_held(held: bool):
# When being held by another player, disable movement
# But keep physics_process running for network sync
if held:
# Just prevent input handling, don't disable physics
velocity = Vector2.ZERO
is_airborne = false
position_z = 0.0
velocity_z = 0.0
else:
# Released - reset struggle state
struggle_time = 0.0
struggle_direction = Vector2.ZERO
being_held_by = null
# RPC function called by attacker to deal damage to this player
@rpc("any_peer", "reliable")
func rpc_take_damage(amount: float, attacker_position: Vector2):
# Only apply damage on the victim's own client (where they're authority)
if is_multiplayer_authority():
take_damage(amount, attacker_position)
func take_damage(amount: float, attacker_position: Vector2):
# Don't take damage if already dead
if is_dead:
return
# Check for dodge chance (based on DEX)
var _was_dodged = false
if character_stats:
var dodge_roll = randf()
var dodge_chance = character_stats.dodge_chance
if dodge_roll < dodge_chance:
_was_dodged = true
print(name, " DODGED the attack! (DEX: ", character_stats.baseStats.dex + character_stats.get_pass("dex"), ", dodge chance: ", dodge_chance * 100.0, "%)")
# Show "DODGED" text
_show_damage_number(0.0, attacker_position, false, false, true) # is_dodged = true
# Sync dodge visual to other clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, true]) # is_dodged = true
return # No damage taken, exit early
# If taking damage while holding something, drop/throw immediately
if held_object:
if is_lifting:
var throw_dir = (global_position - attacker_position).normalized()
_force_throw_held_object(throw_dir)
else:
_stop_pushing()
# If not dodged, apply damage with DEF reduction
var actual_damage = amount
if character_stats:
# Calculate damage after DEF reduction (critical hits pierce 80% of DEF)
actual_damage = character_stats.calculate_damage(amount, false, false) # false = not magical, false = not critical (enemy attacks don't crit yet)
# Apply the reduced damage using take_damage (which handles health modification and signals)
var _old_hp = character_stats.hp
character_stats.modify_health(-actual_damage)
if character_stats.hp <= 0:
character_stats.no_health.emit()
character_stats.character_changed.emit(character_stats)
print(name, " took ", actual_damage, " damage (", amount, " base - ", character_stats.defense, " DEF = ", actual_damage, ")! Health: ", character_stats.hp, "/", character_stats.maxhp)
else:
# Fallback for legacy
current_health -= amount
actual_damage = amount
print(name, " took ", amount, " damage! Health: ", current_health)
# Play damage sound effect (rate limited to prevent spam when tab becomes active)
var game_world = get_tree().get_first_node_in_group("game_world")
if sfx_take_damage:
if game_world and game_world.has_method("can_play_sound"):
if game_world.can_play_sound("player_damage_" + str(get_instance_id())):
sfx_take_damage.play()
else:
sfx_take_damage.play()
# Play damage animation
_set_animation("DAMAGE")
# Calculate direction FROM attacker TO victim
var direction_from_attacker = (global_position - attacker_position).normalized()
# Knockback - push player away from attacker
velocity = direction_from_attacker * 250.0 # Scaled down for 1x scale
# Face the attacker (opposite of knockback direction)
current_direction = _get_direction_from_vector(-direction_from_attacker) as Direction
# Enable knockback state (prevents player control for a short time)
is_knocked_back = true
knockback_time = 0.0
# Flash red on body sprite
if sprite_body:
var tween = create_tween()
tween.tween_property(sprite_body, "modulate", Color.RED, 0.1)
tween.tween_property(sprite_body, "modulate", Color.WHITE, 0.1)
# Show damage number (red, using dmg_numbers.png font)
_show_damage_number(actual_damage, attacker_position)
# Sync damage visual effects to other clients (including damage numbers)
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_damage", [actual_damage, attacker_position])
# Check if dead - but wait for damage animation to play first
var health = character_stats.hp if character_stats else current_health
if health <= 0:
if character_stats:
character_stats.hp = 0 # Clamp to 0
else:
current_health = 0 # Clamp to 0
is_dead = true # Set flag immediately to prevent more damage
# Wait a bit for damage animation and knockback to show
await get_tree().create_timer(0.3).timeout
_die()
func _die():
# Already processing death - prevent multiple concurrent death sequences
if is_processing_death:
print(name, " already processing death, ignoring duplicate call")
return
is_processing_death = true # Set IMMEDIATELY to block duplicates
is_dead = true # Ensure flag is set
velocity = Vector2.ZERO
is_knocked_back = false
# CRITICAL: Release any held object/player BEFORE dying to restore their collision layers
if held_object:
var released_obj = held_object
held_object = null
is_lifting = false
is_pushing = false
grab_offset = Vector2.ZERO
push_axis = Vector2.ZERO
# Re-enable collision for released object/player
if _is_box(released_obj):
released_obj.set_collision_layer_value(2, true)
released_obj.set_collision_mask_value(1, true)
released_obj.set_collision_mask_value(2, true)
if "is_being_held" in released_obj:
released_obj.is_being_held = false
if "held_by_player" in released_obj:
released_obj.held_by_player = null
elif _is_player(released_obj):
released_obj.set_collision_layer_value(1, true)
released_obj.set_collision_mask_value(1, true)
if released_obj.has_method("set_being_held"):
released_obj.set_being_held(false)
# Sync release 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(released_obj)
_rpc_to_ready_peers("_sync_release", [obj_name])
print(name, " released ", released_obj.name, " on death")
else:
is_lifting = false
is_pushing = false
print(name, " died!")
# Play death sound effect
if sfx_die:
for i in 12:
var angle = randf_range(0, TAU)
var speed = randf_range(50, 100)
var initial_velocityZ = randf_range(50, 90)
var b = blood_scene.instantiate() as CharacterBody2D
b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2))
b.global_position = global_position
# Set initial velocities from the synchronized data
var direction = Vector2.from_angle(angle)
b.velocity = direction * speed
b.velocityZ = initial_velocityZ
get_parent().call_deferred("add_child", b)
sfx_die.play()
# Play DIE animation
_set_animation("DIE")
# Sync death over network (only authority sends)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_death", [])
# Wait for DIE animation to complete (200+200+200+800 = 1400ms = 1.4s)
await get_tree().create_timer(1.4).timeout
# Fade out over 0.5 seconds (fade all sprite layers)
var fade_tween = create_tween()
fade_tween.set_parallel(true)
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon, shadow]:
if sprite_layer:
fade_tween.tween_property(sprite_layer, "modulate:a", 0.0, 0.5)
# Wait for fade to finish
await fade_tween.finished
# Force holder to drop us NOW (after fade, before respawn)
# Search for any player holding us (don't rely on being_held_by)
print(name, " searching for anyone holding us...")
var found_holder = false
for other_player in get_tree().get_nodes_in_group("player"):
if other_player != self and other_player.held_object == self:
print(name, " FOUND holder: ", other_player.name, "! Clearing locally and syncing via RPC")
# Clear LOCALLY first
other_player.held_object = null
other_player.is_lifting = false
other_player.is_pushing = false
other_player.grab_offset = Vector2.ZERO
other_player.push_axis = Vector2.ZERO
# Re-enable our collision
set_collision_layer_value(1, true)
set_collision_mask_value(1, true)
# THEN sync to other clients
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_force_holder_to_drop", [other_player.name])
found_holder = true
break
if not found_holder:
print(name, " is NOT being held by anyone")
being_held_by = null
# Wait 0.5 seconds after fade before respawning
await get_tree().create_timer(0.5).timeout
# Respawn (this will reset is_processing_death)
_respawn()
func _respawn():
print(name, " respawning!")
# being_held_by already cleared in _die() before this
# Holder already dropped us 0.2 seconds ago
# Re-enable collision in case it was disabled while being carried
set_collision_layer_value(1, true)
set_collision_mask_value(1, true)
# Reset health and state
if character_stats:
character_stats.hp = character_stats.maxhp
else:
current_health = max_health
is_dead = false
is_processing_death = false # Reset processing flag
velocity = Vector2.ZERO
is_knocked_back = false
is_airborne = false
position_z = 0.0
velocity_z = 0.0
# Restore visibility (fade all sprite layers back in)
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon, shadow]:
if sprite_layer:
sprite_layer.modulate.a = 1.0
# Get respawn position - use spawn room (start room) for respawning
var new_respawn_pos = respawn_point
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world:
push_error(name, " respawn: Could not find game_world!")
return
if not game_world.dungeon_data.has("start_room"):
push_error(name, " respawn: No start_room in dungeon_data!")
return
# Update spawn points to use the start room (spawn room)
var start_room = game_world.dungeon_data.start_room
if start_room.is_empty():
push_error(name, " respawn: start_room is empty!")
return
game_world._update_spawn_points(start_room, true) # Clear existing and use start room
# Get a free spawn point from player manager
var player_manager = game_world.get_node_or_null("PlayerManager")
if not player_manager:
push_error(name, " respawn: Could not find PlayerManager!")
return
if player_manager.spawn_points.size() == 0:
push_error(name, " respawn: No spawn points available!")
# Fallback: Calculate center of start room
var tile_size = 16
var room_center_x = (start_room.x + start_room.w / 2.0) * tile_size
var room_center_y = (start_room.y + start_room.h / 2.0) * tile_size
new_respawn_pos = Vector2(room_center_x, room_center_y)
print(name, " respawn: Using fallback center position: ", new_respawn_pos)
else:
# Find a free spawn point
var found_free = false
for spawn_pos in player_manager.spawn_points:
# Validate spawn position is within reasonable bounds
if spawn_pos.x < 0 or spawn_pos.y < 0 or spawn_pos.x > 2000 or spawn_pos.y > 2000:
continue # Skip invalid positions
var is_free = true
# Check if any player is too close
for player in player_manager.get_all_players():
if player != self and spawn_pos.distance_to(player.position) < 32:
is_free = false
break
if is_free:
new_respawn_pos = spawn_pos
found_free = true
break
# If no free spawn point, use a random one
if not found_free:
var random_spawn = player_manager.spawn_points[randi() % player_manager.spawn_points.size()]
# Validate the random spawn position
if random_spawn.x >= 0 and random_spawn.y >= 0 and random_spawn.x < 2000 and random_spawn.y < 2000:
new_respawn_pos = random_spawn
else:
# Last resort: use center of start room
var tile_size = 16
var room_center_x = (start_room.x + start_room.w / 2.0) * tile_size
var room_center_y = (start_room.y + start_room.h / 2.0) * tile_size
new_respawn_pos = Vector2(room_center_x, room_center_y)
print(name, " respawning at spawn room position: ", new_respawn_pos)
# Teleport to respawn point (AFTER release is processed)
global_position = new_respawn_pos
respawn_point = new_respawn_pos # Update respawn point for next time
# Play idle animation
_set_animation("IDLE")
# Sync respawn over network (only authority sends)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
_rpc_to_ready_peers("_sync_respawn", [new_respawn_pos])
@rpc("any_peer", "reliable")
func _force_holder_to_drop(holder_name: String):
# Force a specific player to drop what they're holding
_force_holder_to_drop_local(holder_name)
func _force_holder_to_drop_local(holder_name: String):
# Local function to clear holder's held object
print("_force_holder_to_drop_local called for holder: ", holder_name)
var holder = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
holder = entities_node.get_node_or_null(holder_name)
if holder and is_instance_valid(holder):
print(" Found holder: ", holder.name, ", their held_object: ", holder.held_object)
if holder.held_object == self:
print(" ✓ DROPPING! Clearing ", holder.name, "'s held_object (dropping ", name, ")")
holder.held_object = null
holder.is_lifting = false
holder.is_pushing = false
holder.grab_offset = Vector2.ZERO
holder.push_axis = Vector2.ZERO
# Re-enable collision on dropped player
set_collision_layer_value(1, true)
set_collision_mask_value(1, true)
else:
print(" ✗ held_object doesn't match self")
else:
print(" ✗ Holder not found or invalid")
@rpc("any_peer", "reliable")
func _sync_death():
if not is_multiplayer_authority():
_die()
@rpc("any_peer", "reliable")
func _sync_respawn(spawn_pos: Vector2):
if not is_multiplayer_authority():
# being_held_by already cleared via RPC in _die()
# Holder already dropped us via _force_holder_to_drop RPC
# Re-enable collision in case it was disabled while being carried
set_collision_layer_value(1, true)
set_collision_mask_value(1, true)
# Just teleport and reset on clients (AFTER release is processed)
global_position = spawn_pos
current_health = max_health
is_dead = false
is_processing_death = false # Reset processing flag
# Restore visibility
for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair,
sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons,
sprite_headgear, sprite_weapon, shadow]:
if sprite_layer:
sprite_layer.modulate.a = 1.0
_set_animation("IDLE")
func add_coins(amount: int):
if character_stats:
character_stats.add_coin(amount)
print(name, " picked up ", amount, " coin(s)! Total coins: ", character_stats.coin)
# Sync coins to client if this is server-side coin collection
# (e.g., when loot is collected on server, sync to client)
if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and can_send_rpcs and is_inside_tree():
# Server is adding coins to a player - sync to the client if it's a client player
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():
print(name, " syncing stats to client peer_id=", the_peer_id, " kills=", character_stats.kills, " coins=", character_stats.coin)
_sync_stats_update.rpc_id(the_peer_id, character_stats.kills, character_stats.coin)
else:
coins += amount
print(name, " picked up ", amount, " coin(s)! Total coins: ", coins)
@rpc("any_peer", "reliable")
func _sync_stats_update(kills_count: int, coins_count: int):
# Client receives stats update from server (for kills and coins)
# Update local stats to match server
# Only process on client (not on server where the update originated)
if multiplayer.is_server():
return # Server ignores this (it's the sender)
if character_stats:
character_stats.kills = kills_count
character_stats.coin = coins_count
print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count)
@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
# 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
# Update equipment from data
for slot_name in equipment_data.keys():
var item_data = equipment_data[slot_name]
if item_data != null:
character_stats.equipment[slot_name] = Item.new(item_data)
else:
character_stats.equipment[slot_name] = null
# Update appearance
_apply_appearance_to_sprites()
print(name, " equipment synced: ", equipment_data.size(), " slots")
@rpc("any_peer", "reliable")
func _sync_inventory(inventory_data: Array):
# Client receives inventory update from server
# Update inventory to match server's inventory
# Unlike _sync_equipment, we WANT to receive our own inventory from the server
# So we check if we're the server (sender) and ignore, not if we're the authority
if multiplayer.is_server():
return # Server ignores this (it's the sender)
if not character_stats:
return
# Clear and rebuild inventory from server data
character_stats.inventory.clear()
for item_data in inventory_data:
if item_data != null:
character_stats.inventory.append(Item.new(item_data))
# Emit character_changed to update UI
character_stats.character_changed.emit(character_stats)
print(name, " inventory synced from server: ", character_stats.inventory.size(), " items")
func heal(amount: float):
if is_dead:
return
if character_stats:
character_stats.heal(amount)
print(name, " healed for ", amount, " HP! Health: ", character_stats.hp, "/", character_stats.maxhp)
else:
# Fallback for legacy
current_health = min(current_health + amount, max_health)
print(name, " healed for ", amount, " HP! Health: ", current_health, "/", max_health)
func add_key(amount: int = 1):
keys += amount
print(name, " picked up ", amount, " key(s)! Total keys: ", keys)
# Sync key count to owning client (server authoritative)
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var owner_peer_id = get_multiplayer_authority()
if owner_peer_id != 0 and owner_peer_id != multiplayer.get_unique_id():
_sync_keys.rpc_id(owner_peer_id, keys)
func has_key() -> bool:
return keys > 0
func use_key():
if keys > 0:
keys -= 1
print(_get_log_prefix(), name, " used a key! Remaining keys: ", keys)
return true
return false
@rpc("any_peer", "reliable")
func _sync_keys(new_key_count: int):
# Sync key count to client
if not is_inside_tree():
return
keys = new_key_count
@rpc("authority", "reliable")
func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false):
# Show damage number (red, using dmg_numbers.png font) above player
# Show even if amount is 0 for MISS/DODGED
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
# Set text and color based on type
if is_dodged:
damage_label.label = "DODGED"
damage_label.color = Color.CYAN
elif is_miss:
damage_label.label = "MISS"
damage_label.color = Color.GRAY
else:
damage_label.label = str(int(amount))
damage_label.color = Color.ORANGE if is_crit else Color.RED
# Calculate direction from attacker (slight upward variation)
var direction_from_attacker = (global_position - from_position).normalized()
# Add slight upward bias
direction_from_attacker = direction_from_attacker.lerp(Vector2(0, -1), 0.5).normalized()
damage_label.direction = direction_from_attacker
# Position above player's head
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
entities_node.add_child(damage_label)
damage_label.global_position = global_position + Vector2(0, -16) # Above player head
else:
get_tree().current_scene.add_child(damage_label)
damage_label.global_position = global_position + Vector2(0, -16)
else:
get_tree().current_scene.add_child(damage_label)
damage_label.global_position = global_position + Vector2(0, -16)
func _on_level_up_stats(stats_increased: Array):
# Show floating text for level up - "LEVEL UP!" and stat increases
# Use damage_number scene with damage_numbers font
if not character_stats:
return
# Stat name to display name mapping
var stat_display_names = {
"str": "STR",
"dex": "DEX",
"int": "INT",
"end": "END",
"wis": "WIS",
"lck": "LCK"
}
# Stat name to color mapping
var stat_colors = {
"str": Color.RED,
"dex": Color.GREEN,
"int": Color.BLUE,
"end": Color.WHITE,
"wis": Color(0.5, 0.0, 0.5), # Purple
"lck": Color.YELLOW
}
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
return
# Get entities node for adding text
var game_world = get_tree().get_first_node_in_group("game_world")
var entities_node = null
if game_world:
entities_node = game_world.get_node_or_null("Entities")
if not entities_node:
entities_node = get_tree().current_scene
var base_y_offset = -32.0 # Start above player head
var y_spacing = 12.0 # Space between each text
# Show "LEVEL UP!" first (in white)
var level_up_text = damage_number_scene.instantiate()
if level_up_text:
level_up_text.label = "LEVEL UP!"
level_up_text.color = Color.WHITE
level_up_text.direction = Vector2(0, -1) # Straight up
entities_node.add_child(level_up_text)
level_up_text.global_position = global_position + Vector2(0, base_y_offset)
base_y_offset -= y_spacing
# Show each stat increase
for i in range(stats_increased.size()):
var stat_name = stats_increased[i]
var stat_text = damage_number_scene.instantiate()
if stat_text:
var display_name = stat_display_names.get(stat_name, stat_name.to_upper())
stat_text.label = "+1 " + display_name
stat_text.color = stat_colors.get(stat_name, Color.WHITE)
stat_text.direction = Vector2(randf_range(-0.2, 0.2), -1.0).normalized() # Slight random spread
entities_node.add_child(stat_text)
stat_text.global_position = global_position + Vector2(0, base_y_offset)
base_y_offset -= y_spacing
@rpc("any_peer", "reliable")
func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false):
# This RPC only syncs visual effects, not damage application
# (damage is already applied via rpc_take_damage)
if not is_multiplayer_authority():
# If dodged, only show dodge text, no other effects
if is_dodged:
_show_damage_number(0.0, attacker_position, false, false, true)
return
# Play damage sound and effects (rate limited to prevent spam when tab becomes active)
var game_world = get_tree().get_first_node_in_group("game_world")
if sfx_take_damage:
if game_world and game_world.has_method("can_play_sound"):
if game_world.can_play_sound("player_damage_sync_" + str(get_instance_id())):
sfx_take_damage.play()
else:
sfx_take_damage.play()
# Play damage animation
_set_animation("DAMAGE")
# Calculate direction FROM attacker TO victim
var direction_from_attacker = (global_position - attacker_position).normalized()
# Knockback visual
velocity = direction_from_attacker * 250.0
# Face the attacker
current_direction = _get_direction_from_vector(-direction_from_attacker) as Direction
# Enable knockback state
is_knocked_back = true
knockback_time = 0.0
# Flash red on body sprite
if sprite_body:
var tween = create_tween()
tween.tween_property(sprite_body, "modulate", Color.RED, 0.1)
tween.tween_property(sprite_body, "modulate", Color.WHITE, 0.1)
# Show damage number
_show_damage_number(_amount, attacker_position, is_crit, is_miss, false)
func on_grabbed(by_player):
print(name, " grabbed by ", by_player.name)
func on_released(by_player):
print(name, " released by ", by_player.name)
func on_thrown(by_player, force: Vector2):
velocity = force
print(name, " thrown by ", by_player.name)