diff --git a/tests/test_gas_bomber_cooldowns.gd b/tests/test_gas_bomber_cooldowns.gd index c80883e..ff1d616 100644 --- a/tests/test_gas_bomber_cooldowns.gd +++ b/tests/test_gas_bomber_cooldowns.gd @@ -44,8 +44,16 @@ func _process(delta: float) -> bool: skill._process(0.0) if first.is_ready() or not second.is_ready() or not skill.button.enabled: return _fail("the first shot disabled more than its firing bomber") - if not first._filled_hull(fleet.ships[0].definition.hull_points, 0.0).is_empty() \ - or first._filled_hull(fleet.ships[0].definition.hull_points, 0.5).size() < 3: + # _filled_hull returns a LIST of polygons: a notched hull fills as two + # separate puddles until the level rises past the notch, and the gas + # bomber's tail is exactly such a notch. + var hull: PackedVector2Array = fleet.ships[0].definition.hull_points + var empty: Array = first._filled_hull(hull, 0.0) + var half: Array = first._filled_hull(hull, 0.5) + var half_points: int = 0 + for piece in half: + half_points += piece.size() + if not empty.is_empty() or half_points < 3: return _fail("bomber cooldown did not produce an empty-to-filling hull") if skill.projectiles.size() != 1: return _fail("the first bomber did not launch exactly one projectile") diff --git a/vessels/VesselCooldownFillComponent.gd b/vessels/VesselCooldownFillComponent.gd index 8c009c1..f1c1b36 100644 --- a/vessels/VesselCooldownFillComponent.gd +++ b/vessels/VesselCooldownFillComponent.gd @@ -7,6 +7,14 @@ extends Node2D 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 @@ -37,30 +45,76 @@ func cooldown_progress() -> float: return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) -func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: +# 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 PackedVector2Array() + return [] if progress >= 0.999: - return points.duplicate() + 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)) - var clipped := PackedVector2Array() - var previous: Vector2 = points[-1] - var previous_inside: bool = previous.y >= fill_y - for current in points: - var current_inside: bool = current.y >= fill_y - if current_inside != previous_inside: - var amount: float = (fill_y - previous.y) / (current.y - previous.y) - clipped.append(previous.lerp(current, amount)) - if current_inside: - clipped.append(current) - previous = current - previous_inside = current_inside - return clipped + # 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: @@ -75,9 +129,8 @@ func _draw() -> void: ship.core_color, 0.75) return draw_colored_polygon(hull, Color(_cooling_color(), 0.58)) - var filled: PackedVector2Array = _filled_hull(hull, cooldown_progress()) - if filled.size() >= 3: - draw_colored_polygon(filled, Color(ready_color, 0.68)) + for piece in _filled_hull(hull, cooldown_progress()): + draw_colored_polygon(piece, Color(ready_color, 0.68)) func _ready_color() -> Color: return Color.WHITE