diff --git a/src/features/particles/Particles.vue b/src/features/particles/Particles.vue index 57feb7e..afe40be 100644 --- a/src/features/particles/Particles.vue +++ b/src/features/particles/Particles.vue @@ -1,11 +1,19 @@ +onMounted, <template> <Particles - id="particles" + :id="id" + :class="{ + 'not-fullscreen': !fullscreen + }" + :style="{ + zIndex + }" ref="particles" :particlesInit="particlesInit" + :particlesLoaded="particlesLoaded" :options="{ fpsLimit: 60, - fullScreen: { zIndex: 1 }, + fullScreen: { enable: fullscreen, zIndex }, particles: { number: { value: 0 @@ -15,40 +23,105 @@ autoPlay: false } }" + v-bind="$attrs" /> + <div ref="resizeListener" class="resize-listener" /> </template> <script lang="tsx"> import { loadFull } from "tsparticles"; -import { Engine } from "tsparticles-engine"; +import { Engine, Container } from "tsparticles-engine"; import { Emitters } from "tsparticles-plugin-emitters/Emitters"; -import { defineComponent, nextTick, ref } from "vue"; +import { EmitterContainer } from "tsparticles-plugin-emitters/EmitterContainer"; +import { defineComponent, inject, nextTick, onMounted, PropType, ref } from "vue"; import { ParticlesComponent } from "particles.vue3"; +import { NodesInjectionKey } from "game/layers"; // TODO get typing support on the Particles component export default defineComponent({ - emits: ["init"], + props: { + zIndex: { + type: Number, + required: true + }, + fullscreen: { + type: Boolean, + required: true + }, + onInit: { + type: Function as PropType<(container: EmitterContainer & Container) => void>, + required: true + }, + id: { + type: String, + required: true + }, + onContainerResized: Function as PropType<(rect: DOMRect) => void> + }, components: { Particles: ParticlesComponent }, - setup(props, { emit }) { + setup(props) { const particles = ref<null | { particles: { container: Emitters } }>(null); async function particlesInit(engine: Engine) { await loadFull(engine); - finishInit(engine); } - function finishInit(engine: Engine) { - if (engine.domArray.length) { - emit("init", engine.domItem(0)); - } else { - nextTick(() => finishInit(engine)); + function particlesLoaded(container: EmitterContainer & Container) { + props.onInit(container); + } + + const resizeObserver = new ResizeObserver(updateBounds); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nodes = inject(NodesInjectionKey)!; + + const resizeListener = ref<Element | null>(null); + + onMounted(() => { + // ResizeListener exists because ResizeObserver's don't work when told to observe an SVG element + const resListener = resizeListener.value; + if (resListener != null) { + resizeObserver.observe(resListener); + } + updateBounds(); + }); + + let isDirty = true; + function updateBounds() { + if (isDirty) { + isDirty = false; + nextTick(() => { + if (resizeListener.value != null && props.onContainerResized) { + // TODO don't overlap with Links.vue + Object.values(nodes.value).forEach( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + node => (node!.rect = node?.element.getBoundingClientRect()) + ); + props.onContainerResized(resizeListener.value.getBoundingClientRect()); + } + isDirty = true; + }); } } return { particles, - particlesInit + particlesInit, + particlesLoaded, + resizeListener }; } }); </script> + +<style scoped> +.not-fullscreen, +.resize-listener { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} +</style> diff --git a/src/features/particles/particles.tsx b/src/features/particles/particles.tsx index 010bb36..4f460a9 100644 --- a/src/features/particles/particles.tsx +++ b/src/features/particles/particles.tsx @@ -1,39 +1,101 @@ -import ParticlesComponent from "./Particles.vue"; +import ParticlesComponent from "features/particles/Particles.vue"; +import { Container } from "tsparticles-engine"; import { IEmitter } from "tsparticles-plugin-emitters/Options/Interfaces/IEmitter"; import { EmitterInstance } from "tsparticles-plugin-emitters/EmitterInstance"; import { EmitterContainer } from "tsparticles-plugin-emitters/EmitterContainer"; import { Ref, shallowRef } from "vue"; -import { registerGameComponent } from "game/settings"; -import { jsx } from "features/feature"; +import { Component, GatherProps, getUniqueID, Replace, setDefault } from "features/feature"; +import { createLazyProxy } from "util/proxies"; -registerGameComponent(jsx(() => <ParticlesComponent onInit={onInit} />)); +export const ParticlesType = Symbol("Particles"); -const containerRef: Ref<null | EmitterContainer> = shallowRef(null); +export interface ParticlesOptions { + fullscreen?: boolean; + zIndex?: number; + onContainerResized?: (boundingRect: DOMRect) => void; +} -let emittersToAdd: { - resolve: (value: EmitterInstance | PromiseLike<EmitterInstance>) => void; - options: IEmitter & { particles: Required<IEmitter>["particles"] }; -}[] = []; +export interface BaseParticles { + id: string; + containerRef: Ref<null | (EmitterContainer & Container)>; + addEmitter: ( + options: IEmitter & { particles: Required<IEmitter>["particles"] } + ) => Promise<EmitterInstance>; + removeEmitter: (emitter: EmitterInstance) => void; + type: typeof ParticlesType; + [Component]: typeof ParticlesComponent; + [GatherProps]: () => Record<string, unknown>; +} -export function addEmitter( - options: IEmitter & { particles: Required<IEmitter>["particles"] } -): Promise<EmitterInstance> { - if (containerRef.value) { - // TODO why does addEmitter require a position parameter - return Promise.resolve(containerRef.value.addEmitter(options)); +export type Particles<T extends ParticlesOptions> = Replace< + T & BaseParticles, + { + fullscreen: undefined extends T["fullscreen"] ? true : T["fullscreen"]; + zIndex: undefined extends T["zIndex"] ? 1 : T["zIndex"]; } - return new Promise<EmitterInstance>(resolve => { - emittersToAdd.push({ resolve, options }); +>; + +export type GenericParticles = Replace< + Particles<ParticlesOptions>, + { + fullscreen: boolean; + zIndex: number; + } +>; + +export function createParticles<T extends ParticlesOptions>( + optionsFunc: () => T & ThisType<Particles<T>> +): Particles<T> { + return createLazyProxy(() => { + const particles: T & Partial<BaseParticles> = optionsFunc(); + particles.id = getUniqueID("particles-"); + particles.type = ParticlesType; + particles[Component] = ParticlesComponent; + + particles.containerRef = shallowRef(null); + particles.addEmitter = ( + options: IEmitter & { particles: Required<IEmitter>["particles"] } + ): Promise<EmitterInstance> => { + const genericParticles = particles as GenericParticles; + if (genericParticles.containerRef.value) { + // TODO why does addEmitter require a position parameter + return Promise.resolve(genericParticles.containerRef.value.addEmitter(options)); + } + return new Promise<EmitterInstance>(resolve => { + emittersToAdd.push({ resolve, options }); + }); + }; + particles.removeEmitter = (emitter: EmitterInstance) => { + // TODO I can't find a proper way to remove an emitter without accessing private functions + emitter.emitters.removeEmitter(emitter); + }; + + let emittersToAdd: { + resolve: (value: EmitterInstance | PromiseLike<EmitterInstance>) => void; + options: IEmitter & { particles: Required<IEmitter>["particles"] }; + }[] = []; + + function onInit(container: EmitterContainer & Container) { + (particles as GenericParticles).containerRef.value = container; + emittersToAdd.forEach(({ resolve, options }) => resolve(container.addEmitter(options))); + emittersToAdd = []; + } + + setDefault(particles, "fullscreen", true); + setDefault(particles, "zIndex", 1); + particles.onContainerResized = particles.onContainerResized?.bind(particles); + + particles[GatherProps] = function (this: GenericParticles) { + const { id, fullscreen, zIndex, onContainerResized } = this; + return { + id, + fullscreen, + zIndex, + onContainerResized, + onInit + }; + }; + + return particles as unknown as Particles<T>; }); } - -export function removeEmitter(emitter: EmitterInstance) { - // TODO I can't find a proper way to remove an emitter without accessing private functions - emitter.emitters.removeEmitter(emitter); -} - -function onInit(container: EmitterContainer) { - containerRef.value = container; - emittersToAdd.forEach(({ resolve, options }) => resolve(container.addEmitter(options))); - emittersToAdd = []; -}