1345 lines
58 KiB
Java
1345 lines
58 KiB
Java
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_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;
|
||
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)。
|
||
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 setValue = button(loader, skin, "Set Value");
|
||
|
||
// 按钮直接放进根表格:两行按钮 + 一行滚动列表(与阶段四验证过的结构一致)
|
||
Object[] top = {refresh, newBone, newSlot, newBBox, newPath, delete, rename, inspect, setValue};
|
||
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);
|
||
layoutCell(cell, Obf.CELL_LEFT, Obf.CELL_FILL_X);
|
||
final Object buttonsCell = cell;
|
||
call(root, Obf.TABLE_ROW, new Class<?>[0], cellClass);
|
||
// 上/中/下 = 根表格的**三个单元格**(这样 setDebug 画出来就是三条带):
|
||
// 上 = 按钮块(已经在上面加过)
|
||
// 中 = 列表/Inspector:固定高度 500 + fillX + expand + fill(下边自然顶到日志上沿)
|
||
// 下 = 日志 Label:自然高度贴底
|
||
state.root = root;
|
||
cell = call(root, Obf.TABLE_ADD, new Class<?>[] {actorClass}, cellClass, scroll);
|
||
// 中段 = 列表,是**最长的一段**:只用固定高度,**不用 expand**(用户明确不要)。
|
||
// 纵向拉伸用已验证的 fillY=`L`(Cell.fill() 的载荷名 `D` 在 Cell 上有多个同名无参方法,会取错)。
|
||
layoutCell(cell, Obf.CELL_TOP, Obf.CELL_FILL_X, Obf.CELL_FILL_Y);
|
||
try {
|
||
// 中段固定高度:Cell.height(float) 在载荷里叫 `I`(实测确认:设 900 -> 根表格 580->1080)
|
||
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);
|
||
// 下段 = 日志:只占它需要的高度(1~6 行),贴在面板最下面
|
||
layoutCell(logCell, Obf.CELL_LEFT, Obf.CELL_FILL_X);
|
||
// 中段高度固定: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, 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;
|
||
// 用户指定:中段 1400、日志段 400;上段 = 剩下的高度(保持铺满,不出空带)
|
||
float middleH = 1400f;
|
||
float logH = 400f;
|
||
float buttonsH = panelH - middleH - logH;
|
||
if (buttonsH < 120f) buttonsH = 120f;
|
||
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);
|
||
System.err.println("[panel] 面板高度(自动): stage=" + stageH + " -> " + panelH
|
||
+ ";三段 = " + buttonsH + " / " + middleH + " / " + logH);
|
||
} 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();
|
||
}
|
||
|
||
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
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|