70 Commits

Author SHA1 Message Date
√(noham)²
5797c055ed 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.
2026-04-06 12:55:19 +02:00
Idrees Hassan
96ff61625a Fix bird/birb in menu items 2026-04-04 10:13:17 -07:00
Idrees Hassan
85ade65a57 Update colors and add icons 2026-04-03 19:06:28 -07:00
Idrees Hassan
2f3d7958ea Update application.js 2026-03-30 06:36:33 -07:00
Idrees
eab6086f4d Merge pull request #9 from IdreesInc/new-birds
New birds and new logic!
2026-03-29 14:45:03 -07:00
Idrees Hassan
c770e3d1f6 Update icon to new bird design 2026-03-29 14:43:21 -07:00
Idrees Hassan
0d007f8c1e Update highlight color 2026-03-29 14:26:26 -07:00
Idrees Hassan
92c083138d Add the violet backed starling 2026-03-29 14:24:03 -07:00
Idrees Hassan
5d6ea50c87 Add the cuban tody 2026-03-29 12:26:50 -07:00
Idrees Hassan
77a29c549f Slightly increase uncommon feather chance 2026-03-29 11:51:21 -07:00
Idrees Hassan
a8ba15489f Add the spangled cotinga 2026-03-29 11:47:01 -07:00
Idrees Hassan
39e84be775 Add chin to sprite 2026-03-29 11:36:43 -07:00
Idrees Hassan
f924343ac3 Add the painted bunting 2026-03-29 11:19:45 -07:00
Idrees Hassan
fd55924025 Add shoulder to sprite 2026-03-29 11:13:36 -07:00
Idrees Hassan
f891f8f06d Update euphonia colors 2026-03-28 15:38:43 -07:00
Idrees Hassan
9aee4eab1a Remove shoulder 2026-03-28 14:36:30 -07:00
Idrees Hassan
763b50f34b Change pigeon name 2026-03-28 12:15:53 -07:00
Idrees Hassan
b4c577a0ac Fix collar scruff 2026-03-28 12:12:06 -07:00
Idrees Hassan
5a3b555d3a Fix flapping sprite 2026-03-28 12:07:36 -07:00
Idrees Hassan
74a776cd4f Add latin names to the birds 2026-03-28 11:48:02 -07:00
Idrees Hassan
ebb9f92be2 Fix chickadee color overrides 2026-03-28 11:34:42 -07:00
Idrees Hassan
b7d6ca63c1 Enforce enum types 2026-03-28 11:29:35 -07:00
Idrees Hassan
7d16459a76 Implement rarity calculations 2026-03-28 11:26:39 -07:00
Idrees Hassan
abe4439d5e Add spots to the strawberry finch 2026-03-28 11:15:24 -07:00
Idrees Hassan
6f88d386ec Add the pigeon 2026-03-28 11:07:32 -07:00
Idrees Hassan
86a14d6dca Add the elegant euphonia 2026-03-28 10:52:08 -07:00
Idrees Hassan
30b9c86cca Remove color override redundency 2026-03-28 10:38:19 -07:00
Idrees Hassan
3765713fd0 Fix sprite color overrides 2026-03-28 10:31:23 -07:00
Idrees Hassan
0f90eb4492 Add more sprite template colors 2026-03-28 10:27:26 -07:00
Idrees Hassan
30d6c2fee5 Update house finch colors 2026-03-28 09:48:22 -07:00
Idrees Hassan
18fa5e8683 Lighten pink robin 2026-03-28 09:44:29 -07:00
Idrees Hassan
c43dd4c7b4 Add the red warbler 2026-03-21 16:36:27 -07:00
Idrees Hassan
9e6f5feae1 Fix template colors 2026-03-21 14:44:27 -07:00
Idrees Hassan
b6e93088a8 Add more eye template colors 2026-03-21 14:37:15 -07:00
Idrees Hassan
b440f633a5 Add nose tip 2026-03-21 14:25:41 -07:00
Idrees Hassan
7b20a376ce Reduce pink robin wing edge contrast 2026-03-21 14:12:31 -07:00
Idrees Hassan
61fbe89986 Prevent flying to the same element and reduce chirp volume 2026-03-21 14:11:58 -07:00
Idrees Hassan
c1511aae71 Update pink robin 2026-03-20 17:15:08 -07:00
Idrees Hassan
0a11ebe87d Update pink robin colors 2026-03-18 17:16:08 -07:00
Idrees Hassan
5cf96da868 Update house finch theme highlight 2026-03-18 17:12:57 -07:00
Idrees Hassan
5d99142b74 Add pink robin and update scarlet robin 2026-03-18 17:11:05 -07:00
Idrees Hassan
2a7ad229be Add support for different rarities in field guide 2026-03-18 16:48:13 -07:00
Idrees Hassan
c880b99744 Add house finch 2026-03-18 15:52:16 -07:00
Idrees Hassan
fe0310cb36 Add dark eyed junco 2026-03-11 20:50:16 -07:00
Idrees Hassan
efddf12ba5 Add potential for multiple cheeps 2026-03-11 18:47:38 -07:00
Idrees Hassan
7aa9996857 Add wing edge to blue jay 2026-03-11 18:35:34 -07:00
Idrees Hassan
7f334d789f Add blue jay 2026-03-11 18:28:37 -07:00
Idrees Hassan
a57615b3da Add black-capped chickadee 2026-03-11 17:07:50 -07:00
Idrees Hassan
37a8b6cc6e Merge branch 'main' into new-birds 2026-03-11 16:57:53 -07:00
Idrees Hassan
31a3f7cac9 Update .gitignore 2026-03-11 16:57:48 -07:00
Idrees Hassan
9fb0ab3f3f Revert carolina wren colors 2026-03-11 16:56:52 -07:00
Idrees Hassan
736d01e015 Add scruff 2026-03-11 16:51:11 -07:00
Idrees Hassan
dd3ef01bef Actually fix colors not updating on refresh 2026-03-11 16:39:09 -07:00
Idrees Hassan
3e48360632 Fix colors not updating on refresh 2026-03-11 16:36:24 -07:00
Idrees Hassan
3eda5ffc92 Store changes in local storage 2026-03-11 16:33:17 -07:00
Idrees Hassan
6cfd32270c Fix sprite flag inconsistencies 2026-03-11 16:22:00 -07:00
Idrees Hassan
1d4c1a000e Fix collar 2026-03-11 16:14:12 -07:00
Idrees Hassan
71b74c9b6f Add history for species changes 2026-03-11 16:09:36 -07:00
Idrees Hassan
80bcf60a07 Add other palette items and toggles 2026-03-11 15:46:38 -07:00
Idrees Hassan
a2dea8a17d Add editor to this repository 2026-03-11 15:24:55 -07:00
Idrees Hassan
fd09a35b51 Decrease hat chance default 2026-03-08 17:25:34 -07:00
Idrees Hassan
11ea3c012b Merge branch 'main' of https://github.com/IdreesInc/Pocket-Bird 2026-03-08 17:25:18 -07:00
Idrees Hassan
1bf82dfbad Use species json 2026-03-08 17:25:17 -07:00
Idrees
b04edbc2c5 Update README.md 2026-03-08 12:53:31 -07:00
Idrees
927b287f98 Update README.md 2026-03-08 12:51:29 -07:00
Idrees Hassan
45743d2caf Update font handling to better bundle fonts 2026-03-08 12:47:08 -07:00
Idrees
953d2cde47 Add gif to README 2026-02-03 12:45:03 -08:00
Idrees Hassan
6309aed971 Create new build for release 2026-01-25 14:04:37 -05:00
Idrees Hassan
ea85c61955 Don't switch hats when unlocked 2026-01-24 22:55:46 -05:00
Idrees Hassan
cd06a886bd Update viking helmet design 2026-01-24 22:50:20 -05:00
75 changed files with 6963 additions and 1922 deletions

5
.gitignore vendored
View File

@@ -3,3 +3,8 @@
/dist/birb.bundled.js /dist/birb.bundled.js
obsidian-test.sh obsidian-test.sh
build-cache.json build-cache.json
.vscode/settings.json
aseprite/birb-test.aseprite
aseprite/wren.aseprite
aseprite/birb-no-shoulder.aseprite
aseprite/birb-fat.aseprite

View File

