ProjectRR — нативный прототип на ассетах ProjectR

- Все 189 скелетов Spine сконвертированы в нативные Godot Skeleton2D (.tscn), без Spine в рантайме
- Меню + сцены: подземелье (карта комнат), просмотр спрайтов, нативные анимации
- Кастомизация цвета (кожа/волосы/глаза) через шейдер
- Вытащенные текстуры/UI/шрифты + нарезка спрайтов из атласа
- Датасет комнат для LoRA, офлайн-инструменты конвертации в tools/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-03 16:29:21 +03:00
co-authored by Claude Opus 4.8
commit 04cf5f90b0
2193 changed files with 360105 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
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)