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.
// 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);
}
}
