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! 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 1. Install [Tampermonkey](https://www.tampermonkey.net/) on your web browser
2. Enable the Tampermonkey extension and give it the permissions requested 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! 4. Now any websites you visit will have a little bird hopping around!

611
birb.js
View File

@@ -1,5 +1,6 @@
// @ts-check // @ts-check
// @ts-ignore
const SHARED_CONFIG = { const SHARED_CONFIG = {
birbCssScale: 1, birbCssScale: 1,
uiCssScale: 1, uiCssScale: 1,
@@ -8,8 +9,9 @@ const SHARED_CONFIG = {
hopDistance: 45, hopDistance: 45,
}; };
const DESKTOP_CONFIG = { const DESKTOP_CONFIG = {
flySpeed: 0.25 flySpeed: 0.2,
}; };
const MOBILE_CONFIG = { const MOBILE_CONFIG = {
@@ -26,6 +28,17 @@ const BIRB_CSS_SCALE = CONFIG.birbCssScale;
const UI_CSS_SCALE = CONFIG.uiCssScale; const UI_CSS_SCALE = CONFIG.uiCssScale;
const CANVAS_PIXEL_SIZE = CONFIG.canvasPixelSize; const CANVAS_PIXEL_SIZE = CONFIG.canvasPixelSize;
const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE; 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 = { const DEFAULT_SETTINGS = {
birbMode: false birbMode: false
@@ -35,23 +48,6 @@ const DEFAULT_SETTINGS = {
* @typedef {typeof DEFAULT_SETTINGS} 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>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
@@ -384,7 +380,6 @@ const Directions = {
}; };
const SPRITE_WIDTH = 32; const SPRITE_WIDTH = 32;
const SPRITE_HEIGHT = 32;
const DECORATIONS_SPRITE_WIDTH = 48; const DECORATIONS_SPRITE_WIDTH = 48;
const FEATHER_SPRITE_WIDTH = 32; const FEATHER_SPRITE_WIDTH = 32;
@@ -392,34 +387,6 @@ const SPRITE_SHEET = "__SPRITE_SHEET__";
const DECORATIONS_SPRITE_SHEET = "__DECORATIONS_SPRITE_SHEET__"; const DECORATIONS_SPRITE_SHEET = "__DECORATIONS_SPRITE_SHEET__";
const FEATHER_SPRITE_SHEET = "__FEATHER_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 * Load the sprite sheet and return the pixel-map template
* @param {string} dataUri * @param {string} dataUri
@@ -476,14 +443,8 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) {
}); });
} }
log("Loading sprite sheets..."); // @ts-ignore
Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false), loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)]).then(([birbPixels, decorationPixels, featherPixels]) => {
Promise.all([
loadSpriteSheetPixels(SPRITE_SHEET),
loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false),
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)
]).then(([birbPixels, decorationPixels, featherPixels]) => {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const DECORATIONS_SPRITE_SHEET = decorationPixels; const DECORATIONS_SPRITE_SHEET = decorationPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
@@ -636,6 +597,8 @@ Promise.all([
const menuItems = [ const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet), new MenuItem(`Pet ${birdBirb()}`, pet),
new MenuItem("Field Guide", insertFieldGuide), new MenuItem("Field Guide", insertFieldGuide),
// new MenuItem("Decorations", insertDecoration),
new DebugMenuItem("Applications", () => switchMenuItems(otherItems), false),
new MenuItem("Sticky Note", newStickyNote), new MenuItem("Sticky Note", newStickyNote),
new MenuItem(`Hide ${birdBirb()}`, hideBirb), new MenuItem(`Hide ${birdBirb()}`, hideBirb),
new DebugMenuItem("Freeze/Unfreeze", () => { 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 styleElement = document.createElement("style");
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
/** @type {CanvasRenderingContext2D} */ /** @type {CanvasRenderingContext2D} */
// @ts-expect-error // @ts-ignore
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const States = { const States = {
@@ -685,7 +676,7 @@ Promise.all([
let ticks = 0; let ticks = 0;
// Bird's current position // Bird's current position
let birdY = 0; let birdY = 0;
let birdX = 40; let birdX = 0;
// Bird's starting position (when flying) // Bird's starting position (when flying)
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
@@ -694,7 +685,6 @@ Promise.all([
let targetY = 0; let targetY = 0;
/** @type {HTMLElement|null} */ /** @type {HTMLElement|null} */
let focusedElement = null; let focusedElement = null;
let focusedBounds = { left: 0, right: 0, top: 0 };
let lastActionTimestamp = Date.now(); let lastActionTimestamp = Date.now();
/** @type {number[]} */ /** @type {number[]} */
let petStack = []; let petStack = [];
@@ -705,11 +695,12 @@ Promise.all([
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
let stickyNotes = []; let stickyNotes = [];
/** /**
* @returns {boolean} Whether the script is running in a userscript extension context * @returns {boolean} Whether the script is running in a userscript extension context
*/ */
function isUserScript() { function isUserScript() {
// @ts-expect-error // @ts-ignore
return typeof GM_getValue === "function"; return typeof GM_getValue === "function";
} }
@@ -722,10 +713,9 @@ Promise.all([
function load() { function load() {
/** @type {Record<string, any>} */ /** @type {Record<string, any>} */
let saveData = {}; let saveData = {};
if (isUserScript()) { if (isUserScript()) {
log("Loading save data from UserScript storage"); log("Loading save data from UserScript storage");
// @ts-expect-error // @ts-ignore
saveData = GM_getValue("birbSaveData", {}) ?? {}; saveData = GM_getValue("birbSaveData", {}) ?? {};
} else if (isTestEnvironment()) { } else if (isTestEnvironment()) {
log("Test environment detected, loading save data from localStorage"); log("Test environment detected, loading save data from localStorage");
@@ -733,18 +723,14 @@ Promise.all([
} else { } else {
log("Not a UserScript"); log("Not a UserScript");
} }
debug("Loaded data: " + JSON.stringify(saveData)); debug("Loaded data: " + JSON.stringify(saveData));
if (!saveData.settings) { if (!saveData.settings) {
log("No user settings found in save data, starting fresh"); log("No user settings found in save data, starting fresh");
} }
userSettings = saveData.settings ?? {}; userSettings = saveData.settings ?? {};
unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD]; unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD];
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
for (let note of saveData.stickyNotes) { for (let note of saveData.stickyNotes) {
if (note.id) { if (note.id) {
@@ -752,19 +738,17 @@ Promise.all([
} }
} }
} }
log(stickyNotes.length + " sticky notes loaded"); log(stickyNotes.length + " sticky notes loaded");
switchSpecies(currentSpecies); switchSpecies(currentSpecies);
} }
function save() { function save() {
/** @type {BirbSaveData} */ /** @type {Record<string, any>} */
const saveData = { let saveData = {
unlockedSpecies, unlockedSpecies: unlockedSpecies,
currentSpecies, currentSpecies: currentSpecies,
settings: userSettings settings: userSettings
}; };
if (stickyNotes.length > 0) { if (stickyNotes.length > 0) {
saveData.stickyNotes = stickyNotes.map(note => ({ saveData.stickyNotes = stickyNotes.map(note => ({
id: note.id, id: note.id,
@@ -774,10 +758,9 @@ Promise.all([
left: note.left left: note.left
})); }));
} }
if (isUserScript()) { if (isUserScript()) {
log("Saving data to UserScript storage"); log("Saving data to UserScript storage");
// @ts-expect-error // @ts-ignore
GM_setValue("birbSaveData", saveData); GM_setValue("birbSaveData", saveData);
} else if (isTestEnvironment()) { } else if (isTestEnvironment()) {
log("Test environment detected, saving data to localStorage"); log("Test environment detected, saving data to localStorage");
@@ -790,7 +773,7 @@ Promise.all([
function resetSaveData() { function resetSaveData() {
if (isUserScript()) { if (isUserScript()) {
log("Resetting save data in UserScript storage"); log("Resetting save data in UserScript storage");
// @ts-expect-error // @ts-ignore
GM_deleteValue("birbSaveData"); GM_deleteValue("birbSaveData");
} else if (isTestEnvironment()) { } else if (isTestEnvironment()) {
log("Test environment detected, resetting save data in localStorage"); log("Test environment detected, resetting save data in localStorage");
@@ -819,10 +802,8 @@ Promise.all([
function init() { function init() {
if (window !== window.top) { if (window !== window.top) {
// Skip installation if within an iframe // Skip installation if within an iframe
log("In iframe, skipping Birb script initialization");
return; return;
} }
log("Sprite sheets loaded successfully, initializing bird...");
// Preload font // Preload font
const MONOCRAFT_SRC = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf"; 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; canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE;
document.body.appendChild(canvas); document.body.appendChild(canvas);
/** @type {NodeJS.Timeout} */
let scrollTimeout;
window.addEventListener("scroll", () => { 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(); lastActionTimestamp = Date.now();
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
canvas.style.transition = "opacity 0.4s";
canvas.style.opacity = "1";
}, 100);
}); });
onClick(document, (e) => { onClick(document, (e) => {
@@ -866,10 +859,6 @@ Promise.all([
}); });
onClick(canvas, () => { onClick(canvas, () => {
if (currentAnimation === Animations.HEART && (Date.now() - lastPetTimestamp < PET_MENU_COOLDOWN)) {
// Currently being pet, don't open menu
return;
}
insertMenu(); insertMenu();
}); });
@@ -882,17 +871,13 @@ Promise.all([
} }
const pets = petStack.filter((time) => Date.now() - time < 1000).length; const pets = petStack.filter((time) => Date.now() - time < 1000).length;
if (pets >= 3) { if (pets >= 3) {
pet(); setAnimation(Animations.HEART);
// Clear the stack // Clear the stack
petStack = []; petStack = [];
} }
} }
}); });
canvas.addEventListener("touchmove", (e) => {
pet();
});
drawStickyNotes(); drawStickyNotes();
let lastUrl = (window.location.href ?? "").split("?")[0]; let lastUrl = (window.location.href ?? "").split("?")[0];
@@ -903,11 +888,31 @@ Promise.all([
lastUrl = currentUrl; lastUrl = currentUrl;
drawStickyNotes(); 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() { function update() {
ticks++; ticks++;
@@ -917,29 +922,18 @@ Promise.all([
// Won't be restored on fullscreen exit // Won't be restored on fullscreen exit
} }
if (currentState === States.IDLE && !frozen && !isMenuOpen()) { if (currentState === States.IDLE) {
if (Math.random() < HOP_CHANCE && currentAnimation !== Animations.HEART) { if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART && !isMenuOpen()) {
hop(); hop();
} else if (Date.now() - lastActionTimestamp > AFK_TIME) { } else if (Math.random() < 1 / (60 * 20) && Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
// Idle for a while, do something focusOnElement();
if (focusedElement === null) { lastActionTimestamp = Date.now();
// Fly to an element
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 // 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) { if (visible && Math.random() < FEATHER_CHANCE * petMod) {
lastPetTimestamp = 0; lastPetTimestamp = 0;
activateFeather(); activateFeather();
@@ -947,6 +941,9 @@ Promise.all([
updateFeather(); updateFeather();
} }
/**
* Render the bird in the dom and update its position if necessary
*/
function draw() { function draw() {
requestAnimationFrame(draw); requestAnimationFrame(draw);
@@ -954,28 +951,40 @@ Promise.all([
return; return;
} }
updateFocusedElementBounds();
// Update the bird's position // Update the bird's position
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement !== null) {
focusOnGround(); birdY = getFocusedElementY();
if (!isWithinHorizontalBounds(birdX)) {
focusOnGround();
}
} else {
// Ground the bird
birdY = getWindowBottom();
} }
birdY = getFocusedY();
} else if (currentState === States.FLYING) { } else if (currentState === States.FLYING) {
// Fly to target location (even if in the air) // Fly to target location (even if in the air)
if (updateParabolicPath(FLY_SPEED)) { if (updateParabolicPath(FLY_SPEED)) {
setState(States.IDLE); setState(States.IDLE);
} }
} else if (currentState === States.HOP) {
if (updateParabolicPath(HOP_SPEED)) {
setState(States.IDLE);
}
} }
const oldTargetY = targetY; if (focusedElement === null) {
targetY = getFocusedY(); if (Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) {
// Adjust startY to account for scrolling // Fly to an element if the user is AFK
startY += targetY - oldTargetY; // focusOnElement();
if (targetY < 0 || targetY > window.innerHeight) { // lastActionTimestamp = Date.now();
// Fly to ground if the focused element moves out of bounds }
focusOnGround(); } 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); ctx.clearRect(0, 0, canvas.width, canvas.height);
@@ -988,6 +997,9 @@ Promise.all([
setY(birdY); setY(birdY);
} }
init();
draw();
function newStickyNote() { function newStickyNote() {
const id = Date.now().toString(); const id = Date.now().toString();
const site = window.location.href; const site = window.location.href;
@@ -1088,8 +1100,17 @@ Promise.all([
return false; return false;
} }
const stickyNoteParams = parseUrlParams(stickyNoteUrl); /** @type {Record<string, string>} */
const currentParams = parseUrlParams(currentUrl); 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); debug("Comparing params: ", stickyNoteParams, currentParams);
@@ -1101,18 +1122,6 @@ Promise.all([
return true; 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 * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -1132,42 +1141,6 @@ Promise.all([
return element; 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() { function insertDecoration() {
// Create a canvas element for the decoration // Create a canvas element for the decoration
const decorationCanvas = document.createElement("canvas"); const decorationCanvas = document.createElement("canvas");
@@ -1247,16 +1220,21 @@ Promise.all([
function updateFeather() { function updateFeather() {
const feather = document.querySelector("#birb-feather"); const feather = document.querySelector("#birb-feather");
const featherGravity = 1;
if (!feather || !(feather instanceof HTMLElement)) { if (!feather || !(feather instanceof HTMLElement)) {
return; 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`; feather.style.top = `${Math.min(y, window.innerHeight - feather.offsetHeight)}px`;
if (y < window.innerHeight - feather.offsetHeight) { if (y < window.innerHeight - feather.offsetHeight) {
feather.style.left = `${Math.sin(3.14 * 2 * (ticks / 120)) * 25}px`; feather.style.left = `${Math.sin(3.14 * 2 * (ticks / 120)) * 25}px`;
} }
} }
// insertDecoration();
// insertFieldGuide();
/** /**
* @param {HTMLElement} element * @param {HTMLElement} element
*/ */
@@ -1273,14 +1251,28 @@ Promise.all([
if (document.querySelector("#" + FIELD_GUIDE_ID)) { if (document.querySelector("#" + FIELD_GUIDE_ID)) {
return; return;
} }
let html = `
const modal = createWindow("birb-modal", title, ` <div class="birb-window-header">
<div class="birb-message-content"> <div class="birb-window-title">${title}</div>
${message} <div class="birb-window-close">x</div>
</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.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); centerElement(modal);
} }
@@ -1389,10 +1381,60 @@ Promise.all([
} }
} }
// insertPico8();
function isSafari() { function isSafari() {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 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 * @param {string} type
*/ */
@@ -1506,23 +1548,7 @@ Promise.all([
*/ */
function onClick(element, action) { function onClick(element, action) {
element.addEventListener("click", (e) => action(e)); element.addEventListener("click", (e) => action(e));
element.addEventListener("touchend", (e) => { element.addEventListener("touchstart", (e) => action(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);
}
});
} }
/** /**
@@ -1656,70 +1682,63 @@ Promise.all([
} }
function getFocusedElementRandomX() { 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() { function getFocusedElementY() {
return birdX >= focusedBounds.left && birdX <= focusedBounds.right; if (focusedElement === null) {
} return getWindowBottom();
}
function getFocusedY() { const rect = focusedElement.getBoundingClientRect();
return getFullWindowHeight() - focusedBounds.top; 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() { function isWithinHorizontalBounds(x) {
// Necessary because iOS 26 Safari is terrible and won't render if (focusedElement === null) {
// fixed elements behind the address bar return true;
return window.innerHeight; }
} const rect = focusedElement.getBoundingClientRect();
return x >= rect.left && x <= rect.right;
/**
* @returns The true height of the inner browser window
*/
function getFullWindowHeight() {
return document.documentElement.clientHeight;
} }
function focusOnGround() { function focusOnGround() {
if (focusedElement === null) {
// Already focused on ground
return;
}
console.log("Focusing on ground"); console.log("Focusing on ground");
focusedElement = null; focusedElement = null;
focusedBounds = { left: 0, right: window.innerWidth, top: getSafeWindowHeight() }; flyTo(Math.random() * window.innerWidth, getWindowBottom());
flyTo(Math.random() * window.innerWidth, 0);
} }
function focusOnElement() { function focusOnElement() {
if (frozen) { if (frozen) {
return; return;
} }
console.log("Focusing on element");
const elements = document.querySelectorAll("img, video"); const elements = document.querySelectorAll("img, video");
const inWindow = Array.from(elements).filter((img) => { const inWindow = Array.from(elements).filter((img) => {
const rect = img.getBoundingClientRect(); 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[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error // @ts-ignore
const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH);
if (largeElements.length === 0) { if (largeElements.length === 0) {
return; return;
} }
const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)]; const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)];
focusedElement = randomElement; focusedElement = randomElement;
log("Focusing on element: ", focusedElement); flyTo(getFocusedElementRandomX(), getFocusedElementY());
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 };
} }
function getCanvasWidth() { function getCanvasWidth() {
@@ -1730,24 +1749,40 @@ Promise.all([
return canvas.height * BIRB_CSS_SCALE return canvas.height * BIRB_CSS_SCALE
} }
function getWindowBottom() {
return window.scrollY + window.innerHeight;
}
function hop() { function hop() {
if (frozen) { if (frozen) {
return; return;
} }
if (currentState === States.IDLE) { 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); setState(States.HOP);
setAnimation(Animations.FLYING); 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; targetX = birdX - HOP_DISTANCE;
} else { } else {
targetX = birdX + HOP_DISTANCE; targetX = birdX + HOP_DISTANCE;
} }
targetY = getFocusedY(); targetY = y;
console.log("hopping from", birdX, birdY, "to", targetX, targetY);
} }
} }
function pet() { function pet() {
if (currentState === States.IDLE && currentAnimation !== Animations.HEART) { if (currentState === States.IDLE) {
setAnimation(Animations.HEART); setAnimation(Animations.HEART);
lastPetTimestamp = Date.now(); lastPetTimestamp = Date.now();
} }
@@ -1763,19 +1798,13 @@ Promise.all([
* @param {number} y * @param {number} y
*/ */
function flyTo(x, y) { function flyTo(x, y) {
console.log("Flying to", x, y);
targetX = x; targetX = x;
targetY = y; targetY = y;
setState(States.FLYING); setState(States.FLYING);
setAnimation(Animations.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 * Set the current animation and reset the animation timer
* @param {Anim} animation * @param {Anim} animation
@@ -1790,6 +1819,7 @@ Promise.all([
* @param {string} state * @param {string} state
*/ */
function setState(state) { function setState(state) {
console.log("State:", state);
stateStart = Date.now(); stateStart = Date.now();
startX = birdX; startX = birdX;
startY = birdY; startY = birdY;
@@ -1797,83 +1827,66 @@ Promise.all([
if (state === States.IDLE) { if (state === States.IDLE) {
setAnimation(Animations.BOB); 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 * @param {number} x
*/ */
function setX(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`; 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 * @param {number} y
*/ */
function setY(y) { function setY(y) {
let bottom; const mod = getCanvasHeight() + WINDOW_PIXEL_SIZE;
if (isAbsolute()) { canvas.style.top = `${y - mod}px`;
// Position is absolute, convert from fixed
bottom = y - window.scrollY;
} else {
// Position is fixed
bottom = y;
}
canvas.style.bottom = `${bottom}px`;
} }
// Helper functions
/**
* @param {number} startX
* @param {number} startY
* @param {number} endX
* @param {number} endY
* @param {number} amount
* @param {number} [intensity]
* @returns {{x: number, y: number}}
*/
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 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>}
*/
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);
}); });
/**
* @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
* @param {number} endY
* @param {number} amount
* @param {number} [intensity]
* @returns {{x: number, y: number}}
*/
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 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 };
}
/**
* @param {number} value
*/
function roundToPixel(value) {
return Math.round(value / WINDOW_PIXEL_SIZE) * WINDOW_PIXEL_SIZE;
}
/** /**
* @returns {boolean} Whether the user is on a mobile device * @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_PATH = "./stylesheet.css";
const STYLESHEET_KEY = "___STYLESHEET___"; const STYLESHEET_KEY = "___STYLESHEET___";
const now = new Date(); let version = "0.0.0";
const versionDate = `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`; // Try to read version from manifest.json
// Get current build number from manifest.json
let buildNumber = 0;
try { try {
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
if (manifest.version) { if (manifest.version) {
if (manifest.version.startsWith(versionDate)) { version = manifest.version;
// Same day, increment build number
const parts = manifest.version.split('.');
if (parts.length === 4) {
buildNumber = parseInt(parts[3], 10) + 1;
}
}
} }
} catch (e) { } catch (e) {
console.error("Could not read version from manifest.json"); console.error("Could not read version from manifest.json");
throw e; 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 = const userScriptHeader =
`// ==UserScript== `// ==UserScript==
// @name Pocket Bird // @name Browser Bird
// @namespace https://idreesinc.com // @namespace https://idreesinc.com
// @version ${version} // @version ${version}
// @description birb // @description birb
// @author Idrees // @author Idrees
// @downloadURL 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/Pocket-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 *://*/* // @match *://*/*
// @grant GM_setValue // @grant GM_setValue
// @grant GM_getValue // @grant GM_getValue

628
dist/birb.js vendored

File diff suppressed because it is too large Load Diff

636
dist/birb.user.js vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,29 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Pocket Bird", "name": "Browser Bird",
"description": "It's a bird, in your browser. What more could you want?", "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", "homepage_url": "https://idreesinc.com",
"content_scripts": [ "content_scripts": [
{ {
"matches": [ "matches": ["<all_urls>"],
"<all_urls>" "js": ["birb.js"]
], }
"js": [ ],
"./dist/birb.js" "permissions": [
] "storage",
} "activeTab"
], ],
"permissions": [ "web_accessible_resources": [
"storage", {
"activeTab" "resources": ["images/*"],
], "matches": ["<all_urls>"]
"web_accessible_resources": [ }
{ ],
"resources": [ "browser_specific_settings": {
"images/*" "gecko": {
], "id": "birb@idreesinc.com"
"matches": [ }
"<all_urls>" }
] }
}
],
"browser_specific_settings": {
"gecko": {
"id": "birb@idreesinc.com"
}
}
}

View File

@@ -7,7 +7,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node build.js", "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": { "devDependencies": {
"nodemon": "^3.1.10" "nodemon": "^3.1.10"

View File

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

View File

@@ -13,18 +13,13 @@
#birb { #birb {
image-rendering: pixelated; image-rendering: pixelated;
position: fixed; position: absolute;
bottom: 0;
transform: scale(var(--birb-scale)) !important; transform: scale(var(--birb-scale)) !important;
transform-origin: bottom; transform-origin: bottom;
z-index: 2147483638 !important; z-index: 2147483638 !important;
cursor: pointer; cursor: pointer;
} }
.birb-absolute {
position: absolute !important;
}
.birb-decoration { .birb-decoration {
image-rendering: pixelated; image-rendering: pixelated;
position: fixed; position: fixed;
@@ -241,8 +236,8 @@
flex-direction: row; flex-direction: row;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
padding-left: 10px; padding-left: 15px;
padding-right: 10px; padding-right: 15px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -289,12 +284,12 @@
} }
.birb-field-guide-description { .birb-field-guide-description {
width: calc(100% - 20px); width: calc(100% - 16px);
margin-top: 5px; margin-top: 10px;
padding: 8px; padding: 8px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
margin-bottom: 10px; margin-bottom: 6px;
font-size: 14px; font-size: 14px;
box-sizing: border-box; box-sizing: border-box;
color: rgb(124, 108, 75); color: rgb(124, 108, 75);