fix(m110): use sprite-alpha occlusion and self-target exclusion

This commit is contained in:
2026-09-23 21:21:47 +08:00
parent 6b3b8c0d73
commit c16657e4d6
+52 -17
View File
@@ -1196,17 +1196,6 @@ func _visible_ui_ink_rects(shell: Control) -> Array:
return rects
func _visible_actor_rects(shell: Control) -> Array:
var rects: Array = []
for actor_group in ["BattleActors", "BattleEnemies"]:
var group := shell.find_child(actor_group, true, false)
if group == null:
continue
for actor in group.get_children():
rects.append((actor as Control).get_global_rect().grow(1.0))
return rects
func _visible_text_nodes(node: Node, out: Array) -> void:
for child in node.get_children():
if (child is Label or child is BaseButton) and (child as CanvasItem).is_visible_in_tree():
@@ -1214,6 +1203,50 @@ func _visible_text_nodes(node: Node, out: Array) -> void:
_visible_text_nodes(child, out)
func _actor_occlusion_masks(shell: Control) -> Array:
# Sprite-space occlusion: a pixel counts as occluded only when the actor
# portrait texture is actually opaque there (the art layer stays below the
# actor layer, so the path is hidden only behind opaque sprite pixels).
var masks: Array = []
for group_name in ["BattleActors", "BattleEnemies"]:
var group := shell.find_child(group_name, true, false)
if group == null:
continue
for actor in group.get_children():
for portrait_name in ["BattlePortrait", "BattleEnemyPortrait"]:
var portrait := actor.find_child(portrait_name, true, false) as TextureRect
if portrait == null or portrait.texture == null or not portrait.is_visible_in_tree():
continue
var sprite := portrait.texture.get_image()
if sprite == null:
continue
masks.append({"rect": portrait.get_global_rect(), "image": sprite})
return masks
func _pixel_occluded(masks: Array, pixel: Vector2) -> bool:
for mask_value in masks:
var mask: Dictionary = mask_value
var rect: Rect2 = mask.rect
if not rect.has_point(pixel):
continue
var sprite: Image = mask.image
var texture_size := Vector2(float(sprite.get_width()), float(sprite.get_height()))
if texture_size.x <= 0.0 or texture_size.y <= 0.0:
continue
var scale := minf(rect.size.x / texture_size.x, rect.size.y / texture_size.y)
if scale <= 0.0:
continue
var drawn := texture_size * scale
var origin := rect.position + (rect.size - drawn) * 0.5
var uv := (pixel - origin) / scale
var sx := clampi(int(uv.x), 0, sprite.get_width() - 1)
var sy := clampi(int(uv.y), 0, sprite.get_height() - 1)
if sprite.get_pixel(sx, sy).a > 0.4:
return true
return false
func _point_in_any_rect(rects: Array, point: Vector2) -> bool:
for rect_value in rects:
if (rect_value as Rect2).has_point(point):
@@ -1263,12 +1296,12 @@ func _is_palette_blend(color: Color, stroke: Color, underlays: Array) -> bool:
return false
func _core_carrier_at(image: Image, point: Vector2, normal: Vector2, side: float, stroke: Color, underlays: Array, ui_rects: Array, actor_rects: Array) -> Dictionary:
func _core_carrier_at(image: Image, point: Vector2, normal: Vector2, side: float, stroke: Color, underlays: Array, ui_rects: Array, occlusion_masks: Array) -> Dictionary:
# Walk outward from the bright core and take the first non-chrome pixel:
# with the A5 underlay in place this is the underlay; without it, the raw
# rendered carrier is returned (so the measurement can still fail honestly).
# A first valid pixel inside an actor portrait is a sprite occluder, not a
# carrier: the point is reported as occluded and excluded (M110 A5 rule).
# A first valid pixel covered by an opaque actor sprite is an occluder, not
# a carrier: the point is reported as occluded and excluded (M110 A5 rule).
for step in range(7):
var distance := 1.6 + 0.5 * float(step)
var probe := point + normal * distance * side
@@ -1285,7 +1318,7 @@ func _core_carrier_at(image: Image, point: Vector2, normal: Vector2, side: float
return {
"color": color,
"distance": distance,
"occluded": _point_in_any_rect(actor_rects, pixel_position),
"occluded": _pixel_occluded(occlusion_masks, pixel_position),
}
return {}
@@ -1329,6 +1362,8 @@ func _path_contrast(image: Image, shell: Control) -> Dictionary:
var height := float(image.get_height())
var source: Vector2 = art.get("source_anchor") * Vector2(width, height)
var target: Vector2 = art.get("target_anchor") * Vector2(width, height)
if source.distance_to(target) < 1.0:
return {"status": "NOT_APPLICABLE", "reason": "self-target event: no relation span between distinct actors"}
var bend := -height * 0.095 if source.x < target.x else height * 0.075
var midpoint := (source + target) * 0.5 + Vector2(0.0, bend)
var points: Array = []
@@ -1336,7 +1371,7 @@ func _path_contrast(image: Image, shell: Control) -> Dictionary:
var t := float(index) / 20.0
points.append(source * pow(1.0 - t, 2.0) + midpoint * 2.0 * (1.0 - t) * t + target * pow(t, 2.0))
var ui_rects := _visible_ui_ink_rects(shell)
var actor_rects := _visible_actor_rects(shell)
var occlusion_masks := _actor_occlusion_masks(shell)
var measured: Array = []
var skipped := 0
var occluded := 0
@@ -1346,7 +1381,7 @@ func _path_contrast(image: Image, shell: Control) -> Dictionary:
var normal := Vector2(-tangent.y, tangent.x)
var point_carriers: Array = []
for side in [-1.0, 1.0]:
var entry := _core_carrier_at(image, point, normal, side, stroke, underlays, ui_rects, actor_rects)
var entry := _core_carrier_at(image, point, normal, side, stroke, underlays, ui_rects, occlusion_masks)
if entry.is_empty():
continue
if bool(entry.get("occluded", false)):