extends Area2D const IMPACT_EFFECT_SCENE := preload("res://scenes/game/impact_effect.tscn") const PLASMA_TEXTURE := preload("res://assets/other/bullet.png") const PLASMA_VISUAL_WIDTH := 24.0 var direction := Vector2.UP var projectile_speed := 600.0 var damage := 1.0 var is_critical := false var max_range := 800.0 var weapon_kind := ItemDefinition.WeaponKind.PLASMA var homing_target: Node2D var missile_homing := true var homing_turn_acceleration := 2.3 var homing_max_turn_speed := 1.7 var missile_steering_acceleration := 520.0 var target_acquisition_range := 360.0 var seeker_arm_delay := 0.35 var _flight_velocity := Vector2.ZERO var _heading := 0.0 var _turn_velocity := 0.0 var _flight_time := 0.0 var _launch_wobble_phase := 0.0 var _has_locked_target := false var _distance_travelled := 0.0 var _already_hit: Dictionary = {} var _impact_handled := false var _engine_time := 0.0 var _smoke_emission_timer := 0.0 var _smoke_puffs: Array[Dictionary] = [] func _ready() -> void: body_entered.connect(_on_body_entered) area_entered.connect(_on_area_entered) rotation = direction.angle() func configure( start_position: Vector2, travel_direction: Vector2, kind: int, speed: float, projectile_damage: float, projectile_range: float, target: Node2D = null, critical_hit: bool = false ) -> void: global_position = start_position direction = travel_direction.normalized() _heading = direction.angle() weapon_kind = kind projectile_speed = speed _flight_velocity = direction * projectile_speed _launch_wobble_phase = randf_range(0.0, TAU) damage = projectile_damage is_critical = critical_hit max_range = projectile_range homing_target = target func _physics_process(delta: float) -> void: var travel_velocity := direction * projectile_speed if weapon_kind == ItemDefinition.WeaponKind.MISSILE and missile_homing: _flight_time += delta if not is_instance_valid(homing_target): _has_locked_target = false if _flight_time >= seeker_arm_delay: homing_target = _find_enemy_in_acquisition_range() _has_locked_target = is_instance_valid(homing_target) var desired_turn_velocity := 0.0 if is_instance_valid(homing_target): var to_target := homing_target.global_position - global_position if to_target.length_squared() > 0.001: var target_velocity := Vector2.ZERO if homing_target is CharacterBody2D: target_velocity = homing_target.velocity var estimated_intercept_time := to_target.length() / maxf(projectile_speed, 1.0) var lead_time := minf(estimated_intercept_time, 2.0) var predicted_target_position := homing_target.global_position + target_velocity * lead_time var angle_error := wrapf( (predicted_target_position - global_position).angle() - _heading, -PI, PI ) desired_turn_velocity = clampf( angle_error * 2.0, -homing_max_turn_speed, homing_max_turn_speed ) _turn_velocity = move_toward( _turn_velocity, desired_turn_velocity, homing_turn_acceleration * delta ) _heading += _turn_velocity * delta var wobble_strength := 0.16 * exp(-3.2 * _flight_time) var wobble_angle := sin(_flight_time * 17.0 + _launch_wobble_phase) * wobble_strength direction = Vector2.RIGHT.rotated(_heading + wobble_angle) var desired_velocity := direction * projectile_speed _flight_velocity = _flight_velocity.move_toward( desired_velocity, missile_steering_acceleration * delta ) travel_velocity = _flight_velocity rotation = direction.angle() var displacement := travel_velocity * delta var step := displacement.length() var previous_position := global_position var next_position := previous_position + displacement var query := PhysicsRayQueryParameters2D.create( previous_position, next_position, collision_mask, [get_rid()] ) var result := get_world_2d().direct_space_state.intersect_ray(query) if not result.is_empty(): global_position = result["position"] _hit_target(result.get("collider")) return global_position = next_position _distance_travelled += step if _distance_travelled >= max_range and not _has_locked_target: queue_free() func _process(delta: float) -> void: if weapon_kind == ItemDefinition.WeaponKind.MISSILE: _engine_time += delta _smoke_emission_timer += delta while _smoke_emission_timer >= 0.028: _smoke_emission_timer -= 0.028 _spawn_smoke_puff() _update_smoke_puffs(delta) queue_redraw() func _spawn_smoke_puff() -> void: if _smoke_puffs.size() >= 64: return var side := Vector2(-direction.y, direction.x) _smoke_puffs.append({ "position": global_position - direction * 8.0, "velocity": direction * projectile_speed * 0.12 + side * randf_range(-9.0, 9.0), "age": 0.0, "life": randf_range(0.45, 0.75), "size": randf_range(3.0, 5.0), }) func _update_smoke_puffs(delta: float) -> void: for puff_index in range(_smoke_puffs.size() - 1, -1, -1): var puff := _smoke_puffs[puff_index] puff["position"] = puff["position"] + puff["velocity"] * delta puff["age"] = float(puff["age"]) + delta _smoke_puffs[puff_index] = puff if float(puff["age"]) >= float(puff["life"]): _smoke_puffs.remove_at(puff_index) func _find_enemy_in_acquisition_range() -> Node2D: var nearest_enemy: Node2D var nearest_distance_squared := target_acquisition_range * target_acquisition_range for candidate in get_tree().get_nodes_in_group("enemies"): if not candidate is Node2D or candidate.is_queued_for_deletion(): continue var distance_squared := global_position.distance_squared_to(candidate.global_position) if distance_squared <= nearest_distance_squared: nearest_distance_squared = distance_squared nearest_enemy = candidate return nearest_enemy func _draw() -> void: if weapon_kind == ItemDefinition.WeaponKind.MISSILE: _draw_missile_smoke() match weapon_kind: ItemDefinition.WeaponKind.PLASMA: var texture_size := PLASMA_TEXTURE.get_size() * (PLASMA_VISUAL_WIDTH / PLASMA_TEXTURE.get_width()) # The source image points left; projectiles travel along their local +X. draw_set_transform(Vector2.ZERO, PI) draw_texture_rect(PLASMA_TEXTURE, Rect2(-texture_size * 0.5, texture_size), false) draw_set_transform(Vector2.ZERO) ItemDefinition.WeaponKind.MACHINE_GUN: draw_rect(Rect2(-7.0, -2.0, 14.0, 4.0), Color(1.0, 0.82, 0.38, 1.0)) ItemDefinition.WeaponKind.MISSILE: _draw_missile() func _draw_missile_smoke() -> void: for puff in _smoke_puffs: var progress := clampf(float(puff["age"]) / float(puff["life"]), 0.0, 1.0) var fade := 1.0 - progress var puff_position: Vector2 = to_local(puff["position"]) var puff_size := float(puff["size"]) * (0.8 + progress * 0.75) draw_circle(puff_position, puff_size, Color(0.46, 0.58, 0.67, fade * 0.2)) draw_circle(puff_position, puff_size * 0.48, Color(0.67, 0.75, 0.8, fade * 0.12)) func _draw_missile() -> void: var flicker := sin(_engine_time * 37.0) * 2.0 + sin(_engine_time * 23.0) * 1.0 var flame_tip := -23.0 - flicker # Narrow, layered exhaust behind the missile. draw_line(Vector2(-10, 0), Vector2(flame_tip, 0), Color(1.0, 0.25, 0.045, 0.72), 4.2, true) draw_line(Vector2(-10, 0), Vector2(flame_tip * 0.72, 0), Color(1.0, 0.72, 0.18, 0.94), 2.4, true) draw_line(Vector2(-10, 0), Vector2(flame_tip * 0.42, 0), Color(0.9, 0.98, 1.0, 1.0), 1.1, true) # Rear fins, long cylindrical casing, and a pointed nose; +X is forward. var fin_color := Color(0.48, 0.13, 0.11, 1.0) draw_line(Vector2(-7, -1.7), Vector2(-12, -5.0), fin_color, 1.8, true) draw_line(Vector2(-12, -5.0), Vector2(-10, -1.4), fin_color, 1.8, true) draw_line(Vector2(-7, 1.7), Vector2(-12, 5.0), fin_color, 1.8, true) draw_line(Vector2(-12, 5.0), Vector2(-10, 1.4), fin_color, 1.8, true) draw_line(Vector2(-8, -1.8), Vector2(6, -1.8), Color(0.25, 0.31, 0.35, 1.0), 3.8, true) draw_line(Vector2(-8, 0), Vector2(7, 0), Color(0.78, 0.83, 0.86, 1.0), 3.0, true) draw_line(Vector2(-8, 1.8), Vector2(6, 1.8), Color(0.38, 0.45, 0.49, 1.0), 1.2, true) draw_line(Vector2(-5, -1.35), Vector2(3, -1.35), Color(0.97, 0.98, 0.99, 0.9), 0.7, true) # Guidance band and seeker dome add small material details. draw_line(Vector2(3.0, -1.8), Vector2(3.0, 1.8), Color(0.84, 0.27, 0.16, 1.0), 1.3, true) draw_line(Vector2(6, -1.6), Vector2(10, 0), Color(0.66, 0.72, 0.75, 1.0), 2.4, true) draw_line(Vector2(10, 0), Vector2(6, 1.6), Color(0.66, 0.72, 0.75, 1.0), 2.4, true) draw_circle(Vector2(4.7, 0), 0.85, Color(0.44, 0.82, 0.95, 1.0)) func _on_body_entered(body: Node2D) -> void: _hit_target(body) func _on_area_entered(area: Area2D) -> void: _hit_target(area) func _hit_target(target: Node) -> void: if _impact_handled: return _impact_handled = true var is_explosion := weapon_kind == ItemDefinition.WeaponKind.MISSILE _spawn_impact_effect(is_explosion) if weapon_kind == ItemDefinition.WeaponKind.MISSILE: _explode_at_target(target) else: _apply_damage_once(target) queue_free() func _spawn_impact_effect(is_explosion: bool) -> void: var effect := IMPACT_EFFECT_SCENE.instantiate() as Node2D effect.global_position = global_position get_parent().add_child(effect) var effect_color := Color(1.0, 0.48, 0.22) if is_explosion else Color(1.0, 0.78, 0.38) effect.call("configure", is_explosion, effect_color) func _explode_at_target(impact_target: Node) -> void: _apply_damage_once(impact_target) var explosion_shape := CircleShape2D.new() explosion_shape.radius = 120.0 var query := PhysicsShapeQueryParameters2D.new() query.shape = explosion_shape query.transform = Transform2D(0.0, global_position) query.collision_mask = 4 # Enemy physics layer 3. for result in get_world_2d().direct_space_state.intersect_shape(query, 32): _apply_damage_once(result.get("collider")) func _apply_damage_once(target: Variant) -> void: if not (target is Node): return var instance_id: int = target.get_instance_id() if _already_hit.has(instance_id): return _already_hit[instance_id] = true if target.has_method("take_weapon_hit"): target.call("take_weapon_hit", damage, is_critical) elif target.has_method("take_damage"): target.call("take_damage", damage)