diff --git a/package.json b/package.json index 333e43c..3573276 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@babel/cli": "^7.26.4", "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", + "@eslint/js": "^9.24.0", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^25.0.8", "@rollup/plugin-json": "^6.1.0", diff --git a/packages-user/client-modules/src/render/components/input.tsx b/packages-user/client-modules/src/render/components/input.tsx new file mode 100644 index 0000000..e939fda --- /dev/null +++ b/packages-user/client-modules/src/render/components/input.tsx @@ -0,0 +1,521 @@ +import { DefaultProps } from '@motajs/render-vue'; +import { computed, defineComponent, onUnmounted, ref, watch } from 'vue'; +import { TextContent, TextContentProps } from './textbox'; +import { SetupComponentOptions } from './types'; +import { RectRCircleParams } from '@motajs/render-elements'; +import { + Container, + ElementLocator, + MotaRenderer, + RenderItem, + Transform +} from '@motajs/render-core'; +import { Font } from '@motajs/render-style'; +import { transitionedColor } from '../use'; +import { linear } from 'mutate-animate'; +import { Background, Selection } from './misc'; +import { GameUI, IUIMountable } from '@motajs/system-ui'; + +export interface InputProps extends DefaultProps, Partial { + /** 输入框的提示内容 */ + placeholder?: string; + /** 输入框的值 */ + value?: string; + /** 是否是多行输入,多行输入时,允许换行 */ + multiline?: boolean; + /** 边框颜色 */ + border?: string; + /** 边框圆角 */ + circle?: RectRCircleParams; + /** 边框宽度 */ + borderWidth?: number; + /** 内边距 */ + pad?: number; +} + +export type InputEmits = { + /** + * 当输入框的值被确认时触发,例如失焦时 + * @param value 输入框的值 + */ + change: (value: string) => void; + + /** + * 当输入框的值发生改变时触发,例如输入了一个字符,或者删除了一个字母 + * @param value 输入框的值 + */ + input: (value: string) => void; + + 'update:value': (value: string) => void; +}; + +const inputProps = { + props: ['placeholder', 'value', 'multiline'], + emits: ['change', 'input', 'update:value'] +} satisfies SetupComponentOptions; + +/** + * 输入组件,点击后允许编辑。参数参考 {@link InputProps},事件参考 {@link InputEmits}。 + * 完全继承 TextContent 组件的参数,用于控制输入内容的显示方式。用法示例: + * ```tsx + * const inputValue = ref(''); + * + * ``` + */ +export const Input = defineComponent( + (props, { attrs, emit }) => { + let ele: HTMLInputElement | HTMLTextAreaElement | null = null; + + const value = ref(props.value ?? ''); + const root = ref(); + + const width = computed(() => props.loc?.[2] ?? 200); + const height = computed(() => props.loc?.[3] ?? 200); + const showText = computed(() => value.value || props.placeholder || ''); + const padding = computed(() => props.pad ?? 4); + const textLoc = computed(() => [ + padding.value, + padding.value, + width.value - padding.value * 2, + height.value - padding.value * 2 + ]); + + const borderColor = transitionedColor( + props.border ?? '#ddd', + 200, + linear() + )!; + + const listenInput = () => { + if (!ele) return; + ele.addEventListener('input', () => { + if (ele) { + updateInput(ele.value); + } + }); + ele.addEventListener('change', () => { + if (ele) { + updateValue(ele.value); + } + }); + ele.addEventListener('blur', () => { + ele?.remove(); + }); + }; + + const createInput = (mul: boolean) => { + if (mul) { + ele = document.createElement('textarea'); + } else { + ele = document.createElement('input'); + } + // See file://./../../../../../src/styles.less + ele.classList.add('motajs-input-element'); + listenInput(); + }; + + const updateValue = (newValue: string) => { + value.value = newValue; + emit('update:value', newValue); + emit('change', newValue); + }; + + const updateInput = (newValue: string) => { + value.value = newValue; + emit('update:value', newValue); + emit('input', newValue); + }; + + const click = () => { + if (!ele) createInput(props.multiline ?? false); + if (!ele) return; + // 计算当前绝对位置 + const renderer = MotaRenderer.get('render-main'); + const canvas = renderer?.getCanvas(); + if (!canvas) return; + const chain: RenderItem[] = []; + let now: RenderItem | undefined = root.value; + if (!now) return; + while (now) { + chain.unshift(now); + now = now.parent; + } + const { clientLeft, clientTop } = canvas; + const trans = new Transform(); + trans.translate(clientLeft, clientTop); + trans.scale(core.domStyle.scale); + for (const item of chain) { + const { anchorX, anchorY, width, height } = item; + trans.translate(-anchorX * width, -anchorY * height); + trans.multiply(item.transform); + } + trans.translate(padding.value, padding.value); + const [a, b, , c, d, , e, f] = trans.mat; + const str = `matrix(${a},${b},${c},${d},${e},${f})`; + const w = width.value * core.domStyle.scale; + const h = height.value * core.domStyle.scale; + const font = props.font ?? Font.defaults(); + ele.style.transform = str; + ele.style.width = `${w - padding.value * 2}px`; + ele.style.height = `${h - padding.value * 2}px`; + ele.style.font = font.string(); + ele.style.color = String(props.fillStyle ?? 'white'); + document.body.appendChild(ele); + ele.focus(); + }; + + const enter = () => { + borderColor.set('#0ff'); + }; + + const leave = () => { + borderColor.set(props.border ?? '#ddd'); + }; + + watch( + () => props.value, + newValue => { + value.value = newValue ?? ''; + } + ); + + watch( + () => props.multiline, + value => { + createInput(value ?? false); + }, + { + immediate: true + } + ); + + onUnmounted(() => { + ele?.remove(); + }); + + return () => ( + + + + + ); + }, + inputProps +); + +export interface InputBoxProps extends TextContentProps { + loc: ElementLocator; + input?: InputProps; + winskin?: ImageIds; + color?: CanvasStyle; + border?: CanvasStyle; + pad?: number; + inputHeight?: number; + text?: string; + yesText?: string; + noText?: string; + selFont?: Font; + selFill?: CanvasStyle; +} + +export type InputBoxEmits = { + /** + * 当确认输入框的内容时触发 + * @param value 输入框的内容 + */ + confirm: (value: string) => void; + + /** + * 当取消时触发 + * @param value 输入框的内容 + */ + cancel: (value: string) => void; +} & InputEmits; + +const inputBoxProps = { + props: [ + 'loc', + 'input', + 'winskin', + 'color', + 'border', + 'pad', + 'inputHeight', + 'text', + 'yesText', + 'noText', + 'selFont', + 'selFill', + 'width' + ], + emits: ['confirm', 'cancel', 'change', 'input', 'update:value'] +} satisfies SetupComponentOptions< + InputBoxProps, + InputBoxEmits, + keyof InputBoxEmits +>; + +/** + * 输入框组件,与 2.x 的 myconfirm 类似,单次调用参考 {@link getInput}。用法与 `ConfirmBox` 类似。 + * 参数参考 {@link InputBoxProps},事件参考 {@link InputBoxEmits},用例如下: + * ```tsx + * const onConfirm = (value: string) => console.log(value); + * + * + * ``` + */ +export const InputBox = defineComponent< + InputBoxProps, + InputBoxEmits, + keyof InputBoxEmits +>((props, { attrs, emit }) => { + const contentHeight = ref(0); + const value = ref(''); + const selected = ref(false); + const yesSize = ref<[number, number]>([0, 0]); + const noSize = ref<[number, number]>([0, 0]); + const height = ref(200); + + const yesText = computed(() => props.yesText ?? '确认'); + const noText = computed(() => props.noText ?? '取消'); + const text = computed(() => props.text ?? '请输入内容:'); + const padding = computed(() => props.pad ?? 8); + const inputHeight = computed(() => props.inputHeight ?? 16); + const inputLoc = computed(() => [ + padding.value, + padding.value * 2 + contentHeight.value, + props.width - padding.value * 2, + inputHeight.value - padding.value * 2 + ]); + const yesLoc = computed(() => { + const y = height.value - padding.value; + return [props.width / 3, y, void 0, void 0, 0.5, 1]; + }); + const noLoc = computed(() => { + const y = height.value - padding.value; + return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1]; + }); + const selectionLoc = computed(() => { + if (selected.value) { + const [x = 0, y = 0] = yesLoc.value; + const [width, height] = yesSize.value; + return [x, y + 4, width + 8, height + 8, 0.5, 1]; + } else { + const [x = 0, y = 0] = noLoc.value; + const [width, height] = noSize.value; + return [x, y + 4, width + 8, height + 8, 0.5, 1]; + } + }); + + const updateHeight = (h: number) => { + contentHeight.value = h; + height.value = h + inputHeight.value + padding.value * 4; + }; + + const change = (value: string) => { + emit('change', value); + }; + + const input = (value: string) => { + emit('update:value', value); + emit('input', value); + }; + + const setYes = (_: string, width: number, height: number) => { + yesSize.value = [width, height]; + }; + + const setNo = (_: string, width: number, height: number) => { + noSize.value = [width, height]; + }; + + const confirm = () => { + emit('confirm', value.value); + }; + + const cancel = () => { + emit('cancel', value.value); + }; + + return () => ( + + + + + + (selected.value = true)} + onSetText={setYes} + /> + (selected.value = false)} + onSetText={setNo} + /> + + ); +}, inputBoxProps); + +/** + * 弹出一个输入框,然后将结果返回: + * ```ts + * const value = await getInput( + * // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可 + * props.controller, + * // 提示内容 + * '请输入文本:', + * // 输入框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效 + * [240, 240, void 0, void 0, 0.5, 0.5], + * // 宽度设为 240 + * 240, + * // 可以给选择框传入其他的 props,例如指定字体,此项可选 + * { font: new Font('Verdana', 20) } + * ); + * // 之后,就可以根据 value 来执行不同的操作了 + * console.log(value); // 输出用户输入的内容 + * ``` + * @param controller UI 控制器 + * @param text 确认文本内容 + * @param loc 确认框的位置 + * @param width 确认框的宽度 + * @param props 额外的 props,参考 {@link ConfirmBoxProps} + */ +export function getInput( + controller: IUIMountable, + text: string, + loc: ElementLocator, + width: number, + props?: InputBoxProps +) { + return new Promise(res => { + const instance = controller.open( + InputBoxUI, + { + ...(props ?? {}), + text, + loc, + width, + onConfirm: value => { + controller.close(instance); + res(value); + }, + onCancel: () => { + controller.close(instance); + res(''); + } + }, + true + ); + }); +} + +/** + * 与 `getInput` 类似,不过会将结果转为数字。用法参考 {@link getInput} + */ +export async function getInputNumber( + controller: IUIMountable, + text: string, + loc: ElementLocator, + width: number, + props?: InputBoxProps +) { + const value = await getInput(controller, text, loc, width, props); + return parseFloat(value); +} + +export const InputBoxUI = new GameUI('input-box', InputBox); diff --git a/packages-user/client-modules/src/render/components/misc.tsx b/packages-user/client-modules/src/render/components/misc.tsx index 94a4076..b8bc83b 100644 --- a/packages-user/client-modules/src/render/components/misc.tsx +++ b/packages-user/client-modules/src/render/components/misc.tsx @@ -8,7 +8,7 @@ import { import { computed, defineComponent, ref, SetupContext, watch } from 'vue'; import { SetupComponentOptions } from './types'; import { MotaOffscreenCanvas2D } from '@motajs/render'; -import { TextboxProps, TextContent, TextContentProps } from './textbox'; +import { TextContent, TextContentProps } from './textbox'; import { Scroll, ScrollExpose, ScrollProps } from './scroll'; import { transitioned } from '../use'; import { hyper } from 'mutate-animate'; @@ -457,6 +457,8 @@ const waitBoxProps = { * winskin="winskin2.png" * // 完全继承 TextContent 的参数,因此可以直接指定字体 * font={new Font('Verdana', 28)} + * // 当传入的 Promise 兑现时触发此事件,注意此事件只可能触发一次,触发后便不会再次触发 + * onResolve={(time) => console.log(time)} * /> * ``` */ diff --git a/packages-user/client-modules/src/render/ui/settings.tsx b/packages-user/client-modules/src/render/ui/settings.tsx index e258efc..f2df369 100644 --- a/packages-user/client-modules/src/render/ui/settings.tsx +++ b/packages-user/client-modules/src/render/ui/settings.tsx @@ -14,7 +14,8 @@ import { mainUi } from '@motajs/legacy-ui'; import { gameKey } from '@motajs/system-action'; import { generateKeyboardEvent } from '@motajs/system-action'; import { getVitualKeyOnce } from '@motajs/legacy-ui'; -import { getAllSavesData, getSaveData } from '../../utils'; +import { getAllSavesData, getSaveData, syncFromServer } from '../../utils'; +import { getInput } from '../components/input'; export interface SettingsProps extends Partial, UIComponentProps { loc: ElementLocator; @@ -294,14 +295,20 @@ export const SyncSave = defineComponent(props => { [SyncSaveChoice.Back, '返回上一级'] ]; - const choose = (key: ChoiceKey) => { + const choose = async (key: ChoiceKey) => { switch (key) { case SyncSaveChoice.ToServer: { props.controller.open(SyncSaveSelectUI, { loc: props.loc }); break; } case SyncSaveChoice.FromServer: { - // todo + const replay = await getInput( + props.controller, + '请输入存档编号+密码', + [240, 240, void 0, void 0, 0.5, 0.5], + 240 + ); + await syncFromServer(props.controller, replay); break; } case SyncSaveChoice.ToLocal: { diff --git a/packages-user/client-modules/src/utils/saves.ts b/packages-user/client-modules/src/utils/saves.ts index 5e3e7e6..12cdf81 100644 --- a/packages-user/client-modules/src/utils/saves.ts +++ b/packages-user/client-modules/src/utils/saves.ts @@ -1,3 +1,8 @@ +import { compressToBase64, decompressFromBase64 } from 'lz-string'; +import { getConfirm, waitbox } from '../render'; +import { IUIMountable } from '@motajs/system-ui'; +import { SyncSaveFromServerResponse } from '@motajs/client-base'; + export function getAllSavesData() { return new Promise(res => { core.getAllSaves(saves => { @@ -10,8 +15,7 @@ export function getAllSavesData() { version: core.firstData.version, data: saves }; - // @ts-expect-error 暂时无法推导 - res(LZString.compressToBase64(JSON.stringify(content))); + res(compressToBase64(JSON.stringify(content))); }); }); } @@ -28,8 +32,140 @@ export function getSaveData(index: number) { version: core.firstData.version, data: data }; - // @ts-expect-error 暂时无法推导 - res(LZString.compressToBase64(JSON.stringify(content))); + res(compressToBase64(JSON.stringify(content))); }); }); } + +//#region 服务器加载 + +const enum FromServerResponse { + Success, + ErrorCannotParse, + ErrorCannotSync, + ErrorUnexpectedCode +} + +function parseIdPassword(id: string): [string, string] { + if (id.length === 7) return [id.slice(0, 4), id.slice(4)]; + else return [id.slice(0, 6), id.slice(6)]; +} + +async function parseResponse(response: SyncSaveFromServerResponse) { + let msg: Save | Save[] | null = null; + try { + msg = JSON.parse(decompressFromBase64(response.msg)); + } catch { + // 无视报错 + } + if (!msg) { + try { + msg = JSON.parse(response.msg); + } catch { + // 无视报错 + } + } + if (msg) { + return msg; + } else { + return FromServerResponse.ErrorCannotParse; + } +} + +async function syncLoad(id: string, password: string) { + const formData = new FormData(); + formData.append('type', 'load'); + formData.append('name', core.firstData.name); + formData.append('id', id); + formData.append('password', password); + + try { + const response = await fetch('/games/sync.php', { + method: 'POST', + body: formData + }); + + const data = (await response.json()) as SyncSaveFromServerResponse; + if (data.code === 0) { + return parseResponse(data); + } else { + return FromServerResponse.ErrorUnexpectedCode; + } + } catch { + return FromServerResponse.ErrorCannotSync; + } +} + +export async function syncFromServer( + controller: IUIMountable, + identifier: string +): Promise { + if (!/^\d{6}\w{4}$/.test(identifier) && !/^\d{4}\w{3}$/.test(identifier)) { + return void getConfirm( + controller, + '不合法的存档编号+密码!请检查格式!', + [240, 240, void 0, void 0, 0.5, 0.5], + 240 + ); + } + const [id, password] = parseIdPassword(identifier); + const result = await waitbox( + controller, + [240, 240, void 0, void 0, 0.5, 0.5], + 240, + syncLoad(id, password) + ); + if (typeof result === 'number') { + const map = { + [FromServerResponse.ErrorCannotParse]: '出错啦!\n存档解析失败', + [FromServerResponse.ErrorCannotSync]: + '出错啦!\n无法从服务器同步存档。', + [FromServerResponse.ErrorUnexpectedCode]: + '出错啦!\n无法从服务器同步存档。' + }; + return void getConfirm( + controller, + map[result], + [240, 240, void 0, void 0, 0.5, 0.5], + 240 + ); + } + if (result instanceof Array) { + const confirm = await getConfirm( + controller, + '所有本地存档都将被覆盖,确认?', + [240, 240, void 0, void 0, 0.5, 0.5], + 240, + { + defaultYes: true + } + ); + if (confirm) { + const max = 5 * (main.savePages || 30); + for (let i = 1; i <= max; i++) { + if (i <= result.length) { + core.setLocalForage('save' + i, result[i - 1]); + } else if (core.saves.ids[i]) { + core.removeLocalForage('save' + i); + } + } + return void getConfirm( + controller, + '同步成功!\n你的本地所有存档均已被覆盖。', + [240, 240, void 0, void 0, 0.5, 0.5], + 240 + ); + } + } else { + const idx = core.saves.saveIndex; + await new Promise(res => { + core.setLocalForage(`save${idx}`, result, res); + }); + return void getConfirm( + controller, + `同步成功!\n单存档已覆盖至存档 ${idx}`, + [240, 240, void 0, void 0, 0.5, 0.5], + 240 + ); + } +} diff --git a/packages-user/client-modules/src/utils/use.ts b/packages-user/client-modules/src/utils/use.ts index d165e6c..a07d223 100644 --- a/packages-user/client-modules/src/utils/use.ts +++ b/packages-user/client-modules/src/utils/use.ts @@ -1,10 +1,11 @@ -import { getCurrentInstance, onUnmounted } from 'vue'; +import { onUnmounted } from 'vue'; import { WeatherController } from '../weather'; let weatherId = 0; export function useWeather(): [WeatherController] { const weather = new WeatherController(`@weather-${weatherId}`); + weatherId++; onUnmounted(() => { weather.destroy(); diff --git a/packages/client-base/src/types.ts b/packages/client-base/src/types.ts index 7e28266..7a2151c 100644 --- a/packages/client-base/src/types.ts +++ b/packages/client-base/src/types.ts @@ -2,3 +2,7 @@ export interface ResponseBase { code: number; message: string; } + +export interface SyncSaveFromServerResponse extends ResponseBase { + msg: string; +} diff --git a/packages/render-style/src/font.ts b/packages/render-style/src/font.ts index 6c84710..ed46429 100644 --- a/packages/render-style/src/font.ts +++ b/packages/render-style/src/font.ts @@ -69,7 +69,7 @@ export class Font implements IFontConfig { } private getFallbackFont(used: Set) { - let font = ''; + let font = this.build(); this.fallbacks.forEach(v => { if (used.has(v)) { logger.warn(62, this.build()); @@ -86,7 +86,7 @@ export class Font implements IFontConfig { return this.build(); } else { const usedFont = new Set(); - return this.build() + this.getFallbackFont(usedFont); + return this.getFallbackFont(usedFont); } } diff --git a/packages/system-ui/src/controller.ts b/packages/system-ui/src/controller.ts index d9ef07c..9147d7e 100644 --- a/packages/system-ui/src/controller.ts +++ b/packages/system-ui/src/controller.ts @@ -23,11 +23,11 @@ import { import { UIContainer } from './container'; export const enum UIMode { - /** 仅显示最后一个 UI,在关闭时,只会关闭指定的 UI */ + /** 仅显示最后一个非 alwaysShow 的 UI,在关闭时,只会关闭指定的 UI */ LastOnly, /** 显示所有非手动隐藏的 UI,在关闭时,只会关闭指定 UI */ All, - /** 仅显示最后一个 UI,在关闭时,在此之后的所有 UI 会全部关闭 */ + /** 仅显示最后一个非 alwaysShow 的 UI,在关闭时,在此之后的所有 UI 会全部关闭 */ LastOnlyStack, /** 显示所有非手动隐藏的 UI,在关闭时,在此之后的所有 UI 会全部关闭 */ AllStack, @@ -181,8 +181,9 @@ export class UIController switch (this.mode) { case UIMode.LastOnly: case UIMode.LastOnlyStack: - this.stack.forEach(v => v.hide()); this.stack.push(ins); + this.stack.forEach(v => v.hide()); + this.stack.findLast(v => !v.alwaysShow)?.show(); break; case UIMode.All: case UIMode.AllStack: @@ -204,17 +205,13 @@ export class UIController case UIMode.LastOnly: { this.stack.splice(index, 1); this.stack.forEach(v => v.hide()); - const last = this.stack.at(-1); - if (!last) break; - last.show(); + this.stack.findLast(v => !v.alwaysShow)?.show(); break; } case UIMode.LastOnlyStack: { this.stack.splice(index); this.stack.forEach(v => v.hide()); - const last = this.stack[index - 1]; - if (!last) break; - last.show(); + this.stack.findLast(v => !v.alwaysShow)?.show(); break; } case UIMode.All: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f32aa2..541ea7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@babel/preset-env': specifier: ^7.26.9 version: 7.26.9(@babel/core@7.26.10) + '@eslint/js': + specifier: ^9.24.0 + version: 9.24.0 '@rollup/plugin-babel': specifier: ^6.0.4 version: 6.0.4(@babel/core@7.26.10)(@types/babel__core@7.20.5)(rollup@3.29.5) @@ -1750,6 +1753,10 @@ packages: resolution: {integrity: sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.24.0': + resolution: {integrity: sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7059,6 +7066,8 @@ snapshots: '@eslint/js@9.22.0': {} + '@eslint/js@9.24.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.2.7': diff --git a/src/styles.less b/src/styles.less index cee91ac..ae3357c 100644 --- a/src/styles.less +++ b/src/styles.less @@ -120,3 +120,12 @@ div.toolbar-editor-item { display: flex; align-items: center; } + +.motajs-input-element { + left: 0; + top: 0; + position: fixed; + border: none; + z-index: 1000; + background-color: transparent; +}