580 lines
22 KiB
GDScript
580 lines
22 KiB
GDScript
@tool
|
|
extends PanelContainer
|
|
class_name CghAssetCard
|
|
## One asset card — 1:1 with the After Effects addon card anatomy:
|
|
## square thumbnail (object-fit: cover) + badges (resolution pill / NEW / Premium)
|
|
## + favorite ♥ + cross-addon ✓ downloaded tick + title + "Category / Subcategory"
|
|
## + a .cdl-row footer: [⬇ label | sep | ▾ dropdown]; Upgrade = orange gradient.
|
|
## The card background is ALWAYS #1e1e24 (uniform grid); hover only lifts the border.
|
|
|
|
signal request_download(asset: Dictionary, entry: Dictionary) # entry={} -> best free
|
|
signal request_upgrade(asset: Dictionary)
|
|
signal favorite_toggled(asset: Dictionary, is_fav: bool)
|
|
signal preview_requested(card, asset) # hover -> main_dock loads still frames
|
|
signal flipbook_sheet_requested(card, asset) # hover on a flipbook -> main_dock fetches the real sprite sheet
|
|
signal cancel_requested(card) # ✕ on the in-flight download
|
|
signal delete_requested(asset: Dictionary) # 🗑 delete the downloaded file(s) -> re-download
|
|
signal viewed(asset) # user opened the format/options dropdown
|
|
signal login_required() # guest tried to favourite -> ask them to log in
|
|
|
|
const CARD_W := 168 # reference width
|
|
const MIN_W := 92 # min width so the responsive column count always fits the dock
|
|
const HOVER_DELAY := 0.12 # brief debounce before the NETWORK still-preview (flipbooks play instantly)
|
|
const PREVIEW_FPS := 0.45 # seconds per still frame
|
|
|
|
# dropdown special item ids (above any file index)
|
|
const ID_FAV := 9000
|
|
const ID_COPY := 9001
|
|
const ID_VIEW := 9002
|
|
const ID_DEL := 9003
|
|
|
|
var asset: Dictionary
|
|
var auth: CghAuth # set by main_dock; favourites need login
|
|
var _locked := false
|
|
var _thumb: TextureRect
|
|
var _wrap: Control
|
|
var _title: Label
|
|
var _subtitle: Label
|
|
var _action: Button
|
|
var _menu_btn: MenuButton
|
|
var _sep: ColorRect # divider between the main button and the ▾ dropdown
|
|
var _badge_row: HBoxContainer
|
|
var _fav_btn: Button
|
|
var _progress: ProgressBar
|
|
var _footer: PanelContainer
|
|
var _prog_row: HBoxContainer
|
|
var _thumb_base: Texture2D
|
|
var _preview_frames: Array = []
|
|
var _preview_idx := 0
|
|
var _preview_loaded := false
|
|
var _flipbook_sheet_asked := false # asked main_dock for this flipbook's real sprite sheet already
|
|
var _menu_entries: Array = [] # deduped entries backing the dropdown items
|
|
var _delay_timer: Timer
|
|
var _preview_timer: Timer
|
|
|
|
func setup(a: Dictionary) -> void:
|
|
asset = a
|
|
custom_minimum_size = Vector2(MIN_W, 0) # min width; height is content-driven
|
|
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
add_theme_stylebox_override("panel", CghTheme.card_box(false))
|
|
mouse_filter = Control.MOUSE_FILTER_PASS
|
|
mouse_entered.connect(_on_enter)
|
|
mouse_exited.connect(_on_exit)
|
|
|
|
# hover-preview timers
|
|
_delay_timer = Timer.new()
|
|
_delay_timer.one_shot = true
|
|
_delay_timer.wait_time = HOVER_DELAY
|
|
_delay_timer.timeout.connect(_start_preview)
|
|
add_child(_delay_timer)
|
|
_preview_timer = Timer.new()
|
|
_preview_timer.wait_time = PREVIEW_FPS
|
|
_preview_timer.timeout.connect(_next_preview_frame)
|
|
add_child(_preview_timer)
|
|
|
|
var v := VBoxContainer.new()
|
|
v.add_theme_constant_override("separation", 4)
|
|
# PASS (not the default STOP) so a hover anywhere over this container — thumbnail,
|
|
# title, subtitle, footer gaps — propagates up to the card and fires mouse_entered.
|
|
# Without this the card only "saw" hovers on the bare label rows, so the preview
|
|
# played only when the pointer was on/below the title (the thumbnail ate the event).
|
|
v.mouse_filter = Control.MOUSE_FILTER_PASS
|
|
add_child(v)
|
|
|
|
# --- square thumbnail wrap (keeps a 1:1 aspect as the card width changes) ---
|
|
_wrap = Control.new()
|
|
_wrap.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_wrap.custom_minimum_size = Vector2(0, MIN_W)
|
|
_wrap.clip_contents = true
|
|
_wrap.mouse_filter = Control.MOUSE_FILTER_PASS # let hover over the thumbnail reach the card
|
|
_wrap.resized.connect(_keep_square)
|
|
v.add_child(_wrap)
|
|
|
|
# dark bg behind the (possibly still-loading) thumbnail
|
|
var thumb_bg := ColorRect.new()
|
|
thumb_bg.color = CghConfig.C_NAV
|
|
thumb_bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
thumb_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_wrap.add_child(thumb_bg)
|
|
|
|
_thumb = TextureRect.new()
|
|
_thumb.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
_thumb.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
|
_thumb.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_thumb.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_wrap.add_child(_thumb)
|
|
|
|
# badges top-left
|
|
_badge_row = HBoxContainer.new()
|
|
_badge_row.position = Vector2(5, 5)
|
|
_badge_row.add_theme_constant_override("separation", 3)
|
|
_badge_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_wrap.add_child(_badge_row)
|
|
|
|
# top-right cluster: favorite ♥
|
|
var topright := HBoxContainer.new()
|
|
topright.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
|
topright.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
|
topright.offset_top = 4
|
|
topright.offset_right = -4
|
|
topright.add_theme_constant_override("separation", 2)
|
|
_wrap.add_child(topright)
|
|
|
|
_fav_btn = Button.new()
|
|
_fav_btn.flat = true
|
|
_fav_btn.custom_minimum_size = Vector2(22, 22)
|
|
_fav_btn.pressed.connect(_on_fav)
|
|
topright.add_child(_fav_btn)
|
|
_refresh_fav_icon()
|
|
|
|
# --- title ---
|
|
_title = Label.new()
|
|
_title.text = CghAsset.title(asset)
|
|
_title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
|
_title.clip_text = true
|
|
_title.add_theme_color_override("font_color", CghConfig.C_TEXT1)
|
|
_title.add_theme_font_size_override("font_size", 12)
|
|
_title.mouse_filter = Control.MOUSE_FILTER_PASS
|
|
v.add_child(_title)
|
|
|
|
# --- subtitle: "Category / Subcategory" ---
|
|
_subtitle = Label.new()
|
|
_subtitle.text = _subtitle_text()
|
|
_subtitle.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
|
_subtitle.clip_text = true
|
|
_subtitle.add_theme_color_override("font_color", CghConfig.C_TEXT3)
|
|
_subtitle.add_theme_font_size_override("font_size", 10)
|
|
_subtitle.mouse_filter = Control.MOUSE_FILTER_PASS
|
|
v.add_child(_subtitle)
|
|
|
|
# --- footer: .cdl-row pill [ main | sep | ▾ ] ---
|
|
_footer = PanelContainer.new()
|
|
_footer.add_theme_stylebox_override("panel", CghTheme.footer_pill_box())
|
|
_footer.custom_minimum_size = Vector2(0, 24)
|
|
_footer.mouse_filter = Control.MOUSE_FILTER_PASS # gaps around the buttons still hover the card
|
|
v.add_child(_footer)
|
|
var frow := HBoxContainer.new()
|
|
frow.add_theme_constant_override("separation", 0)
|
|
_footer.add_child(frow)
|
|
|
|
_action = Button.new()
|
|
_action.flat = true
|
|
_action.custom_minimum_size = Vector2(0, 22)
|
|
_action.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_action.add_theme_font_size_override("font_size", 11)
|
|
_action.pressed.connect(_on_action)
|
|
frow.add_child(_action)
|
|
|
|
_sep = ColorRect.new()
|
|
_sep.color = CghConfig.C_BORDER
|
|
_sep.custom_minimum_size = Vector2(1, 14)
|
|
_sep.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
frow.add_child(_sep)
|
|
|
|
_menu_btn = MenuButton.new()
|
|
_menu_btn.flat = true
|
|
_menu_btn.text = "▾"
|
|
_menu_btn.custom_minimum_size = Vector2(22, 22)
|
|
frow.add_child(_menu_btn)
|
|
_build_format_menu()
|
|
_menu_btn.get_popup().about_to_popup.connect(func(): viewed.emit(asset))
|
|
|
|
# --- progress row (hidden until downloading): thin accent bar + ✕ cancel ---
|
|
_prog_row = HBoxContainer.new()
|
|
_prog_row.add_theme_constant_override("separation", 4)
|
|
_prog_row.visible = false
|
|
v.add_child(_prog_row)
|
|
_progress = ProgressBar.new()
|
|
_progress.custom_minimum_size = Vector2(0, 6)
|
|
_progress.show_percentage = false
|
|
_progress.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_progress.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
var pfill := StyleBoxFlat.new()
|
|
pfill.bg_color = CghConfig.C_ACCENT
|
|
pfill.set_corner_radius_all(2)
|
|
_progress.add_theme_stylebox_override("fill", pfill)
|
|
var pbg := StyleBoxFlat.new()
|
|
pbg.bg_color = Color(0.5, 0.5, 0.5, 0.15)
|
|
pbg.set_corner_radius_all(2)
|
|
_progress.add_theme_stylebox_override("background", pbg)
|
|
_prog_row.add_child(_progress)
|
|
var cancel := Button.new()
|
|
cancel.flat = true
|
|
cancel.text = "✕"
|
|
cancel.tooltip_text = "Cancel download"
|
|
cancel.custom_minimum_size = Vector2(18, 18)
|
|
cancel.add_theme_font_size_override("font_size", 10)
|
|
cancel.add_theme_color_override("font_color", CghConfig.C_TEXT3)
|
|
cancel.add_theme_color_override("font_hover_color", CghConfig.C_ERR)
|
|
cancel.pressed.connect(func(): cancel_requested.emit(self))
|
|
_prog_row.add_child(cancel)
|
|
|
|
_apply_state()
|
|
|
|
func _keep_square() -> void:
|
|
if not _wrap:
|
|
return
|
|
var w := _wrap.size.x
|
|
if w > 1.0 and absf(_wrap.custom_minimum_size.y - w) > 0.5:
|
|
_wrap.custom_minimum_size.y = w
|
|
|
|
func _subtitle_text() -> String:
|
|
var cat := CghAsset.category_title(asset)
|
|
return cat if cat != "" else "Asset"
|
|
|
|
func _apply_state() -> void:
|
|
# Badges (NEW / Premium / resolution) intentionally removed per design.
|
|
_locked = not CghAsset.has_free_file(asset)
|
|
|
|
# footer main button — Upgrade (orange) / Import (downloaded) / <res> / Download
|
|
if _locked:
|
|
_action.text = "Upgrade"
|
|
_action.flat = false
|
|
_action.add_theme_stylebox_override("normal", CghTheme.brand_button_box())
|
|
_action.add_theme_stylebox_override("hover", CghTheme.brand_button_box(true))
|
|
_action.add_theme_stylebox_override("pressed", CghTheme.brand_button_box(true))
|
|
_action.add_theme_color_override("font_color", Color.WHITE)
|
|
_action.add_theme_color_override("font_hover_color", Color.WHITE)
|
|
# A locked (patreon) asset for a non-paying user has nothing to choose/download,
|
|
# so hide the ▾ dropdown + divider — the orange Upgrade fills the whole footer.
|
|
_menu_btn.visible = false
|
|
_sep.visible = false
|
|
else:
|
|
var best := CghAsset.best_free_entry(asset)
|
|
var rl: String = best.get("res", "") if not best.is_empty() else ""
|
|
# Cross-addon: "downloaded" if any CGHEVEN addon grabbed this asset (id match).
|
|
var dl: bool = CghDownloads.is_downloaded(asset)
|
|
if dl:
|
|
_action.text = ("Import %s" % rl).strip_edges() if rl != "" else "Import"
|
|
else:
|
|
_action.text = ("⤓ %s" % rl) if rl != "" else "⤓ Download"
|
|
_action.flat = true
|
|
_action.add_theme_color_override("font_color", CghConfig.C_TEXT2)
|
|
_action.add_theme_color_override("font_hover_color", CghConfig.C_ACCENT)
|
|
_menu_btn.visible = true
|
|
_menu_btn.disabled = false
|
|
_sep.visible = true
|
|
|
|
## Public: re-evaluate the download state (called after a delete) so the footer flips
|
|
## between Download / Import and the dropdown's delete row appears/disappears.
|
|
func refresh_state() -> void:
|
|
_apply_state()
|
|
_build_format_menu()
|
|
|
|
func _top_res_label() -> String:
|
|
var best := -1
|
|
var lbl := ""
|
|
for e in CghAsset.file_entries(asset):
|
|
if e["res"] != "" and e["res_rank"] >= best:
|
|
best = e["res_rank"]; lbl = e["res"]
|
|
return lbl
|
|
|
|
# AE-style dropdown: grouped by FORMAT, one row per resolution (deduped).
|
|
func _build_format_menu() -> void:
|
|
var pm := _menu_btn.get_popup()
|
|
pm.clear()
|
|
pm.add_theme_stylebox_override("panel", CghTheme.dropdown_box())
|
|
_menu_entries = []
|
|
var entries := CghAsset.importable_entries(asset)
|
|
# Hide the low-poly preview .glb from the menu when real files exist (it's only an
|
|
# internal download fallback) — keeps the list clean.
|
|
var non_preview := entries.filter(func(e): return not e.get("preview", false))
|
|
if not non_preview.is_empty():
|
|
entries = non_preview
|
|
var pref := ["GLTF", "GLB", "FBX", "OBJ", "ZIP", "EXR", "HDR", "PNG", "JPG", "JPEG", "WEBP"]
|
|
var verb := "Download"
|
|
if entries.is_empty():
|
|
pm.add_item("No Godot-importable file", -1)
|
|
pm.set_item_disabled(0, true)
|
|
elif _collapse_by_resolution(entries):
|
|
# Like Blender for 3D models / flipbooks: ONE resolution ladder, no format
|
|
# names (gltf/glb/fbx are interchangeable for Godot; png/jpg/webp likewise).
|
|
var by_res := {} # res -> best entry
|
|
for e in entries:
|
|
var rk: String = e["res"]
|
|
var cur = by_res.get(rk, null)
|
|
if cur == null:
|
|
by_res[rk] = e
|
|
else:
|
|
var better: bool = bool(cur["locked"]) and not bool(e["locked"])
|
|
if not better and bool(cur["locked"]) == bool(e["locked"]):
|
|
var ie: int = pref.find(_base_fmt(e["format"]))
|
|
var ic: int = pref.find(_base_fmt(cur["format"]))
|
|
if ie == -1: ie = 99
|
|
if ic == -1: ic = 99
|
|
better = ie < ic
|
|
if better:
|
|
by_res[rk] = e
|
|
var rows: Array = by_res.values()
|
|
rows.sort_custom(func(a, b): return int(a["res_rank"]) < int(b["res_rank"]))
|
|
for e in rows:
|
|
var v := "Import" if (CghDownloads.has_file(e["filename"]) or CghDownloads.is_res_downloaded(CghAsset.id(asset), e["res"])) else verb
|
|
var label := ("%s (%s)" % [e["res"], v]) if e["res"] != "" else v
|
|
if e["locked"]:
|
|
label += " 🔒"
|
|
_menu_entries.append(e)
|
|
pm.add_item(label, _menu_entries.size() - 1)
|
|
if e["locked"]:
|
|
pm.set_item_disabled(pm.get_item_count() - 1, true)
|
|
else:
|
|
# Mixed media (VFX/HDRI): keep format groups (MP4 vs EXR actually differ).
|
|
var groups := {} # format -> { res -> entry }
|
|
var order := [] # format first-seen order
|
|
for e in entries:
|
|
var fmt: String = e["format"]
|
|
var rk: String = e["res"]
|
|
if not groups.has(fmt):
|
|
groups[fmt] = {}
|
|
order.append(fmt)
|
|
var g: Dictionary = groups[fmt]
|
|
if not g.has(rk) or (bool(g[rk]["locked"]) and not bool(e["locked"])):
|
|
g[rk] = e
|
|
order.sort_custom(func(a, b):
|
|
var ia: int = pref.find(a); var ib: int = pref.find(b)
|
|
if ia == -1: ia = 99
|
|
if ib == -1: ib = 99
|
|
return ia < ib)
|
|
for fmt in order:
|
|
pm.add_separator(fmt)
|
|
var rows: Array = groups[fmt].values()
|
|
rows.sort_custom(func(a, b): return int(a["res_rank"]) < int(b["res_rank"]))
|
|
for e in rows:
|
|
var v := "Import" if (CghDownloads.has_file(e["filename"]) or CghDownloads.is_res_downloaded(CghAsset.id(asset), e["res"])) else verb
|
|
var label := ("%s (%s)" % [e["res"], v]) if e["res"] != "" else v
|
|
if e["locked"]:
|
|
label += " 🔒"
|
|
_menu_entries.append(e)
|
|
pm.add_item(label, _menu_entries.size() - 1)
|
|
if e["locked"]:
|
|
pm.set_item_disabled(pm.get_item_count() - 1, true)
|
|
# AE dropdown footer items
|
|
pm.add_separator()
|
|
pm.add_item(("♥ Remove from Favorites" if CghFavorites.is_fav(asset) else "♡ Add to Favorites"), ID_FAV)
|
|
pm.add_item("⧉ Copy asset link", ID_COPY)
|
|
pm.add_item("🌐 View online", ID_VIEW)
|
|
# Only offer delete when a copy is actually on disk — lets the user wipe a missing/
|
|
# corrupt download so the footer flips back to Download and they can re-fetch.
|
|
if CghDownloads.is_downloaded(asset):
|
|
pm.add_item("🗑 Delete download", ID_DEL)
|
|
if not pm.id_pressed.is_connected(_on_menu_pick):
|
|
pm.id_pressed.connect(_on_menu_pick)
|
|
|
|
# True when every importable entry is a 3D model (gltf/glb/fbx/obj/zip) OR every one
|
|
# is an image (png/jpg/webp) — those formats are interchangeable for Godot, so we show
|
|
# a single resolution list instead of one group per format (matches the Blender addon).
|
|
func _collapse_by_resolution(entries: Array) -> bool:
|
|
var model := ["glb", "gltf", "fbx", "obj", "zip"]
|
|
var image := ["png", "jpg", "jpeg", "webp"]
|
|
var all_model := true
|
|
var all_image := true
|
|
for e in entries:
|
|
if not (e["ext"] in model):
|
|
all_model = false
|
|
if not (e["ext"] in image):
|
|
all_image = false
|
|
return all_model or all_image
|
|
|
|
func _base_fmt(fmt: String) -> String:
|
|
return fmt.replace(" (GS)", "").to_upper()
|
|
|
|
func _on_menu_pick(idx: int) -> void:
|
|
match idx:
|
|
ID_FAV:
|
|
_on_fav()
|
|
ID_COPY:
|
|
DisplayServer.clipboard_set(_asset_url())
|
|
ID_VIEW:
|
|
OS.shell_open(_asset_url())
|
|
ID_DEL:
|
|
delete_requested.emit(asset)
|
|
_:
|
|
if idx >= 0 and idx < _menu_entries.size():
|
|
request_download.emit(asset, _menu_entries[idx])
|
|
|
|
func _asset_url() -> String:
|
|
return CghAsset.web_url(asset) # https://cgheven.com/assets/<assets_slug>, like Blender
|
|
|
|
func _on_action() -> void:
|
|
if _locked:
|
|
request_upgrade.emit(asset)
|
|
else:
|
|
request_download.emit(asset, {}) # {} -> caller uses best free entry
|
|
|
|
func _on_fav() -> void:
|
|
# Favourites require login — a guest gets a "please login" prompt instead.
|
|
if auth == null or not auth.is_logged_in():
|
|
login_required.emit()
|
|
return
|
|
var now := CghFavorites.toggle(asset)
|
|
_refresh_fav_icon()
|
|
_build_format_menu()
|
|
favorite_toggled.emit(asset, now)
|
|
|
|
func _refresh_fav_icon() -> void:
|
|
var fav := CghFavorites.is_fav(asset)
|
|
_fav_btn.text = "♥" if fav else "♡"
|
|
_fav_btn.add_theme_color_override("font_color", CghConfig.C_ERR if fav else CghConfig.C_TEXT1)
|
|
|
|
func _add_res_badge(text: String) -> void:
|
|
var b := Label.new()
|
|
b.text = text
|
|
b.add_theme_color_override("font_color", Color.WHITE)
|
|
b.add_theme_font_size_override("font_size", 9)
|
|
var p := PanelContainer.new()
|
|
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
p.add_theme_stylebox_override("panel", CghTheme.res_badge_box())
|
|
p.add_child(b)
|
|
_badge_row.add_child(p)
|
|
|
|
func _add_badge(text: String, col: Color) -> void:
|
|
var b := Label.new()
|
|
b.text = text
|
|
b.add_theme_color_override("font_color", Color.WHITE)
|
|
b.add_theme_font_size_override("font_size", 9)
|
|
var p := PanelContainer.new()
|
|
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
p.add_theme_stylebox_override("panel", CghTheme.badge_box(col))
|
|
p.add_child(b)
|
|
_badge_row.add_child(p)
|
|
|
|
# ---- called by main_dock during this card's download ----
|
|
func set_progress(pct: float) -> void:
|
|
_prog_row.visible = true
|
|
_footer.visible = false
|
|
if pct < 0:
|
|
_progress.indeterminate = true
|
|
else:
|
|
_progress.indeterminate = false
|
|
_progress.value = pct
|
|
|
|
func set_download_done(_ok: bool) -> void:
|
|
_prog_row.visible = false
|
|
_footer.visible = true
|
|
|
|
## Download cancelled — restore the footer to its pre-download state.
|
|
func reset_after_cancel() -> void:
|
|
_prog_row.visible = false
|
|
_footer.visible = true
|
|
_apply_state()
|
|
|
|
func set_thumbnail(tex: Texture2D) -> void:
|
|
if tex and _thumb:
|
|
_thumb.texture = tex
|
|
_thumb_base = tex
|
|
|
|
func get_thumb_url() -> String:
|
|
return CghAsset.thumbnail(asset)
|
|
|
|
# ---------------- hover preview (image slideshow; Godot can't play mp4) ----------------
|
|
func _on_enter() -> void:
|
|
add_theme_stylebox_override("panel", CghTheme.card_box(true))
|
|
if _preview_loaded:
|
|
if _preview_frames.size() > 1:
|
|
_preview_timer.start()
|
|
return
|
|
if CghAsset.is_flipbook(asset):
|
|
# Flipbooks: play the REAL sprite sheet, not the card thumbnail. Ask main_dock to fetch
|
|
# the low-res sheet once; it calls set_flipbook_sheet() when ready. (Slicing the card
|
|
# thumbnail was unreliable — some categories, e.g. muzzle-flashes, use a single-frame
|
|
# thumbnail whose layout doesn't match the sheet's NxM, so slicing it showed garbage.)
|
|
if not _flipbook_sheet_asked:
|
|
_flipbook_sheet_asked = true
|
|
flipbook_sheet_requested.emit(self, asset)
|
|
return
|
|
# Non-flipbooks: brief debounce, then load still frames over the network.
|
|
_delay_timer.start()
|
|
|
|
func _on_exit() -> void:
|
|
# Moving the pointer onto a child button (Download / ▾ / ♥) fires mouse_exited on the
|
|
# card even though the pointer is still INSIDE it. Ignore those spurious exits so the
|
|
# preview keeps playing across the whole card — only stop when truly leaving the card.
|
|
# (If the pointer then leaves the card VIA that child button, the card gets NO second
|
|
# mouse_exited — the watchdog in _next_preview_frame catches that case and stops.)
|
|
if is_visible_in_tree() and get_global_rect().has_point(get_global_mouse_position()):
|
|
return
|
|
_stop_preview()
|
|
|
|
# Stop the hover preview and restore the card to its resting look.
|
|
func _stop_preview() -> void:
|
|
add_theme_stylebox_override("panel", CghTheme.card_box(false))
|
|
_delay_timer.stop()
|
|
_preview_timer.stop()
|
|
# If the flipbook sheet never actually loaded (download failed / not a flipbook), allow a
|
|
# fresh attempt on the next hover instead of being stuck forever.
|
|
if not _preview_loaded:
|
|
_flipbook_sheet_asked = false
|
|
if _thumb_base:
|
|
_thumb.texture = _thumb_base # revert to thumbnail
|
|
|
|
func _start_preview() -> void:
|
|
if _preview_loaded:
|
|
return
|
|
# Non-flipbooks: ask main_dock for still frames (flipbooks are handled in _on_enter via the
|
|
# real sprite-sheet download).
|
|
preview_requested.emit(self, asset)
|
|
|
|
# main_dock delivers the REAL low-res sprite sheet (as a texture) + its grid; slice it into
|
|
# per-cell AtlasTexture frames and play. Using the actual sheet (not the card thumbnail)
|
|
# guarantees the grid matches the image — this fixes categories like muzzle-flashes whose card
|
|
# thumbnail is a single frame, not a contact sheet, so slicing it NxM produced garbage.
|
|
func set_flipbook_sheet(tex: Texture2D, grid: Vector2i) -> void:
|
|
if tex == null:
|
|
return
|
|
var cols: int = maxi(1, grid.x)
|
|
var rows: int = maxi(1, grid.y)
|
|
if cols * rows <= 1:
|
|
return
|
|
var tw := tex.get_width()
|
|
var th := tex.get_height()
|
|
if tw < cols or th < rows:
|
|
return
|
|
# INTEGER cell size (float-divide then floor) so regions land on exact pixel boundaries —
|
|
# fractional regions sample slivers of the neighbouring cell and make the playback shimmer.
|
|
# The last column/row absorbs any remainder pixels so we never read past the sheet edge.
|
|
var cw := int(float(tw) / float(cols))
|
|
var ch := int(float(th) / float(rows))
|
|
if cw < 1 or ch < 1:
|
|
return
|
|
var frames := []
|
|
for r in rows:
|
|
for c in cols:
|
|
var w := cw if c < cols - 1 else tw - cw * (cols - 1)
|
|
var h := ch if r < rows - 1 else th - ch * (rows - 1)
|
|
var at := AtlasTexture.new()
|
|
at.atlas = tex
|
|
at.region = Rect2(c * cw, r * ch, w, h)
|
|
frames.append(at)
|
|
if frames.size() <= 1:
|
|
return
|
|
_preview_frames = frames
|
|
_preview_loaded = true
|
|
_preview_idx = 0
|
|
_preview_timer.wait_time = 0.04 # ~25 fps flipbook playback, smooth (stills use PREVIEW_FPS)
|
|
# Only start playing if the pointer is still over this card (the sheet may have finished
|
|
# downloading after the user moved on).
|
|
if is_visible_in_tree() and get_global_rect().has_point(get_global_mouse_position()):
|
|
_thumb.texture = frames[0]
|
|
_preview_timer.start()
|
|
|
|
func set_preview_frames(frames: Array) -> void:
|
|
_preview_frames = frames
|
|
_preview_loaded = true
|
|
if _preview_frames.size() >= 1 and is_visible_in_tree():
|
|
if get_global_rect().has_point(get_global_mouse_position()):
|
|
_preview_idx = 0
|
|
_thumb.texture = _preview_frames[0]
|
|
if _preview_frames.size() > 1:
|
|
_preview_timer.start()
|
|
|
|
func _next_preview_frame() -> void:
|
|
# Watchdog: when the pointer leaves the card VIA a child button, the card never gets a
|
|
# second mouse_exited, so the preview would otherwise play forever. This frame timer only
|
|
# runs while previewing, so re-check the pointer each tick and stop once it's truly gone.
|
|
if not is_visible_in_tree() or not get_global_rect().has_point(get_global_mouse_position()):
|
|
_stop_preview()
|
|
return
|
|
if _preview_frames.is_empty():
|
|
return
|
|
_preview_idx = (_preview_idx + 1) % _preview_frames.size()
|
|
_thumb.texture = _preview_frames[_preview_idx]
|