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'; export const enum WordBreak { /** 不换行 */ None, /** 仅空格和连字符等可换行,CJK 字符可任意换行,默认值 */ Space, /** 所有字符都可以换行 */ All } export const enum TextAlign { Left, Center, End } 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(); }); interface TextContentRenderData { text: string; x?: number; y?: number; width?: number; height?: number; /** 字体 */ font?: string; /** 是否持续上一次的文本,开启后,如果修改后的文本以修改前的文本为开头,那么会继续播放而不会从头播放 */ 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 interface TextContentProps extends ContainerProps, TextContentRenderData {} 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', 'font', '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, font: props.font ?? core.status.globalAttribute?.font ?? '16px Verdana', 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-ignore renderData[key] = value; } } } }; const makeSplitData = (): TextContentData => { ensureProps(); return { text: renderData.text, width: renderData.width!, font: renderData.font!, 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[] }>; 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); 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; } }