This commit is contained in:
Marek Lenczewski
2026-04-04 00:00:15 +02:00
parent 3488856b91
commit f1d34ebf1d
104 changed files with 1912 additions and 1789 deletions

View File

@@ -1,124 +1,83 @@
extends Node
func _ready() -> void:
EventBus.ability_use_requested.connect(_on_ability_use_requested)
EventBus.ability_use.connect(_on_ability_use)
func _process(_delta: float) -> void:
var players := get_tree().get_nodes_in_group("player")
for player in players:
if not Stats.is_alive(player):
continue
_try_auto_attack(player)
func _try_auto_attack(player: Node) -> void:
var targeting: Node = player.get_node_or_null("Targeting")
if not targeting or not targeting.in_combat or not targeting.current_target:
func _on_ability_use(_player: Node, ability_index: int) -> void:
if not PlayerData.alive:
return
if not is_instance_valid(targeting.current_target):
return
var cooldown_system: Node = get_node("../CooldownSystem")
if not cooldown_system.is_aa_ready(player):
return
var role: Node = player.get_node("Role")
var ability_set: AbilitySet = role.get_ability_set()
if not ability_set:
return
var aa_damage: float = ability_set.aa_damage
var aa_range: float = ability_set.aa_range
var aa_is_heal: bool = ability_set.aa_is_heal
var dmg: float = _apply_passive(player, aa_damage, "heal" if aa_is_heal else "damage")
if aa_is_heal:
EventBus.heal_requested.emit(player, player, dmg)
else:
var dist: float = player.global_position.distance_to(targeting.current_target.global_position)
if dist > aa_range:
return
EventBus.damage_requested.emit(player, targeting.current_target, dmg)
var player_base: BaseStats = Stats.get_base(player)
var aa_cd: float = player_base.aa_cooldown if player_base is PlayerStats else 0.5
cooldown_system.set_aa_cooldown(player, aa_cd)
func _on_ability_use_requested(player: Node, ability_index: int) -> void:
var role: Node = player.get_node_or_null("Role")
if not role:
return
var ability_set: AbilitySet = role.get_ability_set()
var ability_set: AbilitySet = PlayerData.ability_set
if not ability_set or ability_index >= ability_set.abilities.size():
return
var ability: Ability = ability_set.abilities[ability_index]
if not ability or ability.type == Ability.Type.PASSIVE:
return
var cooldown_system: Node = get_node("../CooldownSystem")
if not cooldown_system.is_ready(player, ability_index):
if PlayerData.cooldowns[ability_index] > 0:
return
if ability.uses_gcd and not cooldown_system.is_gcd_ready(player):
if ability.uses_gcd and PlayerData.gcd > 0:
return
var success: bool = _execute_ability(player, ability)
var success: bool = _execute_ability(ability)
if not success:
return
var player_base: BaseStats = Stats.get_base(player)
var gcd_time: float = player_base.gcd_time if player_base is PlayerStats else 0.5
var gcd: float = gcd_time if ability.uses_gcd else 0.0
cooldown_system.set_cooldown(player, ability_index, ability.cooldown, gcd)
var gcd: float = PlayerData.gcd_time if ability.uses_gcd else 0.0
PlayerData.cooldowns[ability_index] = ability.cooldown
PlayerData.max_cooldowns[ability_index] = max(ability.cooldown, gcd)
if gcd > 0:
PlayerData.gcd = gcd
func _execute_ability(player: Node, ability: Ability) -> bool:
var targeting: Node = player.get_node("Targeting")
func _execute_ability(ability: Ability) -> bool:
var stat: String = "heal" if ability.is_heal else "damage"
var dmg: float = _apply_passive(player, ability.damage, stat)
var dmg: float = _apply_passive(ability.damage, stat)
var player: Node = get_tree().get_first_node_in_group("player")
match ability.type:
Ability.Type.SINGLE:
return _execute_single(player, targeting, ability, dmg)
return _execute_single(player, ability, dmg)
Ability.Type.AOE:
return _execute_aoe(player, ability, dmg)
Ability.Type.UTILITY:
return _execute_utility(player, ability)
return _execute_utility(ability)
Ability.Type.ULT:
return _execute_ult(player, targeting, ability, dmg)
return _execute_ult(player, ability, dmg)
return false
func _apply_passive(player: Node, base: float, stat: String) -> float:
var mult: Variant = Stats.get_stat(player, "buff_" + stat)
if mult != null:
return base * mult
return base
func _apply_passive(base: float, stat: String) -> float:
var mult: float = 1.0
match stat:
"damage": mult = PlayerData.buff_damage
"heal": mult = PlayerData.buff_heal
return base * mult
func _in_range(player: Node, targeting: Node, ability: Ability) -> bool:
func _in_range(ability: Ability) -> bool:
if ability.ability_range <= 0 or ability.is_heal:
return true
if not is_instance_valid(targeting.current_target):
if not is_instance_valid(PlayerData.target):
return false
var dist: float = player.global_position.distance_to(targeting.current_target.global_position)
var player: Node = get_tree().get_first_node_in_group("player")
var dist: float = player.global_position.distance_to(PlayerData.target.global_position)
return dist <= ability.ability_range
func _execute_single(player: Node, targeting: Node, ability: Ability, dmg: float) -> bool:
func _execute_single(player: Node, ability: Ability, dmg: float) -> bool:
if ability.is_heal:
EventBus.heal_requested.emit(player, player, dmg)
EventBus.attack_executed.emit(player, player.global_position, -player.global_transform.basis.z, dmg)
return true
if not _in_range(player, targeting, ability):
if not _in_range(ability):
return false
if not is_instance_valid(targeting.current_target):
if not is_instance_valid(PlayerData.target):
return false
EventBus.damage_requested.emit(player, targeting.current_target, dmg)
EventBus.damage_requested.emit(player, PlayerData.target, dmg)
if ability.element != 0:
EventBus.element_damage_dealt.emit(player, targeting.current_target, dmg, ability.element)
EventBus.element_damage_dealt.emit(player, PlayerData.target, dmg, ability.element)
EventBus.attack_executed.emit(player, player.global_position, -player.global_transform.basis.z, dmg)
return true
func _execute_aoe(player: Node, ability: Ability, dmg: float) -> bool:
if ability.is_heal:
EventBus.heal_requested.emit(player, player, dmg)
var players := get_tree().get_nodes_in_group("player")
for p in players:
if p != player and is_instance_valid(p):
var dist: float = player.global_position.distance_to(p.global_position)
if dist <= ability.ability_range:
EventBus.heal_requested.emit(player, p, dmg)
EventBus.attack_executed.emit(player, player.global_position, -player.global_transform.basis.z, dmg)
return true
var hit := false
var enemies := get_tree().get_nodes_in_group("enemies")
for enemy in enemies:
for enemy in get_tree().get_nodes_in_group("enemies"):
var dist: float = player.global_position.distance_to(enemy.global_position)
if dist <= ability.ability_range:
EventBus.damage_requested.emit(player, enemy, dmg)
@@ -129,44 +88,35 @@ func _execute_aoe(player: Node, ability: Ability, dmg: float) -> bool:
EventBus.attack_executed.emit(player, player.global_position, -player.global_transform.basis.z, dmg)
return hit
func _execute_utility(player: Node, ability: Ability) -> bool:
var max_shield: float = Stats.get_stat(player, "max_shield")
if max_shield <= 0:
func _execute_utility(ability: Ability) -> bool:
if PlayerData.max_shield <= 0:
return false
var shield: float = Stats.get_stat(player, "shield")
var shield: float = PlayerData.shield
if ability.damage > 0:
shield = max_shield * (ability.damage / 100.0)
shield = PlayerData.max_shield * (ability.damage / 100.0)
else:
if shield >= max_shield:
if shield >= PlayerData.max_shield:
return false
shield = max_shield
Stats.set_stat(player, "shield", shield)
EventBus.shield_changed.emit(player, shield, max_shield)
shield = PlayerData.max_shield
PlayerData.set_shield(shield)
return true
func _execute_ult(player: Node, targeting: Node, ability: Ability, dmg: float) -> bool:
func _execute_ult(player: Node, ability: Ability, dmg: float) -> bool:
if ability.is_heal:
EventBus.heal_requested.emit(player, player, dmg)
var players := get_tree().get_nodes_in_group("player")
var aoe_range: float = ability.aoe_radius if ability.aoe_radius > 0 else ability.ability_range
for p in players:
if p != player and is_instance_valid(p):
var dist: float = player.global_position.distance_to(p.global_position)
if dist <= aoe_range:
EventBus.heal_requested.emit(player, p, dmg * 0.4)
EventBus.attack_executed.emit(player, player.global_position, -player.global_transform.basis.z, dmg)
return true
if not _in_range(player, targeting, ability):
if not _in_range(ability):
return false
if not is_instance_valid(targeting.current_target):
if not is_instance_valid(PlayerData.target):
return false
var target: Node3D = targeting.current_target
var target: Node3D = PlayerData.target
EventBus.damage_requested.emit(player, target, dmg * 5.0)
if ability.element != 0:
EventBus.element_damage_dealt.emit(player, target, dmg * 5.0, ability.element)
var splash_range: float = ability.aoe_radius if ability.aoe_radius > 0 else ability.ability_range
var enemies := get_tree().get_nodes_in_group("enemies")
for enemy in enemies:
for enemy in get_tree().get_nodes_in_group("enemies"):
if enemy != target and is_instance_valid(enemy):
var enemy_dist: float = target.global_position.distance_to(enemy.global_position)
if enemy_dist <= splash_range:

