package com.spineview; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.List; /** * "Spine View" 面板的内容:**场景对象的增删改查**。 * * 布局(都是英文,编辑器缺中文字形): * ``` * [Refresh] [New Bone] * [New Slot] [Delete] [Rename] * [新名字 TextField] * ┌ 对象列表(每个对象一行,点一下就选中它)┐ * │ skeleton skeleton │ * │ bone root │ * │ ... │ * └─────────────────────────────────────────┘ * 状态行 * ``` * * 两条硬约束(都在 `编辑器交互与数据API.md` 里): * 1. **凡是要动编辑器模型/控件的代码,必须在编辑器的渲染线程上跑** —— * 统一走 {@link SceneActions#onEditorThread}(它用编辑器的 `OG.B(Runnable)`)。 * 2. **所有动作都作用在"当前选择"上** —— 新建/删除/改名之前先 {@link SceneActions#select}。 */ public final class ObjectPanel { private ObjectPanel() { } /** 面板状态(静态:按钮回调是另一条线程进来的,靠它记住"当前对着谁操作")。 */ private static final class State { ClassLoader loader; Object skin; Object listTable; Object listScroll; Object status; String statusText; Object nameField; final java.util.Deque log = new java.util.ArrayDeque(); boolean rendering; boolean logBlockStarted; Object inspected; // 当前 Inspect 的对象 java.lang.reflect.Field fieldTarget; // 被点选的字段(可写值) Object fieldOwner; java.util.List inspectFields = new ArrayList(); List objects = new ArrayList(); Object target; Object root; Object content; Object buttons; boolean scrollFocusLogged; boolean geometryDumped; Class tableClass; Class scrollClass; Class actorClass; Class cellClass; Object listCell; // 承载列表 ScrollPane 的单元格 } /** * 建面板(由 {@link ViewInstaller#buildPanel} 调用)。返回的 Table 会被塞进 us.spineView。 */ public static Object build(ClassLoader loader, Object skin) throws Exception { final State state = new State(); LAST_STATE = state; state.loader = loader; state.skin = skin; Class tableClass = Class.forName(Obf.TABLE, true, loader); Class cellClass = Class.forName(Obf.CELL, true, loader); Class actorClass = Class.forName(Obf.ACTOR, true, loader); Class scrollClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.ScrollPane", true, loader); Class skinClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Skin", true, loader); Class fieldClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.TextField", true, loader); state.tableClass = tableClass; state.scrollClass = scrollClass; state.actorClass = actorClass; state.cellClass = cellClass; Object root = tableClass.getConstructor().newInstance(); Object rows = tableClass.getConstructor().newInstance(); call(rows, Obf.TABLE_TOP, new Class[0], tableClass); state.listTable = tableClass.getConstructor().newInstance(); // 列表表格撑满 ScrollPane 视口(= 中段那一格),否则它按自己内容的固定高度画一小块 call(state.listTable, Obf.TABLE_SET_FILL_PARENT, new Class[] {boolean.class}, void.class, Boolean.TRUE); // 列表内容靠左上:ScrollPane 里控件比视口小时默认会居中,看起来就像"列表跑到中间/下面" call(state.listTable, Obf.TABLE_TOP, new Class[0], tableClass); call(state.listTable, Obf.TABLE_LEFT, new Class[0], tableClass); // (曾试过"包一层容器让表格铺满视口",会让编辑器布局抛 RuntimeException: Actor: ScrollPane,已撤掉) Object scroll = scrollClass.getConstructor(actorClass, skinClass).newInstance(state.listTable, skin); state.listScroll = scroll; makeTouchable(scroll); attachEventProbe(scroll, "listScroll"); state.status = label(loader, skin, "Ready. Press Refresh to list scene objects."); Object logScroll = null; state.nameField = fieldClass.getConstructor(String.class, skinClass) .newInstance("particle_bone", skin); try { logScroll = scrollClass.getConstructor(actorClass, skinClass).newInstance(state.status, skin); } catch (Throwable error) { System.err.println("[panel] 提示区滚动容器建不出来: " + error); } // 让面板铺满停靠框(WidgetGroup.setFillParent)。 // 不设 setFillParent:那会把根表格拉成"父容器大小"(父容器又按我们的 pref 算), // 结果三段被压扁、底下留空带;让根表格按自己的 pref(600+1400+400)撑开即可。 // 内容整体靠上:不设的话控件会在停靠框里垂直居中/被挤到下面 call(root, Obf.TABLE_TOP, new Class[0], tableClass); Object refresh = button(loader, skin, "Refresh"); Object newBone = button(loader, skin, "New Bone"); Object newSlot = button(loader, skin, "New Slot"); Object newBBox = button(loader, skin, "New BBox"); Object newPath = button(loader, skin, "New Path"); Object delete = button(loader, skin, "Delete"); Object rename = button(loader, skin, "Rename"); Object inspect = button(loader, skin, "Inspect"); Object setValue = button(loader, skin, "Set Value"); Object addTen = button(loader, skin, "+10 Red Bones"); // 按钮直接放进根表格:两行按钮 + 一行滚动列表(与阶段四验证过的结构一致) Object[] top = {refresh, newBone, newSlot, newBBox, newPath, delete, rename, inspect, setValue, addTen}; Object buttons = tableClass.getConstructor().newInstance(); call(buttons, Obf.TABLE_TOP, new Class[0], tableClass); call(buttons, Obf.TABLE_LEFT, new Class[0], tableClass); // 按钮块靠左上(不然整宽后会居中) for (int i = 0; i < top.length; i++) { Object buttonCell = call(buttons, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, top[i]); layoutCell(buttonCell, Obf.CELL_LEFT); // 每个按钮一行:上段是**一条整带**(不要左右两列),整体读起来才是"上/中/下" call(buttons, Obf.TABLE_ROW, new Class[0], cellClass); } call(buttons, Obf.TABLE_ROW, new Class[0], cellClass); Object nameCell = call(buttons, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, state.nameField); layoutCell(nameCell, Obf.CELL_LEFT); // 上段:按钮块(自适应高度,紧贴顶部) Object cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, buttons); // expand:吃掉停靠框里"没人要"的那部分空间(用户点名要的),否则底部会留一条空带 layoutCell(cell, Obf.CELL_LEFT, Obf.CELL_FILL_X, Obf.CELL_FILL_Y, Obf.CELL_EXPAND); final Object buttonsCell = cell; call(root, Obf.TABLE_ROW, new Class[0], cellClass); // 上/中/下 = 根表格的**三个单元格**(这样 setDebug 画出来就是三条带): // 上 = 按钮块(已经在上面加过) // 中 = 列表/Inspector:固定高度 500 + fillX + expand + fill(下边自然顶到日志上沿) // 下 = 日志 Label:自然高度贴底 state.root = root; // 中段放 ScrollPane(它会填满这一格 = 红框高度,内容超出时裁剪 + 可滚) cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, scroll); // 中段 = 列表 // 纵向拉伸用已验证的 fillY=`L`(Cell.fill() 的载荷名 `D` 在 Cell 上有多个同名无参方法,会取错)。 layoutCell(cell, Obf.CELL_TOP, Obf.CELL_FILL_X, Obf.CELL_FILL_Y, Obf.CELL_EXPAND); try { // Cell.height(float) 在载荷里叫 `I`( call(cell, "I", new Class[] {float.class}, cellClass, Float.valueOf(700f)); } catch (Throwable error) { System.err.println("[panel] 设置中段高度失败: " + error); } state.listCell = cell; state.buttons = buttons; call(root, Obf.TABLE_ROW, new Class[0], cellClass); Object logCell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, state.status); // 下段 = 日志,贴在面板最下面 layoutCell(logCell, Obf.CELL_TOP, Obf.CELL_LEFT, Obf.CELL_FILL_X, Obf.CELL_FILL_Y, Obf.CELL_EXPAND); // Cell.height(float) 在载荷里叫 `I`(描述符 (F)LdR;) // 实测确认:设 900 后根表格高度 580 -> 1080,说明它就是 height()。 try { call(cell, "I", new Class[] {float.class}, cellClass, Float.valueOf(700f)); } catch (Throwable error) { System.err.println("[panel] 设置中段高度失败: " + error); } wire(loader, addTen, new Runnable() { @Override public void run() { createTenRedBones(state); } }); wire(loader, setValue, new Runnable() { @Override public void run() { applyFieldValue(state); } }); wire(loader, inspect, new Runnable() { @Override public void run() { inspect(state); } }); wire(loader, delete, new Runnable() { @Override public void run() { if (state.target == null) { setStatus(state, "Delete: select an object first."); return; } Object victim = state.target; String what = SceneObjects.kind(victim) + " " + SceneObjects.nameOf(victim); state.target = null; boolean ok = SceneActions.delete(state.loader, victim); setStatus(state, (ok ? "Deleted " : "Delete failed: ") + what); refreshList(state); } }); wire(loader, rename, new Runnable() { @Override public void run() { if (state.target == null) { setStatus(state, "Rename: select an object first."); return; } String name = textFieldText(state.nameField); boolean ok = SceneActions.rename(state.loader, state.target, name); setStatus(state, (ok ? "Renamed to " : "Rename failed: ") + name); refreshList(state); } }); startSelectionWatcher(state); // 把上/中/下三段边界画出来:根表格正好是"按钮 / 中段 / 日志"三个单元格, // Table.setDebug(true) 会给每个单元格描边(载荷名 `et.B(Z)V`,对照表已确认)。 try { call(root, Obf.TABLE_SET_DEBUG, new Class[] {boolean.class}, void.class, Boolean.TRUE); System.err.println("[panel] 已开启三段边界描边(setDebug)"); } catch (Throwable error) { System.err.println("[panel] 开启描边失败: " + error); } // 三段高度按用户要求:上 20% / 中 60% / 下 20%(不用 expand)。 // 面板高度取实测值 984(见日志 pass0 dock=984),换算 = 197 / 590 / 197。 // 三段高度 20/60/20:面板高度从编辑器 Stage 读(建面板时 Stage 可能还没挂上,延迟 1.2s 再读) final Object middleCell = cell; Thread fit = new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(1200); float stageH = 0f; Object stage = SceneObjects.global(state.loader, "uk"); if (stage == null) stage = SceneObjects.global(state.loader, "dt"); // Stage 上:`B()F`=getHeight、`D()F`=getWidth(实测:4K 窗口 2044 / 3726) if (stage != null) stageH = floatOf(stage, "B"); float panelH = stageH > 300f ? stageH - 100f : 984f; // 用户要固定高度 600/1400/400;但面板实际高度(root.getHeight)装不下时, // 就按比例缩到刚好装下 —— 反复几轮直到"下面是空的区域1"消失。 for (int pass = 0; pass < 3; pass++) { // 用 Stage 高度当"可用高度"(它不随我们的内容变,稳定);比停靠框略大, // 这样内容一定盖过底部,就不会再留出"区域1"那条空带。 float available = stageH > 300f ? stageH : panelH; float scale = available / 2400f; float buttonsH = 600f * scale; float middleH = 1400f * scale; float logH = 400f * scale; call(buttonsCell, "I", new Class[] {float.class}, cellClass, Float.valueOf(buttonsH)); call(middleCell, "I", new Class[] {float.class}, cellClass, Float.valueOf(middleH)); call(logCell, "I", new Class[] {float.class}, cellClass, Float.valueOf(logH)); call(state.root, "b_", new Class[0], void.class); Thread.sleep(300); System.err.println("[panel] pass" + pass + " available=" + available + " -> " + buttonsH + " / " + middleH + " / " + logH); } call(state.root, "b_", new Class[0], void.class); Thread.sleep(800); System.err.println("[panel] 实测高度: root=" + floatOf(state.root, "init") + " buttonsTable=" + floatOf(state.buttons, "init") + " logLabel=" + floatOf(state.status, "init")); System.err.println("[panel] 面板高度(自动): stage=" + stageH + " -> " + panelH); } catch (Throwable error) { System.err.println("[panel] 自动三段高度失败: " + error); } } }, "spineview-fit"); fit.setDaemon(true); fit.start(); System.err.println("[panel] 增删改查面板已建好"); refreshList(state); return root; } /** 把日志缓冲写进底部 Label(多行)。 */ private static void writeLogLabel(final State state) { if (state.status == null) return; final StringBuilder joined = new StringBuilder(); synchronized (state.log) { for (String line : state.log) { if (joined.length() > 0) joined.append('\n'); joined.append(line); } } SceneActions.onEditorThread(state.loader, new Runnable() { @Override public void run() { try { call(state.status, Obf.LABEL_SET_TEXT, new Class[] {CharSequence.class}, void.class, joined.toString()); } catch (Throwable error) { System.err.println("[panel] 日志 Label 更新失败: " + error); } } }); } /** 只往内容表格末尾追加**一行**日志(必要时先补一行分隔标记)。 */ private static void appendLogRow(State state, String text) { try { Class cellClass = Class.forName(Obf.CELL, true, state.loader); Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); if (!state.logBlockStarted) { Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, label(state.loader, state.skin, LOG_SEPARATOR)); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); state.logBlockStarted = true; } Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, label(state.loader, state.skin, text)); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); invalidateList(state); } catch (Throwable error) { System.err.println("[panel] 日志行追加失败: " + error); } } private static final String LOG_SEPARATOR = "---------- log ----------"; /** 在内容表格末尾追加"日志块"(分隔行 + 最近几条消息)。 */ private static void appendLogRows(State state) { try { Class cellClass = Class.forName(Obf.CELL, true, state.loader); Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, label(state.loader, state.skin, LOG_SEPARATOR)); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); synchronized (state.log) { for (String line : state.log) { cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, label(state.loader, state.skin, line)); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); } } state.logBlockStarted = true; } catch (Throwable error) { System.err.println("[panel] 日志块追加失败: " + error); } } /** 状态文字:作为列表区的第一行显示(这样不再单独占根表格的一行,布局更稳)。 */ private static void statusRow(final State state, final String text) { try { Class cellClass = Class.forName(Obf.CELL, true, state.loader); Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); Object label = label(state.loader, state.skin, text); Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, label); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); } catch (Throwable error) { System.err.println("[panel] 状态行写入失败: " + error); } } /** 重新读一遍树、重建对象列表(全部在编辑器渲染线程上做)。 */ private static void refreshList(final State state) { SceneActions.onEditorThread(state.loader, new Runnable() { @Override public void run() { List objects; state.rendering = true; try { objects = SceneObjects.listAllNodes(state.loader); } catch (Throwable error) { setStatus(state, "List failed: " + error); return; } state.objects = objects; try { // Table.clearChildren()(载荷名 `SourceFile()V`):把列表里的行全清掉重建。 beginRender(state); Class cellClass = Class.forName(Obf.CELL, true, state.loader); Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); for (int i = 0; i < objects.size() && i < 300; i++) { final Object object = objects.get(i); Object row = button(state.loader, state.skin, SceneObjects.kind(object) + " " + SceneObjects.nameOf(object)); Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, row); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); wire(state.loader, row, new Runnable() { @Override public void run() { selectTarget(state, object); } }); } } catch (Throwable error) { setStatus(state, "List build failed: " + error); return; } setStatus(state, objects.size() + " scene objects" + (state.target == null ? "" : "; target = " + SceneObjects.kind(state.target) + " " + SceneObjects.nameOf(state.target))); state.rendering = false; invalidateList(state); if (!state.geometryDumped) { state.geometryDumped = true; dumpFloats("pane(before)", state.listScroll); for (String candidate : new String[] {"I", "L", "B", "Z", "V", "C", "S"}) { try { call(state.listCell, candidate, new Class[] {float.class}, state.cellClass, Float.valueOf(400f)); System.err.println("[panel] tried Cell." + candidate + "(400)"); dumpFloats("pane(after " + candidate + ")", state.listScroll); } catch (Throwable ignored) { // 该名字上没有 (F)Cell 方法 } } } } }); } /** 点列表里的某一行:在编辑器层级树里选中它,并把它记成"当前操作对象"。 */ private static void selectTarget(final State state, final Object object) { state.target = object; boolean ok = SceneActions.select(state.loader, object); setStatus(state, (ok ? "Selected " : "Select failed: ") + SceneObjects.kind(object) + " " + SceneObjects.nameOf(object)); } /** 新建:以当前对象为父级跑编辑器的 `New `。 */ /** * 新建:以当前对象为父级跑编辑器的 `New `。 * 名称输入框里**有字就用它**,没字就让编辑器那套自动化自己生成(`New Slot` 会弹输入框, * 面板会自动把名字填进去并点 OK,见 SceneActions#fillDialogWhenReady)。 */ private static void create(final State state, final String type) { if (state.target == null) { setStatus(state, "New " + type + ": select a parent object first."); return; } Object parent = state.target; String typed = textFieldText(state.nameField); SceneActions.nextName(typed == null ? "" : typed.trim()); boolean ok = SceneActions.create(state.loader, parent, type); String used = SceneActions.lastName(); setStatus(state, (ok ? "New " + type + " under " : "New " + type + " failed: ") + SceneObjects.kind(parent) + " " + SceneObjects.nameOf(parent) + (used == null ? "" : " name=" + used)); refreshList(state); } /** * Inspector:把**编辑器里当前选中对象**的字段与方法列到列表区。 * 字段名是被混淆的(一堆 D/B/L),所以每行都带上类型;对象引用只显示类名。 */ private static void inspect(final State state) { SceneActions.onEditorThread(state.loader, new Runnable() { @Override public void run() { Object object; try { object = SceneObjects.selected(state.loader); } catch (Throwable error) { setStatus(state, "Inspect failed: " + error); return; } if (object == null) { setStatus(state, "Inspect: nothing selected (click a row, or select in the editor tree)."); return; } List lines = new ArrayList(); final java.util.Map fieldRows = new java.util.HashMap(); lines.add("selected: " + SceneObjects.kind(object) + " \"" + SceneObjects.nameOf(object) + "\" (" + object.getClass().getName() + ")"); state.inspected = object; state.fieldTarget = null; lines.add("--- fields (" + countFields(object) + ") --- (点一行字段可改值)"); final java.util.List fields = new ArrayList(); // {owner, field, valueText} for (Class type = object.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (java.lang.reflect.Field field : type.getDeclaredFields()) { if (Modifier.isStatic(field.getModifiers())) continue; String value; try { field.setAccessible(true); Object raw = field.get(object); value = raw == null ? "null" : (raw instanceof Number || raw instanceof Boolean || raw instanceof String) ? String.valueOf(raw) : raw.getClass().getName(); } catch (Throwable error) { value = "<" + error.getClass().getSimpleName() + ">"; } final int rowIndex = lines.size(); String shown = realName(type.getName(), field.getName(), field.getType()); lines.add(" " + shortType(field.getType().getName()) + " " + shown + (shown.equals(field.getName()) ? "" : "(" + field.getName() + ")") + " = " + value + " @" + type.getName()); fieldRows.put(Integer.valueOf(rowIndex), new Object[] {object, field, value}); fields.add(new Object[] {object, field, value}); } } state.inspectFields = fields; lines.add("--- methods ---"); for (Method method : object.getClass().getMethods()) { if (method.getDeclaringClass() == Object.class) continue; StringBuilder params = new StringBuilder(); for (Class parameter : method.getParameterTypes()) { if (params.length() > 0) params.append(", "); params.append(shortType(parameter.getName())); } StringBuilder descriptor = new StringBuilder("("); for (Class parameter : method.getParameterTypes()) { descriptor.append(descriptorOf(parameter)); } descriptor.append(')').append(descriptorOf(method.getReturnType())); String methodShown = realName(method.getDeclaringClass().getName(), method.getName(), descriptor.toString()); lines.add(" " + shortType(method.getReturnType().getName()) + " " + methodShown + (methodShown.equals(method.getName()) ? "" : "(" + method.getName() + ")") + "(" + params + ")"); } fillRows(state, lines, fieldRows); setStatus(state, "Inspected " + SceneObjects.kind(object) + " \"" + SceneObjects.nameOf(object) + "\": " + (lines.size() - 2) + " entries"); } }); } /** * 把输入框里的值写进"被点选的字段":直接反射改模型字段 + 包一次撤销 + 重建树节点, * 然后重新 Inspect 一遍(值会立刻显出来)——这就是"改一个值看哪个字段动"的实验入口。 */ private static void applyFieldValue(final State state) { final java.lang.reflect.Field field = state.fieldTarget; final Object owner = state.fieldOwner; final String text = textFieldText(state.nameField); if (field == null || owner == null) { setStatus(state, "Set Value: click a field row first."); return; } SceneActions.onEditorThread(state.loader, new Runnable() { @Override public void run() { try { field.setAccessible(true); Class type = field.getType(); Object value; if (type == float.class) value = Float.valueOf(Float.parseFloat(text.trim())); else if (type == int.class) value = Integer.valueOf(Integer.parseInt(text.trim())); else if (type == boolean.class) value = Boolean.valueOf(Boolean.parseBoolean(text.trim())); else if (type == double.class) value = Double.valueOf(Double.parseDouble(text.trim())); else if (type == String.class) value = text; else { setStatus(state, "Set Value: unsupported type " + type.getName()); return; } Object skeleton = SceneActions.skeletonOf(owner); Class undo = Class.forName("un", true, state.loader); java.lang.reflect.Method begin = null, commit = null; for (Method m : undo.getDeclaredMethods()) { if (!java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue; if (m.getParameterTypes().length != 0) continue; if (m.getReturnType() != void.class) continue; if (m.getName().equals("D")) begin = m; if (m.getName().equals("B")) commit = m; } if (begin != null) begin.invoke(null); field.set(owner, value); if (commit != null) commit.invoke(null); SceneActions.refresh(state.loader, skeleton); setStatus(state, "Set " + field.getName() + " = " + text); } catch (Throwable error) { setStatus(state, "Set Value failed: " + error); error.printStackTrace(System.err); } inspect(state); } }); } /** 跟随编辑器里的选择:变了就自动重新 Inspect(做成"实时显示数据")。 */ private static void startSelectionWatcher(final State state) { Thread thread = new Thread(new Runnable() { @Override public void run() { Object last = null; while (true) { try { Object current = SceneObjects.selected(state.loader); if (current != null && current != last) { last = current; inspect(state); } Thread.sleep(600); } catch (InterruptedException ignored) { return; } catch (Throwable error) { try { Thread.sleep(600); } catch (InterruptedException ignored) { return; } } } } }, "spineview-selection-watch"); thread.setDaemon(true); thread.start(); } /** * 在当前选中的骨骼下新建 10 根骨骼:位置随机、颜色红色。 * 建用编辑器自己的 New Bone(带撤销),建完立刻改该骨骼的 x/y 与 color 字段。 */ private static void createTenRedBones(final State state) { SceneActions.onEditorThread(state.loader, new Runnable() { @Override public void run() { // 父级取"编辑器里的当前选择"(用户是在层级面板里选的,不是点我们列表行) Object parent = null; try { parent = SceneObjects.selected(state.loader); } catch (Throwable error) { System.err.println("[panel] 读当前选择失败: " + error); } if (parent == null) parent = state.target; if (parent == null) { setStatus(state, "+10 Red Bones: select a parent bone first."); return; } try { Class colorClass = Class.forName("com.badlogic.gdx.graphics.Color", true, state.loader); Object red = colorClass.getConstructor(float.class, float.class, float.class, float.class).newInstance(Float.valueOf(1f), Float.valueOf(0f), Float.valueOf(0f), Float.valueOf(1f)); int made = 0; for (int i = 0; i < 10; i++) { java.util.List before = SceneObjects.listAllNodes(state.loader); SceneActions.create(state.loader, parent, "Bone"); Object fresh = firstNew(before, SceneObjects.listAllNodes(state.loader)); if (fresh == null) continue; float x = (float) (Math.random() * 400.0 - 200.0); float y = (float) (Math.random() * 400.0 - 200.0); setBoneNumber(fresh, "x", x); setBoneNumber(fresh, "y", y); setBoneColor(fresh, red); made++; } setStatus(state, "made " + made + " red bones under " + SceneObjects.nameOf(parent)); } catch (Throwable error) { setStatus(state, "+10 Red Bones failed: " + error); error.printStackTrace(System.err); } refreshList(state); } }); } /** 前后列表对比,取第一个新出现的对象。 */ private static Object firstNew(java.util.List before, java.util.List after) { for (Object item : after) { boolean seen = false; for (Object old : before) { if (old == item) { seen = true; break; } } if (!seen) return item; } return null; } /** 按"真名"(x/y/rotation…)改骨骼上的 float 字段:真名→混淆名走 names.tsv。 */ private static void setBoneNumber(Object bone, String realName, float value) { try { String obf = obfNameOf("Gp", "F", realName); if (obf == null) return; for (Class type = bone.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (java.lang.reflect.Field field : type.getDeclaredFields()) { if (!field.getName().equals(obf) || field.getType() != float.class) continue; field.setAccessible(true); field.setFloat(bone, value); return; } } } catch (Throwable error) { System.err.println("[panel] 设置 " + realName + " 失败: " + error); } } /** 骨骼颜色:找类型是 libGDX Color 的字段写进去。 */ private static void setBoneColor(Object bone, Object color) { try { for (Class type = bone.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (java.lang.reflect.Field field : type.getDeclaredFields()) { if (!field.getType().getName().equals("com.badlogic.gdx.graphics.Color")) continue; field.setAccessible(true); field.set(bone, color); return; } } } catch (Throwable error) { System.err.println("[panel] 设置颜色失败: " + error); } } /** 映射表反查:owner + 描述符 + 真名 → 混淆名。 */ private static String obfNameOf(String owner, String desc, String realName) { loadNames(); for (java.util.Map.Entry entry : NAMES.entrySet()) { if (!entry.getValue().equals(realName)) continue; String[] parts = entry.getKey().split("#"); if (parts.length == 3 && parts[0].equals(owner) && parts[2].equals(desc)) return parts[1]; } return null; } private static int countFields(Object object) { int count = 0; for (Class type = object.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (java.lang.reflect.Field field : type.getDeclaredFields()) { if (!Modifier.isStatic(field.getModifiers())) count++; } } return count; } private static String shortType(String name) { int index = name.lastIndexOf('.'); String simple = index < 0 ? name : name.substring(index + 1); if (simple.length() == 1) return name; // D/B/L 这种混淆名:带上包名才分得清 return simple; } /** * 列表内容变了以后,必须让 `Table` 与 `ScrollPane` 重新算一遍尺寸 —— * 否则 ScrollPane 里缓存的高度还是旧的,表现为"内容多了却不能滚"。 * `b_()V` = `invalidate()`(来源:MapMethods 对 gdx-1.11.0 的指纹比对)。 */ /** * 每次渲染都换一套**全新的 Table + ScrollPane**,塞回同一个单元格。 * * 为什么必须换:ScrollPane 只在 layout 时量一次里面 widget 的高度,我们之后往 widget 里 * 加/删行,它记的高度还是旧的 —— maxY 一直是 0,滚轮就滚不动(你看到的现象)。 * 换一个新的 ScrollPane 最省事也最稳(只做这一件事,不改任何别的行为)。 */ /** 每次渲染:清空列表里的旧行(复用同一个 Table / ScrollPane,避免换控件引起的异常)。 */ private static void beginRender(State state) throws Exception { call(state.listTable, Obf.TABLE_CLEAR_CHILDREN, new Class[0], void.class); state.logBlockStarted = false; } /** * 把 ScrollPane 的 touchable 设成 `enabled`(它默认是 childrenOnly)。 * * 为什么:ScrollPane 是靠自己的 InputListener 在鼠标"进入"时调 `Stage.setScrollFocus(this)` * 来接收滚轮的;childrenOnly 时它自己不是命中目标,就永远收不到 enter → 滚轮事件不会送到它, * 表现就是"面板能程序化滚动,但鼠标滚轮没用"。 * * 不靠名字定位:`Actor.getTouchable()` 是"**无参 + 返回枚举**"的那个方法;枚举第 0 个常量 * 就是 `Touchable.enabled`(libGDX 的声明顺序 enabled/disabled/childrenOnly)。 */ /** * 只读诊断:给某个 Actor 挂一个事件监听(`dn` = EventListener 接口的 **动态代理**), * 把收到的每个事件打出来。用来确认"鼠标滚轮到底有没有送到面板"。 * * 为什么要代理:载荷里的监听器都是匿名内部类,反射 new 不出来; * 但 `dn` 是接口,用 `java.lang.reflect.Proxy` 就能造一个。 */ /** * 把编辑器的**滚动焦点**抢到面板的列表上(鼠标滚轮才会滚列表)。 * * 识别方式(不猜名字、也不会误调 addActor): * - 只在 **Stage 自己声明的**方法里找(`uk`/`dt`/`lpt6` 这些层), * `addActor` 声明在 Group 上、不在这一层,所以不会被误选; * - 候选 setter = 「1 个参数是 Actor 类型、返回 void」;候选 getter = 「无参、返回 Actor 类型」; * - 调 setter 后看哪个 getter 变成了我们的面板 —— 那就是 setScrollFocus 那一对,日志会写出来。 */ static void grabScrollFocus(final State state) { try { Object stage = SceneObjects.global(state.loader, "uk"); if (stage == null) stage = SceneObjects.global(state.loader, "dt"); if (stage == null || state.listScroll == null) return; Class actorClass = state.actorClass; java.util.List getters = new ArrayList(); java.util.List setters = new ArrayList(); // ⚠️ 必须用 getMethods()(含接口继承来的方法):Stage 的焦点 API 声明在接口里 // (`lpt6 implements LPt9`),只看 getDeclaredMethods() 一个都扫不到(实测:setter 1 / getter 0)。 // 但要排除声明在 Actor / Group / WidgetGroup 上的方法 —— 那里的 (Actor)V 是 addActor 之类,误调会把面板搬家。 String[] skipDeclaring = {Obf.ACTOR, Obf.GROUP, Obf.WIDGET_GROUP}; for (Method method : stage.getClass().getMethods()) { String declaring = method.getDeclaringClass().getName(); boolean skip = false; for (String bad : skipDeclaring) if (declaring.equals(bad)) skip = true; if (skip) continue; Class[] types = method.getParameterTypes(); if (types.length == 0) { if (actorClass.isAssignableFrom(method.getReturnType())) getters.add(method); } else if (types.length == 1 && types[0].isAssignableFrom(actorClass) && method.getReturnType() == void.class) { setters.add(method); } } for (Method setter : setters) { try { setter.setAccessible(true); setter.invoke(stage, state.listScroll); } catch (Throwable ignored) { continue; } for (Method getter : getters) { try { getter.setAccessible(true); if (getter.invoke(stage) == state.listScroll) { if (state.scrollFocusLogged) return; state.scrollFocusLogged = true; System.err.println("[panel] 滚动焦点已抢到面板:setter=" + setter.getName() + " getter=" + getter.getName()); return; } } catch (Throwable ignored) { // 下一个 } } } if (!state.scrollFocusLogged) { state.scrollFocusLogged = true; System.err.println("[panel] 没找到 setScrollFocus(候选 setter " + setters.size() + " 个 / getter " + getters.size() + " 个)"); } } catch (Throwable error) { System.err.println("[panel] 抢滚动焦点失败: " + error); } } /** 只读诊断:打印对象上所有**无参返回 float**的方法名和值(用来看 ScrollPane 的真实宽高)。 */ static void dumpFloats(String tag, Object target) { StringBuilder out = new StringBuilder("[panel] " + tag + ": "); try { for (Method method : target.getClass().getMethods()) { if (method.getParameterTypes().length != 0) continue; if (method.getReturnType() != float.class) continue; try { method.setAccessible(true); out.append(method.getName()).append('=').append(method.invoke(target)).append(" "); } catch (Throwable ignored) { // 跳过 } } System.err.println(out.toString()); } catch (Throwable error) { System.err.println("[panel] dumpFloats 失败: " + error); } } static void attachEventProbe(final Object actor, final String tag) { try { Class listenerClass = Class.forName("dn", true, actor.getClass().getClassLoader()); if (!listenerClass.isInterface()) { System.err.println("[probe] dn 不是接口,跳过"); return; } Object listener = java.lang.reflect.Proxy.newProxyInstance( listenerClass.getClassLoader(), new Class[] {listenerClass}, new java.lang.reflect.InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) { if (method.getName().equals("handle") && args != null && args.length == 1) { Object event = args[0]; String name = event == null ? "null" : event.getClass().getName(); String type = event == null ? "" : String.valueOf(readField(event, "D")); System.err.println("[probe] " + tag + " event=" + name + " typeField=" + type); } return null; } }); for (Method method : actor.getClass().getMethods()) { Class[] types = method.getParameterTypes(); if (types.length != 1 || types[0] != listenerClass) continue; method.setAccessible(true); Object result = method.invoke(actor, listener); System.err.println("[probe] 已给 " + tag + " 挂事件监听: " + method.getName() + " -> " + result); return; } System.err.println("[probe] 找不到 addListener"); } catch (Throwable error) { System.err.println("[probe] 挂事件监听失败: " + error); } } /** 读一个字段(诊断用,读不到返回 "?")。 */ static Object readField(Object target, String name) { for (Class type = target.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (java.lang.reflect.Field field : type.getDeclaredFields()) { if (!field.getName().equals(name)) continue; try { field.setAccessible(true); return field.get(target); } catch (Throwable ignored) { return "?"; } } } return "?"; } static void makeTouchable(Object actor) { try { Class enumType = null; for (Method method : actor.getClass().getMethods()) { if (method.getParameterTypes().length != 0) continue; Class returns = method.getReturnType(); if (!returns.isEnum()) continue; enumType = returns; break; } if (enumType == null) { System.err.println("[panel] 找不到 Touchable 枚举,跳过"); return; } Object[] constants = enumType.getEnumConstants(); if (constants == null || constants.length == 0) return; Object enabled = constants[0]; for (Method method : actor.getClass().getMethods()) { Class[] types = method.getParameterTypes(); if (types.length != 1 || types[0] != enumType) continue; if (method.getReturnType() != void.class) continue; method.setAccessible(true); method.invoke(actor, enabled); System.err.println("[panel] ScrollPane.touchable = " + enabled + "(枚举 " + enumType.getName() + " 第 0 个常量)"); return; } System.err.println("[panel] 找不到 setTouchable"); } catch (Throwable error) { System.err.println("[panel] 设置 touchable 失败: " + error); } } /** 读某个对象的某个无参 float 方法(诊断用)。 */ static float floatOf(Object target, String name) { try { for (Method m : target.getClass().getMethods()) { if (!m.getName().equals(name)) continue; if (m.getParameterTypes().length != 0 || m.getReturnType() != float.class) continue; m.setAccessible(true); return ((Float) m.invoke(target)).floatValue(); } } catch (Throwable ignored) { // 读不到 } return -1f; } private static float readFloat(Object target, String name) { try { for (Method method : target.getClass().getMethods()) { if (!method.getName().equals(name)) continue; if (method.getParameterTypes().length != 0) continue; if (method.getReturnType() != float.class) continue; method.setAccessible(true); return ((Float) method.invoke(target)).floatValue(); } } catch (Throwable ignored) { // 读不到就返回 -1 } return -1f; } private static void invalidateList(final State state) { try { call(state.listTable, "b_", new Class[0], void.class); } catch (Throwable ignored) { // 名字对不上就算了 } try { call(state.listScroll, "b_", new Class[0], void.class); } catch (Throwable ignored) { // 同上 } try { // ⚠️ ScrollPane 上这个 (boolean,boolean) 方法是 **setScrollingDisabled(x, y)**(指纹表已确认), // 不是 setForceScroll —— 传 (false,true) 会把纵向滚动**关掉**,看起来就是"列表滚不动"。 // 这里显式恢复:两个方向都可以滚。 for (Method method : state.listScroll.getClass().getMethods()) { Class[] types = method.getParameterTypes(); if (types.length != 2 || types[0] != boolean.class || types[1] != boolean.class) continue; if (method.getReturnType() != void.class) continue; method.setAccessible(true); method.invoke(state.listScroll, Boolean.FALSE, Boolean.FALSE); break; } // 再打开"强制纵向滚动":setForceScroll 在载荷里叫 `I`((ZZ)V,指纹确认 ~2/3) call(state.listScroll, "I", new Class[] {boolean.class, boolean.class}, void.class, Boolean.FALSE, Boolean.TRUE); // 诊断(只读+可回滚):读一下当前滚动位置,再程序化滚 200,再读一次。 // before/after 都写日志:如果 after 还是 0 → 说明 ScrollPane 认为"内容装得下"(高度没测到); // 如果 after 变成 200 → 说明面板能滚,是滚轮事件没送到面板。 float before = readFloat(state.listScroll, "invoke"); call(state.listScroll, "Class", new Class[] {float.class}, void.class, Float.valueOf(200f)); float after = readFloat(state.listScroll, "invoke"); System.err.println("[panel] scroll diag: before=" + before + " after=" + after); } catch (Throwable error) { System.err.println("[panel] 恢复滚动失败: " + error); } } /** * 填列表区:普通行是只读文本;`clickable` 里的行做成可点按钮—— * 点一下 = 选中该字段(把当前值读进输入框),配合 `Set Value` 就能改值做实验。 */ private static void fillRows(final State state, List lines, java.util.Map clickable) { try { beginRender(state); Class cellClass = Class.forName(Obf.CELL, true, state.loader); Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); for (int i = 0; i < lines.size(); i++) { final Object[] target = clickable.get(Integer.valueOf(i)); Object row = button(state.loader, state.skin, lines.get(i)); Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, row); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); if (target != null) { final String valueText = String.valueOf(target[2]); wire(state.loader, row, new Runnable() { @Override public void run() { state.fieldOwner = target[0]; state.fieldTarget = (java.lang.reflect.Field) target[1]; setInputText(state, valueText); setStatus(state, "Picked field " + state.fieldTarget.getName() + " (value " + valueText + ") -- edit the name box, then Set Value"); } }); } } state.rendering = false; invalidateList(state); } catch (Throwable error) { System.err.println("[panel] 字段列表填充失败: " + error); } } /** 把文字写进面板顶部的输入框(改字段值用)。 */ private static void setInputText(final State state, final String text) { SceneActions.onEditorThread(state.loader, new Runnable() { @Override public void run() { for (Method method : state.nameField.getClass().getMethods()) { Class[] types = method.getParameterTypes(); if (types.length != 1) continue; if (method.getReturnType() != void.class) continue; if (types[0] != String.class && types[0] != CharSequence.class) continue; try { method.setAccessible(true); method.invoke(state.nameField, text); return; } catch (Throwable ignored) { // 换下一个 } } } }); } /** 用纯文本行填列表区(行不可点)。 */ private static void fillTextRows(final State state, List lines) { try { beginRender(state); Class cellClass = Class.forName(Obf.CELL, true, state.loader); Class actorClass = Class.forName(Obf.ACTOR, true, state.loader); for (String line : lines) { Object row = button(state.loader, state.skin, line); Object cell = call(state.listTable, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, row); layoutCell(cell, Obf.CELL_LEFT); call(state.listTable, Obf.TABLE_ROW, new Class[0], cellClass); } state.rendering = false; invalidateList(state); } catch (Throwable error) { System.err.println("[panel] inspector 填充失败: " + error); } } /** * 状态文字:改完布局后不再单独占一行(那会把列表挤下去),改成写日志 + * 记在 state 里,下一次 {@link #refreshList} 会把它显示为列表区第一行。 */ /** * 下段的"实时提示":保留最近 {@link #LOG_LINES} 条,多行显示在底部。 * 所有 setText 都要在渲染线程上做。 */ private static final int LOG_LINES = 6; private static void setStatus(final State state, final String text) { state.statusText = text; System.err.println("[panel] " + text); synchronized (state.log) { state.log.addLast(text); while (state.log.size() > LOG_LINES) state.log.removeFirst(); } // 写进底部日志 Label(多行、只保留最近几条)——不再动列表内容 writeLogLabel(state); } /** TextField 里的文字(名字被混淆了,按"无参 + 返回 String"取)。 */ private static String textFieldText(Object field) { for (Method method : field.getClass().getMethods()) { if (method.getParameterTypes().length != 0) continue; if (method.getReturnType() != String.class) continue; try { method.setAccessible(true); Object value = method.invoke(field); if (value instanceof String) return (String) value; } catch (Throwable ignored) { // 换下一个 } } return "particle_bone"; } private static Object button(ClassLoader loader, Object skin, String text) throws Exception { Class buttonClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.TextButton", true, loader); Class skinClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Skin", true, loader); return buttonClass.getConstructor(String.class, skinClass).newInstance(text, skin); } private static Object label(ClassLoader loader, Object skin, String text) throws Exception { Class labelClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Label", true, loader); Class skinClass = Class.forName("com.badlogic.gdx.scenes.scene2d.ui.Skin", true, loader); return labelClass.getConstructor(CharSequence.class, skinClass).newInstance(text, skin); } // ==================== 按钮点击:一个轮询线程 + 一张路由表 ==================== // // libGDX 的 Button 自己带 ClickListener,点一下会翻转 isChecked,所以轮询 isChecked 就等于监听点击。 // 用轮询而不是 addListener,是因为监听器接口的实例没法用反射 new 出来(Scene2dPanel 里已经踩过)。 // 一条路由 = { button, action, isCheckedMethod, setCheckedQuietMethod }。 private static final List ROUTES = new ArrayList(); private static volatile boolean routerStarted; /** 验证用:记住面板上的按钮(标签 → 按钮),探针可以按标签"点"它们。 */ /** * 混淆名 → 真名 的映射表(读 tools/deobf/names.tsv)。key = owner#混淆名#描述符。 * * 为什么需要它:编辑器**运行时**的类和成员名不能改(它的 indy 靠加密串里的原名反射查找), * 所以反射拿到的是 `C/L/I/S/D…`;面板显示时用这张表翻译成有意义的名字。 */ private static final java.util.Map NAMES = new java.util.HashMap(); /** 上一次 build 的 state(按钮路由里用来在动作后抢滚动焦点)。 */ private static volatile ObjectPanel.State LAST_STATE; private static void loadNames() { if (!NAMES.isEmpty()) return; try { String classes = System.getProperty("spine.classes"); if (classes == null) return; // 从 classes 往上找,直到找到含 tools/deobf/names.tsv 的仓库根(不数层数,稳) java.io.File dir = new java.io.File(classes).getAbsoluteFile(); java.io.File table = null; for (int i = 0; i < 6 && dir != null; i++) { java.io.File candidate = new java.io.File(dir, "tools/deobf/names.tsv"); if (candidate.isFile()) { table = candidate; break; } dir = dir.getParentFile(); } if (table == null) { System.err.println("[panel] 没找到映射表(从 " + classes + " 往上找了 6 层)"); return; } for (String line : java.nio.file.Files.readAllLines(table.toPath(), java.nio.charset.StandardCharsets.UTF_8)) { if (line.isEmpty() || line.startsWith("#")) continue; String[] parts = line.split("\t"); if (parts.length < 4) continue; NAMES.put(parts[0] + "#" + parts[1] + "#" + parts[2], parts[3]); } System.err.println("[panel] 已载入映射表 " + NAMES.size() + " 条:" + table); } catch (Throwable error) { System.err.println("[panel] 载入映射表失败: " + error); } } /** 类型的 JVM 描述符(用于查映射表)。 */ private static String descriptorOf(Class type) { if (type == void.class) return "V"; if (type == boolean.class) return "Z"; if (type == byte.class) return "B"; if (type == char.class) return "C"; if (type == short.class) return "S"; if (type == int.class) return "I"; if (type == long.class) return "J"; if (type == float.class) return "F"; if (type == double.class) return "D"; if (type.isArray()) return "[" + descriptorOf(type.getComponentType()); return "L" + type.getName().replace('.', '/') + ";"; } /** 把"声明类 + 混淆名 + 类型"翻成真名;查不到就返回原名。 */ static String realName(String owner, String obfName, Class type) { loadNames(); String hit = NAMES.get(owner + "#" + obfName + "#" + descriptorOf(type)); return hit == null ? obfName : hit; } static String realName(String owner, String obfName, String descriptor) { loadNames(); String hit = NAMES.get(owner + "#" + obfName + "#" + descriptor); return hit == null ? obfName : hit; } private static final java.util.Map BUTTONS = new java.util.LinkedHashMap(); /** 建面板的顺序(验证用):0 Refresh 1 New Bone 2 New Slot 3 New BBox 4 New Path 5 Delete 6 Rename。 */ private static final List ORDER = new ArrayList(); static Object buttonAt(int index) { synchronized (ORDER) { return index >= 0 && index < ORDER.size() ? ORDER.get(index) : null; } } static void remember(String label, Object button) { synchronized (BUTTONS) { BUTTONS.put(label, button); ORDER.add(button); if (ROUTES.size() > 100000) BUTTONS.clear(); } } static Object panelButton(String label) { synchronized (BUTTONS) { return BUTTONS.get(label); } } static void wire(ClassLoader loader, Object button, Runnable action) { try { Method isChecked = null; Method resetQuiet = null; Class buttonClass = button.getClass(); for (Method method : buttonClass.getMethods()) { if (!method.getName().equals(Obf.BUTTON_IS_CHECKED)) continue; if (method.getParameterTypes().length != 0) continue; if (method.getReturnType() != boolean.class) continue; isChecked = method; break; } for (Method method : buttonClass.getMethods()) { if (!method.getName().equals(Obf.BUTTON_SET_CHECKED_FIRE)) continue; if (method.getParameterTypes().length != 2) continue; if (method.getParameterTypes()[0] != boolean.class || method.getParameterTypes()[1] != boolean.class) continue; resetQuiet = method; break; } if (isChecked == null) { System.err.println("[panel] 找不到 isChecked,按钮不响应: " + button); return; } isChecked.setAccessible(true); if (resetQuiet != null) resetQuiet.setAccessible(true); synchronized (ROUTES) { ROUTES.add(new Object[] {button, action, isChecked, resetQuiet}); } remember(buttonLabel(button), button); startRouter(); } catch (Throwable error) { System.err.println("[panel] 接按钮失败: " + error); } } /** 按钮上的文字(TextButton 自己的 getText;不取 Actor.getName)。 */ static String buttonLabel(Object button) { for (Class type = button.getClass(); type != null && !type.getName().equals(Obf.ACTOR); type = type.getSuperclass()) { for (Method method : type.getDeclaredMethods()) { if (method.getParameterTypes().length != 0) continue; if (method.getReturnType() != String.class) continue; // 跳过 Object/Actor 那一批(toString 会返回 "TextButton: Refresh" 这种调试串) if (method.getName().equals("toString")) continue; if (method.getName().equals("hashCode")) continue; if (method.getName().equals("equals")) continue; if (method.getName().equals("getName")) continue; try { method.setAccessible(true); Object value = method.invoke(button); if (value instanceof String) return (String) value; } catch (Throwable ignored) { // 换下一个 } } } return "?"; } private static void startRouter() { if (routerStarted) return; synchronized (ObjectPanel.class) { if (routerStarted) return; routerStarted = true; } Thread thread = new Thread(new Runnable() { @Override public void run() { while (true) { try { List snapshot; synchronized (ROUTES) { snapshot = new ArrayList(ROUTES); } for (Object[] route : snapshot) { if (!Boolean.TRUE.equals(((Method) route[2]).invoke(route[0]))) continue; Method resetQuiet = (Method) route[3]; if (resetQuiet != null) { resetQuiet.invoke(route[0], Boolean.FALSE, Boolean.FALSE); } try { ((Runnable) route[1]).run(); Object st = LAST_STATE; if (st instanceof State) grabScrollFocus((State) st); } catch (Throwable error) { System.err.println("[panel] 动作失败: " + error); error.printStackTrace(System.err); } } Thread.sleep(80); } catch (InterruptedException ignored) { return; } catch (Throwable error) { System.err.println("[panel] 轮询异常: " + error); return; } } } }, "spineview-crud-router"); thread.setDaemon(true); thread.start(); } // ==================== 反射小工具(名字被混淆,一律"名字 + 参数 + 返回类型"三判) ==================== /** * 验证用探针:按顺序"点"面板上的按钮,然后把每一步的结果打出来。 * 点击 = 调 `setChecked(true, true)`(与真人点击效果一致:路由器看到 isChecked 就执行动作)。 */ public static String probePanel(ClassLoader loader) { StringBuilder out = new StringBuilder(); out.append("buttons: ").append(buttonLabels()).append('\n'); press(0, out); // Refresh // 直接"选中 bone3 当父级"(列表行的标签取不到,就程序化选中) press(9, out); // +10 Red Bones(未选父级时应提示) sleepQuiet(1000); Object parent = null; try { parent = SceneActions.findByName(loader, "bone3"); } catch (Throwable error) { out.append("find bone3 failed: ").append(error).append('\n'); } out.append("parent = ").append(parent == null ? "null" : SceneObjects.nameOf(parent)).append('\n'); if (parent != null) { final Object target = parent; SceneActions.onEditorThread(loader, new Runnable() { @Override public void run() { SceneActions.select(loader, target); } }); // 让 state.target 记住它(正常是点列表行设置的) Object st = LAST_STATE; if (st instanceof State) ((State) st).target = parent; } press(9, out); // 现在应当真的建 10 根 sleepQuiet(4000); out.append("after +10 Red Bones: ").append(rows(loader)).append('\n'); return out.toString(); } private static void press(String label, StringBuilder out) { press(panelButton(label), label, out); } private static void press(int index, StringBuilder out) { press(buttonAt(index), "#" + index, out); } private static void press(Object button, String label, StringBuilder out) { if (button == null) { out.append("press ").append(label).append(" -> button not found\n"); return; } try { Method setChecked = null; for (Method method : button.getClass().getMethods()) { if (!method.getName().equals(Obf.BUTTON_SET_CHECKED_FIRE)) continue; Class[] types = method.getParameterTypes(); if (types.length != 2 || types[0] != boolean.class || types[1] != boolean.class) continue; setChecked = method; break; } if (setChecked == null) { out.append("press ").append(label).append(" -> no setChecked(boolean,boolean)\n"); return; } setChecked.setAccessible(true); setChecked.invoke(button, Boolean.TRUE, Boolean.TRUE); out.append("press ").append(label).append(" ok\n"); sleepQuiet(600); } catch (Throwable error) { out.append("press ").append(label).append(" failed: ").append(error).append('\n'); } } private static void sleepQuiet(long millis) { try { Thread.sleep(millis); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } } private static String buttonLabels() { StringBuilder out = new StringBuilder(); synchronized (BUTTONS) { for (String label : BUTTONS.keySet()) { if (out.length() > 0) out.append(", "); out.append('"').append(label).append('"'); } } return out.toString(); } private static String rows(ClassLoader loader) { StringBuilder out = new StringBuilder(); try { for (Object item : SceneObjects.listAllNodes(loader)) { out.append(SceneObjects.kind(item)).append(' ').append(SceneObjects.nameOf(item)) .append(" | "); } } catch (Throwable ignored) { // 换下面那条路 } return out.toString(); } static Object call(Object target, String name, Class[] parameterTypes, Class returnType, Object... arguments) throws Exception { for (Class type = target.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (Method method : type.getMethods()) { if (!method.getName().equals(name)) continue; if (Modifier.isStatic(method.getModifiers())) continue; if (!java.util.Arrays.equals(method.getParameterTypes(), parameterTypes)) continue; if (returnType != null && method.getReturnType() != returnType) continue; method.setAccessible(true); return method.invoke(target, arguments); } } throw new NoSuchMethodException(target.getClass().getName() + "." + name + java.util.Arrays.toString(parameterTypes)); } static void layoutCell(Object cell, String... names) { for (String name : names) { try { Method method = cell.getClass().getMethod(name); method.setAccessible(true); method.invoke(cell); } catch (Throwable error) { System.err.println("[panel] 单元格布局 " + name + " 失败: " + error); } } } }