mirror of
https://github.com/NohamR/Pocket-Bird.git
synced 2026-05-26 12:17:23 +00:00
Compare commits
1 Commits
extension
...
absolute-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bb587c96f |
@@ -1,8 +1,8 @@
|
||||
# Pocket Bird (Work in Progress!)
|
||||
# Browser Bird (Work in Progress!)
|
||||
|
||||
This project is still being worked on, but if you wish to help me beta test it, join the [Discord](https://discord.gg/6yxE9prcNc) and follow the installation instructions below!
|
||||
|
||||
1. Install [Tampermonkey](https://www.tampermonkey.net/) on your web browser
|
||||
2. Enable the Tampermonkey extension and give it the permissions requested
|
||||
3. Install my Pocket Bird script by going to this link and clicking install: [https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js](https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js)
|
||||
3. Install my Browser Bird script by going to this link and clicking install: [https://github.com/IdreesInc/Browser-Bird/raw/refs/heads/main/dist/birb.user.js](https://github.com/IdreesInc/Browser-Bird/raw/refs/heads/main/dist/birb.user.js)
|
||||
4. Now any websites you visit will have a little bird hopping around!
|
||||
543
birb.js
543
birb.js
@@ -1,5 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
// @ts-ignore
|
||||
const SHARED_CONFIG = {
|
||||
birbCssScale: 1,
|
||||
uiCssScale: 1,
|
||||
@@ -8,8 +9,9 @@ const SHARED_CONFIG = {
|
||||
hopDistance: 45,
|
||||
};
|
||||
|
||||
|
||||
const DESKTOP_CONFIG = {
|
||||
flySpeed: 0.25
|
||||
flySpeed: 0.2,
|
||||
};
|
||||
|
||||
const MOBILE_CONFIG = {
|
||||
@@ -26,6 +28,17 @@ const BIRB_CSS_SCALE = CONFIG.birbCssScale;
|
||||
const UI_CSS_SCALE = CONFIG.uiCssScale;
|
||||
const CANVAS_PIXEL_SIZE = CONFIG.canvasPixelSize;
|
||||
const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE;
|
||||
const HOP_SPEED = CONFIG.hopSpeed;
|
||||
const FLY_SPEED = CONFIG.flySpeed;
|
||||
const HOP_DISTANCE = CONFIG.hopDistance;
|
||||
// Time in milliseconds until the user is considered AFK
|
||||
const AFK_TIME = debugMode ? 0 : 1000 * 30;
|
||||
const SPRITE_HEIGHT = 32;
|
||||
const MENU_ID = "birb-menu";
|
||||
const MENU_EXIT_ID = "birb-menu-exit";
|
||||
const FIELD_GUIDE_ID = "birb-field-guide";
|
||||
const FEATHER_ID = "birb-feather";
|
||||
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
birbMode: false
|
||||
@@ -35,23 +48,6 @@ const DEFAULT_SETTINGS = {
|
||||
* @typedef {typeof DEFAULT_SETTINGS} Settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SavedStickyNote
|
||||
* @property {string} id
|
||||
* @property {string} site
|
||||
* @property {string} content
|
||||
* @property {number} top
|
||||
* @property {number} left
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BirbSaveData
|
||||
* @property {string[]} unlockedSpecies
|
||||
* @property {string} currentSpecies
|
||||
* @property {Partial<Settings>} settings
|
||||
* @property {SavedStickyNote[]} [stickyNotes]
|
||||
*/
|
||||
|
||||
/** @type {Partial<Settings>} */
|
||||
let userSettings = {};
|
||||
|
||||
@@ -384,7 +380,6 @@ const Directions = {
|
||||
};
|
||||
|
||||
const SPRITE_WIDTH = 32;
|
||||
const SPRITE_HEIGHT = 32;
|
||||
const DECORATIONS_SPRITE_WIDTH = 48;
|
||||
const FEATHER_SPRITE_WIDTH = 32;
|
||||
|
||||
@@ -392,34 +387,6 @@ const SPRITE_SHEET = "__SPRITE_SHEET__";
|
||||
const DECORATIONS_SPRITE_SHEET = "__DECORATIONS_SPRITE_SHEET__";
|
||||
const FEATHER_SPRITE_SHEET = "__FEATHER_SPRITE_SHEET__";
|
||||
|
||||
const MENU_ID = "birb-menu";
|
||||
const MENU_EXIT_ID = "birb-menu-exit";
|
||||
const FIELD_GUIDE_ID = "birb-field-guide";
|
||||
const FEATHER_ID = "birb-feather";
|
||||
|
||||
const HOP_SPEED = CONFIG.hopSpeed;
|
||||
const FLY_SPEED = CONFIG.flySpeed;
|
||||
const HOP_DISTANCE = CONFIG.hopDistance;
|
||||
/** Speed at which the feather falls per tick */
|
||||
const FEATHER_FALL_SPEED = 1;
|
||||
/** Time in milliseconds until the user is considered AFK */
|
||||
const AFK_TIME = debugMode ? 0 : 1000 * 30;
|
||||
const UPDATE_INTERVAL = 1000 / 60; // 60 FPS
|
||||
// Per-frame chances
|
||||
const HOP_CHANCE = 1 / (60 * 3); // 3 seconds
|
||||
const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // 20 seconds
|
||||
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // 2 hours
|
||||
/** Multiplier after petting that increases the feather drop chance */
|
||||
const PET_FEATHER_BOOST = 2;
|
||||
/** How long the pet boost lasts in milliseconds */
|
||||
const PET_BOOST_DURATION = 1000 * 60 * 5;
|
||||
const MIN_FOCUS_ELEMENT_WIDTH = 100;
|
||||
const MIN_FOCUS_ELEMENT_TOP = 80;
|
||||
/** Time between checking whether the URL has changed */
|
||||
const URL_CHECK_INTERVAL = 500;
|
||||
/** Time after petting before the menu can be opened */
|
||||
const PET_MENU_COOLDOWN = 1000;
|
||||
|
||||
/**
|
||||
* Load the sprite sheet and return the pixel-map template
|
||||
* @param {string} dataUri
|
||||
@@ -476,14 +443,8 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) {
|
||||
});
|
||||
}
|
||||
|
||||
log("Loading sprite sheets...");
|
||||
|
||||
Promise.all([
|
||||
loadSpriteSheetPixels(SPRITE_SHEET),
|
||||
loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false),
|
||||
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)
|
||||
]).then(([birbPixels, decorationPixels, featherPixels]) => {
|
||||
|
||||
// @ts-ignore
|
||||
Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false), loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)]).then(([birbPixels, decorationPixels, featherPixels]) => {
|
||||
const SPRITE_SHEET = birbPixels;
|
||||
const DECORATIONS_SPRITE_SHEET = decorationPixels;
|
||||
const FEATHER_SPRITE_SHEET = featherPixels;
|
||||
@@ -636,6 +597,8 @@ Promise.all([
|
||||
const menuItems = [
|
||||
new MenuItem(`Pet ${birdBirb()}`, pet),
|
||||
new MenuItem("Field Guide", insertFieldGuide),
|
||||
// new MenuItem("Decorations", insertDecoration),
|
||||
new DebugMenuItem("Applications", () => switchMenuItems(otherItems), false),
|
||||
new MenuItem("Sticky Note", newStickyNote),
|
||||
new MenuItem(`Hide ${birdBirb()}`, hideBirb),
|
||||
new DebugMenuItem("Freeze/Unfreeze", () => {
|
||||
@@ -664,11 +627,39 @@ Promise.all([
|
||||
})
|
||||
];
|
||||
|
||||
const otherItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(menuItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Video Games", () => switchMenuItems(gameItems), false),
|
||||
new MenuItem("Utilities", () => switchMenuItems(utilityItems), false),
|
||||
new MenuItem("Music Player", () => insertMusicPlayer(), false),
|
||||
];
|
||||
|
||||
const gameItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(otherItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Pinball", () => insertPico8("Terra Nova Pinball", "terra_nova_pinball")),
|
||||
new MenuItem("Pico Dino", () => insertPico8("Pico Dino", "picodino")),
|
||||
new MenuItem("Woodworm", () => insertPico8("Woodworm", "woodworm")),
|
||||
new MenuItem("Pico and Chill", () => insertPico8("Pico and Chill", "picochill")),
|
||||
new MenuItem("Lani's Trek", () => insertPico8("Celeste 2 Lani's Trek", "celeste_classic_2")),
|
||||
new MenuItem("Tetraminis", () => insertPico8("Tetraminis", "tetraminisdeffect")),
|
||||
// new MenuItem("Pool", () => insertPico8("Pool", "mot_pool")),
|
||||
];
|
||||
|
||||
const utilityItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(otherItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Pomodoro Timer", () => insertPico8("Pomodoro", "pomodorotimerv1")),
|
||||
new MenuItem("Ohm Calculator", () => insertPico8("Resistor Calculator", "picoohm")),
|
||||
new MenuItem("Wobblepaint ", () => insertPico8("Wobblepaint", "wobblepaint")),
|
||||
];
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const States = {
|
||||
@@ -685,7 +676,7 @@ Promise.all([
|
||||
let ticks = 0;
|
||||
// Bird's current position
|
||||
let birdY = 0;
|
||||
let birdX = 40;
|
||||
let birdX = 0;
|
||||
// Bird's starting position (when flying)
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
@@ -694,7 +685,6 @@ Promise.all([
|
||||
let targetY = 0;
|
||||
/** @type {HTMLElement|null} */
|
||||
let focusedElement = null;
|
||||
let focusedBounds = { left: 0, right: 0, top: 0 };
|
||||
let lastActionTimestamp = Date.now();
|
||||
/** @type {number[]} */
|
||||
let petStack = [];
|
||||
@@ -705,11 +695,12 @@ Promise.all([
|
||||
/** @type {StickyNote[]} */
|
||||
let stickyNotes = [];
|
||||
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the script is running in a userscript extension context
|
||||
*/
|
||||
function isUserScript() {
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
return typeof GM_getValue === "function";
|
||||
}
|
||||
|
||||
@@ -722,10 +713,9 @@ Promise.all([
|
||||
function load() {
|
||||
/** @type {Record<string, any>} */
|
||||
let saveData = {};
|
||||
|
||||
if (isUserScript()) {
|
||||
log("Loading save data from UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
saveData = GM_getValue("birbSaveData", {}) ?? {};
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, loading save data from localStorage");
|
||||
@@ -733,18 +723,14 @@ Promise.all([
|
||||
} else {
|
||||
log("Not a UserScript");
|
||||
}
|
||||
|
||||
debug("Loaded data: " + JSON.stringify(saveData));
|
||||
|
||||
if (!saveData.settings) {
|
||||
log("No user settings found in save data, starting fresh");
|
||||
}
|
||||
|
||||
userSettings = saveData.settings ?? {};
|
||||
unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD];
|
||||
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
|
||||
stickyNotes = [];
|
||||
|
||||
if (saveData.stickyNotes) {
|
||||
for (let note of saveData.stickyNotes) {
|
||||
if (note.id) {
|
||||
@@ -752,19 +738,17 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(stickyNotes.length + " sticky notes loaded");
|
||||
switchSpecies(currentSpecies);
|
||||
}
|
||||
|
||||
function save() {
|
||||
/** @type {BirbSaveData} */
|
||||
const saveData = {
|
||||
unlockedSpecies,
|
||||
currentSpecies,
|
||||
/** @type {Record<string, any>} */
|
||||
let saveData = {
|
||||
unlockedSpecies: unlockedSpecies,
|
||||
currentSpecies: currentSpecies,
|
||||
settings: userSettings
|
||||
};
|
||||
|
||||
if (stickyNotes.length > 0) {
|
||||
saveData.stickyNotes = stickyNotes.map(note => ({
|
||||
id: note.id,
|
||||
@@ -774,10 +758,9 @@ Promise.all([
|
||||
left: note.left
|
||||
}));
|
||||
}
|
||||
|
||||
if (isUserScript()) {
|
||||
log("Saving data to UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
GM_setValue("birbSaveData", saveData);
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, saving data to localStorage");
|
||||
@@ -790,7 +773,7 @@ Promise.all([
|
||||
function resetSaveData() {
|
||||
if (isUserScript()) {
|
||||
log("Resetting save data in UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
GM_deleteValue("birbSaveData");
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, resetting save data in localStorage");
|
||||
@@ -819,10 +802,8 @@ Promise.all([
|
||||
function init() {
|
||||
if (window !== window.top) {
|
||||
// Skip installation if within an iframe
|
||||
log("In iframe, skipping Birb script initialization");
|
||||
return;
|
||||
}
|
||||
log("Sprite sheets loaded successfully, initializing bird...");
|
||||
|
||||
// Preload font
|
||||
const MONOCRAFT_SRC = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf";
|
||||
@@ -854,8 +835,20 @@ Promise.all([
|
||||
canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE;
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
let scrollTimeout;
|
||||
window.addEventListener("scroll", () => {
|
||||
// TODO: Only do this if focused on the ground
|
||||
if (focusedElement === null && currentState !== States.FLYING) {
|
||||
canvas.style.transition = "opacity 0.2s";
|
||||
canvas.style.opacity = "0";
|
||||
}
|
||||
lastActionTimestamp = Date.now();
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
canvas.style.transition = "opacity 0.4s";
|
||||
canvas.style.opacity = "1";
|
||||
}, 100);
|
||||
});
|
||||
|
||||
onClick(document, (e) => {
|
||||
@@ -866,10 +859,6 @@ Promise.all([
|
||||
});
|
||||
|
||||
onClick(canvas, () => {
|
||||
if (currentAnimation === Animations.HEART && (Date.now() - lastPetTimestamp < PET_MENU_COOLDOWN)) {
|
||||
// Currently being pet, don't open menu
|
||||
return;
|
||||
}
|
||||
insertMenu();
|
||||
});
|
||||
|
||||
@@ -882,17 +871,13 @@ Promise.all([
|
||||
}
|
||||
const pets = petStack.filter((time) => Date.now() - time < 1000).length;
|
||||
if (pets >= 3) {
|
||||
pet();
|
||||
setAnimation(Animations.HEART);
|
||||
// Clear the stack
|
||||
petStack = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("touchmove", (e) => {
|
||||
pet();
|
||||
});
|
||||
|
||||
drawStickyNotes();
|
||||
|
||||
let lastUrl = (window.location.href ?? "").split("?")[0];
|
||||
@@ -903,11 +888,31 @@ Promise.all([
|
||||
lastUrl = currentUrl;
|
||||
drawStickyNotes();
|
||||
}
|
||||
}, URL_CHECK_INTERVAL);
|
||||
}, 500);
|
||||
|
||||
setInterval(update, UPDATE_INTERVAL);
|
||||
setInterval(update, 1000 / 60);
|
||||
|
||||
birdY = getWindowBottom();
|
||||
|
||||
// TODO: For testing only
|
||||
hop();
|
||||
}
|
||||
|
||||
function drawStickyNotes() {
|
||||
// Remove all existing sticky notes
|
||||
const existingNotes = document.querySelectorAll(".birb-sticky-note");
|
||||
existingNotes.forEach(note => note.remove());
|
||||
// Render all sticky notes
|
||||
for (let stickyNote of stickyNotes) {
|
||||
if (isStickyNoteApplicable(stickyNote)) {
|
||||
renderStickyNote(stickyNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the bird's behavior logic
|
||||
*/
|
||||
function update() {
|
||||
ticks++;
|
||||
|
||||
@@ -917,29 +922,18 @@ Promise.all([
|
||||
// Won't be restored on fullscreen exit
|
||||
}
|
||||
|
||||
if (currentState === States.IDLE && !frozen && !isMenuOpen()) {
|
||||
if (Math.random() < HOP_CHANCE && currentAnimation !== Animations.HEART) {
|
||||
if (currentState === States.IDLE) {
|
||||
if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART && !isMenuOpen()) {
|
||||
hop();
|
||||
} else if (Date.now() - lastActionTimestamp > AFK_TIME) {
|
||||
// Idle for a while, do something
|
||||
if (focusedElement === null) {
|
||||
// Fly to an element
|
||||
} else if (Math.random() < 1 / (60 * 20) && Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
|
||||
focusOnElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
} else if (Math.random() < FOCUS_SWITCH_CHANCE) {
|
||||
// Fly to another element if idle for a longer while
|
||||
focusOnElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
}
|
||||
}
|
||||
} else if (currentState === States.HOP) {
|
||||
if (updateParabolicPath(HOP_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // 1 every 2 hours (ticks * seconds * minutes * hours)
|
||||
// Double the chance of a feather if recently pet
|
||||
const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1;
|
||||
let petMod = Date.now() - lastPetTimestamp < 1000 * 60 * 5 ? 2 : 1;
|
||||
if (visible && Math.random() < FEATHER_CHANCE * petMod) {
|
||||
lastPetTimestamp = 0;
|
||||
activateFeather();
|
||||
@@ -947,6 +941,9 @@ Promise.all([
|
||||
updateFeather();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the bird in the dom and update its position if necessary
|
||||
*/
|
||||
function draw() {
|
||||
requestAnimationFrame(draw);
|
||||
|
||||
@@ -954,29 +951,41 @@ Promise.all([
|
||||
return;
|
||||
}
|
||||
|
||||
updateFocusedElementBounds();
|
||||
|
||||
// Update the bird's position
|
||||
if (currentState === States.IDLE) {
|
||||
if (focusedElement && !isWithinHorizontalBounds()) {
|
||||
if (focusedElement !== null) {
|
||||
birdY = getFocusedElementY();
|
||||
if (!isWithinHorizontalBounds(birdX)) {
|
||||
focusOnGround();
|
||||
}
|
||||
birdY = getFocusedY();
|
||||
} else {
|
||||
// Ground the bird
|
||||
birdY = getWindowBottom();
|
||||
}
|
||||
} else if (currentState === States.FLYING) {
|
||||
// Fly to target location (even if in the air)
|
||||
if (updateParabolicPath(FLY_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
} else if (currentState === States.HOP) {
|
||||
if (updateParabolicPath(HOP_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
const oldTargetY = targetY;
|
||||
targetY = getFocusedY();
|
||||
// Adjust startY to account for scrolling
|
||||
startY += targetY - oldTargetY;
|
||||
if (targetY < 0 || targetY > window.innerHeight) {
|
||||
if (focusedElement === null) {
|
||||
if (Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
|
||||
// Fly to an element if the user is AFK
|
||||
// focusOnElement();
|
||||
// lastActionTimestamp = Date.now();
|
||||
}
|
||||
} else if (focusedElement !== null) {
|
||||
targetY = getFocusedElementY();
|
||||
if (targetY < window.scrollY || targetY > window.scrollY + window.innerHeight) {
|
||||
// Fly to ground if the focused element moves out of bounds
|
||||
focusOnGround();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (currentAnimation.draw(ctx, direction, animStart, species[currentSpecies])) {
|
||||
@@ -988,6 +997,9 @@ Promise.all([
|
||||
setY(birdY);
|
||||
}
|
||||
|
||||
init();
|
||||
draw();
|
||||
|
||||
function newStickyNote() {
|
||||
const id = Date.now().toString();
|
||||
const site = window.location.href;
|
||||
@@ -1088,8 +1100,17 @@ Promise.all([
|
||||
return false;
|
||||
}
|
||||
|
||||
const stickyNoteParams = parseUrlParams(stickyNoteUrl);
|
||||
const currentParams = parseUrlParams(currentUrl);
|
||||
/** @type {Record<string, string>} */
|
||||
const stickyNoteParams = stickyNoteUrl.split("?")[1]?.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const currentParams = currentUrl.split("?")[1]?.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
|
||||
debug("Comparing params: ", stickyNoteParams, currentParams);
|
||||
|
||||
@@ -1101,18 +1122,6 @@ Promise.all([
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawStickyNotes() {
|
||||
// Remove all existing sticky notes
|
||||
const existingNotes = document.querySelectorAll(".birb-sticky-note");
|
||||
existingNotes.forEach(note => note.remove());
|
||||
// Render all sticky notes
|
||||
for (let stickyNote of stickyNotes) {
|
||||
if (isStickyNoteApplicable(stickyNote)) {
|
||||
renderStickyNote(stickyNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTML element with the specified parameters
|
||||
* @param {string} className
|
||||
@@ -1132,42 +1141,6 @@ Promise.all([
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a window element with header and content
|
||||
* @param {string} id
|
||||
* @param {string} title
|
||||
* @param {string} contentHtml
|
||||
* @param {() => void} [onClose]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createWindow(id, title, contentHtml, onClose) {
|
||||
const window = makeElement("birb-window", undefined, id);
|
||||
window.innerHTML = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${title}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content">
|
||||
${contentHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(window);
|
||||
makeDraggable(window.querySelector(".birb-window-header"));
|
||||
|
||||
const closeButton = window.querySelector(".birb-window-close");
|
||||
if (closeButton) {
|
||||
makeClosable(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
window.remove();
|
||||
}, closeButton);
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
function insertDecoration() {
|
||||
// Create a canvas element for the decoration
|
||||
const decorationCanvas = document.createElement("canvas");
|
||||
@@ -1247,16 +1220,21 @@ Promise.all([
|
||||
|
||||
function updateFeather() {
|
||||
const feather = document.querySelector("#birb-feather");
|
||||
const featherGravity = 1;
|
||||
if (!feather || !(feather instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const y = parseInt(feather.style.top || "0") + FEATHER_FALL_SPEED;
|
||||
const y = parseInt(feather.style.top || "0") + featherGravity;
|
||||
feather.style.top = `${Math.min(y, window.innerHeight - feather.offsetHeight)}px`;
|
||||
if (y < window.innerHeight - feather.offsetHeight) {
|
||||
feather.style.left = `${Math.sin(3.14 * 2 * (ticks / 120)) * 25}px`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// insertDecoration();
|
||||
// insertFieldGuide();
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
@@ -1273,14 +1251,28 @@ Promise.all([
|
||||
if (document.querySelector("#" + FIELD_GUIDE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = createWindow("birb-modal", title, `
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${title}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content">
|
||||
<div class="birb-message-content">
|
||||
${message}
|
||||
</div>
|
||||
`);
|
||||
|
||||
</div>`
|
||||
const modal = makeElement("birb-window");
|
||||
modal.style.width = "270px";
|
||||
modal.innerHTML = html;
|
||||
document.body.appendChild(modal);
|
||||
makeDraggable(modal.querySelector(".birb-window-header"));
|
||||
|
||||
const closeButton = modal.querySelector(".birb-window-close");
|
||||
if (closeButton) {
|
||||
makeClosable(() => {
|
||||
modal.remove();
|
||||
}, closeButton);
|
||||
}
|
||||
centerElement(modal);
|
||||
}
|
||||
|
||||
@@ -1389,10 +1381,60 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
|
||||
// insertPico8();
|
||||
|
||||
function isSafari() {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} pid
|
||||
*/
|
||||
function insertPico8(name, pid) {
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${name}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content birb-pico-8-content">
|
||||
<iframe src="https://www.lexaloffle.com/bbs/widget.php?pid=${pid}" scrolling='${isSafari() ? "yes" : "no"}'></iframe>
|
||||
</div>`
|
||||
const pico8 = makeElement("birb-window");
|
||||
pico8.innerHTML = html;
|
||||
document.body.appendChild(pico8);
|
||||
makeDraggable(pico8.querySelector(".birb-window-header"));
|
||||
const close = pico8.querySelector(".birb-window-close");
|
||||
if (close) {
|
||||
makeClosable(() => {
|
||||
pico8.remove();
|
||||
}, close);
|
||||
}
|
||||
centerElement(pico8);
|
||||
}
|
||||
|
||||
function insertMusicPlayer() {
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">Music Player</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content birb-music-player-content">
|
||||
<iframe style="border-radius:12px" src="https://open.spotify.com/embed/playlist/31FWVQBp3WQydWLNhO0ACi?utm_source=generator" width="250" height="352" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
|
||||
</div>`;
|
||||
const pico8 = makeElement("birb-window");
|
||||
pico8.innerHTML = html;
|
||||
document.body.appendChild(pico8);
|
||||
makeDraggable(pico8.querySelector(".birb-window-header"));
|
||||
const close = pico8.querySelector(".birb-window-close");
|
||||
if (close) {
|
||||
makeClosable(() => {
|
||||
pico8.remove();
|
||||
}, close);
|
||||
}
|
||||
centerElement(pico8);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
@@ -1506,23 +1548,7 @@ Promise.all([
|
||||
*/
|
||||
function onClick(element, action) {
|
||||
element.addEventListener("click", (e) => action(e));
|
||||
element.addEventListener("touchend", (e) => {
|
||||
if (e instanceof TouchEvent === false) {
|
||||
return;
|
||||
} else if (element instanceof HTMLElement === false) {
|
||||
return;
|
||||
}
|
||||
const touch = e.changedTouches[0];
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
touch.clientX >= rect.left &&
|
||||
touch.clientX <= rect.right &&
|
||||
touch.clientY >= rect.top &&
|
||||
touch.clientY <= rect.bottom
|
||||
) {
|
||||
action(e);
|
||||
}
|
||||
});
|
||||
element.addEventListener("touchstart", (e) => action(e));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1656,70 +1682,63 @@ Promise.all([
|
||||
}
|
||||
|
||||
function getFocusedElementRandomX() {
|
||||
return Math.random() * (focusedBounds.right - focusedBounds.left) + focusedBounds.left;
|
||||
if (focusedElement === null) {
|
||||
return Math.random() * window.innerWidth;
|
||||
}
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return Math.random() * (rect.right - rect.left) + rect.left + window.scrollX;
|
||||
}
|
||||
|
||||
function isWithinHorizontalBounds() {
|
||||
return birdX >= focusedBounds.left && birdX <= focusedBounds.right;
|
||||
function getFocusedElementY() {
|
||||
if (focusedElement === null) {
|
||||
return getWindowBottom();
|
||||
}
|
||||
|
||||
function getFocusedY() {
|
||||
return getFullWindowHeight() - focusedBounds.top;
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The render-safe height of the inner browser window
|
||||
* @param {number} x
|
||||
* @returns {boolean} Whether the x coordinate is within the horizontal bounds of the focused element
|
||||
*/
|
||||
function getSafeWindowHeight() {
|
||||
// Necessary because iOS 26 Safari is terrible and won't render
|
||||
// fixed elements behind the address bar
|
||||
return window.innerHeight;
|
||||
function isWithinHorizontalBounds(x) {
|
||||
if (focusedElement === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The true height of the inner browser window
|
||||
*/
|
||||
function getFullWindowHeight() {
|
||||
return document.documentElement.clientHeight;
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return x >= rect.left && x <= rect.right;
|
||||
}
|
||||
|
||||
function focusOnGround() {
|
||||
if (focusedElement === null) {
|
||||
// Already focused on ground
|
||||
return;
|
||||
}
|
||||
console.log("Focusing on ground");
|
||||
focusedElement = null;
|
||||
focusedBounds = { left: 0, right: window.innerWidth, top: getSafeWindowHeight() };
|
||||
flyTo(Math.random() * window.innerWidth, 0);
|
||||
flyTo(Math.random() * window.innerWidth, getWindowBottom());
|
||||
}
|
||||
|
||||
function focusOnElement() {
|
||||
if (frozen) {
|
||||
return;
|
||||
}
|
||||
console.log("Focusing on element");
|
||||
const elements = document.querySelectorAll("img, video");
|
||||
const inWindow = Array.from(elements).filter((img) => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight;
|
||||
return rect.left >= 0 && rect.top >= 80 && rect.right <= window.innerWidth && rect.top <= window.innerHeight;
|
||||
});
|
||||
const MIN_WIDTH = 100;
|
||||
/** @type {HTMLElement[]} */
|
||||
// @ts-expect-error
|
||||
const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
|
||||
// @ts-ignore
|
||||
const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH);
|
||||
if (largeElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)];
|
||||
focusedElement = randomElement;
|
||||
log("Focusing on element: ", focusedElement);
|
||||
updateFocusedElementBounds();
|
||||
flyTo(getFocusedElementRandomX(), getFocusedY());
|
||||
}
|
||||
|
||||
function updateFocusedElementBounds() {
|
||||
if (focusedElement === null) {
|
||||
// Update ground location to bottom of window
|
||||
focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() };
|
||||
return;
|
||||
}
|
||||
const { left, right, top } = focusedElement.getBoundingClientRect();
|
||||
focusedBounds = { left, right, top };
|
||||
flyTo(getFocusedElementRandomX(), getFocusedElementY());
|
||||
}
|
||||
|
||||
function getCanvasWidth() {
|
||||
@@ -1730,24 +1749,40 @@ Promise.all([
|
||||
return canvas.height * BIRB_CSS_SCALE
|
||||
}
|
||||
|
||||
function getWindowBottom() {
|
||||
return window.scrollY + window.innerHeight;
|
||||
}
|
||||
|
||||
function hop() {
|
||||
if (frozen) {
|
||||
return;
|
||||
}
|
||||
if (currentState === States.IDLE) {
|
||||
// Determine bounds for hopping
|
||||
let minX = 0;
|
||||
let maxX = window.innerWidth;
|
||||
let y = getWindowBottom();
|
||||
if (focusedElement !== null) {
|
||||
// Hop on the element
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
minX = rect.left;
|
||||
maxX = rect.right;
|
||||
y = window.innerHeight - rect.top;
|
||||
}
|
||||
setState(States.HOP);
|
||||
setAnimation(Animations.FLYING);
|
||||
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
|
||||
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > minX) || birdX + HOP_DISTANCE > maxX) {
|
||||
targetX = birdX - HOP_DISTANCE;
|
||||
} else {
|
||||
targetX = birdX + HOP_DISTANCE;
|
||||
}
|
||||
targetY = getFocusedY();
|
||||
targetY = y;
|
||||
console.log("hopping from", birdX, birdY, "to", targetX, targetY);
|
||||
}
|
||||
}
|
||||
|
||||
function pet() {
|
||||
if (currentState === States.IDLE && currentAnimation !== Animations.HEART) {
|
||||
if (currentState === States.IDLE) {
|
||||
setAnimation(Animations.HEART);
|
||||
lastPetTimestamp = Date.now();
|
||||
}
|
||||
@@ -1763,19 +1798,13 @@ Promise.all([
|
||||
* @param {number} y
|
||||
*/
|
||||
function flyTo(x, y) {
|
||||
console.log("Flying to", x, y);
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
setState(States.FLYING);
|
||||
setAnimation(Animations.FLYING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the bird should be absolutely positioned
|
||||
*/
|
||||
function isAbsolute() {
|
||||
return focusedElement !== null && (currentState === States.IDLE || currentState === States.HOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current animation and reset the animation timer
|
||||
* @param {Anim} animation
|
||||
@@ -1790,6 +1819,7 @@ Promise.all([
|
||||
* @param {string} state
|
||||
*/
|
||||
function setState(state) {
|
||||
console.log("State:", state);
|
||||
stateStart = Date.now();
|
||||
startX = birdX;
|
||||
startY = birdY;
|
||||
@@ -1797,38 +1827,36 @@ Promise.all([
|
||||
if (state === States.IDLE) {
|
||||
setAnimation(Animations.BOB);
|
||||
}
|
||||
if (isAbsolute()) {
|
||||
canvas.classList.add("birb-absolute");
|
||||
} else {
|
||||
canvas.classList.remove("birb-absolute");
|
||||
}
|
||||
setY(birdY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bird element's X position, with the element origin at the center of the bird
|
||||
* @param {number} x
|
||||
*/
|
||||
function setX(x) {
|
||||
let mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2));
|
||||
const mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2));
|
||||
canvas.style.left = `${x + mod}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bird element's Y position, with the element origin at the bottom of the bird
|
||||
* @param {number} y
|
||||
*/
|
||||
function setY(y) {
|
||||
let bottom;
|
||||
if (isAbsolute()) {
|
||||
// Position is absolute, convert from fixed
|
||||
bottom = y - window.scrollY;
|
||||
} else {
|
||||
// Position is fixed
|
||||
bottom = y;
|
||||
}
|
||||
canvas.style.bottom = `${bottom}px`;
|
||||
const mod = getCanvasHeight() + WINDOW_PIXEL_SIZE;
|
||||
canvas.style.top = `${y - mod}px`;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
/**
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @param {number} amount
|
||||
* @returns {number}
|
||||
*/
|
||||
function linearLerp(start, end, amount) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} startX
|
||||
@@ -1845,7 +1873,7 @@ Promise.all([
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const midX = startX + Math.cos(angle) * distance / 2;
|
||||
const midY = startY + Math.sin(angle) * distance / 2 + distance / 4 * intensity;
|
||||
const midY = startY + Math.sin(angle) * distance / 2 - distance / 4 * intensity;
|
||||
const t = amount;
|
||||
const x = (1 - t) ** 2 * startX + 2 * (1 - t) * t * midX + t ** 2 * endX;
|
||||
const y = (1 - t) ** 2 * startY + 2 * (1 - t) * t * midY + t ** 2 * endY;
|
||||
@@ -1853,27 +1881,12 @@ Promise.all([
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL parameters into a key-value map
|
||||
* @param {string} url
|
||||
* @returns {Record<string, string>}
|
||||
* @param {number} value
|
||||
*/
|
||||
function parseUrlParams(url) {
|
||||
const queryString = url.split("?")[1];
|
||||
if (!queryString) return {};
|
||||
|
||||
return queryString.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
function roundToPixel(value) {
|
||||
return Math.round(value / WINDOW_PIXEL_SIZE) * WINDOW_PIXEL_SIZE;
|
||||
}
|
||||
|
||||
// Run the birb
|
||||
init();
|
||||
draw();
|
||||
}).catch((e) => {
|
||||
error("Error while loading sprite sheets: ", e);
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the user is on a mobile device
|
||||
*/
|
||||
|
||||
33
build.js
33
build.js
@@ -20,48 +20,27 @@ const spriteSheets = [
|
||||
const STYLESHEET_PATH = "./stylesheet.css";
|
||||
const STYLESHEET_KEY = "___STYLESHEET___";
|
||||
|
||||
const now = new Date();
|
||||
const versionDate = `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
|
||||
|
||||
// Get current build number from manifest.json
|
||||
let buildNumber = 0;
|
||||
let version = "0.0.0";
|
||||
// Try to read version from manifest.json
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
|
||||
if (manifest.version) {
|
||||
if (manifest.version.startsWith(versionDate)) {
|
||||
// Same day, increment build number
|
||||
const parts = manifest.version.split('.');
|
||||
if (parts.length === 4) {
|
||||
buildNumber = parseInt(parts[3], 10) + 1;
|
||||
}
|
||||
}
|
||||
version = manifest.version;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Could not read version from manifest.json");
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Update manifest.json with new version
|
||||
const version = `${versionDate}.${buildNumber}`;
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
|
||||
manifest.version = version;
|
||||
writeFileSync('manifest.json', JSON.stringify(manifest, null, 4), 'utf8');
|
||||
} catch (e) {
|
||||
console.error("Could not update version in manifest.json");
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
||||
const userScriptHeader =
|
||||
`// ==UserScript==
|
||||
// @name Pocket Bird
|
||||
// @name Browser Bird
|
||||
// @namespace https://idreesinc.com
|
||||
// @version ${version}
|
||||
// @description birb
|
||||
// @author Idrees
|
||||
// @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @updateURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @downloadURL https://github.com/IdreesInc/Browser-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @updateURL https://github.com/IdreesInc/Browser-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @match *://*/*
|
||||
// @grant GM_setValue
|
||||
// @grant GM_getValue
|
||||
|
||||
560
dist/birb.js
vendored
560
dist/birb.js
vendored
@@ -1,5 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
// @ts-ignore
|
||||
const SHARED_CONFIG = {
|
||||
birbCssScale: 1,
|
||||
uiCssScale: 1,
|
||||
@@ -8,8 +9,9 @@ const SHARED_CONFIG = {
|
||||
hopDistance: 45,
|
||||
};
|
||||
|
||||
|
||||
const DESKTOP_CONFIG = {
|
||||
flySpeed: 0.25
|
||||
flySpeed: 0.2,
|
||||
};
|
||||
|
||||
const MOBILE_CONFIG = {
|
||||
@@ -26,6 +28,17 @@ const BIRB_CSS_SCALE = CONFIG.birbCssScale;
|
||||
const UI_CSS_SCALE = CONFIG.uiCssScale;
|
||||
const CANVAS_PIXEL_SIZE = CONFIG.canvasPixelSize;
|
||||
const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE;
|
||||
const HOP_SPEED = CONFIG.hopSpeed;
|
||||
const FLY_SPEED = CONFIG.flySpeed;
|
||||
const HOP_DISTANCE = CONFIG.hopDistance;
|
||||
// Time in milliseconds until the user is considered AFK
|
||||
const AFK_TIME = debugMode ? 0 : 1000 * 30;
|
||||
const SPRITE_HEIGHT = 32;
|
||||
const MENU_ID = "birb-menu";
|
||||
const MENU_EXIT_ID = "birb-menu-exit";
|
||||
const FIELD_GUIDE_ID = "birb-field-guide";
|
||||
const FEATHER_ID = "birb-feather";
|
||||
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
birbMode: false
|
||||
@@ -35,23 +48,6 @@ const DEFAULT_SETTINGS = {
|
||||
* @typedef {typeof DEFAULT_SETTINGS} Settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SavedStickyNote
|
||||
* @property {string} id
|
||||
* @property {string} site
|
||||
* @property {string} content
|
||||
* @property {number} top
|
||||
* @property {number} left
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BirbSaveData
|
||||
* @property {string[]} unlockedSpecies
|
||||
* @property {string} currentSpecies
|
||||
* @property {Partial<Settings>} settings
|
||||
* @property {SavedStickyNote[]} [stickyNotes]
|
||||
*/
|
||||
|
||||
/** @type {Partial<Settings>} */
|
||||
let userSettings = {};
|
||||
|
||||
@@ -70,18 +66,13 @@ const STYLESHEET = `:root {
|
||||
|
||||
#birb {
|
||||
image-rendering: pixelated;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
transform: scale(var(--birb-scale)) !important;
|
||||
transform-origin: bottom;
|
||||
z-index: 2147483638 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.birb-absolute {
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
.birb-decoration {
|
||||
image-rendering: pixelated;
|
||||
position: fixed;
|
||||
@@ -298,8 +289,8 @@ const STYLESHEET = `:root {
|
||||
flex-direction: row;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -346,12 +337,12 @@ const STYLESHEET = `:root {
|
||||
}
|
||||
|
||||
.birb-field-guide-description {
|
||||
width: calc(100% - 20px);
|
||||
margin-top: 5px;
|
||||
width: calc(100% - 16px);
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
color: rgb(124, 108, 75);
|
||||
@@ -727,7 +718,6 @@ const Directions = {
|
||||
};
|
||||
|
||||
const SPRITE_WIDTH = 32;
|
||||
const SPRITE_HEIGHT = 32;
|
||||
const DECORATIONS_SPRITE_WIDTH = 48;
|
||||
const FEATHER_SPRITE_WIDTH = 32;
|
||||
|
||||
@@ -735,34 +725,6 @@ const SPRITE_SHEET = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAgCAYA
|
||||
const DECORATIONS_SPRITE_SHEET = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAPNJREFUaIHtmTESgzAMBHWZDC+gp0vP/x9Bn44+L6BRmrhJA4csM05uGzfY1s1JxggzIYQQQgghxEnATnB3zwikAICKiXq4BE/uwaxvn/UPb3BnNwFg27Ky0w6vzRp8S4mkIbQD3wzzFJofdTMkYJgn89czFADGKSSiSgphfFBjTaoIKC4cHWvSxIFMmjiQSYoDLUlxoCVywOwHHWjpROop1IL/vsxty2oYO77M1QggSvcpJAFXE66BPfa+2C4v4j2yi7z7FJKAq6FrwN3TO3MMlAAAKO3F2sVZTiu2N9p9CnUv4FR7PbMG2BQ69SJL/kVA8QauAnHUj36BVwAAAABJRU5ErkJggg==";
|
||||
const FEATHER_SPRITE_SHEET = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAARhJREFUWIXtlbENwjAQRf8hSiZIRQ+9WQNRUFIAKzACBSsAA1Ag1mAABqCCBomG3hQQ9OMEx4ZDNH5SikSJ3/fZ5wCJRCKRSPwZ0RzMWmtLAhGvQyUAi9mXP/aFaGjJRQQiguHihMvcFMJUVUYlAMuHixPGy4en1WmVQqgHYHkuZjiEj6a2/LjtYzTY0eiZbgC37Mxh1UN3sn/dr6cCz/LHB/DJj9s+2oMdbtdz6TtfFwQHcMvOInfmQNjsgchNWLXmdfK6gyioAu/6uKrsm1kWLAciKuCuey5nYuXAh234bdmZ6INIUw4E/Ix49xtjCmXfzLL8nY/ktdgnAKwxxgIoXIyqmAOwvIqfiN0ALNd21HYBO9XXGMAdnZTYyHWzWjQAAAAASUVORK5CYII=";
|
||||
|
||||
const MENU_ID = "birb-menu";
|
||||
const MENU_EXIT_ID = "birb-menu-exit";
|
||||
const FIELD_GUIDE_ID = "birb-field-guide";
|
||||
const FEATHER_ID = "birb-feather";
|
||||
|
||||
const HOP_SPEED = CONFIG.hopSpeed;
|
||||
const FLY_SPEED = CONFIG.flySpeed;
|
||||
const HOP_DISTANCE = CONFIG.hopDistance;
|
||||
/** Speed at which the feather falls per tick */
|
||||
const FEATHER_FALL_SPEED = 1;
|
||||
/** Time in milliseconds until the user is considered AFK */
|
||||
const AFK_TIME = debugMode ? 0 : 1000 * 30;
|
||||
const UPDATE_INTERVAL = 1000 / 60; // 60 FPS
|
||||
// Per-frame chances
|
||||
const HOP_CHANCE = 1 / (60 * 3); // 3 seconds
|
||||
const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // 20 seconds
|
||||
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // 2 hours
|
||||
/** Multiplier after petting that increases the feather drop chance */
|
||||
const PET_FEATHER_BOOST = 2;
|
||||
/** How long the pet boost lasts in milliseconds */
|
||||
const PET_BOOST_DURATION = 1000 * 60 * 5;
|
||||
const MIN_FOCUS_ELEMENT_WIDTH = 100;
|
||||
const MIN_FOCUS_ELEMENT_TOP = 80;
|
||||
/** Time between checking whether the URL has changed */
|
||||
const URL_CHECK_INTERVAL = 500;
|
||||
/** Time after petting before the menu can be opened */
|
||||
const PET_MENU_COOLDOWN = 1000;
|
||||
|
||||
/**
|
||||
* Load the sprite sheet and return the pixel-map template
|
||||
* @param {string} dataUri
|
||||
@@ -819,14 +781,8 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) {
|
||||
});
|
||||
}
|
||||
|
||||
log("Loading sprite sheets...");
|
||||
|
||||
Promise.all([
|
||||
loadSpriteSheetPixels(SPRITE_SHEET),
|
||||
loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false),
|
||||
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)
|
||||
]).then(([birbPixels, decorationPixels, featherPixels]) => {
|
||||
|
||||
// @ts-ignore
|
||||
Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false), loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)]).then(([birbPixels, decorationPixels, featherPixels]) => {
|
||||
const SPRITE_SHEET = birbPixels;
|
||||
const DECORATIONS_SPRITE_SHEET = decorationPixels;
|
||||
const FEATHER_SPRITE_SHEET = featherPixels;
|
||||
@@ -979,6 +935,8 @@ Promise.all([
|
||||
const menuItems = [
|
||||
new MenuItem(`Pet ${birdBirb()}`, pet),
|
||||
new MenuItem("Field Guide", insertFieldGuide),
|
||||
// new MenuItem("Decorations", insertDecoration),
|
||||
new DebugMenuItem("Applications", () => switchMenuItems(otherItems), false),
|
||||
new MenuItem("Sticky Note", newStickyNote),
|
||||
new MenuItem(`Hide ${birdBirb()}`, hideBirb),
|
||||
new DebugMenuItem("Freeze/Unfreeze", () => {
|
||||
@@ -1007,11 +965,39 @@ Promise.all([
|
||||
})
|
||||
];
|
||||
|
||||
const otherItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(menuItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Video Games", () => switchMenuItems(gameItems), false),
|
||||
new MenuItem("Utilities", () => switchMenuItems(utilityItems), false),
|
||||
new MenuItem("Music Player", () => insertMusicPlayer(), false),
|
||||
];
|
||||
|
||||
const gameItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(otherItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Pinball", () => insertPico8("Terra Nova Pinball", "terra_nova_pinball")),
|
||||
new MenuItem("Pico Dino", () => insertPico8("Pico Dino", "picodino")),
|
||||
new MenuItem("Woodworm", () => insertPico8("Woodworm", "woodworm")),
|
||||
new MenuItem("Pico and Chill", () => insertPico8("Pico and Chill", "picochill")),
|
||||
new MenuItem("Lani's Trek", () => insertPico8("Celeste 2 Lani's Trek", "celeste_classic_2")),
|
||||
new MenuItem("Tetraminis", () => insertPico8("Tetraminis", "tetraminisdeffect")),
|
||||
// new MenuItem("Pool", () => insertPico8("Pool", "mot_pool")),
|
||||
];
|
||||
|
||||
const utilityItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(otherItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Pomodoro Timer", () => insertPico8("Pomodoro", "pomodorotimerv1")),
|
||||
new MenuItem("Ohm Calculator", () => insertPico8("Resistor Calculator", "picoohm")),
|
||||
new MenuItem("Wobblepaint ", () => insertPico8("Wobblepaint", "wobblepaint")),
|
||||
];
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const States = {
|
||||
@@ -1028,7 +1014,7 @@ Promise.all([
|
||||
let ticks = 0;
|
||||
// Bird's current position
|
||||
let birdY = 0;
|
||||
let birdX = 40;
|
||||
let birdX = 0;
|
||||
// Bird's starting position (when flying)
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
@@ -1037,7 +1023,6 @@ Promise.all([
|
||||
let targetY = 0;
|
||||
/** @type {HTMLElement|null} */
|
||||
let focusedElement = null;
|
||||
let focusedBounds = { left: 0, right: 0, top: 0 };
|
||||
let lastActionTimestamp = Date.now();
|
||||
/** @type {number[]} */
|
||||
let petStack = [];
|
||||
@@ -1048,11 +1033,12 @@ Promise.all([
|
||||
/** @type {StickyNote[]} */
|
||||
let stickyNotes = [];
|
||||
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the script is running in a userscript extension context
|
||||
*/
|
||||
function isUserScript() {
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
return typeof GM_getValue === "function";
|
||||
}
|
||||
|
||||
@@ -1065,10 +1051,9 @@ Promise.all([
|
||||
function load() {
|
||||
/** @type {Record<string, any>} */
|
||||
let saveData = {};
|
||||
|
||||
if (isUserScript()) {
|
||||
log("Loading save data from UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
saveData = GM_getValue("birbSaveData", {}) ?? {};
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, loading save data from localStorage");
|
||||
@@ -1076,18 +1061,14 @@ Promise.all([
|
||||
} else {
|
||||
log("Not a UserScript");
|
||||
}
|
||||
|
||||
debug("Loaded data: " + JSON.stringify(saveData));
|
||||
|
||||
if (!saveData.settings) {
|
||||
log("No user settings found in save data, starting fresh");
|
||||
}
|
||||
|
||||
userSettings = saveData.settings ?? {};
|
||||
unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD];
|
||||
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
|
||||
stickyNotes = [];
|
||||
|
||||
if (saveData.stickyNotes) {
|
||||
for (let note of saveData.stickyNotes) {
|
||||
if (note.id) {
|
||||
@@ -1095,19 +1076,17 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(stickyNotes.length + " sticky notes loaded");
|
||||
switchSpecies(currentSpecies);
|
||||
}
|
||||
|
||||
function save() {
|
||||
/** @type {BirbSaveData} */
|
||||
const saveData = {
|
||||
unlockedSpecies,
|
||||
currentSpecies,
|
||||
/** @type {Record<string, any>} */
|
||||
let saveData = {
|
||||
unlockedSpecies: unlockedSpecies,
|
||||
currentSpecies: currentSpecies,
|
||||
settings: userSettings
|
||||
};
|
||||
|
||||
if (stickyNotes.length > 0) {
|
||||
saveData.stickyNotes = stickyNotes.map(note => ({
|
||||
id: note.id,
|
||||
@@ -1117,10 +1096,9 @@ Promise.all([
|
||||
left: note.left
|
||||
}));
|
||||
}
|
||||
|
||||
if (isUserScript()) {
|
||||
log("Saving data to UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
GM_setValue("birbSaveData", saveData);
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, saving data to localStorage");
|
||||
@@ -1133,7 +1111,7 @@ Promise.all([
|
||||
function resetSaveData() {
|
||||
if (isUserScript()) {
|
||||
log("Resetting save data in UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
GM_deleteValue("birbSaveData");
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, resetting save data in localStorage");
|
||||
@@ -1162,10 +1140,8 @@ Promise.all([
|
||||
function init() {
|
||||
if (window !== window.top) {
|
||||
// Skip installation if within an iframe
|
||||
log("In iframe, skipping Birb script initialization");
|
||||
return;
|
||||
}
|
||||
log("Sprite sheets loaded successfully, initializing bird...");
|
||||
|
||||
// Preload font
|
||||
const MONOCRAFT_SRC = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf";
|
||||
@@ -1197,8 +1173,20 @@ Promise.all([
|
||||
canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE;
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
let scrollTimeout;
|
||||
window.addEventListener("scroll", () => {
|
||||
// TODO: Only do this if focused on the ground
|
||||
if (focusedElement === null && currentState !== States.FLYING) {
|
||||
canvas.style.transition = "opacity 0.2s";
|
||||
canvas.style.opacity = "0";
|
||||
}
|
||||
lastActionTimestamp = Date.now();
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
canvas.style.transition = "opacity 0.4s";
|
||||
canvas.style.opacity = "1";
|
||||
}, 100);
|
||||
});
|
||||
|
||||
onClick(document, (e) => {
|
||||
@@ -1209,10 +1197,6 @@ Promise.all([
|
||||
});
|
||||
|
||||
onClick(canvas, () => {
|
||||
if (currentAnimation === Animations.HEART && (Date.now() - lastPetTimestamp < PET_MENU_COOLDOWN)) {
|
||||
// Currently being pet, don't open menu
|
||||
return;
|
||||
}
|
||||
insertMenu();
|
||||
});
|
||||
|
||||
@@ -1225,17 +1209,13 @@ Promise.all([
|
||||
}
|
||||
const pets = petStack.filter((time) => Date.now() - time < 1000).length;
|
||||
if (pets >= 3) {
|
||||
pet();
|
||||
setAnimation(Animations.HEART);
|
||||
// Clear the stack
|
||||
petStack = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("touchmove", (e) => {
|
||||
pet();
|
||||
});
|
||||
|
||||
drawStickyNotes();
|
||||
|
||||
let lastUrl = (window.location.href ?? "").split("?")[0];
|
||||
@@ -1246,11 +1226,31 @@ Promise.all([
|
||||
lastUrl = currentUrl;
|
||||
drawStickyNotes();
|
||||
}
|
||||
}, URL_CHECK_INTERVAL);
|
||||
}, 500);
|
||||
|
||||
setInterval(update, UPDATE_INTERVAL);
|
||||
setInterval(update, 1000 / 60);
|
||||
|
||||
birdY = getWindowBottom();
|
||||
|
||||
// TODO: For testing only
|
||||
hop();
|
||||
}
|
||||
|
||||
function drawStickyNotes() {
|
||||
// Remove all existing sticky notes
|
||||
const existingNotes = document.querySelectorAll(".birb-sticky-note");
|
||||
existingNotes.forEach(note => note.remove());
|
||||
// Render all sticky notes
|
||||
for (let stickyNote of stickyNotes) {
|
||||
if (isStickyNoteApplicable(stickyNote)) {
|
||||
renderStickyNote(stickyNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the bird's behavior logic
|
||||
*/
|
||||
function update() {
|
||||
ticks++;
|
||||
|
||||
@@ -1260,29 +1260,18 @@ Promise.all([
|
||||
// Won't be restored on fullscreen exit
|
||||
}
|
||||
|
||||
if (currentState === States.IDLE && !frozen && !isMenuOpen()) {
|
||||
if (Math.random() < HOP_CHANCE && currentAnimation !== Animations.HEART) {
|
||||
if (currentState === States.IDLE) {
|
||||
if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART && !isMenuOpen()) {
|
||||
hop();
|
||||
} else if (Date.now() - lastActionTimestamp > AFK_TIME) {
|
||||
// Idle for a while, do something
|
||||
if (focusedElement === null) {
|
||||
// Fly to an element
|
||||
} else if (Math.random() < 1 / (60 * 20) && Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
|
||||
focusOnElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
} else if (Math.random() < FOCUS_SWITCH_CHANCE) {
|
||||
// Fly to another element if idle for a longer while
|
||||
focusOnElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
}
|
||||
}
|
||||
} else if (currentState === States.HOP) {
|
||||
if (updateParabolicPath(HOP_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // 1 every 2 hours (ticks * seconds * minutes * hours)
|
||||
// Double the chance of a feather if recently pet
|
||||
const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1;
|
||||
let petMod = Date.now() - lastPetTimestamp < 1000 * 60 * 5 ? 2 : 1;
|
||||
if (visible && Math.random() < FEATHER_CHANCE * petMod) {
|
||||
lastPetTimestamp = 0;
|
||||
activateFeather();
|
||||
@@ -1290,6 +1279,9 @@ Promise.all([
|
||||
updateFeather();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the bird in the dom and update its position if necessary
|
||||
*/
|
||||
function draw() {
|
||||
requestAnimationFrame(draw);
|
||||
|
||||
@@ -1297,29 +1289,41 @@ Promise.all([
|
||||
return;
|
||||
}
|
||||
|
||||
updateFocusedElementBounds();
|
||||
|
||||
// Update the bird's position
|
||||
if (currentState === States.IDLE) {
|
||||
if (focusedElement && !isWithinHorizontalBounds()) {
|
||||
if (focusedElement !== null) {
|
||||
birdY = getFocusedElementY();
|
||||
if (!isWithinHorizontalBounds(birdX)) {
|
||||
focusOnGround();
|
||||
}
|
||||
birdY = getFocusedY();
|
||||
} else {
|
||||
// Ground the bird
|
||||
birdY = getWindowBottom();
|
||||
}
|
||||
} else if (currentState === States.FLYING) {
|
||||
// Fly to target location (even if in the air)
|
||||
if (updateParabolicPath(FLY_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
} else if (currentState === States.HOP) {
|
||||
if (updateParabolicPath(HOP_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
const oldTargetY = targetY;
|
||||
targetY = getFocusedY();
|
||||
// Adjust startY to account for scrolling
|
||||
startY += targetY - oldTargetY;
|
||||
if (targetY < 0 || targetY > window.innerHeight) {
|
||||
if (focusedElement === null) {
|
||||
if (Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
|
||||
// Fly to an element if the user is AFK
|
||||
// focusOnElement();
|
||||
// lastActionTimestamp = Date.now();
|
||||
}
|
||||
} else if (focusedElement !== null) {
|
||||
targetY = getFocusedElementY();
|
||||
if (targetY < window.scrollY || targetY > window.scrollY + window.innerHeight) {
|
||||
// Fly to ground if the focused element moves out of bounds
|
||||
focusOnGround();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (currentAnimation.draw(ctx, direction, animStart, species[currentSpecies])) {
|
||||
@@ -1331,6 +1335,9 @@ Promise.all([
|
||||
setY(birdY);
|
||||
}
|
||||
|
||||
init();
|
||||
draw();
|
||||
|
||||
function newStickyNote() {
|
||||
const id = Date.now().toString();
|
||||
const site = window.location.href;
|
||||
@@ -1431,8 +1438,17 @@ Promise.all([
|
||||
return false;
|
||||
}
|
||||
|
||||
const stickyNoteParams = parseUrlParams(stickyNoteUrl);
|
||||
const currentParams = parseUrlParams(currentUrl);
|
||||
/** @type {Record<string, string>} */
|
||||
const stickyNoteParams = stickyNoteUrl.split("?")[1]?.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const currentParams = currentUrl.split("?")[1]?.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
|
||||
debug("Comparing params: ", stickyNoteParams, currentParams);
|
||||
|
||||
@@ -1444,18 +1460,6 @@ Promise.all([
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawStickyNotes() {
|
||||
// Remove all existing sticky notes
|
||||
const existingNotes = document.querySelectorAll(".birb-sticky-note");
|
||||
existingNotes.forEach(note => note.remove());
|
||||
// Render all sticky notes
|
||||
for (let stickyNote of stickyNotes) {
|
||||
if (isStickyNoteApplicable(stickyNote)) {
|
||||
renderStickyNote(stickyNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTML element with the specified parameters
|
||||
* @param {string} className
|
||||
@@ -1475,42 +1479,6 @@ Promise.all([
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a window element with header and content
|
||||
* @param {string} id
|
||||
* @param {string} title
|
||||
* @param {string} contentHtml
|
||||
* @param {() => void} [onClose]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createWindow(id, title, contentHtml, onClose) {
|
||||
const window = makeElement("birb-window", undefined, id);
|
||||
window.innerHTML = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${title}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content">
|
||||
${contentHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(window);
|
||||
makeDraggable(window.querySelector(".birb-window-header"));
|
||||
|
||||
const closeButton = window.querySelector(".birb-window-close");
|
||||
if (closeButton) {
|
||||
makeClosable(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
window.remove();
|
||||
}, closeButton);
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
function insertDecoration() {
|
||||
// Create a canvas element for the decoration
|
||||
const decorationCanvas = document.createElement("canvas");
|
||||
@@ -1590,16 +1558,21 @@ Promise.all([
|
||||
|
||||
function updateFeather() {
|
||||
const feather = document.querySelector("#birb-feather");
|
||||
const featherGravity = 1;
|
||||
if (!feather || !(feather instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const y = parseInt(feather.style.top || "0") + FEATHER_FALL_SPEED;
|
||||
const y = parseInt(feather.style.top || "0") + featherGravity;
|
||||
feather.style.top = `${Math.min(y, window.innerHeight - feather.offsetHeight)}px`;
|
||||
if (y < window.innerHeight - feather.offsetHeight) {
|
||||
feather.style.left = `${Math.sin(3.14 * 2 * (ticks / 120)) * 25}px`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// insertDecoration();
|
||||
// insertFieldGuide();
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
@@ -1616,14 +1589,28 @@ Promise.all([
|
||||
if (document.querySelector("#" + FIELD_GUIDE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = createWindow("birb-modal", title, `
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${title}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content">
|
||||
<div class="birb-message-content">
|
||||
${message}
|
||||
</div>
|
||||
`);
|
||||
|
||||
</div>`
|
||||
const modal = makeElement("birb-window");
|
||||
modal.style.width = "270px";
|
||||
modal.innerHTML = html;
|
||||
document.body.appendChild(modal);
|
||||
makeDraggable(modal.querySelector(".birb-window-header"));
|
||||
|
||||
const closeButton = modal.querySelector(".birb-window-close");
|
||||
if (closeButton) {
|
||||
makeClosable(() => {
|
||||
modal.remove();
|
||||
}, closeButton);
|
||||
}
|
||||
centerElement(modal);
|
||||
}
|
||||
|
||||
@@ -1732,10 +1719,60 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
|
||||
// insertPico8();
|
||||
|
||||
function isSafari() {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} pid
|
||||
*/
|
||||
function insertPico8(name, pid) {
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${name}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content birb-pico-8-content">
|
||||
<iframe src="https://www.lexaloffle.com/bbs/widget.php?pid=${pid}" scrolling='${isSafari() ? "yes" : "no"}'></iframe>
|
||||
</div>`
|
||||
const pico8 = makeElement("birb-window");
|
||||
pico8.innerHTML = html;
|
||||
document.body.appendChild(pico8);
|
||||
makeDraggable(pico8.querySelector(".birb-window-header"));
|
||||
const close = pico8.querySelector(".birb-window-close");
|
||||
if (close) {
|
||||
makeClosable(() => {
|
||||
pico8.remove();
|
||||
}, close);
|
||||
}
|
||||
centerElement(pico8);
|
||||
}
|
||||
|
||||
function insertMusicPlayer() {
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">Music Player</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content birb-music-player-content">
|
||||
<iframe style="border-radius:12px" src="https://open.spotify.com/embed/playlist/31FWVQBp3WQydWLNhO0ACi?utm_source=generator" width="250" height="352" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
|
||||
</div>`;
|
||||
const pico8 = makeElement("birb-window");
|
||||
pico8.innerHTML = html;
|
||||
document.body.appendChild(pico8);
|
||||
makeDraggable(pico8.querySelector(".birb-window-header"));
|
||||
const close = pico8.querySelector(".birb-window-close");
|
||||
if (close) {
|
||||
makeClosable(() => {
|
||||
pico8.remove();
|
||||
}, close);
|
||||
}
|
||||
centerElement(pico8);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
@@ -1849,23 +1886,7 @@ Promise.all([
|
||||
*/
|
||||
function onClick(element, action) {
|
||||
element.addEventListener("click", (e) => action(e));
|
||||
element.addEventListener("touchend", (e) => {
|
||||
if (e instanceof TouchEvent === false) {
|
||||
return;
|
||||
} else if (element instanceof HTMLElement === false) {
|
||||
return;
|
||||
}
|
||||
const touch = e.changedTouches[0];
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
touch.clientX >= rect.left &&
|
||||
touch.clientX <= rect.right &&
|
||||
touch.clientY >= rect.top &&
|
||||
touch.clientY <= rect.bottom
|
||||
) {
|
||||
action(e);
|
||||
}
|
||||
});
|
||||
element.addEventListener("touchstart", (e) => action(e));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1999,70 +2020,63 @@ Promise.all([
|
||||
}
|
||||
|
||||
function getFocusedElementRandomX() {
|
||||
return Math.random() * (focusedBounds.right - focusedBounds.left) + focusedBounds.left;
|
||||
if (focusedElement === null) {
|
||||
return Math.random() * window.innerWidth;
|
||||
}
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return Math.random() * (rect.right - rect.left) + rect.left + window.scrollX;
|
||||
}
|
||||
|
||||
function isWithinHorizontalBounds() {
|
||||
return birdX >= focusedBounds.left && birdX <= focusedBounds.right;
|
||||
function getFocusedElementY() {
|
||||
if (focusedElement === null) {
|
||||
return getWindowBottom();
|
||||
}
|
||||
|
||||
function getFocusedY() {
|
||||
return getFullWindowHeight() - focusedBounds.top;
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The render-safe height of the inner browser window
|
||||
* @param {number} x
|
||||
* @returns {boolean} Whether the x coordinate is within the horizontal bounds of the focused element
|
||||
*/
|
||||
function getSafeWindowHeight() {
|
||||
// Necessary because iOS 26 Safari is terrible and won't render
|
||||
// fixed elements behind the address bar
|
||||
return window.innerHeight;
|
||||
function isWithinHorizontalBounds(x) {
|
||||
if (focusedElement === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The true height of the inner browser window
|
||||
*/
|
||||
function getFullWindowHeight() {
|
||||
return document.documentElement.clientHeight;
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return x >= rect.left && x <= rect.right;
|
||||
}
|
||||
|
||||
function focusOnGround() {
|
||||
if (focusedElement === null) {
|
||||
// Already focused on ground
|
||||
return;
|
||||
}
|
||||
console.log("Focusing on ground");
|
||||
focusedElement = null;
|
||||
focusedBounds = { left: 0, right: window.innerWidth, top: getSafeWindowHeight() };
|
||||
flyTo(Math.random() * window.innerWidth, 0);
|
||||
flyTo(Math.random() * window.innerWidth, getWindowBottom());
|
||||
}
|
||||
|
||||
function focusOnElement() {
|
||||
if (frozen) {
|
||||
return;
|
||||
}
|
||||
console.log("Focusing on element");
|
||||
const elements = document.querySelectorAll("img, video");
|
||||
const inWindow = Array.from(elements).filter((img) => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight;
|
||||
return rect.left >= 0 && rect.top >= 80 && rect.right <= window.innerWidth && rect.top <= window.innerHeight;
|
||||
});
|
||||
const MIN_WIDTH = 100;
|
||||
/** @type {HTMLElement[]} */
|
||||
// @ts-expect-error
|
||||
const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
|
||||
// @ts-ignore
|
||||
const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH);
|
||||
if (largeElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)];
|
||||
focusedElement = randomElement;
|
||||
log("Focusing on element: ", focusedElement);
|
||||
updateFocusedElementBounds();
|
||||
flyTo(getFocusedElementRandomX(), getFocusedY());
|
||||
}
|
||||
|
||||
function updateFocusedElementBounds() {
|
||||
if (focusedElement === null) {
|
||||
// Update ground location to bottom of window
|
||||
focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() };
|
||||
return;
|
||||
}
|
||||
const { left, right, top } = focusedElement.getBoundingClientRect();
|
||||
focusedBounds = { left, right, top };
|
||||
flyTo(getFocusedElementRandomX(), getFocusedElementY());
|
||||
}
|
||||
|
||||
function getCanvasWidth() {
|
||||
@@ -2073,24 +2087,40 @@ Promise.all([
|
||||
return canvas.height * BIRB_CSS_SCALE
|
||||
}
|
||||
|
||||
function getWindowBottom() {
|
||||
return window.scrollY + window.innerHeight;
|
||||
}
|
||||
|
||||
function hop() {
|
||||
if (frozen) {
|
||||
return;
|
||||
}
|
||||
if (currentState === States.IDLE) {
|
||||
// Determine bounds for hopping
|
||||
let minX = 0;
|
||||
let maxX = window.innerWidth;
|
||||
let y = getWindowBottom();
|
||||
if (focusedElement !== null) {
|
||||
// Hop on the element
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
minX = rect.left;
|
||||
maxX = rect.right;
|
||||
y = window.innerHeight - rect.top;
|
||||
}
|
||||
setState(States.HOP);
|
||||
setAnimation(Animations.FLYING);
|
||||
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
|
||||
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > minX) || birdX + HOP_DISTANCE > maxX) {
|
||||
targetX = birdX - HOP_DISTANCE;
|
||||
} else {
|
||||
targetX = birdX + HOP_DISTANCE;
|
||||
}
|
||||
targetY = getFocusedY();
|
||||
targetY = y;
|
||||
console.log("hopping from", birdX, birdY, "to", targetX, targetY);
|
||||
}
|
||||
}
|
||||
|
||||
function pet() {
|
||||
if (currentState === States.IDLE && currentAnimation !== Animations.HEART) {
|
||||
if (currentState === States.IDLE) {
|
||||
setAnimation(Animations.HEART);
|
||||
lastPetTimestamp = Date.now();
|
||||
}
|
||||
@@ -2106,19 +2136,13 @@ Promise.all([
|
||||
* @param {number} y
|
||||
*/
|
||||
function flyTo(x, y) {
|
||||
console.log("Flying to", x, y);
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
setState(States.FLYING);
|
||||
setAnimation(Animations.FLYING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the bird should be absolutely positioned
|
||||
*/
|
||||
function isAbsolute() {
|
||||
return focusedElement !== null && (currentState === States.IDLE || currentState === States.HOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current animation and reset the animation timer
|
||||
* @param {Anim} animation
|
||||
@@ -2133,6 +2157,7 @@ Promise.all([
|
||||
* @param {string} state
|
||||
*/
|
||||
function setState(state) {
|
||||
console.log("State:", state);
|
||||
stateStart = Date.now();
|
||||
startX = birdX;
|
||||
startY = birdY;
|
||||
@@ -2140,38 +2165,36 @@ Promise.all([
|
||||
if (state === States.IDLE) {
|
||||
setAnimation(Animations.BOB);
|
||||
}
|
||||
if (isAbsolute()) {
|
||||
canvas.classList.add("birb-absolute");
|
||||
} else {
|
||||
canvas.classList.remove("birb-absolute");
|
||||
}
|
||||
setY(birdY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bird element's X position, with the element origin at the center of the bird
|
||||
* @param {number} x
|
||||
*/
|
||||
function setX(x) {
|
||||
let mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2));
|
||||
const mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2));
|
||||
canvas.style.left = `${x + mod}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bird element's Y position, with the element origin at the bottom of the bird
|
||||
* @param {number} y
|
||||
*/
|
||||
function setY(y) {
|
||||
let bottom;
|
||||
if (isAbsolute()) {
|
||||
// Position is absolute, convert from fixed
|
||||
bottom = y - window.scrollY;
|
||||
} else {
|
||||
// Position is fixed
|
||||
bottom = y;
|
||||
}
|
||||
canvas.style.bottom = `${bottom}px`;
|
||||
const mod = getCanvasHeight() + WINDOW_PIXEL_SIZE;
|
||||
canvas.style.top = `${y - mod}px`;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
/**
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @param {number} amount
|
||||
* @returns {number}
|
||||
*/
|
||||
function linearLerp(start, end, amount) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} startX
|
||||
@@ -2188,7 +2211,7 @@ Promise.all([
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const midX = startX + Math.cos(angle) * distance / 2;
|
||||
const midY = startY + Math.sin(angle) * distance / 2 + distance / 4 * intensity;
|
||||
const midY = startY + Math.sin(angle) * distance / 2 - distance / 4 * intensity;
|
||||
const t = amount;
|
||||
const x = (1 - t) ** 2 * startX + 2 * (1 - t) * t * midX + t ** 2 * endX;
|
||||
const y = (1 - t) ** 2 * startY + 2 * (1 - t) * t * midY + t ** 2 * endY;
|
||||
@@ -2196,27 +2219,12 @@ Promise.all([
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL parameters into a key-value map
|
||||
* @param {string} url
|
||||
* @returns {Record<string, string>}
|
||||
* @param {number} value
|
||||
*/
|
||||
function parseUrlParams(url) {
|
||||
const queryString = url.split("?")[1];
|
||||
if (!queryString) return {};
|
||||
|
||||
return queryString.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
function roundToPixel(value) {
|
||||
return Math.round(value / WINDOW_PIXEL_SIZE) * WINDOW_PIXEL_SIZE;
|
||||
}
|
||||
|
||||
// Run the birb
|
||||
init();
|
||||
draw();
|
||||
}).catch((e) => {
|
||||
error("Error while loading sprite sheets: ", e);
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the user is on a mobile device
|
||||
*/
|
||||
|
||||
568
dist/birb.user.js
vendored
568
dist/birb.user.js
vendored
@@ -1,11 +1,11 @@
|
||||
// ==UserScript==
|
||||
// @name Pocket Bird
|
||||
// @name Browser Bird
|
||||
// @namespace https://idreesinc.com
|
||||
// @version 2025.10.25.130
|
||||
// @version 2025.9.16.1
|
||||
// @description birb
|
||||
// @author Idrees
|
||||
// @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @updateURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @downloadURL https://github.com/IdreesInc/Browser-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @updateURL https://github.com/IdreesInc/Browser-Bird/raw/refs/heads/main/dist/birb.user.js
|
||||
// @match *://*/*
|
||||
// @grant GM_setValue
|
||||
// @grant GM_getValue
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
// @ts-check
|
||||
|
||||
// @ts-ignore
|
||||
const SHARED_CONFIG = {
|
||||
birbCssScale: 1,
|
||||
uiCssScale: 1,
|
||||
@@ -22,8 +23,9 @@ const SHARED_CONFIG = {
|
||||
hopDistance: 45,
|
||||
};
|
||||
|
||||
|
||||
const DESKTOP_CONFIG = {
|
||||
flySpeed: 0.25
|
||||
flySpeed: 0.2,
|
||||
};
|
||||
|
||||
const MOBILE_CONFIG = {
|
||||
@@ -40,6 +42,17 @@ const BIRB_CSS_SCALE = CONFIG.birbCssScale;
|
||||
const UI_CSS_SCALE = CONFIG.uiCssScale;
|
||||
const CANVAS_PIXEL_SIZE = CONFIG.canvasPixelSize;
|
||||
const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE;
|
||||
const HOP_SPEED = CONFIG.hopSpeed;
|
||||
const FLY_SPEED = CONFIG.flySpeed;
|
||||
const HOP_DISTANCE = CONFIG.hopDistance;
|
||||
// Time in milliseconds until the user is considered AFK
|
||||
const AFK_TIME = debugMode ? 0 : 1000 * 30;
|
||||
const SPRITE_HEIGHT = 32;
|
||||
const MENU_ID = "birb-menu";
|
||||
const MENU_EXIT_ID = "birb-menu-exit";
|
||||
const FIELD_GUIDE_ID = "birb-field-guide";
|
||||
const FEATHER_ID = "birb-feather";
|
||||
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
birbMode: false
|
||||
@@ -49,23 +62,6 @@ const DEFAULT_SETTINGS = {
|
||||
* @typedef {typeof DEFAULT_SETTINGS} Settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SavedStickyNote
|
||||
* @property {string} id
|
||||
* @property {string} site
|
||||
* @property {string} content
|
||||
* @property {number} top
|
||||
* @property {number} left
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BirbSaveData
|
||||
* @property {string[]} unlockedSpecies
|
||||
* @property {string} currentSpecies
|
||||
* @property {Partial<Settings>} settings
|
||||
* @property {SavedStickyNote[]} [stickyNotes]
|
||||
*/
|
||||
|
||||
/** @type {Partial<Settings>} */
|
||||
let userSettings = {};
|
||||
|
||||
@@ -84,18 +80,13 @@ const STYLESHEET = `:root {
|
||||
|
||||
#birb {
|
||||
image-rendering: pixelated;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
transform: scale(var(--birb-scale)) !important;
|
||||
transform-origin: bottom;
|
||||
z-index: 2147483638 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.birb-absolute {
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
.birb-decoration {
|
||||
image-rendering: pixelated;
|
||||
position: fixed;
|
||||
@@ -312,8 +303,8 @@ const STYLESHEET = `:root {
|
||||
flex-direction: row;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -360,12 +351,12 @@ const STYLESHEET = `:root {
|
||||
}
|
||||
|
||||
.birb-field-guide-description {
|
||||
width: calc(100% - 20px);
|
||||
margin-top: 5px;
|
||||
width: calc(100% - 16px);
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
color: rgb(124, 108, 75);
|
||||
@@ -741,7 +732,6 @@ const Directions = {
|
||||
};
|
||||
|
||||
const SPRITE_WIDTH = 32;
|
||||
const SPRITE_HEIGHT = 32;
|
||||
const DECORATIONS_SPRITE_WIDTH = 48;
|
||||
const FEATHER_SPRITE_WIDTH = 32;
|
||||
|
||||
@@ -749,34 +739,6 @@ const SPRITE_SHEET = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAgCAYA
|
||||
const DECORATIONS_SPRITE_SHEET = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAPNJREFUaIHtmTESgzAMBHWZDC+gp0vP/x9Bn44+L6BRmrhJA4csM05uGzfY1s1JxggzIYQQQgghxEnATnB3zwikAICKiXq4BE/uwaxvn/UPb3BnNwFg27Ky0w6vzRp8S4mkIbQD3wzzFJofdTMkYJgn89czFADGKSSiSgphfFBjTaoIKC4cHWvSxIFMmjiQSYoDLUlxoCVywOwHHWjpROop1IL/vsxty2oYO77M1QggSvcpJAFXE66BPfa+2C4v4j2yi7z7FJKAq6FrwN3TO3MMlAAAKO3F2sVZTiu2N9p9CnUv4FR7PbMG2BQ69SJL/kVA8QauAnHUj36BVwAAAABJRU5ErkJggg==";
|
||||
const FEATHER_SPRITE_SHEET = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAARhJREFUWIXtlbENwjAQRf8hSiZIRQ+9WQNRUFIAKzACBSsAA1Ag1mAABqCCBomG3hQQ9OMEx4ZDNH5SikSJ3/fZ5wCJRCKRSPwZ0RzMWmtLAhGvQyUAi9mXP/aFaGjJRQQiguHihMvcFMJUVUYlAMuHixPGy4en1WmVQqgHYHkuZjiEj6a2/LjtYzTY0eiZbgC37Mxh1UN3sn/dr6cCz/LHB/DJj9s+2oMdbtdz6TtfFwQHcMvOInfmQNjsgchNWLXmdfK6gyioAu/6uKrsm1kWLAciKuCuey5nYuXAh234bdmZ6INIUw4E/Ix49xtjCmXfzLL8nY/ktdgnAKwxxgIoXIyqmAOwvIqfiN0ALNd21HYBO9XXGMAdnZTYyHWzWjQAAAAASUVORK5CYII=";
|
||||
|
||||
const MENU_ID = "birb-menu";
|
||||
const MENU_EXIT_ID = "birb-menu-exit";
|
||||
const FIELD_GUIDE_ID = "birb-field-guide";
|
||||
const FEATHER_ID = "birb-feather";
|
||||
|
||||
const HOP_SPEED = CONFIG.hopSpeed;
|
||||
const FLY_SPEED = CONFIG.flySpeed;
|
||||
const HOP_DISTANCE = CONFIG.hopDistance;
|
||||
/** Speed at which the feather falls per tick */
|
||||
const FEATHER_FALL_SPEED = 1;
|
||||
/** Time in milliseconds until the user is considered AFK */
|
||||
const AFK_TIME = debugMode ? 0 : 1000 * 30;
|
||||
const UPDATE_INTERVAL = 1000 / 60; // 60 FPS
|
||||
// Per-frame chances
|
||||
const HOP_CHANCE = 1 / (60 * 3); // 3 seconds
|
||||
const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // 20 seconds
|
||||
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // 2 hours
|
||||
/** Multiplier after petting that increases the feather drop chance */
|
||||
const PET_FEATHER_BOOST = 2;
|
||||
/** How long the pet boost lasts in milliseconds */
|
||||
const PET_BOOST_DURATION = 1000 * 60 * 5;
|
||||
const MIN_FOCUS_ELEMENT_WIDTH = 100;
|
||||
const MIN_FOCUS_ELEMENT_TOP = 80;
|
||||
/** Time between checking whether the URL has changed */
|
||||
const URL_CHECK_INTERVAL = 500;
|
||||
/** Time after petting before the menu can be opened */
|
||||
const PET_MENU_COOLDOWN = 1000;
|
||||
|
||||
/**
|
||||
* Load the sprite sheet and return the pixel-map template
|
||||
* @param {string} dataUri
|
||||
@@ -833,14 +795,8 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) {
|
||||
});
|
||||
}
|
||||
|
||||
log("Loading sprite sheets...");
|
||||
|
||||
Promise.all([
|
||||
loadSpriteSheetPixels(SPRITE_SHEET),
|
||||
loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false),
|
||||
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)
|
||||
]).then(([birbPixels, decorationPixels, featherPixels]) => {
|
||||
|
||||
// @ts-ignore
|
||||
Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false), loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)]).then(([birbPixels, decorationPixels, featherPixels]) => {
|
||||
const SPRITE_SHEET = birbPixels;
|
||||
const DECORATIONS_SPRITE_SHEET = decorationPixels;
|
||||
const FEATHER_SPRITE_SHEET = featherPixels;
|
||||
@@ -993,6 +949,8 @@ Promise.all([
|
||||
const menuItems = [
|
||||
new MenuItem(`Pet ${birdBirb()}`, pet),
|
||||
new MenuItem("Field Guide", insertFieldGuide),
|
||||
// new MenuItem("Decorations", insertDecoration),
|
||||
new DebugMenuItem("Applications", () => switchMenuItems(otherItems), false),
|
||||
new MenuItem("Sticky Note", newStickyNote),
|
||||
new MenuItem(`Hide ${birdBirb()}`, hideBirb),
|
||||
new DebugMenuItem("Freeze/Unfreeze", () => {
|
||||
@@ -1021,11 +979,39 @@ Promise.all([
|
||||
})
|
||||
];
|
||||
|
||||
const otherItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(menuItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Video Games", () => switchMenuItems(gameItems), false),
|
||||
new MenuItem("Utilities", () => switchMenuItems(utilityItems), false),
|
||||
new MenuItem("Music Player", () => insertMusicPlayer(), false),
|
||||
];
|
||||
|
||||
const gameItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(otherItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Pinball", () => insertPico8("Terra Nova Pinball", "terra_nova_pinball")),
|
||||
new MenuItem("Pico Dino", () => insertPico8("Pico Dino", "picodino")),
|
||||
new MenuItem("Woodworm", () => insertPico8("Woodworm", "woodworm")),
|
||||
new MenuItem("Pico and Chill", () => insertPico8("Pico and Chill", "picochill")),
|
||||
new MenuItem("Lani's Trek", () => insertPico8("Celeste 2 Lani's Trek", "celeste_classic_2")),
|
||||
new MenuItem("Tetraminis", () => insertPico8("Tetraminis", "tetraminisdeffect")),
|
||||
// new MenuItem("Pool", () => insertPico8("Pool", "mot_pool")),
|
||||
];
|
||||
|
||||
const utilityItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(otherItems), false),
|
||||
new Separator(),
|
||||
new MenuItem("Pomodoro Timer", () => insertPico8("Pomodoro", "pomodorotimerv1")),
|
||||
new MenuItem("Ohm Calculator", () => insertPico8("Resistor Calculator", "picoohm")),
|
||||
new MenuItem("Wobblepaint ", () => insertPico8("Wobblepaint", "wobblepaint")),
|
||||
];
|
||||
|
||||
const styleElement = document.createElement("style");
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const States = {
|
||||
@@ -1042,7 +1028,7 @@ Promise.all([
|
||||
let ticks = 0;
|
||||
// Bird's current position
|
||||
let birdY = 0;
|
||||
let birdX = 40;
|
||||
let birdX = 0;
|
||||
// Bird's starting position (when flying)
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
@@ -1051,7 +1037,6 @@ Promise.all([
|
||||
let targetY = 0;
|
||||
/** @type {HTMLElement|null} */
|
||||
let focusedElement = null;
|
||||
let focusedBounds = { left: 0, right: 0, top: 0 };
|
||||
let lastActionTimestamp = Date.now();
|
||||
/** @type {number[]} */
|
||||
let petStack = [];
|
||||
@@ -1062,11 +1047,12 @@ Promise.all([
|
||||
/** @type {StickyNote[]} */
|
||||
let stickyNotes = [];
|
||||
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the script is running in a userscript extension context
|
||||
*/
|
||||
function isUserScript() {
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
return typeof GM_getValue === "function";
|
||||
}
|
||||
|
||||
@@ -1079,10 +1065,9 @@ Promise.all([
|
||||
function load() {
|
||||
/** @type {Record<string, any>} */
|
||||
let saveData = {};
|
||||
|
||||
if (isUserScript()) {
|
||||
log("Loading save data from UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
saveData = GM_getValue("birbSaveData", {}) ?? {};
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, loading save data from localStorage");
|
||||
@@ -1090,18 +1075,14 @@ Promise.all([
|
||||
} else {
|
||||
log("Not a UserScript");
|
||||
}
|
||||
|
||||
debug("Loaded data: " + JSON.stringify(saveData));
|
||||
|
||||
if (!saveData.settings) {
|
||||
log("No user settings found in save data, starting fresh");
|
||||
}
|
||||
|
||||
userSettings = saveData.settings ?? {};
|
||||
unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD];
|
||||
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
|
||||
stickyNotes = [];
|
||||
|
||||
if (saveData.stickyNotes) {
|
||||
for (let note of saveData.stickyNotes) {
|
||||
if (note.id) {
|
||||
@@ -1109,19 +1090,17 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(stickyNotes.length + " sticky notes loaded");
|
||||
switchSpecies(currentSpecies);
|
||||
}
|
||||
|
||||
function save() {
|
||||
/** @type {BirbSaveData} */
|
||||
const saveData = {
|
||||
unlockedSpecies,
|
||||
currentSpecies,
|
||||
/** @type {Record<string, any>} */
|
||||
let saveData = {
|
||||
unlockedSpecies: unlockedSpecies,
|
||||
currentSpecies: currentSpecies,
|
||||
settings: userSettings
|
||||
};
|
||||
|
||||
if (stickyNotes.length > 0) {
|
||||
saveData.stickyNotes = stickyNotes.map(note => ({
|
||||
id: note.id,
|
||||
@@ -1131,10 +1110,9 @@ Promise.all([
|
||||
left: note.left
|
||||
}));
|
||||
}
|
||||
|
||||
if (isUserScript()) {
|
||||
log("Saving data to UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
GM_setValue("birbSaveData", saveData);
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, saving data to localStorage");
|
||||
@@ -1147,7 +1125,7 @@ Promise.all([
|
||||
function resetSaveData() {
|
||||
if (isUserScript()) {
|
||||
log("Resetting save data in UserScript storage");
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
GM_deleteValue("birbSaveData");
|
||||
} else if (isTestEnvironment()) {
|
||||
log("Test environment detected, resetting save data in localStorage");
|
||||
@@ -1176,10 +1154,8 @@ Promise.all([
|
||||
function init() {
|
||||
if (window !== window.top) {
|
||||
// Skip installation if within an iframe
|
||||
log("In iframe, skipping Birb script initialization");
|
||||
return;
|
||||
}
|
||||
log("Sprite sheets loaded successfully, initializing bird...");
|
||||
|
||||
// Preload font
|
||||
const MONOCRAFT_SRC = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf";
|
||||
@@ -1211,8 +1187,20 @@ Promise.all([
|
||||
canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE;
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
let scrollTimeout;
|
||||
window.addEventListener("scroll", () => {
|
||||
// TODO: Only do this if focused on the ground
|
||||
if (focusedElement === null && currentState !== States.FLYING) {
|
||||
canvas.style.transition = "opacity 0.2s";
|
||||
canvas.style.opacity = "0";
|
||||
}
|
||||
lastActionTimestamp = Date.now();
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
canvas.style.transition = "opacity 0.4s";
|
||||
canvas.style.opacity = "1";
|
||||
}, 100);
|
||||
});
|
||||
|
||||
onClick(document, (e) => {
|
||||
@@ -1223,10 +1211,6 @@ Promise.all([
|
||||
});
|
||||
|
||||
onClick(canvas, () => {
|
||||
if (currentAnimation === Animations.HEART && (Date.now() - lastPetTimestamp < PET_MENU_COOLDOWN)) {
|
||||
// Currently being pet, don't open menu
|
||||
return;
|
||||
}
|
||||
insertMenu();
|
||||
});
|
||||
|
||||
@@ -1239,17 +1223,13 @@ Promise.all([
|
||||
}
|
||||
const pets = petStack.filter((time) => Date.now() - time < 1000).length;
|
||||
if (pets >= 3) {
|
||||
pet();
|
||||
setAnimation(Animations.HEART);
|
||||
// Clear the stack
|
||||
petStack = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("touchmove", (e) => {
|
||||
pet();
|
||||
});
|
||||
|
||||
drawStickyNotes();
|
||||
|
||||
let lastUrl = (window.location.href ?? "").split("?")[0];
|
||||
@@ -1260,11 +1240,31 @@ Promise.all([
|
||||
lastUrl = currentUrl;
|
||||
drawStickyNotes();
|
||||
}
|
||||
}, URL_CHECK_INTERVAL);
|
||||
}, 500);
|
||||
|
||||
setInterval(update, UPDATE_INTERVAL);
|
||||
setInterval(update, 1000 / 60);
|
||||
|
||||
birdY = getWindowBottom();
|
||||
|
||||
// TODO: For testing only
|
||||
hop();
|
||||
}
|
||||
|
||||
function drawStickyNotes() {
|
||||
// Remove all existing sticky notes
|
||||
const existingNotes = document.querySelectorAll(".birb-sticky-note");
|
||||
existingNotes.forEach(note => note.remove());
|
||||
// Render all sticky notes
|
||||
for (let stickyNote of stickyNotes) {
|
||||
if (isStickyNoteApplicable(stickyNote)) {
|
||||
renderStickyNote(stickyNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the bird's behavior logic
|
||||
*/
|
||||
function update() {
|
||||
ticks++;
|
||||
|
||||
@@ -1274,29 +1274,18 @@ Promise.all([
|
||||
// Won't be restored on fullscreen exit
|
||||
}
|
||||
|
||||
if (currentState === States.IDLE && !frozen && !isMenuOpen()) {
|
||||
if (Math.random() < HOP_CHANCE && currentAnimation !== Animations.HEART) {
|
||||
if (currentState === States.IDLE) {
|
||||
if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART && !isMenuOpen()) {
|
||||
hop();
|
||||
} else if (Date.now() - lastActionTimestamp > AFK_TIME) {
|
||||
// Idle for a while, do something
|
||||
if (focusedElement === null) {
|
||||
// Fly to an element
|
||||
} else if (Math.random() < 1 / (60 * 20) && Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
|
||||
focusOnElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
} else if (Math.random() < FOCUS_SWITCH_CHANCE) {
|
||||
// Fly to another element if idle for a longer while
|
||||
focusOnElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
}
|
||||
}
|
||||
} else if (currentState === States.HOP) {
|
||||
if (updateParabolicPath(HOP_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // 1 every 2 hours (ticks * seconds * minutes * hours)
|
||||
// Double the chance of a feather if recently pet
|
||||
const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1;
|
||||
let petMod = Date.now() - lastPetTimestamp < 1000 * 60 * 5 ? 2 : 1;
|
||||
if (visible && Math.random() < FEATHER_CHANCE * petMod) {
|
||||
lastPetTimestamp = 0;
|
||||
activateFeather();
|
||||
@@ -1304,6 +1293,9 @@ Promise.all([
|
||||
updateFeather();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the bird in the dom and update its position if necessary
|
||||
*/
|
||||
function draw() {
|
||||
requestAnimationFrame(draw);
|
||||
|
||||
@@ -1311,29 +1303,41 @@ Promise.all([
|
||||
return;
|
||||
}
|
||||
|
||||
updateFocusedElementBounds();
|
||||
|
||||
// Update the bird's position
|
||||
if (currentState === States.IDLE) {
|
||||
if (focusedElement && !isWithinHorizontalBounds()) {
|
||||
if (focusedElement !== null) {
|
||||
birdY = getFocusedElementY();
|
||||
if (!isWithinHorizontalBounds(birdX)) {
|
||||
focusOnGround();
|
||||
}
|
||||
birdY = getFocusedY();
|
||||
} else {
|
||||
// Ground the bird
|
||||
birdY = getWindowBottom();
|
||||
}
|
||||
} else if (currentState === States.FLYING) {
|
||||
// Fly to target location (even if in the air)
|
||||
if (updateParabolicPath(FLY_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
} else if (currentState === States.HOP) {
|
||||
if (updateParabolicPath(HOP_SPEED)) {
|
||||
setState(States.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
const oldTargetY = targetY;
|
||||
targetY = getFocusedY();
|
||||
// Adjust startY to account for scrolling
|
||||
startY += targetY - oldTargetY;
|
||||
if (targetY < 0 || targetY > window.innerHeight) {
|
||||
if (focusedElement === null) {
|
||||
if (Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
|
||||
// Fly to an element if the user is AFK
|
||||
// focusOnElement();
|
||||
// lastActionTimestamp = Date.now();
|
||||
}
|
||||
} else if (focusedElement !== null) {
|
||||
targetY = getFocusedElementY();
|
||||
if (targetY < window.scrollY || targetY > window.scrollY + window.innerHeight) {
|
||||
// Fly to ground if the focused element moves out of bounds
|
||||
focusOnGround();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (currentAnimation.draw(ctx, direction, animStart, species[currentSpecies])) {
|
||||
@@ -1345,6 +1349,9 @@ Promise.all([
|
||||
setY(birdY);
|
||||
}
|
||||
|
||||
init();
|
||||
draw();
|
||||
|
||||
function newStickyNote() {
|
||||
const id = Date.now().toString();
|
||||
const site = window.location.href;
|
||||
@@ -1445,8 +1452,17 @@ Promise.all([
|
||||
return false;
|
||||
}
|
||||
|
||||
const stickyNoteParams = parseUrlParams(stickyNoteUrl);
|
||||
const currentParams = parseUrlParams(currentUrl);
|
||||
/** @type {Record<string, string>} */
|
||||
const stickyNoteParams = stickyNoteUrl.split("?")[1]?.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const currentParams = currentUrl.split("?")[1]?.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
|
||||
debug("Comparing params: ", stickyNoteParams, currentParams);
|
||||
|
||||
@@ -1458,18 +1474,6 @@ Promise.all([
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawStickyNotes() {
|
||||
// Remove all existing sticky notes
|
||||
const existingNotes = document.querySelectorAll(".birb-sticky-note");
|
||||
existingNotes.forEach(note => note.remove());
|
||||
// Render all sticky notes
|
||||
for (let stickyNote of stickyNotes) {
|
||||
if (isStickyNoteApplicable(stickyNote)) {
|
||||
renderStickyNote(stickyNote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTML element with the specified parameters
|
||||
* @param {string} className
|
||||
@@ -1489,42 +1493,6 @@ Promise.all([
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a window element with header and content
|
||||
* @param {string} id
|
||||
* @param {string} title
|
||||
* @param {string} contentHtml
|
||||
* @param {() => void} [onClose]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createWindow(id, title, contentHtml, onClose) {
|
||||
const window = makeElement("birb-window", undefined, id);
|
||||
window.innerHTML = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${title}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content">
|
||||
${contentHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(window);
|
||||
makeDraggable(window.querySelector(".birb-window-header"));
|
||||
|
||||
const closeButton = window.querySelector(".birb-window-close");
|
||||
if (closeButton) {
|
||||
makeClosable(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
window.remove();
|
||||
}, closeButton);
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
function insertDecoration() {
|
||||
// Create a canvas element for the decoration
|
||||
const decorationCanvas = document.createElement("canvas");
|
||||
@@ -1604,16 +1572,21 @@ Promise.all([
|
||||
|
||||
function updateFeather() {
|
||||
const feather = document.querySelector("#birb-feather");
|
||||
const featherGravity = 1;
|
||||
if (!feather || !(feather instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const y = parseInt(feather.style.top || "0") + FEATHER_FALL_SPEED;
|
||||
const y = parseInt(feather.style.top || "0") + featherGravity;
|
||||
feather.style.top = `${Math.min(y, window.innerHeight - feather.offsetHeight)}px`;
|
||||
if (y < window.innerHeight - feather.offsetHeight) {
|
||||
feather.style.left = `${Math.sin(3.14 * 2 * (ticks / 120)) * 25}px`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// insertDecoration();
|
||||
// insertFieldGuide();
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
@@ -1630,14 +1603,28 @@ Promise.all([
|
||||
if (document.querySelector("#" + FIELD_GUIDE_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = createWindow("birb-modal", title, `
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${title}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content">
|
||||
<div class="birb-message-content">
|
||||
${message}
|
||||
</div>
|
||||
`);
|
||||
|
||||
</div>`
|
||||
const modal = makeElement("birb-window");
|
||||
modal.style.width = "270px";
|
||||
modal.innerHTML = html;
|
||||
document.body.appendChild(modal);
|
||||
makeDraggable(modal.querySelector(".birb-window-header"));
|
||||
|
||||
const closeButton = modal.querySelector(".birb-window-close");
|
||||
if (closeButton) {
|
||||
makeClosable(() => {
|
||||
modal.remove();
|
||||
}, closeButton);
|
||||
}
|
||||
centerElement(modal);
|
||||
}
|
||||
|
||||
@@ -1746,10 +1733,60 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
|
||||
// insertPico8();
|
||||
|
||||
function isSafari() {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} pid
|
||||
*/
|
||||
function insertPico8(name, pid) {
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">${name}</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content birb-pico-8-content">
|
||||
<iframe src="https://www.lexaloffle.com/bbs/widget.php?pid=${pid}" scrolling='${isSafari() ? "yes" : "no"}'></iframe>
|
||||
</div>`
|
||||
const pico8 = makeElement("birb-window");
|
||||
pico8.innerHTML = html;
|
||||
document.body.appendChild(pico8);
|
||||
makeDraggable(pico8.querySelector(".birb-window-header"));
|
||||
const close = pico8.querySelector(".birb-window-close");
|
||||
if (close) {
|
||||
makeClosable(() => {
|
||||
pico8.remove();
|
||||
}, close);
|
||||
}
|
||||
centerElement(pico8);
|
||||
}
|
||||
|
||||
function insertMusicPlayer() {
|
||||
let html = `
|
||||
<div class="birb-window-header">
|
||||
<div class="birb-window-title">Music Player</div>
|
||||
<div class="birb-window-close">x</div>
|
||||
</div>
|
||||
<div class="birb-window-content birb-music-player-content">
|
||||
<iframe style="border-radius:12px" src="https://open.spotify.com/embed/playlist/31FWVQBp3WQydWLNhO0ACi?utm_source=generator" width="250" height="352" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
|
||||
</div>`;
|
||||
const pico8 = makeElement("birb-window");
|
||||
pico8.innerHTML = html;
|
||||
document.body.appendChild(pico8);
|
||||
makeDraggable(pico8.querySelector(".birb-window-header"));
|
||||
const close = pico8.querySelector(".birb-window-close");
|
||||
if (close) {
|
||||
makeClosable(() => {
|
||||
pico8.remove();
|
||||
}, close);
|
||||
}
|
||||
centerElement(pico8);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
@@ -1863,23 +1900,7 @@ Promise.all([
|
||||
*/
|
||||
function onClick(element, action) {
|
||||
element.addEventListener("click", (e) => action(e));
|
||||
element.addEventListener("touchend", (e) => {
|
||||
if (e instanceof TouchEvent === false) {
|
||||
return;
|
||||
} else if (element instanceof HTMLElement === false) {
|
||||
return;
|
||||
}
|
||||
const touch = e.changedTouches[0];
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
touch.clientX >= rect.left &&
|
||||
touch.clientX <= rect.right &&
|
||||
touch.clientY >= rect.top &&
|
||||
touch.clientY <= rect.bottom
|
||||
) {
|
||||
action(e);
|
||||
}
|
||||
});
|
||||
element.addEventListener("touchstart", (e) => action(e));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2013,70 +2034,63 @@ Promise.all([
|
||||
}
|
||||
|
||||
function getFocusedElementRandomX() {
|
||||
return Math.random() * (focusedBounds.right - focusedBounds.left) + focusedBounds.left;
|
||||
if (focusedElement === null) {
|
||||
return Math.random() * window.innerWidth;
|
||||
}
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return Math.random() * (rect.right - rect.left) + rect.left + window.scrollX;
|
||||
}
|
||||
|
||||
function isWithinHorizontalBounds() {
|
||||
return birdX >= focusedBounds.left && birdX <= focusedBounds.right;
|
||||
function getFocusedElementY() {
|
||||
if (focusedElement === null) {
|
||||
return getWindowBottom();
|
||||
}
|
||||
|
||||
function getFocusedY() {
|
||||
return getFullWindowHeight() - focusedBounds.top;
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The render-safe height of the inner browser window
|
||||
* @param {number} x
|
||||
* @returns {boolean} Whether the x coordinate is within the horizontal bounds of the focused element
|
||||
*/
|
||||
function getSafeWindowHeight() {
|
||||
// Necessary because iOS 26 Safari is terrible and won't render
|
||||
// fixed elements behind the address bar
|
||||
return window.innerHeight;
|
||||
function isWithinHorizontalBounds(x) {
|
||||
if (focusedElement === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The true height of the inner browser window
|
||||
*/
|
||||
function getFullWindowHeight() {
|
||||
return document.documentElement.clientHeight;
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
return x >= rect.left && x <= rect.right;
|
||||
}
|
||||
|
||||
function focusOnGround() {
|
||||
if (focusedElement === null) {
|
||||
// Already focused on ground
|
||||
return;
|
||||
}
|
||||
console.log("Focusing on ground");
|
||||
focusedElement = null;
|
||||
focusedBounds = { left: 0, right: window.innerWidth, top: getSafeWindowHeight() };
|
||||
flyTo(Math.random() * window.innerWidth, 0);
|
||||
flyTo(Math.random() * window.innerWidth, getWindowBottom());
|
||||
}
|
||||
|
||||
function focusOnElement() {
|
||||
if (frozen) {
|
||||
return;
|
||||
}
|
||||
console.log("Focusing on element");
|
||||
const elements = document.querySelectorAll("img, video");
|
||||
const inWindow = Array.from(elements).filter((img) => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight;
|
||||
return rect.left >= 0 && rect.top >= 80 && rect.right <= window.innerWidth && rect.top <= window.innerHeight;
|
||||
});
|
||||
const MIN_WIDTH = 100;
|
||||
/** @type {HTMLElement[]} */
|
||||
// @ts-expect-error
|
||||
const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
|
||||
// @ts-ignore
|
||||
const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH);
|
||||
if (largeElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)];
|
||||
focusedElement = randomElement;
|
||||
log("Focusing on element: ", focusedElement);
|
||||
updateFocusedElementBounds();
|
||||
flyTo(getFocusedElementRandomX(), getFocusedY());
|
||||
}
|
||||
|
||||
function updateFocusedElementBounds() {
|
||||
if (focusedElement === null) {
|
||||
// Update ground location to bottom of window
|
||||
focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() };
|
||||
return;
|
||||
}
|
||||
const { left, right, top } = focusedElement.getBoundingClientRect();
|
||||
focusedBounds = { left, right, top };
|
||||
flyTo(getFocusedElementRandomX(), getFocusedElementY());
|
||||
}
|
||||
|
||||
function getCanvasWidth() {
|
||||
@@ -2087,24 +2101,40 @@ Promise.all([
|
||||
return canvas.height * BIRB_CSS_SCALE
|
||||
}
|
||||
|
||||
function getWindowBottom() {
|
||||
return window.scrollY + window.innerHeight;
|
||||
}
|
||||
|
||||
function hop() {
|
||||
if (frozen) {
|
||||
return;
|
||||
}
|
||||
if (currentState === States.IDLE) {
|
||||
// Determine bounds for hopping
|
||||
let minX = 0;
|
||||
let maxX = window.innerWidth;
|
||||
let y = getWindowBottom();
|
||||
if (focusedElement !== null) {
|
||||
// Hop on the element
|
||||
const rect = focusedElement.getBoundingClientRect();
|
||||
minX = rect.left;
|
||||
maxX = rect.right;
|
||||
y = window.innerHeight - rect.top;
|
||||
}
|
||||
setState(States.HOP);
|
||||
setAnimation(Animations.FLYING);
|
||||
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
|
||||
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > minX) || birdX + HOP_DISTANCE > maxX) {
|
||||
targetX = birdX - HOP_DISTANCE;
|
||||
} else {
|
||||
targetX = birdX + HOP_DISTANCE;
|
||||
}
|
||||
targetY = getFocusedY();
|
||||
targetY = y;
|
||||
console.log("hopping from", birdX, birdY, "to", targetX, targetY);
|
||||
}
|
||||
}
|
||||
|
||||
function pet() {
|
||||
if (currentState === States.IDLE && currentAnimation !== Animations.HEART) {
|
||||
if (currentState === States.IDLE) {
|
||||
setAnimation(Animations.HEART);
|
||||
lastPetTimestamp = Date.now();
|
||||
}
|
||||
@@ -2120,19 +2150,13 @@ Promise.all([
|
||||
* @param {number} y
|
||||
*/
|
||||
function flyTo(x, y) {
|
||||
console.log("Flying to", x, y);
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
setState(States.FLYING);
|
||||
setAnimation(Animations.FLYING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the bird should be absolutely positioned
|
||||
*/
|
||||
function isAbsolute() {
|
||||
return focusedElement !== null && (currentState === States.IDLE || currentState === States.HOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current animation and reset the animation timer
|
||||
* @param {Anim} animation
|
||||
@@ -2147,6 +2171,7 @@ Promise.all([
|
||||
* @param {string} state
|
||||
*/
|
||||
function setState(state) {
|
||||
console.log("State:", state);
|
||||
stateStart = Date.now();
|
||||
startX = birdX;
|
||||
startY = birdY;
|
||||
@@ -2154,38 +2179,36 @@ Promise.all([
|
||||
if (state === States.IDLE) {
|
||||
setAnimation(Animations.BOB);
|
||||
}
|
||||
if (isAbsolute()) {
|
||||
canvas.classList.add("birb-absolute");
|
||||
} else {
|
||||
canvas.classList.remove("birb-absolute");
|
||||
}
|
||||
setY(birdY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bird element's X position, with the element origin at the center of the bird
|
||||
* @param {number} x
|
||||
*/
|
||||
function setX(x) {
|
||||
let mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2));
|
||||
const mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2));
|
||||
canvas.style.left = `${x + mod}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the bird element's Y position, with the element origin at the bottom of the bird
|
||||
* @param {number} y
|
||||
*/
|
||||
function setY(y) {
|
||||
let bottom;
|
||||
if (isAbsolute()) {
|
||||
// Position is absolute, convert from fixed
|
||||
bottom = y - window.scrollY;
|
||||
} else {
|
||||
// Position is fixed
|
||||
bottom = y;
|
||||
}
|
||||
canvas.style.bottom = `${bottom}px`;
|
||||
const mod = getCanvasHeight() + WINDOW_PIXEL_SIZE;
|
||||
canvas.style.top = `${y - mod}px`;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
/**
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @param {number} amount
|
||||
* @returns {number}
|
||||
*/
|
||||
function linearLerp(start, end, amount) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} startX
|
||||
@@ -2202,7 +2225,7 @@ Promise.all([
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const midX = startX + Math.cos(angle) * distance / 2;
|
||||
const midY = startY + Math.sin(angle) * distance / 2 + distance / 4 * intensity;
|
||||
const midY = startY + Math.sin(angle) * distance / 2 - distance / 4 * intensity;
|
||||
const t = amount;
|
||||
const x = (1 - t) ** 2 * startX + 2 * (1 - t) * t * midX + t ** 2 * endX;
|
||||
const y = (1 - t) ** 2 * startY + 2 * (1 - t) * t * midY + t ** 2 * endY;
|
||||
@@ -2210,27 +2233,12 @@ Promise.all([
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL parameters into a key-value map
|
||||
* @param {string} url
|
||||
* @returns {Record<string, string>}
|
||||
* @param {number} value
|
||||
*/
|
||||
function parseUrlParams(url) {
|
||||
const queryString = url.split("?")[1];
|
||||
if (!queryString) return {};
|
||||
|
||||
return queryString.split("&").reduce((params, param) => {
|
||||
const [key, value] = param.split("=");
|
||||
return { ...params, [key]: value };
|
||||
}, {});
|
||||
function roundToPixel(value) {
|
||||
return Math.round(value / WINDOW_PIXEL_SIZE) * WINDOW_PIXEL_SIZE;
|
||||
}
|
||||
|
||||
// Run the birb
|
||||
init();
|
||||
draw();
|
||||
}).catch((e) => {
|
||||
error("Error while loading sprite sheets: ", e);
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether the user is on a mobile device
|
||||
*/
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Pocket Bird",
|
||||
"name": "Browser Bird",
|
||||
"description": "It's a bird, in your browser. What more could you want?",
|
||||
"version": "2025.10.25.130",
|
||||
"version": "2025.9.16.1",
|
||||
"homepage_url": "https://idreesinc.com",
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"./dist/birb.js"
|
||||
]
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["birb.js"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
@@ -20,12 +16,8 @@
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
"images/*"
|
||||
],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
]
|
||||
"resources": ["images/*"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
@@ -34,3 +26,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev": "nodemon --watch birb.js --watch stylesheet.css --watch build.js --exec \"npm run build\""
|
||||
"dev": "nodemon --watch birb.js --watch stylesheet.css --watch build.js --watch manifest.json --exec \"npm run build\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.10"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 100vh;
|
||||
height: 200vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -13,18 +13,13 @@
|
||||
|
||||
#birb {
|
||||
image-rendering: pixelated;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
transform: scale(var(--birb-scale)) !important;
|
||||
transform-origin: bottom;
|
||||
z-index: 2147483638 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.birb-absolute {
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
.birb-decoration {
|
||||
image-rendering: pixelated;
|
||||
position: fixed;
|
||||
@@ -241,8 +236,8 @@
|
||||
flex-direction: row;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -289,12 +284,12 @@
|
||||
}
|
||||
|
||||
.birb-field-guide-description {
|
||||
width: calc(100% - 20px);
|
||||
margin-top: 5px;
|
||||
width: calc(100% - 16px);
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
color: rgb(124, 108, 75);
|
||||
|
||||
Reference in New Issue
Block a user