@@ -19,6 +19,8 @@ It's a pet bird that hops around your computer, what more could you want?
#### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas! #### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas!
![Screen Recording 2026-01-26 at 5 09 45PM](https://github.com/user-attachments/assets/dbde4b75-7195-476f-9411-c1ee7ba7a96f)
## Features ## Features
- A cute little pixel art bird hops around your apps and websites - A cute little pixel art bird hops around your apps and websites
@@ -95,7 +97,8 @@ If you are running Pocket bird on a browser, the extension needs these permissio
Here are some websites where you can find Pocket Bird hopping around: Here are some websites where you can find Pocket Bird hopping around:
- [https://grepjason.sh](https://grepjason.sh) - [https://grepjason.sh](https://grepjason.sh)
- [https://binarydigit.dev](https://binarydigit.dev) - [https://binarydigit.net](https://binarydigit.net)
- [melvinsalas.com](melvinsalas.com)
*If you've added Pocket Bird to your website, let me know and I'll add it to this list!* *If you've added Pocket Bird to your website, let me know and I'll add it to this list!*

Binary file not shown.

Binary file not shown.

View File

@@ -35,7 +35,7 @@ const MONOCRAFT_URL = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40
const VERSION_KEY = "__VERSION__"; const VERSION_KEY = "__VERSION__";
const STYLESHEET_KEY = "___STYLESHEET___"; const STYLESHEET_KEY = "___STYLESHEET___";
const MONOCRAFT_SRC_KEY = "__MONOCRAFT_SRC__"; const MONOCRAFT_URL_KEY = "__MONOCRAFT_URL__";
const CODE_KEY = "__CODE__"; const CODE_KEY = "__CODE__";
const spriteSheets = [ const spriteSheets = [
@@ -85,7 +85,9 @@ writeFileSync(BUILD_CACHE_PATH, JSON.stringify(buildCache), 'utf8');
/** /**
* @param {string} entryPoint * @param {string} entryPoint
* @param {boolean} [embedFont] * @param {boolean} [embedFont] When true, the Monocraft font is base64-encoded
* and substituted into the __MONOCRAFT_FONT_FACE__ placeholder so the
* build is fully self-contained (used for Obsidian).
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async function generateCode(entryPoint, embedFont = false) { async function generateCode(entryPoint, embedFont = false) {
@@ -109,6 +111,15 @@ async function generateCode(entryPoint, embedFont = false) {
// Replace version placeholder // Replace version placeholder
birbJs = birbJs.replaceAll(VERSION_KEY, version); birbJs = birbJs.replaceAll(VERSION_KEY, version);
// Replace CDN font URL placeholder
if (embedFont) {
// Embed as a base64 data URI so the build works fully offline.
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
birbJs = birbJs.replaceAll(MONOCRAFT_URL_KEY, `data:font/otf;base64,${monocraftFontData}`);
} else {
birbJs = birbJs.replaceAll(MONOCRAFT_URL_KEY, MONOCRAFT_URL);
}
// Compile and insert sprite sheets // Compile and insert sprite sheets
for (const spriteSheet of spriteSheets) { for (const spriteSheet of spriteSheets) {
const dataUri = readFileSync(spriteSheet.path, 'base64'); const dataUri = readFileSync(spriteSheet.path, 'base64');
@@ -119,14 +130,6 @@ async function generateCode(entryPoint, embedFont = false) {
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8'); const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent); birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
if (embedFont) {
// Encode font to data URI
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
const monocraftDataUri = `data:font/otf;base64,${monocraftFontData}`;
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, monocraftDataUri);
} else {
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, MONOCRAFT_URL);
}
return birbJs; return birbJs;
} }
@@ -187,6 +190,7 @@ async function buildExtension() {
} }
async function buildObsidian() { async function buildObsidian() {
// embedFont=true: bakes the font as base64 so the plugin works fully offline.
const birbJs = await generateCode(OBSIDIAN_ENTRY, true); const birbJs = await generateCode(OBSIDIAN_ENTRY, true);
mkdirSync(OBSIDIAN_DIR, { recursive: true }); mkdirSync(OBSIDIAN_DIR, { recursive: true });

BIN
dist/extension.zip vendored

Binary file not shown.

1340
dist/extension/birb.js vendored

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 B

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

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.1.24", "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",

1332
dist/obsidian/main.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
{ {
"id": "pocket-bird", "id": "pocket-bird",
"name": "Pocket Bird", "name": "Pocket Bird",
"version": "2026.1.24", "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",

File diff suppressed because it is too large Load Diff

1330
dist/web/birb.embed.js vendored

File diff suppressed because it is too large Load Diff

1330
dist/web/birb.js vendored

File diff suppressed because it is too large Load Diff

380
editor/editor.js Normal file
View File

@@ -0,0 +1,380 @@
// @ts-check
import { SPRITE_SHEET_COLOR_MAP, PALETTE, DEFAULT_COLOR_OVERRIDES, loadSpriteSheetPixels } from '../src/animation/sprites.js';
import Layer, { TAG } from '../src/animation/layer.js';
import Frame from '../src/animation/frame.js';
import { Directions, getLayerPixels } from '../src/shared.js';
import species from '../src/species.js';
/** @typedef {import('../src/species.js').Species} Species */
const COLOR_MAP = SPRITE_SHEET_COLOR_MAP;
const SPRITE_PATH = "../sprites/birb.png";
const SPRITE_SIZE = 32;
const IGNORED_PARTS = new Set(
["transparent", "border", "heart", "heart-border", "heart-shine", "feather-spine"]
);
/** @type {HTMLCanvasElement} */
// @ts-ignore
const canvas = document.getElementById("preview");
/** @type {CanvasRenderingContext2D} */
// @ts-ignore
const ctx = canvas.getContext("2d");
/** @type {HTMLElement} */
// @ts-ignore
const editor = document.getElementById("editor");
const colorPickerInput = document.createElement("input");
/** @type {HTMLElement} */
// @ts-ignore
const jsonElement = document.getElementById("json");
/** @type {Record<string, HTMLElement>} */
const colorElements = {};
/** @type {string|null} */
let selectedPart = null;
/** @type {HTMLElement|null} */
let selectedColorElement = null;
const spriteCanvas = document.createElement('canvas');
spriteCanvas.width = canvas.width;
spriteCanvas.height = canvas.height;
/** @type {CanvasRenderingContext2D} */
// @ts-ignore
const spriteCtx = spriteCanvas.getContext('2d');
/** @type {Species} */
let currentSpecies = JSON.parse(JSON.stringify(species.bluebird));
let speciesHistory = [JSON.parse(JSON.stringify(currentSpecies))];
let historyIndex = 0;
/** @type {Frame|null} */
let baseFrame = null;
function drawBackground() {
const patternSize = 2;
const colors = ["#edf0f4", "#dadbe0"];
for (let y = 0; y < canvas.height; y += patternSize) {
for (let x = 0; x < canvas.width; x += patternSize) {
ctx.fillStyle = ((x / patternSize + y / patternSize) % 2 === 0) ? colors[0] : colors[1];
ctx.fillRect(x, y, patternSize, patternSize);
}
}
}
/**
* Build the full palette color scheme from the current species settings
* @returns {Record<string, string>}
*/
function buildColorScheme() {
/** @type {Record<string, string>} */
const scheme = {};
for (const paletteName of Object.values(PALETTE)) {
scheme[paletteName] = getColor(paletteName);
}
return scheme;
}
function draw() {
if (!baseFrame) {
return;
}
drawBackground();
baseFrame.draw(spriteCtx, Directions.LEFT, 1, buildColorScheme(), currentSpecies.tags || []);
ctx.drawImage(spriteCanvas, 0, 0);
}
function commitChange() {
const previousSpecies = speciesHistory[historyIndex];
let changed = false;
// Check for changes in colors
for (const part of Object.keys(currentSpecies.colors)) {
if (currentSpecies.colors[part] !== previousSpecies.colors[part]) {
changed = true;
break;
}
}
if (!changed) {
for (const part of Object.keys(previousSpecies.colors)) {
if (!(part in currentSpecies.colors)) {
changed = true;
break;
}
}
}
// Check for changes in tags
if (!changed) {
const prevTags = new Set(previousSpecies.tags || []);
const currTags = new Set(currentSpecies.tags || []);
for (const tag of prevTags) {
if (!currTags.has(tag)) {
changed = true;
break;
}
}
}
if (!changed) {
for (const tag of currentSpecies.tags || []) {
if (!previousSpecies.tags || !previousSpecies.tags.includes(tag)) {
changed = true;
break;
}
}
}
if (changed) {
speciesHistory = speciesHistory.slice(0, historyIndex + 1);
speciesHistory.push(JSON.parse(JSON.stringify(currentSpecies)));
historyIndex++;
localStorage.setItem("speciesHistory", JSON.stringify(speciesHistory));
}
updateJson();
draw();
}
function loadEditor() {
for (const [color, part] of Object.entries(COLOR_MAP)) {
if (IGNORED_PARTS.has(part)) {
continue;
}
const item = createColorSwatch(part, getColor(part) || color);
editor.appendChild(item);
}
for (const value of Object.values(TAG)) {
if (value === TAG.DEFAULT) {
continue;
}
editor.appendChild(createTagToggle(value, getTag(value)));
}
}
/**
* @param {string} part
* @return {string}
*/
function getColor(part) {
if (currentSpecies.colors[part]) {
return currentSpecies.colors[part];
}
const override = DEFAULT_COLOR_OVERRIDES[/** @type {keyof typeof DEFAULT_COLOR_OVERRIDES} */ (part)];
if (override) {
return getColor(override);
}
for (const [color, partName] of Object.entries(COLOR_MAP)) {
if (partName === part) {
return color;
}
}
return "transparent";
}
/**
* @param {string} tag
* @returns {boolean}
*/
function getTag(tag) {
return currentSpecies.tags ? currentSpecies.tags.includes(tag) : false;
}
/**
* @param {string} tag
* @param {boolean} enabled
*/
function setTag(tag, enabled) {
if (!currentSpecies.tags) {
currentSpecies.tags = [];
}
if (enabled) {
if (!currentSpecies.tags.includes(tag)) {
currentSpecies.tags.push(tag);
}
} else {
currentSpecies.tags = currentSpecies.tags.filter(t => t !== tag);
}
}
function createColorPicker() {
colorPickerInput.type = "text";
colorPickerInput.id = "color-picker-interceptor";
colorPickerInput.setAttribute("data-coloris", "");
document.body.appendChild(colorPickerInput);
colorPickerInput.addEventListener("input", () => {
if (selectedColorElement && selectedPart !== null) {
const newColor = colorPickerInput.value;
selectedColorElement.style.backgroundColor = newColor;
currentSpecies.colors[selectedPart] = newColor;
draw();
}
});
document.addEventListener("mouseup", () => {
if (selectedPart !== null && !jsonElement.contains(document.activeElement)) {
commitChange();
}
});
}
/**
* @param {string} label
* @param {string} color
* @returns {HTMLDivElement}
*/
function createColorSwatch(label, color) {
const item = document.createElement("div");
item.classList.add("editor-item");
const colorElement = document.createElement("div");
colorElement.classList.add("color");
colorElement.style.backgroundColor = color;
colorElements[label] = colorElement;
item.appendChild(colorElement);
if (color !== "transparent") {
colorElement.addEventListener("click", () => {
selectedPart = label;
selectedColorElement = colorElement;
const rect = colorElement.getBoundingClientRect();
colorPickerInput.style.left = rect.left + "px";
colorPickerInput.style.top = (rect.bottom + window.scrollY) + "px";
colorPickerInput.value = currentSpecies.colors[label] || color;
colorPickerInput.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
} else {
colorElement.classList.add("color--transparent");
}
const labelElement = document.createElement("div");
const labelText = label.replaceAll("-", " ").toUpperCase();
labelElement.classList.add("label");
labelElement.textContent = labelText;
labelElement.title = "Click to remove from species";
labelElement.addEventListener("click", () => {
delete currentSpecies.colors[label];
colorElement.style.backgroundColor = getColor(label);
commitChange();
refreshEditor();
});
item.appendChild(labelElement);
return item;
}
/**
* @param {string} tag
* @param {boolean} enabled
* @returns {HTMLDivElement}
*/
function createTagToggle(tag, enabled) {
const item = document.createElement("div");
item.classList.add("editor-item");
const toggle = document.createElement("button");
toggle.id = `tag-toggle-${tag}`;
toggle.classList.add("tag-toggle");
toggle.textContent = "✓";
toggle.addEventListener("click", () => {
setTag(tag, !getTag(tag));
toggle.classList.toggle("tag-toggle--active", getTag(tag));
commitChange();
draw();
});
item.appendChild(toggle);
const labelElement = document.createElement("div");
labelElement.classList.add("label");
labelElement.textContent = tag.toUpperCase();
item.appendChild(labelElement);
return item;
}
function refreshEditor() {
for (const [, part] of Object.entries(COLOR_MAP)) {
const el = colorElements[part];
if (el && !el.classList.contains("color--transparent")) {
el.style.backgroundColor = getColor(part);
}
}
if (selectedColorElement && selectedPart !== null) {
colorPickerInput.value = currentSpecies.colors[selectedPart] || "";
}
for (const value of Object.values(TAG)) {
const toggle = editor.querySelector(`#tag-toggle-${value}`);
if (toggle && toggle instanceof HTMLElement) {
toggle.classList.toggle("tag-toggle--active", getTag(value));
}
}
}
function updateJson() {
jsonElement.textContent = JSON.stringify(currentSpecies, null, 2);
}
document.addEventListener("keydown", (e) => {
if (!(e.metaKey || e.ctrlKey)) {
return;
}
if (e.key === "z" && !e.shiftKey) {
if (historyIndex > 0) {
historyIndex--;
currentSpecies = JSON.parse(JSON.stringify(speciesHistory[historyIndex]));
refreshEditor();
updateJson();
draw();
e.preventDefault();
}
} else if ((e.key === "z" && e.shiftKey) || e.key === "y") {
if (historyIndex < speciesHistory.length - 1) {
historyIndex++;
currentSpecies = JSON.parse(JSON.stringify(speciesHistory[historyIndex]));
refreshEditor();
updateJson();
draw();
e.preventDefault();
}
}
});
jsonElement.addEventListener("input", () => {
try {
const parsed = JSON.parse(jsonElement.textContent || "");
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
currentSpecies = parsed;
refreshEditor();
draw();
}
} catch (e) {
}
});
jsonElement.addEventListener("blur", () => {
commitChange();
});
function loadSpeciesHistory() {
const storedHistory = localStorage.getItem("speciesHistory");
if (storedHistory) {
try {
const parsedHistory = JSON.parse(storedHistory);
if (Array.isArray(parsedHistory) && parsedHistory.length > 0) {
speciesHistory = parsedHistory;
currentSpecies = JSON.parse(JSON.stringify(speciesHistory[speciesHistory.length - 1]));
historyIndex = speciesHistory.length - 1;
}
} catch (e) {
console.warn("Failed to parse species history from localStorage:", e);
}
}
refreshEditor();
draw();
}
createColorPicker();
loadEditor();
loadSpeciesHistory();
(async () => {
const pixels = await loadSpriteSheetPixels(SPRITE_PATH);
baseFrame = new Frame([
new Layer(getLayerPixels(pixels, 0, SPRITE_SIZE)),
new Layer(getLayerPixels(pixels, 5, SPRITE_SIZE), TAG.TUFT),
]);
updateJson();
draw();
})();

24
editor/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Birb Editor</title>
<link rel="stylesheet" href="stylesheet.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/mdbassit/Coloris@latest/dist/coloris.min.css"/>
<script src="https://cdn.jsdelivr.net/gh/mdbassit/Coloris@latest/dist/coloris.min.js"></script>
</head>
<body>
<div class="container">
<div class="horizontal-container">
<canvas id="preview" width="32px" height="32px"></canvas>
<div id="editor"></div>
<pre id="json" contenteditable="true"></pre>
</div>
</div>
<script type="module" src="editor.js"></script>
</body>
</html>

143
editor/stylesheet.css Normal file
View File

@@ -0,0 +1,143 @@
@import url(https://cdn.jsdelivr.net/npm/firacode@6.2.0/distr/fira_code.css);
body {
background: linear-gradient(to top, #D2DAE9, white);
height: 100%;
margin: 0;
background-repeat: no-repeat;
background-attachment: fixed;
}
* {
box-sizing: border-box;
}
.container {
width: 100vw;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.horizontal-container {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 40px;
}
#preview {
width: 480px;
height: 480px;
image-rendering: pixelated;
filter: drop-shadow(0px 0px 40px rgba(0, 0, 0, 0.1));
box-shadow: 0px 0px 40px rgba(0, 0, 0, 0.1);
border-radius: 40px;
}
#editor {
width: 460px;
height: 480px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0px 0px 40px rgba(0, 0, 0, 0.1);
padding: 40px 50px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 30px 20px;
column-count: 2;
overflow-y: scroll;
}
#json {
width: 200px;
height: 480px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0px 0px 40px rgba(0, 0, 0, 0.1);
padding: 20px;
gap: 20px;
text-align: left;
overflow-x: hidden;
overflow-y: auto;
white-space: pre-wrap;
font-family: 'Fira Code', monospace;
font-size: 12px;
}
.editor-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 20px;
height: 38px;
}
.label {
font-size: 18px;
font-family: 'Fira Code', monospace;
width: 100px;
}
.color {
width: 32px;
height: 32px;
border-radius: 4px;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
background: red;
transition: transform 0.1s;
cursor: pointer;
}
.color:hover {
transform: scale(1.15);
transition: 0.1s ease-in;
}
.color--transparent {
pointer-events: none;
cursor: default;
}
#color-picker-interceptor {
position: fixed;
width: 1px;
height: 1px;
opacity: 0;
border: none;
padding: 0;
pointer-events: none;
}
.tag-toggle {
width: 32px;
height: 32px;
border-radius: 4px;
border: none;
background: #f1f1f1;
cursor: pointer;
font-size: 16px;
color: transparent;
transition: background 0.15s, color 0.15s, transform 0.1s;
border: 3px solid #dadada;
}
.tag-toggle:hover {
transform: scale(1.15);
transition: 0.1s ease-in;
}
.tag-toggle--active {
background: #34c85a;
color: white;
border: 0;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 B

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 B

After

Width:  |  Height:  |  Size: 949 B

View File

@@ -1,10 +1,6 @@
import { TAG } from "./layer.js"; import species from "../species.js"
/** export const PALETTE = Object.freeze(/** @type {const} */ ({
* Palette color names
* @type {Record<string, string>}
*/
export const PALETTE = {
THEME_HIGHLIGHT: "theme-highlight", THEME_HIGHLIGHT: "theme-highlight",
TRANSPARENT: "transparent", TRANSPARENT: "transparent",
OUTLINE: "outline", OUTLINE: "outline",
@@ -14,20 +10,37 @@ export const PALETTE = {
EYE: "eye", EYE: "eye",
FACE: "face", FACE: "face",
HOOD: "hood", HOOD: "hood",
EYEBROW: "eyebrow",
UPPER_EYELID: "upper-eyelid",
UPPER_CORNER_EYE: "upper-corner-eye",
BEHIND_EYE: "behind-eye",
CORNER_EYE: "corner-eye",
TEMPLE: "temple",
LOWER_EYELID: "lower-eyelid",
NOSE: "nose", NOSE: "nose",
NOSE_TIP: "nose-tip",
CHEEK: "cheek",
SCRUFF: "scruff",
CHIN: "chin",
COLLAR: "collar",
COLLAR_SCRUFF: "collar-scruff",
BELLY: "belly", BELLY: "belly",
UNDERBELLY: "underbelly", UNDERBELLY: "underbelly",
WING: "wing", WING: "wing",
SHOULDER: "shoulder",
WING_SPOTS: "wing-spots",
WING_EDGE: "wing-edge", WING_EDGE: "wing-edge",
HEART: "heart", HEART: "heart",
HEART_BORDER: "heart-border", HEART_BORDER: "heart-border",
HEART_SHINE: "heart-shine", HEART_SHINE: "heart-shine",
FEATHER_SPINE: "feather-spine", FEATHER_SPINE: "feather-spine",
}; }));
/** @typedef {typeof PALETTE[keyof typeof PALETTE]} PaletteColor */
/** /**
* Mapping of sprite sheet colors to palette colors * Mapping of sprite sheet colors to palette colors
* @type {Record<string, string>} * @type {Record<string, PaletteColor>}
*/ */
export const SPRITE_SHEET_COLOR_MAP = { export const SPRITE_SHEET_COLOR_MAP = {
"transparent": PALETTE.TRANSPARENT, "transparent": PALETTE.TRANSPARENT,
@@ -39,10 +52,25 @@ export const SPRITE_SHEET_COLOR_MAP = {
"#af8e75": PALETTE.FOOT, "#af8e75": PALETTE.FOOT,
"#639bff": PALETTE.FACE, "#639bff": PALETTE.FACE,
"#99e550": PALETTE.HOOD, "#99e550": PALETTE.HOOD,
"#ff5573": PALETTE.EYEBROW,
"#ff768e": PALETTE.UPPER_EYELID,
"#ff90a4": PALETTE.UPPER_CORNER_EYE,
"#ff2c88": PALETTE.BEHIND_EYE,
"#e34f9c": PALETTE.CORNER_EYE,
"#b53477": PALETTE.TEMPLE,
"#ae65f1": PALETTE.LOWER_EYELID,
"#d95763": PALETTE.NOSE, "#d95763": PALETTE.NOSE,
"#b93844": PALETTE.NOSE_TIP,
"#ff67a9": PALETTE.CHEEK,
"#c5e550": PALETTE.SCRUFF,
"#b87af1": PALETTE.CHIN,
"#ffe955": PALETTE.COLLAR,
"#f8ff55": PALETTE.COLLAR_SCRUFF,
"#f8b143": PALETTE.BELLY, "#f8b143": PALETTE.BELLY,
"#ec8637": PALETTE.UNDERBELLY, "#ec8637": PALETTE.UNDERBELLY,
"#578ae6": PALETTE.WING, "#578ae6": PALETTE.WING,
"#55d1f3": PALETTE.SHOULDER,
"#90b0e8": PALETTE.WING_SPOTS,
"#326ed9": PALETTE.WING_EDGE, "#326ed9": PALETTE.WING_EDGE,
"#c82e2e": PALETTE.HEART, "#c82e2e": PALETTE.HEART,
"#501a1a": PALETTE.HEART_BORDER, "#501a1a": PALETTE.HEART_BORDER,
@@ -50,16 +78,52 @@ export const SPRITE_SHEET_COLOR_MAP = {
"#373737": PALETTE.FEATHER_SPINE, "#373737": PALETTE.FEATHER_SPINE,
}; };
/**
* @type {Partial<Record<PaletteColor, PaletteColor>>}
*/
export const DEFAULT_COLOR_OVERRIDES = {
[PALETTE.HOOD]: PALETTE.FACE,
[PALETTE.EYEBROW]: PALETTE.FACE,
[PALETTE.UPPER_EYELID]: PALETTE.EYEBROW,
[PALETTE.UPPER_CORNER_EYE]: PALETTE.EYEBROW,
[PALETTE.BEHIND_EYE]: PALETTE.FACE,
[PALETTE.CORNER_EYE]: PALETTE.FACE,
[PALETTE.TEMPLE]: PALETTE.FACE,
[PALETTE.LOWER_EYELID]: PALETTE.FACE,
[PALETTE.NOSE]: PALETTE.FACE,
[PALETTE.NOSE_TIP]: PALETTE.NOSE,
[PALETTE.CHEEK]: PALETTE.FACE,
[PALETTE.SCRUFF]: PALETTE.FACE,
[PALETTE.CHIN]: PALETTE.FACE,
[PALETTE.COLLAR]: PALETTE.FACE,
[PALETTE.COLLAR_SCRUFF]: PALETTE.COLLAR,
[PALETTE.WING_SPOTS]: PALETTE.WING,
[PALETTE.SHOULDER]: PALETTE.WING,
};
export const RARITY = Object.freeze(/** @type {const} */ ({
COMMON: "common",
UNCOMMON: "uncommon"
}));
/** @typedef {typeof RARITY[keyof typeof RARITY]} Rarity */
export class BirdType { export class BirdType {
/** /**
* @param {string} name * @param {string} name
* @param {string} description * @param {string} description
* @param {string} latinName
* @param {string} url
* @param {Record<string, string>} colors * @param {Record<string, string>} colors
* @param {string[]} [tags] * @param {string[]} [tags]
* @param {Rarity} [rarity]
*/ */
constructor(name, description, colors, tags = []) { constructor(name, description, latinName, url, colors, tags = [], rarity = RARITY.COMMON) {
this.name = name; this.name = name;
this.description = description; this.description = description;
this.latinName = latinName;
this.url = url;
const defaultColors = { const defaultColors = {
[PALETTE.TRANSPARENT]: "transparent", [PALETTE.TRANSPARENT]: "transparent",
[PALETTE.OUTLINE]: "#000000", [PALETTE.OUTLINE]: "#000000",
@@ -71,139 +135,90 @@ export class BirdType {
[PALETTE.HEART_SHINE]: "#ff6b6b", [PALETTE.HEART_SHINE]: "#ff6b6b",
[PALETTE.FEATHER_SPINE]: "#373737", [PALETTE.FEATHER_SPINE]: "#373737",
[PALETTE.HOOD]: colors.face, [PALETTE.HOOD]: colors.face,
[PALETTE.EYEBROW]: colors.face,
[PALETTE.UPPER_EYELID]: colors.eyebrow || colors.face,
[PALETTE.UPPER_CORNER_EYE]: colors.eyebrow || colors.face,
[PALETTE.BEHIND_EYE]: colors.face,
[PALETTE.CORNER_EYE]: colors.face,
[PALETTE.TEMPLE]: colors.face,
[PALETTE.LOWER_EYELID]: colors.face,
[PALETTE.NOSE]: colors.face, [PALETTE.NOSE]: colors.face,
[PALETTE.NOSE_TIP]: colors.nose || colors.face,
[PALETTE.CHEEK]: colors.face,
[PALETTE.SCRUFF]: colors.face,
[PALETTE.CHIN]: colors.face,
[PALETTE.COLLAR]: colors.face,
[PALETTE.COLLAR_SCRUFF]: colors.collar || colors.face,
[PALETTE.SHOULDER]: colors.wing,
}; };
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
this.colors = { ...defaultColors, ...colors, [PALETTE.THEME_HIGHLIGHT]: colors[PALETTE.THEME_HIGHLIGHT] ?? colors.hood ?? colors.face }; this.colors = { ...defaultColors, ...colors, [PALETTE.THEME_HIGHLIGHT]: colors[PALETTE.THEME_HIGHLIGHT] ?? colors.hood ?? colors.face };
this.tags = tags; this.tags = tags;
/** @type {Rarity} */
this.rarity = rarity;
} }
} }
/** @type {Record<string, BirdType>} */ /**
export const SPECIES = { * Load a sprite sheet image and convert it to a 2D array of palette color names
bluebird: new BirdType("Eastern Bluebird", * @param {string} src URL or data URI of the sprite sheet image
"Native to North American and very social, though can be timid around people.", { * @param {boolean} [templateColors] Whether to map pixel colors to palette names
[PALETTE.FOOT]: "#af8e75", * @returns {Promise<string[][]>}
[PALETTE.FACE]: "#639bff", */
[PALETTE.BELLY]: "#f8b143", export function loadSpriteSheetPixels(src, templateColors = true) {
[PALETTE.UNDERBELLY]: "#ec8637", return new Promise((resolve, reject) => {
[PALETTE.WING]: "#578ae6", const img = new Image();
[PALETTE.WING_EDGE]: "#326ed9", img.src = src;
}), img.onload = () => {
shimaEnaga: new BirdType("Shima Enaga", const canvas = document.createElement('canvas');
"Small, fluffy birds found in the snowy regions of Japan, these birds are highly sought after by ornithologists and nature photographers.", { canvas.width = img.width;
[PALETTE.FOOT]: "#af8e75", canvas.height = img.height;
[PALETTE.FACE]: "#ffffff", const ctx = canvas.getContext('2d');
[PALETTE.BELLY]: "#ebe9e8", if (!ctx) {
[PALETTE.UNDERBELLY]: "#ebd9d0", reject(new Error('Failed to get canvas context'));
[PALETTE.WING]: "#f3d3c1", return;
[PALETTE.WING_EDGE]: "#2d2d2d", }
[PALETTE.THEME_HIGHLIGHT]: "#d7ac93", ctx.drawImage(img, 0, 0);
}), const imageData = ctx.getImageData(0, 0, img.width, img.height);
tuftedTitmouse: new BirdType("Tufted Titmouse", const pixels = imageData.data;
"Native to the eastern United States, full of personality, and notably my wife's favorite bird.", { const hexArray = [];
[PALETTE.FOOT]: "#af8e75", for (let y = 0; y < img.height; y++) {
[PALETTE.FACE]: "#c7cad7", const row = [];
[PALETTE.BELLY]: "#e4e5eb", for (let x = 0; x < img.width; x++) {
[PALETTE.UNDERBELLY]: "#d7cfcb", const index = (y * img.width + x) * 4;
[PALETTE.WING]: "#b1b5c5", const r = pixels[index];
[PALETTE.WING_EDGE]: "#9d9fa9", const g = pixels[index + 1];
[PALETTE.THEME_HIGHLIGHT]: "#b9abcf", const b = pixels[index + 2];
}, [TAG.TUFT]), const a = pixels[index + 3];
europeanRobin: new BirdType("European Robin", if (a === 0) {
"Native to western Europe, this is the quintessential robin. Quite friendly, you'll often find them searching for worms.", { row.push(PALETTE.TRANSPARENT);
[PALETTE.FOOT]: "#af8e75", continue;
[PALETTE.FACE]: "#ffaf34", }
[PALETTE.HOOD]: "#aaa094", const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
[PALETTE.BELLY]: "#ffaf34", if (!templateColors) {
[PALETTE.UNDERBELLY]: "#babec2", row.push(hex);
[PALETTE.WING]: "#aaa094", continue;
[PALETTE.WING_EDGE]: "#888580", }
[PALETTE.THEME_HIGHLIGHT]: "#ffaf34", if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
}), row.push(hex);
redCardinal: new BirdType("Red Cardinal", continue;
"Native to the eastern United States, this strikingly red bird is hard to miss.", { }
[PALETTE.BEAK]: "#d93619", row.push(SPRITE_SHEET_COLOR_MAP[hex]);
[PALETTE.FOOT]: "#af8e75", }
[PALETTE.FACE]: "#31353d", hexArray.push(row);
[PALETTE.HOOD]: "#e83a1b", }
[PALETTE.BELLY]: "#e83a1b", resolve(hexArray);
[PALETTE.UNDERBELLY]: "#dc3719",
[PALETTE.WING]: "#d23215",
[PALETTE.WING_EDGE]: "#b1321c",
}, [TAG.TUFT]),
americanGoldfinch: new BirdType("American Goldfinch",
"Coloured a brilliant yellow, this bird feeds almost entirely on the seeds of plants such as thistle, sunflowers, and coneflowers.", {
[PALETTE.BEAK]: "#ffaf34",
[PALETTE.FOOT]: "#af8e75",
[PALETTE.FACE]: "#fff255",
[PALETTE.NOSE]: "#383838",
[PALETTE.HOOD]: "#383838",
[PALETTE.BELLY]: "#fff255",
[PALETTE.UNDERBELLY]: "#f5ea63",
[PALETTE.WING]: "#e8e079",
[PALETTE.WING_EDGE]: "#191919",
[PALETTE.THEME_HIGHLIGHT]: "#ffcc00"
}),
barnSwallow: new BirdType("Barn Swallow",
"Agile birds that often roost in man-made structures, these birds are known to build nests near Ospreys for protection.", {
[PALETTE.FOOT]: "#af8e75",
[PALETTE.FACE]: "#db7c4d",
[PALETTE.BELLY]: "#f7e1c9",
[PALETTE.UNDERBELLY]: "#ebc9a3",
[PALETTE.WING]: "#2252a9",
[PALETTE.WING_EDGE]: "#1c448b",
[PALETTE.HOOD]: "#2252a9",
}),
mistletoebird: new BirdType("Mistletoebird",
"Native to Australia, these birds eat mainly mistletoe and in turn spread the seeds far and wide.", {
[PALETTE.FOOT]: "#6c6a7c",
[PALETTE.FACE]: "#352e6d",
[PALETTE.BELLY]: "#fd6833",
[PALETTE.UNDERBELLY]: "#e6e1d8",
[PALETTE.WING]: "#342b7c",
[PALETTE.WING_EDGE]: "#282065",
}),
redAvadavat: new BirdType("Red Avadavat",
"Native to India and southeast Asia, these birds are also known as Strawberry Finches due to their speckled plumage.", {
[PALETTE.BEAK]: "#f71919",
[PALETTE.FOOT]: "#af7575",
[PALETTE.FACE]: "#cb092b",
[PALETTE.BELLY]: "#ae1724",
[PALETTE.UNDERBELLY]: "#831b24",
[PALETTE.WING]: "#7e3030",
[PALETTE.WING_EDGE]: "#490f0f",
}),
scarletRobin: new BirdType("Scarlet Robin",
"Native to Australia, this striking robin can be found in Eucalyptus forests.", {
[PALETTE.FOOT]: "#494949",
[PALETTE.FACE]: "#3d3d3d",
[PALETTE.BELLY]: "#fc5633",
[PALETTE.UNDERBELLY]: "#dcdcdc",
[PALETTE.WING]: "#2b2b2b",
[PALETTE.WING_EDGE]: "#ebebeb",
[PALETTE.THEME_HIGHLIGHT]: "#fc5633",
}),
americanRobin: new BirdType("American Robin",
"While not a true robin, this social North American bird is so named due to its orange coloring. It seems unbothered by nearby humans.", {
[PALETTE.BEAK]: "#e89f30",
[PALETTE.FOOT]: "#9f8075",
[PALETTE.FACE]: "#2d2d2d",
[PALETTE.BELLY]: "#eb7a3a",
[PALETTE.UNDERBELLY]: "#eb7a3a",
[PALETTE.WING]: "#444444",
[PALETTE.WING_EDGE]: "#232323",
[PALETTE.THEME_HIGHLIGHT]: "#eb7a3a",
}),
carolinaWren: new BirdType("Carolina Wren",
"Native to the eastern United States, these little birds are known for their curious and energetic nature.", {
[PALETTE.FOOT]: "#af8e75",
[PALETTE.FACE]: "#edc7a9",
[PALETTE.NOSE]: "#f7eee5",
[PALETTE.HOOD]: "#c58a5b",
[PALETTE.BELLY]: "#e1b796",
[PALETTE.UNDERBELLY]: "#c79e7c",
[PALETTE.WING]: "#c58a5b",
[PALETTE.WING_EDGE]: "#866348",
}),
}; };
img.onerror = (err) => {
reject(err);
};
});
}
/** @type {Record<string, BirdType>} */
export const SPECIES = Object.fromEntries(
Object.entries(species).map(([id, data]) => [
id,
new BirdType(data.name, data.description, data.latinName, data.url, data.colors, data.tags, data.rarity)
]),
);

View File

@@ -3,7 +3,7 @@ import Layer, { TAG } from './animation/layer.js';
import Anim from './animation/anim.js'; import Anim from './animation/anim.js';
import { Birb, Animations } from './birb.js'; import { Birb, Animations } from './birb.js';
import { Birdsong } from './sound.js'; import { Birdsong } from './sound.js';
import { Context, ObsidianContext } from './context.js'; import { Context } from './context.js';
import { import {
getContext, getContext,
@@ -24,8 +24,9 @@ import {
} from './shared.js'; } from './shared.js';
import { import {
PALETTE, PALETTE,
SPRITE_SHEET_COLOR_MAP, SPECIES,
SPECIES RARITY,
loadSpriteSheetPixels,
} from './animation/sprites.js'; } from './animation/sprites.js';
import { import {
StickyNote, StickyNote,
@@ -50,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
@@ -58,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]
*/ */
/** /**
@@ -109,18 +118,24 @@ const HOP_DELAY = 500;
const HOP_CHANCE = 1 / (60 * 2.5); // Every 2.5 seconds const HOP_CHANCE = 1 / (60 * 2.5); // Every 2.5 seconds
const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours
const HAT_CHANCE = 1 / (60 * 60 * 10); // Every 10 minutes const UNCOMMON_FEATHER_CHANCE = 0.15; // 15% of feathers are uncommon
const HAT_CHANCE = 1 / (60 * 60 * 25); // Every 25 minutes
// Feathers // Feathers
const FEATHER_FALL_SPEED = 1; 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 = {};
@@ -167,12 +182,47 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
}; };
const menuItems = [ const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet), new MenuItem(() => `Pet ${birdBirb()}`, pet, [
new MenuItem("Field Guide", insertFieldGuide), [0, 1, 1, 0, 1, 1, 0],
new MenuItem("Wardrobe", insertWardrobe), [1, 0, 0, 1, 0, 0, 1],
new ConditionalMenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote), () => getContext().areStickyNotesEnabled()), [1, 0, 0, 0, 0, 0, 1],
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)), [0, 1, 0, 0, 0, 1, 0],
new DebugMenuItem("Freeze/Unfreeze", () => { [0, 0, 1, 0, 1, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
]),
new MenuItem("Field Guide", insertFieldGuide, [
[0, 1, 1, 0, 1, 1, 0],
[1, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 1, 0, 0, 1],
[1, 1, 1, 0, 1, 1, 1],
]),
new MenuItem("Wardrobe", insertWardrobe, [
[0, 1, 1, 0, 1, 1, 0],
[1, 0, 0, 1, 0, 0, 1],
[1, 1, 0, 0, 0, 1, 1],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 1, 1, 1, 1, 1, 0],
]),
new ConditionalMenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote), () => getContext().areStickyNotesEnabled(), [
[0, 0, 1, 1, 1, 1, 0],
[0, 1, 0, 0, 0, 1, 0],
[1, 0, 0, 1, 0, 1, 0],
[1, 0, 1, 0, 0, 1, 0],
[1, 0, 0, 0, 0, 1, 0],
[1, 1, 1, 1, 1, 1, 0],
]),
new MenuItem(() => `Hide ${birdBirb()}`, () => birb.setVisible(false), [
[0, 1, 0, 1, 0, 1, 0],
[1, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 1, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 1],
[0, 1, 0, 0, 0, 1, 0],
[0, 0, 1, 1, 1, 0, 0],
]),
new DebugMenuItem("Freeze", () => {
frozen = !frozen; frozen = !frozen;
}), }),
new DebugMenuItem("Reset Data", resetSaveData), new DebugMenuItem("Reset Data", resetSaveData),
@@ -191,11 +241,18 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
setDebug(false); setDebug(false);
}), }),
new Separator(), new Separator(),
new MenuItem("Settings", () => switchMenuItems(settingsItems, updateMenuLocation), false), new MenuItem("Settings", () => switchMenuItems(settingsItems, updateMenuLocation), [
[0, 0, 0, 0, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 1],
[0, 0, 0, 0, 1, 1, 1],
[1, 1, 1, 0, 0, 0, 0],
[1, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 0, 0, 0, 0],
], false),
]; ];
const settingsItems = [ const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), undefined, false),
new Separator(), new Separator(),
new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => { new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.soundEnabled = !settings().soundEnabled; userSettings.soundEnabled = !settings().soundEnabled;
@@ -215,11 +272,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
}), }),
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("__VERSION__", () => { alert("Thank you for using Pocket Bird! You are on version: __VERSION__") }, false), new MenuItem("Build __VERSION__", () => { alert("Thank you for using Pocket Bird! You are on version: __VERSION__") }, undefined, false),
]; ];
const styleElement = document.createElement("style");
/** @type {Birb} */ /** @type {Birb} */
let birb; let birb;
@@ -256,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 = [];
@@ -274,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) {
@@ -308,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);
} }
@@ -345,8 +411,8 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
} }
function onLoad() { function onLoad() {
styleElement.textContent = STYLESHEET; injectStyleElement(getContext().getFontStyles());
document.head.appendChild(styleElement); injectStyleElement(STYLESHEET);
birb = new Birb(BIRB_CSS_SCALE, CANVAS_PIXEL_SIZE, SPRITE_SHEET, SPRITE_WIDTH, SPRITE_HEIGHT, HATS_SPRITE_SHEET); birb = new Birb(BIRB_CSS_SCALE, CANVAS_PIXEL_SIZE, SPRITE_SHEET, SPRITE_WIDTH, SPRITE_HEIGHT, HATS_SPRITE_SHEET);
birb.setAnimation(Animations.BOB); birb.setAnimation(Animations.BOB);
@@ -395,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));
focusOnElement(true); if (!restoreBirdPosition()) {
flyToElement(true);
}
} }
function update() { function update() {
@@ -426,11 +499,11 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
// Idle for a while, do something // Idle for a while, do something
if (focusedElement === null) { if (focusedElement === null) {
// Fly to an element // Fly to an element
focusOnElement(); flyToElement();
lastActionTimestamp = Date.now(); lastActionTimestamp = Date.now();
} else if (Math.random() < FOCUS_SWITCH_CHANCE) { } else if (Math.random() < FOCUS_SWITCH_CHANCE) {
// Fly to another element if idle for a longer while // Fly to another element if idle for a longer while
focusOnElement(); flyToElement();
lastActionTimestamp = Date.now(); lastActionTimestamp = Date.now();
} }
} }
@@ -466,9 +539,11 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
// Update the bird's position // Update the bird's position
if (currentState === States.IDLE) { if (currentState === States.IDLE) {
if (focusedElement && !isWithinHorizontalBounds()) { if (focusedElement && !isWithinHorizontalBounds()) {
flySomewhere(); flyToElement();
} }
if (focusedElement || !holdRestoredYPosition) {
birdY = getFocusedY(); 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)) {
@@ -482,7 +557,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
startY += targetY - oldTargetY; startY += targetY - oldTargetY;
if (targetY < 0 || targetY > getWindowHeight()) { if (targetY < 0 || targetY > getWindowHeight()) {
// Fly to another element or the ground if the focused element moves out of bounds // Fly to another element or the ground if the focused element moves out of bounds
flySomewhere(); flyToElement();
} }
if (birb.draw(SPECIES[currentSpecies], currentHat)) { if (birb.draw(SPECIES[currentSpecies], currentHat)) {
@@ -498,6 +573,25 @@ 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;
}
}
/**
* @param {string|null} stylesheetContents
*/
function injectStyleElement(stylesheetContents) {
if (!stylesheetContents) {
return;
}
const element = document.createElement("style");
element.textContent = stylesheetContents;
document.head.appendChild(element);
} }
/** /**
@@ -551,7 +645,8 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
if (document.querySelector("#" + FEATHER_ID)) { if (document.querySelector("#" + FEATHER_ID)) {
return; return;
} }
const speciesToUnlock = Object.keys(SPECIES).filter((species) => !unlockedSpecies.includes(species)); const rarity = Math.random() < UNCOMMON_FEATHER_CHANCE ? RARITY.UNCOMMON : RARITY.COMMON;
const speciesToUnlock = Object.keys(SPECIES).filter((species) => !unlockedSpecies.includes(species) && SPECIES[species].rarity === rarity);
if (speciesToUnlock.length === 0) { if (speciesToUnlock.length === 0) {
// No more species to unlock // No more species to unlock
return; return;
@@ -669,7 +764,6 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
if (!unlockedHats.includes(hatId)) { if (!unlockedHats.includes(hatId)) {
unlockedHats.push(hatId); unlockedHats.push(hatId);
save(); save();
switchHat(hatId);
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode("You've unlocked the ")); message.appendChild(document.createTextNode("You've unlocked the "));
const bold = document.createElement("b"); const bold = document.createElement("b");
@@ -748,9 +842,23 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
removeWardrobe(); removeWardrobe();
const contentContainer = document.createElement("div"); const contentContainer = document.createElement("div");
const content = makeElement("birb-grid-content"); const familiarBirds = makeElement("birb-grid-content");
const uncommonBirds = makeElement("birb-grid-content");
const familiarLabel = document.createElement("div");
familiarLabel.className = "birb-field-guide-section-label";
familiarLabel.textContent = `----- Familiar ${birdBirb()}s -----`;
const uncommonLabel = document.createElement("div");
uncommonLabel.className = "birb-field-guide-section-label";
uncommonLabel.textContent = `----- Uncommon ${birdBirb()}s -----`;
uncommonLabel.title = "Arbitrarily classified birds that are a little harder to find, but worth the wait!";
const description = makeElement("birb-field-guide-description"); const description = makeElement("birb-field-guide-description");
contentContainer.appendChild(content); contentContainer.appendChild(familiarLabel);
contentContainer.appendChild(familiarBirds);
contentContainer.appendChild(uncommonLabel);
contentContainer.appendChild(uncommonBirds);
contentContainer.appendChild(description); contentContainer.appendChild(description);
const fieldGuide = createWindow( const fieldGuide = createWindow(
@@ -766,14 +874,26 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
const boldName = document.createElement("b"); const boldName = document.createElement("b");
boldName.textContent = type.name; boldName.textContent = type.name;
const spacer = document.createElement("div");
spacer.style.height = "0.3em"; const spacerOne = document.createElement("div");
spacerOne.style.height = "0.3em";
const latinName = document.createElement("a");
latinName.className = "birb-field-guide-latin-name";
latinName.textContent = type.latinName;
latinName.href = type.url;
latinName.target = "_blank";
const spacerTwo = document.createElement("div");
spacerTwo.style.height = "0.4em";
const descText = document.createTextNode(!unlocked ? "Not yet unlocked" : type.description); const descText = document.createTextNode(!unlocked ? "Not yet unlocked" : type.description);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
fragment.appendChild(boldName); fragment.appendChild(boldName);
fragment.appendChild(spacer); fragment.appendChild(spacerOne);
fragment.appendChild(latinName);
fragment.appendChild(spacerTwo);
fragment.appendChild(descText); fragment.appendChild(descText);
return fragment; return fragment;
@@ -795,7 +915,11 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
} }
birb.getFrames().base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type.colors, type.tags); birb.getFrames().base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type.colors, type.tags);
speciesElement.appendChild(speciesCanvas); speciesElement.appendChild(speciesCanvas);
content.appendChild(speciesElement); let section = familiarBirds;
if (type.rarity === RARITY.UNCOMMON) {
section = uncommonBirds;
}
section.appendChild(speciesElement);
if (unlocked) { if (unlocked) {
onClick(speciesElement, () => { onClick(speciesElement, () => {
switchSpecies(id); switchSpecies(id);
@@ -977,26 +1101,6 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
return getWindowHeight() - focusedBounds.top; return getWindowHeight() - focusedBounds.top;
} }
/**
* Fly to either an element or the ground
*/
function flySomewhere() {
// On mobile, always prefer to focus on an element
// If not mobile, 50% chance to focus on ground
// if ((!isMobile() && coinFlip()) || !focusOnElement()) {
// focusOnGround();
// }
if (!focusOnElement()) {
focusOnGround();
}
}
function focusOnGround() {
focusedElement = null;
updateFocusedElementBounds();
flyTo(Math.random() * window.innerWidth, 0);
}
/** /**
* @returns {HTMLElement|null} The random element, or null if no valid element was found * @returns {HTMLElement|null} The random element, or null if no valid element was found
*/ */
@@ -1014,8 +1118,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
} }
return true; return true;
}); });
/** @type {HTMLElement[]} */ const largeElements = /** @type {HTMLElement[]} */ (Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH));
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements // Ensure the bird doesn't land on fixed or sticky elements
// const fixedAllowed = getContext() instanceof ObsidianContext; // const fixedAllowed = getContext() instanceof ObsidianContext;
// TODO: FIX // TODO: FIX
@@ -1035,20 +1138,21 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
} }
/** /**
* Focus on an element within the viewport * Fly to an element within the viewport
* @param {boolean} [teleport] Whether to teleport to the element instead of flying * @param {boolean} [teleport] Whether to teleport to the element instead of flying
* @returns Whether an element to focus on was found * @returns Whether an element to fly to was found (null if flying to the ground)
*/ */
function focusOnElement(teleport = false) { function flyToElement(teleport = false) {
if (frozen) { if (frozen) {
return false; return false;
} }
holdRestoredYPosition = false;
const previousElement = focusedElement;
focusedElement = getRandomValidElement(); focusedElement = getRandomValidElement();
log("Focusing on element: ", focusedElement);
updateFocusedElementBounds(); updateFocusedElementBounds();
if (teleport) { if (teleport) {
teleportTo(getFocusedElementRandomX(), getFocusedY()); teleportTo(getFocusedElementRandomX(), getFocusedY());
} else { } else if (focusedElement !== previousElement) {
flyTo(getFocusedElementRandomX(), getFocusedY()); flyTo(getFocusedElementRandomX(), getFocusedY());
} }
return focusedElement !== null; return focusedElement !== null;
@@ -1059,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);
@@ -1102,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) {
@@ -1132,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);
@@ -1165,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
/** /**
@@ -1193,60 +1477,3 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
init(); init();
draw(); draw();
} }
/**
* Load the sprite sheet and return the pixel-map template
* @param {string} dataUri
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/
function loadSpriteSheetPixels(dataUri, templateColors = true) {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = dataUri;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const pixels = imageData.data;
const hexArray = [];
for (let y = 0; y < img.height; y++) {
const row = [];
for (let x = 0; x < img.width; x++) {
const index = (y * img.width + x) * 4;
const r = pixels[index];
const g = pixels[index + 1];
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(PALETTE.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
if (!templateColors) {
row.push(hex);
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
// Return the color as-is if not found in the map
row.push(hex);
continue;
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}
hexArray.push(row);
}
resolve(hexArray);
};
img.onerror = (err) => {
reject(err);
};
});
}

View File

@@ -3,6 +3,7 @@ import { debug, log, error } from "./shared.js";
export const SAVE_KEY = "birbSaveData"; export const SAVE_KEY = "birbSaveData";
const ROOT_PATH = ""; const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__" const SET_CONTEXT = "__CONTEXT__"
const MONOCRAFT_URL = "__MONOCRAFT_URL__";
/** /**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData * @typedef {import('./application.js').BirbSaveData} BirbSaveData
@@ -92,6 +93,13 @@ export class Context {
areStickyNotesEnabled() { areStickyNotesEnabled() {
return true; return true;
} }
/**
* @returns {string}
*/
getFontStyles() {
return getFontFaceImport(MONOCRAFT_URL);
}
} }
export class LocalContext extends Context { export class LocalContext extends Context {
@@ -194,6 +202,16 @@ export class BrowserExtensionContext extends Context {
// @ts-expect-error // @ts-expect-error
chrome.storage.sync.clear(); chrome.storage.sync.clear();
} }
/**
* @override
* @returns {string}
*/
getFontStyles() {
// Use extension bundled font file
// @ts-expect-error
return getFontFaceImport(chrome.runtime.getURL('fonts/Monocraft.otf'));
}
} }
export class ObsidianContext extends Context { export class ObsidianContext extends Context {
@@ -276,6 +294,14 @@ export class ObsidianContext extends Context {
} }
} }
/**
* @param {string} src
* @returns {string}
*/
function getFontFaceImport(src) {
return `@font-face { font-family: 'Monocraft'; src: url("${src}") format('opentype'); font-weight: normal; font-style: normal; }`;
}
/** /**
* Parse URL parameters into a key-value map * Parse URL parameters into a key-value map
* @param {string} url * @param {string} url

View File

@@ -14,11 +14,13 @@ export class MenuItem {
/** /**
* @param {string|(() => string)} text * @param {string|(() => string)} text
* @param {() => void} action * @param {() => void} action
* @param {number[][]} [icon]
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
constructor(text, action, removeMenu = true) { constructor(text, action, icon, removeMenu = true) {
this.text = text; this.text = text;
this.action = action; this.action = action;
this.icon = icon;
this.removeMenu = removeMenu; this.removeMenu = removeMenu;
} }
} }
@@ -28,10 +30,11 @@ export class ConditionalMenuItem extends MenuItem {
* @param {string} text * @param {string} text
* @param {() => void} action * @param {() => void} action
* @param {() => boolean} condition * @param {() => boolean} condition
* @param {number[][]} [icon]
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
constructor(text, action, condition, removeMenu = true) { constructor(text, action, condition, icon, removeMenu = true) {
super(text, action, removeMenu); super(text, action, icon, removeMenu);
this.condition = condition; this.condition = condition;
} }
} }
@@ -42,7 +45,7 @@ export class DebugMenuItem extends ConditionalMenuItem {
* @param {() => void} action * @param {() => void} action
*/ */
constructor(text, action, removeMenu = true) { constructor(text, action, removeMenu = true) {
super(text, action, () => isDebug(), removeMenu); super(text, action, () => isDebug(), undefined, removeMenu);
} }
} }
@@ -57,11 +60,29 @@ export class Separator extends MenuItem {
* @param {() => void} removeMenuCallback * @param {() => void} removeMenuCallback
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
function makeMenuItem(item, removeMenuCallback) { function createMenuItem(item, removeMenuCallback) {
if (item instanceof Separator) { if (item instanceof Separator) {
return makeElement("birb-window-separator"); return makeElement("birb-window-separator");
} }
let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text); let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
if (item.icon) {
const iconCanvas = document.createElement("canvas");
iconCanvas.width = 7;
iconCanvas.height = 6;
iconCanvas.classList.add("birb-menu-item-icon");
const ctx = iconCanvas.getContext("2d");
if (ctx) {
for (let row = 0; row < item.icon.length; row++) {
for (let col = 0; col < item.icon[row].length; col++) {
if (item.icon[row][col]) {
ctx.fillStyle = "black";
ctx.fillRect(col, row, 1, 1);
}
}
}
}
menuItem.prepend(iconCanvas);
}
onClick(menuItem, () => { onClick(menuItem, () => {
if (item.removeMenu) { if (item.removeMenu) {
removeMenuCallback(); removeMenuCallback();
@@ -89,7 +110,7 @@ export function insertMenu(menuItems, title, updateLocationCallback) {
const removeCallback = () => removeMenu(); const removeCallback = () => removeMenu();
for (const item of menuItems) { for (const item of menuItems) {
if (!(item instanceof ConditionalMenuItem) || item.condition()) { if (!(item instanceof ConditionalMenuItem) || item.condition()) {
content.appendChild(makeMenuItem(item, removeCallback)); content.appendChild(createMenuItem(item, removeCallback));
} }
} }
menu.appendChild(header); menu.appendChild(header);
@@ -146,7 +167,7 @@ export function switchMenuItems(menuItems, updateLocationCallback) {
const removeCallback = () => removeMenu(); const removeCallback = () => removeMenu();
for (const item of menuItems) { for (const item of menuItems) {
if (!(item instanceof ConditionalMenuItem) || item.condition()) { if (!(item instanceof ConditionalMenuItem) || item.condition()) {
content.appendChild(makeMenuItem(item, removeCallback)); content.appendChild(createMenuItem(item, removeCallback));
} }
} }
updateLocationCallback(menu); updateLocationCallback(menu);

View File

@@ -8,16 +8,19 @@ export class Birdsong {
audioContext; audioContext;
chirp() { chirp() {
const count = Math.floor(1 + Math.random() * 1.5);
for (let i = 0; i < count; i++) {
setTimeout(() => {
if (!this.audioContext) { if (!this.audioContext) {
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
} }
const TIMES = [0, 0.06, 0.10, 0.15]; const TIMES = [0, 0.06, 0.10, 0.15];
const FREQUENCIES = [2200, const FREQUENCIES = [2200,
3500 + Math.random() * 600, 3500 + Math.random() * 600 * count,
2100 + Math.random() * 200, 2100 + Math.random() * 200 * count,
1600 + Math.random() * 400]; 1600 + Math.random() * 400 * count];
const VOLUMES = [0.0001, 0.2, 0.2, 0.0001]; const VOLUMES = [0.00005, 0.165, 0.165, 0.0001];
const oscillator = this.audioContext.createOscillator(); const oscillator = this.audioContext.createOscillator();
oscillator.type = "sine"; oscillator.type = "sine";
@@ -39,5 +42,7 @@ export class Birdsong {
oscillator.start(now); oscillator.start(now);
oscillator.stop(now + TIMES[TIMES.length - 1]); oscillator.stop(now + TIMES[TIMES.length - 1]);
}, i * 120);
}
} }
} }

430
src/species.js Normal file
View File

@@ -0,0 +1,430 @@
/** @typedef {Object} Species
* @property {string} name
* @property {string} description
* @property {Record<string, string>} colors
* @property {string[]} [tags]
*/
export default {
"bluebird": {
"name": "Eastern Bluebird",
"description": "Native to North American and very social, though can be timid around people.",
"latinName": "Sialia sialis",
"url": "https://en.wikipedia.org/wiki/Eastern_bluebird",
"colors": {
"foot": "#af8e75",
"face": "#639bff",
"belly": "#f8b143",
"underbelly": "#ec8637",
"wing": "#578ae6",
"wing-edge": "#326ed9"
}
},
"shimaEnaga": {
"name": "Shima Enaga",
"description": "Small, fluffy birds found in the snowy regions of Japan, these birds are highly sought after by ornithologists and nature photographers.",
"latinName": "Aegithalos caudatus",
"url": "https://en.wikipedia.org/wiki/Long-tailed_tit",
"colors": {
"foot": "#af8e75",
"face": "#ffffff",
"belly": "#ebe9e8",
"underbelly": "#ebd9d0",
"wing": "#f3d3c1",
"wing-edge": "#2d2d2d",
"theme-highlight": "#d7ac93"
}
},
"tuftedTitmouse": {
"name": "Tufted Titmouse",
"description": "Native to the eastern United States, full of personality, and notably my wife's favorite bird.",
"latinName": "Baeolophus bicolor",
"url": "https://en.wikipedia.org/wiki/Tufted_titmouse",
"colors": {
"foot": "#af8e75",
"face": "#c7cad7",
"belly": "#e4e5eb",
"underbelly": "#d7cfcb",
"wing": "#b1b5c5",
"wing-edge": "#9d9fa9",
"theme-highlight": "#b9abcf"
},
"tags": [
"tuft"
]
},
"europeanRobin": {
"name": "European Robin",
"description": "Native to western Europe, this is the quintessential robin. Quite friendly, you'll often find them searching for worms.",
"latinName": "Erithacus rubecula",
"url": "https://en.wikipedia.org/wiki/European_robin",
"colors": {
"foot": "#af8e75",
"face": "#ffaf34",
"hood": "#aaa094",
"belly": "#ffaf34",
"underbelly": "#babec2",
"wing": "#aaa094",
"wing-edge": "#888580",
"theme-highlight": "#ffaf34"
}
},
"redCardinal": {
"name": "Red Cardinal",
"description": "Native to the eastern United States, this strikingly red bird is hard to miss.",
"latinName": "Cardinalis cardinalis",
"url": "https://en.wikipedia.org/wiki/Red_cardinal",
"colors": {
"beak": "#d93619",
"foot": "#af8e75",
"face": "#31353d",
"hood": "#e83a1b",
"belly": "#e83a1b",
"underbelly": "#dc3719",
"wing": "#d23215",
"wing-edge": "#b1321c",
"collar": "#e83a1b",
"scruff": "#d23215",
},
"tags": [
"tuft"
]
},
"americanGoldfinch": {
"name": "American Goldfinch",
"description": "Coloured a brilliant yellow, this bird feeds almost entirely on the seeds of plants such as thistle, sunflowers, and coneflowers.",
"latinName": "Spinus tristis",
"url": "https://en.wikipedia.org/wiki/American_goldfinch",
"colors": {
"beak": "#ffaf34",
"foot": "#af8e75",
"face": "#fff255",
"nose": "#383838",
"hood": "#383838",
"belly": "#fff255",
"underbelly": "#f5ea63",
"wing": "#e8e079",
"wing-edge": "#191919",
"theme-highlight": "#ffcc00"
}
},
"barnSwallow": {
"name": "Barn Swallow",
"description": "Agile birds that often roost in man-made structures, these birds are known to build nests near Ospreys for protection.",
"latinName": "Hirundo rustica",
"url": "https://en.wikipedia.org/wiki/Barn_swallow",
"colors": {
"foot": "#af8e75",
"face": "#db7c4d",
"belly": "#f7e1c9",
"underbelly": "#ebc9a3",
"wing": "#2252a9",
"wing-edge": "#1c448b",
"hood": "#2252a9"
}
},
"mistletoebird": {
"name": "Mistletoebird",
"description": "Native to Australia, these birds eat mainly mistletoe and in turn spread the seeds far and wide.",
"latinName": "Dicaeum hirundinaceum",
"url": "https://en.wikipedia.org/wiki/Mistletoebird",
"colors": {
"foot": "#6c6a7c",
"face": "#352e6d",
"belly": "#fd6833",
"underbelly": "#e6e1d8",
"wing": "#342b7c",
"wing-edge": "#282065"
}
},
"scarletRobin": {
"name": "Scarlet Robin",
"description": "Native to Australia, this striking robin can be found in Eucalyptus forests.",
"latinName": "Petroica boodang",
"url": "https://en.wikipedia.org/wiki/Scarlet_robin",
"colors": {
"foot": "#494949",
"face": "#3d3d3d",
"belly": "#fc5633",
"underbelly": "#dcdcdc",
"wing": "#2b2b2b",
"wing-edge": "#ebebeb",
"nose": "#ebebeb",
"theme-highlight": "#fc5633"
}
},
"americanRobin": {
"name": "American Robin",
"description": "While not a true robin, this social North American bird is so named due to its orange coloring. It seems unbothered by nearby humans.",
"latinName": "Turdus migratorius",
"url": "https://en.wikipedia.org/wiki/American_robin",
"colors": {
"beak": "#e89f30",
"foot": "#9f8075",
"face": "#2d2d2d",
"belly": "#eb7a3a",
"underbelly": "#eb7a3a",
"wing": "#444444",
"wing-edge": "#232323",
"theme-highlight": "#eb7a3a"
}
},
"carolinaWren": {
"name": "Carolina Wren",
"description": "Native to the eastern United States, these little birds are known for their curious and energetic nature.",
"latinName": "Thryothorus ludovicianus",
"url": "https://en.wikipedia.org/wiki/Carolina_wren",
"colors": {
"foot": "#af8e75",
"face": "#edc7a9",
"nose": "#f7eee5",
"hood": "#c58a5b",
"belly": "#e1b796",
"underbelly": "#c79e7c",
"wing": "#c58a5b",
"wing-edge": "#866348"
}
},
"blackCappedChickadee": {
"name": "Black-capped Chickadee",
"description": "Native to North America, these small and curious birds are known for their distinctive call from which they get their name.",
"latinName": "Poecile atricapillus",
"url": "https://en.wikipedia.org/wiki/Black-capped_chickadee",
"colors": {
"hood": "#363636",
"cheek": "#363636",
"eyebrow": "#363636",
"nose": "#363636",
"collar": "#363636",
"belly": "#d6d4cf",
"underbelly": "#cfc5b4",
"face": "#eaeaea",
"wing": "#8f8e9a",
"wing-edge": "#706f7d",
"scruff": "#8f8e9a",
"foot": "#535259"
},
"tags": []
},
"blueJay": {
"name": "Blue Jay",
"description": "This loud and rambunctious bird is native to North America and is known for challenging anything in its path.",
"latinName": "Cyanocitta cristata",
"url": "https://en.wikipedia.org/wiki/Blue_jay",
"colors": {
"foot": "#5a626b",
"face": "#ebf2ff",
"belly": "#e5ecfa",
"underbelly": "#c4cbd6",
"wing": "#5890ff",
"wing-edge": "#3a77e8",
"hood": "#6391e8",
"nose": "#6391e8",
"collar": "#2e3136",
"scruff": "#6391e8"
},
"tags": [
"tuft"
]
},
"darkEyedJunco": {
"name": "Dark-eyed Junco",
"description": "Native across North America, these social birds will often be seen hopping along the ground in winter.",
"latinName": "Junco hyemalis",
"url": "https://en.wikipedia.org/wiki/Dark-eyed_junco",
"colors": {
"face": "#55565e",
"wing": "#5c5f69",
"wing-edge": "#444547",
"belly": "#6c7180",
"underbelly": "#b8bbcc",
"foot": "#87776d",
"beak": "#ab8a98"
}
},
"houseFinch": {
"name": "House Finch",
"description": "Native to North America, these highly social birds sing cheerful songs and are often seen at bird feeders.",
"latinName": "Haemorhous mexicanus",
"url": "https://en.wikipedia.org/wiki/House_finch",
"colors": {
"face": "#cc3a3f",
"wing": "#ae8e78",
"wing-edge": "#8f6c54",
"belly": "#d97c77",
"underbelly": "#c5a489",
"foot": "#705b4c",
"beak": "#cf8479",
"hood": "#b02f35",
"nose": "#ab2b31",
"theme-highlight": "#ef444d"
}
},
"pigeon": {
"name": "Rock Pigeon",
"description": "Descended from the Rock Dove, these once domesticated birds are often found in cities worldwide. Quite friendly and intelligent, they were favored companions of Nikola Tesla.",
"latinName": "Columba livia",
"url": "https://en.wikipedia.org/wiki/Rock_dove",
"colors": {
"foot": "#ef6e5b",
"face": "#5a6c91",
"wing-edge": "#65686e",
"nose": "#ebebeb",
"belly": "#977699",
"underbelly": "#b0b3ba",
"wing": "#c7cbd4"
}
},
"redAvadavat": {
"name": "Red Avadavat",
"description": "Native to India and southeast Asia, these birds are also known as Strawberry Finches due to their speckled plumage.",
"latinName": "Amandava amandava",
"url": "https://en.wikipedia.org/wiki/Red_avadavat",
"colors": {
"beak": "#f71919",
"foot": "#af7575",
"face": "#cb092b",
"belly": "#ae1724",
"underbelly": "#831b24",
"wing": "#7e3030",
"wing-edge": "#490f0f",
"wing-spots": "#e8e4e4",
},
"rarity": "uncommon"
},
"pinkRobin": {
"name": "Pink Robin",
"description": "Native to Australia, these bubblegum-pink puffballs are quieter than most, instead relying on their vibrant colours to attract partners.",
"latinName": "Petroica rodinogaster",
"url": "https://en.wikipedia.org/wiki/Pink_robin",
"colors": {
"face": "#403a46",
"wing": "#38333d",
"wing-edge": "#252325",
"underbelly": "#ff7eb8",
"belly": "#ff6eaf",
"foot": "#3c393c",
"theme-highlight": "#ff82ba"
},
"rarity": "uncommon"
},
"spangledCotinga": {
"name": "Spangled Cotinga",
"description": "This South American bird can be found in the Amazon rainforest, flashing its iridescent turquoise feathers high above in the canopy.",
"latinName": "Cotinga cayana",
"url": "https://en.wikipedia.org/wiki/Spangled_cotinga",
"colors": {
"face": "#62eafe",
"chin": "#a12457",
"collar": "#a12457",
"belly": "#62eafe",
"underbelly": "#5cd8ea",
"wing": "#227c89",
"wing-edge": "#13353a",
"foot": "#68696b",
"collar-scruff": "#62eafe"
},
"rarity": "uncommon"
},
"elegantEuphonia": {
"name": "Elegant Euphonia",
"description": "This vividly coloured finch is found throughout Central America and is known for the distinctive blue hood that crowns its head.",
"latinName": "Chlorophonia elegantissima",
"url": "https://en.wikipedia.org/wiki/Elegant_euphonia",
"colors": {
"wing": "#2d31a1",
"wing-edge": "#191c6d",
"face": "#1f2392",
"hood": "#6bc6ed",
"nose-tip": "#fd7e1d",
"foot": "#555650",
"belly": "#ff952b",
"underbelly": "#fd7e1d",
"temple": "#57c8fa",
"upper-corner-eye": "#57c8fa",
"upper-eyelid": "#57c8fa",
"collar-scruff": "#57c8fa",
"scruff": "#57c8fa",
"beak": "#252c31",
"collar": "#191c6d"
},
"rarity": "uncommon"
},
"paintedBunting": {
"name": "Painted Bunting",
"description": "A remarkably colourful bird, this North American species is quite difficult to observe despite its vivid palette due to its shy nature and vulnerable habitat.",
"latinName": "Passerina ciris",
"url": "https://en.wikipedia.org/wiki/Painted_bunting",
"colors": {
"face": "#5567f0",
"underbelly": "#f16534",
"belly": "#ef3b3b",
"wing": "#a3e65a",
"wing-edge": "#91cc50",
"shoulder": "#f6fe40",
"foot": "#767980"
},
"rarity": "uncommon"
},
"redWarbler": {
"name": "Red Warbler",
"description": "Endemic to the highlands of Mexico, this bird has the rare distinction of being one of the very few toxic birds in the world.",
"latinName": "Cardellina rubra",
"url": "https://en.wikipedia.org/wiki/Red_warbler",
"colors": {
"face": "#e80a28",
"belly": "#d90921",
"underbelly": "#c70c18",
"wing": "#ba121d",
"wing-edge": "#5b3535",
"foot": "#5e4645",
"behind-eye": "#deedff",
"temple": "#e8f0fa",
"corner-eye": "#d5e4f5",
"lower-eyelid": "#e34a61",
"beak": "#873535",
"cheek": "#db1734"
},
"rarity": "uncommon"
},
"cubanTody": {
"name": "Cuban Tody",
"description": "As the name suggests, this little green bird is only found on the island of Cuba and is known for being particularly round.",
"latinName": "Todus multicolor",
"url": "https://en.wikipedia.org/wiki/Cuban_tody",
"colors": {
"beak": "#f16f54",
"face": "#5ad63e",
"chin": "#e8273b",
"collar": "#f12d3e",
"belly": "#f6f5e4",
"collar-scruff": "#a3ebff",
"underbelly": "#eae9d2",
"wing": "#11c751",
"wing-edge": "#156631",
"foot": "#ac7055",
"scruff": "#11c751",
"theme-highlight": "#4adc67"
},
"rarity": "uncommon"
},
"violetBackedStarling": {
"name": "Violet-backed Starling",
"description": "Native to Sub-Saharan Africa, these small starlings are known for being the most vividly purple birds in the world.",
"latinName": "Cinnyricinclus leucogaster",
"url": "https://en.wikipedia.org/wiki/Violet-backed_starling",
"colors": {
"face": "#9c3af2",
"wing": "#8f37ed",
"wing-edge": "#5b20c2",
"belly": "#ffffff",
"underbelly": "#f2f2f2",
"foot": "#736a66",
"collar": "#b760e6",
"nose": "#7a2ec7",
"cheek": "#7a2ec7",
"nose-tip": "#7a2ec7"
},
"rarity": "uncommon"
}
}

View File

@@ -1,10 +1,3 @@
@font-face {
font-family: 'Monocraft';
src: url("__MONOCRAFT_SRC__") format('opentype');
font-weight: normal;
font-style: normal;
}
:root { :root {
--birb-border-size: 2px; --birb-border-size: 2px;
--birb-neg-border-size: calc(var(--birb-border-size) * -1); --birb-neg-border-size: calc(var(--birb-border-size) * -1);
@@ -218,15 +211,17 @@
font-size: 14px; font-size: 14px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
padding-left: 10px; padding-left: 2px;
padding-right: 10px; padding-right: 10px;
box-sizing: border-box; box-sizing: border-box;
opacity: 0.7 !important; opacity: 0.7 !important;
user-select: none; user-select: none;
display: flex; display: flex;
justify-content: space-between; justify-content: left;
align-items: center;
cursor: pointer; cursor: pointer;
color: black !important; color: black !important;
transition: background 0.1s, color 0.1s;
} }
.birb-menu-item:hover { .birb-menu-item:hover {
@@ -238,6 +233,21 @@
var(--birb-neg-border-size) 0 var(--birb-highlight), var(--birb-neg-border-size) 0 var(--birb-highlight),
0 var(--birb-neg-border-size) var(--birb-highlight), 0 var(--birb-neg-border-size) var(--birb-highlight),
0 var(--birb-border-size) var(--birb-highlight); 0 var(--birb-border-size) var(--birb-highlight);
transition: none;
}
.birb-menu-item-icon {
width: calc(7 * var(--birb-border-size));
height: calc(6 * var(--birb-border-size));
padding-right: calc(5 * var(--birb-border-size));
flex-shrink: 0;
image-rendering: pixelated;
color: var(--birb-highlight);
opacity: 0.9;
}
.birb-menu-item:hover > .birb-menu-item-icon {
filter: invert(1);
} }
.birb-menu-item-arrow { .birb-menu-item-arrow {
@@ -259,7 +269,7 @@
} }
#birb-field-guide .birb-grid-content { #birb-field-guide .birb-grid-content {
grid-template-rows: repeat(3, auto); grid-template-columns: repeat(4, auto);
} }
#birb-wardrobe .birb-grid-content { #birb-wardrobe .birb-grid-content {
@@ -269,7 +279,7 @@
.birb-grid-content { .birb-grid-content {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: row;
gap: 10px; gap: 10px;
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
@@ -288,10 +298,12 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
transition: border-color 0.1s;
} }
.birb-grid-item:hover { .birb-grid-item:hover {
border-color: var(--birb-highlight); border-color: var(--birb-highlight);
transition: none;
} }
.birb-grid-item canvas { .birb-grid-item canvas {
@@ -301,7 +313,7 @@
} }
.birb-grid-item, .birb-field-guide-description, .birb-message-content { .birb-grid-item, .birb-field-guide-description, .birb-message-content {
border: var(--birb-border-size) solid rgb(255, 207, 144); border: var(--birb-border-size) solid #ffcf90;
box-shadow: 0 0 0 var(--birb-border-size) white; box-shadow: 0 0 0 var(--birb-border-size) white;
background: rgba(255, 221, 177, 0.5); background: rgba(255, 221, 177, 0.5);
} }
@@ -320,6 +332,15 @@
background: var(--birb-mix-color); background: var(--birb-mix-color);
} }
.birb-field-guide-section-label {
padding-top: 4px;
/* padding-left: calc(10px + var(--birb-border-size) / 2); */
color: #876c4e;
text-align: center;
/* Italics */
font-style: italic;
}
.birb-field-guide-description { .birb-field-guide-description {
max-width: calc(100% - 20px); max-width: calc(100% - 20px);
margin-left: 10px; margin-left: 10px;
@@ -331,7 +352,14 @@
margin-bottom: 10px; margin-bottom: 10px;
font-size: 14px; font-size: 14px;
box-sizing: border-box; box-sizing: border-box;
color: rgb(124, 108, 75); color: #7c6c4b;
}
.birb-field-guide-latin-name {
text-decoration: underline;
font-style: italic;
font-weight: bold;
color: inherit;
} }
#birb-feather { #birb-feather {
@@ -344,7 +372,7 @@
width: 100%; width: 100%;
padding: 10px; padding: 10px;
font-size: 14px; font-size: 14px;
color: rgb(124, 108, 75); color: #7c6c4b;
} }
.birb-sticky-note { .birb-sticky-note {