diff --git a/README.md b/README.md index c2de630..b73fe0d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Browser Bird (Work in Progress!) +# Pocket 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 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) +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) 4. Now any websites you visit will have a little bird hopping around! \ No newline at end of file diff --git a/birb.js b/birb.js index 3715d1f..3f00d3e 100644 --- a/birb.js +++ b/birb.js @@ -1,6 +1,5 @@ // @ts-check -// @ts-ignore const SHARED_CONFIG = { birbCssScale: 1, uiCssScale: 1, @@ -9,9 +8,8 @@ const SHARED_CONFIG = { hopDistance: 45, }; - const DESKTOP_CONFIG = { - flySpeed: 0.2, + flySpeed: 0.25 }; const MOBILE_CONFIG = { @@ -28,17 +26,6 @@ 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 @@ -48,6 +35,23 @@ 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 + * @property {SavedStickyNote[]} [stickyNotes] + */ + /** @type {Partial} */ let userSettings = {}; @@ -380,6 +384,7 @@ const Directions = { }; const SPRITE_WIDTH = 32; +const SPRITE_HEIGHT = 32; const DECORATIONS_SPRITE_WIDTH = 48; const FEATHER_SPRITE_WIDTH = 32; @@ -387,6 +392,34 @@ 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 @@ -443,8 +476,14 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) { }); } -// @ts-ignore -Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false), loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)]).then(([birbPixels, decorationPixels, featherPixels]) => { +log("Loading sprite sheets..."); + +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; @@ -597,8 +636,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI 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", () => { @@ -627,39 +664,11 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI }) ]; - 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-ignore + // @ts-expect-error const ctx = canvas.getContext("2d"); const States = { @@ -700,20 +709,23 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI * @returns {boolean} Whether the script is running in a userscript extension context */ function isUserScript() { - // @ts-ignore + // @ts-expect-error return typeof GM_getValue === "function"; } function isTestEnvironment() { - return window.location.hostname === "127.0.0.1"; + return window.location.hostname === "127.0.0.1" + || window.location.hostname === "localhost" + || window.location.hostname.startsWith("192.168."); } function load() { /** @type {Record} */ let saveData = {}; + if (isUserScript()) { log("Loading save data from UserScript storage"); - // @ts-ignore + // @ts-expect-error saveData = GM_getValue("birbSaveData", {}) ?? {}; } else if (isTestEnvironment()) { log("Test environment detected, loading save data from localStorage"); @@ -721,14 +733,18 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } 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) { @@ -736,17 +752,19 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } } + log(stickyNotes.length + " sticky notes loaded"); switchSpecies(currentSpecies); } function save() { - /** @type {Record} */ - let saveData = { - unlockedSpecies: unlockedSpecies, - currentSpecies: currentSpecies, + /** @type {BirbSaveData} */ + const saveData = { + unlockedSpecies, + currentSpecies, settings: userSettings }; + if (stickyNotes.length > 0) { saveData.stickyNotes = stickyNotes.map(note => ({ id: note.id, @@ -756,9 +774,10 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI left: note.left })); } + if (isUserScript()) { log("Saving data to UserScript storage"); - // @ts-ignore + // @ts-expect-error GM_setValue("birbSaveData", saveData); } else if (isTestEnvironment()) { log("Test environment detected, saving data to localStorage"); @@ -771,7 +790,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function resetSaveData() { if (isUserScript()) { log("Resetting save data in UserScript storage"); - // @ts-ignore + // @ts-expect-error GM_deleteValue("birbSaveData"); } else if (isTestEnvironment()) { log("Test environment detected, resetting save data in localStorage"); @@ -797,6 +816,178 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return settings().birbMode ? "Birb" : "Bird"; } + 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"; + const fontLink = document.createElement("link"); + fontLink.rel = "stylesheet"; + fontLink.href = `url(${MONOCRAFT_SRC}) format('opentype')`; + document.head.appendChild(fontLink); + + // Add stylesheet font-face + const fontFace = ` + @font-face { + font-family: 'Monocraft'; + src: url(${MONOCRAFT_SRC}) format('opentype'); + font-weight: normal; + font-style: normal; + } + `; + const fontStyle = document.createElement("style"); + fontStyle.innerHTML = fontFace; + document.head.appendChild(fontStyle); + + load(); + + styleElement.innerHTML = STYLESHEET; + document.head.appendChild(styleElement); + + canvas.id = "birb"; + canvas.width = birbFrames.base.getPixels()[0].length * CANVAS_PIXEL_SIZE; + canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; + document.body.appendChild(canvas); + + window.addEventListener("scroll", () => { + lastActionTimestamp = Date.now(); + }); + + onClick(document, (e) => { + lastActionTimestamp = Date.now(); + if (e.target instanceof Node && document.querySelector("#" + MENU_EXIT_ID)?.contains(e.target)) { + removeMenu(); + } + }); + + onClick(canvas, () => { + if (currentAnimation === Animations.HEART && (Date.now() - lastPetTimestamp < PET_MENU_COOLDOWN)) { + // Currently being pet, don't open menu + return; + } + insertMenu(); + }); + + canvas.addEventListener("mouseover", () => { + lastActionTimestamp = Date.now(); + if (currentState === States.IDLE) { + petStack.push(Date.now()); + if (petStack.length > 10) { + petStack.shift(); + } + const pets = petStack.filter((time) => Date.now() - time < 1000).length; + if (pets >= 3) { + pet(); + // Clear the stack + petStack = []; + } + } + }); + + canvas.addEventListener("touchmove", (e) => { + pet(); + }); + + drawStickyNotes(); + + let lastUrl = (window.location.href ?? "").split("?")[0]; + setInterval(() => { + const currentUrl = (window.location.href ?? "").split("?")[0]; + if (currentUrl !== lastUrl) { + log("URL changed, updating sticky notes"); + lastUrl = currentUrl; + drawStickyNotes(); + } + }, URL_CHECK_INTERVAL); + + setInterval(update, UPDATE_INTERVAL); + } + + function update() { + ticks++; + + // Hide bird if the browser is fullscreen + if (document.fullscreenElement) { + hideBirb(); + // Won't be restored on fullscreen exit + } + + if (currentState === States.IDLE && !frozen && !isMenuOpen()) { + if (Math.random() < HOP_CHANCE && currentAnimation !== Animations.HEART) { + hop(); + } else if (Date.now() - lastActionTimestamp > AFK_TIME) { + // Idle for a while, do something + if (focusedElement === null) { + // 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); + } + } + + // Double the chance of a feather if recently pet + const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1; + if (visible && Math.random() < FEATHER_CHANCE * petMod) { + lastPetTimestamp = 0; + activateFeather(); + } + updateFeather(); + } + + function draw() { + requestAnimationFrame(draw); + + if (!visible) { + return; + } + + updateFocusedElementBounds(); + + // Update the bird's position + if (currentState === States.IDLE) { + if (focusedElement && !isWithinHorizontalBounds()) { + focusOnGround(); + } + birdY = getFocusedY(); + } else if (currentState === States.FLYING) { + // Fly to target location (even if in the air) + if (updateParabolicPath(FLY_SPEED)) { + setState(States.IDLE); + } + } + + const oldTargetY = targetY; + targetY = getFocusedY(); + // Adjust startY to account for scrolling + startY += targetY - oldTargetY; + if (targetY < 0 || targetY > 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])) { + setAnimation(Animations.STILL); + } + + // Update HTML element position + setX(birdX); + setY(birdY); + } + function newStickyNote() { const id = Date.now().toString(); const site = window.location.href; @@ -897,17 +1088,8 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return false; } - /** @type {Record} */ - const stickyNoteParams = stickyNoteUrl.split("?")[1]?.split("&").reduce((params, param) => { - const [key, value] = param.split("="); - return { ...params, [key]: value }; - }, {}); - - /** @type {Record} */ - const currentParams = currentUrl.split("?")[1]?.split("&").reduce((params, param) => { - const [key, value] = param.split("="); - return { ...params, [key]: value }; - }, {}); + const stickyNoteParams = parseUrlParams(stickyNoteUrl); + const currentParams = parseUrlParams(currentUrl); debug("Comparing params: ", stickyNoteParams, currentParams); @@ -919,88 +1101,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return true; } - function init() { - if (window !== window.top) { - // Skip installation if within an iframe - return; - } - - // Preload font - const MONOCRAFT_SRC = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf"; - const fontLink = document.createElement("link"); - fontLink.rel = "stylesheet"; - fontLink.href = `url(${MONOCRAFT_SRC}) format('opentype')`; - document.head.appendChild(fontLink); - - // Add stylesheet font-face - const fontFace = ` - @font-face { - font-family: 'Monocraft'; - src: url(${MONOCRAFT_SRC}) format('opentype'); - font-weight: normal; - font-style: normal; - } - `; - const fontStyle = document.createElement("style"); - fontStyle.innerHTML = fontFace; - document.head.appendChild(fontStyle); - - load(); - - styleElement.innerHTML = STYLESHEET; - document.head.appendChild(styleElement); - - canvas.id = "birb"; - canvas.width = birbFrames.base.getPixels()[0].length * CANVAS_PIXEL_SIZE; - canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; - document.body.appendChild(canvas); - - window.addEventListener("scroll", () => { - lastActionTimestamp = Date.now(); - }); - - onClick(document, (e) => { - lastActionTimestamp = Date.now(); - if (e.target instanceof Node && document.querySelector("#" + MENU_EXIT_ID)?.contains(e.target)) { - removeMenu(); - } - }); - - onClick(canvas, () => { - insertMenu(); - }); - - canvas.addEventListener("mouseover", () => { - lastActionTimestamp = Date.now(); - if (currentState === States.IDLE) { - petStack.push(Date.now()); - if (petStack.length > 10) { - petStack.shift(); - } - const pets = petStack.filter((time) => Date.now() - time < 1000).length; - if (pets >= 3) { - setAnimation(Animations.HEART); - // Clear the stack - petStack = []; - } - } - }); - - drawStickyNotes(); - - let lastUrl = (window.location.href ?? "").split("?")[0]; - setInterval(() => { - const currentUrl = (window.location.href ?? "").split("?")[0]; - if (currentUrl !== lastUrl) { - log("URL changed, updating sticky notes"); - lastUrl = currentUrl; - drawStickyNotes(); - } - }, 500); - - setInterval(update, 1000 / 60); - } - function drawStickyNotes() { // Remove all existing sticky notes const existingNotes = document.querySelectorAll(".birb-sticky-note"); @@ -1013,89 +1113,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } - function update() { - ticks++; - - // Hide bird if the browser is fullscreen - if (document.fullscreenElement) { - hideBirb(); - // Won't be restored on fullscreen exit - } - - if (currentState === States.IDLE && !frozen && !isMenuOpen()) { - if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART) { - hop(); - } else if (Date.now() - lastActionTimestamp > AFK_TIME) { - // Idle for a while, do something - if (focusedElement === null) { - // Fly to an element - focusOnElement(); - lastActionTimestamp = Date.now(); - } else if (Math.random() < 1 / (60 * 20)) { - // 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 - let petMod = Date.now() - lastPetTimestamp < 1000 * 60 * 5 ? 2 : 1; - if (visible && Math.random() < FEATHER_CHANCE * petMod) { - lastPetTimestamp = 0; - activateFeather(); - } - updateFeather(); - } - - function draw() { - requestAnimationFrame(draw); - - if (!visible) { - return; - } - - updateFocusedElementBounds(); - - // Update the bird's position - if (currentState === States.IDLE) { - if (focusedElement && !isWithinHorizontalBounds()) { - focusOnGround(); - } - birdY = getFocusedY(); - } else if (currentState === States.FLYING) { - // Fly to target location (even if in the air) - if (updateParabolicPath(FLY_SPEED)) { - setState(States.IDLE); - } - } - - const oldTargetY = targetY; - targetY = getFocusedY(); - // Adjust startY to account for scrolling - startY += targetY - oldTargetY; - if (targetY < 0 || targetY > 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])) { - setAnimation(Animations.STILL); - } - - // Update HTML element position - setX(birdX); - setY(birdY); - } - - init(); - draw(); - /** * Create an HTML element with the specified parameters * @param {string} className @@ -1115,6 +1132,42 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI 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 = ` +
+
${title}
+
x
+
+
+ ${contentHtml} +
+ `; + + 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"); @@ -1194,21 +1247,16 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function updateFeather() { const feather = document.querySelector("#birb-feather"); - const featherGravity = 1; if (!feather || !(feather instanceof HTMLElement)) { return; } - const y = parseInt(feather.style.top || "0") + featherGravity; + const y = parseInt(feather.style.top || "0") + FEATHER_FALL_SPEED; 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 */ @@ -1225,28 +1273,14 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI if (document.querySelector("#" + FIELD_GUIDE_ID)) { return; } - let html = ` -
-
${title}
-
x
-
-
-
- ${message} -
-
` - 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); - } + const modal = createWindow("birb-modal", title, ` +
+ ${message} +
+ `); + + modal.style.width = "270px"; centerElement(modal); } @@ -1355,60 +1389,10 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } - // insertPico8(); - function isSafari() { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); } - /** - * @param {string} name - * @param {string} pid - */ - function insertPico8(name, pid) { - let html = ` -
-
${name}
-
x
-
-
- -
` - 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 = ` -
-
Music Player
-
x
-
-
- -
`; - 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 */ @@ -1522,7 +1506,23 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI */ function onClick(element, action) { element.addEventListener("click", (e) => action(e)); - element.addEventListener("touchstart", (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); + } + }); } /** @@ -1682,7 +1682,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function getFullWindowHeight() { return document.documentElement.clientHeight; } - + function focusOnGround() { console.log("Focusing on ground"); focusedElement = null; @@ -1697,12 +1697,11 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI const elements = document.querySelectorAll("img, video"); const inWindow = Array.from(elements).filter((img) => { const rect = img.getBoundingClientRect(); - return rect.left >= 0 && rect.top >= 80 && rect.right <= window.innerWidth && rect.top <= window.innerHeight; + return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight; }); - const MIN_WIDTH = 100; /** @type {HTMLElement[]} */ - // @ts-ignore - const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH); + // @ts-expect-error + const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); if (largeElements.length === 0) { return; } @@ -1719,12 +1718,8 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() }; return; } - const rect = focusedElement.getBoundingClientRect(); - focusedBounds = { - left: rect.left, - right: rect.right, - top: rect.top - }; + const { left, right, top } = focusedElement.getBoundingClientRect(); + focusedBounds = { left, right, top }; } function getCanvasWidth() { @@ -1752,7 +1747,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } function pet() { - if (currentState === States.IDLE) { + if (currentState === States.IDLE && currentAnimation !== Animations.HEART) { setAnimation(Animations.HEART); lastPetTimestamp = Date.now(); } @@ -1832,47 +1827,53 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } 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} + */ + 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 */ diff --git a/build.js b/build.js index 4169937..238ae8c 100644 --- a/build.js +++ b/build.js @@ -20,11 +20,44 @@ 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; +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; + } + } + } +} 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 // @namespace https://idreesinc.com -// @version 2025-10-23-01 +// @version ${version} // @description birb // @author Idrees // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js diff --git a/dist/birb.js b/dist/birb.js index 8e1113a..d9b3a20 100644 --- a/dist/birb.js +++ b/dist/birb.js @@ -1,6 +1,5 @@ // @ts-check -// @ts-ignore const SHARED_CONFIG = { birbCssScale: 1, uiCssScale: 1, @@ -9,9 +8,8 @@ const SHARED_CONFIG = { hopDistance: 45, }; - const DESKTOP_CONFIG = { - flySpeed: 0.2, + flySpeed: 0.25 }; const MOBILE_CONFIG = { @@ -28,17 +26,6 @@ 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 @@ -48,6 +35,23 @@ 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 + * @property {SavedStickyNote[]} [stickyNotes] + */ + /** @type {Partial} */ let userSettings = {}; @@ -294,8 +298,8 @@ const STYLESHEET = `:root { flex-direction: row; padding-top: 4px; padding-bottom: 4px; - padding-left: 15px; - padding-right: 15px; + padding-left: 10px; + padding-right: 10px; box-sizing: border-box; } @@ -342,12 +346,12 @@ const STYLESHEET = `:root { } .birb-field-guide-description { - width: calc(100% - 16px); - margin-top: 10px; + width: calc(100% - 20px); + margin-top: 5px; padding: 8px; padding-top: 4px; padding-bottom: 4px; - margin-bottom: 6px; + margin-bottom: 10px; font-size: 14px; box-sizing: border-box; color: rgb(124, 108, 75); @@ -723,6 +727,7 @@ const Directions = { }; const SPRITE_WIDTH = 32; +const SPRITE_HEIGHT = 32; const DECORATIONS_SPRITE_WIDTH = 48; const FEATHER_SPRITE_WIDTH = 32; @@ -730,6 +735,34 @@ 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 @@ -786,8 +819,14 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) { }); } -// @ts-ignore -Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false), loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)]).then(([birbPixels, decorationPixels, featherPixels]) => { +log("Loading sprite sheets..."); + +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; @@ -940,8 +979,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI 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", () => { @@ -970,39 +1007,11 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI }) ]; - 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-ignore + // @ts-expect-error const ctx = canvas.getContext("2d"); const States = { @@ -1043,20 +1052,23 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI * @returns {boolean} Whether the script is running in a userscript extension context */ function isUserScript() { - // @ts-ignore + // @ts-expect-error return typeof GM_getValue === "function"; } function isTestEnvironment() { - return window.location.hostname === "127.0.0.1"; + return window.location.hostname === "127.0.0.1" + || window.location.hostname === "localhost" + || window.location.hostname.startsWith("192.168."); } function load() { /** @type {Record} */ let saveData = {}; + if (isUserScript()) { log("Loading save data from UserScript storage"); - // @ts-ignore + // @ts-expect-error saveData = GM_getValue("birbSaveData", {}) ?? {}; } else if (isTestEnvironment()) { log("Test environment detected, loading save data from localStorage"); @@ -1064,14 +1076,18 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } 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) { @@ -1079,17 +1095,19 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } } + log(stickyNotes.length + " sticky notes loaded"); switchSpecies(currentSpecies); } function save() { - /** @type {Record} */ - let saveData = { - unlockedSpecies: unlockedSpecies, - currentSpecies: currentSpecies, + /** @type {BirbSaveData} */ + const saveData = { + unlockedSpecies, + currentSpecies, settings: userSettings }; + if (stickyNotes.length > 0) { saveData.stickyNotes = stickyNotes.map(note => ({ id: note.id, @@ -1099,9 +1117,10 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI left: note.left })); } + if (isUserScript()) { log("Saving data to UserScript storage"); - // @ts-ignore + // @ts-expect-error GM_setValue("birbSaveData", saveData); } else if (isTestEnvironment()) { log("Test environment detected, saving data to localStorage"); @@ -1114,7 +1133,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function resetSaveData() { if (isUserScript()) { log("Resetting save data in UserScript storage"); - // @ts-ignore + // @ts-expect-error GM_deleteValue("birbSaveData"); } else if (isTestEnvironment()) { log("Test environment detected, resetting save data in localStorage"); @@ -1140,6 +1159,178 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return settings().birbMode ? "Birb" : "Bird"; } + 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"; + const fontLink = document.createElement("link"); + fontLink.rel = "stylesheet"; + fontLink.href = `url(${MONOCRAFT_SRC}) format('opentype')`; + document.head.appendChild(fontLink); + + // Add stylesheet font-face + const fontFace = ` + @font-face { + font-family: 'Monocraft'; + src: url(${MONOCRAFT_SRC}) format('opentype'); + font-weight: normal; + font-style: normal; + } + `; + const fontStyle = document.createElement("style"); + fontStyle.innerHTML = fontFace; + document.head.appendChild(fontStyle); + + load(); + + styleElement.innerHTML = STYLESHEET; + document.head.appendChild(styleElement); + + canvas.id = "birb"; + canvas.width = birbFrames.base.getPixels()[0].length * CANVAS_PIXEL_SIZE; + canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; + document.body.appendChild(canvas); + + window.addEventListener("scroll", () => { + lastActionTimestamp = Date.now(); + }); + + onClick(document, (e) => { + lastActionTimestamp = Date.now(); + if (e.target instanceof Node && document.querySelector("#" + MENU_EXIT_ID)?.contains(e.target)) { + removeMenu(); + } + }); + + onClick(canvas, () => { + if (currentAnimation === Animations.HEART && (Date.now() - lastPetTimestamp < PET_MENU_COOLDOWN)) { + // Currently being pet, don't open menu + return; + } + insertMenu(); + }); + + canvas.addEventListener("mouseover", () => { + lastActionTimestamp = Date.now(); + if (currentState === States.IDLE) { + petStack.push(Date.now()); + if (petStack.length > 10) { + petStack.shift(); + } + const pets = petStack.filter((time) => Date.now() - time < 1000).length; + if (pets >= 3) { + pet(); + // Clear the stack + petStack = []; + } + } + }); + + canvas.addEventListener("touchmove", (e) => { + pet(); + }); + + drawStickyNotes(); + + let lastUrl = (window.location.href ?? "").split("?")[0]; + setInterval(() => { + const currentUrl = (window.location.href ?? "").split("?")[0]; + if (currentUrl !== lastUrl) { + log("URL changed, updating sticky notes"); + lastUrl = currentUrl; + drawStickyNotes(); + } + }, URL_CHECK_INTERVAL); + + setInterval(update, UPDATE_INTERVAL); + } + + function update() { + ticks++; + + // Hide bird if the browser is fullscreen + if (document.fullscreenElement) { + hideBirb(); + // Won't be restored on fullscreen exit + } + + if (currentState === States.IDLE && !frozen && !isMenuOpen()) { + if (Math.random() < HOP_CHANCE && currentAnimation !== Animations.HEART) { + hop(); + } else if (Date.now() - lastActionTimestamp > AFK_TIME) { + // Idle for a while, do something + if (focusedElement === null) { + // 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); + } + } + + // Double the chance of a feather if recently pet + const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1; + if (visible && Math.random() < FEATHER_CHANCE * petMod) { + lastPetTimestamp = 0; + activateFeather(); + } + updateFeather(); + } + + function draw() { + requestAnimationFrame(draw); + + if (!visible) { + return; + } + + updateFocusedElementBounds(); + + // Update the bird's position + if (currentState === States.IDLE) { + if (focusedElement && !isWithinHorizontalBounds()) { + focusOnGround(); + } + birdY = getFocusedY(); + } else if (currentState === States.FLYING) { + // Fly to target location (even if in the air) + if (updateParabolicPath(FLY_SPEED)) { + setState(States.IDLE); + } + } + + const oldTargetY = targetY; + targetY = getFocusedY(); + // Adjust startY to account for scrolling + startY += targetY - oldTargetY; + if (targetY < 0 || targetY > 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])) { + setAnimation(Animations.STILL); + } + + // Update HTML element position + setX(birdX); + setY(birdY); + } + function newStickyNote() { const id = Date.now().toString(); const site = window.location.href; @@ -1240,17 +1431,8 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return false; } - /** @type {Record} */ - const stickyNoteParams = stickyNoteUrl.split("?")[1]?.split("&").reduce((params, param) => { - const [key, value] = param.split("="); - return { ...params, [key]: value }; - }, {}); - - /** @type {Record} */ - const currentParams = currentUrl.split("?")[1]?.split("&").reduce((params, param) => { - const [key, value] = param.split("="); - return { ...params, [key]: value }; - }, {}); + const stickyNoteParams = parseUrlParams(stickyNoteUrl); + const currentParams = parseUrlParams(currentUrl); debug("Comparing params: ", stickyNoteParams, currentParams); @@ -1262,88 +1444,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return true; } - function init() { - if (window !== window.top) { - // Skip installation if within an iframe - return; - } - - // Preload font - const MONOCRAFT_SRC = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf"; - const fontLink = document.createElement("link"); - fontLink.rel = "stylesheet"; - fontLink.href = `url(${MONOCRAFT_SRC}) format('opentype')`; - document.head.appendChild(fontLink); - - // Add stylesheet font-face - const fontFace = ` - @font-face { - font-family: 'Monocraft'; - src: url(${MONOCRAFT_SRC}) format('opentype'); - font-weight: normal; - font-style: normal; - } - `; - const fontStyle = document.createElement("style"); - fontStyle.innerHTML = fontFace; - document.head.appendChild(fontStyle); - - load(); - - styleElement.innerHTML = STYLESHEET; - document.head.appendChild(styleElement); - - canvas.id = "birb"; - canvas.width = birbFrames.base.getPixels()[0].length * CANVAS_PIXEL_SIZE; - canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; - document.body.appendChild(canvas); - - window.addEventListener("scroll", () => { - lastActionTimestamp = Date.now(); - }); - - onClick(document, (e) => { - lastActionTimestamp = Date.now(); - if (e.target instanceof Node && document.querySelector("#" + MENU_EXIT_ID)?.contains(e.target)) { - removeMenu(); - } - }); - - onClick(canvas, () => { - insertMenu(); - }); - - canvas.addEventListener("mouseover", () => { - lastActionTimestamp = Date.now(); - if (currentState === States.IDLE) { - petStack.push(Date.now()); - if (petStack.length > 10) { - petStack.shift(); - } - const pets = petStack.filter((time) => Date.now() - time < 1000).length; - if (pets >= 3) { - setAnimation(Animations.HEART); - // Clear the stack - petStack = []; - } - } - }); - - drawStickyNotes(); - - let lastUrl = (window.location.href ?? "").split("?")[0]; - setInterval(() => { - const currentUrl = (window.location.href ?? "").split("?")[0]; - if (currentUrl !== lastUrl) { - log("URL changed, updating sticky notes"); - lastUrl = currentUrl; - drawStickyNotes(); - } - }, 500); - - setInterval(update, 1000 / 60); - } - function drawStickyNotes() { // Remove all existing sticky notes const existingNotes = document.querySelectorAll(".birb-sticky-note"); @@ -1356,89 +1456,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } - function update() { - ticks++; - - // Hide bird if the browser is fullscreen - if (document.fullscreenElement) { - hideBirb(); - // Won't be restored on fullscreen exit - } - - if (currentState === States.IDLE && !frozen && !isMenuOpen()) { - if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART) { - hop(); - } else if (Date.now() - lastActionTimestamp > AFK_TIME) { - // Idle for a while, do something - if (focusedElement === null) { - // Fly to an element - focusOnElement(); - lastActionTimestamp = Date.now(); - } else if (Math.random() < 1 / (60 * 20)) { - // 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 - let petMod = Date.now() - lastPetTimestamp < 1000 * 60 * 5 ? 2 : 1; - if (visible && Math.random() < FEATHER_CHANCE * petMod) { - lastPetTimestamp = 0; - activateFeather(); - } - updateFeather(); - } - - function draw() { - requestAnimationFrame(draw); - - if (!visible) { - return; - } - - updateFocusedElementBounds(); - - // Update the bird's position - if (currentState === States.IDLE) { - if (focusedElement && !isWithinHorizontalBounds()) { - focusOnGround(); - } - birdY = getFocusedY(); - } else if (currentState === States.FLYING) { - // Fly to target location (even if in the air) - if (updateParabolicPath(FLY_SPEED)) { - setState(States.IDLE); - } - } - - const oldTargetY = targetY; - targetY = getFocusedY(); - // Adjust startY to account for scrolling - startY += targetY - oldTargetY; - if (targetY < 0 || targetY > 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])) { - setAnimation(Animations.STILL); - } - - // Update HTML element position - setX(birdX); - setY(birdY); - } - - init(); - draw(); - /** * Create an HTML element with the specified parameters * @param {string} className @@ -1458,6 +1475,42 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI 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 = ` +
+
${title}
+
x
+
+
+ ${contentHtml} +
+ `; + + 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"); @@ -1537,21 +1590,16 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function updateFeather() { const feather = document.querySelector("#birb-feather"); - const featherGravity = 1; if (!feather || !(feather instanceof HTMLElement)) { return; } - const y = parseInt(feather.style.top || "0") + featherGravity; + const y = parseInt(feather.style.top || "0") + FEATHER_FALL_SPEED; 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 */ @@ -1568,28 +1616,14 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI if (document.querySelector("#" + FIELD_GUIDE_ID)) { return; } - let html = ` -
-
${title}
-
x
-
-
-
- ${message} -
-
` - 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); - } + const modal = createWindow("birb-modal", title, ` +
+ ${message} +
+ `); + + modal.style.width = "270px"; centerElement(modal); } @@ -1698,60 +1732,10 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } - // insertPico8(); - function isSafari() { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); } - /** - * @param {string} name - * @param {string} pid - */ - function insertPico8(name, pid) { - let html = ` -
-
${name}
-
x
-
-
- -
` - 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 = ` -
-
Music Player
-
x
-
-
- -
`; - 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 */ @@ -1865,7 +1849,23 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI */ function onClick(element, action) { element.addEventListener("click", (e) => action(e)); - element.addEventListener("touchstart", (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); + } + }); } /** @@ -2025,7 +2025,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function getFullWindowHeight() { return document.documentElement.clientHeight; } - + function focusOnGround() { console.log("Focusing on ground"); focusedElement = null; @@ -2040,12 +2040,11 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI const elements = document.querySelectorAll("img, video"); const inWindow = Array.from(elements).filter((img) => { const rect = img.getBoundingClientRect(); - return rect.left >= 0 && rect.top >= 80 && rect.right <= window.innerWidth && rect.top <= window.innerHeight; + return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight; }); - const MIN_WIDTH = 100; /** @type {HTMLElement[]} */ - // @ts-ignore - const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH); + // @ts-expect-error + const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); if (largeElements.length === 0) { return; } @@ -2062,12 +2061,8 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() }; return; } - const rect = focusedElement.getBoundingClientRect(); - focusedBounds = { - left: rect.left, - right: rect.right, - top: rect.top - }; + const { left, right, top } = focusedElement.getBoundingClientRect(); + focusedBounds = { left, right, top }; } function getCanvasWidth() { @@ -2095,7 +2090,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } function pet() { - if (currentState === States.IDLE) { + if (currentState === States.IDLE && currentAnimation !== Animations.HEART) { setAnimation(Animations.HEART); lastPetTimestamp = Date.now(); } @@ -2175,47 +2170,53 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } 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} + */ + 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 */ diff --git a/dist/birb.user.js b/dist/birb.user.js index 0827b5d..1a06cad 100644 --- a/dist/birb.user.js +++ b/dist/birb.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Pocket Bird // @namespace https://idreesinc.com -// @version 2025-10-23-01 +// @version 2025.10.25.130 // @description birb // @author Idrees // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js @@ -14,7 +14,6 @@ // @ts-check -// @ts-ignore const SHARED_CONFIG = { birbCssScale: 1, uiCssScale: 1, @@ -23,9 +22,8 @@ const SHARED_CONFIG = { hopDistance: 45, }; - const DESKTOP_CONFIG = { - flySpeed: 0.2, + flySpeed: 0.25 }; const MOBILE_CONFIG = { @@ -42,17 +40,6 @@ 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 @@ -62,6 +49,23 @@ 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 + * @property {SavedStickyNote[]} [stickyNotes] + */ + /** @type {Partial} */ let userSettings = {}; @@ -308,8 +312,8 @@ const STYLESHEET = `:root { flex-direction: row; padding-top: 4px; padding-bottom: 4px; - padding-left: 15px; - padding-right: 15px; + padding-left: 10px; + padding-right: 10px; box-sizing: border-box; } @@ -356,12 +360,12 @@ const STYLESHEET = `:root { } .birb-field-guide-description { - width: calc(100% - 16px); - margin-top: 10px; + width: calc(100% - 20px); + margin-top: 5px; padding: 8px; padding-top: 4px; padding-bottom: 4px; - margin-bottom: 6px; + margin-bottom: 10px; font-size: 14px; box-sizing: border-box; color: rgb(124, 108, 75); @@ -737,6 +741,7 @@ const Directions = { }; const SPRITE_WIDTH = 32; +const SPRITE_HEIGHT = 32; const DECORATIONS_SPRITE_WIDTH = 48; const FEATHER_SPRITE_WIDTH = 32; @@ -744,6 +749,34 @@ 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 @@ -800,8 +833,14 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) { }); } -// @ts-ignore -Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATIONS_SPRITE_SHEET, false), loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)]).then(([birbPixels, decorationPixels, featherPixels]) => { +log("Loading sprite sheets..."); + +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; @@ -954,8 +993,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI 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", () => { @@ -984,39 +1021,11 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI }) ]; - 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-ignore + // @ts-expect-error const ctx = canvas.getContext("2d"); const States = { @@ -1057,20 +1066,23 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI * @returns {boolean} Whether the script is running in a userscript extension context */ function isUserScript() { - // @ts-ignore + // @ts-expect-error return typeof GM_getValue === "function"; } function isTestEnvironment() { - return window.location.hostname === "127.0.0.1"; + return window.location.hostname === "127.0.0.1" + || window.location.hostname === "localhost" + || window.location.hostname.startsWith("192.168."); } function load() { /** @type {Record} */ let saveData = {}; + if (isUserScript()) { log("Loading save data from UserScript storage"); - // @ts-ignore + // @ts-expect-error saveData = GM_getValue("birbSaveData", {}) ?? {}; } else if (isTestEnvironment()) { log("Test environment detected, loading save data from localStorage"); @@ -1078,14 +1090,18 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } 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) { @@ -1093,17 +1109,19 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } } + log(stickyNotes.length + " sticky notes loaded"); switchSpecies(currentSpecies); } function save() { - /** @type {Record} */ - let saveData = { - unlockedSpecies: unlockedSpecies, - currentSpecies: currentSpecies, + /** @type {BirbSaveData} */ + const saveData = { + unlockedSpecies, + currentSpecies, settings: userSettings }; + if (stickyNotes.length > 0) { saveData.stickyNotes = stickyNotes.map(note => ({ id: note.id, @@ -1113,9 +1131,10 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI left: note.left })); } + if (isUserScript()) { log("Saving data to UserScript storage"); - // @ts-ignore + // @ts-expect-error GM_setValue("birbSaveData", saveData); } else if (isTestEnvironment()) { log("Test environment detected, saving data to localStorage"); @@ -1128,7 +1147,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function resetSaveData() { if (isUserScript()) { log("Resetting save data in UserScript storage"); - // @ts-ignore + // @ts-expect-error GM_deleteValue("birbSaveData"); } else if (isTestEnvironment()) { log("Test environment detected, resetting save data in localStorage"); @@ -1154,6 +1173,178 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return settings().birbMode ? "Birb" : "Bird"; } + 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"; + const fontLink = document.createElement("link"); + fontLink.rel = "stylesheet"; + fontLink.href = `url(${MONOCRAFT_SRC}) format('opentype')`; + document.head.appendChild(fontLink); + + // Add stylesheet font-face + const fontFace = ` + @font-face { + font-family: 'Monocraft'; + src: url(${MONOCRAFT_SRC}) format('opentype'); + font-weight: normal; + font-style: normal; + } + `; + const fontStyle = document.createElement("style"); + fontStyle.innerHTML = fontFace; + document.head.appendChild(fontStyle); + + load(); + + styleElement.innerHTML = STYLESHEET; + document.head.appendChild(styleElement); + + canvas.id = "birb"; + canvas.width = birbFrames.base.getPixels()[0].length * CANVAS_PIXEL_SIZE; + canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; + document.body.appendChild(canvas); + + window.addEventListener("scroll", () => { + lastActionTimestamp = Date.now(); + }); + + onClick(document, (e) => { + lastActionTimestamp = Date.now(); + if (e.target instanceof Node && document.querySelector("#" + MENU_EXIT_ID)?.contains(e.target)) { + removeMenu(); + } + }); + + onClick(canvas, () => { + if (currentAnimation === Animations.HEART && (Date.now() - lastPetTimestamp < PET_MENU_COOLDOWN)) { + // Currently being pet, don't open menu + return; + } + insertMenu(); + }); + + canvas.addEventListener("mouseover", () => { + lastActionTimestamp = Date.now(); + if (currentState === States.IDLE) { + petStack.push(Date.now()); + if (petStack.length > 10) { + petStack.shift(); + } + const pets = petStack.filter((time) => Date.now() - time < 1000).length; + if (pets >= 3) { + pet(); + // Clear the stack + petStack = []; + } + } + }); + + canvas.addEventListener("touchmove", (e) => { + pet(); + }); + + drawStickyNotes(); + + let lastUrl = (window.location.href ?? "").split("?")[0]; + setInterval(() => { + const currentUrl = (window.location.href ?? "").split("?")[0]; + if (currentUrl !== lastUrl) { + log("URL changed, updating sticky notes"); + lastUrl = currentUrl; + drawStickyNotes(); + } + }, URL_CHECK_INTERVAL); + + setInterval(update, UPDATE_INTERVAL); + } + + function update() { + ticks++; + + // Hide bird if the browser is fullscreen + if (document.fullscreenElement) { + hideBirb(); + // Won't be restored on fullscreen exit + } + + if (currentState === States.IDLE && !frozen && !isMenuOpen()) { + if (Math.random() < HOP_CHANCE && currentAnimation !== Animations.HEART) { + hop(); + } else if (Date.now() - lastActionTimestamp > AFK_TIME) { + // Idle for a while, do something + if (focusedElement === null) { + // 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); + } + } + + // Double the chance of a feather if recently pet + const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1; + if (visible && Math.random() < FEATHER_CHANCE * petMod) { + lastPetTimestamp = 0; + activateFeather(); + } + updateFeather(); + } + + function draw() { + requestAnimationFrame(draw); + + if (!visible) { + return; + } + + updateFocusedElementBounds(); + + // Update the bird's position + if (currentState === States.IDLE) { + if (focusedElement && !isWithinHorizontalBounds()) { + focusOnGround(); + } + birdY = getFocusedY(); + } else if (currentState === States.FLYING) { + // Fly to target location (even if in the air) + if (updateParabolicPath(FLY_SPEED)) { + setState(States.IDLE); + } + } + + const oldTargetY = targetY; + targetY = getFocusedY(); + // Adjust startY to account for scrolling + startY += targetY - oldTargetY; + if (targetY < 0 || targetY > 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])) { + setAnimation(Animations.STILL); + } + + // Update HTML element position + setX(birdX); + setY(birdY); + } + function newStickyNote() { const id = Date.now().toString(); const site = window.location.href; @@ -1254,17 +1445,8 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return false; } - /** @type {Record} */ - const stickyNoteParams = stickyNoteUrl.split("?")[1]?.split("&").reduce((params, param) => { - const [key, value] = param.split("="); - return { ...params, [key]: value }; - }, {}); - - /** @type {Record} */ - const currentParams = currentUrl.split("?")[1]?.split("&").reduce((params, param) => { - const [key, value] = param.split("="); - return { ...params, [key]: value }; - }, {}); + const stickyNoteParams = parseUrlParams(stickyNoteUrl); + const currentParams = parseUrlParams(currentUrl); debug("Comparing params: ", stickyNoteParams, currentParams); @@ -1276,88 +1458,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI return true; } - function init() { - if (window !== window.top) { - // Skip installation if within an iframe - return; - } - - // Preload font - const MONOCRAFT_SRC = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf"; - const fontLink = document.createElement("link"); - fontLink.rel = "stylesheet"; - fontLink.href = `url(${MONOCRAFT_SRC}) format('opentype')`; - document.head.appendChild(fontLink); - - // Add stylesheet font-face - const fontFace = ` - @font-face { - font-family: 'Monocraft'; - src: url(${MONOCRAFT_SRC}) format('opentype'); - font-weight: normal; - font-style: normal; - } - `; - const fontStyle = document.createElement("style"); - fontStyle.innerHTML = fontFace; - document.head.appendChild(fontStyle); - - load(); - - styleElement.innerHTML = STYLESHEET; - document.head.appendChild(styleElement); - - canvas.id = "birb"; - canvas.width = birbFrames.base.getPixels()[0].length * CANVAS_PIXEL_SIZE; - canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; - document.body.appendChild(canvas); - - window.addEventListener("scroll", () => { - lastActionTimestamp = Date.now(); - }); - - onClick(document, (e) => { - lastActionTimestamp = Date.now(); - if (e.target instanceof Node && document.querySelector("#" + MENU_EXIT_ID)?.contains(e.target)) { - removeMenu(); - } - }); - - onClick(canvas, () => { - insertMenu(); - }); - - canvas.addEventListener("mouseover", () => { - lastActionTimestamp = Date.now(); - if (currentState === States.IDLE) { - petStack.push(Date.now()); - if (petStack.length > 10) { - petStack.shift(); - } - const pets = petStack.filter((time) => Date.now() - time < 1000).length; - if (pets >= 3) { - setAnimation(Animations.HEART); - // Clear the stack - petStack = []; - } - } - }); - - drawStickyNotes(); - - let lastUrl = (window.location.href ?? "").split("?")[0]; - setInterval(() => { - const currentUrl = (window.location.href ?? "").split("?")[0]; - if (currentUrl !== lastUrl) { - log("URL changed, updating sticky notes"); - lastUrl = currentUrl; - drawStickyNotes(); - } - }, 500); - - setInterval(update, 1000 / 60); - } - function drawStickyNotes() { // Remove all existing sticky notes const existingNotes = document.querySelectorAll(".birb-sticky-note"); @@ -1370,89 +1470,6 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } - function update() { - ticks++; - - // Hide bird if the browser is fullscreen - if (document.fullscreenElement) { - hideBirb(); - // Won't be restored on fullscreen exit - } - - if (currentState === States.IDLE && !frozen && !isMenuOpen()) { - if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART) { - hop(); - } else if (Date.now() - lastActionTimestamp > AFK_TIME) { - // Idle for a while, do something - if (focusedElement === null) { - // Fly to an element - focusOnElement(); - lastActionTimestamp = Date.now(); - } else if (Math.random() < 1 / (60 * 20)) { - // 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 - let petMod = Date.now() - lastPetTimestamp < 1000 * 60 * 5 ? 2 : 1; - if (visible && Math.random() < FEATHER_CHANCE * petMod) { - lastPetTimestamp = 0; - activateFeather(); - } - updateFeather(); - } - - function draw() { - requestAnimationFrame(draw); - - if (!visible) { - return; - } - - updateFocusedElementBounds(); - - // Update the bird's position - if (currentState === States.IDLE) { - if (focusedElement && !isWithinHorizontalBounds()) { - focusOnGround(); - } - birdY = getFocusedY(); - } else if (currentState === States.FLYING) { - // Fly to target location (even if in the air) - if (updateParabolicPath(FLY_SPEED)) { - setState(States.IDLE); - } - } - - const oldTargetY = targetY; - targetY = getFocusedY(); - // Adjust startY to account for scrolling - startY += targetY - oldTargetY; - if (targetY < 0 || targetY > 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])) { - setAnimation(Animations.STILL); - } - - // Update HTML element position - setX(birdX); - setY(birdY); - } - - init(); - draw(); - /** * Create an HTML element with the specified parameters * @param {string} className @@ -1472,6 +1489,42 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI 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 = ` +
+
${title}
+
x
+
+
+ ${contentHtml} +
+ `; + + 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"); @@ -1551,21 +1604,16 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function updateFeather() { const feather = document.querySelector("#birb-feather"); - const featherGravity = 1; if (!feather || !(feather instanceof HTMLElement)) { return; } - const y = parseInt(feather.style.top || "0") + featherGravity; + const y = parseInt(feather.style.top || "0") + FEATHER_FALL_SPEED; 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 */ @@ -1582,28 +1630,14 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI if (document.querySelector("#" + FIELD_GUIDE_ID)) { return; } - let html = ` -
-
${title}
-
x
-
-
-
- ${message} -
-
` - 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); - } + const modal = createWindow("birb-modal", title, ` +
+ ${message} +
+ `); + + modal.style.width = "270px"; centerElement(modal); } @@ -1712,60 +1746,10 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } } - // insertPico8(); - function isSafari() { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); } - /** - * @param {string} name - * @param {string} pid - */ - function insertPico8(name, pid) { - let html = ` -
-
${name}
-
x
-
-
- -
` - 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 = ` -
-
Music Player
-
x
-
-
- -
`; - 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 */ @@ -1879,7 +1863,23 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI */ function onClick(element, action) { element.addEventListener("click", (e) => action(e)); - element.addEventListener("touchstart", (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); + } + }); } /** @@ -2039,7 +2039,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI function getFullWindowHeight() { return document.documentElement.clientHeight; } - + function focusOnGround() { console.log("Focusing on ground"); focusedElement = null; @@ -2054,12 +2054,11 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI const elements = document.querySelectorAll("img, video"); const inWindow = Array.from(elements).filter((img) => { const rect = img.getBoundingClientRect(); - return rect.left >= 0 && rect.top >= 80 && rect.right <= window.innerWidth && rect.top <= window.innerHeight; + return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight; }); - const MIN_WIDTH = 100; /** @type {HTMLElement[]} */ - // @ts-ignore - const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH); + // @ts-expect-error + const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); if (largeElements.length === 0) { return; } @@ -2076,12 +2075,8 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() }; return; } - const rect = focusedElement.getBoundingClientRect(); - focusedBounds = { - left: rect.left, - right: rect.right, - top: rect.top - }; + const { left, right, top } = focusedElement.getBoundingClientRect(); + focusedBounds = { left, right, top }; } function getCanvasWidth() { @@ -2109,7 +2104,7 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } function pet() { - if (currentState === States.IDLE) { + if (currentState === States.IDLE && currentAnimation !== Animations.HEART) { setAnimation(Animations.HEART); lastPetTimestamp = Date.now(); } @@ -2189,47 +2184,53 @@ Promise.all([loadSpriteSheetPixels(SPRITE_SHEET), loadSpriteSheetPixels(DECORATI } 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} + */ + 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 */ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..519db20 --- /dev/null +++ b/manifest.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 3, + "name": "Pocket Bird", + "description": "It's a bird, in your browser. What more could you want?", + "version": "2025.10.25.130", + "homepage_url": "https://idreesinc.com", + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "./dist/birb.js" + ] + } + ], + "permissions": [ + "storage", + "activeTab" + ], + "web_accessible_resources": [ + { + "resources": [ + "images/*" + ], + "matches": [ + "" + ] + } + ], + "browser_specific_settings": { + "gecko": { + "id": "birb@idreesinc.com" + } + } +} \ No newline at end of file diff --git a/stylesheet.css b/stylesheet.css index 504506c..5025e81 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -241,8 +241,8 @@ flex-direction: row; padding-top: 4px; padding-bottom: 4px; - padding-left: 15px; - padding-right: 15px; + padding-left: 10px; + padding-right: 10px; box-sizing: border-box; } @@ -289,12 +289,12 @@ } .birb-field-guide-description { - width: calc(100% - 16px); - margin-top: 10px; + width: calc(100% - 20px); + margin-top: 5px; padding: 8px; padding-top: 4px; padding-bottom: 4px; - margin-bottom: 6px; + margin-bottom: 10px; font-size: 14px; box-sizing: border-box; color: rgb(124, 108, 75);