View File

@@ -23,8 +23,12 @@ func _update_combat_timers(delta: float) -> void:
func _decay_aggro(enemy: Node, delta: float) -> void:
var table: Dictionary = tracker.aggro_tables[enemy]
var base: BaseStats = Stats.get_base(enemy)
var aggro_decay: float = base.aggro_decay if base is EnemyStats else 1.0
var data_source: Node = tracker._get_data_source(enemy)
var aggro_decay: float = 1.0
if data_source:
var base: EnemyStats = data_source.get_base(enemy)
if base:
aggro_decay = base.aggro_decay
for player in table.keys():
if is_in_combat(player):
continue
@@ -58,10 +62,11 @@ func spread_aggro(source: Node, attacker: Node, amount: float) -> void:
func alert_nearby(enemy: Node, target: Node) -> void:
var radius: float = tracker.get_alert_radius(enemy)
for other in tracker.get_enemies_in_radius(enemy, radius):
if "state" in other and other.state == other.State.IDLE:
var data_source: Node = tracker._get_data_source(other)
if data_source and data_source.get_stat(other, "state") == 0:
tracker.add_aggro(other, target, 1.0)
other.target = target
other.state = other.State.CHASE
data_source.set_stat(other, "target", target)
data_source.set_stat(other, "state", 1)
EventBus.enemy_engaged.emit(other, target)
func erase_entity(entity: Node) -> void:

View File

@@ -14,14 +14,16 @@ func _ready() -> void:
func _on_enemy_detected(enemy: Node, player: Node) -> void:
if not enemy.is_in_group("enemies"):
return
if "state" in enemy:
if enemy.state == enemy.State.CHASE or enemy.state == enemy.State.ATTACK:
var data_source: Node = tracker._get_data_source(enemy)
if data_source:
var state: int = data_source.get_stat(enemy, "state")
if state == 1 or state == 2:
return
tracker.add_player_in_range(enemy, player)
tracker.add_aggro(enemy, player, 1.0)
if "state" in enemy:
enemy.target = player
enemy.state = enemy.State.CHASE
if data_source:
data_source.set_stat(enemy, "target", player)
data_source.set_stat(enemy, "state", 1)
EventBus.enemy_engaged.emit(enemy, player)
decay.alert_nearby(enemy, player)
@@ -33,8 +35,7 @@ func _on_damage_dealt(attacker: Node, target: Node, amount: float) -> void:
return
decay.reset_combat_timer(attacker)
var multiplier := 1.0
var role: Node = attacker.get_node_or_null("Role")
if role and role.current_role == 0:
if PlayerData.current_role == PlayerData.Role.TANK:
multiplier = config.tank_multiplier
var aggro: float = amount * multiplier
tracker.add_aggro(target, attacker, aggro)

View File

@@ -43,17 +43,19 @@ func get_top_target(table: Dictionary) -> Node:
return top
func update_target(enemy: Node) -> void:
if not "state" in enemy:
return
var table: Dictionary = aggro_tables[enemy]
var top: Node = get_top_target(table)
if top and top != enemy.target:
enemy.target = top
if enemy.state == enemy.State.IDLE or enemy.state == enemy.State.RETURN:
enemy.state = enemy.State.CHASE
elif not top and enemy.state != enemy.State.IDLE and enemy.state != enemy.State.RETURN:
enemy.target = null
enemy.state = enemy.State.RETURN
var data_source: Node = _get_data_source(enemy)
if not data_source:
return
var state: int = data_source.get_stat(enemy, "state")
if top and top != data_source.get_stat(enemy, "target"):
data_source.set_stat(enemy, "target", top)
if state == 0 or state == 3:
data_source.set_stat(enemy, "state", 1)
elif not top and state != 0 and state != 3:
data_source.set_stat(enemy, "target", null)
data_source.set_stat(enemy, "state", 3)
func get_enemies_in_radius(source: Node, radius: float) -> Array:
var result: Array = []
@@ -65,8 +67,12 @@ func get_enemies_in_radius(source: Node, radius: float) -> Array:
return result
func get_alert_radius(entity: Node) -> float:
var base: BaseStats = Stats.get_base(entity)
return base.alert_radius if base is EnemyStats else 10.0
var data_source: Node = _get_data_source(entity)
if data_source:
var base: EnemyStats = data_source.get_base(entity)
if base:
return base.alert_radius
return 10.0
func erase_entity(entity: Node) -> void:
aggro_tables.erase(entity)
@@ -74,9 +80,17 @@ func erase_entity(entity: Node) -> void:
for enemy in aggro_tables:
if is_instance_valid(enemy):
aggro_tables[enemy].erase(entity)
if "target" in enemy and entity == enemy.target:
enemy.target = null
enemy.state = enemy.State.RETURN
var data_source: Node = _get_data_source(enemy)
if data_source and data_source.get_stat(enemy, "target") == entity:
data_source.set_stat(enemy, "target", null)
data_source.set_stat(enemy, "state", 3)
for enemy in players_in_range:
if is_instance_valid(enemy):
players_in_range[enemy].erase(entity)
func _get_data_source(entity: Node) -> Node:
if entity.is_in_group("boss"):
return BossData
elif entity.is_in_group("enemies"):
return EnemyData
return null

91
systems/ai_system.gd Normal file
View File

@@ -0,0 +1,91 @@
extends Node
enum State { IDLE, CHASE, ATTACK, RETURN }
func _physics_process(delta: float) -> void:
_process_group(delta, EnemyData)
_process_group(delta, BossData)
func _process_group(delta: float, data_source: Node) -> void:
for entity in data_source.entities:
if not is_instance_valid(entity) or not data_source.is_alive(entity):
continue
var data: Dictionary = data_source.entities[entity]
var state: int = data["state"]
match state:
State.IDLE:
entity.velocity.x = 0
entity.velocity.z = 0
State.CHASE:
_chase(entity, data, data_source)
State.ATTACK:
_attack(entity, data, data_source, delta)
State.RETURN:
_return_to_spawn(entity, data, data_source, delta)
func _chase(entity: Node, data: Dictionary, data_source: Node) -> void:
if not is_instance_valid(data["target"]):
data["state"] = State.RETURN
return
var base: EnemyStats = data_source.get_base(entity)
var attack_range: float = base.attack_range
var dist: float = entity.global_position.distance_to(data["target"].global_position)
if dist <= attack_range:
data["state"] = State.ATTACK
return
var nav_agent: NavigationAgent3D = entity.get_node_or_null("NavigationAgent3D")
if not nav_agent:
return
nav_agent.target_position = data["target"].global_position
var next_pos := nav_agent.get_next_path_position()
var direction: Vector3 = (next_pos - entity.global_position).normalized()
direction.y = 0
entity.velocity.x = direction.x * base.speed
entity.velocity.z = direction.z * base.speed
func _attack(entity: Node, data: Dictionary, data_source: Node, delta: float) -> void:
data["attack_timer"] -= delta
if not is_instance_valid(data["target"]):
data["state"] = State.RETURN
return
var base: EnemyStats = data_source.get_base(entity)
var dist: float = entity.global_position.distance_to(data["target"].global_position)
if dist > base.attack_range:
data["state"] = State.CHASE
return
if data["attack_timer"] <= 0:
data["attack_timer"] = base.attack_cooldown
EventBus.damage_requested.emit(entity, data["target"], base.attack_damage)
entity.velocity.x = 0
entity.velocity.z = 0
func _return_to_spawn(entity: Node, data: Dictionary, data_source: Node, delta: float) -> void:
var spawn_pos: Vector3 = data["spawn_position"]
var dist: float = entity.global_position.distance_to(spawn_pos)
if dist < 1.0:
data["state"] = State.IDLE
entity.velocity.x = 0
entity.velocity.z = 0
return
var base: EnemyStats = data_source.get_base(entity)
var nav_agent: NavigationAgent3D = entity.get_node_or_null("NavigationAgent3D")
if not nav_agent:
return
nav_agent.target_position = spawn_pos
var next_pos := nav_agent.get_next_path_position()
var direction: Vector3 = (next_pos - entity.global_position).normalized()
direction.y = 0
entity.velocity.x = direction.x * base.speed
entity.velocity.z = direction.z * base.speed
_regenerate(entity, data, data_source, delta)
func _regenerate(entity: Node, data: Dictionary, data_source: Node, delta: float) -> void:
var health: float = data["health"]
var max_health: float = data["max_health"]
if health < max_health:
var base: EnemyStats = data_source.get_base(entity)
var rate: float = base.regen_fast
if health >= max_health * 0.99:
rate = base.regen_slow
health = min(health + max_health * rate * delta, max_health)
data_source.set_health(entity, health)

