Added support for non-fullscreen particles

This also drastically improves positioning effects based on elements
This commit is contained in:
thepaperpilot 2022-03-26 22:35:31 -05:00
parent 8d0489c8e1
commit 887002e95b
2 changed files with 176 additions and 41 deletions

View file

@ -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>

View file

@ -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 = [];
}