平面重力场与物理模拟
Ark-Pets 的桌宠之所以能"站在桌面上、被拖到半空后自然坠落、落地时触发专属动画",全部依赖 core 模块中的 Plane 类所实现的一个轻量级二维(平面)刚体物理模拟器。本文深入剖析该物理引擎的重力场模型、摩擦与静电场力、障碍物支撑、落地检测机制,以及它们如何通过 ArkConfig 配置与桌面端 UI 联动。
Purpose and Scope
本页覆盖以下内容(以源码为依据):
Plane类的完整物理模型:状态变量、重力/摩擦/静电力的速度积分、位移积分与边界夹取;- 落地(dropped / dropping)状态的判定逻辑与
Const.droppedYThreshold阈值; - 障碍物(
barriers)、点电荷(pointCharges)与世界区域(world)三种物理场景元素的语义; - 物理参数的配置链路:
ArkConfig(physic_gravity_acc等 JSON 字段)→ArkPets窗口核心初始化 → 桌面端BehaviorModule滑块实时调节。
以下相关主题有意留给兄弟页面,不在本页展开:
- 窗口的创建、置顶、透明与消息循环(属于桌面窗口层,
Plane只是被其驱动的物理组件); - 行为状态机(HFSM)如何根据
getDropped()的返回值切换到"落地"动画——本页只说明物理侧的状态查询接口; - 渲染管线与 libGDX 纹理绘制。
Overview
Plane(位于 core/src/cn/harryh/arkpets/utils/Plane.java)把桌宠抽象为一个在平面内运动的矩形物体,并为其维护一个"重力场"。窗口核心类 ArkPets 在初始化时创建 Plane 实例,把配置文件中的物理参数(重力加速度等)注入进去,随后在渲染循环中每帧调用 updatePosition(deltaTime) 推进模拟。
该物理引擎的核心概念包括:
- 重力场(gravity field):一个沿 y 轴负方向的恒定加速度(
speed.y -= gravity * deltaTime),默认由配置项physic_gravity_acc提供(800.0px/s²)。公式中大量使用Math.signum(gravity),因此重力取负值时整套落地/下坠判定依然对称成立。 - 摩擦(friction):分为地面静摩擦
staticFrict与空气摩擦airFrict,均以加速度(px/s²)形式表达,并通过applyFriction()施加——该实现保证摩擦永远不会把速度"推过零点",物体只会渐止而不会反向。 - 反弹(resilience):左右边界处的弹性系数(保留动能比例,被夹取到
[0,1]);当前窗口初始化时被设为0(不反弹)。 - 静电场(point charges):一组固定的"点电荷",按库仑力形式对物体施加斥力,用于实现"鼠标点击推开桌宠"之类的交互。
- 障碍物(barriers):可支撑物体的水平线段(记录左端 x、顶面 y 与宽度),用于实现"桌宠可以站在某个窗口/任务栏上"。
- 落地检测(drop detection):通过累积下坠高度
droppedHeight与一次性标志dropped,配合Const.droppedYThreshold阈值,向行为层报告"这是一次足够高的坠落"。
Architecture
架构要点(均可回溯到源码):
- 配置即物理参数:
ArkConfig中的physic_gravity_acc通过 fastjson 的@JSONField(defaultValue = "800.0")声明默认值,持久化在 JSON 配置文件中;桌面端BehaviorModule的滑块直接回写该字段并调用app.config.save(),实现"改了就存"。 - 单向依赖:
Plane不反向依赖窗口或配置类,它只暴露 setter/getter,由ArkPets在启动时注入参数、在帧循环中驱动积分。这种"哑物理组件 + 外部驱动"的设计让Plane可以脱离 UI 单独复用与测试。 - 常量解耦:落地判定阈值
droppedYThreshold以静态导入方式从cn.harryh.arkpets.Const引入,避免把体验调参硬编码进物理公式。
物理模型与状态变量
Plane 的全部状态集中在一组私有字段中(obj 与 speedLimit 为 final 的 Vector2,可复用避免每帧分配):
1public class Plane {
2 public final ArrayList<Vector3> barriers;
3 public final ArrayList<Vector3> pointCharges;
4 public final ArrayList<RectArea> world;
5 private final Vector2 obj;
6 private final Vector2 position;
7 private final Vector2 speed;
8 private final Vector2 speedLimit;
9 private float gravity;
10 private float resilience;
11 private float airFrict;
12 private float staticFrict;
13 private boolean dropped = false;
14 private float droppedHeight = 0;
15
16 /** Initializes a plane with gravity field.
17 */
18 public Plane() {
19 barriers = new ArrayList<>();
20 pointCharges = new ArrayList<>();
21 world = new ArrayList<>();
22 obj = new Vector2(0, 0);
23 position = new Vector2(0, 0);
24 speed = new Vector2(0, 0);
25 speedLimit = new Vector2(0, 0);
26 gravity = 0;
27 resilience = 0;
28 airFrict = 0;
29 staticFrict = 0;
30 }
31}Source: Plane.java
| 字段 | 类型 | 语义 |
|---|---|---|
obj | Vector2 | 物体尺寸(宽/高,px),用于顶部碰撞(position.y + obj.y >= top)与点电荷力臂计算 |
position | Vector2 | 物体位置(px),配合 limitX/limitY 被夹取在可行走边界内 |
speed | Vector2 | 当前速度(px/s) |
speedLimit | Vector2 | 各轴速度上限,0 表示不限速 |
gravity | float | 重力加速度(px/s²),沿 y 轴负方向 |
resilience | float | 边界反弹保留动能比例,构造后经 setResilience() 夹取到 [0,1] |
airFrict / staticFrict | float | 空气摩擦 / 地面静摩擦加速度(px/s²),构造后经 setFrict() 钳为非负 |
dropped | boolean | 一次性落地标志,由 getDropped() 读取后自动复位 |
droppedHeight | float | 本次坠落中离地高度的历史最大值(带 signum(gravity) 方向归一) |
barriers | ArrayList<Vector3> | 障碍物集合,Vector3(x=左端X, y=顶面Y, z=宽度) |
pointCharges | ArrayList<Vector3> | 点电荷集合,Vector3(x=X, y=Y, z=电量乘积) |
world | ArrayList<RectArea> | 世界区域表(如屏幕工作区),在 getDebugMsg() 中以 Areas 输出 |
设计意图:把"场景描述"(barriers / pointCharges / world)声明为 public final 集合,允许外部(窗口层)随时增删物理场景元素;而"物体状态与场参数"全部私有化并经 setter 校验,防止外部直接破坏物理一致性。这是典型的值对象 + 受控变更的组合。
速度积分:updateVelocity()
每帧的速度更新严格按以下顺序进行,顺序本身承载语义(重力 → 静电场 → 地面摩擦 → 空气摩擦 → 限速 → 反弹):
1private void updateVelocity(float deltaTime) {
2 final float top = borderTop();
3 final float bottom = borderBottom();
4 // Gravity
5 speed.y -= gravity * deltaTime;
6 if (position.y == bottom || (position.y + obj.y >= top && speed.y > 0))
7 speed.y = 0;
8 // Electrostatic forces
9 for (Vector3 pc : pointCharges) {
10 float dx = position.x + obj.x / 2f - pc.x;
11 float dy = position.y + obj.y / 2f - pc.y;
12 float hypot = (float) Math.hypot(dx, dy);
13 speed.x = applyElectrostaticEffect(speed.x, pc.z, hypot, dx / hypot, deltaTime);
14 speed.y = applyElectrostaticEffect(speed.y, pc.z, hypot, dy / hypot, deltaTime);
15 }
16 // Ground friction
17 if (position.y == bottom)
18 speed.x = applyFriction(speed.x, staticFrict, deltaTime);
19 // Air friction
20 speed.x = applyFriction(speed.x, airFrict, deltaTime);
21 speed.y = applyFriction(speed.y, airFrict, deltaTime);
22 // Limit
23 if (speedLimit.x != 0 && Math.abs(speed.x) > speedLimit.x)
24 speed.x = Math.signum(speed.x) * speedLimit.x;
25 if (speedLimit.y != 0 && Math.abs(speed.y) > speedLimit.y)
26 speed.y = Math.signum(speed.y) * speedLimit.y;
27 // Bounce
28 if (resilience != 0 && (position.x == borderLeft() || position.x == borderRight())) {
29 speed.x = (float) (Math.sqrt(speed.x * speed.x * resilience) * Math.signum(-speed.x));
30 }
31}Source: Plane.java
逐段解读:
- 重力与静止条件:
speed.y -= gravity * deltaTime表明重力沿 y 轴负方向作用。紧接着的两条件判断分别处理"贴地静止"(position.y == bottom)与"顶到上边界"(position.y + obj.y >= top && speed.y > 0,即物体上沿越过顶边且仍在向上运动)——两种情形都直接把垂直速度清零,避免物体被"压"在边界上持续累积速度。 - 静电斥力:以物体几何中心(
position + obj/2)到点电荷的位移向量计算距离hypot,再把力按方向余弦(dx/hypot、dy/hypot)分解到两轴,交给applyElectrostaticEffect()累加到速度上。pc.z存放的是"电荷电量乘积"(见setPointCharge()的quantityProduct参数),符号决定排斥或吸引。注意hypot未做零距离保护——点电荷恰好放在物体中心时会产生除零(见"边界情况"一节)。 - 摩擦的分层:地面静摩擦仅在
position.y == bottom(贴地)时作用于水平速度;空气摩擦则无条件作用于两轴。两者共用applyFriction()。 - 限速:
speedLimit为 0 表示该轴不限速;否则按符号截断到上限,保证"快而不失控"。 - 边界反弹:仅在物体贴住左右边界(
position.x == borderLeft() || borderRight())时,将水平速度按sqrt(v²·resilience)反向——即按动能比例衰减(能量保留resilience,速度保留sqrt(resilience)),符合弹性碰撞的能量模型。当前ArkPets初始化时调用setResilience(0),即默认关闭反弹。
摩擦的具体实现是本引擎里最值得注意的健壮性细节:
1private float applyFriction(float speed, float frict, float deltaTime) {
2 float delta = Math.signum(speed) * frict * deltaTime;
3 float estimated = speed - delta;
4 return delta * estimated < 0 ? 0 : estimated;
5}Source: Plane.java
delta 是本帧摩擦造成的速度变化量(方向与速度相反)。若减去后符号翻转(delta * estimated < 0),说明摩擦"过量",直接归零——摩擦永远不会反向推动物体。若没有这个保护,低帧率(大 deltaTime)下物体会出现摩擦驱动的抖动。
位移积分与落地检测:updatePosition()
速度更新完成后,updatePosition() 完成位移积分,并在这里完成"落地"事件的捕获:
1public void updatePosition(float deltaTime) {
2 updateVelocity(deltaTime);
3 float deltaX = speed.x * deltaTime;
4 float deltaY = speed.y * deltaTime;
5 final float bottom = borderBottom();
6 droppedHeight = Math.max(Math.signum(gravity) * (position.y - bottom), droppedHeight);
7 if (position.y != bottom && limitY(deltaY + position.y) == bottom) {
8 // When it fell to the ground.
9 if (Math.signum(gravity) * (position.y - bottom) > 0)
10 dropped = true;
11 speed.y = 0;
12 }
13 position.set(limitX(deltaX + position.x), limitY(deltaY + position.y));
14}Source: Plane.java
关键逻辑分解:
- 下坠高度记录先于积分:在移动之前,先用
Math.signum(gravity) * (position.y - bottom)把"当前离地高度"沿重力方向归一成正值,并取历史最大值存入droppedHeight。之所以在积分前采样,是因为积分后可能已经贴地(高度为 0),会丢失本次坠落的真实峰值。 - 穿越检测(swept detection):
limitY(deltaY + position.y) == bottom判断"预测位置被边界函数夹到地面",即本帧内物体穿越了地面。配合position.y != bottom(此前确实不在地面)与方向校验signum(gravity) * (position.y - bottom) > 0(确实是朝着重力方向落下,而不是从地面下方被抬上来),三重条件确保dropped只在真实的"落地瞬间"置位。 - 清零垂直速度:落地当帧
speed.y = 0,随后updateVelocity()中的贴地判定会持续把重力抵消,物体稳定停在地面上——这是"静置"状态能长期维持的原因。
落地状态的对外查询接口是一次性读取语义:
1public boolean getDropped() {
2 if (dropped) {
3 dropped = false; // Reset
4 if (droppedHeight >= droppedYThreshold) {
5 droppedHeight = 0; // Reset
6 return true;
7 }
8 }
9 return false;
10}
11
12public boolean getDropping() {
13 return Math.abs(position.y - borderBottom()) > droppedYThreshold;
14}Source: Plane.java
getDropped():读即清除(标志位与高度都会复位)。只有当累积下坠高度达到Const.droppedYThreshold时才返回true——这过滤掉了在地面附近的小幅颠簸,确保行为层只在"摔得不轻"时播放落地动画。getDropping():实时判定"是否正在半空中"(当前离地高度超过同一阈值),供行走/拖拽逻辑区分空中与地面状态。
落地状态机
场景元素:障碍物、点电荷与强制位移
线段障碍物(barriers)
1public void setBarrier(float posTop, float posLeft, float width, boolean overCover) {
2 if (overCover)
3 barriers.add(0, new Vector3(posLeft, posTop, width));
4 else
5 barriers.add(new Vector3(posLeft, posTop, width));
6}Source: Plane.java
Vector3 的三个分量被复用为 (x=左端X, y=顶面Y, z=宽度)。overCover=true 时插入到链表头部——由于底部边界的选取按遍历优先级进行(源码中未展示的 borderBottom() 实现属于本次未读取的尾部 203 行),头插即"最高优先级",用于覆盖已有支撑面(例如用户指定的平台优先于系统检测到的窗口)。
点电荷(pointCharges)
public void setPointCharge(float posTop, float posLeft, float quantityProduct) {
pointCharges.add(new Vector3(posLeft, posTop, quantityProduct));
}Source: Plane.java
quantityProduct(C²)是"点电荷电量 × 物体电量"的乘积——库仑定律 F = k·q₁q₂/r² 中真正参与运算的正是这个乘积,因此引擎不单独维护物体电量,直接由调用方给出乘积,简化了参数面。
强制位移(拖拽)
1public void changePosition(float deltaTime, float x, float y) {
2 if (deltaTime > 0)
3 speed.set((x - position.x) / deltaTime, (y - position.y) / deltaTime);
4 position.set(x, y);
5 position.set(limitX(x), limitY(y));
6}Source: Plane.java
用户拖拽桌宠时,窗口层直接指定新坐标。deltaTime > 0 时用位移差分反推速度,使松手瞬间物体继承拖拽的惯性("甩出去"的效果);deltaTime = 0 则只改位置不污染速度。随后位置仍被 limitX/limitY 夹取,保证强制位移不会把物体推出可行走边界。
配置链路与窗口装配
Plane 的物理参数完全由配置驱动。字段定义在 ArkConfig 中:
1/** @since Ark Pets 2.2 */ @JSONField(defaultValue = "800.0")
2public float physic_gravity_acc;
3/** @since Ark Pets 2.2 */ @JSONField(defaultValue = "100.0")
4public float physic_air_friction_acc;Source: ArkConfig.java
窗口核心 ArkPets 在启动时完成注入:
plane = new Plane();
plane.setGravity(config.physic_gravity_acc);
plane.setResilience(0);Source: ArkPets.java
setResilience(0) 说明当前版本有意关闭边界反弹——桌宠撞到屏幕边缘时被直接贴住而非弹开,避免用户失去对桌宠位置的控制感。
桌面端"行为"设置面板提供实时调节滑块:
1SliderSetup<Integer> setupPhysicGravity = new SimpleMultipleIntegerSliderSetup(configPhysicGravity, 10);
2setupPhysicGravity
3 .setDisplay(configPhysicGravityValue, "%d px/s²", "像素每平方秒 (pixel/s²)")
4 .setRange(0, 2000)
5 ...
6 .setSliderValue(app.config.physic_gravity_acc)
7 .setOnChanged((observable, oldValue, newValue) -> {
8 ...
9 app.config.physic_gravity_acc = setupPhysicGravity.getValidatedValue();
10 app.config.save();Source: BehaviorModule.java
配置项一览
| 配置项 | 类型 | 默认值 | UI 范围 | 说明 |
|---|---|---|---|---|
physic_gravity_acc | float | 800.0 | 0–2000 | 重力加速度(px/s²),传入 Plane.setGravity() |
physic_air_friction_acc | float | 100.0 | —(同面板相邻滑块) | 空气摩擦加速度(px/s²),传入 Plane.setFrict() |
注:setFrict() 同时接收 airFrict 与 staticFrict 两个参数;本次已读源码片段中仅确认了 physic_air_friction_acc 的定义与 physic_gravity_acc 的滑块绑定,静摩擦参数的具体配置来源未在已读片段中出现(Plane.java 第 241 行之后的实现细节亦未读取)。
每帧物理流水线(核心时序)
该时序解释了三个设计选择:
- 速度先行、位移在后:
updatePosition()首行调用updateVelocity(),保证本帧速度决定本帧位移,物理表现与帧率解耦(半隐式欧拉积分,数值上比显式欧拉更稳定)。 getDropped()的读即清除语义:把"事件"压缩成单次布尔查询,窗口层无需注册监听器,也天然避免同一落地事件被重复消费。- 落地判定在夹取前完成:穿越检测依赖"预测位置被夹到地面"这一中间量,因此必须在最终
position.set(...)之前执行,顺序不可调换。
API Reference
setGravity(gravity: float): void
设置重力加速度。Parameters: gravity — 加速度(px/s²)。说明: 逐帧累加为 speed.y -= gravity * deltaTime;引擎内部多处用 Math.signum(gravity) 归一方向,因此该值支持任意符号。
setResilience(resilience: float): void
设置边界反弹系数。Parameters: resilience — 反弹后保留动能的比例,自动夹取到 [0,1](>1 取 1,<0 取 0)。
setFrict(airFrict: float, staticFrict: float): void
设置摩擦参数。Parameters: airFrict — 空气摩擦加速度(px/s²),无条件作用于两轴;staticFrict — 地面静摩擦加速度(px/s²),仅贴地时作用于水平轴。两者均经 Math.max(0, ·) 钳为非负。
setObjSize(objWidth: float, objHeight: float): void
设置物体尺寸(px)。影响顶部碰撞检测与点电荷力臂(物体几何中心 = position + obj/2)。
setSpeedLimit(x: float, y: float): void
设置各轴速度上限(px/s),0 表示不限。钳为非负后,超限速度按符号截断。
changePosition(deltaTime: float, x: float, y: float): void
强制设置位置。deltaTime > 0 时反推速度(惯性拖拽);deltaTime = 0 仅改位置。
updatePosition(deltaTime: float): void
每帧推进物理模拟:先速度后位移,处理落地穿越检测。
setBarrier(posTop: float, posLeft: float, width: float, overCover: boolean): void
添加可支撑物体的水平线段障碍;overCover=true 头插(最高优先级)。
setPointCharge(posTop: float, posLeft: float, quantityProduct: float): void
添加固定点电荷;quantityProduct 为电荷电量乘积(C²),决定斥/吸方向与强度。
getX(): float / getY(): float
返回物体位置。
getDropped(): boolean
一次性落地事件查询:内部复位标志与高度,仅当累积下坠高度 ≥ Const.droppedYThreshold 时返回 true。
getDropping(): boolean
实时查询是否处于空中(|position.y - borderBottom()| > droppedYThreshold)。
getDebugMsg(): String
输出多行调试信息:位置(含 dropping 标记)、速度、四向边界、world 区域列表、barriers 列表(Y = …, X range = (…,…))。
Failure Modes、边界情况与并发
基于已读源码可确认的边界行为:
- 摩擦零点保护:
applyFriction()以符号翻转检测(delta * estimated < 0)防止摩擦过量反推,杜绝低帧率抖动。 - 参数钳制:
setResilience()夹到[0,1];setFrict()/setSpeedLimit()钳为非负。配置文件写入异常值时物理引擎仍保持稳定。 - 静置稳定性:贴地时
updateVelocity()持续清零speed.y,物体不会因重力累积而"下沉"。 - 上边界保护:
position.y + obj.y >= top && speed.y > 0时清零垂直速度,防止物体被顶边持续"顶住"积累速度。 - 已知风险——点电荷零距离:
updateVelocity()中hypot未做零值保护,点电荷与物体几何中心重合时dx/hypot、dy/hypot将产生NaN并污染整个速度向量。调用方应避免把点电荷放在物体中心。 - 浮点相等比较:引擎大量使用
==比较浮点位置(如position.y == bottom)。由于边界值同样来自limitY()的钳制结果(同源浮点),这种比较在实际运行中成立;但外部若用任意浮点值设置位置(changePosition内部也会再过一次limitX/limitY),仍以钳制后的值为准,保持一致性。 - 并发性:
Plane未做任何同步。其设计假设是被单一渲染线程串行驱动;barriers/pointCharges为公开集合,若其他线程在帧间修改需自行保证可见性与安全发布。
Performance 与扩展点
- 零分配热路径:
obj/position/speed/speedLimit均为构造时创建的 finalVector2,帧内复用、原地修改,避免 GC 压力——这对 60 FPS 级别的桌面常驻进程尤为关键。 - 复杂度:每帧
updateVelocity()为O(pointCharges.size()),静电场是唯一随场景规模增长的项;barriers参与边界计算的复杂度取决于未读取的borderBottom()实现(本页不臆测)。 - 扩展点:新增场力只需在
updateVelocity()中按"重力 → 静电 → 摩擦 → 限速 → 反弹"的既有次序插入一段积分逻辑,并与既有清零/夹取条件协调;新增可支撑地形则通过setBarrier()即可注入,无需改动引擎。 - 可观测性:
getDebugMsg()提供了开箱即用的物理状态转储(位置/速度/边界/区域/障碍物),是排查"桌宠不落地/悬空"类问题的第一入口。
Related Links
- Plane.java — 平面物理引擎完整实现
- ArkConfig.java —
physic_*配置字段定义 - ArkPets.java — 窗口核心对
Plane的装配与驱动 - BehaviorModule.java — 物理参数调节 UI(滑块)