Repository Wiki
isHarryh/Ark-Pets

平台窗口控制与多显示器支持

Ark-Pets 通过 cn.harryh.arkpets.platform 包将"桌宠窗口"这种非常规窗口(无边框、透明、可点击穿透、置顶、隐藏任务栏项)的控制能力抽象为平台无关的 HWndCtrl 接口层,并由 User32HWndCtrl(Windows 原生实现)与 NullHWndCtrl(空实现)提供具体平台分支。本页覆盖该抽象层、窗口几何模型、鼠标事件注入、多实例窗口标题编号,以及与 GLFW/libGDX 渲染窗口的衔接方式。

Purpose and Scope

本页聚焦 桌面窗口(HWND)的平台级控制机制,即 Ark-Pets 如何在操作系统层面查找、定位、样式化桌宠窗口,以及多只桌宠实例如何在同一桌面共存。具体包括:

  • HWndCtrl 抽象基类的完整 API 契约(前景/可见性、置顶、任务栏、穿透、定位、关闭)
  • WindowRect / MousePoint 几何模型与 MouseEvent 事件枚举
  • NumberedTitleManager:基于窗口标题扫描的多实例编号机制
  • 平台分支结构:WindowSystem(窗口枚举/查找入口)、User32HWndCtrl、NullHWndCtrl

以下相邻主题有意留给兄弟页面,本页仅点到为止:

  • 桌宠渲染循环、动画与行为状态机 —— 见「桌宠运行核心」相关页面
  • 开机自启动(StartupConfig / WindowsStartupConfig / NullStartupConfig)—— 属于独立的自启动能力,不属于窗口控制
  • 应用配置文件(ArkPetsConfig)的解析 —— 见配置系统页面

Overview

Ark-Pets 的桌宠本质是一个 libGDX/LWJGL3 渲染的 GLFW 窗口,但要让它表现得像"趴在桌面上的宠物",仅靠 libGDX 提供的窗口 API 是不够的。需要操作系统原生层面的窗口操作:

需求libGDX/GLFW 能力需要平台层补充的能力
鼠标穿透(宠物身体空白处点击落到下层窗口)glfwSetWindowAttrib(GLFW_MOUSE_PASSTHROUGH)无需原生 API
隐藏任务栏图标(桌宠不应出现在任务栏)不提供setTaskbar()(Win32 窗口样式扩展)
始终置顶(宠物不被其他窗口盖住)部分提供setTopmost()(Z 序控制)
查找/关闭其他 ArkPets 实例窗口(多开管理)不提供WindowSystem.findWindow() + close()
无激活移动窗口(拖拽宠物不抢焦点)不提供setWindowPosition(insertAfter, x, y, w, h)
向其他窗口注入鼠标消息不提供sendMouseEvent()

因此 Ark-Pets 设计了一个模板方法风格的抽象基类 HWndCtrl:通用逻辑(几何计算、GLFW 穿透切换、标题编号)实现在基类,平台相关逻辑(前景、置顶、任务栏、消息注入等)声明为抽象方法,由 User32HWndCtrl(Windows)和 NullHWndCtrl(不支持的平台,全部空操作)分别实现。

多显示器方面,HWndCtrl 携带的 posTop/posBottom/posLeft/posRight 是操作系统全局屏幕坐标系下的窗口外接矩形,窗口跨显示器移动时该矩形随虚拟桌面坐标系连续变化;具体的显示器枚举与摆放策略由 WindowSystem 与上层 UI 协同完成(该部分实现细节未在本页读取范围内,见下文"已验证边界"说明)。

Architecture

Loading diagram...

架构要点说明:

  1. 单一抽象出口:上层代码只依赖 HWndCtrl 与 WindowSystem.findWindow(),不直接接触任何 Win32 概念,保证非 Windows 平台上代码可编译、可运行(仅功能降级)。
  2. GLFW 与原生句柄双轨:桌宠自身的渲染窗口由 GLFW 管理(glfwHandle,通过 attachGLFWWindow 注入),穿透切换走 GLFW;而其他进程/实例的窗口(无 GLFW 句柄)只能走平台抽象方法。setTransparent 在 glfwHandle == 0 时直接返回,正体现了这一双轨设计的防御性。
  3. 不可变快照语义:HWndCtrl 持有的位置信息是构造时的快照,调用 updated() 才能拿到最新状态——窗口是操作系统共享资源,其属性随时可能被用户或其他进程改变,不可变快照避免了脏读引发的并发问题。

已验证边界(Implementation Boundary)

