Creating An Electric Chain Between Projectiles

Here’s how I did it. The plug-looking thingies you see are all instances of the following scene:

VoltagePoint inherits from Area2D and has the following script:

Click to show.
class_name VoltagePoint extends Area2D

signal check_power_source_request(do_not_inform : VoltagePoint, lost_power_source : VoltagePoint)

enum POINT_TYPE
{
	SOURCE = 0,
	TRANSFER = 1
}

enum STATE
{
	DISABLED = 0,
	ENABLED = 1
}

@export var point_type : POINT_TYPE = POINT_TYPE.TRANSFER
@export var state : STATE = STATE.DISABLED
var previous_state : STATE = state

@export var low_voltage_electric_lines : Array[LowVoltageElectricLine] = []

@export_category("Switch behavior")
@export var is_switch : bool = false ## Set to [code]true[/code] to make the [code]VoltagePoint[/code] fire a signal if turned on or off. Only works for a [code]VoltagePoint[/code] of type [code]TRANSFER[/code].
@export var switch_name : String = "" ## Identifier to use in signal.

@export_category("Crackle SFX")
@export var audio_stream_player_2d : AudioStreamPlayer2D = null

func set_point_type(new_value : POINT_TYPE)->void:
	point_type = new_value

func set_state(new_value : STATE)->void:
	state = new_value

func set_is_switch(new_value : bool)->void:
	is_switch = new_value

func set_switch_name(new_value : String)->void:
	switch_name = new_value

var power_sources : Array[VoltagePoint] = []
var neighbours : Array[VoltagePoint] = []
var nearest_point : VoltagePoint = null

var dont_draw_a_line : Array[VoltagePoint] = []
var tapped_out : bool = false

func _ready() -> void:
	area_entered.connect(_on_area_entered.bind())
	area_exited.connect(_on_area_exited.bind())
	low_voltage_electric_lines.shuffle()

func _physics_process(_delta: float) -> void:
	previous_state = state
	if point_type == POINT_TYPE.TRANSFER:
		power_sources = clean_up(power_sources)
		neighbours = clean_up(neighbours)

		for node : VoltagePoint in neighbours:
			if not node.check_power_source_request.is_connected(_on_check_power_source_request.bind()):
				node.check_power_source_request.connect(_on_check_power_source_request.bind())

		if power_sources.is_empty():
			state = STATE.DISABLED
		elif power_sources.any(func(element : VoltagePoint): return element.state == STATE.ENABLED):
			state = STATE.ENABLED
		else:
			state = STATE.DISABLED
	
		if state == STATE.ENABLED:
			for neighbour : VoltagePoint in neighbours:
				for power_source : VoltagePoint in power_sources:
					neighbour.add_power_source(power_source)
	
	if point_type == POINT_TYPE.SOURCE:
		if state == STATE.ENABLED:
			var points_near_power_source : Array[Area2D] = get_overlapping_areas()
			for area in points_near_power_source:
				if area is VoltagePoint:
					var point = (area as VoltagePoint)
					if point.point_type == POINT_TYPE.TRANSFER:
						point.add_power_source(self)

	update_low_voltage_lines()

	if point_type == POINT_TYPE.TRANSFER and is_switch == true:
		if previous_state != state:
			MessageBus.transfer_point_changed_state.emit(switch_name, state)

	if audio_stream_player_2d != null:
		if state == STATE.ENABLED:
			audio_stream_player_2d.play()
		else:
			audio_stream_player_2d.stop()

func _on_area_entered(area : Area2D)->void:
	if point_type == POINT_TYPE.SOURCE:
		if state == STATE.DISABLED:
			if area is VoltagePoint and area != self:
				var voltage_point = (area as VoltagePoint)
				if voltage_point.point_type == POINT_TYPE.TRANSFER:
					voltage_point.add_power_source(self)
	
	if point_type == POINT_TYPE.TRANSFER:
		if area is VoltagePoint and area != self:
			var voltage_point = (area as VoltagePoint)
			if voltage_point.point_type == POINT_TYPE.TRANSFER:
				voltage_point.add_neighbour(self)
				for power_source : VoltagePoint in power_sources:
					voltage_point.add_power_source(power_source)

func _on_area_exited(area : Area2D)->void:
	if point_type == POINT_TYPE.SOURCE:
		if area is VoltagePoint and area != self:
			var voltage_point = (area as VoltagePoint)
			voltage_point.remove_power_source(self)

	if point_type == POINT_TYPE.TRANSFER:
		if area is VoltagePoint and area != self:
			var voltage_point = (area as VoltagePoint)
			voltage_point.remove_neighbour(self)
			
			# Quite a bit of code. Simply removing all voltage points would also suffice because
			# wrongful deletions are reverted the next frame.
			for power_source : VoltagePoint in power_sources:
				if power_source == null: # Skip because the reference will be deleted later anyway.
					break
	
				var points_near_power_source : Array[Area2D] = power_source.get_overlapping_areas()
	
				if power_source.state == STATE.DISABLED:
					voltage_point.remove_power_source(power_source)
	
				if power_source.state == STATE.ENABLED and points_near_power_source.find(voltage_point) == -1:
					voltage_point.remove_power_source(power_source)