1
systems/ai_system.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dokr1ut7ea541

27
systems/attack_system.gd Normal file
View File

@@ -0,0 +1,27 @@
extends Node
func _process(_delta: float) -> void:
if not PlayerData.alive or not PlayerData.in_combat:
return
if not is_instance_valid(PlayerData.target):
return
if PlayerData.aa_timer > 0:
return
var ability_set: AbilitySet = PlayerData.ability_set
if not ability_set:
return
var player: Node = get_tree().get_first_node_in_group("player")
if not player:
return
var aa_damage: float = ability_set.aa_damage
var aa_range: float = ability_set.aa_range
var aa_is_heal: bool = ability_set.aa_is_heal
var dmg: float = aa_damage * (PlayerData.buff_heal if aa_is_heal else PlayerData.buff_damage)
if aa_is_heal:
EventBus.heal_requested.emit(player, player, dmg)
else:
var dist: float = player.global_position.distance_to(PlayerData.target.global_position)
if dist > aa_range:
return
EventBus.damage_requested.emit(player, PlayerData.target, dmg)
PlayerData.aa_timer = PlayerData.aa_cooldown

View File

@@ -0,0 +1 @@
uid://dvuds0uuffj6t

62
systems/aura_system.gd Normal file
View File

@@ -0,0 +1,62 @@
extends Node
const AURA_REFRESH := 0.5
var active_auras: Dictionary = {}
func _ready() -> void:
EventBus.role_changed.connect(_on_role_changed)
EventBus.entity_died.connect(_on_entity_died)
func _process(_delta: float) -> void:
for entity in active_auras.keys():
if not is_instance_valid(entity):
active_auras.erase(entity)
continue
for aura in active_auras[entity]:
_propagate(entity, aura)
func _propagate(source_entity: Node, aura: Effect) -> void:
if not source_entity is Node3D:
return
var buff_system: Node = get_node("../BuffSystem")
var players := get_tree().get_nodes_in_group("player")
for player in players:
if not is_instance_valid(player) or not PlayerData.alive:
continue
var dist: float = source_entity.global_position.distance_to(player.global_position)
if dist > aura.aura_radius:
continue
if buff_system.has_aura_buff(player, aura.effect_name, source_entity):
buff_system.refresh_aura_buff(player, aura.effect_name, source_entity, AURA_REFRESH)
else:
var buff := Effect.new()
buff.effect_name = aura.effect_name
buff.type = Effect.Type.BUFF
buff.stat = aura.stat
buff.value = aura.value
buff.duration = AURA_REFRESH
buff.is_multiplier = aura.is_multiplier
buff_system.apply_aura_buff(player, buff, source_entity)
func _on_role_changed(player: Node, _role_type: int) -> void:
active_auras.erase(player)
var ability_set: AbilitySet = PlayerData.ability_set
if not ability_set:
return
for ability in ability_set.abilities:
if ability and ability.type == Ability.Type.PASSIVE:
var effect := Effect.new()
effect.effect_name = ability.ability_name
effect.type = Effect.Type.AURA
effect.stat = ability.passive_stat
effect.value = ability.damage / 100.0
effect.duration = -1.0
effect.is_multiplier = true
effect.aura_radius = ability.ability_range
if not active_auras.has(player):
active_auras[player] = []
active_auras[player].append(effect)
func _on_entity_died(entity: Node) -> void:
active_auras.erase(entity)

View File

@@ -0,0 +1 @@
uid://b17o3hfdm8uo6

142
systems/buff_system.gd Normal file
View File

@@ -0,0 +1,142 @@
extends Node
var active_buffs: Dictionary = {}
func _ready() -> void:
EventBus.effect_requested.connect(_on_effect_requested)
EventBus.role_changed.connect(_on_role_changed)
EventBus.entity_died.connect(_on_entity_died)
func _process(delta: float) -> void:
for entity in active_buffs.keys():
if not is_instance_valid(entity):
active_buffs.erase(entity)
continue
var entries: Array = active_buffs[entity]
var i: int = entries.size() - 1
while i >= 0:
var entry: Dictionary = entries[i]
var effect: Effect = entry["effect"]
if effect.duration > 0:
entry["remaining"] -= delta
if entry["remaining"] <= 0:
var is_aura_buff: bool = entry.get("is_aura_buff", false)
entries.remove_at(i)
if not is_aura_buff:
EventBus.effect_expired.emit(entity, effect)
_recalc(entity)
i -= 1
continue
if effect.tick_interval > 0:
entry["tick_timer"] -= delta
if entry["tick_timer"] <= 0:
entry["tick_timer"] += effect.tick_interval
if not effect.is_multiplier and effect.type == Effect.Type.BUFF:
var source: Node = entry["source"]
if not is_instance_valid(source):
source = entity
EventBus.heal_requested.emit(source, entity, effect.value)
i -= 1
func apply(target: Node, effect: Effect, source: Node) -> void:
if effect.type != Effect.Type.BUFF and effect.type != Effect.Type.AURA:
return
if not active_buffs.has(target):
active_buffs[target] = []
var replaced := false
var entries: Array = active_buffs[target]
for i in range(entries.size()):
if entries[i]["effect"].effect_name == effect.effect_name:
entries[i]["effect"] = effect
entries[i]["source"] = source
entries[i]["remaining"] = effect.duration
entries[i]["tick_timer"] = effect.tick_interval
replaced = true
break
if not replaced:
entries.append({
"effect": effect,
"source": source,
"remaining": effect.duration,
"tick_timer": effect.tick_interval,
})
EventBus.effect_applied.emit(target, effect)
if effect.is_multiplier:
_recalc(target)
func apply_aura_buff(target: Node, effect: Effect, source: Node) -> void:
if not active_buffs.has(target):
active_buffs[target] = []
var entry := {
"effect": effect,
"source": source,
"remaining": effect.duration,
"tick_timer": effect.tick_interval,
"aura_source": source,
"is_aura_buff": true,
}
active_buffs[target].append(entry)
if effect.is_multiplier:
_recalc(target)
func has_aura_buff(target: Node, aura_name: String, source: Node) -> bool:
if not active_buffs.has(target):
return false
for entry in active_buffs[target]:
if entry["effect"].effect_name == aura_name and entry.get("aura_source") == source:
return true
return false
func refresh_aura_buff(target: Node, aura_name: String, source: Node, duration: float) -> void:
if not active_buffs.has(target):
return
for entry in active_buffs[target]:
if entry["effect"].effect_name == aura_name and entry.get("aura_source") == source:
entry["remaining"] = duration
return
func clear(entity: Node) -> void:
active_buffs.erase(entity)
_recalc(entity)
func _on_effect_requested(target: Node, effect: Effect, source: Node) -> void:
if effect.type == Effect.Type.BUFF or effect.type == Effect.Type.AURA:
apply(target, effect, source)
func _on_entity_died(entity: Node) -> void:
active_buffs.erase(entity)
_recalc(entity)
func _on_role_changed(player: Node, _role_type: int) -> void:
_remove_permanent(player)
func _remove_permanent(entity: Node) -> void:
if not active_buffs.has(entity):
return
var entries: Array = active_buffs[entity]
var i: int = entries.size() - 1
while i >= 0:
if entries[i]["effect"].duration < 0:
EventBus.effect_expired.emit(entity, entries[i]["effect"])
entries.remove_at(i)
i -= 1
_recalc(entity)
func _recalc(entity: Node) -> void:
var mults := { "damage": 1.0, "heal": 1.0, "shield": 1.0 }
if active_buffs.has(entity):
for entry in active_buffs[entity]:
var effect: Effect = entry["effect"]
if effect.is_multiplier and effect.stat in mults:
mults[effect.stat] += effect.value
var player: Node = get_tree().get_first_node_in_group("player")
if entity == player:
PlayerData.buff_damage = mults["damage"]
PlayerData.buff_heal = mults["heal"]
PlayerData.buff_shield = mults["shield"]
if PlayerData.base:
var new_max: float = PlayerData.base.max_shield * mults["shield"]
PlayerData.max_shield = new_max
PlayerData.shield = min(PlayerData.shield, new_max)
PlayerData.set_shield(PlayerData.shield)
EventBus.buff_changed.emit(entity, "damage", mults["damage"])

View File

@@ -0,0 +1 @@
uid://y2bm5ssu77wp

View File

