- Все 189 скелетов Spine сконвертированы в нативные Godot Skeleton2D (.tscn), без Spine в рантайме - Меню + сцены: подземелье (карта комнат), просмотр спрайтов, нативные анимации - Кастомизация цвета (кожа/волосы/глаза) через шейдер - Вытащенные текстуры/UI/шрифты + нарезка спрайтов из атласа - Датасет комнат для LoRA, офлайн-инструменты конвертации в tools/ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
226 lines
9.3 KiB
GDScript
226 lines
9.3 KiB
GDScript
extends SceneTree
|
|
|
|
# Пакетный конвертер: по всем tools/rig/geom/<char>__<stem>.json запекает анимации из Spine
|
|
# и собирает нативные characters/<char>/<stem>_native.tscn (Skeleton2D + Polygon2D + AnimationPlayer).
|
|
# Запуск: godot ... --headless --script res://tools/convert_all.gd
|
|
|
|
const GEOM_DIR := "res://tools/rig/geom"
|
|
const FPS := 30.0
|
|
const VP := Vector2(1280, 720)
|
|
|
|
func _initialize():
|
|
var files := []
|
|
var d := DirAccess.open(GEOM_DIR)
|
|
d.list_dir_begin()
|
|
var n := d.get_next()
|
|
while n != "":
|
|
if n.ends_with(".json"): files.append(n)
|
|
n = d.get_next()
|
|
files.sort()
|
|
|
|
var ok := 0; var fail := 0; var i := 0
|
|
for fn in files:
|
|
i += 1
|
|
var stem_full: String = fn.get_basename() # char__stem
|
|
var sep := stem_full.find("__")
|
|
var char := stem_full.substr(0, sep)
|
|
var stem := stem_full.substr(sep + 2)
|
|
var base := "res://characters/%s/spine/%s" % [char, stem]
|
|
if not FileAccess.file_exists(base + ".skel.bytes"):
|
|
fail += 1; continue
|
|
var out := "res://characters/%s/%s_native.tscn" % [char, stem]
|
|
if _convert(base, GEOM_DIR + "/" + fn, out):
|
|
ok += 1
|
|
else:
|
|
fail += 1
|
|
if i % 10 == 0:
|
|
print("... %d/%d (ok=%d)" % [i, files.size(), ok])
|
|
await process_frame
|
|
print("CONVERT ALL done: ok=%d fail=%d из %d" % [ok, fail, files.size()])
|
|
quit()
|
|
|
|
func _convert(base: String, geom_path: String, out_path: String) -> bool:
|
|
var gf := FileAccess.open(geom_path, FileAccess.READ)
|
|
if gf == null: return false
|
|
var geom: Dictionary = JSON.parse_string(gf.get_as_text()); gf.close()
|
|
if geom == null or not geom.has("bones"): return false
|
|
|
|
var anims = _bake(base)
|
|
if anims == null: return false
|
|
|
|
var root = _build(geom, anims, base.get_base_dir())
|
|
if root == null: return false
|
|
_set_owner(root, root)
|
|
|
|
var packed := PackedScene.new()
|
|
if packed.pack(root) != OK:
|
|
root.free(); return false
|
|
var err := ResourceSaver.save(packed, out_path)
|
|
root.free()
|
|
return err == OK
|
|
|
|
# ---------- запекание анимаций ----------
|
|
func _bake(base: String):
|
|
var atlas := SpineAtlasResource.new()
|
|
if atlas.load_from_atlas_file(base + ".atlas.txt") != OK: return null
|
|
var skf := SpineSkeletonFileResource.new(); skf.load_from_file(base + ".skel.bytes")
|
|
var data := SpineSkeletonDataResource.new()
|
|
data.set_atlas_res(atlas); data.set_skeleton_file_res(skf)
|
|
if not data.is_skeleton_data_loaded(): return null
|
|
|
|
var sprite := SpineSprite.new(); sprite.set_skeleton_data_res(data)
|
|
get_root().add_child(sprite)
|
|
var st = sprite.get_animation_state()
|
|
var skel = sprite.get_skeleton()
|
|
|
|
var bone_names := []
|
|
for bd in data.get_bones(): bone_names.append(bd.get_bone_name())
|
|
var slot_names := []; var slot_index := {}
|
|
var live_slots = skel.get_slots()
|
|
for i in live_slots.size():
|
|
var sn = live_slots[i].get_data().get_name()
|
|
slot_names.append(sn); slot_index[sn] = i
|
|
|
|
var out := { "fps": FPS, "bones": bone_names, "slots": slot_names, "animations": {} }
|
|
for sa in data.get_animations():
|
|
var anim_name = sa.get_name()
|
|
var dur: float = sa.get_duration()
|
|
var nframes := int(round(dur * FPS)) + 1
|
|
st.clear_tracks(); st.set_animation(anim_name, false, 0)
|
|
var frames := []; var draworder := []
|
|
for f in nframes:
|
|
skel.set_bones_to_setup_pose(); skel.set_slots_to_setup_pose()
|
|
st.apply(skel)
|
|
var frame := []
|
|
for nm in bone_names:
|
|
var b = skel.find_bone(nm)
|
|
frame.append([snappedf(b.get_x(),0.001), snappedf(b.get_y(),0.001), snappedf(b.get_rotation(),0.001), snappedf(b.get_scale_x(),0.001), snappedf(b.get_scale_y(),0.001)])
|
|
frames.append(frame)
|
|
var order := []
|
|
for s in skel.get_draw_order(): order.append(slot_index[s.get_data().get_name()])
|
|
draworder.append(order)
|
|
st.update(1.0 / FPS)
|
|
out["animations"][anim_name] = { "duration": dur, "frames": frames, "draworder": draworder }
|
|
|
|
get_root().remove_child(sprite); sprite.free()
|
|
return out
|
|
|
|
# ---------- сборка нативного рига ----------
|
|
func _build(geom: Dictionary, anims: Dictionary, spine_dir: String):
|
|
# текстуры страниц атласа
|
|
var pagetex := {}
|
|
for pg in geom.get("pages", []):
|
|
pagetex[pg] = load(spine_dir + "/" + pg)
|
|
|
|
var root := Node2D.new(); root.name = "Native"
|
|
var spine := Node2D.new(); spine.name = "SpineRoot"; root.add_child(spine)
|
|
var skel := Skeleton2D.new(); skel.name = "Skeleton2D"; spine.add_child(skel)
|
|
|
|
var by_name := {}; var bones := []
|
|
for bd in geom["bones"]:
|
|
var b := Bone2D.new(); b.name = bd["name"]
|
|
b.set_autocalculate_length_and_angle(false); b.set_length(float(bd["length"])); b.set_bone_angle(0.0)
|
|
var t := Transform2D(deg_to_rad(float(bd["rotation"])), Vector2(bd["scaleX"], bd["scaleY"]), 0.0, Vector2(bd["x"], bd["y"]))
|
|
var parent = by_name.get(bd["parent"], skel)
|
|
parent.add_child(b); b.transform = t; b.rest = t
|
|
bones.append(b); by_name[bd["name"]] = b
|
|
|
|
var poly_by_slot := {}; var base_rank := {}
|
|
var minv := Vector2(1e9,1e9); var maxv := Vector2(-1e9,-1e9); var rank := 0
|
|
for att in geom["attachments"]:
|
|
var world: Array = att["world"]; var uv: Array = att["uv"]; var tris: Array = att["triangles"]; var weights: Array = att["weights"]
|
|
var vc := world.size() / 2
|
|
if vc < 3: continue
|
|
var tex = pagetex.get(att["page"], null)
|
|
if tex == null: continue
|
|
var ts: Vector2 = tex.get_size()
|
|
var poly := Polygon2D.new(); poly.name = String(att["slot"]); poly.texture = tex
|
|
var pts := PackedVector2Array(); var uvs := PackedVector2Array(); pts.resize(vc); uvs.resize(vc)
|
|
for i in vc:
|
|
var p := Vector2(world[i*2], world[i*2+1]); pts[i] = p
|
|
uvs[i] = Vector2(uv[i*2] * ts.x, uv[i*2+1] * ts.y)
|
|
minv = minv.min(p); maxv = maxv.max(p)
|
|
poly.polygon = pts; poly.uv = uvs
|
|
var polys := []
|
|
for t in range(0, tris.size(), 3): polys.append(PackedInt32Array([tris[t], tris[t+1], tris[t+2]]))
|
|
poly.polygons = polys
|
|
var c: Array = att["color"]; poly.modulate = Color(c[0],c[1],c[2],c[3])
|
|
poly.z_index = rank
|
|
skel.add_child(poly); poly.skeleton = poly.get_path_to(skel)
|
|
poly_by_slot[String(att["slot"])] = poly; base_rank[String(att["slot"])] = rank; rank += 1
|
|
var bw := {}
|
|
for i in vc:
|
|
for pair in weights[i]:
|
|
var bi := int(pair[0])
|
|
if not bw.has(bi): var a := PackedFloat32Array(); a.resize(vc); bw[bi] = a
|
|
bw[bi][i] = float(pair[1])
|
|
for bi in bw: poly.add_bone(skel.get_path_to(bones[bi]), bw[bi])
|
|
|
|
if maxv.x < minv.x: root.free(); return null # нет привязок
|
|
var size := maxv - minv; var center := (minv + maxv) * 0.5
|
|
var fit: float = min(VP.x * 0.55 / max(size.x,1), VP.y * 0.8 / max(size.y,1))
|
|
spine.scale = Vector2(fit, -fit)
|
|
spine.position = Vector2(VP.x*0.5 - center.x*fit, VP.y*0.5 + center.y*fit)
|
|
|
|
# AnimationPlayer
|
|
var player := AnimationPlayer.new(); player.name = "AnimationPlayer"; root.add_child(player)
|
|
player.root_node = player.get_path_to(skel)
|
|
var lib := AnimationLibrary.new()
|
|
var bone_names: Array = anims["bones"]; var slot_names: Array = anims.get("slots", [])
|
|
for anim_name in anims["animations"]:
|
|
var a: Dictionary = anims["animations"][anim_name]
|
|
var frames: Array = a["frames"]; var nf := frames.size()
|
|
var anim := Animation.new(); anim.length = max(float(a["duration"]), 0.001); anim.loop_mode = Animation.LOOP_LINEAR
|
|
for bi in bone_names.size():
|
|
var bn: String = bone_names[bi]
|
|
if not by_name.has(bn): continue
|
|
var rel := String(skel.get_path_to(by_name[bn]))
|
|
var f0: Array = frames[0][bi]; var pv := false; var rv := false; var sv := false
|
|
for fr in range(1, nf):
|
|
var v: Array = frames[fr][bi]
|
|
if not pv and (not is_equal_approx(v[0],f0[0]) or not is_equal_approx(v[1],f0[1])): pv = true
|
|
if not rv and not is_equal_approx(v[2],f0[2]): rv = true
|
|
if not sv and (not is_equal_approx(v[3],f0[3]) or not is_equal_approx(v[4],f0[4])): sv = true
|
|
_track(anim, rel+":position", frames, bi, nf, 0, pv)
|
|
_track(anim, rel+":rotation", frames, bi, nf, 1, rv)
|
|
_track(anim, rel+":scale", frames, bi, nf, 2, sv)
|
|
var draworder: Array = a.get("draworder", [])
|
|
if not draworder.is_empty() and not slot_names.is_empty():
|
|
var ranks := {}
|
|
for nm in poly_by_slot: ranks[nm] = []
|
|
for fr in nf:
|
|
var r := 0
|
|
for si in draworder[fr]:
|
|
var sn: String = slot_names[si]
|
|
if poly_by_slot.has(sn): ranks[sn].append(r); r += 1
|
|
for sn in ranks:
|
|
var seq: Array = ranks[sn]; var vary := false
|
|
for v in seq:
|
|
if v != base_rank[sn]: vary = true; break
|
|
if not vary or seq.size() != nf: continue
|
|
var rel := String(skel.get_path_to(poly_by_slot[sn]))
|
|
var ti := anim.add_track(Animation.TYPE_VALUE)
|
|
anim.track_set_path(ti, rel + ":z_index")
|
|
anim.value_track_set_update_mode(ti, Animation.UPDATE_DISCRETE)
|
|
anim.track_set_interpolation_type(ti, Animation.INTERPOLATION_NEAREST)
|
|
for fr in nf: anim.track_insert_key(ti, fr / FPS, seq[fr])
|
|
lib.add_animation(anim_name, anim)
|
|
player.add_animation_library("", lib)
|
|
return root
|
|
|
|
func _track(anim: Animation, path: String, frames: Array, bi: int, nf: int, kind: int, vary: bool):
|
|
if not vary: return
|
|
var ti := anim.add_track(Animation.TYPE_VALUE)
|
|
anim.track_set_path(ti, path); anim.track_set_interpolation_type(ti, Animation.INTERPOLATION_LINEAR)
|
|
for fr in nf:
|
|
var v: Array = frames[fr][bi]; var val
|
|
match kind:
|
|
0: val = Vector2(v[0], v[1])
|
|
1: val = deg_to_rad(float(v[2]))
|
|
2: val = Vector2(v[3], v[4])
|
|
anim.track_insert_key(ti, fr / FPS, val)
|
|
|
|
func _set_owner(node: Node, owner: Node):
|
|
for ch in node.get_children():
|
|
ch.owner = owner; _set_owner(ch, owner)
|