mirror of
				https://github.com/unanmed/HumanBreak.git
				synced 2025-11-04 07:02:58 +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-namespace': 'off',
 | 
				
			||||||
            '@typescript-eslint/no-this-alias': 'off'
 | 
					            '@typescript-eslint/no-this-alias': 'off',
 | 
				
			||||||
 | 
					            'no-console': 'warn'
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    eslintPluginPrettierRecommended
 | 
					    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 { FloorChange } from '@/plugin/fallback';
 | 
				
			||||||
import { createApp } from './renderer';
 | 
					import { createApp } from './renderer';
 | 
				
			||||||
import { defineComponent } from 'vue';
 | 
					import { defineComponent } from 'vue';
 | 
				
			||||||
import { Textbox } from './components';
 | 
					import { Textbox } from '../../module/ui/components';
 | 
				
			||||||
import { ILayerGroupRenderExtends, ILayerRenderExtends } from './preset';
 | 
					import { ILayerGroupRenderExtends, ILayerRenderExtends } from './preset';
 | 
				
			||||||
import { Props } from './utils';
 | 
					import { Props } from './utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -53,7 +53,7 @@ Mota.require('var', 'loading').once('coreInit', () => {
 | 
				
			|||||||
            fontFamily: 'normal',
 | 
					            fontFamily: 'normal',
 | 
				
			||||||
            titleFont: '700 20px normal',
 | 
					            titleFont: '700 20px normal',
 | 
				
			||||||
            winskin: 'winskin2.png',
 | 
					            winskin: 'winskin2.png',
 | 
				
			||||||
            interval: 25,
 | 
					            interval: 100,
 | 
				
			||||||
            lineHeight: 6
 | 
					            lineHeight: 6
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -101,4 +101,4 @@ export * from './shader';
 | 
				
			|||||||
export * from './sprite';
 | 
					export * from './sprite';
 | 
				
			||||||
export * from './transform';
 | 
					export * from './transform';
 | 
				
			||||||
export * from './utils';
 | 
					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;
 | 
					    readonly isRoot: boolean = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    protected needUpdate: boolean = false;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    /** 该元素的变换矩阵 */
 | 
					    /** 该元素的变换矩阵 */
 | 
				
			||||||
    transform: Transform = new Transform();
 | 
					    transform: Transform = new Transform();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -250,7 +248,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
 | 
				
			|||||||
    /** 渲染缓存信息 */
 | 
					    /** 渲染缓存信息 */
 | 
				
			||||||
    protected cache: MotaOffscreenCanvas2D = new MotaOffscreenCanvas2D();
 | 
					    protected cache: MotaOffscreenCanvas2D = new MotaOffscreenCanvas2D();
 | 
				
			||||||
    /** 是否需要更新缓存 */
 | 
					    /** 是否需要更新缓存 */
 | 
				
			||||||
    protected cacheDirty: boolean = true;
 | 
					    protected cacheDirty: boolean = false;
 | 
				
			||||||
    /** 是否启用缓存机制 */
 | 
					    /** 是否启用缓存机制 */
 | 
				
			||||||
    readonly enableCache: boolean = true;
 | 
					    readonly enableCache: boolean = true;
 | 
				
			||||||
    /** 是否启用transform下穿机制,即画布的变换是否会继续作用到下一层画布 */
 | 
					    /** 是否启用transform下穿机制,即画布的变换是否会继续作用到下一层画布 */
 | 
				
			||||||
