69 Commits

Author SHA1 Message Date
Idrees Hassan
ddcd7a693d Create birb-white.aseprite 2026-01-24 22:32:16 -05:00
Idrees Hassan
868cd06210 Update wizard hat design 2026-01-24 22:30:59 -05:00
Idrees Hassan
307d4a8895 Update fez design 2026-01-24 22:28:58 -05:00
Idrees Hassan
5a33cef4d5 Move hats around 2026-01-24 21:31:36 -05:00
Idrees Hassan
7b4ebf7ab8 Add description to cordovan hat 2026-01-24 21:18:49 -05:00
Idrees Hassan
e393013b27 Make top hat taller 2026-01-24 18:34:56 -05:00
Idrees Hassan
db1a3dcbb6 Add more hats 2026-01-24 18:27:47 -05:00
Idrees
8cd93bb623 Merge pull request #7 from IdreesInc/hat
Add hats!
2026-01-22 18:51:02 -05:00
Idrees Hassan
a3a09c6819 Add source code link to menu 2026-01-22 18:50:43 -05:00
Idrees Hassan
912327a348 Spawn hats based on time again 2026-01-22 00:01:46 -05:00
Idrees Hassan
d54f208cc4 Revert "Remove hat pet boost because it doesn't work on page load"
This reverts commit cb1f2f605f.
2026-01-21 23:36:54 -05:00
Idrees Hassan
cb1f2f605f Remove hat pet boost because it doesn't work on page load 2026-01-21 23:36:31 -05:00
Idrees Hassan
2ee6ea84a7 Move pet boost indicator to settings menu 2026-01-21 23:27:33 -05:00
Idrees Hassan
5e04727a1b Use chance to determine hat unlocks and add heart to menu title 2026-01-21 23:23:00 -05:00
Idrees Hassan
7b1df9bc4f Store unlocked hats 2026-01-21 22:52:53 -05:00
Idrees Hassan
130fae6e0c Add hat collection message 2026-01-21 22:36:12 -05:00
Idrees Hassan
3b2081943d Add hat item 2026-01-21 22:25:49 -05:00
Idrees Hassan
f5742ac3a7 Make the baseball cap pink 2026-01-20 17:17:37 -05:00
Idrees Hassan
867d214292 Add more hats 2026-01-20 17:02:48 -05:00
Idrees Hassan
d97e39449e Fix hats on birds with tufts 2026-01-19 21:06:27 -05:00
Idrees Hassan
7628ee2c87 Add hat to save data 2026-01-19 20:38:00 -05:00
Idrees Hassan
3227167cb5 Add wardrobe menu 2026-01-19 20:31:49 -05:00
Idrees Hassan
e0fae3781a Add bowler hat and fez 2026-01-19 17:18:02 -05:00
Idrees Hassan
2773538a6c Add cowboy hat 2026-01-18 22:41:37 -05:00
Idrees Hassan
2a90a56a2b Add viking helmet 2026-01-18 21:07:02 -05:00
Idrees Hassan
94454a2338 Fix missing tuft sprites 2026-01-18 19:32:38 -05:00
Idrees Hassan
cf968dfec4 Move heart up 2026-01-18 19:30:07 -05:00
Idrees Hassan
4838457054 Create none hat 2026-01-18 19:25:17 -05:00
Idrees Hassan
e09d4f9eea Control hat type from application 2026-01-18 19:24:17 -05:00
Idrees Hassan
7c38bf9164 Separate hat functions 2026-01-18 19:14:18 -05:00
Idrees Hassan
8263fadfba Add hat placeholder 2026-01-18 19:04:40 -05:00
Idrees Hassan
9f7d864e57 Add tag enum 2026-01-18 18:14:40 -05:00
Idrees Hassan
579967a302 Rename color constants enum 2026-01-18 18:10:43 -05:00
Idrees Hassan
ca1495a9f1 Rename getLayer 2026-01-18 18:05:36 -05:00
Idrees Hassan
fd865cacb8 Minor path changes 2026-01-18 17:08:30 -05:00
Idrees Hassan
5e94998410 Move sprite related files to separate folder 2026-01-18 17:03:14 -05:00
Idrees Hassan
e13a67e967 Reduce volume of chirp 2026-01-18 16:55:50 -05:00
Idrees
e1759bc235 Add binarydigit.dev to the readme 2026-01-10 10:46:55 -05:00
Idrees
1d818d83cf Merge pull request #6 from IdreesInc/birdsong
Add chirping when pet
2026-01-04 18:08:44 -05:00
Idrees Hassan
47b418324c Fix menu item text not working at first 2026-01-04 18:08:26 -05:00
Idrees Hassan
e5956426d5 Add toggle to enable/disable sound 2026-01-04 18:02:47 -05:00
Idrees Hassan
0cc06a8856 Change sound timings 2026-01-04 17:39:38 -05:00
Idrees Hassan
dd4184f642 Refine chirp sound 2026-01-04 17:38:14 -05:00
Idrees Hassan
5a82ba858f Add chirping when pet 2026-01-04 17:34:34 -05:00
Idrees Hassan
b8de14bb94 Create new build for extension update 2026-01-01 14:09:13 -05:00
Idrees Hassan
e0ab21d608 Update README.md 2025-12-13 23:08:46 -05:00
Idrees Hassan
86c254b2cb Merge branch 'main' of https://github.com/IdreesInc/Pocket-Bird 2025-12-13 23:05:37 -05:00
Idrees Hassan
14ef2713d8 Add site to readme 2025-12-13 23:04:56 -05:00
Idrees
bc3f5cce02 Update README.md 2025-11-16 13:13:11 -05:00
Idrees Hassan
c3ff3b39dc Add embed code and instructions 2025-11-16 13:10:57 -05:00
Idrees
8b8aa50cae Update README with new download options
Added download links for Obsidian and TamperMonkey.
2025-11-16 13:02:22 -05:00
Idrees Hassan
f4b598d2dc Remove vencord 2025-11-16 12:09:17 -05:00
Idrees
bccaa0aa15 Merge pull request #5 from IdreesInc/vencord
Add support for Vencord and clean up build and context process
2025-11-16 11:12:51 -05:00
Idrees Hassan
37a30ea509 Add Vencord save support 2025-11-16 11:05:44 -05:00
Idrees Hassan
e7be2b7661 Clean up output 2025-11-16 10:38:11 -05:00
Idrees Hassan
c750bf5560 Move files into src 2025-11-16 10:34:43 -05:00
Idrees Hassan
c927ce23e4 Add separate entry points 2025-11-16 10:25:57 -05:00
Idrees Hassan
6ee9efd5a8 Add browser-specific entry point 2025-11-16 09:51:46 -05:00
Idrees Hassan
a5e81e4265 Allow for set contexts 2025-11-16 09:25:48 -05:00
Idrees Hassan
76e55a3caa Merge branch 'main' of https://github.com/IdreesInc/Pocket-Bird 2025-11-15 18:25:48 -05:00
Idrees Hassan
224fe4aaec Update obsidian manifest 2025-11-15 18:25:44 -05:00
Idrees
764e8311fa Update plugin URL for Obsidian instructions 2025-11-15 18:09:39 -05:00
Idrees Hassan
b66521b7a2 Remove eslint configs 2025-11-15 17:32:18 -05:00
Idrees Hassan
72f2805fa0 Create eslint.config.js 2025-11-15 16:14:12 -05:00
Idrees Hassan
1ca5094536 Add readme for obsidian plugin testing 2025-11-15 15:35:14 -05:00
Idrees Hassan
2ddf10bb01 Update version to remove build number 2025-11-15 15:30:57 -05:00
Idrees Hassan
9eba0c13ed Create .eslintignore 2025-11-15 14:55:06 -05:00
Idrees
45626fda25 Update README with Obsidian section
Added a section for Obsidian with a note about upcoming features.
2025-11-14 17:19:58 -05:00
Idrees Hassan
322b0d0b90 Add obsidian manifest to root 2025-11-14 17:08:22 -05:00
41 changed files with 7754 additions and 2520 deletions

