mirror of
https://github.com/NohamR/Pocket-Bird.git
synced 2026-05-25 04:07:23 +00:00
Compare commits
67 Commits
obsidian-r
...
soft-outli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddcd7a693d | ||
|
|
868cd06210 | ||
|
|
307d4a8895 | ||
|
|
5a33cef4d5 | ||
|
|
7b4ebf7ab8 | ||
|
|
e393013b27 | ||
|
|
db1a3dcbb6 | ||
|
|
8cd93bb623 | ||
|
|
a3a09c6819 | ||
|
|
912327a348 | ||
|
|
d54f208cc4 | ||
|
|
cb1f2f605f | ||
|
|
2ee6ea84a7 | ||
|
|
5e04727a1b | ||
|
|
7b1df9bc4f | ||
|
|
130fae6e0c | ||
|
|
3b2081943d | ||
|
|
f5742ac3a7 | ||
|
|
867d214292 | ||
|
|
d97e39449e | ||
|
|
7628ee2c87 | ||
|
|
3227167cb5 | ||
|
|
e0fae3781a | ||
|
|
2773538a6c | ||
|
|
2a90a56a2b | ||
|
|
94454a2338 | ||
|
|
cf968dfec4 | ||
|
|
4838457054 | ||
|
|
e09d4f9eea | ||
|
|
7c38bf9164 | ||
|
|
8263fadfba | ||
|
|
9f7d864e57 | ||
|
|
579967a302 | ||
|
|
ca1495a9f1 | ||
|
|
fd865cacb8 | ||
|
|
5e94998410 | ||
|
|
e13a67e967 | ||
|
|
e1759bc235 | ||
|
|
1d818d83cf | ||
|
|
47b418324c | ||
|
|
e5956426d5 | ||
|
|
0cc06a8856 | ||
|
|
dd4184f642 | ||
|
|
5a82ba858f | ||
|
|
b8de14bb94 | ||
|
|
e0ab21d608 | ||
|
|
86c254b2cb | ||
|
|
14ef2713d8 | ||
|
|
bc3f5cce02 | ||
|
|
c3ff3b39dc | ||
|
|
8b8aa50cae | ||
|
|
f4b598d2dc | ||
|
|
bccaa0aa15 | ||
|
|
37a30ea509 | ||
|
|
e7be2b7661 | ||
|
|
c750bf5560 | ||
|
|
c927ce23e4 | ||
|
|
6ee9efd5a8 | ||
|
|
a5e81e4265 | ||
|
|
76e55a3caa | ||
|
|
224fe4aaec | ||
|
|
764e8311fa | ||
|
|
b66521b7a2 | ||
|
|
72f2805fa0 | ||
|
|
1ca5094536 | ||
|
|
2ddf10bb01 | ||
|
|
9eba0c13ed |
30
README.md
30
README.md
@@ -9,10 +9,14 @@
|
||||
|
||||
It's a pet bird that hops around your computer, what more could you want?
|
||||
|
||||
### Get it for [Google Chrome](https://chromewebstore.google.com/detail/pocket-bird/lbbdngkbbgaecefacpnhnhleggabghak)
|
||||
### Get it for [Google Chrome](https://chromewebstore.google.com/detail/pocket-bird/lbbdngkbbgaecefacpnhnhleggabghak)
|
||||
|
||||
### Get it for [Mozilla Firefox](https://addons.mozilla.org/en-US/firefox/addon/pocket-bird/)
|
||||
|
||||
### Get it for [Obsidian (beta)](https://github.com/IdreesInc/Pocket-Bird#Obsidian)
|
||||
|
||||
### Get it for [TamperMonkey](https://github.com/IdreesInc/Pocket-Bird#Userscript)
|
||||
|
||||
#### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas!
|
||||
|
||||
## Features
|
||||
@@ -37,7 +41,11 @@ It's a pet bird that hops around your computer, what more could you want?
|
||||
|
||||
### Obsidian
|
||||
|
||||
_Coming soon!_
|
||||
1. Install the [Beta Plugin Manager (BRAT)](https://obsidian.md/plugins?id=obsidian42-brat) plugin for Obsidian
|
||||
2. Enable the BRAT plugin and open its settings
|
||||
3. In the BRAT settings, click "Add Beta Plugin" and enter the following URL: `https://github.com/IdreesInc/PB-Obsidian-Releases`
|
||||
4. Select "Latest version" and click "Add Plugin"
|
||||
5. Enjoy a pet bird in your Obsidian notes!
|
||||
|
||||
### Userscript
|
||||
|
||||
@@ -48,6 +56,14 @@ _Coming soon!_
|
||||
3. Install my Pocket Bird script by going to this link and clicking install: [https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js](https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js)
|
||||
4. Now any websites you visit will have a little bird hopping around!
|
||||
|
||||
### Your Own Website
|
||||
|
||||
Pocket Bird can also be embedded directly into your own website! Just include the following code snippet anywhere in your HTML:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/gh/IdreesInc/Pocket-Bird@main/dist/web/birb.embed.js"></script>
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### How do I pet the bird?
|
||||
@@ -74,6 +90,16 @@ Open the Pocket Bird menu by clicking the bird and select "Settings". From there
|
||||
|
||||
If you are running Pocket bird on a browser, the extension needs these permissions in order to insert the bird and sticky notes into your webpages. Pocket Bird does not collect any of your data or browsing history and all data is stored locally on your device!
|
||||
|
||||
## Sites With Pocket Bird
|
||||
|
||||
Here are some websites where you can find Pocket Bird hopping around:
|
||||
|
||||
- [https://grepjason.sh](https://grepjason.sh)
|
||||
- [https://binarydigit.dev](https://binarydigit.dev)
|
||||
|
||||
*If you've added Pocket Bird to your website, let me know and I'll add it to this list!*
|
||||
|
||||
|
||||
## Getting in Touch
|
||||
|
||||
If you'd like to get in touch, check out the [Discord](https://discord.gg/6yxE9prcNc) to suggest features, report bugs, and stay updated on development!
|
||||
|
||||
BIN
aseprite/birb-white.aseprite
Normal file
BIN
aseprite/birb-white.aseprite
Normal file
Binary file not shown.
Binary file not shown.
BIN
aseprite/hats.aseprite
Normal file
BIN
aseprite/hats.aseprite
Normal file
Binary file not shown.
206
build.js
206
build.js
@@ -12,19 +12,24 @@ const IMAGES_DIR = "./images";
|
||||
const FONTS_DIR = "./fonts";
|
||||
const DIST_DIR = "./dist";
|
||||
|
||||
const BROWSER_MANIFEST = "./platform-specific/extension/manifest.json";
|
||||
const OBSIDIAN_MANIFEST = "./platform-specific/obsidian/manifest.json";
|
||||
const USERSCRIPT_HEADER = "./platform-specific/userscript/header.txt";
|
||||
const OBSIDIAN_WRAPPER = "./platform-specific/obsidian/wrapper.js";
|
||||
|
||||
const WEB_DIR = DIST_DIR + "/web";
|
||||
const USERSCRIPT_DIR = DIST_DIR + "/userscript";
|
||||
const EXTENSION_DIR = DIST_DIR + "/extension";
|
||||
const OBSIDIAN_DIR = DIST_DIR + "/obsidian";
|
||||
|
||||
const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css";
|
||||
const APPLICATION_ENTRY = SRC_DIR + "/application.js";
|
||||
const BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js";
|
||||
const BIRB_OUTPUT = DIST_DIR + "/birb.js";
|
||||
|
||||
const WEB_ENTRY = SRC_DIR + "/platforms/web/web.js";
|
||||
const USERSCRIPT_ENTRY = SRC_DIR + "/platforms/userscript/userscript.js";
|
||||
const BROWSER_EXTENSION_ENTRY = SRC_DIR + "/platforms/extension/extension.js";
|
||||
const OBSIDIAN_ENTRY = SRC_DIR + "/platforms/obsidian/obsidian.js";
|
||||
|
||||
const BROWSER_MANIFEST = SRC_DIR + "/platforms/extension/manifest.json";
|
||||
const OBSIDIAN_MANIFEST = SRC_DIR + "/platforms/obsidian/manifest.json";
|
||||
const USERSCRIPT_HEADER = SRC_DIR + "/platforms/userscript/header.txt";
|
||||
const OBSIDIAN_WRAPPER = SRC_DIR + "/platforms/obsidian/wrapper.js";
|
||||
|
||||
const TEMP_BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js";
|
||||
|
||||
const MONOCRAFT_URL = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf";
|
||||
|
||||
@@ -41,6 +46,10 @@ const spriteSheets = [
|
||||
{
|
||||
key: "__FEATHER_SPRITE_SHEET__",
|
||||
path: SPRITES_DIR + "/feather.png"
|
||||
},
|
||||
{
|
||||
key: "__HATS_SPRITE_SHEET__",
|
||||
path: SPRITES_DIR + "/hats.png"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -67,119 +76,138 @@ if (buildCache.version && buildCache.version.startsWith(versionDate)) {
|
||||
}
|
||||
}
|
||||
|
||||
const version = `${versionDate}.${buildNumber}`;
|
||||
// const version = `${versionDate}.${buildNumber}`;
|
||||
const version = `${versionDate}`; // Disable build number for now
|
||||
|
||||
// Update build cache
|
||||
buildCache.version = version;
|
||||
writeFileSync(BUILD_CACHE_PATH, JSON.stringify(buildCache), 'utf8');
|
||||
|
||||
// =============================================
|
||||
// Build JavaScript function
|
||||
// =============================================
|
||||
/**
|
||||
* @param {string} entryPoint
|
||||
* @param {boolean} [embedFont]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function generateCode(entryPoint, embedFont = false) {
|
||||
// Bundle with rollup
|
||||
const bundle = await rollup({
|
||||
input: entryPoint,
|
||||
});
|
||||
|
||||
// Bundle with rollup
|
||||
const bundle = await rollup({
|
||||
input: APPLICATION_ENTRY,
|
||||
});
|
||||
await bundle.write({
|
||||
file: TEMP_BUNDLED_OUTPUT,
|
||||
format: 'iife',
|
||||
});
|
||||
|
||||
await bundle.write({
|
||||
file: BUNDLED_OUTPUT,
|
||||
format: 'iife',
|
||||
});
|
||||
await bundle.close();
|
||||
|
||||
await bundle.close();
|
||||
let birbJs = readFileSync(TEMP_BUNDLED_OUTPUT, 'utf8');
|
||||
|
||||
let birbJs = readFileSync(BUNDLED_OUTPUT, 'utf8');
|
||||
// Delete bundled file
|
||||
unlinkSync(TEMP_BUNDLED_OUTPUT);
|
||||
|
||||
// Delete bundled file
|
||||
unlinkSync(BUNDLED_OUTPUT);
|
||||
// Replace version placeholder
|
||||
birbJs = birbJs.replaceAll(VERSION_KEY, version);
|
||||
|
||||
// Replace version placeholder
|
||||
birbJs = birbJs.replaceAll(VERSION_KEY, version);
|
||||
// Compile and insert sprite sheets
|
||||
for (const spriteSheet of spriteSheets) {
|
||||
const dataUri = readFileSync(spriteSheet.path, 'base64');
|
||||
birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`);
|
||||
}
|
||||
|
||||
// Compile and insert sprite sheets
|
||||
for (const spriteSheet of spriteSheets) {
|
||||
const dataUri = readFileSync(spriteSheet.path, 'base64');
|
||||
birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`);
|
||||
// Insert stylesheet
|
||||
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
|
||||
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
|
||||
|
||||
if (embedFont) {
|
||||
// Encode font to data URI
|
||||
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
|
||||
const monocraftDataUri = `data:font/otf;base64,${monocraftFontData}`;
|
||||
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, monocraftDataUri);
|
||||
} else {
|
||||
birbJs = birbJs.replaceAll(MONOCRAFT_SRC_KEY, MONOCRAFT_URL);
|
||||
}
|
||||
return birbJs;
|
||||
}
|
||||
|
||||
// Insert stylesheet
|
||||
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
|
||||
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent).replace(MONOCRAFT_SRC_KEY, MONOCRAFT_URL);
|
||||
async function buildWeb() {
|
||||
const birbJs = await generateCode(WEB_ENTRY);
|
||||
mkdirSync(WEB_DIR, { recursive: true });
|
||||
writeFileSync(WEB_DIR + '/birb.js', birbJs);
|
||||
writeFileSync(WEB_DIR + '/birb.embed.js', birbJs);
|
||||
}
|
||||
|
||||
async function buildUserscript() {
|
||||
const birbJs = await generateCode(USERSCRIPT_ENTRY);
|
||||
|
||||
// Write bundled JavaScript function
|
||||
writeFileSync(BIRB_OUTPUT, birbJs);
|
||||
// Get userscript header
|
||||
const userScriptHeader = readFileSync(USERSCRIPT_HEADER, 'utf8').replaceAll(VERSION_KEY, version);
|
||||
|
||||
// =============================================
|
||||
// Build userscript
|
||||
// =============================================
|
||||
mkdirSync(USERSCRIPT_DIR, { recursive: true });
|
||||
const userScript = userScriptHeader + "\n" + birbJs;
|
||||
writeFileSync(USERSCRIPT_DIR + '/birb.user.js', userScript);
|
||||
}
|
||||
|
||||
// Get userscript header
|
||||
const userScriptHeader = readFileSync(USERSCRIPT_HEADER, 'utf8').replaceAll(VERSION_KEY, version);
|
||||
async function buildExtension() {
|
||||
const birbJs = await generateCode(BROWSER_EXTENSION_ENTRY);
|
||||
|
||||
mkdirSync(USERSCRIPT_DIR, { recursive: true });
|
||||
const userScript = userScriptHeader + "\n" + birbJs;
|
||||
writeFileSync(USERSCRIPT_DIR + '/birb.user.js', userScript);
|
||||
mkdirSync(EXTENSION_DIR, { recursive: true });
|
||||
|
||||
// =============================================
|
||||
// Build browser extension
|
||||
// =============================================
|
||||
// Copy birb.js
|
||||
writeFileSync(EXTENSION_DIR + '/birb.js', birbJs);
|
||||
|
||||
mkdirSync(EXTENSION_DIR, { recursive: true });
|
||||
// Copy manifest.json
|
||||
let browserManifest = readFileSync(BROWSER_MANIFEST, 'utf8');
|
||||
browserManifest = browserManifest.replace(VERSION_KEY, version);
|
||||
writeFileSync(EXTENSION_DIR + '/manifest.json', browserManifest);
|
||||
|
||||
// Copy birb.js
|
||||
writeFileSync(EXTENSION_DIR + '/birb.js', birbJs);
|
||||
// Copy icons folder
|
||||
mkdirSync(EXTENSION_DIR + '/images/icons', { recursive: true });
|
||||
cpSync(IMAGES_DIR + '/icons/transparent', EXTENSION_DIR + '/images/icons/transparent', { recursive: true });
|
||||
|
||||
// Copy manifest.json
|
||||
let browserManifest = readFileSync(BROWSER_MANIFEST, 'utf8');
|
||||
browserManifest = browserManifest.replace(VERSION_KEY, version);
|
||||
writeFileSync(EXTENSION_DIR + '/manifest.json', browserManifest);
|
||||
// Copy fonts folder
|
||||
mkdirSync(EXTENSION_DIR + '/fonts', { recursive: true });
|
||||
cpSync(FONTS_DIR, EXTENSION_DIR + '/fonts', { recursive: true });
|
||||
|
||||
// Copy icons folder
|
||||
mkdirSync(EXTENSION_DIR + '/images/icons', { recursive: true });
|
||||
cpSync(IMAGES_DIR + '/icons/transparent', EXTENSION_DIR + '/images/icons/transparent', { recursive: true });
|
||||
// Compress extension folder into zip
|
||||
const output = createWriteStream(DIST_DIR + "/extension.zip");
|
||||
const archive = archiver('zip');
|
||||
|
||||
// Copy fonts folder
|
||||
mkdirSync(EXTENSION_DIR + '/fonts', { recursive: true });
|
||||
cpSync(FONTS_DIR, EXTENSION_DIR + '/fonts', { recursive: true });
|
||||
output.on('close', () => {
|
||||
console.log(`Created zip file: ${archive.pointer()} total bytes`);
|
||||
});
|
||||
|
||||
// Compress extension folder into zip
|
||||
const output = createWriteStream(DIST_DIR + "/extension.zip");
|
||||
const archive = archiver('zip');
|
||||
archive.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
output.on('close', () => {
|
||||
console.log(`Created zip file: ${archive.pointer()} total bytes`);
|
||||
});
|
||||
archive.pipe(output);
|
||||
archive.directory(EXTENSION_DIR + '/', false);
|
||||
archive.finalize();
|
||||
}
|
||||
|
||||
archive.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
async function buildObsidian() {
|
||||
const birbJs = await generateCode(OBSIDIAN_ENTRY, true);
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(EXTENSION_DIR + '/', false);
|
||||
archive.finalize();
|
||||
mkdirSync(OBSIDIAN_DIR, { recursive: true });
|
||||
|
||||
// =============================================
|
||||
// Build Obsidian plugin
|
||||
// =============================================
|
||||
// Wrap birb.js with plugin boilerplate
|
||||
let obsidianPlugin = readFileSync(OBSIDIAN_WRAPPER, 'utf8').replace(VERSION_KEY, version).replace(CODE_KEY, birbJs);
|
||||
|
||||
mkdirSync(OBSIDIAN_DIR, { recursive: true });
|
||||
// Create main.js with plugin code
|
||||
writeFileSync(OBSIDIAN_DIR + '/main.js', obsidianPlugin);
|
||||
|
||||
// Wrap birb.js with plugin boilerplate
|
||||
let obsidianPlugin = readFileSync(OBSIDIAN_WRAPPER, 'utf8').replace(VERSION_KEY, version).replace(CODE_KEY, birbJs);
|
||||
// Copy manifest.json
|
||||
let obsidianManifest = readFileSync(OBSIDIAN_MANIFEST, 'utf8');
|
||||
obsidianManifest = obsidianManifest.replace(/"version":\s*".*"/, `"version": "${version}"`);
|
||||
writeFileSync(OBSIDIAN_DIR + '/manifest.json', obsidianManifest);
|
||||
}
|
||||
|
||||
// Encode font to data URI since Obsidian plugins can't have external font files
|
||||
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
|
||||
const monocraftDataUri = `data:font/otf;base64,${monocraftFontData}`;
|
||||
obsidianPlugin = obsidianPlugin.replace(MONOCRAFT_URL, monocraftDataUri);
|
||||
console.log("Starting build...");
|
||||
|
||||
// Create main.js with plugin code
|
||||
writeFileSync(OBSIDIAN_DIR + '/main.js', obsidianPlugin);
|
||||
await buildWeb();
|
||||
await buildUserscript();
|
||||
await buildExtension();
|
||||
await buildObsidian();
|
||||
|
||||
// Copy manifest.json
|
||||
let obsidianManifest = readFileSync(OBSIDIAN_MANIFEST, 'utf8');
|
||||
obsidianManifest = obsidianManifest.replace(/"version":\s*".*"/, `"version": "${version}"`);
|
||||
writeFileSync(OBSIDIAN_DIR + '/manifest.json', obsidianManifest);
|
||||
|
||||
console.log(`Build complete: ${version}`);
|
||||
console.log("Build completed successfully!");
|
||||
BIN
dist/extension.zip
vendored
BIN
dist/extension.zip
vendored
Binary file not shown.
1345
dist/extension/birb.js
vendored
1345
dist/extension/birb.js
vendored
File diff suppressed because it is too large
Load Diff
2
dist/extension/manifest.json
vendored
2
dist/extension/manifest.json
vendored
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Pocket Bird",
|
||||
"description": "It's a pet bird in your browser, what more could you want?",
|
||||
"version": "2025.11.14.205",
|
||||
"version": "2026.1.24",
|
||||
"homepage_url": "https://idreesinc.com",
|
||||
"icons": {
|
||||
"48": "images/icons/transparent/48x48x1.png",
|
||||
|
||||
1659
dist/obsidian/main.js
vendored
1659
dist/obsidian/main.js
vendored
File diff suppressed because one or more lines are too long
4
dist/obsidian/manifest.json
vendored
4
dist/obsidian/manifest.json
vendored
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "pocket-bird",
|
||||
"name": "Pocket Bird",
|
||||
"version": "2025.11.14",
|
||||
"version": "2026.1.24",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "It's a pet bird in your Obsidian, what more could you want?",
|
||||
"description": "Add a pet bird to fly around your notes and keep you company!",
|
||||
"author": "Idrees Hassan",
|
||||
"authorUrl": "https://idreesinc.com",
|
||||
"isDesktopOnly": false
|
||||
|
||||
372
dist/obsidian/styles.css
vendored
372
dist/obsidian/styles.css
vendored
File diff suppressed because one or more lines are too long
1352
dist/userscript/birb.user.js
vendored
1352
dist/userscript/birb.user.js
vendored
File diff suppressed because it is too large
Load Diff
3182
dist/web/birb.embed.js
vendored
Normal file
3182
dist/web/birb.embed.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1356
dist/birb.js → dist/web/birb.js
vendored
1356
dist/birb.js → dist/web/birb.js
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"id": "pocket-bird",
|
||||
"name": "Pocket Bird",
|
||||
"version": "__VERSION__",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "It's a pet bird in your Obsidian, what more could you want?",
|
||||
"author": "Idrees Hassan",
|
||||
"authorUrl": "https://idreesinc.com",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
@@ -26,6 +26,6 @@
|
||||
</div>
|
||||
<div id="spacer"></div>
|
||||
<!-- <script type="module" src="spritesheet-compiler.js"></script> -->
|
||||
<script src="../dist/birb.js"></script>
|
||||
<script src="../dist/web/birb.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
sprites/birb.png
BIN
sprites/birb.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
sprites/hats.png
Normal file
BIN
sprites/hats.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 939 B |
76
src/Frame.js
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
12
src/Layer.js
@@ -1,12 +0,0 @@
|
||||
class Layer {
|
||||
/**
|
||||
* @param {string[][]} pixels
|
||||
* @param {string} [tag]
|
||||
*/
|
||||
constructor(pixels, tag = "default") {
|
||||
this.pixels = pixels;
|
||||
this.tag = tag;
|
||||
}
|
||||
}
|
||||
|
||||
export default Layer;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Frame from "./frame.js";
|
||||
import { BirdType } from "./sprites";
|
||||
import { BirdType } from "./sprites.js";
|
||||
|
||||
class Anim {
|
||||
/**
|
||||
@@ -59,10 +59,11 @@ class Anim {
|
||||
* @param {number} direction
|
||||
* @param {number} timeStart The start time of the animation in milliseconds
|
||||
* @param {number} canvasPixelSize The size of a canvas pixel in pixels
|
||||
* @param {BirdType} [species] The species to use for the animation
|
||||
* @param {{ [key: string]: string }} colorScheme The color scheme to use for the animation
|
||||
* @param {string[]} tags The tags to use for the animation
|
||||
* @returns {boolean} Whether the animation is complete
|
||||
*/
|
||||
draw(ctx, direction, timeStart, canvasPixelSize, species) {
|
||||
draw(ctx, direction, timeStart, canvasPixelSize, colorScheme, tags) {
|
||||
// Reset cache if animation was restarted
|
||||
if (this.lastTimeStart !== timeStart) {
|
||||
this.#clearCache();
|
||||
@@ -79,7 +80,7 @@ class Anim {
|
||||
const currentFrameIndex = this.getCurrentFrameIndex(time);
|
||||
|
||||
if (this.#shouldRedraw(currentFrameIndex, direction)) {
|
||||
this.frames[currentFrameIndex].draw(ctx, direction, canvasPixelSize, species);
|
||||
this.frames[currentFrameIndex].draw(ctx, direction, canvasPixelSize, colorScheme, tags);
|
||||
this.lastFrameIndex = currentFrameIndex;
|
||||
this.lastDirection = direction;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Directions } from './shared.js';
|
||||
import { Sprite, BirdType } from './sprites.js';
|
||||
import Layer from './layer.js';
|
||||
import { Directions } from '../shared.js';
|
||||
import { PALETTE, BirdType } from './sprites.js';
|
||||
import Layer, { TAG } from './layer.js';
|
||||
|
||||
class Frame {
|
||||
|
||||
@@ -16,25 +16,25 @@ class Frame {
|
||||
for (let layer of layers) {
|
||||
tags.add(layer.tag);
|
||||
}
|
||||
tags.add("default");
|
||||
tags.add(TAG.DEFAULT);
|
||||
for (let tag of tags) {
|
||||
let maxHeight = layers.reduce((max, layer) => Math.max(max, layer.pixels.length), 0);
|
||||
if (layers[0].tag !== "default") {
|
||||
if (layers[0].tag !== TAG.DEFAULT) {
|
||||
throw new Error("First layer must have the 'default' tag");
|
||||
}
|
||||
this.pixels = layers[0].pixels.map(row => row.slice());
|
||||
// Pad from top with transparent pixels
|
||||
while (this.pixels.length < maxHeight) {
|
||||
this.pixels.unshift(new Array(this.pixels[0].length).fill(Sprite.TRANSPARENT));
|
||||
this.pixels.unshift(new Array(this.pixels[0].length).fill(PALETTE.TRANSPARENT));
|
||||
}
|
||||
// Combine layers
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
if (layers[i].tag === "default" || layers[i].tag === tag) {
|
||||
if (layers[i].tag === TAG.DEFAULT || layers[i].tag === tag) {
|
||||
let layerPixels = layers[i].pixels;
|
||||
let topMargin = maxHeight - layerPixels.length;
|
||||
for (let y = 0; y < layerPixels.length; y++) {
|
||||
for (let x = 0; x < layerPixels[y].length; x++) {
|
||||
this.pixels[y + topMargin][x] = layerPixels[y][x] !== Sprite.TRANSPARENT ? layerPixels[y][x] : this.pixels[y + topMargin][x];
|
||||
this.pixels[y + topMargin][x] = layerPixels[y][x] !== PALETTE.TRANSPARENT ? layerPixels[y][x] : this.pixels[y + topMargin][x];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,29 +44,36 @@ class Frame {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [tag]
|
||||
* @param {string[]} [tags]
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
getPixels(tag = "default") {
|
||||
return this.#pixelsByTag[tag] ?? this.#pixelsByTag["default"];
|
||||
getPixels(tags = [TAG.DEFAULT]) {
|
||||
for (let i = tags.length - 1; i >= 0; i--) {
|
||||
const tag = tags[i];
|
||||
if (this.#pixelsByTag[tag]) {
|
||||
return this.#pixelsByTag[tag];
|
||||
}
|
||||
}
|
||||
return this.#pixelsByTag[TAG.DEFAULT];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {BirdType} [species]
|
||||
* @param {number} direction
|
||||
* @param {number} direction
|
||||
* @param {number} canvasPixelSize
|
||||
* @param {{ [key: string]: string }} colorScheme
|
||||
* @param {string[]} tags
|
||||
*/
|
||||
draw(ctx, direction, canvasPixelSize, species) {
|
||||
draw(ctx, direction, canvasPixelSize, colorScheme, tags) {
|
||||
// Clear the canvas before drawing the new frame
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
const pixels = this.getPixels(species?.tags[0]);
|
||||
const pixels = this.getPixels(tags);
|
||||
for (let y = 0; y < pixels.length; y++) {
|
||||
const row = pixels[y];
|
||||
for (let x = 0; x < pixels[y].length; x++) {
|
||||
const cell = direction === Directions.LEFT ? row[x] : row[pixels[y].length - x - 1];
|
||||
ctx.fillStyle = species?.colors[cell] ?? cell;
|
||||
ctx.fillStyle = colorScheme[cell] ?? cell;
|
||||
ctx.fillRect(x * canvasPixelSize, y * canvasPixelSize, canvasPixelSize, canvasPixelSize);
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
export const TAG = {
|
||||
DEFAULT: "default",
|
||||
TUFT: "tuft",
|
||||
};
|
||||
|
||||
class Layer {
|
||||
/**
|
||||
* @param {string[][]} pixels
|
||||
* @param {string} [tag]
|
||||
*/
|
||||
constructor(pixels, tag = "default") {
|
||||
constructor(pixels, tag = TAG.DEFAULT) {
|
||||
this.pixels = pixels;
|
||||
this.tag = tag;
|
||||
}
|
||||
209
src/animation/sprites.js
Normal file
209
src/animation/sprites.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { TAG } from "./layer.js";
|
||||
|
||||
/**
|
||||
* Palette color names
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
export const PALETTE = {
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of sprite sheet colors to palette colors
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
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,
|
||||
"#d95763": PALETTE.NOSE,
|
||||
"#f8b143": PALETTE.BELLY,
|
||||
"#ec8637": PALETTE.UNDERBELLY,
|
||||
"#578ae6": PALETTE.WING,
|
||||
"#326ed9": PALETTE.WING_EDGE,
|
||||
"#c82e2e": PALETTE.HEART,
|
||||
"#501a1a": PALETTE.HEART_BORDER,
|
||||
"#ff6b6b": PALETTE.HEART_SHINE,
|
||||
"#373737": PALETTE.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 = {
|
||||
[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.NOSE]: colors.face,
|
||||
};
|
||||
/** @type {Record<string, string>} */
|
||||
this.colors = { ...defaultColors, ...colors, [PALETTE.THEME_HIGHLIGHT]: colors[PALETTE.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.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#639bff",
|
||||
[PALETTE.BELLY]: "#f8b143",
|
||||
[PALETTE.UNDERBELLY]: "#ec8637",
|
||||
[PALETTE.WING]: "#578ae6",
|
||||
[PALETTE.WING_EDGE]: "#326ed9",
|
||||
}),
|
||||
shimaEnaga: new BirdType("Shima Enaga",
|
||||
"Small, fluffy birds found in the snowy regions of Japan, these birds are highly sought after by ornithologists and nature photographers.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#ffffff",
|
||||
[PALETTE.BELLY]: "#ebe9e8",
|
||||
[PALETTE.UNDERBELLY]: "#ebd9d0",
|
||||
[PALETTE.WING]: "#f3d3c1",
|
||||
[PALETTE.WING_EDGE]: "#2d2d2d",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#d7ac93",
|
||||
}),
|
||||
tuftedTitmouse: new BirdType("Tufted Titmouse",
|
||||
"Native to the eastern United States, full of personality, and notably my wife's favorite bird.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#c7cad7",
|
||||
[PALETTE.BELLY]: "#e4e5eb",
|
||||
[PALETTE.UNDERBELLY]: "#d7cfcb",
|
||||
[PALETTE.WING]: "#b1b5c5",
|
||||
[PALETTE.WING_EDGE]: "#9d9fa9",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#b9abcf",
|
||||
}, [TAG.TUFT]),
|
||||
europeanRobin: new BirdType("European Robin",
|
||||
"Native to western Europe, this is the quintessential robin. Quite friendly, you'll often find them searching for worms.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#ffaf34",
|
||||
[PALETTE.HOOD]: "#aaa094",
|
||||
[PALETTE.BELLY]: "#ffaf34",
|
||||
[PALETTE.UNDERBELLY]: "#babec2",
|
||||
[PALETTE.WING]: "#aaa094",
|
||||
[PALETTE.WING_EDGE]: "#888580",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#ffaf34",
|
||||
}),
|
||||
redCardinal: new BirdType("Red Cardinal",
|
||||
"Native to the eastern United States, this strikingly red bird is hard to miss.", {
|
||||
[PALETTE.BEAK]: "#d93619",
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#31353d",
|
||||
[PALETTE.HOOD]: "#e83a1b",
|
||||
[PALETTE.BELLY]: "#e83a1b",
|
||||
[PALETTE.UNDERBELLY]: "#dc3719",
|
||||
[PALETTE.WING]: "#d23215",
|
||||
[PALETTE.WING_EDGE]: "#b1321c",
|
||||
}, [TAG.TUFT]),
|
||||
americanGoldfinch: new BirdType("American Goldfinch",
|
||||
"Coloured a brilliant yellow, this bird feeds almost entirely on the seeds of plants such as thistle, sunflowers, and coneflowers.", {
|
||||
[PALETTE.BEAK]: "#ffaf34",
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#fff255",
|
||||
[PALETTE.NOSE]: "#383838",
|
||||
[PALETTE.HOOD]: "#383838",
|
||||
[PALETTE.BELLY]: "#fff255",
|
||||
[PALETTE.UNDERBELLY]: "#f5ea63",
|
||||
[PALETTE.WING]: "#e8e079",
|
||||
[PALETTE.WING_EDGE]: "#191919",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#ffcc00"
|
||||
}),
|
||||
barnSwallow: new BirdType("Barn Swallow",
|
||||
"Agile birds that often roost in man-made structures, these birds are known to build nests near Ospreys for protection.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#db7c4d",
|
||||
[PALETTE.BELLY]: "#f7e1c9",
|
||||
[PALETTE.UNDERBELLY]: "#ebc9a3",
|
||||
[PALETTE.WING]: "#2252a9",
|
||||
[PALETTE.WING_EDGE]: "#1c448b",
|
||||
[PALETTE.HOOD]: "#2252a9",
|
||||
}),
|
||||
mistletoebird: new BirdType("Mistletoebird",
|
||||
"Native to Australia, these birds eat mainly mistletoe and in turn spread the seeds far and wide.", {
|
||||
[PALETTE.FOOT]: "#6c6a7c",
|
||||
[PALETTE.FACE]: "#352e6d",
|
||||
[PALETTE.BELLY]: "#fd6833",
|
||||
[PALETTE.UNDERBELLY]: "#e6e1d8",
|
||||
[PALETTE.WING]: "#342b7c",
|
||||
[PALETTE.WING_EDGE]: "#282065",
|
||||
}),
|
||||
redAvadavat: new BirdType("Red Avadavat",
|
||||
"Native to India and southeast Asia, these birds are also known as Strawberry Finches due to their speckled plumage.", {
|
||||
[PALETTE.BEAK]: "#f71919",
|
||||
[PALETTE.FOOT]: "#af7575",
|
||||
[PALETTE.FACE]: "#cb092b",
|
||||
[PALETTE.BELLY]: "#ae1724",
|
||||
[PALETTE.UNDERBELLY]: "#831b24",
|
||||
[PALETTE.WING]: "#7e3030",
|
||||
[PALETTE.WING_EDGE]: "#490f0f",
|
||||
}),
|
||||
scarletRobin: new BirdType("Scarlet Robin",
|
||||
"Native to Australia, this striking robin can be found in Eucalyptus forests.", {
|
||||
[PALETTE.FOOT]: "#494949",
|
||||
[PALETTE.FACE]: "#3d3d3d",
|
||||
[PALETTE.BELLY]: "#fc5633",
|
||||
[PALETTE.UNDERBELLY]: "#dcdcdc",
|
||||
[PALETTE.WING]: "#2b2b2b",
|
||||
[PALETTE.WING_EDGE]: "#ebebeb",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#fc5633",
|
||||
}),
|
||||
americanRobin: new BirdType("American Robin",
|
||||
"While not a true robin, this social North American bird is so named due to its orange coloring. It seems unbothered by nearby humans.", {
|
||||
[PALETTE.BEAK]: "#e89f30",
|
||||
[PALETTE.FOOT]: "#9f8075",
|
||||
[PALETTE.FACE]: "#2d2d2d",
|
||||
[PALETTE.BELLY]: "#eb7a3a",
|
||||
[PALETTE.UNDERBELLY]: "#eb7a3a",
|
||||
[PALETTE.WING]: "#444444",
|
||||
[PALETTE.WING_EDGE]: "#232323",
|
||||
[PALETTE.THEME_HIGHLIGHT]: "#eb7a3a",
|
||||
}),
|
||||
carolinaWren: new BirdType("Carolina Wren",
|
||||
"Native to the eastern United States, these little birds are known for their curious and energetic nature.", {
|
||||
[PALETTE.FOOT]: "#af8e75",
|
||||
[PALETTE.FACE]: "#edc7a9",
|
||||
[PALETTE.NOSE]: "#f7eee5",
|
||||
[PALETTE.HOOD]: "#c58a5b",
|
||||
[PALETTE.BELLY]: "#e1b796",
|
||||
[PALETTE.UNDERBELLY]: "#c79e7c",
|
||||
[PALETTE.WING]: "#c58a5b",
|
||||
[PALETTE.WING_EDGE]: "#866348",
|
||||
}),
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import Frame from './frame.js';
|
||||
import Layer from './layer.js';
|
||||
import Anim from './anim.js';
|
||||
import Frame from './animation/frame.js';
|
||||
import Layer, { TAG } from './animation/layer.js';
|
||||
import Anim from './animation/anim.js';
|
||||
import { Birb, Animations } from './birb.js';
|
||||
import { getContext, ObsidianContext } from './context.js';
|
||||
import { Birdsong } from './sound.js';
|
||||
import { Context, ObsidianContext } from './context.js';
|
||||
|
||||
import {
|
||||
getContext,
|
||||
setContext,
|
||||
Directions,
|
||||
isDebug,
|
||||
setDebug,
|
||||
@@ -16,14 +19,14 @@ import {
|
||||
log,
|
||||
debug,
|
||||
error,
|
||||
getLayer,
|
||||
getLayerPixels,
|
||||
getWindowHeight
|
||||
} from './shared.js';
|
||||
import {
|
||||
Sprite,
|
||||
PALETTE,
|
||||
SPRITE_SHEET_COLOR_MAP,
|
||||
SPECIES
|
||||
} from './sprites.js';
|
||||
} from './animation/sprites.js';
|
||||
import {
|
||||
StickyNote,
|
||||
createNewStickyNote,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
switchMenuItems,
|
||||
MENU_EXIT_ID
|
||||
} from './menu.js';
|
||||
import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js';
|
||||
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,8 @@ import {
|
||||
* @typedef {Object} BirbSaveData
|
||||
* @property {string[]} unlockedSpecies
|
||||
* @property {string} currentSpecies
|
||||
* @property {string[]} unlockedHats
|
||||
* @property {string} currentHat
|
||||
* @property {Partial<Settings>} settings
|
||||
* @property {SavedStickyNote[]} [stickyNotes]
|
||||
*/
|
||||
@@ -58,7 +64,8 @@ import {
|
||||
* @typedef {typeof DEFAULT_SETTINGS} Settings
|
||||
*/
|
||||
const DEFAULT_SETTINGS = {
|
||||
birbMode: false
|
||||
birbMode: false,
|
||||
soundEnabled: true
|
||||
};
|
||||
|
||||
// Rendering constants
|
||||
@@ -74,12 +81,16 @@ const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE;
|
||||
const STYLESHEET = `___STYLESHEET___`;
|
||||
const SPRITE_SHEET = "__SPRITE_SHEET__";
|
||||
const FEATHER_SPRITE_SHEET = "__FEATHER_SPRITE_SHEET__";
|
||||
const HATS_SPRITE_SHEET = "__HATS_SPRITE_SHEET__";
|
||||
|
||||
// Element IDs
|
||||
const FIELD_GUIDE_ID = "birb-field-guide";
|
||||
const FEATHER_ID = "birb-feather";
|
||||
const WARDROBE_ID = "birb-wardrobe";
|
||||
const HAT_ID = "birb-hat";
|
||||
|
||||
const DEFAULT_BIRD = "bluebird";
|
||||
const DEFAULT_HAT = HAT.NONE;
|
||||
|
||||
// Birb movement
|
||||
const HOP_SPEED = 0.07;
|
||||
@@ -88,8 +99,8 @@ const HOP_DISTANCE = 35;
|
||||
|
||||
// Timing constants (in milliseconds)
|
||||
const UPDATE_INTERVAL = 1000 / 60; // 60 FPS
|
||||
const AFK_TIME = isDebug() ? 0 : 1000 * 5;
|
||||
const PET_BOOST_DURATION = 1000 * 60 * 5;
|
||||
const AFK_TIME = isDebug() ? 0 : 1000 * 5; // 5 seconds
|
||||
const SUPER_AFK_TIME = 1000 * 60 * 60; // 1 hour
|
||||
const PET_MENU_COOLDOWN = 1000;
|
||||
const URL_CHECK_INTERVAL = 150;
|
||||
const HOP_DELAY = 500;
|
||||
@@ -98,10 +109,15 @@ const HOP_DELAY = 500;
|
||||
const HOP_CHANCE = 1 / (60 * 2.5); // Every 2.5 seconds
|
||||
const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds
|
||||
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours
|
||||
const HAT_CHANCE = 1 / (60 * 60 * 10); // Every 10 minutes
|
||||
|
||||
// Feathers
|
||||
const FEATHER_FALL_SPEED = 1;
|
||||
|
||||
// Petting boosts
|
||||
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
|
||||
const PET_FEATHER_BOOST = 2;
|
||||
const PET_HAT_BOOST = 1.5;
|
||||
|
||||
// Focus element constraints
|
||||
const MIN_FOCUS_ELEMENT_WIDTH = 100;
|
||||
@@ -109,74 +125,33 @@ const MIN_FOCUS_ELEMENT_WIDTH = 100;
|
||||
/** @type {Partial<Settings>} */
|
||||
let userSettings = {};
|
||||
|
||||
/**
|
||||
* Load the sprite sheet and return the pixel-map template
|
||||
* @param {string} dataUri
|
||||
* @param {boolean} [templateColors]
|
||||
* @returns {Promise<string[][]>}
|
||||
|
||||
/**
|
||||
* @param {Context} context
|
||||
*/
|
||||
function loadSpriteSheetPixels(dataUri, templateColors = true) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = dataUri;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||
const pixels = imageData.data;
|
||||
const hexArray = [];
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const index = (y * img.width + x) * 4;
|
||||
const r = pixels[index];
|
||||
const g = pixels[index + 1];
|
||||
const b = pixels[index + 2];
|
||||
const a = pixels[index + 3];
|
||||
if (a === 0) {
|
||||
row.push(Sprite.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) {
|
||||
error(`Unknown color: ${hex}`);
|
||||
row.push(Sprite.TRANSPARENT);
|
||||
}
|
||||
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
|
||||
}
|
||||
hexArray.push(row);
|
||||
}
|
||||
resolve(hexArray);
|
||||
};
|
||||
img.onerror = (err) => {
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
export async function initializeApplication(context) {
|
||||
log("birbOS booting up...");
|
||||
setContext(context);
|
||||
log("Loading sprite sheets...");
|
||||
const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
|
||||
const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
|
||||
const hatsPixels = await loadSpriteSheetPixels(HATS_SPRITE_SHEET);
|
||||
startApplication(birbPixels, featherPixels, hatsPixels);
|
||||
}
|
||||
|
||||
log("Loading sprite sheets...");
|
||||
|
||||
Promise.all([
|
||||
loadSpriteSheetPixels(SPRITE_SHEET),
|
||||
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET)
|
||||
]).then(([birbPixels, featherPixels]) => {
|
||||
/**
|
||||
* @param {string[][]} birbPixels
|
||||
* @param {string[][]} featherPixels
|
||||
* @param {string[][]} hatsPixels
|
||||
*/
|
||||
function startApplication(birbPixels, featherPixels, hatsPixels) {
|
||||
|
||||
const SPRITE_SHEET = birbPixels;
|
||||
const FEATHER_SPRITE_SHEET = featherPixels;
|
||||
const HATS_SPRITE_SHEET = hatsPixels;
|
||||
|
||||
const featherLayers = {
|
||||
feather: new Layer(getLayer(FEATHER_SPRITE_SHEET, 0, FEATHER_SPRITE_WIDTH)),
|
||||
feather: new Layer(getLayerPixels(FEATHER_SPRITE_SHEET, 0, FEATHER_SPRITE_WIDTH)),
|
||||
};
|
||||
|
||||
const featherFrames = {
|
||||
@@ -194,6 +169,7 @@ Promise.all([
|
||||
const menuItems = [
|
||||
new MenuItem(`Pet ${birdBirb()}`, pet),
|
||||
new MenuItem("Field Guide", insertFieldGuide),
|
||||
new MenuItem("Wardrobe", insertWardrobe),
|
||||
new ConditionalMenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote), () => getContext().areStickyNotesEnabled()),
|
||||
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
|
||||
new DebugMenuItem("Freeze/Unfreeze", () => {
|
||||
@@ -204,6 +180,9 @@ Promise.all([
|
||||
for (let type in SPECIES) {
|
||||
unlockBird(type);
|
||||
}
|
||||
for (let hat in HAT) {
|
||||
unlockHat(HAT[hat]);
|
||||
}
|
||||
}),
|
||||
new DebugMenuItem("Add Feather", () => {
|
||||
activateFeather();
|
||||
@@ -218,12 +197,16 @@ Promise.all([
|
||||
const settingsItems = [
|
||||
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
|
||||
new Separator(),
|
||||
new MenuItem("Toggle Birb Mode", () => {
|
||||
userSettings.birbMode = !userSettings.birbMode;
|
||||
new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
|
||||
userSettings.soundEnabled = !settings().soundEnabled;
|
||||
save();
|
||||
}),
|
||||
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
|
||||
userSettings.birbMode = !settings().birbMode;
|
||||
save();
|
||||
const message = makeElement("birb-message-content");
|
||||
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`));
|
||||
if (userSettings.birbMode) {
|
||||
if (settings().birbMode) {
|
||||
message.appendChild(document.createElement("br"));
|
||||
message.appendChild(document.createElement("br"));
|
||||
message.appendChild(document.createTextNode("Welcome back to 2012"));
|
||||
@@ -231,6 +214,7 @@ Promise.all([
|
||||
insertModal(`${birdBirb()} Mode`, message);
|
||||
}),
|
||||
new Separator(),
|
||||
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
|
||||
new MenuItem("__VERSION__", () => { alert("Thank you for using Pocket Bird! You are on version: __VERSION__") }, false),
|
||||
];
|
||||
|
||||
@@ -245,6 +229,8 @@ Promise.all([
|
||||
FLYING: "flying",
|
||||
};
|
||||
|
||||
const birdsong = new Birdsong();
|
||||
|
||||
let frozen = false;
|
||||
let stateStart = Date.now();
|
||||
let currentState = States.IDLE;
|
||||
@@ -266,6 +252,8 @@ Promise.all([
|
||||
let petStack = [];
|
||||
let currentSpecies = DEFAULT_BIRD;
|
||||
let unlockedSpecies = [DEFAULT_BIRD];
|
||||
let unlockedHats = [DEFAULT_HAT];
|
||||
let currentHat = DEFAULT_HAT;
|
||||
// let visible = true;
|
||||
let lastPetTimestamp = 0;
|
||||
/** @type {StickyNote[]} */
|
||||
@@ -284,6 +272,8 @@ Promise.all([
|
||||
userSettings = saveData.settings ?? {};
|
||||
unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD];
|
||||
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
|
||||
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
|
||||
currentHat = saveData.currentHat ?? DEFAULT_HAT;
|
||||
stickyNotes = [];
|
||||
|
||||
if (saveData.stickyNotes) {
|
||||
@@ -296,13 +286,16 @@ Promise.all([
|
||||
|
||||
log(stickyNotes.length + " sticky notes loaded");
|
||||
switchSpecies(currentSpecies);
|
||||
switchHat(currentHat);
|
||||
}
|
||||
|
||||
function save() {
|
||||
/** @type {BirbSaveData} */
|
||||
const saveData = {
|
||||
unlockedSpecies,
|
||||
currentSpecies,
|
||||
unlockedSpecies: unlockedSpecies,
|
||||
currentSpecies: currentSpecies,
|
||||
unlockedHats: unlockedHats,
|
||||
currentHat: currentHat,
|
||||
settings: userSettings
|
||||
};
|
||||
|
||||
@@ -335,8 +328,8 @@ Promise.all([
|
||||
/**
|
||||
* Bird or birb, you decide
|
||||
*/
|
||||
function birdBirb() {
|
||||
return settings().birbMode ? "Birb" : "Bird";
|
||||
function birdBirb(invert = false) {
|
||||
return settings().birbMode !== invert ? "Birb" : "Bird";
|
||||
}
|
||||
|
||||
function init() {
|
||||
@@ -355,7 +348,7 @@ Promise.all([
|
||||
styleElement.textContent = STYLESHEET;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
birb = new Birb(BIRB_CSS_SCALE, CANVAS_PIXEL_SIZE, SPRITE_SHEET, SPRITE_WIDTH, SPRITE_HEIGHT);
|
||||
birb = new Birb(BIRB_CSS_SCALE, CANVAS_PIXEL_SIZE, SPRITE_SHEET, SPRITE_WIDTH, SPRITE_HEIGHT, HATS_SPRITE_SHEET);
|
||||
birb.setAnimation(Animations.BOB);
|
||||
|
||||
window.addEventListener("scroll", () => {
|
||||
@@ -376,6 +369,7 @@ Promise.all([
|
||||
// Currently being pet, don't open menu
|
||||
return;
|
||||
}
|
||||
|
||||
insertMenu(menuItems, `${birdBirb().toLowerCase()}OS`, updateMenuLocation);
|
||||
});
|
||||
|
||||
@@ -405,7 +399,7 @@ Promise.all([
|
||||
setInterval(() => {
|
||||
const currentPath = getContext().getPath().split("?")[0];
|
||||
if (currentPath !== lastPath) {
|
||||
log("Path changed, updating sticky notes: " + currentPath);
|
||||
log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
|
||||
lastPath = currentPath;
|
||||
drawStickyNotes(stickyNotes, save, deleteStickyNote);
|
||||
}
|
||||
@@ -446,12 +440,17 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
|
||||
// Double the chance of a feather if recently pet
|
||||
const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1;
|
||||
if (birb.isVisible() && Math.random() < FEATHER_CHANCE * petMod) {
|
||||
lastPetTimestamp = 0;
|
||||
activateFeather();
|
||||
if (birb.isVisible() && Date.now() - lastActionTimestamp < SUPER_AFK_TIME) {
|
||||
if (Math.random() < FEATHER_CHANCE * (isPetBoostActive() ? PET_FEATHER_BOOST : 1)) {
|
||||
lastPetTimestamp = 0;
|
||||
activateFeather();
|
||||
}
|
||||
if (Math.random() < (HAT_CHANCE * (isPetBoostActive() ? PET_HAT_BOOST : 1))) {
|
||||
lastPetTimestamp = 0;
|
||||
insertHat();
|
||||
}
|
||||
}
|
||||
|
||||
updateFeather();
|
||||
}
|
||||
|
||||
@@ -486,7 +485,7 @@ Promise.all([
|
||||
flySomewhere();
|
||||
}
|
||||
|
||||
if (birb.draw(SPECIES[currentSpecies])) {
|
||||
if (birb.draw(SPECIES[currentSpecies], currentHat)) {
|
||||
birb.setAnimation(Animations.STILL);
|
||||
}
|
||||
|
||||
@@ -578,7 +577,7 @@ Promise.all([
|
||||
if (!featherCtx) {
|
||||
return;
|
||||
}
|
||||
FEATHER_ANIMATIONS.feather.draw(featherCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, type);
|
||||
FEATHER_ANIMATIONS.feather.draw(featherCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, type.colors, type.tags);
|
||||
document.body.appendChild(featherCanvas);
|
||||
onClick(featherCanvas, () => {
|
||||
unlockBird(birdType);
|
||||
@@ -597,12 +596,62 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the hat as an item element in the document if possible
|
||||
*/
|
||||
function insertHat() {
|
||||
if (document.querySelector("#" + HAT_ID)) {
|
||||
return;
|
||||
}
|
||||
// Select a random hat that hasn't been unlocked yet
|
||||
const availableHats = Object.values(HAT)
|
||||
.filter(hat => hat !== HAT.NONE && !unlockedHats.includes(hat));
|
||||
if (availableHats.length === 0) {
|
||||
return;
|
||||
}
|
||||
const hatId = availableHats[Math.floor(Math.random() * availableHats.length)];
|
||||
|
||||
// Find a random valid element to place the hat on
|
||||
const element = getRandomValidElement();
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create hat element
|
||||
const hatCanvas = document.createElement("canvas");
|
||||
hatCanvas.id = HAT_ID;
|
||||
hatCanvas.classList.add("birb-item");
|
||||
hatCanvas.width = 14 * CANVAS_PIXEL_SIZE;
|
||||
hatCanvas.height = 14 * CANVAS_PIXEL_SIZE;
|
||||
const hatCtx = hatCanvas.getContext("2d");
|
||||
if (!hatCtx) {
|
||||
return;
|
||||
}
|
||||
onClick(hatCanvas, () => {
|
||||
unlockHat(hatId);
|
||||
hatCanvas.remove();
|
||||
});
|
||||
|
||||
// Create hat animation
|
||||
const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET);
|
||||
hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]);
|
||||
|
||||
// Position hat above the element
|
||||
const rect = element.getBoundingClientRect();
|
||||
hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px";
|
||||
hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px";
|
||||
|
||||
// Append to document
|
||||
document.body.appendChild(hatCanvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} birdType
|
||||
*/
|
||||
function unlockBird(birdType) {
|
||||
if (!unlockedSpecies.includes(birdType)) {
|
||||
unlockedSpecies.push(birdType);
|
||||
save();
|
||||
const message = makeElement("birb-message-content");
|
||||
message.appendChild(document.createTextNode("You've found a "));
|
||||
const bold = document.createElement("b");
|
||||
@@ -611,7 +660,24 @@ Promise.all([
|
||||
message.appendChild(document.createTextNode(" feather! Use the Field Guide to switch your bird's species."));
|
||||
insertModal("New Bird Unlocked!", message);
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hatId
|
||||
*/
|
||||
function unlockHat(hatId) {
|
||||
if (!unlockedHats.includes(hatId)) {
|
||||
unlockedHats.push(hatId);
|
||||
save();
|
||||
switchHat(hatId);
|
||||
const message = makeElement("birb-message-content");
|
||||
message.appendChild(document.createTextNode("You've unlocked the "));
|
||||
const bold = document.createElement("b");
|
||||
bold.textContent = HAT_METADATA[hatId].name;
|
||||
message.appendChild(bold);
|
||||
message.appendChild(document.createTextNode("! To see all of your unlocked accessories, click the Wardrobe from the menu."));
|
||||
insertModal("New Hat Found!", message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFeather() {
|
||||
@@ -678,6 +744,8 @@ Promise.all([
|
||||
if (document.querySelector("#" + FIELD_GUIDE_ID)) {
|
||||
return;
|
||||
}
|
||||
// Remove wardrobe if open
|
||||
removeWardrobe();
|
||||
|
||||
const contentContainer = document.createElement("div");
|
||||
const content = makeElement("birb-grid-content");
|
||||
@@ -725,7 +793,7 @@ Promise.all([
|
||||
if (!speciesCtx) {
|
||||
return;
|
||||
}
|
||||
birb.getFrames().base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type);
|
||||
birb.getFrames().base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type.colors, type.tags);
|
||||
speciesElement.appendChild(speciesCanvas);
|
||||
content.appendChild(speciesElement);
|
||||
if (unlocked) {
|
||||
@@ -758,13 +826,114 @@ Promise.all([
|
||||
}
|
||||
}
|
||||
|
||||
function insertWardrobe() {
|
||||
console.log("Inserting wardrobe");
|
||||
if (document.querySelector("#" + WARDROBE_ID)) {
|
||||
return;
|
||||
}
|
||||
// Remove field guide if open
|
||||
removeFieldGuide();
|
||||
|
||||
const contentContainer = document.createElement("div");
|
||||
const content = makeElement("birb-grid-content");
|
||||
const description = makeElement("birb-field-guide-description");
|
||||
contentContainer.appendChild(content);
|
||||
contentContainer.appendChild(description);
|
||||
|
||||
const wardrobe = createWindow(
|
||||
WARDROBE_ID,
|
||||
"Wardrobe",
|
||||
contentContainer
|
||||
);
|
||||
|
||||
const generateDescription = (/** @type {string} */ hat) => {
|
||||
const metadata = HAT_METADATA[hat] ?? { name: "Unknown Hat", description: "todo" };
|
||||
const unlocked = unlockedHats.includes(hat);
|
||||
|
||||
const boldName = document.createElement("b");
|
||||
boldName.textContent = metadata.name;
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
spacer.style.height = "0.3em";
|
||||
|
||||
const descText = document.createTextNode(!unlocked ? "Not yet unlocked" : metadata.description);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(boldName);
|
||||
fragment.appendChild(spacer);
|
||||
fragment.appendChild(descText);
|
||||
|
||||
return fragment;
|
||||
};
|
||||
|
||||
description.appendChild(generateDescription(currentHat));
|
||||
for (const hat of Object.values(HAT)) {
|
||||
const unlocked = unlockedHats.includes(hat);
|
||||
const hatElement = makeElement("birb-grid-item");
|
||||
if (hat === currentHat) {
|
||||
hatElement.classList.add("birb-grid-item-selected");
|
||||
}
|
||||
const hatCanvas = document.createElement("canvas");
|
||||
hatCanvas.width = SPRITE_WIDTH * CANVAS_PIXEL_SIZE;
|
||||
hatCanvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE;
|
||||
const hatCtx = hatCanvas.getContext("2d");
|
||||
if (!hatCtx) {
|
||||
return;
|
||||
}
|
||||
birb.getFrames().base.draw(
|
||||
hatCtx,
|
||||
Directions.RIGHT,
|
||||
CANVAS_PIXEL_SIZE,
|
||||
SPECIES[currentSpecies].colors,
|
||||
[...SPECIES[currentSpecies].tags, hat]
|
||||
);
|
||||
hatElement.appendChild(hatCanvas);
|
||||
content.appendChild(hatElement);
|
||||
if (unlocked) {
|
||||
onClick(hatElement, () => {
|
||||
switchHat(hat);
|
||||
document.querySelectorAll(".birb-grid-item").forEach((element) => {
|
||||
element.classList.remove("birb-grid-item-selected");
|
||||
});
|
||||
hatElement.classList.add("birb-grid-item-selected");
|
||||
});
|
||||
} else {
|
||||
hatElement.classList.add("birb-grid-item-locked");
|
||||
}
|
||||
hatElement.addEventListener("mouseover", () => {
|
||||
description.textContent = "";
|
||||
description.appendChild(generateDescription(hat));
|
||||
});
|
||||
hatElement.addEventListener("mouseout", () => {
|
||||
description.textContent = "";
|
||||
description.appendChild(generateDescription(currentHat));
|
||||
});
|
||||
}
|
||||
centerElement(wardrobe);
|
||||
}
|
||||
|
||||
function removeWardrobe() {
|
||||
const wardrobe = document.querySelector("#" + WARDROBE_ID);
|
||||
if (wardrobe) {
|
||||
wardrobe.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
function switchSpecies(type) {
|
||||
currentSpecies = type;
|
||||
// Update CSS variable --birb-highlight to be wing color
|
||||
document.documentElement.style.setProperty("--birb-highlight", SPECIES[type].colors[Sprite.THEME_HIGHLIGHT]);
|
||||
document.documentElement.style.setProperty("--birb-highlight", SPECIES[type].colors[PALETTE.THEME_HIGHLIGHT]);
|
||||
save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hat
|
||||
*/
|
||||
function switchHat(hat) {
|
||||
currentHat = hat;
|
||||
save();
|
||||
}
|
||||
|
||||
@@ -829,14 +998,9 @@ Promise.all([
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on an element within the viewport
|
||||
* @param {boolean} [teleport] Whether to teleport to the element instead of flying
|
||||
* @returns Whether an element to focus on was found
|
||||
* @returns {HTMLElement|null} The random element, or null if no valid element was found
|
||||
*/
|
||||
function focusOnElement(teleport = false) {
|
||||
if (frozen) {
|
||||
return false;
|
||||
}
|
||||
function getRandomValidElement() {
|
||||
const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin();
|
||||
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
|
||||
const inWindow = Array.from(elements).filter((img) => {
|
||||
@@ -851,10 +1015,11 @@ Promise.all([
|
||||
return true;
|
||||
});
|
||||
/** @type {HTMLElement[]} */
|
||||
// @ts-expect-error
|
||||
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
|
||||
// Ensure the bird doesn't land on fixed or sticky elements
|
||||
const fixedAllowed = getContext() instanceof ObsidianContext;
|
||||
// const fixedAllowed = getContext() instanceof ObsidianContext;
|
||||
// TODO: FIX
|
||||
const fixedAllowed = true;
|
||||
const nonFixedElements = largeElements.filter((el) => {
|
||||
if (fixedAllowed) {
|
||||
return true;
|
||||
@@ -863,10 +1028,22 @@ Promise.all([
|
||||
return style.position !== "fixed" && style.position !== "sticky";
|
||||
});
|
||||
if (nonFixedElements.length === 0) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)];
|
||||
focusedElement = randomElement;
|
||||
return randomElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on an element within the viewport
|
||||
* @param {boolean} [teleport] Whether to teleport to the element instead of flying
|
||||
* @returns Whether an element to focus on was found
|
||||
*/
|
||||
function focusOnElement(teleport = false) {
|
||||
if (frozen) {
|
||||
return false;
|
||||
}
|
||||
focusedElement = getRandomValidElement();
|
||||
log("Focusing on element: ", focusedElement);
|
||||
updateFocusedElementBounds();
|
||||
if (teleport) {
|
||||
@@ -874,7 +1051,7 @@ Promise.all([
|
||||
} else {
|
||||
flyTo(getFocusedElementRandomX(), getFocusedY());
|
||||
}
|
||||
return randomElement !== null;
|
||||
return focusedElement !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -938,11 +1115,18 @@ Promise.all([
|
||||
|
||||
function pet() {
|
||||
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
|
||||
if (settings().soundEnabled) {
|
||||
birdsong.chirp();
|
||||
}
|
||||
birb.setAnimation(Animations.HEART);
|
||||
lastPetTimestamp = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function isPetBoostActive() {
|
||||
return Date.now() - lastPetTimestamp < PET_BOOST_DURATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
@@ -1008,6 +1192,61 @@ Promise.all([
|
||||
// Run the birb
|
||||
init();
|
||||
draw();
|
||||
}).catch((e) => {
|
||||
error("Error while loading sprite sheets: ", e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the sprite sheet and return the pixel-map template
|
||||
* @param {string} dataUri
|
||||
* @param {boolean} [templateColors]
|
||||
* @returns {Promise<string[][]>}
|
||||
*/
|
||||
function loadSpriteSheetPixels(dataUri, templateColors = true) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.src = dataUri;
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||
const pixels = imageData.data;
|
||||
const hexArray = [];
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const index = (y * img.width + x) * 4;
|
||||
const r = pixels[index];
|
||||
const g = pixels[index + 1];
|
||||
const b = pixels[index + 2];
|
||||
const a = pixels[index + 3];
|
||||
if (a === 0) {
|
||||
row.push(PALETTE.TRANSPARENT);
|
||||
continue;
|
||||
}
|
||||
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
if (!templateColors) {
|
||||
row.push(hex);
|
||||
continue;
|
||||
}
|
||||
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
|
||||
// Return the color as-is if not found in the map
|
||||
row.push(hex);
|
||||
continue;
|
||||
}
|
||||
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
|
||||
}
|
||||
hexArray.push(row);
|
||||
}
|
||||
resolve(hexArray);
|
||||
};
|
||||
img.onerror = (err) => {
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
61
src/birb.js
61
src/birb.js
@@ -1,8 +1,9 @@
|
||||
import { Directions, getLayer, getWindowHeight, getFixedWindowHeight } from './shared.js';
|
||||
import Layer from './layer.js';
|
||||
import Frame from './frame.js';
|
||||
import Anim from './anim.js';
|
||||
import { BirdType } from './sprites.js';
|
||||
import { Directions, getLayerPixels, getWindowHeight, getFixedWindowHeight } from './shared.js';
|
||||
import Layer from './animation/layer.js';
|
||||
import Frame from './animation/frame.js';
|
||||
import Anim from './animation/anim.js';
|
||||
import { BirdType, PALETTE } from './animation/sprites.js';
|
||||
import { createHatLayers } from './hats.js';
|
||||
|
||||
/**
|
||||
* @typedef {keyof typeof Animations} AnimationType
|
||||
@@ -31,8 +32,9 @@ export class Birb {
|
||||
* @param {string[][]} spriteSheet The loaded sprite sheet pixel data
|
||||
* @param {number} spriteWidth
|
||||
* @param {number} spriteHeight
|
||||
* @param {string[][]} hatSpriteSheet The loaded hat sprite sheet pixel data
|
||||
*/
|
||||
constructor(birbCssScale, canvasPixelSize, spriteSheet, spriteWidth, spriteHeight) {
|
||||
constructor(birbCssScale, canvasPixelSize, spriteSheet, spriteWidth, spriteHeight, hatSpriteSheet) {
|
||||
this.birbCssScale = birbCssScale;
|
||||
this.canvasPixelSize = canvasPixelSize;
|
||||
this.windowPixelSize = canvasPixelSize * birbCssScale;
|
||||
@@ -41,28 +43,31 @@ export class Birb {
|
||||
|
||||
// Build layers from sprite sheet
|
||||
this.layers = {
|
||||
base: new Layer(getLayer(spriteSheet, 0, this.spriteWidth)),
|
||||
down: new Layer(getLayer(spriteSheet, 1, this.spriteWidth)),
|
||||
heartOne: new Layer(getLayer(spriteSheet, 2, this.spriteWidth)),
|
||||
heartTwo: new Layer(getLayer(spriteSheet, 3, this.spriteWidth)),
|
||||
heartThree: new Layer(getLayer(spriteSheet, 4, this.spriteWidth)),
|
||||
tuftBase: new Layer(getLayer(spriteSheet, 5, this.spriteWidth), "tuft"),
|
||||
tuftDown: new Layer(getLayer(spriteSheet, 6, this.spriteWidth), "tuft"),
|
||||
wingsUp: new Layer(getLayer(spriteSheet, 7, this.spriteWidth)),
|
||||
wingsDown: new Layer(getLayer(spriteSheet, 8, this.spriteWidth)),
|
||||
happyEye: new Layer(getLayer(spriteSheet, 9, this.spriteWidth)),
|
||||
base: new Layer(getLayerPixels(spriteSheet, 0, this.spriteWidth)),
|
||||
down: new Layer(getLayerPixels(spriteSheet, 1, this.spriteWidth)),
|
||||
heartOne: new Layer(getLayerPixels(spriteSheet, 2, this.spriteWidth)),
|
||||
heartTwo: new Layer(getLayerPixels(spriteSheet, 3, this.spriteWidth)),
|
||||
heartThree: new Layer(getLayerPixels(spriteSheet, 4, this.spriteWidth)),
|
||||
tuftBase: new Layer(getLayerPixels(spriteSheet, 5, this.spriteWidth), "tuft"),
|
||||
tuftDown: new Layer(getLayerPixels(spriteSheet, 6, this.spriteWidth), "tuft"),
|
||||
wingsUp: new Layer(getLayerPixels(spriteSheet, 7, this.spriteWidth)),
|
||||
wingsDown: new Layer(getLayerPixels(spriteSheet, 8, this.spriteWidth)),
|
||||
happyEye: new Layer(getLayerPixels(spriteSheet, 9, this.spriteWidth)),
|
||||
};
|
||||
|
||||
// Build hat layers
|
||||
const hatLayers = createHatLayers(hatSpriteSheet);
|
||||
|
||||
// Build frames from layers
|
||||
this.frames = {
|
||||
base: new Frame([this.layers.base, this.layers.tuftBase]),
|
||||
headDown: new Frame([this.layers.down, this.layers.tuftDown]),
|
||||
wingsDown: new Frame([this.layers.base, this.layers.tuftBase, this.layers.wingsDown]),
|
||||
wingsUp: new Frame([this.layers.down, this.layers.tuftDown, this.layers.wingsUp]),
|
||||
heartOne: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartOne]),
|
||||
heartTwo: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartTwo]),
|
||||
heartThree: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartThree]),
|
||||
heartFour: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartTwo]),
|
||||
base: new Frame([this.layers.base, this.layers.tuftBase, ...hatLayers.base]),
|
||||
headDown: new Frame([this.layers.down, this.layers.tuftDown, ...hatLayers.down]),
|
||||
wingsDown: new Frame([this.layers.base, this.layers.tuftBase, this.layers.wingsDown, ...hatLayers.base]),
|
||||
wingsUp: new Frame([this.layers.down, this.layers.tuftDown, this.layers.wingsUp, ...hatLayers.down]),
|
||||
heartOne: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartOne]),
|
||||
heartTwo: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base,this.layers.heartTwo]),
|
||||
heartThree: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartThree]),
|
||||
heartFour: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartTwo]),
|
||||
};
|
||||
|
||||
// Build animations from frames
|
||||
@@ -121,14 +126,16 @@ export class Birb {
|
||||
|
||||
/**
|
||||
* Draw the current animation frame
|
||||
* @param {BirdType} species The species color data
|
||||
* @param {BirdType} species The species data
|
||||
* @param {string} [hat] The name of the current hat
|
||||
* @returns {boolean} Whether the animation has completed (for non-looping animations)
|
||||
*/
|
||||
draw(species) {
|
||||
draw(species, hat) {
|
||||
const anim = this.animations[this.currentAnimation];
|
||||
return anim.draw(this.ctx, this.direction, this.animStart, this.canvasPixelSize, species);
|
||||
return anim.draw(this.ctx, this.direction, this.animStart, this.canvasPixelSize, species.colors, [...species.tags, hat || '']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns {AnimationType} The current animation key
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { debug, log, error } from "./shared.js";
|
||||
|
||||
const SAVE_KEY = "birbSaveData";
|
||||
export const SAVE_KEY = "birbSaveData";
|
||||
const ROOT_PATH = "";
|
||||
const SET_CONTEXT = "__CONTEXT__"
|
||||
|
||||
/**
|
||||
* @typedef {import('./application.js').BirbSaveData} BirbSaveData
|
||||
@@ -12,14 +13,6 @@ const ROOT_PATH = "";
|
||||
*/
|
||||
export class Context {
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @returns {boolean} Whether this context is applicable
|
||||
*/
|
||||
isContextActive() {
|
||||
throw new Error("Method not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @returns {Promise<BirbSaveData|{}>}
|
||||
@@ -103,16 +96,6 @@ export class Context {
|
||||
|
||||
export class LocalContext extends Context {
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isContextActive() {
|
||||
return window.location.hostname === "127.0.0.1"
|
||||
|| window.location.hostname === "localhost"
|
||||
|| window.location.hostname.startsWith("192.168.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {Promise<BirbSaveData|{}>}
|
||||
@@ -140,15 +123,6 @@ export class LocalContext extends Context {
|
||||
|
||||
export class UserScriptContext extends Context {
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isContextActive() {
|
||||
// @ts-expect-error
|
||||
return typeof GM_getValue === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {Promise<BirbSaveData|{}>}
|
||||
@@ -180,16 +154,7 @@ export class UserScriptContext extends Context {
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserExtensionContext extends Context {
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isContextActive() {
|
||||
// @ts-expect-error
|
||||
return typeof chrome !== "undefined";
|
||||
}
|
||||
export class BrowserExtensionContext extends Context {
|
||||
|
||||
/**
|
||||
* @override
|
||||
@@ -216,9 +181,9 @@ class BrowserExtensionContext extends Context {
|
||||
// @ts-expect-error
|
||||
if (chrome.runtime.lastError) {
|
||||
// @ts-expect-error
|
||||
console.error(chrome.runtime.lastError);
|
||||
error(chrome.runtime.lastError);
|
||||
} else {
|
||||
console.log("Settings saved successfully");
|
||||
log("Settings saved successfully");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -232,14 +197,6 @@ class BrowserExtensionContext extends Context {
|
||||
}
|
||||
|
||||
export class ObsidianContext extends Context {
|
||||
/**
|
||||
* @override
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isContextActive() {
|
||||
// @ts-expect-error
|
||||
return typeof app !== "undefined" && typeof app.vault !== "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
@@ -319,23 +276,6 @@ export class ObsidianContext extends Context {
|
||||
}
|
||||
}
|
||||
|
||||
const CONTEXTS = [
|
||||
new UserScriptContext(),
|
||||
new ObsidianContext(),
|
||||
new BrowserExtensionContext(),
|
||||
new LocalContext()
|
||||
];
|
||||
|
||||
export function getContext() {
|
||||
for (const context of CONTEXTS) {
|
||||
if (context.isContextActive()) {
|
||||
return context;
|
||||
}
|
||||
}
|
||||
error("No applicable context found, defaulting to LocalContext");
|
||||
return new LocalContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL parameters into a key-value map
|
||||
* @param {string} url
|
||||
|
||||
0
src/fieldGuide.js
Normal file
0
src/fieldGuide.js
Normal file
240
src/hats.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;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export const MENU_EXIT_ID = "birb-menu-exit";
|
||||
|
||||
export class MenuItem {
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string|(() => string)} text
|
||||
* @param {() => void} action
|
||||
* @param {boolean} [removeMenu]
|
||||
*/
|
||||
@@ -61,7 +61,7 @@ function makeMenuItem(item, removeMenuCallback) {
|
||||
if (item instanceof Separator) {
|
||||
return makeElement("birb-window-separator");
|
||||
}
|
||||
let menuItem = makeElement("birb-menu-item", item.text);
|
||||
let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
|
||||
onClick(menuItem, () => {
|
||||
if (item.removeMenu) {
|
||||
removeMenuCallback();
|
||||
|
||||
4
src/platforms/extension/extension.js
Normal file
4
src/platforms/extension/extension.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { initializeApplication } from "../../application.js";
|
||||
import { BrowserExtensionContext } from "../../context.js";
|
||||
|
||||
initializeApplication(new BrowserExtensionContext());
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "pocket-bird",
|
||||
"name": "Pocket Bird",
|
||||
"version": "2025.11.14.205",
|
||||
"version": "__VERSION__",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Add a pet bird to fly around your notes and keep you company!",
|
||||
"author": "Idrees Hassan",
|
||||
4
src/platforms/obsidian/obsidian.js
Normal file
4
src/platforms/obsidian/obsidian.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { initializeApplication } from "../../application.js";
|
||||
import { ObsidianContext } from "../../context.js";
|
||||
|
||||
initializeApplication(new ObsidianContext());
|
||||
4
src/platforms/userscript/userscript.js
Normal file
4
src/platforms/userscript/userscript.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { initializeApplication } from "../../application.js";
|
||||
import { UserScriptContext } from "../../context.js";
|
||||
|
||||
initializeApplication(new UserScriptContext());
|
||||
4
src/platforms/web/web.js
Normal file
4
src/platforms/web/web.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { initializeApplication } from "../../application.js";
|
||||
import { LocalContext } from "../../context.js";
|
||||
|
||||
initializeApplication(new LocalContext());
|
||||
@@ -4,6 +4,7 @@ export const Directions = {
|
||||
};
|
||||
|
||||
let debugMode = location.hostname === "127.0.0.1";
|
||||
let context = null;
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether debug mode is enabled
|
||||
@@ -19,6 +20,17 @@ export function setDebug(value) {
|
||||
debugMode = value;
|
||||
}
|
||||
|
||||
export function getContext() {
|
||||
if (!context) {
|
||||
throw new Error("Context requested before being set");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function setContext(newContext) {
|
||||
context = newContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTML element with the specified parameters
|
||||
* @param {string} className
|
||||
@@ -181,7 +193,7 @@ export function error() {
|
||||
* @param {number} width The width of each sprite
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
export function getLayer(spriteSheet, spriteIndex, width) {
|
||||
export function getLayerPixels(spriteSheet, spriteIndex, width) {
|
||||
// From an array of a horizontal sprite sheet, get the layer for a specific sprite
|
||||
const layer = [];
|
||||
for (let y = 0; y < width; y++) {
|
||||
|
||||
43
src/sound.js
Normal file
43
src/sound.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// @ts-check
|
||||
|
||||
export class Birdsong {
|
||||
|
||||
/**
|
||||
* @type {AudioContext}
|
||||
*/
|
||||
audioContext;
|
||||
|
||||
chirp() {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
const TIMES = [0, 0.06, 0.10, 0.15];
|
||||
const FREQUENCIES = [2200,
|
||||
3500 + Math.random() * 600,
|
||||
2100 + Math.random() * 200,
|
||||
1600 + Math.random() * 400];
|
||||
const VOLUMES = [0.0001, 0.2, 0.2, 0.0001];
|
||||
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
oscillator.type = "sine";
|
||||
const gain = this.audioContext.createGain();
|
||||
oscillator.connect(gain);
|
||||
gain.connect(this.audioContext.destination);
|
||||
|
||||
const now = this.audioContext.currentTime;
|
||||
for (let i = 0; i < TIMES.length; i++) {
|
||||
const time = TIMES[i] + now;
|
||||
if (i === 0) {
|
||||
oscillator.frequency.setValueAtTime(FREQUENCIES[i], time);
|
||||
gain.gain.setValueAtTime(VOLUMES[i], time);
|
||||
} else {
|
||||
oscillator.frequency.exponentialRampToValueAtTime(FREQUENCIES[i], time);
|
||||
gain.gain.exponentialRampToValueAtTime(VOLUMES[i], time);
|
||||
}
|
||||
}
|
||||
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + TIMES[TIMES.length - 1]);
|
||||
}
|
||||
}
|
||||
199
src/sprites.js
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,9 +1,9 @@
|
||||
import {
|
||||
getContext,
|
||||
makeElement,
|
||||
makeDraggable,
|
||||
makeClosable
|
||||
} from './shared.js';
|
||||
import { getContext } from './context.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} SavedStickyNote
|
||||
|
||||
@@ -41,6 +41,22 @@
|
||||
z-index: 2147483630 !important;
|
||||
}
|
||||
|
||||
.birb-item {
|
||||
image-rendering: pixelated;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform: scale(calc(var(--birb-scale) * 1.5)) !important;
|
||||
transform-origin: bottom;
|
||||
transition-duration: 0.15s;
|
||||
z-index: 2147483630 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.birb-item:hover {
|
||||
transform: scale(calc(var(--birb-scale) * 1.9)) !important;
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
.birb-window {
|
||||
font-family: "Monocraft", monospace !important;
|
||||
line-height: initial !important;
|
||||
@@ -198,6 +214,7 @@
|
||||
|
||||
.birb-menu-item {
|
||||
width: calc(100% - var(--birb-double-border-size));
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
@@ -237,13 +254,21 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
#birb-field-guide {
|
||||
#birb-field-guide, #birb-wardrobe {
|
||||
width: 322px !important;
|
||||
}
|
||||
|
||||
#birb-field-guide .birb-grid-content {
|
||||
grid-template-rows: repeat(3, auto);
|
||||
}
|
||||
|
||||
#birb-wardrobe .birb-grid-content {
|
||||
grid-template-columns: repeat(4, auto);
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
|
||||
.birb-grid-content {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(3, auto);
|
||||
grid-auto-flow: column;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
@@ -357,6 +382,13 @@
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.birb-sticky-note-input::placeholder {
|
||||
font-family: "Monocraft", monospace !important;
|
||||
font-size: 14px !important;
|
||||
background-color: transparent !important;
|
||||
color: rgba(0, 0, 0, 0.35) !important;
|
||||
}
|
||||
|
||||
.birb-sticky-note-input:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
Reference in New Issue
Block a user