Because of Halloween coming up, we thought it time to turn our simba-starter kit in to a Spooky spinoff, credits to Charlene on that project!
When brainstorming ideas for making it even more spooky, I was reminded of Boo, the ghost in Mario games that flies around the room and hides when you look at him in close proximity.
In this blog we will talk about making a Web Component (Boo) that flies around the page and hides when your mouse cursor gets close enough.
The component will then be reusable for anyone to use and even configure some settings of how Boo will behave.
You might have noticed that Boo is living on this blog-post as well, he's definitely flying around somewhere!
Boo can be found here and this is the GitHub Repository.
Approach
There's multiple ways in which you can create an element that is absolutely positioned relative to the page, and you could even use something like <canvas>
to do this.
For this component we decided to go with a simple CSS solution and inserting the component in the document.documentElement
also known as the <html>
element.
The component will use position: absolute
and will use left
and top
CSS properties as coordinates.
We will then measure the size of the frame (which is the <html>
element) which then becomes our 2D coordinate system, where top left is (0, 0)
.
Visual element
Let's start things off by finding some nice assets (images/svgs) for Boo. We need one for when he's showing and flying around and we need one for when he's paused and hiding.
The images below were the best I could find, so I cropped them to be the same size:
This means that I can now create a component which renders one of the two images based on its hiding state.
Let's create that element. We could use a vanilla HTMLElement
extension, but eventually the amount of boilerplate will get frustrating, so therefore we use a library to make our lives easier and to also make re-renders more performant than they would be with .innerHTML = ...
.
I went with Lit as the library of choice, but note that there are many alternatives! Of course, we will use webcomponents.dev to create the component. Fortunately it comes with lots of starter templates in all kinds of technologies to create web components and the Lit one even comes in variants, one with TypeScript working out of the box.
Usually I would not be so excited about creating a web component in TypeScript and I would use JSDocs comments instead to provide types, in case you're wondering "what the hell is he on about??" check out this blog about strict typing without TypeScript. This is mostly due to all the tooling headaches (tests, linting, formatting, demoing, publishing etc.) as well as a forced compilation step in my developer workflow.
Luckily, webcomponents.dev makes all this very simple, giving support out of the box for this without any setup needed.
import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import ghostHide from './ghostHideURI';
import ghost from './ghostURI';
@customElement('flying-boo')
export class FlyingBoo extends LitElement {
static get styles() {
return css`
:host {
position: absolute;
display: block;
z-index: 999999;
}
`;
}
@property({ type: 'Boolean', reflect: true })
hidden = false;
render() {
return html`
<img src="data:image/gif;base64,${this.hidden ? ghostHide : ghost}" />
`;
}
}
The high z-index is because I want Boo to not get hidden under arbitrary siblings when it is used. Boo has to take the stage!
As you can see, we conditionally render a different base64 encoded image depending on whether the hidden property is set to true
or false
.
The reason I went with base64 here is because handling assets like images in webcomponents is a pretty complex topic, which comes down to a fundamental question:
How should assets be bundled in components?
There's a lot of opinions you can have here but in the absence of a clear consensus I went with the easy option of essentially inlining the image as a string. If you want to learn more about this, check out this awesome CSS-Tricks article about using data URIs.
Injecting it in the right place
This Flying Boo element is pretty obtrusive. It injects itself in the documentElement
and flies around uncontrollably.
I figured a nice Developer Experience would be to be able to declaratively insert this element wherever you want on the page, and Boo makes sure it moves itself to the right place.
In our connectedCallback
, which is called whenever the element is connected to the DOM, we check if we're in the right place, if not we move to where we want to be.
connectedCallback
is often misunderstood as a callback that only happens once in the lifecycle of a component. This is a good example where this is not true. If you move an element withappendChild
orprepend
, the element is disconnected from its original spot and "reconnected" in the target location. Keep that in mind when you're manually setting up event listeners inconnectedCallback
, you'll have to remove those listeners indisconnectedCallback
to prevent duplicate event handlers.
connectedCallback() {
super.connectedCallback();
if (this.parentElement !== document.documentElement) {
document.documentElement.prepend(this);
} else {
this.init();
}
}
Getters & setters
I really like classes and Object Oriented Programming (OOP) for this project.
Not only is it an easy way to encapsulate state, properties and methods with the this
keyword, it also allows you to intercept property access and reassignment with getters and setters. This is very useful if you need a hook for when a property is changed or when you need a proxy for a property.
I also find that with components and games, when you intuitively conceptualize things in terms of "objects" (at least that's the case for me), OOP paradigm just feels right.
Let's start with some useful getters:
export class FlyingBoo extends LitElement {
get frameWidth() {
return document.documentElement.getBoundingClientRect().width;
}
get frameHeight() {
return document.documentElement.getBoundingClientRect().height;
}
get x() {
return parseFloat(
getComputedStyle(this).getPropertyValue('left').replace('px', '')
);
}
get y() {
return parseFloat(
getComputedStyle(this).getPropertyValue('top').replace('px', '')
);
}
}
Instead of having to do a complicated one liner, you can hide that implementation detail in a getter, so that in the rest of your code you can just access this.x
or this.y
and not worry about where it gets the number from.
We will also need setters for our x
and y
properties:
export class FlyingBoo extends LitElement {
set x(value) {
this.style.setProperty('left', `${value}px`);
}
set y(value) {
this.style.setProperty('top', `${value}px`);
}
}
Fun fact: if you extend a class and you only want to override a setter from a getter-setter-pair, you will also need to override the getter for that to work. When getters and setters come in pairs, they can only be overridden as a pair. How romantic!
Velocity
Now that we've got our frame and our position set up, we should figure out movement.
Movement here means a speed and a direction. You can combine those, this would be called velocity.
There's quite a few ways to go about defining velocity in a 2D space, a simple way is to just have an X and a Y parameter ranging between -1 and 1. -1 means as fast as possible to one direction, 1 as fast as possible to other, 0 is standstill.
We can randomize that velocity:
function rangeMap(number, inMin, inMax, outMin, outMax) {
return ((number - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}
export class FlyingBoo extends LitElement {
randomizeVelocity(forced: { x?: number; y?: number } = {}) {
const { x: _x, y: _y } = forced;
const x = _x || rangeMap(Math.random(), 0, 1, -1, 1);
const y = _y || rangeMap(Math.random(), 0, 1, -1, 1);
this.velocity = { x, y };
}
}
What basically happens here: if you call this.randomizeVelocity()
it will take Math.random()
which ranges between 0 and 1, this is then mapped to a range of -1 and 1.
The outcome is that the current velocity is set to a new randomized velocity.
There is also a forced
argument that you can pass, which allows you to randomize the velocity but set either X or Y as a fixed value.
This is useful for when Boo hits the boundary of the frame.
Let's imagine you hit the top of the frame, you then want to randomize the velocity to a new one, but the Y may not be < 0
because we're already at the top (0).
This is displayed in the method that is called when edge detection happens:
boundaryHit(side) {
switch (side) {
case 'right':
this.randomizeVelocity({ x: rangeMap(Math.random(), 0, 1, -0.5, -1) });
break;
case 'left':
this.randomizeVelocity({ x: rangeMap(Math.random(), 0, 1, 0.5, 1) });
break;
case 'top':
this.randomizeVelocity({ y: rangeMap(Math.random(), 0, 1, 0.5, 1) });
break;
case 'bottom':
this.randomizeVelocity({ y: rangeMap(Math.random(), 0, 1, -0.5, -1) });
break;
}
}
The edge detection we can do directly in our x
and y
setters:
export class FlyingBoo extends LitElement {
set x(value) {
this.style.setProperty('left', `${value}px`);
if (value > this.frameWidth - this.getBoundingClientRect().width) {
this.boundaryHit('right');
}
if (value < 0) {
this.boundaryHit('left');
}
}
set y(value) {
this.style.setProperty('top', `${value}px`);
if (value > this.frameHeight - this.getBoundingClientRect().height) {
this.boundaryHit('bottom');
}
if (value < 0) {
this.boundaryHit('top');
}
}
}
Note that we have to take into account our own bounding box width and height. This is because our
x
andy
are based on the element's top left corner.
Now that we figured out velocity, let's look at actually moving Boo.
Moving
We can move Boo by using setInterval
and updating our x
& y
properties using our current velocity.
move() {
this.hidden = false;
this.startVelocityInterval(); // an interval on which the velocity is randomized again
this.moveInterval = setInterval(() => {
this.x = this.x + this.velocity.x * this.speed;
this.y = this.y + this.velocity.y * this.speed;
}, 1);
}
As you can see here, we create an interval that updates Boo every millisecond.
We also store it on this.moveInterval
so that we can clear the interval whenever Boo has to pause.
The same happens for the velocityInterval
.
There's also a speed amplifier that you can pass to Boo to make him speed up, but keep in mind that his final speed is also determined by his velocity, meaning you can't at this moment give Boo a constant speed, only a speed amplification.
Here's the method for when Boo needs to pause and hide:
hide() {
clearInterval(this.moveInterval);
clearInterval(this.velocityInterval);
this.hidden = true;
}
Mouse detection
Now that Boo is moving all around the screen and has the ability to hide, we should setup when it should hide: when the mouse is close to Boo and he gets shy.
First, we need a mousemove handler that gives Boo knowledge of the mouse position, we can do this in the init()
method:
document.documentElement.addEventListener('mousemove', (ev) => {
this.checkDistance({ x: ev.pageX, y: ev.pageY });
});
this.checkDistance
is called so that Boo checks the distance between him and the mouse.
Here, we will need some high school math (Pythagoras Theorem) to calculate the distance
checkDistance(coords: { x: number, y: number }) {
const xDistance = Math.abs(
this.x + this.getBoundingClientRect().width / 2 - coords.x
);
const yDistance = Math.abs(
this.y + this.getBoundingClientRect().height / 2 - coords.y
);
const distance = Math.sqrt(xDistance ** 2 + yDistance ** 2); // Pythagoras a^2 + b^2 = c^2
if (distance < this.scareDistance && !this.hidden) {
this.hide();
}
if (distance >= this.scareDistance && this.hidden) {
this.randomizeVelocity();
this.move();
}
}
Here, we also determine whether Boo should hide, or when he should start moving again.
Lastly, let's use CSS Custom Properties to change the direction Boo is facing while moving, based on the x-direction
attribute.
export class FlyingBoo extends LitElement {
static get styles() {
return css`
:host {
--scale-x: 1;
}
:host([x-direction='right']) {
--scale-x: -1; /* flip horizontally */
}
img {
transform: scaleX(var(--scale-x));
}
`;
}
}
Configuration
We can set up some Reactive properties with Lit, reflect them to attributes, and use them as configuration settings for Boo.
- Configure the interval speed at which Boo changes direction with the
change-speed
attribute (default 5000, which is milliseconds) - Configure speed amplifier to change how fast Boo travels with the
speed
attribute (default 1) - Change the distance at which Boo gets scared with the
scare-distance
attribute (default 100, which is in pixels)
@property({ type: 'Number', reflect: true, attribute: 'change-speed' })
changeSpeed = 5000;
@property({ type: 'Number', reflect: true })
speed = 1;
@property({ type: 'Number', reflect: true, attribute: 'scare-distance' })
scareDistance = 100;
<flying-boo change-speed="2000" speed="2.5" scare-distance="60"></flying-boo>
Cleanup
Since many people build SPA (Single Page Applications), there will be a lot of people using routers to switch between views.
If you use flying-boo
in multiple views and your users switches between these views, multiple Boos will start to accumulate.
Therefore, we made a cleanup utility that you can call before your new view renders, and you can also pass a custom tag name in case you're using that.
export const cleanup = (tagName = 'flying-boo') => {
Array.from(document.querySelectorAll(tagName)).forEach((ghostEl) =>
ghostEl.remove()
);
};
cleanup();
Boo can be found here and this is the GitHub Repository.
Enjoy Halloween!
Brought to you by Twitter Discord