View File

@@ -9,10 +9,14 @@
It's a pet bird that hops around your computer, what more could you want? 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 [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! #### Join the [Discord](https://discord.gg/6yxE9prcNc) to help me beta test new features and suggest ideas!
## Features ## Features
@@ -35,6 +39,14 @@ It's a pet bird that hops around your computer, what more could you want?
2. Click "Add to Firefox" 2. Click "Add to Firefox"
3. Confirm any permission prompts that appear 3. Confirm any permission prompts that appear
### Obsidian
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 ### Userscript
*Note that this is mainly used for beta testing new features, installation via browser extension is recommended for the best experience.* *Note that this is mainly used for beta testing new features, installation via browser extension is recommended for the best experience.*
@@ -44,6 +56,14 @@ It's a pet bird that hops around your computer, what more could you want?
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) 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! 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 ## FAQ
### How do I pet the bird? ### How do I pet the bird?
@@ -70,8 +90,18 @@ 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! 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 ## 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! 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!
Also feel free to check out my other open-source projects like [Monocraft](https://github.com/IdreesInc/Monocraft), [PicoChat](https://github.com/IdreesInc/PicoChat), and more on [my website](https://idreesinc.com/)! Also feel free to check out my other open-source projects like [Monocraft](https://github.com/IdreesInc/Monocraft), [PicoChat](https://github.com/IdreesInc/PicoChat), and more on [my website](https://idreesinc.com/)!

Binary file not shown.

Binary file not shown.

BIN
aseprite/hats.aseprite Normal file

Binary file not shown.

206
build.js
View File

@@ -12,19 +12,24 @@ const IMAGES_DIR = "./images";
const FONTS_DIR = "./fonts"; const FONTS_DIR = "./fonts";
const DIST_DIR = "./dist"; const DIST_DIR = "./dist";
const BROWSER_MANIFEST = "./platform-specific/extension/manifest.json"; const WEB_DIR = DIST_DIR + "/web";
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 USERSCRIPT_DIR = DIST_DIR + "/userscript"; const USERSCRIPT_DIR = DIST_DIR + "/userscript";
const EXTENSION_DIR = DIST_DIR + "/extension"; const EXTENSION_DIR = DIST_DIR + "/extension";
const OBSIDIAN_DIR = DIST_DIR + "/obsidian"; const OBSIDIAN_DIR = DIST_DIR + "/obsidian";
const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css"; const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css";
const APPLICATION_ENTRY = SRC_DIR + "/application.js";
const BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js"; const WEB_ENTRY = SRC_DIR + "/platforms/web/web.js";
const BIRB_OUTPUT = DIST_DIR + "/birb.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"; const MONOCRAFT_URL = "https://cdn.jsdelivr.net/gh/idreesinc/Monocraft@99b32ab40612ff2533a69d8f14bd8b3d9e604456/dist/Monocraft.otf";
@@ -41,6 +46,10 @@ const spriteSheets = [
{ {
key: "__FEATHER_SPRITE_SHEET__", key: "__FEATHER_SPRITE_SHEET__",
path: SPRITES_DIR + "/feather.png" path: SPRITES_DIR + "/feather.png"
},
{
key: "__HATS_SPRITE_SHEET__",
path: SPRITES_DIR + "/hats.png"
} }
]; ];
@@ -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 // Update build cache
buildCache.version = version; buildCache.version = version;
writeFileSync(BUILD_CACHE_PATH, JSON.stringify(buildCache), 'utf8'); 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 await bundle.write({
const bundle = await rollup({ file: TEMP_BUNDLED_OUTPUT,
input: APPLICATION_ENTRY, format: 'iife',
}); });
await bundle.write({ await bundle.close();
file: BUNDLED_OUTPUT,
format: 'iife',
});
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 // Replace version placeholder
unlinkSync(BUNDLED_OUTPUT); birbJs = birbJs.replaceAll(VERSION_KEY, version);
// Replace version placeholder // Compile and insert sprite sheets
birbJs = birbJs.replaceAll(VERSION_KEY, version); 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 // Insert stylesheet
for (const spriteSheet of spriteSheets) { const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
const dataUri = readFileSync(spriteSheet.path, 'base64'); birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`);
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 async function buildWeb() {
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8'); const birbJs = await generateCode(WEB_ENTRY);
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent).replace(MONOCRAFT_SRC_KEY, MONOCRAFT_URL); 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 // Get userscript header
writeFileSync(BIRB_OUTPUT, birbJs); const userScriptHeader = readFileSync(USERSCRIPT_HEADER, 'utf8').replaceAll(VERSION_KEY, version);
// ============================================= mkdirSync(USERSCRIPT_DIR, { recursive: true });
// Build userscript const userScript = userScriptHeader + "\n" + birbJs;
// ============================================= writeFileSync(USERSCRIPT_DIR + '/birb.user.js', userScript);
}
// Get userscript header async function buildExtension() {
const userScriptHeader = readFileSync(USERSCRIPT_HEADER, 'utf8').replaceAll(VERSION_KEY, version); const birbJs = await generateCode(BROWSER_EXTENSION_ENTRY);
mkdirSync(USERSCRIPT_DIR, { recursive: true }); mkdirSync(EXTENSION_DIR, { recursive: true });
const userScript = userScriptHeader + "\n" + birbJs;
writeFileSync(USERSCRIPT_DIR + '/birb.user.js', userScript);
// ============================================= // Copy birb.js
// Build browser extension 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 // Copy icons folder
writeFileSync(EXTENSION_DIR + '/birb.js', birbJs); mkdirSync(EXTENSION_DIR + '/images/icons', { recursive: true });
cpSync(IMAGES_DIR + '/icons/transparent', EXTENSION_DIR + '/images/icons/transparent', { recursive: true });
// Copy manifest.json // Copy fonts folder
let browserManifest = readFileSync(BROWSER_MANIFEST, 'utf8'); mkdirSync(EXTENSION_DIR + '/fonts', { recursive: true });
browserManifest = browserManifest.replace(VERSION_KEY, version); cpSync(FONTS_DIR, EXTENSION_DIR + '/fonts', { recursive: true });
writeFileSync(EXTENSION_DIR + '/manifest.json', browserManifest);
// Copy icons folder // Compress extension folder into zip
mkdirSync(EXTENSION_DIR + '/images/icons', { recursive: true }); const output = createWriteStream(DIST_DIR + "/extension.zip");
cpSync(IMAGES_DIR + '/icons/transparent', EXTENSION_DIR + '/images/icons/transparent', { recursive: true }); const archive = archiver('zip');
// Copy fonts folder output.on('close', () => {
mkdirSync(EXTENSION_DIR + '/fonts', { recursive: true }); console.log(`Created zip file: ${archive.pointer()} total bytes`);
cpSync(FONTS_DIR, EXTENSION_DIR + '/fonts', { recursive: true }); });
// Compress extension folder into zip archive.on('error', (err) => {
const output = createWriteStream(DIST_DIR + "/extension.zip"); throw err;
const archive = archiver('zip'); });
output.on('close', () => { archive.pipe(output);
console.log(`Created zip file: ${archive.pointer()} total bytes`); archive.directory(EXTENSION_DIR + '/', false);
}); archive.finalize();
}
archive.on('error', (err) => { async function buildObsidian() {
throw err; const birbJs = await generateCode(OBSIDIAN_ENTRY, true);
});
archive.pipe(output); mkdirSync(OBSIDIAN_DIR, { recursive: true });
archive.directory(EXTENSION_DIR + '/', false);
archive.finalize();
// ============================================= // Wrap birb.js with plugin boilerplate
// Build Obsidian plugin 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 // Copy manifest.json
let obsidianPlugin = readFileSync(OBSIDIAN_WRAPPER, 'utf8').replace(VERSION_KEY, version).replace(CODE_KEY, birbJs); 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 console.log("Starting build...");
const monocraftFontData = readFileSync(FONTS_DIR + '/Monocraft.otf', 'base64');
const monocraftDataUri = `data:font/otf;base64,${monocraftFontData}`;
obsidianPlugin = obsidianPlugin.replace(MONOCRAFT_URL, monocraftDataUri);
// Create main.js with plugin code await buildWeb();
writeFileSync(OBSIDIAN_DIR + '/main.js', obsidianPlugin); await buildUserscript();
await buildExtension();
await buildObsidian();
// Copy manifest.json console.log("Build completed successfully!");
let obsidianManifest = readFileSync(OBSIDIAN_MANIFEST, 'utf8');
obsidianManifest = obsidianManifest.replace(/"version":\s*".*"/, `"version": "${version}"`);
writeFileSync(OBSIDIAN_DIR + '/manifest.json', obsidianManifest);
console.log(`Build complete: ${version}`);

BIN
dist/extension.zip vendored

Binary file not shown.

1345
dist/extension/birb.js vendored

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Pocket Bird", "name": "Pocket Bird",
"description": "It's a pet bird in your browser, what more could you want?", "description": "It's a pet bird in your browser, what more could you want?",
"version": "2025.11.14.205", "version": "2026.1.24",
"homepage_url": "https://idreesinc.com", "homepage_url": "https://idreesinc.com",
"icons": { "icons": {
"48": "images/icons/transparent/48x48x1.png", "48": "images/icons/transparent/48x48x1.png",

1305
dist/obsidian/main.js vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{ {
"id": "pocket-bird", "id": "pocket-bird",
"name": "Pocket Bird", "name": "Pocket Bird",
"version": "2025.11.14.205", "version": "2026.1.24",
"minAppVersion": "0.15.0", "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", "author": "Idrees Hassan",
"authorUrl": "https://idreesinc.com", "authorUrl": "https://idreesinc.com",
"isDesktopOnly": false "isDesktopOnly": false

File diff suppressed because it is too large Load Diff

3182
dist/web/birb.embed.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,6 @@
</div> </div>
<div id="spacer"></div> <div id="spacer"></div>
<!-- <script type="module" src="spritesheet-compiler.js"></script> --> <!-- <script type="module" src="spritesheet-compiler.js"></script> -->
<script src="../dist/birb.js"></script> <script src="../dist/web/birb.js"></script>
</body> </body>
</html> </html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
sprites/hats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 B

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
import Frame from "./frame.js"; import Frame from "./frame.js";
import { BirdType } from "./sprites"; import { BirdType } from "./sprites.js";
class Anim { class Anim {
/** /**
@@ -59,10 +59,11 @@ class Anim {
* @param {number} direction * @param {number} direction
* @param {number} timeStart The start time of the animation in milliseconds * @param {number} timeStart The start time of the animation in milliseconds
* @param {number} canvasPixelSize The size of a canvas pixel in pixels * @param {number} canvasPixelSize The size of a canvas pixel in pixels
* @param {BirdType} [species] The species to use for the animation * @param {{ [key: string]: string }} colorScheme The color scheme to use for the animation
* @param {string[]} tags The tags to use for the animation
* @returns {boolean} Whether the animation is complete * @returns {boolean} Whether the animation is complete
*/ */
draw(ctx, direction, timeStart, canvasPixelSize, species) { draw(ctx, direction, timeStart, canvasPixelSize, colorScheme, tags) {
// Reset cache if animation was restarted // Reset cache if animation was restarted
if (this.lastTimeStart !== timeStart) { if (this.lastTimeStart !== timeStart) {
this.#clearCache(); this.#clearCache();
@@ -79,7 +80,7 @@ class Anim {
const currentFrameIndex = this.getCurrentFrameIndex(time); const currentFrameIndex = this.getCurrentFrameIndex(time);
if (this.#shouldRedraw(currentFrameIndex, direction)) { if (this.#shouldRedraw(currentFrameIndex, direction)) {
this.frames[currentFrameIndex].draw(ctx, direction, canvasPixelSize, species); this.frames[currentFrameIndex].draw(ctx, direction, canvasPixelSize, colorScheme, tags);
this.lastFrameIndex = currentFrameIndex; this.lastFrameIndex = currentFrameIndex;
this.lastDirection = direction; this.lastDirection = direction;
} }

View File

@@ -1,6 +1,6 @@
import { Directions } from './shared.js'; import { Directions } from '../shared.js';
import { Sprite, BirdType } from './sprites.js'; import { PALETTE, BirdType } from './sprites.js';
import Layer from './layer.js'; import Layer, { TAG } from './layer.js';
class Frame { class Frame {
@@ -16,25 +16,25 @@ class Frame {
for (let layer of layers) { for (let layer of layers) {
tags.add(layer.tag); tags.add(layer.tag);
} }
tags.add("default"); tags.add(TAG.DEFAULT);
for (let tag of tags) { for (let tag of tags) {
let maxHeight = layers.reduce((max, layer) => Math.max(max, layer.pixels.length), 0); let maxHeight = layers.reduce((max, layer) => Math.max(max, layer.pixels.length), 0);
if (layers[0].tag !== "default") { if (layers[0].tag !== TAG.DEFAULT) {
throw new Error("First layer must have the 'default' tag"); throw new Error("First layer must have the 'default' tag");
} }
this.pixels = layers[0].pixels.map(row => row.slice()); this.pixels = layers[0].pixels.map(row => row.slice());
// Pad from top with transparent pixels // Pad from top with transparent pixels
while (this.pixels.length < maxHeight) { while (this.pixels.length < maxHeight) {
this.pixels.unshift(new Array(this.pixels[0].length).fill(Sprite.TRANSPARENT)); this.pixels.unshift(new Array(this.pixels[0].length).fill(PALETTE.TRANSPARENT));
} }
// Combine layers // Combine layers
for (let i = 1; i < layers.length; i++) { for (let i = 1; i < layers.length; i++) {
if (layers[i].tag === "default" || layers[i].tag === tag) { if (layers[i].tag === TAG.DEFAULT || layers[i].tag === tag) {
let layerPixels = layers[i].pixels; let layerPixels = layers[i].pixels;
let topMargin = maxHeight - layerPixels.length; let topMargin = maxHeight - layerPixels.length;
for (let y = 0; y < layerPixels.length; y++) { for (let y = 0; y < layerPixels.length; y++) {
for (let x = 0; x < layerPixels[y].length; x++) { for (let x = 0; x < layerPixels[y].length; x++) {
this.pixels[y + topMargin][x] = layerPixels[y][x] !== Sprite.TRANSPARENT ? layerPixels[y][x] : this.pixels[y + topMargin][x]; this.pixels[y + topMargin][x] = layerPixels[y][x] !== PALETTE.TRANSPARENT ? layerPixels[y][x] : this.pixels[y + topMargin][x];
} }
} }
} }
@@ -44,29 +44,36 @@ class Frame {
} }
/** /**
* @param {string} [tag] * @param {string[]} [tags]
* @returns {string[][]} * @returns {string[][]}
*/ */
getPixels(tag = "default") { getPixels(tags = [TAG.DEFAULT]) {
return this.#pixelsByTag[tag] ?? this.#pixelsByTag["default"]; for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
if (this.#pixelsByTag[tag]) {
return this.#pixelsByTag[tag];
}
}
return this.#pixelsByTag[TAG.DEFAULT];
} }
/** /**
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
* @param {BirdType} [species] * @param {number} direction
* @param {number} direction
* @param {number} canvasPixelSize * @param {number} canvasPixelSize
* @param {{ [key: string]: string }} colorScheme
* @param {string[]} tags
*/ */
draw(ctx, direction, canvasPixelSize, species) { draw(ctx, direction, canvasPixelSize, colorScheme, tags) {
// Clear the canvas before drawing the new frame // Clear the canvas before drawing the new frame
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const pixels = this.getPixels(species?.tags[0]); const pixels = this.getPixels(tags);
for (let y = 0; y < pixels.length; y++) { for (let y = 0; y < pixels.length; y++) {
const row = pixels[y]; const row = pixels[y];
for (let x = 0; x < pixels[y].length; x++) { for (let x = 0; x < pixels[y].length; x++) {
const cell = direction === Directions.LEFT ? row[x] : row[pixels[y].length - x - 1]; const cell = direction === Directions.LEFT ? row[x] : row[pixels[y].length - x - 1];
ctx.fillStyle = species?.colors[cell] ?? cell; ctx.fillStyle = colorScheme[cell] ?? cell;
ctx.fillRect(x * canvasPixelSize, y * canvasPixelSize, canvasPixelSize, canvasPixelSize); ctx.fillRect(x * canvasPixelSize, y * canvasPixelSize, canvasPixelSize, canvasPixelSize);
}; };
}; };

View File

@@ -1,9 +1,14 @@
export const TAG = {
DEFAULT: "default",
TUFT: "tuft",
};
class Layer { class Layer {
/** /**
* @param {string[][]} pixels * @param {string[][]} pixels
* @param {string} [tag] * @param {string} [tag]
*/ */
constructor(pixels, tag = "default") { constructor(pixels, tag = TAG.DEFAULT) {
this.pixels = pixels; this.pixels = pixels;
this.tag = tag; this.tag = tag;
} }

209
src/animation/sprites.js Normal file
View 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",
}),
};

View File

@@ -1,10 +1,13 @@
import Frame from './frame.js'; import Frame from './animation/frame.js';
import Layer from './layer.js'; import Layer, { TAG } from './animation/layer.js';
import Anim from './anim.js'; import Anim from './animation/anim.js';
import { Birb, Animations } from './birb.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 { import {
getContext,
setContext,
Directions, Directions,
isDebug, isDebug,
setDebug, setDebug,
@@ -16,14 +19,14 @@ import {
log, log,
debug, debug,
error, error,
getLayer, getLayerPixels,
getWindowHeight getWindowHeight
} from './shared.js'; } from './shared.js';
import { import {
Sprite, PALETTE,
SPRITE_SHEET_COLOR_MAP, SPRITE_SHEET_COLOR_MAP,
SPECIES SPECIES
} from './sprites.js'; } from './animation/sprites.js';
import { import {
StickyNote, StickyNote,
createNewStickyNote, createNewStickyNote,
@@ -40,6 +43,7 @@ import {
switchMenuItems, switchMenuItems,
MENU_EXIT_ID MENU_EXIT_ID
} from './menu.js'; } from './menu.js';
import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js';
/** /**
@@ -50,6 +54,8 @@ import {
* @typedef {Object} BirbSaveData * @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies * @property {string[]} unlockedSpecies
* @property {string} currentSpecies * @property {string} currentSpecies
* @property {string[]} unlockedHats
* @property {string} currentHat
* @property {Partial<Settings>} settings * @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes] * @property {SavedStickyNote[]} [stickyNotes]
*/ */
@@ -58,7 +64,8 @@ import {
* @typedef {typeof DEFAULT_SETTINGS} Settings * @typedef {typeof DEFAULT_SETTINGS} Settings
*/ */
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
birbMode: false birbMode: false,
soundEnabled: true
}; };
// Rendering constants // Rendering constants
@@ -74,12 +81,16 @@ const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE;
const STYLESHEET = `___STYLESHEET___`; const STYLESHEET = `___STYLESHEET___`;
const SPRITE_SHEET = "__SPRITE_SHEET__"; const SPRITE_SHEET = "__SPRITE_SHEET__";
const FEATHER_SPRITE_SHEET = "__FEATHER_SPRITE_SHEET__"; const FEATHER_SPRITE_SHEET = "__FEATHER_SPRITE_SHEET__";
const HATS_SPRITE_SHEET = "__HATS_SPRITE_SHEET__";
// Element IDs // Element IDs
const FIELD_GUIDE_ID = "birb-field-guide"; const FIELD_GUIDE_ID = "birb-field-guide";
const FEATHER_ID = "birb-feather"; const FEATHER_ID = "birb-feather";
const WARDROBE_ID = "birb-wardrobe";
const HAT_ID = "birb-hat";
const DEFAULT_BIRD = "bluebird"; const DEFAULT_BIRD = "bluebird";
const DEFAULT_HAT = HAT.NONE;
// Birb movement // Birb movement
const HOP_SPEED = 0.07; const HOP_SPEED = 0.07;
@@ -88,8 +99,8 @@ const HOP_DISTANCE = 35;
// Timing constants (in milliseconds) // Timing constants (in milliseconds)
const UPDATE_INTERVAL = 1000 / 60; // 60 FPS const UPDATE_INTERVAL = 1000 / 60; // 60 FPS
const AFK_TIME = isDebug() ? 0 : 1000 * 5; const AFK_TIME = isDebug() ? 0 : 1000 * 5; // 5 seconds
const PET_BOOST_DURATION = 1000 * 60 * 5; const SUPER_AFK_TIME = 1000 * 60 * 60; // 1 hour
const PET_MENU_COOLDOWN = 1000; const PET_MENU_COOLDOWN = 1000;
const URL_CHECK_INTERVAL = 150; const URL_CHECK_INTERVAL = 150;
const HOP_DELAY = 500; const HOP_DELAY = 500;
@@ -98,10 +109,15 @@ const HOP_DELAY = 500;
const HOP_CHANCE = 1 / (60 * 2.5); // Every 2.5 seconds const HOP_CHANCE = 1 / (60 * 2.5); // Every 2.5 seconds
const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours
const HAT_CHANCE = 1 / (60 * 60 * 10); // Every 10 minutes
// Feathers // Feathers
const FEATHER_FALL_SPEED = 1; const FEATHER_FALL_SPEED = 1;
// Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2; const PET_FEATHER_BOOST = 2;
const PET_HAT_BOOST = 1.5;
// Focus element constraints // Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100; const MIN_FOCUS_ELEMENT_WIDTH = 100;
@@ -109,74 +125,33 @@ const MIN_FOCUS_ELEMENT_WIDTH = 100;
/** @type {Partial<Settings>} */ /** @type {Partial<Settings>} */
let userSettings = {}; let userSettings = {};
/**
* Load the sprite sheet and return the pixel-map template /**
* @param {string} dataUri * @param {Context} context
* @param {boolean} [templateColors]
* @returns {Promise<string[][]>}
*/ */
function loadSpriteSheetPixels(dataUri, templateColors = true) { export async function initializeApplication(context) {
return new Promise((resolve, reject) => { log("birbOS booting up...");
const img = new Image(); setContext(context);
img.src = dataUri; log("Loading sprite sheets...");
img.onload = () => { const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const canvas = document.createElement('canvas'); const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
canvas.width = img.width; const hatsPixels = await loadSpriteSheetPixels(HATS_SPRITE_SHEET);
canvas.height = img.height; startApplication(birbPixels, featherPixels, hatsPixels);
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);
};
});
} }
log("Loading sprite sheets..."); /**
* @param {string[][]} birbPixels
Promise.all([ * @param {string[][]} featherPixels
loadSpriteSheetPixels(SPRITE_SHEET), * @param {string[][]} hatsPixels
loadSpriteSheetPixels(FEATHER_SPRITE_SHEET) */
]).then(([birbPixels, featherPixels]) => { function startApplication(birbPixels, featherPixels, hatsPixels) {
const SPRITE_SHEET = birbPixels; const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels; const FEATHER_SPRITE_SHEET = featherPixels;
const HATS_SPRITE_SHEET = hatsPixels;
const featherLayers = { 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 = { const featherFrames = {
@@ -194,6 +169,7 @@ Promise.all([
const menuItems = [ const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet), new MenuItem(`Pet ${birdBirb()}`, pet),
new MenuItem("Field Guide", insertFieldGuide), new MenuItem("Field Guide", insertFieldGuide),
new MenuItem("Wardrobe", insertWardrobe),
new ConditionalMenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote), () => getContext().areStickyNotesEnabled()), new ConditionalMenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote), () => getContext().areStickyNotesEnabled()),
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)), new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
new DebugMenuItem("Freeze/Unfreeze", () => { new DebugMenuItem("Freeze/Unfreeze", () => {
@@ -204,6 +180,9 @@ Promise.all([
for (let type in SPECIES) { for (let type in SPECIES) {
unlockBird(type); unlockBird(type);
} }
for (let hat in HAT) {
unlockHat(HAT[hat]);
}
}), }),
new DebugMenuItem("Add Feather", () => { new DebugMenuItem("Add Feather", () => {
activateFeather(); activateFeather();
@@ -218,12 +197,16 @@ Promise.all([
const settingsItems = [ const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false), new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
new Separator(), new Separator(),
new MenuItem("Toggle Birb Mode", () => { new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.birbMode = !userSettings.birbMode; userSettings.soundEnabled = !settings().soundEnabled;
save();
}),
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
userSettings.birbMode = !settings().birbMode;
save(); save();
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`)); 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.createElement("br")); message.appendChild(document.createElement("br"));
message.appendChild(document.createTextNode("Welcome back to 2012")); message.appendChild(document.createTextNode("Welcome back to 2012"));
@@ -231,6 +214,7 @@ Promise.all([
insertModal(`${birdBirb()} Mode`, message); insertModal(`${birdBirb()} Mode`, message);
}), }),
new Separator(), new Separator(),
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
new MenuItem("__VERSION__", () => { alert("Thank you for using Pocket Bird! You are on version: __VERSION__") }, false), new MenuItem("__VERSION__", () => { alert("Thank you for using Pocket Bird! You are on version: __VERSION__") }, false),
]; ];
@@ -245,6 +229,8 @@ Promise.all([
FLYING: "flying", FLYING: "flying",
}; };
const birdsong = new Birdsong();
let frozen = false; let frozen = false;
let stateStart = Date.now(); let stateStart = Date.now();
let currentState = States.IDLE; let currentState = States.IDLE;
@@ -266,6 +252,8 @@ Promise.all([
let petStack = []; let petStack = [];
let currentSpecies = DEFAULT_BIRD; let currentSpecies = DEFAULT_BIRD;
let unlockedSpecies = [DEFAULT_BIRD]; let unlockedSpecies = [DEFAULT_BIRD];
let unlockedHats = [DEFAULT_HAT];
let currentHat = DEFAULT_HAT;
// let visible = true; // let visible = true;
let lastPetTimestamp = 0; let lastPetTimestamp = 0;
/** @type {StickyNote[]} */ /** @type {StickyNote[]} */
@@ -284,6 +272,8 @@ Promise.all([
userSettings = saveData.settings ?? {}; userSettings = saveData.settings ?? {};
unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD]; unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD];
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT;
stickyNotes = []; stickyNotes = [];
if (saveData.stickyNotes) { if (saveData.stickyNotes) {
@@ -296,13 +286,16 @@ Promise.all([
log(stickyNotes.length + " sticky notes loaded"); log(stickyNotes.length + " sticky notes loaded");
switchSpecies(currentSpecies); switchSpecies(currentSpecies);
switchHat(currentHat);
} }
function save() { function save() {
/** @type {BirbSaveData} */ /** @type {BirbSaveData} */
const saveData = { const saveData = {
unlockedSpecies, unlockedSpecies: unlockedSpecies,
currentSpecies, currentSpecies: currentSpecies,
unlockedHats: unlockedHats,
currentHat: currentHat,
settings: userSettings settings: userSettings
}; };
@@ -335,8 +328,8 @@ Promise.all([
/** /**
* Bird or birb, you decide * Bird or birb, you decide
*/ */
function birdBirb() { function birdBirb(invert = false) {
return settings().birbMode ? "Birb" : "Bird"; return settings().birbMode !== invert ? "Birb" : "Bird";
} }
function init() { function init() {
@@ -355,7 +348,7 @@ Promise.all([
styleElement.textContent = STYLESHEET; styleElement.textContent = STYLESHEET;
document.head.appendChild(styleElement); 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); birb.setAnimation(Animations.BOB);
window.addEventListener("scroll", () => { window.addEventListener("scroll", () => {
@@ -376,6 +369,7 @@ Promise.all([
// Currently being pet, don't open menu // Currently being pet, don't open menu
return; return;
} }
insertMenu(menuItems, `${birdBirb().toLowerCase()}OS`, updateMenuLocation); insertMenu(menuItems, `${birdBirb().toLowerCase()}OS`, updateMenuLocation);
}); });
@@ -405,7 +399,7 @@ Promise.all([
setInterval(() => { setInterval(() => {
const currentPath = getContext().getPath().split("?")[0]; const currentPath = getContext().getPath().split("?")[0];
if (currentPath !== lastPath) { if (currentPath !== lastPath) {
log("Path changed, updating sticky notes: " + currentPath); log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
lastPath = currentPath; lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote); drawStickyNotes(stickyNotes, save, deleteStickyNote);
} }
@@ -446,12 +440,17 @@ Promise.all([
} }
} }
// Double the chance of a feather if recently pet if (birb.isVisible() && Date.now() - lastActionTimestamp < SUPER_AFK_TIME) {
const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1; if (Math.random() < FEATHER_CHANCE * (isPetBoostActive() ? PET_FEATHER_BOOST : 1)) {
if (birb.isVisible() && Math.random() < FEATHER_CHANCE * petMod) { lastPetTimestamp = 0;
lastPetTimestamp = 0; activateFeather();
activateFeather(); }
if (Math.random() < (HAT_CHANCE * (isPetBoostActive() ? PET_HAT_BOOST : 1))) {
lastPetTimestamp = 0;
insertHat();
}
} }
updateFeather(); updateFeather();
} }
@@ -486,7 +485,7 @@ Promise.all([
flySomewhere(); flySomewhere();
} }
if (birb.draw(SPECIES[currentSpecies])) { if (birb.draw(SPECIES[currentSpecies], currentHat)) {
birb.setAnimation(Animations.STILL); birb.setAnimation(Animations.STILL);
} }
@@ -578,7 +577,7 @@ Promise.all([
if (!featherCtx) { if (!featherCtx) {
return; 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); document.body.appendChild(featherCanvas);
onClick(featherCanvas, () => { onClick(featherCanvas, () => {
unlockBird(birdType); 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 * @param {string} birdType
*/ */
function unlockBird(birdType) { function unlockBird(birdType) {
if (!unlockedSpecies.includes(birdType)) { if (!unlockedSpecies.includes(birdType)) {
unlockedSpecies.push(birdType); unlockedSpecies.push(birdType);
save();
const message = makeElement("birb-message-content"); const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode("You've found a ")); message.appendChild(document.createTextNode("You've found a "));
const bold = document.createElement("b"); 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.")); message.appendChild(document.createTextNode(" feather! Use the Field Guide to switch your bird's species."));
insertModal("New Bird Unlocked!", message); 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() { function updateFeather() {
@@ -678,6 +744,8 @@ Promise.all([
if (document.querySelector("#" + FIELD_GUIDE_ID)) { if (document.querySelector("#" + FIELD_GUIDE_ID)) {
return; return;
} }
// Remove wardrobe if open
removeWardrobe();
const contentContainer = document.createElement("div"); const contentContainer = document.createElement("div");
const content = makeElement("birb-grid-content"); const content = makeElement("birb-grid-content");
@@ -725,7 +793,7 @@ Promise.all([
if (!speciesCtx) { if (!speciesCtx) {
return; 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); speciesElement.appendChild(speciesCanvas);
content.appendChild(speciesElement); content.appendChild(speciesElement);
if (unlocked) { 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 * @param {string} type
*/ */
function switchSpecies(type) { function switchSpecies(type) {
currentSpecies = type; currentSpecies = type;
// Update CSS variable --birb-highlight to be wing color // 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(); save();
} }
@@ -829,14 +998,9 @@ Promise.all([
} }
/** /**
* Focus on an element within the viewport * @returns {HTMLElement|null} The random element, or null if no valid element was found
* @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) { function getRandomValidElement() {
if (frozen) {
return false;
}
const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin(); const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin();
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", ")); const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
const inWindow = Array.from(elements).filter((img) => { const inWindow = Array.from(elements).filter((img) => {
@@ -851,10 +1015,11 @@ Promise.all([
return true; return true;
}); });
/** @type {HTMLElement[]} */ /** @type {HTMLElement[]} */
// @ts-expect-error
const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH); const largeElements = Array.from(visible).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_FOCUS_ELEMENT_WIDTH);
// Ensure the bird doesn't land on fixed or sticky elements // Ensure the bird doesn't land on fixed or sticky elements
const fixedAllowed = getContext() instanceof ObsidianContext; // const fixedAllowed = getContext() instanceof ObsidianContext;
// TODO: FIX
const fixedAllowed = true;
const nonFixedElements = largeElements.filter((el) => { const nonFixedElements = largeElements.filter((el) => {
if (fixedAllowed) { if (fixedAllowed) {
return true; return true;
@@ -863,10 +1028,22 @@ Promise.all([
return style.position !== "fixed" && style.position !== "sticky"; return style.position !== "fixed" && style.position !== "sticky";
}); });
if (nonFixedElements.length === 0) { if (nonFixedElements.length === 0) {
return false; return null;
} }
const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)]; 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); log("Focusing on element: ", focusedElement);
updateFocusedElementBounds(); updateFocusedElementBounds();
if (teleport) { if (teleport) {
@@ -874,7 +1051,7 @@ Promise.all([
} else { } else {
flyTo(getFocusedElementRandomX(), getFocusedY()); flyTo(getFocusedElementRandomX(), getFocusedY());
} }
return randomElement !== null; return focusedElement !== null;
} }
/** /**
@@ -938,11 +1115,18 @@ Promise.all([
function pet() { function pet() {
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) { if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
if (settings().soundEnabled) {
birdsong.chirp();
}
birb.setAnimation(Animations.HEART); birb.setAnimation(Animations.HEART);
lastPetTimestamp = Date.now(); lastPetTimestamp = Date.now();
} }
} }
function isPetBoostActive() {
return Date.now() - lastPetTimestamp < PET_BOOST_DURATION;
}
/** /**
* @param {number} x * @param {number} x
* @param {number} y * @param {number} y
@@ -1008,6 +1192,61 @@ Promise.all([
// Run the birb // Run the birb
init(); init();
draw(); 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);
};
});
}

View File

@@ -1,8 +1,9 @@
import { Directions, getLayer, getWindowHeight, getFixedWindowHeight } from './shared.js'; import { Directions, getLayerPixels, getWindowHeight, getFixedWindowHeight } from './shared.js';
import Layer from './layer.js'; import Layer from './animation/layer.js';
import Frame from './frame.js'; import Frame from './animation/frame.js';
import Anim from './anim.js'; import Anim from './animation/anim.js';
import { BirdType } from './sprites.js'; import { BirdType, PALETTE } from './animation/sprites.js';
import { createHatLayers } from './hats.js';
/** /**
* @typedef {keyof typeof Animations} AnimationType * @typedef {keyof typeof Animations} AnimationType
@@ -31,8 +32,9 @@ export class Birb {
* @param {string[][]} spriteSheet The loaded sprite sheet pixel data * @param {string[][]} spriteSheet The loaded sprite sheet pixel data
* @param {number} spriteWidth * @param {number} spriteWidth
* @param {number} spriteHeight * @param {number} spriteHeight
* @param {string[][]} hatSpriteSheet The loaded hat sprite sheet pixel data
*/ */
constructor(birbCssScale, canvasPixelSize, spriteSheet, spriteWidth, spriteHeight) { constructor(birbCssScale, canvasPixelSize, spriteSheet, spriteWidth, spriteHeight, hatSpriteSheet) {
this.birbCssScale = birbCssScale; this.birbCssScale = birbCssScale;
this.canvasPixelSize = canvasPixelSize; this.canvasPixelSize = canvasPixelSize;
this.windowPixelSize = canvasPixelSize * birbCssScale; this.windowPixelSize = canvasPixelSize * birbCssScale;
@@ -41,28 +43,31 @@ export class Birb {
// Build layers from sprite sheet // Build layers from sprite sheet
this.layers = { this.layers = {
base: new Layer(getLayer(spriteSheet, 0, this.spriteWidth)), base: new Layer(getLayerPixels(spriteSheet, 0, this.spriteWidth)),
down: new Layer(getLayer(spriteSheet, 1, this.spriteWidth)), down: new Layer(getLayerPixels(spriteSheet, 1, this.spriteWidth)),
heartOne: new Layer(getLayer(spriteSheet, 2, this.spriteWidth)), heartOne: new Layer(getLayerPixels(spriteSheet, 2, this.spriteWidth)),
heartTwo: new Layer(getLayer(spriteSheet, 3, this.spriteWidth)), heartTwo: new Layer(getLayerPixels(spriteSheet, 3, this.spriteWidth)),
heartThree: new Layer(getLayer(spriteSheet, 4, this.spriteWidth)), heartThree: new Layer(getLayerPixels(spriteSheet, 4, this.spriteWidth)),
tuftBase: new Layer(getLayer(spriteSheet, 5, this.spriteWidth), "tuft"), tuftBase: new Layer(getLayerPixels(spriteSheet, 5, this.spriteWidth), "tuft"),
tuftDown: new Layer(getLayer(spriteSheet, 6, this.spriteWidth), "tuft"), tuftDown: new Layer(getLayerPixels(spriteSheet, 6, this.spriteWidth), "tuft"),
wingsUp: new Layer(getLayer(spriteSheet, 7, this.spriteWidth)), wingsUp: new Layer(getLayerPixels(spriteSheet, 7, this.spriteWidth)),
wingsDown: new Layer(getLayer(spriteSheet, 8, this.spriteWidth)), wingsDown: new Layer(getLayerPixels(spriteSheet, 8, this.spriteWidth)),
happyEye: new Layer(getLayer(spriteSheet, 9, this.spriteWidth)), happyEye: new Layer(getLayerPixels(spriteSheet, 9, this.spriteWidth)),
}; };
// Build hat layers
const hatLayers = createHatLayers(hatSpriteSheet);
// Build frames from layers // Build frames from layers
this.frames = { this.frames = {
base: new Frame([this.layers.base, this.layers.tuftBase]), base: new Frame([this.layers.base, this.layers.tuftBase, ...hatLayers.base]),
headDown: new Frame([this.layers.down, this.layers.tuftDown]), headDown: new Frame([this.layers.down, this.layers.tuftDown, ...hatLayers.down]),
wingsDown: new Frame([this.layers.base, this.layers.tuftBase, this.layers.wingsDown]), wingsDown: new Frame([this.layers.base, this.layers.tuftBase, this.layers.wingsDown, ...hatLayers.base]),
wingsUp: new Frame([this.layers.down, this.layers.tuftDown, this.layers.wingsUp]), wingsUp: new Frame([this.layers.down, this.layers.tuftDown, this.layers.wingsUp, ...hatLayers.down]),
heartOne: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartOne]), heartOne: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartOne]),
heartTwo: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartTwo]), heartTwo: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base,this.layers.heartTwo]),
heartThree: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartThree]), heartThree: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartThree]),
heartFour: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, this.layers.heartTwo]), heartFour: new Frame([this.layers.base, this.layers.tuftBase, this.layers.happyEye, ...hatLayers.base, this.layers.heartTwo]),
}; };
// Build animations from frames // Build animations from frames
@@ -121,14 +126,16 @@ export class Birb {
/** /**
* Draw the current animation frame * Draw the current animation frame
* @param {BirdType} species The species color data * @param {BirdType} species The species data
* @param {string} [hat] The name of the current hat
* @returns {boolean} Whether the animation has completed (for non-looping animations) * @returns {boolean} Whether the animation has completed (for non-looping animations)
*/ */
draw(species) { draw(species, hat) {
const anim = this.animations[this.currentAnimation]; const anim = this.animations[this.currentAnimation];
return anim.draw(this.ctx, this.direction, this.animStart, this.canvasPixelSize, species); return anim.draw(this.ctx, this.direction, this.animStart, this.canvasPixelSize, species.colors, [...species.tags, hat || '']);
} }
/** /**
* @returns {AnimationType} The current animation key * @returns {AnimationType} The current animation key
*/ */

View File

@@ -1,7 +1,8 @@
import { debug, log, error } from "./shared.js"; import { debug, log, error } from "./shared.js";
const SAVE_KEY = "birbSaveData"; export const SAVE_KEY = "birbSaveData";
const ROOT_PATH = ""; const ROOT_PATH = "";
const SET_CONTEXT = "__CONTEXT__"
/** /**
* @typedef {import('./application.js').BirbSaveData} BirbSaveData * @typedef {import('./application.js').BirbSaveData} BirbSaveData
@@ -12,14 +13,6 @@ const ROOT_PATH = "";
*/ */
export class Context { export class Context {
/**
* @abstract
* @returns {boolean} Whether this context is applicable
*/
isContextActive() {
throw new Error("Method not implemented");
}
/** /**
* @abstract * @abstract
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -103,16 +96,6 @@ export class Context {
export class LocalContext extends 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 * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -140,15 +123,6 @@ export class LocalContext extends Context {
export class UserScriptContext extends Context { export class UserScriptContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof GM_getValue === "function";
}
/** /**
* @override * @override
* @returns {Promise<BirbSaveData|{}>} * @returns {Promise<BirbSaveData|{}>}
@@ -180,16 +154,7 @@ export class UserScriptContext extends Context {
} }
} }
class BrowserExtensionContext extends Context { export class BrowserExtensionContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof chrome !== "undefined";
}
/** /**
* @override * @override
@@ -216,9 +181,9 @@ class BrowserExtensionContext extends Context {
// @ts-expect-error // @ts-expect-error
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
// @ts-expect-error // @ts-expect-error
console.error(chrome.runtime.lastError); error(chrome.runtime.lastError);
} else { } else {
console.log("Settings saved successfully"); log("Settings saved successfully");
} }
}); });
} }
@@ -232,14 +197,6 @@ class BrowserExtensionContext extends Context {
} }
export class ObsidianContext extends Context { export class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/** /**
* @override * @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 * Parse URL parameters into a key-value map
* @param {string} url * @param {string} url

0
src/fieldGuide.js Normal file
View File

240
src/hats.js Normal file
View 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;
}

View File

@@ -12,7 +12,7 @@ export const MENU_EXIT_ID = "birb-menu-exit";
export class MenuItem { export class MenuItem {
/** /**
* @param {string} text * @param {string|(() => string)} text
* @param {() => void} action * @param {() => void} action
* @param {boolean} [removeMenu] * @param {boolean} [removeMenu]
*/ */
@@ -61,7 +61,7 @@ function makeMenuItem(item, removeMenuCallback) {
if (item instanceof Separator) { if (item instanceof Separator) {
return makeElement("birb-window-separator"); return makeElement("birb-window-separator");
} }
let menuItem = makeElement("birb-menu-item", item.text); let menuItem = makeElement("birb-menu-item", typeof item.text === "function" ? item.text() : item.text);
onClick(menuItem, () => { onClick(menuItem, () => {
if (item.removeMenu) { if (item.removeMenu) {
removeMenuCallback(); removeMenuCallback();

View File

@@ -0,0 +1,4 @@
import { initializeApplication } from "../../application.js";
import { BrowserExtensionContext } from "../../context.js";
initializeApplication(new BrowserExtensionContext());

View File

@@ -3,7 +3,7 @@
"name": "Pocket Bird", "name": "Pocket Bird",
"version": "__VERSION__", "version": "__VERSION__",
"minAppVersion": "0.15.0", "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", "author": "Idrees Hassan",
"authorUrl": "https://idreesinc.com", "authorUrl": "https://idreesinc.com",
"isDesktopOnly": false "isDesktopOnly": false

View File

@@ -0,0 +1,4 @@
import { initializeApplication } from "../../application.js";
import { ObsidianContext } from "../../context.js";
initializeApplication(new ObsidianContext());

View 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
View File

@@ -0,0 +1,4 @@
import { initializeApplication } from "../../application.js";
import { LocalContext } from "../../context.js";
initializeApplication(new LocalContext());

View File

@@ -4,6 +4,7 @@ export const Directions = {
}; };
let debugMode = location.hostname === "127.0.0.1"; let debugMode = location.hostname === "127.0.0.1";
let context = null;
/** /**
* @returns {boolean} Whether debug mode is enabled * @returns {boolean} Whether debug mode is enabled
@@ -19,6 +20,17 @@ export function setDebug(value) {
debugMode = 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 * Create an HTML element with the specified parameters
* @param {string} className * @param {string} className
@@ -181,7 +193,7 @@ export function error() {
* @param {number} width The width of each sprite * @param {number} width The width of each sprite
* @returns {string[][]} * @returns {string[][]}
*/ */
export function getLayer(spriteSheet, spriteIndex, width) { export function getLayerPixels(spriteSheet, spriteIndex, width) {
// From an array of a horizontal sprite sheet, get the layer for a specific sprite // From an array of a horizontal sprite sheet, get the layer for a specific sprite
const layer = []; const layer = [];
for (let y = 0; y < width; y++) { for (let y = 0; y < width; y++) {

43
src/sound.js Normal file
View 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]);
}
}

View File

@@ -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",
}),
};

View File

@@ -1,9 +1,9 @@
import { import {
getContext,
makeElement, makeElement,
makeDraggable, makeDraggable,
makeClosable makeClosable
} from './shared.js'; } from './shared.js';
import { getContext } from './context.js';
/** /**
* @typedef {Object} SavedStickyNote * @typedef {Object} SavedStickyNote

View File

@@ -41,6 +41,22 @@
z-index: 2147483630 !important; z-index: 2147483630 !important;
} }
.birb-item {
image-rendering: pixelated;
position: absolute;
bottom: 0;
transform: scale(calc(var(--birb-scale) * 1.5)) !important;
transform-origin: bottom;
transition-duration: 0.15s;
z-index: 2147483630 !important;
cursor: pointer;
}
.birb-item:hover {
transform: scale(calc(var(--birb-scale) * 1.9)) !important;
transition-duration: 0.15s;
}
.birb-window { .birb-window {
font-family: "Monocraft", monospace !important; font-family: "Monocraft", monospace !important;
line-height: initial !important; line-height: initial !important;
@@ -198,6 +214,7 @@
.birb-menu-item { .birb-menu-item {
width: calc(100% - var(--birb-double-border-size)); width: calc(100% - var(--birb-double-border-size));
white-space: nowrap;
font-size: 14px; font-size: 14px;
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@@ -237,13 +254,21 @@
opacity: 0.4; opacity: 0.4;
} }
#birb-field-guide { #birb-field-guide, #birb-wardrobe {
width: 322px !important; width: 322px !important;
} }
#birb-field-guide .birb-grid-content {
grid-template-rows: repeat(3, auto);
}
#birb-wardrobe .birb-grid-content {
grid-template-columns: repeat(4, auto);
grid-auto-flow: row;
}
.birb-grid-content { .birb-grid-content {
display: grid; display: grid;
grid-template-rows: repeat(3, auto);
grid-auto-flow: column; grid-auto-flow: column;
gap: 10px; gap: 10px;
padding-top: 8px; padding-top: 8px;
@@ -357,6 +382,13 @@
border: none !important; 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 { .birb-sticky-note-input:focus {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;