Files
Spine_lab/viewpanel/src/com/spineview/SceneActions.java
T

1004 lines
42 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.spineview;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.List;
/**
* 场景对象的"增 / 删 / 改"。
*
* 两条路(都在 `编辑器交互与数据API.md` 里有字节码出处):
*
* 1. **走编辑器菜单命令**(`New Bone` / `Delete` / `Rename` …):
* 路径 = 先 `VZ.D(对象)` 把选择设成目标 → 跑命令。
* 编辑器自己会改属性面板、插树节点、弹确认框、进撤销栈。**增**走这条。
* 2. **直接改模型 + 自己包撤销**:`un.D()` … `对象.L()`/`对象.D(名字,true)` … `un.B()`,
* 再 `VZ.D(骨架,true,true)` 重建树节点。**删/改**走这条(不进确认框,但带撤销)。
*
* 模型对象(载荷名):`GP` 骨架、`Gp` 骨骼、`GU` 插槽、`GT`/`GO` 附件,都 extends `Gj`。
* `L()V` = 把自己从模型里摘掉;`D(String,boolean)V` = 改名(第二参 = 是否同步子对象名字)。
*/
public final class SceneActions {
/** 标记"当前线程就是编辑器渲染线程",避免 `OG.B` 二次投递(它内部会断言不在渲染线程)。 */
private static final ThreadLocal<Boolean> ON_EDITOR_THREAD = new ThreadLocal<Boolean>();
private SceneActions() {
}
/**
* 把一段动作切到**编辑器的渲染线程**上执行并等待。
*
* 为什么必须切:编辑器在改模型前会调 `OG.I()`(`javap -c OG`):
* ```java
* if (!Editor.S && Editor.D != null && Editor.D != Thread.currentThread())
* throw new RuntimeException(); // "不能在非渲染线程动模型"
* ```
* 而 `OG.B(Runnable)V` / `OG.D(Runnable)V` 就是编辑器自己的"投递到渲染线程"接口
* (`B` 用 `CountDownLatch` 等它跑完,`D` 不等;内部走 `cOm4.D:LnUl;.D(Runnable)`)。
*
* 面板按钮本身是 libGDX 事件回调,天然就在渲染线程上;这个方法主要给后台线程/探针用。
*/
public static void onEditorThread(ClassLoader loader, Runnable body) {
if (Boolean.TRUE.equals(ON_EDITOR_THREAD.get())) {
body.run();
return;
}
final Runnable wrapped = new Runnable() {
@Override
public void run() {
ON_EDITOR_THREAD.set(Boolean.TRUE);
try {
body.run();
} finally {
ON_EDITOR_THREAD.remove();
}
}
};
try {
Class<?> og = Class.forName("OG", true, loader);
for (Method method : og.getDeclaredMethods()) {
if (!method.getName().equals("B")) continue;
Class<?>[] parameters = method.getParameterTypes();
if (parameters.length != 1 || parameters[0] != Runnable.class) continue;
method.setAccessible(true);
method.invoke(null, wrapped);
return;
}
System.err.println("[crud] OG.B(Runnable) 没找到,直接在当前线程执行");
} catch (Throwable error) {
System.err.println("[crud] 切渲染线程失败,直接在当前线程执行: " + error);
}
wrapped.run();
}
/** 撤销栈(载荷类 un):`D()` 记一次编辑开始,`B()` 提交。 */
private static Class<?> undoManager(ClassLoader loader) throws Exception {
return Class.forName("un", true, loader);
}
/** 调 `un.D()` / `un.B()` 这类无参静态方法(名字被混淆过,按"名字 + 空参数 + void"取)。 */
private static void undoCall(Class<?> undoClass, String name) throws Exception {
// ⚠️ 不能 getDeclaredMethod(name):`un` 上有 4 个同名无参方法(返回 Z/J/I/V),会抛 NoSuchMethod。
for (Method method : undoClass.getDeclaredMethods()) {
if (!method.getName().equals(name)) continue;
if (!hasNoArgs(method)) continue;
method.setAccessible(true);
method.invoke(null);
return;
}
throw new NoSuchMethodException("un." + name + "():V");
}
private static boolean hasNoArgs(Method method) {
return method.getParameterTypes().length == 0 && method.getReturnType() == void.class
&& Modifier.isStatic(method.getModifiers());
}
/** 选中某个场景对象(编辑器层级树里高亮它,并切换它的属性面板)。 */
public static boolean select(ClassLoader loader, Object sceneObject) {
if (sceneObject == null) return false;
final Object target = sceneObject;
final ClassLoader classLoader = loader;
final boolean[] result = new boolean[1];
// 选中会触发 editor 的事件链路(改属性面板等),同样必须在渲染线程上做。
onEditorThread(loader, new Runnable() {
@Override
public void run() {
try {
result[0] = SceneObjects.select(classLoader, target);
} catch (Throwable error) {
System.err.println("[crud] 选中失败: " + error);
}
}
});
return result[0];
}
/**
* 新建:先把选择设成父对象,再跑编辑器的 `New <type>` 命令。
*
* `type` 用编辑器自己的类型名:`Bone` / `Slot` / `Skin Placeholder` / `Bounding Box` /
* `Path` / `Clipping` / `IK Constraint` / `Transform Constraint` / `Path Constraint`。
* 命令只在"当前选择是合法父级"时生效(例如选骨骼才能 New Bone / New Slot),否则编辑器什么都不做。
*/
public static boolean create(ClassLoader loader, Object parent, String type) {
final Object target = parent;
final String typeName = type;
final ClassLoader classLoader = loader;
final boolean[] result = new boolean[1];
onEditorThread(loader, new Runnable() {
@Override
public void run() {
result[0] = createNow(classLoader, target, typeName);
}
});
// ⚠️ 输入名字的对话框是**下一帧**才建出来的,只能在渲染线程之外等它出现
// (在渲染线程里 sleep 会卡住帧,对话框永远不出现)。
// `New Bone` 是编辑器自动编号、不弹框;其余 New X 都会弹,所以:
// 没给名字 → 自动生成一个不重名的;给了名字 → 用它;然后自动点 OK。
if (!"Bone".equals(typeName)) {
String pending = PENDING_NAME.get();
if (pending == null || pending.length() == 0) {
pending = autoName(loader, typeName);
}
LAST_NAME.set(pending);
fillDialogWhenReady(loader, pending);
PENDING_NAME.remove();
}
return result[0];
}
private static boolean createNow(ClassLoader loader, Object parent, String type) {
if (parent == null) {
System.err.println("[crud] 新建失败:没有选中的父对象");
return false;
}
if (!select(loader, parent)) {
System.err.println("[crud] 新建失败:选不中父对象 " + SceneObjects.nameOf(parent));
return false;
}
String command = type.startsWith("New ") ? type : "New " + type;
System.err.println("[crud] 新建: " + command + "(父级 = " + SceneObjects.nameOf(parent) + ")");
// 有些 New X 会弹"输入名字"对话框(New Slot / New Bounding Box / New Path / …),
// 光执行命令不会建对象,对话框由 create() 在本线程轮询后填名字 + 点 OK(见 fillDialogWhenReady)。
return Commands.run(loader, command);
}
/** 面板/探针给下一次"会弹对话框的新建"准备好的名字。 */
private static final ThreadLocal<String> PENDING_NAME = new ThreadLocal<String>();
/** 这一次新建实际用的名字(面板用来显示)。 */
private static final ThreadLocal<String> LAST_NAME = new ThreadLocal<String>();
public static void nextName(String name) {
PENDING_NAME.set(name);
}
public static String lastName() {
return LAST_NAME.get();
}
/**
* 自动生成一个**不重名**的名字:`Slot` → `slot` / `slot_2` / `slot_3` …
* 判断重名时比的是"同一类对象"的现有名字(骨骼/插槽全局唯一,附件同名也能查出来)。
*/
static String autoName(ClassLoader loader, String type) {
String base = type.toLowerCase().replace(' ', '_');
java.util.Set<String> used = new java.util.HashSet<String>();
try {
for (Object item : SceneObjects.listAllNodes(loader)) {
used.add(SceneObjects.nameOf(item));
}
} catch (Throwable ignored) {
// 读不到就用 1
}
if (!used.contains(base)) return base;
for (int i = 2; i < 10000; i++) {
String candidate = base + "_" + i;
if (!used.contains(candidate)) return candidate;
}
return base + "_" + System.nanoTime();
}
// ==================== 输入对话框自动化 ====================
//
// `New Slot` / `New Bounding Box` / `New Path` / `New Clipping` / `New Skin Placeholder` /
// `New IK Constraint` / `New Event` / `New Animation` 都会弹一个"输入名字"的窗口
// (标题如 "New Slot",正文 "Enter a name to create a new slot:",按钮 OK / Cancel)。
// 命令本身只负责弹窗,名字与 OK 必须有人点。这里把这一步自动化:
// Stage(OG.D:Ldt;) → 找最后一个 TextField(对话框里的输入框)
// → 找最后一个文字含 "OK" 的 TextButton → 填名字 + 点它。
/** 轮询等对话框出现(最多约 3 秒),出现后填名字 + 点 OK。 */
static boolean fillDialogWhenReady(ClassLoader loader, String name) {
for (int i = 0; i < 30; i++) {
if (fillDialog(loader, name)) {
System.err.println("[crud] 已自动填写对话框并点 OK: " + name);
sleep(300);
return true;
}
sleep(100);
}
System.err.println("[crud] 没等到输入对话框: " + name);
return false;
}
static boolean fillDialog(ClassLoader loader, String name) {
try {
Object stage = SceneObjects.global(loader, "dt");
if (stage == null) return false;
// 不顺着"children 数组"找了(编辑器的弹窗并不一定在 Stage 根的孩子里)。
// 改成:从 Stage 出发按字段广度遍历,找"**有 TextField 字段 + 有 L(Object)V 回调**"的对象
// —— 载荷里所有这类输入对话框(New Slot / New Bounding Box / New Event / …)都长这样:
// zY(New Slot 对话框)的 OK 回调就是 `L(Object)`,体里只做
// `String name = TextField.getText(...); …建对象…`。
Object dialog = findDialog(stage);
if (dialog == null) return false;
Object textField = SceneObjects.fieldOfType(dialog, "com.badlogic.gdx.scenes.scene2d.ui.TextField");
if (textField == null) return false;
setText(textField, name);
Method ok = findMethod(dialog.getClass(), "L", new Class<?>[] {Object.class}, void.class);
if (ok == null) return false;
ok.setAccessible(true);
ok.invoke(dialog, Boolean.TRUE);
return true;
} catch (Throwable error) {
System.err.println("[crud] 对话框自动化失败: " + error);
return false;
}
}
/** 广度遍历 Stage 上的对象图,找"带 TextField 且有 L(Object)V"的对话框。 */
private static Object findDialog(Object stage) {
java.util.Set<Object> seen = java.util.Collections.newSetFromMap(
new java.util.IdentityHashMap<Object, Boolean>());
java.util.ArrayDeque<Object> queue = new java.util.ArrayDeque<Object>();
queue.add(stage);
int visited = 0;
while (!queue.isEmpty() && visited++ < 40000) {
Object current = queue.poll();
if (current == null || !seen.add(current)) continue;
if (isDialog(current)) return current;
if (current.getClass().isArray()) {
if (!current.getClass().getComponentType().isPrimitive()) {
for (Object item : (Object[]) current) queue.add(item);
}
continue;
}
String typeName = current.getClass().getName();
if (typeName.startsWith("java.") || typeName.startsWith("javax.")) continue;
for (Class<?> type = current.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) continue;
if (field.getType().isPrimitive()) continue;
try {
field.setAccessible(true);
Object value = field.get(current);
if (value != null) queue.add(value);
} catch (Throwable ignored) {
// 跳过
}
}
}
}
return null;
}
private static boolean isDialog(Object candidate) {
if (SceneObjects.fieldOfType(candidate, "com.badlogic.gdx.scenes.scene2d.ui.TextField") == null) {
return false;
}
return findMethod(candidate.getClass(), "L", new Class<?>[] {Object.class}, void.class) != null;
}
/** 深度优先遍历 actor 树,记住**最后一个** TextField 和最后一个文字含 OK 的按钮。 */
private static void walkActors(Object actor, Object[] found, int depth) {
if (actor == null || depth > 24) return;
String type = actor.getClass().getName();
if (type.equals("com.badlogic.gdx.scenes.scene2d.ui.TextField")) found[0] = actor;
if (type.startsWith("com.badlogic.gdx.scenes.scene2d.ui.TextButton")) {
String label = ObjectPanel.buttonLabel(actor);
if (label != null && label.toUpperCase().contains("OK")) found[1] = actor;
}
for (Object child : children(actor)) walkActors(child, found, depth + 1);
}
/** Group 的子节点(`do` 上的 Object[] 字段 + 一个 int size)。 */
private static Object[] children(Object actor) {
for (Class<?> type = actor.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) continue;
try {
field.setAccessible(true);
Object value = field.get(actor);
Object[] items = SceneObjects.arrayItems(value);
if (items.length > 0) return items;
} catch (Throwable ignored) {
// 跳过
}
}
if (type.getName().equals(Obf.GROUP)) break;
}
return new Object[0];
}
private static void setText(Object textField, String text) throws Exception {
Method setter = findMethod(textField.getClass(), Obf.LABEL_SET_TEXT, new Class<?>[] {String.class}, void.class);
if (setter == null) {
setter = findMethod(textField.getClass(), Obf.LABEL_SET_TEXT, new Class<?>[] {CharSequence.class}, void.class);
}
if (setter == null) throw new NoSuchMethodException("TextField.setText");
setter.setAccessible(true);
setter.invoke(textField, text);
}
private static void pressButton(Object button) throws Exception {
Method setChecked = findMethod(button.getClass(), Obf.BUTTON_SET_CHECKED_FIRE,
new Class<?>[] {boolean.class, boolean.class}, void.class);
if (setChecked == null) {
setChecked = findMethod(button.getClass(), Obf.BUTTON_SET_CHECKED,
new Class<?>[] {boolean.class}, void.class);
setChecked.setAccessible(true);
setChecked.invoke(button, Boolean.TRUE);
return;
}
setChecked.setAccessible(true);
setChecked.invoke(button, Boolean.TRUE, Boolean.TRUE);
}
/**
* 删除:`un.D()` → `对象.L()` → `un.B()` → 重建树。
* 返回 true 表示调用链没抛异常(不代表编辑器真的删掉了,删掉与否看树)。
*/
public static boolean delete(ClassLoader loader, Object sceneObject) {
final Object target = sceneObject;
final ClassLoader classLoader = loader;
final boolean[] result = new boolean[1];
onEditorThread(loader, new Runnable() {
@Override
public void run() {
result[0] = deleteNow(classLoader, target);
}
});
return result[0];
}
private static boolean deleteNow(ClassLoader loader, Object sceneObject) {
if (sceneObject == null) return false;
try {
Method remove = findMethod(sceneObject.getClass(), "L", new Class<?>[0], void.class);
if (remove == null) {
System.err.println("[crud] 删除失败:" + sceneObject.getClass().getName() + " 上没有 L()V");
return false;
}
Object skeleton = skeletonOf(sceneObject);
Class<?> undoClass = undoManager(loader);
undoCall(undoClass, "D");
remove.setAccessible(true);
remove.invoke(sceneObject);
undoCall(undoClass, "B");
refresh(loader, skeleton);
System.err.println("[crud] 已删除 " + SceneObjects.kind(sceneObject) + " "
+ SceneObjects.nameOf(sceneObject));
return true;
} catch (Throwable error) {
System.err.println("[crud] 删除失败: " + error);
error.printStackTrace(System.err);
return false;
}
}
/** 改名:`un.D()` → `对象.D(新名字, true)` → `un.B()` → 重建树。 */
public static boolean rename(ClassLoader loader, Object sceneObject, String newName) {
final Object target = sceneObject;
final String name = newName;
final ClassLoader classLoader = loader;
final boolean[] result = new boolean[1];
onEditorThread(loader, new Runnable() {
@Override
public void run() {
result[0] = renameNow(classLoader, target, name);
}
});
return result[0];
}
private static boolean renameNow(ClassLoader loader, Object sceneObject, String newName) {
if (sceneObject == null || newName == null || newName.length() == 0) return false;
try {
Method setName = findMethod(sceneObject.getClass(), "D",
new Class<?>[] {String.class, boolean.class}, void.class);
if (setName == null) {
System.err.println("[crud] 改名失败:" + sceneObject.getClass().getName()
+ " 上没有 D(String,boolean)V");
return false;
}
Object skeleton = skeletonOf(sceneObject);
Class<?> undoClass = undoManager(loader);
undoCall(undoClass, "D");
setName.setAccessible(true);
setName.invoke(sceneObject, newName, Boolean.TRUE);
undoCall(undoClass, "B");
refresh(loader, skeleton);
System.err.println("[crud] 已改名 -> " + newName);
return true;
} catch (Throwable error) {
System.err.println("[crud] 改名失败: " + error);
error.printStackTrace(System.err);
return false;
}
}
/**
* 重建某个骨架在层级树里的节点(编辑器删除流程里用的就是这个:
* `VZ.D(LGP;ZZ)V`,见 `vq_0.java:54`)。
*/
public static boolean refresh(ClassLoader loader, Object skeleton) {
if (skeleton == null) return false;
try {
Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW);
Class<?> skeletonClass = Class.forName("GP", true, loader);
Method rebuild = null;
for (Method method : treeView.getClass().getDeclaredMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 3 || types[0] != skeletonClass) continue;
if (types[1] != boolean.class || types[2] != boolean.class) continue;
rebuild = method;
break;
}
if (rebuild == null) {
System.err.println("[crud] 刷新失败:VZ 上没有 D(GP,boolean,boolean)V");
return false;
}
System.err.println("[crud] 刷新用方法 " + rebuild.getDeclaringClass().getName() + "."
+ rebuild.getName() + java.util.Arrays.toString(rebuild.getParameterTypes())
+ " static=" + Modifier.isStatic(rebuild.getModifiers()));
rebuild.setAccessible(true);
// ⚠️ 参数就 3 个(GP, boolean, boolean):editor 不是参数,别多传。
rebuild.invoke(treeView, skeleton, Boolean.TRUE, Boolean.TRUE);
return true;
} catch (Throwable error) {
System.err.println("[crud] 刷新失败: " + error);
return false;
}
}
/** 找到所属骨架:骨骼是 `Gp.D:LGP;`,骨架自己就是 `GP`。 */
public static Object skeletonOf(Object sceneObject) {
if (sceneObject == null) return null;
if (sceneObject.getClass().getName().equals("GP")) return sceneObject;
for (Class<?> type = sceneObject.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals("GP")) continue;
try {
field.setAccessible(true);
Object value = field.get(sceneObject);
if (value != null) return value;
} catch (Throwable ignored) {
// 跳过
}
}
}
return null;
}
/** 当前选中对象的类型名(给面板显示用)。 */
public static String selectedKind(ClassLoader loader) {
try {
return SceneObjects.kind(SceneObjects.selected(loader));
} catch (Throwable error) {
return "?";
}
}
/** 当前显示在 Tree 视图里的"属性面板控件"类名(诊断用)。 */
public static String propertyPanelClass(ClassLoader loader) {
try {
Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW);
Object panel = SceneObjects.fieldOfType(treeView, "VY");
return panel == null ? "null" : panel.getClass().getName();
} catch (Throwable error) {
return "err:" + error;
}
}
/** 按"名字 + 参数 + 返回类型"找实例方法(载荷里同名方法成堆,必须全判)。 */
static Method findMethod(Class<?> owner, String name, Class<?>[] parameters,
Class<?> returnType) {
for (Class<?> type = owner; type != null && type != Object.class;
type = type.getSuperclass()) {
for (Method method : type.getDeclaredMethods()) {
if (!method.getName().equals(name)) continue;
if (!java.util.Arrays.equals(method.getParameterTypes(), parameters)) continue;
if (returnType != null && method.getReturnType() != returnType) continue;
return method;
}
}
return null;
}
/** 诊断:把树里每个对象的类型 + 属性面板类名打出来。 */
static String describePanels(ClassLoader loader) {
StringBuilder out = new StringBuilder();
try {
List<Object> all = SceneObjects.listAllNodes(loader);
for (Object item : all) {
out.append(" ").append(SceneObjects.kind(item)).append(' ')
.append(SceneObjects.nameOf(item));
// 选中动作必须在编辑器渲染线程上做(否则属性面板不刷新,见 onEditorThread)。
final Object target = item;
final String[] info = new String[1];
onEditorThread(loader, new Runnable() {
@Override
public void run() {
boolean ok = select(loader, target);
Object selected;
try {
selected = SceneObjects.selected(loader);
} catch (Throwable error) {
selected = error;
}
info[0] = " select=" + ok + " treeSelected="
+ (selected == null ? "null" : SceneObjects.nameOf(selected))
+ " panel=" + propertyPanelClass(loader);
}
});
out.append(info[0] == null ? " (no info)" : info[0]);
out.append('\n');
}
} catch (Throwable error) {
out.append("describePanels failed: ").append(error).append('\n');
}
return out.toString();
}
/**
* 探针:真机跑一遍"选中 → 新建 → 改名 → 删除",每步都把对象列表打出来。
* 只用于验证,不参与面板逻辑。
*/
/** 探针:走编辑器自己的 `Select - Bones`,用来确认"选中"到底体现在哪。 */
public static String probeSelect(final ClassLoader loader) {
final StringBuilder out = new StringBuilder();
onEditorThread(loader, new Runnable() {
@Override
public void run() {
out.append("before: treeSelected=").append(describeSelection(loader)).append('\n');
Commands.run(loader, "Select - Bones");
out.append("after Select - Bones: ").append(describeSelection(loader)).append('\n');
Object bone = null;
try {
bone = findByName(loader, "bone3");
} catch (Throwable error) {
out.append("findByName failed: ").append(error).append('\n');
}
out.append("select(\"bone3\") = ").append(select(loader, bone)).append('\n');
out.append("after select: ").append(describeSelection(loader)).append('\n');
}
});
return out.toString();
}
private static String describeSelection(ClassLoader loader) {
StringBuilder out = new StringBuilder();
try {
Object selected = SceneObjects.selected(loader);
out.append("treeSelected=").append(selected == null ? "null" : SceneObjects.nameOf(selected));
Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW);
Object selection = SceneObjects.fieldNamed(treeView, "D", "fC");
out.append(" selectionItems=").append(selection == null ? "noSelection" : String.valueOf(
selectionCount(selection)));
out.append(" lastSelected=").append(selection == null ? "-" : String.valueOf(
lastSelected(selection)));
out.append(" panel=").append(propertyPanelClass(loader));
} catch (Throwable error) {
out.append(" describeSelection failed: ").append(error);
}
return out.toString();
}
private static int selectionCount(Object selection) {
try {
Method items = findMethod(selection.getClass(), "D", new Class<?>[0],
Class.forName("fI", true, selection.getClass().getClassLoader()));
if (items == null) return -1;
items.setAccessible(true);
Object array = items.invoke(selection);
Object[] values = SceneObjects.arrayItems(array);
return values.length;
} catch (Throwable error) {
return -2;
}
}
private static String lastSelected(Object selection) {
try {
Method last = findMethod(selection.getClass(), "D", new Class<?>[0], Object.class);
if (last == null) return "?";
last.setAccessible(true);
Object value = last.invoke(selection);
if (value == null) return "null";
Object object = SceneObjects.nodeObject(value);
return object == null ? String.valueOf(value) : SceneObjects.nameOf(object);
} catch (Throwable error) {
return "err:" + error;
}
}
public static String probeCrud(ClassLoader loader) {
StringBuilder out = new StringBuilder();
try {
out.append("--- before ---\n").append(listNames(loader));
Object parent = findByName(loader, "bone");
out.append("parent = ").append(parent == null ? "null" : SceneObjects.kind(parent)
+ " " + SceneObjects.nameOf(parent)).append('\n');
if (parent == null) return out.toString();
select(loader, parent);
out.append("parent panel = ").append(propertyPanelClass(loader)).append('\n');
out.append("--- create New Bone under ").append(SceneObjects.nameOf(parent)).append(" ---\n");
boolean created = create(loader, parent, "Bone");
sleep(400);
out.append("created=").append(created).append('\n').append(listNames(loader));
Object fresh = findByName(loader, "bone3");
out.append("fresh = ").append(fresh == null ? "null" : SceneObjects.nameOf(fresh))
.append('\n');
if (fresh != null) {
out.append("--- rename -> panel_test ---\n");
boolean renamed = rename(loader, fresh, "panel_test");
sleep(400);
out.append("renamed=").append(renamed).append('\n').append(listNames(loader));
Object renamedObject = findByName(loader, "panel_test");
if (renamedObject != null) {
out.append("--- delete panel_test ---\n");
boolean deleted = delete(loader, renamedObject);
sleep(400);
out.append("deleted=").append(deleted).append('\n').append(listNames(loader));
}
}
} catch (Throwable error) {
out.append("probeCrud failed: ").append(error).append('\n');
error.printStackTrace(System.err);
}
return out.toString();
}
private static String listNames(ClassLoader loader) throws Exception {
StringBuilder out = new StringBuilder();
for (Object item : SceneObjects.listAllNodes(loader)) {
out.append(" ").append(SceneObjects.kind(item)).append(' ')
.append(SceneObjects.nameOf(item)).append('\n');
}
return out.toString();
}
/** 按名字在树里找一个场景对象。 */
static Object findByName(ClassLoader loader, String name) throws Exception {
for (Object item : SceneObjects.listAllNodes(loader)) {
if (name.equals(SceneObjects.nameOf(item))) return item;
}
return null;
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
// ==================== 综合矩阵测试:骨骼 / 插槽 / 附件 的增删改查 + 同名规则 ====================
/**
* 一次跑完三类对象的增删改查与同名规则,把每一步的**事实**打出来(不做断言)。
*
* 三类对象的模型 API 不一样,这正是要测的点:
* - 骨骼:`GP.D(String)LGp;`(按名字查,全局唯一)
* - 插槽:`GP.D(String)LGU;`(按名字查,全局唯一)
* - 附件:`GP.D(LGU;String)LGn;`(**先按插槽、再按名字**查 —— 附件名只在自己插槽内唯一)
*/
public static String probeMatrix(final ClassLoader loader) {
final StringBuilder out = new StringBuilder();
// ⚠️ 不要整段包 onEditorThread:编辑器有些创建是"投递到下一帧"再做的,
// 而 onEditorThread 会一直占着渲染线程等我们返回,帧就跑不起来、对象永远建不出来。
// 所以:每一步各自切渲染线程(select/create/delete 内部已经切了),sleep 留在本线程。
{
try {
out.append("== initial ==\n").append(names(loader));
// ---- 骨骼 ----
out.append("\n== bone ==\n");
Object bone = findByName(loader, "bone");
Object created = createAndReport(loader, bone, "Bone", out, "New Bone");
Object created2 = createAndReport(loader, bone, "Bone", out, "New Bone#2");
out.append(" names like bone*: ").append(countNames(loader, "bone", "bone")).append('\n');
if (created2 != null) {
// 重名:把第二个新建的骨骼改成第一个的名字
boolean renamed = rename(loader, created2, SceneObjects.nameOf(created));
sleep(200);
out.append(" rename(").append(SceneObjects.nameOf(created2)).append(" -> ")
.append(SceneObjects.nameOf(created)).append(") = ").append(renamed)
.append("; 现在叫 ").append(SceneObjects.nameOf(created2))
.append("; 同名骨骼数=").append(countNames(loader, "bone",
SceneObjects.nameOf(created))).append('\n');
}
// 按名字查(模型 API)
Object skeleton = findByName(loader, "skeleton");
out.append(" getBone(\"").append(SceneObjects.nameOf(created)).append("\") = ")
.append(describe(lookup(skeleton, "Gp", SceneObjects.nameOf(created)))).append('\n');
out.append(" getBone(\"no_such_bone\") = ")
.append(describe(lookup(skeleton, "Gp", "no_such_bone"))).append('\n');
// 删除
if (created != null) {
boolean ok = delete(loader, created);
sleep(200);
out.append(" delete(").append(SceneObjects.nameOf(created)).append(") = ").append(ok)
.append("; 还在吗=").append(findByName(loader, SceneObjects.nameOf(created)) != null)
.append('\n');
}
// ---- 插槽 ----
out.append("\n== slot ==\n");
Object slot = createAndReport(loader, bone, "Slot", out, "New Slot");
Object slot2 = createAndReport(loader, bone, "Slot", out, "New Slot#2");
if (slot != null) {
out.append(" getSlot(\"").append(SceneObjects.nameOf(slot)).append("\") = ")
.append(describe(lookup(skeleton, "GU", SceneObjects.nameOf(slot)))).append('\n');
}
if (slot2 != null && slot != null) {
boolean renamed = rename(loader, slot2, SceneObjects.nameOf(slot));
sleep(200);
out.append(" rename(slot2 -> \"").append(SceneObjects.nameOf(slot)).append("\") = ")
.append(renamed).append("; 现在叫 ").append(SceneObjects.nameOf(slot2))
.append("; 同名插槽数=").append(countNames(loader, "slot",
SceneObjects.nameOf(slot))).append('\n');
}
// ---- 附件(挂在插槽下)----
out.append("\n== attachment ==\n");
if (slot != null) {
out.append(" select slot ").append(SceneObjects.nameOf(slot)).append(" = ")
.append(select(loader, slot)).append('\n');
sleep(200);
Object att1 = createAndReport(loader, slot, "Bounding Box", out, "New Bounding Box");
Object att2 = createAndReport(loader, slot, "Path", out, "New Path");
if (att1 != null && att2 != null) {
out.append(" attachment 类: ").append(att1.getClass().getName()).append(" / ")
.append(att2.getClass().getName()).append('\n');
boolean renamed = rename(loader, att2, SceneObjects.nameOf(att1));
sleep(200);
out.append(" rename(att2 -> \"").append(SceneObjects.nameOf(att1))
.append("\") = ").append(renamed).append("; att2 现在叫 ")
.append(SceneObjects.nameOf(att2)).append('\n');
out.append(" getAttachment(slot,\"").append(SceneObjects.nameOf(att1))
.append("\") = ")
.append(describe(lookupAttachment(skeleton, slot, SceneObjects.nameOf(att1))))
.append('\n');
out.append(" delete(att1) = ").append(delete(loader, att1)).append('\n');
sleep(200);
}
}
// ---- 跨类型同名 ----
out.append("\n== cross-type duplicate names ==\n");
if (slot != null && bone != null) {
boolean renamed = rename(loader, slot, SceneObjects.nameOf(bone));
sleep(200);
out.append(" rename(slot -> bone 的名字 \"").append(SceneObjects.nameOf(bone))
.append("\") = ").append(renamed).append("; slot 现在叫 ")
.append(SceneObjects.nameOf(slot)).append('\n');
}
out.append("\n== final ==\n").append(names(loader));
} catch (Throwable error) {
out.append("probeMatrix failed: ").append(error).append('\n');
error.printStackTrace(System.err);
}
}
return out.toString();
}
/** 建一个对象并把"新建前后的差集"报出来。 */
private static Object createAndReport(ClassLoader loader, Object parent, String type,
StringBuilder out, String label) {
try {
List<Object> before = SceneObjects.listAllNodes(loader);
// 会弹名字对话框的那几种,先把名字准备好(bone 不用,编辑器自己编号)。
if (!type.equals("Bone")) {
nextName(type.toLowerCase().replace(' ', '_') + "_probe"
+ (System.nanoTime() % 1000));
}
boolean ok = create(loader, parent, type);
sleep(300);
List<Object> after = SceneObjects.listAllNodes(loader);
Object fresh = null;
for (Object item : after) {
boolean seen = false;
for (Object old : before) {
if (old == item) {
seen = true;
break;
}
}
if (!seen) {
fresh = item;
break;
}
}
out.append(" ").append(label).append("(parent=").append(SceneObjects.nameOf(parent))
.append(") -> ok=").append(ok).append(" new=")
.append(fresh == null ? "null" : fresh.getClass().getName() + " \""
+ SceneObjects.nameOf(fresh) + "\"")
.append('\n');
return fresh;
} catch (Throwable error) {
out.append(" ").append(label).append(" failed: ").append(error).append('\n');
return null;
}
}
/** 模型层的按名字查:骨骼 `GP.D(String)LGp;` / 插槽 `GP.D(String)LGU;`。 */
static Object lookup(Object skeleton, String typeName, String name) {
if (skeleton == null) return null;
for (Method method : skeleton.getClass().getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 1 || types[0] != String.class) continue;
if (!method.getReturnType().getName().equals(typeName)) continue;
try {
method.setAccessible(true);
return method.invoke(skeleton, name);
} catch (Throwable ignored) {
// 换下一个
}
}
return null;
}
/** 附件的按名字查要连插槽一起给:`GP.D(LGU;Ljava/lang/String;)LGn;`。 */
static Object lookupAttachment(Object skeleton, Object slot, String name) {
if (skeleton == null || slot == null) return null;
for (Method method : skeleton.getClass().getMethods()) {
Class<?>[] types = method.getParameterTypes();
if (types.length != 2 || types[0] != slot.getClass()) continue;
if (types[1] != String.class) continue;
try {
method.setAccessible(true);
return method.invoke(skeleton, slot, name);
} catch (Throwable ignored) {
// 换下一个
}
}
return null;
}
private static String describe(Object object) {
return object == null ? "null" : object.getClass().getName() + " \""
+ SceneObjects.nameOf(object) + "\"";
}
private static String names(ClassLoader loader) throws Exception {
StringBuilder out = new StringBuilder();
for (Object item : SceneObjects.listAllNodes(loader)) {
String kind = SceneObjects.kind(item);
if (kind.equals("skeleton") || kind.equals("bone") || kind.equals("slot")
|| kind.equals("attachment")) {
out.append(" ").append(kind).append(" \"").append(SceneObjects.nameOf(item))
.append("\"\n");
}
}
return out.toString();
}
private static int countNames(ClassLoader loader, String kind, String name) throws Exception {
int count = 0;
for (Object item : SceneObjects.listAllNodes(loader)) {
if (!SceneObjects.kind(item).equals(kind)) continue;
if (SceneObjects.nameOf(item).equals(name)) count++;
}
return count;
}
// ==================== 直接模型 API:新建骨骼(不经 UI 菜单、不依赖下一帧) ====================
//
// 编辑器自己的"新建骨骼"代码就在 `Yi.run()` 里,去掉 UI 后本质是:
// new Gp() → 设 name/parent/skeleton/x/y/color → 骨架.D(骨骼) ← GP 上的 (LGp;)V = addBone
// → 骨架.StackMapTable() ← 重建索引 → Editor.D(骨骼, true) → VZ.D(骨架, true, true) 重建树
// 一次调用可以连建 N 根,最后只刷新一次;外层包 un.D()/un.B() 就是"一次撤销"。
/** 直接建一根骨骼(返回新骨骼对象;失败返回 null)。 */
public static Object createBone(ClassLoader loader, Object skeleton, Object parent, String name,
float x, float y, Object color) {
try {
System.err.println("[crud] createBone step1 new Gp " + skeleton + " parent=" + parent);
Class<?> boneClass = Class.forName("Gp", true, loader);
Object bone = boneClass.getConstructor().newInstance();
setByType(bone, "GP", skeleton);
if (parent != null) setByType(bone, "Gp", parent);
setByNameType(bone, "D", String.class, name);
setByNameType(bone, "Z", float.class, Float.valueOf(x));
setByNameType(bone, "V", float.class, Float.valueOf(y));
if (color != null) setByNameType(bone, "B",
Class.forName("com.badlogic.gdx.graphics.Color", true, loader), color);
System.err.println("[crud] createBone step2 fields ok");
// 骨架.D(骨骼):GP 上的 (LGp;)V
Method addBone = findMethod(skeleton.getClass(), "D", new Class<?>[] {boneClass}, void.class);
if (addBone == null) throw new NoSuchMethodException("GP.D(LGp;)V addBone");
addBone.setAccessible(true);
System.err.println("[crud] createBone step3 " + addBone.getDeclaringClass().getName() + "."
+ addBone.getName() + "(Gp) 调用中");
addBone.invoke(skeleton, bone);
System.err.println("[crud] createBone step3 ok");
// 骨架.StackMapTable():重建索引
Method rebuild = null;
for (Method m : skeleton.getClass().getMethods()) {
if (m.getName().equals("StackMapTable") && m.getParameterTypes().length == 0
&& m.getReturnType() == void.class) {
rebuild = m;
break;
}
}
if (rebuild != null) {
rebuild.setAccessible(true);
rebuild.invoke(skeleton);
}
// Editor.D(骨骼, true):通知编辑器(找不到就跳过,不影响模型)
try {
Class<?> editorClass = Class.forName("com.esotericsoftware.spine.editor.Editor", true, loader);
Method notify = findMethod(editorClass, "D", new Class<?>[] {boneClass, boolean.class},
void.class);
Object editor = SceneObjects.global(loader, "com.esotericsoftware.spine.editor.Editor");
if (notify != null && editor != null) {
notify.setAccessible(true);
notify.invoke(editor, bone, Boolean.TRUE);
}
} catch (Throwable error) {
System.err.println("[crud] Editor.D(bone,true) 跳过: " + error);
}
return bone;
} catch (Throwable error) {
System.err.println("[crud] createBone 失败: " + error);
Throwable cause = error;
while (cause != null) {
System.err.println("[crud] cause: " + cause);
cause.printStackTrace(System.err);
cause = cause.getCause();
}
return null;
}
}
/** 按字段类型写(用于 D:LGP; / D:LGp; 这种同名不同描述符的坑)。 */
static void setByType(Object target, String typeName, Object value) throws Exception {
for (Class<?> type = target.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getType().getName().equals(typeName)) continue;
field.setAccessible(true);
field.set(target, value);
return;
}
}
}
/** 按"名字 + 类型"写字段。 */
static void setByNameType(Object target, String name, Class<?> fieldType, Object value)
throws Exception {
for (Class<?> type = target.getClass(); type != null && type != Object.class;
type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (!field.getName().equals(name) || field.getType() != fieldType) continue;
field.setAccessible(true);
field.set(target, value);
return;
}
}
}
}