概览
OpenWarp = 可视化积木编程编辑器(2D / 3D 舞台)+ 独立播放器产物。这份文档记录**接口、格式、引擎口径**,全部对着代码写,不凭印象。
三个工程的关系
| 目录 | 是什么 | 作用 |
|---|---|---|
OpenWarp-desktop/ | 编辑器(Electron 桌面版 + 网页版) | 积木编程、舞台、资源、联机、打包入口;主进程负责磁盘与系统能力 |
OpenWarp-packager/ | 作品打包器(CLI + 网页页) | 把一个**工程文件夹**打成单个自包含 HTML(内置运行时 + 播放器) |
废弃/ | 历史归档 | 旧整包 / 旧 website / 旧 packager —— **不再随产品分发**,代码里也**故意不再兜底到它** |
数据流
工程文件夹 ──readProject──▶ project 对象 ──createRuntime──▶ 运行时(解释执行积木)
│ │ │
│ │ └─▶ 画布每帧读 state / liveOf / cameraOf / lightOf
│ └─▶ 编辑器 UI(React,只读快照 + 改数据)
└─packager──▶ 单个 HTML(bundle + 运行时 + 播放器,双击即玩)
技术选型(2D / 3D 到底用什么写的)
| 层 | 用什么 | 说明 |
|---|---|---|
| 语言 | 纯 JavaScript(ES5+ 写法)+ JSX | 没有 TypeScript —— renderer-webpack/ 下没有任何 .ts/.tsx |
| 编辑器界面 | React 16.14 + react-dom 16.14(ReactDOM.render,函数组件 + Hooks) | 界面全自研:不是 Scratch GUI,也不是 Blockly |
| 构建 | webpack 4.47 + Babel(@babel/preset-env / preset-react);file-loader 管资源、css-loader(modules) + postcss 管样式 | 两套配置:桌面 webpack.config.cjs / 网页 webpack.web.cjs |
| 样式 / 主题 | CSS Modules(类名被哈希 → 组件里大量行内 style)+ CSS 变量做深/浅主题var(--ow-*) | 所以你会看到样式就写在 JSX 的 style={{…}} 里 |
| 2D 舞台 / 画布 | HTML5 <canvas> + Canvas 2D API:canvas.getContext('2d', {willReadFrequently: true}) | components/Canvas2D.jsx。willReadFrequently 是因为「碰到颜色」要读像素 |
| 3D 舞台 / 画布 | three.js 0.160(WebGL):THREE.WebGLRenderer / PerspectiveCamera / MeshStandardMaterial… | components/Canvas3D.jsx。模型导入靠 GLTFLoader / FBXLoader / STLLoader |
| 积木渲染 | 自研:积木是自己用 DOM(div)画出来的(吸铁、拖拽、缩放、右键菜单都自己写) | 只有导出积木图时才转 SVG:优先把 DOM 包进 <foreignObject>,退化路径是纯 SVG 绘制(blocksToSvg.js) |
| 音频 | new Audio(dataUrl)(HTMLAudioElement) | 没用 Web Audio API |
| 运行时(积木解释执行) | 纯 JS,零依赖、不依赖 React | components/runtime.js。编辑器、打包产物、播放器用的都是它 |
| 联机 | 原生 WebSocket(手写 MQTT 3.1.1 报文)+ RTCPeerConnection / RTCDataChannel | 零依赖;信令走公共 MQTT over WebSocket,见「联机与云变量」 |
| 云变量 | 原生 WebSocket | 对接 TurboWarp 云变量服务器 |
.ow 作品包 | 手写 ZIP + 平台自带 CompressionStream('deflate-raw') | 零依赖,Node 与浏览器共用同一份 |
| 打包器 | 纯 Node,零依赖;产物内联的是经典脚本(非 ESM) | 见「打包器与产物」 |
| 桌面壳 | Electron 42(内含 Node 24)+ electron-builder(NSIS 安装包) | 发行加固:javascript-obfuscator + bytenode + electronFuses(npm run build:secure / release) |
| 网页版 | webpack target:'web' → dist-web/ 静态站 | 和桌面版共用同一份 App.jsx |
| 测试 | Node 直跑的 .cjs(不用编译:用 new Function 把 ESM 源码的 export 去掉再执行) | npm test |
| 依赖策略 | 运行时依赖为空 —— package.json 的 dependencies 是 {},全都只是 devDependencies | 上面那些"手写"的根源:联机、云变量、.ow、打包器全是自己实现的 |
术语
| 词 | 含义 |
|---|---|
| 角色(role) | 工程里的一个可编程对象。OpenWarp 没有「舞台」这个角色 —— 舞台是画布窗口(project.stage),老工程里 isStage 的角色读进来会被转成普通角色 |
| 造型(costume) | 角色的图片/模型引用,{name, file}。引用口径见「资源地址三条规则」 |
| 预制件(prefab) | ≈ 克隆体。放置出来的每个副本是**独立 roleId**(prefab_<源角色id>_<序号>),私有变量与源角色共用 |
| 帽子积木(hat) | 能单独起头的积木(程序运行时、定义 xxx、联机/云变量的「当…时」)。见「积木与 opcode」 |
| 扩展(extension) | 积木栏里的**一整个分类**,可在「扩展」窗口整类加载/移除。默认**一个都不加载** |
作品包(.ow) | 整个工程压成一个标准 ZIP。桌面版与网页版互通,见对应章节 |
| 产物 | 打包器输出的那个自包含 HTML |
工程格式(磁盘)
FORMAT_VERSION = 2。工程就是一个**普通文件夹**,没有数据库、没有加密(加密只在发行包 build-secure 那一层)。
我的作品/ ├── project.json 工程级:模式 / 舞台 / UI / meta / 角色索引 ├── Sprite/Code/<角色id>.json 每个角色的**完整文档**(积木 / 变量 / 造型 / 位置) ├── Images/ 素材:图片(png/jpg/svg…) ├── Sounds/ 素材:音频 ├── Models/ 素材:3D 模型(glb/gltf/fbx/stl) ├── Variables/<角色id>.json **可读副本**(读工程时**不读它**,只认 Sprite/Code) ├── Lists/<角色id>.json 同上 └── Logs/save.log 每次保存写一行,调试用
project.json
| 字段 | 类型 | 说明 |
|---|---|---|
openwarpFormatVersion | number | 当前 2;读的时候缺失按 1 处理 |
mode | '2d' | '3d' | 创建后不可改。2D/3D 的积木栏、画布、摄像机口径都跟着它走 |
stage | object | {ratio}('auto' | '16:9' | '4:3' | '3:2' | '1:1' | '9:16')+ 共有变量/数组:{variables, lists, variableTypes, listTypes}(共有那张表不属于任何角色,所以存在这儿) |
ui | object | {toolbarColor},缺省 #4acb3a |
meta | object | {name, createdAt, updatedAt}。文件夹名 = 作品名,导入时以文件夹名为准 |
roles | array | 只是索引:[{id, name, isStage}]。角色的真身在 Sprite/Code/<id>.json |
Sprite/Code/<角色id>.json
| 字段 | 说明 |
|---|---|
isStage / name | 老工程可能是 isStage: true → 读进来转成普通角色(名字叫「舞台」的会改成「角色1」) |
blocks | {scripts: [...]}。脚本是一串积木;积木形如 {id, opcode, inputs, menus, substacks, x, y} |
variables / lists | {名字: 值} —— 角色**私有**的变量与数组 |
variableTypes / listTypes | {名字: 'number' | 'string' | 'any'}。类型影响取值积木的边框与「增加」能不能放 |
costumes / sounds | [{name, file}] |
transform | {x, y, z, rotation, pitch, roll} —— 初始舞台状态 |
资源地址三条规则
| 写法 | 去哪儿读 |
|---|---|
C:\… / \\… / /… | 电脑上的**绝对路径**(「本地操作」扩展专属口径,不受工程文件夹约束) |
含 /(如 Images/a.png) | **相对工程根**,过 resolveInside 沙箱 |
| 只有文件名 | 去 Images/ 或 Sounds/(模型则 Models/)里按名字找 |
读取实现的三个副本(改一个要想三个)
main/openwarp-project.js的readProject—— 桌面版(唯一权威)OpenWarp-packager/src/pure.js的normalizeProject—— 打包器(Node + 浏览器共用)renderer-webpack/web/web-bridge.js的importEntries—— 网页版导入(文件夹 /.ow共用同一段)
.ow 作品包
整个工程压成**一个文件**,桌面版与网页版互通。容器是**标准 ZIP**,别人拿 7-Zip 直接就能打开看。
里面装什么
project.json 同上(roles 只留索引) Sprite/Code/<角色id>.json 每个角色的完整文档 Images/ Sounds/ Models/ 素材真文件(各目录**本级**的文件)
Variables/ Lists/ Logs/ —— 前两个是可读副本(读工程时根本不读),Logs/ 是日志。反过来:解出来的东西**就是一份正常工程文件夹**,直接拿它当工程打开也行。
读写实现:main/openwarp-ow.js
| 导出 | 签名 | 说明 |
|---|---|---|
OW_EXT | '.ow' | 扩展名 |
packOw | (entries) → Promise<Uint8Array> | entries = [{path, data:Uint8Array}],按给定顺序写 |
unpackOw | (bytes) → Promise<entries> | 带 CRC 校验、路径安全校验 |
cleanPath / crc32 | 工具 | cleanPath 挡 ..(zip-slip) |
零依赖、Node 与浏览器共用同一份:只用两边的公共能力 CompressionStream('deflate-raw')(Node ≥18 / Chromium ≥103)。拿不到这个 API 时自动退化成 store(照样能读,只是大一点)。整个文件没有 import/require,所以网页版 bundle 得进去。
兼容性与校验
- 只写
store(0)与deflate(8);读的时候两种都认。其它压缩方法(bzip2/lzma)**明确报错**,不静默产出坏工程 - ZIP64 不支持(明确报错);每个条目做 **CRC32 校验**;中央目录与尾记录位置做收尾校验
- 文件名带 UTF-8 标志(中文素材名)
- zip-slip 双向拦:写入时
cleanPath拒..,落盘时主进程再过一遍path.resolve前缀检查
两边的落点
| 导出 | 导入 | |
|---|---|---|
| 桌面版 | ow.export-ow:当前工程文件夹 → 另存对话框 | ow.import-ow:解到**工作区里一个新工程文件夹**(重名自动 -2,不覆盖)→ 照常打开 |
| 网页版 | exportOw():从浏览器存储按 writeProject 的口径重建结构 → 浏览器下载 | importOw():解出来走**和「导入文件夹」同一段解析** → 存进浏览器存储 |
宿主接口 window.OpenWarpEditor
渲染层只认这一个全局对象(const api = () => window.OpenWarpEditor)。桌面版由 preload 提供,网页版由 web-bridge.js 提供,安卓壳将来自己实现 —— 这三者就是「宿主」。
契约(自己写宿主时必须守)
- 每个方法都要返回 Promise。渲染层会写
api().setChanged(x).catch(…)—— 同步函数返回undefined会变成undefined.catch抛错,整个界面白屏(线上真崩过) - 只有两种「订阅」方法例外:
onDetachedChanged/onStageDetachedChanged(以及onAiChatDelta)—— 它们返回**取消订阅的函数**,不是 Promise - 返回形状要对齐:打开/新建/导入类 →
{name, project}或null(取消)或{name, error};readAsset→ **data URL**;saveAs→{ok:true, path}/{ok:false, canceled:true} - 没有的能力就别挂:扩展按「宿主有没有这个方法」判断「本机能不能用」(
platform字段另说) platform:'win'(桌面)/'web'(网页)/'android'(安卓壳)。扩展窗口的导航栏按它判断可用性
方法一览
标记:桌面 网页 安卓 非网站可用(网页版故意不挂)。
| 方法 | 入参 → 返回 | 说明 | 可用 |
|---|---|---|---|
| 作品生命周期 | |||
getInitialFolder | () → {name, project} | null | 启动时接着上次那份;没有就 null | 桌面网页 |
newProject | (mode) → 同上 | 选文件夹新建(桌面弹目录框 / 网页弹输入名字) | 桌面网页 |
openProject | () → 同上 | 桌面 = 选**文件夹**;网页 = 选单个 project.json | 桌面网页 |
saveProject | (project) → true | 写回当前工程 | 桌面网页 |
setChanged | (changed) → true | 「有未保存改动」标记(网页版转成关标签页提醒) | 桌面网页 |
| 工作区 / 作品列表 | |||
workspaceRoot | () → path | null | 作品都放在它下面 | 桌面网页 |
selectWorkspaceRoot | () → path | null | 网页版没有真实目录 → 固定虚拟工作区「浏览器存储」 | 桌面网页 |
listProjects | (root) → [{name, path}] | 主页列表 | 桌面网页 |
openProjectPath | (path) → {name, project} | 列表里点一个直接开 | 桌面网页 |
createProjectInRoot | (root, name, mode) → 同上 | 在工作区里新建 | 桌面网页 |
exportOw / importOw | () → {ok, path} / {name, project} | .ow 作品包,见上一节 | 桌面网页 |
importProjectFolder | () → {name, project} | 只有网页版有(桌面版「打开」本来就是选文件夹) | 网页 |
importSb3 | () → 同上 | 桌面:转换器在主进程里,需要读编辑器源码做 opcode 校验 网页:**明确降级**(提示用桌面版) | 桌面 |
| 素材(Images / Sounds / Models) | |||
listAssets | (kind) → [文件名] | kind ∈ images|sounds|models | 桌面网页 |
readAsset | (kind, fileName) → data URL | null | 只认文件名 | 桌面网页 |
readAssetAddress | (kind, address) → data URL | null | 按「地址三条规则」读 | 桌面网页 |
addAsset | (kind, fileName, data) → 存下来的文件名 | data 可以是 data URL 或纯 base64 —— 画板、资源导入都走它 | 桌面网页 |
deleteAsset | (kind, fileName) → bool | 桌面网页 | |
| 工程文件夹浏览(资源窗口用) | |||
listDir | (rel) → {rel, parent, entries} | '' = 工程根;路径都相对 root | 桌面 |
readProjectFile | (rel) → data URL | 任意文件(缩略图 / 试听 / 文本预览) | 桌面 |
copyProjectFile / renameProjectFile / deleteProjectFile / writeProjectFile | (rel, …) → … | 资源右键菜单:复制 / 重命名 / 删除 / 覆盖写入 | 桌面 |
showItemInFolder | (absPath) → void | 在文件资源管理器中打开 | 桌面 |
| 「本地操作」扩展的积木(非网站可用) | |||
makeDir / writeProjectText / copyEntry / moveEntry / deleteEntry / entryExists / listEntry / readProjectText | (rel, …) → … | 创建(=写入)/ 复制 / 移动 / 删除 / 存在? / 列出名字 / 读取原文 | 桌面安卓 |
| 「命令调用」扩展(非网站可用) | |||
runCommand | (cmd, mode) → 输出 | mode ∈ wait|detach|capture;工作目录 = 工程文件夹 | 桌面 |
| 其它 | |||
saveAs | ({defaultPath, contents, filters}) → {ok, path} | 代码导出用的另存 | 桌面网页 |
openTextFile | () → {name, text} | {name, tooBig} | null | 代码预览「导入」(桌面:UTF-8 严格失败后按 GBK 兜底) | 桌面 |
writeClipboard | (text) → bool | 桌面走主进程;网页走 navigator.clipboard(需 https/localhost) | 桌面网页 |
copyImageToClipboard | (dataUrl) → bool | 桌面 | |
alert / confirm | (message) → void / bool | 桌面用系统对话框;网页用 window.alert/confirm | 桌面网页 |
detachBlocks / detachStage | () → void | 把积木区/舞台分离成独立窗口。网页版**没有多窗口** → 只提示 | 桌面 |
onDetachedChanged / onStageDetachedChanged | (cb) → 取消函数 | 订阅「已分离」状态 | 桌面 |
| AI 编程 | |||
aiConfig / aiSetConfig | () / (config) → config | 接口配置。桌面 Key 存主进程设置,网页存 localStorage | 桌面网页 |
aiModels / aiGenerate / aiChat / aiChatCancel | (…) → … | 模型列表 / 生成积木 / 多轮对话 / 取消 | 桌面网页 |
onAiChatDelta | (cb) → 取消函数 | 流式增量 | 桌面网页 |
| 局域网协作信令中继(主进程开 HTTP 服务) | |||
lanStart | () → {ip, port, ips} | 当主机:起本地中继。ips 是**按可用性排序**的候选地址(物理网卡优先,VPN/虚拟网卡靠后) | 桌面 |
lanSetTarget | (url) → bool | 当客机:设主机地址 | 桌面 |
lanPing | (url) → {ok, body} | 探活(走 /info,立刻返回)—— 连接判定用它,别用 lanPoll(那是长轮询,房里没消息要挂 20 秒) | 桌面 |
lanPublish / lanPoll / lanStop | (room, msg) / (room, since) / () | 发消息 / 拉消息(长轮询)/ 停服务 | 桌面 |
桌面版:IPC 名对照
preload 里每个方法就是对 ow.* 的一次 ipcRenderer.invoke,名字基本一一对应:ow.get-initial-folder、ow.new-project、ow.open-project、ow.workspace-root、ow.select-workspace-root、ow.list-projects、ow.open-project-path、ow.create-project-in-root、ow.export-ow、ow.import-ow、ow.save-project、ow.set-changed、ow.list-assets、ow.read-asset、ow.add-asset、ow.delete-asset、ow.read-asset-address、ow.list-dir、ow.read-project-file、ow.copy-project-file、ow.rename-project-file、ow.delete-project-file、ow.write-project-file、ow.make-dir、ow.write-project-text、ow.copy-entry、ow.move-entry、ow.delete-entry、ow.entry-exists、ow.list-entry、ow.read-project-text、ow.run-command、ow.open-text-file、ow.save-as、ow.clipboard-write、ow.copy-image、ow.show-item-in-folder、ow.alert、ow.confirm、ow.ai-*、ow.lan-*、ow.detach-blocks、ow.detach-stage。
主进程往渲染层推的事件:ow.detached-changed、ow.stage-detached-changed、ow.ai-chat-delta。
加一件新能力要动三步
1. main/windows/openwarp-editor.js this.ipc.handle('ow.xxx', async (event, …) => {…})
2. preload/openwarp-editor.js xxx: (…) => ipcRenderer.invoke('ow.xxx', …)
3. 渲染层(App.jsx / 某个组件) typeof (api() || {}).xxx === 'function' 判一下再用
↳ 网页版:renderer-webpack/web/web-bridge.js 里补同名方法(没有就"非网站可用")
运行时 API(createRuntime)
积木的「解释执行」只有这一个实现(renderer-webpack/editor/openwarp-editor/components/runtime.js)。编辑器、打包器产物、播放器用的都是它 —— 打包器现读现内联这一份,不复制(复制就会漂移)。
import {createRuntime} from './components/runtime.js';
const rt = createRuntime({
api: () => window.OpenWarpEditor, // 宿主桥(读素材、跑命令…用)
getProject: () => project, // 每次现取,运行时**不缓存**工程对象
net, // 可选:联机(82 个 net_* 积木全委托给它)
cloud // 可选:云变量(cloud_* 积木)
});
返回的成员
| 分组 | 成员 | 说明 |
|---|---|---|
| 同步工程 | sync() | 把工程里的角色/变量/列表装进运行时(换工程、改角色后调) |
| 变量 / 数组 | varsOf, listsOf, varNames, listNames, numericVarNames, typeOf, getVar, setVar, getList, addVariable, addList, removeVariable, removeList, renameVariable, renameList, setVariableType, setListType, snapshotData, seedData | 键都是 roleId;'stage' 是共有那张表。snapshotData/seedData 给保存与装载用 |
| 舞台显示状态 | liveOf(roleId), resetLive(), resetData(), forgetRole(roleId), state | liveOf 按需建、没有就按 transform 初始化。forgetRole = 删角色时把它的变量/舞台状态/副本/脚本一起收掉 |
| 预制件 | prefabList(), prefabSource(id), prefabPlace(srcRoleId, atRoleId, srcObj?), prefabEnd(roleId) | 副本上限 300;副本是独立 roleId |
| 画布 / 取景 | setCanvas(c), setWorld(w, h) | 画布注册自己;setWorld 同时刷新摄像机的自适应分辨率 |
| 摄像机 | cameraOf(), setCamera(patch), setAutoViewport(w,h), bindCamera, unbindCamera, isBoundToCamera, worldPosOf(roleId) | 绑定了的角色,坐标 = 摄像机局部坐标(HUD) |
| 3D 光源 | lightOf(), setLight(patch) | 默认 {x:600,y:1200,z:800, intensity:0.8, type:'平行光', distance:0, decay:2, shadow:false} |
| 输入 | setMouse(x, y, down), setKey(code, down), markClicked(roleId), clearClicked() | 鼠标键号由 setMouse 顺手挂一次 window 监听补上 |
| 运行 | runFrom(c, script), startProject(), evaluate(script), stopAll(), startHatsByOpcode(opcode), runningBlocks(), isRunning() | startProject = 绿旗(只跑 event_when_run 开头的脚本) |
| 通知 | onChange(cb) → 取消函数 | 每帧最多一次(见下) |
state 里有什么
mouse {x, y, down, button}—— 世界坐标keys(Set ofKeyboardEvent.code)、clicked(Set of roleId)volume、world {w, h}(画布当前有多少世界单位)pen(笔迹数组:线段 / 图章快照)、pens(哪些角色落着笔)
执行模型
| 机制 | 值 / 做法 | 说明 |
|---|---|---|
| 求值链 | 异步 | 积木的执行链是 async 的 —— 所以写新积木时「先 await 求值再传」是硬要求(少个 await 就把 Promise 当字符串发出去了) |
| 帧预算 | YIELD_MS = 8 | Scratch/TurboWarp 那套循环计时器:到点就让出(yieldFrame) |
| 死循环闸 | FOREVER_GUARD = 100000 | 单脚本最大步数。编辑器「设置 · 插件」里能关「防卡死」(关掉 = 不限制,真死循环会卡界面) |
| 停止 | 代次 gen | 线程键是 roleId|…;代次 +1 = 停它的脚本(stopAll、prefabEnd、forgetRole 都用这招) |
| 自定义积木 | callFrames 参数栈 | 支持递归;深度上限 200;同名先找本角色再找其它角色 |
WARP | 定义积木的「运行时不刷新屏幕」 | 函数体里不再逐帧让出(紧循环加速) |
| 通知合并 | notify() = penTrail() + 每帧最多一次的 onChange | 同一帧里改 100 次变量只让 React 渲染一次。但 penTrail() 每次都必须跑(漏一次笔迹就走直线)。没有 requestAnimationFrame 的宿主(Node 测试)退回同步 |
积木与 opcode
积木定义全在 components/BlocksEditor.jsx 的 buildCategories() 里(当前约 296 条定义,含 compat 里给 sb3 转换兜底的那批)。
命名约定
<家族>_<动作>,家族 id 与积木分类(CAT 的第一个参数)同名。四个家族不走大 switch,而是按前缀先分流到专用处理器(execBlock / evalBlock 开头那几行):
| 前缀 | 处理器 | 实现位置 |
|---|---|---|
net_ | netBlock | 委托给创建运行时时传进来的 net(编辑器:components/net.js) |
cloud_ | cloudBlock | 委托给 cloud(components/cloud.js,对接 TurboWarp 云变量服务器) |
local_ | localBlock | 转调宿主桥的 makeDir/writeProjectText/…;没有这些方法就安静地什么都不做 |
cmd_ | cmdBlock | 转调宿主桥的 runCommand;同上 |
其余全部进 switch (block.opcode)。取值块在 evalBlock 的 switch 里、命令块在 execBlock 里 —— 加一块新积木两处都要写(形状是 reporter/boolean 的只需前者)。
分类
| 分类 id | 名字 | 说明 | 是否扩展 |
|---|---|---|---|
event | 事件 | 程序运行时 | 核心 |
control | 控制 | 重复执行 / 如果…否则…(C 型,分支走 substacks.SUB / SUB2) | 核心 |
motion | 运动 | 坐标 / 旋转 / 平移(2D 与 3D 共用一套,3D 另有 z 与姿态) | 核心 |
looks | 外观 | 大小 / 拉伸 / 透明度(ghost)/ 显示隐藏 / 说… | 核心 |
res | 资源 | 使用资源(显示成一张平面图)/ 使用纹理(贴到形状上)/ 作为文字 / 换成图片 | 核心 |
audio | 音频 | 播放 / 音量 / 停止 | 核心 |
sensing | 侦测 | 碰到?(鼠标 / 屏幕边缘 / 角色)/ 颜色碰撞 / 按键 / 鼠标坐标 | 核心 |
operator | 运算 | 四则 / 比较 / 随机 / 取整…(可变参数,插槽只收数值) | 核心 |
string | 字符串 | 包含? / 连接 / 长度… | 核心 |
variable / list | 变量 / 数组 | 角色私有 + 共有两张表;取值积木按类型改边框 | 核心 |
func | 函数 | 定义 xxx(帽子)+ 调用 + 参数取值 | 核心 |
pen | 绘制 | 落笔 / 抬笔 / 粗细 / 颜色 / 图章(约 7 块) | 扩展 |
camera | 摄像机 | 绑定 / 坐标 / 旋转 / 分辨率 / 取值(约 19 块) | 扩展 |
light | 灯光 | 位置 / 强度 / 类型 / 衰减 / 距离 / 阴影 / 取值(约 10 块,3D 专属) | 扩展 |
prefab | 预制件 | 在此放置 / 预制件启动时 / 结束预制件 | 扩展 |
net | 联机 | 房间 / 消息 / 事件 / 共享变量 / 分区 / 成员状态…(约 91 块) | 扩展 |
cloud | 云变量 | 连接 / 设值 / 取值 / 变更事件(约 16 块) | 扩展 |
local | 本地操作 | 创建 / 复制 / 移动 / 删除 / 存在 / 列出 / 读取(7 块)非网站可用 | 扩展 |
cmd | 命令调用 | 调用(直到完成 / 过 / 返回值)(2 块)非网站可用 | 扩展 |
compat | 兼容 | 整类默认不进积木栏 —— 只为了让 sb3 转换出来的积木「有块可落」(转换器拿它当 opcode 白名单) | 隐藏 |
DEFAULT_EXTENSION_IDS = [])。另外有一套「自动加载」:扫全工程的积木文本,用到哪个扩展就把它补进积木栏(只补不删)。形状与插槽写法
| shape | 含义 |
|---|---|
hat | 帽子,单独起头,不能插进别人下面 |
stack | 命令块 |
reporter / boolean | 取值块(圆角 / 六边形) |
c | C 型(分支走 substacks,label 里用 %SUB 标位置) |
| label 里写 | 渲染成 |
|---|---|
{NAME} | 文本输入框(值在 inputs 里) |
[NAME] | 下拉菜单(值在 menus 里)—— 写成花括号会变成输入框 |
%SUB | C 型的分支位置(内容在 substacks.SUB) |
帽子积木(HAT_OPCODES)
event_when_run、func_define、prefab_start、net_onConnected、net_onDisconnected、net_onKicked、net_onMessage、net_onEvent、net_onVarChange、net_onZoneChange、net_onPeerJoin、net_onPeerLeave、cloud_onConnected、cloud_onDisconnected、cloud_onChange。
HAT_OPCODES,否则编辑器不把它当帽子(能拼到别人下面、绿旗也不会启动它)。联机/云变量的帽子由对应模块在事件发生时调 startHatsByOpcode 启动。引擎与口径
这一节的数字(0.75 / 0.8 / 1000 / 50°)都是**约定值**,散落在运行时、2D 画布、3D 画布、播放器四处 —— 改一处必须一起改。
两个画布分别是什么技术
| 实现 | 每帧做什么 | |
|---|---|---|
| 2D | HTML5 <canvas> + Canvas 2D API(getContext('2d')),没有任何 2D 引擎/框架 | 按「世界坐标 → 屏幕」的变换**逐帧重画**:角色、笔迹、图章快照、摄像机框、网格 |
| 3D | three.js(WebGL) | 每帧把运行时的 liveOf / cameraOf / lightOf 刷到网格与灯光上,再 renderer.render() |
这条分工是刻意的:运行时只存状态、画布只读状态 —— 积木与画布因此完全解耦(同一个运行时能同时喂 2D 画布、3D 画布和打包产物的播放器)。
打包产物的播放器同样是 Canvas 2D + three:player.js 是 2D 主循环、three3d.js 是 3D;three 用 UMD 版 three.min.js 原样内联成经典脚本,播放器只读全局 THREE、不打包它。
「画板」窗口同理:位图 / 像素板是 Canvas 2D,矢量板是生成 SVG,3D 板是 three.js。
坐标系与单位
- 2D:左上角 (0,0),x 向右、y 向下(Scratch 口径),旋转 0° 指向右、顺时针为正
- 3D:世界坐标 y 向上(three 口径)—— 同一个
x/y字段在两种模式下屏幕方向不同,写 3D 积木时要留意 - 1 世界单位 = 1 像素(角色方块 60px、网格 4000px、默认视距 1200px)
摄像机
| 字段 | 含义 |
|---|---|
x, y, z | 摄像机在舞台上的坐标(不是注视点) |
rotation / pitch / roll | 偏航 / 俯仰 / 翻滚,度。旋转顺序 YXZ(偏航取负,让「右转」看着是右转) |
width / length | 分辨率:宽(横向)/ 长(竖向) |
auto | 自适应:画布尺寸一变就跟着变;脚本一设分辨率就关掉 |
bound | 绑定到摄像机的角色(它们的 x/y 是镜头局部坐标 ⇒ 当 HUD 用) |
3D 取景:fov 固定 50°,比例 = 分辨率 宽:长;「取景距离」由「长」反推 d = (长/2)/tan(fov/2) —— 黄框摆在相机前方 d 处,正好等于摄像机看到的那一块。
光照(3D)
| 项 | 值 | 说明 |
|---|---|---|
| 环境光 | 0.75 | 常亮,不在积木里 |
| 平行光默认 | 强度 0.8,方向 (6,12,8) ≙ (600,1200,800) | 平行光只看方向,所以这两组**观感完全一样**;后者才有"灯距",切点光时才合理 |
| 点光强度归一 | POINT_REF = 1000 | 积木里的「强度」= 在 1000px 处有多亮。three 是物理口径 强度/距离^decay,不归一到像素尺度的话 0.8 在 1000px 外等于没有 |
| 衰减 | 0 / 1 / 2 | 不衰减 / 线性(=「线性过渡」)/ 平方反比(物理默认) |
| 阴影 | 默认关 | 开了之后:角色/模型/图片平面都能投影,地面用 ShadowMaterial 只显示影子不显示平面;castShadow 变化时要让材质重编译一次(three 不会自己发现) |
角色的舞台显示状态(liveOf)
{x, y, z, rotation, pitch, roll, size, ghost, visible, image, texture, text, font, dataUrl, img, stretchX, stretchY}
size是百分比(100 = 原大小),ghost0~100(透明度)image与texture互斥:使用资源= 一张平面图(像 Unity 的 Quad),使用纹理= 贴到角色的 3D 形状上。设一个会把另一个清掉img/dataUrl是 2D 画布直接画的东西(按需加载)
预制件(≈ 克隆体)
- 副本 id =
prefab_<源角色id>_<序号>,在live里是**独立的一条**(画布照 roleId 画) - 私有变量/列表不另起一套:键先过
ownerOf()归一化到源角色(和 Scratch 一致) - 放置时直接跑源角色的
prefab_start帽子脚本(自带调度,不需要外部壳) - 上限 300;
结束预制件只销毁自己(本体调用无操作)
碰撞(touching)
- 2D 用**轴对齐矩形**(角色边长由
size决定):碰到 鼠标指针/屏幕边缘/角色名或id - 世界无限大:「屏幕」= 摄像机那块矩形(宽 × 长),出框就算碰到边缘
- 颜色碰撞:读画布像素,支持
#f00这种简写 - 3D 产物里这套也是 2D 判定(编辑器 Canvas3D 的射线拾取没搬过去)—— 对「点一下角色」够用,精细 3D 碰撞不精确
画笔
笔迹**不是**往画布里画像素,而是往 state.pen 里记「线段 / 图章快照」,画布按同一个变换再画一次。采集挂在 notify() 上:落笔的角色位置一变就补一段线(所以 notify 里 penTrail 那半不能合并)。
音频 / 资源
素材读取一律走宿主桥(readAsset / readAssetAddress),运行时侧有音频缓存与图片缓存。图片地址变了才重新加载。
联机与云变量
联机(components/net.js)—— 血统与结论已定稿
stp2p/v1、DataChannel 标签 stp2p、MQTT clientId 前缀 stp2p-),因为对方作者的许可是「按它的协议来」;中途改成自有协议(openwarp/v1)会让两边互相听不到。| 项 | 口径 |
|---|---|
| 话题 | stp2p/v1/<FNV-1a(房间 + '#' + 密码) 的 base36>/<房间名去符号前 12 字> —— 密码混进哈希,密码不同 = 不同话题 |
| 消息字段 | {t, id, nick, ts, to, i, d, vars, reason, sdp, cand};t ∈ hello/ping/pong/leave/offer/answer/ice/data/snap/kick/reject |
| 应用数据 | 走 data 的 d:{k:'msg'|'evt'|'var'|'del'|'zone'|'state', …} |
| 信令服务器 | 用户可以自己填。预设几个都是**公共 MQTT broker**(EMQX / HiveMQ / Mosquitto 的公开测试服,公共基础设施);也可以填自建 broker 或任何 MQTT over WebSocket 地址 |
| 直连 | WebRTC DataChannel;myId < 对端id 的一方发起。失败或选了「强制服务器中继」就 mode='relay' |
| 房主 | id 最小的那台:负责锁房 / 人数上限拒绝 / 给新人发共享变量快照与分区 |
| 已知限制 | WebRTC 只有 STUN 没有 TURN → 对称 NAT 打不通就回落中继(功能不受影响,延迟高些) |
局域网协作(主进程 main/lan-relay.js)
没有公网也要能一起做作品:主进程起一个**本地 HTTP 服务**当中继,渲染层只调桥方法。
| 端点 / 方法 | 说明 |
|---|---|
GET /info | 立刻返回 {ip, port}(探活用;不要拿 /poll 探活,那是长轮询) |
POST /publish | {room, msg} 发一条 |
GET /poll?room=&since= | 长轮询拉消息(房里没消息时最长挂 20 秒) |
lanStart() 返回的 ips | 候选地址**按可用性排序**:物理网卡(WLAN/以太网)优先,VPN / Hyper-V / VMware 等虚拟网卡靠后,169.254 垫底 —— 主机要把首选那个地址发给同伴 |
/poll,房里没消息要挂 20 秒才返回,看着像卡住。现在 /info 探活 + 多网卡全部列出来让用户挑。云变量(components/cloud.js)
- 对接 TurboWarp 云变量服务器(原生 WebSocket,零依赖)
- 变量名对用户不带
☁前缀(模块进出自动加/去) - 服务器只认数字值;同一个变量每秒别发超过 10 次(官方限制)
- 帽子块:
cloud_onConnected/cloud_onDisconnected/cloud_onChange
打包器与产物
把工程打成**一个自包含 HTML**:工程数据 + 素材(dataURL)+ 运行时 + 播放器全在里面,双击就能玩,不需要联网、不需要 Electron。
两种用法(同一份逻辑)
| 入口 | 读工程靠什么 | |
|---|---|---|
| 命令行 | bin/openwarp-packager.js → src/pack.js 的 pack() | src/read-project.js(fs) |
| 网页页 | ui/packager.html(构建产物,双击即用) | src/browser/folder.js(File API / 拖放) |
两者共用 src/pure.js(归一化 / 统计 / 提醒 / 自检)与 src/html.js(模板),所以产物结构不可能不一样。差别只有「文件从哪来」。
产物结构
<script>window.__OPENWARP_BUNDLE__ = {
format:'openwarp-bundle', version:1, packedBy, packedAt, title,
project: {…}, // 工程对象
assets: {images:{文件名:dataURL}, sounds:{…}}
}</script>
<script>(function(){ 运行时 + 播放器 + 启动 })()</script>
- 工程数据用
JSON.stringify后把<转成\u003c(防</script>截断) - 内联脚本必须是**经典脚本**(不能
type="module")—— 运行时去掉 export 后和播放器同作用域 - 打包时
new Function(script)自检一次,宁可不给也不给白屏产物 - 运行时是现读编辑器那一份(
components/runtime.js),不复制 —— 复制就会漂移 - 3D 工程才内联 three(
three.min.js+ 播放器的three3d.js+ 模型加载器),2D 产物保持小
播放器(src/player/)
打包器侧的**第二份**显示实现(产物里没法内联 React 组件)。规矩是「只抄显示」:不抄黄框 gizmo、选中高亮、右键菜单、舞台拖动。取景口径与 Canvas3D **逐字一致**(CAM_FOV / POINT_REF / 灯位默认值必须两处同值)。
player.js = 2D 主循环(对着 Canvas2D / Stage 抄的 worldToScreen / screenToWorld / fitBox);three3d.js = 3D 渲染(光源 / 阴影 / 模型 / 使用资源平面 / 使用纹理)。
已知限制
- 素材按**文件名**内联:工程里写绝对/相对路径的素材,网页里只能按文件名兜底匹配;工程文件夹之外的素材不会被打进去
- 3D 产物里
碰到颜色不可用(WebGL 画布不能当 2D 读);3D 碰撞走 2D 判定 - WebRTC 无 TURN;没有撤销、没有工程编辑器(产物是播放器)
- 页面版依赖
webkitdirectory/webkitGetAsEntry(Chromium 系最稳)
网页版编辑器
网页版不是另一套代码:**同一个 App.jsx**,换一个入口 + 装一个浏览器桥,由单独的 webpack 配置打出来(给 Cloudflare Pages 这类纯静态托管)。
构建
| 配置 | 产物 | 入口 | 说 |
|---|---|---|---|
webpack.config.cjs | dist-renderer-webpack/ | renderer-webpack/editor/openwarp-editor/index.jsx | 桌面版渲染层(ow-editor:// 协议加载,见 main/protocols.js) |
webpack.web.cjs | dist-web/ | renderer-webpack/web/index.jsx | target:'web'、publicPath:''(挂域名根或子目录都能跑) |
入口与桥
// renderer-webpack/web/index.jsx
installWebBridge(); // ⚠️ 顺序要紧:先装桥(App 里所有 api() 都读它)
initTheme();
ReactDOM.render(<App />, document.getElementById('app'));
| 能力 | 桌面版 | 网页版(web-bridge.js) |
|---|---|---|
| 作品与素材存哪 | 磁盘文件夹 | IndexedDB(openwarp-web:projects / assets / meta 三张表;素材按 作品名|种类|文件名 存) |
| 工作区 | 用户选的文件夹(记在设置里) | 固定虚拟工作区「浏览器存储」 |
| 打开作品 | 选文件夹 | 选单个 project.json;另有 importProjectFolder 与 importOw |
| 另存为 | 系统对话框 | Blob + <a download> |
| AI | Key 存主进程设置 | localStorage;生成逻辑复用 main/openwarp-ai.js(纯 fetch) |
| 多窗口 / sb3 / 本地操作 / 命令调用 | 有 | 明确降级:多窗口只提示;sb3 提示用桌面版;本地操作与命令调用不挂方法 ⇒ 那两个扩展显示「非网站可用」,积木判到没有宿主方法就安静跳过 |
网页版的 platform 是 'web',扩展窗口的导航栏按它判断可用性。
目录与脚本
关键文件
| 路径 | 作用 |
|---|---|
main/entrypoint.js / index.js | Electron 启动 |
main/windows/openwarp-editor.js | 编辑器窗口 + **全部 ow.* IPC** + .ow 导入导出 |
main/openwarp-project.js | 工程读写(格式权威):readProject / writeProject / createProject / 素材 / 路径沙箱 / 本地操作 |
main/openwarp-ow.js | .ow 编解码(零依赖,Node + 浏览器共用) |
main/openwarp-ai.js | AI 接口(纯 fetch,网页版也复用) |
main/openwarp-command.js | 「命令调用」扩展(跑系统命令) |
main/lan-relay.js | 局域网信令中继(本地 HTTP 服务) |
main/protocols.js | ow-editor:// 等自定义协议 |
main/sb3/ | Scratch 3 工程转换器 |
preload/openwarp-editor.js | 桌面版宿主桥(渲染层唯一入口) |
renderer-webpack/editor/openwarp-editor/App.jsx | 编辑器主界面(布局 / 工具栏 / 各浮动画板) |
…/components/runtime.js | 积木解释执行(唯一实现) |
…/components/BlocksEditor.jsx | 积木定义表 / 积木栏 / 积木区 / 扩展清单 / 代码↔积木工具 |
…/components/Canvas2D.jsx / Canvas3D.jsx | 两个画布(取景、绘制、拾取) |
…/components/net.js / cloud.js / collab.js | 联机 / 云变量 / 编辑器协作面板逻辑 |
…/components/PaintBoard.jsx | 画板窗口(SVG / PNG / 像素 / 3D 四种,存进 Images/) |
renderer-webpack/web/ | 网页版入口 / 页面 / 桥 |
OpenWarp-packager/ | 作品打包器(见对应章节) |
npm 脚本
| 工程 | 脚本 | 作用 |
|---|---|---|
| OpenWarp-desktop | webpack:compile / webpack:watch | 桌面版渲染层(改 App/组件后要跑) |
webpack:prod | 生产版渲染层 | |
web:build / web:watch / web:serve | 网页版构建 / 盯改动 / 本地起服务 | |
start / electron:start | 编译 + 起 Electron | |
test | 6 个 Node 测试(主题 / 代码解析 / 积木图 / 运行时启动 / 摄像机 / sb3 转换 / 角色与工程) | |
check-requires | 检查主进程的本地 require 是否都能找到 | |
build:secure | 主进程/preload 的加固(混淆 + bytenode 编译) | |
release | 生产渲染层 + 加固 + electron-builder 打包 | |
| OpenWarp-packager | test | 打包器测试 |
build:ui | 重建 ui/packager.html(把运行时/播放器烘进去) | |
ui | 重建页面 + 起本地服务 | |
pack:example | 拿示例工程打一个产物 |
components/runtime.js(或 net.js / three3d.js / three-loaders.js),打包器页面必须重跑 npm run build:ui —— 页面里那份运行时是构建时烘死的文本,不重建的话「网页打包」出来的产物还是旧的。刻意接受的重复:必须两处一起改
| 口径 | 哪两处(或多处) |
|---|---|
CAM_FOV = 50 / POINT_REF = 1000 / 灯位默认值 / 环境光 0.75 / 平行光 0.8 | Canvas3D.jsx ↔ OpenWarp-packager/src/player/three3d.js |
2D 取景数学(worldToScreen / screenToWorld / fitBox 与 setWorld) | Canvas2D.jsx / Stage.jsx ↔ src/player/player.js |
| 工程格式读取规则 | main/openwarp-project.js ↔ packager/src/pure.js ↔ web-bridge.js |
MIME 表(MIME_BY_EXT) | main/openwarp-project.js ↔ web-bridge.js |
FORMAT_VERSION = 2 | main/openwarp-project.js ↔ web-bridge.js ↔ packager/src/pure.js |
| webpack 的 babel / file-loader / css 规则 | webpack.config.cjs ↔ webpack.web.cjs |
| 联机协议(话题 / 频道标签 / clientId) | components/net.js(只有这一处,但别再改 —— 改了就和第三方扩展互相听不到) |
| 积木定义 ↔ 运行时实现 | BlocksEditor.jsx(定义 + 帽子登记)↔ runtime.js(execBlock + evalBlock 两处) |
常见改动的完整步骤
加一块新积木
1. BlocksEditor.jsx buildCategories() 加 {opcode:'家族_动作', shape, label, defaults, menus}
· 菜单插槽写 [NAME]、输入框写 {NAME}
· 帽子块还要登记进 HAT_OPCODES
2. runtime.js evalBlock 的 switch 取值块(reporter / boolean)
3. runtime.js execBlock 的 switch 命令块(stack / c)
· 要读插槽:先 await(少个 await 就把 Promise 传下去了)
4. npm run webpack:compile 桌面渲染层
npm run web:build 网页版
(改了 runtime.js 还要 npm run build:ui 打包器页面)
加一个扩展
1. BlocksEditor.jsx EXTENSIONS 加 {id, category, icon, color, name, desc, platforms?}
platforms 省略 = 三端通用;['win'] = 桌面专属(导航栏会按 platform 判断)
2. 同文件 CAT('分类id', …) 定义积木(分类 id 要和 EXTENSIONS 的 category 相同)
3. runtime.js 实现(前缀分流家族要单独写处理器)
4. 想"作品里用到就自动加载"→ 不用做事:自动加载按 opcode 前缀扫工程文本
加一件宿主能力
1. main/windows/openwarp-editor.js this.ipc.handle('ow.xxx', …)
2. preload/openwarp-editor.js xxx: (…) => ipcRenderer.invoke('ow.xxx', …)
3. 渲染层 typeof (api() || {}).xxx === 'function' 再调
4. 网页版(可选) web-bridge.js 里补同名实现,否则那个扩展就是"非网站可用"