Body Trails - A Visual Aesthetic
Colored visual effect that trails delayed
Right of Way needed a visual upgrade. One of the new visuals is the trails tracking algorithm.
As the Foilist’s blade moves, a trail of red/green (depending on being player 1 or player 2) follows the tip. This improves tracking, helping players comprehend the movement of the blade and whether attacks landed; large or multi-hitting attacks may be poorly telegraphed and/or explosive, so the trail guides the viewer’s interpretation.
I could have gone through each animation and keyframed a marker to follow the tip of the blade. However, do I really want to do that for every single animation for every single character? That’d be weeks of monotonous busywork! You know what’s easier? Programming a detection algorithm. I created a “BladeHandler” node and attached a new script. The entire body of code is below, but for now I’ll focus on the detection algorithm.
First, let’s call the detection function(s):
var tip_scan_color: Color = Color(0.788, 0.788, 0.788) # #c9c9c9
var color_tolerance: float = 0.15
func _update_tip_position() -> void:
if sprite.texture == null:
return
if sprite.frame != _cached_frame:
_cached_frame = sprite.frame
blade_tip.position = _find_tip_by_color()
if sprite.flip_h:
blade_tip.position.x *= -1
As you can see, we start by detecting the tip by its color (which is different from the rest of the blade). If this fails, the find-farthest-right() function is called.
func _find_tip_by_color() -> Vector2:
var region := _get_frame_region()
var best_pixel := Vector2(-1, -1)
var best_score := INF
var farthest_x := -1
for y in range(int(region.position.y), int(region.position.y + region.size.y)):
for x in range(int(region.position.x), int(region.position.x + region.size.x)):
if _cached_img.get_pixel(x, y).a > 0.1 and x > farthest_x:
farthest_x = x
if farthest_x == -1:
push_warning("Sprite appears empty — no non-transparent pixels found")
return Vector2.ZERO
var proximity_threshold := region.size.x * 0.25
for y in range(int(region.position.y), int(region.position.y + region.size.y)):
for x in range(int(region.position.x), int(region.position.x + region.size.x)):
var pixel := _cached_img.get_pixel(x, y)
if pixel.a < 0.1:
continue
if farthest_x - x > proximity_threshold:
continue
var dist := Vector3(
pixel.r - tip_scan_color.r,
pixel.g - tip_scan_color.g,
pixel.b - tip_scan_color.b
).length()
if dist < color_tolerance and dist < best_score:
best_score = dist
best_pixel = Vector2(x, y)
if best_pixel == Vector2(-1, -1):
push_warning("No color match near right edge — falling back to farthest right pixel")
return _find_farthest_right()
return _texture_pixel_to_local(best_pixel)
func _find_farthest_right() -> Vector2:
var region := _get_frame_region()
var farthest_x := -1
var farthest_y := region.position.y + region.size.y / 2.0
for y in range(int(region.position.y), int(region.position.y + region.size.y)):
for x in range(int(region.position.x), int(region.position.x + region.size.x)):
var pixel := _cached_img.get_pixel(x, y)
if pixel.a > 0.1 and x > farthest_x:
farthest_x = x
farthest_y = y
return _texture_pixel_to_local(Vector2(farthest_x, farthest_y))
Oops, I should’ve said that the color algorithm isn’t even used–it would occasionally detect colors on the Foilist’s bellguard. Future character sprites will have a separate color for the blade detection, but otherwise, the Foilist can only use the farthest-right algorithm. The actual update function is:
func _update_tip_position() -> void:
if sprite.texture == null:
return
if sprite.frame != _cached_frame:
_cached_frame = sprite.frame
blade_tip.position = _find_farthest_right() #_find_tip_by_color() #sorry!
if sprite.flip_h:
blade_tip.position.x *= -1

