Files
navi/public/components/avatar.mjs
ztimson 5018311990
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
Home screen update
2026-03-03 20:16:26 -05:00

238 lines
8.2 KiB
JavaScript

import {TTS} from '../tts.mjs';
class AvatarComponent extends HTMLElement {
static get observedAttributes() { return []; }
constructor() {
super();
this.attachShadow({mode: 'open'});
this.activeEmotes = [];
this.mouthSvg = null;
this.mouthState = 'closed';
this.setupMouthAnimation();
this.navi = window.navi;
this.navi.animations().then(animations => {
this.animations = animations;
if(!this.animations) return console.error(`Invalid animations: ${this.animations}`);
this.render(this.animations);
navi.on('emote', emote => this.emote(emote));
});
window.emote = this.emote.bind(this);
}
render(data) {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: relative;
pointer-events: none;
width: 500px;
height: 500px;
}
.avatar-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(2px 4px 6px black);
}
.emote-overlay {
position: absolute;
width: auto;
height: auto;
object-fit: contain;
pointer-events: none;
}
.mouth-overlay {
position: absolute;
pointer-events: none;
z-index: 10;
}
</style>
<div class="avatar-container">
<img src="${this.navi.avatar}" class="avatar" alt="Avatar">
</div>`;
this.loadMouthSvg();
}
loadMouthSvg() {
fetch('/emotes/mouth.svg').then(r => r.text()).then(svg => {
const container = document.createElement('div');
container.className = 'mouth-overlay';
container.innerHTML = svg;
this.mouthSvg = container.firstElementChild;
const mouthPos = this.animations?.emote?.['mouth'] || {x: 50, y: 60, r: 0};
container.style.left = `${mouthPos.x}%`;
container.style.top = `${mouthPos.y}%`;
container.style.transform = `translate(-50%, -50%) rotate(${mouthPos.r || 0}deg)`;
container.style.width = '50px';
container.style.height = '25px';
const avatarContainer = this.shadowRoot.querySelector('.avatar-container');
if(avatarContainer) avatarContainer.appendChild(container);
});
}
setupMouthAnimation() {
const tts = TTS.getInstance();
let mouthAnimationInterval = null;
tts.on('onSentenceStart', () => {
if(mouthAnimationInterval) return;
const next = () => {
mouthAnimationInterval = setTimeout(() => {
next();
this.toggleMouthState();
}, ~~(Math.random() * 100) + 100);
}
next();
});
tts.on('onSentenceEnd', () => this.setMouthState('closed'));
tts.on('onComplete', () => {
if(mouthAnimationInterval) {
clearTimeout(mouthAnimationInterval);
mouthAnimationInterval = null;
}
this.setMouthState('closed');
});
}
toggleMouthState() {
if(!this.mouthSvg) return;
this.setMouthState(this.mouthState === 'open' ? 'partial' : 'open');
}
setMouthState(state) {
if(!this.mouthSvg) return;
this.mouthState = state;
this.mouthSvg.classList.remove('closed', 'partial', 'open');
this.mouthSvg.classList.add(state);
}
clear(all = true) {
if(all) {
const a = this.shadowRoot.querySelector('.avatar');
a.animate([{filter: 'drop-shadow(2px 4px 6px black) grayscale(0%) brightness(100%)'}], {duration: 100, fill: 'forwards'});
}
this.activeEmotes.forEach(e => e.remove());
this.activeEmotes = [];
}
emote(emote) {
const animate = (e, emote, style, index) => {
const duration = 3000;
if(emote === 'blush') {
e.animate([
{transform: `scale(0.75) rotate(${style.r ?? 0}deg)`, opacity: 0},
{transform: `scale(0.75) rotate(${style.r ?? 0}deg)`, opacity: 1}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'cry') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-25%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.5)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'dead') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(0)`},
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-10%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
setTimeout(() => {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-10%)`},
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-8%)`}
], {duration: 1500, easing: 'ease-in-out', iterations: Infinity, direction: 'alternate'});
}, duration);
} else if(emote === 'drool') {
e.src = `${this.navi.api}/emotes/tear.png`;
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-12.5%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.25)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'love') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.5)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.7)`}
], {duration: 200, easing: 'steps(2, jump-end)', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'question') {
e.style.transform = `rotate(${style.r ?? 0}deg)`;
e.animate([
{opacity: 1, offset: 0},
{opacity: 1, offset: 0.49},
{opacity: 0, offset: 0.5},
{opacity: 0, offset: 1}
], {duration: 200, iterations: Infinity, direction: 'alternate', delay: (index % 2) * 200});
} else if(emote === 'realize') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.9)`},
{transform: `rotate(${style.r ?? 0}deg) scale(1.1)`}
], {duration: 500, easing: 'ease-out', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'stars') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.25)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.3)`}
], {duration: 100, easing: 'steps(2, jump-end)', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'stress') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.9)`},
{transform: `rotate(${style.r ?? 0}deg) scale(1.1)`}
], {duration: 333, easing: 'ease-out', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'sigh') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translate(0, 0)`},
{transform: `rotate(${style.r ?? 0}deg) translate(10%, 10%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'sweat') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.5) translateY(0)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.5) translateY(20%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'tear') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-15%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.3)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
}
};
if(!emote || emote === 'none') return this.clear();
if(!this.animations.emote[emote]) throw new Error(`Invalid animation: ${emote}`);
const pos = this.animations.emote[emote];
this.clear(false);
const a = this.shadowRoot.querySelector('.avatar');
const container = this.shadowRoot.querySelector('.avatar-container');
const positions = Array.isArray(pos) ? pos : (pos.x != null ? [pos] : []);
if(['dead', 'grey'].includes(emote)) {
a.animate([
{filter: 'drop-shadow(2px 4px 6px black) grayscale(100%) brightness(150%)'}
], {duration: 100, fill: 'forwards'});
} else {
a.animate([
{filter: 'drop-shadow(2px 4px 6px black) grayscale(0%) brightness(100%)'}
], {duration: 100, fill: 'forwards'});
}
positions.forEach((p, i) => {
const e = document.createElement('img');
e.className = 'emote-overlay';
e.src = `${this.navi.api}/emotes/${emote}.png`;
e.style.top = `${p.y}%`;
e.style.left = `${p.x}%`;
container.appendChild(e);
this.activeEmotes.push(e);
animate(e, emote, p, i);
});
}
}
customElements.define('avatar-component', AvatarComponent);