From bea4d6996475b3ad5eebfefffb6f1ed838898096 Mon Sep 17 00:00:00 2001 From: Icemic Date: Fri, 15 May 2026 04:09:43 +0800 Subject: [PATCH 01/40] feat: add UI schema and configuration for title screen with dynamic button handling --- assets/data/ui.json | 3 + package.json | 1 + src/data/ui.ts | 151 ++++++++++++++++++++++ src/index.tsx | 16 ++- src/pages/title.tsx | 122 +++++++++--------- ui.schema.json | 299 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 521 insertions(+), 71 deletions(-) create mode 100644 assets/data/ui.json create mode 100644 src/data/ui.ts create mode 100644 ui.schema.json diff --git a/assets/data/ui.json b/assets/data/ui.json new file mode 100644 index 0000000..eb9b678 --- /dev/null +++ b/assets/data/ui.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc86ba1acdfff05ca6c06a31e9fe8249232eed497a3b778b7080272b79dd289f +size 1340 diff --git a/package.json b/package.json index fd1b277..dbdf50a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/data/ui.ts b/src/data/ui.ts new file mode 100644 index 0000000..c607529 --- /dev/null +++ b/src/data/ui.ts @@ -0,0 +1,151 @@ +import z from 'zod'; + +const imageSrc = z.string().describe('Image asset path').meta({ + title: 'Image', + format: 'asset', + 'x-asset-kind': 'image', + 'x-i18n': { 'zh-CN': '图片' }, + 'x-i18n-desc': { 'zh-CN': '图片资源路径' }, +}); + +const posX = z.number().describe('X coordinate').meta({ + title: 'X', + 'x-i18n': { 'zh-CN': 'X 坐标' }, + 'x-i18n-desc': { 'zh-CN': '按钮的 X 坐标' }, +}); + +const posY = z.number().describe('Y coordinate').meta({ + title: 'Y', + 'x-i18n': { 'zh-CN': 'Y 坐标' }, + 'x-i18n-desc': { 'zh-CN': '按钮的 Y 坐标' }, +}); + +const TitlePageNameSchema = z.enum(['stage', 'cg', 'bgm', 'credits']).describe('Target page name').meta({ + title: 'Page', + 'x-i18n': { 'zh-CN': '页面' }, + 'x-i18n-desc': { 'zh-CN': '跳转的页面名称' }, +}); + +const TitleOverlayNameSchema = z.enum(['saveload', 'settings']).describe('Target overlay name').meta({ + title: 'Overlay', + 'x-i18n': { 'zh-CN': '浮层' }, + 'x-i18n-desc': { 'zh-CN': '弹出的浮层名称' }, +}); + +const TitleGotoPageActionUiSchema = z + .object({ + type: z.literal('gotoPage'), + name: TitlePageNameSchema, + }) + .describe('Navigate to another page') + .meta({ + title: 'Go To Page', + 'x-i18n': { 'zh-CN': '跳转页面' }, + 'x-i18n-desc': { 'zh-CN': '点击按钮后跳转到指定页面' }, + }); + +const TitlePushOverlayActionUiSchema = z + .object({ + type: z.literal('pushOverlay'), + name: TitleOverlayNameSchema, + }) + .describe('Open an overlay') + .meta({ + title: 'Push Overlay', + 'x-i18n': { 'zh-CN': '打开浮层' }, + 'x-i18n-desc': { 'zh-CN': '点击按钮后打开指定浮层' }, + }); + +const TitleQuitActionUiSchema = z + .object({ + type: z.literal('quit'), + }) + .describe('Quit the game') + .meta({ + title: 'Quit', + 'x-i18n': { 'zh-CN': '退出游戏' }, + 'x-i18n-desc': { 'zh-CN': '点击按钮后退出游戏' }, + }); + +export const TitleButtonActionUiSchema = z + .discriminatedUnion('type', [TitleGotoPageActionUiSchema, TitlePushOverlayActionUiSchema, TitleQuitActionUiSchema]) + .describe('Title button action') + .meta({ + title: 'Action', + 'x-i18n': { 'zh-CN': '动作' }, + 'x-i18n-desc': { 'zh-CN': '标题按钮执行的动作' }, + }); + +export const TitleButtonUiSchema = z + .object({ + x: posX, + y: posY, + fileNames: z.tuple([imageSrc, imageSrc, imageSrc]).describe('Button images in idle, hover, and pressed states').meta({ + title: 'Button Images', + 'x-i18n': { 'zh-CN': '按钮图片' }, + 'x-i18n-desc': { 'zh-CN': '按钮空闲、悬停和按下状态的图片' }, + }), + text: z.string().describe('Button label').meta({ + title: 'Text', + 'x-i18n': { 'zh-CN': '文字' }, + 'x-i18n-desc': { 'zh-CN': '按钮上显示的文字' }, + }), + fontSize: z.number().optional().default(36).describe('Button font size').meta({ + title: 'Font Size', + 'x-i18n': { 'zh-CN': '字号' }, + 'x-i18n-desc': { 'zh-CN': '按钮文字字号' }, + }), + color: z.string().optional().default('#ffffff').describe('Button text color').meta({ + title: 'Color', + format: 'color', + 'x-i18n': { 'zh-CN': '颜色' }, + 'x-i18n-desc': { 'zh-CN': '按钮文字颜色' }, + }), + action: TitleButtonActionUiSchema, + }) + .describe('Title screen button') + .meta({ + title: 'Button', + 'x-i18n': { 'zh-CN': '按钮' }, + 'x-i18n-desc': { 'zh-CN': '标题界面上的一个按钮配置' }, + }); + +export const TitleUiSchema = z + .object({ + background: imageSrc.describe('Title screen background image').meta({ + title: 'Background', + format: 'asset', + 'x-asset-kind': 'image', + 'x-i18n': { 'zh-CN': '背景图' }, + 'x-i18n-desc': { 'zh-CN': '标题界面的背景图片' }, + }), + fadeTime: z.number().optional().default(500).describe('Fade-in time in milliseconds').meta({ + title: 'Fade Time', + 'x-i18n': { 'zh-CN': '淡入时间' }, + 'x-i18n-desc': { 'zh-CN': '标题界面的淡入时间(毫秒)' }, + }), + buttons: z.array(TitleButtonUiSchema).min(1).describe('Title screen buttons').meta({ + title: 'Buttons', + 'x-i18n': { 'zh-CN': '按钮列表' }, + 'x-i18n-desc': { 'zh-CN': '标题界面的按钮列表' }, + }), + }) + .describe('Title screen UI configuration') + .meta({ + title: 'Title Screen', + 'x-i18n': { 'zh-CN': '标题界面' }, + 'x-i18n-desc': { 'zh-CN': '标题界面的可配置数据' }, + }); + +export const GameUiSchema = z.object({ + title: TitleUiSchema, +}); + +export type TitleButtonAction = z.infer; +export type TitleButtonUiData = z.infer; +export type TitleUiData = z.infer; +export type GameUiData = z.infer; + +declare module '@momoyu-ink/kit' { + interface RootUiDataMap extends GameUiData {} +} diff --git a/src/index.tsx b/src/index.tsx index eb9af15..38298f6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import { addEventListener, createRoot, executePluginCommand, + initUiData, registerAppStateAdapter, startRuntimeDebugSession, stopRuntimeDebugSession, @@ -13,6 +14,7 @@ import { Navigation } from './router'; import { uiActions } from './state/ui'; import { Notification } from './components/notification'; import { gameStateDebugAdapter } from './debug/gameStateAdapter'; +import { GameUiSchema } from './data/ui'; registerAppStateAdapter(gameStateDebugAdapter); @@ -49,10 +51,12 @@ function Main() { } addEventListener('ready', () => { - try { - const root = createRoot(); - root.render(
); - } catch (err) { - console.error('Failed to render the game:', err); - } + void initUiData(GameUiSchema) + .then(() => { + const root = createRoot(); + root.render(
); + }) + .catch((err) => { + console.error('Failed to initialize UI data:', err); + }); }); diff --git a/src/pages/title.tsx b/src/pages/title.tsx index f61e3a2..0adf7dc 100644 --- a/src/pages/title.tsx +++ b/src/pages/title.tsx @@ -1,29 +1,55 @@ import { useEffect } from 'react'; -import { MouseEvent, useFadeIn, useSoundEffect, useNavigation, animated, executePluginCommand } from '@momoyu-ink/kit'; +import { + MouseEvent, + useFadeIn, + useSoundEffect, + useNavigation, + useUiData, + animated, + executePluginCommand, +} from '@momoyu-ink/kit'; import { Button } from '../components/button'; +import type { TitleButtonAction } from '../data/ui'; import { uiActions } from '../state/ui'; export function Title() { - const [contentStyle, contentApi, contentSkip] = useFadeIn(500, true); + const titleUi = useUiData('title'); + const [contentStyle, contentApi, contentSkip] = useFadeIn(titleUi.fadeTime, true); const navigation = useNavigation(); const hoverButtonSound = useSoundEffect('audio/cursor_style_4.opus'); const clickButtonSound = useSoundEffect('audio/confirm_style_5_echo_001.opus'); - const handleStart = (e: MouseEvent) => { - clickButtonSound(); - contentApi.start({ - to: { opacity: 0 }, - delay: 0, - pause: false, - onRest: () => { - navigation.navigate('stage', { story: 'start', entry: 'entry', isNewGame: true }); - }, - }); - e.stopPropagation(); - }; + const handleButtonClick = (action: TitleButtonAction) => (e: MouseEvent) => { + if (action.type === 'gotoPage') { + clickButtonSound(); + contentApi.start({ + to: { opacity: 0 }, + delay: 0, + pause: false, + onRest: () => { + if (action.name === 'stage') { + navigation.navigate('stage', { story: 'start', entry: 'entry', isNewGame: true }); + return; + } + + navigation.navigate(action.name as never); + }, + }); + e.stopPropagation(); + return; + } + + if (action.type === 'pushOverlay') { + if (action.name === 'saveload') { + navigation.pushOverlay('saveload', { type: 'load' }); + } else { + navigation.pushOverlay('settings'); + } + e.stopPropagation(); + return; + } - const handleExit = (e: MouseEvent) => { clickButtonSound(); uiActions.confirm('确定要退出游戏吗?', () => { executePluginCommand('system', { @@ -48,57 +74,23 @@ export function Title() { } }} > - - -