面板(viewpanel/src/com/spineview/) - ObjectPanel:Refresh / New Bone / New Slot / New BBox / New Path / Delete / Rename / Inspect + 名字输入框 + 对象列表(点行=选中)+ 状态行,全英文。 - SceneObjects:查(Tree 控件按层级/扁平列全部对象)、选中(VZ.D(Tree.Node,boolean) —— VZ.D(Object) 内部只是 Selection.L(),不真选中)、读当前选择(VZ.B())。 - SceneActions:增/删/改 + 撤销包装(un.D()/un.B())+ 树节点重建(VZ.D(GP,true,true)); 统一在编辑器渲染线程执行(OG.B(Runnable);OG.I() 有线程断言,非渲染线程会抛 RuntimeException); 输入名字对话框自动化:找到『有 TextField 字段 + L(Object)V 回调』的对话框对象 → 填名字 → 调 L(true)。 - 实测:骨骼新建自动编号(bone7/bone8);模型层允许重名、按名字查只返回第一个;删父骨骼连子节点一起删; 不能在渲染线程里 sleep(有些创建是下一帧做的)。 - 探针:-Dspine.probe=objects|panels|select|crud|matrix|panel。 可识别源码版(工具) - tools/deobf/Renamer.java:ASM Remapper 只改成员名不改类名(indy 运行时要用加密串里的类名反射查)。 - tools/deobf/anchors.tsv(手动 14 条有证据的锚点)+ anchors-libgdx.tsv(MapMethods 对 gdx-1.11.0 指纹自动生成 178 条)→ names.tsv 192 条。 - 产物 ~/Desktop/spine398/src-named(4176 类全量,原 src-readable 不动);已验证 Table.add/clearChildren、SelectBox.getSelected/setSelected/setSelectedIndex、Gp.name。 - 文档:混淆名对照表.md 新增第六节(工具链 + 已验证 + 已知限制)。
905 lines
38 KiB
Java
905 lines
38 KiB
Java
package com.spineview;
|
||
|
||
import java.lang.reflect.Field;
|
||
import java.lang.reflect.Method;
|
||
import java.lang.reflect.Modifier;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* 场景对象的"增 / 删 / 改"。
|
||
*
|
||
* 两条路(都在 `编辑器交互与数据API.md` 里有字节码出处):
|
||
*
|
||
* 1. **走编辑器菜单命令**(`New Bone` / `Delete` / `Rename` …):
|
||
* 路径 = 先 `VZ.D(对象)` 把选择设成目标 → 跑命令。
|
||
* 编辑器自己会改属性面板、插树节点、弹确认框、进撤销栈。**增**走这条。
|
||
* 2. **直接改模型 + 自己包撤销**:`un.D()` … `对象.L()`/`对象.D(名字,true)` … `un.B()`,
|
||
* 再 `VZ.D(骨架,true,true)` 重建树节点。**删/改**走这条(不进确认框,但带撤销)。
|
||
*
|
||
* 模型对象(载荷名):`GP` 骨架、`Gp` 骨骼、`GU` 插槽、`GT`/`GO` 附件,都 extends `Gj`。
|
||
* `L()V` = 把自己从模型里摘掉;`D(String,boolean)V` = 改名(第二参 = 是否同步子对象名字)。
|
||
*/
|
||
public final class SceneActions {
|
||
|
||
/** 标记"当前线程就是编辑器渲染线程",避免 `OG.B` 二次投递(它内部会断言不在渲染线程)。 */
|
||
private static final ThreadLocal<Boolean> ON_EDITOR_THREAD = new ThreadLocal<Boolean>();
|
||
|
||
private SceneActions() {
|
||
}
|
||
|
||
/**
|
||
* 把一段动作切到**编辑器的渲染线程**上执行并等待。
|
||
*
|
||
* 为什么必须切:编辑器在改模型前会调 `OG.I()`(`javap -c OG`):
|
||
* ```java
|
||
* if (!Editor.S && Editor.D != null && Editor.D != Thread.currentThread())
|
||
* throw new RuntimeException(); // "不能在非渲染线程动模型"
|
||
* ```
|
||
* 而 `OG.B(Runnable)V` / `OG.D(Runnable)V` 就是编辑器自己的"投递到渲染线程"接口
|
||
* (`B` 用 `CountDownLatch` 等它跑完,`D` 不等;内部走 `cOm4.D:LnUl;.D(Runnable)`)。
|
||
*
|
||
* 面板按钮本身是 libGDX 事件回调,天然就在渲染线程上;这个方法主要给后台线程/探针用。
|
||
*/
|
||
public static void onEditorThread(ClassLoader loader, Runnable body) {
|
||
if (Boolean.TRUE.equals(ON_EDITOR_THREAD.get())) {
|
||
body.run();
|
||
return;
|
||
}
|
||
final Runnable wrapped = new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
ON_EDITOR_THREAD.set(Boolean.TRUE);
|
||
try {
|
||
body.run();
|
||
} finally {
|
||
ON_EDITOR_THREAD.remove();
|
||
}
|
||
}
|
||
};
|
||
try {
|
||
Class<?> og = Class.forName("OG", true, loader);
|
||
for (Method method : og.getDeclaredMethods()) {
|
||
if (!method.getName().equals("B")) continue;
|
||
Class<?>[] parameters = method.getParameterTypes();
|
||
if (parameters.length != 1 || parameters[0] != Runnable.class) continue;
|
||
method.setAccessible(true);
|
||
method.invoke(null, wrapped);
|
||
return;
|
||
}
|
||
System.err.println("[crud] OG.B(Runnable) 没找到,直接在当前线程执行");
|
||
} catch (Throwable error) {
|
||
System.err.println("[crud] 切渲染线程失败,直接在当前线程执行: " + error);
|
||
}
|
||
wrapped.run();
|
||
}
|
||
|
||
/** 撤销栈(载荷类 un):`D()` 记一次编辑开始,`B()` 提交。 */
|
||
private static Class<?> undoManager(ClassLoader loader) throws Exception {
|
||
return Class.forName("un", true, loader);
|
||
}
|
||
|
||
/** 调 `un.D()` / `un.B()` 这类无参静态方法(名字被混淆过,按"名字 + 空参数 + void"取)。 */
|
||
private static void undoCall(Class<?> undoClass, String name) throws Exception {
|
||
// ⚠️ 不能 getDeclaredMethod(name):`un` 上有 4 个同名无参方法(返回 Z/J/I/V),会抛 NoSuchMethod。
|
||
for (Method method : undoClass.getDeclaredMethods()) {
|
||
if (!method.getName().equals(name)) continue;
|
||
if (!hasNoArgs(method)) continue;
|
||
method.setAccessible(true);
|
||
method.invoke(null);
|
||
return;
|
||
}
|
||
throw new NoSuchMethodException("un." + name + "():V");
|
||
}
|
||
|
||
private static boolean hasNoArgs(Method method) {
|
||
return method.getParameterTypes().length == 0 && method.getReturnType() == void.class
|
||
&& Modifier.isStatic(method.getModifiers());
|
||
}
|
||
|
||
/** 选中某个场景对象(编辑器层级树里高亮它,并切换它的属性面板)。 */
|
||
public static boolean select(ClassLoader loader, Object sceneObject) {
|
||
if (sceneObject == null) return false;
|
||
final Object target = sceneObject;
|
||
final ClassLoader classLoader = loader;
|
||
final boolean[] result = new boolean[1];
|
||
// 选中会触发 editor 的事件链路(改属性面板等),同样必须在渲染线程上做。
|
||
onEditorThread(loader, new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
try {
|
||
result[0] = SceneObjects.select(classLoader, target);
|
||
} catch (Throwable error) {
|
||
System.err.println("[crud] 选中失败: " + error);
|
||
}
|
||
}
|
||
});
|
||
return result[0];
|
||
}
|
||
|
||
/**
|
||
* 新建:先把选择设成父对象,再跑编辑器的 `New <type>` 命令。
|
||
*
|
||
* `type` 用编辑器自己的类型名:`Bone` / `Slot` / `Skin Placeholder` / `Bounding Box` /
|
||
* `Path` / `Clipping` / `IK Constraint` / `Transform Constraint` / `Path Constraint`。
|
||
* 命令只在"当前选择是合法父级"时生效(例如选骨骼才能 New Bone / New Slot),否则编辑器什么都不做。
|
||
*/
|
||
public static boolean create(ClassLoader loader, Object parent, String type) {
|
||
final Object target = parent;
|
||
final String typeName = type;
|
||
final ClassLoader classLoader = loader;
|
||
final boolean[] result = new boolean[1];
|
||
onEditorThread(loader, new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
result[0] = createNow(classLoader, target, typeName);
|
||
}
|
||
});
|
||
// ⚠️ 输入名字的对话框是**下一帧**才建出来的,只能在渲染线程之外等它出现
|
||
// (在渲染线程里 sleep 会卡住帧,对话框永远不出现)。
|
||
// `New Bone` 是编辑器自动编号、不弹框;其余 New X 都会弹,所以:
|
||
// 没给名字 → 自动生成一个不重名的;给了名字 → 用它;然后自动点 OK。
|
||
if (!"Bone".equals(typeName)) {
|
||
String pending = PENDING_NAME.get();
|
||
if (pending == null || pending.length() == 0) {
|
||
pending = autoName(loader, typeName);
|
||
}
|
||
LAST_NAME.set(pending);
|
||
fillDialogWhenReady(loader, pending);
|
||
PENDING_NAME.remove();
|
||
}
|
||
return result[0];
|
||
}
|
||
|
||
private static boolean createNow(ClassLoader loader, Object parent, String type) {
|
||
if (parent == null) {
|
||
System.err.println("[crud] 新建失败:没有选中的父对象");
|
||
return false;
|
||
}
|
||
if (!select(loader, parent)) {
|
||
System.err.println("[crud] 新建失败:选不中父对象 " + SceneObjects.nameOf(parent));
|
||
return false;
|
||
}
|
||
String command = type.startsWith("New ") ? type : "New " + type;
|
||
System.err.println("[crud] 新建: " + command + "(父级 = " + SceneObjects.nameOf(parent) + ")");
|
||
// 有些 New X 会弹"输入名字"对话框(New Slot / New Bounding Box / New Path / …),
|
||
// 光执行命令不会建对象,对话框由 create() 在本线程轮询后填名字 + 点 OK(见 fillDialogWhenReady)。
|
||
return Commands.run(loader, command);
|
||
}
|
||
|
||
/** 面板/探针给下一次"会弹对话框的新建"准备好的名字。 */
|
||
private static final ThreadLocal<String> PENDING_NAME = new ThreadLocal<String>();
|
||
/** 这一次新建实际用的名字(面板用来显示)。 */
|
||
private static final ThreadLocal<String> LAST_NAME = new ThreadLocal<String>();
|
||
|
||
public static void nextName(String name) {
|
||
PENDING_NAME.set(name);
|
||
}
|
||
|
||
public static String lastName() {
|
||
return LAST_NAME.get();
|
||
}
|
||
|
||
/**
|
||
* 自动生成一个**不重名**的名字:`Slot` → `slot` / `slot_2` / `slot_3` …
|
||
* 判断重名时比的是"同一类对象"的现有名字(骨骼/插槽全局唯一,附件同名也能查出来)。
|
||
*/
|
||
static String autoName(ClassLoader loader, String type) {
|
||
String base = type.toLowerCase().replace(' ', '_');
|
||
java.util.Set<String> used = new java.util.HashSet<String>();
|
||
try {
|
||
for (Object item : SceneObjects.listAllNodes(loader)) {
|
||
used.add(SceneObjects.nameOf(item));
|
||
}
|
||
} catch (Throwable ignored) {
|
||
// 读不到就用 1
|
||
}
|
||
if (!used.contains(base)) return base;
|
||
for (int i = 2; i < 10000; i++) {
|
||
String candidate = base + "_" + i;
|
||
if (!used.contains(candidate)) return candidate;
|
||
}
|
||
return base + "_" + System.nanoTime();
|
||
}
|
||
|
||
// ==================== 输入对话框自动化 ====================
|
||
//
|
||
// `New Slot` / `New Bounding Box` / `New Path` / `New Clipping` / `New Skin Placeholder` /
|
||
// `New IK Constraint` / `New Event` / `New Animation` 都会弹一个"输入名字"的窗口
|
||
// (标题如 "New Slot",正文 "Enter a name to create a new slot:",按钮 OK / Cancel)。
|
||
// 命令本身只负责弹窗,名字与 OK 必须有人点。这里把这一步自动化:
|
||
// Stage(OG.D:Ldt;) → 找最后一个 TextField(对话框里的输入框)
|
||
// → 找最后一个文字含 "OK" 的 TextButton → 填名字 + 点它。
|
||
|
||
/** 轮询等对话框出现(最多约 3 秒),出现后填名字 + 点 OK。 */
|
||
static boolean fillDialogWhenReady(ClassLoader loader, String name) {
|
||
for (int i = 0; i < 30; i++) {
|
||
if (fillDialog(loader, name)) {
|
||
System.err.println("[crud] 已自动填写对话框并点 OK: " + name);
|
||
sleep(300);
|
||
return true;
|
||
}
|
||
sleep(100);
|
||
}
|
||
System.err.println("[crud] 没等到输入对话框: " + name);
|
||
return false;
|
||
}
|
||
|
||
static boolean fillDialog(ClassLoader loader, String name) {
|
||
try {
|
||
Object stage = SceneObjects.global(loader, "dt");
|
||
if (stage == null) return false;
|
||
// 不顺着"children 数组"找了(编辑器的弹窗并不一定在 Stage 根的孩子里)。
|
||
// 改成:从 Stage 出发按字段广度遍历,找"**有 TextField 字段 + 有 L(Object)V 回调**"的对象
|
||
// —— 载荷里所有这类输入对话框(New Slot / New Bounding Box / New Event / …)都长这样:
|
||
// zY(New Slot 对话框)的 OK 回调就是 `L(Object)`,体里只做
|
||
// `String name = TextField.getText(...); …建对象…`。
|
||
Object dialog = findDialog(stage);
|
||
if (dialog == null) return false;
|
||
Object textField = SceneObjects.fieldOfType(dialog, "com.badlogic.gdx.scenes.scene2d.ui.TextField");
|
||
if (textField == null) return false;
|
||
setText(textField, name);
|
||
Method ok = findMethod(dialog.getClass(), "L", new Class<?>[] {Object.class}, void.class);
|
||
if (ok == null) return false;
|
||
ok.setAccessible(true);
|
||
ok.invoke(dialog, Boolean.TRUE);
|
||
return true;
|
||
} catch (Throwable error) {
|
||
System.err.println("[crud] 对话框自动化失败: " + error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 广度遍历 Stage 上的对象图,找"带 TextField 且有 L(Object)V"的对话框。 */
|
||
private static Object findDialog(Object stage) {
|
||
java.util.Set<Object> seen = java.util.Collections.newSetFromMap(
|
||
new java.util.IdentityHashMap<Object, Boolean>());
|
||
java.util.ArrayDeque<Object> queue = new java.util.ArrayDeque<Object>();
|
||
queue.add(stage);
|
||
int visited = 0;
|
||
while (!queue.isEmpty() && visited++ < 40000) {
|
||
Object current = queue.poll();
|
||
if (current == null || !seen.add(current)) continue;
|
||
if (isDialog(current)) return current;
|
||
if (current.getClass().isArray()) {
|
||
if (!current.getClass().getComponentType().isPrimitive()) {
|
||
for (Object item : (Object[]) current) queue.add(item);
|
||
}
|
||
continue;
|
||
}
|
||
String typeName = current.getClass().getName();
|
||
if (typeName.startsWith("java.") || typeName.startsWith("javax.")) continue;
|
||
for (Class<?> type = current.getClass(); type != null && type != Object.class;
|
||
type = type.getSuperclass()) {
|
||
for (Field field : type.getDeclaredFields()) {
|
||
if (Modifier.isStatic(field.getModifiers())) continue;
|
||
if (field.getType().isPrimitive()) continue;
|
||
try {
|
||
field.setAccessible(true);
|
||
Object value = field.get(current);
|
||
if (value != null) queue.add(value);
|
||
} catch (Throwable ignored) {
|
||
// 跳过
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static boolean isDialog(Object candidate) {
|
||
if (SceneObjects.fieldOfType(candidate, "com.badlogic.gdx.scenes.scene2d.ui.TextField") == null) {
|
||
return false;
|
||
}
|
||
return findMethod(candidate.getClass(), "L", new Class<?>[] {Object.class}, void.class) != null;
|
||
}
|
||
|
||
/** 深度优先遍历 actor 树,记住**最后一个** TextField 和最后一个文字含 OK 的按钮。 */
|
||
private static void walkActors(Object actor, Object[] found, int depth) {
|
||
if (actor == null || depth > 24) return;
|
||
String type = actor.getClass().getName();
|
||
if (type.equals("com.badlogic.gdx.scenes.scene2d.ui.TextField")) found[0] = actor;
|
||
if (type.startsWith("com.badlogic.gdx.scenes.scene2d.ui.TextButton")) {
|
||
String label = ObjectPanel.buttonLabel(actor);
|
||
if (label != null && label.toUpperCase().contains("OK")) found[1] = actor;
|
||
}
|
||
for (Object child : children(actor)) walkActors(child, found, depth + 1);
|
||
}
|
||
|
||
/** Group 的子节点(`do` 上的 Object[] 字段 + 一个 int size)。 */
|
||
private static Object[] children(Object actor) {
|
||
for (Class<?> type = actor.getClass(); type != null && type != Object.class;
|
||
type = type.getSuperclass()) {
|
||
for (Field field : type.getDeclaredFields()) {
|
||
if (Modifier.isStatic(field.getModifiers())) continue;
|
||
try {
|
||
field.setAccessible(true);
|
||
Object value = field.get(actor);
|
||
Object[] items = SceneObjects.arrayItems(value);
|
||
if (items.length > 0) return items;
|
||
} catch (Throwable ignored) {
|
||
// 跳过
|
||
}
|
||
}
|
||
if (type.getName().equals(Obf.GROUP)) break;
|
||
}
|
||
return new Object[0];
|
||
}
|
||
|
||
private static void setText(Object textField, String text) throws Exception {
|
||
Method setter = findMethod(textField.getClass(), Obf.LABEL_SET_TEXT, new Class<?>[] {String.class}, void.class);
|
||
if (setter == null) {
|
||
setter = findMethod(textField.getClass(), Obf.LABEL_SET_TEXT, new Class<?>[] {CharSequence.class}, void.class);
|
||
}
|
||
if (setter == null) throw new NoSuchMethodException("TextField.setText");
|
||
setter.setAccessible(true);
|
||
setter.invoke(textField, text);
|
||
}
|
||
|
||
private static void pressButton(Object button) throws Exception {
|
||
Method setChecked = findMethod(button.getClass(), Obf.BUTTON_SET_CHECKED_FIRE,
|
||
new Class<?>[] {boolean.class, boolean.class}, void.class);
|
||
if (setChecked == null) {
|
||
setChecked = findMethod(button.getClass(), Obf.BUTTON_SET_CHECKED,
|
||
new Class<?>[] {boolean.class}, void.class);
|
||
setChecked.setAccessible(true);
|
||
setChecked.invoke(button, Boolean.TRUE);
|
||
return;
|
||
}
|
||
setChecked.setAccessible(true);
|
||
setChecked.invoke(button, Boolean.TRUE, Boolean.TRUE);
|
||
}
|
||
|
||
/**
|
||
* 删除:`un.D()` → `对象.L()` → `un.B()` → 重建树。
|
||
* 返回 true 表示调用链没抛异常(不代表编辑器真的删掉了,删掉与否看树)。
|
||
*/
|
||
public static boolean delete(ClassLoader loader, Object sceneObject) {
|
||
final Object target = sceneObject;
|
||
final ClassLoader classLoader = loader;
|
||
final boolean[] result = new boolean[1];
|
||
onEditorThread(loader, new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
result[0] = deleteNow(classLoader, target);
|
||
}
|
||
});
|
||
return result[0];
|
||
}
|
||
|
||
private static boolean deleteNow(ClassLoader loader, Object sceneObject) {
|
||
if (sceneObject == null) return false;
|
||
try {
|
||
Method remove = findMethod(sceneObject.getClass(), "L", new Class<?>[0], void.class);
|
||
if (remove == null) {
|
||
System.err.println("[crud] 删除失败:" + sceneObject.getClass().getName() + " 上没有 L()V");
|
||
return false;
|
||
}
|
||
Object skeleton = skeletonOf(sceneObject);
|
||
Class<?> undoClass = undoManager(loader);
|
||
undoCall(undoClass, "D");
|
||
remove.setAccessible(true);
|
||
remove.invoke(sceneObject);
|
||
undoCall(undoClass, "B");
|
||
refresh(loader, skeleton);
|
||
System.err.println("[crud] 已删除 " + SceneObjects.kind(sceneObject) + " "
|
||
+ SceneObjects.nameOf(sceneObject));
|
||
return true;
|
||
} catch (Throwable error) {
|
||
System.err.println("[crud] 删除失败: " + error);
|
||
error.printStackTrace(System.err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 改名:`un.D()` → `对象.D(新名字, true)` → `un.B()` → 重建树。 */
|
||
public static boolean rename(ClassLoader loader, Object sceneObject, String newName) {
|
||
final Object target = sceneObject;
|
||
final String name = newName;
|
||
final ClassLoader classLoader = loader;
|
||
final boolean[] result = new boolean[1];
|
||
onEditorThread(loader, new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
result[0] = renameNow(classLoader, target, name);
|
||
}
|
||
});
|
||
return result[0];
|
||
}
|
||
|
||
private static boolean renameNow(ClassLoader loader, Object sceneObject, String newName) {
|
||
if (sceneObject == null || newName == null || newName.length() == 0) return false;
|
||
try {
|
||
Method setName = findMethod(sceneObject.getClass(), "D",
|
||
new Class<?>[] {String.class, boolean.class}, void.class);
|
||
if (setName == null) {
|
||
System.err.println("[crud] 改名失败:" + sceneObject.getClass().getName()
|
||
+ " 上没有 D(String,boolean)V");
|
||
return false;
|
||
}
|
||
Object skeleton = skeletonOf(sceneObject);
|
||
Class<?> undoClass = undoManager(loader);
|
||
undoCall(undoClass, "D");
|
||
setName.setAccessible(true);
|
||
setName.invoke(sceneObject, newName, Boolean.TRUE);
|
||
undoCall(undoClass, "B");
|
||
refresh(loader, skeleton);
|
||
System.err.println("[crud] 已改名 -> " + newName);
|
||
return true;
|
||
} catch (Throwable error) {
|
||
System.err.println("[crud] 改名失败: " + error);
|
||
error.printStackTrace(System.err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重建某个骨架在层级树里的节点(编辑器删除流程里用的就是这个:
|
||
* `VZ.D(LGP;ZZ)V`,见 `vq_0.java:54`)。
|
||
*/
|
||
public static boolean refresh(ClassLoader loader, Object skeleton) {
|
||
if (skeleton == null) return false;
|
||
try {
|
||
Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW);
|
||
Class<?> skeletonClass = Class.forName("GP", true, loader);
|
||
Method rebuild = null;
|
||
for (Method method : treeView.getClass().getDeclaredMethods()) {
|
||
Class<?>[] types = method.getParameterTypes();
|
||
if (types.length != 3 || types[0] != skeletonClass) continue;
|
||
if (types[1] != boolean.class || types[2] != boolean.class) continue;
|
||
rebuild = method;
|
||
break;
|
||
}
|
||
if (rebuild == null) {
|
||
System.err.println("[crud] 刷新失败:VZ 上没有 D(GP,boolean,boolean)V");
|
||
return false;
|
||
}
|
||
System.err.println("[crud] 刷新用方法 " + rebuild.getDeclaringClass().getName() + "."
|
||
+ rebuild.getName() + java.util.Arrays.toString(rebuild.getParameterTypes())
|
||
+ " static=" + Modifier.isStatic(rebuild.getModifiers()));
|
||
rebuild.setAccessible(true);
|
||
// ⚠️ 参数就 3 个(GP, boolean, boolean):editor 不是参数,别多传。
|
||
rebuild.invoke(treeView, skeleton, Boolean.TRUE, Boolean.TRUE);
|
||
return true;
|
||
} catch (Throwable error) {
|
||
System.err.println("[crud] 刷新失败: " + error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 找到所属骨架:骨骼是 `Gp.D:LGP;`,骨架自己就是 `GP`。 */
|
||
public static Object skeletonOf(Object sceneObject) {
|
||
if (sceneObject == null) return null;
|
||
if (sceneObject.getClass().getName().equals("GP")) return sceneObject;
|
||
for (Class<?> type = sceneObject.getClass(); type != null && type != Object.class;
|
||
type = type.getSuperclass()) {
|
||
for (Field field : type.getDeclaredFields()) {
|
||
if (!field.getType().getName().equals("GP")) continue;
|
||
try {
|
||
field.setAccessible(true);
|
||
Object value = field.get(sceneObject);
|
||
if (value != null) return value;
|
||
} catch (Throwable ignored) {
|
||
// 跳过
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 当前选中对象的类型名(给面板显示用)。 */
|
||
public static String selectedKind(ClassLoader loader) {
|
||
try {
|
||
return SceneObjects.kind(SceneObjects.selected(loader));
|
||
} catch (Throwable error) {
|
||
return "?";
|
||
}
|
||
}
|
||
|
||
/** 当前显示在 Tree 视图里的"属性面板控件"类名(诊断用)。 */
|
||
public static String propertyPanelClass(ClassLoader loader) {
|
||
try {
|
||
Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW);
|
||
Object panel = SceneObjects.fieldOfType(treeView, "VY");
|
||
return panel == null ? "null" : panel.getClass().getName();
|
||
} catch (Throwable error) {
|
||
return "err:" + error;
|
||
}
|
||
}
|
||
|
||
/** 按"名字 + 参数 + 返回类型"找实例方法(载荷里同名方法成堆,必须全判)。 */
|
||
static Method findMethod(Class<?> owner, String name, Class<?>[] parameters,
|
||
Class<?> returnType) {
|
||
for (Class<?> type = owner; type != null && type != Object.class;
|
||
type = type.getSuperclass()) {
|
||
for (Method method : type.getDeclaredMethods()) {
|
||
if (!method.getName().equals(name)) continue;
|
||
if (!java.util.Arrays.equals(method.getParameterTypes(), parameters)) continue;
|
||
if (returnType != null && method.getReturnType() != returnType) continue;
|
||
return method;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 诊断:把树里每个对象的类型 + 属性面板类名打出来。 */
|
||
static String describePanels(ClassLoader loader) {
|
||
StringBuilder out = new StringBuilder();
|
||
try {
|
||
List<Object> all = SceneObjects.listAllNodes(loader);
|
||
for (Object item : all) {
|
||
out.append(" ").append(SceneObjects.kind(item)).append(' ')
|
||
.append(SceneObjects.nameOf(item));
|
||
// 选中动作必须在编辑器渲染线程上做(否则属性面板不刷新,见 onEditorThread)。
|
||
final Object target = item;
|
||
final String[] info = new String[1];
|
||
onEditorThread(loader, new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
boolean ok = select(loader, target);
|
||
Object selected;
|
||
try {
|
||
selected = SceneObjects.selected(loader);
|
||
} catch (Throwable error) {
|
||
selected = error;
|
||
}
|
||
info[0] = " select=" + ok + " treeSelected="
|
||
+ (selected == null ? "null" : SceneObjects.nameOf(selected))
|
||
+ " panel=" + propertyPanelClass(loader);
|
||
}
|
||
});
|
||
out.append(info[0] == null ? " (no info)" : info[0]);
|
||
out.append('\n');
|
||
}
|
||
} catch (Throwable error) {
|
||
out.append("describePanels failed: ").append(error).append('\n');
|
||
}
|
||
return out.toString();
|
||
}
|
||
|
||
/**
|
||
* 探针:真机跑一遍"选中 → 新建 → 改名 → 删除",每步都把对象列表打出来。
|
||
* 只用于验证,不参与面板逻辑。
|
||
*/
|
||
/** 探针:走编辑器自己的 `Select - Bones`,用来确认"选中"到底体现在哪。 */
|
||
public static String probeSelect(final ClassLoader loader) {
|
||
final StringBuilder out = new StringBuilder();
|
||
onEditorThread(loader, new Runnable() {
|
||
@Override
|
||
public void run() {
|
||
out.append("before: treeSelected=").append(describeSelection(loader)).append('\n');
|
||
Commands.run(loader, "Select - Bones");
|
||
out.append("after Select - Bones: ").append(describeSelection(loader)).append('\n');
|
||
Object bone = null;
|
||
try {
|
||
bone = findByName(loader, "bone3");
|
||
} catch (Throwable error) {
|
||
out.append("findByName failed: ").append(error).append('\n');
|
||
}
|
||
out.append("select(\"bone3\") = ").append(select(loader, bone)).append('\n');
|
||
out.append("after select: ").append(describeSelection(loader)).append('\n');
|
||
}
|
||
});
|
||
return out.toString();
|
||
}
|
||
|
||
private static String describeSelection(ClassLoader loader) {
|
||
StringBuilder out = new StringBuilder();
|
||
try {
|
||
Object selected = SceneObjects.selected(loader);
|
||
out.append("treeSelected=").append(selected == null ? "null" : SceneObjects.nameOf(selected));
|
||
Object treeView = SceneObjects.global(loader, SceneObjects.TREE_VIEW);
|
||
Object selection = SceneObjects.fieldNamed(treeView, "D", "fC");
|
||
out.append(" selectionItems=").append(selection == null ? "noSelection" : String.valueOf(
|
||
selectionCount(selection)));
|
||
out.append(" lastSelected=").append(selection == null ? "-" : String.valueOf(
|
||
lastSelected(selection)));
|
||
out.append(" panel=").append(propertyPanelClass(loader));
|
||
} catch (Throwable error) {
|
||
out.append(" describeSelection failed: ").append(error);
|
||
}
|
||
return out.toString();
|
||
}
|
||
|
||
private static int selectionCount(Object selection) {
|
||
try {
|
||
Method items = findMethod(selection.getClass(), "D", new Class<?>[0],
|
||
Class.forName("fI", true, selection.getClass().getClassLoader()));
|
||
if (items == null) return -1;
|
||
items.setAccessible(true);
|
||
Object array = items.invoke(selection);
|
||
Object[] values = SceneObjects.arrayItems(array);
|
||
return values.length;
|
||
} catch (Throwable error) {
|
||
return -2;
|
||
}
|
||
}
|
||
|
||
private static String lastSelected(Object selection) {
|
||
try {
|
||
Method last = findMethod(selection.getClass(), "D", new Class<?>[0], Object.class);
|
||
if (last == null) return "?";
|
||
last.setAccessible(true);
|
||
Object value = last.invoke(selection);
|
||
if (value == null) return "null";
|
||
Object object = SceneObjects.nodeObject(value);
|
||
return object == null ? String.valueOf(value) : SceneObjects.nameOf(object);
|
||
} catch (Throwable error) {
|
||
return "err:" + error;
|
||
}
|
||
}
|
||
|
||
public static String probeCrud(ClassLoader loader) {
|
||
StringBuilder out = new StringBuilder();
|
||
try {
|
||
out.append("--- before ---\n").append(listNames(loader));
|
||
Object parent = findByName(loader, "bone");
|
||
out.append("parent = ").append(parent == null ? "null" : SceneObjects.kind(parent)
|
||
+ " " + SceneObjects.nameOf(parent)).append('\n');
|
||
if (parent == null) return out.toString();
|
||
select(loader, parent);
|
||
out.append("parent panel = ").append(propertyPanelClass(loader)).append('\n');
|
||
|
||
out.append("--- create New Bone under ").append(SceneObjects.nameOf(parent)).append(" ---\n");
|
||
boolean created = create(loader, parent, "Bone");
|
||
sleep(400);
|
||
out.append("created=").append(created).append('\n').append(listNames(loader));
|
||
|
||
Object fresh = findByName(loader, "bone3");
|
||
out.append("fresh = ").append(fresh == null ? "null" : SceneObjects.nameOf(fresh))
|
||
.append('\n');
|
||
if (fresh != null) {
|
||
out.append("--- rename -> panel_test ---\n");
|
||
boolean renamed = rename(loader, fresh, "panel_test");
|
||
sleep(400);
|
||
out.append("renamed=").append(renamed).append('\n').append(listNames(loader));
|
||
Object renamedObject = findByName(loader, "panel_test");
|
||
if (renamedObject != null) {
|
||
out.append("--- delete panel_test ---\n");
|
||
boolean deleted = delete(loader, renamedObject);
|
||
sleep(400);
|
||
out.append("deleted=").append(deleted).append('\n').append(listNames(loader));
|
||
}
|
||
}
|
||
} catch (Throwable error) {
|
||
out.append("probeCrud failed: ").append(error).append('\n');
|
||
error.printStackTrace(System.err);
|
||
}
|
||
return out.toString();
|
||
}
|
||
|
||
private static String listNames(ClassLoader loader) throws Exception {
|
||
StringBuilder out = new StringBuilder();
|
||
for (Object item : SceneObjects.listAllNodes(loader)) {
|
||
out.append(" ").append(SceneObjects.kind(item)).append(' ')
|
||
.append(SceneObjects.nameOf(item)).append('\n');
|
||
}
|
||
return out.toString();
|
||
}
|
||
|
||
/** 按名字在树里找一个场景对象。 */
|
||
static Object findByName(ClassLoader loader, String name) throws Exception {
|
||
for (Object item : SceneObjects.listAllNodes(loader)) {
|
||
if (name.equals(SceneObjects.nameOf(item))) return item;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static void sleep(long millis) {
|
||
try {
|
||
Thread.sleep(millis);
|
||
} catch (InterruptedException ignored) {
|
||
Thread.currentThread().interrupt();
|
||
}
|
||
}
|
||
|
||
// ==================== 综合矩阵测试:骨骼 / 插槽 / 附件 的增删改查 + 同名规则 ====================
|
||
|
||
/**
|
||
* 一次跑完三类对象的增删改查与同名规则,把每一步的**事实**打出来(不做断言)。
|
||
*
|
||
* 三类对象的模型 API 不一样,这正是要测的点:
|
||
* - 骨骼:`GP.D(String)LGp;`(按名字查,全局唯一)
|
||
* - 插槽:`GP.D(String)LGU;`(按名字查,全局唯一)
|
||
* - 附件:`GP.D(LGU;String)LGn;`(**先按插槽、再按名字**查 —— 附件名只在自己插槽内唯一)
|
||
*/
|
||
public static String probeMatrix(final ClassLoader loader) {
|
||
final StringBuilder out = new StringBuilder();
|
||
// ⚠️ 不要整段包 onEditorThread:编辑器有些创建是"投递到下一帧"再做的,
|
||
// 而 onEditorThread 会一直占着渲染线程等我们返回,帧就跑不起来、对象永远建不出来。
|
||
// 所以:每一步各自切渲染线程(select/create/delete 内部已经切了),sleep 留在本线程。
|
||
{
|
||
try {
|
||
out.append("== initial ==\n").append(names(loader));
|
||
|
||
// ---- 骨骼 ----
|
||
out.append("\n== bone ==\n");
|
||
Object bone = findByName(loader, "bone");
|
||
Object created = createAndReport(loader, bone, "Bone", out, "New Bone");
|
||
Object created2 = createAndReport(loader, bone, "Bone", out, "New Bone#2");
|
||
out.append(" names like bone*: ").append(countNames(loader, "bone", "bone")).append('\n');
|
||
if (created2 != null) {
|
||
// 重名:把第二个新建的骨骼改成第一个的名字
|
||
boolean renamed = rename(loader, created2, SceneObjects.nameOf(created));
|
||
sleep(200);
|
||
out.append(" rename(").append(SceneObjects.nameOf(created2)).append(" -> ")
|
||
.append(SceneObjects.nameOf(created)).append(") = ").append(renamed)
|
||
.append("; 现在叫 ").append(SceneObjects.nameOf(created2))
|
||
.append("; 同名骨骼数=").append(countNames(loader, "bone",
|
||
SceneObjects.nameOf(created))).append('\n');
|
||
}
|
||
// 按名字查(模型 API)
|
||
Object skeleton = findByName(loader, "skeleton");
|
||
out.append(" getBone(\"").append(SceneObjects.nameOf(created)).append("\") = ")
|
||
.append(describe(lookup(skeleton, "Gp", SceneObjects.nameOf(created)))).append('\n');
|
||
out.append(" getBone(\"no_such_bone\") = ")
|
||
.append(describe(lookup(skeleton, "Gp", "no_such_bone"))).append('\n');
|
||
// 删除
|
||
if (created != null) {
|
||
boolean ok = delete(loader, created);
|
||
sleep(200);
|
||
out.append(" delete(").append(SceneObjects.nameOf(created)).append(") = ").append(ok)
|
||
.append("; 还在吗=").append(findByName(loader, SceneObjects.nameOf(created)) != null)
|
||
.append('\n');
|
||
}
|
||
|
||
// ---- 插槽 ----
|
||
out.append("\n== slot ==\n");
|
||
Object slot = createAndReport(loader, bone, "Slot", out, "New Slot");
|
||
Object slot2 = createAndReport(loader, bone, "Slot", out, "New Slot#2");
|
||
if (slot != null) {
|
||
out.append(" getSlot(\"").append(SceneObjects.nameOf(slot)).append("\") = ")
|
||
.append(describe(lookup(skeleton, "GU", SceneObjects.nameOf(slot)))).append('\n');
|
||
}
|
||
if (slot2 != null && slot != null) {
|
||
boolean renamed = rename(loader, slot2, SceneObjects.nameOf(slot));
|
||
sleep(200);
|
||
out.append(" rename(slot2 -> \"").append(SceneObjects.nameOf(slot)).append("\") = ")
|
||
.append(renamed).append("; 现在叫 ").append(SceneObjects.nameOf(slot2))
|
||
.append("; 同名插槽数=").append(countNames(loader, "slot",
|
||
SceneObjects.nameOf(slot))).append('\n');
|
||
}
|
||
|
||
// ---- 附件(挂在插槽下)----
|
||
out.append("\n== attachment ==\n");
|
||
if (slot != null) {
|
||
out.append(" select slot ").append(SceneObjects.nameOf(slot)).append(" = ")
|
||
.append(select(loader, slot)).append('\n');
|
||
sleep(200);
|
||
Object att1 = createAndReport(loader, slot, "Bounding Box", out, "New Bounding Box");
|
||
Object att2 = createAndReport(loader, slot, "Path", out, "New Path");
|
||
if (att1 != null && att2 != null) {
|
||
out.append(" attachment 类: ").append(att1.getClass().getName()).append(" / ")
|
||
.append(att2.getClass().getName()).append('\n');
|
||
boolean renamed = rename(loader, att2, SceneObjects.nameOf(att1));
|
||
sleep(200);
|
||
out.append(" rename(att2 -> \"").append(SceneObjects.nameOf(att1))
|
||
.append("\") = ").append(renamed).append("; att2 现在叫 ")
|
||
.append(SceneObjects.nameOf(att2)).append('\n');
|
||
out.append(" getAttachment(slot,\"").append(SceneObjects.nameOf(att1))
|
||
.append("\") = ")
|
||
.append(describe(lookupAttachment(skeleton, slot, SceneObjects.nameOf(att1))))
|
||
.append('\n');
|
||
out.append(" delete(att1) = ").append(delete(loader, att1)).append('\n');
|
||
sleep(200);
|
||
}
|
||
}
|
||
|
||
// ---- 跨类型同名 ----
|
||
out.append("\n== cross-type duplicate names ==\n");
|
||
if (slot != null && bone != null) {
|
||
boolean renamed = rename(loader, slot, SceneObjects.nameOf(bone));
|
||
sleep(200);
|
||
out.append(" rename(slot -> bone 的名字 \"").append(SceneObjects.nameOf(bone))
|
||
.append("\") = ").append(renamed).append("; slot 现在叫 ")
|
||
.append(SceneObjects.nameOf(slot)).append('\n');
|
||
}
|
||
out.append("\n== final ==\n").append(names(loader));
|
||
} catch (Throwable error) {
|
||
out.append("probeMatrix failed: ").append(error).append('\n');
|
||
error.printStackTrace(System.err);
|
||
}
|
||
}
|
||
return out.toString();
|
||
}
|
||
|
||
/** 建一个对象并把"新建前后的差集"报出来。 */
|
||
private static Object createAndReport(ClassLoader loader, Object parent, String type,
|
||
StringBuilder out, String label) {
|
||
try {
|
||
List<Object> before = SceneObjects.listAllNodes(loader);
|
||
// 会弹名字对话框的那几种,先把名字准备好(bone 不用,编辑器自己编号)。
|
||
if (!type.equals("Bone")) {
|
||
nextName(type.toLowerCase().replace(' ', '_') + "_probe"
|
||
+ (System.nanoTime() % 1000));
|
||
}
|
||
boolean ok = create(loader, parent, type);
|
||
sleep(300);
|
||
List<Object> after = SceneObjects.listAllNodes(loader);
|
||
Object fresh = null;
|
||
for (Object item : after) {
|
||
boolean seen = false;
|
||
for (Object old : before) {
|
||
if (old == item) {
|
||
seen = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!seen) {
|
||
fresh = item;
|
||
break;
|
||
}
|
||
}
|
||
out.append(" ").append(label).append("(parent=").append(SceneObjects.nameOf(parent))
|
||
.append(") -> ok=").append(ok).append(" new=")
|
||
.append(fresh == null ? "null" : fresh.getClass().getName() + " \""
|
||
+ SceneObjects.nameOf(fresh) + "\"")
|
||
.append('\n');
|
||
return fresh;
|
||
} catch (Throwable error) {
|
||
out.append(" ").append(label).append(" failed: ").append(error).append('\n');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 模型层的按名字查:骨骼 `GP.D(String)LGp;` / 插槽 `GP.D(String)LGU;`。 */
|
||
static Object lookup(Object skeleton, String typeName, String name) {
|
||
if (skeleton == null) return null;
|
||
for (Method method : skeleton.getClass().getMethods()) {
|
||
Class<?>[] types = method.getParameterTypes();
|
||
if (types.length != 1 || types[0] != String.class) continue;
|
||
if (!method.getReturnType().getName().equals(typeName)) continue;
|
||
try {
|
||
method.setAccessible(true);
|
||
return method.invoke(skeleton, name);
|
||
} catch (Throwable ignored) {
|
||
// 换下一个
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 附件的按名字查要连插槽一起给:`GP.D(LGU;Ljava/lang/String;)LGn;`。 */
|
||
static Object lookupAttachment(Object skeleton, Object slot, String name) {
|
||
if (skeleton == null || slot == null) return null;
|
||
for (Method method : skeleton.getClass().getMethods()) {
|
||
Class<?>[] types = method.getParameterTypes();
|
||
if (types.length != 2 || types[0] != slot.getClass()) continue;
|
||
if (types[1] != String.class) continue;
|
||
try {
|
||
method.setAccessible(true);
|
||
return method.invoke(skeleton, slot, name);
|
||
} catch (Throwable ignored) {
|
||
// 换下一个
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static String describe(Object object) {
|
||
return object == null ? "null" : object.getClass().getName() + " \""
|
||
+ SceneObjects.nameOf(object) + "\"";
|
||
}
|
||
|
||
private static String names(ClassLoader loader) throws Exception {
|
||
StringBuilder out = new StringBuilder();
|
||
for (Object item : SceneObjects.listAllNodes(loader)) {
|
||
String kind = SceneObjects.kind(item);
|
||
if (kind.equals("skeleton") || kind.equals("bone") || kind.equals("slot")
|
||
|| kind.equals("attachment")) {
|
||
out.append(" ").append(kind).append(" \"").append(SceneObjects.nameOf(item))
|
||
.append("\"\n");
|
||
}
|
||
}
|
||
return out.toString();
|
||
}
|
||
|
||
private static int countNames(ClassLoader loader, String kind, String name) throws Exception {
|
||
int count = 0;
|
||
for (Object item : SceneObjects.listAllNodes(loader)) {
|
||
if (!SceneObjects.kind(item).equals(kind)) continue;
|
||
if (SceneObjects.nameOf(item).equals(name)) count++;
|
||
}
|
||
return count;
|
||
}
|
||
}
|