@@ -1,73 +1,19 @@
extends Node
var cooldowns: Dictionary = {}
func _ready() -> void:
add_to_group("cooldown_system")
EventBus.role_changed.connect(_on_role_changed)
func register(entity: Node, ability_count: int) -> void:
cooldowns[entity] = {
"cds": [] as Array[float],
"max_cds": [] as Array[float],
"gcd": 0.0,
"aa": 0.0,
}
cooldowns[entity]["cds"].resize(ability_count)
cooldowns[entity]["cds"].fill(0.0)
cooldowns[entity]["max_cds"].resize(ability_count)
cooldowns[entity]["max_cds"].fill(0.0)
func deregister(entity: Node) -> void:
cooldowns.erase(entity)
func _process(delta: float) -> void:
for entity in cooldowns:
if not is_instance_valid(entity):
continue
var data: Dictionary = cooldowns[entity]
if data["gcd"] > 0:
data["gcd"] -= delta
if data["aa"] > 0:
data["aa"] -= delta
var cds: Array = data["cds"]
for i in range(cds.size()):
if cds[i] > 0:
cds[i] -= delta
EventBus.cooldown_tick.emit(cds, data["max_cds"], data["gcd"])
if PlayerData.gcd > 0:
PlayerData.gcd -= delta
if PlayerData.aa_timer > 0:
PlayerData.aa_timer -= delta
for i in range(PlayerData.cooldowns.size()):
if PlayerData.cooldowns[i] > 0:
PlayerData.cooldowns[i] -= delta
EventBus.cooldown_tick.emit(PlayerData.cooldowns, PlayerData.max_cooldowns, PlayerData.gcd)
func is_ready(entity: Node, index: int) -> bool:
if entity not in cooldowns:
return false
return cooldowns[entity]["cds"][index] <= 0
func is_gcd_ready(entity: Node) -> bool:
if entity not in cooldowns:
return false
return cooldowns[entity]["gcd"] <= 0
func is_aa_ready(entity: Node) -> bool:
if entity not in cooldowns:
return false
return cooldowns[entity]["aa"] <= 0
func set_cooldown(entity: Node, index: int, cd: float, gcd: float) -> void:
if entity not in cooldowns:
return
var data: Dictionary = cooldowns[entity]
data["cds"][index] = cd
data["max_cds"][index] = max(cd, gcd)
if gcd > 0:
data["gcd"] = gcd
func set_aa_cooldown(entity: Node, cd: float) -> void:
if entity not in cooldowns:
return
cooldowns[entity]["aa"] = cd
func _on_role_changed(player: Node, _role_type: int) -> void:
if player in cooldowns:
var data: Dictionary = cooldowns[player]
data["cds"].fill(0.0)
data["max_cds"].fill(0.0)
data["gcd"] = 0.0
func _on_role_changed(_player: Node, _role_type: int) -> void:
PlayerData.cooldowns.fill(0.0)
PlayerData.max_cooldowns.fill(0.0)
PlayerData.gcd = 0.0

View File

@@ -1 +1,38 @@
extends Node
func _ready() -> void:
EventBus.damage_requested.connect(_on_damage_requested)
func _on_damage_requested(attacker: Node, target: Node, amount: float) -> void:
var remaining: float = amount
var shield_system: Node = get_node_or_null("../ShieldSystem")
if shield_system:
remaining = shield_system.absorb(target, remaining)
EventBus.damage_dealt.emit(attacker, target, amount)
if remaining > 0:
_apply_damage(target, remaining)
func _apply_damage(entity: Node, amount: float) -> void:
if entity == _get_player():
var health: float = PlayerData.health - amount
if health < 0:
health = 0
PlayerData.set_health(health)
elif entity.is_in_group("boss"):
var health: float = BossData.get_stat(entity, "health") - amount
if health < 0:
health = 0
BossData.set_health(entity, health)
elif entity.is_in_group("enemies"):
var health: float = EnemyData.get_stat(entity, "health") - amount
if health < 0:
health = 0
EnemyData.set_health(entity, health)
elif entity.is_in_group("portals"):
var health: float = PortalData.get_stat(entity, "health") - amount
if health < 0:
health = 0
PortalData.set_health(entity, health)
func _get_player() -> Node:
return get_tree().get_first_node_in_group("player")

View File

@@ -1 +1 @@
uid://cbd1bryh0e2dw
uid://cmy1kqo1pk1q8

65
systems/debuff_system.gd Normal file
View File

@@ -0,0 +1,65 @@
extends Node
var active_debuffs: Dictionary = {}
func _ready() -> void:
EventBus.effect_requested.connect(_on_effect_requested)
EventBus.entity_died.connect(_on_entity_died)
func _process(delta: float) -> void:
for entity in active_debuffs.keys():
if not is_instance_valid(entity):
active_debuffs.erase(entity)
continue
var entries: Array = active_debuffs[entity]
var i: int = entries.size() - 1
while i >= 0:
var entry: Dictionary = entries[i]
var effect: Effect = entry["effect"]
if effect.duration > 0:
entry["remaining"] -= delta
if entry["remaining"] <= 0:
entries.remove_at(i)
EventBus.effect_expired.emit(entity, effect)
i -= 1
continue
if effect.tick_interval > 0:
entry["tick_timer"] -= delta
if entry["tick_timer"] <= 0:
entry["tick_timer"] += effect.tick_interval
var source: Node = entry["source"]
if not is_instance_valid(source):
source = entity
EventBus.damage_requested.emit(source, entity, effect.value)
i -= 1
func apply(target: Node, effect: Effect, source: Node) -> void:
if effect.type != Effect.Type.DEBUFF:
return
if not active_debuffs.has(target):
active_debuffs[target] = []
var replaced := false
var entries: Array = active_debuffs[target]
for i in range(entries.size()):
if entries[i]["effect"].effect_name == effect.effect_name:
entries[i]["effect"] = effect
entries[i]["source"] = source
entries[i]["remaining"] = effect.duration
entries[i]["tick_timer"] = effect.tick_interval
replaced = true
break
if not replaced:
entries.append({
"effect": effect,
"source": source,
"remaining": effect.duration,
"tick_timer": effect.tick_interval,
})
EventBus.effect_applied.emit(target, effect)
func _on_effect_requested(target: Node, effect: Effect, source: Node) -> void:
if effect.type == Effect.Type.DEBUFF:
apply(target, effect, source)
func _on_entity_died(entity: Node) -> void:
active_debuffs.erase(entity)

View File

@@ -0,0 +1 @@
uid://ce12ledregjqx

13
systems/dungeon_system.gd Normal file
View File

@@ -0,0 +1,13 @@
extends Node
func _ready() -> void:
EventBus.entity_died.connect(_on_entity_died)
func _on_entity_died(entity: Node) -> void:
if entity.is_in_group("boss"):
await get_tree().create_timer(2.0).timeout
PlayerData.dungeon_cleared = true
PlayerData.returning_from_dungeon = false
PlayerData.clear_cache()
EventBus.dungeon_cleared.emit()
get_tree().change_scene_to_file("res://scenes/world/world.tscn")

View File

@@ -0,0 +1 @@
uid://lc5n3uxi4fho

View File

