720 lines
27 KiB
JavaScript
720 lines
27 KiB
JavaScript
// Ari was here uwu
|
|
// Professional boob lover
|
|
// girls kissing,,,
|
|
console.log(`
|
|
⣿⣿⣿⠏⣴⣿⣿⣿⣿⡿⠟⢹⣿⣿⣿⡿⠋⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡉⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
⣿⣿⢇⣾⣿⣿⣿⡿⢋⢀⣴⣿⣿⡿⠋⠀⠘⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣀⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
⣿⡏⣼⣿⣿⣿⠏⣴⢃⣾⣿⡿⢋⣴⠟⣠⣾⣿⣿⣿⠏⢁⣼⣿⣿⣿⣿⣿⣿⠟⣿⣿⣿⠟⠂⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠟⠛⠀⠘⠛⠛⣛⣛⣋⢉⣉⣉⣛⡛⠻⠿⣿⣿
|
|
⡟⣸⣿⣿⣿⡏⡸⢡⣾⣿⢋⣤⡿⢡⣾⣿⣿⣿⠟⠁⣰⣿⠟⣹⡿⢿⣿⠋⢀⣾⣿⣿⠏⡄⢻⡆⢀⠙⣿⣿⡿⠟⢋⣩⣤⣶⣾⣿⣿⣿⠟⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶
|
|
⢠⣿⣿⣿⣿⠃⢡⣿⡟⣡⣾⠏⣰⣿⣿⣿⡿⠋⢀⣾⡿⢁⣼⠟⢠⠞⠁⣰⣿⣿⡿⢣⣾⡇⢸⣿⣾⠆⠙⠁⣰⣾⣿⣿⣿⣿⣿⣿⣿⠏⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
⣿⣿⣿⣿⡏⢠⣿⠏⣴⣿⠟⣰⣿⣿⣿⡿⣡⢃⣾⠟⢀⡞⠁⣴⢋⠄⣼⣿⣿⠏⣰⣿⡟⢀⡼⠋⣠⡶⠀⣴⣿⢿⣿⣿⢿⠏⣸⣿⡏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
|
⣿⣿⣿⣿⣧⣿⠏⣼⡿⠿⢠⣿⣿⣿⡟⣰⢃⣾⠏⢀⠞⠀⡼⢡⠟⣼⣿⡟⣡⣾⣿⠏⠀⠄⠀⣾⡿⠁⣼⡿⢃⡜⣽⡏⠈⣰⡟⠀⠁⣾⣿⣿⣿⣿⣿⣿⡇⢹⣿⣿⣿⣿⣿⡇⢻
|
|
⣿⣿⣿⣿⣿⡏⣸⣿⠃⠀⣼⣿⣿⡟⣴⢃⣾⠏⠀⢀⢀⡾⢡⡟⣰⣿⢋⣴⡿⠋⠁⣀⠀⠀⣰⡿⠁⡌⡸⢁⣾⣿⡟⠀⢠⣿⡇⠀⣸⣿⣿⣿⣿⣿⣿⣿⡇⢸⣿⣿⣿⣿⣿⡇⢸
|
|
⣿⣿⣿⣿⡿⢰⣿⠃⡆⢀⣿⣿⡟⣸⠏⣾⠏⣴⠂⠂⣾⢡⡟⣰⡟⣡⠞⣩⠄⢀⣼⠁⠀⣼⣿⠇⡼⠠⠃⣼⣿⡿⠁⢀⣾⠿⠀⠀⡏⢸⣿⣿⣿⠃⣿⣿⠇⢸⣿⣿⣿⣿⣿⡇⢸
|
|
⣿⣿⡇⢸⠇⣾⡏⣸⠃⢸⣿⡿⢱⡟⣸⡟⣼⠃⠂⣼⢃⠏⢠⠏⠰⠋⠀⢁⣠⣾⠇⠀⣼⣿⡟⠀⠁⣦⣾⣿⡿⠀⣴⡾⠀⠀⠀⠠⠁⣿⡿⢹⠉⢠⣿⣿⠀⢸⣿⣿⣿⣿⣿⡇⢸
|
|
⣿⣿⠀⣼⢰⣿⡀⡿⢀⢸⣿⢡⣿⢡⡿⠰⡏⠀⢰⠇⡞⠀⠀⣾⠞⠀⢴⣿⣿⣷⠀⣼⣿⣿⠃⠀⣸⣿⡿⡿⠀⢀⣿⠇⠀⠀⠀⠐⢀⣿⠇⡌⢠⣸⣿⡇⠀⢸⣿⣿⣿⣿⣿⠃⣸
|
|
⣿⡟⠀⡇⣸⣿⢠⡇⣏⣾⡏⣼⡇⡼⠁⠘⠁⠀⡟⡸⠀⠐⠚⢁⣦⣶⣿⣿⣿⡇⠀⣿⠇⡟⠀⠀⣿⡿⠁⠀⠀⢸⡟⠀⢰⠆⠀⡄⢸⡏⢠⠃⣿⡟⢹⡇⠀⣿⣿⣿⣿⣿⡿⠀⡏
|
|
⣿⡇⠀⡇⣿⣿⢸⡇⡟⣿⢰⣿⢡⠃⠀⠀⣰⠃⢡⠁⠈⠀⣴⣿⣿⣿⣿⣿⠟⡁⡀⢻⠀⡇⠀⢀⣿⠃⠀⠀⡄⢸⠃⠀⣿⡇⠀⠀⣼⡇⢸⢀⣿⡇⢸⠀⢀⣿⣿⣿⣿⣿⠇⢸⠁
|
|
⣿⢃⡆⡇⢿⣿⢸⣷⡇⠏⣼⡏⡌⠀⠀⠀⡏⢀⣼⡘⢀⣤⡈⠛⢿⣿⣿⣧⣾⡇⣇⠘⠀⠁⠀⢸⡏⠀⠀⣼⠃⡏⠀⢸⣿⡇⠀⠀⣿⠀⣾⣼⣿⠀⡟⠀⢸⣿⣿⣿⣿⡟⢀⡟⢠
|
|
⣿⣿⡇⡇⢸⣿⢸⠛⡇⠀⣿⠇⠁⠀⠏⣼⣷⢸⡿⢃⣾⣿⣷⣄⠀⠈⠛⢿⣿⡇⣿⡀⠀⠀⠀⠈⠀⠀⣼⣿⠀⠀⠀⣀⠙⢧⠀⠀⣿⠀⣿⣿⣿⠀⡇⠀⣼⣿⣿⣿⣿⠃⣼⠃⣾
|
|
⣿⣿⡇⠁⢸⣿⠘⠀⡇⠀⣿⠀⠀⠀⢰⣿⣿⡆⠃⣼⣿⣿⣿⣿⣷⣤⣄⣤⣽⣇⢹⡇⠀⣦⡄⠀⠀⢸⣿⡟⠀⠀⢠⣿⣷⣄⠀⠀⣿⠀⣿⣿⡇⢰⠁⠀⣿⣿⣿⣿⡟⢠⡏⢰⡿
|
|
⣿⠻⣷⠀⢸⣿⡄⠀⣷⣾⣧⠀⠀⠀⠈⣿⣿⠇⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣷⣦⡘⡷⠄⠀⣿⣿⡇⠀⣰⣾⣿⣿⣿⡇⠀⠈⠀⣿⣿⠁⠈⠀⢸⣿⣿⣿⡿⢀⣾⣧⣿⠃
|
|
⣿⡆⣿⠀⢸⣿⣇⠀⣿⣿⣿⣷⠀⢀⠀⣿⣿⣷⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠻⠷⢂⣤⣼⣿⣿⣇⢀⣿⣿⣿⣿⣿⣷⠀⠀⠀⣿⣿⠀⠀⠀⣼⣿⣿⣿⠃⣸⣿⣿⠃⠀
|
|
⣿⣧⢸⡆⠘⣿⣿⠀⢻⣿⣿⡇⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣶⣶⣿⣿⣿⣿⣿⣿⣿⠘⠛⣻⣿⣿⣿⣿⡀⠀⠀⣿⡏⢠⠀⠀⣾⣿⣿⠇⠠⢿⢻⠏⠀⠀
|
|
⢹⣿⡌⣧⠀⠻⣿⣷⣾⣿⣿⡇⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠻⣿⣿⣿⣿⣷⡆⠀⢿⠃⠀⠀⠀⣿⣿⠏⠀⠀⠆⠀⠀⠀⠀
|
|
⡌⢿⣷⢹⡆⠈⢿⣿⣿⣿⣿⠧⣿⡟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣄⡈⠛⠿⣿⣿⣷⠀⠸⠀⠀⡄⢸⣿⡏⢠⠂⠘⠀⠀⠀⠀⠀
|
|
⣷⠘⣿⡆⢿⣧⡈⠻⢿⣿⣿⠀⣿⣧⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⣡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣾⣿⡆⠀⠀⢠⠁⣸⡟⢀⠎⠀⠀⠀⠀⠀⠀⢠
|
|
⣿⣧⢹⣿⡘⣿⣷⣀⠈⣿⣿⠀⣿⣿⣧⡹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⢸⠀⠟⠁⡼⠀⠀⠀⠀⠀⠀⢀⣾
|
|
⣿⣿⣆⢻⣷⡘⣿⣿⡀⠘⣿⡆⢹⣿⣿⣷⡌⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⢸⠀⠀⢰⠁⠀⠀⢀⠀⠀⢀⣿⣿
|
|
⢿⢿⣿⣦⠹⣷⠸⣿⣷⠀⠹⡇⠘⣿⣿⠿⢿⣦⣙⣿⣿⣿⣿⣿⣿⣿⣿⠟⣡⣄⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠸⠀⠀⠌⠀⠀⢀⠆⠀⢀⣿⣿⣿
|
|
⠀⠈⢿⣿⣷⡙⢧⠹⣿⣇⢧⠉⠀⣿⠏⣰⣶⣤⣍⡛⠿⣿⣿⣿⣿⠟⣡⣾⣿⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠈⠀⢠⣾⣿⡿⠟
|
|
⣀⠀⠈⠻⣿⣿⣌⠣⠙⣿⡌⢧⠀⠁⣼⣿⣿⣿⣿⣿⣷⣶⣬⣭⣥⣾⣿⣿⣿⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⠀⠀⠀⠀⠀⠀⣰⣿⡟⠁⠀⠀
|
|
⣿⣿⣶⠀⠈⠛⢿⣷⡄⠈⢿⡌⣇⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⠸⣟⡀⣀⠀⢠⣼⣿⣿⣷⣿⠀⠀
|
|
⣿⣿⣿⣿⣷⣤⡀⠉⠛⢦⣀⣿⡘⡄⢹⣬⡙⣿⣿⣿⠟⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣤⣙⠻⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⡙⠛⠠⠶⠃⣸⣿⣿⣿⣿⠀⠀
|
|
⣿⣿⣿⣿⣿⣿⣧⡀⠀⠰⣿⣿⣷⠸⡄⠙⣷⣼⣧⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣦⣭⣭⡉⣙⡛⠛⠿⣿⣿⣿⣿⣿⡇⠐⠄⢀⢂⡀⢘⣿⣿⣿⣿⣿⣷⡄
|
|
⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀⢹⣿⣿⣧⢹⡄⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢇⣿⢿⣷⡄⠘⣿⣿⣿⣿⠇⣾⣿⣦⣤⡀⢸⣿⣿⣿⣿⣿⣿⣿
|
|
⣿⣿⣿⣷⣤⣈⡙⠻⢿⡇⠀⢿⣇⢻⡆⢿⡀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣧⡀⡜⢁⣤⡘⣿⣿⡿⢠⣿⣿⣿⣿⠁⣿⣿⣿⣿⣿⣿⣿⣿
|
|
⣿⣿⣿⣿⡿⢿⣿⣿⣿⣿⡀⠘⣿⣄⢻⡘⡇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣸⣿⡀⠀⣷⡘⣿⠇⣼⣿⣿⣿⡿⢸⣿⣿⣿⣿⣿⣿⣿⣿`);
|
|
// mmmmmmmmmmmmmmmmm girls kissing,,,,,
|
|
|
|
document.querySelectorAll("[data-href]").forEach((el) => {
|
|
el.style.cursor = "pointer";
|
|
if (!el.hasAttribute("role")) el.setAttribute("role", "link");
|
|
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
|
|
|
|
const go = () => {
|
|
const url = el.dataset.href;
|
|
if (!url) return;
|
|
location.href = url;
|
|
};
|
|
|
|
el.addEventListener("click", go);
|
|
el.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
go();
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================== flavors.js ========================= */
|
|
(function flavors() {
|
|
// Metadata only — colors are defined in the per-flavor CSS files.
|
|
const FLAVORS = {
|
|
mocha: { label: "Mocha", dot: "#f5c2e7" },
|
|
macchiato: { label: "Macchiato", dot: "#f5bde6" },
|
|
frappe: { label: "Frappé", dot: "#f4b8e4" },
|
|
latte: { label: "Latte", dot: "#ea76cb" },
|
|
};
|
|
const ORDER = ["mocha", "macchiato", "frappe", "latte"];
|
|
|
|
const root = document.documentElement;
|
|
const ls = window.localStorage;
|
|
|
|
function apply(name) {
|
|
const f = FLAVORS[name] || FLAVORS.mocha;
|
|
root.setAttribute("data-flavor", name); // CSS does the rest
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
if (meta) meta.setAttribute("content", f.dot);
|
|
}
|
|
|
|
let current = ls.getItem("ctpFlavor");
|
|
if (!ORDER.includes(current)) current = "mocha";
|
|
apply(current); // the <head> snippet already set this to avoid a flash
|
|
|
|
// ---- top-right corner icon button ----
|
|
const bar = document.createElement("div");
|
|
bar.className = "beta-bar";
|
|
bar.innerHTML = `
|
|
<button class="beta-btn" id="flavor-btn" type="button">
|
|
<img class="beta-icon" alt="">
|
|
</button>`;
|
|
// Group the single-item widgets (discord + theme toggle) into one
|
|
// top bar. On mobile they sit side by side; on desktop both stay
|
|
// position:fixed, so this wrapper is zero-size and invisible.
|
|
let topbar = document.querySelector(".topbar");
|
|
if (!topbar) {
|
|
topbar = document.createElement("div");
|
|
topbar.className = "topbar";
|
|
document.body.insertBefore(topbar, document.body.firstChild);
|
|
const dc = document.getElementById("discord");
|
|
// Don't hijack the presence card when it's the centerpiece of the
|
|
// dedicated /discord page (it lives inside .presence-stage there).
|
|
if (dc && !dc.closest(".presence-stage")) topbar.appendChild(dc);
|
|
}
|
|
topbar.appendChild(bar);
|
|
|
|
const btn = bar.querySelector("#flavor-btn");
|
|
const icon = bar.querySelector(".beta-icon");
|
|
|
|
function paintBtn() {
|
|
const f = FLAVORS[current];
|
|
icon.src = `/assets/theme/${current}.png`; // e.g. /assets/theme/mocha.png
|
|
icon.alt = f.label;
|
|
btn.title = `Theme: ${f.label} (click to cycle)`;
|
|
}
|
|
paintBtn();
|
|
|
|
btn.addEventListener("click", () => {
|
|
current = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
|
|
ls.setItem("ctpFlavor", current);
|
|
apply(current);
|
|
paintBtn();
|
|
});
|
|
|
|
// ---- cat collection button (sits next to the theme button) ----
|
|
const catBtn = document.createElement("button");
|
|
catBtn.className = "beta-btn";
|
|
catBtn.id = "cat-btn";
|
|
catBtn.type = "button";
|
|
catBtn.title = "Cat collection";
|
|
catBtn.setAttribute("aria-label", "Open cat collection");
|
|
catBtn.innerHTML = `<span class="beta-cat-icon" aria-hidden="true"></span>`;
|
|
bar.appendChild(catBtn);
|
|
|
|
catBtn.addEventListener("click", () => {
|
|
if (typeof window.toggleCatPicker === "function") window.toggleCatPicker();
|
|
});
|
|
})();
|
|
|
|
/* ===================== cat.js (oneko.js) ======================= */
|
|
// oneko.js: https://github.com/adryd325/oneko.js
|
|
|
|
(function oneko() {
|
|
const isReducedMotion =
|
|
window.matchMedia(`(prefers-reduced-motion: reduce)`) === true ||
|
|
window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true;
|
|
|
|
if (isReducedMotion) return;
|
|
|
|
const nekoEl = document.createElement("div");
|
|
let persistPosition = true;
|
|
|
|
let nekoPosX = 32;
|
|
let nekoPosY = 32;
|
|
|
|
let mousePosX = 0;
|
|
let mousePosY = 0;
|
|
|
|
let frameCount = 0;
|
|
let idleTime = 0;
|
|
let idleAnimation = null;
|
|
let idleAnimationFrame = 0;
|
|
|
|
const nekoSpeed = 10;
|
|
const spriteSets = {
|
|
idle: [[-3, -3]],
|
|
alert: [[-7, -3]],
|
|
scratchSelf: [
|
|
[-5, 0],
|
|
[-6, 0],
|
|
[-7, 0],
|
|
],
|
|
scratchWallN: [
|
|
[0, 0],
|
|
[0, -1],
|
|
],
|
|
scratchWallS: [
|
|
[-7, -1],
|
|
[-6, -2],
|
|
],
|
|
scratchWallE: [
|
|
[-2, -2],
|
|
[-2, -3],
|
|
],
|
|
scratchWallW: [
|
|
[-4, 0],
|
|
[-4, -1],
|
|
],
|
|
tired: [[-3, -2]],
|
|
sleeping: [
|
|
[-2, 0],
|
|
[-2, -1],
|
|
],
|
|
N: [
|
|
[-1, -2],
|
|
[-1, -3],
|
|
],
|
|
NE: [
|
|
[0, -2],
|
|
[0, -3],
|
|
],
|
|
E: [
|
|
[-3, 0],
|
|
[-3, -1],
|
|
],
|
|
SE: [
|
|
[-5, -1],
|
|
[-5, -2],
|
|
],
|
|
S: [
|
|
[-6, -3],
|
|
[-7, -2],
|
|
],
|
|
SW: [
|
|
[-5, -3],
|
|
[-6, -1],
|
|
],
|
|
W: [
|
|
[-4, -2],
|
|
[-4, -3],
|
|
],
|
|
NW: [
|
|
[-1, 0],
|
|
[-1, -1],
|
|
],
|
|
};
|
|
|
|
function init() {
|
|
let nekoFile = "./oneko.gif"
|
|
const curScript = document.currentScript
|
|
if (curScript && curScript.dataset.cat) {
|
|
nekoFile = curScript.dataset.cat
|
|
}
|
|
if (curScript && curScript.dataset.persistPosition) {
|
|
if (curScript.dataset.persistPosition === "") {
|
|
persistPosition = true;
|
|
} else {
|
|
persistPosition = JSON.parse(curScript.dataset.persistPosition.toLowerCase());
|
|
}
|
|
}
|
|
|
|
if (persistPosition) {
|
|
let storedNeko = JSON.parse(window.localStorage.getItem("oneko"));
|
|
if (storedNeko !== null) {
|
|
nekoPosX = storedNeko.nekoPosX;
|
|
nekoPosY = storedNeko.nekoPosY;
|
|
mousePosX = storedNeko.mousePosX;
|
|
mousePosY = storedNeko.mousePosY;
|
|
frameCount = storedNeko.frameCount;
|
|
idleTime = storedNeko.idleTime;
|
|
idleAnimation = storedNeko.idleAnimation;
|
|
idleAnimationFrame = storedNeko.idleAnimationFrame;
|
|
nekoEl.style.backgroundPosition = storedNeko.bgPos;
|
|
}
|
|
}
|
|
|
|
nekoEl.id = "oneko";
|
|
nekoEl.ariaHidden = true;
|
|
nekoEl.style.width = "32px";
|
|
nekoEl.style.height = "32px";
|
|
nekoEl.style.position = "fixed";
|
|
nekoEl.style.pointerEvents = "none";
|
|
nekoEl.style.imageRendering = "pixelated";
|
|
nekoEl.style.left = `${nekoPosX - 16}px`;
|
|
nekoEl.style.top = `${nekoPosY - 16}px`;
|
|
nekoEl.style.zIndex = 2147483647;
|
|
|
|
nekoEl.style.backgroundImage = `url(${nekoFile})`;
|
|
|
|
document.body.appendChild(nekoEl);
|
|
|
|
document.addEventListener("mousemove", function (event) {
|
|
mousePosX = event.clientX;
|
|
mousePosY = event.clientY;
|
|
});
|
|
|
|
if (persistPosition) {
|
|
window.addEventListener("beforeunload", function (event) {
|
|
window.localStorage.setItem("oneko", JSON.stringify({
|
|
nekoPosX: nekoPosX,
|
|
nekoPosY: nekoPosY,
|
|
mousePosX: mousePosX,
|
|
mousePosY: mousePosY,
|
|
frameCount: frameCount,
|
|
idleTime: idleTime,
|
|
idleAnimation: idleAnimation,
|
|
idleAnimationFrame: idleAnimationFrame,
|
|
bgPos: nekoEl.style.backgroundPosition
|
|
}));
|
|
});
|
|
}
|
|
|
|
window.requestAnimationFrame(onAnimationFrame);
|
|
}
|
|
|
|
let lastFrameTimestamp;
|
|
|
|
function onAnimationFrame(timestamp) {
|
|
// Stops execution if the neko element is removed from DOM
|
|
if (!nekoEl.isConnected) {
|
|
return;
|
|
}
|
|
if (!lastFrameTimestamp) {
|
|
lastFrameTimestamp = timestamp;
|
|
}
|
|
if (timestamp - lastFrameTimestamp > 100) {
|
|
lastFrameTimestamp = timestamp;
|
|
frame();
|
|
}
|
|
window.requestAnimationFrame(onAnimationFrame);
|
|
}
|
|
|
|
function setSprite(name, frame) {
|
|
const sprite = spriteSets[name][frame % spriteSets[name].length];
|
|
nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`;
|
|
}
|
|
|
|
function resetIdleAnimation() {
|
|
idleAnimation = null;
|
|
idleAnimationFrame = 0;
|
|
}
|
|
|
|
function idle() {
|
|
idleTime += 1;
|
|
|
|
// every ~ 20 seconds
|
|
if (
|
|
idleTime > 10 &&
|
|
Math.floor(Math.random() * 200) == 0 &&
|
|
idleAnimation == null
|
|
) {
|
|
let avalibleIdleAnimations = ["sleeping", "scratchSelf"];
|
|
if (nekoPosX < 32) {
|
|
avalibleIdleAnimations.push("scratchWallW");
|
|
}
|
|
if (nekoPosY < 32) {
|
|
avalibleIdleAnimations.push("scratchWallN");
|
|
}
|
|
if (nekoPosX > window.innerWidth - 32) {
|
|
avalibleIdleAnimations.push("scratchWallE");
|
|
}
|
|
if (nekoPosY > window.innerHeight - 32) {
|
|
avalibleIdleAnimations.push("scratchWallS");
|
|
}
|
|
idleAnimation =
|
|
avalibleIdleAnimations[
|
|
Math.floor(Math.random() * avalibleIdleAnimations.length)
|
|
];
|
|
}
|
|
|
|
switch (idleAnimation) {
|
|
case "sleeping":
|
|
if (idleAnimationFrame < 8) {
|
|
setSprite("tired", 0);
|
|
break;
|
|
}
|
|
setSprite("sleeping", Math.floor(idleAnimationFrame / 4));
|
|
if (idleAnimationFrame > 192) {
|
|
resetIdleAnimation();
|
|
}
|
|
break;
|
|
case "scratchWallN":
|
|
case "scratchWallS":
|
|
case "scratchWallE":
|
|
case "scratchWallW":
|
|
case "scratchSelf":
|
|
setSprite(idleAnimation, idleAnimationFrame);
|
|
if (idleAnimationFrame > 9) {
|
|
resetIdleAnimation();
|
|
}
|
|
break;
|
|
default:
|
|
setSprite("idle", 0);
|
|
return;
|
|
}
|
|
idleAnimationFrame += 1;
|
|
}
|
|
|
|
function frame() {
|
|
frameCount += 1;
|
|
const diffX = nekoPosX - mousePosX;
|
|
const diffY = nekoPosY - mousePosY;
|
|
const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
|
|
|
|
if (distance < nekoSpeed || distance < 48) {
|
|
idle();
|
|
return;
|
|
}
|
|
|
|
idleAnimation = null;
|
|
idleAnimationFrame = 0;
|
|
|
|
if (idleTime > 1) {
|
|
setSprite("alert", 0);
|
|
// count down after being alerted before moving
|
|
idleTime = Math.min(idleTime, 7);
|
|
idleTime -= 1;
|
|
return;
|
|
}
|
|
|
|
let direction;
|
|
direction = diffY / distance > 0.5 ? "N" : "";
|
|
direction += diffY / distance < -0.5 ? "S" : "";
|
|
direction += diffX / distance > 0.5 ? "W" : "";
|
|
direction += diffX / distance < -0.5 ? "E" : "";
|
|
setSprite(direction, frameCount);
|
|
|
|
nekoPosX -= (diffX / distance) * nekoSpeed;
|
|
nekoPosY -= (diffY / distance) * nekoSpeed;
|
|
|
|
nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
|
|
nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
|
|
|
|
nekoEl.style.left = `${nekoPosX - 16}px`;
|
|
nekoEl.style.top = `${nekoPosY - 16}px`;
|
|
}
|
|
|
|
init();
|
|
})();
|
|
|
|
const BASE_SPRITE = "/assets/oneko/classics/classic.png";
|
|
|
|
let CAT_MODES = [];
|
|
|
|
// Order the category sections appear in the menu
|
|
const CATEGORY_ORDER = ["Classics", "Pride", "Cats", "Romance", "Gaming", "Pokémon", "Other Animals", "Things", "Rare"];
|
|
|
|
// click-count goals (total clicks on the cat)
|
|
const CLICK_GOALS = { filter: 13, romance: 69, weed: 420 };
|
|
const SPRITE = BASE_SPRITE; // base sprite used for filter modes + previews
|
|
const IDLE_POS = "-97px -97px"; // idle frame, inset 1px to avoid neighbour-frame bleed
|
|
const spriteFor = (c) => c.sprite || BASE_SPRITE;
|
|
|
|
(async function catModes() {
|
|
try {
|
|
// index.json lists which per-folder configs to load (one per oneko folder)
|
|
const index = await fetch("/js/on/index.json").then((r) => {
|
|
if (!r.ok) throw new Error(`index.json (${r.status})`);
|
|
return r.json();
|
|
});
|
|
// load every /js/on/<folder>.json and merge them into one list
|
|
const lists = await Promise.all(
|
|
index.map((name) =>
|
|
fetch(`/js/on/${name}.json`)
|
|
.then((r) => (r.ok ? r.json() : []))
|
|
.catch(() => [])
|
|
)
|
|
);
|
|
CAT_MODES = lists.flat();
|
|
} catch (err) {
|
|
console.error("Could not load cat data:", err);
|
|
return;
|
|
}
|
|
const oneko = document.getElementById("oneko");
|
|
if (!oneko) return;
|
|
|
|
oneko.style.pointerEvents = "auto";
|
|
oneko.style.cursor = "pointer";
|
|
|
|
const ls = window.localStorage;
|
|
let clicks = parseInt(ls.getItem("onekoClicks") || "0", 10);
|
|
let mode = parseInt(ls.getItem("onekoMode") || "0", 10);
|
|
|
|
// permanently-earned methods (konami, gold, pokemon, + any click goal hit)
|
|
let unlocks;
|
|
try { unlocks = new Set(JSON.parse(ls.getItem("onekoUnlocks") || "[]")); }
|
|
catch (e) { unlocks = new Set(); }
|
|
const saveUnlocks = () => ls.setItem("onekoUnlocks", JSON.stringify([...unlocks]));
|
|
|
|
// Returns true if a method was newly unlocked (false if already had it)
|
|
function unlockMethod(key) {
|
|
if (unlocks.has(key)) return false;
|
|
unlocks.add(key);
|
|
saveUnlocks();
|
|
if (overlay && !overlay.hidden) renderGrid();
|
|
return true;
|
|
}
|
|
|
|
const methodOf = (c) => c.unlockMethod || "gay";
|
|
const isUnlocked = (i) => {
|
|
const key = methodOf(CAT_MODES[i]);
|
|
if (key === "gay") return true;
|
|
if (key in CLICK_GOALS) return clicks >= CLICK_GOALS[key] || unlocks.has(key);
|
|
return unlocks.has(key); // konami / gold / pokemon
|
|
};
|
|
const unlockedIndices = () =>
|
|
CAT_MODES.map((_, i) => i).filter(isUnlocked);
|
|
|
|
const apply = (i) => {
|
|
const c = CAT_MODES[i];
|
|
oneko.style.backgroundImage = `url('${spriteFor(c)}')`;
|
|
oneko.style.filter = c.filter || "none";
|
|
};
|
|
|
|
/* ---------- picker overlay (no visible trigger — press C to find it) ---------- */
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "cat-picker";
|
|
overlay.hidden = true;
|
|
overlay.innerHTML = `
|
|
<div class="cat-picker-panel" role="dialog" aria-label="Choose a cat">
|
|
<div class="cat-picker-head">
|
|
<span>Cat collection</span>
|
|
<button class="cat-picker-close" type="button" aria-label="Close">×</button>
|
|
</div>
|
|
<div class="cat-grid"></div>
|
|
<p class="cat-hint">Some cats are still hidden… · press C to toggle</p>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
const grid = overlay.querySelector(".cat-grid");
|
|
|
|
function makeOption(i) {
|
|
const c = CAT_MODES[i];
|
|
const unlocked = isUnlocked(i);
|
|
const opt = document.createElement(unlocked ? "button" : "div");
|
|
opt.className =
|
|
"cat-option" + (unlocked ? "" : " locked") + (i === mode ? " current" : "");
|
|
if (unlocked) opt.type = "button";
|
|
const previewFilter = unlocked ? (c.filter || "none") : "brightness(0) opacity(0.3)";
|
|
opt.innerHTML = `
|
|
<span class="cat-preview" style="background-image:url('${spriteFor(c)}');background-position:${IDLE_POS};filter:${previewFilter}"></span>
|
|
<span class="cat-name">${unlocked ? c.name : "???"}</span>`;
|
|
if (unlocked) opt.addEventListener("click", () => selectMode(i));
|
|
return opt;
|
|
}
|
|
|
|
function renderGrid() {
|
|
grid.innerHTML = "";
|
|
|
|
// bucket cat indices by category
|
|
const byCat = {};
|
|
CAT_MODES.forEach((c, i) => {
|
|
const cat = c.category || "Classics";
|
|
(byCat[cat] = byCat[cat] || []).push(i);
|
|
});
|
|
|
|
// known categories first (in order), then any stragglers
|
|
const order = CATEGORY_ORDER.filter((c) => byCat[c])
|
|
.concat(Object.keys(byCat).filter((c) => !CATEGORY_ORDER.includes(c)));
|
|
|
|
order.forEach((cat) => {
|
|
const section = document.createElement("div");
|
|
section.className = "cat-section";
|
|
|
|
const title = document.createElement("h4");
|
|
title.className = "cat-section-title";
|
|
title.textContent = cat;
|
|
section.appendChild(title);
|
|
|
|
const items = document.createElement("div");
|
|
items.className = "cat-section-items";
|
|
byCat[cat].forEach((i) => items.appendChild(makeOption(i)));
|
|
section.appendChild(items);
|
|
|
|
grid.appendChild(section);
|
|
});
|
|
}
|
|
|
|
function selectMode(i) {
|
|
mode = i;
|
|
ls.setItem("onekoMode", String(i));
|
|
apply(i);
|
|
renderGrid();
|
|
}
|
|
|
|
const openPicker = () => {
|
|
renderGrid();
|
|
overlay.hidden = false;
|
|
};
|
|
const closePicker = () => (overlay.hidden = true);
|
|
const togglePicker = () => (overlay.hidden ? openPicker() : closePicker());
|
|
|
|
// let other scripts (e.g. the theme-bar button) open the cat menu
|
|
window.toggleCatPicker = togglePicker;
|
|
|
|
overlay
|
|
.querySelector(".cat-picker-close")
|
|
.addEventListener("click", closePicker);
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) closePicker();
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
// ignore while typing in a field or with modifier keys held
|
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "");
|
|
if (e.key === "Escape" && !overlay.hidden) {
|
|
closePicker();
|
|
} else if (
|
|
(e.key === "c" || e.key === "C") &&
|
|
!e.ctrlKey && !e.metaKey && !e.altKey && !typing
|
|
) {
|
|
togglePicker();
|
|
} else if ((e.key === "x" || e.key === "X") &&
|
|
!e.ctrlKey && !e.metaKey && !e.altKey && !typing) {
|
|
if (unlockMethod("gaming")) {
|
|
toast("✨ Gaming sprites unlocked!");
|
|
}
|
|
}
|
|
});
|
|
|
|
/* ---------- toast ---------- */
|
|
let toastEl, toastTimer;
|
|
function toast(msg) {
|
|
if (!toastEl) {
|
|
toastEl = document.createElement("div");
|
|
toastEl.className = "cat-toast";
|
|
document.body.appendChild(toastEl);
|
|
}
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.remove("show");
|
|
void toastEl.offsetWidth;
|
|
toastEl.classList.add("show");
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 1700);
|
|
}
|
|
|
|
/* ---------- squeak / boop sound on click ---------- */
|
|
const boop = new Audio("/assets/oneko/boop.mp3");
|
|
boop.preload = "auto";
|
|
function playBoop() {
|
|
try {
|
|
boop.currentTime = 0; // rewind so rapid clicks each squeak
|
|
boop.play().catch(() => { }); // ignore autoplay/missing-file errors
|
|
} catch (e) { /* no-op */ }
|
|
}
|
|
|
|
/* ---------- init + cat click ---------- */
|
|
if (!isUnlocked(mode)) mode = 0; // fall back to Classic if current is locked
|
|
apply(mode);
|
|
|
|
// Clicking the cat no longer changes its look — it only counts toward
|
|
// the click-based unlocks (13 / 69 / 420). Pick a cat from the menu.
|
|
oneko.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
playBoop();
|
|
clicks += 1;
|
|
ls.setItem("onekoClicks", String(clicks));
|
|
|
|
// Did this click hit a click-count goal exactly? (13 / 69 / 420)
|
|
for (const key in CLICK_GOALS) {
|
|
if (clicks === CLICK_GOALS[key]) {
|
|
unlocks.add(key);
|
|
saveUnlocks();
|
|
const idx = CAT_MODES.findIndex((c) => methodOf(c) === key);
|
|
const name = idx >= 0 ? CAT_MODES[idx].name : key;
|
|
toast(`✨ Unlocked: ${name}! — open the cat menu 🐱`);
|
|
if (!overlay.hidden) renderGrid();
|
|
}
|
|
}
|
|
});
|
|
|
|
/* ---------- Konami code → press Enter to confirm ---------- */
|
|
const KONAMI = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown",
|
|
"ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"];
|
|
let kProg = 0, kArmed = false;
|
|
document.addEventListener("keydown", (e) => {
|
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "");
|
|
if (typing || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
|
|
if (kArmed && e.key === "Enter") {
|
|
kArmed = false;
|
|
if (unlockMethod("konami")) toast("✨ Konami cats unlocked!");
|
|
return;
|
|
}
|
|
|
|
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
|
if (key === KONAMI[kProg]) {
|
|
kProg += 1;
|
|
if (kProg === KONAMI.length) {
|
|
kProg = 0;
|
|
kArmed = true;
|
|
toast("Konami code… press Enter ↵");
|
|
}
|
|
} else {
|
|
kProg = key === KONAMI[0] ? 1 : 0; // allow a fresh start on ↑
|
|
}
|
|
});
|
|
|
|
/* ---------- Gold → opened the site while Discord status is Idle ---------- */
|
|
const dc = document.getElementById("discord");
|
|
if (dc) {
|
|
const checkIdle = () => {
|
|
if (dc.dataset.status === "idle" && unlockMethod("gold")) {
|
|
toast("✨ Gold Cat unlocked!");
|
|
}
|
|
};
|
|
checkIdle();
|
|
new MutationObserver(checkIdle)
|
|
.observe(dc, { attributes: true, attributeFilter: ["data-status"] });
|
|
}
|
|
|
|
/* ---------- Pokémon → find & click the hidden pokéball ---------- */
|
|
const poke = document.getElementById("pokeball-secret");
|
|
if (poke) {
|
|
poke.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
poke.classList.add("found");
|
|
if (unlockMethod("pokemon")) toast("✨ Pokémon cats unlocked!");
|
|
});
|
|
}
|
|
|
|
/* ---------- Timer → keep the page open for a while ---------- */
|
|
// Counts only while the tab is visible; resets each visit, but once the
|
|
// goal is reached the unlock is saved for good.
|
|
const TIMER_GOAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
if (!unlocks.has("timer")) {
|
|
let elapsed = 0;
|
|
let last = Date.now();
|
|
const timer = setInterval(() => {
|
|
if (document.hidden) { last = Date.now(); return; }
|
|
const now = Date.now();
|
|
elapsed += now - last;
|
|
last = now;
|
|
if (elapsed >= TIMER_GOAL_MS) {
|
|
clearInterval(timer);
|
|
if (unlockMethod("timer")) toast("✨ Patience pays off — timer cats unlocked!");
|
|
}
|
|
}, 1000);
|
|
}
|
|
})();
|