Files
project-rr/tools/convert_all.gd
T
Leonid PershinandClaude Opus 4.8 04cf5f90b0 ProjectRR — нативный прототип на ассетах ProjectR
- Все 189 скелетов Spine сконвертированы в нативные Godot Skeleton2D (.tscn), без Spine в рантайме
- Меню + сцены: подземелье (карта комнат), просмотр спрайтов, нативные анимации
- Кастомизация цвета (кожа/волосы/глаза) через шейдер
- Вытащенные текстуры/UI/шрифты + нарезка спрайтов из атласа
- Датасет комнат для LoRA, офлайн-инструменты конвертации в tools/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 16:29:21 +03:00

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)