From 8bc2a00bd2832395699130765aa5ffb843069464 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sat, 22 Feb 2025 14:58:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=BB=9A=E5=8A=A8=E6=9D=A1=E7=9A=84?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=97=AE=E9=A2=98=20&=20feat:=20=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=AE=B9=E5=99=A8=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/render/container.ts | 50 +++- src/core/render/item.ts | 19 +- src/core/render/renderer/elements.tsx | 5 + src/core/render/renderer/map.ts | 26 ++- src/core/render/renderer/props.ts | 8 + src/core/render/sprite.ts | 9 - src/module/render/components/scroll.tsx | 297 +++++++++++++++++------- src/module/render/ui/statusBar.tsx | 7 + vite.config.ts | 3 +- 9 files changed, 314 insertions(+), 110 deletions(-) diff --git a/src/core/render/container.ts b/src/core/render/container.ts index 3086650..d975cce 100644 --- a/src/core/render/container.ts +++ b/src/core/render/container.ts @@ -1,3 +1,4 @@ +import { ElementNamespace, ComponentInternalInstance } from 'vue'; import { MotaOffscreenCanvas2D } from '../fx/canvas2d'; import { ActionType, EventProgress, ActionEventMap } from './event'; import { @@ -14,7 +15,6 @@ export class Container extends RenderItem implements IRenderChildable { - children: Set = new Set(); sortedChildren: RenderItem[] = []; private needSort: boolean = false; @@ -133,7 +133,53 @@ export class Container destroy(): void { super.destroy(); this.children.forEach(v => { - v.destroy(); + v.remove(); }); } } + +export type CustomContainerRenderFn = ( + canvas: MotaOffscreenCanvas2D, + children: RenderItem[], + transform: Transform +) => void; + +export class ContainerCustom extends Container { + private renderFn?: CustomContainerRenderFn; + + protected render( + canvas: MotaOffscreenCanvas2D, + transform: Transform + ): void { + if (!this.renderFn) { + super.render(canvas, transform); + } else { + this.renderFn(canvas, this.sortedChildren, transform); + } + } + + /** + * 设置这个自定义容器的渲染函数 + * @param render 渲染函数 + */ + setRenderFn(render?: CustomContainerRenderFn) { + this.renderFn = render; + } + + patchProp( + key: string, + prevValue: any, + nextValue: any, + namespace?: ElementNamespace, + parentComponent?: ComponentInternalInstance | null + ): void { + switch (key) { + case 'render': { + if (!this.assertType(nextValue, 'function', key)) return; + this.setRenderFn(nextValue); + return; + } + } + super.patchProp(key, prevValue, nextValue, namespace, parentComponent); + } +} diff --git a/src/core/render/item.ts b/src/core/render/item.ts index f9deaf0..f81562c 100644 --- a/src/core/render/item.ts +++ b/src/core/render/item.ts @@ -362,6 +362,11 @@ export abstract class RenderItem this._transform.bind(this); this.cache = this.requireCanvas(); this.cache.withGameScale(true); + if (!enableCache) { + this.cache.withGameScale(false); + this.cache.size(1, 1); + this.cache.freeze(); + } } /** @@ -437,7 +442,9 @@ export abstract class RenderItem size(width: number, height: number): void { this.width = width; this.height = height; - this.cache.size(width, height); + if (this.enableCache) { + this.cache.size(width, height); + } this.update(this); } @@ -480,13 +487,17 @@ export abstract class RenderItem setHD(hd: boolean): void { this.highResolution = hd; - this.cache.setHD(hd); + if (this.enableCache) { + this.cache.setHD(hd); + } this.update(this); } setAntiAliasing(anti: boolean): void { this.antiAliasing = anti; - this.cache.setAntiAliasing(anti); + if (this.enableCache) { + this.cache.setAntiAliasing(anti); + } this.update(this); } @@ -540,7 +551,7 @@ export abstract class RenderItem } /** - * 获取到可以包围这个元素的最小矩形 + * 获取到可以包围这个元素的最小矩形,相对于父元素 */ getBoundingRect(): DOMRectReadOnly { if (this.type === 'absolute') { diff --git a/src/core/render/renderer/elements.tsx b/src/core/render/renderer/elements.tsx index aac021a..818b070 100644 --- a/src/core/render/renderer/elements.tsx +++ b/src/core/render/renderer/elements.tsx @@ -13,6 +13,7 @@ import { BezierProps, CirclesProps, CommentProps, + ConatinerCustomProps, ContainerProps, CustomProps, DamageProps, @@ -84,6 +85,10 @@ declare module 'vue/jsx-runtime' { export interface IntrinsicElements { sprite: TagDefine; container: TagDefine; + 'container-custom': TagDefine< + ConatinerCustomProps, + EContainerEvent + >; shader: TagDefine; text: TagDefine; image: TagDefine; diff --git a/src/core/render/renderer/map.ts b/src/core/render/renderer/map.ts index d7cc4a9..611e443 100644 --- a/src/core/render/renderer/map.ts +++ b/src/core/render/renderer/map.ts @@ -1,7 +1,7 @@ import { logger } from '@/core/common/logger'; import { ERenderItemEvent, RenderItem, RenderItemPosition } from '../item'; import { ElementNamespace, VNodeProps } from 'vue'; -import { Container } from '../container'; +import { Container, ContainerCustom } from '../container'; import { MotaRenderer } from '../render'; import { Sprite } from '../sprite'; import { @@ -75,8 +75,13 @@ const standardElement = ( return (_0: any, _1: any, props?: any) => { if (!props) return new Item('static'); else { - const { type = 'static', cache = true, fall = false } = props; - return new Item(type, cache, fall); + const { + type = 'static', + cache = true, + fall = false, + nocache = false + } = props; + return new Item(type, cache && !nocache, fall); } }; }; @@ -91,8 +96,13 @@ const standardElementNoCache = ( return (_0: any, _1: any, props?: any) => { if (!props) return new Item('static'); else { - const { type = 'static', cache = false, fall = false } = props; - return new Item(type, cache, fall); + const { + type = 'static', + cache = false, + fall = false, + nocache = true + } = props; + return new Item(type, cache && !nocache, fall); } }; }; @@ -124,15 +134,17 @@ const se = ( const { type = position, cache = defaultCache, - fall = defautFall + fall = defautFall, + nocache = !defaultCache } = props; - return new Item(type, cache, fall); + return new Item(type, cache && !nocache, fall); } }; }; // Default elements tagMap.register('container', standardElement(Container)); +tagMap.register('container-custom', standardElement(ContainerCustom)); tagMap.register('template', standardElement(Container)); tagMap.register('mota-renderer', (_0, _1, props) => { return new MotaRenderer(props?.id); diff --git a/src/core/render/renderer/props.ts b/src/core/render/renderer/props.ts index 0f155ab..9d2e204 100644 --- a/src/core/render/renderer/props.ts +++ b/src/core/render/renderer/props.ts @@ -8,6 +8,7 @@ import { import type { EnemyCollection } from '@/game/enemy/damage'; import { ILineProperty } from '../preset/graphics'; import { ElementAnchor, ElementLocator } from '../utils'; +import { CustomContainerRenderFn } from '../container'; export interface CustomProps { _item: (props: BaseProps) => RenderItem; @@ -27,7 +28,10 @@ export interface BaseProps { hidden?: boolean; transform?: Transform; type?: RenderItemPosition; + /** 是否启用缓存,用处较少,主要用于一些默认不启用缓存的元素的特殊优化 */ cache?: boolean; + /** 是否不启用缓存,优先级大于 cache,用处较少,主要用于一些特殊优化 */ + nocache?: boolean; fall?: boolean; id?: string; alpha?: number; @@ -49,6 +53,10 @@ export interface SpriteProps extends BaseProps { export interface ContainerProps extends BaseProps {} +export interface ConatinerCustomProps extends ContainerProps { + render?: CustomContainerRenderFn; +} + export interface GL2Props extends BaseProps {} export interface ShaderProps extends BaseProps {} diff --git a/src/core/render/sprite.ts b/src/core/render/sprite.ts index ddb177f..db8c5e9 100644 --- a/src/core/render/sprite.ts +++ b/src/core/render/sprite.ts @@ -7,7 +7,6 @@ import { import { MotaOffscreenCanvas2D } from '../fx/canvas2d'; import { Transform } from './transform'; import { ElementNamespace, ComponentInternalInstance } from 'vue'; -import { ActionType, EventProgress, ActionEventMap } from './event'; export interface ESpriteEvent extends ERenderItemEvent {} @@ -43,14 +42,6 @@ export class Sprite< this.update(this); } - protected propagateEvent( - type: T, - _progress: EventProgress, - event: ActionEventMap[T] - ): void { - this.parent?.bubbleEvent(type, event); - } - patchProp( key: string, prevValue: any, diff --git a/src/module/render/components/scroll.tsx b/src/module/render/components/scroll.tsx index 0d1c1df..88ba74d 100644 --- a/src/module/render/components/scroll.tsx +++ b/src/module/render/components/scroll.tsx @@ -1,10 +1,10 @@ import { computed, defineComponent, + nextTick, onMounted, onUnmounted, onUpdated, - reactive, ref, SlotsType, VNode, @@ -13,11 +13,10 @@ import { import { SetupComponentOptions } from './types'; import { Container, - ContainerProps, ElementLocator, RenderItem, Sprite, - SpriteProps + Transform } from '@/core/render'; import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; import { hyper, Transition } from 'mutate-animate'; @@ -29,9 +28,18 @@ export const enum ScrollDirection { Vertical } -interface ScrollProps { - direction: ScrollDirection; +export interface ScrollExpose { + /** + * 控制滚动条滚动至目标位置 + * @param y 滚动至的目标位置 + * @param time 滚动的动画时长,默认为无动画 + */ + scrollTo(y: number, time?: number): void; +} + +export interface ScrollProps { loc: ElementLocator; + hor?: boolean; noscroll?: boolean; /** * 滚动到最下方(最右方)时的填充大小,如果默认的高度计算方式有误, @@ -45,22 +53,47 @@ type ScrollSlots = SlotsType<{ }>; const scrollProps = { - props: ['direction', 'noscroll'] + props: ['hor', 'noscroll', 'loc', 'padHeight'] } satisfies SetupComponentOptions; /** 滚动条图示的最短长度 */ const SCROLL_MIN_LENGTH = 20; /** 滚动条图示的宽度 */ const SCROLL_WIDTH = 10; +/** 滚动条的颜色 */ +const SCROLL_COLOR = '#ddd'; +/** + * 滚动条组件,具有虚拟滚动功能,即在画面外的不渲染。参数参考 {@link ScrollProps},暴露接口参考 {@link ScrollExpose} + * + * --- + * + * 使用时,建议使用平铺式布局,即包含很多子元素,而不要用一个 container 将所有内容包裹, + * 每个子元素的高度(宽度)不建议过大,以更好地通过虚拟滚动优化 + * + * **推荐写法**: + * ```tsx + * + * + * + * ...其他元素 + * + * + * + * ``` + * **不推荐**使用这种写法: + * ```tsx + * + * + * + * + * + * ``` + */ export const Scroll = defineComponent( - (props, { slots }) => { - const scrollProps: SpriteProps = reactive({ - loc: [0, 0, 0, 0] - }); - const contentProps: ContainerProps = reactive({ - loc: [0, 0, 0, 0] - }); + (props, { slots, expose }) => { + /** 滚动条的定位 */ + const sp = ref([0, 0, 1, 1]); const listenedChild: Set = new Set(); const areaMap: Map = new Map(); @@ -69,52 +102,63 @@ export const Scroll = defineComponent( const width = computed(() => props.loc[2] ?? 200); const height = computed(() => props.loc[3] ?? 200); + const direction = computed(() => + props.hor ? ScrollDirection.Horizontal : ScrollDirection.Vertical + ); - let showScroll = 0; - let nowScroll = 0; + /** 滚动内容的当前位置 */ + let contentPos = 0; + /** 滚动条的当前位置 */ + let scrollPos = 0; + /** 滚动内容的目标位置 */ + let contentTarget = 0; + /** 滚动条的目标位置 */ + let scrollTarget = 0; + /** 滚动内容的长度 */ let maxLength = 0; + /** 滚动条的长度 */ let scrollLength = SCROLL_MIN_LENGTH; const transition = new Transition(); transition.value.scroll = 0; + transition.value.showScroll = 0; transition.mode(hyper('sin', 'out')).absolute(); + //#region 滚动操作 + transition.ticker.add(() => { - if (transition.value.scroll !== nowScroll) { - showScroll = transition.value.scroll; - scroll.value?.update(); + if (scrollPos !== scrollTarget) { + scrollPos = transition.value.scroll; + content.value?.update(); + } + if (contentPos !== contentTarget) { + contentPos = transition.value.showScroll; + checkAllItem(); + updatePosition(); 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 scrollTo = (y: number, time: number = 0) => { + if (maxLength < height.value) return; + const max = maxLength - height.value; + const target = clamp(y, 0, max); + contentTarget = target; + scrollTarget = + (height.value - scrollLength) * (contentTarget / max); + transition.time(time).transition('scroll', scrollTarget); + transition.time(time).transition('showScroll', target); }; /** * 计算一个元素会在画面上显示的区域 */ const getArea = (item: RenderItem, rect: DOMRectReadOnly) => { - if (props.direction === ScrollDirection.Horizontal) { + if (direction.value === ScrollDirection.Horizontal) { areaMap.set(item, [rect.left - width.value, rect.right]); } else { areaMap.set(item, [rect.top - height.value, rect.bottom]); @@ -131,13 +175,20 @@ export const Scroll = defineComponent( return; } const [min, max] = area; - if (nowScroll > min - 10 && nowScroll < max + 10) { + if (contentPos > min - 10 && contentPos < max + 10) { item.show(); } else { item.hide(); } }; + /** + * 对所有元素执行显示检查 + */ + const checkAllItem = () => { + content.value?.children.forEach(v => checkItem(v)); + }; + /** * 当一个元素的矩阵发生变换时执行,检查其显示区域 */ @@ -147,15 +198,46 @@ export const Scroll = defineComponent( checkItem(item); }; + /** + * 更新滚动条位置 + */ + const updatePosition = () => { + if (direction.value === ScrollDirection.Horizontal) { + scrollLength = Math.max( + SCROLL_MIN_LENGTH, + (width.value / maxLength) * width.value + ); + const h = props.noscroll + ? height.value + : height.value - SCROLL_WIDTH; + sp.value = [0, h, width.value, SCROLL_WIDTH]; + } else { + scrollLength = clamp( + (height.value / maxLength) * height.value, + SCROLL_MIN_LENGTH, + height.value - 10 + ); + const w = props.noscroll + ? width.value + : width.value - SCROLL_WIDTH; + sp.value = [w, 0, SCROLL_WIDTH, height.value]; + } + }; + + let updating = false; const updateScroll = () => { - if (!content.value) return; + if (!content.value || updating) return; + updating = true; + nextTick(() => { + updating = false; + }); 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 (direction.value === ScrollDirection.Horizontal) { if (rect.right > max) { max = rect.right; } @@ -166,76 +248,95 @@ export const Scroll = defineComponent( } v.on('transform', onTransform); listenedChild.add(v); + checkItem(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]; - } + updatePosition(); scroll.value?.update(); }; + watch(() => props.loc, updateScroll); onUpdated(updateScroll); onMounted(updateScroll); onUnmounted(() => { listenedChild.forEach(v => v.off('transform', onTransform)); }); + //#endregion + + //#region 渲染滚动 + const drawScroll = (canvas: MotaOffscreenCanvas2D) => { if (props.noscroll) return; const ctx = canvas.ctx; ctx.lineCap = 'round'; - ctx.lineWidth = 6; - ctx.strokeStyle = '#fff'; + ctx.lineWidth = 3; + ctx.strokeStyle = SCROLL_COLOR; ctx.beginPath(); - if (props.direction === ScrollDirection.Horizontal) { - ctx.moveTo(nowScroll + 5, 5); - ctx.lineTo(nowScroll + scrollLength + 5, 5); + const scroll = transition.value.scroll; + if (direction.value === ScrollDirection.Horizontal) { + ctx.moveTo(scroll + 5, 5); + ctx.lineTo(scroll + scrollLength - 5, 5); } else { - ctx.moveTo(5, nowScroll + 5); - ctx.lineTo(5, nowScroll + scrollLength + 5); + ctx.moveTo(5, scroll + 5); + ctx.lineTo(5, scroll + scrollLength - 5); } ctx.stroke(); }; + const renderContent = ( + canvas: MotaOffscreenCanvas2D, + children: RenderItem[], + transform: Transform + ) => { + const ctx = canvas.ctx; + ctx.save(); + if (direction.value === ScrollDirection.Horizontal) { + ctx.translate(-contentPos, 0); + } else { + ctx.translate(0, -contentPos); + } + children.forEach(v => { + if (v.hidden) return; + v.renderContent(canvas, transform); + }); + ctx.restore(); + }; + + //#endregion + + //#region 事件监听 + + const wheelScroll = (delta: number, max: number) => { + const sign = Math.sign(delta); + const dx = Math.abs(delta); + const movement = Math.min(max, dx) * sign; + scrollTo(contentTarget + movement, dx > 10 ? 300 : 0); + }; + const wheel = (ev: IWheelEvent) => { - if (props.direction === ScrollDirection.Horizontal) { + if (direction.value === ScrollDirection.Horizontal) { if (ev.wheelX !== 0) { - scrollTo(nowScroll + ev.wheelX, 300); + wheelScroll(ev.wheelX, width.value / 5); } else if (ev.wheelY !== 0) { - scrollTo(nowScroll + ev.wheelY, 300); + wheelScroll(ev.wheelY, width.value / 5); } } else { - scrollTo(nowScroll + ev.wheelY, 300); + wheelScroll(ev.wheelY, height.value / 5); } }; const getPos = (ev: IActionEvent) => { - if (props.direction === ScrollDirection.Horizontal) { + if (direction.value === ScrollDirection.Horizontal) { return ev.offsetX; } else { return ev.offsetY; } }; - let identifier: number = -1; - let lastPos: number = 0; + let identifier = -2; + let lastPos = 0; + const down = (ev: IActionEvent) => { identifier = ev.identifier; lastPos = getPos(ev); @@ -249,24 +350,32 @@ export const Scroll = defineComponent( } else { if (ev.buttons & MouseType.Left) { pos = getPos(ev); + } else { + return; } } const movement = pos - lastPos; - scrollTo(nowScroll + movement, 1); + + scrollTo(contentTarget - movement, 0); lastPos = pos; }; + /** 最初滚动条在哪 */ let scrollBefore = 0; - let scrollIdentifier = -1; + /** 本次拖动滚动条的操作标识符 */ + let scrollIdentifier = -2; + /** 点击滚动条时,点击位置在平行于滚动条方向的位置 */ let scrollDownPos = 0; + /** 是否是点击了滚动条区域中滚动条之外的地方,这样视为类滚轮操作 */ let scrollMutate = false; + /** 点击滚动条时,点击位置垂直于滚动条方向的位置 */ let scrollPin = 0; /** * 获取点击滚动条时,垂直于滚动条方向的位置 */ const getScrollPin = (ev: IActionEvent) => { - if (props.direction === ScrollDirection.Horizontal) { + if (direction.value === ScrollDirection.Horizontal) { return ev.absoluteY; } else { return ev.absoluteX; @@ -274,13 +383,13 @@ export const Scroll = defineComponent( }; const downScroll = (ev: IActionEvent) => { - scrollBefore = nowScroll; + scrollBefore = contentTarget; scrollIdentifier = ev.identifier; const pos = getPos(ev); // 计算点击在了滚动条的哪个位置 - const sEnd = nowScroll + scrollLength; - if (pos >= nowScroll && pos <= sEnd) { - scrollDownPos = pos - nowScroll; + const sEnd = contentTarget + scrollLength; + if (pos >= contentTarget && pos <= sEnd) { + scrollDownPos = pos - contentTarget; scrollMutate = false; scrollPin = getScrollPin(ev); } else { @@ -304,22 +413,25 @@ export const Scroll = defineComponent( threshold = 100; } if (deltaPin > threshold) { - scrollTo(scrollBefore, 1); + scrollTo(scrollBefore, 0); } else { - scrollTo(scrollPos, 1); + const pos = (scrollPos / height.value) * maxLength; + scrollTo(pos, 0); } }; const upScroll = (ev: IActionEvent) => { if (!scrollMutate) return; const pos = getPos(ev); - if (pos < nowScroll) { + if (pos < contentTarget) { scrollTo(pos - 50); } else { scrollTo(pos + 50); } }; + //#endregion + onMounted(() => { scroll.value?.root?.on('move', move); scroll.value?.root?.on('move', moveScroll); @@ -328,16 +440,27 @@ export const Scroll = defineComponent( onUnmounted(() => { scroll.value?.root?.off('move', move); scroll.value?.root?.off('move', moveScroll); + transition.ticker.destroy(); + }); + + expose({ + scrollTo }); return () => { return ( - - {slots.default()} - + + {slots.default?.()} + >( return ( + + + + + + ); }; diff --git a/vite.config.ts b/vite.config.ts index df06f72..e0889d6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,8 @@ const FSHOST = 'http://127.0.0.1:3000/'; const custom = [ 'container', 'image', 'sprite', 'shader', 'text', 'comment', 'custom', - 'layer', 'layer-group', 'animate', 'damage', 'graphics', 'icon', 'winskin' + 'layer', 'layer-group', 'animate', 'damage', 'graphics', 'icon', 'winskin', + 'container-custom' ] // https://vitejs.dev/config/