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; List objects = new ArrayList(); Object target; } /** * 建面板(由 {@link ViewInstaller#buildPanel} 调用)。返回的 Table 会被塞进 us.spineView。 */ public static Object build(ClassLoader loader, Object skin) throws Exception { final State state = new 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); 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_TOP, new Class[0], tableClass); call(state.listTable, Obf.TABLE_LEFT, new Class[0], tableClass); Object scroll = scrollClass.getConstructor(actorClass, skinClass).newInstance(state.listTable, skin); state.listScroll = scroll; 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)。 call(root, Obf.TABLE_SET_FILL_PARENT, new Class[] {boolean.class}, void.class, Boolean.TRUE); // 内容整体靠上:不设的话控件会在停靠框里垂直居中/被挤到下面 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[] top = {refresh, newBone, newSlot, newBBox, newPath, delete, rename, inspect}; for (int i = 0; i < top.length; i++) { Object cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, top[i]); layoutCell(cell, Obf.CELL_LEFT); if (i % 2 == 1 || i == top.length - 1) { call(root, Obf.TABLE_ROW, new Class[0], cellClass); } } Object cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, state.nameField); layoutCell(cell, Obf.CELL_LEFT); call(root, Obf.TABLE_ROW, new Class[0], cellClass); // 唯一 expand+fill 的单元格:内容区(上面是列表/Inspector,最下面是日志块,同一个滚动区) cell = call(root, Obf.TABLE_ADD, new Class[] {actorClass}, cellClass, scroll); layoutCell(cell, Obf.CELL_TOP, Obf.CELL_EXPAND, Obf.CELL_FILL); wire(loader, refresh, new Runnable() { @Override public void run() { refreshList(state); } }); wire(loader, newBone, new Runnable() { @Override public void run() { create(state, "Bone"); } }); wire(loader, newSlot, new Runnable() { @Override public void run() { create(state, "Slot"); } }); wire(loader, newBBox, new Runnable() { @Override public void run() { create(state, "Bounding Box"); } }); wire(loader, newPath, new Runnable() { @Override public void run() { create(state, "Path"); } }); 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); } }); System.err.println("[panel] 增删改查面板已建好"); refreshList(state); return root; } /** 只往内容表格末尾追加**一行**日志(必要时先补一行分隔标记)。 */ 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`):把列表里的行全清掉重建。 call(state.listTable, Obf.TABLE_CLEAR_CHILDREN, new Class[0], void.class); state.logBlockStarted = false; 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; appendLogRows(state); invalidateList(state); } }); } /** 点列表里的某一行:在编辑器层级树里选中它,并把它记成"当前操作对象"。 */ 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(); lines.add("selected: " + SceneObjects.kind(object) + " \"" + SceneObjects.nameOf(object) + "\" (" + object.getClass().getName() + ")"); lines.add("--- fields (" + countFields(object) + ") ---"); 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() + ">"; } lines.add(" " + shortType(field.getType().getName()) + " " + field.getName() + " = " + value + " @" + type.getName()); } } 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())); } lines.add(" " + shortType(method.getReturnType().getName()) + " " + method.getName() + "(" + params + ")"); } fillTextRows(state, lines); setStatus(state, "Inspected " + SceneObjects.kind(object) + " \"" + SceneObjects.nameOf(object) + "\": " + (lines.size() - 2) + " entries"); } }); } 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 的指纹比对)。 */ 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) { // 同上 } } /** 用纯文本行填列表区(行不可点)。 */ private static void fillTextRows(final State state, List lines) { try { call(state.listTable, Obf.TABLE_CLEAR_CHILDREN, new Class[0], void.class); state.logBlockStarted = false; 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; appendLogRows(state); 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(); } // 只追加一行日志:千万不要在这里重跑列表渲染,否则 Inspect 写好的字段清单会被覆盖 if (!state.rendering) appendLogRow(state, text); } /** 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; /** 验证用:记住面板上的按钮(标签 → 按钮),探针可以按标签"点"它们。 */ 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(); } 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 out.append("after Refresh: ").append(rows(loader)).append('\n'); // 列表是 Refresh 之后才建出来的,找到 "bone bone3" 那一行并按它 press(panelButton("bone bone3"), "row bone bone3", out); out.append("after select: ").append(rows(loader)).append('\n'); press(2, out); // New Slot(会弹输入框,看面板能不能自己填+点 OK) sleepQuiet(3000); out.append("after New Slot: ").append(rows(loader)).append('\n'); press(3, out); // New BBox sleepQuiet(3000); out.append("after New BBox: ").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); } } } }