feat: Scroll 组件

This commit is contained in:
unanmed 2025-02-21 22:03:53 +08:00
parent ac5eefdb02
commit 6238c8f00a
7 changed files with 424 additions and 30 deletions

View File

@ -2,7 +2,7 @@ import { isNil } from 'lodash-es';
import { EventEmitter } from 'eventemitter3';
import { MotaOffscreenCanvas2D } from '../fx/canvas2d';
import { Ticker, TickerFn } from 'mutate-animate';
import { Transform } from './transform';
import { ITransformUpdatable, Transform } from './transform';
import { logger } from '../common/logger';
import { ElementNamespace, ComponentInternalInstance } from 'vue';
import { transformCanvas } from './utils';
@ -189,6 +189,7 @@ export interface ERenderItemEvent extends ERenderItemActionEvent {
beforeRender: [transform: Transform];
afterRender: [transform: Transform];
destroy: [];
transform: [item: RenderItem, transform: Transform];
}
interface TickerDelegation {
@ -211,7 +212,8 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
IRenderFrame,
IRenderTickerSupport,
IRenderChildable,
IRenderVueSupport
IRenderVueSupport,
ITransformUpdatable
{
/** 渲染的全局ticker */
static ticker: Ticker = new Ticker();
@ -519,6 +521,8 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
//#endregion
//#region 功能方法
/**
* 使
*/
@ -535,6 +539,28 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
}
}
/**
*
*/
getBoundingRect(): DOMRectReadOnly {
if (this.type === 'absolute') {
return new DOMRectReadOnly(0, 0, this.width, this.height);
}
const tran = this.transformFallThrough
? this.fallTransform
: this._transform;
if (!tran) return new DOMRectReadOnly(0, 0, this.width, this.height);
const [x1, y1] = tran.transformed(0, 0);
const [x2, y2] = tran.transformed(this.width, 0);
const [x3, y3] = tran.transformed(0, this.height);
const [x4, y4] = tran.transformed(this.width, this.height);
const left = Math.min(x1, x2, x3, x4);
const right = Math.max(x1, x2, x3, x4);
const top = Math.min(y1, y2, y3, y4);
const bottom = Math.max(y1, y2, y3, y4);
return new DOMRectReadOnly(left, top, right - left, bottom - top);
}
update(item: RenderItem<any> = this): void {
if (this.cacheDirty) return;
this.cacheDirty = true;
@ -542,6 +568,13 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
this.parent?.update(item);
}
updateTransform() {
this.update();
this.emit('transform', this, this._transform);
}
//#endregion
//#region 动画帧与 ticker
requestBeforeFrame(fn: () => void): void {
@ -639,6 +672,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
this.checkRoot();
this._root?.connect(this);
this.canvases.forEach(v => v.activate());
this._transform.bind(this);
}
/**
@ -653,6 +687,7 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
parent.requestSort();
parent.update();
this.canvases.forEach(v => v.deactivate());
this._transform.bind();
if (!success) return false;
this._root?.disconnect(this);
this._root = void 0;
@ -1115,6 +1150,8 @@ export abstract class RenderItem<E extends ERenderItemEvent = ERenderItemEvent>
this.emit('destroy');
this.removeAllListeners();
this.cache.delete();
this.canvases.forEach(v => v.delete());
this.canvases.clear();
}
}

View File

@ -31,7 +31,6 @@ export const { createApp, render } = createRenderer<RenderItem, RenderItem>({
remove: function (el: RenderItem<ERenderItemEvent>): void {
el.remove();
el.destroy();
},
createElement: function (

View File

@ -1,6 +1,8 @@
import { gameKey, Hotkey } from '@/core/main/custom/hotkey';
import { Animation, Ticker, Transition } from 'mutate-animate';
import { onMounted, onUnmounted } from 'vue';
import { ERenderItemEvent, RenderItem } from '../item';
import EventEmitter from 'eventemitter3';
const ticker = new Ticker();
@ -60,3 +62,13 @@ export function useKey(noScope: boolean = false): KeyUsing {
return [gameKey, sym];
}
}
export function onEvent<
T extends ERenderItemEvent,
K extends EventEmitter.EventNames<T>
>(item: RenderItem<T>, key: K, listener: EventEmitter.EventListener<T, K>) {
item.on(key, listener);
onUnmounted(() => {
item.off(key, listener);
});
}

View File

@ -1,7 +1,7 @@
import { mat3, ReadonlyMat3, ReadonlyVec3, vec2, vec3 } from 'gl-matrix';
export interface ITransformUpdatable {
update(): void;
updateTransform?(): void;
}
export class Transform {
@ -48,7 +48,7 @@ export class Transform {
this.scaleX *= x;
this.scaleY *= y;
this.modified = true;
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**
@ -59,7 +59,7 @@ export class Transform {
this.x += x;
this.y += y;
this.modified = true;
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**
@ -73,7 +73,7 @@ export class Transform {
this.rad -= n * Math.PI * 2;
}
this.modified = true;
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**
@ -84,7 +84,7 @@ export class Transform {
this.scaleX = x;
this.scaleY = y;
this.modified = true;
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**
@ -95,7 +95,7 @@ export class Transform {
this.x = x;
this.y = y;
this.modified = true;
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**
@ -105,7 +105,7 @@ export class Transform {
mat3.rotate(this.mat, this.mat, rad - this.rad);
this.rad = rad;
this.modified = true;
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**
@ -131,7 +131,7 @@ export class Transform {
mat3.fromValues(a, b, 0, c, d, 0, e, f, 1)
);
this.calAttributes();
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**
@ -153,7 +153,7 @@ export class Transform {
) {
mat3.set(this.mat, a, b, 0, c, d, 0, e, f, 1);
this.calAttributes();
this.bindedObject?.update();
this.bindedObject?.updateTransform?.();
}
/**

View File

@ -1,6 +1,7 @@
import { ElementLocator, Sprite } from '@/core/render';
import { defineComponent, onMounted, ref, watch } from 'vue';
import { defineComponent, ref, watch } from 'vue';
import { SetupComponentOptions } from './types';
import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d';
interface ProgressProps {
/** 进度条的位置 */
@ -20,23 +21,21 @@ const progressProps = {
export const Progress = defineComponent<ProgressProps>(props => {
const element = ref<Sprite>();
onMounted(() => {
element.value?.setRenderFn(canvas => {
const { ctx } = canvas;
const width = props.loc[2] ?? 200;
const height = props.loc[3] ?? 200;
ctx.fillStyle = props.background ?? 'gray';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = props.success ?? 'green';
ctx.fillRect(0, 0, width * props.progress, height);
});
});
const render = (canvas: MotaOffscreenCanvas2D) => {
const { ctx } = canvas;
const width = props.loc[2] ?? 200;
const height = props.loc[3] ?? 200;
ctx.fillStyle = props.background ?? 'gray';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = props.success ?? 'green';
ctx.fillRect(0, 0, width * props.progress, height);
};
watch(props, () => {
element.value?.update();
});
return () => {
return <sprite ref={element} loc={props.loc}></sprite>;
return <sprite ref={element} loc={props.loc} render={render}></sprite>;
};
}, progressProps);

View File

@ -0,0 +1,351 @@
import {
computed,
defineComponent,
onMounted,
onUnmounted,
onUpdated,
reactive,
ref,
SlotsType,
VNode,
watch
} from 'vue';
import { SetupComponentOptions } from './types';
import {
Container,
ContainerProps,
ElementLocator,
RenderItem,
Sprite,
SpriteProps
} from '@/core/render';
import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d';
import { hyper, Transition } from 'mutate-animate';
import { clamp } from 'lodash-es';
import { IActionEvent, IWheelEvent, MouseType } from '@/core/render/event';
export const enum ScrollDirection {
Horizontal,
Vertical
}
interface ScrollProps {
direction: ScrollDirection;
loc: ElementLocator;
noscroll?: boolean;
/**
*
*
*/
padHeight?: number;
}
type ScrollSlots = SlotsType<{
default: () => VNode | VNode[];
}>;
const scrollProps = {
props: ['direction', 'noscroll']
} satisfies SetupComponentOptions<ScrollProps, {}, string, ScrollSlots>;
/** 滚动条图示的最短长度 */
const SCROLL_MIN_LENGTH = 20;
/** 滚动条图示的宽度 */
const SCROLL_WIDTH = 10;
export const Scroll = defineComponent<ScrollProps, {}, string, ScrollSlots>(
(props, { slots }) => {
const scrollProps: SpriteProps = reactive({
loc: [0, 0, 0, 0]
});
const contentProps: ContainerProps = reactive({
loc: [0, 0, 0, 0]
});
const listenedChild: Set<RenderItem> = new Set();
const areaMap: Map<RenderItem, [number, number]> = new Map();
const content = ref<Container>();
const scroll = ref<Sprite>();
const width = computed(() => props.loc[2] ?? 200);
const height = computed(() => props.loc[3] ?? 200);
let showScroll = 0;
let nowScroll = 0;
let maxLength = 0;
let scrollLength = SCROLL_MIN_LENGTH;
const transition = new Transition();
transition.value.scroll = 0;
transition.mode(hyper('sin', 'out')).absolute();
transition.ticker.add(() => {
if (transition.value.scroll !== nowScroll) {
showScroll = transition.value.scroll;
scroll.value?.update();
content.value?.update();
}
});
watch(
() => props.loc,
value => {
const width = value[2] ?? 200;
const height = value[3] ?? 200;
if (props.direction === ScrollDirection.Horizontal) {
props.loc = [0, height - SCROLL_WIDTH, width, SCROLL_WIDTH];
} else {
props.loc = [width - SCROLL_WIDTH, 0, SCROLL_WIDTH, height];
}
}
);
/**
*
* @param time
*/
const scrollTo = (y: number, time: number = 1) => {
const target = clamp(y, 0, maxLength);
transition.time(time).transition('scroll', target);
nowScroll = y;
};
/**
*
*/
const getArea = (item: RenderItem, rect: DOMRectReadOnly) => {
if (props.direction === ScrollDirection.Horizontal) {
areaMap.set(item, [rect.left - width.value, rect.right]);
} else {
areaMap.set(item, [rect.top - height.value, rect.bottom]);
}
};
/**
*
*/
const checkItem = (item: RenderItem) => {
const area = areaMap.get(item);
if (!area) {
item.show();
return;
}
const [min, max] = area;
if (nowScroll > min - 10 && nowScroll < max + 10) {
item.show();
} else {
item.hide();
}
};
/**
*
*/
const onTransform = (item: RenderItem) => {
const rect = item.getBoundingRect();
getArea(item, rect);
checkItem(item);
};
const updateScroll = () => {
if (!content.value) return;
let max = 0;
listenedChild.forEach(v => v.off('transform', onTransform));
listenedChild.clear();
areaMap.clear();
content.value.children.forEach(v => {
const rect = v.getBoundingRect();
if (props.direction === ScrollDirection.Horizontal) {
if (rect.right > max) {
max = rect.right;
}
} else {
if (rect.bottom > max) {
max = rect.bottom;
}
}
v.on('transform', onTransform);
listenedChild.add(v);
});
maxLength = max + (props.padHeight ?? 0);
if (props.direction === ScrollDirection.Horizontal) {
scrollLength = Math.max(
SCROLL_MIN_LENGTH,
(width.value / max) * width.value
);
const h = props.noscroll
? height.value
: height.value - SCROLL_WIDTH;
contentProps.loc = [-showScroll, 0, width.value, h];
} else {
scrollLength = clamp(
(height.value / max) * height.value,
SCROLL_MIN_LENGTH,
height.value - 10
);
const w = props.noscroll
? width.value
: width.value - SCROLL_WIDTH;
contentProps.loc = [0, -showScroll, w, height.value];
}
scroll.value?.update();
};
onUpdated(updateScroll);
onMounted(updateScroll);
onUnmounted(() => {
listenedChild.forEach(v => v.off('transform', onTransform));
});
const drawScroll = (canvas: MotaOffscreenCanvas2D) => {
if (props.noscroll) return;
const ctx = canvas.ctx;
ctx.lineCap = 'round';
ctx.lineWidth = 6;
ctx.strokeStyle = '#fff';
ctx.beginPath();
if (props.direction === ScrollDirection.Horizontal) {
ctx.moveTo(nowScroll + 5, 5);
ctx.lineTo(nowScroll + scrollLength + 5, 5);
} else {
ctx.moveTo(5, nowScroll + 5);
ctx.lineTo(5, nowScroll + scrollLength + 5);
}
ctx.stroke();
};
const wheel = (ev: IWheelEvent) => {
if (props.direction === ScrollDirection.Horizontal) {
if (ev.wheelX !== 0) {
scrollTo(nowScroll + ev.wheelX, 300);
} else if (ev.wheelY !== 0) {
scrollTo(nowScroll + ev.wheelY, 300);
}
} else {
scrollTo(nowScroll + ev.wheelY, 300);
}
};
const getPos = (ev: IActionEvent) => {
if (props.direction === ScrollDirection.Horizontal) {
return ev.offsetX;
} else {
return ev.offsetY;
}
};
let identifier: number = -1;
let lastPos: number = 0;
const down = (ev: IActionEvent) => {
identifier = ev.identifier;
lastPos = getPos(ev);
};
const move = (ev: IActionEvent) => {
if (ev.identifier !== identifier) return;
let pos = 0;
if (ev.touch) {
pos = getPos(ev);
} else {
if (ev.buttons & MouseType.Left) {
pos = getPos(ev);
}
}
const movement = pos - lastPos;
scrollTo(nowScroll + movement, 1);
lastPos = pos;
};
let scrollBefore = 0;
let scrollIdentifier = -1;
let scrollDownPos = 0;
let scrollMutate = false;
let scrollPin = 0;
/**
*
*/
const getScrollPin = (ev: IActionEvent) => {
if (props.direction === ScrollDirection.Horizontal) {
return ev.absoluteY;
} else {
return ev.absoluteX;
}
};
const downScroll = (ev: IActionEvent) => {
scrollBefore = nowScroll;
scrollIdentifier = ev.identifier;
const pos = getPos(ev);
// 计算点击在了滚动条的哪个位置
const sEnd = nowScroll + scrollLength;
if (pos >= nowScroll && pos <= sEnd) {
scrollDownPos = pos - nowScroll;
scrollMutate = false;
scrollPin = getScrollPin(ev);
} else {
scrollMutate = true;
}
};
const moveScroll = (ev: IActionEvent) => {
if (ev.identifier !== scrollIdentifier) return;
const pos = getPos(ev);
const scrollPos = pos - scrollDownPos;
let deltaPin = 0;
let threshold = 0;
if (ev.touch) {
const pin = getScrollPin(ev);
deltaPin = Math.abs(pin - scrollPin);
threshold = 200;
} else {
const pin = getScrollPin(ev);
deltaPin = Math.abs(pin - scrollPin);
threshold = 100;
}
if (deltaPin > threshold) {
scrollTo(scrollBefore, 1);
} else {
scrollTo(scrollPos, 1);
}
};
const upScroll = (ev: IActionEvent) => {
if (!scrollMutate) return;
const pos = getPos(ev);
if (pos < nowScroll) {
scrollTo(pos - 50);
} else {
scrollTo(pos + 50);
}
};
onMounted(() => {
scroll.value?.root?.on('move', move);
scroll.value?.root?.on('move', moveScroll);
});
onUnmounted(() => {
scroll.value?.root?.off('move', move);
scroll.value?.root?.off('move', moveScroll);
});
return () => {
return (
<container loc={props.loc} onWheel={wheel}>
<container {...contentProps} ref={content} onDown={down}>
{slots.default()}
</container>
<sprite
{...scrollProps}
ref={scroll}
render={drawScroll}
onDown={downScroll}
onUp={upScroll}
></sprite>
</container>
);
};
},
scrollProps
);

View File

@ -122,11 +122,7 @@ export const LeftStatusBar = defineComponent<StatusBarProps<ILeftHeroStatus>>(
<image image={mdefIcon} loc={iconLoc(3)}></image>
<text text={f(s.mdef)} loc={textLoc(3)} font={font1}></text>
<image image={moneyIcon} loc={iconLoc(4)}></image>
<text
text={f(s.money)}
loc={textLoc(4)}
font={font1}
></text>
<text text={f(s.money)} loc={textLoc(4)} font={font1} />
<image image={expIcon} loc={iconLoc(5)}></image>
<text text={f(s.exp)} loc={textLoc(5)} font={font1}></text>
<text