diff --git a/AGENTS.md b/AGENTS.md index 30e7e1a..216ca0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,6 +301,112 @@ useAutoBlocker(block); navigation.pushOverlay('myoverlay', { x: 1 }); ``` +#### 为页面定义 FishFlow 可配置项(UI schema) + +如果这个 page 需要暴露给 FishFlow 做可视化配置,就在 `src/data/ui.ts` 中为该页面定义 schema,并生成根目录 `ui.schema.json`。 + +1. 先定义页面级 schema,再组合出根 schema: + + ```typescript + export const TitleUiSchema = z.object({ + // ... + }); + + export const GameUiSchema = z.object({ + title: TitleUiSchema, + }); + ``` + + 注意:`GameUiSchema` 顶层属性名(如 `title`)就是 page route name;FishFlow 会直接用它作为页面标识与预览跳转目标,改名会影响已有 UI 数据和页面路由。 + +2. 每个页面、字段和可选分支都要补足展示元数据: + + ```typescript + z.string().describe('Title screen background image').meta({ + title: 'Background', + format: 'asset', + 'x-asset-kind': 'image', + 'x-i18n': { 'zh-CN': '背景图' }, + 'x-i18n-desc': { 'zh-CN': '标题界面的背景图片' }, + }); + ``` + + 这些元数据会进入 `ui.schema.json`,由 FishFlow 用来生成字段标题、说明、资源选择器、颜色字段等编辑体验。 + +3. 分支结构优先使用 `z.discriminatedUnion()` + `z.literal()`: + + ```typescript + const ActionUiSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('gotoPage'), name: TitlePageNameSchema }), + z.object({ type: z.literal('pushOverlay'), name: TitleOverlayNameSchema }), + z.object({ type: z.literal('quit') }), + ]); + ``` + + 这里的 `type` 是分支判别字段,不是给最终用户自由编辑的业务字段;真正可配的输入应放在其他属性里(如 `name`)。 + +4. 运行时有自然默认值的字段,优先使用 `.optional().default(...)`: + + ```typescript + z.number().optional().default(500) + ``` + + FishFlow 当前会保留稀疏 `ui.json`,不会主动把所有默认值展开写回文件;默认值应由 schema / runtime 在读取时补齐。 + +5. `assets/data/ui.json` 是 framework 提供给项目的默认 UI 样例,修改 schema 结构时应同步维护它;如果 framework 暴露了 `ui.schema.json` 却没有可用的默认 `assets/data/ui.json`,FishFlow 会把它视为不完整配置并提示错误。 + +6. 每次修改 `src/data/ui.ts` 后,都要运行: + + ```bash + yarn generate:ui-schema + ``` + + 以刷新根目录 `ui.schema.json`。若字段结构变更,同时检查 `assets/data/ui.json` 和相关页面实现是否需要同步更新。 + +7. 若根 UI 数据结构发生变化,记得同步更新类型导出与 kit 注册: + + ```typescript + export type GameUiData = z.infer; + + declare module '@momoyu-ink/kit' { + interface RootUiDataMap extends GameUiData {} + } + ``` + +8. 在运行时消费这份数据时,优先通过 kit 提供的 UI data API,而不是自己手动读取 `ui.json`: + + - 入口初始化继续放在 `src/index.tsx`: + + ```typescript + import { initUiData } from '@momoyu-ink/kit'; + import { GameUiSchema } from './data/ui'; + + addEventListener('ready', () => { + void initUiData(GameUiSchema).then(() => { + const root = createRoot(); + root.render(
); + }); + }); + ``` + + 也就是说:`GameUiSchema` 不只是给 FishFlow 生成 `ui.schema.json` 用,runtime 自己也用它来校验和初始化 UI 数据。 + + - 在 page / overlay 组件里,通过 `useUiData('')` 读取对应页面的数据: + + ```typescript + import { useUiData } from '@momoyu-ink/kit'; + + export function Title() { + const titleUi = useUiData('title'); + + return ; + } + ``` + + `useUiData('title')` 返回值会按照 `RootUiDataMap` 推断出正确类型,并且在 debug 热替换后保持响应式更新。 + + - 如果只是命令式地读取一次数据(非 React render 路径),再考虑使用 `getUiData()`;组件渲染路径优先使用 `useUiData()`。 + ### 新增一个可复用组件 放 `components/`,仅使用 kit intrinsic element 与现有 hook(如 `useButton`): diff --git a/assets/data/ui.json b/assets/data/ui.json new file mode 100644 index 0000000..cde1406 --- /dev/null +++ b/assets/data/ui.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78f57ec7cd821b33a00e680a43ea5e1d6c15294faf1aab99f47eaaadff1eeaa7 +size 18923 diff --git a/commands.schema.json b/commands.schema.json index dc13ac0..13a6f0d 100644 --- a/commands.schema.json +++ b/commands.schema.json @@ -137,13 +137,14 @@ } }, "printMode": { - "description": "Text printing mode", + "default": "typewriter", + "description": "Default text printing mode", "title": "Print Mode", "x-i18n": { "zh-CN": "打字模式" }, "x-i18n-desc": { - "zh-CN": "文本打字模式" + "zh-CN": "正文默认的文字打印模式" }, "type": "string", "enum": [ @@ -153,51 +154,75 @@ ] }, "printSpeed": { - "description": "Print speed: chars/sec (typewriter) or lines/sec (printer)", + "default": 20, + "description": "Default print speed: chars/sec (typewriter) or lines/sec (printer)", "title": "Print Speed", + "x-step": 0.1, "x-i18n": { "zh-CN": "打字速度" }, "x-i18n-desc": { - "zh-CN": "逐字模式为字/秒,打印机模式为行/秒" + "zh-CN": "正文默认的打印速度;逐字模式为字/秒,打印机模式为行/秒" }, - "type": "number" + "type": "number", + "minimum": 0 + }, + "fontSize": { + "default": 32, + "description": "Text font size", + "title": "Font Size", + "x-i18n": { + "zh-CN": "字号" + }, + "x-i18n-desc": { + "zh-CN": "文本字号" + }, + "type": "number", + "minimum": 0 }, "fillColor": { + "default": "#f0f0f0", "description": "Text color", - "title": "Text Color", + "title": "Fill Color", "format": "color", "x-i18n": { "zh-CN": "文字颜色" }, "x-i18n-desc": { - "zh-CN": "文字颜色" + "zh-CN": "文字填充颜色" }, "type": "string" }, "lineHeight": { + "default": 1.5, "description": "Line height multiplier", "title": "Line Height", + "x-step": 0.1, "x-i18n": { "zh-CN": "行高" }, "x-i18n-desc": { "zh-CN": "行高倍数" }, - "type": "number" + "type": "number", + "minimum": 0 }, "indent": { + "default": 0, "description": "First-line indentation in pixels", "title": "Indent", + "x-step": 0.1, "x-i18n": { "zh-CN": "缩进" }, "x-i18n-desc": { "zh-CN": "段落首行缩进(像素)" }, - "type": "number" + "type": "number", + "minimum": 0 }, "stroke": { + "default": false, "description": "Whether to apply text stroke", "title": "Stroke", "x-i18n": { @@ -208,18 +233,8 @@ }, "type": "boolean" }, - "shadow": { - "description": "Whether to apply text shadow", - "title": "Shadow", - "x-i18n": { - "zh-CN": "阴影" - }, - "x-i18n-desc": { - "zh-CN": "是否启用文字阴影" - }, - "type": "boolean" - }, "strokeColor": { + "default": "#000000", "description": "Stroke color", "title": "Stroke Color", "format": "color", @@ -227,22 +242,38 @@ "zh-CN": "描边颜色" }, "x-i18n-desc": { - "zh-CN": "描边颜色" + "zh-CN": "文字描边颜色" }, "type": "string" }, "strokeWidth": { + "default": 2, "description": "Stroke width in pixels", "title": "Stroke Width", + "x-step": 0.01, "x-i18n": { "zh-CN": "描边宽度" }, "x-i18n-desc": { - "zh-CN": "描边宽度(像素)" + "zh-CN": "文字描边宽度(像素)" }, - "type": "number" + "type": "number", + "minimum": 0 + }, + "shadow": { + "default": false, + "description": "Whether to apply text shadow", + "title": "Shadow", + "x-i18n": { + "zh-CN": "阴影" + }, + "x-i18n-desc": { + "zh-CN": "是否启用文字阴影" + }, + "type": "boolean" }, "shadowColor": { + "default": "#000000", "description": "Shadow color", "title": "Shadow Color", "format": "color", @@ -250,13 +281,15 @@ "zh-CN": "阴影颜色" }, "x-i18n-desc": { - "zh-CN": "阴影颜色" + "zh-CN": "文字阴影颜色" }, "type": "string" }, "shadowOffsetX": { + "default": 0, "description": "Shadow X offset in pixels", "title": "Shadow Offset X", + "x-step": 0.01, "x-i18n": { "zh-CN": "阴影偏移 X" }, @@ -266,8 +299,10 @@ "type": "number" }, "shadowOffsetY": { + "default": 0, "description": "Shadow Y offset in pixels", "title": "Shadow Offset Y", + "x-step": 0.01, "x-i18n": { "zh-CN": "阴影偏移 Y" }, @@ -277,26 +312,32 @@ "type": "number" }, "shadowBlur": { + "default": 0, "description": "Shadow blur radius in pixels", "title": "Shadow Blur", + "x-step": 0.01, "x-i18n": { "zh-CN": "阴影模糊" }, "x-i18n-desc": { "zh-CN": "阴影模糊半径(像素)" }, - "type": "number" + "type": "number", + "minimum": 0 }, "shadowWidth": { + "default": 0, "description": "Shadow width in pixels, only effective when shadow is enabled", "title": "Shadow Width", + "x-step": 0.01, "x-i18n": { "zh-CN": "阴影宽度" }, "x-i18n-desc": { "zh-CN": "阴影宽度(像素),仅在启用阴影时生效" }, - "type": "number" + "type": "number", + "minimum": 0 } }, "required": [ @@ -311,6 +352,26 @@ "zh-CN": "配置文本框外观与行为" } }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "textBoxReset" + } + }, + "required": [ + "command" + ], + "description": "Reset textbox runtime style overrides", + "title": "Reset Text Box Style", + "x-i18n": { + "zh-CN": "重置文本框样式" + }, + "x-i18n-desc": { + "zh-CN": "清除文本框运行时样式覆盖,恢复使用 UI 默认值" + } + }, { "type": "object", "properties": { @@ -351,6 +412,192 @@ "zh-CN": "隐藏文本框" } }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "avatar" + }, + "src": { + "type": "string", + "description": "Image asset path", + "title": "Image", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "图片" + }, + "x-i18n-desc": { + "zh-CN": "图片资源路径" + } + }, + "enable": { + "description": "Whether to show the avatar; defaults to true when setting a source", + "title": "Enable", + "x-i18n": { + "zh-CN": "启用" + }, + "x-i18n-desc": { + "zh-CN": "是否显示头像;设置 src 时默认启用" + }, + "type": "boolean" + }, + "offsetX": { + "description": "Horizontal offset relative to the default avatar placement", + "title": "Offset X", + "x-step": 1, + "x-i18n": { + "zh-CN": "偏移 X" + }, + "x-i18n-desc": { + "zh-CN": "相对于默认头像摆放位置的水平偏移" + }, + "type": "number" + }, + "offsetY": { + "description": "Vertical offset relative to the default avatar placement", + "title": "Offset Y", + "x-step": 1, + "x-i18n": { + "zh-CN": "偏移 Y" + }, + "x-i18n-desc": { + "zh-CN": "相对于默认头像摆放位置的垂直偏移" + }, + "type": "number" + }, + "spacing": { + "description": "Horizontal retreat applied to the text and name area when the avatar is visible", + "title": "Spacing", + "x-step": 1, + "x-i18n": { + "zh-CN": "退让尺寸" + }, + "x-i18n-desc": { + "zh-CN": "头像可见时,文本区和姓名框在水平方向上的退让尺寸" + }, + "type": "number", + "minimum": 0 + } + }, + "required": [ + "command" + ], + "description": "Configure the textbox avatar", + "title": "Textbox Avatar", + "x-i18n": { + "zh-CN": "文本框头像" + }, + "x-i18n-desc": { + "zh-CN": "配置文本框头像及其布局参数" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "avatarFor" + }, + "character": { + "type": "string", + "description": "Character identifier", + "title": "Name", + "format": "character", + "x-i18n": { + "zh-CN": "角色名" + }, + "x-i18n-desc": { + "zh-CN": "角色标识符" + } + }, + "name": { + "description": "Optional avatar variant name matched by the third text leading slot", + "title": "Variant Name", + "x-i18n": { + "zh-CN": "头像名" + }, + "x-i18n-desc": { + "zh-CN": "可选的头像名,对应文本前导的第三个槽位" + }, + "type": "string" + }, + "src": { + "type": "string", + "description": "Image asset path", + "title": "Image", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "图片" + }, + "x-i18n-desc": { + "zh-CN": "图片资源路径" + } + }, + "enable": { + "description": "Whether to show the avatar; defaults to true when setting a source", + "title": "Enable", + "x-i18n": { + "zh-CN": "启用" + }, + "x-i18n-desc": { + "zh-CN": "是否显示头像;设置 src 时默认启用" + }, + "type": "boolean" + }, + "offsetX": { + "description": "Horizontal offset relative to the default avatar placement", + "title": "Offset X", + "x-step": 1, + "x-i18n": { + "zh-CN": "偏移 X" + }, + "x-i18n-desc": { + "zh-CN": "相对于默认头像摆放位置的水平偏移" + }, + "type": "number" + }, + "offsetY": { + "description": "Vertical offset relative to the default avatar placement", + "title": "Offset Y", + "x-step": 1, + "x-i18n": { + "zh-CN": "偏移 Y" + }, + "x-i18n-desc": { + "zh-CN": "相对于默认头像摆放位置的垂直偏移" + }, + "type": "number" + }, + "spacing": { + "description": "Horizontal retreat applied to the text and name area when the avatar is visible", + "title": "Spacing", + "x-step": 1, + "x-i18n": { + "zh-CN": "退让尺寸" + }, + "x-i18n-desc": { + "zh-CN": "头像可见时,文本区和姓名框在水平方向上的退让尺寸" + }, + "type": "number", + "minimum": 0 + } + }, + "required": [ + "command", + "character" + ], + "description": "Configure a textbox avatar for a specific character and optional avatar name", + "title": "Textbox Avatar For Character", + "x-i18n": { + "zh-CN": "角色文本框头像" + }, + "x-i18n-desc": { + "zh-CN": "为特定角色和可选头像名配置文本框头像" + } + }, { "type": "object", "properties": { @@ -393,6 +640,7 @@ "x-i18n-desc": { "zh-CN": "音量,范围 0 到 1" }, + "x-step": 0.01, "type": "number", "minimum": 0, "maximum": 1 @@ -419,7 +667,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -476,7 +727,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -557,6 +811,7 @@ "x-i18n-desc": { "zh-CN": "音量,范围 0 到 1" }, + "x-step": 0.01, "type": "number", "minimum": 0, "maximum": 1 @@ -583,7 +838,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -640,7 +898,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -720,6 +981,7 @@ "x-i18n-desc": { "zh-CN": "音量,范围 0 到 1" }, + "x-step": 0.01, "type": "number", "minimum": 0, "maximum": 1 @@ -834,6 +1096,7 @@ "x-i18n-desc": { "zh-CN": "音量,范围 0 到 1" }, + "x-step": 0.01, "type": "number", "minimum": 0, "maximum": 1 @@ -860,7 +1123,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -929,7 +1195,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -999,7 +1268,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1057,7 +1329,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1126,7 +1401,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1213,6 +1491,7 @@ "maximum": 2, "description": "Camera zoom ratio", "title": "Zoom", + "x-step": 0.01, "x-i18n": { "zh-CN": "镜头缩放" }, @@ -1221,9 +1500,8 @@ } }, "depth": { - "type": "number", - "minimum": 0, - "maximum": 1, + "default": 0, + "x-step": 0.01, "description": "Depth intensity from 0 to 1", "title": "Depth", "x-i18n": { @@ -1231,12 +1509,16 @@ }, "x-i18n-desc": { "zh-CN": "景深强度,范围 0 到 1" - } + }, + "type": "number", + "minimum": 0, + "maximum": 1 }, "blur": { "type": "number", "minimum": 0, "maximum": 8, + "multipleOf": 1, "description": "Background blur radius", "title": "Blur", "x-i18n": { @@ -1256,7 +1538,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1300,22 +1585,1717 @@ "properties": { "command": { "type": "string", - "const": "charEnter" + "const": "transPrepare" }, - "src": { + "retain": { + "default": "static", "type": "string", - "description": "Image asset path", - "title": "Image", - "format": "asset", - "x-asset-kind": "image", + "enum": [ + "static", + "live" + ], + "description": "How the previous scene should be retained before the transition starts", + "title": "Retain", "x-i18n": { - "zh-CN": "图片" + "zh-CN": "保留模式" }, "x-i18n-desc": { - "zh-CN": "图片资源路径" + "zh-CN": "在转场开始前如何保留旧场景" } + } + }, + "required": [ + "command" + ], + "description": "Prepare a full-scene transition while keeping the current scene visible", + "title": "Prepare Scene Transition", + "x-i18n": { + "zh-CN": "准备场景转场" + }, + "x-i18n-desc": { + "zh-CN": "准备整个场景的转场,但暂不开始播放" + } + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "default": "crossfade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + }, + "type": "string", + "const": "crossfade" + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command" + ] }, - "name": { + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "type": "string", + "const": "wipe", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "softness": { + "default": 0, + "description": "Edge feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "擦除边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "type": "string", + "const": "push", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "type": "string", + "const": "slideaway", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "type": "string", + "const": "fade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "in": { + "default": 0, + "x-step": 0.01, + "description": "Fade-in ratio from 0 to 1", + "title": "In", + "x-i18n": { + "zh-CN": "淡入占比" + }, + "x-i18n-desc": { + "zh-CN": "新画面从纯色淡入阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "hold": { + "default": 0, + "x-step": 0.01, + "description": "Hold ratio from 0 to 1", + "title": "Hold", + "x-i18n": { + "zh-CN": "停留占比" + }, + "x-i18n-desc": { + "zh-CN": "纯色画面停留阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "out": { + "default": 0, + "x-step": 0.01, + "description": "Fade-out ratio from 0 to 1", + "title": "Out", + "x-i18n": { + "zh-CN": "淡出占比" + }, + "x-i18n-desc": { + "zh-CN": "旧画面淡出到纯色阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "color": { + "default": "#000", + "description": "Fade color", + "title": "Color", + "format": "color", + "x-i18n": { + "zh-CN": "颜色" + }, + "x-i18n-desc": { + "zh-CN": "淡出与停留阶段使用的颜色" + }, + "type": "string" + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "type": "string", + "const": "zoom", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "startScale": { + "default": 0, + "description": "Initial display scale of the incoming scene", + "title": "Start Scale", + "x-i18n": { + "zh-CN": "起始缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的起始显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "endScale": { + "default": 1, + "description": "Final display scale of the incoming scene", + "title": "End Scale", + "x-i18n": { + "zh-CN": "结束缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的结束显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "origin": { + "default": [ + 0.5, + 0.5 + ], + "description": "Zoom origin normalized to the screen, from 0 to 1", + "title": "Origin", + "x-i18n": { + "zh-CN": "缩放原点" + }, + "x-i18n-desc": { + "zh-CN": "按屏幕归一化的缩放原点,范围 0 到 1" + }, + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point" + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "type": "string", + "const": "pixellate", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "steps": { + "default": 4, + "description": "Pixel block size exponent, where 4 means 16x16 blocks", + "title": "Steps", + "x-i18n": { + "zh-CN": "像素化步数" + }, + "x-i18n-desc": { + "zh-CN": "像素块大小的 2 次幂指数,例如 4 表示 16x16 像素块" + }, + "type": "number", + "minimum": 0 + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "transPerform" + }, + "effect": { + "type": "string", + "const": "mask", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "rule": { + "type": "string", + "description": "Mask rule image", + "title": "Rule", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "规则图" + }, + "x-i18n-desc": { + "zh-CN": "用于遮罩转场的规则图资源" + } + }, + "softness": { + "default": 0.0625, + "description": "Mask feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "遮罩边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reverse": { + "default": false, + "description": "Whether to reverse the rule order", + "title": "Reverse", + "x-i18n": { + "zh-CN": "反转" + }, + "x-i18n-desc": { + "zh-CN": "是否反转黑白出现顺序" + }, + "type": "boolean" + }, + "fadeTime": { + "default": 1000, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": false, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect", + "rule" + ] + } + ], + "description": "Start a prepared full-scene transition", + "title": "Start Scene Transition", + "x-i18n": { + "zh-CN": "执行场景转场" + }, + "x-i18n-desc": { + "zh-CN": "启动已准备好的整个场景转场" + } + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "default": "crossfade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + }, + "type": "string", + "const": "crossfade" + } + }, + "required": [ + "command" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "type": "string", + "const": "wipe", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "softness": { + "default": 0, + "description": "Edge feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "擦除边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "type": "string", + "const": "push", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "type": "string", + "const": "slideaway", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "type": "string", + "const": "fade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "in": { + "default": 0, + "x-step": 0.01, + "description": "Fade-in ratio from 0 to 1", + "title": "In", + "x-i18n": { + "zh-CN": "淡入占比" + }, + "x-i18n-desc": { + "zh-CN": "新画面从纯色淡入阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "hold": { + "default": 0, + "x-step": 0.01, + "description": "Hold ratio from 0 to 1", + "title": "Hold", + "x-i18n": { + "zh-CN": "停留占比" + }, + "x-i18n-desc": { + "zh-CN": "纯色画面停留阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "out": { + "default": 0, + "x-step": 0.01, + "description": "Fade-out ratio from 0 to 1", + "title": "Out", + "x-i18n": { + "zh-CN": "淡出占比" + }, + "x-i18n-desc": { + "zh-CN": "旧画面淡出到纯色阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "color": { + "default": "#000", + "description": "Fade color", + "title": "Color", + "format": "color", + "x-i18n": { + "zh-CN": "颜色" + }, + "x-i18n-desc": { + "zh-CN": "淡出与停留阶段使用的颜色" + }, + "type": "string" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "type": "string", + "const": "zoom", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "startScale": { + "default": 0, + "description": "Initial display scale of the incoming scene", + "title": "Start Scale", + "x-i18n": { + "zh-CN": "起始缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的起始显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "endScale": { + "default": 1, + "description": "Final display scale of the incoming scene", + "title": "End Scale", + "x-i18n": { + "zh-CN": "结束缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的结束显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "origin": { + "default": [ + 0.5, + 0.5 + ], + "description": "Zoom origin normalized to the screen, from 0 to 1", + "title": "Origin", + "x-i18n": { + "zh-CN": "缩放原点" + }, + "x-i18n-desc": { + "zh-CN": "按屏幕归一化的缩放原点,范围 0 到 1" + }, + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "type": "string", + "const": "pixellate", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "steps": { + "default": 4, + "description": "Pixel block size exponent, where 4 means 16x16 blocks", + "title": "Steps", + "x-i18n": { + "zh-CN": "像素化步数" + }, + "x-i18n-desc": { + "zh-CN": "像素块大小的 2 次幂指数,例如 4 表示 16x16 像素块" + }, + "type": "number", + "minimum": 0 + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "bgTransEffect" + }, + "effect": { + "type": "string", + "const": "mask", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "rule": { + "type": "string", + "description": "Mask rule image", + "title": "Rule", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "规则图" + }, + "x-i18n-desc": { + "zh-CN": "用于遮罩转场的规则图资源" + } + }, + "softness": { + "default": 0.0625, + "description": "Mask feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "遮罩边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reverse": { + "default": false, + "description": "Whether to reverse the rule order", + "title": "Reverse", + "x-i18n": { + "zh-CN": "反转" + }, + "x-i18n-desc": { + "zh-CN": "是否反转黑白出现顺序" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect", + "rule" + ] + } + ], + "description": "Set the transition effect used by background changes", + "title": "Set Background Transition Effect", + "x-i18n": { + "zh-CN": "设置背景转场效果" + }, + "x-i18n-desc": { + "zh-CN": "设置背景切换时使用的转场效果" + } + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "default": "crossfade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + }, + "type": "string", + "const": "crossfade" + } + }, + "required": [ + "command" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "type": "string", + "const": "wipe", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "softness": { + "default": 0, + "description": "Edge feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "擦除边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "type": "string", + "const": "push", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "type": "string", + "const": "slideaway", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "type": "string", + "const": "fade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "in": { + "default": 0, + "x-step": 0.01, + "description": "Fade-in ratio from 0 to 1", + "title": "In", + "x-i18n": { + "zh-CN": "淡入占比" + }, + "x-i18n-desc": { + "zh-CN": "新画面从纯色淡入阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "hold": { + "default": 0, + "x-step": 0.01, + "description": "Hold ratio from 0 to 1", + "title": "Hold", + "x-i18n": { + "zh-CN": "停留占比" + }, + "x-i18n-desc": { + "zh-CN": "纯色画面停留阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "out": { + "default": 0, + "x-step": 0.01, + "description": "Fade-out ratio from 0 to 1", + "title": "Out", + "x-i18n": { + "zh-CN": "淡出占比" + }, + "x-i18n-desc": { + "zh-CN": "旧画面淡出到纯色阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "color": { + "default": "#000", + "description": "Fade color", + "title": "Color", + "format": "color", + "x-i18n": { + "zh-CN": "颜色" + }, + "x-i18n-desc": { + "zh-CN": "淡出与停留阶段使用的颜色" + }, + "type": "string" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "type": "string", + "const": "zoom", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "startScale": { + "default": 0, + "description": "Initial display scale of the incoming scene", + "title": "Start Scale", + "x-i18n": { + "zh-CN": "起始缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的起始显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "endScale": { + "default": 1, + "description": "Final display scale of the incoming scene", + "title": "End Scale", + "x-i18n": { + "zh-CN": "结束缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的结束显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "origin": { + "default": [ + 0.5, + 0.5 + ], + "description": "Zoom origin normalized to the screen, from 0 to 1", + "title": "Origin", + "x-i18n": { + "zh-CN": "缩放原点" + }, + "x-i18n-desc": { + "zh-CN": "按屏幕归一化的缩放原点,范围 0 到 1" + }, + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point" + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "type": "string", + "const": "pixellate", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "steps": { + "default": 4, + "description": "Pixel block size exponent, where 4 means 16x16 blocks", + "title": "Steps", + "x-i18n": { + "zh-CN": "像素化步数" + }, + "x-i18n-desc": { + "zh-CN": "像素块大小的 2 次幂指数,例如 4 表示 16x16 像素块" + }, + "type": "number", + "minimum": 0 + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charTransEffect" + }, + "effect": { + "type": "string", + "const": "mask", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "rule": { + "type": "string", + "description": "Mask rule image", + "title": "Rule", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "规则图" + }, + "x-i18n-desc": { + "zh-CN": "用于遮罩转场的规则图资源" + } + }, + "softness": { + "default": 0.0625, + "description": "Mask feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "遮罩边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reverse": { + "default": false, + "description": "Whether to reverse the rule order", + "title": "Reverse", + "x-i18n": { + "zh-CN": "反转" + }, + "x-i18n-desc": { + "zh-CN": "是否反转黑白出现顺序" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "effect", + "rule" + ] + } + ], + "description": "Set the transition effect used by character changes", + "title": "Set Character Transition Effect", + "x-i18n": { + "zh-CN": "设置角色转场效果" + }, + "x-i18n-desc": { + "zh-CN": "设置角色切换时使用的转场效果" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "charEnter" + }, + "src": { + "type": "string", + "description": "Image asset path", + "title": "Image", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "图片" + }, + "x-i18n-desc": { + "zh-CN": "图片资源路径" + } + }, + "name": { "type": "string", "description": "Character identifier", "title": "Name", @@ -1371,7 +3351,8 @@ } }, "scale": { - "type": "number", + "default": 1, + "x-step": 0.01, "description": "Scale factor", "title": "Scale", "x-i18n": { @@ -1379,7 +3360,10 @@ }, "x-i18n-desc": { "zh-CN": "缩放系数" - } + }, + "type": "number", + "minimum": 0, + "maximum": 1 }, "tint": { "type": "string", @@ -1397,12 +3381,21 @@ "type": "array", "prefixItems": [ { - "type": "number" + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 }, { - "type": "number" + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 } ], + "format": "point", "description": "Pivot point (x, y) in pixels", "title": "Pivot", "x-i18n": { @@ -1422,7 +3415,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1538,7 +3534,8 @@ } }, "scale": { - "type": "number", + "default": 1, + "x-step": 0.01, "description": "Scale factor", "title": "Scale", "x-i18n": { @@ -1546,7 +3543,10 @@ }, "x-i18n-desc": { "zh-CN": "缩放系数" - } + }, + "type": "number", + "minimum": 0, + "maximum": 1 }, "tint": { "type": "string", @@ -1564,12 +3564,21 @@ "type": "array", "prefixItems": [ { - "type": "number" + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 }, { - "type": "number" + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 } ], + "format": "point", "description": "Pivot point (x, y) in pixels", "title": "Pivot", "x-i18n": { @@ -1589,7 +3598,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1657,7 +3669,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1713,7 +3728,10 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 }, "skippable": { "default": false, @@ -1831,7 +3849,8 @@ } }, "scale": { - "type": "number", + "default": 1, + "x-step": 0.01, "description": "Scale factor", "title": "Scale", "x-i18n": { @@ -1839,7 +3858,10 @@ }, "x-i18n-desc": { "zh-CN": "缩放系数" - } + }, + "type": "number", + "minimum": 0, + "maximum": 1 }, "tint": { "type": "string", @@ -1868,12 +3890,21 @@ "type": "array", "prefixItems": [ { - "type": "number" + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 }, { - "type": "number" + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 } ], + "format": "point", "description": "Pivot point (x, y) in pixels", "title": "Pivot", "x-i18n": { @@ -1892,7 +3923,11 @@ "x-i18n-desc": { "zh-CN": "渐变时间(毫秒)" }, - "type": "number" + "default": 0, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 } }, "required": [ @@ -1956,43 +3991,313 @@ "properties": { "command": { "type": "string", - "const": "wait" + "const": "sprite" }, - "time": { + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + }, + "src": { + "type": "string", + "description": "Sprite asset path", + "title": "Asset", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "资源" + }, + "x-i18n-desc": { + "zh-CN": "精灵资源路径;当前 schema 按 image asset 处理" + } + }, + "kind": { + "type": "string", + "enum": [ + "image", + "video", + "animation" + ], + "description": "Sprite resource kind", + "title": "Kind", + "x-i18n": { + "zh-CN": "资源类型" + }, + "x-i18n-desc": { + "zh-CN": "精灵资源类型,可选 image / video / animation" + } + }, + "animationFormat": { + "type": "string", + "enum": [ + "apng", + "webp" + ], + "description": "Animation format", + "title": "Animation Format", + "x-i18n": { + "zh-CN": "动画格式" + }, + "x-i18n-desc": { + "zh-CN": "动画格式,可选 apng / webp" + } + }, + "parent": { + "type": "string", + "description": "Parent sprite node name", + "title": "Parent", + "x-i18n": { + "zh-CN": "父节点" + }, + "x-i18n-desc": { + "zh-CN": "父精灵节点名称" + } + }, + "x": { "type": "number", - "description": "Wait duration in milliseconds", - "title": "Duration", + "description": "X coordinate", + "title": "X", + "x-i18n-desc": { + "zh-CN": "X 坐标" + } + }, + "y": { + "type": "number", + "description": "Y coordinate", + "title": "Y", + "x-i18n-desc": { + "zh-CN": "Y 坐标" + } + }, + "scaleX": { + "type": "number", + "description": "Horizontal scale factor", + "title": "Scale X", "x-i18n": { - "zh-CN": "时长" + "zh-CN": "横向缩放" }, "x-i18n-desc": { - "zh-CN": "等待时长(毫秒)" + "zh-CN": "横向缩放系数" + } + }, + "scaleY": { + "type": "number", + "description": "Vertical scale factor", + "title": "Scale Y", + "x-i18n": { + "zh-CN": "纵向缩放" + }, + "x-i18n-desc": { + "zh-CN": "纵向缩放系数" + } + }, + "rotation": { + "type": "number", + "description": "Rotation in radians", + "title": "Rotation", + "x-i18n": { + "zh-CN": "旋转" + }, + "x-i18n-desc": { + "zh-CN": "旋转角度(弧度)" + } + }, + "skewX": { + "type": "number", + "description": "Horizontal skew in radians", + "title": "Skew X", + "x-i18n": { + "zh-CN": "横向斜切" + }, + "x-i18n-desc": { + "zh-CN": "横向斜切角度(弧度)" + } + }, + "skewY": { + "type": "number", + "description": "Vertical skew in radians", + "title": "Skew Y", + "x-i18n": { + "zh-CN": "纵向斜切" + }, + "x-i18n-desc": { + "zh-CN": "纵向斜切角度(弧度)" + } + }, + "anchor": { + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point", + "description": "Anchor point normalized to the node, from 0 to 1", + "title": "Anchor", + "x-i18n": { + "zh-CN": "锚点" + }, + "x-i18n-desc": { + "zh-CN": "锚点坐标 (x, y),通常范围 0 到 1" + } + }, + "pivot": { + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point", + "description": "Pivot point (x, y) in pixels", + "title": "Pivot", + "x-i18n": { + "zh-CN": "旋转中心" + }, + "x-i18n-desc": { + "zh-CN": "旋转中心点 (x, y),单位像素" + } + }, + "opacity": { + "default": 0, + "x-step": 0.01, + "description": "Opacity from 0 to 1", + "title": "Opacity", + "x-i18n": { + "zh-CN": "透明度" + }, + "x-i18n-desc": { + "zh-CN": "透明度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "visible": { + "type": "boolean", + "description": "Whether visible", + "title": "Visible", + "x-i18n": { + "zh-CN": "可见" + }, + "x-i18n-desc": { + "zh-CN": "是否可见" + } + }, + "tint": { + "type": "string", + "description": "Color tint", + "title": "Tint", + "format": "color", + "x-i18n": { + "zh-CN": "色调" + }, + "x-i18n-desc": { + "zh-CN": "颜色色调" + } + }, + "interactive": { + "type": "boolean", + "description": "Whether the node is interactive", + "title": "Interactive", + "x-i18n": { + "zh-CN": "可交互" + }, + "x-i18n-desc": { + "zh-CN": "是否标记为可交互节点" + } + }, + "zIndex": { + "type": "number", + "description": "Sibling order under the same parent", + "title": "Z Index", + "x-i18n": { + "zh-CN": "层级顺序" + }, + "x-i18n-desc": { + "zh-CN": "同一父节点下的层级顺序" } }, + "fadeTime": { + "default": 500, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, "skippable": { "default": false, - "description": "Whether the wait can be skipped", + "description": "Whether it can be skipped", "title": "Skippable", "x-i18n": { "zh-CN": "可跳过" }, "x-i18n-desc": { - "zh-CN": "是否可跳过等待" + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": true, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" }, "type": "boolean" } }, "required": [ "command", - "time" + "name", + "src" ], - "description": "Wait for a specified duration", - "title": "Wait", + "description": "Create a sprite node or update it when the name already exists", + "title": "Create Sprite", "x-i18n": { - "zh-CN": "等待" + "zh-CN": "创建精灵" }, "x-i18n-desc": { - "zh-CN": "等待指定时长" + "zh-CN": "创建一个自由精灵节点;若同名已存在,则按局部更新处理" } }, { @@ -2000,19 +4305,1251 @@ "properties": { "command": { "type": "string", - "const": "waitClick" - } - }, - "required": [ - "command" - ], - "description": "Wait for a player click", - "title": "Wait for Click", - "x-i18n": { - "zh-CN": "等待点击" - }, - "x-i18n-desc": { - "zh-CN": "等待玩家点击" + "const": "spriteChange" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + }, + "src": { + "type": "string", + "description": "Sprite asset path", + "title": "Asset", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "资源" + }, + "x-i18n-desc": { + "zh-CN": "精灵资源路径;当前 schema 按 image asset 处理" + } + }, + "kind": { + "type": "string", + "enum": [ + "image", + "video", + "animation" + ], + "description": "Sprite resource kind", + "title": "Kind", + "x-i18n": { + "zh-CN": "资源类型" + }, + "x-i18n-desc": { + "zh-CN": "精灵资源类型,可选 image / video / animation" + } + }, + "animationFormat": { + "type": "string", + "enum": [ + "apng", + "webp" + ], + "description": "Animation format", + "title": "Animation Format", + "x-i18n": { + "zh-CN": "动画格式" + }, + "x-i18n-desc": { + "zh-CN": "动画格式,可选 apng / webp" + } + }, + "x": { + "type": "number", + "description": "X coordinate", + "title": "X", + "x-i18n-desc": { + "zh-CN": "X 坐标" + } + }, + "y": { + "type": "number", + "description": "Y coordinate", + "title": "Y", + "x-i18n-desc": { + "zh-CN": "Y 坐标" + } + }, + "scaleX": { + "type": "number", + "description": "Horizontal scale factor", + "title": "Scale X", + "x-i18n": { + "zh-CN": "横向缩放" + }, + "x-i18n-desc": { + "zh-CN": "横向缩放系数" + } + }, + "scaleY": { + "type": "number", + "description": "Vertical scale factor", + "title": "Scale Y", + "x-i18n": { + "zh-CN": "纵向缩放" + }, + "x-i18n-desc": { + "zh-CN": "纵向缩放系数" + } + }, + "rotation": { + "type": "number", + "description": "Rotation in radians", + "title": "Rotation", + "x-i18n": { + "zh-CN": "旋转" + }, + "x-i18n-desc": { + "zh-CN": "旋转角度(弧度)" + } + }, + "skewX": { + "type": "number", + "description": "Horizontal skew in radians", + "title": "Skew X", + "x-i18n": { + "zh-CN": "横向斜切" + }, + "x-i18n-desc": { + "zh-CN": "横向斜切角度(弧度)" + } + }, + "skewY": { + "type": "number", + "description": "Vertical skew in radians", + "title": "Skew Y", + "x-i18n": { + "zh-CN": "纵向斜切" + }, + "x-i18n-desc": { + "zh-CN": "纵向斜切角度(弧度)" + } + }, + "anchor": { + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point", + "description": "Anchor point normalized to the node, from 0 to 1", + "title": "Anchor", + "x-i18n": { + "zh-CN": "锚点" + }, + "x-i18n-desc": { + "zh-CN": "锚点坐标 (x, y),通常范围 0 到 1" + } + }, + "pivot": { + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point", + "description": "Pivot point (x, y) in pixels", + "title": "Pivot", + "x-i18n": { + "zh-CN": "旋转中心" + }, + "x-i18n-desc": { + "zh-CN": "旋转中心点 (x, y),单位像素" + } + }, + "opacity": { + "default": 0, + "x-step": 0.01, + "description": "Opacity from 0 to 1", + "title": "Opacity", + "x-i18n": { + "zh-CN": "透明度" + }, + "x-i18n-desc": { + "zh-CN": "透明度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "visible": { + "type": "boolean", + "description": "Whether visible", + "title": "Visible", + "x-i18n": { + "zh-CN": "可见" + }, + "x-i18n-desc": { + "zh-CN": "是否可见" + } + }, + "tint": { + "type": "string", + "description": "Color tint", + "title": "Tint", + "format": "color", + "x-i18n": { + "zh-CN": "色调" + }, + "x-i18n-desc": { + "zh-CN": "颜色色调" + } + }, + "interactive": { + "type": "boolean", + "description": "Whether the node is interactive", + "title": "Interactive", + "x-i18n": { + "zh-CN": "可交互" + }, + "x-i18n-desc": { + "zh-CN": "是否标记为可交互节点" + } + }, + "zIndex": { + "type": "number", + "description": "Sibling order under the same parent", + "title": "Z Index", + "x-i18n": { + "zh-CN": "层级顺序" + }, + "x-i18n-desc": { + "zh-CN": "同一父节点下的层级顺序" + } + }, + "fadeTime": { + "default": 500, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": true, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "name" + ], + "description": "Change an existing sprite node", + "title": "Change Sprite", + "x-i18n": { + "zh-CN": "修改精灵" + }, + "x-i18n-desc": { + "zh-CN": "修改一个已存在的自由精灵节点" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteRemove" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + }, + "fadeTime": { + "default": 500, + "description": "Fade time in milliseconds", + "title": "Fade Time", + "x-i18n": { + "zh-CN": "渐变时间" + }, + "x-i18n-desc": { + "zh-CN": "渐变时间(毫秒)" + }, + "x-step": 50, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether it can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过" + }, + "type": "boolean" + }, + "noWait": { + "default": true, + "description": "Whether to skip waiting for the transition to complete", + "title": "No Wait", + "x-i18n": { + "zh-CN": "不等待" + }, + "x-i18n-desc": { + "zh-CN": "是否跳过等待过渡完成" + }, + "type": "boolean" + } + }, + "required": [ + "command", + "name" + ], + "description": "Remove a sprite node and all of its descendants", + "title": "Remove Sprite", + "x-i18n": { + "zh-CN": "移除精灵" + }, + "x-i18n-desc": { + "zh-CN": "移除一个自由精灵节点及其整棵子树" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteMove" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + }, + "toParent": { + "type": "string", + "description": "Parent sprite node name", + "title": "Parent", + "x-i18n": { + "zh-CN": "父节点" + }, + "x-i18n-desc": { + "zh-CN": "父精灵节点名称" + } + }, + "zIndex": { + "type": "number", + "description": "Sibling order under the same parent", + "title": "Z Index", + "x-i18n": { + "zh-CN": "层级顺序" + }, + "x-i18n-desc": { + "zh-CN": "同一父节点下的层级顺序" + } + } + }, + "required": [ + "command", + "name" + ], + "description": "Move a sprite node to another parent or to the root layer", + "title": "Move Sprite", + "x-i18n": { + "zh-CN": "移动精灵" + }, + "x-i18n-desc": { + "zh-CN": "移动自由精灵节点到新的父节点或根层" + } + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "default": "crossfade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + }, + "type": "string", + "const": "crossfade" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "type": "string", + "const": "wipe", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "softness": { + "default": 0, + "description": "Edge feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "擦除边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "type": "string", + "const": "push", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "type": "string", + "const": "slideaway", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "direction": { + "default": "left", + "description": "Transition direction", + "title": "Direction", + "x-i18n": { + "zh-CN": "方向" + }, + "x-i18n-desc": { + "zh-CN": "转场方向" + }, + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "type": "string", + "const": "fade", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "in": { + "default": 0, + "x-step": 0.01, + "description": "Fade-in ratio from 0 to 1", + "title": "In", + "x-i18n": { + "zh-CN": "淡入占比" + }, + "x-i18n-desc": { + "zh-CN": "新画面从纯色淡入阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "hold": { + "default": 0, + "x-step": 0.01, + "description": "Hold ratio from 0 to 1", + "title": "Hold", + "x-i18n": { + "zh-CN": "停留占比" + }, + "x-i18n-desc": { + "zh-CN": "纯色画面停留阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "out": { + "default": 0, + "x-step": 0.01, + "description": "Fade-out ratio from 0 to 1", + "title": "Out", + "x-i18n": { + "zh-CN": "淡出占比" + }, + "x-i18n-desc": { + "zh-CN": "旧画面淡出到纯色阶段的时长占比,预期范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "color": { + "default": "#000", + "description": "Fade color", + "title": "Color", + "format": "color", + "x-i18n": { + "zh-CN": "颜色" + }, + "x-i18n-desc": { + "zh-CN": "淡出与停留阶段使用的颜色" + }, + "type": "string" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "type": "string", + "const": "zoom", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "startScale": { + "default": 0, + "description": "Initial display scale of the incoming scene", + "title": "Start Scale", + "x-i18n": { + "zh-CN": "起始缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的起始显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "endScale": { + "default": 1, + "description": "Final display scale of the incoming scene", + "title": "End Scale", + "x-i18n": { + "zh-CN": "结束缩放" + }, + "x-i18n-desc": { + "zh-CN": "新画面的结束显示缩放" + }, + "type": "number", + "minimum": 0 + }, + "origin": { + "default": [ + 0.5, + 0.5 + ], + "description": "Zoom origin normalized to the screen, from 0 to 1", + "title": "Origin", + "x-i18n": { + "zh-CN": "缩放原点" + }, + "x-i18n-desc": { + "zh-CN": "按屏幕归一化的缩放原点,范围 0 到 1" + }, + "type": "array", + "prefixItems": [ + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "default": 0, + "x-step": 0.01, + "type": "number", + "minimum": 0, + "maximum": 1 + } + ], + "format": "point" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "type": "string", + "const": "pixellate", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "steps": { + "default": 4, + "description": "Pixel block size exponent, where 4 means 16x16 blocks", + "title": "Steps", + "x-i18n": { + "zh-CN": "像素化步数" + }, + "x-i18n-desc": { + "zh-CN": "像素块大小的 2 次幂指数,例如 4 表示 16x16 像素块" + }, + "type": "number", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command", + "effect" + ] + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffect" + }, + "effect": { + "type": "string", + "const": "mask", + "description": "Transition effect", + "title": "Effect", + "x-i18n": { + "zh-CN": "效果" + }, + "x-i18n-desc": { + "zh-CN": "转场效果" + } + }, + "rule": { + "type": "string", + "description": "Mask rule image", + "title": "Rule", + "format": "asset", + "x-asset-kind": "image", + "x-i18n": { + "zh-CN": "规则图" + }, + "x-i18n-desc": { + "zh-CN": "用于遮罩转场的规则图资源" + } + }, + "softness": { + "default": 0.0625, + "description": "Mask feather ratio from 0 to 1", + "title": "Softness", + "x-i18n": { + "zh-CN": "羽化" + }, + "x-i18n-desc": { + "zh-CN": "遮罩边缘的羽化程度,范围 0 到 1" + }, + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reverse": { + "default": false, + "description": "Whether to reverse the rule order", + "title": "Reverse", + "x-i18n": { + "zh-CN": "反转" + }, + "x-i18n-desc": { + "zh-CN": "是否反转黑白出现顺序" + }, + "type": "boolean" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command", + "effect", + "rule" + ] + } + ], + "description": "Set the transition effect used by free sprite changes", + "title": "Set Sprite Transition Effect", + "x-i18n": { + "zh-CN": "设置精灵转场效果" + }, + "x-i18n-desc": { + "zh-CN": "设置自由精灵层或单个精灵节点使用的转场效果" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "spriteTransEffectReset" + }, + "name": { + "type": "string", + "description": "Sprite node name", + "title": "Name", + "x-i18n": { + "zh-CN": "名称" + }, + "x-i18n-desc": { + "zh-CN": "精灵节点名称,要求全局唯一" + } + } + }, + "required": [ + "command" + ], + "description": "Reset free sprite transition effect override to the default effect", + "title": "Reset Sprite Transition Effect", + "x-i18n": { + "zh-CN": "重置精灵转场效果" + }, + "x-i18n-desc": { + "zh-CN": "重置自由精灵层或单个精灵节点的转场效果配置" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "wait" + }, + "time": { + "default": 0, + "x-step": 50, + "description": "Wait duration in milliseconds", + "title": "Duration", + "x-i18n": { + "zh-CN": "时长" + }, + "x-i18n-desc": { + "zh-CN": "等待时长(毫秒)" + }, + "type": "number", + "minimum": 0, + "multipleOf": 1 + }, + "skippable": { + "default": false, + "description": "Whether the wait can be skipped", + "title": "Skippable", + "x-i18n": { + "zh-CN": "可跳过" + }, + "x-i18n-desc": { + "zh-CN": "是否可跳过等待" + }, + "type": "boolean" + } + }, + "required": [ + "command" + ], + "description": "Wait for a specified duration", + "title": "Wait", + "x-i18n": { + "zh-CN": "等待" + }, + "x-i18n-desc": { + "zh-CN": "等待指定时长" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "waitClick" + } + }, + "required": [ + "command" + ], + "description": "Wait for a player click", + "title": "Wait for Click", + "x-i18n": { + "zh-CN": "等待点击" + }, + "x-i18n-desc": { + "zh-CN": "等待玩家点击" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "achievementSet" + }, + "name": { + "type": "string", + "description": "Achievement API name configured in Steamworks", + "title": "Achievement Name", + "x-i18n": { + "zh-CN": "成就名称" + }, + "x-i18n-desc": { + "zh-CN": "在 Steamworks 中配置的成就 API 名称" + } + } + }, + "required": [ + "command", + "name" + ], + "description": "Unlock a Steam achievement", + "title": "Unlock Steam Achievement", + "x-i18n": { + "zh-CN": "解锁 Steam 成就" + }, + "x-i18n-desc": { + "zh-CN": "解锁指定 Steam 成就并立即提交状态" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "achievementClear" + }, + "name": { + "type": "string", + "description": "Achievement API name configured in Steamworks", + "title": "Achievement Name", + "x-i18n": { + "zh-CN": "成就名称" + }, + "x-i18n-desc": { + "zh-CN": "在 Steamworks 中配置的成就 API 名称" + } + } + }, + "required": [ + "command", + "name" + ], + "description": "Clear a Steam achievement", + "title": "Clear Steam Achievement", + "x-i18n": { + "zh-CN": "清除 Steam 成就" + }, + "x-i18n-desc": { + "zh-CN": "清除指定 Steam 成就并立即提交状态" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "achievementClearAll" + } + }, + "required": [ + "command" + ], + "description": "Clear all Steam achievements", + "title": "Clear All Steam Achievements", + "x-i18n": { + "zh-CN": "清除全部 Steam 成就" + }, + "x-i18n-desc": { + "zh-CN": "清除全部 Steam 成就并立即提交状态" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "achievementGet" + }, + "name": { + "type": "string", + "description": "Achievement API name configured in Steamworks", + "title": "Achievement Name", + "x-i18n": { + "zh-CN": "成就名称" + }, + "x-i18n-desc": { + "zh-CN": "在 Steamworks 中配置的成就 API 名称" + } + }, + "saveTo": { + "type": "string", + "description": "Local variable name to save the achievement state to", + "title": "Save To", + "x-i18n": { + "zh-CN": "局部变量" + }, + "x-i18n-desc": { + "zh-CN": "保存成就是否已解锁的局部变量名" + } + } + }, + "required": [ + "command", + "name", + "saveTo" + ], + "description": "Get whether a Steam achievement is unlocked", + "title": "Get Steam Achievement", + "x-i18n": { + "zh-CN": "查询 Steam 成就" + }, + "x-i18n-desc": { + "zh-CN": "查询指定 Steam 成就是否已解锁" + } + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "const": "achievementIndicateProgress" + }, + "name": { + "type": "string", + "description": "Achievement API name configured in Steamworks", + "title": "Achievement Name", + "x-i18n": { + "zh-CN": "成就名称" + }, + "x-i18n-desc": { + "zh-CN": "在 Steamworks 中配置的成就 API 名称" + } + }, + "current": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Current achievement progress", + "title": "Current Progress", + "x-step": 1, + "x-i18n": { + "zh-CN": "当前进度" + }, + "x-i18n-desc": { + "zh-CN": "当前成就进度,必须小于最大进度" + } + }, + "max": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Maximum achievement progress", + "title": "Maximum Progress", + "x-step": 1, + "x-i18n": { + "zh-CN": "最大进度" + }, + "x-i18n-desc": { + "zh-CN": "成就最大进度,必须大于 0" + } + } + }, + "required": [ + "command", + "name", + "current", + "max" + ], + "description": "Show Steam achievement progress without storing it", + "title": "Indicate Steam Achievement Progress", + "x-i18n": { + "zh-CN": "显示 Steam 成就进度" + }, + "x-i18n-desc": { + "zh-CN": "显示指定 Steam 成就的进度提示,但不保存进度值" } }, { diff --git a/framework.json b/framework.json index 2919880..6e61804 100644 --- a/framework.json +++ b/framework.json @@ -3,6 +3,6 @@ "title": "标准模板", "description": "标准模板,适合大多数视觉小说项目使用。", "author": "Icemic", - "version": "0.1.0-alpha", - "assets": ["ui", "fonts", "audio/**/*"] + "version": "0.5.2", + "assets": ["*.*", "data", "ui", "fonts", "audio/**/*"] } diff --git a/package.json b/package.json index fd1b277..fc32725 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@momoyu-ink/framework", - "version": "0.1.0", + "version": "0.2.1", "description": "Base library of Moyu Engine", "main": "src/index.tsx", "scripts": { @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit", "build": "rspack build", "generate:schema": "moyu schema", + "generate:ui-schema": "moyu ui-schema", "engine:update": "moyu update", "engine:download": "moyu download", "engine:switch": "moyu switch", @@ -22,7 +23,7 @@ "node": ">=22.20" }, "dependencies": { - "@momoyu-ink/kit": "^0.6.1", + "@momoyu-ink/kit": "^0.10.1", "lodash.debounce": "^4.0.8", "react": "19.2.1", "react-error-boundary": "^6.0.0", @@ -31,7 +32,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.1.4", - "@momoyu-ink/cli": "^0.4.0", + "@momoyu-ink/cli": "^0.6.1", "@rspack/cli": "^1.7.11", "@rspack/core": "^1.7.11", "@rspack/plugin-react-refresh": "^1.6.2", diff --git a/rspack.config.js b/rspack.config.js index 3374010..29ce147 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -14,6 +14,9 @@ module.exports = { watchOptions: { ignored: /[\\/](?:\.git[\\/]|node_modules[\\/](?!@momoyu-ink[\\/]))/, }, + performance: { + hints: false, + }, module: { rules: [ { diff --git a/src/actors/background.tsx b/src/actors/background.tsx index b732b07..0ae37f5 100644 --- a/src/actors/background.tsx +++ b/src/actors/background.tsx @@ -1,35 +1,18 @@ -import { animated, useSpring, useSpringRef, useTransition, useSkipCallback, useIsSeeking, useIsSkipping } from '@momoyu-ink/kit'; -import { useSnapshot } from 'valtio'; -import { gameState } from '../state/game'; +import { useSpring, useSpringRef, useSkipCallback, useIsSeeking, useIsSkipping } from '@momoyu-ink/kit'; import { useCallback, useEffect } from 'react'; +import { useGameStateSection } from '../state/game'; +import { Sprite } from '../components/sprite'; -// File extensions treated as looping background video instead of static image. -const VIDEO_EXTENSIONS = ['.mp4', '.webm']; - -function isVideoSrc(src: string): boolean { - const lower = src.toLowerCase(); - return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext)); -} +const EMPTY_BACKGROUND_KEY = '__background-empty__'; export function BackgroundActor() { - const backgroundState = useSnapshot(gameState.background); + const backgroundState = useGameStateSection('background'); const skipping = useIsSkipping(); const seeking = useIsSeeking(); const shouldSkipVisuals = skipping || seeking; - const transRef = useSpringRef(); + const transitionKey = backgroundState.src || EMPTY_BACKGROUND_KEY; const tintSpringRef = useSpringRef(); - const transitions = useTransition(backgroundState.src ? [backgroundState.src] : [], { - keys: (src) => src, - ref: transRef, - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 1 }, // keep opacity until removed to simulate crossfade - config: { - duration: shouldSkipVisuals ? 0 : backgroundState.fadeTime, - }, - }); - // Animate tint changes smoothly using react-spring's built-in color interpolation const [tintSpring] = useSpring( () => ({ @@ -42,11 +25,6 @@ export function BackgroundActor() { [], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: we must recall start on src change - useEffect(() => { - transRef.start(); - }, [transRef, backgroundState.src]); - // Update tint animation when tint changes // biome-ignore lint/correctness/useExhaustiveDependencies: fadeTime and skipping are read at trigger time, only tint change should re-trigger useEffect(() => { @@ -62,13 +40,6 @@ export function BackgroundActor() { const tryFinish = useCallback(() => { let finished = false; - // Check and finish opacity transition - const currentOpacity = transRef.current[0]?.get().opacity; - if (currentOpacity < 1) { - transRef.set({ opacity: 1 }); - finished = true; - } - // Check and finish tint transition const currentTint = tintSpringRef.current[0]?.get().tint; const targetTint = backgroundState.tint || '#FFFFFF'; @@ -78,28 +49,27 @@ export function BackgroundActor() { } return finished; - }, [transRef, tintSpringRef, backgroundState.tint]); + }, [tintSpringRef, backgroundState.tint]); // Register skip callback so waiting-cancelled events finish the transition useSkipCallback(tryFinish); return ( - - {transitions((style, src) => - isVideoSrc(src) ? ( - - ) : ( - - ), - )} - + ); } diff --git a/src/actors/camera.tsx b/src/actors/camera.tsx index df58e31..2bd592c 100644 --- a/src/actors/camera.tsx +++ b/src/actors/camera.tsx @@ -10,9 +10,8 @@ import { useSpringRef, } from '@momoyu-ink/kit'; import { Children, cloneElement, isValidElement, useCallback, useEffect, type ReactNode } from 'react'; -import { useSnapshot } from 'valtio'; -import { gameState } from '../state/game'; import { getCameraPlaneTargets } from '../lib/camera'; +import { useGameStateSection } from '../state/game'; interface CameraSpringState { backgroundX: number; @@ -93,7 +92,7 @@ export function CharacterPlane({ children, cameraRuntime }: CameraPlaneProps) { } export function CameraActor({ children }: CameraActorProps) { - const cameraState = useSnapshot(gameState.camera); + const cameraState = useGameStateSection('camera'); const skipping = useIsSkipping(); const seeking = useIsSeeking(); const shouldSkipVisuals = skipping || seeking; @@ -143,7 +142,7 @@ export function CameraActor({ children }: CameraActorProps) { // Skip should immediately settle both planes at the final target so visual // state and flow-control timing stay consistent. const finishTransition = useCallback(() => { - const targets = getCameraPlaneTargets(gameState.camera, { width, height }); + const targets = getCameraPlaneTargets(cameraState, { width, height }); springRef.set({ backgroundX: targets.backgroundX, backgroundY: targets.backgroundY, @@ -151,9 +150,9 @@ export function CameraActor({ children }: CameraActorProps) { characterX: targets.characterX, characterY: targets.characterY, characterScale: targets.characterScale, - blur: gameState.camera.blur, + blur: cameraState.blur, }); - }, [height, springRef, width]); + }, [cameraState, height, springRef, width]); useSkipCallback(finishTransition); diff --git a/src/actors/character.tsx b/src/actors/character.tsx index 40d0145..694683c 100644 --- a/src/actors/character.tsx +++ b/src/actors/character.tsx @@ -1,77 +1,97 @@ -import { useRef } from 'react'; -import { animated, useTransition, useSpring, useIsSeeking, useIsSkipping, getStageSize, easings } from '@momoyu-ink/kit'; -import { useSnapshot } from 'valtio'; -import { gameState, Character } from '../state/game'; +import { useCallback, useLayoutEffect } from 'react'; +import { animated, useSpring, useIsSeeking, useIsSkipping, getStageSize, easings } from '@momoyu-ink/kit'; +import { Character, useGameStateSection, useGameStateStore } from '../state/game'; +import { Sprite } from '../components/sprite'; + +const EMPTY_CHARACTER_KEY = '__character-empty__'; +const DEFAULT_CHARACTER_SLOT_KEY = '__default__'; + +function getCharacterSlotKey(name?: string): string { + return name ?? DEFAULT_CHARACTER_SLOT_KEY; +} export function CharacterActor() { - const characterState = useSnapshot(gameState.character) as typeof gameState.character; - const textboxState = useSnapshot(gameState.textbox); - const skipping = useIsSkipping(); - const seeking = useIsSeeking(); - const shouldSkipVisuals = skipping || seeking; + const characterStore = useGameStateStore().character; + const characterState = useGameStateSection('character'); + + // biome-ignore lint/correctness/useExhaustiveDependencies: it is essential to listen to characterState.characterschanges to trigger presence state update, but characterStore is also needed to update character visibility and presence state. + useLayoutEffect(() => { + for (const character of characterStore.characters) { + if (character.presence === 'entering' && !character.visible) { + character.visible = true; + character.presence = 'present'; + } + } + }, [characterStore, characterState.characters]); - const transitions = useTransition( - Object.values(characterState.characters).filter((char) => char.visible), - { - keys: (char) => char.name ?? char.src, - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, - config: (char) => ({ - duration: shouldSkipVisuals ? 0 : char.fadeTime, - }), + const handleCharacterExited = useCallback( + (slotKey: string) => { + const index = characterStore.characters.findIndex((character) => getCharacterSlotKey(character.name) === slotKey); + if (index === -1) { + return; + } + + const character = characterStore.characters[index]; + if (character.presence === 'leaving' && !character.visible) { + characterStore.characters.splice(index, 1); + } }, + [characterStore], ); const stageSize = getStageSize(); return ( - {transitions((style, character) => ( - + {characterState.characters.map((character) => { + const slotKey = getCharacterSlotKey(character.name); + const characterLabel = character.name ?? character.src; + + return ( - - ))} + ); + })} ); } interface CharacterSpriteProps { - character: Character; - isCurrentSpeaker: boolean; + slotKey: string; + characterLabel: string; + fallbackCharacter: Character; + onExited: (slotKey: string) => void; } -function CharacterSprite({ character: propCharacter, isCurrentSpeaker }: CharacterSpriteProps) { +const AnimatedSprite = animated(Sprite); + +function CharacterSprite({ slotKey, characterLabel, fallbackCharacter, onExited }: CharacterSpriteProps) { + const characterState = useGameStateSection('character'); const skipping = useIsSkipping(); const seeking = useIsSeeking(); const shouldSkipVisuals = skipping || seeking; - const characterState = useSnapshot(gameState.character) as typeof gameState.character; - - const liveCharacter = (characterState.characters as Character[]).find( - (c) => (c.name ?? c.src) === (propCharacter.name ?? propCharacter.src), - ); - - // Track the last known live state so the leave animation uses correct props. - // propCharacter from useTransition is stale (captured at enter time) and does - // not reflect changes made by charAction, so we cannot rely on it as a fallback. - const lastLiveRef = useRef(null); - if (liveCharacter) { - lastLiveRef.current = liveCharacter; - } - - // During leave phase, fall back to last known live state instead of propCharacter - const character = liveCharacter ?? lastLiveRef.current ?? propCharacter; + const liveCharacter = characterState.characters.find((character) => getCharacterSlotKey(character.name) === slotKey); + const character = liveCharacter ?? fallbackCharacter; + const transitionKey = + liveCharacter === undefined || + !liveCharacter.visible || + liveCharacter.presence === 'entering' || + liveCharacter.presence === 'leaving' + ? EMPTY_CHARACTER_KEY + : liveCharacter.src; + const fadeTime = character.fadeTime; + const isCurrentSpeaker = character.name !== undefined && character.name === characterState.currentSpeaker; const autoTintEnabled = characterState.autoTintEnabled; const autoTint = autoTintEnabled ? characterState.autoTint : '#fff'; const currentTint = autoTintEnabled && !isCurrentSpeaker ? autoTint : character.tint; const tintFadeTime = autoTintEnabled && !isCurrentSpeaker ? 200 : character.fadeTime; - // Reactive spring: re-animates automatically whenever character state changes const springs = useSpring({ x: character.x, y: character.y, @@ -84,7 +104,8 @@ function CharacterSprite({ character: propCharacter, isCurrentSpeaker }: Charact }); return ( - onExited(slotKey), + }} /> ); } diff --git a/src/actors/freeSprite.tsx b/src/actors/freeSprite.tsx new file mode 100644 index 0000000..5950de4 --- /dev/null +++ b/src/actors/freeSprite.tsx @@ -0,0 +1,181 @@ +import { animated, easings, useIsSeeking, useIsSkipping, useSpring } from '@momoyu-ink/kit'; +import { useCallback, useLayoutEffect } from 'react'; +import { Sprite } from '../components/sprite'; +import { type FreeSpriteNode, gameState, useGameStateSection, useGameStateStore } from '../state/game'; + +const ROOT_KEY = '__free-sprite-root__'; +const EMPTY_SPRITE_KEY = '__free-sprite-empty__'; + +function buildChildMap( + nodes: Record, +) { + const childMap = new Map(); + + for (const node of Object.values(nodes)) { + const parentKey = node.parent ?? ROOT_KEY; + const list = childMap.get(parentKey); + if (list) { + list.push(node); + } else { + childMap.set(parentKey, [node]); + } + } + + for (const list of childMap.values()) { + list.sort((a, b) => { + if (a.zIndex !== b.zIndex) { + return a.zIndex - b.zIndex; + } + + return a.order - b.order; + }); + } + + return childMap; +} + +function getTransitionKey(node: FreeSpriteNode): string { + if (!node.visible || node.presence === 'entering' || node.presence === 'leaving') { + return EMPTY_SPRITE_KEY; + } + + return `${node.name}:${node.resource.src}`; +} + +function cleanupLeavingSubtree(name: string) { + for (const child of Object.values(gameState.freeSprite.nodes)) { + if (child.parent === name) { + cleanupLeavingSubtree(child.name); + } + } + + delete gameState.freeSprite.nodes[name]; +} + +interface FreeSpriteNodeViewProps { + name: string; + fallbackNode: FreeSpriteNode; + childNames: string[]; + childMap: Map; + onExited: (name: string) => void; +} + +function FreeSpriteNodeView({ name, fallbackNode, childNames, childMap, onExited }: FreeSpriteNodeViewProps) { + const freeSpriteState = useGameStateSection('freeSprite'); + const skipping = useIsSkipping(); + const seeking = useIsSeeking(); + const shouldSkipVisuals = skipping || seeking; + const liveNode = freeSpriteState.nodes[name]; + const node = liveNode ?? fallbackNode; + const transitionKey = liveNode === undefined ? EMPTY_SPRITE_KEY : getTransitionKey(liveNode); + const effect = node.transitionEffect ?? freeSpriteState.defaultTransitionEffect; + const spriteVisible = liveNode?.visible === true && liveNode.presence === 'present'; + const containerVisible = + liveNode !== undefined && (spriteVisible || liveNode.presence === 'entering' || liveNode.presence === 'leaving'); + const springs = useSpring({ + x: node.x, + y: node.y, + scaleX: node.scaleX, + scaleY: node.scaleY, + rotation: node.rotation, + skewX: node.skewX, + skewY: node.skewY, + opacity: node.opacity, + tint: node.tint ?? '#fff', + config: { + duration: shouldSkipVisuals ? 0 : node.fadeTime, + easing: easings.easeInOutCubic, + }, + }); + + return ( + + onExited(node.name), + }} + /> + {childNames.map((childName) => ( + child.name === childName) ?? fallbackNode} + childNames={(childMap.get(childName) ?? []).map((child) => child.name)} + childMap={childMap} + onExited={onExited} + /> + ))} + + ); +} + +export function FreeSpriteActor() { + const freeSpriteStore = useGameStateStore().freeSprite; + const freeSpriteState = useGameStateSection('freeSprite'); + + // biome-ignore lint/correctness/useExhaustiveDependencies: this must react to snapshot changes while mutating the live store, matching CharacterActor's pattern. + useLayoutEffect(() => { + for (const node of Object.values(freeSpriteStore.nodes)) { + if (node.presence === 'entering' && !node.visible) { + node.visible = true; + node.presence = 'present'; + } + } + }, [freeSpriteStore, freeSpriteState.nodes]); + + const handleFreeSpriteExited = useCallback( + (name: string) => { + const node = freeSpriteStore.nodes[name]; + if (node?.presence === 'leaving' && !node.visible) { + cleanupLeavingSubtree(name); + } + }, + [freeSpriteStore], + ); + + const childMap = buildChildMap(freeSpriteState.nodes as Record); + const rootNodes = childMap.get(ROOT_KEY) ?? []; + + return ( + + {rootNodes.map((node) => ( + child.name)} + childMap={childMap} + onExited={handleFreeSpriteExited} + /> + ))} + + ); +} diff --git a/src/actors/selection.tsx b/src/actors/selection.tsx index e4cca44..9c11198 100644 --- a/src/actors/selection.tsx +++ b/src/actors/selection.tsx @@ -1,18 +1,46 @@ -import { executePluginCommand, nextLine, useAutoBlocker, useIsSeeking, useSkipBlocker } from '@momoyu-ink/kit'; -import { useSnapshot } from 'valtio'; +import { + animated, + Button, + executePluginCommand, + nextLine, + useAutoBlocker, + useIsSeeking, + useSkipBlocker, + useTransition, +} from '@momoyu-ink/kit'; import { useCallback, useEffect } from 'react'; +import { useSnapshot } from 'valtio'; import { gameState } from '../state/game'; -import { Button } from '../components/button'; -// Vertical spacing between selection options (pixels) -const ITEM_SPACING = 90; // Screen center coordinates const CENTER_X = 960; const CENTER_Y = 540; // Button dimensions and nine-slice settings const BUTTON_WIDTH = 700; const BUTTON_HEIGHT = 67; +const BUTTON_GAP = 23; const NINESLICE_BOUNDS: [number, number, number, number] = [0.3, 0.3, 0.3, 0.3]; +const PANEL_TRANSITION = { + from: { + opacity: 0, + scale: 0.985, + offsetY: 16, + }, + enter: { + opacity: 1, + scale: 1, + offsetY: 0, + }, + leave: { + opacity: 0, + scale: 0.985, + offsetY: 16, + }, + config: { + tension: 280, + friction: 24, + }, +}; export function SelectionActor() { const selectionState = useSnapshot(gameState.selection); @@ -49,35 +77,50 @@ export function SelectionActor() { } }, [seeking]); - // Vertically center the option list around the screen center - const startY = CENTER_Y - ((selectionState.options.length - 1) * ITEM_SPACING) / 2; + const show = selectionState.visible && !seeking; + const transitions = useTransition(show ? [0] : [], { + keys: (item) => item, + ...PANEL_TRANSITION, + }); + + if (!show) { + return null; + } - return ( - - {selectionState.options.map((option, index) => ( -