Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen, drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall, ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73, M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.8 KiB
GDScript
173 lines
5.8 KiB
GDScript
class_name LuneUnit
|
|
extends RefCounted
|
|
## One combat instance. Persistent ownership stays in LuneMatch; this object lives for
|
|
## one combat phase. Damage, heals and effects are queued and applied by LuneCombat.
|
|
|
|
const DAMAGE_MATRIX = [[1.30, 0.75, 1.00], [1.00, 1.30, 0.75], [0.75, 1.00, 1.30]]
|
|
const MOBILE_KINDS: Array[String] = ["troop", "hero", "monster", "champion", "boss"]
|
|
|
|
var id: int = 0
|
|
## Spawn index within the own team; mirrored units share it (used for fair staggering).
|
|
var team_index: int = 0
|
|
var kind: String = "troop"
|
|
var team: int = 0
|
|
var owner_slot: int = -1
|
|
var persistent_uid: String = ""
|
|
var lane: String = "mid"
|
|
var zone: String = "lane"
|
|
var def: Dictionary = {}
|
|
var building_id: String = ""
|
|
var position: Vector2 = Vector2.ZERO
|
|
var previous_position: Vector2 = Vector2.ZERO
|
|
var facing: float = 0.0
|
|
var hp: float = 1.0
|
|
var max_hp: float = 1.0
|
|
var alive: bool = true
|
|
var target: LuneUnit = null
|
|
var progress: float = 0.0
|
|
var lateral: float = 0.0
|
|
var attack_progress: float = 0.0
|
|
var hit_count: int = 0
|
|
var combat_time: float = 0.0
|
|
var shield: float = 0.0
|
|
var shield_remaining: float = INF
|
|
var effects: Dictionary = {}
|
|
var dots: Dictionary = {}
|
|
var ability_ready: float = 6.0
|
|
var reached_base: bool = false
|
|
## Grotto boss waits while two expeditions still fight each other.
|
|
var dormant: bool = false
|
|
var aura_ready: float = 4.0
|
|
# Presentation-only signals; never read by the rules.
|
|
var attack_serial: int = 0
|
|
var ability_serial: int = 0
|
|
var hit_flash: float = 0.0
|
|
var in_range: bool = false
|
|
var death_time: float = -1.0
|
|
|
|
func is_building() -> bool:
|
|
return kind == "tower" or kind == "core"
|
|
|
|
func is_mobile() -> bool:
|
|
return kind in MOBILE_KINDS
|
|
|
|
func attack_range() -> float:
|
|
return float(def.get("attack_range", 1.0))
|
|
|
|
func speed() -> float:
|
|
return float(def.get("speed", 2.0))
|
|
|
|
func armor_type() -> int:
|
|
return clampi(int(def.get("armor_type", 1 if is_building() else 0)), 0, 2)
|
|
|
|
func ability() -> String:
|
|
return str(def.get("ability", ""))
|
|
|
|
func is_boss() -> bool:
|
|
return kind == "boss" or bool(def.get("is_boss", false))
|
|
|
|
func mitigated(raw: float, attack_type: int) -> float:
|
|
var value: float = maxf(0.0, raw) * float(DAMAGE_MATRIX[clampi(attack_type, 0, 2)][armor_type()])
|
|
return value * (1.0 - damage_reduction())
|
|
|
|
## Applies an already mitigated amount through the shield.
|
|
func take(amount: float) -> void:
|
|
if not alive or amount <= 0.0:
|
|
return
|
|
var absorbed: float = minf(shield, amount)
|
|
shield -= absorbed
|
|
hp = maxf(0.0, hp - (amount - absorbed))
|
|
hit_flash = .18
|
|
|
|
func heal(amount: float) -> void:
|
|
if alive and not is_building():
|
|
hp = minf(max_hp, hp + maxf(0.0, amount) * (1.0 - strongest_effect("healing_reduction")))
|
|
|
|
func grant_shield(amount: float, duration: float = INF) -> void:
|
|
if alive and amount > shield:
|
|
shield = amount
|
|
shield_remaining = duration
|
|
|
|
func apply_effect(family: String, effect_kind: String, amount: float, duration: float) -> void:
|
|
# Buildings ignore crowd control and buffs; their gates are handled elsewhere.
|
|
if not alive or is_building():
|
|
return
|
|
if effects.has(family):
|
|
effects[family].amount = maxf(float(effects[family].amount), amount)
|
|
effects[family].remaining = maxf(float(effects[family].remaining), duration)
|
|
else:
|
|
effects[family] = {"kind": effect_kind, "amount": amount, "remaining": duration}
|
|
|
|
func apply_dot(family: String, damage: float, duration: float, attack_type: int) -> void:
|
|
if not alive or is_building():
|
|
return
|
|
if dots.has(family):
|
|
var effect: Dictionary = dots[family]
|
|
if damage > float(effect.damage):
|
|
effect.damage = damage
|
|
effect.attack_type = attack_type
|
|
effect.remaining = maxf(float(effect.remaining), duration)
|
|
else:
|
|
dots[family] = {"damage": damage, "remaining": duration, "next_tick": 1.0, "attack_type": attack_type}
|
|
|
|
func strongest_effect(effect_kind: String) -> float:
|
|
var amount: float = 0.0
|
|
for effect: Dictionary in effects.values():
|
|
if str(effect.kind) == effect_kind:
|
|
amount = maxf(amount, float(effect.amount))
|
|
return amount
|
|
|
|
func damage_bonus() -> float:
|
|
var result: float = 0.0
|
|
for effect: Dictionary in effects.values():
|
|
if str(effect.kind) == "damage":
|
|
result += float(effect.amount)
|
|
return minf(0.30, result) + float(def.get("damage_bonus", 0.0))
|
|
|
|
func damage_reduction() -> float:
|
|
var result: float = strongest_effect("reduction")
|
|
match ability():
|
|
"g03":
|
|
if combat_time < 8.0 - 0.000001:
|
|
result = maxf(result, 0.15)
|
|
"g04":
|
|
result = maxf(result, 0.25 if combat_time < 10.0 - 0.000001 else 0.10)
|
|
return minf(0.35, result + float(def.get("reduction_bonus", 0.0)))
|
|
|
|
func attack_speed_factor() -> float:
|
|
var slowing: float = strongest_effect("attack_slow") * (0.5 if is_boss() else 1.0)
|
|
var bonus: float = float(def.get("attack_speed_bonus", 0.0))
|
|
return 1.0 + clampf(strongest_effect("attack_speed") - slowing + bonus, -0.25, 0.5)
|
|
|
|
func has_talent(id: String) -> bool:
|
|
return (def.get("talents", []) as Array).has(id)
|
|
|
|
func role() -> String:
|
|
return str(def.get("role", ""))
|
|
|
|
func movement_factor() -> float:
|
|
return 1.0 - minf(0.35, strongest_effect("movement_slow") * (0.5 if is_boss() else 1.0))
|
|
|
|
## Status timers; DoT damage is returned so the caller can queue it with all other damage.
|
|
func advance_status(delta: float) -> float:
|
|
combat_time += delta
|
|
hit_flash = maxf(0.0, hit_flash - delta)
|
|
for family: String in effects.keys():
|
|
effects[family].remaining -= delta
|
|
if float(effects[family].remaining) <= 0.000001:
|
|
effects.erase(family)
|
|
if shield_remaining != INF:
|
|
shield_remaining -= delta
|
|
if shield_remaining <= 0.000001:
|
|
shield = 0.0
|
|
var dot_damage: float = 0.0
|
|
for family: String in dots.keys():
|
|
var effect: Dictionary = dots[family]
|
|
effect.next_tick -= minf(delta, maxf(0.0, float(effect.remaining)))
|
|
while float(effect.next_tick) <= 0.000001:
|
|
dot_damage += mitigated(float(effect.damage), int(effect.attack_type))
|
|
effect.next_tick += 1.0
|
|
effect.remaining -= delta
|
|
if float(effect.remaining) <= 0.000001:
|
|
dots.erase(family)
|
|
return dot_damage
|