Trails - Detection Algorithim

Trails - Detection Algorithim

in

Right of Way needed a visual upgrade. One of the new visuals is the trails tracking algorithm. Right of Way 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.

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

Right of Way

Coordinate Points

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)