本页文档基于 core/src/cn/harryh/arkpets/platform/HWndCtrl.java 的完整源码验证。platform 包实际包含以下 7 个文件:

文件角色本页验证程度
HWndCtrl.java窗口控制抽象基类✅ 完整读取
User32HWndCtrl.javaWindows 原生实现仅确认存在,内部实现细节未在本页验证
NullHWndCtrl.java空实现(不支持平台)仅确认存在,内部实现细节未在本页验证
WindowSystem.java平台窗口系统工具仅通过 findWindow(null, title) 调用点确认签名返回 HWndCtrl 或 null
WindowsStartupConfig.java开机自启动(Windows)不属于本页主题
NullStartupConfig.java开机自启动(空实现)不属于本页主题
StartupConfig.java自启动抽象不属于本页主题

对于 User32HWndCtrl / NullHWndCtrl / WindowSystem 的内部实现(例如是否使用 JNA 调用 user32.dll、显示器枚举细节),本页不做未经证实的描述。

主内容:HWndCtrl 抽象层实现剖析

1. 窗口几何快照与构造

HWndCtrl 构造函数接收窗口标题与 WindowRect,将屏幕坐标系的窗口外接矩形展开为公开字段,并派生宽高:

java
1public abstract class HWndCtrl { 2 public final String windowText; 3 public final int posTop; 4 public final int posBottom; 5 public final int posLeft; 6 public final int posRight; 7 public final int windowWidth; 8 public final int windowHeight; 9 protected long glfwHandle; 10 11 public HWndCtrl(String windowText, WindowRect windowRect) { 12 this.windowText = windowText; 13 posTop = windowRect.top; 14 posBottom = windowRect.bottom; 15 posLeft = windowRect.left; 16 posRight = windowRect.right; 17 windowWidth = posRight - posLeft; 18 windowHeight = posBottom - posTop; 19 }

Source: HWndCtrl.java

设计意图:posTop/posBottom/posLeft/posRight 使用操作系统全局(虚拟桌面)坐标——在多显示器环境下,副屏窗口的坐标可以落在主屏矩形之外甚至为负。字段声明为 public final 意味着每个 HWndCtrl 实例是窗口状态的一次性快照,配合 updated() 抽象方法实现"拉取最新状态"的显式协议,而非引用共享可变状态。

2. 几何辅助方法

java
1 public float getCenterX() { 2 return posLeft + windowWidth / 2f; 3 } 4 5 public float getCenterY() { 6 return posTop + windowHeight / 2f; 7 }

Source: HWndCtrl.java

getCenterX/getCenterY 返回 float 而非 int,供上层以亚像素精度计算桌宠身体中心(例如锚定宠物脚部/中心点)。

3. 鼠标穿透:基类中唯一的 GLFW 具体实现

java
1 public void setTransparent(boolean enable) { 2 if (glfwHandle == 0) return; 3 GLFW.glfwSetWindowAttrib(glfwHandle, GLFW.GLFW_MOUSE_PASSTHROUGH, enable ? 1 : 0); 4 } 5 6 public void attachGLFWWindow(Lwjgl3Graphics graphics) { 7 this.glfwHandle = graphics.getWindow().getWindowHandle(); 8 }

Source: HWndCtrl.java

这是抽象基类中唯一带具体实现的行为方法。设计意图有三层:

  • 跨平台免费获得:GLFW_MOUSE_PASSTHROUGH 是 GLFW 3.3+ 标准属性,Windows/macOS/Linux 均支持,无需各平台重复实现,所以放在基类;
  • glfwHandle == 0 防御:HWndCtrl 也可能包装其他 ArkPets 实例的窗口(此时没有 GLFW 句柄),对这类窗口调用穿透毫无意义,直接短路返回;
  • attachGLFWWindow 显式注入:libGDX 的 Lwjgl3Graphics.getWindow().getWindowHandle() 返回原生句柄(Win32 下即 HWND 的 long 表示),基类用 protected long glfwHandle 保存,避免每平台重复获取。

4. 抽象 API 契约

其余平台相关行为全部声明为抽象方法,构成每个平台实现必须满足的契约:

java
1 /** Returns true if the window is a foreground window now. */ 2 public abstract boolean isForeground(); 3 4 /** Returns true if the window is visible now. */ 5 public abstract boolean isVisible(); 6 7 /** Requests to close the window. 8 * @param timeout Timeout for waiting response (ms). 9 * @return true=success, false=failure. */ 10 public abstract boolean close(int timeout); 11 12 /** Gets a new HWndCtrl which contains the updated information of this window. 13 * @return The up-to-dated HWndCtrl. */ 14 public abstract HWndCtrl updated(); 15 16 /** Sets the window as the foreground window. */ 17 public abstract void setForeground(); 18 19 /** Sets the window's position without activating the window. 20 * @param insertAfter The window to precede the positioned window in the Z order. 21 * @param x The new position of the left side of the window, in client coordinates. 22 * @param y The new position of the top of the window, in client coordinates. 23 * @param w The new width of the window, in pixels. 24 * @param h The new height of the window, in pixels. */ 25 public abstract void setWindowPosition(HWndCtrl insertAfter, int x, int y, int w, int h); 26 27 /** Sets the window's taskbar visibility. 28 * @param enable Whether to let the window's entry visible on the taskbar. */ 29 public abstract void setTaskbar(boolean enable); 30 31 /** Sets the window's topmost style. 32 * @param enable Whether to enable the topmost style. */ 33 public abstract void setTopmost(boolean enable); 34 35 /** Sends a mouse event message to the window. 36 * @param msg The window message value. 37 * @param x The X-axis coordinate, related to the left border of the window. 38 * @param y The Y-axis coordinate, related to the top border of the window. */ 39 public abstract void sendMouseEvent(MouseEvent msg, int x, int y); 40 41 @Override 42 public abstract boolean equals(Object o); 43 44 @Override 45 public abstract int hashCode();

Source: HWndCtrl.java

几个值得注意的契约细节:

  • setWindowPosition(HWndCtrl insertAfter, ...):insertAfter 参数对应 Win32 SetWindowPos 的 hWndInsertAfter 语义——移动窗口的同时指定其在 Z 序中插入到哪个窗口之后。这正是"拖拽桌宠不抢焦点"的关键:移动窗口的 API 与激活窗口的 API 解耦,桌宠被拖动时既改位置又保持 Z 序。参数说明中"client coordinates"(客户区坐标)与像素宽高,配合 setTopmost 可以组合出"贴在某个窗口上方/下方"的桌面伴生行为。
  • close(int timeout):以毫秒超时等待窗口响应关闭请求,返回成败布尔。超时语义暗示实现层存在"发送关闭消息后轮询/等待"的逻辑,用于优雅关闭其他 ArkPets 实例(例如多开管理时逐个关闭旧实例)。
  • updated() 返回新 HWndCtrl:而非原地修改——与不可变快照设计一致。
  • equals/hashCode 强制抽象:窗口身份比较(判断两次枚举到的窗口是否同一个)是平台实现细节(Windows 下应基于 HWND 句柄),故必须由子类定义。
  • sendMouseEvent 坐标系:x/y 相对目标窗口左边框/上边框(窗口局部坐标),MouseEvent 枚举覆盖移动与左/右/中键按下与抬起。

5. 多实例窗口标题编号:NumberedTitleManager

桌宠多开时每只宠物是一个独立进程/窗口。Ark-Pets 用窗口标题编号作为实例间互认与寻址机制:

java
1 public static class NumberedTitleManager { 2 private final String zeroNameFormat; 3 private final String numberedNameFormat; 4 private final Pattern zeroNamePattern; 5 private final Pattern numberedNamePattern; 6 7 public NumberedTitleManager(String coreName) { 8 zeroNameFormat = coreName; 9 numberedNameFormat = coreName + " (%d)"; 10 zeroNamePattern = Pattern.compile("^" + coreName + "$"); 11 numberedNamePattern = Pattern.compile("^" + coreName + " \\(([0-9]+)\\)"); 12 } 13 14 public int getNumber(HWndCtrl hWndCtrl) { 15 if (hWndCtrl == null) return -1; 16 return getNumber(hWndCtrl.windowText); 17 } 18 19 public int getNumber(String windowText) { 20 if (windowText.isEmpty()) return -1; 21 if (zeroNamePattern.matcher(windowText).find()) return 0; 22 try { 23 Matcher matcher = numberedNamePattern.matcher(windowText); 24 return matcher.find() ? Integer.parseInt(matcher.group(1)) : -1; 25 } catch (NumberFormatException ignored) { 26 return -1; 27 } 28 }

Source: HWndCtrl.java

编号解析规则:核心名(如 ArkPets)映射为编号 0,ArkPets (2) 解析为 2;任何无法匹配的标题返回 -1(空标题、null 窗口、数字非法均视为未知)。

java
1 public String getIdleTitle() { 2 String title = String.format(zeroNameFormat); 3 if (WindowSystem.findWindow(null, title) == null) { 4 return title; 5 } else { 6 for (int cur = 2; cur <= 1024; cur++) { 7 title = String.format(numberedNameFormat, cur); 8 if (WindowSystem.findWindow(null, title) == null) 9 return title; 10 } 11 throw new IllegalStateException("Failed to get idle title."); 12 } 13 }

Source: HWndCtrl.java

getIdleTitle() 是启动时申请空闲标题的核心:先尝试无编号标题;被占用则从 2 开始线性探测(注意编号 1 被跳过),最多探测到 1024,全部占用则抛出 IllegalStateException。每次探测都调用 WindowSystem.findWindow(null, title) 做一次真实窗口枚举——这是一个 TOCTOU(检查-使用竞态)敏感点:如果两个 ArkPets 实例同时启动并探测到同一个空闲标题,可能出现标题冲突;实现将探测循环限制在 1024 次以内以保证有界终止。此处 findWindow 的第一个参数为 null(推测为窗口类/过滤条件,具体签名未在本页验证范围内)。

6. 窗口数据模型

java
1 public record WindowRect(int top, int bottom, int left, int right) { 2 public WindowRect() { 3 this(0, 0, 0, 0); 4 } 5 6 public int width() { return right - left; } 7 public int height() { return bottom - top; } 8 } 9 10 public enum MouseEvent { 11 EMPTY, 12 MOUSEMOVE, 13 LBUTTONDOWN, 14 LBUTTONUP, 15 RBUTTONDOWN, 16 RBUTTONUP, 17 MBUTTONDOWN, 18 MBUTTONUP, 19 } 20 21 public record MousePoint(int x, int y) { 22 }

Source: HWndCtrl.java

  • WindowRect:Java record 定义的不可变屏幕矩形,提供无参构造(全零矩形,供"空窗口占位"场景)与 width()/height() 派生量。字段顺序为 top, bottom, left, right,与 Win32 RECT 的 left/top/right/bottom 顺序不同,是使用时需注意的细节。
  • MouseEvent:与 Win32 WM_MOUSEMOVE/WM_LBUTTONDOWN/... 消息一一对应的枚举(含 EMPTY 占位),用于 sendMouseEvent 注入。
  • MousePoint:简单二维点 record。

toString() 的展示格式为 ‘标题’ 宽*高(注意使用的是中文单引号与乘号):

java
1 @Override 2 public String toString() { 3 return "‘" + windowText + "’ " + windowWidth + "*" + windowHeight; 4 }

Source: HWndCtrl.java

Core Flow:多实例启动时的标题申请时序

Loading diagram...

流程要点:标题申请发生在窗口创建之前,因此探测针对的是已存在的其他实例;探测上限 1024 保证方法必然终止(成功返回或抛出异常)。运行期还可以反向使用 getNumber(HWndCtrl) 对枚举到的窗口做编号识别,从而在多开管理 UI 中区分实例顺序。

API Reference

HWndCtrl 公开/受保护成员

成员签名说明
构造器HWndCtrl(String windowText, WindowRect windowRect)以标题与屏幕矩形构造窗口快照;派生 windowWidth/windowHeight
isForegroundabstract boolean isForeground()窗口当前是否为前台窗口
isVisibleabstract boolean isVisible()窗口当前是否可见
getCenterXfloat getCenterX()窗口中心 X(全局坐标)
getCenterYfloat getCenterY()窗口中心 Y(全局坐标)
closeabstract boolean close(int timeout)请求关闭窗口,timeout 为等待响应毫秒数;返回是否成功
updatedabstract HWndCtrl updated()返回包含该窗口最新信息的新 HWndCtrl
setForegroundabstract void setForeground()将窗口设为前台
setWindowPositionabstract void setWindowPosition(HWndCtrl insertAfter, int x, int y, int w, int h)不激活窗口地设置位置与尺寸;insertAfter 指定 Z 序插入参考窗口
setTaskbarabstract void setTaskbar(boolean enable)设置任务栏条目可见性
setTopmostabstract void setTopmost(boolean enable)设置置顶样式
setTransparentvoid setTransparent(boolean enable)切换鼠标穿透;仅当已 attachGLFWWindow 时生效
sendMouseEventabstract void sendMouseEvent(MouseEvent msg, int x, int y)向窗口注入鼠标消息,坐标相对窗口左上角
attachGLFWWindowvoid attachGLFWWindow(Lwjgl3Graphics graphics)附加 GLFW 窗口句柄,启用 setTransparent
equals / hashCodeabstract由平台实现定义窗口身份
toStringString格式 ‘标题’ 宽*高
glfwHandleprotected long附加的 GLFW 原生句柄,0 表示未附加

NumberedTitleManager 成员

成员签名说明
构造器NumberedTitleManager(String coreName)以核心窗口名构建格式与正则
getNumberint getNumber(HWndCtrl hWndCtrl)从窗口对象解析编号;null 返回 -1
getNumberint getNumber(String windowText)从标题字符串解析编号;核心名为 0,非法返回 -1
getIdleTitleString getIdleTitle()申请一个未被任何现存窗口占用的标题;探测范围 2..1024,耗尽抛 IllegalStateException

WindowRect / MousePoint / MouseEvent

类型成员说明
WindowRect(int top, int bottom, int left, int right)width(), height()不可变屏幕矩形;另含全零无参构造
MousePoint(int x, int y)—二维点
MouseEventEMPTY, MOUSEMOVE, LBUTTONDOWN, LBUTTONUP, RBUTTONDOWN, RBUTTONUP, MBUTTONDOWN, MBUTTONUP鼠标消息枚举,对应 Win32 鼠标窗口消息

Failure Modes, Edge Cases & Concurrency

  • 快照失效(staleness):所有 pos* 字段都是构造时快照。上层若持有旧 HWndCtrl 引用做拖拽/碰撞计算,会基于过期坐标操作;正确用法是周期性 updated() 或重新 WindowSystem.findWindow。
  • setTransparent 无句柄静默失败:glfwHandle == 0(包装他人窗口或未调用 attachGLFWWindow)时直接 return,不抛异常。调用方若忘记 attach,桌宠穿透功能会无报错地失效——排查时需检查是否附加了 GLFW 句柄。
  • getIdleTitle 的 TOCTOU 竞态:探测空闲标题与窗口实际创建之间存在时间窗,两个同时启动的实例可能拿到同一编号标题。探测循环上限 1024 保证有界,但冲突需上层(或用户重命名/重启实例)消化。
  • 编号语义边界:getNumber 对空标题、null、非数字后缀统一返回 -1,不区分"未编号的陌生窗口"与"解析失败";getIdleTitle 从 2 起探测,编号 1 永远不会由本机制产生。
  • close(timeout) 超时失败:目标窗口可能拒绝/忽略关闭请求(例如渲染主循环繁忙),此时方法返回 false,上层需要决定重试或放弃;不会阻塞超过 timeout 毫秒。
  • 非 Windows 平台降级:NullHWndCtrl 提供空实现,setTaskbar/setTopmost/sendMouseEvent 等调用不会生效也不报错;多开标题探测依赖 WindowSystem.findWindow 的平台实现,若平台不支持枚举,getIdleTitle 会立即认为标题空闲。

Performance & Operational Notes

  • 窗口枚举成本:getIdleTitle 最坏情况下发起 1024 次 findWindow 调用,每次都是跨进程窗口枚举;正常路径只有 1 次。实例数极多时启动延迟会线性上升,实际桌面场景(几十只宠物)可忽略。
  • 渲染热路径隔离:穿透切换(glfwSetWindowAttrib)是轻量属性设置,可在运行期按宠物身体透明度动态调用而不会明显影响帧率;但 updated()/findWindow 这类涉及系统窗口枚举的调用不宜放在逐帧循环中。
  • 多显示器:pos* 为虚拟桌面全局坐标,副屏可为负坐标/超主屏范围;跨屏拖拽时坐标连续变化,无需特殊处理,但上层做屏幕边界裁剪时必须使用虚拟桌面矩形而非主屏矩形。

Extension Points

  • 新增平台支持:继承 HWndCtrl 实现全部抽象方法(参考 User32HWndCtrl/NullHWndCtrl 的分支结构,配合 WindowSystem 选择实现),上层代码零改动——这是该抽象层最主要的扩展点。
  • setWindowPosition 的 Z 序编排:通过传入不同的 insertAfter 窗口,可实现"宠物夹在两个特定窗口之间"这类桌面伴生布局,无需新增 API。
  • MouseEvent 扩展:枚举目前覆盖基本鼠标事件,若需要滚轮/双击等注入能力,可在枚举中增加值并在平台实现中映射到对应原生消息。
  • 源码入口:HWndCtrl.java
  • 平台包目录:core/src/cn/harryh/arkpets/platform/ 下的 User32HWndCtrl.java、NullHWndCtrl.java、WindowSystem.java
  • 兄弟主题:开机自启动(StartupConfig / WindowsStartupConfig / NullStartupConfig)属于独立能力,见相关页面
  • 兄弟主题:桌宠渲染与行为控制(GLFW 窗口的创建、动画循环)见「桌宠运行核心」相关页面

Sources

(1 files)