1 Commits

Author SHA1 Message Date
Idrees Hassan
6bb587c96f Start work on absolute positioning 2025-09-16 19:47:11 -04:00
9 changed files with 996 additions and 1000 deletions

View File

@@ -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!

553
birb.js
View File

@@ -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,40 +1827,38 @@ 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
* @param {number} startY
* @param {number} endX
@@ -1839,40 +1867,25 @@ Promise.all([
* @param {number} [intensity]
* @returns {{x: number, y: number}}
*/
function parabolicLerp(startX, startY, endX, endY, amount, intensity = 1.2) {
function parabolicLerp(startX, startY, endX, endY, amount, intensity = 1.2) {
const dx = endX - startX;
const dy = endY - startY;
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;
return { x, y };
}
}
/**
* 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 };
}, {});
}
// Run the birb
init();
draw();
}).catch((e) => {
error("Error while loading sprite sheets: ", e);
});
function roundToPixel(value) {
return Math.round(value / WINDOW_PIXEL_SIZE) * WINDOW_PIXEL_SIZE;
}
/**
* @returns {boolean} Whether the user is on a mobile device

View File

@@ -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

570
dist/birb.js vendored
View File

@@ -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,40 +2165,38 @@ 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
* @param {number} startY
* @param {number} endX
@@ -2182,40 +2205,25 @@ Promise.all([
* @param {number} [intensity]
* @returns {{x: number, y: number}}
*/
function parabolicLerp(startX, startY, endX, endY, amount, intensity = 1.2) {
function parabolicLerp(startX, startY, endX, endY, amount, intensity = 1.2) {
const dx = endX - startX;
const dy = endY - startY;
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;
return { x, y };
}
}
/**
* 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 };
}, {});
}
// Run the birb
init();
draw();
}).catch((e) => {
error("Error while loading sprite sheets: ", e);
});
function roundToPixel(value) {
return Math.round(value / WINDOW_PIXEL_SIZE) * WINDOW_PIXEL_SIZE;
}
/**
* @returns {boolean} Whether the user is on a mobile device

578
dist/birb.user.js vendored

File diff suppressed because it is too large Load Diff

View File

@@ -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 @@
}
}
}

View File

@@ -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"

View File

@@ -12,7 +12,7 @@
}
#spacer {
height: 100vh;
height: 200vh;
}
</style>
</head>

View File

@@ -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);