- Все 189 скелетов Spine сконвертированы в нативные Godot Skeleton2D (.tscn), без Spine в рантайме - Меню + сцены: подземелье (карта комнат), просмотр спрайтов, нативные анимации - Кастомизация цвета (кожа/волосы/глаза) через шейдер - Вытащенные текстуры/UI/шрифты + нарезка спрайтов из атласа - Датасет комнат для LoRA, офлайн-инструменты конвертации в tools/ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
7.0 KiB
GDScript
176 lines
7.0 KiB
GDScript
extends SceneTree
|
||
|
||
# Собирает нативного персонажа (Skeleton2D + взвешенные Polygon2D + AnimationPlayer с
|
||
# запечёнными анимациями) и СОХРАНЯЕТ как редактируемую сцену res://characters/elf1/elf1_native.tscn.
|
||
# Запуск: godot ... --headless --script res://tools/build_tscn.gd
|
||
|
||
const GEOM := "res://tools/rig/elf1_combat.json"
|
||
const ANIMS := "res://tools/rig/elf1_combat_anims.json"
|
||
const PAGE := "res://characters/elf1/spine/elf1_combat.png"
|
||
const OUT := "res://characters/elf1/elf1_native.tscn"
|
||
const VP := Vector2(1280, 720)
|
||
|
||
var _root: Node2D
|
||
var _skel: Skeleton2D
|
||
var _bones := []
|
||
var _by_name := {}
|
||
var _poly_by_slot := {} # имя слота -> Polygon2D
|
||
var _base_rank := {} # имя слота -> setup-ранг (индекс в порядке отрисовки)
|
||
|
||
func _initialize():
|
||
_build_rig()
|
||
_build_anims()
|
||
_set_owner(_root, _root)
|
||
|
||
var packed := PackedScene.new()
|
||
var err := packed.pack(_root)
|
||
if err != OK:
|
||
push_error("pack failed: %d" % err); quit(); return
|
||
err = ResourceSaver.save(packed, OUT)
|
||
print("SAVE ", OUT, " -> ", err, " (кости=%d, привязок=%d)" % [_bones.size(), _skel.get_child_count() - _bones_direct])
|
||
quit()
|
||
|
||
var _bones_direct := 0
|
||
|
||
func _build_rig():
|
||
var f := FileAccess.open(GEOM, FileAccess.READ)
|
||
var data: Dictionary = JSON.parse_string(f.get_as_text()); f.close()
|
||
var tex: Texture2D = load(PAGE)
|
||
var ts := tex.get_size()
|
||
|
||
_root = Node2D.new(); _root.name = "ElfNative"
|
||
var spine := Node2D.new(); spine.name = "SpineRoot"; _root.add_child(spine)
|
||
_skel = Skeleton2D.new(); _skel.name = "Skeleton2D"; spine.add_child(_skel)
|
||
|
||
for bd in data["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
|
||
_bones_direct = _skel.get_child_count()
|
||
|
||
var minv := Vector2(1e9, 1e9); var maxv := Vector2(-1e9, -1e9)
|
||
var att_rank := 0
|
||
for att in data["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
|
||
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 = att_rank # базовый порядок отрисовки (setup)
|
||
_skel.add_child(poly)
|
||
poly.skeleton = poly.get_path_to(_skel)
|
||
_poly_by_slot[String(att["slot"])] = poly
|
||
_base_rank[String(att["slot"])] = att_rank
|
||
att_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])
|
||
|
||
var size := maxv - minv; var center := (minv + maxv) * 0.5
|
||
var fit: float = min(VP.x * 0.55 / size.x, VP.y * 0.8 / size.y)
|
||
spine.scale = Vector2(fit, -fit)
|
||
spine.position = Vector2(VP.x * 0.5 - center.x * fit, VP.y * 0.5 + center.y * fit)
|
||
|
||
func _build_anims():
|
||
var f := FileAccess.open(ANIMS, FileAccess.READ)
|
||
var data: Dictionary = JSON.parse_string(f.get_as_text()); f.close()
|
||
var fps := float(data["fps"])
|
||
var bone_names: Array = data["bones"]
|
||
var slot_names: Array = data.get("slots", [])
|
||
var anims: Dictionary = data["animations"]
|
||
|
||
var player := AnimationPlayer.new(); player.name = "AnimationPlayer"
|
||
_root.add_child(player)
|
||
player.root_node = player.get_path_to(_skel)
|
||
var lib := AnimationLibrary.new()
|
||
|
||
for anim_name in anims:
|
||
var a: Dictionary = anims[anim_name]
|
||
var frames: Array = a["frames"]; var nf := frames.size()
|
||
var anim := Animation.new(); anim.length = float(a["duration"]); 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
|
||
_add_track(anim, rel + ":position", frames, bi, fps, nf, 0, pv)
|
||
_add_track(anim, rel + ":rotation", frames, bi, fps, nf, 1, rv)
|
||
_add_track(anim, rel + ":scale", frames, bi, fps, nf, 2, sv)
|
||
|
||
# --- draw order -> z_index (дискретно) ---
|
||
var draworder: Array = a.get("draworder", [])
|
||
if not draworder.is_empty() and not slot_names.is_empty():
|
||
# ранг полигон-слота = позиция среди слотов-С-привязкой в порядке отрисовки
|
||
var ranks := {} # имя слота -> [ранг по кадрам]
|
||
for name in _poly_by_slot: ranks[name] = []
|
||
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: 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)
|
||
|
||
func _add_track(anim: Animation, path: String, frames: Array, bi: int, fps: float, 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)
|