Back to Browser Side Quest
SOURCE / PINNED RELEASE

Made of little things.

Browser Side Quest

Release
b9d42f5768e4…
Author-recorded commit
c267690145fa…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest

Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/client/sprite.ts
// Port of BrowserQuest client/js/sprite.js (MPL-2.0). Only the 1x art is shipped;
// the renderer upscales it with image smoothing disabled ("upscaledRendering").
import { Animation } from './timing.js';
import { imageUrl, sprites, type SpriteData } from './assets.js';

export interface DrawableSprite {
  image: CanvasImageSource;
  isLoaded: boolean;
  offsetX: number;
  offsetY: number;
  width: number;
  height: number;
}

/**
 * Sprites the port derives at runtime instead of shipping. The original
 * invincibility costume depicted a third-party mascot; the port recolors the
 * ruby armor into an original flame costume with the same animation rows.
 */
const derivedSprites: Record<string, { base: string; recolor: (r: number, g: number, b: number) => [number, number, number] }> = {
  firefox: {
    base: 'redarmor',
    recolor: (r, g, b) => {
      const l = (r * 0.3 + g * 0.59 + b * 0.11) / 255;
      // Map luminance onto an ember → flame → spark ramp.
      return [Math.min(255, 90 + l * 230), Math.min(255, 20 + l * l * 260), Math.min(255, l * l * l * 160)];
    },
  },
};

export class Sprite implements DrawableSprite {
  readonly id: string;
  image: CanvasImageSource;
  private readonly source = new Image();
  readonly animationData: SpriteData['animations'];
  readonly width: number;
  readonly height: number;
  readonly offsetX: number;
  readonly offsetY: number;
  isLoaded = false;
  whiteSprite: DrawableSprite | null = null;
  silhouetteSprite: DrawableSprite | null = null;
  private readonly loaded: Promise<void>;

  constructor(readonly name: string) {
    const derived = derivedSprites[name];
    const data = sprites[derived ? derived.base : name];
    if (!data) throw new Error(`Missing sprite definition: ${name}`);
    this.id = derived ? name : data.id;
    this.animationData = data.animations;
    this.width = data.width;
    this.height = data.height;
    this.offsetX = data.offset_x !== undefined ? data.offset_x : -16;
    this.offsetY = data.offset_y !== undefined ? data.offset_y : -16;
    this.image = this.source;

    this.loaded = new Promise((resolve, reject) => {
      this.source.onload = () => {
        if (derived) this.image = this.recolored(derived.recolor);
        this.isLoaded = true;
        resolve();
      };
      this.source.onerror = () => reject(new Error(`Could not decode sprite image: ${this.id}`));
    });
    this.source.src = imageUrl(data.id);
  }

  private recolored(map: (r: number, g: number, b: number) => [number, number, number]): HTMLCanvasElement {
    const canvas = document.createElement('canvas');
    canvas.width = this.source.width;
    canvas.height = this.source.height;
    const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
    ctx.drawImage(this.source, 0, 0);
    const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const d = pixels.data;
    for (let i = 0; i < d.length; i += 4) {
      if (d[i + 3] === 0) continue;
      const [r, g, b] = map(d[i], d[i + 1], d[i + 2]);
      d[i] = r;
      d[i + 1] = g;
      d[i + 2] = b;
    }
    ctx.putImageData(pixels, 0, 0);
    return canvas;
  }

  get imageWidth(): number {
    return this.source.width;
  }

  get imageHeight(): number {
    return this.source.height;
  }

  ready(): Promise<void> {
    return this.loaded;
  }

  createAnimations(): Record<string, Animation> {
    const animations: Record<string, Animation> = {};
    for (const [name, a] of Object.entries(this.animationData)) {
      animations[name] = new Animation(name, a.length, a.row, this.width, this.height);
    }
    return animations;
  }

  private derived(image: HTMLCanvasElement): DrawableSprite {
    return {
      image,
      isLoaded: true,
      offsetX: this.offsetX,
      offsetY: this.offsetY,
      width: this.width,
      height: this.height,
    };
  }

  private copyToCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
    const canvas = document.createElement('canvas');
    canvas.width = this.imageWidth;
    canvas.height = this.imageHeight;
    const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
    ctx.drawImage(this.image, 0, 0);
    return { canvas, ctx };
  }

  createHurtSprite(): void {
    const { canvas, ctx } = this.copyToCanvas();
    const spriteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const data = spriteData.data;
    for (let i = 0; i < data.length; i += 4) {
      data[i] = 255;
      data[i + 1] = data[i + 2] = 75;
    }
    ctx.putImageData(spriteData, 0, 0);
    this.whiteSprite = this.derived(canvas);
  }

  getHurtSprite(): DrawableSprite | null {
    return this.whiteSprite;
  }

  createSilhouette(): void {
    const { canvas, ctx } = this.copyToCanvas();
    const width = canvas.width;
    const height = canvas.height;
    const data = ctx.getImageData(0, 0, width, height).data;
    const finalData = ctx.getImageData(0, 0, width, height);
    const fdata = finalData.data;

    const isBlankPixel = (i: number): boolean => {
      if (i < 0 || i >= data.length) return true;
      return data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0 && data[i + 3] === 0;
    };
    const getIndex = (x: number, y: number): number => (width * (y - 1) + x - 1) * 4;
    const hasAdjacentPixel = (i: number): boolean => {
      const n = i / 4 + 1;
      const x = n % width;
      const y = (n - x) / width + 1;
      if (x < width && !isBlankPixel(getIndex(x + 1, y))) return true;
      if (x > 1 && !isBlankPixel(getIndex(x - 1, y))) return true;
      if (y < height && !isBlankPixel(getIndex(x, y + 1))) return true;
      if (y > 1 && !isBlankPixel(getIndex(x, y - 1))) return true;
      return false;
    };

    for (let i = 0; i < data.length; i += 4) {
      if (isBlankPixel(i) && hasAdjacentPixel(i)) {
        fdata[i] = fdata[i + 1] = 255;
        fdata[i + 2] = 150;
        fdata[i + 3] = 150;
      }
    }
    ctx.putImageData(finalData, 0, 0);
    this.silhouetteSprite = this.derived(canvas);
  }
}