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 ON_EDITOR_THREAD = new ThreadLocal(); 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` 用编辑器自己的类型名:`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 PENDING_NAME = new ThreadLocal(); /** 这一次新建实际用的名字(面板用来显示)。 */ private static final ThreadLocal LAST_NAME = new ThreadLocal(); 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 used = new java.util.HashSet(); 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 seen = java.util.Collections.newSetFromMap( new java.util.IdentityHashMap()); java.util.ArrayDeque queue = new java.util.ArrayDeque(); 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 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 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 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; } } } }