replace with multiplayer-coop files
This commit is contained in:
456
src/scripts/enemy_base.gd
Normal file
456
src/scripts/enemy_base.gd
Normal 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
|
||||
Reference in New Issue
Block a user