1
0
Files
auto-git-gui/animeCat.js

555 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

window.AnimeCat = class AnimeCat {
/**
* @param {HTMLElement} container
* @param {Object} [options]
*/
constructor(container, options = {}) {
this.container = container;
this.images = Object.assign({
default: 'default.png',
eyesClosed: 'eyes_closed.png',
blink: 'blink.png',
mouthOpen: 'mouth_open.png',
joy: 'joy.png',
mischievous: 'mischievous.png'
}, options.images);
this.blinkMin = options.blinkMin ?? 5000;
this.blinkMax = options.blinkMax ?? 15000;
this.blinkDuration = options.blinkDuration ?? 175;
this.talkInterval = options.talkInterval ?? 300;
this._consolationCount = 0;
this._isSpeaking = false;
this._blinkTimeout = null;
this._talkIntervalId = null;
this._speechTimeout = null;
this._mouthOpen = false;
this._pettingActive = false;
this._createElements();
this._bindMouseHold();
this._startBlinking();
}
_createElements() {
// Outer wrapper (relativ für absolute Kinder!)
this.wrapper = document.createElement('div');
Object.assign(this.wrapper.style, {
position: 'relative',
width: '100px',
height: '80px',
display: 'flex',
alignItems: 'flex-end',
zIndex: 1
});
// --- Glow (unter der Katze) ---
this.glow = document.createElement('div');
this.glow.id = 'cat-glow';
Object.assign(this.glow.style, {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -52%)',
borderRadius: '50%',
width: '120px',
height: '80px',
pointerEvents: 'none',
zIndex: 1,
transition: 'background 0.4s, width 0.2s, height 0.2s, opacity 0.3s'
});
this.wrapper.appendChild(this.glow);
// --- Cat image (über Glow) ---
this.img = document.createElement('img');
this.img.src = this.images.default;
Object.assign(this.img.style, {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -60%)',
width: '62px',
height: '50px',
zIndex: 2,
userSelect: 'none',
webkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
webkitUserDrag: 'none',
pointerEvents: 'auto'
});
this.img.draggable = false;
this.wrapper.appendChild(this.img);
// --- Speech bubble (daneben, nicht überlagert) ---
this.bubble = document.createElement('div');
Object.assign(this.bubble.style, {
position: 'relative',
marginLeft: '14px',
marginBottom: '11px',
padding: '10px 16px',
background: 'white',
border: '1px solid #ccc',
borderRadius: '16px',
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
opacity: '0',
transition: 'opacity 0.3s',
maxWidth: '240px',
wordBreak: 'break-word',
fontFamily: 'sans-serif',
fontSize: '15px',
color: '#333',
pointerEvents: 'none',
minHeight: '30px',
zIndex: 10,
overflowWrap: 'anywhere',
display: 'flex',
alignItems: 'center'
});
// --- Bubble "Tail" (kleines Dreieck) ---
this.bubblePointer = document.createElement('div');
Object.assign(this.bubblePointer.style, {
position: 'absolute',
left: '-13px',
bottom: '12px',
width: '0',
height: '0',
borderTop: '8px solid transparent',
borderBottom: '8px solid transparent',
borderRight: '13px solid #fff',
filter: 'drop-shadow(-2px 1px 2px rgba(0,0,0,0.10))',
zIndex: 11
});
this.bubble.appendChild(this.bubblePointer);
// --- Wrapper komplettieren ---
this.wrapper.appendChild(this.bubble);
this.container.appendChild(this.wrapper);
// --- Herz-Emitter (für Animationen) ---
this.heartEmitter = document.createElement('div');
Object.assign(this.heartEmitter.style, {
position: 'absolute',
left: '82px',
bottom: '32px',
pointerEvents: 'none',
width: '1px',
height: '1px',
zIndex: 20
});
this.container.appendChild(this.heartEmitter);
}
// Hilfsfunktionen zur Farbinterpolation
_lerp(a, b, t) { return a + (b - a) * t; }
_lerpColor(a, b, t) {
return [
Math.round(this._lerp(a[0], b[0], t)),
Math.round(this._lerp(a[1], b[1], t)),
Math.round(this._lerp(a[2], b[2], t)),
];
}
// Animiert den Glow je nach Commit-Anzahl
animateCatGlow(commitCount) {
const glow = this.glow;
if (!glow) return;
// Glow-Farbstufen für die Commit-Bereiche
const stops = [
{ c: 0, color: [60, 230, 100], opacity: 0.00 }, // unsichtbar → grün
{ c: 10, color: [60, 230, 100], opacity: 0.25 }, // grün sichtbar
{ c: 50, color: [100, 180, 255], opacity: 0.40 }, // grün → blau
{ c: 100, color: [180, 120, 255], opacity: 0.55 }, // blau → lila
{ c: 500, color: [255, 180, 60], opacity: 0.90 }, // lila → orange
];
// Richtige Interpolationsstufe finden:
let lower = stops[0], upper = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; ++i) {
if (commitCount >= stops[i].c && commitCount < stops[i+1].c) {
lower = stops[i];
upper = stops[i+1];
break;
}
}
const range = upper.c - lower.c || 1;
const t = Math.min(Math.max((commitCount - lower.c) / range, 0), 1);
// Farbe und Opazität sanft interpolieren
const [r, g, b] = this._lerpColor(lower.color, upper.color, t);
const opacity = this._lerp(lower.opacity, upper.opacity, t);
// Größe interpolieren (0500 Commits)
const minSize = 80, maxSize = 170;
const sizeFactor = Math.min(commitCount / 500, 1);
const size = minSize + sizeFactor * (maxSize - minSize);
// Styles setzen
glow.style.width = `${size}px`;
glow.style.height = `${size}px`; // immer Kreis!
glow.style.opacity = opacity;
glow.style.background = `radial-gradient(circle, rgba(${r},${g},${b},0.85) 0%, rgba(${r},${g},${b},0.14) 70%, rgba(0,0,0,0) 100%)`;
}
// Bubble-Position absolut anpassen, wenn detached
_positionBubbleDetached() {
// Katze relativ im Container finden
const catRect = this.img.getBoundingClientRect();
const contRect = this.container.getBoundingClientRect();
// "Andockpunkt": rechts neben der Katze, leicht versetzt nach oben
const left = (catRect.right - contRect.left) + 12;
const bottom = (contRect.bottom - catRect.bottom) + 8;
Object.assign(this.bubble.style, {
position: 'absolute',
left: `${left}px`,
bottom: `${bottom}px`,
marginLeft: '0',
marginBottom: '0'
});
// Tail bleibt am linken Rand der Bubble!
this.bubblePointer.style.left = '-13px';
this.bubblePointer.style.bottom = '12px';
}
_resetBubbleAttach() {
Object.assign(this.bubble.style, {
position: 'relative',
left: '',
bottom: '',
marginLeft: '14px',
marginBottom: '11px'
});
this.bubblePointer.style.left = '-13px';
this.bubblePointer.style.bottom = '12px';
}
_startBlinking() {
const delay = this.blinkMin + Math.random() * (this.blinkMax - this.blinkMin);
this._blinkTimeout = setTimeout(() => {
if (!this._isSpeaking) {
this.img.src = this.images.blink;
setTimeout(() => {
if (!this._pettingActive) {
this.img.src = this.images.default;
}
this._startBlinking();
}, this.blinkDuration);
} else {
this._startBlinking();
}
}, delay);
}
_bindMouseHold() {
let holdTimer = null;
let joyActive = false;
let mouseDown = false;
let mouseDownAt = null;
let lastPos = null;
let moveDist = 0;
const CAT_TOLERANCE = 15;
const MOVE_THRESHOLD = 350;
const isMouseNearCat = (e) => {
const rect = this.img.getBoundingClientRect();
return (
e.clientX >= rect.left - CAT_TOLERANCE &&
e.clientX <= rect.right + CAT_TOLERANCE &&
e.clientY >= rect.top - CAT_TOLERANCE &&
e.clientY <= rect.bottom + CAT_TOLERANCE
);
};
const closeEyes = () => { this.img.src = this.images.eyesClosed; };
const reopenEyes = () => {
if (!joyActive && !this._isSpeaking && !this._pettingActive) this.img.src = this.images.default;
};
this.img.addEventListener('mousedown', (e) => {
if (this._isSpeaking || joyActive) return;
if (!isMouseNearCat(e)) return;
this._pettingActive = true;
mouseDown = true;
mouseDownAt = Date.now();
lastPos = { x: e.clientX, y: e.clientY };
moveDist = 0;
closeEyes();
clearTimeout(this._blinkTimeout); // Blinzeln pausieren
function onMove(ev) {
if (!mouseDown) return;
if (!isMouseNearCat(ev)) {
cleanup();
reopenEyes();
mouseDown = false;
this._startBlinking();
return;
}
if (lastPos) {
const dx = ev.clientX - lastPos.x;
const dy = ev.clientY - lastPos.y;
moveDist += Math.sqrt(dx * dx + dy * dy);
lastPos = { x: ev.clientX, y: ev.clientY };
}
}
const onMoveBound = onMove.bind(this);
holdTimer = setTimeout(() => {
if (!mouseDown) return; // Schon abgebrochen
if (moveDist >= MOVE_THRESHOLD) {
cleanup();
joyActive = true;
this._runJoyAnimation(() => {
joyActive = false;
reopenEyes();
this._startBlinking();
});
mouseDown = false;
} else {
this._consolation(mouseDownAt);
mouseDown = false;
}
}, 4000);
function onUp() {
if (!mouseDown) return;
cleanup();
const heldFor = Date.now() - mouseDownAt;
if (heldFor >= 4000) return; // already handled by timer above
if (heldFor >= 4000 && moveDist >= MOVE_THRESHOLD) {
joyActive = true;
this._runJoyAnimation(() => {
joyActive = false;
reopenEyes();
this._startBlinking();
});
} else if (heldFor > 1000) {
this._consolation(mouseDownAt);
} else {
reopenEyes();
this._startBlinking();
}
mouseDown = false;
}
const onUpBound = onUp.bind(this);
const cleanup = () => {
window.removeEventListener('mousemove', onMoveBound);
window.removeEventListener('mouseup', onUpBound);
if (holdTimer) clearTimeout(holdTimer);
holdTimer = null;
this._pettingActive = false;
};
window.addEventListener('mousemove', onMoveBound);
window.addEventListener('mouseup', onUpBound);
});
}
_consolation(mouseDownAt) {
// Trostpreis: Augen auf, dann mouth_open oder mischievous
this._pettingActive = false;
this.img.src = this.images.default;
const heldFor = Date.now() - mouseDownAt;
if (heldFor > 1000) {
const mouthOpenTime = Math.max(heldFor - 1000, 1000);
let imgToShow = this.images.mouthOpen || this.images.default;
if (this._consolationCount >= 3) {
if (
this._consolationCount === 3 ||
Math.random() < 0.2
) {
imgToShow = this.images.mischievous || imgToShow;
}
}
this.img.src = imgToShow;
this._consolationCount++;
this._startBlinking();
setTimeout(() => {
if (!this._isSpeaking && !this._pettingActive) {
this.img.src = this.images.default;
}
}, mouthOpenTime);
} else {
this._startBlinking();
}
}
_runJoyAnimation(onFinish) {
const img = this.img;
const origTransition = img.style.transition;
const origTransform = img.style.transform;
const origOrigin = img.style.transformOrigin;
img.src = this.images.joy || this.images.default;
// 20% Chance auf Salto!
const salto = Math.random() < 0.2;
// Bubble abkoppeln wie gehabt
this.container.appendChild(this.bubble);
this._positionBubbleDetached();
// Herz-Emitter richtig platzieren
const catRect = this.img.getBoundingClientRect();
const contRect = this.container.getBoundingClientRect();
// <-- Hier kannst du x/y anpassen!
const emitterX = catRect.right - contRect.left + 16; // <-- X-Versatz, mehr = weiter rechts
const emitterY = contRect.bottom - catRect.bottom + 40; // <-- Y-Versatz, mehr = weiter oben
this.heartEmitter.style.left = emitterX + 'px';
this.heartEmitter.style.bottom = emitterY + 'px';
const spawnHearts = () => this._spawnHearts(12);
if (salto) {
// Setzt das Zentrum der Drehung auf die Bildmitte
img.style.transformOrigin = '50% 70%';
// Salto und Sprung gleichzeitig
img.style.transition = 'transform 1.2s cubic-bezier(.19,1,.22,1)';
img.style.transform = 'translateY(-40px) rotate(360deg)';
spawnHearts();
setTimeout(() => {
// Nur runterfallen, Drehung bleibt auf 360°
img.style.transition = 'transform 0.8s cubic-bezier(.19,1,.22,1)';
img.style.transform = 'translateY(0) rotate(360deg)';
img.src = this.images.mouthOpen || this.images.default;
setTimeout(() => {
if (!this._pettingActive) img.src = this.images.default;
setTimeout(() => {
img.style.transition = origTransition || '';
img.style.transform = origTransform || '';
img.style.transformOrigin = origOrigin || '';
this.wrapper.appendChild(this.bubble);
this._resetBubbleAttach();
if (typeof onFinish === 'function') onFinish();
}, 700);
}, 2000);
}, 1200); // Erst springen + drehen, dann runterfallen
} else {
// Normale Joy-Animation
img.style.transformOrigin = '50% 70%';
img.style.transition = 'transform 0.6s cubic-bezier(.19,1,.22,1)';
img.style.transform = 'translateY(-40px) rotate(12deg)';
setTimeout(() => {
spawnHearts();
setTimeout(() => {
img.style.transition = 'transform 0.8s cubic-bezier(.19,1,.22,1)';
img.style.transform = 'translateY(0) rotate(0deg)';
img.src = this.images.mouthOpen || this.images.default;
setTimeout(() => {
if (!this._pettingActive) img.src = this.images.default;
setTimeout(() => {
img.style.transition = origTransition || '';
img.style.transform = origTransform || '';
img.style.transformOrigin = origOrigin || '';
this.wrapper.appendChild(this.bubble);
this._resetBubbleAttach();
if (typeof onFinish === 'function') onFinish();
}, 700);
}, 2000);
}, 2200);
}, 400);
}
}
_spawnHearts(count = 10) {
for (let i = 0; i < count; ++i) {
setTimeout(() => this._makeHeart(), Math.random() * 300);
}
}
_makeHeart() {
const emoji = Math.random() < 0.7 ? '❤️' : '💕';
const heart = document.createElement('span');
heart.textContent = emoji;
heart.style.position = 'absolute';
heart.style.left = '0%';
heart.style.bottom= '0%';
heart.style.fontSize = `${16 + Math.random() * 14}px`;
heart.style.pointerEvents = 'none';
heart.style.opacity = '0.9';
heart.style.zIndex = 100;
// Start/End-Pos, Flugwinkel
const angle = (Math.random() * Math.PI) - (Math.PI/2); // spread -90° to 90°
const distance = 60 + Math.random() * 45;
const dx = Math.cos(angle) * distance;
const dy = Math.sin(angle) * distance;
heart.animate([
{
transform: 'translate(-50%, 0) scale(1)',
opacity: 0.95
},
{
transform: `translate(calc(-50% + ${dx}px), ${-dy}px) scale(${1.3 + Math.random()*0.4}) rotate(${Math.random()*60-30}deg)`,
opacity: 0.3
}
], {
duration: 1100 + Math.random()*800,
easing: 'cubic-bezier(.28,1.01,.57,.99)'
});
// Remove after animation
setTimeout(() => heart.remove(), 1600);
// Im Herzen-Emitter platzieren!
this.heartEmitter.appendChild(heart);
}
/** Call when streaming text begins */
beginSpeech() {
clearTimeout(this._speechTimeout);
clearInterval(this._talkIntervalId);
this._isSpeaking = true;
this._mouthOpen = false;
this.img.src = this.images.default;
this.bubble.style.opacity = '1';
this.bubble.style.visibility = 'visible';
Array.from(this.bubble.childNodes).forEach(node => {
if (node !== this.bubblePointer) node.remove();
});
this._bubbleTextNode = document.createTextNode('');
this.bubble.appendChild(this._bubbleTextNode);
this._talkIntervalId = setInterval(() => {
this._mouthOpen = !this._mouthOpen;
this.img.src = this._mouthOpen
? this.images.mouthOpen
: this.images.default;
}, this.talkInterval / 2);
}
appendSpeech(chunk) {
if (this._bubbleTextNode)
this._bubbleTextNode.textContent += chunk;
}
endSpeech() {
clearInterval(this._talkIntervalId);
if (!this._pettingActive) {
this.img.src = this.images.default;
}
this._speechTimeout = setTimeout(() => {
this.bubble.style.opacity = '0';
this._isSpeaking = false;
}, 6000);
}
destroy() {
clearTimeout(this._blinkTimeout);
clearInterval(this._talkIntervalId);
clearTimeout(this._speechTimeout);
this.wrapper.remove();
this.heartEmitter.remove();
}
}