diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07e6e47 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules diff --git a/aseprite/birb.aseprite b/aseprite/birb.aseprite new file mode 100644 index 0000000..5a4c923 Binary files /dev/null and b/aseprite/birb.aseprite differ diff --git a/aseprite/decorations.aseprite b/aseprite/decorations.aseprite new file mode 100644 index 0000000..df89c80 Binary files /dev/null and b/aseprite/decorations.aseprite differ diff --git a/aseprite/feather.aseprite b/aseprite/feather.aseprite new file mode 100644 index 0000000..ebc6a6b Binary files /dev/null and b/aseprite/feather.aseprite differ diff --git a/birb.js b/birb.js index ab980e2..81a33d9 100644 --- a/birb.js +++ b/birb.js @@ -12,6 +12,7 @@ // @ts-check +// @ts-ignore const sharedSettings = { cssScale: 1, canvasPixelSize: 1, @@ -30,7 +31,7 @@ let mobileSettings = { const settings = { ...sharedSettings, ...isMobile() ? mobileSettings : desktopSettings }; -const DEBUG = false; +const DEBUG = true; const CSS_SCALE = settings.cssScale; const CANVAS_PIXEL_SIZE = settings.canvasPixelSize; @@ -234,7 +235,7 @@ const styles = ` font-size: 14px; padding-top: 4px; padding-bottom: 4px; - opacity: 0.8 !important; + opacity: 0.7 !important; user-select: none; display: flex; justify-content: space-between; @@ -660,9 +661,10 @@ const Directions = { const SPRITE_WIDTH = 32; const DECORATIONS_SPRITE_WIDTH = 48; const FEATHER_SPRITE_WIDTH = 32; -const SPRITE_SHEET_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAgCAYAAABjE6FEAAAAAXNSR0IArs4c6QAABGBJREFUeJztnL9rE2EYx79vdAiKWxB6cZU4VXDpH9Di4JJk6iSCDgqFDoKl9A8o0kGwIFh0UadOsQid7NTFOnRzCF2bViTg0Apd7OOQvNc3l/uRxty97yXfD4S8uUvueZJ730+e93IXgBBCCCGEEEIImQiU7QSI+4iIRK1TSrEPkdzCzpsDbApIx66XywCARqvV084iB0LIhCJdap4nNc/ra8fJcVTxa54n36anRRYW+tppxyckTQq2EyDJ1MtlLJdKaNTrfe0s2dvdxXKp5LcJyTsUYI6wJaBGq4WX7XbPspfttj8FJoSQVDCnwN+mp/1bVlPgsByyjE0IcQAJIevYNgVk5kD5ETJhiEjn4L9xn3V82wKyIX9C0uSq7QTyxt7du1biKqWUiIjNU09sn+4SFK/tfAiZGHTlo6s/VkHZYnzmoh/ayoH7f8IIO/41iR1gkt+7I8hGq6YlmG1gBwRMRk/iaTB6Ryulem7murRxRcDKIOvYBNho1XTTyvRfKeXn4HIfsD1O8kTsMUBTfkbng14mIuZzUukQZg6B5RARcbkjktHytPzZ6r62KeAkgqIzihSOkxgiBWiKpzn/BHgOrM2999dvtOqpf8guCJgQjW0BJ2EOgfm1I3/ZuBWBU1eUHP+VkeyL2AqwcN2DujaFO1+2AQCP54DtZwU8eHuO79sNzK8dA0hHRi4ImJCcIEopX3rjzNQVJQ/enmP7WWEkEozcgCmgJ+/Cv0FOfx/77c0lT78uOtglBCUiUrju9Sx7/PrIF7AZf3PJ64lLEY4/82tHsrnkcT8DEiW+g8MT7K9XICIcExFEVoD6vLOo9ab8gN6SO4xhqrTzP0d9AtbyC8YOVoNh22MnyCdnW7NSrO4o3QaAYtWu/BwRcKL8wG4fy0AnQr+5OYeFX1/9NgA8+v0p9LnBHXJweALg8sciXBAwcYNidUddiG8n8/3nqoBdjN+dCeZmjMUm2hUGzrZm+9YVqzu4t9jE7Vs3Ql+rxdf9FvLld9lpsI7fJ+BmuIDj8uBUgAyLTQGHESWgwLhLM9fI6jNPEhz6UrizrVkUqxVgsRm6Pig+4P9qcS0+zeaSN5SACRkGV8Sn+VB52FMEBPs7MhDQweFJ5PjLC4kVINCZVgarwNMfpwCA0spe1GsvggwpvrgKFLioQsMYtYAJcY326owsXG0AuPgREtlVXhJXgOSlCkxMMExCWn5hlFb2hpruRsXubseKgAlxnfbqjN/R7//8CADYX69Yl6B5ZobL428gAQIdCbVXZ2KfO0r5mfFtCZgQkojoWVhQhFqCLo/DxGOA+tfY7huJlGCa4tGxAcRKmPIjJHPU/nqlM+giRDgWGBdWS+PVC/9fMczHaV14HbiwOzY+L/4mxBqCTkUo9xab4/OvOWHyCXuctgBtxSeEDIYelxinMRmUTdR9mgK0GZ8QMhgSwHY+SQx8HqA+Dhd3nya24xNCksnbsfdLXZWRuLEU37zt+ISQ8eMfWq9d4TT6RMoAAAAASUVORK5CYII="; -const DECORATIONS_SPRITE_SHEET_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAPNJREFUaIHtmTESgzAMBHWZDC+gp0vP/x9Bn44+L6BRmrhJA4csM05uGzfY1s1JxggzIYQQQgghxEnATnB3zwikAICKiXq4BE/uwaxvn/UPb3BnNwFg27Ky0w6vzRp8S4mkIbQD3wzzFJofdTMkYJgn89czFADGKSSiSgphfFBjTaoIKC4cHWvSxIFMmjiQSYoDLUlxoCVywOwHHWjpROop1IL/vsxty2oYO77M1QggSvcpJAFXE66BPfa+2C4v4j2yi7z7FJKAq6FrwN3TO3MMlAAAKO3F2sVZTiu2N9p9CnUv4FR7PbMG2BQ69SJL/kVA8QauAnHUj36BVwAAAABJRU5ErkJggg=="; -const FEATHER_SPRITE_SHEET_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAARhJREFUWIXtlbENwjAQRf8hSiZIRQ+9WQNRUFIAKzACBSsAA1Ag1mAABqCCBomG3hQQ9OMEx4ZDNH5SikSJ3/fZ5wCJRCKRSPwZ0RzMWmtLAhGvQyUAi9mXP/aFaGjJRQQiguHihMvcFMJUVUYlAMuHixPGy4en1WmVQqgHYHkuZjiEj6a2/LjtYzTY0eiZbgC37Mxh1UN3sn/dr6cCz/LHB/DJj9s+2oMdbtdz6TtfFwQHcMvOInfmQNjsgchNWLXmdfK6gyioAu/6uKrsm1kWLAciKuCuey5nYuXAh234bdmZ6INIUw4E/Ix49xtjCmXfzLL8nY/ktdgnAKwxxgIoXIyqmAOwvIqfiN0ALNd21HYBO9XXGMAdnZTYyHWzWjQAAAAASUVORK5CYII="; + +const SPRITE_SHEET_URI = "__SPRITE_SHEET__"; +const DECORATIONS_SPRITE_SHEET_URI = "__DECORATIONS_SPRITE_SHEET__"; +const FEATHER_SPRITE_SHEET_URI = "__FEATHER_SPRITE_SHEET__"; /** * Load the spritesheet and return the pixelmap template @@ -783,7 +785,7 @@ Promise.all([loadSpritesheetPixels(SPRITE_SHEET_URI), loadSpritesheetPixels(DECO 30, 80, 30, - 80, + 60, ]), HEART: new Anim([ birbFrames.heartOne, diff --git a/build.js b/build.js new file mode 100644 index 0000000..87a8194 --- /dev/null +++ b/build.js @@ -0,0 +1,27 @@ +// @ts-check + +import { readFileSync, writeFileSync } from 'fs'; + +const spriteSheets = [ + { + key: "__SPRITE_SHEET__", + path: "./sprites/birb.png" + }, + { + key: "__FEATHER_SPRITE_SHEET__", + path: "./sprites/feather.png" + }, + { + key: "__DECORATIONS_SPRITE_SHEET__", + path: "./sprites/decorations.png" + } +]; + +let birbJs = readFileSync('birb.js', 'utf8'); + +for (const spriteSheet of spriteSheets) { + const dataUri = readFileSync(spriteSheet.path, 'base64'); + birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`); +} + +writeFileSync('./dist/birb.js', birbJs); \ No newline at end of file diff --git a/dist/birb.js b/dist/birb.js new file mode 100644 index 0000000..12d8e99 --- /dev/null +++ b/dist/birb.js @@ -0,0 +1,1855 @@ +// ==UserScript== +// @name birb +// @namespace https://idreesinc.com +// @version 2025-01-09 +// @description birb +// @author Idrees +// @match *://*/* +// @grant GM_setValue +// @grant GM_getValue +// @grant GM_deleteValue +// ==/UserScript== + +// @ts-check + +// @ts-ignore +const sharedSettings = { + cssScale: 1, + canvasPixelSize: 1, + hopSpeed: 0.07, + hopDistance: 45, +}; + + +let desktopSettings = { + flySpeed: 0.2, +}; + +let mobileSettings = { + flySpeed: 0.125, +}; + +const settings = { ...sharedSettings, ...isMobile() ? mobileSettings : desktopSettings }; + +const DEBUG = true; + +const CSS_SCALE = settings.cssScale; +const CANVAS_PIXEL_SIZE = settings.canvasPixelSize; +const WINDOW_PIXEL_SIZE = CANVAS_PIXEL_SIZE * CSS_SCALE; +const HOP_SPEED = settings.hopSpeed; +const FLY_SPEED = settings.flySpeed; +const HOP_DISTANCE = settings.hopDistance; +// Time in milliseconds until the user is considered AFK +const AFK_TIME = DEBUG ? 0 : 1000 * 30; +const SPRITE_HEIGHT = 32; +const MENU_ID = "birb-menu"; +const MENU_EXIT_ID = "birb-menu-exit"; +const FIELD_GUIDE_ID = "birb-field-guide"; +const FEATHER_ID = "birb-feather"; + +const styles = ` + @font-face { + font-family: Monocraft; + src: url("https://cdn.jsdelivr.net/gh/idreesinc/Monocraft/dist/Monocraft.otf"); + } + + :root { + --border-size: 2px; + --neg-border-size: calc(var(--border-size) * -1); + --double-border-size: calc(var(--border-size) * 2); + --neg-double-border-size: calc(var(--neg-border-size) * 2); + --border-color: #000000; + --highlight: #ffa3cb; + } + + #birb { + image-rendering: pixelated; + position: fixed; + bottom: 0; + transform: scale(${CSS_SCALE}); + transform-origin: bottom; + z-index: 2147483638 !important; + cursor: pointer; + } + + .birb-decoration { + image-rendering: pixelated; + position: fixed; + bottom: 0; + transform: scale(${CSS_SCALE}); + transform-origin: bottom; + z-index: 2147483630 !important; + } + + .birb-window { + font-family: "Monocraft", monospace !important; + line-height: initial !important; + color: #000000 !important; + z-index: 2147483639 !important; + position: fixed; + background-color: #ffecda; + box-shadow: + var(--border-size) 0 var(--border-color), + var(--neg-border-size) 0 var(--border-color), + 0 var(--neg-border-size) var(--border-color), + 0 var(--border-size) var(--border-color), + var(--double-border-size) 0 var(--border-color), + var(--neg-double-border-size) 0 var(--border-color), + 0 var(--neg-double-border-size) var(--border-color), + 0 var(--double-border-size) var(--border-color), + 0 0 0 var(--border-size) var(--border-color), + 0 0 0 var(--double-border-size) white, + var(--double-border-size) 0 0 var(--border-size) white, + var(--neg-double-border-size) 0 0 var(--border-size) white, + 0 var(--neg-double-border-size) 0 var(--border-size) white, + 0 var(--double-border-size) 0 var(--border-size) white; + box-sizing: border-box; + display: flex; + flex-direction: column; + animation: pop-in 0.08s; + transition-timing-function: ease-in; + } + + #${MENU_ID} { + transition-duration: 0.2s; + transition-timing-function: ease-out; + min-width: 140px; + z-index: 2147483639 !important; + } + + #${MENU_EXIT_ID} { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 2147483637 !important; + } + + @keyframes pop-in { + 0% { opacity: 1; transform: scale(0.1); } + 100% { opacity: 1; transform: scale(1); } + } + + .birb-window-header { + box-sizing: border-box; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 7px; + padding-top: 3px; + padding-bottom: 3px; + padding-left: 30px; + padding-right: 30px; + background-color: var(--highlight); + box-shadow: + var(--border-size) 0 var(--highlight), + var(--neg-border-size) 0 var(--highlight), + 0 var(--neg-border-size) var(--highlight), + var(--neg-border-size) var(--border-size) var(--border-color), + var(--border-size) var(--border-size) var(--border-color); + color: var(--border-color) !important; + font-size: 16px; + } + + .birb-window-title { + text-align: center; + flex-grow: 1; + user-select: none; + color: #ffecda; + } + + .birb-window-close { + position: absolute; + top: 1px; + right: 0; + color: #ffecda; + user-select: none; + cursor: pointer; + padding-left: 5px; + padding-right: 5px; + } + + .birb-window-close:hover { + transform: scale(1.1); + } + + .birb-window-content { + box-sizing: border-box; + background-color: #ffecda; + margin-top: var(--border-size); + width: 100%; + flex-grow: 1; + box-shadow: + var(--border-size) 0 #ffecda, + var(--neg-border-size) 0 #ffecda, + 0 var(--border-size) #ffecda, + 0 var(--neg-border-size) var(--border-color), + 0 var(--border-size) var(--border-color); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-left: 15px; + padding-right: 15px; + padding-top: 8px; + padding-bottom: 8px; + } + + .birb-pico-8-content { + background: #111111; + box-shadow: none; + display: flex; + justify-content: center; + overflow: hidden; + border: none; + } + + .birb-pico-8-content iframe { + width: 300px; + margin-left: -15px; + margin-right: -30px; + margin-top: -10px; + margin-bottom: -23px; + border:none; + aspect-ratio: 1; + } + + .birb-music-player-content { + background: #ffecda; + box-shadow: + var(--border-size) 0 #ffecda, + var(--neg-border-size) 0 #ffecda, + 0 var(--border-size) #ffecda, + 0 var(--neg-border-size) var(--border-color), + 0 var(--border-size) var(--border-color); + display: flex; + justify-content: center; + overflow: hidden; + padding: 10px; + } + + .birb-window-list-item { + width: 100%; + font-size: 14px; + padding-top: 4px; + padding-bottom: 4px; + opacity: 0.7 !important; + user-select: none; + display: flex; + justify-content: space-between; + cursor: pointer; + color: black !important; + } + + .birb-window-list-item:hover { + opacity: 1 !important; + } + + .birb-window-list-item-arrow { + display: inline-block; + } + + .birb-window-separator { + width: 100%; + height: 1.5px; + background-color: #000000; + box-sizing: border-box; + margin-top: 4px; + margin-bottom: 4px; + opacity: 0.4; + } + + #${FIELD_GUIDE_ID} { + width: 340px; + } + + .birb-grid-content { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + flex-direction: row; + padding-top: 4px; + padding-bottom: 4px; + } + + .birb-grid-item { + width: 64px; + height: 64px; + overflow: hidden; + margin-top: 6px; + margin-bottom: 6px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + + .birb-grid-item canvas { + image-rendering: pixelated; + transform: scale(2); + padding-bottom: var(--border-size); + } + + .birb-grid-item, .birb-field-guide-description, .birb-message-content { + border: var(--border-size) solid rgb(255, 207, 144); + box-shadow: 0 0 0 var(--border-size) white; + background: rgba(255, 221, 177, 0.5); + } + + .birb-grid-item-locked { + cursor: auto; + filter: grayscale(100%) sepia(30%); + } + + .birb-grid-item-locked canvas { + filter: contrast(90%); + } + + .birb-grid-item-selected { + border: var(--border-size) solid var(--highlight); + } + + .birb-field-guide-description { + box-sizing: border-box; + width: 100%; + margin-top: 10px; + padding: 8px; + padding-top: 4px; + padding-bottom: 4px; + margin-bottom: 6px; + font-size: 14px; + color: rgb(124, 108, 75); + } + + #${FEATHER_ID} { + cursor: pointer; + } + + .birb-message-content { + box-sizing: border-box; + width: 100%; + margin-top: 10px; + padding: 8px; + padding-top: 4px; + padding-bottom: 4px; + font-size: 14px; + color: rgb(124, 108, 75); + } +`; + +class Layer { + /** + * @param {string[][]} pixels + * @param {string} [tag] + */ + constructor(pixels, tag="default") { + this.pixels = pixels; + this.tag = tag; + } +} + +class Frame { + + #pixelsByTag = {}; + + /** + * @param {Layer[]} layers + */ + constructor(layers) { + /** @type {Set} */ + 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(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] !== 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 {number} direction + * @param {BirdType} [species] + */ + draw(ctx, direction, species) { + 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 * CANVAS_PIXEL_SIZE, y * CANVAS_PIXEL_SIZE, CANVAS_PIXEL_SIZE, CANVAS_PIXEL_SIZE); + }; + }; + } +} + +class Anim { + /** + * @param {Frame[]} frames + * @param {number[]} durations + * @param {boolean} loop + */ + constructor(frames, durations, loop = true) { + this.frames = frames; + this.durations = durations; + this.loop = loop; + } + + getAnimationDuration() { + return this.durations.reduce((a, b) => a + b, 0); + } + + /** + * @param {CanvasRenderingContext2D} ctx + * @param {number} direction + * @param {number} timeStart The start time of the animation in milliseconds + * @param {BirdType} [species] The species to use for the animation + * @returns {boolean} Whether the animation is complete + */ + draw(ctx, direction, timeStart, species) { + let time = Date.now() - timeStart; + const duration = this.getAnimationDuration(); + if (this.loop) { + time %= duration; + } + let totalDuration = 0; + for (let i = 0; i < this.durations.length; i++) { + totalDuration += this.durations[i]; + if (time < totalDuration) { + this.frames[i].draw(ctx, direction, species); + return false; + } + } + // Draw the last frame if the animation is complete + this.frames[this.frames.length - 1].draw(ctx, direction, species); + return true; + } +} + +const THEME_HIGHLIGHT = "theme-highlight"; +const TRANSPARENT = "transparent"; +const OUTLINE = "outline"; +const BORDER = "border"; +const FOOT = "foot"; +const BEAK = "beak"; +const EYE = "eye"; +const FACE = "face"; +const HOOD = "hood"; +const NOSE = "nose"; +const BELLY = "belly"; +const UNDERBELLY = "underbelly"; +const WING = "wing"; +const WING_EDGE = "wing-edge"; +const HEART = "heart"; +const HEART_BORDER = "heart-border"; +const HEART_SHINE = "heart-shine"; +const FEATHER_SPINE = "feather-spine"; + +const SPRITESHEET_COLOR_MAP = { + "transparent": TRANSPARENT, + "#ffffff": BORDER, + "#000000": OUTLINE, + "#010a19": BEAK, + "#190301": EYE, + "#af8e75": FOOT, + "#639bff": FACE, + "#99e550": HOOD, + "#d95763": NOSE, + "#f8b143": BELLY, + "#ec8637": UNDERBELLY, + "#578ae6": WING, + "#326ed9": WING_EDGE, + "#c82e2e": HEART, + "#501a1a": HEART_BORDER, + "#ff6b6b": HEART_SHINE, + "#373737": FEATHER_SPINE, +}; + +class BirdType { + /** + * @param {string} name + * @param {string} description + * @param {Record} colors + * @param {string[]} [tags] + */ + constructor(name, description, colors, tags=[]) { + this.name = name; + this.description = description; + const defaultColors = { + [TRANSPARENT]: "transparent", + [OUTLINE]: "#000000", + [BORDER]: "#ffffff", + [BEAK]: "#000000", + [EYE]: "#000000", + [HEART]: "#c82e2e", + [HEART_BORDER]: "#501a1a", + [HEART_SHINE]: "#ff6b6b", + [FEATHER_SPINE]: "#373737", + [HOOD]: colors.face, + [NOSE]: colors.face, + }; + this.colors = { ...defaultColors, ...colors, [THEME_HIGHLIGHT]: colors[THEME_HIGHLIGHT] ?? colors.hood ?? colors.face }; + this.tags = tags; + } +} + +const species = { + bluebird: new BirdType("Eastern Bluebird", + "Native to North American and very social, though can be timid around people.", { + [FOOT]: "#af8e75", + [FACE]: "#639bff", + [BELLY]: "#f8b143", + [UNDERBELLY]: "#ec8637", + [WING]: "#578ae6", + [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.", { + [FOOT]: "#af8e75", + [FACE]: "#ffffff", + [BELLY]: "#ebe9e8", + [UNDERBELLY]: "#ebd9d0", + [WING]: "#e3cabd", + [WING_EDGE]: "#9b8b82", + [THEME_HIGHLIGHT]: "#e3cabd", + }), + tuftedTitmouse: new BirdType("Tufted Titmouse", + "Native to the eastern United States, full of personality, and notably my wife's favorite bird.", { + [FOOT]: "#af8e75", + [FACE]: "#c7cad7", + [BELLY]: "#e4e5eb", + [UNDERBELLY]: "#d7cfcb", + [WING]: "#b1b5c5", + [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.", { + [FOOT]: "#af8e75", + [FACE]: "#ffaf34", + [HOOD]: "#aaa094", + [BELLY]: "#ffaf34", + [UNDERBELLY]: "#babec2", + [WING]: "#aaa094", + [WING_EDGE]: "#888580", + }), + redCardinal: new BirdType("Red Cardinal", + "Native to the eastern United States, this strikingly red bird is hard to miss.", { + [BEAK]: "#d93619", + [FOOT]: "#af8e75", + [FACE]: "#31353d", + [HOOD]: "#e83a1b", + [BELLY]: "#e83a1b", + [UNDERBELLY]: "#dc3719", + [WING]: "#d23215", + [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.", { + [BEAK]: "#ffaf34", + [FOOT]: "#af8e75", + [FACE]: "#fff255", + [NOSE]: "#383838", + [HOOD]: "#383838", + [BELLY]: "#fff255", + [UNDERBELLY]: "#f5ea63", + [WING]: "#e8e079", + [WING_EDGE]: "#191919", + }), + 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.", { + [FOOT]: "#af8e75", + [FACE]: "#db7c4d", + [BELLY]: "#f7e1c9", + [UNDERBELLY]: "#ebc9a3", + [WING]: "#2252a9", + [WING_EDGE]: "#1c448b", + [HOOD]: "#2252a9", + }), + mistletoebird: new BirdType("Mistletoebird", + "Native to Australia, these birds eat mainly mistletoe and in turn spread the seeds far and wide.", { + [FOOT]: "#6c6a7c", + [FACE]: "#352e6d", + [BELLY]: "#fd6833", + [UNDERBELLY]: "#e6e1d8", + [WING]: "#342b7c", + [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 plummage.", { + [BEAK]: "#f71919", + [FOOT]: "#af7575", + [FACE]: "#cb092b", + [BELLY]: "#ae1724", + [UNDERBELLY]: "#831b24", + [WING]: "#7e3030", + [WING_EDGE]: "#490f0f", + }), + scarletRobin: new BirdType("Scarlet Robin", + "Native to Australia, this striking robin can be found in Eucalyptus forests.", { + [FOOT]: "#494949", + [FACE]: "#3d3d3d", + [BELLY]: "#fc5633", + [UNDERBELLY]: "#dcdcdc", + [WING]: "#2b2b2b", + [WING_EDGE]: "#ebebeb", + }), + americanRobin: new BirdType("American Robin", + "While not a true robin, this social North American bird is so named due to its orange colouring. It seems unbothered by nearby humans.", { + [BEAK]: "#e89f30", + [FOOT]: "#9f8075", + [FACE]: "#2d2d2d", + [BELLY]: "#eb7a3a", + [UNDERBELLY]: "#eb7a3a", + [WING]: "#444444", + [WING_EDGE]: "#232323", + }), + carolinaWren: new BirdType("Carolina Wren", + "Native to the eastern United States, these little birds are known for their curious and energetic nature.", { + [FOOT]: "#af8e75", + [FACE]: "#edc7a9", + [NOSE]: "#f7eee5", + [HOOD]: "#c58a5b", + [BELLY]: "#e1b796", + [UNDERBELLY]: "#c79e7c", + [WING]: "#c58a5b", + [WING_EDGE]: "#866348", + }), +}; + +const DEFAULT_BIRD = "bluebird"; + + +const Directions = { + LEFT: -1, + RIGHT: 1, +}; + +const SPRITE_WIDTH = 32; +const DECORATIONS_SPRITE_WIDTH = 48; +const FEATHER_SPRITE_WIDTH = 32; + +const SPRITE_SHEET_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAgCAYAAABjE6FEAAAAAXNSR0IArs4c6QAABFtJREFUeJztnLFrE1Ecx78vOgSLWxB6cZU6VXDpH9Di4JJk6iSCDgqFDoJF/AOKdBAUBIsu6tQpFqGTnVysQzeH0LVpRQIOrdDF/hyad75c7t2lMXfvXfL9QMjL3SW/l9x7n/u9l7sDCCGEEEIIIYSQiUC5rgDxHxER2zqlFNsQKSxsvAXApYB07Ea1CgBotts95TzqQAiZUKRLPQikHgR95SQ5jip+PQjk6+ysyNJSXznr+IRkScl1BUg6jWoVTyoVNBuNvnKe7Hz5gieVSlgmpOhQgAXClYCa7TaedTo9y551OuEQmBBCMsEcAn+dnQ0feQ2B4+qQZ2xCiAdIDHnHdikgsw6UHyEThoicTf4bz3nHdy0gF/InJEsuuq5A0di5ccNJXKWUEhFxeeqJ69NdouJ1XR9CJgad+ejsj1lQvhi/ueiXrurA/T9hxM1/TWIDmOTv7gmy3q5rCeYb2AMBk9GTehqM3tFKqZ4HUq5QGCW+CFgZ5B2bAOvtui46Gf4rpcI6+NwGXPeTIpE4B2jKz2h80MtExNwmkwZh1iGyHCIiPjdEMloeVD863dcuBZxGzPyoXs5+koBVgKZ4Wov3gUfA2sLbcP16u5H5j+yDgAnRuBZwGmYXWFw7CJeNWxI4fUHJ4R8Zyb5IzABLUwHUpWlc/7QFALi3AGw9LOH261N822pice0QyEhGPgiYkIIgSqlQeuPM9AUlt1+fYuthaSQStH6AKaD7b+KPIMe/DsPyxkqg32cPdg5BiYiUpoKeZfdeHIQCNuNvrAQ9cSnC8Wdx7UA2VgLuZ0Bs4tvbP8LuyxmICPuEBWsGqM87s6035YdIyh3HMFna6e+DPgFr+UVjR7NB23caNDbxh5PNeSnXtpUuA0C55lZ+ngg4VX5s9skMdCL0qysLWPr5OSwDwN1fH2K3je6Qvf0jYIi5CB8ETPygXNtW/8S3nfv+81XAPsbvjgQL08cSK9oVBk425/vWlWvbuLncwrWrl2Pfq8XXPQqF8jvvMFjH7xNwK17ASfXgUIAMi0sBx2ETUKTfZVlXa/ZZJAkOfSncyeY8yrUZYLkVuz4qPvxnLq7Fp9lYCYYSMCHD4Iv4NO9m7vQkAdH2noeA9vaPrP2vKKRmgOgOK6NZ4PH3YwBA5emO7b3/ggwpvqQMFEYWGseoBUyIb3RW52TpYhMw/oTMMfOSpASkKFlgagXjJKTlF0fl6c5Qw11bbDgUMCG+01mdCxv6rR/vgbODv3MJmmdm+Nz/BhIguhLqrM4lbjtK+ZnxXQmYEJKK6FFYVIRagj73w9Q5QP1vbPeLWCWYpXh0bJwd8azbUX6E5I7afTlz1uksIhwLzLthNJ8/Du+KYb7O6sLryIXdifF58TchzpBuRig3l1vjc9ecOPnEvc5agK7iE0IGI3rLsLHok1HZ2J6zFKDL+ISQwSjarbgGPg9Qz8MlPWeJ6/iEkHSKNvd+rqsyUj8swy/vOj4hZPz4C1qvXeFTrAUOAAAAAElFTkSuQmCC"; +const DECORATIONS_SPRITE_SHEET_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAPNJREFUaIHtmTESgzAMBHWZDC+gp0vP/x9Bn44+L6BRmrhJA4csM05uGzfY1s1JxggzIYQQQgghxEnATnB3zwikAICKiXq4BE/uwaxvn/UPb3BnNwFg27Ky0w6vzRp8S4mkIbQD3wzzFJofdTMkYJgn89czFADGKSSiSgphfFBjTaoIKC4cHWvSxIFMmjiQSYoDLUlxoCVywOwHHWjpROop1IL/vsxty2oYO77M1QggSvcpJAFXE66BPfa+2C4v4j2yi7z7FJKAq6FrwN3TO3MMlAAAKO3F2sVZTiu2N9p9CnUv4FR7PbMG2BQ69SJL/kVA8QauAnHUj36BVwAAAABJRU5ErkJggg=="; +const FEATHER_SPRITE_SHEET_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAARhJREFUWIXtlbENwjAQRf8hSiZIRQ+9WQNRUFIAKzACBSsAA1Ag1mAABqCCBomG3hQQ9OMEx4ZDNH5SikSJ3/fZ5wCJRCKRSPwZ0RzMWmtLAhGvQyUAi9mXP/aFaGjJRQQiguHihMvcFMJUVUYlAMuHixPGy4en1WmVQqgHYHkuZjiEj6a2/LjtYzTY0eiZbgC37Mxh1UN3sn/dr6cCz/LHB/DJj9s+2oMdbtdz6TtfFwQHcMvOInfmQNjsgchNWLXmdfK6gyioAu/6uKrsm1kWLAciKuCuey5nYuXAh234bdmZ6INIUw4E/Ix49xtjCmXfzLL8nY/ktdgnAKwxxgIoXIyqmAOwvIqfiN0ALNd21HYBO9XXGMAdnZTYyHWzWjQAAAAASUVORK5CYII="; + +/** + * Load the spritesheet and return the pixelmap template + * @param {string} dataUri + * @param {boolean} [templateColors] + * @returns {Promise} + */ +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(TRANSPARENT); + continue; + } + const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + if (!templateColors) { + row.push(hex); + continue; + } + if (SPRITESHEET_COLOR_MAP[hex] === undefined) { + error(`Unknown color: ${hex}`); + row.push(TRANSPARENT); + } + row.push(SPRITESHEET_COLOR_MAP[hex]); + } + hexArray.push(row); + } + resolve(hexArray); + }; + img.onerror = (err) => { + reject(err); + }; + }); +} + +Promise.all([loadSpritesheetPixels(SPRITE_SHEET_URI), loadSpritesheetPixels(DECORATIONS_SPRITE_SHEET_URI, false), loadSpritesheetPixels(FEATHER_SPRITE_SHEET_URI)]).then(([birbPixels, decorationPixels, featherPixels ]) => { + const SPRITE_SHEET = birbPixels; + const DECORATIONS_SPRITE_SHEET = decorationPixels; + const FEATHER_SPRITE_SHEET = featherPixels; + + const layers = { + base: new Layer(getLayer(SPRITE_SHEET, 0)), + down: new Layer(getLayer(SPRITE_SHEET, 1)), + heartOne: new Layer(getLayer(SPRITE_SHEET, 2)), + heartTwo: new Layer(getLayer(SPRITE_SHEET, 3)), + heartThree: new Layer(getLayer(SPRITE_SHEET, 4)), + tuftBase: new Layer(getLayer(SPRITE_SHEET, 5), "tuft"), + tuftDown: new Layer(getLayer(SPRITE_SHEET, 6), "tuft"), + wingsUp: new Layer(getLayer(SPRITE_SHEET, 7)), + wingsDown: new Layer(getLayer(SPRITE_SHEET, 8)), + happyEye: new Layer(getLayer(SPRITE_SHEET, 9)), + }; + + const decorationLayers = { + mac: new Layer(getLayer(DECORATIONS_SPRITE_SHEET, 0, DECORATIONS_SPRITE_WIDTH)), + }; + + const featherLayers = { + feather: new Layer(getLayer(FEATHER_SPRITE_SHEET, 0, FEATHER_SPRITE_WIDTH)), + }; + + const birbFrames = { + base: new Frame([layers.base, layers.tuftBase]), + headDown: new Frame([layers.down, layers.tuftDown]), + wingsDown: new Frame([layers.base, layers.tuftBase, layers.wingsDown]), + wingsUp: new Frame([layers.down, layers.tuftDown, layers.wingsUp]), + heartOne: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartOne]), + heartTwo: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartTwo]), + heartThree: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartThree]), + heartFour: new Frame([layers.base, layers.tuftBase, layers.happyEye, layers.heartTwo]), + }; + + const decorationFrames = { + mac: new Frame([decorationLayers.mac]), + }; + + const featherFrames = { + feather: new Frame([featherLayers.feather]), + }; + + const Animations = { + STILL: new Anim([birbFrames.base], [1000]), + BOB: new Anim([ + birbFrames.base, + birbFrames.headDown + ], [ + 420, + 420 + ]), + FLYING: new Anim([ + birbFrames.base, + birbFrames.wingsUp, + birbFrames.headDown, + birbFrames.wingsDown, + ], [ + 30, + 80, + 30, + 60, + ]), + HEART: new Anim([ + birbFrames.heartOne, + birbFrames.heartTwo, + birbFrames.heartThree, + birbFrames.heartFour, + birbFrames.heartThree, + birbFrames.heartFour, + birbFrames.heartThree, + birbFrames.heartFour, + ], [ + 60, + 80, + 250, + 250, + 250, + 250, + 250, + 250, + ], false), + }; + + const DECORATION_ANIMATIONS = { + mac: new Anim([ + decorationFrames.mac, + ], [ + 1000, + ]), + }; + + const FEATHER_ANIMATIONS = { + feather: new Anim([ + featherFrames.feather, + ], [ + 1000, + ]), + }; + + class MenuItem { + /** + * @param {string} text + * @param {() => void} action + * @param {boolean} [removeMenu] + */ + constructor(text, action, removeMenu = true) { + this.text = text; + this.action = action; + this.removeMenu = removeMenu; + } + } + + class Separator extends MenuItem { + constructor() { + super("", () => {}); + } + } + + const menuItems = [ + new MenuItem("Pet Birb", pet), + new MenuItem("Field Guide", insertFieldGuide), + // new MenuItem("Decorations", insertDecoration), + new MenuItem("Applications", () => switchMenuItems(otherItems), false), + new MenuItem("Hide Birb", hideBirb), + new MenuItem("Reset Data", resetSaveData), + new MenuItem("Unlock All", () => { + for (let type in species) { + unlockBird(type); + } + }), + new Separator(), + new MenuItem("Settings", () => {}), + ]; + + const otherItems = [ + new MenuItem("Go Back", () => switchMenuItems(menuItems), false), + new Separator(), + new MenuItem("Video Games", () => switchMenuItems(gameItems), false), + new MenuItem("Utilities", () => switchMenuItems(utilityItems), false), + new MenuItem("Music Player", () => insertMusicPlayer(), false), + ]; + + const gameItems = [ + new MenuItem("Go Back", () => switchMenuItems(otherItems), false), + new Separator(), + new MenuItem("Pinball", () => insertPico8("Terra Nova Pinball", "terra_nova_pinball")), + new MenuItem("Pico Dino", () => insertPico8("Pico Dino", "picodino")), + new MenuItem("Woodworm", () => insertPico8("Woodworm", "woodworm")), + new MenuItem("Pico and Chill", () => insertPico8("Pico and Chill", "picochill")), + new MenuItem("Lani's Trek", () => insertPico8("Celeste 2 Lani's Trek", "celeste_classic_2")), + new MenuItem("Tetraminis", () => insertPico8("Tetraminis", "tetraminisdeffect")), + // new MenuItem("Pool", () => insertPico8("Pool", "mot_pool")), + ]; + + const utilityItems = [ + new MenuItem("Go Back", () => switchMenuItems(otherItems), false), + new Separator(), + new MenuItem("Pomodoro Timer", () => insertPico8("Pomodoro", "pomodorotimerv1")), + new MenuItem("Ohm Calculator", () => insertPico8("Resistor Calculator", "picoohm")), + new MenuItem("Wobblepaint ", () => insertPico8("Wobblepaint", "wobblepaint")), + ]; + + const styleElement = document.createElement("style"); + const canvas = document.createElement("canvas"); + + /** @type {CanvasRenderingContext2D} */ + // @ts-ignore + const ctx = canvas.getContext("2d"); + + const States = { + IDLE: "idle", + HOP: "hop", + FLYING: "flying", + }; + + let stateStart = Date.now(); + let currentState = States.IDLE; + let animStart = Date.now(); + let currentAnimation = Animations.BOB; + let direction = Directions.RIGHT; + let ticks = 0; + // Bird's current position + let birdY = 0; + let birdX = 40; + // Bird's starting position (when flying) + let startX = 0; + let startY = 0; + // Bird's target position (when flying) + let targetX = 0; + let targetY = 0; + /** @type {HTMLElement|null} */ + let focusedElement = null; + let lastActionTimestamp = Date.now(); + let petStack = []; + let currentSpecies = DEFAULT_BIRD; + let unlockedSpecies = [DEFAULT_BIRD]; + let visible = true; + let lastPetTimestamp = 0; + + /** + * @returns {boolean} Whether the script is running in a userscript extension context + */ + function isUserscript() { + // @ts-ignore + return typeof GM_getValue === "function"; + } + + function isTestEnvironment() { + return window.location.hostname === "127.0.0.1"; + } + + function load() { + let saveData = {}; + if (isUserscript()) { + log("Loading save data from userscript storage"); + // @ts-ignore + saveData = GM_getValue("birbSaveData", {}) ?? {}; + } else if (isTestEnvironment()) { + log("Test environment detected, loading save data from localStorage"); + saveData = JSON.parse(localStorage.getItem("birbSaveData") ?? "{}"); + } else { + log("Not a userscript"); + } + log("Loaded data: " + JSON.stringify(saveData)); + unlockedSpecies = saveData.unlockedSpecies ?? [DEFAULT_BIRD]; + currentSpecies = saveData.currentSpecies ?? DEFAULT_BIRD; + switchSpecies(currentSpecies); + } + + function save() { + let saveData = { + unlockedSpecies: unlockedSpecies, + currentSpecies: currentSpecies, + }; + if (isUserscript()) { + log("Saving data to userscript storage"); + // @ts-ignore + GM_setValue("birbSaveData", saveData); + } else if (isTestEnvironment()) { + log("Test environment detected, saving data to localStorage"); + localStorage.setItem("birbSaveData", JSON.stringify(saveData)); + } else { + log("Not a userscript"); + } + } + + function resetSaveData() { + if (isUserscript()) { + log("Resetting save data in userscript storage"); + // @ts-ignore + GM_deleteValue("birbSaveData"); + } else if (isTestEnvironment()) { + log("Test environment detected, resetting save data in localStorage"); + localStorage.removeItem("birbSaveData"); + } else { + log("Not a userscript"); + } + load(); + } + + function init() { + if (window !== window.top) { + // Skip installation if within an iframe + return; + } + + load(); + + styleElement.innerHTML = styles; + document.head.appendChild(styleElement); + + canvas.id = "birb"; + canvas.width = birbFrames.base.getPixels()[0].length * CANVAS_PIXEL_SIZE; + canvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; + document.body.appendChild(canvas); + + window.addEventListener("scroll", () => { + lastActionTimestamp = Date.now(); + // Can't keep up with scrolling on mobile devices so fly down instead + if (isMobile()) { + focusOnGround(); + } + + }); + + onClick(document, (e) => { + lastActionTimestamp = Date.now(); + if (e.target instanceof Node && document.querySelector("#" + MENU_EXIT_ID)?.contains(e.target)) { + removeMenu(); + } + }); + + onClick(canvas, () => { + insertMenu(); + }); + + canvas.addEventListener("mouseover", () => { + lastActionTimestamp = Date.now(); + if (currentState === States.IDLE) { + petStack.push(Date.now()); + if (petStack.length > 10) { + petStack.shift(); + } + const pets = petStack.filter((time) => Date.now() - time < 1000).length; + if (pets >= 4) { + setAnimation(Animations.HEART); + // Clear the stack + petStack = []; + } + } + }); + + setInterval(update, 1000 / 60); + } + + function update() { + ticks++; + if (currentState === States.IDLE) { + if (Math.random() < 1 / (60 * 3) && currentAnimation !== Animations.HEART && !isMenuOpen()) { + hop(); + } else if (focusedElement !== null && Math.random() < 1 / (60 * 20) && Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) { + focusOnElement(); + lastActionTimestamp = Date.now(); + } + } else if (currentState === States.HOP) { + if (updateParabolicPath(HOP_SPEED)) { + setState(States.IDLE); + } + } + const FEATHER_CHANCE = 1 / (60 * 60 * 60 * 2); // 1 every 2 hours (ticks * seconds * minutes * hours) + // Double the chance of a feather if recently pet + let petMod = Date.now() - lastPetTimestamp < 1000 * 60 * 5 ? 2 : 1; + if (visible && Math.random() < FEATHER_CHANCE * petMod) { + lastPetTimestamp = 0; + activateFeather(); + } + updateFeather(); + } + + function draw() { + requestAnimationFrame(draw); + + if (!visible) { + return; + } + + // Update the bird's position + if (currentState === States.IDLE) { + if (focusedElement !== null) { + birdY = getFocusedElementY(); + if (!isWithinHorizontalBounds()) { + focusOnGround(); + } + } + } else if (currentState === States.FLYING) { + // Fly to target location (even if in the air) + if (updateParabolicPath(FLY_SPEED)) { + setState(States.IDLE); + } + } + + if (focusedElement === null) { + if (Date.now() - lastActionTimestamp > AFK_TIME && !isMenuOpen()) { + // Fly to an element if the user is AFK + focusOnElement(); + lastActionTimestamp = Date.now(); + } + } else if (focusedElement !== null) { + targetY = getFocusedElementY(); + if (targetY < 0 || targetY > window.innerHeight) { + // Fly to ground if the focused element moves out of bounds + focusOnGround(); + } + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (currentAnimation.draw(ctx, direction, animStart, species[currentSpecies])) { + setAnimation(Animations.STILL); + } + + // Update HTML element position + setX(birdX); + setY(birdY); + } + + init(); + draw(); + + /** + * Create an HTML element with the specified parameters + * @param {string} className + * @param {string} [textContent] + * @param {string} [id] + * @returns {HTMLElement} + */ + function makeElement(className, textContent, id) { + const element = document.createElement("div"); + element.classList.add(className); + if (textContent) { + element.textContent = textContent; + } + if (id) { + element.id = id; + } + return element; + } + + function insertDecoration() { + // Create a canvas element for the decoration + const decorationCanvas = document.createElement("canvas"); + decorationCanvas.classList.add("birb-decoration"); + decorationCanvas.width = DECORATIONS_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + decorationCanvas.height = DECORATIONS_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + const decorationCtx = decorationCanvas.getContext("2d"); + if (!decorationCtx) { + return; + } + // Draw the decoration + DECORATION_ANIMATIONS.mac.draw(decorationCtx, Directions.LEFT, Date.now()); + // Add the decoration to the page + document.body.appendChild(decorationCanvas); + makeDraggable(decorationCanvas, false); + } + + function activateFeather() { + if (document.querySelector("#" + FEATHER_ID)) { + return; + } + const speciesToUnlock = Object.keys(species).filter((species) => !unlockedSpecies.includes(species)); + if (speciesToUnlock.length === 0) { + // No more species to unlock + return; + } + const birdType = speciesToUnlock[Math.floor(Math.random() * speciesToUnlock.length)]; + insertFeather(birdType); + } + + /** + * @param {string} birdType + */ + function insertFeather(birdType) { + let type = species[birdType]; + const featherCanvas = document.createElement("canvas"); + featherCanvas.id = FEATHER_ID; + featherCanvas.classList.add("birb-decoration"); + featherCanvas.width = FEATHER_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + featherCanvas.height = FEATHER_SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + const x = featherCanvas.width * 2 + Math.random() * (window.innerWidth - featherCanvas.width * 4); + featherCanvas.style.marginLeft = `${x}px`; + featherCanvas.style.top = `${-featherCanvas.height}px`; + const featherCtx = featherCanvas.getContext("2d"); + if (!featherCtx) { + return; + } + FEATHER_ANIMATIONS.feather.draw(featherCtx, Directions.LEFT, Date.now(), type); + document.body.appendChild(featherCanvas); + onClick(featherCanvas, () => { + unlockBird(birdType); + removeFeather(); + if (document.querySelector("#" + FIELD_GUIDE_ID)) { + removeFieldGuide(); + insertFieldGuide(); + } + }); + } + + function removeFeather() { + const feather = document.querySelector("#" + FEATHER_ID); + if (feather) { + feather.remove(); + } + } + + /** + * @param {string} birdType + */ + function unlockBird(birdType) { + if (!unlockedSpecies.includes(birdType)) { + unlockedSpecies.push(birdType); + insertModal("New Bird Unlocked!", `You've found a ${species[birdType].name} feather! Use the Field Guide to switch your bird's species.`); + } + save(); + } + + function updateFeather() { + const feather = document.querySelector("#birb-feather"); + const featherGravity = 1; + if (!feather || !(feather instanceof HTMLElement)) { + return; + } + const y = parseInt(feather.style.top || "0") + featherGravity; + feather.style.top = `${Math.min(y, window.innerHeight - feather.offsetHeight)}px`; + if (y < window.innerHeight - feather.offsetHeight) { + feather.style.left = `${Math.sin(3.14 * 2 * (ticks / 120)) * 25}px`; + } + } + + + // insertDecoration(); + // insertFieldGuide(); + + /** + * @param {HTMLElement} element + */ + function centerElement(element) { + element.style.left = `${window.innerWidth / 2 - element.offsetWidth / 2}px`; + element.style.top = `${window.innerHeight / 2 - element.offsetHeight / 2}px`; + } + + /** + * @param {string} title + * @param {string} message + */ + function insertModal(title, message) { + if (document.querySelector("#" + FIELD_GUIDE_ID)) { + return; + } + let html = ` +
+
${title}
+
x
+
+
+
+ ${message} +
+
` + const modal = makeElement("birb-window"); + modal.style.width = "270px"; + modal.innerHTML = html; + document.body.appendChild(modal); + makeDraggable(modal.querySelector(".birb-window-header")); + + const closeButton = modal.querySelector(".birb-window-close"); + if (closeButton) { + makeClosable(() => { + modal.remove(); + }, closeButton); + } + centerElement(modal); + } + + function insertFieldGuide() { + if (document.querySelector("#" + FIELD_GUIDE_ID)) { + return; + } + let html = ` +
+
Field Guide
+
x
+
+
+
+
+
` + const fieldGuide = makeElement("birb-window", undefined, FIELD_GUIDE_ID); + fieldGuide.innerHTML = html; + document.body.appendChild(fieldGuide); + makeDraggable(fieldGuide.querySelector(".birb-window-header")); + + const closeButton = fieldGuide.querySelector(".birb-window-close"); + if (closeButton) { + makeClosable(() => { + fieldGuide.remove(); + }, closeButton); + } + + const content = fieldGuide.querySelector(".birb-grid-content"); + if (!content) { + return; + } + content.innerHTML = ""; + + const generateDescription = (/** @type {string} */ speciesId) => { + const type = species[speciesId]; + const unlocked = unlockedSpecies.includes(speciesId); + return "" + type.name + "
" + (!unlocked ? "Not yet unlocked" : type.description); + }; + + const description = fieldGuide.querySelector(".birb-field-guide-description"); + if (!description) { + return; + } + description.innerHTML = generateDescription(currentSpecies); + for (const [id, type] of Object.entries(species)) { + const unlocked = unlockedSpecies.includes(id); + const speciesElement = makeElement("birb-grid-item"); + if (id === currentSpecies) { + speciesElement.classList.add("birb-grid-item-selected"); + } + const speciesCanvas = document.createElement("canvas"); + speciesCanvas.width = SPRITE_WIDTH * CANVAS_PIXEL_SIZE; + speciesCanvas.height = SPRITE_HEIGHT * CANVAS_PIXEL_SIZE; + const speciesCtx = speciesCanvas.getContext("2d"); + if (!speciesCtx) { + return; + } + birbFrames.base.draw(speciesCtx, Directions.RIGHT, type); + speciesElement.appendChild(speciesCanvas); + content.appendChild(speciesElement); + if (unlocked) { + onClick(speciesElement, () => { + switchSpecies(id); + document.querySelectorAll(".birb-grid-item").forEach((element) => { + element.classList.remove("birb-grid-item-selected"); + }); + speciesElement.classList.add("birb-grid-item-selected"); + }); + } else { + speciesElement.classList.add("birb-grid-item-locked"); + } + speciesElement.addEventListener("mouseover", () => { + log("mouseover"); + description.innerHTML = generateDescription(id); + }); + speciesElement.addEventListener("mouseout", () => { + description.innerHTML = generateDescription(currentSpecies); + }); + } + centerElement(fieldGuide); + } + + /** + * @param {() => void} func + * @param {Element} [closeButton] + */ + function makeClosable(func, closeButton) { + if (closeButton) { + onClick(closeButton, func); + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + func(); + } + }); + } + + function removeFieldGuide() { + const fieldGuide = document.querySelector("#" + FIELD_GUIDE_ID); + if (fieldGuide) { + fieldGuide.remove(); + } + } + + // insertPico8(); + + function isSafari() { + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + } + + /** + * @param {string} name + * @param {string} pid + */ + function insertPico8(name, pid) { + let html = ` +
+
${name}
+
x
+
+
+ +
` + const pico8 = makeElement("birb-window"); + pico8.innerHTML = html; + document.body.appendChild(pico8); + makeDraggable(pico8.querySelector(".birb-window-header")); + const close = pico8.querySelector(".birb-window-close"); + if (close) { + makeClosable(() => { + pico8.remove(); + }, close); + } + centerElement(pico8); + } + + function insertMusicPlayer() { + let html = ` +
+
Music Player
+
x
+
+
+ +
`; + const pico8 = makeElement("birb-window"); + pico8.innerHTML = html; + document.body.appendChild(pico8); + makeDraggable(pico8.querySelector(".birb-window-header")); + const close = pico8.querySelector(".birb-window-close"); + if (close) { + makeClosable(() => { + pico8.remove(); + }, close); + } + centerElement(pico8); + } + + /** + * @param {string} type + */ + function switchSpecies(type) { + currentSpecies = type; + // Update CSS variable --highlight to be wing color + document.documentElement.style.setProperty("--highlight", species[type].colors[THEME_HIGHLIGHT]); + save(); + } + + /** + * Add the menu to the page if it doesn't already exist + */ + function insertMenu() { + if (document.querySelector("#" + MENU_ID)) { + return; + } + let menu = makeElement("birb-window", undefined, MENU_ID); + let header = makeElement("birb-window-header"); + header.innerHTML = '
birbOS
'; + let content = makeElement("birb-window-content"); + for (const item of menuItems) { + content.appendChild(makeMenuItem(item)); + } + menu.appendChild(header); + menu.appendChild(content); + document.body.appendChild(menu); + makeDraggable(document.querySelector(".birb-window-header")); + + let menuExit = makeElement("birb-window-exit", undefined, MENU_EXIT_ID); + onClick(menuExit, () => { + removeMenu(); + }); + document.body.appendChild(menuExit); + makeClosable(removeMenu); + + updateMenuLocation(menu); + } + + /** + * Update the menu's location based on the bird's position + * @param {HTMLElement} menu + */ + function updateMenuLocation(menu) { + let x = birdX; + let y = canvas.offsetTop + canvas.height / 2 + WINDOW_PIXEL_SIZE * 10; + const offset = 20; + if (x < window.innerWidth / 2) { + // Left side + x += offset; + } else { + // Right side + x -= menu.offsetWidth + offset; + } + if (y > window.innerHeight / 2) { + // Top side + y -= menu.offsetHeight + offset + 10; + } else { + // Bottom side + y += offset; + } + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + } + + /** + * @param {MenuItem[]} menuItems + */ + function switchMenuItems(menuItems) { + const menu = document.querySelector("#" + MENU_ID); + if (!menu || !(menu instanceof HTMLElement)) { + return; + } + const content = menu.querySelector(".birb-window-content"); + if (!content) { + error("Content not found"); + return; + } + content.innerHTML = ""; + for (const item of menuItems) { + content.appendChild(makeMenuItem(item)); + } + updateMenuLocation(menu); + } + + /** + * @param {MenuItem} item + * @returns {HTMLElement} + */ + function makeMenuItem(item) { + if (item instanceof Separator) { + return makeElement("birb-window-separator"); + } + let menuItem = makeElement("birb-window-list-item", item.text); + onClick(menuItem, () => { + if (item.removeMenu) { + removeMenu(); + } + item.action(); + }); + return menuItem; + } + + /** + * @param {Document|Element} element + * @param {(e: Event) => void} action + */ + function onClick(element, action) { + element.addEventListener("click", (e) => action(e)); + element.addEventListener("touchstart", (e) => action(e)); + } + + /** + * Remove the menu from the page + */ + function removeMenu() { + const menu = document.querySelector("#" + MENU_ID); + if (menu) { + menu.remove(); + } + const exitMenu = document.querySelector("#" + MENU_EXIT_ID); + if (exitMenu) { + exitMenu.remove(); + } + } + + /** + * @returns {boolean} Whether the menu element is on the page + */ + function isMenuOpen() { + return document.querySelector("#" + MENU_ID) !== null; + } + + /** + * @param {HTMLElement|null} element The element to detect drag events on + * @param {boolean} [parent] Whether to move the parent element when the child is dragged + */ + function makeDraggable(element, parent = true) { + if (!element) { + return; + } + + let isMouseDown = false; + let offsetX = 0; + let offsetY = 0; + let elementToMove = parent ? element.parentElement : element; + + if (!elementToMove) { + error("Parent element not found"); + return; + } + + element.addEventListener("mousedown", (e) => { + isMouseDown = true; + offsetX = e.clientX - elementToMove.offsetLeft; + offsetY = e.clientY - elementToMove.offsetTop; + }); + + element.addEventListener("touchstart", (e) => { + isMouseDown = true; + const touch = e.touches[0]; + offsetX = touch.clientX - elementToMove.offsetLeft; + offsetY = touch.clientY - elementToMove.offsetTop; + e.preventDefault(); + }); + + document.addEventListener("mouseup", () => { + isMouseDown = false; + }); + + document.addEventListener("touchend", () => { + isMouseDown = false; + }); + + document.addEventListener("mousemove", (e) => { + if (isMouseDown) { + elementToMove.style.left = `${e.clientX - offsetX}px`; + elementToMove.style.top = `${e.clientY - offsetY}px`; + } + }); + + document.addEventListener("touchmove", (e) => { + if (isMouseDown) { + const touch = e.touches[0]; + elementToMove.style.left = `${touch.clientX - offsetX}px`; + elementToMove.style.top = `${touch.clientY - offsetY}px`; + } + }); + } + + /** + * @param {string[][]} array + * @param {number} sprite + * @param {number} [width] + * @returns {string[][]} + */ + function getLayer(array, sprite, width = SPRITE_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++) { + layer.push(array[y].slice(sprite * width, (sprite + 1) * width)); + } + return layer; + } + + /** + * Update the birds location from the start to the target location on a parabolic path + * @param {number} speed The speed of the bird along the path + * @param {number} [intensity] The intensity of the parabolic path + * @returns {boolean} Whether the bird has reached the target location + */ + function updateParabolicPath(speed, intensity = 2.5) { + const dx = targetX - startX; + const dy = targetY - startY; + const distance = Math.sqrt(dx * dx + dy * dy); + const time = Date.now() - stateStart; + if (distance > Math.max(window.innerWidth, window.innerHeight) / 2) { + speed *= 1.3; + } + const amount = Math.min(1, time / (distance / speed)); + const { x, y } = parabolicLerp(startX, startY, targetX, targetY, amount, intensity); + birdX = x; + birdY = y; + const complete = Math.abs(birdX - targetX) < 1 && Math.abs(birdY - targetY) < 1; + if (complete) { + birdX = targetX; + birdY = targetY; + } else { + direction = targetX > birdX ? Directions.RIGHT : Directions.LEFT; + } + return complete; + } + + function getFocusedElementRandomX() { + if (focusedElement === null) { + return Math.random() * window.innerWidth; + } + const rect = focusedElement.getBoundingClientRect(); + return Math.random() * (rect.right - rect.left) + rect.left; + } + + function isWithinHorizontalBounds() { + if (focusedElement === null) { + return true; + } + const rect = focusedElement.getBoundingClientRect(); + return birdX >= rect.left && birdX <= rect.right; + } + + function getFocusedElementY() { + if (focusedElement === null) { + return 0; + } + const rect = focusedElement.getBoundingClientRect(); + return window.innerHeight - rect.top; + } + + function focusOnGround() { + if (focusedElement === null) { + return; + } + focusedElement = null; + flyTo(Math.random() * window.innerWidth, 0); + } + + function focusOnElement() { + const elements = document.querySelectorAll("img, video"); + const inWindow = Array.from(elements).filter((img) => { + const rect = img.getBoundingClientRect(); + return rect.left >= 0 && rect.top >= 80 && rect.right <= window.innerWidth && rect.top <= window.innerHeight; + }); + const MIN_WIDTH = 100; + /** @type {HTMLElement[]} */ + // @ts-ignore + const largeElements = Array.from(inWindow).filter((img) => img instanceof HTMLElement && img !== focusedElement && img.offsetWidth >= MIN_WIDTH); + if (largeElements.length === 0) { + return; + } + const randomElement = largeElements[Math.floor(Math.random() * largeElements.length)]; + focusedElement = randomElement; + flyTo(getFocusedElementRandomX(), getFocusedElementY()); + } + + function getCanvasWidth() { + return canvas.width * CSS_SCALE + } + + function getCanvasHeight() { + return canvas.height * CSS_SCALE + } + + function hop() { + if (currentState === States.IDLE) { + // Determine bounds for hopping + let minX = 0; + let maxX = window.innerWidth; + let y = 0; + if (focusedElement !== null) { + // Hop on the element + const rect = focusedElement.getBoundingClientRect(); + minX = rect.left; + maxX = rect.right; + y = window.innerHeight - rect.top; + } + setState(States.HOP); + setAnimation(Animations.FLYING); + if ((Math.random() < 0.5 && birdX - HOP_DISTANCE > minX) || birdX + HOP_DISTANCE > maxX) { + targetX = birdX - HOP_DISTANCE; + } else { + targetX = birdX + HOP_DISTANCE; + } + targetY = y; + } + } + + function pet() { + if (currentState === States.IDLE) { + setAnimation(Animations.HEART); + lastPetTimestamp = Date.now(); + } + } + + function hideBirb() { + canvas.style.display = "none"; + visible = false; + } + + /** + * @param {number} x + * @param {number} y + */ + function flyTo(x, y) { + targetX = x; + targetY = y; + setState(States.FLYING); + setAnimation(Animations.FLYING); + } + + /** + * Set the current animation and reset the animation timer + * @param {Anim} animation + */ + function setAnimation(animation) { + currentAnimation = animation; + animStart = Date.now(); + } + + /** + * Set the current state and reset the state timer + * @param {string} state + */ + function setState(state) { + stateStart = Date.now(); + startX = birdX; + startY = birdY; + currentState = state; + if (state === States.IDLE) { + setAnimation(Animations.BOB); + } + } + + /** + * @param {number} x + */ + function setX(x) { + let mod = getCanvasWidth() / -2 - (WINDOW_PIXEL_SIZE * (direction === Directions.RIGHT ? 2 : -2)); + canvas.style.left = `${x + mod}px`; + } + + /** + * @param {number} y + */ + function setY(y) { + canvas.style.bottom = `${y}px`; + } +}); + +/** + * @param {number} start + * @param {number} end + * @param {number} amount + * @returns {number} + */ +function linearLerp(start, end, amount) { + return start + (end - start) * amount; +} + +/** + * @param {number} startX + * @param {number} startY + * @param {number} endX + * @param {number} endY + * @param {number} amount + * @param {number} [intensity] + * @returns {{x: number, y: number}} + */ +function parabolicLerp(startX, startY, endX, endY, amount, intensity = 1.2) { + const dx = endX - startX; + const dy = endY - startY; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + const midX = startX + Math.cos(angle) * distance / 2; + const midY = startY + Math.sin(angle) * distance / 2 + distance / 4 * intensity; + const t = amount; + const x = (1 - t) ** 2 * startX + 2 * (1 - t) * t * midX + t ** 2 * endX; + const y = (1 - t) ** 2 * startY + 2 * (1 - t) * t * midY + t ** 2 * endY; + return { x, y }; +} + +/** + * @param {number} value + */ +function roundToPixel(value) { + return Math.round(value / WINDOW_PIXEL_SIZE) * WINDOW_PIXEL_SIZE; +} + +/** + * @returns {boolean} Whether the user is on a mobile device + */ +function isMobile() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} + +function log() { + console.log("Birb: ", ...arguments); +} + +function error() { + console.error("Birb: ", ...arguments); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9acd8f4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,389 @@ +{ + "name": "birb", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "birb", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "nodemon": "^3.1.10" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4ecdf7b --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "birb", + "version": "1.0.0", + "description": "", + "license": "ISC", + "author": "Idrees Hassan", + "type": "module", + "scripts": { + "build": "node build.js", + "dev": "nodemon --watch birb.js --exec \"npm run build\"" + }, + "devDependencies": { + "nodemon": "^3.1.10" + } +} diff --git a/images/bird-1.jpg b/preview/images/bird-1.jpg similarity index 100% rename from images/bird-1.jpg rename to preview/images/bird-1.jpg diff --git a/images/bird-2.jpg b/preview/images/bird-2.jpg similarity index 100% rename from images/bird-2.jpg rename to preview/images/bird-2.jpg diff --git a/images/bird-3.jpg b/preview/images/bird-3.jpg similarity index 100% rename from images/bird-3.jpg rename to preview/images/bird-3.jpg diff --git a/index.html b/preview/index.html similarity index 95% rename from index.html rename to preview/index.html index 9bf63aa..be72f8f 100644 --- a/index.html +++ b/preview/index.html @@ -26,6 +26,6 @@
- + \ No newline at end of file diff --git a/sprites/birb.png b/sprites/birb.png new file mode 100644 index 0000000..f3b3d1e Binary files /dev/null and b/sprites/birb.png differ diff --git a/sprites/decorations.png b/sprites/decorations.png new file mode 100644 index 0000000..f0471da Binary files /dev/null and b/sprites/decorations.png differ diff --git a/sprites/feather.png b/sprites/feather.png new file mode 100644 index 0000000..564867e Binary files /dev/null and b/sprites/feather.png differ diff --git a/spritesheet-compiler.js b/spritesheet-compiler.js deleted file mode 100644 index 8226868..0000000 --- a/spritesheet-compiler.js +++ /dev/null @@ -1,121 +0,0 @@ - -// @ts-check - -const TRANSPARENT = 0; -const OUTLINE = 1; -const BORDER = 2; -const FOOT = 3; -const BEAK = 4; -const EYE = 5; -const FACE = 6; -const BELLY = 7; -const UNDERBELLY = 8; -const WING = 9; -const WING_EDGE = 10; -const HEART = 11; -const HEART_BORDER = 12; -const HEART_SHINE = 13; - -const SPRITESHEET_COLOR_MAP = { - "transparent": TRANSPARENT, - "#ffffff": BORDER, - "#000000": OUTLINE, - "#010a19": BEAK, - "#190301": EYE, - "#af8e75": FOOT, - "#639bff": FACE, - "#f8b143": BELLY, - "#ec8637": UNDERBELLY, - "#578ae6": WING, - "#326ed9": WING_EDGE, - "#c82e2e": HEART, - "#501a1a": HEART_BORDER, - "#ff6b6b": HEART_SHINE -}; - -const SPRITE_SHEET_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASAAAAAgCAYAAACy9KU0AAAAAXNSR0IArs4c6QAABBdJREFUeJztnL9LHEEUx79zWkgk3RFwTRvsDKS5KpWpbDSVVQgkjSCYKkfIHyDBIhAhIAiBkOoqsUmVVDbaBFKkkLTxhHBgEQM28aU4Z53d25lddXfm1vt+4Ni5X/vm9t77znfm9hYghBBCCCGEEOIJFboDhOQhImJ7TinFHK4x/PJILiEFQMd+PD0NANg+PEy0ffSBEBIIOWcximQxigbaLnEqK/5iFMne7KzIyspAu+r4pFrGQ3eAFCOr0HyN/I+np/Gq2UTr4cOBtnYhPtjf3cWrZjNuk/rTCN0Bkk96GqK3vkf/UAKwfXiIN71e4rE3vZ5X8SNkZDGnIebWhwCZU7C92dn45msKltUHn7FJtdABFUQyCNEP7UB8oad5pgsx3YePaWC6D1x8JiOHiPQXP42tz9ihHYDZh1ACHFr8SflwBCmIiMj+/fvx/db3715HYHMdKJQDMAuf7oMQj+iRV7sfOgBCrk+hUcyW8KM2CtIBEFIuuUWkiy5db7oWfRQiBZDUCeZrcZwnIpri83wreUyVUhAR8zWVHFyXAIqI8Eslw0BadGwDNkliFSCX+ADA8y2JD3JVYjAMAkhIUcwUXFrvxu1OOwrRncqYGlNy9E9KqTenA2pMRlC3pvDhRf8APnvXxeflBuY3zwBcHOQqxGAYBJCQgohSKiE6N5WpMSXzm2f4vNwoRYSsO8gTAAA4OT6K21rlXVbzMgIhItKYTI4caQHU8TvtKBGXQkQ8Ii7hMeuCeTmI1QEppZTr515TfICkG8riKi7l7G93QAC1+KRjp91Q1v6YAKRknOJjwNSzUOjf8O/vPMLK7y9xGwCeHn/KfG36C/n56w+Ai2laUYZBAAmxsbTezU3mKtd+bPGNmLXIc2cnzwsWpztzA89NLHzFg9UD3Lt7O/O9Wni+bczoffUDXnIapuMPCOBBtgC6+kEbTMoiT4A67QhL61102lFV+WZ1X+ciVIs8v/L1gE535jCxMAOsHmQ+nxYe4Ho+VAuPptOOriSAhJTBx5kn1kHQg/gA6Oe4Lf/rQq4DAvrTmrQLOvlxAgBovt63vfciyBWFx+XAgAsXlkXZAkhImt5aS1bGtwEkp1s+xAeAuAbgurigQmdCp0VAi08Wzdf7pZ0lHVoACcmjt9aKE80QI1/5ZhUh85fhYc7/S/0Vo7fWcr62TPEx44cSQEJqgOhZQFqItAgNcx3krgHpX6POP4hVhKosfB0bgFMEKT5kBFHfNmb6SW8RohuBcRkI2X77UgDEN32/qstEpC5D4YzPS1WQEUbQd0TyYPWg0pr0SlbxZ92vWoBCxSekLui60LcbURPpYrdtqxSgkPEJqQuSQeg+uSh8HpBeh3FtqyR0fELqQN3WPi91VnLuzir88KHjE0LK5z8tFuzphqiPOAAAAABJRU5ErkJggg=="; -console.log(stringifyPixels(compress(loadSpritesheetPixels(SPRITE_SHEET_URI)))) - -function compress(pixels) { - let counts = []; - let rowCounts = []; - let count = null; - for (let row of pixels) { - console.log("Row length: " + row.length); - for (let pixel of row) { - if (count === null) { - count = [pixel, 1]; - } else if (pixel === count[0]) { - count[1] = count[1] + 1; - } else { - rowCounts.push(count); - count = [pixel, 1]; - } - } - rowCounts.push(count); - counts.push([...rowCounts]); - rowCounts = []; - count = null; - } - return counts; -} - -function stringifyPixels(pixels) { - // Add newlines between every row - let str = ""; - for (let row of pixels) { - str += JSON.stringify(row) + ",\n"; - } - str = str.slice(0, -2); - return "[" + str + "]"; -} - -/** - * Load the spritesheet and return the pixelmap template - * @param {string} dataUri - * @param {boolean} [templateColors] - * @returns {string[][]} - */ -function loadSpritesheetPixels(dataUri, templateColors = true) { - const img = new Image(); - img.src = dataUri; - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - if (!ctx) { - 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(TRANSPARENT); - continue; - } - const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; - if (!templateColors) { - row.push(hex); - continue; - } - if (SPRITESHEET_COLOR_MAP[hex] === undefined) { - console.error(`Unknown color: ${hex}`); - row.push(TRANSPARENT); - } - row.push(SPRITESHEET_COLOR_MAP[hex]); - } - hexArray.push(row); - } - return hexArray; -} - -export {}; \ No newline at end of file