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

1499 lines
64 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.spineview;
import java.lang.reflect.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<String> log = new java.util.ArrayDeque<String>();
boolean rendering;
boolean logBlockStarted;
Object inspected; // 当前 Inspect 的对象
java.lang.reflect.Field fieldTarget; // 被点选的字段(可写值)
Object fieldOwner;
java.util.List<Object[]> inspectFields = new ArrayList<Object[]>();
List<Object> objects = new ArrayList<Object>();
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<Object> 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 <type>`。 */
/**
* 新建:以当前对象为父级跑编辑器的 `New <type>`。
* 名称输入框里**有字就用它**,没字就让编辑器那套自动化自己生成(`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<String> lines = new ArrayList<String>();
final java.util.Map<Integer, Object[]> fieldRows =
new java.util.HashMap<Integer, Object[]>();
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<Object[]> fields = new ArrayList<Object[]>(); // {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<Object> 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<Object> before, java.util.List<Object> 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<String, String> 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<Method> getters = new ArrayList<Method>();
java.util.List<Method> setters = new ArrayList<Method>();
// ⚠️ 必须用 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<String> lines,
java.util.Map<Integer, Object[]> 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<String> 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<Object[]> ROUTES = new ArrayList<Object[]>();
private static volatile boolean routerStarted;
/** 验证用:记住面板上的按钮(标签 → 按钮),探针可以按标签"点"它们。 */
/**
* 混淆名 → 真名 的映射表(读 tools/deobf/names.tsv)。key = owner#混淆名#描述符。
*
* 为什么需要它:编辑器**运行时**的类和成员名不能改(它的 indy 靠加密串里的原名反射查找),
* 所以反射拿到的是 `C/L/I/S/D…`;面板显示时用这张表翻译成有意义的名字。
*/
private static final java.util.Map<String, String> NAMES =
new java.util.HashMap<String, String>();
/** 上一次 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<String, Object> BUTTONS =
new java.util.LinkedHashMap<String, Object>();
/** 建面板的顺序(验证用):0 Refresh 1 New Bone 2 New Slot 3 New BBox 4 New Path 5 Delete 6 Rename。 */
private static final List<Object> ORDER = new ArrayList<Object>();
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<Object[]> snapshot;
synchronized (ROUTES) {
snapshot = new ArrayList<Object[]>(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);
}
}
}
}