@@ -1,190 +0,0 @@
extends Node
var active_effects: Dictionary = {}
func _ready() -> void:
EventBus.role_changed.connect(_on_role_changed)
EventBus.entity_died.connect(_on_entity_died)
EventBus.effect_requested.connect(_on_effect_requested)
const AURA_REFRESH := 0.5
func _process(delta: float) -> void:
for entity in active_effects.keys():
if not is_instance_valid(entity):
active_effects.erase(entity)
continue
var entries: Array = active_effects[entity]
var i: int = entries.size() - 1
while i >= 0:
var entry: Dictionary = entries[i]
var effect: Effect = entry["effect"]
if effect.duration > 0:
entry["remaining"] -= delta
if entry["remaining"] <= 0:
var is_aura_buff: bool = entry.get("is_aura_buff", false)
entries.remove_at(i)
if not is_aura_buff:
EventBus.effect_expired.emit(entity, effect)
_recalc_stat_buffs(entity)
i -= 1
continue
if effect.tick_interval > 0:
entry["tick_timer"] -= delta
if entry["tick_timer"] <= 0:
entry["tick_timer"] += effect.tick_interval
_apply_tick(entity, entry)
if effect.type == Effect.Type.AURA and effect.aura_radius > 0 and effect.duration < 0:
_propagate_aura(entity, entry, effect)
i -= 1
func _propagate_aura(source_entity: Node, _entry: Dictionary, aura: Effect) -> void:
if not source_entity is Node3D:
return
var players := get_tree().get_nodes_in_group("player")
for player in players:
if not is_instance_valid(player) or not Stats.is_alive(player):
continue
var dist: float = source_entity.global_position.distance_to(player.global_position)
if dist > aura.aura_radius:
continue
if _has_aura_buff(player, aura.effect_name, source_entity):
_refresh_aura_buff(player, aura.effect_name, source_entity)
else:
var buff := Effect.new()
buff.effect_name = aura.effect_name
buff.type = Effect.Type.BUFF
buff.stat = aura.stat
buff.value = aura.value
buff.duration = AURA_REFRESH
buff.is_multiplier = aura.is_multiplier
_apply_aura_buff(player, buff, source_entity)
func _has_aura_buff(target: Node, aura_name: String, source: Node) -> bool:
if not active_effects.has(target):
return false
for entry in active_effects[target]:
if entry["effect"].effect_name == aura_name and entry.get("aura_source") == source:
return true
return false
func _refresh_aura_buff(target: Node, aura_name: String, source: Node) -> void:
if not active_effects.has(target):
return
for entry in active_effects[target]:
if entry["effect"].effect_name == aura_name and entry.get("aura_source") == source:
entry["remaining"] = AURA_REFRESH
return
func _apply_aura_buff(target: Node, effect: Effect, source: Node) -> void:
if not active_effects.has(target):
active_effects[target] = []
var entry := {
"effect": effect,
"source": source,
"remaining": effect.duration,
"tick_timer": effect.tick_interval,
"aura_source": source,
"is_aura_buff": true,
}
active_effects[target].append(entry)
if effect.is_multiplier:
_recalc_stat_buffs(target)
func apply_effect(target: Node, effect: Effect, source: Node) -> void:
if not active_effects.has(target):
active_effects[target] = []
var replaced := false
var entries: Array = active_effects[target]
for i in range(entries.size()):
if entries[i]["effect"].effect_name == effect.effect_name:
entries[i]["effect"] = effect
entries[i]["source"] = source
entries[i]["remaining"] = effect.duration
entries[i]["tick_timer"] = effect.tick_interval
replaced = true
break
if not replaced:
entries.append({
"effect": effect,
"source": source,
"remaining": effect.duration,
"tick_timer": effect.tick_interval,
})
EventBus.effect_applied.emit(target, effect)
if effect.is_multiplier:
_recalc_stat_buffs(target)
func clear_effects(entity: Node) -> void:
active_effects.erase(entity)
if is_instance_valid(entity):
_recalc_stat_buffs(entity)
func _on_effect_requested(target: Node, effect: Effect, source: Node) -> void:
apply_effect(target, effect, source)
func _on_entity_died(entity: Node) -> void:
clear_effects(entity)
func _on_role_changed(player: Node, _role_type: int) -> void:
_remove_permanent_effects(player)
var role: Node = player.get_node_or_null("Role")
if not role:
return
var ability_set: AbilitySet = role.get_ability_set()
if not ability_set:
return
for ability in ability_set.abilities:
if ability and ability.type == Ability.Type.PASSIVE:
var effect := Effect.new()
effect.effect_name = ability.ability_name
effect.type = Effect.Type.AURA
effect.stat = ability.passive_stat
effect.value = ability.damage / 100.0
effect.duration = -1.0
effect.is_multiplier = true
effect.aura_radius = ability.ability_range
apply_effect(player, effect, player)
func _remove_permanent_effects(entity: Node) -> void:
if not active_effects.has(entity):
return
var entries: Array = active_effects[entity]
var i: int = entries.size() - 1
while i >= 0:
if entries[i]["effect"].duration < 0:
EventBus.effect_expired.emit(entity, entries[i]["effect"])
entries.remove_at(i)
i -= 1
_recalc_stat_buffs(entity)
func _recalc_stat_buffs(entity: Node) -> void:
var mults := { "damage": 1.0, "heal": 1.0, "shield": 1.0 }
if active_effects.has(entity):
for entry in active_effects[entity]:
var effect: Effect = entry["effect"]
if effect.is_multiplier and effect.stat in mults:
mults[effect.stat] += effect.value
for stat in mults:
Stats.set_stat(entity, "buff_" + stat, mults[stat])
EventBus.buff_changed.emit(entity, stat, mults[stat])
var base: BaseStats = Stats.get_base(entity)
if base:
var shield_mult: float = mults["shield"]
var new_max: float = base.max_shield * shield_mult
Stats.set_stat(entity, "max_shield", new_max)
var shield: float = Stats.get_stat(entity, "shield")
shield = min(shield, new_max)
Stats.set_stat(entity, "shield", shield)
EventBus.shield_changed.emit(entity, shield, new_max)
func _apply_tick(entity: Node, entry: Dictionary) -> void:
var effect: Effect = entry["effect"]
var source: Node = entry["source"]
if not is_instance_valid(source):
source = entity
if not effect.is_multiplier:
if effect.type == Effect.Type.DEBUFF:
EventBus.damage_requested.emit(source, entity, effect.value)
elif effect.type == Effect.Type.BUFF:
EventBus.heal_requested.emit(source, entity, effect.value)

View File

@@ -1 +0,0 @@
uid://drdlh6tq0dfwo

View File

@@ -1,36 +0,0 @@
extends Node
var attack_timers: Dictionary = {}
func _physics_process(delta: float) -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy) or not Stats.is_alive(enemy):
continue
if enemy.state != enemy.State.ATTACK:
continue
_handle_attack(enemy, delta)
func _handle_attack(enemy: Node, delta: float) -> void:
if enemy not in attack_timers:
attack_timers[enemy] = 0.0
attack_timers[enemy] -= delta
if not is_instance_valid(enemy.target):
enemy.state = enemy.State.RETURN
return
var base: BaseStats = Stats.get_base(enemy)
var attack_range: float = base.attack_range if base is EnemyStats else 2.0
var dist: float = enemy.global_position.distance_to(enemy.target.global_position)
if dist > attack_range:
enemy.state = enemy.State.CHASE
return
if attack_timers[enemy] <= 0:
var attack_cooldown: float = base.attack_cooldown if base is EnemyStats else 1.5
var attack_damage: float = base.attack_damage if base is EnemyStats else 5.0
attack_timers[enemy] = attack_cooldown
EventBus.damage_requested.emit(enemy, enemy.target, attack_damage)
enemy.velocity.x = 0
enemy.velocity.z = 0

View File

@@ -1 +0,0 @@
uid://bwhxu5586lc1l

22
systems/heal_system.gd Normal file
View File

@@ -0,0 +1,22 @@
extends Node
func _ready() -> void:
EventBus.heal_requested.connect(_on_heal_requested)
func _on_heal_requested(_healer: Node, target: Node, amount: float) -> void:
if target == _get_player():
var health: float = min(PlayerData.health + amount, PlayerData.max_health)
PlayerData.set_health(health)
elif target.is_in_group("boss"):
var health: float = BossData.get_stat(target, "health")
var max_health: float = BossData.get_stat(target, "max_health")
health = min(health + amount, max_health)
BossData.set_health(target, health)
elif target.is_in_group("enemies"):
var health: float = EnemyData.get_stat(target, "health")
var max_health: float = EnemyData.get_stat(target, "max_health")
health = min(health + amount, max_health)
EnemyData.set_health(target, health)
func _get_player() -> Node:
return get_tree().get_first_node_in_group("player")

View File

@@ -0,0 +1 @@
uid://8jyik37e4tjw

View File

@@ -1,14 +1,25 @@
extends Node
func _ready() -> void:
EventBus.damage_requested.connect(_on_damage_requested)
EventBus.heal_requested.connect(_on_heal_requested)
_emit_initial.call_deferred()
func _process(delta: float) -> void:
for entity in Stats.entities:
_regen_player(delta)
_regen_entities(delta, EnemyData.entities)
_regen_entities(delta, BossData.entities)
func _regen_player(delta: float) -> void:
if not PlayerData.alive or PlayerData.health_regen <= 0:
return
if PlayerData.health < PlayerData.max_health:
var health: float = min(PlayerData.health + PlayerData.health_regen * delta, PlayerData.max_health)
PlayerData.set_health(health)
func _regen_entities(delta: float, entities: Dictionary) -> void:
for entity in entities:
if not is_instance_valid(entity):
continue
var data: Dictionary = Stats.entities[entity]
var data: Dictionary = entities[entity]
if not data["alive"]:
continue
var regen: float = data["health_regen"]
@@ -16,34 +27,6 @@ func _process(delta: float) -> void:
data["health"] = min(data["health"] + regen * delta, data["max_health"])
EventBus.health_changed.emit(entity, data["health"], data["max_health"])
func _on_damage_requested(attacker: Node, target: Node, amount: float) -> void:
if not Stats.is_alive(target):
return
var remaining: float = amount
var shield_system: Node = get_node_or_null("../ShieldSystem")
if shield_system:
remaining = shield_system.absorb(target, remaining)
EventBus.damage_dealt.emit(attacker, target, amount)
if remaining > 0:
_take_damage(target, remaining)
func _take_damage(entity: Node, amount: float) -> void:
var health: float = Stats.get_stat(entity, "health")
health -= amount
if health <= 0:
health = 0
Stats.set_stat(entity, "health", health)
var max_health: float = Stats.get_stat(entity, "max_health")
EventBus.health_changed.emit(entity, health, max_health)
if health <= 0:
Stats.set_stat(entity, "alive", false)
EventBus.entity_died.emit(entity)
func _on_heal_requested(healer: Node, target: Node, amount: float) -> void:
if not Stats.is_alive(target):
return
var health: float = Stats.get_stat(target, "health")
var max_health: float = Stats.get_stat(target, "max_health")
health = min(health + amount, max_health)
Stats.set_stat(target, "health", health)
EventBus.health_changed.emit(target, health, max_health)
func _emit_initial() -> void:
EventBus.health_changed.emit(PlayerData, PlayerData.health, PlayerData.max_health)
EventBus.shield_changed.emit(PlayerData, PlayerData.shield, PlayerData.max_shield)