@ -303,7 +301,6 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
 | 
				
			|||||||
        this.width = width;
 | 
					        this.width = width;
 | 
				
			||||||
        this.height = height;
 | 
					        this.height = height;
 | 
				
			||||||
        this.cache.size(width, height);
 | 
					        this.cache.size(width, height);
 | 
				
			||||||
        this.cacheDirty = true;
 | 
					 | 
				
			||||||
        this.update(this);
 | 
					        this.update(this);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -315,7 +312,6 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
 | 
				
			|||||||
    renderContent(canvas: MotaOffscreenCanvas2D, transform: Transform) {
 | 
					    renderContent(canvas: MotaOffscreenCanvas2D, transform: Transform) {
 | 
				
			||||||
        if (this.hidden) return;
 | 
					        if (this.hidden) return;
 | 
				
			||||||
        this.emit('beforeRender', transform);
 | 
					        this.emit('beforeRender', transform);
 | 
				
			||||||
        this.needUpdate = false;
 | 
					 | 
				
			||||||
        const tran = this.transformFallThrough ? transform : this.transform;
 | 
					        const tran = this.transformFallThrough ? transform : this.transform;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const ax = -this.anchorX * this.width;
 | 
					        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);
 | 
					            canvas.ctx.drawImage(this.cache.canvas, ax, ay, width, height);
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
 | 
					            this.cacheDirty = false;
 | 
				
			||||||
            canvas.ctx.translate(ax, ay);
 | 
					            canvas.ctx.translate(ax, ay);
 | 
				
			||||||
            this.render(canvas, tran);
 | 
					            this.render(canvas, tran);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@ -403,8 +400,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    update(item: RenderItem<any> = this): void {
 | 
					    update(item: RenderItem<any> = this): void {
 | 
				
			||||||
        if (this.needUpdate) return;
 | 
					        if (this.cacheDirty) return;
 | 
				
			||||||
        this.needUpdate = true;
 | 
					 | 
				
			||||||
        this.cacheDirty = true;
 | 
					        this.cacheDirty = true;
 | 
				
			||||||
        if (this.hidden) return;
 | 
					        if (this.hidden) return;
 | 
				
			||||||
        this.parent?.update(item);
 | 
					        this.parent?.update(item);
 | 
				
			||||||
@ -513,7 +509,6 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
 | 
				
			|||||||
        parent.children.add(this);
 | 
					        parent.children.add(this);
 | 
				
			||||||
        this._parent = parent;
 | 
					        this._parent = parent;
 | 
				
			||||||
        parent.requestSort();
 | 
					        parent.requestSort();
 | 
				
			||||||
        this.needUpdate = false;
 | 
					 | 
				
			||||||
        this.update();
 | 
					        this.update();
 | 
				
			||||||
        if (this._id !== '') {
 | 
					        if (this._id !== '') {
 | 
				
			||||||
            const root = this.findRoot();
 | 
					            const root = this.findRoot();
 | 
				
			||||||
 | 
				
			|||||||
@ -29,19 +29,23 @@ export class MotaRenderer extends Container {
 | 
				
			|||||||
        this.transform.translate(240, 240);
 | 
					        this.transform.translate(240, 240);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        MotaRenderer.list.set(id, this);
 | 
					        MotaRenderer.list.set(id, this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const update = () => {
 | 
				
			||||||
 | 
					            this.requestRenderFrame(() => {
 | 
				
			||||||
 | 
					                this.refresh();
 | 
				
			||||||
 | 
					                update();
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        update();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    update(_item: RenderItem = this) {
 | 
					    update(_item: RenderItem = this) {
 | 
				
			||||||
        if (this.needUpdate || this.hidden) return;
 | 
					        this.cacheDirty = true;
 | 
				
			||||||
        this.needUpdate = true;
 | 
					 | 
				
			||||||
        this.requestRenderFrame(() => {
 | 
					 | 
				
			||||||
            this.refresh();
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    protected refresh(): void {
 | 
					    protected refresh(): void {
 | 
				
			||||||
        if (!this.needUpdate) return;
 | 
					        if (!this.cacheDirty) return;
 | 
				
			||||||
        this.needUpdate = false;
 | 
					 | 
				
			||||||
        this.target.clear();
 | 
					        this.target.clear();
 | 
				
			||||||
        this.renderContent(this.target, Transform.identity);
 | 
					        this.renderContent(this.target, Transform.identity);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -17,7 +17,6 @@ import {
 | 
				
			|||||||
    CustomProps,
 | 
					    CustomProps,
 | 
				
			||||||
    DamageProps,
 | 
					    DamageProps,
 | 
				
			||||||
    EllipseProps,
 | 
					    EllipseProps,
 | 
				
			||||||
    GL2Props,
 | 
					 | 
				
			||||||
    IconProps,
 | 
					    IconProps,
 | 
				
			||||||
    ImageProps,
 | 
					    ImageProps,
 | 
				
			||||||
    LayerGroupProps,
 | 
					    LayerGroupProps,
 | 
				
			||||||
@ -33,9 +32,8 @@ import {
 | 
				
			|||||||
    WinskinProps
 | 
					    WinskinProps
 | 
				
			||||||
} from './props';
 | 
					} from './props';
 | 
				
			||||||
import { ERenderItemEvent, RenderItem } from '../item';
 | 
					import { ERenderItemEvent, RenderItem } from '../item';
 | 
				
			||||||
import { ESpriteEvent, Sprite } from '../sprite';
 | 
					import { ESpriteEvent } from '../sprite';
 | 
				
			||||||
import { EContainerEvent } from '../container';
 | 
					import { EContainerEvent } from '../container';
 | 
				
			||||||
import { EGL2Event } from '../gl2';
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    EIconEvent,
 | 
					    EIconEvent,
 | 
				
			||||||
    EImageEvent,
 | 
					    EImageEvent,
 | 
				
			||||||
 | 
				
			|||||||
@ -7,7 +7,6 @@ import {
 | 
				
			|||||||
} from '../preset/layer';
 | 
					} from '../preset/layer';
 | 
				
			||||||
import type { EnemyCollection } from '@/game/enemy/damage';
 | 
					import type { EnemyCollection } from '@/game/enemy/damage';
 | 
				
			||||||
import { ILineProperty } from '../preset/graphics';
 | 
					import { ILineProperty } from '../preset/graphics';
 | 
				
			||||||
import { SizedCanvasImageSource } from '../preset';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface CustomProps {
 | 
					export interface CustomProps {
 | 
				
			||||||
    _item: (props: BaseProps) => RenderItem;
 | 
					    _item: (props: BaseProps) => RenderItem;
 | 
				
			||||||
 | 
				
			|||||||
@ -85,6 +85,8 @@
 | 
				
			|||||||
        "51": "Cannot decode sound '$1', since audio file may not supported by 2.b.",
 | 
					        "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.",
 | 
					        "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.",
 | 
					        "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.",
 | 
					        "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."
 | 
					        "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 './weather';
 | 
				
			||||||
export * from './audio';
 | 
					export * from './audio';
 | 
				
			||||||
export * from './loader';
 | 
					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