replace with multiplayer-coop files

This commit is contained in:
2026-01-08 16:47:52 +01:00
parent 1725c615ce
commit 22c7025ac4
1230 changed files with 20555 additions and 17232 deletions

456
src/scripts/enemy_base.gd Normal file
View File

@@ -0,0 +1,456 @@
extends CharacterBody2D
# Base Enemy Class - All enemies inherit from this
@export var max_health: float = 50.0
@export var move_speed: float = 80.0
@export var damage: float = 10.0
@export var attack_cooldown: float = 1.0
var current_health: float = 50.0
var is_dead: bool = false
var target_player: Node = null
var attack_timer: float = 0.0
# Knockback
var is_knocked_back: bool = false
var knockback_time: float = 0.0
var knockback_duration: float = 0.3
var knockback_force: float = 125.0 # Scaled down for 1x scale
# Z-axis for flying enemies
var position_z: float = 0.0
var velocity_z: float = 0.0
# Animation
enum Direction {DOWN = 0, LEFT = 1, RIGHT = 2, UP = 3, DOWN_LEFT = 4, DOWN_RIGHT = 5, UP_LEFT = 6, UP_RIGHT = 7}
var current_direction: Direction = Direction.DOWN
var anim_frame: int = 0
var anim_time: float = 0.0
var anim_speed: float = 0.15 # Seconds per frame
@onready var sprite = get_node_or_null("Sprite2D")
@onready var shadow = get_node_or_null("Shadow")
@onready var collision_shape = get_node_or_null("CollisionShape2D")
func _ready():
current_health = max_health
add_to_group("enemy")
# Setup shadow (if it exists - some enemies like humanoids don't have a Shadow node)
if shadow:
shadow.modulate = Color(0, 0, 0, 0.5)
shadow.z_index = -1
# Top-down physics
motion_mode = MOTION_MODE_FLOATING
func _physics_process(delta):
if is_dead:
# Even when dead, allow knockback to continue briefly
if is_knocked_back:
knockback_time += delta
if knockback_time >= knockback_duration:
is_knocked_back = false
knockback_time = 0.0
velocity = Vector2.ZERO
else:
# Apply friction to slow down knockback
velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
move_and_slide()
return
# Only server (authority) runs AI and physics
if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
# Clients only interpolate position (handled by _sync_position)
return
# Update attack timer
if attack_timer > 0:
attack_timer -= delta
# Handle knockback
if is_knocked_back:
knockback_time += delta
if knockback_time >= knockback_duration:
is_knocked_back = false
knockback_time = 0.0
velocity = Vector2.ZERO
else:
# Apply friction to slow down knockback
velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
# Enemy AI (override in subclasses) - only if not knocked back
if not is_knocked_back:
_ai_behavior(delta)
# Move
move_and_slide()
# Check collisions with players
_check_player_collision()
# Sync position and animation to clients (only server sends)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
# Get state value if enemy has a state variable (for bats/slimes)
var state_val = -1
if "state" in self:
state_val = get("state") as int
# Only send RPC if we're in the scene tree
if is_inside_tree():
# Get enemy name/index for identification
var enemy_name = name
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
# Use game_world to send RPC instead of rpc() on node instance
# This avoids node path resolution issues when clients haven't spawned yet
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_enemy_position"):
# Send via game_world using enemy name/index and position for identification
game_world._sync_enemy_position.rpc(enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val)
else:
# Fallback: try direct rpc (may fail if node path doesn't match)
rpc("_sync_position", position, velocity, position_z, current_direction, anim_frame, "", 0, state_val)
func _ai_behavior(_delta):
# Override in subclasses
pass
func _check_player_collision():
# Check if touching a player to deal damage
for i in get_slide_collision_count():
var collision = get_slide_collision(i)
var collider = collision.get_collider()
if collider and collider.is_in_group("player"):
_attack_player(collider)
func _attack_player(player):
# Attack cooldown
if attack_timer > 0:
return
# Deal damage - send RPC to player's authority peer
if player.has_method("rpc_take_damage"):
var player_peer_id = player.get_multiplayer_authority()
if player_peer_id != 0:
# If target peer is the same as server (us), call directly
# rpc_id() might not execute locally when called to same peer
if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id():
# Call directly on the same peer
player.rpc_take_damage(damage, global_position)
else:
# Send RPC to remote peer
player.rpc_take_damage.rpc_id(player_peer_id, damage, global_position)
else:
# Fallback: broadcast if we can't get peer_id
player.rpc_take_damage.rpc(damage, global_position)
attack_timer = attack_cooldown
print(name, " attacked ", player.name, " (peer: ", player_peer_id, ", server: ", multiplayer.get_unique_id(), ")")
func _find_nearest_player() -> Node:
var players = get_tree().get_nodes_in_group("player")
var nearest = null
var nearest_dist = INF
for player in players:
if player and is_instance_valid(player) and not player.is_dead:
var dist = global_position.distance_to(player.global_position)
if dist < nearest_dist:
nearest_dist = dist
nearest = player
return nearest
func _find_nearest_player_in_range(max_range: float) -> Node:
# Find nearest player within specified range
var players = get_tree().get_nodes_in_group("player")
var nearest = null
var nearest_dist = INF
for player in players:
if player and is_instance_valid(player) and not player.is_dead:
var dist = global_position.distance_to(player.global_position)
if dist <= max_range and dist < nearest_dist:
nearest_dist = dist
nearest = player
return nearest
func take_damage(amount: float, from_position: Vector2):
# Only process damage on server/authority
if not is_multiplayer_authority():
return
if is_dead:
return
current_health -= amount
print(name, " took ", amount, " damage! Health: ", current_health)
# Calculate knockback direction (away from attacker)
var knockback_direction = (global_position - from_position).normalized()
velocity = knockback_direction * knockback_force
is_knocked_back = true
knockback_time = 0.0
_on_take_damage()
# Flash red (even if dying, show the hit)
_flash_damage()
# Sync damage visual to clients
# Use game_world to route damage visual sync instead of direct RPC to avoid node path issues
if multiplayer.has_multiplayer_peer() and is_inside_tree():
var enemy_name = name
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_enemy_damage_visual"):
game_world._sync_enemy_damage_visual.rpc(enemy_name, enemy_index)
else:
# Fallback: try direct RPC (may fail if node path doesn't match)
_sync_damage_visual.rpc()
if current_health <= 0:
# Delay death slightly so knockback is visible
call_deferred("_die")
@rpc("any_peer", "reliable")
func rpc_take_damage(amount: float, from_position: Vector2):
# RPC version - only process on server/authority
if is_multiplayer_authority():
take_damage(amount, from_position)
func _flash_damage():
# Flash red visual effect
if sprite:
var tween = create_tween()
tween.tween_property(sprite, "modulate", Color.RED, 0.1)
tween.tween_property(sprite, "modulate", Color.WHITE, 0.1)
func _update_client_visuals():
# Override in subclasses to update sprite frame and visuals based on synced state
# Base implementation just updates Z position
if sprite:
sprite.position.y = - position_z * 0.5
if shadow:
var shadow_scale = 1.0 - (position_z / 50.0) * 0.5
shadow.scale = Vector2.ONE * max(0.3, shadow_scale)
shadow.modulate.a = 0.5 - (position_z / 50.0) * 0.2
func _on_take_damage():
# Override in subclasses for custom damage reactions
pass
func _set_animation(_anim_name: String):
# Virtual function - override in subclasses that use animation state system
# (e.g., enemy_humanoid.gd uses player-like animation system)
pass
func _die():
if is_dead:
return
is_dead = true
print(name, " died!")
# Spawn loot immediately (before death animation)
_spawn_loot()
# Sync death to all clients (only server sends RPC)
# Use game_world to route death sync instead of direct RPC to avoid node path issues
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree():
var enemy_name = name
var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_enemy_death"):
game_world._sync_enemy_death.rpc(enemy_name, enemy_index)
else:
# Fallback: try direct RPC (may fail if node path doesn't match)
_sync_death.rpc()
# Play death animation (override in subclasses)
_play_death_animation()
func _play_death_animation():
# Override in subclasses
await get_tree().create_timer(0.5).timeout
queue_free()
func _spawn_loot():
# Only spawn loot on server/authority
if not is_multiplayer_authority():
print(name, " _spawn_loot() called but not authority, skipping")
return
print(name, " _spawn_loot() called on authority")
# Spawn random loot at enemy position
var loot_scene = preload("res://scenes/loot.tscn")
if not loot_scene:
print(name, " ERROR: loot_scene is null!")
return
# Random chance to drop loot (70% chance)
var loot_chance = randf()
print(name, " loot chance roll: ", loot_chance, " (need > 0.3)")
if loot_chance > 0.3:
# Random loot type
var loot_type = 0
# 50% chance for coin
if randf() < 0.5:
loot_type = 0 # COIN
# 50% chance for food item
else:
var food_types = [1, 2, 3] # APPLE, BANANA, CHERRY
loot_type = food_types[randi() % food_types.size()]
# Generate random velocity values (same on all clients)
var random_angle = randf() * PI * 2
var random_force = randf_range(50.0, 100.0)
var random_velocity_z = randf_range(80.0, 120.0)
# Generate initial velocity (same on all clients via RPC)
var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force
# Find safe spawn position (on floor tile, not in walls)
var game_world = get_tree().get_first_node_in_group("game_world")
var safe_spawn_pos = global_position
if game_world and game_world.has_method("_find_nearby_safe_spawn_position"):
safe_spawn_pos = game_world._find_nearby_safe_spawn_position(global_position, 64.0)
# Spawn on server
var loot = loot_scene.instantiate()
var entities_node = get_parent()
if entities_node:
entities_node.add_child(loot)
loot.global_position = safe_spawn_pos
loot.loot_type = loot_type
# Set initial velocity before _ready() processes
loot.velocity = initial_velocity
loot.velocity_z = random_velocity_z
loot.velocity_set_by_spawner = true
loot.is_airborne = true
print(name, " ✓ dropped loot: ", loot_type, " at ", safe_spawn_pos, " (original enemy pos: ", global_position, ")")
# Sync loot spawn to all clients (use safe position)
if multiplayer.has_multiplayer_peer():
# Reuse game_world variable from above
if game_world:
# Generate unique loot ID
if not "loot_id_counter" in game_world:
game_world.loot_id_counter = 0
var loot_id = game_world.loot_id_counter
game_world.loot_id_counter += 1
# Store loot ID on server loot instance
loot.set_meta("loot_id", loot_id)
# Sync to clients with ID
game_world._sync_loot_spawn.rpc(safe_spawn_pos, loot_type, initial_velocity, random_velocity_z, loot_id)
print(name, " ✓ synced loot spawn to clients")
else:
print(name, " ERROR: game_world not found for loot sync!")
else:
print(name, " ERROR: entities_node is null! Cannot spawn loot!")
else:
print(name, " loot chance failed (", loot_chance, " <= 0.3), no loot dropped")
# This function can be called directly (not just via RPC) when game_world routes the update
func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, dir: int = 0, frame: int = 0, anim: String = "", frame_num: int = 0, state_value: int = -1):
# Clients receive position and animation updates from server
# Only process if we're not the authority (i.e., we're a client)
if is_multiplayer_authority():
return # Server ignores its own updates
# Debug: Log when client receives position update (first few times)
if not has_meta("position_sync_count"):
set_meta("position_sync_count", 0)
print("Enemy ", name, " (client) RECEIVED first position sync! pos=", pos, " authority: ", get_multiplayer_authority(), " is_authority: ", is_multiplayer_authority(), " in_tree: ", is_inside_tree())
var sync_count = get_meta("position_sync_count") + 1
set_meta("position_sync_count", sync_count)
if sync_count <= 3: # Log first 3 syncs
print("Enemy ", name, " (client) received position sync #", sync_count, ": pos=", pos)
# Update position and state
position = pos
velocity = vel
position_z = z_pos
current_direction = dir as Direction
# Update state if provided (for enemies with state machines like bats/slimes)
if state_value != -1 and "state" in self:
set("state", state_value)
# Update animation if provided (for humanoid enemies with player-like animation system)
if anim != "":
_set_animation(anim)
if "current_frame" in self:
set("current_frame", frame_num)
else:
# Default: use frame number for simple enemies
anim_frame = frame
# Update visual representation (override in subclasses for custom animation)
_update_client_visuals()
# This function can be called directly (not just via RPC) when game_world routes the update
func _sync_damage_visual():
# Clients receive damage visual sync
# Only process if we're not the authority (i.e., we're a client)
if is_multiplayer_authority():
return # Server ignores its own updates
_flash_damage()
# This function can be called directly (not just via RPC) when game_world routes the update
func _sync_death():
# Clients receive death sync and play death animation locally
# Only process if we're not the authority (i.e., we're a client)
if is_multiplayer_authority():
return # Server ignores its own updates
if not is_dead:
is_dead = true
print(name, " received death sync, dying on client")
# Remove collision layer so they don't collide with players, but still collide with walls
# This matches what happens on the server when rats/slimes die
set_collision_layer_value(2, false) # Remove from enemy collision layer (layer 2)
# Immediately mark as dead and stop AI/physics
# This prevents "inactive" enemies that are already dead
_play_death_animation()
else:
# Already dead, but make sure collision is removed and it's removed from scene
print(name, " received death sync but already dead, ensuring removal")
# Remove collision layer if not already removed
if get_collision_layer_value(2):
set_collision_layer_value(2, false)
if not is_queued_for_deletion():
queue_free()
func _get_direction_from_vector(vec: Vector2) -> Direction:
if vec.length() < 0.1:
return current_direction
var angle = vec.angle()
# Convert angle to 8 directions
if angle >= -PI / 8 and angle < PI / 8:
return Direction.RIGHT
elif angle >= PI / 8 and angle < 3 * PI / 8:
return Direction.DOWN_RIGHT
elif angle >= 3 * PI / 8 and angle < 5 * PI / 8:
return Direction.DOWN
elif angle >= 5 * PI / 8 and angle < 7 * PI / 8:
return Direction.DOWN_LEFT
elif angle >= 7 * PI / 8 or angle < -7 * PI / 8:
return Direction.LEFT
elif angle >= -7 * PI / 8 and angle < -5 * PI / 8:
return Direction.UP_LEFT
elif angle >= -5 * PI / 8 and angle < -3 * PI / 8:
return Direction.UP
return Direction.UP_RIGHT