44 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
30 changed files with 4724 additions and 1468 deletions

View File

@@ -95,6 +95,7 @@ If you are running Pocket bird on a browser, the extension needs these permissio
Here are some websites where you can find Pocket Bird hopping around:
- [https://grepjason.sh](https://grepjason.sh)
- [https://binarydigit.dev](https://binarydigit.dev)
*If you've added Pocket Bird to your website, let me know and I'll add it to this list!*

Binary file not shown.

Binary file not shown.

BIN
aseprite/hats.aseprite Normal file

Binary file not shown.

View File

@@ -46,6 +46,10 @@ const spriteSheets = [
{
key: "__FEATHER_SPRITE_SHEET__",
path: SPRITES_DIR + "/feather.png"
},
{
key: "__HATS_SPRITE_SHEET__",
path: SPRITES_DIR + "/hats.png"
}
];

BIN
dist/extension.zip vendored

Binary file not shown.

987
dist/extension/birb.js vendored

File diff suppressed because it is too large Load Diff

View File

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

989
dist/obsidian/main.js vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"id": "pocket-bird",
"name": "Pocket Bird",
"version": "2026.1.1",
"version": "2026.1.24",
"minAppVersion": "0.15.0",
"description": "Add a pet bird to fly around your notes and keep you company!",
"author": "Idrees Hassan",

File diff suppressed because it is too large Load Diff

987
dist/web/birb.embed.js vendored

File diff suppressed because it is too large Load Diff

987
dist/web/birb.js vendored

File diff suppressed because it is too large Load Diff

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

View File

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

View File

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

209
src/animation/sprites.js Normal file
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,7 +1,8 @@
import Frame from './frame.js';
import Layer from './layer.js';
import Anim from './anim.js';
import Frame from './animation/frame.js';
import Layer, { TAG } from './animation/layer.js';
import Anim from './animation/anim.js';
import { Birb, Animations } from './birb.js';
import { Birdsong } from './sound.js';
import { Context, ObsidianContext } from './context.js';
import {
@@ -18,14 +19,14 @@ import {
log,
debug,
error,
getLayer,
getLayerPixels,
getWindowHeight
} from './shared.js';
import {
Sprite,
PALETTE,
SPRITE_SHEET_COLOR_MAP,
SPECIES
} from './sprites.js';
} from './animation/sprites.js';
import {
StickyNote,
createNewStickyNote,
@@ -42,6 +43,7 @@ import {
switchMenuItems,
MENU_EXIT_ID
} from './menu.js';
import { HAT, HAT_METADATA, createHatItemAnimation } from './hats.js';
/**
@@ -52,6 +54,8 @@ import {
* @typedef {Object} BirbSaveData
* @property {string[]} unlockedSpecies
* @property {string} currentSpecies
* @property {string[]} unlockedHats
* @property {string} currentHat
* @property {Partial<Settings>} settings
* @property {SavedStickyNote[]} [stickyNotes]
*/
@@ -60,7 +64,8 @@ import {
* @typedef {typeof DEFAULT_SETTINGS} Settings
*/
const DEFAULT_SETTINGS = {
birbMode: false
birbMode: false,
soundEnabled: true
};
// Rendering constants
@@ -76,12 +81,16 @@ const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * BIRB_CSS_SCALE;
const STYLESHEET = `___STYLESHEET___`;
const SPRITE_SHEET = "__SPRITE_SHEET__";
const FEATHER_SPRITE_SHEET = "__FEATHER_SPRITE_SHEET__";
const HATS_SPRITE_SHEET = "__HATS_SPRITE_SHEET__";
// Element IDs
const FIELD_GUIDE_ID = "birb-field-guide";
const FEATHER_ID = "birb-feather";
const WARDROBE_ID = "birb-wardrobe";
const HAT_ID = "birb-hat";
const DEFAULT_BIRD = "bluebird";
const DEFAULT_HAT = HAT.NONE;
// Birb movement
const HOP_SPEED = 0.07;
@@ -90,8 +99,8 @@ const HOP_DISTANCE = 35;
// Timing constants (in milliseconds)
const UPDATE_INTERVAL = 1000 / 60; // 60 FPS
const AFK_TIME = isDebug() ? 0 : 1000 * 5;
const PET_BOOST_DURATION = 1000 * 60 * 5;
const AFK_TIME = isDebug() ? 0 : 1000 * 5; // 5 seconds
const SUPER_AFK_TIME = 1000 * 60 * 60; // 1 hour
const PET_MENU_COOLDOWN = 1000;
const URL_CHECK_INTERVAL = 150;
const HOP_DELAY = 500;
@@ -100,10 +109,15 @@ const HOP_DELAY = 500;
const HOP_CHANCE = 1 / (60 * 2.5); // Every 2.5 seconds
const FOCUS_SWITCH_CHANCE = 1 / (60 * 20); // Every 20 seconds
const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // Every 2 hours
const HAT_CHANCE = 1 / (60 * 60 * 10); // Every 10 minutes
// Feathers
const FEATHER_FALL_SPEED = 1;
// Petting boosts
const PET_BOOST_DURATION = 1000 * 60 * 5; // 5 minutes
const PET_FEATHER_BOOST = 2;
const PET_HAT_BOOST = 1.5;
// Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100;
@@ -121,20 +135,23 @@ export async function initializeApplication(context) {
log("Loading sprite sheets...");
const birbPixels = await loadSpriteSheetPixels(SPRITE_SHEET);
const featherPixels = await loadSpriteSheetPixels(FEATHER_SPRITE_SHEET);
startApplication(birbPixels, featherPixels);
const hatsPixels = await loadSpriteSheetPixels(HATS_SPRITE_SHEET);
startApplication(birbPixels, featherPixels, hatsPixels);
}
/**
* @param {string[][]} birbPixels
* @param {string[][]} featherPixels
* @param {string[][]} hatsPixels
*/
function startApplication(birbPixels, featherPixels) {
function startApplication(birbPixels, featherPixels, hatsPixels) {
const SPRITE_SHEET = birbPixels;
const FEATHER_SPRITE_SHEET = featherPixels;
const HATS_SPRITE_SHEET = hatsPixels;
const featherLayers = {
feather: new Layer(getLayer(FEATHER_SPRITE_SHEET, 0, FEATHER_SPRITE_WIDTH)),
feather: new Layer(getLayerPixels(FEATHER_SPRITE_SHEET, 0, FEATHER_SPRITE_WIDTH)),
};
const featherFrames = {
@@ -152,6 +169,7 @@ function startApplication(birbPixels, featherPixels) {
const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet),
new MenuItem("Field Guide", insertFieldGuide),
new MenuItem("Wardrobe", insertWardrobe),
new ConditionalMenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote), () => getContext().areStickyNotesEnabled()),
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
new DebugMenuItem("Freeze/Unfreeze", () => {
@@ -162,6 +180,9 @@ function startApplication(birbPixels, featherPixels) {
for (let type in SPECIES) {
unlockBird(type);
}
for (let hat in HAT) {
unlockHat(HAT[hat]);
}
}),
new DebugMenuItem("Add Feather", () => {
activateFeather();
@@ -176,12 +197,16 @@ function startApplication(birbPixels, featherPixels) {
const settingsItems = [
new MenuItem("Go Back", () => switchMenuItems(menuItems, updateMenuLocation), false),
new Separator(),
new MenuItem("Toggle Birb Mode", () => {
userSettings.birbMode = !userSettings.birbMode;
new MenuItem(() => `${settings().soundEnabled ? "Disable" : "Enable"} Sound`, () => {
userSettings.soundEnabled = !settings().soundEnabled;
save();
}),
new MenuItem(() => `Toggle ${birdBirb(true)} Mode`, () => {
userSettings.birbMode = !settings().birbMode;
save();
const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode(`Your ${birdBirb().toLowerCase()} shall now be referred to as "${birdBirb()}"`));
if (userSettings.birbMode) {
if (settings().birbMode) {
message.appendChild(document.createElement("br"));
message.appendChild(document.createElement("br"));
message.appendChild(document.createTextNode("Welcome back to 2012"));
@@ -189,6 +214,7 @@ function startApplication(birbPixels, featherPixels) {
insertModal(`${birdBirb()} Mode`, message);
}),
new Separator(),
new MenuItem(() => `Source Code ${isPetBoostActive() ? " ❤" : ""}`, () => { window.open("https://github.com/IdreesInc/Pocket-Bird"); }),
new MenuItem("__VERSION__", () => { alert("Thank you for using Pocket Bird! You are on version: __VERSION__") }, false),
];
@@ -203,6 +229,8 @@ function startApplication(birbPixels, featherPixels) {
FLYING: "flying",
};
const birdsong = new Birdsong();
let frozen = false;
let stateStart = Date.now();
let currentState = States.IDLE;
@@ -224,6 +252,8 @@ function startApplication(birbPixels, featherPixels) {
let petStack = [];
let currentSpecies = DEFAULT_BIRD;
let unlockedSpecies = [DEFAULT_BIRD];
let unlockedHats = [DEFAULT_HAT];
let currentHat = DEFAULT_HAT;
// let visible = true;
let lastPetTimestamp = 0;
/** @type {StickyNote[]} */
@@ -242,6 +272,8 @@ function startApplication(birbPixels, featherPixels) {
userSettings = saveData.settings ?? {};
unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD];
currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD;
unlockedHats = saveData.unlockedHats ?? [DEFAULT_HAT];
currentHat = saveData.currentHat ?? DEFAULT_HAT;
stickyNotes = [];
if (saveData.stickyNotes) {
@@ -254,13 +286,16 @@ function startApplication(birbPixels, featherPixels) {
log(stickyNotes.length + " sticky notes loaded");
switchSpecies(currentSpecies);
switchHat(currentHat);
}
function save() {
/** @type {BirbSaveData} */
const saveData = {
unlockedSpecies,
currentSpecies,
unlockedSpecies: unlockedSpecies,
currentSpecies: currentSpecies,
unlockedHats: unlockedHats,
currentHat: currentHat,
settings: userSettings
};
@@ -293,8 +328,8 @@ function startApplication(birbPixels, featherPixels) {
/**
* Bird or birb, you decide
*/
function birdBirb() {
return settings().birbMode ? "Birb" : "Bird";
function birdBirb(invert = false) {
return settings().birbMode !== invert ? "Birb" : "Bird";
}
function init() {
@@ -313,7 +348,7 @@ function startApplication(birbPixels, featherPixels) {
styleElement.textContent = STYLESHEET;
document.head.appendChild(styleElement);
birb = new Birb(BIRB_CSS_SCALE, CANVAS_PIXEL_SIZE, SPRITE_SHEET, SPRITE_WIDTH, SPRITE_HEIGHT);
birb = new Birb(BIRB_CSS_SCALE, CANVAS_PIXEL_SIZE, SPRITE_SHEET, SPRITE_WIDTH, SPRITE_HEIGHT, HATS_SPRITE_SHEET);
birb.setAnimation(Animations.BOB);
window.addEventListener("scroll", () => {
@@ -334,6 +369,7 @@ function startApplication(birbPixels, featherPixels) {
// Currently being pet, don't open menu
return;
}
insertMenu(menuItems, `${birdBirb().toLowerCase()}OS`, updateMenuLocation);
});
@@ -363,7 +399,7 @@ function startApplication(birbPixels, featherPixels) {
setInterval(() => {
const currentPath = getContext().getPath().split("?")[0];
if (currentPath !== lastPath) {
log("Path changed, updating sticky notes: " + currentPath);
log("Path changed from '" + lastPath + "' to '" + currentPath + "'");
lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote);
}
@@ -404,12 +440,17 @@ function startApplication(birbPixels, featherPixels) {
}
}
// Double the chance of a feather if recently pet
const petMod = Date.now() - lastPetTimestamp < PET_BOOST_DURATION ? PET_FEATHER_BOOST : 1;
if (birb.isVisible() && Math.random() < FEATHER_CHANCE * petMod) {
lastPetTimestamp = 0;
activateFeather();
if (birb.isVisible() && Date.now() - lastActionTimestamp < SUPER_AFK_TIME) {
if (Math.random() < FEATHER_CHANCE * (isPetBoostActive() ? PET_FEATHER_BOOST : 1)) {
lastPetTimestamp = 0;
activateFeather();
}
if (Math.random() < (HAT_CHANCE * (isPetBoostActive() ? PET_HAT_BOOST : 1))) {
lastPetTimestamp = 0;
insertHat();
}
}
updateFeather();
}
@@ -444,7 +485,7 @@ function startApplication(birbPixels, featherPixels) {
flySomewhere();
}
if (birb.draw(SPECIES[currentSpecies])) {
if (birb.draw(SPECIES[currentSpecies], currentHat)) {
birb.setAnimation(Animations.STILL);
}
@@ -536,7 +577,7 @@ function startApplication(birbPixels, featherPixels) {
if (!featherCtx) {
return;
}
FEATHER_ANIMATIONS.feather.draw(featherCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, type);
FEATHER_ANIMATIONS.feather.draw(featherCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, type.colors, type.tags);
document.body.appendChild(featherCanvas);
onClick(featherCanvas, () => {
unlockBird(birdType);
@@ -555,12 +596,62 @@ function startApplication(birbPixels, featherPixels) {
}
}
/**
* Insert the hat as an item element in the document if possible
*/
function insertHat() {
if (document.querySelector("#" + HAT_ID)) {
return;
}
// Select a random hat that hasn't been unlocked yet
const availableHats = Object.values(HAT)
.filter(hat => hat !== HAT.NONE && !unlockedHats.includes(hat));
if (availableHats.length === 0) {
return;
}
const hatId = availableHats[Math.floor(Math.random() * availableHats.length)];
// Find a random valid element to place the hat on
const element = getRandomValidElement();
if (!element) {
return;
}
// Create hat element
const hatCanvas = document.createElement("canvas");
hatCanvas.id = HAT_ID;
hatCanvas.classList.add("birb-item");
hatCanvas.width = 14 * CANVAS_PIXEL_SIZE;
hatCanvas.height = 14 * CANVAS_PIXEL_SIZE;
const hatCtx = hatCanvas.getContext("2d");
if (!hatCtx) {
return;
}
onClick(hatCanvas, () => {
unlockHat(hatId);
hatCanvas.remove();
});
// Create hat animation
const hatAnimation = createHatItemAnimation(hatId, HATS_SPRITE_SHEET);
hatAnimation.draw(hatCtx, Directions.LEFT, Date.now(), CANVAS_PIXEL_SIZE, SPECIES[currentSpecies].colors, [TAG.DEFAULT]);
// Position hat above the element
const rect = element.getBoundingClientRect();
hatCanvas.style.left = (rect.left + rect.width / 2 - hatCanvas.width / 2) + "px";
hatCanvas.style.top = (rect.top - hatCanvas.height + window.scrollY) + "px";
// Append to document
document.body.appendChild(hatCanvas);
}
/**
* @param {string} birdType
*/
function unlockBird(birdType) {
if (!unlockedSpecies.includes(birdType)) {
unlockedSpecies.push(birdType);
save();
const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode("You've found a "));
const bold = document.createElement("b");
@@ -569,7 +660,24 @@ function startApplication(birbPixels, featherPixels) {
message.appendChild(document.createTextNode(" feather! Use the Field Guide to switch your bird's species."));
insertModal("New Bird Unlocked!", message);
}
save();
}
/**
* @param {string} hatId
*/
function unlockHat(hatId) {
if (!unlockedHats.includes(hatId)) {
unlockedHats.push(hatId);
save();
switchHat(hatId);
const message = makeElement("birb-message-content");
message.appendChild(document.createTextNode("You've unlocked the "));
const bold = document.createElement("b");
bold.textContent = HAT_METADATA[hatId].name;
message.appendChild(bold);
message.appendChild(document.createTextNode("! To see all of your unlocked accessories, click the Wardrobe from the menu."));
insertModal("New Hat Found!", message);
}
}
function updateFeather() {
@@ -636,6 +744,8 @@ function startApplication(birbPixels, featherPixels) {
if (document.querySelector("#" + FIELD_GUIDE_ID)) {
return;
}
// Remove wardrobe if open
removeWardrobe();
const contentContainer = document.createElement("div");
const content = makeElement("birb-grid-content");
@@ -683,7 +793,7 @@ function startApplication(birbPixels, featherPixels) {
if (!speciesCtx) {
return;
}
birb.getFrames().base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type);
birb.getFrames().base.draw(speciesCtx, Directions.RIGHT, CANVAS_PIXEL_SIZE, type.colors, type.tags);
speciesElement.appendChild(speciesCanvas);
content.appendChild(speciesElement);
if (unlocked) {
@@ -716,13 +826,114 @@ function startApplication(birbPixels, featherPixels) {
}
}
function insertWardrobe() {
console.log("Inserting wardrobe");
if (document.querySelector("#" + WARDROBE_ID)) {
return;
}
// Remove field guide if open
removeFieldGuide();
const contentContainer = document.createElement("div");
const content = makeElement("birb-grid-content");
const description = makeElement("birb-field-guide-description");
contentContainer.appendChild(content);
contentContainer.appendChild(description);
const wardrobe = createWindow(
WARDROBE_ID,
"Wardrobe",
contentContainer
);
const generateDescription = (/** @type {string} */ hat) => {
const metadata = HAT_METADATA[hat] ?? { name: "Unknown Hat", description: "todo" };
const unlocked = unlockedHats.includes(hat);
const boldName = document.createElement("b");
boldName.textContent = metadata.name;
const spacer = document.createElement("div");
spacer.style.height = "0.3em";
const descText = document.createTextNode(!unlocked ? "Not yet unlocked" : metadata.description);
const fragment = document.createDocumentFragment();
fragment.appendChild(boldName);
fragment.appendChild(spacer);
fragment.appendChild(descText);
return fragment;
};
description.appendChild(generateDescription(currentHat));
for (const hat of Object.values(HAT)) {
const unlocked = unlockedHats.includes(hat);
const hatElement = makeElement("birb-grid-item");
if (hat === currentHat) {
hatElement.classList.add("birb-grid-item-selected");
}
const hatCanvas = document.createElement("canvas");
hatCanvas.width = SPRITE_WIDTH * CANVAS_PIXEL_SIZE;
hatCanvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE;
const hatCtx = hatCanvas.getContext("2d");
if (!hatCtx) {
return;
}
birb.getFrames().base.draw(
hatCtx,
Directions.RIGHT,
CANVAS_PIXEL_SIZE,
SPECIES[currentSpecies].colors,
[...SPECIES[currentSpecies].tags, hat]
);
hatElement.appendChild(hatCanvas);
content.appendChild(hatElement);
if (unlocked) {
onClick(hatElement, () => {
switchHat(hat);
document.querySelectorAll(".birb-grid-item").forEach((element) => {
element.classList.remove("birb-grid-item-selected");
});
hatElement.classList.add("birb-grid-item-selected");
});
} else {
hatElement.classList.add("birb-grid-item-locked");
}
hatElement.addEventListener("mouseover", () => {
description.textContent = "";
description.appendChild(generateDescription(hat));
});
hatElement.addEventListener("mouseout", () => {
description.textContent = "";
description.appendChild(generateDescription(currentHat));
});
}
centerElement(wardrobe);
}
function removeWardrobe() {
const wardrobe = document.querySelector("#" + WARDROBE_ID);
if (wardrobe) {
wardrobe.remove();
}
}
/**
* @param {string} type
*/
function switchSpecies(type) {
currentSpecies = type;
// Update CSS variable --birb-highlight to be wing color
document.documentElement.style.setProperty("--birb-highlight", SPECIES[type].colors[Sprite.THEME_HIGHLIGHT]);
document.documentElement.style.setProperty("--birb-highlight", SPECIES[type].colors[PALETTE.THEME_HIGHLIGHT]);
save();
}
/**
* @param {string} hat
*/
function switchHat(hat) {
currentHat = hat;
save();
}
@@ -787,14 +998,9 @@ function startApplication(birbPixels, featherPixels) {
}
/**
* Focus on an element within the viewport
* @param {boolean} [teleport] Whether to teleport to the element instead of flying
* @returns Whether an element to focus on was found
* @returns {HTMLElement|null} The random element, or null if no valid element was found
*/
function focusOnElement(teleport = false) {
if (frozen) {
return false;
}
function getRandomValidElement() {
const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin();
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
const inWindow = Array.from(elements).filter((img) => {
@@ -822,10 +1028,22 @@ function startApplication(birbPixels, featherPixels) {
return style.position !== "fixed" && style.position !== "sticky";
});
if (nonFixedElements.length === 0) {
return false;
return null;
}
const randomElement = nonFixedElements[Math.floor(Math.random() * nonFixedElements.length)];
focusedElement = randomElement;
return randomElement;
}
/**
* Focus on an element within the viewport
* @param {boolean} [teleport] Whether to teleport to the element instead of flying
* @returns Whether an element to focus on was found
*/
function focusOnElement(teleport = false) {
if (frozen) {
return false;
}
focusedElement = getRandomValidElement();
log("Focusing on element: ", focusedElement);
updateFocusedElementBounds();
if (teleport) {
@@ -833,7 +1051,7 @@ function startApplication(birbPixels, featherPixels) {
} else {
flyTo(getFocusedElementRandomX(), getFocusedY());
}
return randomElement !== null;
return focusedElement !== null;
}
/**
@@ -897,11 +1115,18 @@ function startApplication(birbPixels, featherPixels) {
function pet() {
if (currentState === States.IDLE && birb.getCurrentAnimation() !== Animations.HEART) {
if (settings().soundEnabled) {
birdsong.chirp();
}
birb.setAnimation(Animations.HEART);
lastPetTimestamp = Date.now();
}
}
function isPetBoostActive() {
return Date.now() - lastPetTimestamp < PET_BOOST_DURATION;
}
/**
* @param {number} x
* @param {number} y
@@ -1001,7 +1226,7 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) {
const b = pixels[index + 2];
const a = pixels[index + 3];
if (a === 0) {
row.push(Sprite.TRANSPARENT);
row.push(PALETTE.TRANSPARENT);
continue;
}
const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
@@ -1010,8 +1235,9 @@ function loadSpriteSheetPixels(dataUri, templateColors = true) {
continue;
}
if (SPRITE_SHEET_COLOR_MAP[hex] === undefined) {
error(`Unknown color: ${hex}`);
row.push(Sprite.TRANSPARENT);
// Return the color as-is if not found in the map
row.push(hex);
continue;
}
row.push(SPRITE_SHEET_COLOR_MAP[hex]);
}

View File

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

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

View File

@@ -193,7 +193,7 @@ export function error() {
* @param {number} width The width of each sprite
* @returns {string[][]}
*/
export function getLayer(spriteSheet, spriteIndex, width) {
export function getLayerPixels(spriteSheet, spriteIndex, width) {
// From an array of a horizontal sprite sheet, get the layer for a specific sprite
const layer = [];
for (let y = 0; y < width; y++) {

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

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