2026-08-15 04:45:15 +02:00
|
|
|
class_name GasBombController
|
|
|
|
|
extends Node2D
|
|
|
|
|
|
|
|
|
|
const BOMB_COLOR := Color(0.96, 0.22, 0.72)
|
|
|
|
|
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
|
|
|
|
|
const MAX_RANGE := 950.0
|
|
|
|
|
const AOE_RADIUS := 180.0
|
|
|
|
|
const DRAG_RADIUS := 260.0
|
|
|
|
|
const AIM_DEADZONE := 22.0
|
|
|
|
|
const PROJECTILE_SPEED := 720.0
|
|
|
|
|
const DAMAGE := 3
|
|
|
|
|
const COOLDOWN := 5.0
|
2026-08-16 18:54:44 +02:00
|
|
|
# #60: ammunition from the fleet hold, not the bank - the fleet has to be
|
|
|
|
|
# carrying its own gas to fire.
|
|
|
|
|
const GAS_PER_SHOT: int = 5
|
2026-08-15 04:45:15 +02:00
|
|
|
|
|
|
|
|
@export var minimum_zoom: float = 0.4
|
|
|
|
|
@onready var fleet: Node2D = get_node("../Fleet")
|
|
|
|
|
@onready var camera: Camera2D = get_node("../Camera2D")
|
|
|
|
|
@onready var button: IconButton = get_node("../UI/GasBombButton")
|
2026-08-16 18:54:44 +02:00
|
|
|
@onready var status: Label = get_node("../UI/GasBombStatus")
|
|
|
|
|
|
|
|
|
|
var status_message_until: int = 0
|
2026-08-15 04:45:15 +02:00
|
|
|
|
|
|
|
|
var aiming := false
|
|
|
|
|
var aim_touch := -1
|
|
|
|
|
var aim_start_screen := Vector2.ZERO
|
|
|
|
|
var aim_screen := Vector2.ZERO
|
|
|
|
|
var aim_target := Vector2.ZERO
|
|
|
|
|
var saved_zoom := 1.0
|
|
|
|
|
var restoring_zoom := false
|
2026-08-15 10:12:33 +02:00
|
|
|
var projectiles: Array = []
|
|
|
|
|
var explosions: Array = []
|
2026-08-15 04:45:15 +02:00
|
|
|
var last_hit_count := 0
|
|
|
|
|
|
2026-08-15 10:12:33 +02:00
|
|
|
var projectile_active: bool:
|
|
|
|
|
get: return not projectiles.is_empty()
|
|
|
|
|
|
|
|
|
|
var explosion_time: float:
|
|
|
|
|
get:
|
|
|
|
|
var longest := 0.0
|
|
|
|
|
for explosion in explosions:
|
|
|
|
|
longest = maxf(longest, float(explosion.time))
|
|
|
|
|
return longest
|
|
|
|
|
|
2026-08-15 04:45:15 +02:00
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
|
button.visible = false
|
2026-08-16 18:54:44 +02:00
|
|
|
status.text = ""
|
2026-08-15 04:45:15 +02:00
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 18:54:44 +02:00
|
|
|
func _can_afford_shot() -> bool:
|
|
|
|
|
return GameState.fleet_resource(GameState.RESOURCE_GAS) >= GAS_PER_SHOT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _say(message: String) -> void:
|
|
|
|
|
status.text = message
|
|
|
|
|
status_message_until = Time.get_ticks_msec() + 3000
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 04:45:15 +02:00
|
|
|
func _process(delta: float) -> void:
|
2026-08-15 10:12:33 +02:00
|
|
|
var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty()
|
2026-08-15 04:45:15 +02:00
|
|
|
button.visible = fleet.has_capability("gas_bomber")
|
2026-08-16 18:54:44 +02:00
|
|
|
button.enabled = _next_ready_bomber() != null and _can_afford_shot()
|
2026-08-15 04:45:15 +02:00
|
|
|
if not button.visible and aiming:
|
|
|
|
|
_cancel_aim()
|
2026-08-16 18:54:44 +02:00
|
|
|
if status.text != "" and Time.get_ticks_msec() >= status_message_until:
|
|
|
|
|
status.text = ""
|
2026-08-15 04:45:15 +02:00
|
|
|
|
|
|
|
|
if restoring_zoom and not aiming:
|
|
|
|
|
var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0)
|
|
|
|
|
camera.zoom = Vector2(z, z)
|
|
|
|
|
if is_equal_approx(z, saved_zoom):
|
|
|
|
|
restoring_zoom = false
|
|
|
|
|
|
2026-08-15 10:12:33 +02:00
|
|
|
for i in range(projectiles.size() - 1, -1, -1):
|
|
|
|
|
var projectile: Dictionary = projectiles[i]
|
|
|
|
|
var distance: float = (projectile.position as Vector2).distance_to(projectile.target)
|
2026-08-15 04:45:15 +02:00
|
|
|
if distance <= PROJECTILE_SPEED * delta:
|
2026-08-15 10:12:33 +02:00
|
|
|
projectile.position = projectile.target
|
|
|
|
|
_explode(projectile.target)
|
|
|
|
|
projectiles.remove_at(i)
|
2026-08-15 04:45:15 +02:00
|
|
|
else:
|
2026-08-15 10:12:33 +02:00
|
|
|
projectile.position = (projectile.position as Vector2).move_toward(projectile.target,
|
2026-08-15 04:45:15 +02:00
|
|
|
PROJECTILE_SPEED * delta)
|
2026-08-15 10:12:33 +02:00
|
|
|
var trail: PackedVector2Array = projectile.trail
|
|
|
|
|
trail.append(projectile.position)
|
|
|
|
|
while trail.size() > 12:
|
|
|
|
|
trail.remove_at(0)
|
|
|
|
|
projectile.trail = trail
|
|
|
|
|
for i in range(explosions.size() - 1, -1, -1):
|
|
|
|
|
var explosion: Dictionary = explosions[i]
|
|
|
|
|
explosion.time = maxf(float(explosion.time) - delta, 0.0)
|
|
|
|
|
if float(explosion.time) <= 0.0:
|
|
|
|
|
explosions.remove_at(i)
|
|
|
|
|
if aiming or not projectiles.is_empty() or not explosions.is_empty() or had_visuals:
|
2026-08-15 04:45:15 +02:00
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func can_begin_aim() -> bool:
|
2026-08-16 18:54:44 +02:00
|
|
|
# Deliberately NOT button.enabled: an unaffordable shot still needs the
|
|
|
|
|
# press to reach here so it can explain itself (IconButton's own "greyed
|
|
|
|
|
# but still pressable" convention) rather than the touch silently
|
|
|
|
|
# falling through to whatever's behind it. A wholly unready fleet (no
|
|
|
|
|
# bomber at all) has nothing more useful to say than "greyed out".
|
|
|
|
|
return button.visible and not button._blocked_by_modal() \
|
|
|
|
|
and not aiming and _next_ready_bomber() != null
|
2026-08-15 04:45:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func handle_touch(event: InputEventScreenTouch) -> bool:
|
|
|
|
|
if event.pressed:
|
|
|
|
|
if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position):
|
|
|
|
|
return false
|
2026-08-16 18:54:44 +02:00
|
|
|
if not _can_afford_shot():
|
|
|
|
|
_say("Need %d gas in the fleet hold to fire (have %d)" \
|
|
|
|
|
% [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)])
|
|
|
|
|
return true
|
2026-08-15 04:45:15 +02:00
|
|
|
aiming = true
|
|
|
|
|
aim_touch = event.index
|
|
|
|
|
aim_start_screen = event.position
|
|
|
|
|
aim_screen = event.position
|
|
|
|
|
saved_zoom = camera.zoom.x
|
|
|
|
|
restoring_zoom = false
|
|
|
|
|
button.pressed_down = true
|
|
|
|
|
button.queue_redraw()
|
|
|
|
|
_update_aim()
|
|
|
|
|
return true
|
|
|
|
|
if event.index != aim_touch:
|
|
|
|
|
return false
|
|
|
|
|
var should_fire := aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE
|
|
|
|
|
if should_fire:
|
|
|
|
|
_fire()
|
|
|
|
|
else:
|
|
|
|
|
_cancel_aim()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func handle_drag(event: InputEventScreenDrag) -> bool:
|
|
|
|
|
if not aiming or event.index != aim_touch:
|
|
|
|
|
return false
|
|
|
|
|
aim_screen = event.position
|
|
|
|
|
_update_aim()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _update_aim() -> void:
|
|
|
|
|
var drag := aim_screen - aim_start_screen
|
|
|
|
|
var origin: Vector2 = fleet.get_centroid()
|
|
|
|
|
if drag.length() < 0.001:
|
|
|
|
|
aim_target = origin
|
|
|
|
|
else:
|
|
|
|
|
var distance := minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE
|
|
|
|
|
aim_target = origin + drag.normalized() * distance
|
|
|
|
|
_update_aim_zoom(origin, aim_target)
|
|
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
|
|
|
|
|
var displacement := target - origin
|
|
|
|
|
var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
|
|
|
|
|
var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + AOE_RADIUS)
|
|
|
|
|
var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + AOE_RADIUS)
|
|
|
|
|
var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom)
|
|
|
|
|
camera.zoom = Vector2(desired, desired)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _finish_aim() -> void:
|
|
|
|
|
aiming = false
|
|
|
|
|
aim_touch = -1
|
|
|
|
|
button.pressed_down = false
|
|
|
|
|
button.queue_redraw()
|
|
|
|
|
restoring_zoom = true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _cancel_aim() -> void:
|
|
|
|
|
_finish_aim()
|
|
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _fire() -> void:
|
2026-08-15 10:12:33 +02:00
|
|
|
var bomber: Node2D = _next_ready_bomber()
|
2026-08-15 04:45:15 +02:00
|
|
|
if bomber == null:
|
|
|
|
|
_cancel_aim()
|
|
|
|
|
return
|
2026-08-16 18:54:44 +02:00
|
|
|
if not _can_afford_shot():
|
|
|
|
|
_say("Need %d gas in the fleet hold to fire (have %d)" \
|
|
|
|
|
% [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)])
|
|
|
|
|
_cancel_aim()
|
|
|
|
|
return
|
2026-08-15 10:12:33 +02:00
|
|
|
var component: Node = bomber.get_component("gas_bomber")
|
|
|
|
|
if not component.consume_shot(COOLDOWN):
|
|
|
|
|
_cancel_aim()
|
|
|
|
|
return
|
2026-08-16 18:54:44 +02:00
|
|
|
GameState.withdraw_fleet_resource(GameState.RESOURCE_GAS, GAS_PER_SHOT)
|
2026-08-15 10:12:33 +02:00
|
|
|
projectiles.append({
|
|
|
|
|
"position": bomber.global_position,
|
|
|
|
|
"target": aim_target,
|
|
|
|
|
"trail": PackedVector2Array([bomber.global_position]),
|
|
|
|
|
})
|
2026-08-15 04:45:15 +02:00
|
|
|
_finish_aim()
|
2026-08-16 18:54:44 +02:00
|
|
|
button.enabled = _next_ready_bomber() != null and _can_afford_shot()
|
2026-08-15 10:12:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _next_ready_bomber() -> Node2D:
|
|
|
|
|
for ship in fleet.ships:
|
|
|
|
|
if not is_instance_valid(ship):
|
|
|
|
|
continue
|
|
|
|
|
var component: Node = ship.get_component("gas_bomber")
|
|
|
|
|
if component != null and component.is_ready():
|
|
|
|
|
return ship
|
|
|
|
|
return null
|
2026-08-15 04:45:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _explode(at: Vector2) -> void:
|
2026-08-15 10:12:33 +02:00
|
|
|
explosions.append({"position": at, "time": 0.8})
|
2026-08-15 04:45:15 +02:00
|
|
|
last_hit_count = 0
|
|
|
|
|
for enemy in get_tree().get_nodes_in_group("enemies"):
|
|
|
|
|
if is_instance_valid(enemy) and enemy is Node2D \
|
|
|
|
|
and enemy.global_position.distance_to(at) <= AOE_RADIUS:
|
|
|
|
|
enemy.take_damage(DAMAGE)
|
|
|
|
|
last_hit_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _draw() -> void:
|
|
|
|
|
if aiming:
|
|
|
|
|
var origin: Vector2 = fleet.get_centroid()
|
|
|
|
|
NeonDraw.draw_glowing_line(self, origin, aim_target, TARGET_COLOR, 0.7, 0.8)
|
|
|
|
|
_draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8)
|
|
|
|
|
_draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0)
|
2026-08-15 10:12:33 +02:00
|
|
|
for projectile in projectiles:
|
|
|
|
|
var trail: PackedVector2Array = projectile.trail
|
|
|
|
|
if trail.size() >= 2:
|
|
|
|
|
for i in range(1, trail.size()):
|
|
|
|
|
var alpha := float(i) / trail.size()
|
|
|
|
|
draw_line(trail[i - 1], trail[i],
|
|
|
|
|
Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true)
|
|
|
|
|
draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35))
|
|
|
|
|
NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0)
|
|
|
|
|
for explosion in explosions:
|
|
|
|
|
var progress := 1.0 - float(explosion.time) / 0.8
|
2026-08-15 04:45:15 +02:00
|
|
|
var radius := lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
|
2026-08-15 10:12:33 +02:00
|
|
|
draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16))
|
|
|
|
|
_draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress)
|
2026-08-15 04:45:15 +02:00
|
|
|
for i in range(7):
|
|
|
|
|
var angle := TAU * float(i) / 7.0 + progress
|
2026-08-15 10:12:33 +02:00
|
|
|
var cloud_at: Vector2 = explosion.position + Vector2(cos(angle), sin(angle)) * radius * 0.55
|
2026-08-15 04:45:15 +02:00
|
|
|
draw_circle(cloud_at, 18.0 + 14.0 * progress, Color(BOMB_COLOR, (1.0 - progress) * 0.22))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void:
|
|
|
|
|
draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true)
|
|
|
|
|
draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true)
|
|
|
|
|
draw_arc(center, radius, 0.0, TAU, 64, Color(1.0, 0.86, 0.96, 0.9 * alpha), 2.0, true)
|