class_name IngredientPickup extends Area2D @export_group("Magnet Bounce") @export_range(0.0, 256.0, 1.0) var magnet_hop_height := 34.0 @export_range(1.0, 1000.0, 1.0) var magnet_hop_distance := 180.0 var ingredient_id := "turkey_meat" var pickup_enabled := false var collected := false var pickup_shape := "circle" var visuals: VisualHolder ## Happé une fois, happé pour de bon. ## ## La portée dit quand l'attraction prend, pas jusqu'où elle tient: un héros ## qui repart en courant, ou une portée qui rétrécit entre-temps, laissaient ## le butin retomber sur place à mi-chemin, souvent là où plus personne ne ## repasse. var magnetized := false var magnet_travel_distance := 0.0 var magnet_visual_height := 0.0 func _ready() -> void: add_to_group("ingredient_pickup") body_entered.connect(_on_body_entered) _apply_collision_shape() _build_visual_holder() var tween := create_tween() var destination := position + Vector2(randf_range(-18.0, 18.0), randf_range(-18.0, 18.0)) tween.tween_property(self, "position", destination, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween.tween_callback(_enable_pickup) queue_redraw() func setup(id: String) -> void: if not DataCatalog.INGREDIENTS.has(id): push_error("Invalid ingredient pickup: " + id) return ingredient_id = id pickup_shape = str(DataCatalog.INGREDIENTS[id].get("shape", "circle")) _apply_collision_shape() _build_visual_holder() queue_redraw() # L'icône remplace la pastille dessinée. La forme, elle, reste celle du # catalogue: c'est elle qui donne la collision, et une image ne décide pas de # ce qu'on peut ramasser. func _build_visual_holder() -> void: if visuals == null: visuals = VisualHolder.new() visuals.name = "Visuals" add_child(visuals) visuals.bind("ingredients", ingredient_id) visuals.show_state("icon") visuals.position = Vector2(0.0, -magnet_visual_height) func should_draw_body() -> bool: return visuals == null or not visuals.is_showing() func _enable_pickup() -> void: pickup_enabled = true func _physics_process(delta: float) -> void: if not pickup_enabled or collected: return # body_entered may have fired during the initial pop animation, while pickup # was intentionally locked. Polling overlaps after unlock handles a player # who remained on top of the drop and therefore never re-entered the area. for body: Node2D in get_overlapping_bodies(): if _try_collect(body): return var magnet_range := float(GameState.resolve_game_variable("drop_magnet_range")) var player := get_tree().get_first_node_in_group("player") as Player if player == null or not is_instance_valid(player): _reset_magnet_bounce() return var magnet_speed := magnet_speed_for( float(GameState.resolve_game_variable("drop_magnet_speed")), player.get_current_move_speed()) if magnet_speed <= 0.0: _reset_magnet_bounce() return if not magnetized and magnet_range > 0.0 \ and global_position.distance_squared_to(player.global_position) <= magnet_range * magnet_range: magnetized = true if not magnetized: _reset_magnet_bounce() return var previous_position := global_position global_position = magnetized_position(previous_position, player.global_position, magnet_range, magnet_speed, delta, true) _advance_magnet_bounce(previous_position.distance_to(global_position)) ## À quelle vitesse le butin remonte vers le héros, en pixels par seconde. ## ## La statistique est un pourcentage de l'allure du héros, pas une vitesse ## écrite: 100 le suit exactement, 125 le rattrape d'un quart. Une vitesse ## absolue ne voulait plus rien dire dès que le héros en gagnait — elle était ## généreuse au départ et distancée ensuite, sans que personne l'ait décidé. ## ## L'allure prise est celle de la course, ralentissements et hâtes compris: ## l'élan du dash n'en fait pas partie, et le butin n'a donc pas à le suivre. static func magnet_speed_for(coefficient: float, hero_speed: float) -> float: return maxf(0.0, hero_speed) * maxf(0.0, coefficient) * 0.01 ## L'attraction ne téléporte pas. Garder ce calcul pur permet de fixer ## précisément les limites sans dépendre de la scène. ## ## `latched` est le butin déjà happé: la portée l'a laissé passer une fois, ## et elle n'a plus rien à en dire — elle déclenche, elle ne retient pas. static func magnetized_position(current: Vector2, target: Vector2, magnet_range: float, magnet_speed: float, delta: float, latched: bool = false) -> Vector2: if magnet_speed <= 0.0: return current if not latched and (magnet_range <= 0.0 \ or current.distance_squared_to(target) > magnet_range * magnet_range): return current return current.move_toward(target, magnet_speed * maxf(0.0, delta)) ## Une parabole répétée en fonction de la distance parcourue : la collision ## reste au sol, seul l'ingrédient visible prend de la hauteur. static func ballistic_height(travel_distance: float, hop_distance: float, hop_height: float) -> float: if hop_distance <= 0.0 or hop_height <= 0.0: return 0.0 var phase := fposmod(maxf(0.0, travel_distance), hop_distance) / hop_distance return 4.0 * hop_height * phase * (1.0 - phase) func _advance_magnet_bounce(distance: float) -> void: if distance <= 0.0: return magnet_travel_distance += distance _set_magnet_visual_height(ballistic_height(magnet_travel_distance, magnet_hop_distance, magnet_hop_height)) func _reset_magnet_bounce() -> void: if is_zero_approx(magnet_travel_distance) and is_zero_approx(magnet_visual_height): return magnet_travel_distance = 0.0 _set_magnet_visual_height(0.0) func _set_magnet_visual_height(height: float) -> void: magnet_visual_height = maxf(0.0, height) if visuals != null: visuals.position = Vector2(0.0, -magnet_visual_height) queue_redraw() func _on_body_entered(body: Node2D) -> void: _try_collect(body) func _try_collect(body: Node2D) -> bool: if not pickup_enabled or collected or not body is Player: return false collected = true # Counted here rather than in add_ingredient(): only a real pickup counts, # not a cooking refund or a debug grant. GameState.add_entity_counter("ingredients", ingredient_id, "total_collected") Registry.run_hook("ingredients", ingredient_id, "on_collected", self) GameState.add_ingredient(ingredient_id) queue_free() return true 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 magnet_visual_height > 0.1: var shadow_ratio := clampf(magnet_visual_height / maxf(1.0, magnet_hop_height), 0.0, 1.0) draw_circle(Vector2.ZERO, lerpf(7.0, 4.0, shadow_ratio), Color(0.0, 0.0, 0.0, 0.24)) if not should_draw_body(): return var color: Color = DataCatalog.INGREDIENTS.get(ingredient_id, {}).get("color", Color.YELLOW) var item_offset := Vector2(0.0, -magnet_visual_height) if pickup_shape == "circle": draw_circle(item_offset, 10.0, color) draw_circle(item_offset, 10.0, Color.WHITE, false, 2.0) return var points := ShapeFactory.polygon_points(pickup_shape, 10.0) draw_set_transform(item_offset) draw_colored_polygon(points, color) draw_polyline(ShapeFactory.closed_polygon_points(pickup_shape, 10.0), Color.WHITE, 2.0, true) draw_set_transform(Vector2.ZERO) func _apply_collision_shape() -> void: var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D if collision != null: collision.shape = ShapeFactory.create_collision(pickup_shape, 13.0)