mirror of
https://github.com/unanmed/HumanBreak.git
synced 2025-04-11 15:47:06 +08:00
feat: 滚动文字
This commit is contained in:
parent
c505efeb66
commit
24ee1613ed
@ -64,22 +64,24 @@ export class MotaOffscreenCanvas2D extends EventEmitter<OffscreenCanvasEvent> {
|
||||
logger.warn(33);
|
||||
return;
|
||||
}
|
||||
const w = Math.max(width, 1);
|
||||
const h = Math.max(height, 1);
|
||||
let ratio = this.highResolution ? devicePixelRatio : 1;
|
||||
const scale = core.domStyle.scale;
|
||||
if (this.autoScale) {
|
||||
ratio *= scale;
|
||||
}
|
||||
this.scale = ratio;
|
||||
this.canvas.width = width * ratio;
|
||||
this.canvas.height = height * ratio;
|
||||
this.width = width;
|
||||
this.canvas.width = w * ratio;
|
||||
this.canvas.height = h * ratio;
|
||||
this.width = w;
|
||||
this.height = height;
|
||||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.ctx.scale(ratio, ratio);
|
||||
this.ctx.imageSmoothingEnabled = this.antiAliasing;
|
||||
if (this.canvas.isConnected) {
|
||||
this.canvas.style.width = `${width * scale}px`;
|
||||
this.canvas.style.height = `${height * scale}px`;
|
||||
this.canvas.style.width = `${w * scale}px`;
|
||||
this.canvas.style.height = `${h * scale}px`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,15 @@
|
||||
import { DefaultProps, ElementLocator, PathProps, Sprite } from '@/core/render';
|
||||
import {
|
||||
DefaultProps,
|
||||
ElementLocator,
|
||||
onTick,
|
||||
PathProps,
|
||||
Sprite
|
||||
} from '@/core/render';
|
||||
import { computed, defineComponent, ref, watch } from 'vue';
|
||||
import { SetupComponentOptions } from './types';
|
||||
import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d';
|
||||
import { TextboxProps, TextContent } from './textbox';
|
||||
import { Scroll, ScrollExpose, ScrollProps } from './scroll';
|
||||
|
||||
interface ProgressProps extends DefaultProps {
|
||||
/** 进度条的位置 */
|
||||
@ -107,3 +115,121 @@ export const Arrow = defineComponent<ArrowProps>(props => {
|
||||
/>
|
||||
);
|
||||
}, arrowProps);
|
||||
|
||||
export interface ScrollTextProps extends TextboxProps, ScrollProps {
|
||||
/** 自动滚动的速度,每秒多少像素 */
|
||||
speed: number;
|
||||
/** 文字的最大宽度 */
|
||||
width: number;
|
||||
/** 自动滚动组件的定位 */
|
||||
loc: ElementLocator;
|
||||
/** 文字滚动入元素之前要先滚动多少像素,默认16像素 */
|
||||
pad?: number;
|
||||
}
|
||||
|
||||
export type ScrollTextEmits = {
|
||||
/**
|
||||
* 当滚动完毕时触发
|
||||
*/
|
||||
scrollEnd: () => void;
|
||||
};
|
||||
|
||||
export interface ScrollTextExpose {
|
||||
/**
|
||||
* 暂停滚动
|
||||
*/
|
||||
pause(): void;
|
||||
|
||||
/**
|
||||
* 继续滚动
|
||||
*/
|
||||
resume(): void;
|
||||
|
||||
/**
|
||||
* 设置滚动速度
|
||||
*/
|
||||
setSpeed(speed: number): void;
|
||||
|
||||
/**
|
||||
* 立刻重新滚动
|
||||
*/
|
||||
rescroll(): void;
|
||||
}
|
||||
|
||||
const scrollProps = {
|
||||
props: ['speed', 'loc', 'pad', 'width'],
|
||||
emits: ['scrollEnd']
|
||||
} satisfies SetupComponentOptions<
|
||||
ScrollTextProps,
|
||||
ScrollTextEmits,
|
||||
keyof ScrollTextEmits
|
||||
>;
|
||||
|
||||
export const ScrollText = defineComponent<
|
||||
ScrollTextProps,
|
||||
ScrollTextEmits,
|
||||
keyof ScrollTextEmits
|
||||
>((props, { emit, expose, attrs }) => {
|
||||
const scroll = ref<ScrollExpose>();
|
||||
const speed = ref(props.speed);
|
||||
|
||||
const eleHeight = computed(() => props.loc[3] ?? props.height ?? 200);
|
||||
const pad = computed(() => props.pad ?? 16);
|
||||
|
||||
let lastFixedTime = Date.now();
|
||||
let lastFixedPos = 0;
|
||||
let paused = false;
|
||||
let nowScroll = 0;
|
||||
|
||||
onTick(() => {
|
||||
if (paused || !scroll.value) return;
|
||||
const now = Date.now();
|
||||
const dt = now - lastFixedTime;
|
||||
nowScroll = (dt / 1000) * speed.value + lastFixedPos;
|
||||
scroll.value.scrollTo(nowScroll, 0);
|
||||
if (nowScroll >= scroll.value.getScrollLength()) {
|
||||
emit('scrollEnd');
|
||||
paused = true;
|
||||
}
|
||||
});
|
||||
|
||||
const pause = () => {
|
||||
paused = true;
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
paused = false;
|
||||
lastFixedPos = nowScroll;
|
||||
lastFixedTime = Date.now();
|
||||
};
|
||||
|
||||
const setSpeed = (value: number) => {
|
||||
lastFixedPos = nowScroll;
|
||||
lastFixedTime = Date.now();
|
||||
speed.value = value;
|
||||
};
|
||||
|
||||
const rescroll = () => {
|
||||
nowScroll = 0;
|
||||
lastFixedTime = Date.now();
|
||||
lastFixedPos = 0;
|
||||
};
|
||||
|
||||
expose<ScrollTextExpose>({ pause, resume, setSpeed, rescroll });
|
||||
|
||||
return () => (
|
||||
<Scroll
|
||||
ref={scroll}
|
||||
loc={props.loc}
|
||||
padEnd={eleHeight.value + pad.value}
|
||||
noscroll
|
||||
>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
width={props.width}
|
||||
loc={[8, eleHeight.value + pad.value]}
|
||||
autoHeight
|
||||
/>
|
||||
</Scroll>
|
||||
);
|
||||
}, scrollProps);
|
||||
|
@ -37,6 +37,11 @@ export interface ScrollExpose {
|
||||
* @param time 滚动的动画时长,默认为无动画
|
||||
*/
|
||||
scrollTo(y: number, time?: number): void;
|
||||
|
||||
/**
|
||||
* 获取这个滚动条组件最多可以滚动多长
|
||||
*/
|
||||
getScrollLength(): number;
|
||||
}
|
||||
|
||||
export interface ScrollProps extends DefaultProps {
|
||||
@ -47,7 +52,7 @@ export interface ScrollProps extends DefaultProps {
|
||||
* 滚动到最下方(最右方)时的填充大小,如果默认的高度计算方式有误,
|
||||
* 那么可以调整此参数来修复错误
|
||||
*/
|
||||
pad?: number;
|
||||
padEnd?: number;
|
||||
}
|
||||
|
||||
type ScrollSlots = SlotsType<{
|
||||
@ -55,7 +60,7 @@ type ScrollSlots = SlotsType<{
|
||||
}>;
|
||||
|
||||
const scrollProps = {
|
||||
props: ['hor', 'noscroll', 'loc', 'pad']
|
||||
props: ['hor', 'noscroll', 'loc', 'padEnd']
|
||||
} satisfies SetupComponentOptions<ScrollProps, {}, string, ScrollSlots>;
|
||||
|
||||
/** 滚动条图示的最短长度 */
|
||||
@ -112,6 +117,7 @@ export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>(
|
||||
const scrollColor = computed(
|
||||
() => `rgba(${SCROLL_COLOR},${scrollAlpha.ref.value ?? 0.5})`
|
||||
);
|
||||
const padEnd = computed(() => props.padEnd ?? 0);
|
||||
|
||||
watch(scrollColor, () => {
|
||||
scroll.value?.update();
|
||||
@ -145,7 +151,6 @@ export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>(
|
||||
if (contentPos !== contentTarget) {
|
||||
contentPos = transition.value.showScroll;
|
||||
checkAllItem();
|
||||
updatePosition();
|
||||
content.value?.update();
|
||||
}
|
||||
});
|
||||
@ -211,7 +216,7 @@ export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>(
|
||||
*/
|
||||
const onTransform = (item: RenderItem) => {
|
||||
const rect = item.getBoundingRect();
|
||||
const pad = props.pad ?? 0;
|
||||
const pad = props.padEnd ?? 0;
|
||||
if (item.parent === content.value) {
|
||||
if (direction.value === ScrollDirection.Horizontal) {
|
||||
if (rect.right > maxLength - pad) {
|
||||
@ -291,7 +296,7 @@ export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>(
|
||||
}
|
||||
checkItem(v);
|
||||
});
|
||||
maxLength = Math.max(max + (props.pad ?? 0), 10);
|
||||
maxLength = Math.max(max + padEnd.value, 10);
|
||||
updatePosition();
|
||||
scroll.value?.update();
|
||||
};
|
||||
@ -504,10 +509,19 @@ export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>(
|
||||
transition.ticker.destroy();
|
||||
});
|
||||
|
||||
//#region expose 函数
|
||||
|
||||
const getScrollLength = () => {
|
||||
return maxLength - height.value;
|
||||
};
|
||||
|
||||
expose<ScrollExpose>({
|
||||
scrollTo
|
||||
scrollTo,
|
||||
getScrollLength
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<container loc={props.loc} onWheel={wheel}>
|
||||
|
@ -26,6 +26,7 @@ import {
|
||||
WordBreak,
|
||||
TextAlign
|
||||
} from './textboxTyper';
|
||||
import { ElementLocator } from '@/core/render';
|
||||
|
||||
export interface TextContentProps
|
||||
extends DefaultProps,
|
||||
@ -36,6 +37,10 @@ export interface TextContentProps
|
||||
fill?: boolean;
|
||||
/** 是否描边 */
|
||||
stroke?: boolean;
|
||||
/** 是否自适应高度 */
|
||||
autoHeight?: boolean;
|
||||
/** 文字的最大宽度 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
export type TextContentEmits = {
|
||||
@ -53,6 +58,11 @@ export interface TextContentExpose {
|
||||
* 立刻显示所有文字
|
||||
*/
|
||||
showAll(): void;
|
||||
|
||||
/**
|
||||
* 获取这段 TextContent 的总高度
|
||||
*/
|
||||
getHeight(): number;
|
||||
}
|
||||
|
||||
const textContentOptions = {
|
||||
@ -76,7 +86,8 @@ const textContentOptions = {
|
||||
'strokeWidth',
|
||||
'stroke',
|
||||
'loc',
|
||||
'width'
|
||||
'width',
|
||||
'autoHeight'
|
||||
],
|
||||
emits: ['typeEnd', 'typeStart']
|
||||
} satisfies SetupComponentOptions<
|
||||
@ -90,8 +101,11 @@ export const TextContent = defineComponent<
|
||||
TextContentEmits,
|
||||
keyof TextContentEmits
|
||||
>((props, { emit, expose }) => {
|
||||
const width = computed(() => props.width ?? props.loc?.[2] ?? 200);
|
||||
if (width.value < 0) {
|
||||
const loc = ref<ElementLocator>(
|
||||
(props.loc?.slice() as ElementLocator) ?? []
|
||||
);
|
||||
|
||||
if (props.width < 0) {
|
||||
logger.warn(41, String(props.width));
|
||||
}
|
||||
|
||||
@ -112,6 +126,7 @@ export const TextContent = defineComponent<
|
||||
typer.setText(props.text ?? '');
|
||||
typer.type();
|
||||
needUpdate = false;
|
||||
updateLoc();
|
||||
});
|
||||
};
|
||||
|
||||
@ -119,12 +134,23 @@ export const TextContent = defineComponent<
|
||||
typer.typeAll();
|
||||
};
|
||||
|
||||
watch(props, value => {
|
||||
typer.setConfig(value);
|
||||
watch(props, () => {
|
||||
typer.setConfig(props);
|
||||
retype();
|
||||
});
|
||||
|
||||
expose({ retype, showAll });
|
||||
const getHeight = () => {
|
||||
return typer.getHeight();
|
||||
};
|
||||
|
||||
const updateLoc = () => {
|
||||
if (props.autoHeight) {
|
||||
const [x = 0, y = 0, width = 200, , ax = 0, ay = 0] = loc.value;
|
||||
loc.value = [x, y, width, getHeight(), ax, ay];
|
||||
}
|
||||
};
|
||||
|
||||
expose<TextContentExpose>({ retype, showAll, getHeight });
|
||||
|
||||
const spriteElement = shallowRef<Sprite>();
|
||||
const renderContent = (canvas: MotaOffscreenCanvas2D) => {
|
||||
@ -175,7 +201,7 @@ export const TextContent = defineComponent<
|
||||
return () => {
|
||||
return (
|
||||
<sprite
|
||||
loc={props.loc}
|
||||
loc={loc.value}
|
||||
ref={spriteElement}
|
||||
render={renderContent}
|
||||
></sprite>
|
||||
@ -200,6 +226,8 @@ export interface TextboxProps extends TextContentProps, DefaultProps {
|
||||
titleStroke?: CanvasStyle;
|
||||
/** 标题文字与边框间的距离,默认为4 */
|
||||
titlePadding?: number;
|
||||
/** 最大宽度 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface TextboxExpose {
|
||||
@ -257,8 +285,8 @@ export const Textbox = defineComponent<
|
||||
keyof TextboxEmits,
|
||||
TextboxSlots
|
||||
>((props, { slots, expose }) => {
|
||||
const contentData = shallowReactive<TextContentProps>({});
|
||||
const data = shallowReactive<TextboxProps>({});
|
||||
const contentData = shallowReactive<TextContentProps>({ width: 200 });
|
||||
const data = shallowReactive<TextboxProps>({ width: 200 });
|
||||
|
||||
const setContentData = () => {
|
||||
contentData.breakChars = props.breakChars ?? '';
|
||||
|
@ -124,6 +124,8 @@ export interface TyperTextRenderable {
|
||||
strokeStyle: CanvasStyle;
|
||||
/** 文字画到哪个索引 */
|
||||
pointer: number;
|
||||
/** 这段文字的总高度 */
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TyperIconRenderable {
|
||||
@ -231,6 +233,15 @@ export class TextContentTyper extends EventEmitter<TextContentTyperEvent> {
|
||||
onTick(() => this.tick());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取这段文字的总高度
|
||||
*/
|
||||
getHeight() {
|
||||
const heights = this.renderObject.lineHeights;
|
||||
const lines = heights.reduce((prev, curr) => prev + curr, 0);
|
||||
return lines + this.config.lineHeight * heights.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置打字机的配置属性
|
||||
* @param config 配置信息
|
||||
@ -285,19 +296,21 @@ export class TextContentTyper extends EventEmitter<TextContentTyperEvent> {
|
||||
if (line < 0 || line > renderable.splitLines.length) {
|
||||
return false;
|
||||
}
|
||||
const start = renderable.splitLines[line - 1] ?? -1;
|
||||
const start = renderable.splitLines[line - 1] ?? 0;
|
||||
const end =
|
||||
renderable.splitLines[line] ?? renderable.text.length - 1;
|
||||
renderable.splitLines[line] ?? renderable.text.length;
|
||||
const lineHeight = this.renderObject.lineHeights[this.nowLine];
|
||||
|
||||
const data: TyperTextRenderable = {
|
||||
type: TextContentType.Text,
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
text: renderable.text.slice(start + 1, end + 1),
|
||||
text: renderable.text.slice(start, end),
|
||||
font: renderable.font,
|
||||
fillStyle: renderable.fillStyle,
|
||||
strokeStyle: this.config.strokeStyle,
|
||||
pointer: 0
|
||||
pointer: 0,
|
||||
height: lineHeight + this.config.lineHeight
|
||||
};
|
||||
this.processingData = data;
|
||||
this.renderData.push(data);
|
||||
@ -926,7 +939,7 @@ export class TextContentParser {
|
||||
const rest = width - this.lineWidth;
|
||||
const guessRest = guess * (rest / width) * this.guessGain;
|
||||
const length = pointer - this.lineStart + 1;
|
||||
if (length < guessRest) {
|
||||
if (length <= guessRest) {
|
||||
return false;
|
||||
}
|
||||
this.guessGain = 1;
|
||||
@ -941,7 +954,7 @@ export class TextContentParser {
|
||||
if (height > this.lineHeight) {
|
||||
this.lineHeight = height;
|
||||
}
|
||||
if (metrics.width < rest) {
|
||||
if (metrics.width <= rest) {
|
||||
// 实际宽度小于剩余宽度时,将猜测增益乘以剩余总宽度与当前宽度的比值的若干倍
|
||||
this.guessGain *= (rest / metrics.width) * (1.1 + 1 / length);
|
||||
this.bsStart = breakIndex;
|
||||
@ -956,7 +969,7 @@ export class TextContentParser {
|
||||
this.lineHeights.push(this.lineHeight);
|
||||
this.bsStart = index;
|
||||
const text = data.text.slice(
|
||||
this.wordBreak[index],
|
||||
this.wordBreak[index] + 1,
|
||||
pointer + 1
|
||||
);
|
||||
if (text.length < guessRest / 4) {
|
||||
@ -995,7 +1008,7 @@ export class TextContentParser {
|
||||
return start;
|
||||
}
|
||||
const text = data.text.slice(
|
||||
wordBreak[this.bsStart],
|
||||
wordBreak[this.bsStart] + 1,
|
||||
wordBreak[mid] + 1
|
||||
);
|
||||
const metrics = ctx.measureText(text);
|
||||
@ -1028,7 +1041,7 @@ export class TextContentParser {
|
||||
const wordBreak = data.wordBreak;
|
||||
const lastLine = data.splitLines.at(-1);
|
||||
const lastIndex = isNil(lastLine) ? 0 : lastLine;
|
||||
const restText = data.text.slice(lastIndex);
|
||||
const restText = data.text.slice(lastIndex + 1);
|
||||
const ctx = this.testCanvas.ctx;
|
||||
ctx.font = data.font;
|
||||
const metrics = ctx.measureText(restText);
|
||||
@ -1052,7 +1065,7 @@ export class TextContentParser {
|
||||
data.splitLines.push(this.wordBreak[index]);
|
||||
this.lineHeights.push(this.lineHeight);
|
||||
this.bsStart = index;
|
||||
const text = data.text.slice(this.wordBreak[index]);
|
||||
const text = data.text.slice(this.wordBreak[index] + 1);
|
||||
if (!isLast && text.length < guess / 4) {
|
||||
// 如果剩余文字很少,几乎不可能会单独成一行时,直接结束循环
|
||||
this.lastBreakIndex = index;
|
||||
@ -1169,6 +1182,8 @@ export class TextContentParser {
|
||||
this.checkRestLine(width, guess, i);
|
||||
}
|
||||
|
||||
this.lineHeights.push(this.lineHeight);
|
||||
|
||||
return {
|
||||
lineHeights: this.lineHeights,
|
||||
data: this.renderable
|
||||
|
@ -66,7 +66,8 @@ const MainScene = defineComponent(() => {
|
||||
titleFont: '700 20px normal',
|
||||
winskin: 'winskin2.png',
|
||||
interval: 100,
|
||||
lineHeight: 4
|
||||
lineHeight: 4,
|
||||
width: 480
|
||||
};
|
||||
|
||||
const map = ref<LayerGroup>();
|
||||
|
Loading…
Reference in New Issue
Block a user