mirror of
https://github.com/unanmed/HumanBreak.git
synced 2025-04-11 15:47:06 +08:00
refactor: TextContent
This commit is contained in:
parent
d4a2c38484
commit
8c381578db
@ -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 |
@ -1 +0,0 @@
|
||||
export * from './textbox';
|
@ -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;
|
||||
}
|
||||
}
|
@ -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}"`;
|
||||
}
|
@ -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';
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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."
|
||||
}
|
||||
|
@ -15,3 +15,5 @@ Mota.register('module', 'Audio', { soundPlayer });
|
||||
export * from './weather';
|
||||
export * from './audio';
|
||||
export * from './loader';
|
||||
export * from './fallback';
|
||||
export * from './ui';
|
||||
|
3
src/module/ui/components/index.ts
Normal file
3
src/module/ui/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './textbox';
|
||||
export * from './textboxTyper';
|
||||
export * from './types';
|
474
src/module/ui/components/textbox.tsx
Normal file
474
src/module/ui/components/textbox.tsx
Normal 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;
|
||||
}
|
||||
}
|
1145
src/module/ui/components/textboxTyper.ts
Normal file
1145
src/module/ui/components/textboxTyper.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
src/module/ui/index.ts
Normal file
1
src/module/ui/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components';
|
Loading…
Reference in New Issue
Block a user