行为状态机与随机行为决策
Ark-Pets 桌面角色的"会自己动"来自两个紧密协作的类:抽象行为控制器 Behavior 与马尔可夫转移矩阵 StochasticMatrix。前者定义角色对外暴露的全部行为入口(自动待机、手动切换、走动、点击、拖拽、落地等),后者以带权重的随机转移矩阵决定"下一段自动播放什么动画",使角色行为看起来自然而不机械。
目的与范围
本页覆盖 core/src/cn/harryh/arkpets/animations 包中的行为决策核心:
Behavior(抽象类):行为控制器的统一门面,含自动动画的缓存决策机制与全部可覆写的交互钩子。StochasticMatrix:管理自动播放动画状态转移的随机(马尔可夫)矩阵,包括默认权重表、状态禁用、动画绑定与调试输出。StochasticState/StochasticMatrixRow:六态行为枚举(含环形遍历)与矩阵行记录类型(轮盘赌随机算法)。
以下内容有意留给兄弟页面,本页不展开:
- 动画剪辑与合成(
AnimClip、AnimClipGroup、AnimComposer、AnimData的内部实现)——本页仅将AnimData视为决策结果的数据载体。 - 模型资产加载与骨骼装配(
assets包)、桌面窗口与渲染流程、多进程通信(concurrent包)。 - 同包中的
GeneralBehavior是Behavior的具体实现(通用行为集),本页聚焦抽象决策机制,其内部绑定细节请直接参阅其源码。
概述
桌宠角色的行为分为两类,二者由同一个 Behavior 实例统一管理:
- 自动行为(随机决策):角色在无人干预时循环播放待机、坐下、睡眠、左右移动、特殊动作等动画。下一段播什么不是均匀随机,而是由一个 6×6 加权转移矩阵(马尔可夫链)决定——例如"睡眠"状态有 60% 倾向继续睡眠,且完全不会直接跳到移动;"特殊动作"永不自环,避免连续重复。这使行为序列呈现自然的节奏感。
- 交互行为(确定性钩子):走动(
walkAnim)、鼠标按下/抬起(clickStart/clickEnd)、拖拽(dragging)、抛下落地(dropped)、默认动画(defaultAnim)等,由子类覆写提供,基类默认返回null。
自动决策带缓存机制:结果 AnimData 会被缓存,缓存有效期取「动画片段时长」与 0.5 秒下限中的较大值,避免同一动画被瞬间重复决策、也避免每帧都掷骰子。
架构
各组件职责与设计意图:
| 组件 | 职责 | 设计意图 |
|---|---|---|
Behavior | 对外行为门面;装配自动决策缓存 | 把"随机决策"与"交互钩子"收敛到一个对象,调用方无需理解矩阵 |
Cached<AnimData>(actionAutoGetter) | 缓存自动动画决策结果 | 决策昂贵且不应每帧执行;有效期与动画时长对齐 |
StochasticMatrix | 持有权重行、禁用数组、动画绑定数组 | 把"状态图 + 概率 + 动画映射"集中在一处,可整体替换 |
StochasticMatrixRow | 单状态的出边权重 + 轮盘赌采样 | record 类型保证不可变长度;disabledRef 共享引用实现"一处禁用、全行生效" |
StochasticState | 六个行为状态 + 环形 next()/prev() | 支持用户手动逐态切换(右键/快捷键场景) |
类型关系(全部经源码验证):
核心流程
自动行为决策:马尔可夫转移 + 缓存
自动动画的决策入口是 Behavior 构造器中装配的 Cached 值生产器(value producer)。每当缓存过期,autoAnim() 才会真正触发一次矩阵采样:
1public Behavior() {
2 actionAutoGetter = new Cached<>();
3 actionAutoGetter.setValueProducer(() -> {
4 StochasticState newState = currentMatrix.transitedAnimOf(currentState);
5 if (newState == null) {
6 return currentMatrix.getStateAnim(currentState);
7 } else {
8 currentState = newState;
9 return currentMatrix.getStateAnim(newState);
10 }
11 });
12 actionAutoGetter.setCacheAgeProducer(() -> {
13 AnimData cache = actionAutoGetter.getCachedValue();
14 return cache == null ? minAnimCacheAge : Math.max(minAnimCacheAge, cache.animClip().duration);
15 });
16 currentMatrix = null;
17 currentState = null;
18}Source: Behavior.java
逐行解读:
transitedAnimOf(currentState)调用当前状态对应矩阵行的random(),按权重掷骰子得到新状态。- 若采样返回
null(所有状态都被禁用导致random()走到末尾),则保持当前状态并返回其绑定动画——这是优雅降级,而不是抛异常。 - 否则更新
currentState并返回新状态的绑定动画,状态机就此"前进一步"。 - 缓存年龄由上一次缓存的动画时长决定(不小于 0.5 秒
minAnimCacheAge),意味着"播放完这段再决定下一段",天然避免抖动。
轮盘赌采样:StochasticMatrixRow.random()
1public StochasticState random() {
2 int sum = IntStream.range(0, weights.length).filter(i -> !disabledRef[i]).map(i -> weights[i]).sum();
3 int rnd = (int) (Math.random() * sum);
4 int acc = 0;
5 for (int i = 0; i < weights.length; i++) {
6 if (disabledRef[i])
7 continue;
8 acc += weights[i];
9 if (rnd < acc)
10 return StochasticState.values()[i];
11 }
12 return null;
13}Source: StochasticMatrix.java
算法是标准轮盘赌选择:先对未被禁用状态的权重求和 sum,在 [0, sum) 内取随机数,再顺序累加权重命中第一个超过随机数的状态。被禁用的状态权重直接跳过,等价于从状态空间中移除该列。当 sum == 0(全部禁用或权重全为 0)时 Math.random() * 0 == 0,循环不会命中任何分支,返回 null,由 Behavior 的降级逻辑兜底。
手动逐态切换:nextAnim() / prevAnim()
1public final AnimData nextAnim() {
2 if (currentMatrix.isAllDisabled()) return null;
3 AnimData newAnim = currentMatrix.nextAnimOf(currentState);
4 currentState = currentState.next();
5 return newAnim;
6}Source: Behavior.java
手动切换不走概率,而是沿 StochasticState 的环形序(IDLE → SIT → SLEEP → MOVE_L → MOVE_R → SPECIAL → IDLE)前进/后退。nextAnimOf 会跳过被禁用的状态:
1public AnimData nextAnimOf(StochasticState state) {
2 StochasticState newState = state;
3 for (int i = 0; i < StochasticState.values().length; i++) {
4 newState = newState.next();
5 if (!disabled[newState.ordinal()])
6 return binds[newState.ordinal()];
7 }
8 return binds[state.ordinal()];
9}Source: StochasticMatrix.java
循环至多走满一圈(values().length 次);若一圈内全是禁用状态,回落到当前状态自身的绑定动画,保证调用者永远拿到非空结果(除非 binds 里未绑定,见失败模式)。
状态生命周期时序
默认权重矩阵
StochasticMatrix.DEFAULT_WEIGHTS 是 6×6 转移权重表,行是当前状态、列是目标状态(IDLE, SIT, SLEEP, MOVE_L, MOVE_R, SPECIAL):
1public static int[][] DEFAULT_WEIGHTS = new int[][]{
2 // IDLE SIT SLEEP MOVE_L MOVE_R SPECIAL
3 {40, 20, 10, 10, 10, 10}, // IDLE -> ?
4 {30, 40, 20, 10, 10, 10}, // SIT -> ?
5 {20, 20, 60, 0, 0, 0}, // SLEEP -> ?
6 {40, 10, 0, 20, 20, 10}, // MOVE_L -> ?
7 {40, 10, 0, 20, 20, 10}, // MOVE_R -> ?
8 {50, 20, 10, 10, 10, 0} // SPECIAL -> ?
9};Source: StochasticMatrix.java
权重语义与设计意图:
| 当前状态 | 主要去向(权重最高) | 关键约束(权重为 0) | 行为效果 |
|---|---|---|---|
| IDLE | 回到 IDLE (40) | 无 | 待机最常见,是"稳态锚点" |
| SIT | 回到 SIT (40),转 IDLE (30) | 无 | 坐姿短暂持续后回到待机 |
| SLEEP | 继续睡 (60) | → MOVE_L / MOVE_R 均为 0 | 睡觉不会"秒起就走",醒来必经 IDLE/SIT 过渡 |
| MOVE_L | 转 IDLE (40) | → SLEEP 为 0 | 移动后不会直接入睡 |
| MOVE_R | 转 IDLE (40) | → SLEEP 为 0 | 与 MOVE_L 对称,左右移动等价 |
| SPECIAL | 转 IDLE (50) | → SPECIAL 为 0(不自环) | 特殊动作绝不连续播放两次 |
注意 MOVE_L → MOVE_L 与 MOVE_L → MOVE_R 各为 20:角色移动态有概率"再多走一段"或"折返",配合 MOVE_L/MOVE_R 行整体对称,保证左右行为无偏。这组默认值体现了马尔可夫链建模"自然行为节奏"的意图:长驻状态(睡眠)有惯性,过渡态(移动、特殊)快速回归锚点(IDLE)。
使用示例
构造矩阵、绑定动画、禁用状态
典型的具体行为实现(如 GeneralBehavior)会以默认权重建矩阵,按模型实际拥有的动画绑定各状态,并禁用模型缺失的状态:
1public StochasticMatrix(int[][] weights) {
2 if (weights.length != StochasticState.values().length)
3 throw new IllegalArgumentException("Weights length mismatch");
4 this.weights = new StochasticMatrixRow[weights.length];
5 this.disabled = new boolean[StochasticState.values().length];
6 this.binds = new AnimData[StochasticState.values().length];
7 for (int i = 0; i < weights.length; i++)
8 this.weights[i] = new StochasticMatrixRow(weights[i], this.disabled);
9}
10
11public void bind(StochasticState state, AnimData anim) {
12 binds[state.ordinal()] = anim;
13}
14
15public void disable(StochasticState state) {
16 disabled[state.ordinal()] = true;
17}Source: StochasticMatrix.java
关键点:构造器为每一行传入同一个 this.disabled 引用(disabledRef),因此对任一状态调用 disable() 会立即影响所有行的采样与遍历——一次禁用全局生效,无需遍历更新各行。
交互钩子的默认契约
Behavior 基类对所有交互钩子返回 null,子类按需覆写;调用方必须接受"该行为不可用"的语义:
1/** Gets the walk animation.
2 * @param mobility 1=GoRight, -1=GoLeft.
3 * @return Animation data, or {@code null} if not available.
4 */
5public AnimData walkAnim(int mobility) {
6 return null;
7}
8
9/** Gets the animation when the user starts dragging.
10 * @return Animation data, or {@code null} if not available.
11 */
12public AnimData dragging() {
13 return null;
14}Source: Behavior.java
调试矩阵输出
调试用叠加层(getDebugMatrix)把禁用状态编码为负数权重,便于 UI 上直观区分:
1public int[][] getDebugMatrix() {
2 int[][] matrix = new int[StochasticState.values().length][StochasticState.values().length];
3 for (int i = 0; i < weights.length; i++) {
4 StochasticMatrixRow row = weights[i];
5 for (int j = 0; j < row.weights.length; j++) {
6 if (row.disabledRef[j]) matrix[i][j] = -row.weights[j];
7 else matrix[i][j] = row.weights[j];
8 }
9 }
10 return matrix;
11}Source: StochasticMatrix.java
API 参考
Behavior(抽象类)
| 方法 | 签名 | 说明 |
|---|---|---|
isAutoAnimExpired | public final boolean isAutoAnimExpired() | 自动动画缓存是否过期/为空;调用方用它决定是否刷新 |
autoAnim | public final AnimData autoAnim() | 获取随机自动动画(带缓存);内部驱动状态转移 |
nextAnim | public final AnimData nextAnim() | 环形序下一状态的动画,同时推进 currentState;全禁用时返回 null |
prevAnim | public final AnimData prevAnim() | 环形序上一状态的动画,同时回退 currentState;全禁用时返回 null |
defaultAnim | public AnimData defaultAnim() | 默认动画钩子,基类返回 null |
walkAnim | public AnimData walkAnim(int mobility) | 走动动画,mobility 取 1(向右)/ -1(向左);基类返回 null |
clickStart / clickEnd | public AnimData clickStart() / clickEnd() | 鼠标按下/抬起动画;基类返回 null |
dragging / dropped | public AnimData dragging() / dropped() | 拖拽/抛下落地动画;基类返回 null |
getDebugMatrix | public int[][] getDebugMatrix() | 返回调试权重矩阵(禁用列为负值) |
getCurrentMatrixState | public StochasticState getCurrentMatrixState() | 当前所处状态 |
StochasticMatrix
| 方法 | 签名 | 异常 |
|---|---|---|
| 构造器 | public StochasticMatrix(int[][] weights) | 行数不等于状态数时抛 IllegalArgumentException("Weights length mismatch") |
transitedAnimOf | public StochasticState transitedAnimOf(StochasticState state) | — |
nextAnimOf / prevAnimOf | public AnimData nextAnimOf(StochasticState state) | — |
getStateAnim | public AnimData getStateAnim(StochasticState state) | — |
bind | public void bind(StochasticState state, AnimData anim) | — |
scale | public void scale(StochasticState state, float factor) | factor < 0 时抛 IllegalArgumentException("Scale factor cannot be positive") |
disable | public void disable(StochasticState state) | — |
isAllDisabled | public boolean isAllDisabled() | — |
getDebugMatrix | public int[][] getDebugMatrix() | — |
Source: StochasticMatrix.java
失败模式、边界与并发
- 全禁用降级:
StochasticMatrixRow.random()在所有可用权重和为 0 时返回null;Behavior的自动决策生产器据此保持原状态继续播放当前动画,不抛异常。nextAnim()/prevAnim()则在isAllDisabled()为真时直接返回null,由调用方判断。 - 未绑定的状态:
binds[state.ordinal()]若从未bind,getStateAnim会返回null。矩阵本身不校验"启用但未绑定",正确用法是:模型缺少某状态动画时应同时调用disable()(否则采样可能命中一个没有动画的状态)。 scale的舍入:scale使用Math.round,factor < 1多次缩放可能把权重缩到 0,等效于隐式禁用该出边;且源码中的校验文案为"Scale factor cannot be positive"(实际拒绝的是负数),属于历史命名瑕疵。StochasticMatrixRow长度校验:record 的紧凑构造器要求weights.length必须等于状态数,否则IllegalArgumentException("Weights length mismatch"),防止矩阵形状错误静默错位。- 空指针窗口:
Behavior构造后currentMatrix/currentState均为null;子类必须在首次autoAnim()/nextAnim()前完成赋值,否则会 NPE(构造器仅装配生产器,不触发取值)。 - 并发性:
StochasticMatrix与Behavior均未加锁。weights数组是可变的(scale直接改写)、disabled/binds也是共享可变数组。这些对象假定在**单一线程(JavaFX 渲染线程)**内创建和使用,跨线程并发调用采样与禁用没有同步保证。 - 缓存下限 0.5 秒:
minAnimCacheAge防止极短动画导致决策频率过高;首次取值前缓存为空时同样使用 0.5 秒兜底。
扩展点
- 新增行为子类:继承
Behavior,在初始化时设置currentMatrix(可用DEFAULT_WEIGHTS起步)与起始currentState,按模型动画资产逐个bind,缺哪个就disable哪个;再按需覆写defaultAnim、walkAnim、clickStart/End、dragging、dropped。GeneralBehavior即此模式的参考实现。 - 自定义行为概率:向
StochasticMatrix构造器传入自定义int[][]权重即可改变性格(如"多动"角色提高 MOVE 权重、"嗜睡"角色提高 SLEEP 权重);scale可对单行整体缩放微调,配合disable完全剔除状态。 - 调试可视化:
Behavior.getDebugMatrix()+getCurrentMatrixState()提供了实现调试 HUD 所需的全部数据(负值列即禁用项)。
相关链接
- Behavior.java — 行为抽象基类与自动决策缓存
- StochasticMatrix.java — 马尔可夫转移矩阵、状态枚举与轮盘赌采样
- GeneralBehavior.java — 通用行为实现(兄弟页面素材)
- 动画剪辑与合成机制(
AnimClip、AnimClipGroup、AnimComposer、AnimData)见同层级动画页