diff --git a/dist/extension.zip b/dist/extension.zip index a087c47..88c37ae 100644 Binary files a/dist/extension.zip and b/dist/extension.zip differ diff --git a/dist/extension/birb.js b/dist/extension/birb.js index 24d6ef6..cecc246 100644 --- a/dist/extension/birb.js +++ b/dist/extension/birb.js @@ -629,6 +629,7 @@ FLOWER_HAT: "flower-hat" }; + /** @type {{ [hatId: string]: { name: string, description: string } }} */ const HAT_METADATA = { [HAT.NONE]: { name: "Invisible Hat", @@ -684,62 +685,108 @@ } const index = i - 1; const hatKey = HAT[hatName]; - const hatLayer = buildHatLayer(spriteSheet, hatKey, index, false); - const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, false, 1); + const hatLayer = buildHatLayer(spriteSheet, hatKey, index); + const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, 1); hatLayers.base.push(hatLayer); hatLayers.down.push(downHatLayer); } return hatLayers; } + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Anim} + */ + function createHatItemAnimation(hatId, spriteSheet) { + const hatLayer = buildHatItemLayer(spriteSheet, hatId); + const frames = [ + new Frame([hatLayer]) + ]; + return new Anim(frames, [1000], true); + } + /** * @param {string[][]} spriteSheet * @param {string} hatName * @param {number} hatIndex - * @param {boolean} [outlineBottom=false] * @param {number} [yOffset=0] * @returns {Layer} */ - function buildHatLayer(spriteSheet, hatName, hatIndex, outlineBottom = false, yOffset = 0) { + function buildHatLayer(spriteSheet, hatName, hatIndex, yOffset = 0) { const LEFT_PADDING = 6; const RIGHT_PADDING = 14; const TOP_PADDING = 5 + yOffset; const BOTTOM_PADDING = Math.max(0, 15 - yOffset); - const hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); - const paddedHatPixels = []; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, TOP_PADDING, BOTTOM_PADDING, LEFT_PADDING, RIGHT_PADDING); + hatPixels = drawOutline(hatPixels, false); + return new Layer(hatPixels, hatName); + } + + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Layer} + */ + function buildHatItemLayer(spriteSheet, hatId) { + if (hatId === HAT.NONE) { + return new Layer([], TAG.DEFAULT); + } + const hatIndex = Object.keys(HAT).indexOf(hatId) - 1; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, 1, 1, 1, 1); + hatPixels = drawOutline(hatPixels, true); + hatPixels = pushToBottom(hatPixels); + return new Layer(hatPixels, TAG.DEFAULT); + } + + /** + * Add transparent padding around the pixel array + * @param {string[][]} pixels + * @param {number} top + * @param {number} bottom + * @param {number} left + * @param {number} right + * @returns {string[][]} + */ + function pad(pixels, top, bottom, left, right) { + const paddedPixels = []; + const rowLength = pixels[0].length + left + right; // Top padding - for (let y = 0; y < TOP_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < top; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } // Left and right padding - for (let y = 0; y < hatPixels.length; y++) { + for (let y = 0; y < pixels.length; y++) { const row = []; - for (let x = 0; x < LEFT_PADDING; x++) { + for (let x = 0; x < left; x++) { row.push(PALETTE.TRANSPARENT); } - - for (let x = 0; x < hatPixels[y].length; x++) { - row.push(hatPixels[y][x]); + for (let x = 0; x < pixels[y].length; x++) { + row.push(pixels[y][x]); } - - for (let x = 0; x < RIGHT_PADDING; x++) { + for (let x = 0; x < right; x++) { row.push(PALETTE.TRANSPARENT); } - - paddedHatPixels.push(row); + paddedPixels.push(row); } // Bottom padding - for (let y = 0; y < BOTTOM_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < bottom; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } + return paddedPixels; + } - // Add outline + /** + * Draw an outline around non-transparent pixels + * @param {string[][]} pixels + * @param {boolean} [outlineBottom=false] + * @return {string[][]} + */ + function drawOutline(pixels, outlineBottom = false) { let neighborOffsets = [ [-1, 0], [1, 0], @@ -750,21 +797,42 @@ if (outlineBottom) { neighborOffsets.push([0, 1], [-1, 1], [1, 1]); } - for (let y = 0; y < paddedHatPixels.length; y++) { - for (let x = 0; x < paddedHatPixels[y].length; x++) { - const pixel = paddedHatPixels[y][x]; + for (let y = 0; y < pixels.length; y++) { + for (let x = 0; x < pixels[y].length; x++) { + const pixel = pixels[y][x]; if (pixel !== PALETTE.TRANSPARENT && pixel !== PALETTE.BORDER) { for (let [dx, dy] of neighborOffsets) { const newX = x + dx; const newY = y + dy; - if (newY >= 0 && newY < paddedHatPixels.length && newX >= 0 && newX < paddedHatPixels[newY].length && paddedHatPixels[newY][newX] === PALETTE.TRANSPARENT) { - paddedHatPixels[newY][newX] = PALETTE.BORDER; + if (newY >= 0 && newY < pixels.length && newX >= 0 && newX < pixels[newY].length && pixels[newY][newX] === PALETTE.TRANSPARENT) { + pixels[newY][newX] = PALETTE.BORDER; } } } } } - return new Layer(paddedHatPixels, hatName); + return pixels; + } + + /** + * Trim transparent rows from the bottom and push them to the top + * @param {string[][]} pixels + * @returns {string[][]} + */ + function pushToBottom(pixels) { + let trimmedPixels = pixels.slice(); + let trimCount = 0; + while (trimmedPixels.length > 1) { + const firstRow = trimmedPixels[trimmedPixels.length - 1]; + if (firstRow.every(pixel => pixel === PALETTE.TRANSPARENT)) { + trimmedPixels.pop(); + trimCount++; + } else { + break; + } + } + trimmedPixels = pad(trimmedPixels, trimCount, 0, 0, 0); + return trimmedPixels; } /** @@ -1602,6 +1670,16 @@ z-index: 2147483630 !important; } +.birb-item { + image-rendering: pixelated; + position: absolute; + bottom: 0; + transform-origin: bottom; + transform: scale(calc(var(--birb-scale) * 1.5)) !important; + transform-origin: bottom; + z-index: 2147483630 !important; +} + .birb-window { font-family: "Monocraft", monospace !important; line-height: initial !important; @@ -1950,6 +2028,7 @@ const FIELD_GUIDE_ID = "birb-field-guide"; const FEATHER_ID = "birb-feather"; const WARDROBE_ID = "birb-wardrobe"; + const HAT_ID = "birb-hat"; const DEFAULT_BIRD = "bluebird"; const DEFAULT_HAT = HAT.NONE; @@ -2068,7 +2147,7 @@ insertModal(`${birdBirb()} Mode`, message); }), new Separator(), - new MenuItem("2026.1.20", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.20"); }, false), + new MenuItem("2026.1.21", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.21"); }, false), ]; const styleElement = document.createElement("style"); @@ -2257,6 +2336,9 @@ setInterval(update, UPDATE_INTERVAL); focusOnElement(true); + + // TODO: This is for testing + generateHat(); } function update() { @@ -2437,6 +2519,47 @@ } } + /** + * Insert the hat as an item element in the document if possible + */ + function generateHat() { + if (document.querySelector("#" + HAT_ID)) { + return; + } + // Select a random hat + const hatKeys = Object.keys(HAT); + const hatId = hatKeys[Math.floor(Math.random() * (hatKeys.length - 1)) + 1]; + + // Find a random valid element to place the hat on + const element = getRandomValidElement(); + if (!element) { + return; + } + + // Create hat element + const hatCanvas = document.createElement("canvas"); + hatCanvas.id = HAT_ID; + hatCanvas.classList.add("birb-item"); + hatCanvas.width = 14 * CANVAS_PIXEL_SIZE; + hatCanvas.height = 14 * CANVAS_PIXEL_SIZE; + const hatCtx = hatCanvas.getContext("2d"); + if (!hatCtx) { + return; + } + + // Create hat animation + const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET); + hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]); + + // Position hat above the element + const rect = element.getBoundingClientRect(); + hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px"; + hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px"; + + // Append to document + document.body.appendChild(hatCanvas); + } + /** * @param {string} birdType */ @@ -2768,14 +2891,9 @@ } /** - * Focus on an element within the viewport - * @param {boolean} [teleport] Whether to teleport to the element instead of flying - * @returns Whether an element to focus on was found + * @returns {HTMLElement|null} The random element, or null if no valid element was found */ - function focusOnElement(teleport = false) { - if (frozen) { - return false; - } + function getRandomValidElement() { const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin(); const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); const inWindow = Array.from(elements).filter((img) => { @@ -2797,10 +2915,22 @@ } }); if (nonFixedElements.length === 0) { - return false; + return null; } const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)]; - focusedElement = randomElement; + return randomElement; + } + + /** + * Focus on an element within the viewport + * @param {boolean} [teleport] Whether to teleport to the element instead of flying + * @returns Whether an element to focus on was found + */ + function focusOnElement(teleport = false) { + if (frozen) { + return false; + } + focusedElement = getRandomValidElement(); log("Focusing on element: ", focusedElement); updateFocusedElementBounds(); if (teleport) { @@ -2808,7 +2938,7 @@ } else { flyTo(getFocusedElementRandomX(), getFocusedY()); } - return randomElement !== null; + return focusedElement !== null; } /** diff --git a/dist/extension/manifest.json b/dist/extension/manifest.json index 713ccb1..283d052 100644 --- a/dist/extension/manifest.json +++ b/dist/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Pocket Bird", "description": "It's a pet bird in your browser, what more could you want?", - "version": "2026.1.20", + "version": "2026.1.21", "homepage_url": "https://idreesinc.com", "icons": { "48": "images/icons/transparent/48x48x1.png", diff --git a/dist/obsidian/main.js b/dist/obsidian/main.js index dbe0fe1..4b1fd4c 100644 --- a/dist/obsidian/main.js +++ b/dist/obsidian/main.js @@ -1,7 +1,7 @@ const { Plugin, Notice } = require('obsidian'); module.exports = class PocketBird extends Plugin { onload() { - console.log("Loading Pocket Bird version 2026.1.20..."); + console.log("Loading Pocket Bird version 2026.1.21..."); const OBSIDIAN_PLUGIN = this; (function () { 'use strict'; @@ -634,6 +634,7 @@ module.exports = class PocketBird extends Plugin { FLOWER_HAT: "flower-hat" }; + /** @type {{ [hatId: string]: { name: string, description: string } }} */ const HAT_METADATA = { [HAT.NONE]: { name: "Invisible Hat", @@ -689,62 +690,108 @@ module.exports = class PocketBird extends Plugin { } const index = i - 1; const hatKey = HAT[hatName]; - const hatLayer = buildHatLayer(spriteSheet, hatKey, index, false); - const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, false, 1); + const hatLayer = buildHatLayer(spriteSheet, hatKey, index); + const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, 1); hatLayers.base.push(hatLayer); hatLayers.down.push(downHatLayer); } return hatLayers; } + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Anim} + */ + function createHatItemAnimation(hatId, spriteSheet) { + const hatLayer = buildHatItemLayer(spriteSheet, hatId); + const frames = [ + new Frame([hatLayer]) + ]; + return new Anim(frames, [1000], true); + } + /** * @param {string[][]} spriteSheet * @param {string} hatName * @param {number} hatIndex - * @param {boolean} [outlineBottom=false] * @param {number} [yOffset=0] * @returns {Layer} */ - function buildHatLayer(spriteSheet, hatName, hatIndex, outlineBottom = false, yOffset = 0) { + function buildHatLayer(spriteSheet, hatName, hatIndex, yOffset = 0) { const LEFT_PADDING = 6; const RIGHT_PADDING = 14; const TOP_PADDING = 5 + yOffset; const BOTTOM_PADDING = Math.max(0, 15 - yOffset); - const hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); - const paddedHatPixels = []; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, TOP_PADDING, BOTTOM_PADDING, LEFT_PADDING, RIGHT_PADDING); + hatPixels = drawOutline(hatPixels, false); + return new Layer(hatPixels, hatName); + } + + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Layer} + */ + function buildHatItemLayer(spriteSheet, hatId) { + if (hatId === HAT.NONE) { + return new Layer([], TAG.DEFAULT); + } + const hatIndex = Object.keys(HAT).indexOf(hatId) - 1; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, 1, 1, 1, 1); + hatPixels = drawOutline(hatPixels, true); + hatPixels = pushToBottom(hatPixels); + return new Layer(hatPixels, TAG.DEFAULT); + } + + /** + * Add transparent padding around the pixel array + * @param {string[][]} pixels + * @param {number} top + * @param {number} bottom + * @param {number} left + * @param {number} right + * @returns {string[][]} + */ + function pad(pixels, top, bottom, left, right) { + const paddedPixels = []; + const rowLength = pixels[0].length + left + right; // Top padding - for (let y = 0; y < TOP_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < top; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } // Left and right padding - for (let y = 0; y < hatPixels.length; y++) { + for (let y = 0; y < pixels.length; y++) { const row = []; - for (let x = 0; x < LEFT_PADDING; x++) { + for (let x = 0; x < left; x++) { row.push(PALETTE.TRANSPARENT); } - - for (let x = 0; x < hatPixels[y].length; x++) { - row.push(hatPixels[y][x]); + for (let x = 0; x < pixels[y].length; x++) { + row.push(pixels[y][x]); } - - for (let x = 0; x < RIGHT_PADDING; x++) { + for (let x = 0; x < right; x++) { row.push(PALETTE.TRANSPARENT); } - - paddedHatPixels.push(row); + paddedPixels.push(row); } // Bottom padding - for (let y = 0; y < BOTTOM_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < bottom; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } + return paddedPixels; + } - // Add outline + /** + * Draw an outline around non-transparent pixels + * @param {string[][]} pixels + * @param {boolean} [outlineBottom=false] + * @return {string[][]} + */ + function drawOutline(pixels, outlineBottom = false) { let neighborOffsets = [ [-1, 0], [1, 0], @@ -755,21 +802,42 @@ module.exports = class PocketBird extends Plugin { if (outlineBottom) { neighborOffsets.push([0, 1], [-1, 1], [1, 1]); } - for (let y = 0; y < paddedHatPixels.length; y++) { - for (let x = 0; x < paddedHatPixels[y].length; x++) { - const pixel = paddedHatPixels[y][x]; + for (let y = 0; y < pixels.length; y++) { + for (let x = 0; x < pixels[y].length; x++) { + const pixel = pixels[y][x]; if (pixel !== PALETTE.TRANSPARENT && pixel !== PALETTE.BORDER) { for (let [dx, dy] of neighborOffsets) { const newX = x + dx; const newY = y + dy; - if (newY >= 0 && newY < paddedHatPixels.length && newX >= 0 && newX < paddedHatPixels[newY].length && paddedHatPixels[newY][newX] === PALETTE.TRANSPARENT) { - paddedHatPixels[newY][newX] = PALETTE.BORDER; + if (newY >= 0 && newY < pixels.length && newX >= 0 && newX < pixels[newY].length && pixels[newY][newX] === PALETTE.TRANSPARENT) { + pixels[newY][newX] = PALETTE.BORDER; } } } } } - return new Layer(paddedHatPixels, hatName); + return pixels; + } + + /** + * Trim transparent rows from the bottom and push them to the top + * @param {string[][]} pixels + * @returns {string[][]} + */ + function pushToBottom(pixels) { + let trimmedPixels = pixels.slice(); + let trimCount = 0; + while (trimmedPixels.length > 1) { + const firstRow = trimmedPixels[trimmedPixels.length - 1]; + if (firstRow.every(pixel => pixel === PALETTE.TRANSPARENT)) { + trimmedPixels.pop(); + trimCount++; + } else { + break; + } + } + trimmedPixels = pad(trimmedPixels, trimCount, 0, 0, 0); + return trimmedPixels; } /** @@ -1645,6 +1713,16 @@ module.exports = class PocketBird extends Plugin { z-index: 2147483630 !important; } +.birb-item { + image-rendering: pixelated; + position: absolute; + bottom: 0; + transform-origin: bottom; + transform: scale(calc(var(--birb-scale) * 1.5)) !important; + transform-origin: bottom; + z-index: 2147483630 !important; +} + .birb-window { font-family: "Monocraft", monospace !important; line-height: initial !important; @@ -1993,6 +2071,7 @@ module.exports = class PocketBird extends Plugin { const FIELD_GUIDE_ID = "birb-field-guide"; const FEATHER_ID = "birb-feather"; const WARDROBE_ID = "birb-wardrobe"; + const HAT_ID = "birb-hat"; const DEFAULT_BIRD = "bluebird"; const DEFAULT_HAT = HAT.NONE; @@ -2111,7 +2190,7 @@ module.exports = class PocketBird extends Plugin { insertModal(`${birdBirb()} Mode`, message); }), new Separator(), - new MenuItem("2026.1.20", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.20"); }, false), + new MenuItem("2026.1.21", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.21"); }, false), ]; const styleElement = document.createElement("style"); @@ -2300,6 +2379,9 @@ module.exports = class PocketBird extends Plugin { setInterval(update, UPDATE_INTERVAL); focusOnElement(true); + + // TODO: This is for testing + generateHat(); } function update() { @@ -2480,6 +2562,47 @@ module.exports = class PocketBird extends Plugin { } } + /** + * Insert the hat as an item element in the document if possible + */ + function generateHat() { + if (document.querySelector("#" + HAT_ID)) { + return; + } + // Select a random hat + const hatKeys = Object.keys(HAT); + const hatId = hatKeys[Math.floor(Math.random() * (hatKeys.length - 1)) + 1]; + + // Find a random valid element to place the hat on + const element = getRandomValidElement(); + if (!element) { + return; + } + + // Create hat element + const hatCanvas = document.createElement("canvas"); + hatCanvas.id = HAT_ID; + hatCanvas.classList.add("birb-item"); + hatCanvas.width = 14 * CANVAS_PIXEL_SIZE; + hatCanvas.height = 14 * CANVAS_PIXEL_SIZE; + const hatCtx = hatCanvas.getContext("2d"); + if (!hatCtx) { + return; + } + + // Create hat animation + const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET); + hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]); + + // Position hat above the element + const rect = element.getBoundingClientRect(); + hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px"; + hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px"; + + // Append to document + document.body.appendChild(hatCanvas); + } + /** * @param {string} birdType */ @@ -2811,14 +2934,9 @@ module.exports = class PocketBird extends Plugin { } /** - * Focus on an element within the viewport - * @param {boolean} [teleport] Whether to teleport to the element instead of flying - * @returns Whether an element to focus on was found + * @returns {HTMLElement|null} The random element, or null if no valid element was found */ - function focusOnElement(teleport = false) { - if (frozen) { - return false; - } + function getRandomValidElement() { const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin(); const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); const inWindow = Array.from(elements).filter((img) => { @@ -2840,10 +2958,22 @@ module.exports = class PocketBird extends Plugin { } }); if (nonFixedElements.length === 0) { - return false; + return null; } const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)]; - focusedElement = randomElement; + return randomElement; + } + + /** + * Focus on an element within the viewport + * @param {boolean} [teleport] Whether to teleport to the element instead of flying + * @returns Whether an element to focus on was found + */ + function focusOnElement(teleport = false) { + if (frozen) { + return false; + } + focusedElement = getRandomValidElement(); log("Focusing on element: ", focusedElement); updateFocusedElementBounds(); if (teleport) { @@ -2851,7 +2981,7 @@ module.exports = class PocketBird extends Plugin { } else { flyTo(getFocusedElementRandomX(), getFocusedY()); } - return randomElement !== null; + return focusedElement !== null; } /** diff --git a/dist/obsidian/manifest.json b/dist/obsidian/manifest.json index cb12fa6..662f18c 100644 --- a/dist/obsidian/manifest.json +++ b/dist/obsidian/manifest.json @@ -1,7 +1,7 @@ { "id": "pocket-bird", "name": "Pocket Bird", - "version": "2026.1.20", + "version": "2026.1.21", "minAppVersion": "0.15.0", "description": "Add a pet bird to fly around your notes and keep you company!", "author": "Idrees Hassan", diff --git a/dist/userscript/birb.user.js b/dist/userscript/birb.user.js index a42b11e..3a33416 100644 --- a/dist/userscript/birb.user.js +++ b/dist/userscript/birb.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Pocket Bird // @namespace https://idreesinc.com -// @version 2026.1.20 +// @version 2026.1.21 // @description It's a pet bird in your browser, what more could you want? // @author Idrees // @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js @@ -643,6 +643,7 @@ FLOWER_HAT: "flower-hat" }; + /** @type {{ [hatId: string]: { name: string, description: string } }} */ const HAT_METADATA = { [HAT.NONE]: { name: "Invisible Hat", @@ -698,62 +699,108 @@ } const index = i - 1; const hatKey = HAT[hatName]; - const hatLayer = buildHatLayer(spriteSheet, hatKey, index, false); - const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, false, 1); + const hatLayer = buildHatLayer(spriteSheet, hatKey, index); + const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, 1); hatLayers.base.push(hatLayer); hatLayers.down.push(downHatLayer); } return hatLayers; } + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Anim} + */ + function createHatItemAnimation(hatId, spriteSheet) { + const hatLayer = buildHatItemLayer(spriteSheet, hatId); + const frames = [ + new Frame([hatLayer]) + ]; + return new Anim(frames, [1000], true); + } + /** * @param {string[][]} spriteSheet * @param {string} hatName * @param {number} hatIndex - * @param {boolean} [outlineBottom=false] * @param {number} [yOffset=0] * @returns {Layer} */ - function buildHatLayer(spriteSheet, hatName, hatIndex, outlineBottom = false, yOffset = 0) { + function buildHatLayer(spriteSheet, hatName, hatIndex, yOffset = 0) { const LEFT_PADDING = 6; const RIGHT_PADDING = 14; const TOP_PADDING = 5 + yOffset; const BOTTOM_PADDING = Math.max(0, 15 - yOffset); - const hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); - const paddedHatPixels = []; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, TOP_PADDING, BOTTOM_PADDING, LEFT_PADDING, RIGHT_PADDING); + hatPixels = drawOutline(hatPixels, false); + return new Layer(hatPixels, hatName); + } + + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Layer} + */ + function buildHatItemLayer(spriteSheet, hatId) { + if (hatId === HAT.NONE) { + return new Layer([], TAG.DEFAULT); + } + const hatIndex = Object.keys(HAT).indexOf(hatId) - 1; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, 1, 1, 1, 1); + hatPixels = drawOutline(hatPixels, true); + hatPixels = pushToBottom(hatPixels); + return new Layer(hatPixels, TAG.DEFAULT); + } + + /** + * Add transparent padding around the pixel array + * @param {string[][]} pixels + * @param {number} top + * @param {number} bottom + * @param {number} left + * @param {number} right + * @returns {string[][]} + */ + function pad(pixels, top, bottom, left, right) { + const paddedPixels = []; + const rowLength = pixels[0].length + left + right; // Top padding - for (let y = 0; y < TOP_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < top; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } // Left and right padding - for (let y = 0; y < hatPixels.length; y++) { + for (let y = 0; y < pixels.length; y++) { const row = []; - for (let x = 0; x < LEFT_PADDING; x++) { + for (let x = 0; x < left; x++) { row.push(PALETTE.TRANSPARENT); } - - for (let x = 0; x < hatPixels[y].length; x++) { - row.push(hatPixels[y][x]); + for (let x = 0; x < pixels[y].length; x++) { + row.push(pixels[y][x]); } - - for (let x = 0; x < RIGHT_PADDING; x++) { + for (let x = 0; x < right; x++) { row.push(PALETTE.TRANSPARENT); } - - paddedHatPixels.push(row); + paddedPixels.push(row); } // Bottom padding - for (let y = 0; y < BOTTOM_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < bottom; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } + return paddedPixels; + } - // Add outline + /** + * Draw an outline around non-transparent pixels + * @param {string[][]} pixels + * @param {boolean} [outlineBottom=false] + * @return {string[][]} + */ + function drawOutline(pixels, outlineBottom = false) { let neighborOffsets = [ [-1, 0], [1, 0], @@ -764,21 +811,42 @@ if (outlineBottom) { neighborOffsets.push([0, 1], [-1, 1], [1, 1]); } - for (let y = 0; y < paddedHatPixels.length; y++) { - for (let x = 0; x < paddedHatPixels[y].length; x++) { - const pixel = paddedHatPixels[y][x]; + for (let y = 0; y < pixels.length; y++) { + for (let x = 0; x < pixels[y].length; x++) { + const pixel = pixels[y][x]; if (pixel !== PALETTE.TRANSPARENT && pixel !== PALETTE.BORDER) { for (let [dx, dy] of neighborOffsets) { const newX = x + dx; const newY = y + dy; - if (newY >= 0 && newY < paddedHatPixels.length && newX >= 0 && newX < paddedHatPixels[newY].length && paddedHatPixels[newY][newX] === PALETTE.TRANSPARENT) { - paddedHatPixels[newY][newX] = PALETTE.BORDER; + if (newY >= 0 && newY < pixels.length && newX >= 0 && newX < pixels[newY].length && pixels[newY][newX] === PALETTE.TRANSPARENT) { + pixels[newY][newX] = PALETTE.BORDER; } } } } } - return new Layer(paddedHatPixels, hatName); + return pixels; + } + + /** + * Trim transparent rows from the bottom and push them to the top + * @param {string[][]} pixels + * @returns {string[][]} + */ + function pushToBottom(pixels) { + let trimmedPixels = pixels.slice(); + let trimCount = 0; + while (trimmedPixels.length > 1) { + const firstRow = trimmedPixels[trimmedPixels.length - 1]; + if (firstRow.every(pixel => pixel === PALETTE.TRANSPARENT)) { + trimmedPixels.pop(); + trimCount++; + } else { + break; + } + } + trimmedPixels = pad(trimmedPixels, trimCount, 0, 0, 0); + return trimmedPixels; } /** @@ -1607,6 +1675,16 @@ z-index: 2147483630 !important; } +.birb-item { + image-rendering: pixelated; + position: absolute; + bottom: 0; + transform-origin: bottom; + transform: scale(calc(var(--birb-scale) * 1.5)) !important; + transform-origin: bottom; + z-index: 2147483630 !important; +} + .birb-window { font-family: "Monocraft", monospace !important; line-height: initial !important; @@ -1955,6 +2033,7 @@ const FIELD_GUIDE_ID = "birb-field-guide"; const FEATHER_ID = "birb-feather"; const WARDROBE_ID = "birb-wardrobe"; + const HAT_ID = "birb-hat"; const DEFAULT_BIRD = "bluebird"; const DEFAULT_HAT = HAT.NONE; @@ -2073,7 +2152,7 @@ insertModal(`${birdBirb()} Mode`, message); }), new Separator(), - new MenuItem("2026.1.20", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.20"); }, false), + new MenuItem("2026.1.21", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.21"); }, false), ]; const styleElement = document.createElement("style"); @@ -2262,6 +2341,9 @@ setInterval(update, UPDATE_INTERVAL); focusOnElement(true); + + // TODO: This is for testing + generateHat(); } function update() { @@ -2442,6 +2524,47 @@ } } + /** + * Insert the hat as an item element in the document if possible + */ + function generateHat() { + if (document.querySelector("#" + HAT_ID)) { + return; + } + // Select a random hat + const hatKeys = Object.keys(HAT); + const hatId = hatKeys[Math.floor(Math.random() * (hatKeys.length - 1)) + 1]; + + // Find a random valid element to place the hat on + const element = getRandomValidElement(); + if (!element) { + return; + } + + // Create hat element + const hatCanvas = document.createElement("canvas"); + hatCanvas.id = HAT_ID; + hatCanvas.classList.add("birb-item"); + hatCanvas.width = 14 * CANVAS_PIXEL_SIZE; + hatCanvas.height = 14 * CANVAS_PIXEL_SIZE; + const hatCtx = hatCanvas.getContext("2d"); + if (!hatCtx) { + return; + } + + // Create hat animation + const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET); + hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]); + + // Position hat above the element + const rect = element.getBoundingClientRect(); + hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px"; + hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px"; + + // Append to document + document.body.appendChild(hatCanvas); + } + /** * @param {string} birdType */ @@ -2773,14 +2896,9 @@ } /** - * Focus on an element within the viewport - * @param {boolean} [teleport] Whether to teleport to the element instead of flying - * @returns Whether an element to focus on was found + * @returns {HTMLElement|null} The random element, or null if no valid element was found */ - function focusOnElement(teleport = false) { - if (frozen) { - return false; - } + function getRandomValidElement() { const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin(); const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); const inWindow = Array.from(elements).filter((img) => { @@ -2802,10 +2920,22 @@ } }); if (nonFixedElements.length === 0) { - return false; + return null; } const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)]; - focusedElement = randomElement; + return randomElement; + } + + /** + * Focus on an element within the viewport + * @param {boolean} [teleport] Whether to teleport to the element instead of flying + * @returns Whether an element to focus on was found + */ + function focusOnElement(teleport = false) { + if (frozen) { + return false; + } + focusedElement = getRandomValidElement(); log("Focusing on element: ", focusedElement); updateFocusedElementBounds(); if (teleport) { @@ -2813,7 +2943,7 @@ } else { flyTo(getFocusedElementRandomX(), getFocusedY()); } - return randomElement !== null; + return focusedElement !== null; } /** diff --git a/dist/web/birb.embed.js b/dist/web/birb.embed.js index a77d732..5992b61 100644 --- a/dist/web/birb.embed.js +++ b/dist/web/birb.embed.js @@ -629,6 +629,7 @@ FLOWER_HAT: "flower-hat" }; + /** @type {{ [hatId: string]: { name: string, description: string } }} */ const HAT_METADATA = { [HAT.NONE]: { name: "Invisible Hat", @@ -684,62 +685,108 @@ } const index = i - 1; const hatKey = HAT[hatName]; - const hatLayer = buildHatLayer(spriteSheet, hatKey, index, false); - const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, false, 1); + const hatLayer = buildHatLayer(spriteSheet, hatKey, index); + const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, 1); hatLayers.base.push(hatLayer); hatLayers.down.push(downHatLayer); } return hatLayers; } + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Anim} + */ + function createHatItemAnimation(hatId, spriteSheet) { + const hatLayer = buildHatItemLayer(spriteSheet, hatId); + const frames = [ + new Frame([hatLayer]) + ]; + return new Anim(frames, [1000], true); + } + /** * @param {string[][]} spriteSheet * @param {string} hatName * @param {number} hatIndex - * @param {boolean} [outlineBottom=false] * @param {number} [yOffset=0] * @returns {Layer} */ - function buildHatLayer(spriteSheet, hatName, hatIndex, outlineBottom = false, yOffset = 0) { + function buildHatLayer(spriteSheet, hatName, hatIndex, yOffset = 0) { const LEFT_PADDING = 6; const RIGHT_PADDING = 14; const TOP_PADDING = 5 + yOffset; const BOTTOM_PADDING = Math.max(0, 15 - yOffset); - const hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); - const paddedHatPixels = []; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, TOP_PADDING, BOTTOM_PADDING, LEFT_PADDING, RIGHT_PADDING); + hatPixels = drawOutline(hatPixels, false); + return new Layer(hatPixels, hatName); + } + + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Layer} + */ + function buildHatItemLayer(spriteSheet, hatId) { + if (hatId === HAT.NONE) { + return new Layer([], TAG.DEFAULT); + } + const hatIndex = Object.keys(HAT).indexOf(hatId) - 1; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, 1, 1, 1, 1); + hatPixels = drawOutline(hatPixels, true); + hatPixels = pushToBottom(hatPixels); + return new Layer(hatPixels, TAG.DEFAULT); + } + + /** + * Add transparent padding around the pixel array + * @param {string[][]} pixels + * @param {number} top + * @param {number} bottom + * @param {number} left + * @param {number} right + * @returns {string[][]} + */ + function pad(pixels, top, bottom, left, right) { + const paddedPixels = []; + const rowLength = pixels[0].length + left + right; // Top padding - for (let y = 0; y < TOP_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < top; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } // Left and right padding - for (let y = 0; y < hatPixels.length; y++) { + for (let y = 0; y < pixels.length; y++) { const row = []; - for (let x = 0; x < LEFT_PADDING; x++) { + for (let x = 0; x < left; x++) { row.push(PALETTE.TRANSPARENT); } - - for (let x = 0; x < hatPixels[y].length; x++) { - row.push(hatPixels[y][x]); + for (let x = 0; x < pixels[y].length; x++) { + row.push(pixels[y][x]); } - - for (let x = 0; x < RIGHT_PADDING; x++) { + for (let x = 0; x < right; x++) { row.push(PALETTE.TRANSPARENT); } - - paddedHatPixels.push(row); + paddedPixels.push(row); } // Bottom padding - for (let y = 0; y < BOTTOM_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < bottom; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } + return paddedPixels; + } - // Add outline + /** + * Draw an outline around non-transparent pixels + * @param {string[][]} pixels + * @param {boolean} [outlineBottom=false] + * @return {string[][]} + */ + function drawOutline(pixels, outlineBottom = false) { let neighborOffsets = [ [-1, 0], [1, 0], @@ -750,21 +797,42 @@ if (outlineBottom) { neighborOffsets.push([0, 1], [-1, 1], [1, 1]); } - for (let y = 0; y < paddedHatPixels.length; y++) { - for (let x = 0; x < paddedHatPixels[y].length; x++) { - const pixel = paddedHatPixels[y][x]; + for (let y = 0; y < pixels.length; y++) { + for (let x = 0; x < pixels[y].length; x++) { + const pixel = pixels[y][x]; if (pixel !== PALETTE.TRANSPARENT && pixel !== PALETTE.BORDER) { for (let [dx, dy] of neighborOffsets) { const newX = x + dx; const newY = y + dy; - if (newY >= 0 && newY < paddedHatPixels.length && newX >= 0 && newX < paddedHatPixels[newY].length && paddedHatPixels[newY][newX] === PALETTE.TRANSPARENT) { - paddedHatPixels[newY][newX] = PALETTE.BORDER; + if (newY >= 0 && newY < pixels.length && newX >= 0 && newX < pixels[newY].length && pixels[newY][newX] === PALETTE.TRANSPARENT) { + pixels[newY][newX] = PALETTE.BORDER; } } } } } - return new Layer(paddedHatPixels, hatName); + return pixels; + } + + /** + * Trim transparent rows from the bottom and push them to the top + * @param {string[][]} pixels + * @returns {string[][]} + */ + function pushToBottom(pixels) { + let trimmedPixels = pixels.slice(); + let trimCount = 0; + while (trimmedPixels.length > 1) { + const firstRow = trimmedPixels[trimmedPixels.length - 1]; + if (firstRow.every(pixel => pixel === PALETTE.TRANSPARENT)) { + trimmedPixels.pop(); + trimCount++; + } else { + break; + } + } + trimmedPixels = pad(trimmedPixels, trimCount, 0, 0, 0); + return trimmedPixels; } /** @@ -1587,6 +1655,16 @@ z-index: 2147483630 !important; } +.birb-item { + image-rendering: pixelated; + position: absolute; + bottom: 0; + transform-origin: bottom; + transform: scale(calc(var(--birb-scale) * 1.5)) !important; + transform-origin: bottom; + z-index: 2147483630 !important; +} + .birb-window { font-family: "Monocraft", monospace !important; line-height: initial !important; @@ -1935,6 +2013,7 @@ const FIELD_GUIDE_ID = "birb-field-guide"; const FEATHER_ID = "birb-feather"; const WARDROBE_ID = "birb-wardrobe"; + const HAT_ID = "birb-hat"; const DEFAULT_BIRD = "bluebird"; const DEFAULT_HAT = HAT.NONE; @@ -2053,7 +2132,7 @@ insertModal(`${birdBirb()} Mode`, message); }), new Separator(), - new MenuItem("2026.1.20", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.20"); }, false), + new MenuItem("2026.1.21", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.21"); }, false), ]; const styleElement = document.createElement("style"); @@ -2242,6 +2321,9 @@ setInterval(update, UPDATE_INTERVAL); focusOnElement(true); + + // TODO: This is for testing + generateHat(); } function update() { @@ -2422,6 +2504,47 @@ } } + /** + * Insert the hat as an item element in the document if possible + */ + function generateHat() { + if (document.querySelector("#" + HAT_ID)) { + return; + } + // Select a random hat + const hatKeys = Object.keys(HAT); + const hatId = hatKeys[Math.floor(Math.random() * (hatKeys.length - 1)) + 1]; + + // Find a random valid element to place the hat on + const element = getRandomValidElement(); + if (!element) { + return; + } + + // Create hat element + const hatCanvas = document.createElement("canvas"); + hatCanvas.id = HAT_ID; + hatCanvas.classList.add("birb-item"); + hatCanvas.width = 14 * CANVAS_PIXEL_SIZE; + hatCanvas.height = 14 * CANVAS_PIXEL_SIZE; + const hatCtx = hatCanvas.getContext("2d"); + if (!hatCtx) { + return; + } + + // Create hat animation + const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET); + hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]); + + // Position hat above the element + const rect = element.getBoundingClientRect(); + hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px"; + hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px"; + + // Append to document + document.body.appendChild(hatCanvas); + } + /** * @param {string} birdType */ @@ -2753,14 +2876,9 @@ } /** - * Focus on an element within the viewport - * @param {boolean} [teleport] Whether to teleport to the element instead of flying - * @returns Whether an element to focus on was found + * @returns {HTMLElement|null} The random element, or null if no valid element was found */ - function focusOnElement(teleport = false) { - if (frozen) { - return false; - } + function getRandomValidElement() { const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin(); const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); const inWindow = Array.from(elements).filter((img) => { @@ -2782,10 +2900,22 @@ } }); if (nonFixedElements.length === 0) { - return false; + return null; } const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)]; - focusedElement = randomElement; + return randomElement; + } + + /** + * Focus on an element within the viewport + * @param {boolean} [teleport] Whether to teleport to the element instead of flying + * @returns Whether an element to focus on was found + */ + function focusOnElement(teleport = false) { + if (frozen) { + return false; + } + focusedElement = getRandomValidElement(); log("Focusing on element: ", focusedElement); updateFocusedElementBounds(); if (teleport) { @@ -2793,7 +2923,7 @@ } else { flyTo(getFocusedElementRandomX(), getFocusedY()); } - return randomElement !== null; + return focusedElement !== null; } /** diff --git a/dist/web/birb.js b/dist/web/birb.js index a77d732..5992b61 100644 --- a/dist/web/birb.js +++ b/dist/web/birb.js @@ -629,6 +629,7 @@ FLOWER_HAT: "flower-hat" }; + /** @type {{ [hatId: string]: { name: string, description: string } }} */ const HAT_METADATA = { [HAT.NONE]: { name: "Invisible Hat", @@ -684,62 +685,108 @@ } const index = i - 1; const hatKey = HAT[hatName]; - const hatLayer = buildHatLayer(spriteSheet, hatKey, index, false); - const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, false, 1); + const hatLayer = buildHatLayer(spriteSheet, hatKey, index); + const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, 1); hatLayers.base.push(hatLayer); hatLayers.down.push(downHatLayer); } return hatLayers; } + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Anim} + */ + function createHatItemAnimation(hatId, spriteSheet) { + const hatLayer = buildHatItemLayer(spriteSheet, hatId); + const frames = [ + new Frame([hatLayer]) + ]; + return new Anim(frames, [1000], true); + } + /** * @param {string[][]} spriteSheet * @param {string} hatName * @param {number} hatIndex - * @param {boolean} [outlineBottom=false] * @param {number} [yOffset=0] * @returns {Layer} */ - function buildHatLayer(spriteSheet, hatName, hatIndex, outlineBottom = false, yOffset = 0) { + function buildHatLayer(spriteSheet, hatName, hatIndex, yOffset = 0) { const LEFT_PADDING = 6; const RIGHT_PADDING = 14; const TOP_PADDING = 5 + yOffset; const BOTTOM_PADDING = Math.max(0, 15 - yOffset); - const hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); - const paddedHatPixels = []; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, TOP_PADDING, BOTTOM_PADDING, LEFT_PADDING, RIGHT_PADDING); + hatPixels = drawOutline(hatPixels, false); + return new Layer(hatPixels, hatName); + } + + /** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Layer} + */ + function buildHatItemLayer(spriteSheet, hatId) { + if (hatId === HAT.NONE) { + return new Layer([], TAG.DEFAULT); + } + const hatIndex = Object.keys(HAT).indexOf(hatId) - 1; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, 1, 1, 1, 1); + hatPixels = drawOutline(hatPixels, true); + hatPixels = pushToBottom(hatPixels); + return new Layer(hatPixels, TAG.DEFAULT); + } + + /** + * Add transparent padding around the pixel array + * @param {string[][]} pixels + * @param {number} top + * @param {number} bottom + * @param {number} left + * @param {number} right + * @returns {string[][]} + */ + function pad(pixels, top, bottom, left, right) { + const paddedPixels = []; + const rowLength = pixels[0].length + left + right; // Top padding - for (let y = 0; y < TOP_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < top; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } // Left and right padding - for (let y = 0; y < hatPixels.length; y++) { + for (let y = 0; y < pixels.length; y++) { const row = []; - for (let x = 0; x < LEFT_PADDING; x++) { + for (let x = 0; x < left; x++) { row.push(PALETTE.TRANSPARENT); } - - for (let x = 0; x < hatPixels[y].length; x++) { - row.push(hatPixels[y][x]); + for (let x = 0; x < pixels[y].length; x++) { + row.push(pixels[y][x]); } - - for (let x = 0; x < RIGHT_PADDING; x++) { + for (let x = 0; x < right; x++) { row.push(PALETTE.TRANSPARENT); } - - paddedHatPixels.push(row); + paddedPixels.push(row); } // Bottom padding - for (let y = 0; y < BOTTOM_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < bottom; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } + return paddedPixels; + } - // Add outline + /** + * Draw an outline around non-transparent pixels + * @param {string[][]} pixels + * @param {boolean} [outlineBottom=false] + * @return {string[][]} + */ + function drawOutline(pixels, outlineBottom = false) { let neighborOffsets = [ [-1, 0], [1, 0], @@ -750,21 +797,42 @@ if (outlineBottom) { neighborOffsets.push([0, 1], [-1, 1], [1, 1]); } - for (let y = 0; y < paddedHatPixels.length; y++) { - for (let x = 0; x < paddedHatPixels[y].length; x++) { - const pixel = paddedHatPixels[y][x]; + for (let y = 0; y < pixels.length; y++) { + for (let x = 0; x < pixels[y].length; x++) { + const pixel = pixels[y][x]; if (pixel !== PALETTE.TRANSPARENT && pixel !== PALETTE.BORDER) { for (let [dx, dy] of neighborOffsets) { const newX = x + dx; const newY = y + dy; - if (newY >= 0 && newY < paddedHatPixels.length && newX >= 0 && newX < paddedHatPixels[newY].length && paddedHatPixels[newY][newX] === PALETTE.TRANSPARENT) { - paddedHatPixels[newY][newX] = PALETTE.BORDER; + if (newY >= 0 && newY < pixels.length && newX >= 0 && newX < pixels[newY].length && pixels[newY][newX] === PALETTE.TRANSPARENT) { + pixels[newY][newX] = PALETTE.BORDER; } } } } } - return new Layer(paddedHatPixels, hatName); + return pixels; + } + + /** + * Trim transparent rows from the bottom and push them to the top + * @param {string[][]} pixels + * @returns {string[][]} + */ + function pushToBottom(pixels) { + let trimmedPixels = pixels.slice(); + let trimCount = 0; + while (trimmedPixels.length > 1) { + const firstRow = trimmedPixels[trimmedPixels.length - 1]; + if (firstRow.every(pixel => pixel === PALETTE.TRANSPARENT)) { + trimmedPixels.pop(); + trimCount++; + } else { + break; + } + } + trimmedPixels = pad(trimmedPixels, trimCount, 0, 0, 0); + return trimmedPixels; } /** @@ -1587,6 +1655,16 @@ z-index: 2147483630 !important; } +.birb-item { + image-rendering: pixelated; + position: absolute; + bottom: 0; + transform-origin: bottom; + transform: scale(calc(var(--birb-scale) * 1.5)) !important; + transform-origin: bottom; + z-index: 2147483630 !important; +} + .birb-window { font-family: "Monocraft", monospace !important; line-height: initial !important; @@ -1935,6 +2013,7 @@ const FIELD_GUIDE_ID = "birb-field-guide"; const FEATHER_ID = "birb-feather"; const WARDROBE_ID = "birb-wardrobe"; + const HAT_ID = "birb-hat"; const DEFAULT_BIRD = "bluebird"; const DEFAULT_HAT = HAT.NONE; @@ -2053,7 +2132,7 @@ insertModal(`${birdBirb()} Mode`, message); }), new Separator(), - new MenuItem("2026.1.20", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.20"); }, false), + new MenuItem("2026.1.21", () => { alert("Thank you for using Pocket Bird! You are on version: 2026.1.21"); }, false), ]; const styleElement = document.createElement("style"); @@ -2242,6 +2321,9 @@ setInterval(update, UPDATE_INTERVAL); focusOnElement(true); + + // TODO: This is for testing + generateHat(); } function update() { @@ -2422,6 +2504,47 @@ } } + /** + * Insert the hat as an item element in the document if possible + */ + function generateHat() { + if (document.querySelector("#" + HAT_ID)) { + return; + } + // Select a random hat + const hatKeys = Object.keys(HAT); + const hatId = hatKeys[Math.floor(Math.random() * (hatKeys.length - 1)) + 1]; + + // Find a random valid element to place the hat on + const element = getRandomValidElement(); + if (!element) { + return; + } + + // Create hat element + const hatCanvas = document.createElement("canvas"); + hatCanvas.id = HAT_ID; + hatCanvas.classList.add("birb-item"); + hatCanvas.width = 14 * CANVAS_PIXEL_SIZE; + hatCanvas.height = 14 * CANVAS_PIXEL_SIZE; + const hatCtx = hatCanvas.getContext("2d"); + if (!hatCtx) { + return; + } + + // Create hat animation + const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET); + hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]); + + // Position hat above the element + const rect = element.getBoundingClientRect(); + hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px"; + hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px"; + + // Append to document + document.body.appendChild(hatCanvas); + } + /** * @param {string} birdType */ @@ -2753,14 +2876,9 @@ } /** - * Focus on an element within the viewport - * @param {boolean} [teleport] Whether to teleport to the element instead of flying - * @returns Whether an element to focus on was found + * @returns {HTMLElement|null} The random element, or null if no valid element was found */ - function focusOnElement(teleport = false) { - if (frozen) { - return false; - } + function getRandomValidElement() { const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin(); const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); const inWindow = Array.from(elements).filter((img) => { @@ -2782,10 +2900,22 @@ } }); if (nonFixedElements.length === 0) { - return false; + return null; } const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)]; - focusedElement = randomElement; + return randomElement; + } + + /** + * Focus on an element within the viewport + * @param {boolean} [teleport] Whether to teleport to the element instead of flying + * @returns Whether an element to focus on was found + */ + function focusOnElement(teleport = false) { + if (frozen) { + return false; + } + focusedElement = getRandomValidElement(); log("Focusing on element: ", focusedElement); updateFocusedElementBounds(); if (teleport) { @@ -2793,7 +2923,7 @@ } else { flyTo(getFocusedElementRandomX(), getFocusedY()); } - return randomElement !== null; + return focusedElement !== null; } /** diff --git a/src/application.js b/src/application.js index cb76bea..6f1af83 100644 --- a/src/application.js +++ b/src/application.js @@ -1,5 +1,5 @@ import Frame from './animation/frame.js'; -import Layer from './animation/layer.js'; +import Layer, { TAG } from './animation/layer.js'; import Anim from './animation/anim.js'; import { Birb, Animations } from './birb.js'; import { Birdsong } from './sound.js'; @@ -43,7 +43,7 @@ import { switchMenuItems, MENU_EXIT_ID } from './menu.js'; -import { HAT, HAT_METADATA } from './hats.js'; +import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js'; /** @@ -86,6 +86,7 @@ const HATS_SPRITE_SHEET = "__HATS_SPRITE_SHEET__"; const FIELD_GUIDE_ID = "birb-field-guide"; const FEATHER_ID = "birb-feather"; const WARDROBE_ID = "birb-wardrobe"; +const HAT_ID = "birb-hat"; const DEFAULT_BIRD = "bluebird"; const DEFAULT_HAT = HAT.NONE; @@ -393,6 +394,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { setInterval(update, UPDATE_INTERVAL); focusOnElement(true); + + // TODO: This is for testing + generateHat(); } function update() { @@ -576,6 +580,47 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { } } + /** + * Insert the hat as an item element in the document if possible + */ + function generateHat() { + if (document.querySelector("#" + HAT_ID)) { + return; + } + // Select a random hat + const hatKeys = Object.keys(HAT); + const hatId = hatKeys[Math.floor(Math.random() * (hatKeys.length - 1)) + 1]; + + // Find a random valid element to place the hat on + const element = getRandomValidElement(); + if (!element) { + return; + } + + // Create hat element + const hatCanvas = document.createElement("canvas"); + hatCanvas.id = HAT_ID; + hatCanvas.classList.add("birb-item"); + hatCanvas.width = 14 * CANVAS_PIXEL_SIZE; + hatCanvas.height = 14 * CANVAS_PIXEL_SIZE; + const hatCtx = hatCanvas.getContext("2d"); + if (!hatCtx) { + return; + } + + // Create hat animation + const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET); + hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]); + + // Position hat above the element + const rect = element.getBoundingClientRect(); + hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px"; + hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px"; + + // Append to document + document.body.appendChild(hatCanvas); + } + /** * @param {string} birdType */ @@ -912,14 +957,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { } /** - * Focus on an element within the viewport - * @param {boolean} [teleport] Whether to teleport to the element instead of flying - * @returns Whether an element to focus on was found + * @returns {HTMLElement|null} The random element, or null if no valid element was found */ - function focusOnElement(teleport = false) { - if (frozen) { - return false; - } + function getRandomValidElement() { const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin(); const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); const inWindow = Array.from(elements).filter((img) => { @@ -947,10 +987,22 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { return style.position !== "fixed" && style.position !== "sticky"; }); if (nonFixedElements.length === 0) { - return false; + return null; } const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)]; - focusedElement = randomElement; + return randomElement; + } + + /** + * Focus on an element within the viewport + * @param {boolean} [teleport] Whether to teleport to the element instead of flying + * @returns Whether an element to focus on was found + */ + function focusOnElement(teleport = false) { + if (frozen) { + return false; + } + focusedElement = getRandomValidElement(); log("Focusing on element: ", focusedElement); updateFocusedElementBounds(); if (teleport) { @@ -958,7 +1010,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) { } else { flyTo(getFocusedElementRandomX(), getFocusedY()); } - return randomElement !== null; + return focusedElement !== null; } /** diff --git a/src/hats.js b/src/hats.js index c93916d..c875e59 100644 --- a/src/hats.js +++ b/src/hats.js @@ -1,4 +1,6 @@ -import Layer from "./animation/layer.js"; +import Anim from "./animation/anim.js"; +import Frame from "./animation/frame.js"; +import Layer, { TAG } from "./animation/layer.js"; import { PALETTE } from "./animation/sprites.js"; import { getLayerPixels } from "./shared.js"; @@ -16,6 +18,7 @@ export const HAT = { FLOWER_HAT: "flower-hat" }; +/** @type {{ [hatId: string]: { name: string, description: string } }} */ export const HAT_METADATA = { [HAT.NONE]: { name: "Invisible Hat", @@ -71,62 +74,108 @@ export function createHatLayers(spriteSheet) { } const index = i - 1; const hatKey = HAT[hatName]; - const hatLayer = buildHatLayer(spriteSheet, hatKey, index, false); - const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, false, 1); + const hatLayer = buildHatLayer(spriteSheet, hatKey, index); + const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, 1); hatLayers.base.push(hatLayer); hatLayers.down.push(downHatLayer); } return hatLayers; } +/** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Anim} + */ +export function createHatItemAnimation(hatId, spriteSheet) { + const hatLayer = buildHatItemLayer(spriteSheet, hatId); + const frames = [ + new Frame([hatLayer]) + ]; + return new Anim(frames, [1000], true); +} + /** * @param {string[][]} spriteSheet * @param {string} hatName * @param {number} hatIndex - * @param {boolean} [outlineBottom=false] * @param {number} [yOffset=0] * @returns {Layer} */ -function buildHatLayer(spriteSheet, hatName, hatIndex, outlineBottom = false, yOffset = 0) { +function buildHatLayer(spriteSheet, hatName, hatIndex, yOffset = 0) { const LEFT_PADDING = 6; const RIGHT_PADDING = 14; const TOP_PADDING = 5 + yOffset; const BOTTOM_PADDING = Math.max(0, 15 - yOffset); - const hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); - const paddedHatPixels = []; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, TOP_PADDING, BOTTOM_PADDING, LEFT_PADDING, RIGHT_PADDING); + hatPixels = drawOutline(hatPixels, false); + return new Layer(hatPixels, hatName); +} + +/** + * @param {string[][]} spriteSheet + * @param {string} hatId + * @returns {Layer} + */ +function buildHatItemLayer(spriteSheet, hatId) { + if (hatId === HAT.NONE) { + return new Layer([], TAG.DEFAULT); + } + const hatIndex = Object.keys(HAT).indexOf(hatId) - 1; + let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH); + hatPixels = pad(hatPixels, 1, 1, 1, 1); + hatPixels = drawOutline(hatPixels, true); + hatPixels = pushToBottom(hatPixels); + return new Layer(hatPixels, TAG.DEFAULT); +} + +/** + * Add transparent padding around the pixel array + * @param {string[][]} pixels + * @param {number} top + * @param {number} bottom + * @param {number} left + * @param {number} right + * @returns {string[][]} + */ +function pad(pixels, top, bottom, left, right) { + const paddedPixels = []; + const rowLength = pixels[0].length + left + right; // Top padding - for (let y = 0; y < TOP_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < top; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } // Left and right padding - for (let y = 0; y < hatPixels.length; y++) { + for (let y = 0; y < pixels.length; y++) { const row = []; - for (let x = 0; x < LEFT_PADDING; x++) { + for (let x = 0; x < left; x++) { row.push(PALETTE.TRANSPARENT); } - - for (let x = 0; x < hatPixels[y].length; x++) { - row.push(hatPixels[y][x]); + for (let x = 0; x < pixels[y].length; x++) { + row.push(pixels[y][x]); } - - for (let x = 0; x < RIGHT_PADDING; x++) { + for (let x = 0; x < right; x++) { row.push(PALETTE.TRANSPARENT); } - - paddedHatPixels.push(row); + paddedPixels.push(row); } // Bottom padding - for (let y = 0; y < BOTTOM_PADDING; y++) { - paddedHatPixels.push(Array(hatPixels[0].length + LEFT_PADDING + RIGHT_PADDING) - .fill(PALETTE.TRANSPARENT) - ); + for (let y = 0; y < bottom; y++) { + paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT)); } + return paddedPixels; +} - // Add outline +/** + * Draw an outline around non-transparent pixels + * @param {string[][]} pixels + * @param {boolean} [outlineBottom=false] + * @return {string[][]} + */ +function drawOutline(pixels, outlineBottom = false) { let neighborOffsets = [ [-1, 0], [1, 0], @@ -137,19 +186,40 @@ function buildHatLayer(spriteSheet, hatName, hatIndex, outlineBottom = false, yO if (outlineBottom) { neighborOffsets.push([0, 1], [-1, 1], [1, 1]); } - for (let y = 0; y < paddedHatPixels.length; y++) { - for (let x = 0; x < paddedHatPixels[y].length; x++) { - const pixel = paddedHatPixels[y][x]; + for (let y = 0; y < pixels.length; y++) { + for (let x = 0; x < pixels[y].length; x++) { + const pixel = pixels[y][x]; if (pixel !== PALETTE.TRANSPARENT && pixel !== PALETTE.BORDER) { for (let [dx, dy] of neighborOffsets) { const newX = x + dx; const newY = y + dy; - if (newY >= 0 && newY < paddedHatPixels.length && newX >= 0 && newX < paddedHatPixels[newY].length && paddedHatPixels[newY][newX] === PALETTE.TRANSPARENT) { - paddedHatPixels[newY][newX] = PALETTE.BORDER; + if (newY >= 0 && newY < pixels.length && newX >= 0 && newX < pixels[newY].length && pixels[newY][newX] === PALETTE.TRANSPARENT) { + pixels[newY][newX] = PALETTE.BORDER; } } } } } - return new Layer(paddedHatPixels, hatName); + return pixels; +} + +/** + * Trim transparent rows from the bottom and push them to the top + * @param {string[][]} pixels + * @returns {string[][]} + */ +function pushToBottom(pixels) { + let trimmedPixels = pixels.slice(); + let trimCount = 0; + while (trimmedPixels.length > 1) { + const firstRow = trimmedPixels[trimmedPixels.length - 1]; + if (firstRow.every(pixel => pixel === PALETTE.TRANSPARENT)) { + trimmedPixels.pop(); + trimCount++; + } else { + break; + } + } + trimmedPixels = pad(trimmedPixels, trimCount, 0, 0, 0); + return trimmedPixels; } \ No newline at end of file diff --git a/src/stylesheet.css b/src/stylesheet.css index 4df8d50..c02f3d3 100644 --- a/src/stylesheet.css +++ b/src/stylesheet.css @@ -41,6 +41,16 @@ z-index: 2147483630 !important; } +.birb-item { + image-rendering: pixelated; + position: absolute; + bottom: 0; + transform-origin: bottom; + transform: scale(calc(var(--birb-scale) * 1.5)) !important; + transform-origin: bottom; + z-index: 2147483630 !important; +} + .birb-window { font-family: "Monocraft", monospace !important; line-height: initial !important;