Compare commits
108 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 |
5
.gitignore
vendored
@@ -3,3 +3,8 @@
|
|||||||
/dist/birb.bundled.js
|
/dist/birb.bundled.js
|
||||||
obsidian-test.sh
|
obsidian-test.sh
|
||||||
build-cache.json
|
build-cache.json
|
||||||
|
.vscode/settings.json
|
||||||
|
aseprite/birb-test.aseprite
|
||||||
|
aseprite/wren.aseprite
|
||||||
|
aseprite/birb-no-shoulder.aseprite
|
||||||
|
aseprite/birb-fat.aseprite
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ It's a pet bird that hops around your computer, what more could you want?
|
|||||||
|
|
||||||
#### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas!
|
#### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- A cute little pixel art bird hops around your apps and websites
|
- A cute little pixel art bird hops around your apps and websites
|
||||||
@@ -95,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:
|
Here are some websites where you can find Pocket Bird hopping around:
|
||||||
|
|
||||||
- [https://grepjason.sh](https://grepjason.sh)
|
- [https://grepjason.sh](https://grepjason.sh)
|
||||||
|
- [https://binarydigit.net](https://binarydigit.net)
|
||||||
|
- [melvinsalas.com](melvinsalas.com)
|
||||||
|
|
||||||
*If you've added Pocket Bird to your website, let me know and I'll add it to this list!*
|
*If you've added Pocket Bird to your website, let me know and I'll add it to this list!*
|
||||||
|
|
||||||
|
|||||||
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 VERSION_KEY = "__VERSION__";
|
||||||
const STYLESHEET_KEY = "___STYLESHEET___";
|
const STYLESHEET_KEY = "___STYLESHEET___";
|
||||||
const MONOCRAFT_SRC_KEY = "__MONOCRAFT_SRC__";
|
const MONOCRAFT_URL_KEY = "__MONOCRAFT_URL__";
|
||||||
const CODE_KEY = "__CODE__";
|
const CODE_KEY = "__CODE__";
|
||||||
|
|
||||||
const spriteSheets = [
|
const spriteSheets = [
|
||||||
@@ -46,6 +46,10 @@ const spriteSheets = [
|
|||||||
{
|
{
|
||||||
key: "__FEATHER_SPRITE_SHEET__",
|
key: "__FEATHER_SPRITE_SHEET__",
|
||||||
path: SPRITES_DIR + "/feather.png"
|
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 {string} entryPoint
|
||||||
* @param {boolean} [embedFont]
|
* @param {boolean} [embedFont] When true, the Monocraft font is base64-encoded
|
||||||
|
* and substituted into the __MONOCRAFT_FONT_FACE__ placeholder so the
|
||||||
|
* build is fully self-contained (used for Obsidian).
|
||||||
* @returns {Promise<string>}
|
* @returns {Promise<string>}
|
||||||
*/
|
*/
|
||||||
async function generateCode(entryPoint, embedFont = false) {
|
async function generateCode(entryPoint, embedFont = false) {
|
||||||
@@ -105,6 +111,15 @@ async function generateCode(entryPoint, embedFont = false) {
|
|||||||
// Replace version placeholder
|
// Replace version placeholder
|
||||||
birbJs = birbJs.replaceAll(VERSION_KEY, version);
|
birbJs = birbJs.replaceAll(VERSION_KEY, version);
|
||||||
|
|
||||||
|
// Replace CDN font URL placeholder
|
||||||
|
if (embedFont) {
|
||||||
|
// Embed as a base64 data URI so the build works fully offline.
|
||||||
|
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
|
||||||
|
birbJs = birbJs.replaceAll(MONOCRAFT_URL_KEY, `data:font/otf;base64,${monocraftFontData}`);
|
||||||
|
} else {
|
||||||
|
birbJs = birbJs.replaceAll(MONOCRAFT_URL_KEY, MONOCRAFT_URL);
|
||||||
|
}
|
||||||
|
|
||||||
// Compile and insert sprite sheets
|
// Compile and insert sprite sheets
|
||||||
for (const spriteSheet of spriteSheets) {
|
for (const spriteSheet of spriteSheets) {
|
||||||
const dataUri = readFileSync(spriteSheet.path, 'base64');
|
const dataUri = readFileSync(spriteSheet.path, 'base64');
|
||||||
@@ -115,14 +130,6 @@ async function generateCode(entryPoint, embedFont = false) {
|
|||||||
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
|
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
|
||||||
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
|
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
|
||||||
|
|
||||||
if (embedFont) {
|
|
||||||
// Encode font to data URI
|
|
||||||
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
|
|
||||||
const monocraftDataUri = `data:font/otf;base64,${monocraftFontData}`;
|
|
||||||
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, monocraftDataUri);
|
|
||||||
} else {
|
|
||||||
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, MONOCRAFT_URL);
|
|
||||||
}
|
|
||||||
return birbJs;
|
return birbJs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +190,7 @@ async function buildExtension() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildObsidian() {
|
async function buildObsidian() {
|
||||||
|
// embedFont=true: bakes the font as base64 so the plugin works fully offline.
|
||||||
const birbJs = await generateCode(OBSIDIAN_ENTRY, true);
|
const birbJs = await generateCode(OBSIDIAN_ENTRY, true);
|
||||||
|
|
||||||
mkdirSync(OBSIDIAN_DIR, { recursive: true });
|
mkdirSync(OBSIDIAN_DIR, { recursive: true });
|
||||||
|
|||||||
BIN
dist/extension.zip
vendored
1978
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,
|
"manifest_version": 3,
|
||||||
"name": "Pocket Bird",
|
"name": "Pocket Bird",
|
||||||
"description": "It's a pet bird in your browser, what more could you want?",
|
"description": "It's a pet bird in your browser, what more could you want?",
|
||||||
"version": "2026.1.4",
|
"version": "2026.4.6",
|
||||||
"homepage_url": "https://idreesinc.com",
|
"homepage_url": "https://idreesinc.com",
|
||||||
"icons": {
|
"icons": {
|
||||||
"48": "images/icons/transparent/48x48x1.png",
|
"48": "images/icons/transparent/48x48x1.png",
|
||||||
|
|||||||
1970
dist/obsidian/main.js
vendored
2
dist/obsidian/manifest.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "pocket-bird",
|
"id": "pocket-bird",
|
||||||
"name": "Pocket Bird",
|
"name": "Pocket Bird",
|
||||||
"version": "2026.1.4",
|
"version": "2026.4.6",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "Add a pet bird to fly around your notes and keep you company!",
|
"description": "Add a pet bird to fly around your notes and keep you company!",
|
||||||
"author": "Idrees Hassan",
|
"author": "Idrees Hassan",
|
||||||
|
|||||||
1970
dist/userscript/birb.user.js
vendored
1968
dist/web/birb.embed.js
vendored
1968
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 Frame from "./frame.js";
|
||||||
import { BirdType } from "./sprites";
|
import { BirdType } from "./sprites.js";
|
||||||
|
|
||||||
class Anim {
|
class Anim {
|
||||||
/**
|
/**
|
||||||
@@ -59,10 +59,11 @@ class Anim {
|
|||||||
* @param {number} direction
|
* @param {number} direction
|
||||||
* @param {number} timeStart The start time of the animation in milliseconds
|
* @param {number} timeStart The start time of the animation in milliseconds
|
||||||
* @param {number} canvasPixelSize The size of a canvas pixel in pixels
|
* @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
|
* @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
|
// Reset cache if animation was restarted
|
||||||
if (this.lastTimeStart !== timeStart) {
|
if (this.lastTimeStart !== timeStart) {
|
||||||
this.#clearCache();
|
this.#clearCache();
|
||||||
@@ -79,7 +80,7 @@ class Anim {
|
|||||||
const currentFrameIndex = this.getCurrentFrameIndex(time);
|
const currentFrameIndex = this.getCurrentFrameIndex(time);
|
||||||
|
|
||||||
if (this.#shouldRedraw(currentFrameIndex, direction)) {
|
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.lastFrameIndex = currentFrameIndex;
|
||||||
this.lastDirection = direction;
|
this.lastDirection = direction;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Directions } from './shared.js';
|
import { Directions } from '../shared.js';
|
||||||
import { Sprite, BirdType } from './sprites.js';
|
import { PALETTE, BirdType } from './sprites.js';
|
||||||
import Layer from './layer.js';
|
import Layer, { TAG } from './layer.js';
|
||||||
|
|
||||||
class Frame {
|
class Frame {
|
||||||
|
|
||||||
@@ -16,25 +16,25 @@ class Frame {
|
|||||||
for (let layer of layers) {
|
for (let layer of layers) {
|
||||||
tags.add(layer.tag);
|
tags.add(layer.tag);
|
||||||
}
|
}
|
||||||
tags.add("default");
|
tags.add(TAG.DEFAULT);
|
||||||
for (let tag of tags) {
|
for (let tag of tags) {
|
||||||
let maxHeight = layers.reduce((max, layer) => Math.max(max, layer.pixels.length), 0);
|
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");
|
throw new Error("First layer must have the 'default' tag");
|
||||||
}
|
}
|
||||||
this.pixels = layers[0].pixels.map(row => row.slice());
|
this.pixels = layers[0].pixels.map(row => row.slice());
|
||||||
// Pad from top with transparent pixels
|
// Pad from top with transparent pixels
|
||||||
while (this.pixels.length < maxHeight) {
|
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
|
// Combine layers
|
||||||
for (let i = 1; i < layers.length; i++) {
|
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 layerPixels = layers[i].pixels;
|
||||||
let topMargin = maxHeight - layerPixels.length;
|
let topMargin = maxHeight - layerPixels.length;
|
||||||
for (let y = 0; y < layerPixels.length; y++) {
|
for (let y = 0; y < layerPixels.length; y++) {
|
||||||
for (let x = 0; x < layerPixels[y].length; x++) {
|
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[][]}
|
* @returns {string[][]}
|
||||||
*/
|
*/
|
||||||
getPixels(tag = "default") {
|
getPixels(tags = [TAG.DEFAULT]) {
|
||||||
return this.#pixelsByTag[tag] ?? this.#pixelsByTag["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 {CanvasRenderingContext2D} ctx
|
||||||
* @param {BirdType} [species]
|
|
||||||
* @param {number} direction
|
* @param {number} direction
|
||||||
* @param {number} canvasPixelSize
|
* @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
|
// Clear the canvas before drawing the new frame
|
||||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
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++) {
|
for (let y = 0; y < pixels.length; y++) {
|
||||||
const row = pixels[y];
|
const row = pixels[y];
|
||||||
for (let x = 0; x < pixels[y].length; x++) {
|
for (let x = 0; x < pixels[y].length; x++) {
|
||||||
const cell = direction === Directions.LEFT ? row[x] : row[pixels[y].length - x - 1];
|
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);
|
ctx.fillRect(x * canvasPixelSize, y * canvasPixelSize, canvasPixelSize, canvasPixelSize);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
|
export const TAG = {
|
||||||
|
DEFAULT: "default",
|
||||||
|
TUFT: "tuft",
|
||||||
|
};
|
||||||
|
|
||||||
class Layer {
|
class Layer {
|
||||||
/**
|
/**
|
||||||
* @param {string[][]} pixels
|
* @param {string[][]} pixels
|
||||||
* @param {string} [tag]
|
* @param {string} [tag]
|
||||||
*/
|
*/
|
||||||
constructor(pixels, tag = "default") {
|
constructor(pixels, tag = TAG.DEFAULT) {
|
||||||
this.pixels = pixels;
|
this.pixels = pixels;
|
||||||
this.tag = tag;
|
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 { Directions, getLayerPixels, getWindowHeight, getFixedWindowHeight } from './shared.js';
|
||||||
import Layer from './layer.js';
|
import Layer from './animation/layer.js';
|
||||||
import Frame from './frame.js';
|
import Frame from './animation/frame.js';
|
||||||
import Anim from './anim.js';
|
import Anim from './animation/anim.js';
|
||||||
import { BirdType } from './sprites.js';
|
import { BirdType, PALETTE } from './animation/sprites.js';
|
||||||
|
import { createHatLayers } from './hats.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {keyof typeof Animations} AnimationType
|
* @typedef {keyof typeof Animations} AnimationType
|
||||||
@@ -31,8 +32,9 @@ export class Birb {
|
|||||||
* @param {string[][]} spriteSheet The loaded sprite sheet pixel data
|
* @param {string[][]} spriteSheet The loaded sprite sheet pixel data
|
||||||
* @param {number} spriteWidth
|
* @param {number} spriteWidth
|
||||||
* @param {number} spriteHeight
|
* @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.birbCssScale = birbCssScale;
|
||||||
this.canvasPixelSize = canvasPixelSize;
|
this.canvasPixelSize = canvasPixelSize;
|
||||||
this.windowPixelSize = canvasPixelSize * birbCssScale;
|
this.windowPixelSize = canvasPixelSize * birbCssScale;
|
||||||
@@ -41,28 +43,31 @@ export class Birb {
|
|||||||
|
|
||||||
// Build layers from sprite sheet
|
// Build layers from sprite sheet
|
||||||
this.layers = {
|
this.layers = {
|
||||||
base: new Layer(getLayer(spriteSheet, 0, this.spriteWidth)),
|
base: new Layer(getLayerPixels(spriteSheet, 0, this.spriteWidth)),
|
||||||
down: new Layer(getLayer(spriteSheet, 1, this.spriteWidth)),
|
down: new Layer(getLayerPixels(spriteSheet, 1, this.spriteWidth)),
|
||||||
heartOne: new Layer(getLayer(spriteSheet, 2, this.spriteWidth)),
|
heartOne: new Layer(getLayerPixels(spriteSheet, 2, this.spriteWidth)),
|
||||||
heartTwo: new Layer(getLayer(spriteSheet, 3, this.spriteWidth)),
|
heartTwo: new Layer(getLayerPixels(spriteSheet, 3, this.spriteWidth)),
|
||||||
heartThree: new Layer(getLayer(spriteSheet, 4, this.spriteWidth)),
|
heartThree: new Layer(getLayerPixels(spriteSheet, 4, this.spriteWidth)),
|
||||||
tuftBase: new Layer(getLayer(spriteSheet, 5, this.spriteWidth), "tuft"),
|
tuftBase: new Layer(getLayerPixels(spriteSheet, 5, this.spriteWidth), "tuft"),
|
||||||
tuftDown: new Layer(getLayer(spriteSheet, 6, this.spriteWidth), "tuft"),
|
tuftDown: new Layer(getLayerPixels(spriteSheet, 6, this.spriteWidth), "tuft"),
|
||||||
wingsUp: new Layer(getLayer(spriteSheet, 7, this.spriteWidth)),
|
wingsUp: new Layer(getLayerPixels(spriteSheet, 7, this.spriteWidth)),
|
||||||
wingsDown: new Layer(getLayer(spriteSheet, 8, this.spriteWidth)),
|
wingsDown: new Layer(getLayerPixels(spriteSheet, 8, this.spriteWidth)),
|
||||||
happyEye: new Layer(getLayer(spriteSheet, 9, this.spriteWidth)),
|
happyEye: new Layer(getLayerPixels(spriteSheet, 9, this.spriteWidth)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build hat layers
|
||||||
|
const hatLayers = createHatLayers(hatSpriteSheet);
|
||||||
|
|
||||||
// Build frames from layers
|
// Build frames from layers
|
||||||
this.frames = {
|
this.frames = {
|
||||||
base: new Frame([this.layers.base, this.layers.tuftBase]),
|
base: new Frame([this.layers.base, this.layers.tuftBase, ...hatLayers.base]),
|
||||||
headDown: new Frame([this.layers.down, this.layers.tuftDown]),
|
headDown: new Frame([this.layers.down, this.layers.tuftDown, ...hatLayers.down]),
|
||||||
wingsDown: new Frame([this.layers.base, this.layers.tuftBase, this.layers.wingsDown]),
|
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]),
|
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, this.layers.heartOne]),
|
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, this.layers.heartTwo]),
|
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, this.layers.heartThree]),
|
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, this.layers.heartTwo]),
|
heartFour: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartTwo]),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build animations from frames
|
// Build animations from frames
|
||||||
@@ -121,14 +126,16 @@ export class Birb {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw the current animation frame
|
* 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)
|
* @returns {boolean} Whether the animation has completed (for non-looping animations)
|
||||||
*/
|
*/
|
||||||
draw(species) {
|
draw(species, hat) {
|
||||||
const anim = this.animations[this.currentAnimation];
|
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
|
* @returns {AnimationType} The current animation key
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { debug, log, error } from "./shared.js";
|
|||||||
export const SAVE_KEY = "birbSaveData";
|
export const SAVE_KEY = "birbSaveData";
|
||||||
const ROOT_PATH = "";
|
const ROOT_PATH = "";
|
||||||
const SET_CONTEXT = "__CONTEXT__"
|
const SET_CONTEXT = "__CONTEXT__"
|
||||||
|
const MONOCRAFT_URL = "__MONOCRAFT_URL__";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
|
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
|
||||||
@@ -92,6 +93,13 @@ export class Context {
|
|||||||
areStickyNotesEnabled() {
|
areStickyNotesEnabled() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getFontStyles() {
|
||||||
|
return getFontFaceImport(MONOCRAFT_URL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LocalContext extends Context {
|
export class LocalContext extends Context {
|
||||||
@@ -194,6 +202,16 @@ export class BrowserExtensionContext extends Context {
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
chrome.storage.sync.clear();
|
chrome.storage.sync.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getFontStyles() {
|
||||||
|
// Use extension bundled font file
|
||||||
|
// @ts-expect-error
|
||||||
|
return getFontFaceImport(chrome.runtime.getURL('fonts/Monocraft.otf'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ObsidianContext extends Context {
|
export class ObsidianContext extends Context {
|
||||||
@@ -276,6 +294,14 @@ export class ObsidianContext extends Context {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} src
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getFontFaceImport(src) {
|
||||||
|
return `@font-face { font-family: 'Monocraft'; src: url("${src}") format('opentype'); font-weight: normal; font-style: normal; }`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse URL parameters into a key-value map
|
* Parse URL parameters into a key-value map
|
||||||
* @param {string} url
|
* @param {string} url
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
35
src/menu.js
@@ -14,11 +14,13 @@ export class MenuItem {
|
|||||||
/**
|
/**
|
||||||
* @param {string|(() => string)} text
|
* @param {string|(() => string)} text
|
||||||
* @param {() => void} action
|
* @param {() => void} action
|
||||||
|
* @param {number[][]} [icon]
|
||||||
* @param {boolean} [removeMenu]
|
* @param {boolean} [removeMenu]
|
||||||
*/
|
*/
|
||||||
constructor(text, action, removeMenu = true) {
|
constructor(text, action, icon, removeMenu = true) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
|
this.icon = icon;
|
||||||
this.removeMenu = removeMenu;
|
this.removeMenu = removeMenu;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,10 +30,11 @@ export class ConditionalMenuItem extends MenuItem {
|
|||||||
* @param {string} text
|
* @param {string} text
|
||||||
* @param {() => void} action
|
* @param {() => void} action
|
||||||
* @param {() => boolean} condition
|
* @param {() => boolean} condition
|
||||||
|
* @param {number[][]} [icon]
|
||||||
* @param {boolean} [removeMenu]
|
* @param {boolean} [removeMenu]
|
||||||
*/
|
*/
|
||||||
constructor(text, action, condition, removeMenu = true) {
|
constructor(text, action, condition, icon, removeMenu = true) {
|
||||||
super(text, action, removeMenu);
|
super(text, action, icon, removeMenu);
|
||||||
this.condition = condition;
|
this.condition = condition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,7 @@ export class DebugMenuItem extends ConditionalMenuItem {
|
|||||||
* @param {() => void} action
|
* @param {() => void} action
|
||||||
*/
|
*/
|
||||||
constructor(text, action, removeMenu = true) {
|
constructor(text, action, removeMenu = true) {
|
||||||
super(text, action, () => isDebug(), removeMenu);
|
super(text, action, () => isDebug(), undefined, removeMenu);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +60,29 @@ export class Separator extends MenuItem {
|
|||||||
* @param {() => void} removeMenuCallback
|
* @param {() => void} removeMenuCallback
|
||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
function makeMenuItem(item, removeMenuCallback) {
|
function createMenuItem(item, removeMenuCallback) {
|
||||||
if (item instanceof Separator) {
|
if (item instanceof Separator) {
|
||||||
return makeElement("birb-window-separator");
|
return makeElement("birb-window-separator");
|
||||||
}
|
}
|
||||||
let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
|
let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
|
||||||
|
if (item.icon) {
|
||||||
|
const iconCanvas = document.createElement("canvas");
|
||||||
|
iconCanvas.width = 7;
|
||||||
|
iconCanvas.height = 6;
|
||||||
|
iconCanvas.classList.add("birb-menu-item-icon");
|
||||||
|
const ctx = iconCanvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
for (let row = 0; row < item.icon.length; row++) {
|
||||||
|
for (let col = 0; col < item.icon[row].length; col++) {
|
||||||
|
if (item.icon[row][col]) {
|
||||||
|
ctx.fillStyle = "black";
|
||||||
|
ctx.fillRect(col, row, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menuItem.prepend(iconCanvas);
|
||||||
|
}
|
||||||
onClick(menuItem, () => {
|
onClick(menuItem, () => {
|
||||||
if (item.removeMenu) {
|
if (item.removeMenu) {
|
||||||
removeMenuCallback();
|
removeMenuCallback();
|
||||||
@@ -89,7 +110,7 @@ export function insertMenu(menuItems, title, updateLocationCallback) {
|
|||||||
const removeCallback = () => removeMenu();
|
const removeCallback = () => removeMenu();
|
||||||
for (const item of menuItems) {
|
for (const item of menuItems) {
|
||||||
if (!(item instanceof ConditionalMenuItem) || item.condition()) {
|
if (!(item instanceof ConditionalMenuItem) || item.condition()) {
|
||||||
content.appendChild(makeMenuItem(item, removeCallback));
|
content.appendChild(createMenuItem(item, removeCallback));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menu.appendChild(header);
|
menu.appendChild(header);
|
||||||
@@ -146,7 +167,7 @@ export function switchMenuItems(menuItems, updateLocationCallback) {
|
|||||||
const removeCallback = () => removeMenu();
|
const removeCallback = () => removeMenu();
|
||||||
for (const item of menuItems) {
|
for (const item of menuItems) {
|
||||||
if (!(item instanceof ConditionalMenuItem) || item.condition()) {
|
if (!(item instanceof ConditionalMenuItem) || item.condition()) {
|
||||||
content.appendChild(makeMenuItem(item, removeCallback));
|
content.appendChild(createMenuItem(item, removeCallback));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateLocationCallback(menu);
|
updateLocationCallback(menu);
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function error() {
|
|||||||
* @param {number} width The width of each sprite
|
* @param {number} width The width of each sprite
|
||||||
* @returns {string[][]}
|
* @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
|
// From an array of a horizontal sprite sheet, get the layer for a specific sprite
|
||||||
const layer = [];
|
const layer = [];
|
||||||
for (let y = 0; y < width; y++) {
|
for (let y = 0; y < width; y++) {
|
||||||
|
|||||||
13
src/sound.js
@@ -8,16 +8,19 @@ export class Birdsong {
|
|||||||
audioContext;
|
audioContext;
|
||||||
|
|
||||||
chirp() {
|
chirp() {
|
||||||
|
const count = Math.floor(1 + Math.random() * 1.5);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = new AudioContext();
|
this.audioContext = new AudioContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIMES = [0, 0.06, 0.10, 0.15];
|
const TIMES = [0, 0.06, 0.10, 0.15];
|
||||||
const FREQUENCIES = [2200,
|
const FREQUENCIES = [2200,
|
||||||
3500 + Math.random() * 600,
|
3500 + Math.random() * 600 * count,
|
||||||
2100 + Math.random() * 200,
|
2100 + Math.random() * 200 * count,
|
||||||
1600 + Math.random() * 400];
|
1600 + Math.random() * 400 * count];
|
||||||
const VOLUMES = [0.0001, 0.3, 0.3, 0.0001];
|
const VOLUMES = [0.00005, 0.165, 0.165, 0.0001];
|
||||||
|
|
||||||
const oscillator = this.audioContext.createOscillator();
|
const oscillator = this.audioContext.createOscillator();
|
||||||
oscillator.type = "sine";
|
oscillator.type = "sine";
|
||||||
@@ -39,5 +42,7 @@ export class Birdsong {
|
|||||||
|
|
||||||
oscillator.start(now);
|
oscillator.start(now);
|
||||||
oscillator.stop(now + TIMES[TIMES.length - 1]);
|
oscillator.stop(now + TIMES[TIMES.length - 1]);
|
||||||
|
}, i * 120);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
430
src/species.js
Normal file
@@ -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 {
|
:root {
|
||||||
--birb-border-size: 2px;
|
--birb-border-size: 2px;
|
||||||
--birb-neg-border-size: calc(var(--birb-border-size) * -1);
|
--birb-neg-border-size: calc(var(--birb-border-size) * -1);
|
||||||
@@ -41,6 +34,22 @@
|
|||||||
z-index: 2147483630 !important;
|
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 {
|
.birb-window {
|
||||||
font-family: "Monocraft", monospace !important;
|
font-family: "Monocraft", monospace !important;
|
||||||
line-height: initial !important;
|
line-height: initial !important;
|
||||||
@@ -202,15 +211,17 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
padding-left: 10px;
|
padding-left: 2px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
opacity: 0.7 !important;
|
opacity: 0.7 !important;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: left;
|
||||||
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: black !important;
|
color: black !important;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.birb-menu-item:hover {
|
.birb-menu-item:hover {
|
||||||
@@ -222,6 +233,21 @@
|
|||||||
var(--birb-neg-border-size) 0 var(--birb-highlight),
|
var(--birb-neg-border-size) 0 var(--birb-highlight),
|
||||||
0 var(--birb-neg-border-size) var(--birb-highlight),
|
0 var(--birb-neg-border-size) var(--birb-highlight),
|
||||||
0 var(--birb-border-size) var(--birb-highlight);
|
0 var(--birb-border-size) var(--birb-highlight);
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.birb-menu-item-icon {
|
||||||
|
width: calc(7 * var(--birb-border-size));
|
||||||
|
height: calc(6 * var(--birb-border-size));
|
||||||
|
padding-right: calc(5 * var(--birb-border-size));
|
||||||
|
flex-shrink: 0;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
color: var(--birb-highlight);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.birb-menu-item:hover > .birb-menu-item-icon {
|
||||||
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.birb-menu-item-arrow {
|
.birb-menu-item-arrow {
|
||||||
@@ -238,14 +264,22 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
#birb-field-guide {
|
#birb-field-guide, #birb-wardrobe {
|
||||||
width: 322px !important;
|
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 {
|
.birb-grid-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: repeat(3, auto);
|
grid-auto-flow: row;
|
||||||
grid-auto-flow: column;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
@@ -264,10 +298,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.birb-grid-item:hover {
|
.birb-grid-item:hover {
|
||||||
border-color: var(--birb-highlight);
|
border-color: var(--birb-highlight);
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.birb-grid-item canvas {
|
.birb-grid-item canvas {
|
||||||
@@ -277,7 +313,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.birb-grid-item, .birb-field-guide-description, .birb-message-content {
|
.birb-grid-item, .birb-field-guide-description, .birb-message-content {
|
||||||
border: var(--birb-border-size) solid rgb(255, 207, 144);
|
border: var(--birb-border-size) solid #ffcf90;
|
||||||
box-shadow: 0 0 0 var(--birb-border-size) white;
|
box-shadow: 0 0 0 var(--birb-border-size) white;
|
||||||
background: rgba(255, 221, 177, 0.5);
|
background: rgba(255, 221, 177, 0.5);
|
||||||
}
|
}
|
||||||
@@ -296,6 +332,15 @@
|
|||||||
background: var(--birb-mix-color);
|
background: var(--birb-mix-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.birb-field-guide-section-label {
|
||||||
|
padding-top: 4px;
|
||||||
|
/* padding-left: calc(10px + var(--birb-border-size) / 2); */
|
||||||
|
color: #876c4e;
|
||||||
|
text-align: center;
|
||||||
|
/* Italics */
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.birb-field-guide-description {
|
.birb-field-guide-description {
|
||||||
max-width: calc(100% - 20px);
|
max-width: calc(100% - 20px);
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
@@ -307,7 +352,14 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: rgb(124, 108, 75);
|
color: #7c6c4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.birb-field-guide-latin-name {
|
||||||
|
text-decoration: underline;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: bold;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
#birb-feather {
|
#birb-feather {
|
||||||
@@ -320,7 +372,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgb(124, 108, 75);
|
color: #7c6c4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.birb-sticky-note {
|
.birb-sticky-note {
|
||||||
|
|||||||