Compare commits
70 Commits
soft-outli
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5797c055ed | ||
|
|
96ff61625a | ||
|
|
85ade65a57 | ||
|
|
2f3d7958ea | ||
|
|
eab6086f4d | ||
|
|
c770e3d1f6 | ||
|
|
0d007f8c1e | ||
|
|
92c083138d | ||
|
|
5d6ea50c87 | ||
|
|
77a29c549f | ||
|
|
a8ba15489f | ||
|
|
39e84be775 | ||
|
|
f924343ac3 | ||
|
|
fd55924025 | ||
|
|
f891f8f06d | ||
|
|
9aee4eab1a | ||
|
|
763b50f34b | ||
|
|
b4c577a0ac | ||
|
|
5a3b555d3a | ||
|
|
74a776cd4f | ||
|
|
ebb9f92be2 | ||
|
|
b7d6ca63c1 | ||
|
|
7d16459a76 | ||
|
|
abe4439d5e | ||
|
|
6f88d386ec | ||
|
|
86a14d6dca | ||
|
|
30b9c86cca | ||
|
|
3765713fd0 | ||
|
|
0f90eb4492 | ||
|
|
30d6c2fee5 | ||
|
|
18fa5e8683 | ||
|
|
c43dd4c7b4 | ||
|
|
9e6f5feae1 | ||
|
|
b6e93088a8 | ||
|
|
b440f633a5 | ||
|
|
7b20a376ce | ||
|
|
61fbe89986 | ||
|
|
c1511aae71 | ||
|
|
0a11ebe87d | ||
|
|
5cf96da868 | ||
|
|
5d99142b74 | ||
|
|
2a7ad229be | ||
|
|
c880b99744 | ||
|
|
fe0310cb36 | ||
|
|
efddf12ba5 | ||
|
|
7aa9996857 | ||
|
|
7f334d789f | ||
|
|
a57615b3da | ||
|
|
37a8b6cc6e | ||
|
|
31a3f7cac9 | ||
|
|
9fb0ab3f3f | ||
|
|
736d01e015 | ||
|
|
dd3ef01bef | ||
|
|
3e48360632 | ||
|
|
3eda5ffc92 | ||
|
|
6cfd32270c | ||
|
|
1d4c1a000e | ||
|
|
71b74c9b6f | ||
|
|
80bcf60a07 | ||
|
|
a2dea8a17d | ||
|
|
fd09a35b51 | ||
|
|
11ea3c012b | ||
|
|
1bf82dfbad | ||
|
|
b04edbc2c5 | ||
|
|
927b287f98 | ||
|
|
45743d2caf | ||
|
|
953d2cde47 | ||
|
|
6309aed971 | ||
|
|
ea85c61955 | ||
|
|
cd06a886bd |
5
.gitignore
vendored
@@ -3,3 +3,8 @@
|
||||
/dist/birb.bundled.js
|
||||
obsidian-test.sh
|
||||
build-cache.json
|
||||
.vscode/settings.json
|
||||
aseprite/birb-test.aseprite
|
||||
aseprite/wren.aseprite
|
||||
aseprite/birb-no-shoulder.aseprite
|
||||
aseprite/birb-fat.aseprite
|
||||
|
||||
@@ -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!
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 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:
|
||||
|
||||
- [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!*
|
||||
|
||||
|
||||
24
build.js
@@ -35,7 +35,7 @@ const MONOCRAFT_URL = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40
|
||||
|
||||
const VERSION_KEY = "__VERSION__";
|
||||
const STYLESHEET_KEY = "___STYLESHEET___";
|
||||
const MONOCRAFT_SRC_KEY = "__MONOCRAFT_SRC__";
|
||||
const MONOCRAFT_URL_KEY = "__MONOCRAFT_URL__";
|
||||
const CODE_KEY = "__CODE__";
|
||||
|
||||
const spriteSheets = [
|
||||
@@ -85,7 +85,9 @@ writeFileSync(BUILD_CACHE_PATH, JSON.stringify(buildCache), 'utf8');
|
||||
|
||||
/**
|
||||
* @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>}
|
||||
*/
|
||||
async function generateCode(entryPoint, embedFont = false) {
|
||||
@@ -109,6 +111,15 @@ async function generateCode(entryPoint, embedFont = false) {
|
||||
// Replace version placeholder
|
||||
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
|
||||
for (const spriteSheet of spriteSheets) {
|
||||
const dataUri = readFileSync(spriteSheet.path, 'base64');
|
||||
@@ -119,14 +130,6 @@ async function generateCode(entryPoint, embedFont = false) {
|
||||
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -187,6 +190,7 @@ async function buildExtension() {
|
||||
}
|
||||
|
||||
async function buildObsidian() {
|
||||
// embedFont=true: bakes the font as base64 so the plugin works fully offline.
|
||||
const birbJs = await generateCode(OBSIDIAN_ENTRY, true);
|
||||
|
||||
mkdirSync(OBSIDIAN_DIR, { recursive: true });
|
||||
|
||||
BIN
dist/extension.zip
vendored
1394
dist/extension/birb.js
vendored
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
dist/extension/images/icons/transparent/16x16x1.png
vendored
|
Before Width: | Height: | Size: 635 B After Width: | Height: | Size: 662 B |
BIN
dist/extension/images/icons/transparent/16x16x2.png
vendored
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
BIN
dist/extension/images/icons/transparent/27x20x2.png
vendored
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 881 B |
BIN
dist/extension/images/icons/transparent/27x20x3.png
vendored
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 996 B |
BIN
dist/extension/images/icons/transparent/29x29x2.png
vendored
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 933 B |
BIN
dist/extension/images/icons/transparent/29x29x3.png
vendored
|
Before Width: | Height: | Size: 1018 B After Width: | Height: | Size: 1.0 KiB |
BIN
dist/extension/images/icons/transparent/32x24x2.png
vendored
|
Before Width: | Height: | Size: 881 B After Width: | Height: | Size: 933 B |
BIN
dist/extension/images/icons/transparent/32x24x3.png
vendored
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 1008 B |
BIN
dist/extension/images/icons/transparent/32x32x1.png
vendored
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 848 B |
BIN
dist/extension/images/icons/transparent/32x32x2.png
vendored
|
Before Width: | Height: | Size: 936 B After Width: | Height: | Size: 957 B |
BIN
dist/extension/images/icons/transparent/48x48x1.png
vendored
|
Before Width: | Height: | Size: 856 B After Width: | Height: | Size: 866 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
BIN
dist/extension/images/icons/transparent/60x45x2.png
vendored
|
Before Width: | Height: | Size: 1014 B After Width: | Height: | Size: 1.0 KiB |
BIN
dist/extension/images/icons/transparent/60x45x3.png
vendored
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
dist/extension/images/icons/transparent/67x50x2.png
vendored
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
dist/extension/images/icons/transparent/74x55x2.png
vendored
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
dist/extension/images/icons/transparent/96x96x1.png
vendored
|
Before Width: | Height: | Size: 1018 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
2
dist/extension/manifest.json
vendored
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Pocket Bird",
|
||||
"description": "It's a pet bird in your browser, what more could you want?",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.4.6",
|
||||
"homepage_url": "https://idreesinc.com",
|
||||
"icons": {
|
||||
"48": "images/icons/transparent/48x48x1.png",
|
||||
|
||||
1386
dist/obsidian/main.js
vendored
2
dist/obsidian/manifest.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "pocket-bird",
|
||||
"name": "Pocket Bird",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.4.6",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Add a pet bird to fly around your notes and keep you company!",
|
||||
"author": "Idrees Hassan",
|
||||
|
||||
1386
dist/userscript/birb.user.js
vendored
1384
dist/web/birb.embed.js
vendored
1384
dist/web/birb.js
vendored
380
editor/editor.js
Normal 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
@@ -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
@@ -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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 635 B After Width: | Height: | Size: 662 B |
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 881 B |
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 996 B |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 933 B |
|
Before Width: | Height: | Size: 1018 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 881 B After Width: | Height: | Size: 933 B |
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 1008 B |
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 936 B After Width: | Height: | Size: 957 B |
|
Before Width: | Height: | Size: 856 B After Width: | Height: | Size: 866 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1014 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1018 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
BIN
sprites/birb.png
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
sprites/hats.png
|
Before Width: | Height: | Size: 939 B After Width: | Height: | Size: 949 B |
@@ -1,10 +1,6 @@
|
||||
import { TAG } from "./layer.js";
|
||||
import species from "../species.js"
|
||||
|
||||
/**
|
||||
* Palette color names
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
export const PALETTE = {
|
||||
export const PALETTE = Object.freeze(/** @type {const} */ ({
|
||||
THEME_HIGHLIGHT: "theme-highlight",
|
||||
TRANSPARENT: "transparent",
|
||||
OUTLINE: "outline",
|
||||
@@ -14,20 +10,37 @@ export const PALETTE = {
|
||||
EYE: "eye",
|
||||
FACE: "face",
|
||||
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_TIP: "nose-tip",
|
||||
CHEEK: "cheek",
|
||||
SCRUFF: "scruff",
|
||||
CHIN: "chin",
|
||||
COLLAR: "collar",
|
||||
COLLAR_SCRUFF: "collar-scruff",
|
||||
BELLY: "belly",
|
||||
UNDERBELLY: "underbelly",
|
||||
WING: "wing",
|
||||
SHOULDER: "shoulder",
|
||||
WING_SPOTS: "wing-spots",
|
||||
WING_EDGE: "wing-edge",
|
||||
HEART: "heart",
|
||||
HEART_BORDER: "heart-border",
|
||||
HEART_SHINE: "heart-shine",
|
||||
FEATHER_SPINE: "feather-spine",
|
||||
};
|
||||
}));
|
||||
|
||||
/** @typedef {typeof PALETTE[keyof typeof PALETTE]} PaletteColor */
|
||||
|
||||
/**
|
||||
* Mapping of sprite sheet colors to palette colors
|
||||
* @type {Record<string, string>}
|
||||
* @type {Record<string, PaletteColor>}
|
||||
*/
|
||||
export const SPRITE_SHEET_COLOR_MAP = {
|
||||
"transparent": PALETTE.TRANSPARENT,
|
||||
@@ -39,10 +52,25 @@ export const SPRITE_SHEET_COLOR_MAP = {
|
||||
"#af8e75": PALETTE.FOOT,
|
||||
"#639bff": PALETTE.FACE,
|
||||
"#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,
|
||||
"#b93844": PALETTE.NOSE_TIP,
|
||||
"#ff67a9": PALETTE.CHEEK,
|
||||
"#c5e550": PALETTE.SCRUFF,
|
||||
"#b87af1": PALETTE.CHIN,
|
||||
"#ffe955": PALETTE.COLLAR,
|
||||
"#f8ff55": PALETTE.COLLAR_SCRUFF,
|
||||
"#f8b143": PALETTE.BELLY,
|
||||
"#ec8637": PALETTE.UNDERBELLY,
|
||||
"#578ae6": PALETTE.WING,
|
||||
"#55d1f3": PALETTE.SHOULDER,
|
||||
"#90b0e8": PALETTE.WING_SPOTS,
|
||||
"#326ed9": PALETTE.WING_EDGE,
|
||||
"#c82e2e": PALETTE.HEART,
|
||||
"#501a1a": PALETTE.HEART_BORDER,
|
||||
@@ -50,16 +78,52 @@ export const SPRITE_SHEET_COLOR_MAP = {
|
||||
"#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 {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} description
|
||||
* @param {string} latinName
|
||||
* @param {string} url
|
||||
* @param {Record<string, string>} colors
|
||||
* @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.description = description;
|
||||
this.latinName = latinName;
|
||||
this.url = url;
|
||||
const defaultColors = {
|
||||
[PALETTE.TRANSPARENT]: "transparent",
|
||||
[PALETTE.OUTLINE]: "#000000",
|
||||
@@ -71,139 +135,90 @@ export class BirdType {
|
||||
[PALETTE.HEART_SHINE]: "#ff6b6b",
|
||||
[PALETTE.FEATHER_SPINE]: "#373737",
|
||||
[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_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>} */
|
||||
this.colors = { ...defaultColors, ...colors, [PALETTE.THEME_HIGHLIGHT]: colors[PALETTE.THEME_HIGHLIGHT] ?? colors.hood ?? colors.face };
|
||||
this.tags = tags;
|
||||
/** @type {Rarity} */
|
||||
this.rarity = rarity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a sprite sheet image and convert it to a 2D array of palette color names
|
||||
* @param {string} src URL or data URI of the sprite sheet image
|
||||
* @param {boolean} [templateColors] Whether to map pixel colors to palette names
|
||||
* @returns {Promise<string[][]>}
|
||||
*/
|
||||
export function loadSpriteSheetPixels(src, templateColors = true) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
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) {
|
||||
row.push(hex);
|
||||
continue;
|
||||
}
|
||||
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
|
||||
}
|
||||
hexArray.push(row);
|
||||
}
|
||||
resolve(hexArray);
|
||||
};
|
||||
img.onerror = (err) => {
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {Record<string, BirdType>} */
|
||||
export const SPECIES = {
|
||||
bluebird: new BirdType("Eastern Bluebird",
|
||||
"Native to North American and very social, though can be timid around people.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#639bff",
|
||||
[PALETTE.BELLY]: "#f8b143",
|
||||
[PALETTE.UNDERBELLY]: "#ec8637",
|
||||
[PALETTE.WING]: "#578ae6",
|
||||
[PALETTE.WING_EDGE]: "#326ed9",
|
||||
}),
|
||||
shimaEnaga: new BirdType("Shima Enaga",
|
||||
"Small, fluffy birds found in the snowy regions of Japan, these birds are highly sought after by ornithologists and nature photographers.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#ffffff",
|
||||
[PALETTE.BELLY]: "#ebe9e8",
|
||||
[PALETTE.UNDERBELLY]: "#ebd9d0",
|
||||
[PALETTE.WING]: "#f3d3c1",
|
||||
[PALETTE.WING_EDGE]: "#2d2d2d",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#d7ac93",
|
||||
}),
|
||||
tuftedTitmouse: new BirdType("Tufted Titmouse",
|
||||
"Native to the eastern United States, full of personality, and notably my wife's favorite bird.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#c7cad7",
|
||||
[PALETTE.BELLY]: "#e4e5eb",
|
||||
[PALETTE.UNDERBELLY]: "#d7cfcb",
|
||||
[PALETTE.WING]: "#b1b5c5",
|
||||
[PALETTE.WING_EDGE]: "#9d9fa9",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#b9abcf",
|
||||
}, [TAG.TUFT]),
|
||||
europeanRobin: new BirdType("European Robin",
|
||||
"Native to western Europe, this is the quintessential robin. Quite friendly, you'll often find them searching for worms.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#ffaf34",
|
||||
[PALETTE.HOOD]: "#aaa094",
|
||||
[PALETTE.BELLY]: "#ffaf34",
|
||||
[PALETTE.UNDERBELLY]: "#babec2",
|
||||
[PALETTE.WING]: "#aaa094",
|
||||
[PALETTE.WING_EDGE]: "#888580",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#ffaf34",
|
||||
}),
|
||||
redCardinal: new BirdType("Red Cardinal",
|
||||
"Native to the eastern United States, this strikingly red bird is hard to miss.", {
|
||||
[PALETTE.BEAK]: "#d93619",
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#31353d",
|
||||
[PALETTE.HOOD]: "#e83a1b",
|
||||
[PALETTE.BELLY]: "#e83a1b",
|
||||
[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",
|
||||
}),
|
||||
};
|
||||
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)
|
||||
]),
|
||||
);
|
||||
@@ -3,7 +3,7 @@ import Layer, { TAG } from './animation/layer.js';
|
||||
import Anim from './animation/anim.js';
|
||||
import { Birb, Animations } from './birb.js';
|
||||
import { Birdsong } from './sound.js';
|
||||
import { Context, ObsidianContext } from './context.js';
|
||||
import { Context } from './context.js';
|
||||
|
||||
import {
|
||||
getContext,
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
} from './shared.js';
|
||||
import {
|
||||
PALETTE,
|
||||
SPRITE_SHEET_COLOR_MAP,
|
||||
SPECIES
|
||||
SPECIES,
|
||||
RARITY,
|
||||
loadSpriteSheetPixels,
|
||||
} from './animation/sprites.js';
|
||||
import {
|
||||
StickyNote,
|
||||
@@ -50,6 +51,13 @@ import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js';
|
||||
* @typedef {import('./stickyNotes.js').SavedStickyNote} SavedStickyNote
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SavedBirdPosition
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} updatedAt
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BirbSaveData
|
||||
* @property {string[]} unlockedSpecies
|
||||
@@ -58,6 +66,7 @@ import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js';
|
||||
* @property {string} currentHat
|
||||
* @property {Partial<Settings>} settings
|
||||
* @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 FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds
|
||||
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
|
||||
const FEATHER_FALL_SPEED = 1;
|
||||
|
||||
// Petting boosts
|
||||
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
|
||||
const PET_FEATHER_BOOST = 2;
|
||||
const PET_HAT_BOOST = 1.5;
|
||||
const PET_FEATHER_BOOST = 2; // Multiplier for feather effect
|
||||
const PET_HAT_BOOST = 1.5; // Multiplier for hat effect
|
||||
|
||||
// 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>} */
|
||||
let userSettings = {};
|
||||
@@ -167,12 +182,47 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
new MenuItem(`Pet ${birdBirb()}`, pet),
|
||||
new MenuItem("Field Guide", insertFieldGuide),
|
||||
new MenuItem("Wardrobe", insertWardrobe),
|
||||
new ConditionalMenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote), () => getContext().areStickyNotesEnabled()),
|
||||
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
|
||||
new DebugMenuItem("Freeze/Unfreeze", () => {
|
||||
new MenuItem(() => `Pet ${birdBirb()}`, pet, [
|
||||
[0, 1, 1, 0, 1, 1, 0],
|
||||
[1, 0, 0, 1, 0, 0, 1],
|
||||
[1, 0, 0, 0, 0, 0, 1],
|
||||
[0, 1, 0, 0, 0, 1, 0],
|
||||
[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;
|
||||
}),
|
||||
new DebugMenuItem("Reset Data", resetSaveData),
|
||||
@@ -191,11 +241,18 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
setDebug(false);
|
||||
}),
|
||||
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 = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
|
||||
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), undefined, false),
|
||||
new Separator(),
|
||||
new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
|
||||
userSettings.soundEnabled = !settings().soundEnabled;
|
||||
@@ -215,11 +272,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
}),
|
||||
new Separator(),
|
||||
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} */
|
||||
let birb;
|
||||
|
||||
@@ -256,6 +311,13 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
let currentHat = DEFAULT_HAT;
|
||||
// let visible = true;
|
||||
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[]} */
|
||||
let stickyNotes = [];
|
||||
|
||||
@@ -274,6 +336,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
|
||||
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
|
||||
currentHat = saveData.currentHat ?? DEFAULT_HAT;
|
||||
savedBirdPositions = sanitizeSavedBirdPositions(saveData.birdPositions);
|
||||
stickyNotes = [];
|
||||
|
||||
if (saveData.stickyNotes) {
|
||||
@@ -308,6 +371,9 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
left: note.left
|
||||
}));
|
||||
}
|
||||
if (Object.keys(savedBirdPositions).length > 0) {
|
||||
saveData.birdPositions = savedBirdPositions;
|
||||
}
|
||||
|
||||
getContext().putSaveData(saveData);
|
||||
}
|
||||
@@ -345,8 +411,8 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
styleElement.textContent = STYLESHEET;
|
||||
document.head.appendChild(styleElement);
|
||||
injectStyleElement(getContext().getFontStyles());
|
||||
injectStyleElement(STYLESHEET);
|
||||
|
||||
birb = new Birb(BIRB_CSS_SCALE, CANVAS_PIXEL_SIZE, SPRITE_SHEET, SPRITE_WIDTH, SPRITE_HEIGHT, HATS_SPRITE_SHEET);
|
||||
birb.setAnimation(Animations.BOB);
|
||||
@@ -395,19 +461,26 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
|
||||
drawStickyNotes(stickyNotes, save, deleteStickyNote);
|
||||
|
||||
let lastPath = getContext().getPath().split("?")[0];
|
||||
let lastPath = normalizePath(getContext().getPath());
|
||||
setInterval(() => {
|
||||
const currentPath = getContext().getPath().split("?")[0];
|
||||
const currentPath = normalizePath(getContext().getPath());
|
||||
if (currentPath !== lastPath) {
|
||||
log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
|
||||
saveBirdPosition(true);
|
||||
lastPath = currentPath;
|
||||
drawStickyNotes(stickyNotes, save, deleteStickyNote);
|
||||
restoreBirdPosition();
|
||||
}
|
||||
}, URL_CHECK_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() {
|
||||
@@ -426,11 +499,11 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
// Idle for a while, do something
|
||||
if (focusedElement === null) {
|
||||
// Fly to an element
|
||||
focusOnElement();
|
||||
flyToElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
} else if (Math.random() < FOCUS_SWITCH_CHANCE) {
|
||||
// Fly to another element if idle for a longer while
|
||||
focusOnElement();
|
||||
flyToElement();
|
||||
lastActionTimestamp = Date.now();
|
||||
}
|
||||
}
|
||||
@@ -466,9 +539,11 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
// Update the bird's position
|
||||
if (currentState === States.IDLE) {
|
||||
if (focusedElement && !isWithinHorizontalBounds()) {
|
||||
flySomewhere();
|
||||
flyToElement();
|
||||
}
|
||||
if (focusedElement || !holdRestoredYPosition) {
|
||||
birdY = getFocusedY();
|
||||
}
|
||||
birdY = getFocusedY();
|
||||
} else if (currentState === States.FLYING) {
|
||||
// Fly to target location (even if in the air)
|
||||
if (updateParabolicPath(FLY_SPEED, 2)) {
|
||||
@@ -482,7 +557,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
startY += targetY - oldTargetY;
|
||||
if (targetY < 0 || targetY > getWindowHeight()) {
|
||||
// Fly to another element or the ground if the focused element moves out of bounds
|
||||
flySomewhere();
|
||||
flyToElement();
|
||||
}
|
||||
|
||||
if (birb.draw(SPECIES[currentSpecies], currentHat)) {
|
||||
@@ -498,6 +573,25 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
// Update HTML element position
|
||||
birb.setX(birdX);
|
||||
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)) {
|
||||
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) {
|
||||
// No more species to unlock
|
||||
return;
|
||||
@@ -669,7 +764,6 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
if (!unlockedHats.includes(hatId)) {
|
||||
unlockedHats.push(hatId);
|
||||
save();
|
||||
switchHat(hatId);
|
||||
const message = makeElement("birb-message-content");
|
||||
message.appendChild(document.createTextNode("You've unlocked the "));
|
||||
const bold = document.createElement("b");
|
||||
@@ -748,9 +842,23 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
removeWardrobe();
|
||||
|
||||
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");
|
||||
contentContainer.appendChild(content);
|
||||
contentContainer.appendChild(familiarLabel);
|
||||
contentContainer.appendChild(familiarBirds);
|
||||
contentContainer.appendChild(uncommonLabel);
|
||||
contentContainer.appendChild(uncommonBirds);
|
||||
contentContainer.appendChild(description);
|
||||
|
||||
const fieldGuide = createWindow(
|
||||
@@ -766,14 +874,26 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
const boldName = document.createElement("b");
|
||||
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 fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(boldName);
|
||||
fragment.appendChild(spacer);
|
||||
fragment.appendChild(spacerOne);
|
||||
fragment.appendChild(latinName);
|
||||
fragment.appendChild(spacerTwo);
|
||||
fragment.appendChild(descText);
|
||||
|
||||
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);
|
||||
speciesElement.appendChild(speciesCanvas);
|
||||
content.appendChild(speciesElement);
|
||||
let section = familiarBirds;
|
||||
if (type.rarity === RARITY.UNCOMMON) {
|
||||
section = uncommonBirds;
|
||||
}
|
||||
section.appendChild(speciesElement);
|
||||
if (unlocked) {
|
||||
onClick(speciesElement, () => {
|
||||
switchSpecies(id);
|
||||
@@ -977,26 +1101,6 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
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
|
||||
*/
|
||||
@@ -1014,8 +1118,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
/** @type {HTMLElement[]} */
|
||||
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
|
||||
const largeElements = /** @type {HTMLElement[]} */ (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
|
||||
// const fixedAllowed = getContext() instanceof ObsidianContext;
|
||||
// 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
|
||||
* @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) {
|
||||
return false;
|
||||
}
|
||||
holdRestoredYPosition = false;
|
||||
const previousElement = focusedElement;
|
||||
focusedElement = getRandomValidElement();
|
||||
log("Focusing on element: ", focusedElement);
|
||||
updateFocusedElementBounds();
|
||||
if (teleport) {
|
||||
teleportTo(getFocusedElementRandomX(), getFocusedY());
|
||||
} else {
|
||||
} else if (focusedElement !== previousElement) {
|
||||
flyTo(getFocusedElementRandomX(), getFocusedY());
|
||||
}
|
||||
return focusedElement !== null;
|
||||
@@ -1059,6 +1163,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
* @param {number} y
|
||||
*/
|
||||
function teleportTo(x, y) {
|
||||
holdRestoredYPosition = false;
|
||||
birdX = x;
|
||||
birdY = y;
|
||||
setState(States.IDLE);
|
||||
@@ -1102,6 +1207,7 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
return;
|
||||
}
|
||||
if (currentState === States.IDLE) {
|
||||
holdRestoredYPosition = false;
|
||||
setState(States.HOP);
|
||||
birb.setAnimation(Animations.FLYING);
|
||||
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
|
||||
*/
|
||||
function flyTo(x, y) {
|
||||
holdRestoredYPosition = false;
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
setState(States.FLYING);
|
||||
@@ -1165,6 +1272,183 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -1193,60 +1477,3 @@ function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
init();
|
||||
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);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { debug, log, error } from "./shared.js";
|
||||
export const SAVE_KEY = "birbSaveData";
|
||||
const ROOT_PATH = "";
|
||||
const SET_CONTEXT = "__CONTEXT__"
|
||||
const MONOCRAFT_URL = "__MONOCRAFT_URL__";
|
||||
|
||||
/**
|
||||
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
|
||||
@@ -92,6 +93,13 @@ export class Context {
|
||||
areStickyNotesEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
getFontStyles() {
|
||||
return getFontFaceImport(MONOCRAFT_URL);
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalContext extends Context {
|
||||
@@ -194,6 +202,16 @@ export class BrowserExtensionContext extends Context {
|
||||
// @ts-expect-error
|
||||
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 {
|
||||
@@ -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
|
||||
* @param {string} url
|
||||
|
||||
35
src/menu.js
@@ -14,11 +14,13 @@ export class MenuItem {
|
||||
/**
|
||||
* @param {string|(() => string)} text
|
||||
* @param {() => void} action
|
||||
* @param {number[][]} [icon]
|
||||
* @param {boolean} [removeMenu]
|
||||
*/
|
||||
constructor(text, action, removeMenu = true) {
|
||||
constructor(text, action, icon, removeMenu = true) {
|
||||
this.text = text;
|
||||
this.action = action;
|
||||
this.icon = icon;
|
||||
this.removeMenu = removeMenu;
|
||||
}
|
||||
}
|
||||
@@ -28,10 +30,11 @@ export class ConditionalMenuItem extends MenuItem {
|
||||
* @param {string} text
|
||||
* @param {() => void} action
|
||||
* @param {() => boolean} condition
|
||||
* @param {number[][]} [icon]
|
||||
* @param {boolean} [removeMenu]
|
||||
*/
|
||||
constructor(text, action, condition, removeMenu = true) {
|
||||
super(text, action, removeMenu);
|
||||
constructor(text, action, condition, icon, removeMenu = true) {
|
||||
super(text, action, icon, removeMenu);
|
||||
this.condition = condition;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +45,7 @@ export class DebugMenuItem extends ConditionalMenuItem {
|
||||
* @param {() => void} action
|
||||
*/
|
||||
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
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function makeMenuItem(item, removeMenuCallback) {
|
||||
function createMenuItem(item, removeMenuCallback) {
|
||||
if (item instanceof Separator) {
|
||||
return makeElement("birb-window-separator");
|
||||
}
|
||||
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, () => {
|
||||
if (item.removeMenu) {
|
||||
removeMenuCallback();
|
||||
@@ -89,7 +110,7 @@ export function insertMenu(menuItems, title, updateLocationCallback) {
|
||||
const removeCallback = () => removeMenu();
|
||||
for (const item of menuItems) {
|
||||
if (!(item instanceof ConditionalMenuItem) || item.condition()) {
|
||||
content.appendChild(makeMenuItem(item, removeCallback));
|
||||
content.appendChild(createMenuItem(item, removeCallback));
|
||||
}
|
||||
}
|
||||
menu.appendChild(header);
|
||||
@@ -146,7 +167,7 @@ export function switchMenuItems(menuItems, updateLocationCallback) {
|
||||
const removeCallback = () => removeMenu();
|
||||
for (const item of menuItems) {
|
||||
if (!(item instanceof ConditionalMenuItem) || item.condition()) {
|
||||
content.appendChild(makeMenuItem(item, removeCallback));
|
||||
content.appendChild(createMenuItem(item, removeCallback));
|
||||
}
|
||||
}
|
||||
updateLocationCallback(menu);
|
||||
|
||||
65
src/sound.js
@@ -8,36 +8,41 @@ export class Birdsong {
|
||||
audioContext;
|
||||
|
||||
chirp() {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new AudioContext();
|
||||
const count = Math.floor(1 + Math.random() * 1.5);
|
||||
for (let i = 0; i < count; i++) {
|
||||
setTimeout(() => {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
const TIMES = [0, 0.06, 0.10, 0.15];
|
||||
const FREQUENCIES = [2200,
|
||||
3500 + Math.random() * 600 * count,
|
||||
2100 + Math.random() * 200 * count,
|
||||
1600 + Math.random() * 400 * count];
|
||||
const VOLUMES = [0.00005, 0.165, 0.165, 0.0001];
|
||||
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
oscillator.type = "sine";
|
||||
const gain = this.audioContext.createGain();
|
||||
oscillator.connect(gain);
|
||||
gain.connect(this.audioContext.destination);
|
||||
|
||||
const now = this.audioContext.currentTime;
|
||||
for (let i = 0; i < TIMES.length; i++) {
|
||||
const time = TIMES[i] + now;
|
||||
if (i === 0) {
|
||||
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
|
||||
gain.gain.setValueAtTime(VOLUMES[i], time);
|
||||
} else {
|
||||
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
|
||||
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
|
||||
}
|
||||
}
|
||||
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + TIMES[TIMES.length - 1]);
|
||||
}, i * 120);
|
||||
}
|
||||
|
||||
const TIMES = [0, 0.06, 0.10, 0.15];
|
||||
const FREQUENCIES = [2200,
|
||||
3500 + Math.random() * 600,
|
||||
2100 + Math.random() * 200,
|
||||
1600 + Math.random() * 400];
|
||||
const VOLUMES = [0.0001, 0.2, 0.2, 0.0001];
|
||||
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
oscillator.type = "sine";
|
||||
const gain = this.audioContext.createGain();
|
||||
oscillator.connect(gain);
|
||||
gain.connect(this.audioContext.destination);
|
||||
|
||||
const now = this.audioContext.currentTime;
|
||||
for (let i = 0; i < TIMES.length; i++) {
|
||||
const time = TIMES[i] + now;
|
||||
if (i === 0) {
|
||||
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
|
||||
gain.gain.setValueAtTime(VOLUMES[i], time);
|
||||
} else {
|
||||
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
|
||||
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
|
||||
}
|
||||
}
|
||||
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + TIMES[TIMES.length - 1]);
|
||||
}
|
||||
}
|
||||
430
src/species.js
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
@font-face {
|
||||
font-family: 'Monocraft';
|
||||
src: url("__MONOCRAFT_SRC__") format('opentype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
--birb-border-size: 2px;
|
||||
--birb-neg-border-size: calc(var(--birb-border-size) * -1);
|
||||
@@ -218,15 +211,17 @@
|
||||
font-size: 14px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 10px;
|
||||
padding-left: 2px;
|
||||
padding-right: 10px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0.7 !important;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: black !important;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.birb-menu-item:hover {
|
||||
@@ -238,6 +233,21 @@
|
||||
var(--birb-neg-border-size) 0 var(--birb-highlight),
|
||||
0 var(--birb-neg-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 {
|
||||
@@ -259,7 +269,7 @@
|
||||
}
|
||||
|
||||
#birb-field-guide .birb-grid-content {
|
||||
grid-template-rows: repeat(3, auto);
|
||||
grid-template-columns: repeat(4, auto);
|
||||
}
|
||||
|
||||
#birb-wardrobe .birb-grid-content {
|
||||
@@ -269,7 +279,7 @@
|
||||
|
||||
.birb-grid-content {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-flow: row;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
@@ -288,10 +298,12 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
|
||||
.birb-grid-item:hover {
|
||||
border-color: var(--birb-highlight);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.birb-grid-item canvas {
|
||||
@@ -301,7 +313,7 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
background: rgba(255, 221, 177, 0.5);
|
||||
}
|
||||
@@ -320,6 +332,15 @@
|
||||
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 {
|
||||
max-width: calc(100% - 20px);
|
||||
margin-left: 10px;
|
||||
@@ -331,7 +352,14 @@
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
@@ -344,7 +372,7 @@
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
color: rgb(124, 108, 75);
|
||||
color: #7c6c4b;
|
||||
}
|
||||
|
||||
.birb-sticky-note {
|
||||
|
||||