OpenWarp 文档接口 · 引擎 · 格式
没有匹配的章节 —— 清空输入框(或按 Esc)看全部

概览

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,零依赖、不依赖 Reactcomponents/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

字段类型说明
openwarpFormatVersionnumber当前 2;读的时候缺失按 1 处理
mode'2d' | '3d'创建后不可改。2D/3D 的积木栏、画布、摄像机口径都跟着它走
stageobject{ratio}('auto' | '16:9' | '4:3' | '3:2' | '1:1' | '9:16')+ 共有变量/数组:{variables, lists, variableTypes, listTypes}(共有那张表不属于任何角色,所以存在这儿)
uiobject{toolbarColor},缺省 #4acb3a
metaobject{name, createdAt, updatedAt}。文件夹名 = 作品名,导入时以文件夹名为准
rolesarray只是索引:[{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 里补同名方法(没有就"非网站可用")
第 3 步那个特性探测不是可选的:老 preload / 老网页版没有这个方法时,直接调会炸掉整棵树。

运行时 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), stateliveOf 按需建、没有就按 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 of KeyboardEvent.code)、clicked(Set of roleId)
  • volume、world {w, h}(画布当前有多少世界单位)
  • pen(笔迹数组:线段 / 图章快照)、pens(哪些角色落着笔)

执行模型

机制值 / 做法说明
求值链异步积木的执行链是 async 的 —— 所以写新积木时「先 await 求值再传」是硬要求(少个 await 就把 Promise 当字符串发出去了)
帧预算YIELD_MS = 8Scratch/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取值块(圆角 / 六边形)
cC 型(分支走 substacks,label 里用 %SUB 标位置)
label 里写渲染成
{NAME}文本输入框(值在 inputs 里)
[NAME]下拉菜单(值在 menus 里)—— 写成花括号会变成输入框
%SUBC 型的分支位置(内容在 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 画布、播放器四处 —— 改一处必须一起改。

两个画布分别是什么技术

实现每帧做什么
2DHTML5 <canvas> + Canvas 2D API(getContext('2d')),没有任何 2D 引擎/框架按「世界坐标 → 屏幕」的变换**逐帧重画**:角色、笔迹、图章快照、摄像机框、网格
3Dthree.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 不会自己发现)
网格(LineBasicMaterial)和纯自发光材质不吃光。3D 里能看见光照效果的只有:默认方块、GLB/STL 模型、「使用纹理」的方块,以及「使用资源」的平面图(它已改成「标准材质 + emissiveMap 垫底」:亮度约为原图 0.75~1.24 倍,但会跟着灯变亮变暗)。

角色的舞台显示状态(liveOf)

{x, y, z, rotation, pitch, roll, size, ghost, visible, image, texture, text, font, dataUrl, img, stretchX, stretchY}

  • size 是百分比(100 = 原大小),ghost 0~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)—— 血统与结论已定稿

下面这条是用户拍板过的定稿,代码里也写着「这一节别再反复改」:协议与 TurboWarp 上那个第三方扩展保持一致(话题 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 垫底 —— 主机要把首选那个地址发给同伴
踩过的坑:① 主机原先取「第一个非内部 IPv4」,装了 Radmin VPN 时拿到的是 VPN 地址,同伴永远连不上;② 连接判定原先走 /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.cjsdist-renderer-webpack/renderer-webpack/editor/openwarp-editor/index.jsx桌面版渲染层(ow-editor:// 协议加载,见 main/protocols.js)
webpack.web.cjsdist-web/renderer-webpack/web/index.jsxtarget:'web'、publicPath:''(挂域名根或子目录都能跑)
两份配置里 babel / file-loader / css 三条规则是**刻意重复**的(那份没导出 base)—— 改规则要两边一起改。

入口与桥

// 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>
AIKey 存主进程设置localStorage;生成逻辑复用 main/openwarp-ai.js(纯 fetch)
多窗口 / sb3 / 本地操作 / 命令调用有明确降级:多窗口只提示;sb3 提示用桌面版;本地操作与命令调用不挂方法 ⇒ 那两个扩展显示「非网站可用」,积木判到没有宿主方法就安静跳过

网页版的 platform 是 'web',扩展窗口的导航栏按它判断可用性。

目录与脚本

关键文件

路径作用
main/entrypoint.js / index.jsElectron 启动
main/windows/openwarp-editor.js编辑器窗口 + **全部 ow.* IPC** + .ow 导入导出
main/openwarp-project.js工程读写(格式权威):readProject / writeProject / createProject / 素材 / 路径沙箱 / 本地操作
main/openwarp-ow.js.ow 编解码(零依赖,Node + 浏览器共用)
main/openwarp-ai.jsAI 接口(纯 fetch,网页版也复用)
main/openwarp-command.js「命令调用」扩展(跑系统命令)
main/lan-relay.js局域网信令中继(本地 HTTP 服务)
main/protocols.jsow-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-desktopwebpack:compile / webpack:watch桌面版渲染层(改 App/组件后要跑)
webpack:prod生产版渲染层
web:build / web:watch / web:serve网页版构建 / 盯改动 / 本地起服务
start / electron:start编译 + 起 Electron
test6 个 Node 测试(主题 / 代码解析 / 积木图 / 运行时启动 / 摄像机 / sb3 转换 / 角色与工程)
check-requires检查主进程的本地 require 是否都能找到
build:secure主进程/preload 的加固(混淆 + bytenode 编译)
release生产渲染层 + 加固 + electron-builder 打包
OpenWarp-packagertest打包器测试
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.8Canvas3D.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 = 2main/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 里补同名实现,否则那个扩展就是"非网站可用"