diff --git a/src/core/render/preset/misc.ts b/src/core/render/preset/misc.ts index cba8b74..3608208 100644 --- a/src/core/render/preset/misc.ts +++ b/src/core/render/preset/misc.ts @@ -126,7 +126,8 @@ export class Text extends RenderItem { this.length = width; this.descent = actualBoundingBoxAscent; const height = actualBoundingBoxAscent + actualBoundingBoxDescent; - this.size(width, height + this.strokeWidth * 2 + SAFE_PAD * 2); + const stroke = this.strokeWidth * 2; + this.size(width + stroke, height + stroke + SAFE_PAD * 2); } protected handleProps( diff --git a/src/core/render/render.ts b/src/core/render/render.ts index 2ea7794..fb5175f 100644 --- a/src/core/render/render.ts +++ b/src/core/render/render.ts @@ -562,13 +562,17 @@ export class MotaRenderer extends Container implements IRenderTreeRoot { } private toTagString(item: RenderItem, space: number, deep: number): string { + if (item.isComment) return ''; const name = item.constructor.name; if (item.children.size === 0) { - return `${' '.repeat(deep * space)}<${name} id="${item.id}" uid="${item.uid}" type="${item.type}"${item.hidden ? ' hidden' : ''}>\n`; + return `${' '.repeat(deep * space)}<${name} ${item.id ? `id="${item.id}" ` : ''}uid="${item.uid}"${item.hidden ? ' hidden' : ''} />\n`; } else { return ( - `${' '.repeat(deep * space)}<${name} id="${item.id}" uid="${item.uid}" type="${item.type}" ${item.hidden ? 'hidden' : ''}>\n` + - `${[...item.children].map(v => this.toTagString(v, space, deep + 1)).join('')}` + + `${' '.repeat(deep * space)}<${name} ${item.id ? `${item.id} ` : ''}uid="${item.uid}" ${item.hidden ? 'hidden' : ''}>\n` + + `${[...item.children] + .filter(v => !v.isComment) + .map(v => this.toTagString(v, space, deep + 1)) + .join('')}` + `${' '.repeat(deep * space)}\n` ); } diff --git a/src/core/render/style/font.ts b/src/core/render/style/font.ts index 5478532..953c2ac 100644 --- a/src/core/render/style/font.ts +++ b/src/core/render/style/font.ts @@ -33,16 +33,22 @@ type _FontStretch = type _FontVariant = 'normal' | 'small-caps'; export class Font implements IFontConfig { + static defaultFamily: string = 'Verdana'; + static defaultSize: number = 16; + static defaultSizeUnit: string = 'px'; + static defaultWeight: number = 400; + static defaultItalic: boolean = false; + private readonly fallbacks: Font[] = []; private fontString: string = ''; constructor( - public readonly family: string = 'Verdana', - public readonly size: number = 16, - public readonly sizeUnit: string = 'px', - public readonly weight: number = 400, - public readonly italic: boolean = false + public readonly family: string = Font.defaultFamily, + public readonly size: number = Font.defaultSize, + public readonly sizeUnit: string = Font.defaultSizeUnit, + public readonly weight: number = Font.defaultWeight, + public readonly italic: boolean = Font.defaultItalic ) { this.fontString = this.getFont(); } @@ -93,11 +99,11 @@ export class Font implements IFontConfig { private static parseOne(str: string) { if (!str) return new Font(); - let italic = false; - let weight = 400; - let size = 16; - let unit = 'px'; - let family = 'Verdana'; + let italic = this.defaultItalic; + let weight = this.defaultWeight; + let size = this.defaultSize; + let unit = this.defaultSizeUnit; + let family = this.defaultFamily; const tokens = str.split(/\s+/); tokens.forEach(v => { // font-italic @@ -136,6 +142,24 @@ export class Font implements IFontConfig { } } + /** + * 设置默认字体 + */ + static setDefaults(font: Font) { + this.defaultFamily = font.family; + this.defaultItalic = font.italic; + this.defaultSize = font.size; + this.defaultSizeUnit = font.sizeUnit; + this.defaultWeight = font.weight; + } + + /** + * 获取默认字体 + */ + static defaults() { + return new Font(); + } + /** * 复制一个字体,同时修改字体的一部分属性 * @param font 要复制的字体 diff --git a/src/module/render/components/choices.tsx b/src/module/render/components/choices.tsx index e71312a..1a65c6d 100644 --- a/src/module/render/components/choices.tsx +++ b/src/module/render/components/choices.tsx @@ -1,15 +1,16 @@ -import { DefaultProps, ElementLocator, useKey } from '@/core/render'; -import { computed, defineComponent, ref } from 'vue'; +import { DefaultProps, ElementLocator, Font, useKey } from '@/core/render'; +import { computed, defineComponent, reactive, ref } from 'vue'; import { Background, Selection } from './misc'; import { TextContent, TextContentExpose, TextContentProps } from './textbox'; import { SetupComponentOptions } from './types'; import { TextAlign } from './textboxTyper'; +import { Page, PageExpose } from './page'; export interface ConfirmBoxProps extends DefaultProps, TextContentProps { text: string; width: number; loc: ElementLocator; - selFont?: string; + selFont?: Font; selFill?: CanvasStyle; pad?: number; yesText?: string; @@ -47,6 +48,34 @@ const confirmBoxProps = { keyof ConfirmBoxEmits >; +/** + * 确认框组件,与 2.x 的 drawConfirm 类似,可以键盘操作, + * 参数参考 {@link ConfirmBoxProps},事件参考 {@link ConfirmBoxEmits},用例如下: + * ```tsx + * const onYes = () => console.log('yes'); + * const onNo = () => console.log('no'); + * + * + * ``` + */ export const ConfirmBox = defineComponent< ConfirmBoxProps, ConfirmBoxEmits, @@ -132,7 +161,6 @@ export const ConfirmBox = defineComponent< ); }, confirmBoxProps); + +export interface ChoicesProps extends DefaultProps, TextContentProps { + choices: [key: string | number | symbol, text: string][]; + loc: ElementLocator; + width: number; + maxHeight?: number; + text?: string; + title?: string; + winskin?: ImageIds; + color?: CanvasStyle; + border?: CanvasStyle; + selFont?: Font; + selFill?: CanvasStyle; + titleFont?: Font; + titleFill?: CanvasStyle; + pad?: number; + defaultChoice?: string | number | symbol; + interval?: number; +} + +export type ChoicesEmits = { + choice: (key: string | number | symbol) => void; +}; + +const choicesProps = { + props: [ + 'choices', + 'loc', + 'width', + 'maxHeight', + 'text', + 'title', + 'winskin', + 'color', + 'border', + 'selFont', + 'selFill', + 'titleFont', + 'titleFill', + 'pad', + 'defaultChoice', + 'interval' + ], + emits: ['choice'] +} satisfies SetupComponentOptions< + ChoicesProps, + ChoicesEmits, + keyof ChoicesEmits +>; + +export const Choices = defineComponent< + ChoicesProps, + ChoicesEmits, + keyof ChoicesEmits +>((props, { emit, attrs }) => { + const titleHeight = ref(0); + const contentHeight = ref(0); + const selected = ref(0); + const pageCom = ref(); + const choiceSize = reactive<[number, number][]>([]); + + const selFont = computed(() => props.selFont ?? new Font()); + const maxHeight = computed(() => props.maxHeight ?? 360); + const pad = computed(() => props.pad ?? 28); + const choiceInterval = computed(() => props.interval ?? 16); + const hasText = computed(() => !!props.text); + const hasTitle = computed(() => !!props.title); + const contentWidth = computed(() => props.width - pad.value * 2); + const choiceHeight = computed( + () => selFont.value.size + 8 + choiceInterval.value + ); + const contentY = computed(() => { + if (hasTitle.value) { + return pad.value * 2 + titleHeight.value; + } else { + return pad.value; + } + }); + const choicesY = computed(() => { + const padding = pad.value; + const text = hasText.value; + let y = padding; + if (hasTitle.value) { + y += titleHeight.value; + if (text) { + y += padding / 2; + } else { + y += padding; + } + } + if (text) { + y += contentHeight.value; + y += padding / 2; + } + return y; + }); + const choicesMaxHeight = computed( + () => + maxHeight.value - + choicesY.value - + pad.value * 2 - + selFont.value.size - + 8 + ); + const choiceCountPerPage = computed(() => + Math.max(Math.floor(choicesMaxHeight.value / choiceHeight.value), 1) + ); + const pages = computed(() => + Math.ceil(props.choices.length / choiceCountPerPage.value) + ); + const choicesHeight = computed(() => { + const padBottom = pages.value > 1 ? pad.value + selFont.value.size : 0; + if (props.choices.length > choiceCountPerPage.value) { + return choiceCountPerPage.value * choiceHeight.value + padBottom; + } else { + return props.choices.length * choiceHeight.value + padBottom; + } + }); + const boxHeight = computed(() => { + if (props.choices.length > choiceCountPerPage.value) { + return ( + choicesHeight.value + + choicesY.value + + // 不乘2是因为 choiceY 已经算上了顶部填充 + pad.value + ); + } else { + return ( + choicesHeight.value + + choicesY.value + + // 不乘2是因为 choiceY 已经算上了顶部填充 + pad.value + ); + } + }); + const boxLoc = computed(() => { + const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc; + return [x, y, props.width, boxHeight.value, ax, ay]; + }); + const titleLoc = computed(() => { + return [props.width / 2, pad.value, void 0, void 0, 0.5, 0]; + }); + const contentLoc = computed(() => { + return [ + props.width / 2, + contentY.value, + contentWidth.value, + void 0, + 0.5, + 0 + ]; + }); + const choiceLoc = computed(() => { + return [ + props.width / 2, + choicesY.value, + contentWidth.value, + choicesHeight.value, + 0.5, + 0 + ]; + }); + const selectionLoc = computed(() => { + const [width = 200, height = 200] = choiceSize[selected.value] ?? []; + return [ + props.width / 2 - pad.value, + (selected.value + 0.5) * choiceHeight.value, + width + 8, + height + 8, + 0.5, + 0.5 + ]; + }); + + const getPageContent = (page: number) => { + const count = choiceCountPerPage.value; + return props.choices.slice(page * count, (page + 1) * count); + }; + + const getChoiceLoc = (index: number): ElementLocator => { + return [ + props.width / 2 - pad.value, + choiceHeight.value * (index + 0.5), + void 0, + void 0, + 0.5, + 0.5 + ]; + }; + + const updateContentHeight = (height: number) => { + contentHeight.value = height; + }; + + const updateTitleHeight = (_0: string, _1: number, height: number) => { + titleHeight.value = height; + }; + + const updateChoiceSize = (index: number, width: number, height: number) => { + choiceSize[index] = [width, height]; + }; + + const onPageChange = () => { + selected.value = 0; + }; + + const [key] = useKey(); + key.realize('moveUp', () => { + if (selected.value === 0) { + if (pageCom.value?.now() !== 0) { + pageCom.value?.movePage(-1); + selected.value = choiceCountPerPage.value - 1; + } + } else { + selected.value--; + } + }); + key.realize('moveDown', () => { + if (selected.value === choiceCountPerPage.value - 1) { + pageCom.value?.movePage(1); + selected.value = 0; + } else { + const page = pageCom.value?.now() ?? 1; + const index = page * choiceCountPerPage.value + selected.value; + if (index < props.choices.length - 1) { + selected.value++; + } + } + }); + key.realize('moveLeft', () => pageCom.value?.movePage(-1)); + key.realize('moveRight', () => pageCom.value?.movePage(1)); + key.realize('confirm', () => { + const page = pageCom.value?.now() ?? 1; + const index = page * choiceCountPerPage.value + selected.value; + emit('choice', props.choices[index][0]); + }); + + return () => ( + + + {hasTitle.value && ( + + )} + {hasText.value && ( + + )} + + {(page: number) => [ + , + ...getPageContent(page).map((v, i) => { + return ( + emit('choice', v[0])} + onSetText={(_, width, height) => + updateChoiceSize(i, width, height) + } + onEnter={() => (selected.value = i)} + /> + ); + }) + ]} + + + ); +}, choicesProps); diff --git a/src/module/render/components/page.tsx b/src/module/render/components/page.tsx index b6f40a8..a420371 100644 --- a/src/module/render/components/page.tsx +++ b/src/module/render/components/page.tsx @@ -26,12 +26,27 @@ export interface PageProps extends DefaultProps { hideIfSingle?: boolean; } +export type PageEmits = { + pageChange: (page: number) => void; +}; + export interface PageExpose { /** * 切换页码 * @param page 要切换至的页码数,1 表示第一页 */ changePage(page: number): void; + + /** + * 切换到传入的页码数加上当前页码数的页码 + * @param delta 页码数增量 + */ + movePage(delta: number): void; + + /** + * 获取当前在第几页 + */ + now(): number; } type PageSlots = SlotsType<{ @@ -39,251 +54,267 @@ type PageSlots = SlotsType<{ }>; const pageProps = { - props: ['pages', 'loc', 'font', 'hideIfSingle'] -} satisfies SetupComponentOptions; + props: ['pages', 'loc', 'font', 'hideIfSingle'], + emits: ['pageChange'] +} satisfies SetupComponentOptions< + PageProps, + PageEmits, + keyof PageEmits, + PageSlots +>; /** * 分页组件,用于多页切换,例如存档界面等。参数参考 {@link PageProps},函数接口参考 {@link PageExpose} * * --- * - * 用例如下,是一个在每页显示文字的用例,其中 page 表示第几页: + * 用例如下,是一个在每页显示文字的用例,其中 page 表示页码索引,第一页就是 0,第二页就是 1,以此类推: * ```tsx * * { * (page: number) => { - * // 页码从第一页开始,因此这里索引要减一 - * return items[page - 1].map(v => ) + * return items[page].map(v => ) * } * } * * ``` */ -export const Page = defineComponent( - (props, { slots, expose }) => { - const nowPage = ref(1); +export const Page = defineComponent< + PageProps, + PageEmits, + keyof PageEmits, + PageSlots +>((props, { slots, expose, emit }) => { + const nowPage = ref(0); - // 五个元素的位置 - const leftLoc = ref([]); - const leftPageLoc = ref([]); - const nowPageLoc = ref([]); - const rightPageLoc = ref([]); - const rightLoc = ref([]); - /** 内容的位置 */ - const contentLoc = ref([]); - /** 页码容器的位置 */ - const pageLoc = ref([]); - /** 页码的矩形框的位置 */ - const rectLoc = ref([0, 0, 0, 0]); - /** 页面文字的位置 */ - const textLoc = ref([0, 0, 0, 0]); + // 五个元素的位置 + const leftLoc = ref([]); + const leftPageLoc = ref([]); + const nowPageLoc = ref([]); + const rightPageLoc = ref([]); + const rightLoc = ref([]); + /** 内容的位置 */ + const contentLoc = ref([]); + /** 页码容器的位置 */ + const pageLoc = ref([]); + /** 页码的矩形框的位置 */ + const rectLoc = ref([0, 0, 0, 0]); + /** 页面文字的位置 */ + const textLoc = ref([0, 0, 0, 0]); - // 两个监听的参数 - const leftArrow = ref(); - const rightArrow = ref(); + // 两个监听的参数 + const leftArrow = ref(); + const rightArrow = ref(); - const font = computed(() => props.font ?? new Font()); - const isFirst = computed(() => nowPage.value === 1); - const isLast = computed(() => nowPage.value === props.pages); - const width = computed(() => props.loc[2] ?? 200); - const height = computed(() => props.loc[3] ?? 200); - const round = computed(() => font.value.size / 4); - const nowPageFont = computed(() => - Font.clone(font.value, { weight: 700 }) - ); + const hide = computed(() => props.hideIfSingle && props.pages === 1); + const font = computed(() => props.font ?? new Font()); + const isFirst = computed(() => nowPage.value === 0); + const isLast = computed(() => nowPage.value === props.pages - 1); + const width = computed(() => props.loc[2] ?? 200); + const height = computed(() => props.loc[3] ?? 200); + const round = computed(() => font.value.size / 4); + const nowPageFont = computed(() => Font.clone(font.value, { weight: 700 })); - // 左右箭头的颜色 - const leftColor = computed(() => (isFirst.value ? '#666' : '#ddd')); - const rightColor = computed(() => (isLast.value ? '#666' : '#ddd')); + // 左右箭头的颜色 + const leftColor = computed(() => (isFirst.value ? '#666' : '#ddd')); + const rightColor = computed(() => (isLast.value ? '#666' : '#ddd')); - let updating = false; - const updatePagePos = () => { - if (updating) return; - updating = true; - nextTick(() => { - updating = false; - }); - const pageH = font.value.size + 8; - contentLoc.value = [0, 0, width.value, height.value - pageH]; - pageLoc.value = [0, height.value - pageH, width.value, pageH]; - const center = width.value / 2; - const size = font.value.size * 1.5; - nowPageLoc.value = [center, 0, size, size, 0.5, 0]; - leftPageLoc.value = [center - size * 1.5, 0, size, size, 0.5, 0]; - leftLoc.value = [center - size * 3, 0, size, size, 0.5, 0]; - rightPageLoc.value = [center + size * 1.5, 0, size, size, 0.5, 0]; - rightLoc.value = [center + size * 3, 0, size, size, 0.5, 0]; - }; - - const updateArrowPath = () => { - const rectSize = font.value.size * 1.5; - const size = font.value.size; - const pad = rectSize - size; - const left = new Path2D(); - left.moveTo(size, pad); - left.lineTo(pad, rectSize / 2); - left.lineTo(size, rectSize - pad); - const right = new Path2D(); - right.moveTo(pad, pad); - right.lineTo(size, rectSize / 2); - right.lineTo(pad, rectSize - pad); - leftArrow.value = left; - rightArrow.value = right; - }; - - const updateRectAndText = () => { - const size = font.value.size * 1.5; - const pad = RECT_PAD * size; - rectLoc.value = [pad, pad, size - pad * 2, size - pad * 2]; - textLoc.value = [size / 2, size / 2, void 0, void 0, 0.5, 0.5]; - }; - - watch(font, () => { - updatePagePos(); - updateArrowPath(); - updateRectAndText(); + let updating = false; + const updatePagePos = () => { + if (updating) return; + updating = true; + nextTick(() => { + updating = false; }); - watch( - () => props.loc, - () => { - updatePagePos(); - updateRectAndText(); - } - ); + const pageH = hide.value ? 0 : font.value.size + 8; + contentLoc.value = [0, 0, width.value, height.value - pageH]; + pageLoc.value = [0, height.value - pageH, width.value, pageH]; + const center = width.value / 2; + const size = font.value.size * 1.5; + nowPageLoc.value = [center, 0, size, size, 0.5, 0]; + leftPageLoc.value = [center - size * 1.5, 0, size, size, 0.5, 0]; + leftLoc.value = [center - size * 3, 0, size, size, 0.5, 0]; + rightPageLoc.value = [center + size * 1.5, 0, size, size, 0.5, 0]; + rightLoc.value = [center + size * 3, 0, size, size, 0.5, 0]; + }; - /** - * 切换页码 - */ - const changePage = (page: number) => { - const target = clamp(page, 1, props.pages); + const updateArrowPath = () => { + const rectSize = font.value.size * 1.5; + const size = font.value.size; + const pad = rectSize - size; + const left = new Path2D(); + left.moveTo(size, pad); + left.lineTo(pad, rectSize / 2); + left.lineTo(size, rectSize - pad); + const right = new Path2D(); + right.moveTo(pad, pad); + right.lineTo(size, rectSize / 2); + right.lineTo(pad, rectSize - pad); + leftArrow.value = left; + rightArrow.value = right; + }; + + const updateRectAndText = () => { + const size = font.value.size * 1.5; + const pad = RECT_PAD * size; + rectLoc.value = [pad, pad, size - pad * 2, size - pad * 2]; + textLoc.value = [size / 2, size / 2, void 0, void 0, 0.5, 0.5]; + }; + + watch(font, () => { + updatePagePos(); + updateArrowPath(); + updateRectAndText(); + }); + watch( + () => props.loc, + () => { + updatePagePos(); + updateRectAndText(); + } + ); + + /** + * 切换页码 + */ + const changePage = (page: number) => { + const target = clamp(page, 0, props.pages - 1); + if (nowPage.value !== target) { nowPage.value = target; - }; + emit('pageChange', target); + } + }; - const lastPage = () => { - changePage(nowPage.value - 1); - }; + const movePage = (delta: number) => { + changePage(nowPage.value + delta); + }; - const nextPage = () => { - changePage(nowPage.value + 1); - }; + const now = () => nowPage.value; - onMounted(() => { - updatePagePos(); - updateArrowPath(); - updateRectAndText(); - }); + const lastPage = () => { + changePage(nowPage.value - 1); + }; - expose({ changePage }); + const nextPage = () => { + changePage(nowPage.value + 1); + }; - return () => { - return ( - - - {slots.default?.(nowPage.value)} - + onMounted(() => { + updatePagePos(); + updateArrowPath(); + updateRectAndText(); + }); + + expose({ changePage, movePage, now }); + + return () => { + return ( + + + {slots.default?.(nowPage.value)} + + - ); - }; - }, - pageProps -); + + ); + }; +}, pageProps); diff --git a/src/module/render/ui/main.tsx b/src/module/render/ui/main.tsx index c37aa6b..0e770c4 100644 --- a/src/module/render/ui/main.tsx +++ b/src/module/render/ui/main.tsx @@ -19,7 +19,7 @@ import { FloorItemDetail } from '@/plugin/fx/itemDetail'; import { PopText } from '@/plugin/fx/pop'; import { LayerGroupPortal } from '@/plugin/fx/portal'; import { defineComponent, onMounted, reactive, ref } from 'vue'; -import { Textbox } from '../components'; +import { Textbox, Tip } from '../components'; import { GameUI, UIController } from '@/core/system'; import { MAIN_HEIGHT, @@ -35,7 +35,6 @@ import { } from './statusBar'; import { onLoaded } from '../use'; import { ReplayingStatus } from './toolbar'; -import { Tip } from '../components/tip'; const MainScene = defineComponent(() => { const layerGroupExtends: ILayerGroupRenderExtends[] = [