diff --git a/build.js b/build.js index a35435a..f04ad33 100644 --- a/build.js +++ b/build.js @@ -68,7 +68,7 @@ const userScriptHeader = // Bundle with rollup const bundle = await rollup({ - input: 'src/birb.js', + input: 'src/application.js', }); await bundle.write({ diff --git a/dist/birb.user.js b/dist/birb.user.js index 206e0d9..76d2bf5 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.26.227 +// @version 2025.10.26.240 // @description birb // @author Idrees // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/birb.user.js diff --git a/manifest.json b/manifest.json index a370218..aa13af2 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Pocket Bird", "description": "It's a bird, in your browser. What more could you want?", - "version": "2025.10.26.227", + "version": "2025.10.26.240", "homepage_url": "https://idreesinc.com", "content_scripts": [ { diff --git a/src/application.js b/src/application.js new file mode 100644 index 0000000..283bfb3 --- /dev/null +++ b/src/application.js @@ -0,0 +1,1112 @@ +import Frame from './frame.js'; +import Layer from './layer.js'; +import Anim from './anim.js'; +import { + Directions, + isDebug, + setDebug, + makeElement, + onClick, + makeDraggable, + makeClosable, + isMobile, + log, + debug, + error +} from './shared.js'; +import { + SPRITE, + SPRITE_SHEET_COLOR_MAP, + SPECIES +} from './sprites.js'; +import { + StickyNote, + createNewStickyNote, + drawStickyNotes +} from './stickyNotes.js'; +import { + MenuItem, + DebugMenuItem, + Separator, + insertMenu, + removeMenu, + isMenuOpen, + switchMenuItems, + MENU_EXIT_ID +} from './menu.js'; + + +/** + * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote + */ + +/** + * @typedef {Object} BirbSaveData + * @property {string[]} unlockedSpecies + * @property {string} currentSpecies + * @property {Partial} settings + * @property {SavedStickyNote[]} [stickyNotes] + */ + +/** + * @typedef {typeof DEFAULT_SETTINGS} Settings + */ +const DEFAULT_SETTINGS = { + birbMode: false +}; + +// Rendering constants +const SPRITE_WIDTH = 32; +const SPRITE_HEIGHT = 32; +const FEATHER_SPRITE_WIDTH = 32; +const BIRB_CSS_SCALE = 1; +const UI_CSS_SCALE = isMobile() ? 0.9 : 1; +const CANVAS_PIXEL_SIZE = 1; +const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE; + +// Build-time assets +const STYLESHEET = `___STYLESHEET___`; +const SPRITE_SHEET = "__SPRITE_SHEET__"; +const FEATHER_SPRITE_SHEET = "__FEATHER_SPRITE_SHEET__"; + +// Element IDs +const FIELD_GUIDE_ID = "birb-field-guide"; +const FEATHER_ID = "birb-feather"; + +const DEFAULT_BIRD = "bluebird"; + +// Birb movement +const HOP_SPEED = 0.07; +const FLY_SPEED = isMobile() ? 0.125 : 0.25; +const HOP_DISTANCE = 45; + +// Timing constants (in milliseconds) +const UPDATE_INTERVAL = 1000 / 60; // 60 FPS +const AFK_TIME = isDebug() ? 0 : 1000 * 30; +const PET_BOOST_DURATION = 1000 * 60 * 5; +const PET_MENU_COOLDOWN = 1000; +const URL_CHECK_INTERVAL = 500; + +// Random event chances per tick +const HOP_CHANCE = 1 / (60 * 3); // Every 3 seconds +const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds +const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours + +// Feathers +const FEATHER_FALL_SPEED = 1; +const PET_FEATHER_BOOST = 2; + +// Focus element constraints +const MIN_FOCUS_ELEMENT_WIDTH = 100; +const MIN_FOCUS_ELEMENT_TOP = 80; + +/** @type {Partial} */ +let userSettings = {}; + +/** + * Load the sprite sheet and return the pixel-map template + * @param {string} dataUri + * @param {boolean} [templateColors] + * @returns {Promise} + */ +function loadSpriteSheetPixels(dataUri, templateColors = true) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = dataUri; + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Failed to get canvas context')); + return; + } + ctx.drawImage(img, 0, 0); + const imageData = ctx.getImageData(0, 0, img.width, img.height); + const pixels = imageData.data; + const hexArray = []; + for (let y = 0; y < img.height; y++) { + const row = []; + for (let x = 0; x < img.width; x++) { + const index = (y * img.width + x) * 4; + const r = pixels[index]; + const g = pixels[index + 1]; + const b = pixels[index + 2]; + const a = pixels[index + 3]; + if (a === 0) { + row.push(SPRITE.TRANSPARENT); + continue; + } + const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + if (!templateColors) { + row.push(hex); + continue; + } + if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) { + error(`Unknown color: ${hex}`); + row.push(SPRITE.TRANSPARENT); + } + row.push(SPRITE_SHEET_COLOR_MAP[hex]); + } + hexArray.push(row); + } + resolve(hexArray); + }; + img.onerror = (err) => { + reject(err); + }; + }); +} + +log("Loading sprite sheets..."); + +Promise.all([ + loadSpriteSheetPixels(SPRITE_SHEET), + loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) +]).then(([birbPixels, featherPixels]) => { + + const SPRITE_SHEET = birbPixels; + const FEATHER_SPRITE_SHEET = featherPixels; + + const layers = { + base: new Layer(getLayer(SPRITE_SHEET, 0)), + down: new Layer(getLayer(SPRITE_SHEET, 1)), + heartOne: new Layer(getLayer(SPRITE_SHEET, 2)), + heartTwo: new Layer(getLayer(SPRITE_SHEET, 3)), + heartThree: new Layer(getLayer(SPRITE_SHEET, 4)), + tuftBase: new Layer(getLayer(SPRITE_SHEET, 5), "tuft"), + tuftDown: new Layer(getLayer(SPRITE_SHEET, 6), "tuft"), + wingsUp: new Layer(getLayer(SPRITE_SHEET, 7)), + wingsDown: new Layer(getLayer(SPRITE_SHEET, 8)), + happyEye: new Layer(getLayer(SPRITE_SHEET, 9)), + }; + + const featherLayers = { + feather: new Layer(getLayer(FEATHER_SPRITE_SHEET, 0, FEATHER_SPRITE_WIDTH)), + }; + + const birbFrames = { + base: new Frame([layers.base, layers.tuftBase]), + headDown: new Frame([layers.down, layers.tuftDown]), + wingsDown: new Frame([layers.base, layers.tuftBase, layers.wingsDown]), + wingsUp: new Frame([layers.down, layers.tuftDown, layers.wingsUp]), + heartOne: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartOne]), + heartTwo: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartTwo]), + heartThree: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartThree]), + heartFour: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartTwo]), + }; + + const featherFrames = { + feather: new Frame([featherLayers.feather]), + }; + + const Animations = { + STILL: new Anim([birbFrames.base], [1000]), + BOB: new Anim([ + birbFrames.base, + birbFrames.headDown + ], [ + 420, + 420 + ]), + FLYING: new Anim([ + birbFrames.base, + birbFrames.wingsUp, + birbFrames.headDown, + birbFrames.wingsDown, + ], [ + 30, + 80, + 30, + 60, + ]), + HEART: new Anim([ + birbFrames.heartOne, + birbFrames.heartTwo, + birbFrames.heartThree, + birbFrames.heartFour, + birbFrames.heartThree, + birbFrames.heartFour, + birbFrames.heartThree, + birbFrames.heartFour, + ], [ + 60, + 80, + 250, + 250, + 250, + 250, + 250, + 250, + ], false), + }; + + const FEATHER_ANIMATIONS = { + feather: new Anim([ + featherFrames.feather, + ], [ + 1000, + ]), + }; + + const menuItems = [ + new MenuItem(`Pet ${birdBirb()}`, pet), + new MenuItem("Field Guide", insertFieldGuide), + new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote)), + new MenuItem(`Hide ${birdBirb()}`, hideBirb), + new DebugMenuItem("Freeze/Unfreeze", () => { + frozen = !frozen; + }), + new DebugMenuItem("Reset Data", resetSaveData), + new DebugMenuItem("Unlock All", () => { + for (let type in SPECIES) { + unlockBird(type); + } + }), + new DebugMenuItem("Disable Debug", () => { + setDebug(false); + }), + new Separator(), + new MenuItem("Settings", () => switchMenuItems(settingsItems, updateMenuLocation), false), + ]; + + const settingsItems = [ + new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), + new Separator(), + new MenuItem("Toggle Birb Mode", () => { + userSettings.birbMode = !userSettings.birbMode; + save(); + insertModal(`${birdBirb()} Mode`, `Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"${userSettings.birbMode ? "\n\nWelcome back to 2012" : ""}`); + }) + ]; + + const styleElement = document.createElement("style"); + const canvas = document.createElement("canvas"); + + /** @type {CanvasRenderingContext2D} */ + const ctx = canvas.getContext("2d"); + + const States = { + IDLE: "idle", + HOP: "hop", + FLYING: "flying", + }; + + let frozen = false; + let stateStart = Date.now(); + let currentState = States.IDLE; + let animStart = Date.now(); + let currentAnimation = Animations.BOB; + let direction = Directions.RIGHT; + let ticks = 0; + // Bird's current position + let birdY = 0; + let birdX = 40; + // Bird's starting position (when flying) + let startX = 0; + let startY = 0; + // Bird's target position (when flying) + let targetX = 0; + let targetY = 0; + /** @type {HTMLElement|null} */ + let focusedElement = null; + let focusedBounds = { left: 0, right: 0, top: 0 }; + let lastActionTimestamp = Date.now(); + /** @type {number[]} */ + let petStack = []; + let currentSpecies = DEFAULT_BIRD; + let unlockedSpecies = [DEFAULT_BIRD]; + let visible = true; + let lastPetTimestamp = 0; + /** @type {StickyNote[]} */ + let stickyNotes = []; + + /** + * @returns {boolean} Whether the script is running in a userscript extension context + */ + function isUserScript() { + // @ts-expect-error + return typeof GM_getValue === "function"; + } + + function isTestEnvironment() { + 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-expect-error + saveData = GM_getValue("birbSaveData", {}) ?? {}; + } else if (isTestEnvironment()) { + log("Test environment detected, loading save data from localStorage"); + saveData = JSON.parse(localStorage.getItem("birbSaveData") ?? "{}"); + } 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) { + stickyNotes.push(new StickyNote(note.id, note.site, note.content, note.top, note.left)); + } + } + } + + log(stickyNotes.length + " sticky notes loaded"); + switchSpecies(currentSpecies); + } + + function save() { + /** @type {BirbSaveData} */ + const saveData = { + unlockedSpecies, + currentSpecies, + settings: userSettings + }; + + if (stickyNotes.length > 0) { + saveData.stickyNotes = stickyNotes.map(note => ({ + id: note.id, + site: note.site, + content: note.content, + top: note.top, + left: note.left + })); + } + + if (isUserScript()) { + log("Saving data to UserScript storage"); + // @ts-expect-error + GM_setValue("birbSaveData", saveData); + } else if (isTestEnvironment()) { + log("Test environment detected, saving data to localStorage"); + localStorage.setItem("birbSaveData", JSON.stringify(saveData)); + } else { + log("Not a UserScript"); + } + } + + function resetSaveData() { + if (isUserScript()) { + log("Resetting save data in UserScript storage"); + // @ts-expect-error + GM_deleteValue("birbSaveData"); + } else if (isTestEnvironment()) { + log("Test environment detected, resetting save data in localStorage"); + localStorage.removeItem("birbSaveData"); + } else { + log("Not a UserScript"); + } + load(); + } + + /** + * Get the user settings merged with default settings + * @returns {Settings} The merged settings + */ + function settings() { + return { ...DEFAULT_SETTINGS, ...userSettings }; + } + + /** + * Bird or birb, you decide + */ + function birdBirb() { + 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(menuItems, `${birdBirb().toLowerCase()}OS`, updateMenuLocation); + }); 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(stickyNotes, save, deleteStickyNote); + + 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(stickyNotes, save, deleteStickyNote); + } + }, 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, CANVAS_PIXEL_SIZE, SPECIES[currentSpecies])) { + setAnimation(Animations.STILL); + } + + // Update HTML element position + setX(birdX); + setY(birdY); + } + + /** + * @param {StickyNote} stickyNote + */ + function deleteStickyNote(stickyNote) { + stickyNotes = stickyNotes.filter(note => note.id !== stickyNote.id); + save(); + } + + /** + * 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 activateFeather() { + if (document.querySelector("#" + FEATHER_ID)) { + return; + } + const speciesToUnlock = Object.keys(SPECIES).filter((species) => !unlockedSpecies.includes(species)); + if (speciesToUnlock.length === 0) { + // No more species to unlock + return; + } + const birdType = speciesToUnlock[Math.floor(Math.random() * speciesToUnlock.length)]; + insertFeather(birdType); + } + + /** + * @param {string} birdType + */ + function insertFeather(birdType) { + let type = SPECIES[birdType]; + const featherCanvas = document.createElement("canvas"); + featherCanvas.id = FEATHER_ID; + featherCanvas.classList.add("birb-decoration"); + featherCanvas.width = FEATHER_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + featherCanvas.height = FEATHER_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + const x = featherCanvas.width * 2 + Math.random() * (window.innerWidth - featherCanvas.width * 4); + featherCanvas.style.marginLeft = `${x}px`; + featherCanvas.style.top = `${-featherCanvas.height}px`; + const featherCtx = featherCanvas.getContext("2d"); + if (!featherCtx) { + return; + } + FEATHER_ANIMATIONS.feather.draw(featherCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, type); + document.body.appendChild(featherCanvas); + onClick(featherCanvas, () => { + unlockBird(birdType); + removeFeather(); + if (document.querySelector("#" + FIELD_GUIDE_ID)) { + removeFieldGuide(); + insertFieldGuide(); + } + }); + } + + function removeFeather() { + const feather = document.querySelector("#" + FEATHER_ID); + if (feather) { + feather.remove(); + } + } + + /** + * @param {string} birdType + */ + function unlockBird(birdType) { + if (!unlockedSpecies.includes(birdType)) { + unlockedSpecies.push(birdType); + insertModal("New Bird Unlocked!", `You've found a ${SPECIES[birdType].name} feather! Use the Field Guide to switch your bird's species.`); + } + save(); + } + + function updateFeather() { + const feather = document.querySelector("#birb-feather"); + if (!feather || !(feather instanceof HTMLElement)) { + return; + } + 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`; + } + } + + /** + * @param {HTMLElement} element + */ + function centerElement(element) { + element.style.left = `${window.innerWidth / 2 - element.offsetWidth / 2}px`; + element.style.top = `${window.innerHeight / 2 - element.offsetHeight / 2}px`; + } + + /** + * @param {string} title + * @param {string} message + */ + function insertModal(title, message) { + if (document.querySelector("#" + FIELD_GUIDE_ID)) { + return; + } + + const modal = createWindow("birb-modal", title, ` +
+ ${message} +
+ `); + + modal.style.width = "270px"; + centerElement(modal); + } + + /** + * @param {HTMLElement} menu + */ + function updateMenuLocation(menu) { + let x = birdX; + let y = canvas.offsetTop + canvas.height / 2 + WINDOW_PIXEL_SIZE * 10; + const offset = 20; + if (x < window.innerWidth / 2) { + // Left side + x += offset; + } else { + // Right side + x -= (menu.offsetWidth + offset) * UI_CSS_SCALE; + } + if (y > window.innerHeight / 2) { + // Top side + y -= (menu.offsetHeight + offset + 10) * UI_CSS_SCALE; + } else { + // Bottom side + y += offset; + } + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + }; + + function insertFieldGuide() { + if (document.querySelector("#" + FIELD_GUIDE_ID)) { + return; + } + let html = ` +
+
Field Guide
+
x
+
+
+
+
+
` + const fieldGuide = makeElement("birb-window", undefined, FIELD_GUIDE_ID); + fieldGuide.innerHTML = html; + document.body.appendChild(fieldGuide); + makeDraggable(fieldGuide.querySelector(".birb-window-header")); + + const closeButton = fieldGuide.querySelector(".birb-window-close"); + if (closeButton) { + makeClosable(() => { + fieldGuide.remove(); + }, closeButton); + } + + const content = fieldGuide.querySelector(".birb-grid-content"); + if (!content) { + return; + } + content.innerHTML = ""; + + const generateDescription = (/** @type {string} */ speciesId) => { + const type = SPECIES[speciesId]; + const unlocked = unlockedSpecies.includes(speciesId); + return "" + type.name + "
" + (!unlocked ? "Not yet unlocked" : type.description); + }; + + const description = fieldGuide.querySelector(".birb-field-guide-description"); + if (!description) { + return; + } + description.innerHTML = generateDescription(currentSpecies); + for (const [id, type] of Object.entries(SPECIES)) { + const unlocked = unlockedSpecies.includes(id); + const speciesElement = makeElement("birb-grid-item"); + if (id === currentSpecies) { + speciesElement.classList.add("birb-grid-item-selected"); + } + const speciesCanvas = document.createElement("canvas"); + speciesCanvas.width = SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + speciesCanvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; + const speciesCtx = speciesCanvas.getContext("2d"); + if (!speciesCtx) { + return; + } + birbFrames.base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type); + speciesElement.appendChild(speciesCanvas); + content.appendChild(speciesElement); + if (unlocked) { + onClick(speciesElement, () => { + switchSpecies(id); + document.querySelectorAll(".birb-grid-item").forEach((element) => { + element.classList.remove("birb-grid-item-selected"); + }); + speciesElement.classList.add("birb-grid-item-selected"); + }); + } else { + speciesElement.classList.add("birb-grid-item-locked"); + } + speciesElement.addEventListener("mouseover", () => { + log("mouseover"); + description.innerHTML = generateDescription(id); + }); + speciesElement.addEventListener("mouseout", () => { + description.innerHTML = generateDescription(currentSpecies); + }); + } + centerElement(fieldGuide); + } + + function removeFieldGuide() { + const fieldGuide = document.querySelector("#" + FIELD_GUIDE_ID); + if (fieldGuide) { + fieldGuide.remove(); + } + } + + /** + * @param {string} type + */ + function switchSpecies(type) { + currentSpecies = type; + // Update CSS variable --birb-highlight to be wing color + document.documentElement.style.setProperty("--birb-highlight", SPECIES[type].colors[SPRITE.THEME_HIGHLIGHT]); + save(); + } + + /** + * @param {string[][]} array + * @param {number} sprite + * @param {number} [width] + * @returns {string[][]} + */ + function getLayer(array, sprite, width = SPRITE_WIDTH) { + // From an array of a horizontal sprite sheet, get the layer for a specific sprite + const layer = []; + for (let y = 0; y < width; y++) { + layer.push(array[y].slice(sprite * width, (sprite + 1) * width)); + } + return layer; + } + + /** + * Update the birds location from the start to the target location on a parabolic path + * @param {number} speed The speed of the bird along the path + * @param {number} [intensity] The intensity of the parabolic path + * @returns {boolean} Whether the bird has reached the target location + */ + function updateParabolicPath(speed, intensity = 2.5) { + const dx = targetX - startX; + const dy = targetY - startY; + const distance = Math.sqrt(dx * dx + dy * dy); + const time = Date.now() - stateStart; + if (distance > Math.max(window.innerWidth, window.innerHeight) / 2) { + speed *= 1.3; + } + const amount = Math.min(1, time / (distance / speed)); + const { x, y } = parabolicLerp(startX, startY, targetX, targetY, amount, intensity); + birdX = x; + birdY = y; + const complete = Math.abs(birdX - targetX) < 1 && Math.abs(birdY - targetY) < 1; + if (complete) { + birdX = targetX; + birdY = targetY; + } else { + direction = targetX > birdX ? Directions.RIGHT : Directions.LEFT; + } + return complete; + } + + function getFocusedElementRandomX() { + return Math.random() * (focusedBounds.right - focusedBounds.left) + focusedBounds.left; + } + + function isWithinHorizontalBounds() { + return birdX >= focusedBounds.left && birdX <= focusedBounds.right; + } + + function getFocusedY() { + return getFullWindowHeight() - focusedBounds.top; + } + + /** + * @returns The render-safe height of the inner browser window + */ + function getSafeWindowHeight() { + // Necessary because iOS 26 Safari is terrible and won't render + // fixed elements behind the address bar + return window.innerHeight; + } + + /** + * @returns The true height of the inner browser window + */ + function getFullWindowHeight() { + return document.documentElement.clientHeight; + } + + function focusOnGround() { + console.log("Focusing on ground"); + focusedElement = null; + focusedBounds = { left: 0, right: window.innerWidth, top: getSafeWindowHeight() }; + flyTo(Math.random() * window.innerWidth, 0); + } + + function focusOnElement() { + if (frozen) { + return; + } + const elements = document.querySelectorAll("img, video"); + const inWindow = Array.from(elements).filter((img) => { + const rect = img.getBoundingClientRect(); + return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight; + }); + /** @type {HTMLElement[]} */ + // @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; + } + const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)]; + focusedElement = randomElement; + log("Focusing on element: ", focusedElement); + updateFocusedElementBounds(); + flyTo(getFocusedElementRandomX(), getFocusedY()); + } + + function updateFocusedElementBounds() { + if (focusedElement === null) { + // Update ground location to bottom of window + focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() }; + return; + } + const { left, right, top } = focusedElement.getBoundingClientRect(); + focusedBounds = { left, right, top }; + } + + function getCanvasWidth() { + return canvas.width * BIRB_CSS_SCALE + } + + function hop() { + if (frozen) { + return; + } + if (currentState === States.IDLE) { + setState(States.HOP); + setAnimation(Animations.FLYING); + if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { + targetX = birdX - HOP_DISTANCE; + } else { + targetX = birdX + HOP_DISTANCE; + } + targetY = getFocusedY(); + } + } + + function pet() { + if (currentState === States.IDLE && currentAnimation !== Animations.HEART) { + setAnimation(Animations.HEART); + lastPetTimestamp = Date.now(); + } + } + + function hideBirb() { + canvas.style.display = "none"; + visible = false; + } + + /** + * @param {number} x + * @param {number} y + */ + function flyTo(x, y) { + targetX = x; + targetY = y; + setState(States.FLYING); + setAnimation(Animations.FLYING); + } + + /** + * @returns {boolean} Whether the bird should be absolutely positioned + */ + function isAbsolute() { + return focusedElement !== null && (currentState === States.IDLE || currentState === States.HOP); + } + + /** + * Set the current animation and reset the animation timer + * @param {Anim} animation + */ + function setAnimation(animation) { + currentAnimation = animation; + animStart = Date.now(); + } + + /** + * Set the current state and reset the state timer + * @param {string} state + */ + function setState(state) { + stateStart = Date.now(); + startX = birdX; + startY = birdY; + currentState = state; + if (state === States.IDLE) { + setAnimation(Animations.BOB); + } + if (isAbsolute()) { + canvas.classList.add("birb-absolute"); + } else { + canvas.classList.remove("birb-absolute"); + } + setY(birdY); + } + + /** + * @param {number} x + */ + function setX(x) { + let mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2)); + canvas.style.left = `${x + mod}px`; + } + + /** + * @param {number} y + */ + function setY(y) { + let bottom; + if (isAbsolute()) { + // Position is absolute, convert from fixed + bottom = y - window.scrollY; + } else { + // Position is fixed + bottom = y; + } + canvas.style.bottom = `${bottom}px`; + } + + // 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 }; + } + + // Run the birb + init(); + draw(); +}).catch((e) => { + error("Error while loading sprite sheets: ", e); +}); \ No newline at end of file diff --git a/src/birb.js b/src/birb.js index 283bfb3..b98d3f5 100644 --- a/src/birb.js +++ b/src/birb.js @@ -1,1112 +1,3 @@ -import Frame from './frame.js'; -import Layer from './layer.js'; -import Anim from './anim.js'; -import { - Directions, - isDebug, - setDebug, - makeElement, - onClick, - makeDraggable, - makeClosable, - isMobile, - log, - debug, - error -} from './shared.js'; -import { - SPRITE, - SPRITE_SHEET_COLOR_MAP, - SPECIES -} from './sprites.js'; -import { - StickyNote, - createNewStickyNote, - drawStickyNotes -} from './stickyNotes.js'; -import { - MenuItem, - DebugMenuItem, - Separator, - insertMenu, - removeMenu, - isMenuOpen, - switchMenuItems, - MENU_EXIT_ID -} from './menu.js'; +export class Birb { - -/** - * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote - */ - -/** - * @typedef {Object} BirbSaveData - * @property {string[]} unlockedSpecies - * @property {string} currentSpecies - * @property {Partial} settings - * @property {SavedStickyNote[]} [stickyNotes] - */ - -/** - * @typedef {typeof DEFAULT_SETTINGS} Settings - */ -const DEFAULT_SETTINGS = { - birbMode: false -}; - -// Rendering constants -const SPRITE_WIDTH = 32; -const SPRITE_HEIGHT = 32; -const FEATHER_SPRITE_WIDTH = 32; -const BIRB_CSS_SCALE = 1; -const UI_CSS_SCALE = isMobile() ? 0.9 : 1; -const CANVAS_PIXEL_SIZE = 1; -const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE; - -// Build-time assets -const STYLESHEET = `___STYLESHEET___`; -const SPRITE_SHEET = "__SPRITE_SHEET__"; -const FEATHER_SPRITE_SHEET = "__FEATHER_SPRITE_SHEET__"; - -// Element IDs -const FIELD_GUIDE_ID = "birb-field-guide"; -const FEATHER_ID = "birb-feather"; - -const DEFAULT_BIRD = "bluebird"; - -// Birb movement -const HOP_SPEED = 0.07; -const FLY_SPEED = isMobile() ? 0.125 : 0.25; -const HOP_DISTANCE = 45; - -// Timing constants (in milliseconds) -const UPDATE_INTERVAL = 1000 / 60; // 60 FPS -const AFK_TIME = isDebug() ? 0 : 1000 * 30; -const PET_BOOST_DURATION = 1000 * 60 * 5; -const PET_MENU_COOLDOWN = 1000; -const URL_CHECK_INTERVAL = 500; - -// Random event chances per tick -const HOP_CHANCE = 1 / (60 * 3); // Every 3 seconds -const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds -const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours - -// Feathers -const FEATHER_FALL_SPEED = 1; -const PET_FEATHER_BOOST = 2; - -// Focus element constraints -const MIN_FOCUS_ELEMENT_WIDTH = 100; -const MIN_FOCUS_ELEMENT_TOP = 80; - -/** @type {Partial} */ -let userSettings = {}; - -/** - * Load the sprite sheet and return the pixel-map template - * @param {string} dataUri - * @param {boolean} [templateColors] - * @returns {Promise} - */ -function loadSpriteSheetPixels(dataUri, templateColors = true) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.src = dataUri; - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - if (!ctx) { - reject(new Error('Failed to get canvas context')); - return; - } - ctx.drawImage(img, 0, 0); - const imageData = ctx.getImageData(0, 0, img.width, img.height); - const pixels = imageData.data; - const hexArray = []; - for (let y = 0; y < img.height; y++) { - const row = []; - for (let x = 0; x < img.width; x++) { - const index = (y * img.width + x) * 4; - const r = pixels[index]; - const g = pixels[index + 1]; - const b = pixels[index + 2]; - const a = pixels[index + 3]; - if (a === 0) { - row.push(SPRITE.TRANSPARENT); - continue; - } - const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; - if (!templateColors) { - row.push(hex); - continue; - } - if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) { - error(`Unknown color: ${hex}`); - row.push(SPRITE.TRANSPARENT); - } - row.push(SPRITE_SHEET_COLOR_MAP[hex]); - } - hexArray.push(row); - } - resolve(hexArray); - }; - img.onerror = (err) => { - reject(err); - }; - }); -} - -log("Loading sprite sheets..."); - -Promise.all([ - loadSpriteSheetPixels(SPRITE_SHEET), - loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) -]).then(([birbPixels, featherPixels]) => { - - const SPRITE_SHEET = birbPixels; - const FEATHER_SPRITE_SHEET = featherPixels; - - const layers = { - base: new Layer(getLayer(SPRITE_SHEET, 0)), - down: new Layer(getLayer(SPRITE_SHEET, 1)), - heartOne: new Layer(getLayer(SPRITE_SHEET, 2)), - heartTwo: new Layer(getLayer(SPRITE_SHEET, 3)), - heartThree: new Layer(getLayer(SPRITE_SHEET, 4)), - tuftBase: new Layer(getLayer(SPRITE_SHEET, 5), "tuft"), - tuftDown: new Layer(getLayer(SPRITE_SHEET, 6), "tuft"), - wingsUp: new Layer(getLayer(SPRITE_SHEET, 7)), - wingsDown: new Layer(getLayer(SPRITE_SHEET, 8)), - happyEye: new Layer(getLayer(SPRITE_SHEET, 9)), - }; - - const featherLayers = { - feather: new Layer(getLayer(FEATHER_SPRITE_SHEET, 0, FEATHER_SPRITE_WIDTH)), - }; - - const birbFrames = { - base: new Frame([layers.base, layers.tuftBase]), - headDown: new Frame([layers.down, layers.tuftDown]), - wingsDown: new Frame([layers.base, layers.tuftBase, layers.wingsDown]), - wingsUp: new Frame([layers.down, layers.tuftDown, layers.wingsUp]), - heartOne: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartOne]), - heartTwo: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartTwo]), - heartThree: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartThree]), - heartFour: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartTwo]), - }; - - const featherFrames = { - feather: new Frame([featherLayers.feather]), - }; - - const Animations = { - STILL: new Anim([birbFrames.base], [1000]), - BOB: new Anim([ - birbFrames.base, - birbFrames.headDown - ], [ - 420, - 420 - ]), - FLYING: new Anim([ - birbFrames.base, - birbFrames.wingsUp, - birbFrames.headDown, - birbFrames.wingsDown, - ], [ - 30, - 80, - 30, - 60, - ]), - HEART: new Anim([ - birbFrames.heartOne, - birbFrames.heartTwo, - birbFrames.heartThree, - birbFrames.heartFour, - birbFrames.heartThree, - birbFrames.heartFour, - birbFrames.heartThree, - birbFrames.heartFour, - ], [ - 60, - 80, - 250, - 250, - 250, - 250, - 250, - 250, - ], false), - }; - - const FEATHER_ANIMATIONS = { - feather: new Anim([ - featherFrames.feather, - ], [ - 1000, - ]), - }; - - const menuItems = [ - new MenuItem(`Pet ${birdBirb()}`, pet), - new MenuItem("Field Guide", insertFieldGuide), - new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote)), - new MenuItem(`Hide ${birdBirb()}`, hideBirb), - new DebugMenuItem("Freeze/Unfreeze", () => { - frozen = !frozen; - }), - new DebugMenuItem("Reset Data", resetSaveData), - new DebugMenuItem("Unlock All", () => { - for (let type in SPECIES) { - unlockBird(type); - } - }), - new DebugMenuItem("Disable Debug", () => { - setDebug(false); - }), - new Separator(), - new MenuItem("Settings", () => switchMenuItems(settingsItems, updateMenuLocation), false), - ]; - - const settingsItems = [ - new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), - new Separator(), - new MenuItem("Toggle Birb Mode", () => { - userSettings.birbMode = !userSettings.birbMode; - save(); - insertModal(`${birdBirb()} Mode`, `Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"${userSettings.birbMode ? "\n\nWelcome back to 2012" : ""}`); - }) - ]; - - const styleElement = document.createElement("style"); - const canvas = document.createElement("canvas"); - - /** @type {CanvasRenderingContext2D} */ - const ctx = canvas.getContext("2d"); - - const States = { - IDLE: "idle", - HOP: "hop", - FLYING: "flying", - }; - - let frozen = false; - let stateStart = Date.now(); - let currentState = States.IDLE; - let animStart = Date.now(); - let currentAnimation = Animations.BOB; - let direction = Directions.RIGHT; - let ticks = 0; - // Bird's current position - let birdY = 0; - let birdX = 40; - // Bird's starting position (when flying) - let startX = 0; - let startY = 0; - // Bird's target position (when flying) - let targetX = 0; - let targetY = 0; - /** @type {HTMLElement|null} */ - let focusedElement = null; - let focusedBounds = { left: 0, right: 0, top: 0 }; - let lastActionTimestamp = Date.now(); - /** @type {number[]} */ - let petStack = []; - let currentSpecies = DEFAULT_BIRD; - let unlockedSpecies = [DEFAULT_BIRD]; - let visible = true; - let lastPetTimestamp = 0; - /** @type {StickyNote[]} */ - let stickyNotes = []; - - /** - * @returns {boolean} Whether the script is running in a userscript extension context - */ - function isUserScript() { - // @ts-expect-error - return typeof GM_getValue === "function"; - } - - function isTestEnvironment() { - 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-expect-error - saveData = GM_getValue("birbSaveData", {}) ?? {}; - } else if (isTestEnvironment()) { - log("Test environment detected, loading save data from localStorage"); - saveData = JSON.parse(localStorage.getItem("birbSaveData") ?? "{}"); - } 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) { - stickyNotes.push(new StickyNote(note.id, note.site, note.content, note.top, note.left)); - } - } - } - - log(stickyNotes.length + " sticky notes loaded"); - switchSpecies(currentSpecies); - } - - function save() { - /** @type {BirbSaveData} */ - const saveData = { - unlockedSpecies, - currentSpecies, - settings: userSettings - }; - - if (stickyNotes.length > 0) { - saveData.stickyNotes = stickyNotes.map(note => ({ - id: note.id, - site: note.site, - content: note.content, - top: note.top, - left: note.left - })); - } - - if (isUserScript()) { - log("Saving data to UserScript storage"); - // @ts-expect-error - GM_setValue("birbSaveData", saveData); - } else if (isTestEnvironment()) { - log("Test environment detected, saving data to localStorage"); - localStorage.setItem("birbSaveData", JSON.stringify(saveData)); - } else { - log("Not a UserScript"); - } - } - - function resetSaveData() { - if (isUserScript()) { - log("Resetting save data in UserScript storage"); - // @ts-expect-error - GM_deleteValue("birbSaveData"); - } else if (isTestEnvironment()) { - log("Test environment detected, resetting save data in localStorage"); - localStorage.removeItem("birbSaveData"); - } else { - log("Not a UserScript"); - } - load(); - } - - /** - * Get the user settings merged with default settings - * @returns {Settings} The merged settings - */ - function settings() { - return { ...DEFAULT_SETTINGS, ...userSettings }; - } - - /** - * Bird or birb, you decide - */ - function birdBirb() { - 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(menuItems, `${birdBirb().toLowerCase()}OS`, updateMenuLocation); - }); 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(stickyNotes, save, deleteStickyNote); - - 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(stickyNotes, save, deleteStickyNote); - } - }, 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, CANVAS_PIXEL_SIZE, SPECIES[currentSpecies])) { - setAnimation(Animations.STILL); - } - - // Update HTML element position - setX(birdX); - setY(birdY); - } - - /** - * @param {StickyNote} stickyNote - */ - function deleteStickyNote(stickyNote) { - stickyNotes = stickyNotes.filter(note => note.id !== stickyNote.id); - save(); - } - - /** - * 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 activateFeather() { - if (document.querySelector("#" + FEATHER_ID)) { - return; - } - const speciesToUnlock = Object.keys(SPECIES).filter((species) => !unlockedSpecies.includes(species)); - if (speciesToUnlock.length === 0) { - // No more species to unlock - return; - } - const birdType = speciesToUnlock[Math.floor(Math.random() * speciesToUnlock.length)]; - insertFeather(birdType); - } - - /** - * @param {string} birdType - */ - function insertFeather(birdType) { - let type = SPECIES[birdType]; - const featherCanvas = document.createElement("canvas"); - featherCanvas.id = FEATHER_ID; - featherCanvas.classList.add("birb-decoration"); - featherCanvas.width = FEATHER_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; - featherCanvas.height = FEATHER_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; - const x = featherCanvas.width * 2 + Math.random() * (window.innerWidth - featherCanvas.width * 4); - featherCanvas.style.marginLeft = `${x}px`; - featherCanvas.style.top = `${-featherCanvas.height}px`; - const featherCtx = featherCanvas.getContext("2d"); - if (!featherCtx) { - return; - } - FEATHER_ANIMATIONS.feather.draw(featherCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, type); - document.body.appendChild(featherCanvas); - onClick(featherCanvas, () => { - unlockBird(birdType); - removeFeather(); - if (document.querySelector("#" + FIELD_GUIDE_ID)) { - removeFieldGuide(); - insertFieldGuide(); - } - }); - } - - function removeFeather() { - const feather = document.querySelector("#" + FEATHER_ID); - if (feather) { - feather.remove(); - } - } - - /** - * @param {string} birdType - */ - function unlockBird(birdType) { - if (!unlockedSpecies.includes(birdType)) { - unlockedSpecies.push(birdType); - insertModal("New Bird Unlocked!", `You've found a ${SPECIES[birdType].name} feather! Use the Field Guide to switch your bird's species.`); - } - save(); - } - - function updateFeather() { - const feather = document.querySelector("#birb-feather"); - if (!feather || !(feather instanceof HTMLElement)) { - return; - } - 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`; - } - } - - /** - * @param {HTMLElement} element - */ - function centerElement(element) { - element.style.left = `${window.innerWidth / 2 - element.offsetWidth / 2}px`; - element.style.top = `${window.innerHeight / 2 - element.offsetHeight / 2}px`; - } - - /** - * @param {string} title - * @param {string} message - */ - function insertModal(title, message) { - if (document.querySelector("#" + FIELD_GUIDE_ID)) { - return; - } - - const modal = createWindow("birb-modal", title, ` -
- ${message} -
- `); - - modal.style.width = "270px"; - centerElement(modal); - } - - /** - * @param {HTMLElement} menu - */ - function updateMenuLocation(menu) { - let x = birdX; - let y = canvas.offsetTop + canvas.height / 2 + WINDOW_PIXEL_SIZE * 10; - const offset = 20; - if (x < window.innerWidth / 2) { - // Left side - x += offset; - } else { - // Right side - x -= (menu.offsetWidth + offset) * UI_CSS_SCALE; - } - if (y > window.innerHeight / 2) { - // Top side - y -= (menu.offsetHeight + offset + 10) * UI_CSS_SCALE; - } else { - // Bottom side - y += offset; - } - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; - }; - - function insertFieldGuide() { - if (document.querySelector("#" + FIELD_GUIDE_ID)) { - return; - } - let html = ` -
-
Field Guide
-
x
-
-
-
-
-
` - const fieldGuide = makeElement("birb-window", undefined, FIELD_GUIDE_ID); - fieldGuide.innerHTML = html; - document.body.appendChild(fieldGuide); - makeDraggable(fieldGuide.querySelector(".birb-window-header")); - - const closeButton = fieldGuide.querySelector(".birb-window-close"); - if (closeButton) { - makeClosable(() => { - fieldGuide.remove(); - }, closeButton); - } - - const content = fieldGuide.querySelector(".birb-grid-content"); - if (!content) { - return; - } - content.innerHTML = ""; - - const generateDescription = (/** @type {string} */ speciesId) => { - const type = SPECIES[speciesId]; - const unlocked = unlockedSpecies.includes(speciesId); - return "" + type.name + "
" + (!unlocked ? "Not yet unlocked" : type.description); - }; - - const description = fieldGuide.querySelector(".birb-field-guide-description"); - if (!description) { - return; - } - description.innerHTML = generateDescription(currentSpecies); - for (const [id, type] of Object.entries(SPECIES)) { - const unlocked = unlockedSpecies.includes(id); - const speciesElement = makeElement("birb-grid-item"); - if (id === currentSpecies) { - speciesElement.classList.add("birb-grid-item-selected"); - } - const speciesCanvas = document.createElement("canvas"); - speciesCanvas.width = SPRITE_WIDTH * CANVAS_PIXEL_SIZE; - speciesCanvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; - const speciesCtx = speciesCanvas.getContext("2d"); - if (!speciesCtx) { - return; - } - birbFrames.base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type); - speciesElement.appendChild(speciesCanvas); - content.appendChild(speciesElement); - if (unlocked) { - onClick(speciesElement, () => { - switchSpecies(id); - document.querySelectorAll(".birb-grid-item").forEach((element) => { - element.classList.remove("birb-grid-item-selected"); - }); - speciesElement.classList.add("birb-grid-item-selected"); - }); - } else { - speciesElement.classList.add("birb-grid-item-locked"); - } - speciesElement.addEventListener("mouseover", () => { - log("mouseover"); - description.innerHTML = generateDescription(id); - }); - speciesElement.addEventListener("mouseout", () => { - description.innerHTML = generateDescription(currentSpecies); - }); - } - centerElement(fieldGuide); - } - - function removeFieldGuide() { - const fieldGuide = document.querySelector("#" + FIELD_GUIDE_ID); - if (fieldGuide) { - fieldGuide.remove(); - } - } - - /** - * @param {string} type - */ - function switchSpecies(type) { - currentSpecies = type; - // Update CSS variable --birb-highlight to be wing color - document.documentElement.style.setProperty("--birb-highlight", SPECIES[type].colors[SPRITE.THEME_HIGHLIGHT]); - save(); - } - - /** - * @param {string[][]} array - * @param {number} sprite - * @param {number} [width] - * @returns {string[][]} - */ - function getLayer(array, sprite, width = SPRITE_WIDTH) { - // From an array of a horizontal sprite sheet, get the layer for a specific sprite - const layer = []; - for (let y = 0; y < width; y++) { - layer.push(array[y].slice(sprite * width, (sprite + 1) * width)); - } - return layer; - } - - /** - * Update the birds location from the start to the target location on a parabolic path - * @param {number} speed The speed of the bird along the path - * @param {number} [intensity] The intensity of the parabolic path - * @returns {boolean} Whether the bird has reached the target location - */ - function updateParabolicPath(speed, intensity = 2.5) { - const dx = targetX - startX; - const dy = targetY - startY; - const distance = Math.sqrt(dx * dx + dy * dy); - const time = Date.now() - stateStart; - if (distance > Math.max(window.innerWidth, window.innerHeight) / 2) { - speed *= 1.3; - } - const amount = Math.min(1, time / (distance / speed)); - const { x, y } = parabolicLerp(startX, startY, targetX, targetY, amount, intensity); - birdX = x; - birdY = y; - const complete = Math.abs(birdX - targetX) < 1 && Math.abs(birdY - targetY) < 1; - if (complete) { - birdX = targetX; - birdY = targetY; - } else { - direction = targetX > birdX ? Directions.RIGHT : Directions.LEFT; - } - return complete; - } - - function getFocusedElementRandomX() { - return Math.random() * (focusedBounds.right - focusedBounds.left) + focusedBounds.left; - } - - function isWithinHorizontalBounds() { - return birdX >= focusedBounds.left && birdX <= focusedBounds.right; - } - - function getFocusedY() { - return getFullWindowHeight() - focusedBounds.top; - } - - /** - * @returns The render-safe height of the inner browser window - */ - function getSafeWindowHeight() { - // Necessary because iOS 26 Safari is terrible and won't render - // fixed elements behind the address bar - return window.innerHeight; - } - - /** - * @returns The true height of the inner browser window - */ - function getFullWindowHeight() { - return document.documentElement.clientHeight; - } - - function focusOnGround() { - console.log("Focusing on ground"); - focusedElement = null; - focusedBounds = { left: 0, right: window.innerWidth, top: getSafeWindowHeight() }; - flyTo(Math.random() * window.innerWidth, 0); - } - - function focusOnElement() { - if (frozen) { - return; - } - const elements = document.querySelectorAll("img, video"); - const inWindow = Array.from(elements).filter((img) => { - const rect = img.getBoundingClientRect(); - return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= window.innerHeight; - }); - /** @type {HTMLElement[]} */ - // @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; - } - const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)]; - focusedElement = randomElement; - log("Focusing on element: ", focusedElement); - updateFocusedElementBounds(); - flyTo(getFocusedElementRandomX(), getFocusedY()); - } - - function updateFocusedElementBounds() { - if (focusedElement === null) { - // Update ground location to bottom of window - focusedBounds = { left: 0, right: window.innerWidth, top: getFullWindowHeight() }; - return; - } - const { left, right, top } = focusedElement.getBoundingClientRect(); - focusedBounds = { left, right, top }; - } - - function getCanvasWidth() { - return canvas.width * BIRB_CSS_SCALE - } - - function hop() { - if (frozen) { - return; - } - if (currentState === States.IDLE) { - setState(States.HOP); - setAnimation(Animations.FLYING); - if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { - targetX = birdX - HOP_DISTANCE; - } else { - targetX = birdX + HOP_DISTANCE; - } - targetY = getFocusedY(); - } - } - - function pet() { - if (currentState === States.IDLE && currentAnimation !== Animations.HEART) { - setAnimation(Animations.HEART); - lastPetTimestamp = Date.now(); - } - } - - function hideBirb() { - canvas.style.display = "none"; - visible = false; - } - - /** - * @param {number} x - * @param {number} y - */ - function flyTo(x, y) { - targetX = x; - targetY = y; - setState(States.FLYING); - setAnimation(Animations.FLYING); - } - - /** - * @returns {boolean} Whether the bird should be absolutely positioned - */ - function isAbsolute() { - return focusedElement !== null && (currentState === States.IDLE || currentState === States.HOP); - } - - /** - * Set the current animation and reset the animation timer - * @param {Anim} animation - */ - function setAnimation(animation) { - currentAnimation = animation; - animStart = Date.now(); - } - - /** - * Set the current state and reset the state timer - * @param {string} state - */ - function setState(state) { - stateStart = Date.now(); - startX = birdX; - startY = birdY; - currentState = state; - if (state === States.IDLE) { - setAnimation(Animations.BOB); - } - if (isAbsolute()) { - canvas.classList.add("birb-absolute"); - } else { - canvas.classList.remove("birb-absolute"); - } - setY(birdY); - } - - /** - * @param {number} x - */ - function setX(x) { - let mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2)); - canvas.style.left = `${x + mod}px`; - } - - /** - * @param {number} y - */ - function setY(y) { - let bottom; - if (isAbsolute()) { - // Position is absolute, convert from fixed - bottom = y - window.scrollY; - } else { - // Position is fixed - bottom = y; - } - canvas.style.bottom = `${bottom}px`; - } - - // 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 }; - } - - // Run the birb - init(); - draw(); -}).catch((e) => { - error("Error while loading sprite sheets: ", e); -}); \ No newline at end of file +} \ No newline at end of file