Added rpg system for combat

added lots of loot to find
added level up system
This commit is contained in:
2026-01-11 23:12:09 +01:00
parent ab16194c39
commit 3a7fb29d58
32 changed files with 5076 additions and 96 deletions

View File

@@ -4,10 +4,12 @@ extends CharacterBody2D
@export var max_health: float = 50.0
@export var move_speed: float = 80.0
@export var damage: float = 10.0
@export var damage: float = 10.0 # Legacy - use character_stats.damage instead
@export var attack_cooldown: float = 1.0
@export var exp_reward: float = 10.0 # EXP granted when this enemy is defeated
var current_health: float = 50.0
var character_stats: CharacterStats # RPG stats system (same as players)
var is_dead: bool = false
var target_player: Node = null
var attack_timer: float = 0.0
@@ -35,6 +37,9 @@ var anim_speed: float = 0.15 # Seconds per frame
@onready var collision_shape = get_node_or_null("CollisionShape2D")
func _ready():
# Initialize CharacterStats for RPG system
_initialize_character_stats()
current_health = max_health
add_to_group("enemy")
@@ -51,6 +56,27 @@ func _ready():
# Walls are on layer 7 (bit 6 = 64), not layer 4!
collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
# Initialize CharacterStats for this enemy
# Override in subclasses to set specific baseStats
func _initialize_character_stats():
character_stats = CharacterStats.new()
character_stats.character_type = "enemy"
character_stats.character_name = name
# Default stats - override in subclasses
character_stats.baseStats.str = 10
character_stats.baseStats.dex = 10
character_stats.baseStats.int = 10
character_stats.baseStats.end = 10
character_stats.baseStats.wis = 10
character_stats.baseStats.cha = 10
character_stats.baseStats.lck = 10
# Initialize hp and mp
character_stats.hp = character_stats.maxhp
character_stats.mp = character_stats.maxmp
# Sync max_health and current_health from character_stats (for backwards compatibility)
max_health = character_stats.maxhp
current_health = character_stats.hp
func _physics_process(delta):
if is_dead:
# Even when dead, allow knockback to continue briefly
@@ -259,7 +285,7 @@ func _find_nearest_player_to_position(pos: Vector2, max_range: float = 100.0) ->
return nearest
func take_damage(amount: float, from_position: Vector2):
func take_damage(amount: float, from_position: Vector2, is_critical: bool = false):
# Only process damage on server/authority
if not is_multiplayer_authority():
return
@@ -273,8 +299,41 @@ func take_damage(amount: float, from_position: Vector2):
if nearest_player:
killer_player = nearest_player # Update killer to the most recent attacker
current_health -= amount
print(name, " took ", amount, " damage! Health: ", current_health)
# Check for dodge chance (based on DEX) - same as players
var _was_dodged = false
if character_stats:
var dodge_roll = randf()
var dodge_chance = character_stats.dodge_chance
if dodge_roll < dodge_chance:
_was_dodged = true
print(name, " DODGED the attack! (DEX: ", character_stats.baseStats.dex + character_stats.get_pass("dex"), ", dodge chance: ", dodge_chance * 100.0, "%)")
# Show "DODGED" text
_show_damage_number(0.0, from_position, false, false, true) # is_dodged = true
# Sync dodge visual to clients
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)
return # No damage taken, exit early
# If not dodged, apply damage with DEF reduction
var actual_damage = amount
if character_stats:
# Calculate damage after DEF reduction (critical hits pierce 80% of DEF)
actual_damage = character_stats.calculate_damage(amount, false, is_critical) # false = not magical, is_critical = crit pierce
character_stats.modify_health(-actual_damage)
current_health = character_stats.hp
if character_stats.hp <= 0:
character_stats.no_health.emit()
var effective_def = character_stats.defense * (0.2 if is_critical else 1.0)
print(name, " took ", actual_damage, " damage (", amount, " base - ", effective_def, " DEF = ", actual_damage, ")! Health: ", current_health, "/", character_stats.maxhp)
else:
# Fallback for legacy (shouldn't happen if _initialize_character_stats is called)
current_health -= amount
actual_damage = amount
print(name, " took ", amount, " damage! Health: ", current_health, " (critical: ", is_critical, ")")
# Calculate knockback direction (away from attacker)
var knockback_direction = (global_position - from_position).normalized()
@@ -287,10 +346,9 @@ func take_damage(amount: float, from_position: Vector2):
# Flash red (even if dying, show the hit)
_flash_damage()
# Show damage number (red, using dmg_numbers.png font) above enemy
# Only show if damage > 0
if amount > 0:
_show_damage_number(amount, from_position)
# Show damage number (red/orange, using dmg_numbers.png font) above enemy
# Always show damage number, even if 0
_show_damage_number(actual_damage, from_position, is_critical)
# Sync damage visual to clients
# Use game_world to route damage visual sync instead of direct RPC to avoid node path issues
@@ -321,16 +379,14 @@ func take_damage(amount: float, from_position: Vector2):
call_deferred("_notify_doors_enemy_died")
@rpc("any_peer", "reliable")
func rpc_take_damage(amount: float, from_position: Vector2):
func rpc_take_damage(amount: float, from_position: Vector2, is_critical: bool = false):
# RPC version - only process on server/authority
if is_multiplayer_authority():
take_damage(amount, from_position)
take_damage(amount, from_position, is_critical)
func _show_damage_number(amount: float, from_position: Vector2):
# Show damage number (red, using dmg_numbers.png font) above enemy
# Only show if damage > 0
if amount <= 0:
return
func _show_damage_number(amount: float, from_position: Vector2, is_critical: bool = false, is_miss: bool = false, is_dodged: bool = false):
# Show damage number (red/orange for crits, cyan for dodge, gray for miss, using dmg_numbers.png font) above enemy
# Show even if amount is 0 for MISS/DODGED
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
@@ -340,9 +396,16 @@ func _show_damage_number(amount: float, from_position: Vector2):
if not damage_label:
return
# Set damage text and red color
damage_label.label = str(int(amount))
damage_label.color = Color.RED
# Set text and color based on type
if is_dodged:
damage_label.label = "DODGED"
damage_label.color = Color.CYAN
elif is_miss:
damage_label.label = "MISS"
damage_label.color = Color.GRAY
else:
damage_label.label = str(int(amount))
damage_label.color = Color.ORANGE if is_critical else Color.RED
# Calculate direction from attacker (slight upward variation)
var direction_from_attacker = (global_position - from_position).normalized()
@@ -418,11 +481,16 @@ func _die():
is_dead = true
print(name, " died!")
# Credit kill to the player who dealt the fatal damage
# Credit kill and grant EXP to the player who dealt the fatal damage
if killer_player and is_instance_valid(killer_player) and killer_player.character_stats:
killer_player.character_stats.kills += 1
print(name, " kill credited to ", killer_player.name, " (total kills: ", killer_player.character_stats.kills, ")")
# Grant EXP to the killer
if exp_reward > 0:
killer_player.character_stats.add_xp(exp_reward)
print(name, " granted ", exp_reward, " EXP to ", killer_player.name)
# Sync kill update to client if this player belongs to a client
# Only sync if we're on the server and the killer is a client's player
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
@@ -475,16 +543,21 @@ func _spawn_loot():
var loot_chance = randf()
print(name, " loot chance roll: ", loot_chance, " (need > 0.3)")
if loot_chance > 0.3:
# Random loot type
# Decide what to drop: 30% coin, 30% food, 40% item
var drop_roll = randf()
var loot_type = 0
var drop_item = false
# 50% chance for coin
if randf() < 0.5:
if drop_roll < 0.3:
# 30% chance for coin
loot_type = 0 # COIN
# 50% chance for food item
else:
elif drop_roll < 0.6:
# 30% chance for food item
var food_types = [1, 2, 3] # APPLE, BANANA, CHERRY
loot_type = food_types[randi() % food_types.size()]
else:
# 40% chance for Item instance
drop_item = true
# Generate random velocity values (same on all clients)
var random_angle = randf() * PI * 2
@@ -500,10 +573,20 @@ func _spawn_loot():
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:
if not entities_node:
print(name, " ERROR: entities_node is null! Cannot spawn loot!")
return
if drop_item:
# Spawn Item instance as loot
var item = ItemDatabase.get_random_enemy_drop()
if item:
ItemLootHelper.spawn_item_loot(item, global_position, entities_node, game_world)
print(name, " ✓ dropped item: ", item.item_name, " at ", safe_spawn_pos)
else:
# Spawn regular loot (coin or food)
var loot = loot_scene.instantiate()
entities_node.add_child(loot)
loot.global_position = safe_spawn_pos
loot.loot_type = loot_type
@@ -519,8 +602,7 @@ func _spawn_loot():
# 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
# loot_id_counter is declared as a variable in game_world.gd, so it always exists
var loot_id = game_world.loot_id_counter
game_world.loot_id_counter += 1
# Store loot ID on server loot instance
@@ -530,8 +612,6 @@ func _spawn_loot():
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")