2546 lines
88 KiB
GDScript3
2546 lines
88 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 = 100.0
|
|
@export var grab_range: float = 20.0
|
|
@export var throw_force: float = 150.0
|
|
|
|
# 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
|
|
|
|
# Interaction
|
|
var held_object = null
|
|
var grab_offset = Vector2.ZERO
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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": [200, 200, 400],
|
|
"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()
|
|
|
|
# Set up player appearance (randomized based on stats)
|
|
_setup_player_appearance()
|
|
|
|
# Authority is set by player_manager after adding to scene
|
|
# Just log it here
|
|
print("Player ", name, " ready. Authority: ", get_multiplayer_authority(), " Is local: ", is_local_player)
|
|
|
|
# Hide interaction indicator by default
|
|
if interaction_indicator:
|
|
interaction_indicator.visible = false
|
|
|
|
# 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 _initialize_character_stats():
|
|
# Create deterministic RNG based on peer_id and local_index for sync across clients
|
|
appearance_rng = RandomNumberGenerator.new()
|
|
var seed_value = hash(str(peer_id) + "_" + str(local_player_index))
|
|
appearance_rng.seed = seed_value
|
|
print(name, " appearance/stats seed: ", seed_value, " (peer_id: ", peer_id, ", local_index: ", local_player_index, ")")
|
|
|
|
# Create character stats
|
|
character_stats = CharacterStats.new()
|
|
character_stats.character_type = "player"
|
|
character_stats.character_name = "Player_" + str(peer_id) + "_" + str(local_player_index)
|
|
|
|
# Randomize base stats (deterministic)
|
|
_randomize_stats()
|
|
|
|
# Initialize health/mana from stats
|
|
character_stats.hp = character_stats.maxhp
|
|
character_stats.mp = character_stats.maxmp
|
|
|
|
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)
|
|
|
|
print(name, " randomized 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)
|
|
|
|
func _setup_player_appearance():
|
|
# Randomize appearance - players spawn "bare" (naked, no equipment)
|
|
# But with randomized hair, facial hair, eyes, etc.
|
|
# Randomize skin (human only for players)
|
|
var skin_index = appearance_rng.randi_range(0, 6) # 0-6 for Human1-Human7
|
|
character_stats.setSkin(skin_index)
|
|
|
|
# Randomize hairstyle (0 = none, 1-12 = various styles)
|
|
var hair_style = appearance_rng.randi_range(0, 12)
|
|
character_stats.setHair(hair_style)
|
|
|
|
# Randomize hair color
|
|
var hair_colors = [
|
|
Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1), # 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
|
|
]
|
|
character_stats.setHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()])
|
|
|
|
# Randomize facial hair (0 = none, 1-3 = beard/mustache styles)
|
|
var facial_hair_style = appearance_rng.randi_range(0, 3)
|
|
character_stats.setFacialHair(facial_hair_style)
|
|
|
|
# Randomize facial hair color (usually matches hair)
|
|
if facial_hair_style > 0:
|
|
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 eyelashes (0 = none, 1-8 = various styles)
|
|
var eyelash_style = appearance_rng.randi_range(0, 8)
|
|
character_stats.setEyeLashes(eyelash_style)
|
|
|
|
# Randomize ears/addons (0 = none, 1-7 = elf ears)
|
|
var ear_style = appearance_rng.randi_range(0, 7)
|
|
if appearance_rng.randf() < 0.2: # 20% chance for elf ears
|
|
character_stats.setEars(ear_style)
|
|
else:
|
|
character_stats.setEars(0) # No ears
|
|
|
|
# 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 - empty (bare)
|
|
if sprite_boots:
|
|
sprite_boots.texture = null
|
|
|
|
# Armour - empty (bare)
|
|
if sprite_armour:
|
|
sprite_armour.texture = null
|
|
|
|
# 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
|
|
sprite_facial_hair.modulate = character_stats.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
|
|
sprite_hair.modulate = character_stats.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
|
|
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
|
|
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 - empty (bare)
|
|
if sprite_headgear:
|
|
sprite_headgear.texture = null
|
|
|
|
# Weapon - empty (bare)
|
|
if sprite_weapon:
|
|
sprite_weapon.texture = null
|
|
|
|
print(name, " appearance applied: skin=", character_stats.skin,
|
|
" hair=", character_stats.hairstyle,
|
|
" facial_hair=", character_stats.facial_hair,
|
|
" eyes=", character_stats.eyes)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
func _set_animation(anim_name: String):
|
|
if current_animation != anim_name:
|
|
current_animation = anim_name
|
|
current_frame = 0
|
|
time_since_last_frame = 0.0
|
|
|
|
# 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
|
|
if is_knocked_back:
|
|
knockback_time += delta
|
|
if knockback_time >= knockback_duration:
|
|
is_knocked_back = false
|
|
knockback_time = 0.0
|
|
|
|
# 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:
|
|
# Normal input handling
|
|
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)")
|
|
|
|
# On server, also wait a bit after setting all_clients_ready to ensure nodes are registered
|
|
if not multiplayer.is_server():
|
|
_sync_position.rpc(position, velocity, position_z, is_airborne, current_direction, current_animation)
|
|
elif all_clients_ready:
|
|
# Wait an additional 0.2 seconds after setting all_clients_ready before sending RPCs
|
|
var current_time = Time.get_ticks_msec() / 1000.0
|
|
var time_since_ready = current_time - all_clients_ready_time
|
|
if time_since_ready >= 0.2:
|
|
_sync_position.rpc(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
|
|
|
|
if input_device == -1:
|
|
# Keyboard input
|
|
input_vector.x = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
|
|
input_vector.y = Input.get_action_strength("move_down") - Input.get_action_strength("move_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)
|
|
if not is_pushing:
|
|
current_direction = _get_direction_from_vector(input_vector) as Direction
|
|
else:
|
|
# Keep locked direction when pushing
|
|
current_direction = push_direction_locked as Direction
|
|
|
|
# Set animation based on state
|
|
if is_lifting:
|
|
_set_animation("RUN_HOLD")
|
|
elif is_pushing:
|
|
_set_animation("RUN_PUSH")
|
|
elif current_animation != "SWORD":
|
|
_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
|
|
current_direction = push_direction_locked as Direction
|
|
else:
|
|
if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD":
|
|
_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
|
|
var current_speed = move_speed * (0.5 if is_pushing else 1.0)
|
|
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 input
|
|
grab_button_down = Input.is_action_pressed("grab")
|
|
grab_just_pressed = Input.is_action_just_pressed("grab")
|
|
grab_just_released = Input.is_action_just_released("grab")
|
|
|
|
# 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
|
|
attack_just_pressed = Input.is_action_just_pressed("attack")
|
|
else:
|
|
# Gamepad (X button)
|
|
attack_just_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X)
|
|
|
|
if attack_just_pressed and can_attack and not is_lifting and 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
|
|
|
|
# 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():
|
|
_sync_initial_grab.rpc(held_object.get_path(), grab_offset)
|
|
# Sync the grab state
|
|
_sync_grab.rpc(held_object.get_path(), 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():
|
|
_sync_grab.rpc(held_object.get_path(), 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():
|
|
_sync_grab.rpc(held_object.get_path(), 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():
|
|
_sync_release.rpc(released_obj.get_path())
|
|
|
|
# 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
|
|
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)
|
|
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():
|
|
_sync_throw.rpc(thrown_obj.get_path(), throw_start_pos, throw_direction * throw_force, get_path())
|
|
|
|
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_offset = last_movement_direction * 15 # Scaled down for 1x scale
|
|
var place_pos = global_position + place_offset
|
|
var placed_obj = held_object
|
|
|
|
# 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():
|
|
_sync_place_down.rpc(placed_obj.get_path(), 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
|
|
|
|
# Play attack animation
|
|
_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 sword slash
|
|
await get_tree().create_timer(0.15).timeout
|
|
|
|
# Spawn sword projectile
|
|
if sword_projectile_scene:
|
|
var projectile = sword_projectile_scene.instantiate()
|
|
get_parent().add_child(projectile)
|
|
projectile.setup(attack_direction, self)
|
|
# Spawn projectile a bit in front of the player
|
|
var spawn_offset = attack_direction * 10.0 # 10 pixels in front
|
|
projectile.global_position = global_position + spawn_offset
|
|
print(name, " attacked with sword projectile!")
|
|
|
|
# Sync attack over network
|
|
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
|
|
_sync_attack.rpc(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():
|
|
_sync_held_object_pos.rpc(held_object.get_path(), 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 = 64 # Layer 7 = walls (bit 6 = 64)
|
|
query.collide_with_areas = false
|
|
query.collide_with_bodies = true
|
|
query.exclude = [held_object.get_rid()] # Exclude the object itself
|
|
|
|
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 = 64 # Layer 7 = walls (bit 6 = 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)
|
|
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():
|
|
_sync_held_object_pos.rpc(held_object.get_path(), held_object.global_position)
|
|
|
|
# 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"):
|
|
# 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
|
|
if not is_multiplayer_authority():
|
|
current_direction = direction as Direction
|
|
_set_animation("SWORD")
|
|
|
|
# Delay before spawning sword slash
|
|
await get_tree().create_timer(0.15).timeout
|
|
|
|
# Spawn sword projectile on client
|
|
if 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_path: NodePath, throw_pos: Vector2, force: Vector2, thrower_path: NodePath):
|
|
# Sync throw to all clients (RPC sender already threw on their side)
|
|
var obj = get_node_or_null(obj_path)
|
|
var thrower = get_node_or_null(thrower_path)
|
|
print("_sync_throw received: ", obj_path, " 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)
|
|
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)
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _sync_initial_grab(obj_path: NodePath, _offset: Vector2):
|
|
# Sync initial grab to other clients
|
|
if not is_multiplayer_authority():
|
|
var obj = get_node_or_null(obj_path)
|
|
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_path)
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _sync_grab(obj_path: NodePath, is_lift: bool, axis: Vector2 = Vector2.ZERO):
|
|
# Sync lift/push state to other clients
|
|
if not is_multiplayer_authority():
|
|
var obj = get_node_or_null(obj_path)
|
|
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_path: NodePath):
|
|
# Sync release to other clients
|
|
if not is_multiplayer_authority():
|
|
var obj = get_node_or_null(obj_path)
|
|
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_path: NodePath, place_pos: Vector2):
|
|
# Sync placing down to other clients
|
|
if not is_multiplayer_authority():
|
|
var obj = get_node_or_null(obj_path)
|
|
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_path: NodePath, pos: Vector2):
|
|
# Sync held object position to other clients
|
|
if not is_multiplayer_authority():
|
|
var obj = get_node_or_null(obj_path)
|
|
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 = Input.get_axis("move_left", "move_right")
|
|
input_dir.y = Input.get_axis("move_up", "move_down")
|
|
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():
|
|
_sync_break_free.rpc(being_held_by.get_path(), struggle_direction)
|
|
|
|
struggle_time = 0.0
|
|
struggle_direction = Vector2.ZERO
|
|
being_held_by = null
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _sync_break_free(holder_path: NodePath, direction: Vector2):
|
|
var holder = get_node_or_null(holder_path)
|
|
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_offset = direction.normalized() * 20
|
|
if place_offset.length() < 0.1:
|
|
place_offset = last_movement_direction * 20
|
|
|
|
var place_pos = position + place_offset
|
|
var placed_obj = held_object
|
|
|
|
# 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
|
|
|
|
# Take damage using character_stats
|
|
if character_stats:
|
|
character_stats.take_damage(amount, false) # false = not magical damage
|
|
print(name, " took ", amount, " damage! Health: ", character_stats.hp, "/", character_stats.maxhp)
|
|
else:
|
|
# Fallback for legacy
|
|
current_health -= amount
|
|
print(name, " took ", amount, " damage! Health: ", current_health)
|
|
|
|
# Play damage sound effect
|
|
if sfx_take_damage:
|
|
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(amount, 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():
|
|
_sync_damage.rpc(amount, 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():
|
|
_sync_release.rpc(released_obj.get_path())
|
|
|
|
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:
|
|
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():
|
|
_sync_death.rpc()
|
|
|
|
# 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():
|
|
_force_holder_to_drop.rpc(other_player.get_path())
|
|
|
|
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():
|
|
_sync_respawn.rpc(new_respawn_pos)
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _force_holder_to_drop(holder_path: NodePath):
|
|
# Force a specific player to drop what they're holding
|
|
_force_holder_to_drop_local(holder_path)
|
|
|
|
func _force_holder_to_drop_local(holder_path: NodePath):
|
|
# Local function to clear holder's held object
|
|
print("_force_holder_to_drop_local called for holder path: ", holder_path)
|
|
var holder = get_node_or_null(holder_path)
|
|
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 not is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
|
|
# Server is adding coins to a client's player - sync to the client
|
|
var peer_id = get_multiplayer_authority()
|
|
if peer_id != 0:
|
|
_sync_stats_update.rpc_id(peer_id, character_stats.kills, character_stats.coin)
|
|
else:
|
|
coins += amount
|
|
print(name, " picked up ", amount, " coin(s)! Total coins: ", coins)
|
|
|
|
|
|
@rpc("authority", "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
|
|
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)
|
|
|
|
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)
|
|
|
|
func has_key() -> bool:
|
|
return keys > 0
|
|
|
|
func use_key():
|
|
if keys > 0:
|
|
keys -= 1
|
|
print(name, " used a key! Remaining keys: ", keys)
|
|
return true
|
|
return false
|
|
|
|
@rpc("authority", "reliable")
|
|
func _show_damage_number(amount: float, from_position: Vector2):
|
|
# Show damage number (red, using dmg_numbers.png font) above player
|
|
# Only show if damage > 0
|
|
if amount <= 0:
|
|
return
|
|
|
|
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 damage text and red color
|
|
damage_label.label = str(int(amount))
|
|
damage_label.color = 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)
|
|
|
|
@rpc("any_peer", "reliable")
|
|
func _sync_damage(_amount: float, attacker_position: Vector2):
|
|
# This RPC only syncs visual effects, not damage application
|
|
# (damage is already applied via rpc_take_damage)
|
|
if not is_multiplayer_authority():
|
|
# Play damage sound effect on clients
|
|
if sfx_take_damage:
|
|
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)
|
|
|
|
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)
|