The algorithm function(s) return(s) coordinates. At these coordinates, Sprite2D’s are placed. Here’s a video of when their visibility is enabled:
At run-time, a Line2D is drawn through all the points (initially at the origin). As the points move, so does the Line2D, since the points act as, well, points for the line to cross through. Here’s code, with the focus at the bottom:
# ── Clone Trail ───────────────────────────────────────────────────────────────
func _shift_clones(tip_world: Vector2) -> void:
# Shift each clone to the position of the one ahead of it
# Clone 0 = oldest/tail, last clone = newest/just behind tip
for i in range(0, _clones.size() - 1):
_clones[i].global_position = _clones[i + 1].global_position
_clones[i].flip_h = _clones[i + 1].flip_h
# Newest clone goes to current tip position
var newest := _clones[_clones.size() - 1]
newest.global_position = tip_world
newest.flip_h = trail_sprite.flip_h
# Make all clones visible and apply fade
for i in range(_clones.size()):
var clone := _clones[i]
#clone.visible = false
if i == 0:
clone.visible = true
else:
clone.visible = false
if trail_fade:
var t := float(i) / (_clones.size() - 1) # 0 = oldest, 1 = newest
clone.modulate.a = t
else:
clone.modulate.a = 1.0
trail_line.clear_points()
for clone in _clones:
trail_line.add_point(to_local(clone.global_position))
# Add the live tip as the final point
trail_line.add_point(to_local(tip_world))
The full code:
# BladeTrail.gd — attach to BladeHandler (Node2D), child of Player (CharacterBody2D)
extends Node2D
@onready var sprite: Sprite2D = $"../Sprite2D"
@onready var anim_player: AnimationPlayer = $"../AnimationPlayer"
@onready var blade_tip: Marker2D = $BladeTip
var trail_sprite: Sprite2D
var tip_scan_color: Color = Color(0.788, 0.788, 0.788) # #c9c9c9
var color_tolerance: float = 0.15
var hit_mask: int = 1
var is_attacking: bool = false
var defaultTrailLength: int = 26 # how many clones trail behind
var trail_length: int = 26 # how many clones trail behind
var trail_fade: bool = true # whether clones fade out toward the tail
var _cached_img: Image
var _cached_frame: int = -1
var _last_tip_world: Vector2
var _clones: Array[Sprite2D] = []
var trail_line: Line2D
var frameCount := 0
var frameLimit := 1
var offset = Vector2(0.5,-31.5)
@export var fullyEnabled := false
var enabled = false
#My functions
func setEnabled(val):
enabled = val
#if val:
# for clone in _clones:
# if clone.modulate.a<=0.5:
# clone.global_position=Vector2(1000,1000)
func setTrailLength(multiplier : float):
for i in range(_clones.size()-1):
_clones[i].queue_free()
_clones=[]
if multiplier!= 0:
trail_length=defaultTrailLength*multiplier
else: #0, but you could use 1
trail_length = defaultTrailLength
for i in range(trail_length):
var clone := trail_sprite.duplicate() as Sprite2D
#if i == 0:
# clone.visible = true
#else:
# clone.visible = false
#clone.local_coords = false
add_child(clone)
_clones.append(clone)
#AI/me
func _ready() -> void:
if sprite.flip_h:
trail_line=$Line2
offset = Vector2(offset.x-1.0,offset.y)
else:
trail_line=$Line1
if sprite.flip_h:
trail_sprite=$TrailSprite2
else:
trail_sprite=$TrailSprite1
trail_sprite.visible = false
_cached_img = sprite.texture.get_image()
_cached_img.convert(Image.FORMAT_RGBA8)
_update_tip_position()
_last_tip_world = blade_tip.global_position
if not fullyEnabled:
return
# Pre-spawn all clones
for i in range(trail_length):
var clone := trail_sprite.duplicate() as Sprite2D
#if i == 0:
# clone.visible = true
#else:
# clone.visible = false
#clone.local_coords = false
add_child(clone)
_clones.append(clone)
trail_line.width = 2.0
trail_line.begin_cap_mode = Line2D.LINE_CAP_ROUND
trail_line.end_cap_mode = Line2D.LINE_CAP_ROUND
trail_line.joint_mode = Line2D.LINE_JOINT_ROUND
var gradient := Gradient.new()
var trailColor = trail_line.default_color
gradient.set_color(0, trailColor) # tail — transparent
trailColor.v += .2
trailColor.s += .1
gradient.set_color(1, trailColor) # tip — opaque
trail_line.gradient = gradient
#anim_player.animation_changed.connect(_on_animation_changed)
func _process(delta: float) -> void:
if not fullyEnabled:
return
for clone in _clones:
if not enabled:
clone.modulate.a = move_toward(clone.modulate.a,0.0,delta)
#if clone.modulate.a==.3:
# clone.global_position=Vector2(1000,1000)
else:
clone.modulate.a = move_toward(clone.modulate.a,1.0,delta)
frameCount+=1
if not(frameCount % frameLimit == 0):
return
if frameCount>= 999:
frameCount=0
#_spawn_particle_clone()
_update_tip_position()
var tip_world = blade_tip.global_position+offset
_shift_clones(tip_world)
#_check_tip_hit(tip_world)
_last_tip_world = tip_world
# ── Animation Change ──────────────────────────────────────────────────────────
func _on_animation_changed(_old_name: StringName, _new_name: StringName) -> void:
# Update clone textures to match the new animation frame
for clone in _clones:
clone.texture = trail_sprite.texture
clone.hframes = trail_sprite.hframes
clone.vframes = trail_sprite.vframes
clone.frame = trail_sprite.frame
clone.flip_h = trail_sprite.flip_h
# ── Clone Trail ───────────────────────────────────────────────────────────────
func _shift_clones(tip_world: Vector2) -> void:
# Shift each clone to the position of the one ahead of it
# Clone 0 = oldest/tail, last clone = newest/just behind tip
for i in range(0, _clones.size() - 1):
_clones[i].global_position = _clones[i + 1].global_position
#_clones[i].frame = _clones[i + 1].frame
_clones[i].flip_h = _clones[i + 1].flip_h
# Newest clone goes to current tip position
var newest := _clones[_clones.size() - 1]
newest.global_position = tip_world
#newest.texture = trail_sprite.texture
#newest.hframes = trail_sprite.hframes
#newest.vframes = trail_sprite.vframes
#newest.frame = trail_sprite.frame
newest.flip_h = trail_sprite.flip_h
# Make all clones visible and apply fade
for i in range(_clones.size()):
var clone := _clones[i]
#clone.visible = false
if i == 0:
clone.visible = true
else:
clone.visible = false
if trail_fade:
var t := float(i) / (_clones.size() - 1) # 0 = oldest, 1 = newest
clone.modulate.a = t
else:
clone.modulate.a = 1.0
trail_line.clear_points()
for clone in _clones:
trail_line.add_point(to_local(clone.global_position))
# Add the live tip as the final point
trail_line.add_point(to_local(tip_world))
# ── Tip Tracking ──────────────────────────────────────────────────────────────
func _update_tip_position() -> void:
if sprite.texture == null:
return
if sprite.frame != _cached_frame:
_cached_frame = sprite.frame
blade_tip.position = _find_farthest_right() #_find_tip_by_color()
if sprite.flip_h:
blade_tip.position.x *= -1
# ── Tip Detection ─────────────────────────────────────────────────────────────
func _find_tip_by_color() -> Vector2:
var region := _get_frame_region()
var best_pixel := Vector2(-1, -1)
var best_score := INF
var farthest_x := -1
for y in range(int(region.position.y), int(region.position.y + region.size.y)):
for x in range(int(region.position.x), int(region.position.x + region.size.x)):
if _cached_img.get_pixel(x, y).a > 0.1 and x > farthest_x:
farthest_x = x
if farthest_x == -1:
push_warning("Sprite appears empty — no non-transparent pixels found")
return Vector2.ZERO
var proximity_threshold := region.size.x * 0.25
for y in range(int(region.position.y), int(region.position.y + region.size.y)):
for x in range(int(region.position.x), int(region.position.x + region.size.x)):
var pixel := _cached_img.get_pixel(x, y)
if pixel.a < 0.1:
continue
if farthest_x - x > proximity_threshold:
continue
var dist := Vector3(
pixel.r - tip_scan_color.r,
pixel.g - tip_scan_color.g,
pixel.b - tip_scan_color.b
).length()
if dist < color_tolerance and dist < best_score:
best_score = dist
best_pixel = Vector2(x, y)
if best_pixel == Vector2(-1, -1):
push_warning("No color match near right edge — falling back to farthest right pixel")
return _find_farthest_right()
return _texture_pixel_to_local(best_pixel)
func _find_farthest_right() -> Vector2:
var region := _get_frame_region()
var farthest_x := -1
var farthest_y := region.position.y + region.size.y / 2.0
for y in range(int(region.position.y), int(region.position.y + region.size.y)):
for x in range(int(region.position.x), int(region.position.x + region.size.x)):
var pixel := _cached_img.get_pixel(x, y)
if pixel.a > 0.1 and x > farthest_x:
farthest_x = x
farthest_y = y
return _texture_pixel_to_local(Vector2(farthest_x, farthest_y))
# ── Coordinate Helpers ────────────────────────────────────────────────────────
func _texture_pixel_to_local(pixel: Vector2) -> Vector2:
var region := _get_frame_region()
var frame_pixel := pixel - region.position
var local := frame_pixel - region.size / 2.0
local *= sprite.scale
return local
func _get_frame_region() -> Rect2:
if sprite.region_enabled:
return sprite.region_rect
var img_size := Vector2(sprite.texture.get_width(), sprite.texture.get_height())
var frame_size := Vector2(img_size.x / sprite.hframes, img_size.y / sprite.vframes)
var frame_col := sprite.frame % sprite.hframes
var frame_row := sprite.frame / sprite.hframes
return Rect2(Vector2(frame_col, frame_row) * frame_size, frame_size)
The AI gave me this, which I didn’t want, but hey, maybe someone reading this will find it cool! I’ll probably use it for the future gamemode of strict fencing rules–the tip would ALWAYS have a hitbox.
# ── Tip Hitbox ────────────────────────────────────────────────────────────────
func _check_tip_hit(tip_world: Vector2) -> void:
if not is_attacking:
return
var sweep := tip_world - _last_tip_world
if sweep.length() < 0.5:
return
var space := get_world_2d().direct_space_state
var params := PhysicsRayQueryParameters2D.create(
_last_tip_world,
tip_world,
hit_mask
)
params.exclude = [get_parent().get_rid()]
var result := space.intersect_ray(params)
if result:
_on_tip_hit(result)
func _on_tip_hit(hit: Dictionary) -> void:
print("Tip hit: ", hit.collider.name, " at ", hit.position)
if hit.collider.has_method("take_damage"):
hit.collider.take_damage(10, hit.position)