refactor: TextContent

This commit is contained in:
unanmed 2025-01-25 19:46:28 +08:00
parent d4a2c38484
commit 8c381578db
17 changed files with 1647 additions and 1058 deletions

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1 +0,0 @@
export * from './textbox';

View File

@ -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<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',
'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<ITextContentRenderData> = 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<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[];
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<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}
>
{slots.title ? (
slots.title(data)
) : 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);
interface LineSplitData {
text: string;
font: string;
wait: number;
icon: AllIds | AllNumbers;
}
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;
}
}

View File

@ -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<string>;
/** 行尾忽略字符,即不会出现在行尾的字符 */
ignoreLineEnd?: Iterable<string>;
/** 会被分词规则识别的分词字符 */
breakChars?: Iterable<string>;
/** 填充样式 */
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<TextContentTyperEvent> {
testCanvas: MotaOffscreenCanvas2D;
constructor(public readonly data: Required<ITextContentRenderData>) {
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}"`;
}

View File

@ -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';

View File

@ -232,8 +232,6 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
/** 当前元素是否为根元素 */
readonly isRoot: boolean = false;
protected needUpdate: boolean = false;
/** 该元素的变换矩阵 */
transform: Transform = new Transform();
@ -250,7 +248,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
/** 渲染缓存信息 */
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<E extends ERenderItemEvent = ERenderItemEvent>
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<E extends ERenderItemEvent = ERenderItemEvent>
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<E extends ERenderItemEvent = ERenderItemEvent>
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<E extends ERenderItemEvent = ERenderItemEvent>
}
update(item: RenderItem<any> = 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<E extends ERenderItemEvent = ERenderItemEvent>
parent.children.add(this);
this._parent = parent;
parent.requestSort();
this.needUpdate = false;
this.update();
if (this._id !== '') {
const root = this.findRoot();

View File

@ -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);
}

View File

@ -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,

View File

@ -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;

View File

@ -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."
}

View File

@ -15,3 +15,5 @@ Mota.register('module', 'Audio', { soundPlayer });
export * from './weather';
export * from './audio';
export * from './loader';
export * from './fallback';
export * from './ui';

View File

@ -0,0 +1,3 @@
export * from './textbox';
export * from './textboxTyper';
export * from './types';

View File

@ -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<ITextContentConfig> {
/** 显示的文字 */
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<Sprite>();
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 (
<sprite
{...props}
ref={spriteElement}
x={props.x}
y={props.y}
width={props.width}
height={props.height}
render={renderContent}
></sprite>
);
};
}, 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<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}
>
{slots.title ? (
slots.title(data)
) : props.winskin ? (
<winskin image={props.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 ? (
<winskin image={props.winskin}></winskin>
) : (
<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);
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-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;
}
}

File diff suppressed because it is too large Load Diff

1
src/module/ui/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './components';