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") var enemy_id := "slime" var max_health := 30.0 var health := 30.0 var move_speed := 70.0 var contact_damage := 8.0 var sight_range := 240.0 var detection_angle := 180.0 var attack_range := 44.0 var attack_type := "frontal" var attack_pattern_id := "direct_contact" var attack_mode := "direct_hit" 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_visual_color := Color("ff4d6d") var hit_knockback := 240.0 var attack_cooldown_duration := 0.9 var knockback_resistance := 0.0 var damage_variance := 0.10 var body_color := Color.RED var body_shape := "circle" var size_multiplier := 1.0 var target: Player var contact_cooldown := 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_active_remaining := 0.0 var attack_tick_remaining := 0.0 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: 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) contact_damage = float(definition.damage) damage_variance = clampf(float(definition.get("damage_variance", 0.1)), 0.0, 1.0) 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")) attack_pattern_id = str(definition.get("attack_pattern", "direct_contact")) var pattern: Dictionary = DataCatalog.ATTACK_PATTERNS.get(attack_pattern_id, {}) if pattern.is_empty(): push_error("Unknown attack pattern: " + attack_pattern_id) return attack_mode = str(pattern.type) attack_type = "direct" if attack_mode == "direct_hit" else "frontal" attack_range = float(pattern.range) attack_angle = float(pattern.angle) windup_duration = float(pattern.windup) attack_cooldown_duration = float(pattern.cooldown) attack_active_duration = float(pattern.active_duration) attack_rotation_speed = float(pattern.rotation_speed) attack_tick_interval = float(pattern.tick_interval) attack_visual_color = pattern.color hit_knockback = float(definition.get("knockback", 240.0)) 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 func _physics_process(delta: float) -> void: if not active: return contact_cooldown = maxf(0.0, contact_cooldown - 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 desired_velocity := Vector2.ZERO if attack_state != "idle": _advance_attack(delta) desired_velocity = _desired_movement(delta, detected, offset, distance) elif attack_mode == "direct_hit" and reach <= attack_range and contact_cooldown <= 0.0: _perform_direct_hit(offset) desired_velocity = _desired_movement(delta, detected, offset, distance) elif detected and reach <= attack_range and contact_cooldown <= 0.0: _begin_attack(offset) desired_velocity = _desired_movement(delta, detected, offset, distance) else: desired_velocity = _desired_movement(delta, detected, offset, distance) if desired_velocity.length_squared() > 0.01: look_direction = desired_velocity.normalized() velocity = desired_velocity + knockback_velocity move_and_slide() 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() # 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 _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_origin_offset = offset if attack_mode == "sticky" else Vector2.ZERO queue_redraw() func _advance_attack(delta: float) -> void: if attack_state == "windup": if attack_mode == "sticky" and target != null and is_instance_valid(target): attack_origin_offset = target.global_position - global_position windup_remaining -= delta if windup_remaining > 0.0: return if attack_mode == "swipe": attack_state = "active" attack_active_remaining = maxf(0.05, attack_active_duration) attack_tick_remaining = 0.0 else: _try_attack_hit(attack_origin_offset, attack_direction) _finish_attack() elif attack_state == "active": attack_direction = attack_direction.rotated(deg_to_rad(attack_rotation_speed) * delta) attack_active_remaining -= delta attack_tick_remaining -= delta if attack_tick_remaining <= 0.0: _try_attack_hit(Vector2.ZERO, attack_direction) attack_tick_remaining = maxf(0.05, attack_tick_interval) if attack_active_remaining <= 0.0: _finish_attack() queue_redraw() func _perform_direct_hit(offset: Vector2) -> void: if offset.length_squared() <= 0.001: return attack_direction = offset.normalized() look_direction = attack_direction contact_cooldown = attack_cooldown_duration direct_flash_remaining = 0.12 target.take_damage(_roll_damage(contact_damage), attack_direction, hit_knockback) GameState.add_entity_counter("enemies", enemy_id, "times_damaged_player") Registry.run_hook("enemies", enemy_id, "on_damage_player", self) queue_redraw() func _try_attack_hit(origin_offset: Vector2, direction: Vector2) -> void: if target == null or not is_instance_valid(target): return var world_origin := global_position + origin_offset var offset := target.global_position - world_origin var distance := offset.length() var radius := target.body_radius() # 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. if distance - radius > attack_range: return # La zone englobe le héros: l'angle n'a plus de sens. Ça couvre aussi la zone # collante posée pile sur lui, qui est un coup voulu. var inside_attack := attack_angle >= 359.5 or distance <= radius or distance <= 0.001 if not inside_attack: # Un corps occupe un secteur, pas une direction: on retire sa demi- # ouverture angulaire avant de comparer au cône. var half_arc := deg_to_rad(attack_angle * 0.5) var body_half_angle := asin(clampf(radius / distance, 0.0, 1.0)) var spread := acos(clampf(direction.dot(offset / distance), -1.0, 1.0)) inside_attack = spread - body_half_angle <= half_arc 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(contact_damage), knockback_direction, hit_knockback) GameState.add_entity_counter("enemies", enemy_id, "times_damaged_player") Registry.run_hook("enemies", enemy_id, "on_damage_player", self) func _finish_attack() -> void: attack_state = "idle" telegraphing = false windup_remaining = 0.0 attack_active_remaining = 0.0 attack_origin_offset = Vector2.ZERO contact_cooldown = attack_cooldown_duration queue_redraw() 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(base_damage: float) -> float: return maxf(1.0, base_damage * randf_range(1.0 - damage_variance, 1.0 + damage_variance)) 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) -> void: if not active: return health -= amount _spawn_damage_number(amount, Color("ffe066")) if knockback_direction.length_squared() > 0.001 and knockback_force > 0.0: knockback_velocity = knockback_direction.normalized() * knockback_force * (1.0 - knockback_resistance) _interrupt_attack() # 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() 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 ## 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): if not drop_roll_succeeds(float(drop.get("chance", 0.0)), randf()): 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: if telegraphing: var warning_color := attack_visual_color warning_color.a = 0.48 if attack_state == "active" else 0.30 var warning_origin := attack_origin_offset if attack_mode == "sticky" else Vector2.ZERO if 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 direct_flash_remaining > 0.0: draw_circle(Vector2.ZERO, 25.0, attack_visual_color, false, 4.0) draw_set_transform(Vector2.ZERO, 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.position = position + Vector2(randf_range(-8.0, 8.0), -26.0) get_parent().add_child(number) func _interrupt_attack() -> void: attack_state = "idle" telegraphing = false windup_remaining = 0.0 attack_active_remaining = 0.0 attack_origin_offset = Vector2.ZERO # A successful knockback always forces a meaningful recovery window, even # if the enemy had not begun its next windup yet. contact_cooldown = maxf(contact_cooldown, attack_cooldown_duration * 0.65) queue_redraw()