HumanBreak/src/core/render/components/textbox.tsx

947 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>;
/** 行尾忽略字符,即不会出现在行尾的字符 */
ignoreLineEnd?: Iterable<string>;
/** 会被分词规则识别的分词字符 */
breakChars?: Iterable<string>;
/** 填充样式 */
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<string>;
/** 行尾忽略字符,即不会出现在行尾的字符 */
ignoreLineEnd: Set<string>;
/** 会被分词规则识别的分词字符 */
breakChars: Set<string>;
}
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<TextContentRenderData> = 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<Sprite>();
/** 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<TextContentData>(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 (
<sprite
{...renderData}
ref={spriteElement}
x={renderData.x}
y={renderData.y}
width={renderData.width}
height={renderData.height}
render={renderContent}
></sprite>
);
};
}, 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<TextboxProps, {}, string, TextboxSlots>;
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<Text>();
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 (
<container {...data} hidden={hidden.value} alpha={data.alpha}>
{data.title ? (
<container
zIndex={10}
width={titleWidth.value}
height={titleHeight.value}
>
{props.winskin ? (
<winskin></winskin>
) : (
<g-rect
x={0}
y={0}
width={titleWidth.value}
height={titleHeight.value}
fillStyle={data.backColor}
></g-rect>
)}
<text
ref={titleElement}
text={data.title}
x={data.titlePadding}
y={data.titlePadding}
fillStyle={data.titleFill}
strokeStyle={data.titleStroke}
font={data.titleFont}
></text>
</container>
) : (
''
)}
{slots.default ? (
slots.default(data)
) : props.winskin ? (
// todo
<winskin></winskin>
) : (
// todo
<g-rect
x={0}
y={contentY.value}
width={data.width ?? 200}
height={(data.height ?? 200) - contentY.value}
fill
fillStyle={data.backColor}
></g-rect>
)}
<TextContent
{...data}
hidden={false}
x={data.padding!}
y={contentY.value + data.padding!}
width={contentWidth.value}
height={contentHeight.value}
onTypeEnd={onTypeEnd}
onTypeStart={onTypeStart}
zIndex={0}
showAll={data.showAll}
></TextContent>
</container>
);
};
}, textboxOptions);
const fontSizeGuessScale = new Map<string, number>([
['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<TextboxStoreEvent> {
static list: Map<string, TextboxStore> = 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<TextboxProps>) {
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;
}
}