func update_low_voltage_lines()->void:
	var areas : Array[Area2D] = get_overlapping_areas()
	areas.erase(self)
	areas = areas.filter(func(element : Area2D): return element is VoltagePoint)
	areas.sort_custom(func(a : Area2D, b : Area2D): return global_position.distance_to(a.global_position) > global_position.distance_to(b.global_position))
	
	var points = (areas as Array[VoltagePoint])
	for dont_draw : VoltagePoint in dont_draw_a_line:
		points = points.filter(func(element: VoltagePoint): return dont_draw != element)
	
	if state == STATE.ENABLED:
		for i : int in low_voltage_electric_lines.size():
			if i < points.size():
				if points[i] != null and not points[i].tapped_out:
					low_voltage_electric_lines[i].update_line(global_position - global_position, points[i].global_position - global_position, true)
					points[i].dont_draw_a_line.append(self)
			else:
				low_voltage_electric_lines[i].update_line(Vector2.ZERO, Vector2.ZERO, false)
	else:
		for i : int in low_voltage_electric_lines.size():
			low_voltage_electric_lines[i].update_line(Vector2.ZERO, Vector2.ZERO, false)
	
	if low_voltage_electric_lines.all(func(element : LowVoltageElectricLine): return element.visible == true):
		tapped_out = true
	else:
		tapped_out = false
	
	dont_draw_a_line.clear()

func add_neighbour(neighbour : VoltagePoint)->void:
	if not neighbours.has(neighbour):
		neighbours.append(neighbour)
		
	if not neighbour.check_power_source_request.is_connected(_on_check_power_source_request.bind()):
		neighbour.check_power_source_request.connect(_on_check_power_source_request.bind())

func remove_neighbour(neighbour : VoltagePoint)->void:
	if neighbours.has(neighbour):
		neighbours.erase(neighbour)

	if neighbour.check_power_source_request.is_connected(_on_check_power_source_request.bind()):
		neighbour.check_power_source_request.disconnect(_on_check_power_source_request.bind())

func add_power_source(power_source : VoltagePoint)->void:
	if not power_sources.has(power_source):
		power_sources.append(power_source)

func remove_power_source(power_source : VoltagePoint)->void:
	if power_sources.has(power_source):
		power_sources.erase(power_source)
		
		check_power_source_request.emit(power_source)

func clean_up(source_array : Array[VoltagePoint])->Array[VoltagePoint]:
	var result : Array[VoltagePoint] = []
	for element in source_array:
		if is_instance_valid(element):
			result.append(element)
	return result

func _on_check_power_source_request(lost_power_source : VoltagePoint)->void:
	if lost_power_source == null: # Skip because the reference will be deleted later anyway.
		return
	
	var points_near_power_source : Array[Area2D] = lost_power_source.get_overlapping_areas()
	
	if lost_power_source.state == STATE.DISABLED:
		remove_power_source(lost_power_source)
	
	if lost_power_source.state == STATE.ENABLED and points_near_power_source.find(self) == -1:
		remove_power_source(lost_power_source)

(Please note the script needs a good refactor and there are probably a whole bunch of checks that are pointless and can be removed. The checks exist because I tried to chase down a weird bug where some electric bolts ended up getting drawn twice. This turned out to be an issue unrelated to the script-- I had accidentally manually added a point to the Line2D that I use to draw bolts.)

The three LowVoltageElectricLine nodes are all instances of a LowVoltageElectricLine scene. It’s real simple: just a scene with a Line2D as its singular node. The following script is attached to the Line2D node:

Click to show
class_name LowVoltageElectricLine extends Line2D

@export var start_point : Vector2 = Vector2.ZERO
@export var end_point : Vector2 = Vector2.ZERO

func _ready() -> void:
	hide()
	add_point(start_point)
	add_point(end_point)

func update_line(new_start_point : Vector2, new_end_point : Vector2, line_is_visible : bool)->void:
	start_point = new_start_point
	end_point = new_end_point
	
	set_point_position(0, start_point)
	set_point_position(1, end_point)
	
	if line_is_visible:
		show()
	else:
		hide()

The line really needs just two points: a start and an end. (And you really don’t want to add more, unless you love debugging and/or have high pain tolerance.) Those are already added by the _ready function. Changing their values can be done though update_line.

The real magic in making the bolt look like electricity is its shader. In the LowVoltageElectricLine scene, open the inspector and under the fill tab, add a Texture2D and set the Texture Mode to stretch. (It really doesn’t matter what image you use. I used the default Godot logo.)

Under CanvasItem->Material, click the Material field and add a new ShaderMaterial. Then under Shader, add a new Shader script. The one you see in the video is nice, but I currently use an even nicer one that has fixed start and end positions. You can find it here.

Once you add the shader, a bunch of new fields appear. In case you don’t feel like experimenting, here’s the settings I currently use: