From 6238c8f00a27b1bc8cea589e304092b72d9a0114 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 21 Feb 2025 22:03:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Scroll=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/render/item.ts | 41 ++- src/core/render/renderer/index.ts | 1 - src/core/render/renderer/use.ts | 12 + src/core/render/transform.ts | 18 +- src/module/render/components/misc.tsx | 25 +- src/module/render/components/scroll.tsx | 351 ++++++++++++++++++++++++ src/module/render/ui/statusBar.tsx | 6 +- 7 files changed, 424 insertions(+), 30 deletions(-) create mode 100644 src/module/render/components/scroll.tsx diff --git a/src/core/render/item.ts b/src/core/render/item.ts index 8166d05..f9deaf0 100644 --- a/src/core/render/item.ts +++ b/src/core/render/item.ts @@ -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 IRenderFrame, IRenderTickerSupport, IRenderChildable, - IRenderVueSupport + IRenderVueSupport, + ITransformUpdatable { /** 渲染的全局ticker */ static ticker: Ticker = new Ticker(); @@ -519,6 +521,8 @@ export abstract class RenderItem //#endregion + //#region 功能方法 + /** * 获取当前元素的绝对位置(不建议使用,因为应当很少会有获取绝对位置的需求) */ @@ -535,6 +539,28 @@ export abstract class RenderItem } } + /** + * 获取到可以包围这个元素的最小矩形 + */ + 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 = this): void { if (this.cacheDirty) return; this.cacheDirty = true; @@ -542,6 +568,13 @@ export abstract class RenderItem 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 this.checkRoot(); this._root?.connect(this); this.canvases.forEach(v => v.activate()); + this._transform.bind(this); } /** @@ -653,6 +687,7 @@ export abstract class RenderItem 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 this.emit('destroy'); this.removeAllListeners(); this.cache.delete(); + this.canvases.forEach(v => v.delete()); + this.canvases.clear(); } } diff --git a/src/core/render/renderer/index.ts b/src/core/render/renderer/index.ts index c6d84e5..f274f93 100644 --- a/src/core/render/renderer/index.ts +++ b/src/core/render/renderer/index.ts @@ -31,7 +31,6 @@ export const { createApp, render } = createRenderer({ remove: function (el: RenderItem): void { el.remove(); - el.destroy(); }, createElement: function ( diff --git a/src/core/render/renderer/use.ts b/src/core/render/renderer/use.ts index d8eb248..4741de8 100644 --- a/src/core/render/renderer/use.ts +++ b/src/core/render/renderer/use.ts @@ -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 +>(item: RenderItem, key: K, listener: EventEmitter.EventListener) { + item.on(key, listener); + onUnmounted(() => { + item.off(key, listener); + }); +} diff --git a/src/core/render/transform.ts b/src/core/render/transform.ts index cf525ef..6390c17 100644 --- a/src/core/render/transform.ts +++ b/src/core/render/transform.ts @@ -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?.(); } /** diff --git a/src/module/render/components/misc.tsx b/src/module/render/components/misc.tsx index ad2053b..e9f9991 100644 --- a/src/module/render/components/misc.tsx +++ b/src/module/render/components/misc.tsx @@ -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(props => { const element = ref(); - 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 ; + return ; }; }, progressProps); diff --git a/src/module/render/components/scroll.tsx b/src/module/render/components/scroll.tsx new file mode 100644 index 0000000..0d1c1df --- /dev/null +++ b/src/module/render/components/scroll.tsx @@ -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; + +/** 滚动条图示的最短长度 */ +const SCROLL_MIN_LENGTH = 20; +/** 滚动条图示的宽度 */ +const SCROLL_WIDTH = 10; + +export const Scroll = defineComponent( + (props, { slots }) => { + const scrollProps: SpriteProps = reactive({ + loc: [0, 0, 0, 0] + }); + const contentProps: ContainerProps = reactive({ + loc: [0, 0, 0, 0] + }); + + const listenedChild: Set = new Set(); + const areaMap: Map = new Map(); + const content = ref(); + const scroll = ref(); + + 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 ( + + + {slots.default()} + + + + ); + }; + }, + scrollProps +); diff --git a/src/module/render/ui/statusBar.tsx b/src/module/render/ui/statusBar.tsx index 2277f23..3676062 100644 --- a/src/module/render/ui/statusBar.tsx +++ b/src/module/render/ui/statusBar.tsx @@ -122,11 +122,7 @@ export const LeftStatusBar = defineComponent>( - +