View File

@@ -1 +1 @@
uid://b3wkn5118dimy
uid://h362ftxb0cns

168
systems/hud_system.gd Normal file
View File

@@ -0,0 +1,168 @@
extends Node
const GCD_TIME := 0.5
const ICON_SIZE := 20
const FONT_SIZE := 14
const BORDER_WIDTH := 2
const MARGIN := 2
var ability_labels: Array[String] = ["1", "2", "3", "4", "P"]
var effect_container: HBoxContainer = null
func _ready() -> void:
EventBus.health_changed.connect(_on_health_changed)
EventBus.shield_changed.connect(_on_shield_changed)
EventBus.entity_died.connect(_on_entity_died)
EventBus.player_respawned.connect(_on_player_respawned)
EventBus.respawn_tick.connect(_on_respawn_tick)
EventBus.role_changed.connect(_on_role_changed)
EventBus.cooldown_tick.connect(_on_cooldown_tick)
EventBus.effect_applied.connect(_on_effect_applied)
EventBus.effect_expired.connect(_on_effect_expired)
_init_hud.call_deferred()
func _init_hud() -> void:
var hud: CanvasLayer = _get_hud()
if not hud:
return
hud.get_node("RespawnTimer").visible = false
effect_container = HBoxContainer.new()
effect_container.name = "EffectContainer"
effect_container.position = Vector2(10, 60)
effect_container.add_theme_constant_override("separation", 3)
hud.add_child(effect_container)
func _on_health_changed(entity: Node, current: float, max_val: float) -> void:
if entity != PlayerData:
return
var hud: CanvasLayer = _get_hud()
if not hud:
return
var bar: ProgressBar = hud.get_node("HealthBar")
bar.max_value = max_val
bar.value = current
hud.get_node("HealthBar/HealthLabel").text = "%d/%d" % [current, max_val]
func _on_shield_changed(entity: Node, current: float, max_val: float) -> void:
if entity != PlayerData:
return
var hud: CanvasLayer = _get_hud()
if not hud:
return
var bar: ProgressBar = hud.get_node("ShieldBar")
bar.max_value = max_val
bar.value = current
hud.get_node("ShieldBar/ShieldLabel").text = "%d/%d" % [current, max_val]
func _on_entity_died(entity: Node) -> void:
if entity != PlayerData:
return
var hud: CanvasLayer = _get_hud()
if hud:
hud.get_node("RespawnTimer").visible = true
func _on_player_respawned(_player: Node) -> void:
var hud: CanvasLayer = _get_hud()
if hud:
hud.get_node("RespawnTimer").visible = false
func _on_respawn_tick(timer: float) -> void:
var hud: CanvasLayer = _get_hud()
if hud:
hud.get_node("RespawnTimer").text = str(ceil(timer))
func _on_role_changed(_player: Node, role_type: int) -> void:
var hud: CanvasLayer = _get_hud()
if not hud:
return
var icon: Label = hud.get_node("AbilityBar/ClassIcon/Label")
match role_type:
0: icon.text = "T"
1: icon.text = "D"
2: icon.text = "H"
func _on_cooldown_tick(cooldowns: Array, max_cooldowns: Array, gcd_timer: float) -> void:
var hud: CanvasLayer = _get_hud()
if not hud:
return
var panels: Array = [
hud.get_node("AbilityBar/Ability1"),
hud.get_node("AbilityBar/Ability2"),
hud.get_node("AbilityBar/Ability3"),
hud.get_node("AbilityBar/Ability4"),
hud.get_node("AbilityBar/Ability5"),
]
for i in range(min(panels.size(), cooldowns.size())):
var panel: Panel = panels[i]
var label: Label = panel.get_node("Label")
var overlay: ColorRect = panel.get_node("CooldownOverlay")
var cd: float = cooldowns[i]
var gcd: float = gcd_timer if i != 2 and i != 4 else 0.0
var active_cd: float = max(cd, gcd)
var max_cd: float = max_cooldowns[i] if max_cooldowns[i] > 0 else GCD_TIME
if active_cd > 0:
var ratio: float = clamp(active_cd / max_cd, 0.0, 1.0)
overlay.visible = true
overlay.anchor_bottom = ratio
label.text = str(ceil(active_cd))
else:
overlay.visible = false
label.text = ability_labels[i]
func _on_effect_applied(target: Node, effect: Effect) -> void:
if target != PlayerData:
return
if effect_container:
_add_icon(effect)
func _on_effect_expired(target: Node, effect: Effect) -> void:
if target != PlayerData:
return
if effect_container:
_remove_icon(effect)
func _add_icon(effect: Effect) -> void:
var panel := PanelContainer.new()
var style := StyleBoxFlat.new()
match effect.type:
Effect.Type.AURA:
style.bg_color = Color(0.15, 0.15, 0.3, 1)
style.border_color = Color(0.3, 0.5, 1.0, 1)
Effect.Type.BUFF:
style.bg_color = Color(0.15, 0.3, 0.15, 1)
style.border_color = Color(0.3, 1.0, 0.3, 1)
Effect.Type.DEBUFF:
style.bg_color = Color(0.3, 0.15, 0.15, 1)
style.border_color = Color(1.0, 0.3, 0.3, 1)
style.set_border_width_all(BORDER_WIDTH)
style.set_content_margin_all(MARGIN)
panel.add_theme_stylebox_override("panel", style)
var label := Label.new()
label.text = effect.effect_name.left(1)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", Color.WHITE)
label.custom_minimum_size = Vector2(ICON_SIZE, ICON_SIZE)
panel.add_child(label)
panel.custom_minimum_size = Vector2(ICON_SIZE + BORDER_WIDTH * 2, ICON_SIZE + BORDER_WIDTH * 2)
panel.set_meta("effect_type", effect.type)
panel.set_meta("effect_name", effect.effect_name)
var insert_idx := 0
for child in effect_container.get_children():
if child.has_meta("effect_type") and child.get_meta("effect_type") <= effect.type:
insert_idx += 1
else:
break
effect_container.add_child(panel)
effect_container.move_child(panel, insert_idx)
func _remove_icon(effect: Effect) -> void:
for child in effect_container.get_children():
if child.has_meta("effect_type") and child.has_meta("effect_name"):
if child.get_meta("effect_type") == effect.type and child.get_meta("effect_name") == effect.effect_name:
child.queue_free()
return
func _get_hud() -> CanvasLayer:
return get_tree().get_first_node_in_group("hud") as CanvasLayer

View File

@@ -0,0 +1 @@
uid://da87wrxxuhws1

225
systems/nameplate_system.gd Normal file
View File

