chobit/shared/godot/avatar/avatar_hitbox.gd
Claude Code 7067b6dded refactor(shared): ♻️ Improve shared utility structure for better maintainability
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-28 14:55:37 -07:00

143 lines
4.1 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Node
## Maintains the window mouse passthrough polygon from the rendered viewport alpha.
## Only pixels where the avatar is actually drawn capture mouse events.
## Clicks on transparent areas fall through to the desktop at the OS level.
##
## Uses DisplayServer.window_set_mouse_passthrough(polygon) where the polygon
## encloses the opaque region of the viewport (i.e., where Miku is visible).
const GRID_SIZE := 24 # sample resolution (NxN grid across the viewport)
const ALPHA_THRESHOLD := 0.05 # pixels below this alpha are transparent
const UPDATE_INTERVAL := 1.0 # seconds between polygon recalculations
const INITIAL_DELAY := 2.0 # wait for VRM to load and first frame to render
var _elapsed := 0.0
var _initialized := false
var _paused := false
func _ready() -> void:
EventBus.drag_started.connect(_on_interaction_started)
EventBus.drag_ended.connect(_on_interaction_ended)
EventBus.rotate_started.connect(_on_interaction_started)
EventBus.rotate_ended.connect(_on_interaction_ended)
func _on_interaction_started() -> void:
_paused = true
func _on_interaction_ended() -> void:
_paused = false
_elapsed = 0.0
_update_passthrough()
func _process(delta: float) -> void:
if _paused:
return
_elapsed += delta
if not _initialized:
if _elapsed >= INITIAL_DELAY:
_initialized = true
_elapsed = 0.0
_update_passthrough()
return
if _elapsed >= UPDATE_INTERVAL:
_elapsed = 0.0
_update_passthrough()
func _update_passthrough() -> void:
var vp := get_viewport()
if vp == null:
return
var img := vp.get_texture().get_image()
if img == null or img.is_empty():
return
var polygon := _build_polygon(img)
if polygon.is_empty():
DisplayServer.window_set_mouse_passthrough(polygon)
return
# Viewport renders at base resolution (e.g. 720×1440) but window_set_mouse_passthrough
# expects window-space coordinates. Scale the polygon to match the actual window size.
var win := Vector2(DisplayServer.window_get_size())
var img_sz := Vector2(img.get_width(), img.get_height())
var scale := win / img_sz
var scaled := PackedVector2Array()
scaled.resize(polygon.size())
for i: int in polygon.size():
scaled[i] = polygon[i] * scale
DisplayServer.window_set_mouse_passthrough(scaled)
## Samples the image at GRID_SIZE×GRID_SIZE, collects corners of opaque cells,
## then wraps them in a convex hull that becomes the clickable region.
func _build_polygon(img: Image) -> PackedVector2Array:
var w := img.get_width()
var h := img.get_height()
if w == 0 or h == 0:
return PackedVector2Array()
var step_x := float(w) / GRID_SIZE
var step_y := float(h) / GRID_SIZE
var samples := PackedVector2Array()
for gy in range(GRID_SIZE):
for gx in range(GRID_SIZE):
var px := clampi(int((gx + 0.5) * step_x), 0, w - 1)
var py := clampi(int((gy + 0.5) * step_y), 0, h - 1)
if img.get_pixel(px, py).a > ALPHA_THRESHOLD:
# Push all four corners of this grid cell as hull candidates
var x0 := gx * step_x
var y0 := gy * step_y
var x1 := (gx + 1) * step_x
var y1 := (gy + 1) * step_y
samples.append(Vector2(x0, y0))
samples.append(Vector2(x1, y0))
samples.append(Vector2(x0, y1))
samples.append(Vector2(x1, y1))
if samples.is_empty():
# No opaque pixels — fall back to capturing the full window rather than
# making it completely unclickable.
return PackedVector2Array(
[
Vector2(0, 0),
Vector2(w, 0),
Vector2(w, h),
Vector2(0, h),
]
)
return _convex_hull(samples)
## Jarvis march (gift wrapping) convex hull.
## Returns the convex hull of pts in CW order (screen-space, y-down).
func _convex_hull(pts: PackedVector2Array) -> PackedVector2Array:
var n := pts.size()
if n < 3:
return pts
# Start from the leftmost point (guaranteed on the hull)
var start := 0
for i in range(1, n):
if pts[i].x < pts[start].x:
start = i
var hull := PackedVector2Array()
var cur := start
while true:
hull.append(pts[cur])
var nxt := (cur + 1) % n
for i in range(n):
# cross < 0: pts[i] is clockwise of cur→nxt in screen coords (y-down)
var cross := (pts[nxt] - pts[cur]).cross(pts[i] - pts[cur])
if cross < 0:
nxt = i
cur = nxt
if cur == start or hull.size() > n:
break
return hull