Randoloot/scripts/game/weapon_projectile.gd
2026-10-17 16:20:14 +02:00

310 lines
12 KiB
GDScript

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 enemy_shot := false
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 := 5.0
var homing_max_turn_speed := 3.0
var missile_steering_acceleration := 1400.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:
if is_instance_valid(homing_target) and homing_target.is_in_group("virus_allies") and not enemy_shot:
homing_target = null
_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 * 4.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.GAMMA:
_engine_time += delta
queue_redraw()
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
if enemy_shot:
return null
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.GAMMA:
var radius := 9.0 + sin(_engine_time * 9.0) * 2.0
draw_circle(Vector2.ZERO, radius * 1.8, Color(0.3, 1.0, 0.1, 0.15))
draw_circle(Vector2.ZERO, radius, Color(0.35, 0.95, 0.15, 0.8))
draw_circle(Vector2.ZERO, radius * 0.5, Color(0.86, 1.0, 0.6))
for index in 8:
var angle := _engine_time * 5.0 + index * TAU / 8.0
var orbit := Vector2(cos(angle), sin(angle) * 0.65) * (radius + 6.0)
draw_circle(orbit, 2.0, Color(0.7, 1.0, 0.4))
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:
# Tapered tracer, warm halo and a bright metallic projectile core.
for index in 6:
var tail_distance := 5.0 + index * 4.0
if tail_distance >= _distance_travelled:
break
var fade := 1.0 - float(index) / 6.0
var tip := Vector2(-tail_distance, 0)
var tail := Vector2(-minf(tail_distance + 4.0, _distance_travelled), 0)
draw_line(tail, tip, Color(1.0, 0.48, 0.1, fade * 0.55), 1.8 * fade, true)
draw_line(Vector2(-9, 0), Vector2(6, 0), Color(1.0, 0.56, 0.12, 0.18), 7, true)
draw_line(Vector2(-7, 0), Vector2(6, 0), Color(1.0, 0.8, 0.3), 2.5, true)
draw_line(Vector2(-3, 0), Vector2(7, 0), Color(1.0, 0.98, 0.84), 1.2, true)
draw_circle(Vector2(6, 0), 1.5, Color(1.0, 0.94, 0.65))
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 enemy_shot and target.is_in_group("asteroids"):
queue_free()
return
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)
if weapon_kind == ItemDefinition.WeaponKind.GAMMA:
effect_color = Color(0.4, 1.0, 0.15)
is_explosion = true
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)
# Converted ships fire at hostiles; hostile blasts can also hit converted ships.
var converted_shot := enemy_shot and (collision_mask & 4) != 0
query.collision_mask = 4 if converted_shot or not enemy_shot else 66
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
if enemy_shot and target.is_in_group("asteroids"):
return
var instance_id: int = target.get_instance_id()
if _already_hit.has(instance_id):
return
_already_hit[instance_id] = true
if enemy_shot and target is CharacterBody2D and target.has_method("take_damage"):
target.call("take_damage", damage, is_critical, DamageTypes.for_weapon(weapon_kind))
elif target.has_method("take_weapon_hit"):
target.call("take_weapon_hit", damage, is_critical, DamageTypes.for_weapon(weapon_kind))
elif target.has_method("take_damage"):
if target is CharacterBody2D:
target.call("take_damage", damage, is_critical, DamageTypes.for_weapon(weapon_kind))
else:
target.call("take_damage", damage)
if weapon_kind == ItemDefinition.WeaponKind.GAMMA:
preload("res://scripts/game/radiation_status.gd").apply(target, damage * 0.25, not enemy_shot)