diff --git a/src/core/system/ui/container.tsx b/src/core/system/ui/container.tsx index dafcd89..fb94e41 100644 --- a/src/core/system/ui/container.tsx +++ b/src/core/system/ui/container.tsx @@ -23,7 +23,7 @@ export const UIContainer = defineComponent(props => { controller={data} instance={b} key={b.key} - hidden={b.hidden} + hidden={b.hidden && !b.alwaysShow} > ); } @@ -34,7 +34,7 @@ export const UIContainer = defineComponent(props => { key={v.key} controller={data} instance={v} - hidden={v.hidden} + hidden={v.hidden && !v.alwaysShow} > )) ); diff --git a/src/data/logger.json b/src/data/logger.json index 27a3288..450358d 100644 --- a/src/data/logger.json +++ b/src/data/logger.json @@ -94,6 +94,7 @@ "60": "Repeated Tip id: '$1'.", "61": "Unexpected recursive call of $1.update in render function. Please ensure you have to do this, if you do, ignore this warn.", "62": "Recursive fallback fonts in '$1'.", + "63": "Uncaught promise error in waiting box component. Error reason: $1", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency.", "1101": "Cannot add new effect to point effect instance, for there's no more reserve space for it. Please increase the max count of the instance." } diff --git a/src/module/render/components/choices.tsx b/src/module/render/components/choices.tsx index e9720e2..ed05e3b 100644 --- a/src/module/render/components/choices.tsx +++ b/src/module/render/components/choices.tsx @@ -50,7 +50,7 @@ const confirmBoxProps = { >; /** - * 确认框组件,与 2.x 的 drawConfirm 类似,可以键盘操作, + * 确认框组件,与 2.x 的 drawConfirm 类似,可以键盘操作,单次调用参考 {@link getConfirm}。 * 参数参考 {@link ConfirmBoxProps},事件参考 {@link ConfirmBoxEmits},用例如下: * ```tsx * const onYes = () => console.log('yes'); @@ -243,7 +243,7 @@ const choicesProps = { >; /** - * 选项框组件,用于在多个选项中选择一个,例如样板的系统设置就由它实现。 + * 选项框组件,用于在多个选项中选择一个,例如样板的系统设置就由它实现。单次调用参考 {@link getChoice}。 * 参数参考 {@link ChoicesProps},事件参考 {@link ChoicesEmits}。用例如下: * ```tsx * ( controller: IUIMountable, diff --git a/src/module/render/components/misc.tsx b/src/module/render/components/misc.tsx index 9be1f0b..d87ad95 100644 --- a/src/module/render/components/misc.tsx +++ b/src/module/render/components/misc.tsx @@ -5,13 +5,15 @@ import { PathProps, Sprite } from '@/core/render'; -import { computed, defineComponent, ref, watch } from 'vue'; +import { computed, defineComponent, ref, SetupContext, watch } from 'vue'; import { SetupComponentOptions } from './types'; import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; -import { TextboxProps, TextContent } from './textbox'; +import { TextboxProps, TextContent, TextContentProps } from './textbox'; import { Scroll, ScrollExpose, ScrollProps } from './scroll'; import { transitioned } from '../use'; import { hyper } from 'mutate-animate'; +import { logger } from '@/core/common/logger'; +import { GameUI, IUIMountable } from '@/core/system'; interface ProgressProps extends DefaultProps { /** 进度条的位置 */ @@ -401,3 +403,181 @@ export const Background = defineComponent(props => { /> ); }, backgroundProps); + +export interface WaitBoxProps + extends Partial, + Partial { + loc: ElementLocator; + width: number; + promise?: Promise; + text?: string; + pad?: number; +} + +export type WaitBoxEmits = { + resolve: (data: T) => void; +}; + +export interface WaitBoxExpose { + /** + * 手动将此组件兑现,注意调用时如果传入的 Promise 还没有兑现, + * 当 Promise 兑现后将不会再次触发 resolve 事件,即 resolve 事件只会被触发一次 + * @param data 兑现值 + */ + resolve(data: T): void; +} + +const waitBoxProps = { + props: ['promise', 'loc', 'winskin', 'color', 'border'], + emits: ['resolve'] +} satisfies SetupComponentOptions< + WaitBoxProps, + WaitBoxEmits, + keyof WaitBoxEmits +>; + +/** + * 等待框,可以等待某个异步操作 (Promise),操作完毕后触发兑现事件,单次调用参考 {@link waitbox}。 + * 参数参考 {@link WaitBoxProps},事件参考 {@link WaitBoxEmits},函数接口参考 {@link WaitBoxExpose}。用例如下: + * ```tsx + * // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻 + * const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000)); + * + * + * ``` + */ +export const WaitBox = defineComponent< + WaitBoxProps, + WaitBoxEmits, + keyof WaitBoxEmits +>( + ( + props: WaitBoxProps, + { emit, expose, attrs }: SetupContext> + ) => { + const contentHeight = ref(200); + + const text = computed(() => props.text ?? '请等待 ...'); + const pad = computed(() => props.pad ?? 24); + const containerLoc = computed(() => { + const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc; + return [x, y, props.width, contentHeight.value, ax, ay]; + }); + const backLoc = computed(() => { + return [1, 1, props.width - 2, contentHeight.value - 2]; + }); + const contentLoc = computed(() => { + return [ + pad.value, + pad.value, + props.width - pad.value * 2, + contentHeight.value - pad.value * 2 + ]; + }); + + let resolved: boolean = false; + + props.promise?.then( + value => { + resolve(value); + }, + reason => { + logger.warn(63, reason); + } + ); + + const resolve = (data: T) => { + if (resolved) return; + resolved = true; + emit('resolve', data); + }; + + const onContentHeight = (height: number) => { + contentHeight.value = height + pad.value * 2; + }; + + expose>({ resolve }); + + return () => ( + + + + + ); + }, + waitBoxProps +); + +/** + * 打开一个等待框,等待传入的 Promise 兑现后,关闭等待框,并将兑现值返回。 + * 示例,等待 1000ms: + * ```ts + * // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻 + * const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000)); + * const value = await waitbox( + * // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可 + * props.controller, + * // 确认框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效 + * [240, 240, void 0, void 0, 0.5, 0.5], + * // 确认框的宽度 + * 240, + * // 要等待的 Promise + * promise, + * // 额外的 props,例如填写等待文本,此项可选,参考 WaitBoxProps + * { text: '请等待 1000ms' } + * ); + * console.log(value); // 输出时间刻 + * ``` + * @param controller UI 控制器 + * @param loc 等待框的位置 + * @param width 等待框的宽度 + * @param promise 要等待的 Promise + * @param props 额外的 props,参考 {@link WaitBoxProps} + */ +export function waitbox( + controller: IUIMountable, + loc: ElementLocator, + width: number, + promise: Promise, + props?: Partial> +): Promise { + return new Promise(res => { + const instance = controller.open(WaitBoxUI, { + ...(props ?? {}), + loc, + width, + promise, + onResolve: data => { + controller.close(instance); + res(data as T); + } + }); + }); +} + +export const WaitBoxUI = new GameUI('wait-box', WaitBox); diff --git a/src/module/render/ui/settings.tsx b/src/module/render/ui/settings.tsx new file mode 100644 index 0000000..83733ee --- /dev/null +++ b/src/module/render/ui/settings.tsx @@ -0,0 +1,579 @@ +import { ElementLocator } from '@/core/render'; +import { GameUI, UIComponentProps } from '@/core/system'; +import { defineComponent } from 'vue'; +import { + ChoiceItem, + ChoiceKey, + Choices, + ChoicesProps, + getConfirm, + SetupComponentOptions, + waitbox +} from '../components'; +import { mainUi } from '@/core/main/init/ui'; +import { gameKey } from '@/core/main/custom/hotkey'; +import { generateKeyboardEvent } from '@/core/main/custom/keyboard'; +import { getVitualKeyOnce } from '@/plugin/utils'; +import { getAllSavesData, getSaveData } from '@/module/utils'; + +export interface SettingsProps extends Partial, UIComponentProps { + loc: ElementLocator; +} + +const settingsProps = { + props: ['loc', 'controller', 'instance'] +} satisfies SetupComponentOptions; + +const enum MainChoice { + SystemSetting, + VirtualKey, + ViewMap, + /** @see {@link ReplaySettings} */ + Replay, + /** @see {@link SyncSave} */ + SyncSave, + /** @see {@link GameInfo} */ + GameInfo, + Restart, + Back +} + +export const MainSettings = defineComponent(props => { + const choices: ChoiceItem[] = [ + [MainChoice.SystemSetting, '系统设置'], + [MainChoice.VirtualKey, '虚拟键盘'], + [MainChoice.ViewMap, '浏览地图'], + [MainChoice.Replay, '录像回放'], + [MainChoice.SyncSave, '同步存档'], + [MainChoice.GameInfo, '游戏信息'], + [MainChoice.Restart, '返回标题'], + [MainChoice.Back, '返回游戏'] + ]; + + const choose = (key: ChoiceKey) => { + switch (key) { + case MainChoice.SystemSetting: { + mainUi.open('settings'); + break; + } + case MainChoice.VirtualKey: { + getVitualKeyOnce().then(value => { + gameKey.emitKey( + value.key, + value.assist, + 'up', + generateKeyboardEvent(value.key, value.assist) + ); + }); + break; + } + case MainChoice.ViewMap: { + // todo + break; + } + case MainChoice.Replay: { + props.controller.open(ReplaySettingsUI, { loc: props.loc }); + break; + } + case MainChoice.SyncSave: { + props.controller.open(SyncSaveUI, { loc: props.loc }); + break; + } + case MainChoice.GameInfo: { + props.controller.open(GameInfoUI, { loc: props.loc }); + break; + } + case MainChoice.Restart: { + props.controller.closeAll(); + core.restart(); + break; + } + case MainChoice.Back: { + props.controller.close(props.instance); + break; + } + } + }; + + return () => ( + + ); +}, settingsProps); + +const enum ReplayChoice { + Start, + StartFromSave, + ResumeReplay, + ReplayRest, + ChooseReplay, + Download, + Back +} + +export const ReplaySettings = defineComponent(props => { + const choice: ChoiceItem[] = [ + [ReplayChoice.Start, '从头回放录像'], + [ReplayChoice.StartFromSave, '从存档开始回放'], + [ReplayChoice.ResumeReplay, '接续播放剩余录像'], + [ReplayChoice.ReplayRest, '播放存档剩余录像'], + [ReplayChoice.ChooseReplay, '选择录像文件'], + [ReplayChoice.Download, '下载当前录像'], + [ReplayChoice.Back, '返回游戏'] + ]; + + const choose = (key: ChoiceKey) => { + switch (key) { + case ReplayChoice.Start: { + props.controller.closeAll(); + core.ui.closePanel(); + const route = core.status.route.slice(); + const seed = core.getFlag('__seed__'); + core.startGame(core.status.hard, seed, route); + break; + } + case ReplayChoice.StartFromSave: { + // todo + break; + } + case ReplayChoice.ResumeReplay: { + // todo + break; + } + case ReplayChoice.ReplayRest: { + // todo + break; + } + case ReplayChoice.ChooseReplay: { + props.controller.closeAll(); + core.chooseReplayFile(); + break; + } + case ReplayChoice.Download: { + core.download( + core.firstData.name + '_' + core.formatDate2() + '.h5route', + // @ts-expect-error 暂时无法推导 + LZString.compressToBase64( + JSON.stringify({ + name: core.firstData.name, + hard: core.status.hard, + seed: core.getFlag('__seed__'), + route: core.encodeRoute(core.status.route) + }) + ) + ); + break; + } + case ReplayChoice.Back: { + props.controller.close(props.instance); + break; + } + } + }; + + return () => ( + + ); +}, settingsProps); + +const enum GameInfoChoice { + Statistics, + Project, + Tower, + Help, + Download, + Back +} + +export const GameInfo = defineComponent(props => { + const choices: ChoiceItem[] = [ + [GameInfoChoice.Statistics, '数据统计'], + [GameInfoChoice.Project, '查看工程'], + [GameInfoChoice.Tower, '游戏主页'], + [GameInfoChoice.Help, '操作帮助'], + [GameInfoChoice.Download, '下载离线版本'], + [GameInfoChoice.Back, '返回主菜单'] + ]; + + const choose = async (key: ChoiceKey) => { + switch (key) { + case GameInfoChoice.Statistics: { + // todo + break; + } + case GameInfoChoice.Project: { + if (core.platform.isPC) window.open('editor.html', '_blank'); + else { + const confirm = await getConfirm( + props.controller, + '即将离开本游戏,跳转至工程页面,确认跳转?', + props.loc, + 240 + ); + if (confirm) { + window.location.href = 'editor-mobile.html'; + } + } + break; + } + case GameInfoChoice.Tower: { + const name = core.firstData.name; + const href = `/tower/?name=${name}`; + if (core.platform.isPC) { + window.open(href, '_blank'); + } else { + const confirm = await getConfirm( + props.controller, + '即将离开本游戏,跳转至评论页面,确认跳转?', + props.loc, + 240 + ); + if (confirm) { + window.location.href = href; + } + } + break; + } + case GameInfoChoice.Download: { + const name = core.firstData.name; + const href = `/games/${name}/${name}.zip`; + if (core.platform.isPC) window.open(href); + else window.location.href = href; + break; + } + case GameInfoChoice.Help: { + // todo + break; + } + case GameInfoChoice.Back: { + props.controller.close(props.instance); + break; + } + } + }; + + return () => ( + + ); +}); + +const enum SyncSaveChoice { + // ----- 主菜单 + ToServer, + FromServer, + ToLocal, + FromLocal, + ClearLocal, + Back, + // ----- 子菜单 + AllSaves, + NowSave +} + +export const SyncSave = defineComponent(props => { + const choices: ChoiceItem[] = [ + [SyncSaveChoice.ToServer, '同步存档至服务器'], + [SyncSaveChoice.FromServer, '从服务器加载存档'], + [SyncSaveChoice.ToLocal, '存档至本地文件'], + [SyncSaveChoice.FromLocal, '存本地文件读档'], + [SyncSaveChoice.ClearLocal, '清空本地存档'], + [SyncSaveChoice.Back, '返回上一级'] + ]; + + const choose = (key: ChoiceKey) => { + switch (key) { + case SyncSaveChoice.ToServer: { + props.controller.open(SyncSaveSelectUI, { loc: props.loc }); + break; + } + case SyncSaveChoice.FromServer: { + // todo + break; + } + case SyncSaveChoice.ToLocal: { + props.controller.open(DownloadSaveSelectUI, { loc: props.loc }); + break; + } + case SyncSaveChoice.FromLocal: { + // todo + break; + } + case SyncSaveChoice.ClearLocal: { + props.controller.open(ClearSaveSelectUI, { loc: props.loc }); + break; + } + case SyncSaveChoice.Back: { + props.controller.close(props.instance); + break; + } + } + }; + + return () => ( + + ); +}); + +export const SyncSaveSelect = defineComponent(props => { + const choices: ChoiceItem[] = [ + [SyncSaveChoice.AllSaves, '同步全部存档'], + [SyncSaveChoice.NowSave, '同步当前存档'], + [SyncSaveChoice.Back, '返回上一级'] + ]; + + const choose = async (key: ChoiceKey) => { + switch (key) { + case SyncSaveChoice.AllSaves: { + core.playSound('confirm.opus'); + const confirm = await getConfirm( + props.controller, + '你确定要同步全部存档么?这可能在存档较多的时候比较慢。', + props.loc, + 240 + ); + if (confirm) { + core.syncSave('all'); + } + break; + } + case SyncSaveChoice.NowSave: { + core.playSound('confirm.opus'); + const confirm = await getConfirm( + props.controller, + '确定要同步当前存档吗?', + props.loc, + 240 + ); + if (confirm) { + core.syncSave(); + } + break; + } + case SyncSaveChoice.Back: { + props.controller.close(props.instance); + break; + } + } + }; + + return () => ( + + ); +}); + +export const DownloadSaveSelect = defineComponent(props => { + const choices: ChoiceItem[] = [ + [SyncSaveChoice.AllSaves, '下载全部存档'], + [SyncSaveChoice.NowSave, '下载当前存档'], + [SyncSaveChoice.Back, '返回上一级'] + ]; + + const choose = async (key: ChoiceKey) => { + switch (key) { + case SyncSaveChoice.AllSaves: { + const confirm = await getConfirm( + props.controller, + '确认要下载所有存档吗?', + props.loc, + 240 + ); + if (confirm) { + const data = await waitbox( + props.controller, + props.loc, + 240, + getAllSavesData(), + { text: '请等待处理完毕' } + ); + core.download( + `${core.firstData.name}_${core.formatDate2(new Date())}.h5save`, + data + ); + } + break; + } + case SyncSaveChoice.NowSave: { + const confirm = await getConfirm( + props.controller, + '确认要下载当前存档吗?', + props.loc, + 240 + ); + if (confirm) { + const data = await getSaveData(core.saves.saveIndex); + core.download( + `${core.firstData.name}_${core.formatDate2(new Date())}.h5save`, + data + ); + } + break; + } + case SyncSaveChoice.Back: { + props.controller.close(props.instance); + break; + } + } + }; + + return () => ( + + ); +}); + +export const ClearSaveSelect = defineComponent(props => { + const choices: ChoiceItem[] = [ + [SyncSaveChoice.AllSaves, '清空全部塔存档'], + [SyncSaveChoice.NowSave, '清空当前塔存档'], + [SyncSaveChoice.Back, '返回上一级'] + ]; + + const choose = async (key: ChoiceKey) => { + switch (key) { + case SyncSaveChoice.AllSaves: { + const confirm = await getConfirm( + props.controller, + '你确定要清除【全部游戏】的所有本地存档?此行为不可逆!!!', + props.loc, + 240 + ); + if (confirm) { + await waitbox( + props.controller, + props.loc, + 240, + new Promise(res => { + core.clearLocalForage(() => { + core.saves.ids = {}; + core.saves.autosave.data = null; + core.saves.autosave.updated = false; + core.saves.autosave.now = 0; + // @ts-expect-error 沙比样板 + core.saves.cache = {}; + core.saves.saveIndex = 1; + core.saves.favorite = []; + core.saves.favoriteName = {}; + // @ts-expect-error 沙比样板 + core.control._updateFavoriteSaves(); + core.removeLocalStorage('saveIndex'); + res(); + }); + }), + { text: '正在情况,请稍后...' } + ); + await getConfirm( + props.controller, + '所有塔的存档已经全部清空', + props.loc, + 240 + ); + } + break; + } + case SyncSaveChoice.NowSave: { + const confirm = await getConfirm( + props.controller, + '你确定要清除【当前游戏】的所有本地存档?此行为不可逆!!!', + props.loc, + 240 + ); + if (confirm) { + await waitbox( + props.controller, + props.loc, + 240, + new Promise(res => { + Object.keys(core.saves.ids).forEach(function (v) { + core.removeLocalForage('save' + v); + }); + core.removeLocalForage('autoSave', () => { + core.saves.ids = {}; + core.saves.autosave.data = null; + core.saves.autosave.updated = false; + core.saves.autosave.now = 0; + core.ui.closePanel(); + core.saves.saveIndex = 1; + core.saves.favorite = []; + core.saves.favoriteName = {}; + // @ts-expect-error 沙比样板 + core.control._updateFavoriteSaves(); + core.removeLocalStorage('saveIndex'); + res(); + }); + }), + { text: '正在情况,请稍后...' } + ); + await getConfirm( + props.controller, + '当前塔的存档已被清空', + props.loc, + 240 + ); + } + break; + } + case SyncSaveChoice.Back: { + props.controller.close(props.instance); + break; + } + } + }; + + return () => ( + + ); +}); + +/** @see {@link MainSettings} */ +export const MainSettingsUI = new GameUI('main-settings', MainSettings); +/** @see {@link ReplaySettings} */ +export const ReplaySettingsUI = new GameUI('replay-settings', ReplaySettings); +/** @see {@link GameInfo} */ +export const GameInfoUI = new GameUI('game-info', GameInfo); +/** @see {@link SyncSave} */ +export const SyncSaveUI = new GameUI('sync-save', SyncSave); +/** @see {@link SyncSaveSelect} */ +export const SyncSaveSelectUI = new GameUI('sync-save-select', SyncSaveSelect); +/** @see {@link DownloadSaveSelect} */ +export const DownloadSaveSelectUI = new GameUI( + 'download-save-select', + DownloadSaveSelect +); +/** @see {@link ClearSaveSelect} */ +export const ClearSaveSelectUI = new GameUI( + 'clear-save-select', + ClearSaveSelect +); diff --git a/src/module/render/ui/statusBar.tsx b/src/module/render/ui/statusBar.tsx index 5a3a806..b3486c2 100644 --- a/src/module/render/ui/statusBar.tsx +++ b/src/module/render/ui/statusBar.tsx @@ -1,4 +1,4 @@ -import { GameUI } from '@/core/system'; +import { GameUI, UIComponentProps } from '@/core/system'; import { computed, defineComponent, ref, watch } from 'vue'; import { SetupComponentOptions, TextContent } from '../components'; import { DefaultProps, ElementLocator, Sprite, Font } from '@/core/render'; @@ -34,7 +34,7 @@ export interface ILeftHeroStatus { magicDef: number; } -interface StatusBarProps extends DefaultProps { +interface StatusBarProps extends DefaultProps, UIComponentProps { loc: ElementLocator; status: T; hidden: boolean; diff --git a/src/module/utils/index.ts b/src/module/utils/index.ts new file mode 100644 index 0000000..dbc735d --- /dev/null +++ b/src/module/utils/index.ts @@ -0,0 +1 @@ +export * from './saves'; diff --git a/src/module/utils/saves.ts b/src/module/utils/saves.ts new file mode 100644 index 0000000..5e3e7e6 --- /dev/null +++ b/src/module/utils/saves.ts @@ -0,0 +1,35 @@ +export function getAllSavesData() { + return new Promise(res => { + core.getAllSaves(saves => { + if (!saves) { + res(''); + return; + } + const content = { + name: core.firstData.name, + version: core.firstData.version, + data: saves + }; + // @ts-expect-error 暂时无法推导 + res(LZString.compressToBase64(JSON.stringify(content))); + }); + }); +} + +export function getSaveData(index: number) { + return new Promise(res => { + core.getSave(index, data => { + if (!data) { + res(''); + return; + } + const content = { + name: core.firstData.name, + version: core.firstData.version, + data: data + }; + // @ts-expect-error 暂时无法推导 + res(LZString.compressToBase64(JSON.stringify(content))); + }); + }); +} diff --git a/src/types/core.d.ts b/src/types/core.d.ts index f18029a..5df6718 100644 --- a/src/types/core.d.ts +++ b/src/types/core.d.ts @@ -657,7 +657,7 @@ interface CoreSave { /** * 自动存档信息 */ - autosave: Readonly; + autosave: Autosave; /** * 收藏的存档 @@ -679,7 +679,7 @@ interface Autosave { /** * 当前存档信息 */ - data?: Save[]; + data?: Save[] | null; /** * 自动存档位的最大值 @@ -982,7 +982,7 @@ interface Core extends Pick { /** * 存档信息 */ - readonly saves: Readonly; + readonly saves: CoreSave; /** * 全局数值信息 diff --git a/src/types/event.d.ts b/src/types/event.d.ts index 641f484..3dd9da0 100644 --- a/src/types/event.d.ts +++ b/src/types/event.d.ts @@ -46,7 +46,7 @@ interface Events extends EventData { startGame( hard: string, seed?: number, - route?: string, + route?: string[], callback?: () => void ): void;