diff --git a/eslint.config.js b/eslint.config.js index 51d97a0..9d3237d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -63,7 +63,8 @@ export default tseslint.config( } ], '@typescript-eslint/no-namespace': 'off', - '@typescript-eslint/no-this-alias': 'off' + '@typescript-eslint/no-this-alias': 'off', + 'no-console': 'warn' } }, eslintPluginPrettierRecommended diff --git a/public/project/images/winskin2.png b/public/project/images/winskin2.png index 85a8ad7..90a5ba7 100644 Binary files a/public/project/images/winskin2.png and b/public/project/images/winskin2.png differ diff --git a/src/core/render/components/index.ts b/src/core/render/components/index.ts deleted file mode 100644 index 21ee726..0000000 --- a/src/core/render/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './textbox'; diff --git a/src/core/render/components/textbox.tsx b/src/core/render/components/textbox.tsx deleted file mode 100644 index 8682bbb..0000000 --- a/src/core/render/components/textbox.tsx +++ /dev/null @@ -1,923 +0,0 @@ -import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; -import { - computed, - defineComponent, - onMounted, - onUnmounted, - onUpdated, - ref, - shallowReactive, - shallowRef, - SlotsType, - VNode, - watch -} from 'vue'; -import { Transform } from '../transform'; -import { isSetEqual } from '../utils'; -import { logger } from '@/core/common/logger'; -import { Sprite } from '../sprite'; -import { ContainerProps, onTick } from '../renderer'; -import { isNil } from 'lodash-es'; -import { SetupComponentOptions } from './types'; -import EventEmitter from 'eventemitter3'; -import { Text } from '../preset'; -import { - buildFont, - TextAlign, - ITextContentRenderData, - WordBreak -} from './textboxHelper'; - -let testCanvas: MotaOffscreenCanvas2D; -Mota.require('var', 'loading').once('coreInit', () => { - testCanvas = new MotaOffscreenCanvas2D(false); - testCanvas.withGameScale(false); - testCanvas.setHD(false); - testCanvas.size(32, 32); - testCanvas.freeze(); -}); - -export interface TextContentProps - extends ContainerProps, - ITextContentRenderData {} - -export type TextContentEmits = { - typeEnd: () => void; - typeStart: () => void; -}; - -interface TextContentData { - text: string; - width: number; - font: string; - /** 分词规则 */ - wordBreak: WordBreak; - /** 行首忽略字符,即不会出现在行首的字符 */ - ignoreLineStart: Set; - /** 行尾忽略字符,即不会出现在行尾的字符 */ - ignoreLineEnd: Set; - /** 会被分词规则识别的分词字符 */ - breakChars: Set; -} - -interface TextContentRenderable { - x: number; - /** 行高,为0时表示两行间为默认行距 */ - lineHeight: number; - /** 这一行文字的高度,即 measureText 算出的高度 */ - textHeight: number; - /** 这一行的文字 */ - text: string; - /** 当前渲染到了本行的哪个文字 */ - pointer: number; - /** 本行文字在全部文字中的起点 */ - from: number; - /** 本行文字在全部文字中的终点 */ - to: number; -} - -const textContentOptions = { - props: [ - 'breakChars', - 'fontFamily', - 'fontSize', - 'fontBold', - 'fontItalic', - 'height', - 'ignoreLineEnd', - 'ignoreLineStart', - 'interval', - 'keepLast', - 'lineHeight', - 'text', - 'textAlign', - 'width', - 'wordBreak', - 'x', - 'y', - 'fill', - 'fillStyle', - 'strokeStyle', - 'strokeWidth', - 'stroke', - 'showAll' - ], - emits: ['typeEnd', 'typeStart'] -} satisfies SetupComponentOptions< - TextContentProps, - TextContentEmits, - keyof TextContentEmits ->; - -export const TextContent = defineComponent< - TextContentProps, - TextContentEmits, - keyof TextContentEmits ->((props, { emit }) => { - if (props.width && props.width <= 0) { - logger.warn(41, String(props.width)); - } - const renderData: Required = shallowReactive({ - text: props.text, - textAlign: props.textAlign ?? TextAlign.Left, - x: props.x ?? 0, - y: props.y ?? 0, - width: !props.width || props.width <= 0 ? 200 : props.width, - height: props.height ?? 200, - fontFamily: - props.fontFamily ?? core.status.globalAttribute?.font ?? 'Verdana', - fontSize: props.fontSize ?? 16, - fontBold: props.fontWeight ?? false, - fontItalic: props.fontItalic ?? false, - ignoreLineEnd: props.ignoreLineEnd ?? new Set(), - ignoreLineStart: props.ignoreLineStart ?? new Set(), - keepLast: props.keepLast ?? false, - interval: props.interval ?? 0, - lineHeight: props.lineHeight ?? 0, - wordBreak: props.wordBreak ?? WordBreak.Space, - breakChars: props.breakChars ?? new Set(), - fillStyle: props.fillStyle ?? '#fff', - strokeStyle: props.strokeStyle ?? 'transparent', - fill: props.fill ?? true, - stroke: props.stroke ?? false, - strokeWidth: props.strokeWidth ?? 2, - showAll: props.showAll ?? false - }); - - const ensureProps = () => { - for (const [key, value] of Object.entries(props)) { - if (key in renderData && !isNil(value)) { - if (key === 'width') { - if (value && value <= 0) { - logger.warn(41, String(props.width)); - renderData.width = 200; - } else { - renderData.width = value; - } - } else { - // @ts-expect-error may use spread? - renderData[key] = value; - } - } - } - }; - - const makeSplitData = (): TextContentData => { - ensureProps(); - return { - text: renderData.text, - width: renderData.width!, - font: buildFont( - renderData.fontFamily, - renderData.fontSize, - renderData.fontWeight, - renderData.fontItalic - ), - wordBreak: renderData.wordBreak!, - ignoreLineStart: new Set(renderData.ignoreLineStart), - ignoreLineEnd: new Set(renderData.ignoreLineEnd), - breakChars: new Set(renderData.breakChars) - }; - }; - - /** - * 判断是否需要重新分行 - */ - const needResplit = (value: TextContentData, old: TextContentData) => { - return ( - value.text !== old.text || - value.font !== old.font || - value.width !== old.width || - value.wordBreak !== old.wordBreak || - !isSetEqual(value.breakChars, old.breakChars) || - !isSetEqual(value.ignoreLineEnd, old.ignoreLineEnd) || - !isSetEqual(value.ignoreLineStart, old.ignoreLineStart) - ); - }; - - /** 每行的渲染信息 */ - const renderable: TextContentRenderable[] = []; - /** 需要更新的行数 */ - const dirtyIndex: number[] = []; - const spriteElement = ref(); - - /** dirtyIndex 更新指针 */ - let linePointer = 0; - let startTime = 0; - /** 从哪个字符开始渲染 */ - let fromChar = 0; - /** 是否需要更新渲染 */ - let needUpdate = false; - const tick = () => { - if (!needUpdate) return; - spriteElement.value?.update(); - const time = Date.now(); - const char = - Math.floor((time - startTime) / renderData.interval!) + fromChar; - if (!isFinite(char) || renderData.showAll) { - renderable.forEach(v => (v.pointer = v.text.length)); - needUpdate = false; - linePointer = dirtyIndex.length; - emit('typeEnd'); - return; - } - while (linePointer < dirtyIndex.length) { - const line = dirtyIndex[linePointer]; - const data = renderable[line]; - const pointer = char - data.from; - if (char >= data.to) { - data.pointer = data.text.length; - linePointer++; - } else { - data.pointer = pointer; - break; - } - } - - if (linePointer >= dirtyIndex.length) { - needUpdate = false; - renderable.forEach(v => (v.pointer = v.text.length)); - emit('typeEnd'); - } - }; - - onTick(tick); - onMounted(() => { - data.value = makeSplitData(); - lineData.value = splitLines(data.value); - }); - - const renderContent = ( - canvas: MotaOffscreenCanvas2D, - transform: Transform - ) => { - const ctx = canvas.ctx; - // ctx.font = renderData.font; - ctx.fillStyle = renderData.fillStyle; - ctx.strokeStyle = renderData.strokeStyle; - ctx.lineWidth = renderData.strokeWidth; - - let y = renderable[0]?.textHeight ?? 0; - renderable.forEach(v => { - if (v.pointer === 0) return; - const text = v.text.slice(0, v.pointer); - if (renderData.textAlign === TextAlign.Left) { - if (renderData.stroke) ctx.strokeText(text, v.x, y); - if (renderData.fill) ctx.fillText(text, v.x, y); - } else if (renderData.textAlign === TextAlign.Center) { - const x = (renderData.width - v.x) / 2 + v.x; - if (renderData.stroke) ctx.strokeText(text, x, y); - if (renderData.fill) ctx.fillText(text, x, y); - } else { - const x = renderData.width; - if (renderData.stroke) ctx.strokeText(text, x, y); - if (renderData.fill) ctx.fillText(text, x, y); - } - y += v.textHeight + v.lineHeight; - }); - }; - - /** - * 生成 renderable 对象 - * @param text 全部文本 - * @param lines 分行信息 - * @param index 从第几行开始生成 - * @param from 从第几个字符开始生成 - */ - const makeRenderable = ( - text: string, - lines: number[], - index: number, - from: number - ) => { - // renderable.splice(index); - // dirtyIndex.splice(0); - // dirtyIndex.push(index); - // // 初始化渲染 - // linePointer = 0; - // startTime = Date.now(); - // fromChar = from; - // needUpdate = true; - - // let startY = renderable.reduce( - // (prev, curr) => prev + curr.textHeight + curr.lineHeight, - // 0 - // ); - // // 第一个比较特殊,需要特判 - // const start = lines[index - 1] ?? 0; - // const end = lines[index]; - // const startPointer = from > start && from < end ? from - start : 0; - // const height = testHeight(text, renderData.font!); - // startY += height; - // renderable.push({ - // text: text.slice(start, end), - // x: 0, - // lineHeight: renderData.lineHeight!, - // textHeight: height, - // pointer: startPointer, - // from: start, - // to: end ?? text.length - // }); - - // for (let i = index + 1; i < lines.length; i++) { - // dirtyIndex.push(i); - // const start = lines[i - 1] ?? 0; - // const end = lines[i]; - // const height = testHeight(text, renderData.font!); - // startY += height; - - // renderable.push({ - // text: text.slice(start, end), - // x: 0, - // lineHeight: renderData.lineHeight!, - // textHeight: height, - // pointer: 0, - // from: start, - // to: end ?? text.length - // }); - // } - emit('typeStart'); - }; - - /** - * 从头开始渲染 - */ - const rawRender = (text: string, lines: number[]) => { - makeRenderable(text, lines, 0, 0); - spriteElement.value?.update(); - }; - - /** - * 接续上一个继续渲染 - * @param from 从第几个字符接续渲染 - * @param lines 分行信息 - * @param index 从第几行接续渲染 - */ - const continueRender = ( - text: string, - from: number, - lines: number[], - index: number - ) => { - makeRenderable(text, lines, index, from); - spriteElement.value?.update(); - }; - - const data = shallowRef(makeSplitData()); - - onUpdated(() => { - data.value = makeSplitData(); - }); - - let shouldKeep = false; - const lineData = shallowRef([0]); - watch(data, (value, old) => { - if (needResplit(value, old)) { - lineData.value = splitLines(value); - } - - if (renderData.keepLast && value.text.startsWith(old.text)) { - shouldKeep = true; - } - }); - - // 判断是否需要接续渲染 - watch(lineData, (value, old) => { - if (shouldKeep) { - shouldKeep = false; - const isSub = old.slice(0, -1).every((v, i) => v === value[i]); - - // 有点地狱的条件分歧,大体就是分为两种情况,一种是两个末尾一致,如果一致那直接从下一行接着画就完事了 - // 但是如果不一致,那么从旧的最后一个开始往后画 - if (isSub) { - const last = value[old.length - 1]; - const oldLast = value.at(-1); - if (!last) { - rawRender(data.value.text, value); - return; - } - if (last === oldLast) { - const index = old.length - 1; - continueRender(data.value.text, last, value, index); - } else { - if (!oldLast) { - rawRender(data.value.text, value); - } else { - const index = old.length - 1; - continueRender(data.value.text, oldLast, value, index); - } - } - } else { - rawRender(data.value.text, value); - } - } else { - rawRender(data.value.text, value); - } - }); - - return () => { - return ( - - ); - }; -}, textContentOptions); - -export interface TextboxProps extends TextContentProps, ContainerProps { - /** 背景颜色 */ - backColor?: CanvasStyle; - /** 背景 winskin */ - winskin?: string; - /** 边框与文字间的距离,默认为8 */ - padding?: number; - /** 标题 */ - title?: string; - /** 标题字体 */ - titleFont?: string; - /** 标题填充样式 */ - titleFill?: CanvasStyle; - /** 标题描边样式 */ - titleStroke?: CanvasStyle; - /** 标题文字与边框间的距离,默认为4 */ - titlePadding?: number; -} - -type TextboxEmits = TextContentEmits; -type TextboxSlots = SlotsType<{ - default: (data: TextboxProps) => VNode[]; - title: (data: TextboxProps) => VNode[]; -}>; - -const textboxOptions = { - props: (textContentOptions.props as (keyof TextboxProps)[]).concat([ - 'backColor', - 'winskin', - 'id', - 'padding', - 'alpha', - 'hidden', - 'anchorX', - 'anchorY', - 'antiAliasing', - 'cache', - 'composite', - 'fall', - 'hd', - 'transform', - 'type', - 'zIndex', - 'titleFill', - 'titleStroke', - 'titleFont' - ]), - emits: textContentOptions.emits -} satisfies SetupComponentOptions; - -let id = 0; -function getNextTextboxId() { - return `@default-textbox-${id++}`; -} - -export const Textbox = defineComponent< - TextboxProps, - TextboxEmits, - keyof TextboxEmits, - TextboxSlots ->((props, { slots }) => { - const data = shallowReactive({ ...props }); - data.padding ??= 8; - data.width ??= 200; - data.height ??= 200; - data.id ??= ''; - data.alpha ??= 1; - data.titleFill ??= '#000'; - data.titleStroke ??= 'transparent'; - data.titleFont ??= '16px Verdana'; - data.titlePadding ??= 4; - - const titleElement = ref(); - const titleWidth = ref(data.titlePadding * 2); - const titleHeight = ref(data.titlePadding * 2); - const contentY = computed(() => { - const height = titleHeight.value; - return data.title ? height : 0; - }); - const contentWidth = computed(() => data.width! - data.padding! * 2); - const contentHeight = computed( - () => data.height! - data.padding! * 2 - contentY.value - ); - - const calTitleSize = (text: string) => { - if (!titleElement.value) return; - const { width, height } = titleElement.value; - titleWidth.value = width + data.titlePadding! * 2; - titleHeight.value = height + data.titlePadding! * 2; - data.title = text; - }; - - watch(titleElement, (value, old) => { - old?.off('setText', calTitleSize); - value?.on('setText', calTitleSize); - if (value) calTitleSize(value?.text); - }); - - onUnmounted(() => { - titleElement.value?.off('setText', calTitleSize); - }); - - // ----- store - - /** 结束打字机 */ - const storeEmits: TextboxStoreEmits = { - endType() { - data.showAll = true; - } - }; - - const store = TextboxStore.use( - props.id ?? getNextTextboxId(), - data, - storeEmits - ); - const hidden = ref(data.hidden); - store.on('hide', () => (hidden.value = true)); - store.on('show', () => (hidden.value = false)); - store.on('update', value => { - if (value.title) { - titleElement.value?.requestBeforeFrame(() => { - const { width, height } = titleElement.value!; - titleWidth.value = width + data.padding! * 2; - titleHeight.value = height + data.padding! * 2; - }); - } - }); - - const onTypeStart = () => { - store.emitTypeStart(); - }; - - const onTypeEnd = () => { - data.showAll = false; - store.emitTypeEnd(); - }; - - return () => { - return ( - - ); - }; -}, textboxOptions); - -interface LineSplitData { - text: string; - font: string; - wait: number; - icon: AllIds | AllNumbers; -} - -const fontSizeGuessScale = new Map([ - ['px', 1], - ['%', 0.2], - ['', 0.2], - ['cm', 37.8], - ['mm', 3.78], - ['Q', 3.78 / 4], - ['in', 96], - ['pc', 16], - ['pt', 96 / 72], - ['em', 16], - ['vw', 0.2], - ['vh', 0.2], - ['rem', 16] -]); - -/** - * 对文字进行分行操作 - * @param data 文字信息 - * @returns 分行信息,每一项表示应该在这一项索引之后分行 - */ -function splitLines(data: TextContentData) { - const words = breakWords(data); - if (words.length === 0) return []; - else if (words.length === 1) return [words[0]]; - - // 对文字二分,然后计算长度 - const text = data.text; - const res: number[] = []; - const fontSize = data.font.match(/\s*[\d\.-]+[a-zA-Z%]+\s*/)?.[0].trim(); - const unit = fontSize?.match(/[a-zA-Z%]+/)?.[0]; - const guessScale = fontSizeGuessScale.get(unit ?? '') ?? 0.2; - const guessSize = parseInt(fontSize ?? '1') * guessScale; - const averageLength = text.length / words.length; - const guess = Math.max(data.width / guessSize / averageLength, 1); - const ctx = testCanvas.ctx; - ctx.font = data.font; - - let start = 0; - let end = Math.ceil(guess); - let resolved = 0; - let mid = 0; - let guessCount = 1; - let splitProgress = false; - let last = 0; - - while (1) { - if (!splitProgress) { - const chars = text.slice(words[start], words[end]); - const { width } = ctx.measureText(chars); - if (width < data.width && end < words.length) { - guessCount *= 2; - end = Math.ceil(guessCount * guess + start); - if (end > words.length) end = words.length; - } else { - splitProgress = true; - } - continue; - } - const diff = end - start; - - if (diff === 1) { - if (start === last) { - res.push(words[last + 1]); - start = words[last + 1]; - end = start + 1; - } else { - res.push(words[start]); - } - if (end >= words.length) break; - last = resolved; - resolved = start; - end = Math.ceil(start + guess); - if (end > words.length) end = words.length; - guessCount = 1; - splitProgress = false; - } else { - mid = Math.floor((start + end) / 2); - const chars = text.slice(words[resolved], words[mid]); - const { width } = ctx.measureText(chars); - if (width <= data.width) { - start = mid; - if (start === end) end++; - } else { - end = mid; - if (start === end) end++; - } - } - } - - return res; -} - -const defaultsBreak = ' -,.)]}?!;:,。)】?!;:'; -const defaultsIgnoreStart = - '))】》>﹞>)]»›〕〉}]」}〗』,。?!:;·…,.?!:;、……~&@#~&@#'; -const defaultsIgnoreEnd = '((【《<﹝<([«‹〔〈{[「{〖『'; -const breakSet = new Set(defaultsBreak); -const ignoreStart = new Set(defaultsIgnoreStart); -const ignoreEnd = new Set(defaultsIgnoreEnd); - -/** - * 判断一个文字是否是 CJK 文字 - * @param char 文字的编码 - */ -function isCJK(char: number) { - // 参考自 https://blog.csdn.net/brooksychen/article/details/2755395 - return ( - (char >= 0x4e00 && char <= 0x9fff) || - (char >= 0x3000 && char <= 0x30ff) || - (char >= 0xac00 && char <= 0xd7af) || - (char >= 0xf900 && char <= 0xfaff) || - (char >= 0x3400 && char <= 0x4dbf) || - (char >= 0x20000 && char <= 0x2ebef) || - (char >= 0x30000 && char <= 0x323af) || - (char >= 0x2e80 && char <= 0x2eff) || - (char >= 0x31c0 && char <= 0x31ef) - ); -} - -/** - * 对文字进行分词操作 - * @param data 文字信息 - * @returns 一个数字数组,每一项应当在这一项索引之后分词 - */ -function breakWords(data: TextContentData) { - if (data.text.length <= 1) return [data.text.length]; - let allBreak = false; - const breakChars = breakSet.union(data.breakChars); - switch (data.wordBreak) { - case WordBreak.None: { - return [data.text.length]; - } - case WordBreak.Space: { - allBreak = false; - break; - } - case WordBreak.All: { - allBreak = true; - break; - } - } - - const res: number[] = [0]; - const text = data.text; - const ignoreLineStart = data.ignoreLineStart.union(ignoreStart); - const ignoreLineEnd = data.ignoreLineEnd.union(ignoreEnd); - for (let pointer = 0; pointer < text.length; pointer++) { - const char = text[pointer]; - const next = text[pointer + 1]; - - if (!ignoreLineEnd.has(char) && ignoreLineEnd.has(next)) { - res.push(pointer); - continue; - } - - if (ignoreLineStart.has(char) && !ignoreLineStart.has(next)) { - res.push(pointer + 1); - continue; - } - - if ( - breakChars.has(char) || - allBreak || - char === '\n' || - isCJK(char.charCodeAt(0)) - ) { - res.push(pointer + 1); - continue; - } - } - res.push(text.length); - return res; -} - -function testHeight(text: string, font: string) { - const ctx = testCanvas.ctx; - ctx.font = font; - return ctx.measureText(text).fontBoundingBoxAscent; -} - -interface TextboxStoreEmits { - endType: () => void; -} - -interface TextboxStoreEvent { - update: [value: TextboxProps]; - show: []; - hide: []; - typeStart: []; - typeEnd: []; -} - -export class TextboxStore extends EventEmitter { - static list: Map = new Map(); - - typing: boolean = false; - - private constructor( - private readonly data: TextboxProps, - private readonly emits: TextboxStoreEmits - ) { - super(); - } - - /** - * 开始打字,由组件调用,而非组件外调用 - */ - emitTypeStart() { - this.typing = true; - this.emit('typeStart'); - } - - /** - * 结束打字,由组件调用,而非组件外调用 - */ - emitTypeEnd() { - this.typing = false; - this.emit('typeEnd'); - } - - /** - * 结束打字机的打字 - */ - endType() { - this.emits.endType(); - } - - /** - * 修改渲染数据 - */ - modify(data: Partial) { - for (const [key, value] of Object.entries(data)) { - // @ts-ignore - if (!isNil(value)) this.data[key] = value; - } - this.emit('update', this.data); - } - - /** - * 显示文本框 - */ - show() { - this.emit('show'); - } - - /** - * 隐藏文本框 - */ - hide() { - this.emit('hide'); - } - - /** - * 获取文本框 - * @param id 文本框id - */ - static get(id: string): TextboxStore | undefined { - return this.list.get(id); - } - - /** - * 在当前作用域下生成文本框控制器 - * @param id 文本框id - * @param props 文本框渲染数据 - */ - static use(id: string, props: TextboxProps, emits: TextboxStoreEmits) { - const store = new TextboxStore(props, emits); - if (this.list.has(id)) { - logger.warn(42, id); - } - this.list.set(id, store); - onUnmounted(() => { - this.list.delete(id); - }); - return store; - } -} diff --git a/src/core/render/components/textboxHelper.ts b/src/core/render/components/textboxHelper.ts deleted file mode 100644 index a41f6ea..0000000 --- a/src/core/render/components/textboxHelper.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; -import EventEmitter from 'eventemitter3'; - -export const enum WordBreak { - /** 不换行 */ - None, - /** 仅空格和连字符等可换行,CJK 字符可任意换行,默认值 */ - Space, - /** 所有字符都可以换行 */ - All -} - -export const enum TextAlign { - Left, - Center, - End -} - -export interface ITextContentRenderData { - text: string; - x?: number; - y?: number; - width?: number; - height?: number; - /** 字体类型 */ - fontFamily?: string; - /** 字体大小 */ - fontSize?: number; - /** 字体线宽 */ - fontWeight?: number; - /** 是否斜体 */ - fontItalic?: boolean; - /** 是否持续上一次的文本,开启后,如果修改后的文本以修改前的文本为开头,那么会继续播放而不会从头播放 */ - keepLast?: boolean; - /** 打字机时间间隔,即两个字出现之间相隔多长时间 */ - interval?: number; - /** 行高 */ - lineHeight?: number; - /** 分词规则 */ - wordBreak?: WordBreak; - /** 文字对齐方式 */ - textAlign?: TextAlign; - /** 行首忽略字符,即不会出现在行首的字符 */ - ignoreLineStart?: Iterable; - /** 行尾忽略字符,即不会出现在行尾的字符 */ - ignoreLineEnd?: Iterable; - /** 会被分词规则识别的分词字符 */ - breakChars?: Iterable; - /** 填充样式 */ - fillStyle?: CanvasStyle; - /** 描边样式 */ - strokeStyle?: CanvasStyle; - /** 线宽 */ - strokeWidth?: number; - /** 是否填充 */ - fill?: boolean; - /** 是否描边 */ - stroke?: boolean; - /** 是否无视打字机,强制全部显示 */ - showAll?: boolean; -} - -export const enum TextContentType { - Text, - Wait, - Icon -} - -export interface ITextContentRenderable { - type: TextContentType; - text: string; - wait?: number; - icon?: AllNumbers; -} - -interface TextContentTyperEvent { - typeStart: []; - typeEnd: []; -} - -export class TextContentTyper extends EventEmitter { - testCanvas: MotaOffscreenCanvas2D; - - constructor(public readonly data: Required) { - super(); - - this.testCanvas = new MotaOffscreenCanvas2D(false); - this.testCanvas.withGameScale(false); - this.testCanvas.setHD(false); - this.testCanvas.size(32, 32); - this.testCanvas.freeze(); - } - - /** - * 设置显示文本 - */ - setText(text: string) { - this.data.text = text; - } - - private parse(text: string) {} -} - -export function buildFont( - family: string, - size: number, - weight: number = 500, - italic: boolean = false -) { - return `${italic ? 'italic ' : ''}${weight} ${size}px "${family}"`; -} diff --git a/src/core/render/index.tsx b/src/core/render/index.tsx index 5bbbb9f..6999fa8 100644 --- a/src/core/render/index.tsx +++ b/src/core/render/index.tsx @@ -13,7 +13,7 @@ import { PopText } from '@/plugin/fx/pop'; import { FloorChange } from '@/plugin/fallback'; import { createApp } from './renderer'; import { defineComponent } from 'vue'; -import { Textbox } from './components'; +import { Textbox } from '../../module/ui/components'; import { ILayerGroupRenderExtends, ILayerRenderExtends } from './preset'; import { Props } from './utils'; @@ -53,7 +53,7 @@ Mota.require('var', 'loading').once('coreInit', () => { fontFamily: 'normal', titleFont: '700 20px normal', winskin: 'winskin2.png', - interval: 25, + interval: 100, lineHeight: 6 }; @@ -101,4 +101,4 @@ export * from './shader'; export * from './sprite'; export * from './transform'; export * from './utils'; -export * from './components'; +export * from '../../module/ui/components'; diff --git a/src/core/render/item.ts b/src/core/render/item.ts index ba486b6..6f1e9a3 100644 --- a/src/core/render/item.ts +++ b/src/core/render/item.ts @@ -232,8 +232,6 @@ export abstract class RenderItem /** 当前元素是否为根元素 */ readonly isRoot: boolean = false; - protected needUpdate: boolean = false; - /** 该元素的变换矩阵 */ transform: Transform = new Transform(); @@ -250,7 +248,7 @@ export abstract class RenderItem /** 渲染缓存信息 */ protected cache: MotaOffscreenCanvas2D = new MotaOffscreenCanvas2D(); /** 是否需要更新缓存 */ - protected cacheDirty: boolean = true; + protected cacheDirty: boolean = false; /** 是否启用缓存机制 */ readonly enableCache: boolean = true; /** 是否启用transform下穿机制,即画布的变换是否会继续作用到下一层画布 */ @@ -303,7 +301,6 @@ export abstract class RenderItem this.width = width; this.height = height; this.cache.size(width, height); - this.cacheDirty = true; this.update(this); } @@ -315,7 +312,6 @@ export abstract class RenderItem renderContent(canvas: MotaOffscreenCanvas2D, transform: Transform) { if (this.hidden) return; this.emit('beforeRender', transform); - this.needUpdate = false; const tran = this.transformFallThrough ? transform : this.transform; const ax = -this.anchorX * this.width; @@ -339,6 +335,7 @@ export abstract class RenderItem canvas.ctx.drawImage(this.cache.canvas, ax, ay, width, height); } else { + this.cacheDirty = false; canvas.ctx.translate(ax, ay); this.render(canvas, tran); } @@ -403,8 +400,7 @@ export abstract class RenderItem } update(item: RenderItem = this): void { - if (this.needUpdate) return; - this.needUpdate = true; + if (this.cacheDirty) return; this.cacheDirty = true; if (this.hidden) return; this.parent?.update(item); @@ -513,7 +509,6 @@ export abstract class RenderItem parent.children.add(this); this._parent = parent; parent.requestSort(); - this.needUpdate = false; this.update(); if (this._id !== '') { const root = this.findRoot(); diff --git a/src/core/render/render.ts b/src/core/render/render.ts index 19cf0ba..4b0e4b2 100644 --- a/src/core/render/render.ts +++ b/src/core/render/render.ts @@ -29,19 +29,23 @@ export class MotaRenderer extends Container { this.transform.translate(240, 240); MotaRenderer.list.set(id, this); + + const update = () => { + this.requestRenderFrame(() => { + this.refresh(); + update(); + }); + }; + + update(); } update(_item: RenderItem = this) { - if (this.needUpdate || this.hidden) return; - this.needUpdate = true; - this.requestRenderFrame(() => { - this.refresh(); - }); + this.cacheDirty = true; } protected refresh(): void { - if (!this.needUpdate) return; - this.needUpdate = false; + if (!this.cacheDirty) return; this.target.clear(); this.renderContent(this.target, Transform.identity); } diff --git a/src/core/render/renderer/elements.tsx b/src/core/render/renderer/elements.tsx index 4b9e1cc..aac021a 100644 --- a/src/core/render/renderer/elements.tsx +++ b/src/core/render/renderer/elements.tsx @@ -17,7 +17,6 @@ import { CustomProps, DamageProps, EllipseProps, - GL2Props, IconProps, ImageProps, LayerGroupProps, @@ -33,9 +32,8 @@ import { WinskinProps } from './props'; import { ERenderItemEvent, RenderItem } from '../item'; -import { ESpriteEvent, Sprite } from '../sprite'; +import { ESpriteEvent } from '../sprite'; import { EContainerEvent } from '../container'; -import { EGL2Event } from '../gl2'; import { EIconEvent, EImageEvent, diff --git a/src/core/render/renderer/props.ts b/src/core/render/renderer/props.ts index c71fe3d..bd4ae32 100644 --- a/src/core/render/renderer/props.ts +++ b/src/core/render/renderer/props.ts @@ -7,7 +7,6 @@ import { } from '../preset/layer'; import type { EnemyCollection } from '@/game/enemy/damage'; import { ILineProperty } from '../preset/graphics'; -import { SizedCanvasImageSource } from '../preset'; export interface CustomProps { _item: (props: BaseProps) => RenderItem; diff --git a/src/data/logger.json b/src/data/logger.json index 8791bad..1946679 100644 --- a/src/data/logger.json +++ b/src/data/logger.json @@ -85,6 +85,8 @@ "51": "Cannot decode sound '$1', since audio file may not supported by 2.b.", "52": "Cannot play sound '$1', since there is no added data named it.", "53": "Cannot $1 audio route '$2', since there is not added route named it.", + "54": "Missing start tag for '$1', index: $2.", + "55": "Unchildable tag '$1' should follow with param.", "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/index.ts b/src/module/index.ts index 0d1f44f..f34302f 100644 --- a/src/module/index.ts +++ b/src/module/index.ts @@ -15,3 +15,5 @@ Mota.register('module', 'Audio', { soundPlayer }); export * from './weather'; export * from './audio'; export * from './loader'; +export * from './fallback'; +export * from './ui'; diff --git a/src/module/ui/components/index.ts b/src/module/ui/components/index.ts new file mode 100644 index 0000000..cee1da2 --- /dev/null +++ b/src/module/ui/components/index.ts @@ -0,0 +1,3 @@ +export * from './textbox'; +export * from './textboxTyper'; +export * from './types'; diff --git a/src/module/ui/components/textbox.tsx b/src/module/ui/components/textbox.tsx new file mode 100644 index 0000000..67f7adc --- /dev/null +++ b/src/module/ui/components/textbox.tsx @@ -0,0 +1,474 @@ +import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; +import { + computed, + defineComponent, + onUnmounted, + onUpdated, + ref, + shallowReactive, + shallowRef, + SlotsType, + VNode, + watch +} from 'vue'; +import { logger } from '@/core/common/logger'; +import { Sprite } from '../../../core/render/sprite'; +import { ContainerProps } from '../../../core/render/renderer'; +import { isNil } from 'lodash-es'; +import { SetupComponentOptions } from './types'; +import EventEmitter from 'eventemitter3'; +import { Text } from '../../../core/render/preset'; +import { + ITextContentConfig, + TextContentTyper, + TyperRenderable, + TextContentType +} from './textboxTyper'; + +export interface TextContentProps + extends ContainerProps, + Partial { + /** 显示的文字 */ + text?: string; + /** 是否填充 */ + fill?: boolean; + /** 是否描边 */ + stroke?: boolean; + /** 是否忽略打字机,直接显伤全部 */ + showAll?: boolean; +} + +export type TextContentEmits = { + typeEnd: () => void; + typeStart: () => void; +}; + +const textContentOptions = { + props: [ + 'breakChars', + 'fontFamily', + 'fontSize', + 'fontWeight', + 'fontItalic', + 'height', + 'ignoreLineEnd', + 'ignoreLineStart', + 'interval', + 'keepLast', + 'lineHeight', + 'text', + 'textAlign', + 'width', + 'wordBreak', + 'x', + 'y', + 'fill', + 'fillStyle', + 'strokeStyle', + 'strokeWidth', + 'stroke', + 'showAll' + ], + emits: ['typeEnd', 'typeStart'] +} satisfies SetupComponentOptions< + TextContentProps, + TextContentEmits, + keyof TextContentEmits +>; + +export const TextContent = defineComponent< + TextContentProps, + TextContentEmits, + keyof TextContentEmits +>((props, { emit }) => { + if (props.width && props.width <= 0) { + logger.warn(41, String(props.width)); + } + + const typer = new TextContentTyper(props); + let renderable: TyperRenderable[] = []; + let needUpdate = false; + let nowText = ''; + + const retype = () => { + if (props.showAll) { + typer.typeAll(); + } + if (props.text === nowText) return; + if (needUpdate) return; + needUpdate = true; + if (!spriteElement.value) { + needUpdate = false; + } + nowText = props.text ?? ''; + renderable = []; + + spriteElement.value?.requestBeforeFrame(() => { + typer.setConfig(props); + typer.setText(props.text ?? ''); + typer.type(); + if (props.showAll) { + typer.typeAll(); + } + needUpdate = false; + }); + }; + + onUpdated(retype); + + const spriteElement = shallowRef(); + const renderContent = (canvas: MotaOffscreenCanvas2D) => { + const ctx = canvas.ctx; + ctx.textBaseline = 'top'; + renderable.forEach(v => { + if (v.type === TextContentType.Text) { + ctx.fillStyle = v.fillStyle; + ctx.strokeStyle = v.strokeStyle; + ctx.font = v.font; + const text = v.text.slice(0, v.pointer); + + if (props.fill ?? true) { + ctx.fillText(text, v.x, v.y); + } + if (props.stroke) { + ctx.strokeText(text, v.x, v.y); + } + } else { + const r = v.renderable; + const render = r.render; + const [x, y, w, h] = render[0]; + const icon = r.autotile ? r.image[0] : r.image; + ctx.drawImage(icon, x, y, w, h, v.x, v.y, v.width, v.height); + } + }); + }; + + const renderFunc = (data: TyperRenderable[]) => { + renderable = data; + spriteElement.value?.update(); + }; + + typer.setRender(renderFunc); + typer.on('typeStart', () => { + emit('typeStart'); + }); + typer.on('typeEnd', () => { + emit('typeEnd'); + }); + + return () => { + return ( + + ); + }; +}, textContentOptions); + +export interface TextboxProps extends TextContentProps, ContainerProps { + /** 背景颜色 */ + backColor?: CanvasStyle; + /** 背景 winskin */ + winskin?: ImageIds; + /** 边框与文字间的距离,默认为8 */ + padding?: number; + /** 标题 */ + title?: string; + /** 标题字体 */ + titleFont?: string; + /** 标题填充样式 */ + titleFill?: CanvasStyle; + /** 标题描边样式 */ + titleStroke?: CanvasStyle; + /** 标题文字与边框间的距离,默认为4 */ + titlePadding?: number; +} + +type TextboxEmits = TextContentEmits; +type TextboxSlots = SlotsType<{ + default: (data: TextboxProps) => VNode[]; + title: (data: TextboxProps) => VNode[]; +}>; + +const textboxOptions = { + props: (textContentOptions.props as (keyof TextboxProps)[]).concat([ + 'backColor', + 'winskin', + 'id', + 'padding', + 'alpha', + 'hidden', + 'anchorX', + 'anchorY', + 'antiAliasing', + 'cache', + 'composite', + 'fall', + 'hd', + 'transform', + 'type', + 'zIndex', + 'titleFill', + 'titleStroke', + 'titleFont' + ]), + emits: textContentOptions.emits +} satisfies SetupComponentOptions; + +let id = 0; +function getNextTextboxId() { + return `@default-textbox-${id++}`; +} + +export const Textbox = defineComponent< + TextboxProps, + TextboxEmits, + keyof TextboxEmits, + TextboxSlots +>((props, { slots }) => { + const data = shallowReactive({ ...props }); + data.padding ??= 8; + data.width ??= 200; + data.height ??= 200; + data.id ??= ''; + data.alpha ??= 1; + data.titleFill ??= '#000'; + data.titleStroke ??= 'transparent'; + data.titleFont ??= '16px Verdana'; + data.titlePadding ??= 4; + + const titleElement = ref(); + const titleWidth = ref(data.titlePadding * 2); + const titleHeight = ref(data.titlePadding * 2); + const contentY = computed(() => { + const height = titleHeight.value; + return data.title ? height : 0; + }); + const contentWidth = computed(() => data.width! - data.padding! * 2); + const contentHeight = computed( + () => data.height! - data.padding! * 2 - contentY.value + ); + + const calTitleSize = (text: string) => { + if (!titleElement.value) return; + const { width, height } = titleElement.value; + titleWidth.value = width + data.titlePadding! * 2; + titleHeight.value = height + data.titlePadding! * 2; + data.title = text; + }; + + watch(titleElement, (value, old) => { + old?.off('setText', calTitleSize); + value?.on('setText', calTitleSize); + if (value) calTitleSize(value?.text); + }); + + onUnmounted(() => { + titleElement.value?.off('setText', calTitleSize); + }); + + // ----- store + + /** 结束打字机 */ + const storeEmits: TextboxStoreEmits = { + endType() { + data.showAll = true; + } + }; + + const store = TextboxStore.use( + props.id ?? getNextTextboxId(), + data, + storeEmits + ); + const hidden = ref(data.hidden); + store.on('hide', () => (hidden.value = true)); + store.on('show', () => (hidden.value = false)); + store.on('update', value => { + if (value.title) { + titleElement.value?.requestBeforeFrame(() => { + const { width, height } = titleElement.value!; + titleWidth.value = width + data.padding! * 2; + titleHeight.value = height + data.padding! * 2; + }); + } + }); + + const onTypeStart = () => { + store.emitTypeStart(); + }; + + const onTypeEnd = () => { + data.showAll = false; + store.emitTypeEnd(); + }; + + return () => { + return ( + + ); + }; +}, textboxOptions); + +interface TextboxStoreEmits { + endType: () => void; +} + +interface TextboxStoreEvent { + update: [value: TextboxProps]; + show: []; + hide: []; + typeStart: []; + typeEnd: []; +} + +export class TextboxStore extends EventEmitter { + static list: Map = new Map(); + + typing: boolean = false; + + private constructor( + private readonly data: TextboxProps, + private readonly emits: TextboxStoreEmits + ) { + super(); + } + + /** + * 开始打字,由组件调用,而非组件外调用 + */ + emitTypeStart() { + this.typing = true; + this.emit('typeStart'); + } + + /** + * 结束打字,由组件调用,而非组件外调用 + */ + emitTypeEnd() { + this.typing = false; + this.emit('typeEnd'); + } + + /** + * 结束打字机的打字 + */ + endType() { + this.emits.endType(); + } + + /** + * 修改渲染数据 + */ + modify(data: Partial) { + for (const [key, value] of Object.entries(data)) { + // @ts-expect-error 无法推导 + if (!isNil(value)) this.data[key] = value; + } + this.emit('update', this.data); + } + + /** + * 显示文本框 + */ + show() { + this.emit('show'); + } + + /** + * 隐藏文本框 + */ + hide() { + this.emit('hide'); + } + + /** + * 获取文本框 + * @param id 文本框id + */ + static get(id: string): TextboxStore | undefined { + return this.list.get(id); + } + + /** + * 在当前作用域下生成文本框控制器 + * @param id 文本框id + * @param props 文本框渲染数据 + */ + static use(id: string, props: TextboxProps, emits: TextboxStoreEmits) { + const store = new TextboxStore(props, emits); + if (this.list.has(id)) { + logger.warn(42, id); + } + this.list.set(id, store); + onUnmounted(() => { + this.list.delete(id); + }); + return store; + } +} diff --git a/src/module/ui/components/textboxTyper.ts b/src/module/ui/components/textboxTyper.ts new file mode 100644 index 0000000..b22ea77 --- /dev/null +++ b/src/module/ui/components/textboxTyper.ts @@ -0,0 +1,1145 @@ +import { logger } from '@/core/common/logger'; +import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; +import { + AutotileRenderable, + onTick, + RenderableData, + texture +} from '@/core/render'; +import EventEmitter from 'eventemitter3'; +import { isNil } from 'lodash-es'; + +export const enum WordBreak { + /** 不换行 */ + None, + /** 仅空格和连字符等可换行,CJK 字符可任意换行,默认值 */ + Space, + /** 所有字符都可以换行 */ + All +} + +export const enum TextAlign { + Left, + Center, + End +} + +export interface ITextContentConfig { + /** 字体类型 */ + fontFamily: string; + /** 字体大小 */ + fontSize: number; + /** 字体线宽 */ + fontWeight: number; + /** 是否斜体 */ + fontItalic: boolean; + /** 是否持续上一次的文本,开启后,如果修改后的文本以修改前的文本为开头,那么会继续播放而不会从头播放 */ + keepLast: boolean; + /** 打字机时间间隔,即两个字出现之间相隔多长时间 */ + interval: number; + /** 行高 */ + lineHeight: number; + /** 分词规则 */ + wordBreak: WordBreak; + /** 文字对齐方式 */ + textAlign: TextAlign; + /** 行首忽略字符,即不会出现在行首的字符 */ + ignoreLineStart: Iterable; + /** 行尾忽略字符,即不会出现在行尾的字符 */ + ignoreLineEnd: Iterable; + /** 会被分词规则识别的分词字符 */ + breakChars: Iterable; + /** 填充样式 */ + fillStyle: CanvasStyle; + /** 描边样式 */ + strokeStyle: CanvasStyle; + /** 线宽 */ + strokeWidth: number; + /** 文字宽度,到达这么宽之后换行 */ + width: number; +} + +export interface ITextContentRenderData { + text: string; + title?: string; + x?: number; + y?: number; + width?: number; + height?: number; + /** 是否填充 */ + fill?: boolean; + /** 是否描边 */ + stroke?: boolean; + /** 是否无视打字机,强制全部显示 */ + showAll?: boolean; +} + +interface ParserStatus { + fillStyle: CanvasStyle; + fontFamily: string; + fontSize: number; + fontItalic: boolean; + fontWeight: number; +} + +export const enum TextContentType { + Text, + Wait, + Icon +} + +export interface ITextContentRenderable { + type: TextContentType; + text: string; + font: string; + fillStyle: CanvasStyle; + /** 字体大小,用于分行中猜测长度 */ + fontSize: number; + /** 这段文字的分行信息,每一项表示在对应索引后分词 */ + splitLines: number[]; + /** 这段文字的分词信息,每一项表示在对于索引后分行 */ + wordBreak: number[]; + /** 最后一行的宽度 */ + lastLineWidth?: number; + /** 等待时长 */ + wait?: number; + /** 显示的图标 */ + icon?: AllNumbers; +} + +export interface ITextContentRenderObject { + /** 每一行的高度 */ + lineHeights: number[]; + /** 渲染数据 */ + data: ITextContentRenderable[]; +} + +export interface TyperTextRenderable { + type: TextContentType.Text; + x: number; + y: number; + text: string; + font: string; + fillStyle: CanvasStyle; + strokeStyle: CanvasStyle; + /** 文字画到哪个索引 */ + pointer: number; +} + +export interface TyperIconRenderable { + type: TextContentType.Icon; + x: number; + y: number; + width: number; + height: number; + renderable: RenderableData | AutotileRenderable; +} + +export type TyperRenderable = TyperTextRenderable | TyperIconRenderable; + +interface TextContentTyperEvent { + typeStart: []; + typeEnd: []; +} + +type TyperFunction = (data: TyperRenderable[], typing: boolean) => void; + +export class TextContentTyper extends EventEmitter { + /** 文字配置信息 */ + readonly config: Required; + /** 文字解析器 */ + readonly parser: TextContentParser; + + private _text: string = ''; + /** 显示的文字 */ + get text(): string { + return this._text; + } + set text(v: string) { + this._text = v; + } + + /** 渲染信息 */ + private renderObject: ITextContentRenderObject = { + lineHeights: [], + data: [] + }; + /** 渲染信息 */ + private renderData: TyperRenderable[] = []; + /** 现在是第几行,0表示第一行 */ + private nowLine: number = 0; + /** 当前的 renderable 是第几行 */ + private dataLine: number = 0; + /** 当前需要绘制的横坐标 */ + private x: number = 0; + /** 当前需要绘制的纵坐标 */ + private y: number = 0; + /** 当前显示到了哪个 renderable */ + private pointer: number = 0; + /** 上一个字显示出的时间 */ + private lastTypeTime: number = 0; + /** 是否正在打字 */ + private typing: boolean = false; + /** 现在正在打字的 renderable 对象 */ + private processingData?: TyperRenderable; + + /** 渲染函数 */ + render?: TyperFunction; + + constructor(config: Partial) { + super(); + + this.config = { + fontFamily: config.fontFamily ?? 'Verdana', + fontSize: config.fontSize ?? 16, + fontWeight: config.fontWeight ?? 500, + fontItalic: config.fontItalic ?? false, + keepLast: config.keepLast ?? false, + interval: config.interval ?? 0, + lineHeight: config.lineHeight ?? 0, + wordBreak: config.wordBreak ?? WordBreak.Space, + textAlign: config.textAlign ?? TextAlign.Left, + ignoreLineStart: config.ignoreLineStart ?? '', + ignoreLineEnd: config.ignoreLineEnd ?? '', + breakChars: config.breakChars ?? '', + fillStyle: config.fillStyle ?? '#fff', + strokeStyle: config.strokeStyle ?? '#000', + strokeWidth: config.strokeWidth ?? 2, + width: config.width ?? 200 + }; + + this.parser = new TextContentParser( + { + fillStyle: this.config.fillStyle, + fontFamily: this.config.fontFamily, + fontSize: this.config.fontSize, + fontItalic: this.config.fontItalic, + fontWeight: this.config.fontWeight + }, + this.config + ); + + onTick(() => this.tick()); + } + + /** + * 设置打字机的配置属性 + * @param config 配置信息 + */ + setConfig(config: Partial) { + for (const [key, value] of Object.entries(config)) { + if (!isNil(value)) { + // @ts-expect-error 无法推导 + this.config[key] = value; + } + } + this.parser.setStatus({ + fillStyle: this.config.fillStyle, + fontFamily: this.config.fontFamily, + fontSize: this.config.fontSize, + fontItalic: this.config.fontItalic, + fontWeight: this.config.fontWeight + }); + } + + /** + * 重设打字机状态 + * @param _lastText 上一次的文字 + */ + private resetTypeStatus(_lastText: string) { + // todo: 接续打字 + this.renderData = []; + this.nowLine = 0; + this.pointer = 0; + this.processingData = void 0; + this.typing = false; + this.dataLine = 0; + this.x = 0; + this.y = 0; + } + + /** + * 设置显示文本 + */ + setText(text: string) { + const lastText = this._text; + this._text = text; + this.resetTypeStatus(lastText); + this.renderObject = this.parser.parse(text, this.config.width); + } + + private createTyperData(index: number, line: number) { + const renderable = this.renderObject.data[index]; + if (!renderable) return false; + if (renderable.type === TextContentType.Text) { + if (line < 0 || line > renderable.splitLines.length) { + return false; + } + const start = renderable.splitLines[line - 1] ?? 0; + const end = renderable.splitLines[line] ?? renderable.text.length; + const data: TyperTextRenderable = { + type: TextContentType.Text, + x: this.x, + y: this.y, + text: renderable.text.slice(start, end), + font: renderable.font, + fillStyle: renderable.fillStyle, + strokeStyle: this.config.strokeStyle, + pointer: 0 + }; + this.processingData = data; + this.renderData.push(data); + return true; + } else { + const tex = texture.getRenderable(renderable.icon!); + if (!tex) return false; + const { render } = tex; + const [, , width, height] = render[0]; + const aspect = width / height; + let iconWidth = 0; + if (aspect < 1) { + // 这时候应该把高度限定在当前字体大小 + iconWidth = width * (renderable.fontSize / height); + } else { + iconWidth = renderable.fontSize; + } + const data: TyperIconRenderable = { + type: TextContentType.Icon, + x: this.x, + y: this.y, + width: iconWidth, + height: iconWidth / aspect, + renderable: tex + }; + this.processingData = data; + this.renderData.push(data); + return true; + } + } + + /** + * 执行打字 + * @param num 打出多少字 + * @returns 打字是否结束 + */ + private typeChars(num: number): boolean { + let rest = num; + while (rest > 0) { + if (!this.processingData) { + const success = this.createTyperData( + this.pointer, + this.dataLine + ); + if (!success) return true; + } + const now = this.processingData; + if (!now) return true; + const renderable = this.renderObject.data[this.pointer]; + if (!renderable) { + return true; + } + const lineHeight = this.renderObject.lineHeights[this.nowLine]; + if (now.type === TextContentType.Text) { + const restChars = now.text.length - now.pointer; + if (restChars <= rest) { + // 当前这段 renderable 打字完成后,刚好结束或还有内容 + rest -= restChars; + now.pointer = now.text.length; + if (this.dataLine === renderable.splitLines.length) { + // 如果是最后一行 + if (isNil(renderable.lastLineWidth)) { + const ctx = this.parser.testCanvas.ctx; + ctx.font = now.font; + const metrics = ctx.measureText(now.text); + renderable.lastLineWidth = metrics.width; + } + this.x += renderable.lastLineWidth; + this.dataLine = 0; + this.pointer++; + const success = this.createTyperData( + this.pointer, + this.dataLine + ); + if (!success) return true; + } else { + // 不是最后一行,那么换行 + this.x = 0; + this.y += lineHeight + this.config.lineHeight; + this.dataLine++; + this.nowLine++; + const success = this.createTyperData( + this.pointer, + this.dataLine + ); + if (!success) return true; + } + } else { + now.pointer += rest; + return false; + } + } else { + rest--; + this.pointer++; + if (renderable.splitLines[0] === 0) { + // 如果图标换行 + this.x = 0; + this.y += lineHeight + this.config.lineHeight; + this.nowLine++; + this.dataLine = 0; + now.x = 0; + now.y = this.y; + } + const success = this.createTyperData( + this.pointer, + this.dataLine + ); + if (!success) return true; + } + } + return false; + } + + /** + * 每帧执行的函数 + */ + private tick() { + if (!this.typing) return; + const now = Date.now(); + const needType = Math.round( + (now - this.lastTypeTime) / this.config.interval + ); + if (needType === 0) return; + this.lastTypeTime = now; + const end = this.typeChars(needType); + this.render?.(this.renderData, !end); + if (end) { + this.typing = false; + this.emit('typeEnd'); + } + } + + /** + * 开始打字 + */ + type() { + if (this.config.interval === 0) { + this.emit('typeStart'); + this.typeChars(Infinity); + this.render?.(this.renderData, false); + this.emit('typeEnd'); + return; + } + this.emit('typeStart'); + this.lastTypeTime = Date.now(); + this.typing = true; + } + + /** + * 立即显示所有文字 + */ + typeAll() { + this.typeChars(Infinity); + this.render?.(this.renderData, false); + } + + /** + * 设置渲染函数,该函数会被打字机对象在需要执行的时候自动执行 + * @param render 会被执行的渲染函数 + */ + setRender(render: TyperFunction) { + this.render = render; + } +} + +const enum ExpStringType { + Quote, + DoubleQuote, + Backquote +} + +const defaultsBreak = ' -,.)]}?!;:,。)】?!;:'; +const defaultsIgnoreStart = + '))】》>﹞>)]»›〕〉}]」}〗』,。?!:;·…,.?!:;、……~&@#~&@#'; +const defaultsIgnoreEnd = '((【《<﹝<([«‹〔〈{[「{〖『'; +const breakSet = new Set(defaultsBreak); +const ignoreStart = new Set(defaultsIgnoreStart); +const ignoreEnd = new Set(defaultsIgnoreEnd); + +export class TextContentParser { + /** 解析时的状态 */ + private status: ParserStatus; + /** 正在解析的文字 */ + private text: string = ''; + /** 填充颜色栈 */ + private fillStyleStack: CanvasStyle[] = []; + /** 字体大小栈 */ + private fontSizeStack: number[] = []; + /** 字体类型栈 */ + private fontFamilyStack: string[] = []; + /** 解析出的渲染信息 */ + private renderable: ITextContentRenderable[] = []; + /** 当前的字体 */ + private font: string = ''; + /** 当前解析出的文字 */ + private resolved: string = ''; + /** 当前的分词信息 */ + private wordBreak: number[] = []; + + // 在分行中,会出现上一个渲染数据的最后并不能组成一个完整的行,这时候需要把最后一个不完整的行的宽度记录下来 + // 然后把当前渲染数据的宽度计算上再进行分行。 + + /** 上一个渲染数据的最后一个分行对应的分词索引 */ + private lastBreakIndex: number = -1; + /** 当前渲染数据索引 */ + private nowRenderable: number = -1; + /** 当前行的高度 */ + private lineHeight: number = 0; + /** 每一行的行高 */ + private lineHeights: number[] = []; + /** 当前这一行已经有多长 */ + private lineWidth: number = 0; + /** 这一行未计算部分的起始位置索引 */ + private lineStart: number = 0; + /** + * 长度猜测增益,可以减少文字宽度的测量次数,原理为如果测量长度小于剩余宽度,那么将它乘以一个值, + * 使得猜测长度变大,从而过滤掉一些潜在的无用测量 + */ + private guessGain: number = 1; + /** 二分的起始索引 */ + private bsStart: number = 0; + /** 二分的结束索引 */ + private bsEnd: number = 0; + + /** 分词原则 */ + wordBreakRule: WordBreak = WordBreak.Space; + /** 测试画布,用于测量文字 */ + readonly testCanvas: MotaOffscreenCanvas2D; + + /** + * @param initStatus 解析器的初始状态 + * @param config 解析器的配置信息 + */ + constructor( + public readonly initStatus: ParserStatus, + public readonly config: Required + ) { + this.status = { ...initStatus }; + + this.testCanvas = new MotaOffscreenCanvas2D(false); + this.testCanvas.withGameScale(false); + this.testCanvas.setHD(false); + this.testCanvas.size(1, 1); + this.testCanvas.freeze(); + } + + /** + * 设置解析器的初始状态 + * @param st 要设置为的状态,不填的表示不变 + */ + setStatus(st: Partial) { + if (!isNil(st.fillStyle)) this.status.fillStyle = st.fillStyle; + if (!isNil(st.fontSize)) this.status.fontSize = st.fontSize; + if (!isNil(st.fontFamily)) this.status.fontFamily = st.fontFamily; + if (!isNil(st.fontItalic)) this.status.fontItalic = st.fontItalic; + if (!isNil(st.fontWeight)) this.status.fontWeight = st.fontWeight; + } + + /** + * 给定参数开始的索引,获取参数结束时的索引 + * @param start 开始检索的索引 + */ + private indexParam(start: number) { + if (this.text[start] !== '[') return -1; + else return this.text.indexOf(']', start); + } + + /** + * 处理包含起始和结束标记的标签的参数 + * @param start 开始检索的索引 + */ + private getChildableTagParam(start: number): [string, number] { + const end = this.indexParam(start); + if (end === -1) { + // 标签结束 + return ['', start]; + } else { + // 标签开始 + return [this.text.slice(start + 1, end), end]; + } + } + + /** + * 获取没有起始和结束标记的标签的参数 + * @param start 开始检索的索引 + */ + private getTagParam(start: number): [string, number] { + const end = this.indexParam(start); + if (end === -1) return ['', start]; + return [this.text.slice(start + 1, end), end]; + } + + private addTextRenderable() { + const data: ITextContentRenderable = { + type: TextContentType.Text, + text: this.resolved, + font: this.font, + fontSize: this.status.fontSize, + fillStyle: this.status.fillStyle, + splitLines: [], + wordBreak: [] + }; + this.renderable.push(data); + this.resolved = ''; + } + + private addWaitRenderable(wait: number) { + const data: ITextContentRenderable = { + type: TextContentType.Wait, + text: this.resolved, + font: this.font, + fontSize: this.status.fontSize, + fillStyle: this.status.fillStyle, + wait, + splitLines: [], + wordBreak: [] + }; + this.renderable.push(data); + this.resolved = ''; + } + + private addIconRenderable(icon: AllNumbers) { + const data: ITextContentRenderable = { + type: TextContentType.Icon, + text: this.resolved, + font: this.font, + fontSize: this.status.fontSize, + fillStyle: this.status.fillStyle, + icon, + splitLines: [], + wordBreak: [] + }; + this.renderable.push(data); + this.resolved = ''; + } + + private buildFont() { + return buildFont( + this.status.fontFamily, + this.status.fontSize, + this.status.fontWeight, + this.status.fontItalic + ); + } + + private parseFillStyle(pointer: number) { + const [param, end] = this.getChildableTagParam(pointer + 1); + if (!param) { + // 参数为空或没有参数,视为标签结束 + const color = this.fillStyleStack.pop(); + if (!color) { + logger.warn(54, '\\r', pointer.toString()); + return pointer; + } + this.addTextRenderable(); + this.status.fillStyle = color; + return pointer; + } else { + // 标签开始 + this.fillStyleStack.push(this.status.fillStyle); + this.addTextRenderable(); + this.status.fillStyle = param; + return end; + } + } + + private parseFontSize(pointer: number) { + const [param, end] = this.getChildableTagParam(pointer + 1); + if (!param) { + // 参数为空或没有参数,视为标签结束 + const size = this.fontSizeStack.pop(); + if (!size) { + logger.warn(54, '\\c', pointer.toString()); + return pointer; + } + this.addTextRenderable(); + this.status.fontSize = size; + this.font = this.buildFont(); + return pointer; + } else { + // 标签开始 + this.fontSizeStack.push(this.status.fontSize); + this.addTextRenderable(); + this.status.fontSize = parseFloat(param); + this.font = this.buildFont(); + return end; + } + } + + private parseFontFamily(pointer: number) { + const [param, end] = this.getChildableTagParam(pointer + 1); + if (!param) { + // 参数为空或没有参数,视为标签结束 + const font = this.fontFamilyStack.pop(); + if (!font) { + logger.warn(54, '\\g', pointer.toString()); + return pointer; + } + this.addTextRenderable(); + this.status.fontFamily = font; + this.font = this.buildFont(); + return pointer; + } else { + // 标签开始 + this.fontFamilyStack.push(this.status.fontFamily); + this.addTextRenderable(); + this.status.fontFamily = param; + this.font = this.buildFont(); + return end; + } + } + + private parseFontWeight() { + this.addTextRenderable(); + this.status.fontWeight = this.status.fontWeight > 500 ? 500 : 700; + this.font = this.buildFont(); + } + + private parseFontItalic() { + this.addTextRenderable(); + this.status.fontItalic = !this.status.fontItalic; + this.font = this.buildFont(); + } + + private parseWait(pointer: number) { + this.addTextRenderable(); + const [param, end] = this.getTagParam(pointer); + if (!param) { + logger.warn(55, '\\z'); + return pointer; + } + const time = parseInt(param); + this.addWaitRenderable(time); + return end; + } + + private parseIcon(pointer: number) { + this.addTextRenderable(); + const [param, end] = this.getTagParam(pointer); + if (!param) { + logger.warn(55, '\\z'); + return pointer; + } + if (/^\d+$/.test(param)) { + const num = Number(param); + this.addIconRenderable(num as AllNumbers); + } else { + if (/^X\d+$/.test(param)) { + // 额外素材 + const num = Number(param.slice(1)); + this.addIconRenderable(num as AllNumbers); + } else { + const num = texture.idNumberMap[param as AllIds]; + this.addIconRenderable(num); + } + } + return end; + } + + /** + * 将文字解析并分词、分行 + * @param text 要解析的文字 + * @param width 文字宽度,即文字到达这么宽之后换行 + */ + parse(text: string, width: number): ITextContentRenderObject { + this.text = text; + this.fontFamilyStack = []; + this.fontSizeStack = []; + this.fillStyleStack = []; + this.status = { ...this.initStatus }; + this.renderable = []; + this.font = this.buildFont(); + this.resolved = ''; + this.wordBreak = []; + this.lastBreakIndex = -1; + this.nowRenderable = -1; + this.lineHeight = 0; + this.lineHeights = []; + this.lineWidth = 0; + this.lineStart = 0; + this.guessGain = 1; + this.bsStart = 0; + this.bsEnd = 0; + + let inExpression = false; + let inExpString = false; + let stringType: ExpStringType = ExpStringType.Quote; + let expDepth = 0; + let expStart = 0; + + for (let pointer = 0; pointer < text.length; pointer++) { + const char = text[pointer]; + + if (inExpression) { + if (inExpString) { + if ( + char === '"' && + stringType === ExpStringType.DoubleQuote + ) { + inExpString = false; + } else if ( + char === "'" && + stringType === ExpStringType.Quote + ) { + inExpString = false; + } else if ( + char === '`' && + stringType === ExpStringType.Backquote + ) { + inExpString = false; + } + } else { + if (char === '{') { + expDepth++; + } else if (char === '}') { + if (expDepth === 0) { + const exp = this.text.slice(expStart, pointer); + this.resolved += core.calValue(exp); + inExpression = false; + } else { + expDepth--; + } + } else if (char === '"') { + inExpString = true; + stringType = ExpStringType.DoubleQuote; + } else if (char === "'") { + inExpString = true; + stringType = ExpStringType.Quote; + } else if (char === '`') { + inExpString = true; + stringType = ExpStringType.Backquote; + } + } + continue; + } + + if (char === '\\') { + const next = text[pointer + 1]; + switch (next) { + case '\\': + case '$': { + this.resolved += next; + pointer++; + break; + } + case 'r': + pointer = this.parseFillStyle(pointer); + break; + case 'c': + pointer = this.parseFontSize(pointer); + break; + case 'g': + pointer = this.parseFontFamily(pointer); + break; + case 'd': + this.parseFontWeight(); + break; + case 'e': + this.parseFontItalic(); + break; + case 'z': + pointer = this.parseWait(pointer); + break; + case 'i': + pointer = this.parseIcon(pointer); + break; + } + continue; + } else if (char === '\r') { + pointer = this.parseFillStyle(pointer); + continue; + } else if (char === '$') { + // 表达式 + pointer++; + inExpression = true; + expStart = pointer; + continue; + } + + this.resolved += char; + } + + this.addTextRenderable(); + + return this.splitLines(width); + } + + /** + * 检查是否需要换行,如果需要则换行 + * @param width 最大宽度 + * @param guess 猜测到达这么多宽度需要多少字符 + * @param pointer 当前字符的索引 + * @param breakIndex 当前分词的索引 + */ + private checkLineWidth(width: number, guess: number, pointer: number) { + const breakIndex = this.wordBreak.length - 1; + if (breakIndex === -1) return true; + const rest = width - this.lineWidth; + const guessRest = guess * (rest / width) * this.guessGain; + const length = pointer - this.lineStart + 1; + if (length < guessRest) { + return false; + } + this.guessGain = 1; + // 如果大于猜测,那么算长度 + const data = this.renderable[this.nowRenderable]; + const ctx = this.testCanvas.ctx; + ctx.font = this.font; + const metrics = ctx.measureText( + data.text.slice(this.lineStart, pointer + 1) + ); + if (metrics.width < rest) { + // 实际宽度小于剩余宽度时,将猜测增益乘以剩余总宽度与当前宽度的比值的若干倍 + this.guessGain *= (rest / metrics.width) * (1.1 + 1 / length); + this.bsStart = breakIndex; + return false; + } else { + this.bsEnd = breakIndex; + let maxWidth = rest; + // 循环二分,直到不能分行 + while (true) { + const index = this.bsLineWidth(maxWidth, this.nowRenderable); + data.splitLines.push(this.wordBreak[index]); + this.lineHeights.push(this.lineHeight); + this.lineStart = pointer; + this.bsStart = index; + const text = data.text.slice( + this.wordBreak[index], + pointer + 1 + ); + if (text.length < guessRest / 4) { + // 如果剩余文字很少,几乎不可能会单独成一行时,直接结束循环 + this.lastBreakIndex = index; + break; + } + maxWidth = width; + const metrics = ctx.measureText(text); + if (metrics.width < maxWidth) { + this.lastBreakIndex = index; + break; + } + } + return true; + } + } + + private bsLineWidth(width: number, index: number) { + let start = this.bsStart; + let end = this.bsEnd; + let height = 0; + + const data = this.renderable[index]; + const { wordBreak } = data; + const ctx = this.testCanvas.ctx; + ctx.font = this.font; + while (true) { + const mid = Math.floor((start + end) / 2); + if (mid === start) { + if (height > this.lineHeight) { + this.lineHeight = height; + } + return start; + } + const text = data.text.slice( + wordBreak[this.bsStart], + wordBreak[mid] + ); + const metrics = ctx.measureText(text); + if (metrics.width > width) { + end = mid; + } else { + start = mid; + } + height = metrics.fontBoundingBoxAscent; + } + } + + /** + * 检查剩余字符能否分行 + */ + private checkRestLine(width: number, guess: number) { + if (this.wordBreak.length === 0) return true; + const last = this.nowRenderable - 1; + if (last === -1) return; + const data = this.renderable[last]; + const rest = width - this.lineWidth; + if (data.type === TextContentType.Text) { + const wordBreak = data.wordBreak; + const lastLine = data.splitLines.at(-1); + const lastIndex = isNil(lastLine) ? 0 : lastLine; + const restText = data.text.slice(lastIndex); + const ctx = this.testCanvas.ctx; + ctx.font = this.font; + const metrics = ctx.measureText(restText); + // 如果剩余内容不能构成完整的行 + if (metrics.width < rest) { + this.lineWidth += metrics.width; + data.lastLineWidth = metrics.width; + if (metrics.fontBoundingBoxAscent > this.lineHeight) { + this.lineHeight = metrics.fontBoundingBoxAscent; + } + return false; + } else { + // 如果可以构成完整的行,那么循环二分 + const lastBreak = wordBreak.at(-1)!; + this.bsStart = this.lastBreakIndex; + this.bsEnd = lastBreak; + let maxWidth = rest; + while (true) { + const index = this.bsLineWidth(maxWidth, last); + data.splitLines.push(this.wordBreak[index]); + this.lineHeights.push(this.lineHeight); + this.bsStart = index; + const text = data.text.slice(this.wordBreak[index]); + if (text.length < guess / 4) { + // 如果剩余文字很少,几乎不可能会单独成一行时,直接结束循环 + this.lastBreakIndex = index; + break; + } + const metrics = ctx.measureText(text); + maxWidth = width; + if (metrics.width < maxWidth) { + this.lastBreakIndex = index; + break; + } + } + return true; + } + } else if (data.type === TextContentType.Icon) { + const renderable = texture.getRenderable(data.icon!); + if (!renderable) return false; + const [, , width, height] = renderable.render[0]; + const aspect = width / height; + let iconWidth = 0; + if (aspect < 1) { + // 这时候应该把高度限定在当前字体大小 + iconWidth = width * (this.status.fontSize / height); + } else { + iconWidth = this.status.fontSize; + } + if (iconWidth > rest) { + data.splitLines.push(0); + this.lineHeights.push(this.lineHeight); + return true; + } else { + return false; + } + } + } + + /** + * 对解析出的文字分词并分行 + * @param width 文字的宽度,到达这么宽之后换行 + */ + private splitLines(width: number): ITextContentRenderObject { + if (this.wordBreakRule === WordBreak.None) { + return { lineHeights: [0], data: this.renderable }; + } + this.nowRenderable = -1; + + const breakChars = new Set(this.config.breakChars).union(breakSet); + const ignoreLineStart = new Set(this.config.ignoreLineStart).union( + ignoreStart + ); + const ignoreLineEnd = new Set(this.config.ignoreLineEnd).union( + ignoreEnd + ); + + const allBreak = this.wordBreakRule === WordBreak.All; + + for (let i = 0; i < this.renderable.length; i++) { + const data = this.renderable[i]; + const { wordBreak, fontSize } = data; + const guess = (width / fontSize) * 1.1; + this.nowRenderable = i; + this.wordBreak = wordBreak; + + if (data.type === TextContentType.Icon) { + this.checkRestLine(width, guess); + continue; + } else if (data.type === TextContentType.Wait) { + continue; + } + + for (let pointer = 0; pointer < data.text.length; pointer++) { + const char = data.text[pointer]; + + if (allBreak) { + wordBreak.push(pointer); + this.checkLineWidth(width, guess, pointer); + continue; + } + + const next = data.text[pointer + 1]; + + if (char === '\n' || (char === '\\' && next === 'n')) { + const data = this.renderable[this.nowRenderable]; + wordBreak.push(pointer); + data.splitLines.push(pointer); + continue; + } + + if (!ignoreLineEnd.has(char) && ignoreLineEnd.has(next)) { + wordBreak.push(pointer); + this.checkLineWidth(width, guess, pointer); + continue; + } + + if (ignoreLineStart.has(char) && !ignoreLineStart.has(next)) { + wordBreak.push(pointer + 1); + this.checkLineWidth(width, guess, pointer); + continue; + } + + if (breakChars.has(char) || isCJK(char.charCodeAt(0))) { + wordBreak.push(pointer); + this.checkLineWidth(width, guess, pointer); + } + } + + this.checkRestLine(width, guess); + } + + return { + lineHeights: this.lineHeights, + data: this.renderable + }; + } +} + +/** + * 判断一个文字是否是 CJK 文字 + * @param char 文字的编码 + */ +function isCJK(char: number) { + // 参考自 https://blog.csdn.net/brooksychen/article/details/2755395 + return ( + (char >= 0x4e00 && char <= 0x9fff) || + (char >= 0x3000 && char <= 0x30ff) || + (char >= 0xac00 && char <= 0xd7af) || + (char >= 0xf900 && char <= 0xfaff) || + (char >= 0x3400 && char <= 0x4dbf) || + (char >= 0x20000 && char <= 0x2ebef) || + (char >= 0x30000 && char <= 0x323af) || + (char >= 0x2e80 && char <= 0x2eff) || + (char >= 0x31c0 && char <= 0x31ef) + ); +} + +export function buildFont( + family: string, + size: number, + weight: number = 500, + italic: boolean = false +) { + return `${italic ? 'italic ' : ''}${weight} ${size}px "${family}"`; +} diff --git a/src/core/render/components/types.ts b/src/module/ui/components/types.ts similarity index 100% rename from src/core/render/components/types.ts rename to src/module/ui/components/types.ts diff --git a/src/module/ui/index.ts b/src/module/ui/index.ts new file mode 100644 index 0000000..07635cb --- /dev/null +++ b/src/module/ui/index.ts @@ -0,0 +1 @@ +export * from './components';