class_name Enemy extends CharacterBody2D signal defeated(enemy: Enemy) const PICKUP_SCENE := preload("res://scenes/items/ingredient_pickup.tscn") const DAMAGE_NUMBER_SCENE := preload("res://scenes/ui/damage_number.tscn") const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/core/speech_bubble.gd") const AttackGeometry := preload("res://scripts/core/attack_geometry.gd") # Le derouleur partage. La creature et le heros suivent la meme # choregraphie sans partager leur corps. const AttackTargeting = preload("res://scripts/core/attack_targeting.gd") var attack_target_context: Dictionary = {} const AttackTimeline := preload("res://scripts/core/attack_timeline.gd") const ENEMY_PROJECTILE_SCRIPT := preload("res://scripts/combat/enemy_projectile.gd") var enemy_id := "crazy_squirrel" var max_health := 30.0 var health := 30.0 var move_speed := 70.0 var direct_hit_enabled := false var direct_damage_min := 0.0 var direct_damage_max := 0.0 var direct_hit_cooldown_duration := 1.0 var direct_hit_knockback := 240.0 var attacks: Array = [] var sight_range := 240.0 var detection_angle := 180.0 var attack_range := 44.0 ## A quelle distance la creature se decide. Zero veut dire « a la portee de ## la zone », le comportement d'avant ce reglage. var attack_trigger_range := 0.0 # La frise du pattern en cours, et ou le curseur en est. var attack_timeline: Dictionary = {} var attack_cursor: Dictionary = {} # Ce que la frise decide de l'instant present, publie pour le dessin, le # pont 3D et les regles de degats. var attack_zone_anchored := false ## La zone de la fiche, celle que l'on republie quand la sequence est finie. var attack_rest_zone: Dictionary = {} var attack_delivery := "direct" var attack_projectile_id := "" var attack_move_direction := Vector2.ZERO var attack_move_remaining := 0.0 var attack_move_manner := "" var attack_free_movement := false ## Cuirasse: le coup du heros ne coupe plus la sequence. Un effet de ## controle declare, lui, continue de l'arreter — sinon il ne ferait plus ## rien sans que rien ne le dise. var attack_armored := false ## Intouchable: la creature ignore les degats pendant ce moment de sa ## sequence. Le coup se voit refuse a l'ecran, il ne disparait pas. var attack_invulnerable := false var attack_type := "frontal" var attack_pattern_id := "" # Dimensions écrites de la hurtbox, avant mise à l'échelle par `size_multiplier`. # Zéro veut dire « prends le corps physique », donc un cercle. var hurtbox_length := 0.0 var hurtbox_body_radius := 0.0 var attack_animation_clip := "" var attack_mode := "burst" var attack_area_shape := "sector" var attack_area_width := 0.0 var attack_show_hit_warning := true var attack_show_movement_warning := true var attack_angle := 90.0 var windup_duration := 0.3 var attack_active_duration := 0.0 var attack_rotation_speed := 0.0 var attack_tick_interval := 0.0 var attack_tracking_speed := 0.0 var attack_escape_grace_duration := 0.0 var attack_visual_color := Color("ff4d6d") var attack_cooldown_duration := 0.9 var attack_damage_min := 0.0 var attack_damage_max := 0.0 var attack_knockback := 240.0 var attack_interrupt_chance := 100.0 var attack_movement_type := "stationary" var attack_movement_distance := 0.0 var attack_movement_speed := 0.0 var attack_movement_acceleration := 0.0 var attack_movement_windup_distance := 0.0 var attack_movement_arc_height := 0.0 var knockback_resistance := 0.0 var body_color := Color.RED var body_shape := "circle" var size_multiplier := 1.0 var target: Player ## Deux attentes, pas une. `attack_cooldown_remaining` est le plancher: tant ## qu'il court, la creature ne lance rien du tout. `attack_cooldowns` garde ## en plus l'attente propre a chaque ligne d'attaque, pour qu'une charge ## lente ne bloque pas la morsure rapide qui l'accompagne. var attack_cooldown_remaining := 0.0 var attack_cooldown_min_duration := 0.9 ## Index de la ligne d'attaque en cours, -1 au repos: c'est lui qui dit ## quelle attente propre recharger quand elle se termine. var attack_definition_index := -1 var attack_cooldowns: Dictionary = {} var direct_hit_cooldown_remaining := 0.0 var active := true var wander_direction := Vector2.ZERO var wander_timer := 0.0 var look_direction := Vector2.RIGHT var undetected_movement := "patrol" var detected_movement := "linear" var hero_response := "chase" var was_detected := false var current_movement_behavior := "" var hop_move_remaining := 0.0 var hop_rest_remaining := 0.0 var hop_direction := Vector2.ZERO var knockback_velocity := Vector2.ZERO var telegraphing := false var windup_remaining := 0.0 var attack_direction := Vector2.RIGHT var attack_state := "idle" var attack_origin_offset := Vector2.ZERO var attack_world_origin := Vector2.ZERO var attack_grace_remaining := 0.0 var attack_active_remaining := 0.0 var attack_tick_remaining := 0.0 var attack_distance_travelled := 0.0 var attack_windup_travelled := 0.0 var attack_current_speed := 0.0 var attack_has_hit := false var attack_strafe_sign := 1.0 var attack_visual_offset := Vector2.ZERO var direct_flash_remaining := 0.0 var effects: EffectHolder # Porteur d'image. Tant qu'aucun visuel n'est déclaré, il ne montre rien et la # créature dessine sa forme comme avant. var visuals: VisualHolder var speech_bubble: Node2D var _visual_state := "" func _ready() -> void: # Donjon vu du dessus: chaque contact est un mur le long duquel le corps doit # pouvoir glisser. Le mode grounded introduit une logique de sol/pente qui # peut coincer deux CharacterBody2D l'un contre l'autre. motion_mode = CharacterBody2D.MOTION_MODE_FLOATING effects = EffectHolder.new() effects.name = "EffectHolder" add_child(effects) add_to_group("enemy") _apply_collision_shape() queue_redraw() func setup(id: String) -> void: var definition: Dictionary = DataCatalog.ENEMIES.get(id, {}) if definition.is_empty(): push_error("Unknown enemy: " + id) return enemy_id = id max_health = float(definition.health) health = max_health move_speed = float(definition.speed) direct_hit_enabled = bool(definition.get("direct_hit", false)) direct_damage_min = float(definition.get("direct_damage_min", 0.0)) direct_damage_max = maxf(direct_damage_min, float(definition.get("direct_damage_max", direct_damage_min))) direct_hit_cooldown_duration = float(definition.get("direct_hit_cooldown", 1.0)) direct_hit_knockback = float(definition.get("direct_hit_knockback", 240.0)) attacks = definition.get("attacks", []).duplicate(true) sight_range = float(definition.get("detection_range", 240.0)) detection_angle = float(definition.get("detection_angle", 180.0)) undetected_movement = str(definition.get("undetected_movement", "patrol")) detected_movement = str(definition.get("detected_movement", "linear")) hero_response = str(definition.get("hero_response", "chase")) hurtbox_length = maxf(0.0, float(definition.get("hurtbox_length", 0.0))) hurtbox_body_radius = maxf(0.0, float(definition.get("hurtbox_radius", 0.0))) attack_pattern_id = "" attack_animation_clip = "" knockback_resistance = clampf(float(definition.get("knockback_resistance", 0.0)), 0.0, 0.9) body_color = definition.color body_shape = str(definition.get("shape", "circle")) var minimum_size := clampf(float(definition.get("min_size", 1.5)), 0.25, 5.0) var maximum_size := maxf(minimum_size, clampf(float(definition.get("max_size", 2.0)), 0.25, 5.0)) size_multiplier = randf_range(minimum_size, maximum_size) _apply_collision_shape() _build_visual_holder() _build_speech_bubble(definition.get("speech_bubbles", {})) say("spawn") _choose_wander_direction() queue_redraw() func _build_speech_bubble(config: Variant) -> void: if speech_bubble != null and is_instance_valid(speech_bubble): speech_bubble.queue_free() speech_bubble = SPEECH_BUBBLE_SCRIPT.new() speech_bubble.name = "SpeechBubble" add_child(speech_bubble) var safe_config: Dictionary = config if config is Dictionary else {} speech_bubble.call("bind_to_actor", self, safe_config, Vector2(0.0, -body_radius() - 12.0)) func say(trigger_id: String) -> bool: return speech_bubble != null and is_instance_valid(speech_bubble) \ and bool(speech_bubble.call("trigger_event", trigger_id)) func _build_visual_holder() -> void: if visuals == null: visuals = VisualHolder.new() visuals.name = "Visuals" add_child(visuals) visuals.bind("enemies", enemy_id) # L'agrandissement appartient à la créature, pas à l'image. Le porteur ignore # ce qu'est un monstre, et le `scale` du visuel reste ce qu'il doit être: le # calage d'une image sur un corps de référence. Les deux se multiplient. # Sans ça, deux bêtes de hitbox différente portent la même image et # l'agrandissement cesse de se voir dès qu'un art est déclaré. visuals.scale = Vector2.ONE * size_multiplier _visual_state = "" _refresh_visual_state() # L'état visuel se déduit de ce que la créature fait déjà: aucun drapeau neuf à # tenir à jour, donc aucun risque qu'il mente. func _current_visual_state() -> String: if health <= 0.0: return "death" if direct_flash_remaining > 0.0 or knockback_velocity.length_squared() > 400.0: return "hurt" if attack_state != "idle": return "attack" if velocity.length_squared() > 100.0: return "walk" return "idle" func _refresh_visual_state() -> void: if visuals == null or not is_instance_valid(visuals): return var wanted := _current_visual_state() if wanted != _visual_state: _visual_state = wanted visuals.show_state(wanted) visuals.face(look_direction) # La forme procédurale ne se dessine que faute d'image. Extraite du `_draw` # pour être vérifiable: un test headless ne compte pas les pixels. func should_draw_body() -> bool: return visuals == null or not visuals.is_showing() # Rayon réel du corps, agrandissement compris. C'est ce que les tests de portée # doivent viser: un monstre est une surface, pas un point. const BODY_BASE_RADIUS := 18.0 func body_radius() -> float: return BODY_BASE_RADIUS * size_multiplier # --- La hurtbox --------------------------------------------------------------- # # `body_radius()` reste le **corps physique** rond: la collision, les décalages # de tir et l'emprise 3D continuent de le lire. La hurtbox est autre chose — ce # qu'une attaque doit toucher — et elle est écrite dans la donnée, jamais déduite # du modèle affiché: retoucher l'art ne doit pas rééquilibrer le jeu. # # Longueur nulle = un cercle, donc le comportement d'avant, au pixel près. func hurtbox_half_length() -> float: return maxf(0.0, hurtbox_length * size_multiplier) * 0.5 func hurtbox_radius() -> float: var authored := hurtbox_body_radius * size_multiplier if hurtbox_body_radius > 0.0 else body_radius() return maxf(1.0, authored + GameState.hurtbox_margin()) func hurtbox_forward() -> Vector2: return look_direction if look_direction.length_squared() > 0.001 else Vector2.DOWN func _physics_process(delta: float) -> void: if not active: return attack_cooldown_remaining = maxf(0.0, attack_cooldown_remaining - delta) for cooling_index: int in attack_cooldowns: attack_cooldowns[cooling_index] = maxf(0.0, float(attack_cooldowns[cooling_index]) - delta) direct_hit_cooldown_remaining = maxf(0.0, direct_hit_cooldown_remaining - delta) var direct_flash_was_active := direct_flash_remaining > 0.0 direct_flash_remaining = maxf(0.0, direct_flash_remaining - delta) knockback_velocity = knockback_velocity.move_toward(Vector2.ZERO, 900.0 * delta) if target == null or not is_instance_valid(target): target = get_tree().get_first_node_in_group("player") as Player if target == null: velocity = Vector2.ZERO return var offset := target.global_position - global_position var distance := offset.length() var reach := reach_to_target(distance) var detected := _can_detect_target(offset, distance) var movement_allowed := effects == null or not effects.has_flag("can_move") var attack_allowed := effects == null or not effects.has_flag("can_attack") if not attack_allowed and attack_state != "idle": _interrupt_attack() if attack_allowed and direct_hit_enabled and direct_hit_cooldown_remaining <= 0.0 and _bodies_touch(distance): _perform_direct_hit(offset) var desired_velocity := Vector2.ZERO if attack_state != "idle": var attack_velocity := _advance_attack(delta) desired_velocity = _desired_movement(delta, detected, offset, distance) \ if attack_free_movement else attack_velocity elif attack_allowed and detected and attack_cooldown_remaining <= 0.0 and _try_begin_weighted_attack(offset, reach): desired_velocity = Vector2.ZERO else: desired_velocity = _desired_movement(delta, detected, offset, distance) # Une secouade de preparation recule en tremblant, mais la creature garde # sa cible en joue: son regard ne suit pas son recul. La maniere du bloc # courant ne se lit qu'apres l'avance de la frise. if attack_move_manner == "shake": look_direction = attack_direction elif desired_velocity.length_squared() > 0.01: look_direction = desired_velocity.normalized() if not movement_allowed: desired_velocity = Vector2.ZERO elif effects != null: desired_velocity *= effects.movement_multiplier() velocity = desired_velocity + knockback_velocity move_and_slide() _resolve_blocked_charge() if attack_zone_anchored and attack_state != "idle": attack_origin_offset = attack_world_origin - global_position if get_slide_collision_count() > 0 and not detected: _choose_wander_direction() # Redraw once more on the exact frame the direct-hit flash expires. Without # this transition redraw, CanvasItem keeps displaying its previous circle. _refresh_visual_state() if telegraphing or direct_flash_remaining > 0.0 or direct_flash_was_active: queue_redraw() func _resolve_blocked_charge() -> bool: if attack_state != "active" or attack_move_manner != "charge": return false var charge_direction := attack_movement_warning_direction() if charge_direction.length_squared() <= 0.001: return false for collision_index: int in range(get_slide_collision_count()): var collision := get_slide_collision(collision_index) # Un frottement lateral ne doit pas annuler la charge. Seul un obstacle # oppose a son mouvement constitue un impact frontal. if charge_direction.dot(collision.get_normal()) >= -0.25: continue if collision.get_collider() == target and not attack_has_hit: attack_has_hit = _try_attack_hit(attack_origin_offset, attack_direction) _finish_attack() velocity = Vector2.ZERO return true return false # Allonge restante jusqu'au corps du héros. Le héros est une surface, pas un # point: une créature collée à lui se croyait hors d'atteinte et ne frappait # jamais. func reach_to_target(distance: float) -> float: if target == null or not is_instance_valid(target): return distance return distance - target.body_radius() func _bodies_touch(distance: float) -> bool: if target == null or not is_instance_valid(target): return false return distance <= body_radius() + target.body_radius() + 2.0 static func choose_weighted_attack(options: Array, roll: float) -> Dictionary: var total := 0.0 for option: Variant in options: if option is Dictionary: total += maxf(0.0, float(option.get("weight", 0.0))) if total <= 0.0: return {} var threshold := clampf(roll, 0.0, 0.999999) * total for option: Variant in options: if not option is Dictionary: continue threshold -= maxf(0.0, float(option.get("weight", 0.0))) if threshold < 0.0: return option.duplicate(true) return options.back().duplicate(true) if not options.is_empty() else {} func _try_begin_weighted_attack(offset: Vector2, reach: float) -> bool: if offset.length_squared() <= 0.001: return false # Le plancher de la banque se verifie ici, avec le reste de la decision. # Le laisser au seul appelant partageait la regle en deux endroits, dont un # que rien ne pouvait eprouver. if attack_cooldown_remaining > 0.0: return false var available: Array = [] for raw_attack: Variant in attacks: if not raw_attack is Dictionary or float(raw_attack.get("weight", 0.0)) <= 0.0: continue var pattern: Dictionary = DataCatalog.ATTACK_PATTERNS.get(str(raw_attack.get("pattern", "")), {}) # La distance de declenchement decide seule du depart. Elle valait la # portee de la zone, ce qui obligeait a etaler le cone pour s'elancer # de loin: la zone qui blesse suivait la distance d'approche. if pattern.is_empty(): continue # Sans distance ecrite, on retombe sur la plus longue zone qui blesse # dans la frise: avec plusieurs coups, la portee du pattern ne veut # plus rien dire a elle seule. var pattern_reach: float = AttackTimeline.of(pattern).get("hit_reach", 0.0) var pattern_trigger := float(pattern.get("trigger_range", 0.0)) # Une ligne encore en recharge ne se propose pas. Le plancher, lui, a # deja ete verifie par l'appelant: il vaut pour toutes les lignes. if float(attack_cooldowns.get(attacks.find(raw_attack), 0.0)) > 0.0: continue if reach <= (pattern_trigger if pattern_trigger > 0.0 else pattern_reach): available.append(raw_attack) var chosen := choose_weighted_attack(available, randf()) if chosen.is_empty(): return false attack_definition_index = attacks.find(chosen) _apply_attack_definition(chosen) _begin_attack(offset) return true func _apply_attack_definition(definition: Dictionary) -> void: attack_pattern_id = str(definition.get("pattern", "")) attack_animation_clip = str(definition.get("animation", "")) var pattern: Dictionary = DataCatalog.ATTACK_PATTERNS.get(attack_pattern_id, {}) attack_mode = str(pattern.get("type", "burst")) attack_area_shape = str(pattern.get("area_shape", "sector")) attack_area_width = float(pattern.get("area_width", 0.0)) attack_show_hit_warning = bool(pattern.get("show_hit_warning", true)) attack_show_movement_warning = bool(pattern.get("show_movement_warning", true)) attack_type = "frontal" attack_range = float(pattern.get("range", 44.0)) attack_trigger_range = float(pattern.get("trigger_range", 0.0)) attack_angle = float(pattern.get("angle", 90.0)) windup_duration = float(pattern.get("windup", 0.3)) attack_active_duration = float(pattern.get("active_duration", 0.0)) attack_rotation_speed = float(pattern.get("rotation_speed", 0.0)) attack_tick_interval = float(pattern.get("tick_interval", 0.0)) attack_tracking_speed = float(pattern.get("tracking_speed", 0.0)) attack_escape_grace_duration = float(pattern.get("escape_grace_duration", 0.0)) attack_visual_color = pattern.get("color", Color("ff4d6d")) attack_movement_type = str(pattern.get("movement_type", "stationary")) attack_movement_distance = float(pattern.get("movement_distance", 0.0)) attack_movement_speed = float(pattern.get("movement_speed", 0.0)) attack_movement_acceleration = float(pattern.get("movement_acceleration", 0.0)) attack_movement_windup_distance = float(pattern.get("movement_windup_distance", 0.0)) attack_movement_arc_height = float(pattern.get("movement_arc_height", 0.0)) # Un pattern ecrit a plat est compile en frise: un seul lecteur joue les # deux modes, et la compatibilite est une consequence, pas une promesse. attack_timeline = AttackTimeline.of(pattern) attack_rest_zone = {"area_shape": attack_area_shape, "range": attack_range, "angle": attack_angle, "area_width": attack_area_width} attack_free_movement = bool(pattern.get("free_movement", attack_movement_type == "unaffected")) attack_delivery = str(pattern.get("delivery", "direct")) attack_projectile_id = str(pattern.get("projectile", "")) attack_damage_min = float(definition.get("damage_min", 0.0)) attack_damage_max = maxf(attack_damage_min, float(definition.get("damage_max", attack_damage_min))) attack_knockback = float(definition.get("knockback", 0.0)) attack_interrupt_chance = clampf(float(definition.get("interrupt_chance", 100.0)), 0.0, 100.0) attack_cooldown_duration = float(definition.get("cooldown", 1.0)) # Sans valeur declaree, le plancher vaut la recharge de l'attaque: c'est # exactement ce que faisait une attente unique. attack_cooldown_min_duration = float(definition.get("cooldown_min", attack_cooldown_duration)) func _begin_attack(offset: Vector2) -> void: if offset.length_squared() <= 0.001: return attack_state = "windup" telegraphing = true windup_remaining = windup_duration attack_direction = offset.normalized() look_direction = attack_direction attack_zone_anchored = AttackTimeline.starts_anchored(attack_timeline) attack_origin_offset = offset if attack_zone_anchored else Vector2.ZERO attack_world_origin = global_position + attack_origin_offset attack_target_context = AttackTargeting.acquire(self, DataCatalog.ATTACK_PATTERNS.get(attack_pattern_id, {}), attack_direction, global_position + offset) if attack_target_context.get("mode", "aim") != "aim": var selected_offset: Vector2 = attack_target_context.point - global_position if selected_offset.length_squared() > 0.001: attack_direction = selected_offset.normalized() look_direction = attack_direction if attack_zone_anchored: attack_world_origin = attack_target_context.point attack_origin_offset = attack_world_origin - global_position attack_grace_remaining = 0.0 attack_distance_travelled = 0.0 attack_windup_travelled = 0.0 attack_current_speed = 0.0 if attack_movement_type == "charge" and attack_movement_acceleration > 0.0 else attack_movement_speed attack_has_hit = false attack_strafe_sign = -1.0 if randf() < 0.5 else 1.0 attack_visual_offset = Vector2.ZERO attack_cursor = AttackTimeline.begin() attack_armored = false attack_invulnerable = false attack_move_remaining = 0.0 attack_move_manner = "" queue_redraw() # Une image de la sequence: le lecteur dit ce qui doit arriver, la creature # l'applique. Aucune decision de deroule ne se prend ici. func _advance_attack(delta: float) -> Vector2: var plan := AttackTimeline.advance(attack_cursor, attack_timeline, delta, attack_direction.orthogonal()) attack_state = str(plan["state"]) telegraphing = bool(plan["telegraphing"]) attack_armored = bool(plan["armored"]) attack_invulnerable = bool(plan["invulnerable"]) # Un balayage tourne sa zone; tout le reste garde le cap pris au depart. var rotation_step := float(plan["rotation_step"]) if not is_zero_approx(rotation_step): attack_direction = attack_direction.rotated(rotation_step) look_direction = attack_direction _apply_attack_zone(plan["zone"], delta) for block: Dictionary in plan["hits"]: _apply_attack_zone(block, 0.0) attack_has_hit = _try_attack_hit(attack_origin_offset, attack_direction, float(block.get("damage_scale", 1.0))) or attack_has_hit var attack_velocity := _apply_attack_move(plan, delta) attack_visual_offset = plan["visual_offset"] if visuals != null and is_instance_valid(visuals): visuals.position = attack_visual_offset if bool(plan["finished"]): _finish_attack() return Vector2.ZERO queue_redraw() return attack_velocity # La zone que le dessin et le pont doivent montrer maintenant. Une zone posee # dans le monde garde son origine pendant que la creature bouge, et rampe vers # la cible tant que son bloc lui en donne la vitesse: c'est ce qui remplace la # « zone poursuivante » comme type d'attaque. # La zone publiee appartient au bloc en cours. La sequence finie, elle doit # redevenir celle de la fiche: sinon le dernier bloc joue laisse sa portee et # son angle en place, et tout ce qui les lit ensuite mesure autre chose. func _restore_rest_zone() -> void: if attack_rest_zone.is_empty(): return attack_area_shape = str(attack_rest_zone["area_shape"]) attack_range = float(attack_rest_zone["range"]) attack_area_width = float(attack_rest_zone["area_width"]) attack_zone_anchored = false attack_origin_offset = Vector2.ZERO attack_angle = float(attack_rest_zone["angle"]) func _apply_attack_zone(zone: Dictionary, delta: float) -> void: if zone.is_empty(): attack_origin_offset = Vector2.ZERO return var was_anchored := attack_zone_anchored attack_zone_anchored = str(zone.get("anchor", "actor")) == "world" attack_area_shape = str(zone.get("area_shape", attack_area_shape)) attack_range = float(zone.get("range", attack_range)) attack_angle = float(zone.get("angle", attack_angle)) attack_area_width = float(zone.get("area_width", attack_area_width)) if zone.has("delivery"): attack_delivery = str(zone["delivery"]) attack_projectile_id = str(zone.get("projectile", "")) if not attack_zone_anchored: attack_origin_offset = Vector2.ZERO return # Le point du monde est UNIQUE et persistant: la poursuite le pousse, un # bloc verrouille le laisse ou il est, et le coup le relit. C'est ce # point, et non une copie, qui relie l'avertissement a l'impact. # # Il se plante quand la premiere zone ancree commence. Sans cela une # zone posee plus tard dans la frise retombait la ou la creature se # tenait au depart de l'attaque, soit n'importe ou. var destination_point := AttackTargeting.destination(attack_target_context, self, (target.global_position if is_instance_valid(target) else attack_world_origin)) if not was_anchored: attack_world_origin = destination_point attack_world_origin = AttackTargeting.follow(attack_world_origin, destination_point, zone, delta) attack_origin_offset = attack_world_origin - global_position # Le deplacement de l'image. La direction est un angle autour du regard: 0 va # vers la cible, 180 s'en eloigne, 90 passe a cote. Un bloc « en miroir » tire # son cote au sort, comme le pas de cote le faisait. func _apply_attack_move(plan: Dictionary, delta: float) -> Vector2: attack_move_manner = str(plan["manner"]) attack_move_remaining = float(plan["remaining"]) var mirror := attack_strafe_sign if bool(plan["mirror"]) else 1.0 attack_move_direction = attack_direction.rotated(deg_to_rad(float(plan["angle"]) * mirror)) var step := float(plan["step"]) if not bool(plan["moving"]) or step <= 0.0: return Vector2.ZERO attack_distance_travelled += step return attack_move_direction * step / maxf(delta, 0.0001) # La frise deplace-t-elle l'acteur? La question ne porte plus sur un nom de # mouvement mais sur la presence d'un bloc. func _attack_pattern_moves_actor() -> bool: return not (attack_timeline.get("moves", []) as Array).is_empty() func attack_movement_warning_direction() -> Vector2: return attack_move_direction if attack_move_direction.length_squared() > 0.001 else attack_direction func attack_movement_warning_distance() -> float: return attack_move_remaining func attack_movement_warning_width() -> float: return body_radius() * 2.0 # Le decalage porte deux choses distinctes: la cloche d'un bond, qui est une # hauteur, et le tremblement d'une secouade, qui reste au sol. Le pont 3D a # besoin de savoir laquelle, sinon une creature qui tremble decolle. func attack_visual_offset_is_elevation() -> bool: return attack_move_manner == "leap" func _perform_direct_hit(offset: Vector2) -> void: if offset.length_squared() <= 0.001: return attack_direction = offset.normalized() look_direction = attack_direction direct_hit_cooldown_remaining = direct_hit_cooldown_duration direct_flash_remaining = 0.12 target.take_damage(_roll_damage(direct_damage_min, direct_damage_max), attack_direction, direct_hit_knockback) GameState.add_entity_counter("enemies", enemy_id, "times_damaged_player") Registry.run_hook("enemies", enemy_id, "on_damage_player", self) queue_redraw() # Livraison de l'impact. Le pattern decide: une zone resolue sur place, ou une # volee lancee dans la meme direction. La regle est celle du heros, appliquee # dans l'autre sens. # `damage_scale` laisse les degats sur la fiche du monstre: un coup d'une # frise en applique une part. Cinq coups a 0.2 rendent exactement ce que la # fiche annonce. func _try_attack_hit(origin_offset: Vector2, direction: Vector2, damage_scale: float = 1.0) -> bool: if target == null or not is_instance_valid(target): return false if _attack_uses_projectiles(): return _spawn_projectile_volley(origin_offset, direction) var world_origin := global_position + origin_offset var offset := target.global_position - world_origin var distance := offset.length() # Même règle que pour l'arme du joueur, dans l'autre sens: une zone qui mord # le flanc du héros touche, même si son centre reste hors d'atteinte. var inside_attack := AttackGeometry.capsule_overlaps(attack_area_shape, world_origin, direction, attack_range, attack_angle, attack_area_width, target.global_position, target.hurtbox_forward(), target.hurtbox_half_length(), target.hurtbox_radius()) if inside_attack: var knockback_direction := (target.global_position - global_position).normalized() if knockback_direction.length_squared() <= 0.001: knockback_direction = direction target.take_damage(_roll_damage(attack_damage_min, attack_damage_max) * damage_scale, knockback_direction, attack_knockback) _apply_attack_effects_to_target(target) GameState.add_entity_counter("enemies", enemy_id, "times_damaged_player") Registry.run_hook("enemies", enemy_id, "on_damage_player", self) return true return false # La livraison appartient au coup, pas au pattern: une frise a cinq coups # peut meler une zone de griffe et une volee crachee en reculant. func _attack_uses_projectiles() -> bool: return attack_delivery == "projectile" func _attack_projectile_definition() -> Dictionary: if attack_delivery != "projectile": return {} var value: Variant = DataCatalog.PROJECTILES.get(attack_projectile_id, {}) return value if value is Dictionary else {} # Ou partent les tirs d'une salve. Meme repartition que pour le heros: la # dispersion est un angle total partage entre les projectiles. func projectile_warning_directions(direction: Vector2) -> Array[Vector2]: var result: Array[Vector2] = [] var projectile := _attack_projectile_definition() if projectile.is_empty(): return result var forward := direction.normalized() if direction.length_squared() > 0.001 else Vector2.DOWN var count := maxi(1, int(projectile.get("count", 1))) var spread := float(projectile.get("spread_angle", 0.0)) for index: int in range(count): var ratio := 0.5 if count == 1 else float(index) / float(count - 1) result.append(forward.rotated(deg_to_rad(lerpf(-spread * 0.5, spread * 0.5, ratio)))) return result func projectile_warning_radius() -> float: return maxf(1.0, float(_attack_projectile_definition().get("radius", 8.0))) func projectile_warning_distance() -> float: var projectile := _attack_projectile_definition() if projectile.is_empty(): return 0.0 return maxf(1.0, float(projectile.get("speed", 1.0))) * maxf(0.05, float(projectile.get("lifetime", 1.0))) func _spawn_projectile_volley(origin_offset: Vector2, direction: Vector2) -> bool: var projectile := _attack_projectile_definition() if projectile.is_empty() or get_parent() == null: return false var pattern: Dictionary = DataCatalog.ATTACK_PATTERNS.get(attack_pattern_id, {}) var radius := maxf(1.0, float(projectile.get("radius", 8.0))) var forward := direction.normalized() if direction.length_squared() > 0.001 else Vector2.DOWN var origin := global_position + origin_offset + forward * (body_radius() + radius + 2.0) for shot_direction: Vector2 in projectile_warning_directions(forward): var shot := ENEMY_PROJECTILE_SCRIPT.new() as Node2D shot.position = origin shot.setup({ "direction": shot_direction, "speed": float(projectile.get("speed", 400.0)), "radius": radius, "lifetime": float(projectile.get("lifetime", 1.0)), "damage": _roll_damage(attack_damage_min, attack_damage_max), "knockback": attack_knockback, "type": str(projectile.get("type", "orb")), "combat_effects": pattern.get("combat_effects", []), "color": projectile.get("color", attack_visual_color), "source": self, "source_id": enemy_id, }) get_parent().add_child(shot) return true func _apply_attack_effects_to_target(target_node: Node) -> void: if target_node == null or not target_node.has_method("apply_combat_effects"): return var pattern: Dictionary = DataCatalog.ATTACK_PATTERNS.get(attack_pattern_id, {}) target_node.call("apply_combat_effects", pattern.get("combat_effects", []), "enemy:%s" % enemy_id) func apply_combat_effects(assignments: Variant, source_id: String = "") -> int: if effects == null or not assignments is Array: return 0 var applied := 0 for assignment: Variant in assignments: if assignment is Dictionary and effects.try_apply( str((assignment as Dictionary).get("effect", "")), float((assignment as Dictionary).get("chance", 0.0)), source_id): applied += 1 if effects.has_flag("can_attack") and attack_state != "idle": _interrupt_attack() return applied func _finish_attack() -> void: _restore_rest_zone() attack_state = "idle" attack_armored = false attack_invulnerable = false attack_move_remaining = 0.0 attack_move_manner = "" attack_zone_anchored = false telegraphing = false windup_remaining = 0.0 attack_active_remaining = 0.0 attack_grace_remaining = 0.0 attack_origin_offset = Vector2.ZERO attack_world_origin = Vector2.ZERO attack_distance_travelled = 0.0 attack_visual_offset = Vector2.ZERO if visuals != null and is_instance_valid(visuals): visuals.position = Vector2.ZERO attack_cooldown_remaining = attack_cooldown_min_duration if attack_definition_index >= 0: attack_cooldowns[attack_definition_index] = attack_cooldown_duration attack_definition_index = -1 attack_pattern_id = "" attack_animation_clip = "" queue_redraw() func current_attack_animation_clip() -> String: return attack_animation_clip if attack_state != "idle" else "" func _can_detect_target(offset: Vector2, distance: float) -> bool: if distance > sight_range or offset.length_squared() <= 0.001: return false if detection_angle >= 359.5: return true return look_direction.dot(offset.normalized()) >= cos(deg_to_rad(detection_angle * 0.5)) func _desired_movement(delta: float, detected: bool, offset: Vector2, distance: float) -> Vector2: var behavior := detected_movement if detected else undetected_movement if detected != was_detected or behavior != current_movement_behavior: hop_move_remaining = 0.0 hop_rest_remaining = 0.0 current_movement_behavior = behavior was_detected = detected var direction := wander_direction if detected: match hero_response: "chase": direction = offset.normalized() "avoid": direction = -offset.normalized() "ignore": direction = wander_direction return _movement_for_behavior(behavior, direction, detected, delta) func _movement_for_behavior(behavior: String, direction: Vector2, detected: bool, delta: float) -> Vector2: var pattern: Dictionary = DataCatalog.MOVEMENT_PATTERNS.get(behavior, {}) if pattern.is_empty(): return Vector2.ZERO var pattern_type := str(pattern.get("type", "static")) var speed_multiplier := float(pattern.get("detected_speed_multiplier", 1.0)) if detected else float(pattern.get("speed_multiplier", 1.0)) match pattern_type: "static": return Vector2.ZERO "linear": return direction * move_speed * speed_multiplier "patrol": if not detected or hero_response == "ignore": wander_timer -= delta if wander_timer <= 0.0: _choose_wander_direction(pattern) return wander_direction * move_speed * speed_multiplier return direction * move_speed * speed_multiplier "hop": return _hop_velocity(direction, delta, speed_multiplier, float(pattern.get("move_duration", 0.18)), float(pattern.get("rest_duration", 0.42))) _: return Vector2.ZERO func _hop_velocity(direction: Vector2, delta: float, speed_multiplier: float, move_duration: float, rest_duration: float) -> Vector2: if hop_move_remaining > 0.0: hop_move_remaining -= delta return hop_direction * move_speed * speed_multiplier if hop_rest_remaining > 0.0: hop_rest_remaining -= delta return Vector2.ZERO hop_direction = direction.normalized() if hop_direction.length_squared() <= 0.001: _choose_wander_direction() hop_direction = wander_direction hop_move_remaining = move_duration hop_rest_remaining = rest_duration return hop_direction * move_speed * speed_multiplier func _roll_damage(minimum_damage: float, maximum_damage: float) -> float: return maxf(0.0, randf_range(minimum_damage, maxf(minimum_damage, maximum_damage))) func _choose_wander_direction(pattern: Dictionary = {}) -> void: var minimum := float(pattern.get("direction_change_min", 1.2)) var maximum := maxf(minimum, float(pattern.get("direction_change_max", 3.2))) wander_timer = randf_range(minimum, maximum) if randf() < float(pattern.get("idle_chance", 0.22)): wander_direction = Vector2.ZERO else: wander_direction = Vector2.from_angle(randf_range(0.0, TAU)) func take_damage(amount: float, knockback_direction: Vector2 = Vector2.ZERO, knockback_force: float = 0.0, source: Node = null) -> void: if not active: return # Intouchable: le coup ne passe pas, mais il doit se VOIR refuse. Un coup # qui ne fait rien sans rien montrer se lit comme un bug. if attack_invulnerable: _spawn_damage_number(0.0, Color("cfd6e4")) return health -= amount _spawn_damage_number(amount, Color("ffe066")) # Cuirasse: la sequence continue. Le jet d'interruption ne vaut qu'en # dehors des fenetres, la fenetre l'emporte quand elle couvre l'instant. if source is Player and attack_state != "idle" and not attack_armored \ and interruption_roll_succeeds(attack_interrupt_chance, randf()): _interrupt_attack() # Une creature cuirassee garde aussi son elan: une charge qui conserve son # etat mais se fait pousser de cote a l'air cassee. if knockback_direction.length_squared() > 0.001 and knockback_force > 0.0 and not attack_armored: knockback_velocity = knockback_direction.normalized() * knockback_force * (1.0 - knockback_resistance) # Encaisser un coup est un événement comme un autre: il porte ses effets # déclaratifs et ce qu'on en voit. Le clignotement blanc reste le défaut, # et ne joue que si rien de mieux n'est déclaré. Registry.run_hook("enemies", enemy_id, "on_hurt", self) if Vfx.visual_for("enemies", enemy_id, "on_hurt").is_empty(): var tween := create_tween() tween.tween_property(self, "modulate", Color.WHITE, 0.05) tween.tween_property(self, "modulate", body_color, 0.10) if health <= 0.0: die() queue_redraw() ## --- Journal des jets de butin ----------------------------------------- ## ## Un cumul par ingrédient et les derniers jets, affichés sous F3. Il ne ## décide de rien: il enregistre ce qui vient d'être tiré, valeur brute ## comprise, parce que c'est elle qui dit si le tirage balaie l'intervalle ## ou retombe sur quelques valeurs. ## ## Il vit ici plutôt que dans une classe à lui: un `class_name` neuf ## n'existe pas pour un lancement hors éditeur tant que le cache de classes ## n'a pas été régénéré, et tout ce qui le nommerait tomberait avec lui. const DROP_LOG_MAX_ENTRIES := 10 ## Les derniers jets, du plus ancien au plus récent. static var drop_log_entries: Array[String] = [] ## Par ingrédient: [jets, réussites, pourcentage déclaré]. static var drop_log_totals: Dictionary = {} static func record_drop_roll(ingredient_id: String, chance_percent: float, roll: float, succeeded: bool) -> void: drop_log_entries.push_back("%s %.1f%% ← %.5f %s" % [ingredient_id, chance_percent, roll, "OUI" if succeeded else "non"]) while drop_log_entries.size() > DROP_LOG_MAX_ENTRIES: drop_log_entries.remove_at(0) var tally: Array = drop_log_totals.get(ingredient_id, [0, 0, chance_percent]) tally[0] = int(tally[0]) + 1 tally[1] = int(tally[1]) + (1 if succeeded else 0) tally[2] = chance_percent drop_log_totals[ingredient_id] = tally ## Le cumul par ingrédient: c'est lui qui répond à « le tirage tient-il sa ## fréquence », la liste des derniers jets ne servant qu'à le voir vivre. static func drop_log_summary() -> Array[String]: var lines: Array[String] = [] for ingredient_id: String in drop_log_totals: var tally: Array = drop_log_totals[ingredient_id] var rolls := int(tally[0]) lines.append("%s: %d/%d = %.1f%% (déclaré %.1f%%)" % [ ingredient_id, int(tally[1]), rolls, 100.0 * float(tally[1]) / float(maxi(1, rolls)), float(tally[2]), ]) return lines ## Rend la creature immediatement prete a decider: le plancher de la banque ## et l'attente propre de chaque ligne. Lever le seul plancher ne suffit plus ## depuis que chaque ligne porte la sienne. func clear_attack_cooldowns() -> void: attack_cooldown_remaining = 0.0 attack_cooldowns.clear() static func drop_roll_succeeds(chance_percent: float, roll: float) -> bool: if chance_percent <= 0.0: return false if chance_percent >= 100.0: return true return roll * 100.0 < chance_percent static func interruption_roll_succeeds(chance_percent: float, roll: float) -> bool: if chance_percent <= 0.0: return false if chance_percent >= 100.0: return true return roll * 100.0 < chance_percent ## Transforme un multiplicateur continu en nombre entier de jets sans biaiser ## son espérance: 1,25 produit toujours un jet, puis un second dans 25 % des cas. static func drop_attempt_count(count_modifier: float, remainder_roll: float) -> int: var safe_modifier := maxf(0.0, count_modifier) var guaranteed_attempts := floori(safe_modifier) var remainder := safe_modifier - float(guaranteed_attempts) return guaranteed_attempts + (1 if remainder_roll < remainder else 0) func die() -> void: active = false collision_layer = 0 if say("death"): speech_bubble.call("detach_from_actor") # Every enemy feeds its own counter automatically. No per-monster code. GameState.record_entity_event("enemies", enemy_id, "total_killed", "enemies_defeated") Registry.run_hook("enemies", enemy_id, "on_kill", self) var drops: Array = DataCatalog.ENEMIES[enemy_id].drops var count_modifier := float(GameState.resolve_game_variable("drop_count_multiplier")) var angle := 0.0 for drop: Dictionary in drops: var ingredient_id := str(drop.get("ingredient_id", "")) var attempt_count := drop_attempt_count(count_modifier, randf()) for _attempt: int in range(attempt_count): var chance := float(drop.get("chance", 0.0)) var roll := randf() var succeeded := drop_roll_succeeds(chance, roll) # Le journal enregistre le refus autant que la réussite: sans les # deux, un pourcentage observé ne veut rien dire. record_drop_roll(ingredient_id, chance, roll, succeeded) if not succeeded: continue var pickup := PICKUP_SCENE.instantiate() pickup.setup(ingredient_id) # The pickup starts its pop tween in _ready(), which runs as soon as it # enters the tree. Set its local position first so that tween destination # is calculated around the enemy's death point instead of map origin. pickup.position = position + Vector2.from_angle(angle) * 10.0 get_parent().add_child(pickup) angle += 1.8 defeated.emit(self) var tween := create_tween() tween.tween_property(self, "scale", Vector2.ZERO, 0.16).set_trans(Tween.TRANS_BACK) tween.tween_callback(queue_free) func _draw() -> void: # La branche 3D donne un corps à cette entité: son dessin 2D se tait. # Une ligne, et aucune règle ne bouge — la 2D reste l'autorité. if Render3D.active: return if telegraphing and attack_show_hit_warning: var warning_color := attack_visual_color warning_color.a = 0.48 if attack_state == "active" else 0.42 if attack_state == "grace" else 0.30 var warning_origin := attack_origin_offset if _attack_uses_projectiles(): var shot_radius := projectile_warning_radius() for shot_direction: Vector2 in projectile_warning_directions(attack_direction): var shot_origin := warning_origin + shot_direction * (body_radius() + shot_radius + 2.0) var shot_points := AttackGeometry.rectangle_points(shot_direction, projectile_warning_distance(), shot_radius * 2.0, shot_origin) draw_colored_polygon(shot_points, warning_color) draw_polyline(AttackGeometry.closed_points(shot_points), attack_visual_color.lightened(0.2), 2.0, true) elif attack_area_shape == "rectangle": var area_points := AttackGeometry.rectangle_points(attack_direction, attack_range, attack_area_width, warning_origin) draw_colored_polygon(area_points, warning_color) draw_polyline(AttackGeometry.closed_points(area_points), attack_visual_color.lightened(0.2), 2.0, true) elif attack_angle >= 359.5: draw_circle(warning_origin, attack_range, warning_color) draw_circle(warning_origin, attack_range, attack_visual_color.lightened(0.2), false, 2.0) else: var fan_points := _make_fan_points(attack_direction, attack_range, attack_angle, warning_origin) draw_colored_polygon(fan_points, warning_color) draw_polyline(_close_points(fan_points), attack_visual_color.lightened(0.2), 2.0, true) if telegraphing and attack_show_movement_warning and _attack_pattern_moves_actor(): var movement_distance := attack_movement_warning_distance() if movement_distance > 0.5: var movement_points := AttackGeometry.rectangle_points(attack_movement_warning_direction(), movement_distance, attack_movement_warning_width()) draw_colored_polygon(movement_points, Color(1.0, 0.82, 0.38, 0.16)) draw_polyline(AttackGeometry.closed_points(movement_points), Color(1.0, 0.91, 0.63, 0.72), 2.0, true) if direct_flash_remaining > 0.0: draw_circle(Vector2.ZERO, 25.0, attack_visual_color, false, 4.0) draw_set_transform(attack_visual_offset, 0.0, Vector2.ONE * size_multiplier) # L'image remplace le corps, et rien d'autre: télégraphe, barre de vie et # flash restent dessinés, parce qu'ils disent l'état du jeu, pas l'apparence. if should_draw_body(): _draw_body_shape() draw_circle(Vector2(-6, -3), 3.0, Color("241b35")) draw_circle(Vector2(6, -3), 3.0, Color("241b35")) if health < max_health: draw_rect(Rect2(-20, -29, 40, 5), Color("301934")) draw_rect(Rect2(-20, -29, 40 * clampf(health / max_health, 0.0, 1.0), 5), Color("ef233c")) draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) func _make_fan_points(direction: Vector2, radius: float, angle_degrees: float, origin: Vector2 = Vector2.ZERO) -> PackedVector2Array: var points := PackedVector2Array([origin]) var start_angle := direction.angle() - deg_to_rad(angle_degrees * 0.5) for index: int in range(13): var ratio := float(index) / 12.0 points.append(origin + Vector2.from_angle(start_angle + deg_to_rad(angle_degrees) * ratio) * radius) return points func _close_points(points: PackedVector2Array) -> PackedVector2Array: var closed := points.duplicate() if not closed.is_empty(): closed.append(closed[0]) return closed func _apply_collision_shape() -> void: var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D if collision != null: collision.shape = ShapeFactory.create_collision(body_shape, body_radius()) func _draw_body_shape() -> void: if body_shape == "circle": draw_circle(Vector2.ZERO, 18.0, body_color) draw_circle(Vector2.ZERO, 18.0, body_color.lightened(0.28), false, 2.0) return var points := ShapeFactory.polygon_points(body_shape, 18.0) draw_colored_polygon(points, body_color) draw_polyline(ShapeFactory.closed_polygon_points(body_shape, 18.0), body_color.lightened(0.28), 2.0, true) func _spawn_damage_number(amount: float, color: Color) -> void: if get_parent() == null: return var number := DAMAGE_NUMBER_SCENE.instantiate() number.setup(roundi(amount), color) number.float_above(self, randf_range(-8.0, 8.0), 26.0) get_parent().add_child(number) func _interrupt_attack() -> void: _restore_rest_zone() attack_state = "idle" attack_armored = false attack_invulnerable = false attack_move_remaining = 0.0 attack_move_manner = "" attack_zone_anchored = false telegraphing = false windup_remaining = 0.0 attack_active_remaining = 0.0 attack_grace_remaining = 0.0 attack_origin_offset = Vector2.ZERO attack_world_origin = Vector2.ZERO attack_distance_travelled = 0.0 attack_visual_offset = Vector2.ZERO if visuals != null and is_instance_valid(visuals): visuals.position = Vector2.ZERO # A successful knockback always forces a meaningful recovery window, even # if the enemy had not begun its next windup yet. attack_cooldown_remaining = maxf(attack_cooldown_remaining, attack_cooldown_min_duration * 0.65) if attack_definition_index >= 0: attack_cooldowns[attack_definition_index] = maxf( float(attack_cooldowns.get(attack_definition_index, 0.0)), attack_cooldown_duration * 0.65) attack_definition_index = -1 queue_redraw()