import { DefaultProps, ElementLocator, onTick, PathProps, Sprite } from '@motajs/render'; import { computed, defineComponent, ref, SetupContext, watch } from 'vue'; import { MotaOffscreenCanvas2D } from '@motajs/render'; import { TextContent, TextContentProps } from './textbox'; import { Scroll, ScrollExpose, ScrollProps } from './scroll'; import { transitioned } from '../use'; import { hyper } from 'mutate-animate'; import { logger } from '@motajs/common'; import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system-ui'; import { clamp } from 'lodash-es'; interface ProgressProps extends DefaultProps { /** 进度条的位置 */ loc: ElementLocator; /** 进度条的进度,1表示完成,0表示未完成 */ progress: number; /** 已完成部分的样式,默认为绿色(green) */ success?: CanvasStyle; /** 未完成部分的样式,默认为灰色(gray) */ background?: CanvasStyle; /** 线宽度 */ lineWidth?: number; } const progressProps = { props: ['loc', 'progress', 'success', 'background'] } satisfies SetupComponentOptions; /** * 进度条组件,参数参考 {@link ProgressProps},用例如下: * ```tsx * // 定义进度 * const progress = ref(0); * * // 显示进度条 * * ``` */ export const Progress = defineComponent(props => { const element = ref(); const render = (canvas: MotaOffscreenCanvas2D) => { const { ctx } = canvas; const width = props.loc[2] ?? 200; const height = props.loc[3] ?? 200; ctx.lineCap = 'round'; const lineWidth = props.lineWidth ?? 2; ctx.lineWidth = lineWidth; ctx.strokeStyle = props.background ?? 'gray'; ctx.beginPath(); ctx.moveTo(lineWidth, height / 2); ctx.lineTo(width - lineWidth, height / 2); ctx.stroke(); const progress = clamp(props.progress, 0, 1); if (!isNaN(progress)) { ctx.strokeStyle = props.success ?? 'green'; const p = lineWidth + (width - lineWidth * 2) * progress; ctx.beginPath(); ctx.moveTo(lineWidth, height / 2); ctx.lineTo(p, height / 2); ctx.stroke(); } }; watch(props, () => { element.value?.update(); }); return () => { return ; }; }, progressProps); export interface ArrowProps extends PathProps { /** 箭头的起始和终点坐标,前两个是起始坐标,后两个是终点坐标 */ arrow: [number, number, number, number]; /** 箭头的头部大小 */ head?: number; /** 箭头的颜色 */ color?: CanvasStyle; } const arrowProps = { props: ['arrow', 'head', 'color'] } satisfies SetupComponentOptions; /** * 箭头组件,显示一个箭头,参数参考 {@link ArrowProps},用例如下: * ```tsx * // 从 (12, 12) 到 (48, 48) 的箭头 * * ``` */ export const Arrow = defineComponent(props => { const loc = computed(() => { const [x1, y1, x2, y2] = props.arrow; const left = Math.min(x1, x2); const right = Math.max(x1, x2); const top = Math.min(y1, y2); const bottom = Math.max(y1, y2); return [left, top, right - left, bottom - top]; }); const path = computed(() => { const path = new Path2D(); const head = props.head ?? 8; const [x = 0, y = 0] = loc.value; const [x1, y1, x2, y2] = props.arrow; path.moveTo(x1 - x, y1 - y); path.lineTo(x2 - x, y2 - y); const angle = Math.atan2(y2 - y1, x2 - x1); path.moveTo( x2 - head * Math.cos(angle - Math.PI / 6), y2 - head * Math.sin(angle - Math.PI / 6) ); path.lineTo(x2 - x, y2 - y); path.lineTo( x2 - head * Math.cos(angle + Math.PI / 6), y2 - head * Math.sin(angle + Math.PI / 6) ); return path; }); return () => ( ); }, arrowProps); export interface ScrollTextProps extends TextContentProps, ScrollProps { /** 自动滚动的速度,每秒多少像素 */ speed: number; /** 文字的最大宽度 */ width: number; /** 自动滚动组件的定位 */ loc: ElementLocator; /** 文字滚动入元素之前要先滚动多少像素,默认16像素 */ pad?: number; } export type ScrollTextEmits = { /** * 当滚动完毕时触发 */ scrollEnd: () => void; }; export interface ScrollTextExpose { /** * 暂停滚动 */ pause(): void; /** * 继续滚动 */ resume(): void; /** * 设置滚动速度 */ setSpeed(speed: number): void; /** * 立刻重新滚动 */ rescroll(): void; } const scrollProps = { props: ['speed', 'loc', 'pad', 'width'], emits: ['scrollEnd'] } satisfies SetupComponentOptions< ScrollTextProps, ScrollTextEmits, keyof ScrollTextEmits >; /** * 滚动文字,可以用于展示长剧情或者 staff 表,参数参考 {@link ScrollTextProps}, * 事件参考 {@link ScrollTextEmits},函数接口参考 {@link ScrollTextExpose},用例如下: * ```tsx * // 用于接受函数接口 * const scroll = ref(); * // 显示的文字 * const text = '滚动文字'.repeat(100); * * onMounted(() => { * // 设置为每秒滚动 100 像素 * scroll.value?.setSpeed(100); * // 暂停滚动 * scroll.value?.pause(); * }); * * // 显示滚动文字,每秒滚动 50 像素 * * ``` */ export const ScrollText = defineComponent< ScrollTextProps, ScrollTextEmits, keyof ScrollTextEmits >((props, { emit, expose, attrs }) => { const scroll = ref(); const speed = ref(props.speed); const eleHeight = computed(() => props.loc[3] ?? props.width); const pad = computed(() => props.pad ?? 16); let lastFixedTime = Date.now(); let lastFixedPos = 0; let paused = false; let nowScroll = 0; onTick(() => { if (paused || !scroll.value) return; const now = Date.now(); const dt = now - lastFixedTime; nowScroll = (dt / 1000) * speed.value + lastFixedPos; scroll.value.scrollTo(nowScroll, 0); if (nowScroll >= scroll.value.getScrollLength()) { emit('scrollEnd'); paused = true; } lastFixedTime = now; }); const pause = () => { paused = true; }; const resume = () => { paused = false; lastFixedPos = nowScroll; lastFixedTime = Date.now(); }; const setSpeed = (value: number) => { lastFixedPos = nowScroll; lastFixedTime = Date.now(); speed.value = value; }; const rescroll = () => { nowScroll = 0; lastFixedTime = Date.now(); lastFixedPos = 0; }; expose({ pause, resume, setSpeed, rescroll }); return () => ( ); }, scrollProps); export interface SelectionProps extends DefaultProps { loc: ElementLocator; color?: CanvasStyle; border?: CanvasStyle; winskin?: ImageIds; /** 选择图标的不透明度范围 */ alphaRange?: [number, number]; } const selectionProps = { props: ['loc', 'color', 'border', 'winskin', 'alphaRange'] } satisfies SetupComponentOptions; /** * 显示一个选择光标,与 2.x 的 drawUIEventSelector 效果一致,参数参考 {@link SelectionProps},用例如下: * ```tsx * // 使用 winskin.png 作为选择光标,光标动画的不透明度范围是 [0.3, 0.8] * * // 使用指定的填充和边框颜色作为选择光标 * * ``` */ export const Selection = defineComponent(props => { const minAlpha = computed(() => props.alphaRange?.[0] ?? 0.25); const maxAlpha = computed(() => props.alphaRange?.[1] ?? 0.55); const alpha = transitioned(minAlpha.value, 2000, hyper('sin', 'in-out'))!; const isWinskin = computed(() => !!props.winskin); const winskinImage = computed(() => isWinskin.value ? core.material.images.images[props.winskin!] : null ); const fixedLoc = computed(() => { const [x = 0, y = 0, width = 200, height = 200, ax, ay] = props.loc; return [x + 1, y + 1, width - 2, height - 2, ax, ay]; }); const renderWinskin = (canvas: MotaOffscreenCanvas2D) => { const ctx = canvas.ctx; const image = winskinImage.value; if (!image) return; const [, , width = 200, height = 200] = props.loc; // 背景 ctx.drawImage(image, 130, 66, 28, 28, 2, 2, width - 4, height - 4); // 四个角 ctx.drawImage(image, 128, 64, 2, 2, 0, 0, 2, 2); ctx.drawImage(image, 158, 64, 2, 2, width - 2, 0, 2, 2); ctx.drawImage(image, 128, 94, 2, 2, 0, height - 2, 2, 2); ctx.drawImage(image, 158, 94, 2, 2, width - 2, height - 2, 2, 2); // 四条边 ctx.drawImage(image, 130, 64, 28, 2, 2, 0, width - 4, 2); ctx.drawImage(image, 130, 94, 28, 2, 2, height - 2, width - 4, 2); ctx.drawImage(image, 128, 66, 2, 28, 0, 2, 2, height - 4); ctx.drawImage(image, 158, 66, 2, 28, width - 2, 2, 2, height - 4); }; onTick(() => { if (alpha.value === maxAlpha.value) { alpha.set(minAlpha.value); } if (alpha.value === minAlpha.value) { alpha.set(maxAlpha.value); } }); return () => isWinskin.value ? ( ) : ( ); }, selectionProps); export interface BackgroundProps extends DefaultProps { loc: ElementLocator; winskin?: ImageIds; color?: CanvasStyle; border?: CanvasStyle; } const backgroundProps = { props: ['loc', 'winskin', 'color', 'border'] } satisfies SetupComponentOptions; /** * 背景组件,与 Selection 类似,不过绘制的是背景,而不是选择光标,参数参考 {@link BackgroundProps},用例如下: * ```tsx * // 使用 winskin2.png 作为背景 * * // 使用指定填充和边框颜色作为背景 * * ``` */ export const Background = defineComponent(props => { const isWinskin = computed(() => !!props.winskin); const fixedLoc = computed(() => { const [x = 0, y = 0, width = 200, height = 200] = props.loc; return [x + 2, y + 2, width - 4, height - 4]; }); return () => isWinskin.value ? ( ) : ( ); }, 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', 'width'], 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)); * * console.log(time)} * /> * ``` */ 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); export const BackgroundUI = new GameUI('background', Background);