Persist and restore bird position across sessions

Add saving and restoring of bird positions so the bird can persist its location between navigations and reloads.

Introduces SavedBirdPosition and birdPositions in save data, constants for save intervals and thresholds, and tracking of position deltas to mark dirty state. Implements sanitizeSavedBirdPositions, normalizePath, trimming logic, and a per-tab session key (using window.name) to avoid cross-tab restores. Saves periodically and on pagehide/beforeunload, restores on startup (with heuristics to snap to a nearby perchable element), and avoids overwriting restored Y when appropriate.
This commit is contained in:
√(noham)²
2026-04-06 12:55:19 +02:00
parent 96ff61625a
commit 5797c055ed
9 changed files with 1397 additions and 52 deletions

BIN
dist/extension.zip vendored

Binary file not shown.

241
dist/extension/birb.js vendored
View File

@@ -2111,6 +2111,13 @@
* @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote
*/ */
/**
* @typedef {Object} SavedBirdPosition
* @property {number} x
* @property {number} y
* @property {number} updatedAt
*/
/** /**
* @typedef {Object} BirbSaveData * @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies * @property {string[]} unlockedSpecies
@@ -2119,6 +2126,7 @@
* @property {string} currentHat * @property {string} currentHat
* @property {Partial<Settings>} settings * @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes] * @property {SavedStickyNote[]} [stickyNotes]
* @property {Record<string, SavedBirdPosition>} [birdPositions]
*/ */
/** /**
@@ -2600,11 +2608,16 @@
// Petting boosts // Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2; const PET_FEATHER_BOOST = 2; // Multiplier for feather effect
const PET_HAT_BOOST = 1.5; const PET_HAT_BOOST = 1.5; // Multiplier for hat effect
// Focus element constraints // Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100; const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target
const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow
const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again
const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty"
const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep
const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
@@ -2741,7 +2754,7 @@
}), }),
new Separator(), new Separator(),
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false),
]; ];
/** @type {Birb} */ /** @type {Birb} */
@@ -2780,6 +2793,13 @@
let currentHat = DEFAULT_HAT; let currentHat = DEFAULT_HAT;
// let visible = true; // let visible = true;
let lastPetTimestamp = 0; let lastPetTimestamp = 0;
/** @type {Record<string, SavedBirdPosition>} */
let savedBirdPositions = {};
let holdRestoredYPosition = false;
let birdPositionDirty = false;
let lastTrackedBirdX = birdX;
let lastTrackedBirdY = birdY;
let birdSessionKey = "";
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
let stickyNotes = []; let stickyNotes = [];
@@ -2798,6 +2818,7 @@
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT; currentHat = saveData.currentHat ?? DEFAULT_HAT;
savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions);
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
@@ -2832,6 +2853,9 @@
left: note.left left: note.left
})); }));
} }
if (Object.keys(savedBirdPositions).length > 0) {
saveData.birdPositions = savedBirdPositions;
}
getContext().putSaveData(saveData); getContext().putSaveData(saveData);
} }
@@ -2919,19 +2943,26 @@
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastPath = getContext().getPath().split("?")[0]; let lastPath = normalizePath(getContext().getPath());
setInterval(() => { setInterval(() => {
const currentPath = getContext().getPath().split("?")[0]; const currentPath = normalizePath(getContext().getPath());
if (currentPath !== lastPath) { if (currentPath !== lastPath) {
log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
saveBirdPosition(true);
lastPath = currentPath; lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
restoreBirdPosition();
} }
}, URL_CHECK_INTERVAL); }, URL_CHECK_INTERVAL);
setInterval(update, UPDATE_INTERVAL); setInterval(update, UPDATE_INTERVAL);
setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL);
window.addEventListener("pagehide", () => saveBirdPosition(true));
window.addEventListener("beforeunload", () => saveBirdPosition(true));
flyToElement(true); if (!restoreBirdPosition()) {
flyToElement(true);
}
} }
function update() { function update() {
@@ -2992,7 +3023,9 @@
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement && !isWithinHorizontalBounds()) {
flyToElement(); flyToElement();
} }
birdY = getFocusedY(); if (focusedElement || !holdRestoredYPosition) {
birdY = getFocusedY();
}
} else if (currentState === States.FLYING) { } else if (currentState === States.FLYING) {
// Fly to target location (even if in the air) // Fly to target location (even if in the air)
if (updateParabolicPath(FLY_SPEED, 2)) { if (updateParabolicPath(FLY_SPEED, 2)) {
@@ -3022,6 +3055,13 @@
// Update HTML element position // Update HTML element position
birb.setX(birdX); birb.setX(birdX);
birb.setY(birdY); birb.setY(birdY);
const movedX = Math.abs(birdX - lastTrackedBirdX);
const movedY = Math.abs(birdY - lastTrackedBirdY);
if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) {
birdPositionDirty = true;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
}
} }
/** /**
@@ -3578,6 +3618,7 @@
if (frozen) { if (frozen) {
return false; return false;
} }
holdRestoredYPosition = false;
const previousElement = focusedElement; const previousElement = focusedElement;
focusedElement = getRandomValidElement(); focusedElement = getRandomValidElement();
updateFocusedElementBounds(); updateFocusedElementBounds();
@@ -3594,6 +3635,7 @@
* @param {number} y * @param {number} y
*/ */
function teleportTo(x, y) { function teleportTo(x, y) {
holdRestoredYPosition = false;
birdX = x; birdX = x;
birdY = y; birdY = y;
setState(States.IDLE); setState(States.IDLE);
@@ -3628,11 +3670,16 @@
focusedBounds = { left, right, top }; focusedBounds = { left, right, top };
} }
function getCanvasWidth() {
return birb.getElementWidth();
}
function hop() { function hop() {
if (frozen) { if (frozen) {
return; return;
} }
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
holdRestoredYPosition = false;
setState(States.HOP); setState(States.HOP);
birb.setAnimation(Animations.FLYING); birb.setAnimation(Animations.FLYING);
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
@@ -3663,6 +3710,7 @@
* @param {number} y * @param {number} y
*/ */
function flyTo(x, y) { function flyTo(x, y) {
holdRestoredYPosition = false;
targetX = x; targetX = x;
targetY = y; targetY = y;
setState(States.FLYING); setState(States.FLYING);
@@ -3692,6 +3740,183 @@
birb.setY(birdY); birb.setY(birdY);
} }
/**
* @param {unknown} value
* @returns {Record<string, SavedBirdPosition>}
*/
function sanitizeSavedBirdPositions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
/** @type {Record<string, SavedBirdPosition>} */
const result = {};
for (const [key, position] of Object.entries(value)) {
if (!position || typeof position !== "object" || Array.isArray(position)) {
continue;
}
// @ts-expect-error
const x = Number(position.x);
// @ts-expect-error
const y = Number(position.y);
// @ts-expect-error
const updatedAt = Number(position.updatedAt ?? 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 };
}
return result;
}
/**
* @param {string} path
* @returns {string}
*/
function normalizePath(path) {
return path.split("?")[0].split("#")[0];
}
function trimSavedBirdPositions() {
const entries = Object.entries(savedBirdPositions);
if (entries.length <= MAX_SAVED_BIRD_POSITIONS) {
return;
}
entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) {
delete savedBirdPositions[entries[i][0]];
}
}
function getBirdPositionScopeKey() {
if (birdSessionKey) {
return birdSessionKey;
}
const existingWindowName = typeof window.name === "string" ? window.name : "";
const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER);
if (markerIndex >= 0) {
const end = existingWindowName.indexOf("|", markerIndex);
birdSessionKey = end >= 0
? existingWindowName.slice(markerIndex, end)
: existingWindowName.slice(markerIndex);
return birdSessionKey;
}
const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`;
try {
window.name = existingWindowName
? `${existingWindowName}|${birdSessionKey}`
: birdSessionKey;
} catch {
// Ignore if the page blocks changing window.name.
}
return birdSessionKey;
}
/**
* @param {boolean} [force]
*/
function saveBirdPosition(force = false) {
if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) {
return;
}
if (!force && !birdPositionDirty) {
return;
}
const now = Date.now();
const scopeKey = getBirdPositionScopeKey();
const previous = savedBirdPositions[scopeKey];
if (!force && previous) {
const movedX = Math.abs(previous.x - birdX);
const movedY = Math.abs(previous.y - birdY);
if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) {
birdPositionDirty = false;
return;
}
}
savedBirdPositions[scopeKey] = {
x: birdX,
y: birdY,
updatedAt: now
};
trimSavedBirdPositions();
birdPositionDirty = false;
save();
}
/**
* @returns {boolean}
*/
function restoreBirdPosition() {
const scopeKey = getBirdPositionScopeKey();
const saved = savedBirdPositions[scopeKey];
if (!saved) {
holdRestoredYPosition = false;
return false;
}
const maxX = Math.max(0, window.innerWidth - getCanvasWidth());
const maxY = getWindowHeight() * 1.5;
birdX = Math.min(Math.max(saved.x, 0), maxX);
birdY = Math.min(Math.max(saved.y, 0), maxY);
// Attempt to keep the bird perched if an element still exists near the saved position.
focusedElement = getElementAtPosition(birdX, birdY);
updateFocusedElementBounds();
holdRestoredYPosition = focusedElement === null;
birdPositionDirty = false;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
setState(States.IDLE);
birb.setX(birdX);
birb.setY(birdY);
return true;
}
/**
* @param {number} x
* @param {number} y
* @returns {HTMLElement|null}
*/
function getElementAtPosition(x, y) {
const desiredTop = getWindowHeight() - y;
let bestElement = null;
let bestScore = Number.POSITIVE_INFINITY;
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
for (const element of elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const xDistance = Math.abs((rect.left + rect.right) / 2 - x);
const yDistance = Math.abs(rect.top - desiredTop);
const score = xDistance + yDistance * 1.5;
if (score < bestScore) {
bestScore = score;
bestElement = element;
}
}
if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) {
return null;
}
return bestElement;
}
// Helper functions // Helper functions
/** /**

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Pocket Bird", "name": "Pocket Bird",
"description": "It's a pet bird in your browser, what more could you want?", "description": "It's a pet bird in your browser, what more could you want?",
"version": "2026.4.4", "version": "2026.4.6",
"homepage_url": "https://idreesinc.com", "homepage_url": "https://idreesinc.com",
"icons": { "icons": {
"48": "images/icons/transparent/48x48x1.png", "48": "images/icons/transparent/48x48x1.png",

243
dist/obsidian/main.js vendored
View File

@@ -1,7 +1,7 @@
const { Plugin, Notice } = require('obsidian'); const { Plugin, Notice } = require('obsidian');
module.exports = class PocketBird extends Plugin { module.exports = class PocketBird extends Plugin {
onload() { onload() {
console.log("Loading Pocket Bird version 2026.4.4..."); console.log("Loading Pocket Bird version 2026.4.6...");
const OBSIDIAN_PLUGIN = this; const OBSIDIAN_PLUGIN = this;
(function () { (function () {
'use strict'; 'use strict';
@@ -2144,6 +2144,13 @@ module.exports = class PocketBird extends Plugin {
* @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote
*/ */
/**
* @typedef {Object} SavedBirdPosition
* @property {number} x
* @property {number} y
* @property {number} updatedAt
*/
/** /**
* @typedef {Object} BirbSaveData * @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies * @property {string[]} unlockedSpecies
@@ -2152,6 +2159,7 @@ module.exports = class PocketBird extends Plugin {
* @property {string} currentHat * @property {string} currentHat
* @property {Partial<Settings>} settings * @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes] * @property {SavedStickyNote[]} [stickyNotes]
* @property {Record<string, SavedBirdPosition>} [birdPositions]
*/ */
/** /**
@@ -2633,11 +2641,16 @@ module.exports = class PocketBird extends Plugin {
// Petting boosts // Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2; const PET_FEATHER_BOOST = 2; // Multiplier for feather effect
const PET_HAT_BOOST = 1.5; const PET_HAT_BOOST = 1.5; // Multiplier for hat effect
// Focus element constraints // Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100; const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target
const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow
const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again
const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty"
const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep
const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
@@ -2774,7 +2787,7 @@ module.exports = class PocketBird extends Plugin {
}), }),
new Separator(), new Separator(),
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false),
]; ];
/** @type {Birb} */ /** @type {Birb} */
@@ -2813,6 +2826,13 @@ module.exports = class PocketBird extends Plugin {
let currentHat = DEFAULT_HAT; let currentHat = DEFAULT_HAT;
// let visible = true; // let visible = true;
let lastPetTimestamp = 0; let lastPetTimestamp = 0;
/** @type {Record<string, SavedBirdPosition>} */
let savedBirdPositions = {};
let holdRestoredYPosition = false;
let birdPositionDirty = false;
let lastTrackedBirdX = birdX;
let lastTrackedBirdY = birdY;
let birdSessionKey = "";
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
let stickyNotes = []; let stickyNotes = [];
@@ -2831,6 +2851,7 @@ module.exports = class PocketBird extends Plugin {
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT; currentHat = saveData.currentHat ?? DEFAULT_HAT;
savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions);
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
@@ -2865,6 +2886,9 @@ module.exports = class PocketBird extends Plugin {
left: note.left left: note.left
})); }));
} }
if (Object.keys(savedBirdPositions).length > 0) {
saveData.birdPositions = savedBirdPositions;
}
getContext().putSaveData(saveData); getContext().putSaveData(saveData);
} }
@@ -2952,19 +2976,26 @@ module.exports = class PocketBird extends Plugin {
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastPath = getContext().getPath().split("?")[0]; let lastPath = normalizePath(getContext().getPath());
setInterval(() => { setInterval(() => {
const currentPath = getContext().getPath().split("?")[0]; const currentPath = normalizePath(getContext().getPath());
if (currentPath !== lastPath) { if (currentPath !== lastPath) {
log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
saveBirdPosition(true);
lastPath = currentPath; lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
restoreBirdPosition();
} }
}, URL_CHECK_INTERVAL); }, URL_CHECK_INTERVAL);
setInterval(update, UPDATE_INTERVAL); setInterval(update, UPDATE_INTERVAL);
setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL);
window.addEventListener("pagehide", () => saveBirdPosition(true));
window.addEventListener("beforeunload", () => saveBirdPosition(true));
flyToElement(true); if (!restoreBirdPosition()) {
flyToElement(true);
}
} }
function update() { function update() {
@@ -3025,7 +3056,9 @@ module.exports = class PocketBird extends Plugin {
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement && !isWithinHorizontalBounds()) {
flyToElement(); flyToElement();
} }
birdY = getFocusedY(); if (focusedElement || !holdRestoredYPosition) {
birdY = getFocusedY();
}
} else if (currentState === States.FLYING) { } else if (currentState === States.FLYING) {
// Fly to target location (even if in the air) // Fly to target location (even if in the air)
if (updateParabolicPath(FLY_SPEED, 2)) { if (updateParabolicPath(FLY_SPEED, 2)) {
@@ -3055,6 +3088,13 @@ module.exports = class PocketBird extends Plugin {
// Update HTML element position // Update HTML element position
birb.setX(birdX); birb.setX(birdX);
birb.setY(birdY); birb.setY(birdY);
const movedX = Math.abs(birdX - lastTrackedBirdX);
const movedY = Math.abs(birdY - lastTrackedBirdY);
if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) {
birdPositionDirty = true;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
}
} }
/** /**
@@ -3611,6 +3651,7 @@ module.exports = class PocketBird extends Plugin {
if (frozen) { if (frozen) {
return false; return false;
} }
holdRestoredYPosition = false;
const previousElement = focusedElement; const previousElement = focusedElement;
focusedElement = getRandomValidElement(); focusedElement = getRandomValidElement();
updateFocusedElementBounds(); updateFocusedElementBounds();
@@ -3627,6 +3668,7 @@ module.exports = class PocketBird extends Plugin {
* @param {number} y * @param {number} y
*/ */
function teleportTo(x, y) { function teleportTo(x, y) {
holdRestoredYPosition = false;
birdX = x; birdX = x;
birdY = y; birdY = y;
setState(States.IDLE); setState(States.IDLE);
@@ -3661,11 +3703,16 @@ module.exports = class PocketBird extends Plugin {
focusedBounds = { left, right, top }; focusedBounds = { left, right, top };
} }
function getCanvasWidth() {
return birb.getElementWidth();
}
function hop() { function hop() {
if (frozen) { if (frozen) {
return; return;
} }
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
holdRestoredYPosition = false;
setState(States.HOP); setState(States.HOP);
birb.setAnimation(Animations.FLYING); birb.setAnimation(Animations.FLYING);
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
@@ -3696,6 +3743,7 @@ module.exports = class PocketBird extends Plugin {
* @param {number} y * @param {number} y
*/ */
function flyTo(x, y) { function flyTo(x, y) {
holdRestoredYPosition = false;
targetX = x; targetX = x;
targetY = y; targetY = y;
setState(States.FLYING); setState(States.FLYING);
@@ -3725,6 +3773,183 @@ module.exports = class PocketBird extends Plugin {
birb.setY(birdY); birb.setY(birdY);
} }
/**
* @param {unknown} value
* @returns {Record<string, SavedBirdPosition>}
*/
function sanitizeSavedBirdPositions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
/** @type {Record<string, SavedBirdPosition>} */
const result = {};
for (const [key, position] of Object.entries(value)) {
if (!position || typeof position !== "object" || Array.isArray(position)) {
continue;
}
// @ts-expect-error
const x = Number(position.x);
// @ts-expect-error
const y = Number(position.y);
// @ts-expect-error
const updatedAt = Number(position.updatedAt ?? 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 };
}
return result;
}
/**
* @param {string} path
* @returns {string}
*/
function normalizePath(path) {
return path.split("?")[0].split("#")[0];
}
function trimSavedBirdPositions() {
const entries = Object.entries(savedBirdPositions);
if (entries.length <= MAX_SAVED_BIRD_POSITIONS) {
return;
}
entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) {
delete savedBirdPositions[entries[i][0]];
}
}
function getBirdPositionScopeKey() {
if (birdSessionKey) {
return birdSessionKey;
}
const existingWindowName = typeof window.name === "string" ? window.name : "";
const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER);
if (markerIndex >= 0) {
const end = existingWindowName.indexOf("|", markerIndex);
birdSessionKey = end >= 0
? existingWindowName.slice(markerIndex, end)
: existingWindowName.slice(markerIndex);
return birdSessionKey;
}
const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`;
try {
window.name = existingWindowName
? `${existingWindowName}|${birdSessionKey}`
: birdSessionKey;
} catch {
// Ignore if the page blocks changing window.name.
}
return birdSessionKey;
}
/**
* @param {boolean} [force]
*/
function saveBirdPosition(force = false) {
if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) {
return;
}
if (!force && !birdPositionDirty) {
return;
}
const now = Date.now();
const scopeKey = getBirdPositionScopeKey();
const previous = savedBirdPositions[scopeKey];
if (!force && previous) {
const movedX = Math.abs(previous.x - birdX);
const movedY = Math.abs(previous.y - birdY);
if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) {
birdPositionDirty = false;
return;
}
}
savedBirdPositions[scopeKey] = {
x: birdX,
y: birdY,
updatedAt: now
};
trimSavedBirdPositions();
birdPositionDirty = false;
save();
}
/**
* @returns {boolean}
*/
function restoreBirdPosition() {
const scopeKey = getBirdPositionScopeKey();
const saved = savedBirdPositions[scopeKey];
if (!saved) {
holdRestoredYPosition = false;
return false;
}
const maxX = Math.max(0, window.innerWidth - getCanvasWidth());
const maxY = getWindowHeight() * 1.5;
birdX = Math.min(Math.max(saved.x, 0), maxX);
birdY = Math.min(Math.max(saved.y, 0), maxY);
// Attempt to keep the bird perched if an element still exists near the saved position.
focusedElement = getElementAtPosition(birdX, birdY);
updateFocusedElementBounds();
holdRestoredYPosition = focusedElement === null;
birdPositionDirty = false;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
setState(States.IDLE);
birb.setX(birdX);
birb.setY(birdY);
return true;
}
/**
* @param {number} x
* @param {number} y
* @returns {HTMLElement|null}
*/
function getElementAtPosition(x, y) {
const desiredTop = getWindowHeight() - y;
let bestElement = null;
let bestScore = Number.POSITIVE_INFINITY;
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
for (const element of elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const xDistance = Math.abs((rect.left + rect.right) / 2 - x);
const yDistance = Math.abs(rect.top - desiredTop);
const score = xDistance + yDistance * 1.5;
if (score < bestScore) {
bestScore = score;
bestElement = element;
}
}
if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) {
return null;
}
return bestElement;
}
// Helper functions // Helper functions
/** /**

View File

@@ -1,7 +1,7 @@
{ {
"id": "pocket-bird", "id": "pocket-bird",
"name": "Pocket Bird", "name": "Pocket Bird",
"version": "2026.4.4", "version": "2026.4.6",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Add a pet bird to fly around your notes and keep you company!", "description": "Add a pet bird to fly around your notes and keep you company!",
"author": "Idrees Hassan", "author": "Idrees Hassan",

View File

@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Pocket Bird // @name Pocket Bird
// @namespace https://idreesinc.com // @namespace https://idreesinc.com
// @version 2026.4.4 // @version 2026.4.6
// @description It's a pet bird in your browser, what more could you want? // @description It's a pet bird in your browser, what more could you want?
// @author Idrees // @author Idrees
// @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js
@@ -2106,6 +2106,13 @@
* @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote
*/ */
/**
* @typedef {Object} SavedBirdPosition
* @property {number} x
* @property {number} y
* @property {number} updatedAt
*/
/** /**
* @typedef {Object} BirbSaveData * @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies * @property {string[]} unlockedSpecies
@@ -2114,6 +2121,7 @@
* @property {string} currentHat * @property {string} currentHat
* @property {Partial<Settings>} settings * @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes] * @property {SavedStickyNote[]} [stickyNotes]
* @property {Record<string, SavedBirdPosition>} [birdPositions]
*/ */
/** /**
@@ -2595,11 +2603,16 @@
// Petting boosts // Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2; const PET_FEATHER_BOOST = 2; // Multiplier for feather effect
const PET_HAT_BOOST = 1.5; const PET_HAT_BOOST = 1.5; // Multiplier for hat effect
// Focus element constraints // Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100; const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target
const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow
const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again
const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty"
const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep
const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
@@ -2736,7 +2749,7 @@
}), }),
new Separator(), new Separator(),
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false),
]; ];
/** @type {Birb} */ /** @type {Birb} */
@@ -2775,6 +2788,13 @@
let currentHat = DEFAULT_HAT; let currentHat = DEFAULT_HAT;
// let visible = true; // let visible = true;
let lastPetTimestamp = 0; let lastPetTimestamp = 0;
/** @type {Record<string, SavedBirdPosition>} */
let savedBirdPositions = {};
let holdRestoredYPosition = false;
let birdPositionDirty = false;
let lastTrackedBirdX = birdX;
let lastTrackedBirdY = birdY;
let birdSessionKey = "";
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
let stickyNotes = []; let stickyNotes = [];
@@ -2793,6 +2813,7 @@
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT; currentHat = saveData.currentHat ?? DEFAULT_HAT;
savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions);
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
@@ -2827,6 +2848,9 @@
left: note.left left: note.left
})); }));
} }
if (Object.keys(savedBirdPositions).length > 0) {
saveData.birdPositions = savedBirdPositions;
}
getContext().putSaveData(saveData); getContext().putSaveData(saveData);
} }
@@ -2914,19 +2938,26 @@
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastPath = getContext().getPath().split("?")[0]; let lastPath = normalizePath(getContext().getPath());
setInterval(() => { setInterval(() => {
const currentPath = getContext().getPath().split("?")[0]; const currentPath = normalizePath(getContext().getPath());
if (currentPath !== lastPath) { if (currentPath !== lastPath) {
log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
saveBirdPosition(true);
lastPath = currentPath; lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
restoreBirdPosition();
} }
}, URL_CHECK_INTERVAL); }, URL_CHECK_INTERVAL);
setInterval(update, UPDATE_INTERVAL); setInterval(update, UPDATE_INTERVAL);
setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL);
window.addEventListener("pagehide", () => saveBirdPosition(true));
window.addEventListener("beforeunload", () => saveBirdPosition(true));
flyToElement(true); if (!restoreBirdPosition()) {
flyToElement(true);
}
} }
function update() { function update() {
@@ -2987,7 +3018,9 @@
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement && !isWithinHorizontalBounds()) {
flyToElement(); flyToElement();
} }
birdY = getFocusedY(); if (focusedElement || !holdRestoredYPosition) {
birdY = getFocusedY();
}
} else if (currentState === States.FLYING) { } else if (currentState === States.FLYING) {
// Fly to target location (even if in the air) // Fly to target location (even if in the air)
if (updateParabolicPath(FLY_SPEED, 2)) { if (updateParabolicPath(FLY_SPEED, 2)) {
@@ -3017,6 +3050,13 @@
// Update HTML element position // Update HTML element position
birb.setX(birdX); birb.setX(birdX);
birb.setY(birdY); birb.setY(birdY);
const movedX = Math.abs(birdX - lastTrackedBirdX);
const movedY = Math.abs(birdY - lastTrackedBirdY);
if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) {
birdPositionDirty = true;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
}
} }
/** /**
@@ -3573,6 +3613,7 @@
if (frozen) { if (frozen) {
return false; return false;
} }
holdRestoredYPosition = false;
const previousElement = focusedElement; const previousElement = focusedElement;
focusedElement = getRandomValidElement(); focusedElement = getRandomValidElement();
updateFocusedElementBounds(); updateFocusedElementBounds();
@@ -3589,6 +3630,7 @@
* @param {number} y * @param {number} y
*/ */
function teleportTo(x, y) { function teleportTo(x, y) {
holdRestoredYPosition = false;
birdX = x; birdX = x;
birdY = y; birdY = y;
setState(States.IDLE); setState(States.IDLE);
@@ -3623,11 +3665,16 @@
focusedBounds = { left, right, top }; focusedBounds = { left, right, top };
} }
function getCanvasWidth() {
return birb.getElementWidth();
}
function hop() { function hop() {
if (frozen) { if (frozen) {
return; return;
} }
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
holdRestoredYPosition = false;
setState(States.HOP); setState(States.HOP);
birb.setAnimation(Animations.FLYING); birb.setAnimation(Animations.FLYING);
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
@@ -3658,6 +3705,7 @@
* @param {number} y * @param {number} y
*/ */
function flyTo(x, y) { function flyTo(x, y) {
holdRestoredYPosition = false;
targetX = x; targetX = x;
targetY = y; targetY = y;
setState(States.FLYING); setState(States.FLYING);
@@ -3687,6 +3735,183 @@
birb.setY(birdY); birb.setY(birdY);
} }
/**
* @param {unknown} value
* @returns {Record<string, SavedBirdPosition>}
*/
function sanitizeSavedBirdPositions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
/** @type {Record<string, SavedBirdPosition>} */
const result = {};
for (const [key, position] of Object.entries(value)) {
if (!position || typeof position !== "object" || Array.isArray(position)) {
continue;
}
// @ts-expect-error
const x = Number(position.x);
// @ts-expect-error
const y = Number(position.y);
// @ts-expect-error
const updatedAt = Number(position.updatedAt ?? 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 };
}
return result;
}
/**
* @param {string} path
* @returns {string}
*/
function normalizePath(path) {
return path.split("?")[0].split("#")[0];
}
function trimSavedBirdPositions() {
const entries = Object.entries(savedBirdPositions);
if (entries.length <= MAX_SAVED_BIRD_POSITIONS) {
return;
}
entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) {
delete savedBirdPositions[entries[i][0]];
}
}
function getBirdPositionScopeKey() {
if (birdSessionKey) {
return birdSessionKey;
}
const existingWindowName = typeof window.name === "string" ? window.name : "";
const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER);
if (markerIndex >= 0) {
const end = existingWindowName.indexOf("|", markerIndex);
birdSessionKey = end >= 0
? existingWindowName.slice(markerIndex, end)
: existingWindowName.slice(markerIndex);
return birdSessionKey;
}
const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`;
try {
window.name = existingWindowName
? `${existingWindowName}|${birdSessionKey}`
: birdSessionKey;
} catch {
// Ignore if the page blocks changing window.name.
}
return birdSessionKey;
}
/**
* @param {boolean} [force]
*/
function saveBirdPosition(force = false) {
if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) {
return;
}
if (!force && !birdPositionDirty) {
return;
}
const now = Date.now();
const scopeKey = getBirdPositionScopeKey();
const previous = savedBirdPositions[scopeKey];
if (!force && previous) {
const movedX = Math.abs(previous.x - birdX);
const movedY = Math.abs(previous.y - birdY);
if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) {
birdPositionDirty = false;
return;
}
}
savedBirdPositions[scopeKey] = {
x: birdX,
y: birdY,
updatedAt: now
};
trimSavedBirdPositions();
birdPositionDirty = false;
save();
}
/**
* @returns {boolean}
*/
function restoreBirdPosition() {
const scopeKey = getBirdPositionScopeKey();
const saved = savedBirdPositions[scopeKey];
if (!saved) {
holdRestoredYPosition = false;
return false;
}
const maxX = Math.max(0, window.innerWidth - getCanvasWidth());
const maxY = getWindowHeight() * 1.5;
birdX = Math.min(Math.max(saved.x, 0), maxX);
birdY = Math.min(Math.max(saved.y, 0), maxY);
// Attempt to keep the bird perched if an element still exists near the saved position.
focusedElement = getElementAtPosition(birdX, birdY);
updateFocusedElementBounds();
holdRestoredYPosition = focusedElement === null;
birdPositionDirty = false;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
setState(States.IDLE);
birb.setX(birdX);
birb.setY(birdY);
return true;
}
/**
* @param {number} x
* @param {number} y
* @returns {HTMLElement|null}
*/
function getElementAtPosition(x, y) {
const desiredTop = getWindowHeight() - y;
let bestElement = null;
let bestScore = Number.POSITIVE_INFINITY;
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
for (const element of elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const xDistance = Math.abs((rect.left + rect.right) / 2 - x);
const yDistance = Math.abs(rect.top - desiredTop);
const score = xDistance + yDistance * 1.5;
if (score < bestScore) {
bestScore = score;
bestElement = element;
}
}
if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) {
return null;
}
return bestElement;
}
// Helper functions // Helper functions
/** /**

241
dist/web/birb.embed.js vendored
View File

@@ -2086,6 +2086,13 @@
* @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote
*/ */
/**
* @typedef {Object} SavedBirdPosition
* @property {number} x
* @property {number} y
* @property {number} updatedAt
*/
/** /**
* @typedef {Object} BirbSaveData * @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies * @property {string[]} unlockedSpecies
@@ -2094,6 +2101,7 @@
* @property {string} currentHat * @property {string} currentHat
* @property {Partial<Settings>} settings * @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes] * @property {SavedStickyNote[]} [stickyNotes]
* @property {Record<string, SavedBirdPosition>} [birdPositions]
*/ */
/** /**
@@ -2575,11 +2583,16 @@
// Petting boosts // Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2; const PET_FEATHER_BOOST = 2; // Multiplier for feather effect
const PET_HAT_BOOST = 1.5; const PET_HAT_BOOST = 1.5; // Multiplier for hat effect
// Focus element constraints // Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100; const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target
const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow
const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again
const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty"
const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep
const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
@@ -2716,7 +2729,7 @@
}), }),
new Separator(), new Separator(),
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false),
]; ];
/** @type {Birb} */ /** @type {Birb} */
@@ -2755,6 +2768,13 @@
let currentHat = DEFAULT_HAT; let currentHat = DEFAULT_HAT;
// let visible = true; // let visible = true;
let lastPetTimestamp = 0; let lastPetTimestamp = 0;
/** @type {Record<string, SavedBirdPosition>} */
let savedBirdPositions = {};
let holdRestoredYPosition = false;
let birdPositionDirty = false;
let lastTrackedBirdX = birdX;
let lastTrackedBirdY = birdY;
let birdSessionKey = "";
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
let stickyNotes = []; let stickyNotes = [];
@@ -2773,6 +2793,7 @@
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT; currentHat = saveData.currentHat ?? DEFAULT_HAT;
savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions);
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
@@ -2807,6 +2828,9 @@
left: note.left left: note.left
})); }));
} }
if (Object.keys(savedBirdPositions).length > 0) {
saveData.birdPositions = savedBirdPositions;
}
getContext().putSaveData(saveData); getContext().putSaveData(saveData);
} }
@@ -2894,19 +2918,26 @@
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastPath = getContext().getPath().split("?")[0]; let lastPath = normalizePath(getContext().getPath());
setInterval(() => { setInterval(() => {
const currentPath = getContext().getPath().split("?")[0]; const currentPath = normalizePath(getContext().getPath());
if (currentPath !== lastPath) { if (currentPath !== lastPath) {
log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
saveBirdPosition(true);
lastPath = currentPath; lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
restoreBirdPosition();
} }
}, URL_CHECK_INTERVAL); }, URL_CHECK_INTERVAL);
setInterval(update, UPDATE_INTERVAL); setInterval(update, UPDATE_INTERVAL);
setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL);
window.addEventListener("pagehide", () => saveBirdPosition(true));
window.addEventListener("beforeunload", () => saveBirdPosition(true));
flyToElement(true); if (!restoreBirdPosition()) {
flyToElement(true);
}
} }
function update() { function update() {
@@ -2967,7 +2998,9 @@
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement && !isWithinHorizontalBounds()) {
flyToElement(); flyToElement();
} }
birdY = getFocusedY(); if (focusedElement || !holdRestoredYPosition) {
birdY = getFocusedY();
}
} else if (currentState === States.FLYING) { } else if (currentState === States.FLYING) {
// Fly to target location (even if in the air) // Fly to target location (even if in the air)
if (updateParabolicPath(FLY_SPEED, 2)) { if (updateParabolicPath(FLY_SPEED, 2)) {
@@ -2997,6 +3030,13 @@
// Update HTML element position // Update HTML element position
birb.setX(birdX); birb.setX(birdX);
birb.setY(birdY); birb.setY(birdY);
const movedX = Math.abs(birdX - lastTrackedBirdX);
const movedY = Math.abs(birdY - lastTrackedBirdY);
if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) {
birdPositionDirty = true;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
}
} }
/** /**
@@ -3553,6 +3593,7 @@
if (frozen) { if (frozen) {
return false; return false;
} }
holdRestoredYPosition = false;
const previousElement = focusedElement; const previousElement = focusedElement;
focusedElement = getRandomValidElement(); focusedElement = getRandomValidElement();
updateFocusedElementBounds(); updateFocusedElementBounds();
@@ -3569,6 +3610,7 @@
* @param {number} y * @param {number} y
*/ */
function teleportTo(x, y) { function teleportTo(x, y) {
holdRestoredYPosition = false;
birdX = x; birdX = x;
birdY = y; birdY = y;
setState(States.IDLE); setState(States.IDLE);
@@ -3603,11 +3645,16 @@
focusedBounds = { left, right, top }; focusedBounds = { left, right, top };
} }
function getCanvasWidth() {
return birb.getElementWidth();
}
function hop() { function hop() {
if (frozen) { if (frozen) {
return; return;
} }
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
holdRestoredYPosition = false;
setState(States.HOP); setState(States.HOP);
birb.setAnimation(Animations.FLYING); birb.setAnimation(Animations.FLYING);
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
@@ -3638,6 +3685,7 @@
* @param {number} y * @param {number} y
*/ */
function flyTo(x, y) { function flyTo(x, y) {
holdRestoredYPosition = false;
targetX = x; targetX = x;
targetY = y; targetY = y;
setState(States.FLYING); setState(States.FLYING);
@@ -3667,6 +3715,183 @@
birb.setY(birdY); birb.setY(birdY);
} }
/**
* @param {unknown} value
* @returns {Record<string, SavedBirdPosition>}
*/
function sanitizeSavedBirdPositions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
/** @type {Record<string, SavedBirdPosition>} */
const result = {};
for (const [key, position] of Object.entries(value)) {
if (!position || typeof position !== "object" || Array.isArray(position)) {
continue;
}
// @ts-expect-error
const x = Number(position.x);
// @ts-expect-error
const y = Number(position.y);
// @ts-expect-error
const updatedAt = Number(position.updatedAt ?? 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 };
}
return result;
}
/**
* @param {string} path
* @returns {string}
*/
function normalizePath(path) {
return path.split("?")[0].split("#")[0];
}
function trimSavedBirdPositions() {
const entries = Object.entries(savedBirdPositions);
if (entries.length <= MAX_SAVED_BIRD_POSITIONS) {
return;
}
entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) {
delete savedBirdPositions[entries[i][0]];
}
}
function getBirdPositionScopeKey() {
if (birdSessionKey) {
return birdSessionKey;
}
const existingWindowName = typeof window.name === "string" ? window.name : "";
const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER);
if (markerIndex >= 0) {
const end = existingWindowName.indexOf("|", markerIndex);
birdSessionKey = end >= 0
? existingWindowName.slice(markerIndex, end)
: existingWindowName.slice(markerIndex);
return birdSessionKey;
}
const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`;
try {
window.name = existingWindowName
? `${existingWindowName}|${birdSessionKey}`
: birdSessionKey;
} catch {
// Ignore if the page blocks changing window.name.
}
return birdSessionKey;
}
/**
* @param {boolean} [force]
*/
function saveBirdPosition(force = false) {
if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) {
return;
}
if (!force && !birdPositionDirty) {
return;
}
const now = Date.now();
const scopeKey = getBirdPositionScopeKey();
const previous = savedBirdPositions[scopeKey];
if (!force && previous) {
const movedX = Math.abs(previous.x - birdX);
const movedY = Math.abs(previous.y - birdY);
if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) {
birdPositionDirty = false;
return;
}
}
savedBirdPositions[scopeKey] = {
x: birdX,
y: birdY,
updatedAt: now
};
trimSavedBirdPositions();
birdPositionDirty = false;
save();
}
/**
* @returns {boolean}
*/
function restoreBirdPosition() {
const scopeKey = getBirdPositionScopeKey();
const saved = savedBirdPositions[scopeKey];
if (!saved) {
holdRestoredYPosition = false;
return false;
}
const maxX = Math.max(0, window.innerWidth - getCanvasWidth());
const maxY = getWindowHeight() * 1.5;
birdX = Math.min(Math.max(saved.x, 0), maxX);
birdY = Math.min(Math.max(saved.y, 0), maxY);
// Attempt to keep the bird perched if an element still exists near the saved position.
focusedElement = getElementAtPosition(birdX, birdY);
updateFocusedElementBounds();
holdRestoredYPosition = focusedElement === null;
birdPositionDirty = false;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
setState(States.IDLE);
birb.setX(birdX);
birb.setY(birdY);
return true;
}
/**
* @param {number} x
* @param {number} y
* @returns {HTMLElement|null}
*/
function getElementAtPosition(x, y) {
const desiredTop = getWindowHeight() - y;
let bestElement = null;
let bestScore = Number.POSITIVE_INFINITY;
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
for (const element of elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const xDistance = Math.abs((rect.left + rect.right) / 2 - x);
const yDistance = Math.abs(rect.top - desiredTop);
const score = xDistance + yDistance * 1.5;
if (score < bestScore) {
bestScore = score;
bestElement = element;
}
}
if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) {
return null;
}
return bestElement;
}
// Helper functions // Helper functions
/** /**

241
dist/web/birb.js vendored
View File

@@ -2086,6 +2086,13 @@
* @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote
*/ */
/**
* @typedef {Object} SavedBirdPosition
* @property {number} x
* @property {number} y
* @property {number} updatedAt
*/
/** /**
* @typedef {Object} BirbSaveData * @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies * @property {string[]} unlockedSpecies
@@ -2094,6 +2101,7 @@
* @property {string} currentHat * @property {string} currentHat
* @property {Partial<Settings>} settings * @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes] * @property {SavedStickyNote[]} [stickyNotes]
* @property {Record<string, SavedBirdPosition>} [birdPositions]
*/ */
/** /**
@@ -2575,11 +2583,16 @@
// Petting boosts // Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2; const PET_FEATHER_BOOST = 2; // Multiplier for feather effect
const PET_HAT_BOOST = 1.5; const PET_HAT_BOOST = 1.5; // Multiplier for hat effect
// Focus element constraints // Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100; const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target
const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow
const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again
const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty"
const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep
const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
@@ -2716,7 +2729,7 @@
}), }),
new Separator(), new Separator(),
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }), new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
new MenuItem("Build 2026.4.4", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.4"); }, undefined, false), new MenuItem("Build 2026.4.6", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.4.6"); }, undefined, false),
]; ];
/** @type {Birb} */ /** @type {Birb} */
@@ -2755,6 +2768,13 @@
let currentHat = DEFAULT_HAT; let currentHat = DEFAULT_HAT;
// let visible = true; // let visible = true;
let lastPetTimestamp = 0; let lastPetTimestamp = 0;
/** @type {Record<string, SavedBirdPosition>} */
let savedBirdPositions = {};
let holdRestoredYPosition = false;
let birdPositionDirty = false;
let lastTrackedBirdX = birdX;
let lastTrackedBirdY = birdY;
let birdSessionKey = "";
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
let stickyNotes = []; let stickyNotes = [];
@@ -2773,6 +2793,7 @@
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT; currentHat = saveData.currentHat ?? DEFAULT_HAT;
savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions);
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
@@ -2807,6 +2828,9 @@
left: note.left left: note.left
})); }));
} }
if (Object.keys(savedBirdPositions).length > 0) {
saveData.birdPositions = savedBirdPositions;
}
getContext().putSaveData(saveData); getContext().putSaveData(saveData);
} }
@@ -2894,19 +2918,26 @@
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastPath = getContext().getPath().split("?")[0]; let lastPath = normalizePath(getContext().getPath());
setInterval(() => { setInterval(() => {
const currentPath = getContext().getPath().split("?")[0]; const currentPath = normalizePath(getContext().getPath());
if (currentPath !== lastPath) { if (currentPath !== lastPath) {
log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
saveBirdPosition(true);
lastPath = currentPath; lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
restoreBirdPosition();
} }
}, URL_CHECK_INTERVAL); }, URL_CHECK_INTERVAL);
setInterval(update, UPDATE_INTERVAL); setInterval(update, UPDATE_INTERVAL);
setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL);
window.addEventListener("pagehide", () => saveBirdPosition(true));
window.addEventListener("beforeunload", () => saveBirdPosition(true));
flyToElement(true); if (!restoreBirdPosition()) {
flyToElement(true);
}
} }
function update() { function update() {
@@ -2967,7 +2998,9 @@
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement && !isWithinHorizontalBounds()) {
flyToElement(); flyToElement();
} }
birdY = getFocusedY(); if (focusedElement || !holdRestoredYPosition) {
birdY = getFocusedY();
}
} else if (currentState === States.FLYING) { } else if (currentState === States.FLYING) {
// Fly to target location (even if in the air) // Fly to target location (even if in the air)
if (updateParabolicPath(FLY_SPEED, 2)) { if (updateParabolicPath(FLY_SPEED, 2)) {
@@ -2997,6 +3030,13 @@
// Update HTML element position // Update HTML element position
birb.setX(birdX); birb.setX(birdX);
birb.setY(birdY); birb.setY(birdY);
const movedX = Math.abs(birdX - lastTrackedBirdX);
const movedY = Math.abs(birdY - lastTrackedBirdY);
if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) {
birdPositionDirty = true;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
}
} }
/** /**
@@ -3553,6 +3593,7 @@
if (frozen) { if (frozen) {
return false; return false;
} }
holdRestoredYPosition = false;
const previousElement = focusedElement; const previousElement = focusedElement;
focusedElement = getRandomValidElement(); focusedElement = getRandomValidElement();
updateFocusedElementBounds(); updateFocusedElementBounds();
@@ -3569,6 +3610,7 @@
* @param {number} y * @param {number} y
*/ */
function teleportTo(x, y) { function teleportTo(x, y) {
holdRestoredYPosition = false;
birdX = x; birdX = x;
birdY = y; birdY = y;
setState(States.IDLE); setState(States.IDLE);
@@ -3603,11 +3645,16 @@
focusedBounds = { left, right, top }; focusedBounds = { left, right, top };
} }
function getCanvasWidth() {
return birb.getElementWidth();
}
function hop() { function hop() {
if (frozen) { if (frozen) {
return; return;
} }
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
holdRestoredYPosition = false;
setState(States.HOP); setState(States.HOP);
birb.setAnimation(Animations.FLYING); birb.setAnimation(Animations.FLYING);
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
@@ -3638,6 +3685,7 @@
* @param {number} y * @param {number} y
*/ */
function flyTo(x, y) { function flyTo(x, y) {
holdRestoredYPosition = false;
targetX = x; targetX = x;
targetY = y; targetY = y;
setState(States.FLYING); setState(States.FLYING);
@@ -3667,6 +3715,183 @@
birb.setY(birdY); birb.setY(birdY);
} }
/**
* @param {unknown} value
* @returns {Record<string, SavedBirdPosition>}
*/
function sanitizeSavedBirdPositions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
/** @type {Record<string, SavedBirdPosition>} */
const result = {};
for (const [key, position] of Object.entries(value)) {
if (!position || typeof position !== "object" || Array.isArray(position)) {
continue;
}
// @ts-expect-error
const x = Number(position.x);
// @ts-expect-error
const y = Number(position.y);
// @ts-expect-error
const updatedAt = Number(position.updatedAt ?? 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 };
}
return result;
}
/**
* @param {string} path
* @returns {string}
*/
function normalizePath(path) {
return path.split("?")[0].split("#")[0];
}
function trimSavedBirdPositions() {
const entries = Object.entries(savedBirdPositions);
if (entries.length <= MAX_SAVED_BIRD_POSITIONS) {
return;
}
entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) {
delete savedBirdPositions[entries[i][0]];
}
}
function getBirdPositionScopeKey() {
if (birdSessionKey) {
return birdSessionKey;
}
const existingWindowName = typeof window.name === "string" ? window.name : "";
const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER);
if (markerIndex >= 0) {
const end = existingWindowName.indexOf("|", markerIndex);
birdSessionKey = end >= 0
? existingWindowName.slice(markerIndex, end)
: existingWindowName.slice(markerIndex);
return birdSessionKey;
}
const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`;
try {
window.name = existingWindowName
? `${existingWindowName}|${birdSessionKey}`
: birdSessionKey;
} catch {
// Ignore if the page blocks changing window.name.
}
return birdSessionKey;
}
/**
* @param {boolean} [force]
*/
function saveBirdPosition(force = false) {
if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) {
return;
}
if (!force && !birdPositionDirty) {
return;
}
const now = Date.now();
const scopeKey = getBirdPositionScopeKey();
const previous = savedBirdPositions[scopeKey];
if (!force && previous) {
const movedX = Math.abs(previous.x - birdX);
const movedY = Math.abs(previous.y - birdY);
if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) {
birdPositionDirty = false;
return;
}
}
savedBirdPositions[scopeKey] = {
x: birdX,
y: birdY,
updatedAt: now
};
trimSavedBirdPositions();
birdPositionDirty = false;
save();
}
/**
* @returns {boolean}
*/
function restoreBirdPosition() {
const scopeKey = getBirdPositionScopeKey();
const saved = savedBirdPositions[scopeKey];
if (!saved) {
holdRestoredYPosition = false;
return false;
}
const maxX = Math.max(0, window.innerWidth - getCanvasWidth());
const maxY = getWindowHeight() * 1.5;
birdX = Math.min(Math.max(saved.x, 0), maxX);
birdY = Math.min(Math.max(saved.y, 0), maxY);
// Attempt to keep the bird perched if an element still exists near the saved position.
focusedElement = getElementAtPosition(birdX, birdY);
updateFocusedElementBounds();
holdRestoredYPosition = focusedElement === null;
birdPositionDirty = false;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
setState(States.IDLE);
birb.setX(birdX);
birb.setY(birdY);
return true;
}
/**
* @param {number} x
* @param {number} y
* @returns {HTMLElement|null}
*/
function getElementAtPosition(x, y) {
const desiredTop = getWindowHeight() - y;
let bestElement = null;
let bestScore = Number.POSITIVE_INFINITY;
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
for (const element of elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const xDistance = Math.abs((rect.left + rect.right) / 2 - x);
const yDistance = Math.abs(rect.top - desiredTop);
const score = xDistance + yDistance * 1.5;
if (score < bestScore) {
bestScore = score;
bestElement = element;
}
}
if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) {
return null;
}
return bestElement;
}
// Helper functions // Helper functions
/** /**

View File

@@ -51,6 +51,13 @@ import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js';
* @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote * @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote
*/ */
/**
* @typedef {Object} SavedBirdPosition
* @property {number} x
* @property {number} y
* @property {number} updatedAt
*/
/** /**
* @typedef {Object} BirbSaveData * @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies * @property {string[]} unlockedSpecies
@@ -59,6 +66,7 @@ import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js';
* @property {string} currentHat * @property {string} currentHat
* @property {Partial<Settings>} settings * @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes] * @property {SavedStickyNote[]} [stickyNotes]
* @property {Record<string, SavedBirdPosition>} [birdPositions]
*/ */
/** /**
@@ -118,11 +126,16 @@ const FEATHER_FALL_SPEED = 1;
// Petting boosts // Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2; const PET_FEATHER_BOOST = 2; // Multiplier for feather effect
const PET_HAT_BOOST = 1.5; const PET_HAT_BOOST = 1.5; // Multiplier for hat effect
// Focus element constraints // Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100; const MIN_FOCUS_ELEMENT_WIDTH = 100; // Minimum width (in px) for an element to be considered a valid perch target
const BIRD_POSITION_SAVE_INTERVAL = 2000; // How often (ms) we attempt to persist position in normal flow
const BIRD_POSITION_SAVE_MIN_DELTA = 6; // Minimum movement (px) compared to last saved position required before writing again
const BIRD_POSITION_TRACKING_DELTA = 0.5; // Minimum movement (px) in runtime tracking to mark position as "dirty"
const MAX_SAVED_BIRD_POSITIONS = 200; // Maximum number of saved bird positions to keep
const TAB_SESSION_MARKER = "__pocket_bird_tab_session__="; // Marker used in localStorage to identify which tab session saved bird positions belong to, to prevent restoring positions from a different tab
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
@@ -298,6 +311,13 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
let currentHat = DEFAULT_HAT; let currentHat = DEFAULT_HAT;
// let visible = true; // let visible = true;
let lastPetTimestamp = 0; let lastPetTimestamp = 0;
/** @type {Record<string, SavedBirdPosition>} */
let savedBirdPositions = {};
let holdRestoredYPosition = false;
let birdPositionDirty = false;
let lastTrackedBirdX = birdX;
let lastTrackedBirdY = birdY;
let birdSessionKey = "";
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
let stickyNotes = []; let stickyNotes = [];
@@ -316,6 +336,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT]; unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT; currentHat = saveData.currentHat ?? DEFAULT_HAT;
savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions);
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
@@ -350,6 +371,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
left: note.left left: note.left
})); }));
} }
if (Object.keys(savedBirdPositions).length > 0) {
saveData.birdPositions = savedBirdPositions;
}
getContext().putSaveData(saveData); getContext().putSaveData(saveData);
} }
@@ -437,19 +461,26 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastPath = getContext().getPath().split("?")[0]; let lastPath = normalizePath(getContext().getPath());
setInterval(() => { setInterval(() => {
const currentPath = getContext().getPath().split("?")[0]; const currentPath = normalizePath(getContext().getPath());
if (currentPath !== lastPath) { if (currentPath !== lastPath) {
log("Path changed from '" + lastPath + "' to '" + currentPath + "'"); log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
saveBirdPosition(true);
lastPath = currentPath; lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
restoreBirdPosition();
} }
}, URL_CHECK_INTERVAL); }, URL_CHECK_INTERVAL);
setInterval(update, UPDATE_INTERVAL); setInterval(update, UPDATE_INTERVAL);
setInterval(() => saveBirdPosition(), BIRD_POSITION_SAVE_INTERVAL);
window.addEventListener("pagehide", () => saveBirdPosition(true));
window.addEventListener("beforeunload", () => saveBirdPosition(true));
flyToElement(true); if (!restoreBirdPosition()) {
flyToElement(true);
}
} }
function update() { function update() {
@@ -510,7 +541,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement && !isWithinHorizontalBounds()) {
flyToElement(); flyToElement();
} }
birdY = getFocusedY(); if (focusedElement || !holdRestoredYPosition) {
birdY = getFocusedY();
}
} else if (currentState === States.FLYING) { } else if (currentState === States.FLYING) {
// Fly to target location (even if in the air) // Fly to target location (even if in the air)
if (updateParabolicPath(FLY_SPEED, 2)) { if (updateParabolicPath(FLY_SPEED, 2)) {
@@ -540,6 +573,13 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
// Update HTML element position // Update HTML element position
birb.setX(birdX); birb.setX(birdX);
birb.setY(birdY); birb.setY(birdY);
const movedX = Math.abs(birdX - lastTrackedBirdX);
const movedY = Math.abs(birdY - lastTrackedBirdY);
if (movedX >= BIRD_POSITION_TRACKING_DELTA || movedY >= BIRD_POSITION_TRACKING_DELTA) {
birdPositionDirty = true;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
}
} }
/** /**
@@ -1106,6 +1146,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
if (frozen) { if (frozen) {
return false; return false;
} }
holdRestoredYPosition = false;
const previousElement = focusedElement; const previousElement = focusedElement;
focusedElement = getRandomValidElement(); focusedElement = getRandomValidElement();
updateFocusedElementBounds(); updateFocusedElementBounds();
@@ -1122,6 +1163,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
* @param {number} y * @param {number} y
*/ */
function teleportTo(x, y) { function teleportTo(x, y) {
holdRestoredYPosition = false;
birdX = x; birdX = x;
birdY = y; birdY = y;
setState(States.IDLE); setState(States.IDLE);
@@ -1165,6 +1207,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
return; return;
} }
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
holdRestoredYPosition = false;
setState(States.HOP); setState(States.HOP);
birb.setAnimation(Animations.FLYING); birb.setAnimation(Animations.FLYING);
if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) { if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > focusedBounds.left) || birdX + HOP_DISTANCE > focusedBounds.right) {
@@ -1195,6 +1238,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
* @param {number} y * @param {number} y
*/ */
function flyTo(x, y) { function flyTo(x, y) {
holdRestoredYPosition = false;
targetX = x; targetX = x;
targetY = y; targetY = y;
setState(States.FLYING); setState(States.FLYING);
@@ -1228,6 +1272,183 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
return Math.random() < 0.5; return Math.random() < 0.5;
} }
/**
* @param {unknown} value
* @returns {Record<string, SavedBirdPosition>}
*/
function sanitizeSavedBirdPositions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
/** @type {Record<string, SavedBirdPosition>} */
const result = {};
for (const [key, position] of Object.entries(value)) {
if (!position || typeof position !== "object" || Array.isArray(position)) {
continue;
}
// @ts-expect-error
const x = Number(position.x);
// @ts-expect-error
const y = Number(position.y);
// @ts-expect-error
const updatedAt = Number(position.updatedAt ?? 0);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
result[key] = { x, y, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 };
}
return result;
}
/**
* @param {string} path
* @returns {string}
*/
function normalizePath(path) {
return path.split("?")[0].split("#")[0];
}
function trimSavedBirdPositions() {
const entries = Object.entries(savedBirdPositions);
if (entries.length <= MAX_SAVED_BIRD_POSITIONS) {
return;
}
entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
for (let i = 0; i < entries.length - MAX_SAVED_BIRD_POSITIONS; i++) {
delete savedBirdPositions[entries[i][0]];
}
}
function getBirdPositionScopeKey() {
if (birdSessionKey) {
return birdSessionKey;
}
const existingWindowName = typeof window.name === "string" ? window.name : "";
const markerIndex = existingWindowName.indexOf(TAB_SESSION_MARKER);
if (markerIndex >= 0) {
const end = existingWindowName.indexOf("|", markerIndex);
birdSessionKey = end >= 0
? existingWindowName.slice(markerIndex, end)
: existingWindowName.slice(markerIndex);
return birdSessionKey;
}
const sessionToken = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
birdSessionKey = `${TAB_SESSION_MARKER}${sessionToken}`;
try {
window.name = existingWindowName
? `${existingWindowName}|${birdSessionKey}`
: birdSessionKey;
} catch {
// Ignore if the page blocks changing window.name.
}
return birdSessionKey;
}
/**
* @param {boolean} [force]
*/
function saveBirdPosition(force = false) {
if (!Number.isFinite(birdX) || !Number.isFinite(birdY)) {
return;
}
if (!force && !birdPositionDirty) {
return;
}
const now = Date.now();
const scopeKey = getBirdPositionScopeKey();
const previous = savedBirdPositions[scopeKey];
if (!force && previous) {
const movedX = Math.abs(previous.x - birdX);
const movedY = Math.abs(previous.y - birdY);
if (movedX < BIRD_POSITION_SAVE_MIN_DELTA && movedY < BIRD_POSITION_SAVE_MIN_DELTA) {
birdPositionDirty = false;
return;
}
}
savedBirdPositions[scopeKey] = {
x: birdX,
y: birdY,
updatedAt: now
};
trimSavedBirdPositions();
birdPositionDirty = false;
save();
}
/**
* @returns {boolean}
*/
function restoreBirdPosition() {
const scopeKey = getBirdPositionScopeKey();
const saved = savedBirdPositions[scopeKey];
if (!saved) {
holdRestoredYPosition = false;
return false;
}
const maxX = Math.max(0, window.innerWidth - getCanvasWidth());
const maxY = getWindowHeight() * 1.5;
birdX = Math.min(Math.max(saved.x, 0), maxX);
birdY = Math.min(Math.max(saved.y, 0), maxY);
// Attempt to keep the bird perched if an element still exists near the saved position.
focusedElement = getElementAtPosition(birdX, birdY);
updateFocusedElementBounds();
holdRestoredYPosition = focusedElement === null;
birdPositionDirty = false;
lastTrackedBirdX = birdX;
lastTrackedBirdY = birdY;
setState(States.IDLE);
birb.setX(birdX);
birb.setY(birdY);
return true;
}
/**
* @param {number} x
* @param {number} y
* @returns {HTMLElement|null}
*/
function getElementAtPosition(x, y) {
const desiredTop = getWindowHeight() - y;
let bestElement = null;
let bestScore = Number.POSITIVE_INFINITY;
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
for (const element of elements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.offsetWidth < MIN_FOCUS_ELEMENT_WIDTH) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const xDistance = Math.abs((rect.left + rect.right) / 2 - x);
const yDistance = Math.abs(rect.top - desiredTop);
const score = xDistance + yDistance * 1.5;
if (score < bestScore) {
bestScore = score;
bestElement = element;
}
}
if (bestScore > Math.max(window.innerWidth, getWindowHeight()) * 0.75) {
return null;
}
return bestElement;
}
// Helper functions // Helper functions
/** /**
@@ -1256,4 +1477,3 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
init(); init();
draw(); draw();
} }