@@ -0,0 +1,225 @@
extends Node
const ICON_SIZE := 10
const FONT_SIZE := 7
const BORDER_WIDTH := 1
const ICON_MARGIN := 0
const BASE_HEIGHT := 29
var styles: Dictionary = {}
func _ready() -> void:
EventBus.health_changed.connect(_on_health_changed)
EventBus.shield_changed.connect(_on_shield_changed)
EventBus.target_changed.connect(_on_target_changed)
EventBus.entity_died.connect(_on_entity_died)
EventBus.effect_applied.connect(_on_effect_applied)
EventBus.effect_expired.connect(_on_effect_expired)
EventBus.portal_spawn.connect(_on_portal_spawn)
_init_nameplates.call_deferred()
func _init_nameplates() -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
_setup_nameplate(enemy)
for portal in get_tree().get_nodes_in_group("portals"):
_setup_nameplate(portal)
func _setup_nameplate(entity: Node) -> void:
var nameplate: Sprite3D = entity.get_node_or_null("Healthbar")
if not nameplate:
return
var viewport: SubViewport = nameplate.get_node("SubViewport")
nameplate.texture = viewport.get_texture()
var border: ColorRect = viewport.get_node_or_null("Border")
if border:
border.visible = false
func _process(_delta: float) -> void:
var player: Node = get_tree().get_first_node_in_group("player")
for enemy in get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy):
continue
var nameplate: Sprite3D = enemy.get_node_or_null("Healthbar")
if not nameplate:
continue
var health_bar: ProgressBar = nameplate.get_node("SubViewport/HealthBar")
var data_source: Node = _get_data_source(enemy)
if not data_source:
continue
if enemy not in styles:
var style_normal: StyleBoxFlat = health_bar.get_theme_stylebox("fill").duplicate()
var style_aggro: StyleBoxFlat = style_normal.duplicate()
style_aggro.bg_color = Color(0.2, 0.4, 0.9, 1)
styles[enemy] = { "normal": style_normal, "aggro": style_aggro }
var s: Dictionary = styles[enemy]
var enemy_target: Variant = data_source.get_stat(enemy, "target")
if player and enemy_target == player:
health_bar.add_theme_stylebox_override("fill", s["aggro"])
else:
health_bar.add_theme_stylebox_override("fill", s["normal"])
func _on_health_changed(entity: Node, current: float, max_val: float) -> void:
if entity == PlayerData:
return
if not is_instance_valid(entity):
return
var nameplate: Sprite3D = entity.get_node_or_null("Healthbar")
if not nameplate:
return
if not nameplate.texture:
_setup_nameplate(entity)
var bar: ProgressBar = nameplate.get_node("SubViewport/HealthBar")
bar.max_value = max_val
bar.value = current
func _on_shield_changed(entity: Node, current: float, max_val: float) -> void:
if entity == PlayerData:
return
if not is_instance_valid(entity):
return
var nameplate: Sprite3D = entity.get_node_or_null("Healthbar")
if not nameplate:
return
var bar: ProgressBar = nameplate.get_node_or_null("SubViewport/ShieldBar")
if not bar:
return
if max_val <= 0:
bar.visible = false
return
bar.visible = true
bar.max_value = max_val
bar.value = current
func _on_target_changed(_player: Node, target: Node) -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy):
continue
var nameplate: Sprite3D = enemy.get_node_or_null("Healthbar")
if nameplate:
nameplate.get_node("SubViewport/Border").visible = (target == enemy)
for portal in get_tree().get_nodes_in_group("portals"):
if not is_instance_valid(portal):
continue
var nameplate: Sprite3D = portal.get_node_or_null("Healthbar")
if nameplate:
nameplate.get_node("SubViewport/Border").visible = (target == portal)
func _on_entity_died(entity: Node) -> void:
if entity != PlayerData and is_instance_valid(entity):
styles.erase(entity)
func _on_effect_applied(target: Node, effect: Effect) -> void:
if target == PlayerData:
return
if not is_instance_valid(target):
return
var nameplate: Sprite3D = target.get_node_or_null("Healthbar")
if not nameplate:
return
var container: HBoxContainer = _get_or_create_effect_container(nameplate)
_add_icon(container, effect)
_resize_viewport(nameplate)
func _on_effect_expired(target: Node, effect: Effect) -> void:
if target == PlayerData:
return
if not is_instance_valid(target):
return
var nameplate: Sprite3D = target.get_node_or_null("Healthbar")
if not nameplate:
return
var container: HBoxContainer = nameplate.get_node_or_null("SubViewport/EffectContainer")
if container:
_remove_icon(container, effect)
_resize_viewport.call_deferred(nameplate)
func _on_portal_spawn(_portal: Node, enemies: Array) -> void:
for enemy in enemies:
_setup_nameplate.call_deferred(enemy)
func _get_or_create_effect_container(nameplate: Sprite3D) -> HBoxContainer:
var viewport: SubViewport = nameplate.get_node("SubViewport")
var container: HBoxContainer = viewport.get_node_or_null("EffectContainer")
if container:
return container
container = HBoxContainer.new()
container.name = "EffectContainer"
var health_bar: ProgressBar = viewport.get_node("HealthBar")
var shield_bar: ProgressBar = viewport.get_node_or_null("ShieldBar")
var y_pos: float = 0.0
if shield_bar and shield_bar.visible:
y_pos = shield_bar.offset_bottom + 2
else:
y_pos = health_bar.offset_bottom + 2
container.position = Vector2(2, y_pos)
container.add_theme_constant_override("separation", 1)
viewport.add_child(container)
return container
func _resize_viewport(nameplate: Sprite3D) -> void:
var viewport: SubViewport = nameplate.get_node("SubViewport")
var border: ColorRect = viewport.get_node("Border")
var container: HBoxContainer = viewport.get_node_or_null("EffectContainer")
if not container:
return
var icon_count := 0
for child in container.get_children():
if not child.is_queued_for_deletion():
icon_count += 1
if icon_count > 0:
var needed: int = int(container.position.y) + ICON_SIZE + 4
viewport.size.y = max(BASE_HEIGHT, needed)
border.offset_bottom = viewport.size.y
else:
viewport.size.y = BASE_HEIGHT
border.offset_bottom = BASE_HEIGHT
func _add_icon(container: HBoxContainer, effect: Effect) -> void:
var panel := PanelContainer.new()
var style := StyleBoxFlat.new()
match effect.type:
Effect.Type.AURA:
style.bg_color = Color(0.15, 0.15, 0.3, 1)
style.border_color = Color(0.3, 0.5, 1.0, 1)
Effect.Type.BUFF:
style.bg_color = Color(0.15, 0.3, 0.15, 1)
style.border_color = Color(0.3, 1.0, 0.3, 1)
Effect.Type.DEBUFF:
style.bg_color = Color(0.3, 0.15, 0.15, 1)
style.border_color = Color(1.0, 0.3, 0.3, 1)
style.set_border_width_all(BORDER_WIDTH)
style.set_content_margin_all(ICON_MARGIN)
panel.add_theme_stylebox_override("panel", style)
var label := Label.new()
label.text = effect.effect_name.left(1)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", Color.WHITE)
label.custom_minimum_size = Vector2(ICON_SIZE, ICON_SIZE)
panel.add_child(label)
panel.custom_minimum_size = Vector2(ICON_SIZE + BORDER_WIDTH * 2, ICON_SIZE + BORDER_WIDTH * 2)
panel.set_meta("effect_type", effect.type)
panel.set_meta("effect_name", effect.effect_name)
var insert_idx := 0
for child in container.get_children():
if child.has_meta("effect_type") and child.get_meta("effect_type") <= effect.type:
insert_idx += 1
else:
break
container.add_child(panel)
container.move_child(panel, insert_idx)
func _remove_icon(container: HBoxContainer, effect: Effect) -> void:
for child in container.get_children():
if child.has_meta("effect_type") and child.has_meta("effect_name"):
if child.get_meta("effect_type") == effect.type and child.get_meta("effect_name") == effect.effect_name:
child.queue_free()
return
func _get_data_source(entity: Node) -> Node:
if entity.is_in_group("boss"):
return BossData
elif entity.is_in_group("enemies"):
return EnemyData
return null

View File

@@ -0,0 +1 @@
uid://yijhaxo8anul

21
systems/portal_system.gd Normal file
View File

@@ -0,0 +1,21 @@
extends Node
const GATE_SCENE: PackedScene = preload("res://scenes/portal/gate.tscn")
func _ready() -> void:
EventBus.entity_died.connect(_on_entity_died)
func _on_entity_died(entity: Node) -> void:
if not entity.is_in_group("portals"):
return
if not entity.is_inside_tree():
return
var pos: Vector3 = entity.global_position
var gate: Node3D = GATE_SCENE.instantiate()
entity.get_parent().add_child(gate)
gate.global_position = pos
for enemy in get_tree().get_nodes_in_group("enemies"):
if is_instance_valid(enemy):
enemy.queue_free()
EventBus.portal_defeated.emit(entity)
entity.queue_free()

View File

@@ -0,0 +1 @@
uid://c5sqw08twxtnh

View File

@@ -1,48 +1,42 @@
extends Node
var dead_players: Dictionary = {}
var respawn_timer := 0.0
var is_dead := false
func _ready() -> void:
EventBus.entity_died.connect(_on_entity_died)
func _process(delta: float) -> void:
for player in dead_players.keys():
if not is_instance_valid(player):
dead_players.erase(player)
continue
dead_players[player] -= delta
EventBus.respawn_tick.emit(dead_players[player])
if dead_players[player] <= 0:
_respawn(player)
if not is_dead:
return
respawn_timer -= delta
EventBus.respawn_tick.emit(respawn_timer)
if respawn_timer <= 0:
_respawn()
func _on_entity_died(entity: Node) -> void:
if not entity.is_in_group("player"):
return
if entity in dead_players:
if is_dead:
return
var base: BaseStats = Stats.get_base(entity)
var respawn_time: float = base.respawn_time if base is PlayerStats else 3.0
dead_players[entity] = respawn_time
is_dead = true
respawn_timer = PlayerData.respawn_time
entity.velocity = Vector3.ZERO
entity.get_node("Mesh").visible = false
entity.get_node("CollisionShape3D").disabled = true
entity.get_node("Movement").set_physics_process(false)
entity.get_node("Combat").set_process_unhandled_input(false)
entity.get_node("Ability").set_process_unhandled_input(false)
entity.get_node("Targeting").set_process_unhandled_input(false)
func _respawn(player: Node) -> void:
dead_players.erase(player)
func _respawn() -> void:
is_dead = false
var player: Node = get_tree().get_first_node_in_group("player")
if not player:
return
player.global_position = Vector3(0, 1, -5)
player.get_node("Mesh").visible = true
player.get_node("CollisionShape3D").disabled = false
player.get_node("Movement").set_physics_process(true)
player.get_node("Combat").set_process_unhandled_input(true)
player.get_node("Ability").set_process_unhandled_input(true)
player.get_node("Targeting").set_process_unhandled_input(true)
var max_health: float = Stats.get_stat(player, "max_health")
var max_shield: float = Stats.get_stat(player, "max_shield")
Stats.set_stat(player, "health", max_health)
Stats.set_stat(player, "shield", max_shield)
Stats.set_stat(player, "alive", true)
EventBus.health_changed.emit(player, max_health, max_health)
EventBus.shield_changed.emit(player, max_shield, max_shield)
EventBus.player_respawned.emit(player)
PlayerData.respawn()

