spaceship/vessels/VesselCooldownFillComponent.gd
Loïc Wisniewski 12bd5eb7bf Clip the cooldown fill with Geometry2D, not by hand
Every cooling vessel logged "Invalid polygon data, triangulation failed"
once per frame, for as long as its cooldown ran - 16 of them in a single
test_laser_vessel run. The fill was drawn anyway, so nothing looked wrong,
but it buried real errors in test logs.

_filled_hull cut each hull edge against the fill line itself, which is
Sutherland-Hodgman, and Sutherland-Hodgman is only sound for a CONVEX
subject. Hulls are not: the gas bomber has a tail notch and the distorder a
waist. On those it stitched the pieces back together with a zero-width
bridge, and a polygon with a zero-width bridge is not one the triangulator
accepts.

Geometry2D.intersect_polygons does the clipping properly and returns a list
of polygons, which is also the truer answer - a notched hull genuinely fills
as two puddles until the level rises past the notch. A weld-and-area guard
stays in front of the renderer for repeated vertices and slivers.

Zero triangulation errors across the vessel tests now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:05 +02:00

137 lines
4.7 KiB
GDScript

class_name VesselCooldownFillComponent
extends Node2D
# Shared per-vessel cooldown state and hull-fill feedback for abilities that
# grant one independently recharging shot per ship.
var cooldown_duration: float = 1.0
var cooldown_left: float = 0.0
# Two vertices this close together are the same vertex as far as the fill is
# concerned - a hull is tens of units across, so this is well below anything
# that could be a real edge.
const WELD_DISTANCE: float = 0.01
# Below this the fill is a sliver nobody can see, and drawing it is all cost
# and no picture.
const MIN_FILL_AREA: float = 0.5
func setup() -> void:
show_behind_parent = true
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func _begin_cooldown(duration: float) -> bool:
if not is_ready():
return false
cooldown_duration = maxf(duration, 0.001)
cooldown_left = cooldown_duration
queue_redraw()
return true
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
# The part of the hull that is "filled up" at this progress, as a list of
# polygons - plural, because a hull with a notch in it (the gas bomber's
# tail, the distorder's waist) genuinely fills as two separate puddles
# before the level rises past the notch.
#
# Geometry2D does the clipping. This used to walk the hull itself and cut
# each edge against the fill line, which is Sutherland-Hodgman, and
# Sutherland-Hodgman is only sound for a CONVEX subject: on a notched hull
# it stitches the pieces back together with a zero-width bridge, and a
# polygon with a zero-width bridge is not one Godot's triangulator will
# accept. It said so, once per frame per cooling vessel, for as long as any
# cooldown ran.
func _filled_hull(points: PackedVector2Array, progress: float) -> Array:
if points.size() < 3 or progress <= 0.001:
return []
if progress >= 0.999:
return [points.duplicate()]
var top: float = INF
var bottom: float = -INF
var left: float = INF
var right: float = -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
left = minf(left, point.x)
right = maxf(right, point.x)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
# Deliberately oversized sideways and downwards, so the box's own edges
# never coincide with the hull's and the only cut is the horizontal one.
var box := PackedVector2Array([
Vector2(left - 8.0, fill_y), Vector2(right + 8.0, fill_y),
Vector2(right + 8.0, bottom + 8.0), Vector2(left - 8.0, bottom + 8.0),
])
var pieces: Array = []
for piece in Geometry2D.intersect_polygons(points, box):
var drawable: PackedVector2Array = _drawable(piece)
if not drawable.is_empty():
pieces.append(drawable)
return pieces
# Last guard before the renderer. The clipper is sound where the old walk
# was not, but it can still hand back a vertex repeated at a corner, or a
# sliver of a puddle at the very start of a fill - neither is worth drawing
# and both are the shapes triangulation objects to.
func _drawable(points: PackedVector2Array) -> PackedVector2Array:
var welded := PackedVector2Array()
for point in points:
if welded.is_empty() \
or welded[welded.size() - 1].distance_to(point) > WELD_DISTANCE:
welded.append(point)
# The polygon is implicitly closed, so the last point meeting the first is
# the same duplication seen from the other end.
while welded.size() >= 2 \
and welded[0].distance_to(welded[welded.size() - 1]) <= WELD_DISTANCE:
welded.remove_at(welded.size() - 1)
if welded.size() < 3 or _area(welded) < MIN_FILL_AREA:
return PackedVector2Array()
return welded
# Shoelace, unsigned: the clip preserves the hull's winding, but which way a
# hull winds is the definition's business and not worth depending on here.
func _area(points: PackedVector2Array) -> float:
var total: float = 0.0
var previous: Vector2 = points[points.size() - 1]
for point in points:
total += previous.x * point.y - point.x * previous.y
previous = point
return absf(total) * 0.5
func _draw() -> void:
var ship: Node = get_parent()
if ship == null or ship.definition == null:
return
var hull: PackedVector2Array = ship.definition.hull_points
var ready_color: Color = _ready_color()
if is_ready():
draw_colored_polygon(hull, Color(ready_color, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, ready_color, 0.75,
ship.core_color, 0.75)
return
draw_colored_polygon(hull, Color(_cooling_color(), 0.58))
for piece in _filled_hull(hull, cooldown_progress()):
draw_colored_polygon(piece, Color(ready_color, 0.68))
func _ready_color() -> Color: return Color.WHITE
func _cooling_color() -> Color: return Color(0.2, 0.2, 0.2)