# 神岛模型动画状态机 **Repository Path**: ifishcool/dao3modelfsm ## Basic Information - **Project Name**: 神岛模型动画状态机 - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-09-17 - **Last Updated**: 2025-09-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # @dao3fun/fsm — 多实体共享配置的模型动画轻量有限状态机 本 FSM 可视化编辑器: https://box3lab.com/tools/fsm-editor/ 使用说明在最底下~ 用一张“简单、类型安全”的表,统一描述状态变化;在生命周期里自动注入安全的动画工具,降低出错概率,让多人协作有清晰边界。 - 避免状态分散:逻辑散落在多个脚本和 if-else。 - 事件统一:同语义多名字,重构成本高。 - 动画脆弱:初始化时序、片段缺失易崩。 - 多实体复用难:复制-粘贴导致分叉和倾斜。 ## 为什么需要状态机(Before → After) 在实际项目中,我们常见到以下现象: - 同一个实体的行为被写在多个脚本里,靠事件回调“互相调用”,流程不透明。 - 事件命名不统一,`startFetch/tryLoad/doLoad` 表达同一语义,换人维护代价大。 - 动画播放分散在各处,稍有顺序/资源问题就容易崩或卡住。 - 复制一份逻辑给另一个实体,随着时间推移产生分叉,回归困难。 your-fsm 的目标是把“状态变化”收敛到一张表: - 用 `MachineConfig` 明确“当前状态对哪些事件如何响应”,让迁移路径一目了然; - 用生命周期钩子 `onEnter/onExit/onUpdate` 集中管理动画与持续逻辑; - 同一份配置可复用到 N 个实体,既统一又易于替换; - 订阅与全局回调 `subscribe/onTransition` 方便做日志与埋点。 对比示例 - ❌ Before(分散的 if-else + 回调 + 重试/时序/命名不一致): ```ts // 逻辑散落在不同文件/模块中(简化在一处展示) let isBusy = false; let tries = 0; const MAX_TRY = 3; // 各处都在直接操作动画,名称也不统一 function playSpin() { motion.play?.('旋转'); } function stopSpin() { motion.stop?.('旋转'); } function playStart() { motion.play?.('启动'); } function playOk() { motion.play?.('成功'); } function playFail() { motion.play?.('失败'); } // 不同事件命名表达同一语义:startFetch / tryLoad / doLoad function startFetch() { if (isBusy || entity.status === 'loading') return; isBusy = true; entity.status = 'loading'; // 有时先播放“启动”,有时直接“旋转”,时序不一致 if (Math.random() > 0.5) playStart(); playSpin(); // 多处复制的重试逻辑 fetchData() .then((res) => { if (!res.ok) throw new Error('bad'); stopSpin(); playOk(); isBusy = false; entity.status = 'success'; // 另一个监听器里也会把 status 改回 idle,产生竞争条件 }) .catch((err) => { console.warn('fetch error', err); stopSpin(); playFail(); tries++; if (tries < MAX_TRY) { // 分散的退避策略(可能还有 setTimeout 嵌套) setTimeout( () => { // 命名不一致:这里叫 tryLoad tryLoad(); }, 500 + tries * 200 ); } else { isBusy = false; entity.status = 'failure'; } }); } function tryLoad() { // 另一路也会播动画,造成重复/中断 playSpin(); doLoad() .then(() => { stopSpin(); playOk(); isBusy = false; entity.status = 'success'; }) .catch(() => { stopSpin(); playFail(); // 又一层 setTimeout 再试,逻辑分叉增多 setTimeout(() => startFetch(), 1000); }); } // UI/输入层又来一套 if-else,重复判断与动画 button.on('click', () => { if (entity.status === 'idle' || !isBusy) { startFetch(); } else if (entity.status === 'failure') { playStart(); startFetch(); } else { // 正在 loading 时,某些情况下也允许再次触发,导致并发 if (Math.random() < 0.2) startFetch(); } }); // 另一个系统监听网络变化,直接改状态,未与上面流程对齐 network.on('online', () => { if (entity.status === 'failure') { entity.status = 'idle'; // 没停动画,可能造成“失败动画”与“启动动画”交错 playStart(); } }); ``` - ✅ After(统一事件命名 + 配置集中 + 动画在生命周期 + 重试与外部输入事件都通过 send): ```ts type S = 'idle' | 'loading' | 'success' | 'failure'; type E = 'FETCH' | 'RESOLVE' | 'REJECT' | 'RESET'; // 在实体上记录重试次数,集中处理退避 declare const entity: GameEntity & { tries?: number }; const MAX_TRY = 3; const config: MachineConfig = { initial: 'idle', states: { // 空闲:统一从此处发起 FETCH idle: { on: { FETCH: 'loading' }, }, // 加载:只负责“转圈圈 + 等待结果事件”,不自行调接口 loading: { on: { RESOLVE: 'success', REJECT: 'failure' }, onEnter: (_, motion) => motion.loadByName([{ name: '旋转', iterations: Infinity }]).play(), onExit: (_, motion) => motion.pause?.(), }, // 成功:进入即播成功动画 success: { onEnter: (_, motion) => motion.loadByName('成功').play() }, // 失败:集中处理“失败动画 + 退避重试 or 归零等待外部 RESET/ONLINE” failure: { on: { // 允许用户/系统重置到 idle(对应 Before 里 network online 直接改状态) RESET: 'idle', // 也允许直接再次尝试(按钮点一次就再试一次) FETCH: { target: 'loading', guard: (e) => (e.tries ?? 0) < MAX_TRY, action: (e) => { e.tries = (e.tries ?? 0) + 1; // 记录重试次数 }, }, }, onEnter: (e, motion) => { motion.loadByName('失败').play(); // 集中退避策略:如果还有额度,延迟自动再发起 FETCH(替代 Before 里分散的 setTimeout) if ((e.tries ?? 0) < MAX_TRY) { const backoff = 500 + (e.tries ?? 0) * 200; setTimeout(() => fsm.send(e, 'FETCH'), backoff); } }, }, }, }; // 创建状态机实例 const fsm = new StateMachine(config); // 注册实体 const entity = world.querySelector('#海盗船-1'); if (entity) { // 注册实体 fsm.register(entity); // 统一的 UI 入口:按钮点击仅发送 FETCH,不直接碰动画/状态 button.on('click', () => fsm.send(entity, 'FETCH')); // 统一的系统入口:网络恢复只发送 RESET,不直接改状态/动画 network.on('online', () => fsm.send(entity, 'RESET')); } ``` 收益: - 迁移规则集中、可视;动画在对应状态的生命周期中,减少时序错误; - 更易复用,多实体共享一份配置; - 可观测、可埋点,问题定位快; - 类型安全让错误更早暴露。 ## 安装(Install) ```bash npm i @dao3fun/fsm ``` 导出:`StateMachine`、类型(`MachineConfig/StateConfig/...`)。 ## 上手(Getting Started) ```ts import { StateMachine, type MachineConfig } from '@dao3fun/fsm'; // 定义状态和事件 type S = 'idle' | 'loading' | 'success' | 'failure'; type E = 'FETCH' | 'RESOLVE' | 'REJECT'; // 定义状态机配置 const config: MachineConfig = { // 初始状态 initial: 'idle', // 状态配置 states: { // 空闲状态 idle: { // 事件迁移表 // - FETCH -> loading on: { FETCH: 'loading' }, // 进入状态时触发 onEnter: (_, motion) => { // 与实际项目中保持一致:按名称加载动画并播放 motion.loadByName('启动').play(); }, onExit: () => { // 可选:做收尾或日志 }, onUpdate: () => { // 可选:帧更新逻辑 }, }, // 加载状态 loading: { // 事件迁移表: // - RESOLVE -> success // - REJECT -> failure on: { RESOLVE: 'success', REJECT: 'failure' }, // 进入状态时触发 onEnter: (_, motion) => { // 加载并播放动画,无限循环 motion.loadByName([{ name: '旋转', iterations: Infinity }]).play(); }, }, // 成功状态 success: {}, // 失败状态 failure: {}, }, }; // 创建状态机实例 const fsm = new StateMachine(config, { // 状态迁移时触发 onTransition: (entity, next, prev, event) => { console.log('[onTransition]', { prev, next, event }); }, }); // 注册实体 const entity = world.querySelector('#海盗船-1'); if (entity) { // 初始状态为 idle fsm.register(entity); // 3s后发送 FETCH 事件 setTimeout(() => fsm.send(entity, 'FETCH'), 3000); } ``` 执行以上代码后,实体将3s后从 idle 状态迁移至 loading,播放旋转动画。 ### 帧驱动 ```ts setInterval(() => fsm.update(entity, 0.016), 16); // dt 按秒 ``` 执行以上代码后,实体将每帧更新一次,onUpdate 钩子会被调用。 ## 配置入门(Step by Step) > 用一个最小可跑的例子,逐步加到“动画 + 守卫/动作 + 帧驱动 + 订阅”。 1. 定义状态与事件(S/E) ```ts type S = 'idle' | 'loading' | 'success' | 'failure'; type E = 'FETCH' | 'RESOLVE' | 'REJECT'; ``` 2. 写出最小配置(只有 initial 与一个事件迁移) ```ts const config: MachineConfig = { initial: 'idle', states: { idle: { on: { FETCH: 'loading' } }, loading: {}, success: {}, failure: {}, }, }; ``` 3. 给进入状态加动画(onEnter 自动注入 motion) ```ts const config: MachineConfig = { initial: 'idle', states: { idle: { on: { FETCH: 'loading' }, onEnter: (_, motion) => motion.loadByName('启动').play(), }, loading: { on: { RESOLVE: 'success', REJECT: 'failure' }, onEnter: (_, motion) => motion.loadByName([{ name: '旋转', iterations: Infinity }]).play(), }, success: {}, failure: {}, }, }; ``` 4. 给事件加“守卫/动作”(需要时再用完整写法) ```ts idle: { on: { FETCH: { target: 'loading', guard: (entity) => entity.canFetch === true, // 不满足则不跳转 action: async (entity) => entity.log?.('enter loading'), // 切换前执行 }, }, }, ``` 5. 驱动与订阅(让它真实跑起来) ```ts const fsm = new StateMachine(config, { onTransition: (entity, next, prev, event) => { console.log('[onTransition]', { prev, next, event }); }, }); const entity = world.querySelector('#海盗船-1') as GameEntity; fsm.register(entity); fsm.send(entity, 'FETCH'); // 触发一次迁移 // 帧驱动(dt=秒) setInterval(() => fsm.update(entity, 0.016), 16); ``` 6. 扩展 onUpdate(做计时/渐变/轮询) ```ts loading: { on: { RESOLVE: 'success', REJECT: 'failure' }, onUpdate: (entity, motion, current, dt) => { entity.timer = (entity.timer ?? 0) + dt; if (entity.timer > 2) { // 两秒后自动成功 fsm.send(entity, 'RESOLVE'); entity.timer = 0; } }, }, ``` 常见问题(Troubleshooting) - 动画不播放? - 确认 `onEnter` 里使用的是 `motion.loadByName(...).play()`,名称与资源一致; - 若在首帧触发,关注引擎侧“资源是否已就绪/可见”。 - 事件发送无效? - 当前状态下是否存在该事件的迁移; - `guard` 是否返回了 `false`。 - `onUpdate` 未执行? - 是否调用了 `update(entity, dt)`;`dt` 单位为秒。 ## 核心概念(State / Event / Transition 等) > 概念清晰,配起来就不抽象。 - **State(状态)** - 描述实体在某一时刻所处的“行为阶段”,如 `idle`(空闲)、`loading`(加载)、`success`、`failure`。 - 在 `MachineConfig.states` 中逐个定义,每个状态可配置事件表 `on` 和生命周期钩子 `onEnter/onExit/onUpdate`。 - 命名建议:短小、动名词或形容状态的形容词,统一小写,例如 `idle`、`running`、`dead`。 - **Event(事件)** - 触发状态迁移的“信号”。你通过 `fsm.send(entity, 'FETCH')` 发送事件,驱动状态图流转。 - 命名建议:统一风格,常用全大写动词/动宾,如 `FETCH`、`RESOLVE`、`REJECT`、`DIE`、`RESPAWN`。 - 一个事件只有在“当前状态的 `on` 中有定义”时才生效;否则被忽略(或由 guard 返回 false)。 - **Transition(迁移)** - 从“当前状态”响应某个事件跳到“目标状态”。 - 配置写在 `on` 表里: - 简写:`on: { EVENT: 'target' }` - 完整:`on: { EVENT: { target, guard?, action? } }` - **Guard(守卫)** - 类型:`(entity, event) => boolean`。 - 用来判定此次事件是否允许跳转,比如“冷却未结束”“血量不足”。返回 `false` 则不发生迁移。 - 建议把“条件判断”集中到 guard,避免在业务处处 if-else。 - **Action(迁移动作)** - 在“状态切换之前”执行(发生在 `onExit` 之前),可异步;内部已 try/catch,失败不会阻断后续流程。 - 常见用途:上报埋点、预取资源、写入日志等“与切换紧密相关但非动画控制”的动作。 - **onEnter / onExit(生命周期)** - `onEnter(entity, motion, prev, next)`: 进入新状态时调用,常用于加载并播放动画、复位变量; - `onExit(entity, motion, prev, next)`: 离开旧状态时调用,常用于停止动画、释放资源。 - 动画控制通过 `motion` 提供的 API 进行,与你在示例中的 `motion.loadByName(...).play()` 一致。 - **onUpdate(帧更新)** - `onUpdate(entity, motion, current, dt)` 由 `fsm.update(entity, dt)` 驱动,`dt` 单位为“秒”。 - 适合做:时间累计、缓动/渐变、轮询检查、自动超时切换等。 - **Entity(实体)** - 运行中被状态机管理的对象(如游戏中的一个 NPC/模型)。 - 同一个 `StateMachine` 可以注册多个实体,每个实体维护自己的当前状态,但共用同一份配置。 - **Motion(动画控制器)** - 在生命周期钩子里自动注入,用于安全地播放/暂停动画片段: - 单个名称:`motion.loadByName('启动').play()` - 批量配置:`motion.loadByName([{ name: '旋转', iterations: Infinity }]).play()` - 建议把“进入某状态该放哪段动画”放在对应状态的 `onEnter`,离开时在 `onExit` 停止或切换。 - **Subscription / onTransition(订阅/全局回调)** - `fsm.subscribe((entity, next, prev) => { ... })`:用于调试输出或统一埋点;返回 off 函数用于取消订阅。 - 构造器里的 `onTransition` 也会在迁移后触发,位置在 listeners 通知之后。 - **时序(再强调一次)** 1. action(若有) 2. onExit(旧状态) 3. 切换当前状态 4. onEnter(新状态) 5. 通知订阅者(subscribe) 6. 触发 options.onTransition(若提供) 命名与建模建议 - 状态是“阶段”,事件是“触发器”。避免状态名中出现“事件语义”,也避免事件名中出现“目标状态”。 - 保持 S/E 为“字符串字面量联合”,让编辑器在配置里获得最强的类型提示与校验。 - 尽量用“简写迁移”,需要条件或副作用时再切换到“完整写法”。 反例(Anti-pattern) - 在业务处四处 `if (current === 'x') { ... }` 决策,而不是把跳转规则放进 `on`; - 在 `onEnter` 里做复杂的条件判断,本该是 guard 的逻辑; - 在 `onUpdate` 里频繁地直接操纵外部全局状态,导致难以维护和测试。 ## 配置详解(MachineConfig/StateConfig/TransitionConfig) 以下解释基于源码类型定义 `server/src/lib/types.ts`: ```ts /** * 迁移动作:在状态切换前执行(发生在 onExit 之前)。 * - 可异步;失败不会阻止后续 onExit/onEnter(内部已 try/catch)。 */ export type TransitionAction = ( entity: GameEntity, event: E ) => void | Promise; /** * 迁移守卫:返回 true 才允许从当前状态按该事件跳转。 * - 典型用途:冷却、资源条件、死亡保护等。 */ export type TransitionGuard = (entity: GameEntity, event: E) => boolean; /** * 完整迁移配置(当事件需要 guard/action 时使用)。 * 否则可使用简写:on: { EVENT: 'targetState' } */ export interface TransitionConfig { /** 目标状态 */ target: S; /** 可选守卫:返回 true 才允许跳转 */ guard?: TransitionGuard; /** 可选动作:在状态实际切换前执行 */ action?: TransitionAction; } /** * 状态生命周期钩子:onEnter/onExit。 * - prev/next:用于区分是初次进入(prev===next===initial)还是普通迁移。 */ export type StateHook = ( /** 实体 */ entity: GameEntity, /** 动画控制 */ motion: GameMotionController, /** 上一状态 */ prev: S, /** 新状态 */ next: S ) => void | Promise; /** * 单个状态的配置。 */ export interface StateConfig { /** 事件迁移表 */ on?: Partial | S>>; /** 进入状态时的回调 */ onEnter?: StateHook; /** 离开状态时的回调 */ onExit?: StateHook; /** 每帧更新时的回调 */ onUpdate?: ( /** 实体 */ entity: GameEntity, /** 动画控制 */ motion: GameMotionController, /** 当前状态 */ current: S, /** 时差 */ deltaTime: number ) => void | Promise; // 显式终止态(可选)。若不写 on 或 on 为空也会被视为“自然终止态”。 final?: boolean; } /** * 整个状态机的配置对象。 */ export interface MachineConfig { /** 初始状态 */ initial: S; /** 各状态的事件表与生命周期钩子 */ states: Record>; } /** * 单实例状态机的订阅函数签名(当前仓库仅使用了多实体版本)。 */ export type TransitionListener = ( /** 下一状态 */ next: S, /** 上一状态 */ prev: S ) => void; /** * 多实体状态机的订阅函数签名:实体、下一状态、上一状态。 */ export type MultiTransitionListener = ( /** 实体 */ entity: GameEntity, /** 下一状态 */ next: S, /** 上一状态 */ prev: S ) => void; /** * 额外可选项:全局迁移回调等(可不传)。 */ export interface MachineOptions { /** 发生迁移后触发(在 onEnter/onExit 执行完毕、listeners 通知之后) */ onTransition?: ( /** 实体 */ entity: GameEntity, /** 下一状态 */ next: S, /** 上一状态 */ prev: S, /** 触发迁移的事件 */ event: E ) => void | Promise; } ``` - initial(必填) - 初始状态,必须是 `S` 联合类型中的一个。 - states(必填) - 每个状态名对应一个 `StateConfig`;在该状态下定义可响应的事件、生命周期钩子。 - on(可选) - 事件迁移表。两种写法: - 简写:`on: { EVENT: 'targetState' }` - 完整:`on: { EVENT: { target: 'targetState', guard?, action? } }` - guard:返回 true 才允许跳转(冷却/条件检查等)。 - action:在状态切换前执行(发生在 onExit 之前),可异步。 - onEnter(entity, motion, prev, next)(可选) - 进入状态时触发,常用于加载并播放动画、初始化变量、打印日志等。 - 示例:`motion.loadByName('启动').play()` 或批量 `{ name, iterations }`。 - onExit(entity, motion, prev, next)(可选) - 离开状态时触发,常用于停止动画、资源清理等。 - onUpdate(entity, motion, current, deltaTime)(可选) - 每帧由 `update(entity, dt)` 驱动触发,`dt` 单位为秒。 - 适合做计时、渐变、轮询检查等连续性逻辑。 - 终止态(final / 自然终止) - 当 `final: true` 或该状态 `on` 未定义/为空时,视为终止态; - 终止态将忽略 `send` 事件且不会再执行 `onUpdate`; - 建议在该状态的 `onEnter` 中做资源回收(停动画、清理计时器、取消订阅等)。 示例: ```ts type S = 'idle' | 'done' | 'error'; type E = 'START' | 'FAIL'; const cfg: MachineConfig = { initial: 'idle', states: { idle: { on: { START: 'done' } }, done: { final: true, onEnter: (_, m) => m.loadByName('完成').play() }, error: {}, // 自然终止态(无 on) }, }; ``` ### 生命周期执行顺序(一次合法迁移) 1. action(若存在,发生在切换之前) 2. onExit(旧状态) 3. 切换当前状态 4. onEnter(新状态) 5. 通知订阅者(`subscribe`) 6. 触发构造参数中的 `onTransition`(若提供) # FSM 可视化编辑器 - 使用说明 本工具用于可视化编辑有限状态机(FSM),支持直接导入/导出与代码一致的 MachineConfig 配置,所见即所得地维护状态、事件、迁移与生命周期函数预览。 ## 快速上手 1. 在画布空白处右键 → 新增状态。 2. 右键某状态 → 新增迁移 → 选择源/事件/目标。 - 如需新事件,事件下拉选择“自定义事件…”,在出现的输入框填写事件名。 3. 右键状态底部可直接编辑 onEnter/onExit/onUpdate,自动保存。 4. 点击“复制 TS 代码”按钮,可生成 TypeScript 配置预览,或下载 TS 文件。 ## 顶部操作区 - 按钮 - `导出 JSON`:导出为 MachineConfig 结构的 `fsm-config.json`(含 `initial` 与 `states`)。 - `导入 JSON`:从文件选择器导入 JSON。支持两种格式: - MachineConfig JSON(推荐)。 - 兼容旧版“内部模型 JSON”(含 `states/events/transitions/lifecycle`)。 - `粘贴 Config`:弹出对话框,支持粘贴整段 TypeScript/JavaScript 代码或对象字面量: - 可粘贴完整的 `const config: MachineConfig = { ... }` 代码片段(包含注释与类型也可)。 - 也可直接粘贴对象字面量或 JSON(例如 `{ initial: 'idle', states: {...} }`)。 - 导入后自动重新生成图与列表,并自动保存。 - `下载 TS 文件`:生成 TypeScript 配置预览,可下载保存。 - `清空`:清空当前模型(localStorage 也会被清空)。 ## 画布与右键菜单 - 右键画布空白:仅包含“新增状态”。 - 右键状态节点: - `重命名`、`新增迁移`、`设为初始`、`删除`(已移除“新增事件”,在“新增迁移”里可选“自定义事件…”)。 - 菜单底部可直接编辑该状态的生命周期:`onEnter/onExit/onUpdate`。 - 支持自动保存:失焦、关闭菜单或 Cmd/Ctrl+Enter 即保存。 - 保存后会自动更新 TS 预览与本地存储。 - 右键事件箭头(边): - `重命名事件`:仅修改该条迁移的事件名。 - `反转方向`:将 from/to 对调。 - `删除迁移`:仅删除该条边。 > 生命周期函数在编辑器内以字符串形式保存与展示,方便预览与导出 MachineConfig;真正的代码落地建议在你的工程代码中实现与维护。 ## 列表区 - 左侧“状态列表”: - 展示所有状态,可点击高亮并在图中定位。 - 支持“重命名”“删除”。 - 中部“事件列表”(迁移列表): - 展示每一条迁移(形如 `EVENT from -> to`)。 - 支持“选中”“重命名(该条迁移的事件名)”“删除(该条迁移)”。 ## 导入/导出的格式 - MachineConfig JSON(推荐) ```json { "initial": "idle", "states": { "idle": { "on": { "FETCH": "loading" }, "onEnter": "(_, motion) => { /* 建议在工程代码中维护 */ }", "onExit": "() => {}", "onUpdate": "() => {}" }, "loading": { "on": { "RESOLVE": "success", "REJECT": "failure" }, "onEnter": "(_, motion) => {}" }, "success": {}, "failure": {} } } ``` - 粘贴 Config(对象字面量/整段 TS) - 可粘贴: - 完整 TS 片段(例如含 `type S/E`、`const config: MachineConfig = { ... }`、注释、函数)。 - 只包含 `{ initial, states }` 的对象字面量,允许函数值(会以字符串保存)。 ## 自动保存 - 每次编辑(增删改状态/事件/迁移,或修改生命周期文本)后,都会自动保存到浏览器 `localStorage`。 - 生命周期编辑支持失焦/关闭菜单自动保存,Cmd/Ctrl+Enter 快捷保存。 - 下次打开页面会自动恢复,无需手动导入。 ## 快捷键 - 画布/列表通用 - Enter:在对话框中确认 - Esc:关闭当前对话框 - 生命周期编辑 - Cmd/Ctrl + Enter:保存当前编辑内容(同时系统也会在失焦或关闭菜单时自动保存) ## 新增迁移与自定义事件 - 在“新增迁移”弹窗中: - 选择 `源状态` 与 `目标状态`。 - 事件下拉可选择已有事件,或选择“自定义事件…”,此时会出现“新事件名”输入框。 - 未填写自定义事件名前,“确定”按钮会禁用,避免空值提交。 - 弹窗内提供即时的 from/event/to 预览(若你在页面中开启了预览区域)。 ## 行为与校验 - 去重与清理: - 自动去重 `(from + event)`,若重复则更新其目标状态 `to`。 - 自动清理未使用的事件(当没有任何迁移引用某事件时,将其从事件集合中移除)。 - 布局与位置: - 首次使用自动布局,之后记忆节点位置,增删元素时尽量保持手动布局不被打乱。 ## 界面与交互优化 - 对话框与右键菜单内的输入控件已统一样式(圆角、阴影、聚焦高亮)。 - 生命周期编辑区使用等宽字体与浅色背景,阅读与编写更舒适。 ## 小技巧 - 快速新增状态:在画布空白处右键 → “新增状态”。 - 定位节点/事件:在左/中列表点击项,画布会自动选中并定位。 - 避免命名冲突:新增状态或事件时会校验重复,避免覆盖既有配置。 ## 安全与注意事项 - “粘贴 Config”支持对粘贴的对象字面量进行求值(以支持函数值)。为安全起见: - 仅在本地浏览器内求值,不会上传你的内容。 - 请仅粘贴来自可信来源的片段,避免执行潜在恶意代码。 - 生命周期函数建议在工程仓库的 TS/TSX 源码中维护,编辑器内的预览仅为参考。 ## 常见问题(FAQ) - Q:粘贴整段 TS 报“解析失败”? - A:请确认包含 `const config = { ... }` 字面量;若有语法错误或花括号不匹配,工具将无法识别。你也可以尝试只粘贴 `{ initial, states }` 对象部分。 - Q:导入 JSON 后生命周期函数不生效? - A:JSON 不能保存函数值。若需要函数,请使用“粘贴 Config”粘贴对象字面量(允许函数),或在导出后手动回填到你的工程代码中。