23
systems/role_system.gd Normal file
View File

@@ -0,0 +1,23 @@
extends Node
@export var tank_set: AbilitySet
@export var damage_set: AbilitySet
@export var healer_set: AbilitySet
func _ready() -> void:
EventBus.role_change_requested.connect(_on_role_change_requested)
_apply_role.call_deferred(PlayerData.current_role)
func _on_role_change_requested(_player: Node, role: int) -> void:
_apply_role(role)
func _apply_role(role: int) -> void:
PlayerData.current_role = role
match role:
PlayerData.Role.TANK:
PlayerData.ability_set = tank_set
PlayerData.Role.DAMAGE:
PlayerData.ability_set = damage_set
PlayerData.Role.HEALER:
PlayerData.ability_set = healer_set
PlayerData.set_role(role)

View File

@@ -0,0 +1 @@
uid://cuwueo5v43kap

View File

@@ -1,10 +1,28 @@
extends Node
func _process(delta: float) -> void:
for entity in Stats.entities:
_process_player(delta)
_process_entities(delta, EnemyData.entities)
_process_entities(delta, BossData.entities)
func _process_player(delta: float) -> void:
if not PlayerData.alive or PlayerData.max_shield <= 0:
return
if PlayerData.shield < PlayerData.max_shield:
PlayerData.shield_regen_timer += delta
if PlayerData.shield_regen_timer >= PlayerData.shield_regen_delay:
var regen_rate: float = PlayerData.max_shield / PlayerData.shield_regen_time
var shield: float = PlayerData.shield + regen_rate * delta
if shield >= PlayerData.max_shield:
shield = PlayerData.max_shield
EventBus.shield_regenerated.emit(get_tree().get_first_node_in_group("player"))
PlayerData.set_shield(shield)
func _process_entities(delta: float, entities: Dictionary) -> void:
for entity in entities:
if not is_instance_valid(entity):
continue
var data: Dictionary = Stats.entities[entity]
var data: Dictionary = entities[entity]
if not data["alive"]:
continue
var max_shield: float = data["max_shield"]
@@ -22,17 +40,35 @@ func _process(delta: float) -> void:
data["shield"] = shield
EventBus.shield_changed.emit(entity, shield, max_shield)
func absorb(entity: Node, amount: float) -> float:
var shield: float = Stats.get_stat(entity, "shield")
func absorb(target: Node, amount: float) -> float:
var player: Node = get_tree().get_first_node_in_group("player")
if target == player:
if PlayerData.shield <= 0:
return amount
PlayerData.shield_regen_timer = 0.0
var absorbed: float = min(amount, PlayerData.shield)
var shield: float = PlayerData.shield - absorbed
PlayerData.set_shield(shield)
if shield <= 0:
EventBus.shield_broken.emit(target)
return amount - absorbed
var data_source: Node = _get_data_source(target)
if not data_source:
return amount
var shield: float = data_source.get_stat(target, "shield")
if shield == null or shield <= 0:
return amount
Stats.set_stat(entity, "shield_regen_timer", 0.0)
data_source.set_stat(target, "shield_regen_timer", 0.0)
var absorbed: float = min(amount, shield)
shield -= absorbed
Stats.set_stat(entity, "shield", shield)
var max_shield: float = Stats.get_stat(entity, "max_shield")
data_source.set_shield(target, shield)
if shield <= 0:
EventBus.shield_broken.emit(entity)
EventBus.shield_changed.emit(entity, shield, max_shield)
EventBus.shield_broken.emit(target)
return amount - absorbed
func _get_data_source(entity: Node) -> Node:
if entity.is_in_group("boss"):
return BossData
elif entity.is_in_group("enemies"):
return EnemyData
return null

View File

@@ -2,8 +2,6 @@ extends Node
const ENEMY_SCENE: PackedScene = preload("res://scenes/enemy/enemy.tscn")
var portal_data: Dictionary = {}
func _ready() -> void:
EventBus.health_changed.connect(_on_health_changed)
EventBus.entity_died.connect(_on_entity_died)
@@ -11,22 +9,18 @@ func _ready() -> void:
func _on_health_changed(entity: Node, current: float, max_val: float) -> void:
if not entity.is_in_group("portals"):
return
if entity not in portal_data:
var base: BaseStats = Stats.get_base(entity)
var thresholds: Array[float] = base.thresholds if base is PortalStats else [0.85, 0.70, 0.55, 0.40, 0.25, 0.10]
var triggered: Array[bool] = []
triggered.resize(thresholds.size())
triggered.fill(false)
portal_data[entity] = { "thresholds": thresholds, "triggered": triggered }
if current <= 0:
return
var data: Dictionary = portal_data[entity]
var data: Dictionary = PortalData.entities.get(entity, {})
if data.is_empty():
return
var ratio: float = current / max_val
var base: BaseStats = Stats.get_base(entity)
var spawn_count: int = base.spawn_count if base is PortalStats else 3
for i in range(data["thresholds"].size()):
if not data["triggered"][i] and ratio <= data["thresholds"][i]:
data["triggered"][i] = true
var thresholds: Array = data["thresholds"]
var triggered: Array = data["triggered"]
var spawn_count: int = data["spawn_count"]
for i in range(thresholds.size()):
if not triggered[i] and ratio <= thresholds[i]:
triggered[i] = true
_spawn_enemies(entity, spawn_count)
func _spawn_enemies(portal: Node, count: int) -> void:
@@ -36,8 +30,6 @@ func _spawn_enemies(portal: Node, count: int) -> void:
var offset := Vector3(randf_range(-2, 2), 0, randf_range(-2, 2))
portal.get_parent().add_child(entity)
entity.global_position = portal.global_position + offset
entity.spawn_position = portal.global_position
entity.portal = portal
spawned.append(entity)
var player: Node = get_tree().get_first_node_in_group("player")
if player:
@@ -49,4 +41,4 @@ func _spawn_enemies(portal: Node, count: int) -> void:
func _on_entity_died(entity: Node) -> void:
if entity.is_in_group("portals"):
portal_data.erase(entity)
PortalData.deregister(entity)

View File

@@ -0,0 +1,66 @@
extends Node
func _ready() -> void:
EventBus.target_requested.connect(_on_target_requested)
EventBus.entity_died.connect(_on_entity_died)
EventBus.enemy_engaged.connect(_on_enemy_engaged)
EventBus.damage_dealt.connect(_on_damage_dealt)
func _process(delta: float) -> void:
if PlayerData.in_combat:
PlayerData.combat_timer -= delta
if PlayerData.combat_timer <= 0:
PlayerData.in_combat = false
func _on_target_requested(_player: Node, target: Node3D) -> void:
PlayerData.set_target(target)
func _on_entity_died(entity: Node) -> void:
if entity == PlayerData.target:
PlayerData.set_target(null)
if PlayerData.in_combat:
_auto_target(entity)
func _on_enemy_engaged(_enemy: Node, target: Node) -> void:
var player: Node = get_tree().get_first_node_in_group("player")
if target == player:
PlayerData.combat_timer = PlayerData.combat_timeout
PlayerData.in_combat = true
if PlayerData.target == null:
_auto_target()
func _on_damage_dealt(attacker: Node, target: Node, _amount: float) -> void:
var player: Node = get_tree().get_first_node_in_group("player")
if target == player:
PlayerData.combat_timer = PlayerData.combat_timeout
if not PlayerData.in_combat:
PlayerData.in_combat = true
if PlayerData.target == null:
_auto_target()
elif attacker == player:
PlayerData.in_combat = true
PlayerData.combat_timer = PlayerData.combat_timeout
func _auto_target(exclude: Node = null) -> void:
var player: Node = get_tree().get_first_node_in_group("player")
if not player:
return
var nearest: Node3D = null
var nearest_dist: float = INF
for enemy in get_tree().get_nodes_in_group("enemies"):
if is_instance_valid(enemy) and enemy != exclude:
var dist: float = player.global_position.distance_to(enemy.global_position)
if dist < nearest_dist:
nearest_dist = dist
nearest = enemy
if nearest:
PlayerData.set_target(nearest)
return
for p in get_tree().get_nodes_in_group("portals"):
if is_instance_valid(p) and p != exclude:
var dist: float = player.global_position.distance_to(p.global_position)
if dist < nearest_dist:
nearest_dist = dist
nearest = p
if nearest:
PlayerData.set_target(nearest)

View File

@@ -0,0 +1 @@
uid://bvjmdmof4vcyr