Compare commits
113 Commits
| 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 | ||
|
|
868cd06210 | ||
|
|
307d4a8895 | ||
|
|
5a33cef4d5 | ||
|
|
7b4ebf7ab8 | ||
|
|
e393013b27 | ||
|
|
db1a3dcbb6 | ||
|
|
8cd93bb623 | ||
|
|
a3a09c6819 | ||
|
|
912327a348 | ||
|
|
d54f208cc4 | ||
|
|
cb1f2f605f | ||
|
|
2ee6ea84a7 | ||
|
|
5e04727a1b | ||
|
|
7b1df9bc4f | ||
|
|
130fae6e0c | ||
|
|
3b2081943d | ||
|
|
f5742ac3a7 | ||
|
|
867d214292 | ||
|
|
d97e39449e | ||
|
|
7628ee2c87 | ||
|
|
3227167cb5 | ||
|
|
e0fae3781a | ||
|
|
2773538a6c | ||
|
|
2a90a56a2b | ||
|
|
94454a2338 | ||
|
|
cf968dfec4 | ||
|
|
4838457054 | ||
|
|
e09d4f9eea | ||
|
|
7c38bf9164 | ||
|
|
8263fadfba | ||
|
|
9f7d864e57 | ||
|
|
579967a302 | ||
|
|
ca1495a9f1 | ||
|
|
fd865cacb8 | ||
|
|
5e94998410 | ||
|
|
e13a67e967 | ||
|
|
e1759bc235 | ||
|
|
1d818d83cf | ||
|
|
47b418324c | ||
|
|
e5956426d5 | ||
|
|
0cc06a8856 | ||
|
|
dd4184f642 | ||
|
|
5a82ba858f |
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,6 +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.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!*
|
||||
|
||||
|
||||
BIN
aseprite/hats.aseprite
Normal file
28
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 = [
|
||||
@@ -46,6 +46,10 @@ const spriteSheets = [
|
||||
{
|
||||
key: "__FEATHER_SPRITE_SHEET__",
|
||||
path: SPRITES_DIR + "/feather.png"
|
||||
},
|
||||
{
|
||||
key: "__HATS_SPRITE_SHEET__",
|
||||
path: SPRITES_DIR + "/hats.png"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -81,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) {
|
||||
@@ -105,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');
|
||||
@@ -115,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;
|
||||
}
|
||||
|
||||
@@ -183,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
2051
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.1",
|
||||
"version": "2026.4.6",
|
||||
"homepage_url": "https://idreesinc.com",
|
||||
"icons": {
|
||||
"48": "images/icons/transparent/48x48x1.png",
|
||||
|
||||
2043
dist/obsidian/main.js
vendored
2
dist/obsidian/manifest.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "pocket-bird",
|
||||
"name": "Pocket Bird",
|
||||
"version": "2026.1.1",
|
||||
"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",
|
||||
|
||||
2043
dist/userscript/birb.user.js
vendored
2041
dist/web/birb.embed.js
vendored
2041
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
Normal file
|
After Width: | Height: | Size: 949 B |
76
src/Frame.js
@@ -1,76 +0,0 @@
|
||||
import { Directions } from './shared.js';
|
||||
import { Sprite, BirdType } from './sprites.js';
|
||||
import Layer from './layer.js';
|
||||
|
||||
class Frame {
|
||||
|
||||
/** @type {{ [tag: string]: string[][] }} */
|
||||
#pixelsByTag = {};
|
||||
|
||||
/**
|
||||
* @param {Layer[]} layers
|
||||
*/
|
||||
constructor(layers) {
|
||||
/** @type {Set<string>} */
|
||||
let tags = new Set();
|
||||
for (let layer of layers) {
|
||||
tags.add(layer.tag);
|
||||
}
|
||||
tags.add("default");
|
||||
for (let tag of tags) {
|
||||
let maxHeight = layers.reduce((max, layer) => Math.max(max, layer.pixels.length), 0);
|
||||
if (layers[0].tag !== "default") {
|
||||
throw new Error("First layer must have the 'default' tag");
|
||||
}
|
||||
this.pixels = layers[0].pixels.map(row => row.slice());
|
||||
// Pad from top with transparent pixels
|
||||
while (this.pixels.length < maxHeight) {
|
||||
this.pixels.unshift(new Array(this.pixels[0].length).fill(Sprite.TRANSPARENT));
|
||||
}
|
||||
// Combine layers
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
if (layers[i].tag === "default" || layers[i].tag === tag) {
|
||||
let layerPixels = layers[i].pixels;
|
||||
let topMargin = maxHeight - layerPixels.length;
|
||||
for (let y = 0; y < layerPixels.length; y++) {
|
||||
for (let x = 0; x < layerPixels[y].length; x++) {
|
||||
this.pixels[y + topMargin][x] = layerPixels[y][x] !== Sprite.TRANSPARENT ? layerPixels[y][x] : this.pixels[y + topMargin][x];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.#pixelsByTag[tag] = this.pixels.map(row => row.slice());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [tag]
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
getPixels(tag = "default") {
|
||||
return this.#pixelsByTag[tag] ?? this.#pixelsByTag["default"];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {BirdType} [species]
|
||||
* @param {number} direction
|
||||
* @param {number} canvasPixelSize
|
||||
*/
|
||||
draw(ctx, direction, canvasPixelSize, species) {
|
||||
// Clear the canvas before drawing the new frame
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
const pixels = this.getPixels(species?.tags[0]);
|
||||
for (let y = 0; y < pixels.length; y++) {
|
||||
const row = pixels[y];
|
||||
for (let x = 0; x < pixels[y].length; x++) {
|
||||
const cell = direction === Directions.LEFT ? row[x] : row[pixels[y].length - x - 1];
|
||||
ctx.fillStyle = species?.colors[cell] ?? cell;
|
||||
ctx.fillRect(x * canvasPixelSize, y * canvasPixelSize, canvasPixelSize, canvasPixelSize);
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default Frame;
|
||||
12
src/Layer.js
@@ -1,12 +0,0 @@
|
||||
class Layer {
|
||||
/**
|
||||
* @param {string[][]} pixels
|
||||
* @param {string} [tag]
|
||||
*/
|
||||
constructor(pixels, tag = "default") {
|
||||
this.pixels = pixels;
|
||||
this.tag = tag;
|
||||
}
|
||||
}
|
||||
|
||||
export default Layer;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Frame from "./frame.js";
|
||||
import { BirdType } from "./sprites";
|
||||
import { BirdType } from "./sprites.js";
|
||||
|
||||
class Anim {
|
||||
/**
|
||||
@@ -59,10 +59,11 @@ class Anim {
|
||||
* @param {number} direction
|
||||
* @param {number} timeStart The start time of the animation in milliseconds
|
||||
* @param {number} canvasPixelSize The size of a canvas pixel in pixels
|
||||
* @param {BirdType} [species] The species to use for the animation
|
||||
* @param {{ [key: string]: string }} colorScheme The color scheme to use for the animation
|
||||
* @param {string[]} tags The tags to use for the animation
|
||||
* @returns {boolean} Whether the animation is complete
|
||||
*/
|
||||
draw(ctx, direction, timeStart, canvasPixelSize, species) {
|
||||
draw(ctx, direction, timeStart, canvasPixelSize, colorScheme, tags) {
|
||||
// Reset cache if animation was restarted
|
||||
if (this.lastTimeStart !== timeStart) {
|
||||
this.#clearCache();
|
||||
@@ -79,7 +80,7 @@ class Anim {
|
||||
const currentFrameIndex = this.getCurrentFrameIndex(time);
|
||||
|
||||
if (this.#shouldRedraw(currentFrameIndex, direction)) {
|
||||
this.frames[currentFrameIndex].draw(ctx, direction, canvasPixelSize, species);
|
||||
this.frames[currentFrameIndex].draw(ctx, direction, canvasPixelSize, colorScheme, tags);
|
||||
this.lastFrameIndex = currentFrameIndex;
|
||||
this.lastDirection = direction;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Directions } from './shared.js';
|
||||
import { Sprite, BirdType } from './sprites.js';
|
||||
import Layer from './layer.js';
|
||||
import { Directions } from '../shared.js';
|
||||
import { PALETTE, BirdType } from './sprites.js';
|
||||
import Layer, { TAG } from './layer.js';
|
||||
|
||||
class Frame {
|
||||
|
||||
@@ -16,25 +16,25 @@ class Frame {
|
||||
for (let layer of layers) {
|
||||
tags.add(layer.tag);
|
||||
}
|
||||
tags.add("default");
|
||||
tags.add(TAG.DEFAULT);
|
||||
for (let tag of tags) {
|
||||
let maxHeight = layers.reduce((max, layer) => Math.max(max, layer.pixels.length), 0);
|
||||
if (layers[0].tag !== "default") {
|
||||
if (layers[0].tag !== TAG.DEFAULT) {
|
||||
throw new Error("First layer must have the 'default' tag");
|
||||
}
|
||||
this.pixels = layers[0].pixels.map(row => row.slice());
|
||||
// Pad from top with transparent pixels
|
||||
while (this.pixels.length < maxHeight) {
|
||||
this.pixels.unshift(new Array(this.pixels[0].length).fill(Sprite.TRANSPARENT));
|
||||
this.pixels.unshift(new Array(this.pixels[0].length).fill(PALETTE.TRANSPARENT));
|
||||
}
|
||||
// Combine layers
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
if (layers[i].tag === "default" || layers[i].tag === tag) {
|
||||
if (layers[i].tag === TAG.DEFAULT || layers[i].tag === tag) {
|
||||
let layerPixels = layers[i].pixels;
|
||||
let topMargin = maxHeight - layerPixels.length;
|
||||
for (let y = 0; y < layerPixels.length; y++) {
|
||||
for (let x = 0; x < layerPixels[y].length; x++) {
|
||||
this.pixels[y + topMargin][x] = layerPixels[y][x] !== Sprite.TRANSPARENT ? layerPixels[y][x] : this.pixels[y + topMargin][x];
|
||||
this.pixels[y + topMargin][x] = layerPixels[y][x] !== PALETTE.TRANSPARENT ? layerPixels[y][x] : this.pixels[y + topMargin][x];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,29 +44,36 @@ class Frame {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [tag]
|
||||
* @param {string[]} [tags]
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
getPixels(tag = "default") {
|
||||
return this.#pixelsByTag[tag] ?? this.#pixelsByTag["default"];
|
||||
getPixels(tags = [TAG.DEFAULT]) {
|
||||
for (let i = tags.length - 1; i >= 0; i--) {
|
||||
const tag = tags[i];
|
||||
if (this.#pixelsByTag[tag]) {
|
||||
return this.#pixelsByTag[tag];
|
||||
}
|
||||
}
|
||||
return this.#pixelsByTag[TAG.DEFAULT];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {BirdType} [species]
|
||||
* @param {number} direction
|
||||
* @param {number} direction
|
||||
* @param {number} canvasPixelSize
|
||||
* @param {{ [key: string]: string }} colorScheme
|
||||
* @param {string[]} tags
|
||||
*/
|
||||
draw(ctx, direction, canvasPixelSize, species) {
|
||||
draw(ctx, direction, canvasPixelSize, colorScheme, tags) {
|
||||
// Clear the canvas before drawing the new frame
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
const pixels = this.getPixels(species?.tags[0]);
|
||||
const pixels = this.getPixels(tags);
|
||||
for (let y = 0; y < pixels.length; y++) {
|
||||
const row = pixels[y];
|
||||
for (let x = 0; x < pixels[y].length; x++) {
|
||||
const cell = direction === Directions.LEFT ? row[x] : row[pixels[y].length - x - 1];
|
||||
ctx.fillStyle = species?.colors[cell] ?? cell;
|
||||
ctx.fillStyle = colorScheme[cell] ?? cell;
|
||||
ctx.fillRect(x * canvasPixelSize, y * canvasPixelSize, canvasPixelSize, canvasPixelSize);
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
export const TAG = {
|
||||
DEFAULT: "default",
|
||||
TUFT: "tuft",
|
||||
};
|
||||
|
||||
class Layer {
|
||||
/**
|
||||
* @param {string[][]} pixels
|
||||
* @param {string} [tag]
|
||||
*/
|
||||
constructor(pixels, tag = "default") {
|
||||
constructor(pixels, tag = TAG.DEFAULT) {
|
||||
this.pixels = pixels;
|
||||
this.tag = tag;
|
||||
}
|
||||
224
src/animation/sprites.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import species from "../species.js"
|
||||
|
||||
export const PALETTE = Object.freeze(/** @type {const} */ ({
|
||||
THEME_HIGHLIGHT: "theme-highlight",
|
||||
TRANSPARENT: "transparent",
|
||||
OUTLINE: "outline",
|
||||
BORDER: "border",
|
||||
FOOT: "foot",
|
||||
BEAK: "beak",
|
||||
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, PaletteColor>}
|
||||
*/
|
||||
export const SPRITE_SHEET_COLOR_MAP = {
|
||||
"transparent": PALETTE.TRANSPARENT,
|
||||
"#fff000": PALETTE.THEME_HIGHLIGHT,
|
||||
"#ffffff": PALETTE.BORDER,
|
||||
"#000000": PALETTE.OUTLINE,
|
||||
"#010a19": PALETTE.BEAK,
|
||||
"#190301": PALETTE.EYE,
|
||||
"#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,
|
||||
"#ff6b6b": PALETTE.HEART_SHINE,
|
||||
"#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, 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",
|
||||
[PALETTE.BORDER]: "#ffffff",
|
||||
[PALETTE.BEAK]: "#000000",
|
||||
[PALETTE.EYE]: "#000000",
|
||||
[PALETTE.HEART]: "#c82e2e",
|
||||
[PALETTE.HEART_BORDER]: "#501a1a",
|
||||
[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 = 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)
|
||||
]),
|
||||
);
|
||||
61
src/birb.js
@@ -1,8 +1,9 @@
|
||||
import { Directions, getLayer, getWindowHeight, getFixedWindowHeight } from './shared.js';
|
||||
import Layer from './layer.js';
|
||||
import Frame from './frame.js';
|
||||
import Anim from './anim.js';
|
||||
import { BirdType } from './sprites.js';
|
||||
import { Directions, getLayerPixels, getWindowHeight, getFixedWindowHeight } from './shared.js';
|
||||
import Layer from './animation/layer.js';
|
||||
import Frame from './animation/frame.js';
|
||||
import Anim from './animation/anim.js';
|
||||
import { BirdType, PALETTE } from './animation/sprites.js';
|
||||
import { createHatLayers } from './hats.js';
|
||||
|
||||
/**
|
||||
* @typedef {keyof typeof Animations} AnimationType
|
||||
@@ -31,8 +32,9 @@ export class Birb {
|
||||
* @param {string[][]} spriteSheet The loaded sprite sheet pixel data
|
||||
* @param {number} spriteWidth
|
||||
* @param {number} spriteHeight
|
||||
* @param {string[][]} hatSpriteSheet The loaded hat sprite sheet pixel data
|
||||
*/
|
||||
constructor(birbCssScale, canvasPixelSize, spriteSheet, spriteWidth, spriteHeight) {
|
||||
constructor(birbCssScale, canvasPixelSize, spriteSheet, spriteWidth, spriteHeight, hatSpriteSheet) {
|
||||
this.birbCssScale = birbCssScale;
|
||||
this.canvasPixelSize = canvasPixelSize;
|
||||
this.windowPixelSize = canvasPixelSize * birbCssScale;
|
||||
@@ -41,28 +43,31 @@ export class Birb {
|
||||
|
||||
// Build layers from sprite sheet
|
||||
this.layers = {
|
||||
base: new Layer(getLayer(spriteSheet, 0, this.spriteWidth)),
|
||||
down: new Layer(getLayer(spriteSheet, 1, this.spriteWidth)),
|
||||
heartOne: new Layer(getLayer(spriteSheet, 2, this.spriteWidth)),
|
||||
heartTwo: new Layer(getLayer(spriteSheet, 3, this.spriteWidth)),
|
||||
heartThree: new Layer(getLayer(spriteSheet, 4, this.spriteWidth)),
|
||||
tuftBase: new Layer(getLayer(spriteSheet, 5, this.spriteWidth), "tuft"),
|
||||
tuftDown: new Layer(getLayer(spriteSheet, 6, this.spriteWidth), "tuft"),
|
||||
wingsUp: new Layer(getLayer(spriteSheet, 7, this.spriteWidth)),
|
||||
wingsDown: new Layer(getLayer(spriteSheet, 8, this.spriteWidth)),
|
||||
happyEye: new Layer(getLayer(spriteSheet, 9, this.spriteWidth)),
|
||||
base: new Layer(getLayerPixels(spriteSheet, 0, this.spriteWidth)),
|
||||
down: new Layer(getLayerPixels(spriteSheet, 1, this.spriteWidth)),
|
||||
heartOne: new Layer(getLayerPixels(spriteSheet, 2, this.spriteWidth)),
|
||||
heartTwo: new Layer(getLayerPixels(spriteSheet, 3, this.spriteWidth)),
|
||||
heartThree: new Layer(getLayerPixels(spriteSheet, 4, this.spriteWidth)),
|
||||
tuftBase: new Layer(getLayerPixels(spriteSheet, 5, this.spriteWidth), "tuft"),
|
||||
tuftDown: new Layer(getLayerPixels(spriteSheet, 6, this.spriteWidth), "tuft"),
|
||||
wingsUp: new Layer(getLayerPixels(spriteSheet, 7, this.spriteWidth)),
|
||||
wingsDown: new Layer(getLayerPixels(spriteSheet, 8, this.spriteWidth)),
|
||||
happyEye: new Layer(getLayerPixels(spriteSheet, 9, this.spriteWidth)),
|
||||
};
|
||||
|
||||
// Build hat layers
|
||||
const hatLayers = createHatLayers(hatSpriteSheet);
|
||||
|
||||
// Build frames from layers
|
||||
this.frames = {
|
||||
base: new Frame([this.layers.base, this.layers.tuftBase]),
|
||||
headDown: new Frame([this.layers.down, this.layers.tuftDown]),
|
||||
wingsDown: new Frame([this.layers.base, this.layers.tuftBase, this.layers.wingsDown]),
|
||||
wingsUp: new Frame([this.layers.down, this.layers.tuftDown, this.layers.wingsUp]),
|
||||
heartOne: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartOne]),
|
||||
heartTwo: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartTwo]),
|
||||
heartThree: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartThree]),
|
||||
heartFour: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartTwo]),
|
||||
base: new Frame([this.layers.base, this.layers.tuftBase, ...hatLayers.base]),
|
||||
headDown: new Frame([this.layers.down, this.layers.tuftDown, ...hatLayers.down]),
|
||||
wingsDown: new Frame([this.layers.base, this.layers.tuftBase, this.layers.wingsDown, ...hatLayers.base]),
|
||||
wingsUp: new Frame([this.layers.down, this.layers.tuftDown, this.layers.wingsUp, ...hatLayers.down]),
|
||||
heartOne: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartOne]),
|
||||
heartTwo: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base,this.layers.heartTwo]),
|
||||
heartThree: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartThree]),
|
||||
heartFour: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartTwo]),
|
||||
};
|
||||
|
||||
// Build animations from frames
|
||||
@@ -121,14 +126,16 @@ export class Birb {
|
||||
|
||||
/**
|
||||
* Draw the current animation frame
|
||||
* @param {BirdType} species The species color data
|
||||
* @param {BirdType} species The species data
|
||||
* @param {string} [hat] The name of the current hat
|
||||
* @returns {boolean} Whether the animation has completed (for non-looping animations)
|
||||
*/
|
||||
draw(species) {
|
||||
draw(species, hat) {
|
||||
const anim = this.animations[this.currentAnimation];
|
||||
return anim.draw(this.ctx, this.direction, this.animStart, this.canvasPixelSize, species);
|
||||
return anim.draw(this.ctx, this.direction, this.animStart, this.canvasPixelSize, species.colors, [...species.tags, hat || '']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns {AnimationType} The current animation key
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
0
src/fieldGuide.js
Normal file
240
src/hats.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import Anim from "./animation/anim.js";
|
||||
import Frame from "./animation/frame.js";
|
||||
import Layer, { TAG } from "./animation/layer.js";
|
||||
import { PALETTE } from "./animation/sprites.js";
|
||||
import { getLayerPixels } from "./shared.js";
|
||||
|
||||
const HAT_WIDTH = 12;
|
||||
|
||||
export const HAT = {
|
||||
NONE: "none",
|
||||
TOP_HAT: "top-hat",
|
||||
FEZ: "fez",
|
||||
WIZARD_HAT: "wizard-hat",
|
||||
BASEBALL_CAP: "baseball-cap",
|
||||
FLOWER_HAT: "flower-hat",
|
||||
COWBOY_HAT: "cowboy-hat",
|
||||
BEANIE: "beanie",
|
||||
SUN_HAT: "sun-hat",
|
||||
VIKING_HELMET: "viking-helmet",
|
||||
STRAW_HAT: "straw-hat",
|
||||
CORDOVAN_HAT: "cordovan-hat"
|
||||
};
|
||||
|
||||
/** @type {{ [hatId: string]: { name: string, description: string } }} */
|
||||
export const HAT_METADATA = {
|
||||
[HAT.NONE]: {
|
||||
name: "Invisible Hat",
|
||||
description: "It's like you're wearing nothing at all!"
|
||||
},
|
||||
[HAT.TOP_HAT]: {
|
||||
name: "Top Hat",
|
||||
description: "The mark of a true gentlebird."
|
||||
},
|
||||
[HAT.VIKING_HELMET]: {
|
||||
name: "Viking Helmet",
|
||||
description: "Sure, vikings never actually wore this style of helmet, but why let facts get in the way of good fashion?"
|
||||
},
|
||||
[HAT.COWBOY_HAT]: {
|
||||
name: "Cowboy Hat",
|
||||
description: "You can't jam with the console cowboys without the appropriate attire."
|
||||
},
|
||||
[HAT.FEZ]: {
|
||||
name: "Fez",
|
||||
description: "It's a fez. Fezzes are cool."
|
||||
},
|
||||
[HAT.WIZARD_HAT]: {
|
||||
name: "Wizard Hat",
|
||||
description: "Grants the bearer terrifying mystical power, but luckily birds only use it to summon old ladies with bread crumbs."
|
||||
},
|
||||
[HAT.BASEBALL_CAP]: {
|
||||
name: "Baseball Cap",
|
||||
description: "Birds unfortunately only ever hit 'fowl' balls..."
|
||||
},
|
||||
[HAT.FLOWER_HAT]: {
|
||||
name: "Flower Hat",
|
||||
description: "To be fair, this is less of a hat and more of a dirt clod that your pet happened to pick up."
|
||||
},
|
||||
[HAT.BEANIE]: {
|
||||
name: "Beanie",
|
||||
description: "Keeps feathers warm on those long migrations south!"
|
||||
},
|
||||
[HAT.SUN_HAT]: {
|
||||
name: "Sun Hat",
|
||||
description: "Perfect for frolicking through enchanted flower fields."
|
||||
},
|
||||
[HAT.STRAW_HAT]: {
|
||||
name: "Straw Hat",
|
||||
description: "A classic design, though keep away from water as this particular hat is seemingly unable to float."
|
||||
},
|
||||
[HAT.CORDOVAN_HAT]: {
|
||||
name: "Cordovan Hat",
|
||||
description: "A traditional Spanish hat that stays put even in the wildest of sword fights."
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string[][]} spriteSheet
|
||||
* @returns {{ base: Layer[], down: Layer[] }}
|
||||
*/
|
||||
export function createHatLayers(spriteSheet) {
|
||||
const hatLayers = {
|
||||
base: [],
|
||||
down: []
|
||||
};
|
||||
for (let i = 0; i < Object.keys(HAT).length; i++) {
|
||||
const hatName = Object.keys(HAT)[i];
|
||||
if (hatName === 'NONE') {
|
||||
continue;
|
||||
}
|
||||
const index = i - 1;
|
||||
const hatKey = HAT[hatName];
|
||||
const hatLayer = buildHatLayer(spriteSheet, hatKey, index);
|
||||
const downHatLayer = buildHatLayer(spriteSheet, hatKey, index, 1);
|
||||
hatLayers.base.push(hatLayer);
|
||||
hatLayers.down.push(downHatLayer);
|
||||
}
|
||||
return hatLayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[][]} spriteSheet
|
||||
* @param {string} hatId
|
||||
* @returns {Anim}
|
||||
*/
|
||||
export function createHatItemAnimation(hatId, spriteSheet) {
|
||||
const hatLayer = buildHatItemLayer(spriteSheet, hatId);
|
||||
const frames = [
|
||||
new Frame([hatLayer])
|
||||
];
|
||||
return new Anim(frames, [1000], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[][]} spriteSheet
|
||||
* @param {string} hatName
|
||||
* @param {number} hatIndex
|
||||
* @param {number} [yOffset=0]
|
||||
* @returns {Layer}
|
||||
*/
|
||||
function buildHatLayer(spriteSheet, hatName, hatIndex, yOffset = 0) {
|
||||
const LEFT_PADDING = 6;
|
||||
const RIGHT_PADDING = 14;
|
||||
const TOP_PADDING = 5 + yOffset;
|
||||
const BOTTOM_PADDING = Math.max(0, 15 - yOffset);
|
||||
|
||||
let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH);
|
||||
hatPixels = pad(hatPixels, TOP_PADDING, BOTTOM_PADDING, LEFT_PADDING, RIGHT_PADDING);
|
||||
hatPixels = drawOutline(hatPixels, false);
|
||||
|
||||
return new Layer(hatPixels, hatName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[][]} spriteSheet
|
||||
* @param {string} hatId
|
||||
* @returns {Layer}
|
||||
*/
|
||||
function buildHatItemLayer(spriteSheet, hatId) {
|
||||
if (hatId === HAT.NONE) {
|
||||
return new Layer([], TAG.DEFAULT);
|
||||
}
|
||||
const hatIndex = Object.values(HAT).indexOf(hatId) - 1;
|
||||
let hatPixels = getLayerPixels(spriteSheet, hatIndex, HAT_WIDTH);
|
||||
hatPixels = pad(hatPixels, 1, 1, 1, 1);
|
||||
hatPixels = drawOutline(hatPixels, true);
|
||||
hatPixels = pushToBottom(hatPixels);
|
||||
return new Layer(hatPixels, TAG.DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add transparent padding around the pixel array
|
||||
* @param {string[][]} pixels
|
||||
* @param {number} top
|
||||
* @param {number} bottom
|
||||
* @param {number} left
|
||||
* @param {number} right
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
function pad(pixels, top, bottom, left, right) {
|
||||
const paddedPixels = [];
|
||||
const rowLength = pixels[0].length + left + right;
|
||||
// Top padding
|
||||
for (let y = 0; y < top; y++) {
|
||||
paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT));
|
||||
}
|
||||
// Left and right padding
|
||||
for (let y = 0; y < pixels.length; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < left; x++) {
|
||||
row.push(PALETTE.TRANSPARENT);
|
||||
}
|
||||
for (let x = 0; x < pixels[y].length; x++) {
|
||||
row.push(pixels[y][x]);
|
||||
}
|
||||
for (let x = 0; x < right; x++) {
|
||||
row.push(PALETTE.TRANSPARENT);
|
||||
}
|
||||
paddedPixels.push(row);
|
||||
}
|
||||
// Bottom padding
|
||||
for (let y = 0; y < bottom; y++) {
|
||||
paddedPixels.push(Array(rowLength).fill(PALETTE.TRANSPARENT));
|
||||
}
|
||||
return paddedPixels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an outline around non-transparent pixels
|
||||
* @param {string[][]} pixels
|
||||
* @param {boolean} [outlineBottom=false]
|
||||
* @return {string[][]}
|
||||
*/
|
||||
function drawOutline(pixels, outlineBottom = false) {
|
||||
let neighborOffsets = [
|
||||
[-1, 0],
|
||||
[1, 0],
|
||||
[0, -1],
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
];
|
||||
if (outlineBottom) {
|
||||
neighborOffsets.push([0, 1], [-1, 1], [1, 1]);
|
||||
}
|
||||
for (let y = 0; y < pixels.length; y++) {
|
||||
for (let x = 0; x < pixels[y].length; x++) {
|
||||
const pixel = pixels[y][x];
|
||||
if (pixel !== PALETTE.TRANSPARENT && pixel !== PALETTE.BORDER) {
|
||||
for (let [dx, dy] of neighborOffsets) {
|
||||
const newX = x + dx;
|
||||
const newY = y + dy;
|
||||
if (newY >= 0 && newY < pixels.length && newX >= 0 && newX < pixels[newY].length && pixels[newY][newX] === PALETTE.TRANSPARENT) {
|
||||
pixels[newY][newX] = PALETTE.BORDER;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim transparent rows from the bottom and push them to the top
|
||||
* @param {string[][]} pixels
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
function pushToBottom(pixels) {
|
||||
let trimmedPixels = pixels.slice();
|
||||
let trimCount = 0;
|
||||
while (trimmedPixels.length > 1) {
|
||||
const firstRow = trimmedPixels[trimmedPixels.length - 1];
|
||||
if (firstRow.every(pixel => pixel === PALETTE.TRANSPARENT)) {
|
||||
trimmedPixels.pop();
|
||||
trimCount++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
trimmedPixels = pad(trimmedPixels, trimCount, 0, 0, 0);
|
||||
return trimmedPixels;
|
||||
}
|
||||
39
src/menu.js
@@ -12,13 +12,15 @@ export const MENU_EXIT_ID = "birb-menu-exit";
|
||||
|
||||
export class MenuItem {
|
||||
/**
|
||||
* @param {string} text
|
||||
* @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", item.text);
|
||||
let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
|
||||
if (item.icon) {
|
||||
const iconCanvas = document.createElement("canvas");
|
||||
iconCanvas.width = 7;
|
||||
iconCanvas.height = 6;
|
||||
iconCanvas.classList.add("birb-menu-item-icon");
|
||||
const ctx = iconCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
for (let row = 0; row < item.icon.length; row++) {
|
||||
for (let col = 0; col < item.icon[row].length; col++) {
|
||||
if (item.icon[row][col]) {
|
||||
ctx.fillStyle = "black";
|
||||
ctx.fillRect(col, row, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
menuItem.prepend(iconCanvas);
|
||||
}
|
||||
onClick(menuItem, () => {
|
||||
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);
|
||||
|
||||
@@ -193,7 +193,7 @@ export function error() {
|
||||
* @param {number} width The width of each sprite
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
export function getLayer(spriteSheet, spriteIndex, width) {
|
||||
export function getLayerPixels(spriteSheet, spriteIndex, width) {
|
||||
// From an array of a horizontal sprite sheet, get the layer for a specific sprite
|
||||
const layer = [];
|
||||
for (let y = 0; y < width; y++) {
|
||||
|
||||
48
src/sound.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// @ts-check
|
||||
|
||||
export class Birdsong {
|
||||
|
||||
/**
|
||||
* @type {AudioContext}
|
||||
*/
|
||||
audioContext;
|
||||
|
||||
chirp() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
199
src/sprites.js
@@ -1,199 +0,0 @@
|
||||
/** Indicators for parts of the base bird sprite sheet */
|
||||
export const Sprite = {
|
||||
THEME_HIGHLIGHT: "theme-highlight",
|
||||
TRANSPARENT: "transparent",
|
||||
OUTLINE: "outline",
|
||||
BORDER: "border",
|
||||
FOOT: "foot",
|
||||
BEAK: "beak",
|
||||
EYE: "eye",
|
||||
FACE: "face",
|
||||
HOOD: "hood",
|
||||
NOSE: "nose",
|
||||
BELLY: "belly",
|
||||
UNDERBELLY: "underbelly",
|
||||
WING: "wing",
|
||||
WING_EDGE: "wing-edge",
|
||||
HEART: "heart",
|
||||
HEART_BORDER: "heart-border",
|
||||
HEART_SHINE: "heart-shine",
|
||||
FEATHER_SPINE: "feather-spine",
|
||||
};
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
export const SPRITE_SHEET_COLOR_MAP = {
|
||||
"transparent": Sprite.TRANSPARENT,
|
||||
"#ffffff": Sprite.BORDER,
|
||||
"#000000": Sprite.OUTLINE,
|
||||
"#010a19": Sprite.BEAK,
|
||||
"#190301": Sprite.EYE,
|
||||
"#af8e75": Sprite.FOOT,
|
||||
"#639bff": Sprite.FACE,
|
||||
"#99e550": Sprite.HOOD,
|
||||
"#d95763": Sprite.NOSE,
|
||||
"#f8b143": Sprite.BELLY,
|
||||
"#ec8637": Sprite.UNDERBELLY,
|
||||
"#578ae6": Sprite.WING,
|
||||
"#326ed9": Sprite.WING_EDGE,
|
||||
"#c82e2e": Sprite.HEART,
|
||||
"#501a1a": Sprite.HEART_BORDER,
|
||||
"#ff6b6b": Sprite.HEART_SHINE,
|
||||
"#373737": Sprite.FEATHER_SPINE,
|
||||
};
|
||||
|
||||
export class BirdType {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} description
|
||||
* @param {Record<string, string>} colors
|
||||
* @param {string[]} [tags]
|
||||
*/
|
||||
constructor(name, description, colors, tags = []) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
const defaultColors = {
|
||||
[Sprite.TRANSPARENT]: "transparent",
|
||||
[Sprite.OUTLINE]: "#000000",
|
||||
[Sprite.BORDER]: "#ffffff",
|
||||
[Sprite.BEAK]: "#000000",
|
||||
[Sprite.EYE]: "#000000",
|
||||
[Sprite.HEART]: "#c82e2e",
|
||||
[Sprite.HEART_BORDER]: "#501a1a",
|
||||
[Sprite.HEART_SHINE]: "#ff6b6b",
|
||||
[Sprite.FEATHER_SPINE]: "#373737",
|
||||
[Sprite.HOOD]: colors.face,
|
||||
[Sprite.NOSE]: colors.face,
|
||||
};
|
||||
/** @type {Record<string, string>} */
|
||||
this.colors = { ...defaultColors, ...colors, [Sprite.THEME_HIGHLIGHT]: colors[Sprite.THEME_HIGHLIGHT] ?? colors.hood ?? colors.face };
|
||||
this.tags = tags;
|
||||
}
|
||||
}
|
||||
|
||||
/** @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.", {
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#639bff",
|
||||
[Sprite.BELLY]: "#f8b143",
|
||||
[Sprite.UNDERBELLY]: "#ec8637",
|
||||
[Sprite.WING]: "#578ae6",
|
||||
[Sprite.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.", {
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#ffffff",
|
||||
[Sprite.BELLY]: "#ebe9e8",
|
||||
[Sprite.UNDERBELLY]: "#ebd9d0",
|
||||
[Sprite.WING]: "#f3d3c1",
|
||||
[Sprite.WING_EDGE]: "#2d2d2dff",
|
||||
[Sprite.THEME_HIGHLIGHT]: "#d7ac93",
|
||||
}),
|
||||
tuftedTitmouse: new BirdType("Tufted Titmouse",
|
||||
"Native to the eastern United States, full of personality, and notably my wife's favorite bird.", {
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#c7cad7",
|
||||
[Sprite.BELLY]: "#e4e5eb",
|
||||
[Sprite.UNDERBELLY]: "#d7cfcb",
|
||||
[Sprite.WING]: "#b1b5c5",
|
||||
[Sprite.WING_EDGE]: "#9d9fa9",
|
||||
}, ["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.", {
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#ffaf34",
|
||||
[Sprite.HOOD]: "#aaa094",
|
||||
[Sprite.BELLY]: "#ffaf34",
|
||||
[Sprite.UNDERBELLY]: "#babec2",
|
||||
[Sprite.WING]: "#aaa094",
|
||||
[Sprite.WING_EDGE]: "#888580",
|
||||
[Sprite.THEME_HIGHLIGHT]: "#ffaf34",
|
||||
}),
|
||||
redCardinal: new BirdType("Red Cardinal",
|
||||
"Native to the eastern United States, this strikingly red bird is hard to miss.", {
|
||||
[Sprite.BEAK]: "#d93619",
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#31353d",
|
||||
[Sprite.HOOD]: "#e83a1b",
|
||||
[Sprite.BELLY]: "#e83a1b",
|
||||
[Sprite.UNDERBELLY]: "#dc3719",
|
||||
[Sprite.WING]: "#d23215",
|
||||
[Sprite.WING_EDGE]: "#b1321c",
|
||||
}, ["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.", {
|
||||
[Sprite.BEAK]: "#ffaf34",
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#fff255",
|
||||
[Sprite.NOSE]: "#383838",
|
||||
[Sprite.HOOD]: "#383838",
|
||||
[Sprite.BELLY]: "#fff255",
|
||||
[Sprite.UNDERBELLY]: "#f5ea63",
|
||||
[Sprite.WING]: "#e8e079",
|
||||
[Sprite.WING_EDGE]: "#191919",
|
||||
[Sprite.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.", {
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#db7c4d",
|
||||
[Sprite.BELLY]: "#f7e1c9",
|
||||
[Sprite.UNDERBELLY]: "#ebc9a3",
|
||||
[Sprite.WING]: "#2252a9",
|
||||
[Sprite.WING_EDGE]: "#1c448b",
|
||||
[Sprite.HOOD]: "#2252a9",
|
||||
}),
|
||||
mistletoebird: new BirdType("Mistletoebird",
|
||||
"Native to Australia, these birds eat mainly mistletoe and in turn spread the seeds far and wide.", {
|
||||
[Sprite.FOOT]: "#6c6a7c",
|
||||
[Sprite.FACE]: "#352e6d",
|
||||
[Sprite.BELLY]: "#fd6833",
|
||||
[Sprite.UNDERBELLY]: "#e6e1d8",
|
||||
[Sprite.WING]: "#342b7c",
|
||||
[Sprite.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.", {
|
||||
[Sprite.BEAK]: "#f71919",
|
||||
[Sprite.FOOT]: "#af7575",
|
||||
[Sprite.FACE]: "#cb092b",
|
||||
[Sprite.BELLY]: "#ae1724",
|
||||
[Sprite.UNDERBELLY]: "#831b24",
|
||||
[Sprite.WING]: "#7e3030",
|
||||
[Sprite.WING_EDGE]: "#490f0f",
|
||||
}),
|
||||
scarletRobin: new BirdType("Scarlet Robin",
|
||||
"Native to Australia, this striking robin can be found in Eucalyptus forests.", {
|
||||
[Sprite.FOOT]: "#494949",
|
||||
[Sprite.FACE]: "#3d3d3d",
|
||||
[Sprite.BELLY]: "#fc5633",
|
||||
[Sprite.UNDERBELLY]: "#dcdcdc",
|
||||
[Sprite.WING]: "#2b2b2b",
|
||||
[Sprite.WING_EDGE]: "#ebebeb",
|
||||
[Sprite.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.", {
|
||||
[Sprite.BEAK]: "#e89f30",
|
||||
[Sprite.FOOT]: "#9f8075",
|
||||
[Sprite.FACE]: "#2d2d2d",
|
||||
[Sprite.BELLY]: "#eb7a3a",
|
||||
[Sprite.UNDERBELLY]: "#eb7a3a",
|
||||
[Sprite.WING]: "#444444",
|
||||
[Sprite.WING_EDGE]: "#232323",
|
||||
[Sprite.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.", {
|
||||
[Sprite.FOOT]: "#af8e75",
|
||||
[Sprite.FACE]: "#edc7a9",
|
||||
[Sprite.NOSE]: "#f7eee5",
|
||||
[Sprite.HOOD]: "#c58a5b",
|
||||
[Sprite.BELLY]: "#e1b796",
|
||||
[Sprite.UNDERBELLY]: "#c79e7c",
|
||||
[Sprite.WING]: "#c58a5b",
|
||||
[Sprite.WING_EDGE]: "#866348",
|
||||
}),
|
||||
};
|
||||
@@ -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);
|
||||
@@ -41,6 +34,22 @@
|
||||
z-index: 2147483630 !important;
|
||||
}
|
||||
|
||||
.birb-item {
|
||||
image-rendering: pixelated;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform: scale(calc(var(--birb-scale) * 1.5)) !important;
|
||||
transform-origin: bottom;
|
||||
transition-duration: 0.15s;
|
||||
z-index: 2147483630 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.birb-item:hover {
|
||||
transform: scale(calc(var(--birb-scale) * 1.9)) !important;
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
.birb-window {
|
||||
font-family: "Monocraft", monospace !important;
|
||||
line-height: initial !important;
|
||||
@@ -198,18 +207,21 @@
|
||||
|
||||
.birb-menu-item {
|
||||
width: calc(100% - var(--birb-double-border-size));
|
||||
white-space: nowrap;
|
||||
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 {
|
||||
@@ -221,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 {
|
||||
@@ -237,14 +264,22 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
#birb-field-guide {
|
||||
#birb-field-guide, #birb-wardrobe {
|
||||
width: 322px !important;
|
||||
}
|
||||
|
||||
#birb-field-guide .birb-grid-content {
|
||||
grid-template-columns: repeat(4, auto);
|
||||
}
|
||||
|
||||
#birb-wardrobe .birb-grid-content {
|
||||
grid-template-columns: repeat(4, auto);
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
|
||||
.birb-grid-content {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(3, auto);
|
||||
grid-auto-flow: column;
|
||||
grid-auto-flow: row;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
@@ -263,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 {
|
||||
@@ -276,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);
|
||||
}
|
||||
@@ -295,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;
|
||||
@@ -306,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 {
|
||||
@@ -319,7 +372,7 @@
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
color: rgb(124, 108, 75);
|
||||
color: #7c6c4b;
|
||||
}
|
||||
|
||||
.birb-sticky-note {
|
||||
|
||||