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/renderer.js (MPL-2.0).
// Always uses the original "upscaledRendering" path: 1x art drawn at an integer
// scale with smoothing off. The scale and visible grid are fitted to the frame
// instead of the fixed desktop/mobile breakpoints.
import { Entities } from '../shared/gametypes.js';
import { Camera } from './camera.js';
import { Character, type Entity } from './entity.js';
import { Item, Player } from './entities.js';
import type { Game } from './game.js';
import type { Sprite } from './sprite.js';
import { AnimatedTile, Timer } from './timing.js';
const getX = (id: number, w: number): number => {
if (id === 0) return 0;
return id % w === 0 ? w - 1 : (id % w) - 1;
};
/** Visible grid bounds. The original showed 15x7 (mobile) or 30x14 tiles (desktop). */
const MIN_GRID_W = 9;
const MIN_GRID_H = 7;
const MAX_GRID_W = 30;
const MAX_GRID_H = 16;
export interface Fit {
scale: number;
gridW: number;
gridH: number;
}
/**
* Pick the integer scale that shows as close to the original desktop view as the
* frame allows: the smallest scale whose grid fits the caps, never below 1.
*/
export function fitToFrame(deviceWidth: number, deviceHeight: number): Fit {
let best: Fit | null = null;
for (let scale = 1; scale <= 8; scale += 1) {
const gridW = Math.floor(deviceWidth / (16 * scale));
const gridH = Math.floor(deviceHeight / (16 * scale));
if (gridW < MIN_GRID_W || gridH < MIN_GRID_H) break;
best = { scale, gridW: Math.min(gridW, MAX_GRID_W), gridH: Math.min(gridH, MAX_GRID_H) };
if (gridW <= MAX_GRID_W && gridH <= MAX_GRID_H) break;
}
if (best) return best;
// Tiny frame: keep scale 1 and show whatever fits (at least 3x3 so zoning works).
return {
scale: 1,
gridW: Math.max(3, Math.floor(deviceWidth / 16)),
gridH: Math.max(3, Math.floor(deviceHeight / 16)),
};
}
export class Renderer {
readonly tilesize = 16;
readonly context: CanvasRenderingContext2D;
readonly background: CanvasRenderingContext2D;
readonly foreground: CanvasRenderingContext2D;
scale = 2;
dpr = 1;
camera: Camera;
tileset: HTMLImageElement | null = null;
supportsSilhouettes = true;
isDebugInfoVisible = false;
animatedTileCount = 0;
highTileCount = 0;
/** True on touch-first devices: hide hover cursor and silhouettes. */
touch = false;
private frameCount = 0;
private lastFpsTime = performance.now();
private realFPS = 0;
readonly FPS = 50;
readonly fixFlickeringTimer = new Timer(100);
constructor(
private readonly game: Game,
readonly canvas: HTMLCanvasElement,
readonly backcanvas: HTMLCanvasElement,
readonly forecanvas: HTMLCanvasElement,
) {
this.context = canvas.getContext('2d')!;
this.background = backcanvas.getContext('2d')!;
this.foreground = forecanvas.getContext('2d')!;
this.camera = new Camera(15, 7);
}
getWidth(): number {
return this.canvas.width;
}
getHeight(): number {
return this.canvas.height;
}
setTileset(tileset: HTMLImageElement): void {
this.tileset = tileset;
}
/** Fit the canvases to the available CSS box. Returns true when the grid changed. */
resize(cssWidth: number, cssHeight: number): boolean {
const dpr = Math.max(1, Math.min(4, window.devicePixelRatio || 1));
const fit = fitToFrame(Math.floor(cssWidth * dpr), Math.floor(cssHeight * dpr));
const changed =
fit.scale !== this.scale ||
fit.gridW !== this.camera.gridW ||
fit.gridH !== this.camera.gridH ||
dpr !== this.dpr;
this.scale = fit.scale;
this.dpr = dpr;
const { x, y } = this.camera;
this.camera = new Camera(fit.gridW, fit.gridH);
this.camera.setPosition(x, y);
const w = fit.gridW * this.tilesize * this.scale;
const h = fit.gridH * this.tilesize * this.scale;
for (const c of [this.canvas, this.backcanvas, this.forecanvas]) {
c.width = w;
c.height = h;
c.style.width = `${w / dpr}px`;
c.style.height = `${h / dpr}px`;
}
for (const ctx of [this.context, this.background, this.foreground]) {
ctx.imageSmoothingEnabled = false;
}
this.initFont();
return changed;
}
initFont(): void {
const s = this.scale;
this.setFontSize(s === 1 ? 10 : Math.round(s * 6.6));
}
setFontSize(size: number): void {
const font = `${size}px GraphicPixel`;
this.context.font = font;
this.background.font = font;
}
drawText(
text: string | number,
x: number,
y: number,
centered: boolean,
color?: string,
strokeColor?: string,
): void {
const ctx = this.context;
const strokeSize = this.scale <= 2 ? 3 : Math.round(this.scale * 1.66);
if (text !== '' && x && y) {
ctx.save();
if (centered) ctx.textAlign = 'center';
ctx.strokeStyle = strokeColor || '#373737';
ctx.lineWidth = strokeSize;
ctx.lineJoin = 'round';
ctx.strokeText(String(text), x, y);
ctx.fillStyle = color || 'white';
ctx.fillText(String(text), x, y);
ctx.restore();
}
}
drawCellRect(x: number, y: number, color: string): void {
const ctx = this.context;
ctx.save();
ctx.lineWidth = 2 * this.scale;
ctx.strokeStyle = color;
ctx.translate(x + 2, y + 2);
ctx.strokeRect(0, 0, this.tilesize * this.scale - 4, this.tilesize * this.scale - 4);
ctx.restore();
}
drawCellHighlight(x: number, y: number, color: string): void {
const s = this.scale;
const ts = this.tilesize;
this.drawCellRect(x * ts * s, y * ts * s, color);
}
drawTargetCell(): void {
const mouse = this.game.getMouseGridPosition();
if (
!this.touch &&
this.game.targetCellVisible &&
!(mouse.x === this.game.selectedX && mouse.y === this.game.selectedY)
) {
this.drawCellHighlight(mouse.x, mouse.y, this.game.targetColor);
}
}
drawSelectedCell(): void {
const sprite = this.game.cursors.target;
const anim = this.game.targetAnimation;
if (!this.game.selectedCellVisible) return;
if (this.touch) {
this.drawCellHighlight(this.game.selectedX, this.game.selectedY, 'rgb(51, 255, 0)');
} else if (sprite && anim) {
const frame = anim.currentFrame;
const s = this.scale;
const dx = this.game.selectedX * 16 * s;
const dy = this.game.selectedY * 16 * s;
this.context.save();
this.context.translate(dx, dy);
this.context.drawImage(sprite.image, frame.x, frame.y, sprite.width, sprite.height, 0, 0, sprite.width * s, sprite.height * s);
this.context.restore();
}
}
drawCursor(): void {
if (this.touch) return;
const cursor = this.game.currentCursor;
const s = this.scale;
if (cursor && cursor.isLoaded) {
this.context.drawImage(cursor.image, 0, 0, 14, 14, this.game.mouse.x, this.game.mouse.y, 14 * s, 14 * s);
}
}
drawScaledImage(
ctx: CanvasRenderingContext2D,
image: CanvasImageSource,
x: number,
y: number,
w: number,
h: number,
dx: number,
dy: number,
): void {
const s = this.scale;
ctx.drawImage(image, x, y, w, h, dx * s, dy * s, w * s, h * s);
}
drawTile(
ctx: CanvasRenderingContext2D,
tileid: number,
tileset: HTMLImageElement,
setW: number,
gridW: number,
cellid: number,
): void {
// -1 when tile is empty in Tiled. Don't attempt to draw it.
if (tileid === -1) return;
this.drawScaledImage(
ctx,
tileset,
getX(tileid + 1, setW) * this.tilesize,
Math.floor(tileid / setW) * this.tilesize,
this.tilesize,
this.tilesize,
getX(cellid + 1, gridW) * this.tilesize,
Math.floor(cellid / gridW) * this.tilesize,
);
}
drawEntity(entity: Entity): void {
const sprite = entity.sprite;
const shadow = this.game.shadows.small;
const anim = entity.currentAnimation;
if (!anim || !sprite) return;
const frame = anim.currentFrame;
const s = this.scale;
const x = frame.x;
const y = frame.y;
const w = sprite.width;
const h = sprite.height;
const ox = sprite.offsetX * s;
const oy = sprite.offsetY * s;
const dx = entity.x * s;
const dy = entity.y * s;
const dw = w * s;
const dh = h * s;
const ctx = this.context;
if (entity.isFading) {
ctx.save();
ctx.globalAlpha = entity.fadingAlpha;
}
this.drawEntityName(entity);
ctx.save();
if (entity.flipSpriteX) {
ctx.translate(dx + this.tilesize * s, dy);
ctx.scale(-1, 1);
} else if (entity.flipSpriteY) {
ctx.translate(dx, dy + dh);
ctx.scale(1, -1);
} else {
ctx.translate(dx, dy);
}
if (entity.isVisible()) {
if (entity.hasShadow() && shadow) {
ctx.drawImage(shadow.image, 0, 0, shadow.width, shadow.height, 0, entity.shadowOffsetY * s, shadow.width * s, shadow.height * s);
}
ctx.drawImage(sprite.image, x, y, w, h, ox, oy, dw, dh);
if (entity instanceof Item && entity.kind !== Entities.CAKE) {
const sparks = this.game.sprites.sparks;
const sanim = this.game.sparksAnimation;
if (sparks && sanim) {
const sw = sparks.width;
const sh = sparks.width;
ctx.drawImage(
sparks.image,
sparks.width * sanim.currentFrame.index,
sparks.height * sanim.row,
sw,
sh,
sparks.offsetX * s,
sparks.offsetY * s,
sw * s,
sh * s,
);
}
}
}
if (entity instanceof Character && !entity.isDead && entity.hasWeapon()) {
const weapon = this.game.sprites[entity.getWeaponName() ?? ''];
const weaponAnimData = weapon?.animationData[anim.name];
if (weapon && weaponAnimData) {
const index = frame.index < weaponAnimData.length ? frame.index : frame.index % weaponAnimData.length;
ctx.drawImage(
weapon.image,
weapon.width * index,
weapon.height * anim.row,
weapon.width,
weapon.height,
weapon.offsetX * s,
weapon.offsetY * s,
weapon.width * s,
weapon.height * s,
);
}
}
ctx.restore();
if (entity.isFading) ctx.restore();
}
drawEntities(): void {
this.game.forEachVisibleEntityByDepth((entity) => {
if (entity.isLoaded) this.drawEntity(entity);
});
}
drawEntityName(entity: Entity): void {
if (entity.name && entity instanceof Player) {
const color = entity.id === this.game.playerId ? '#fcda5c' : 'white';
this.drawText(entity.name, (entity.x + 8) * this.scale, (entity.y + entity.nameOffsetY) * this.scale, true, color);
}
}
private tilesetWidth(): number {
return this.tileset ? this.tileset.width / this.game.map.tilesize : 1;
}
drawTerrain(): void {
const m = this.game.map;
const tileset = this.tileset;
if (!tileset) return;
const setW = this.tilesetWidth();
this.game.forEachVisibleTile((id, index) => {
// Don't draw unnecessary tiles
if (!m.isHighTile(id) && !m.isAnimatedTile(id)) {
this.drawTile(this.background, id, tileset, setW, m.width, index);
}
}, 1);
}
drawAnimatedTiles(): void {
const m = this.game.map;
const tileset = this.tileset;
if (!tileset) return;
const setW = this.tilesetWidth();
this.animatedTileCount = 0;
this.game.forEachAnimatedTile((tile: AnimatedTile) => {
this.drawTile(this.context, tile.id, tileset, setW, m.width, tile.index);
this.animatedTileCount += 1;
});
}
drawHighTiles(ctx: CanvasRenderingContext2D): void {
const m = this.game.map;
const tileset = this.tileset;
if (!tileset) return;
const setW = this.tilesetWidth();
this.highTileCount = 0;
this.game.forEachVisibleTile((id, index) => {
if (m.isHighTile(id)) {
this.drawTile(ctx, id, tileset, setW, m.width, index);
this.highTileCount += 1;
}
}, 1);
}
drawFPS(): void {
const now = performance.now();
if (now - this.lastFpsTime >= 1000) {
this.realFPS = this.frameCount;
this.frameCount = 0;
this.lastFpsTime = now;
}
this.frameCount++;
this.drawText(`FPS: ${this.realFPS}`, 30, 30, false);
}
drawDebugInfo(): void {
if (this.isDebugInfoVisible) {
this.drawFPS();
this.drawText(`A: ${this.animatedTileCount}`, 100, 30, false);
this.drawText(`H: ${this.highTileCount}`, 140, 30, false);
}
}
drawCombatInfo(): void {
this.setFontSize(this.scale === 1 ? 10 : this.scale * 10);
this.game.infoManager.forEachInfo((info) => {
this.context.save();
this.context.globalAlpha = Math.max(0, info.opacity);
this.drawText(info.value, (info.x + 8) * this.scale, Math.floor(info.y * this.scale), true, info.fillColor, info.strokeColor);
this.context.restore();
});
this.initFont();
}
setCameraView(ctx: CanvasRenderingContext2D): void {
ctx.translate(-this.camera.x * this.scale, -this.camera.y * this.scale);
}
clearScreen(ctx: CanvasRenderingContext2D): void {
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
/** Small portrait of the player (armor + weapon), saved with the character. */
getPlayerImage(): string {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const player = this.game.player;
if (!player) return '';
const sprite = player.getArmorSprite() as Sprite;
const weapon = this.game.sprites[player.getWeaponName() ?? ''];
const shadow = this.game.shadows.small;
const spriteAnim = sprite.animationData.idle_down;
const row = spriteAnim.row;
const w = sprite.width;
const h = sprite.height;
canvas.width = w;
canvas.height = h;
ctx.clearRect(0, 0, w, h);
if (shadow) ctx.drawImage(shadow.image, 0, 0, shadow.width, shadow.height, -sprite.offsetX, -sprite.offsetY, shadow.width, shadow.height);
ctx.drawImage(sprite.image, 0, row * h, w, h, 0, 0, w, h);
if (weapon) {
ctx.drawImage(
weapon.image,
0,
weapon.height * row,
weapon.width,
weapon.height,
weapon.offsetX - sprite.offsetX,
weapon.offsetY - sprite.offsetY,
weapon.width,
weapon.height,
);
}
return canvas.toDataURL('image/png');
}
renderStaticCanvases(): void {
this.clearScreen(this.background);
this.background.save();
this.setCameraView(this.background);
this.drawTerrain();
this.background.restore();
}
renderFrame(): void {
const ctx = this.context;
this.clearScreen(ctx);
ctx.save();
this.setCameraView(ctx);
this.drawAnimatedTiles();
if (this.game.started) {
this.drawSelectedCell();
this.drawTargetCell();
}
this.drawEntities();
this.drawCombatInfo();
this.drawHighTiles(ctx);
ctx.restore();
// Overlay UI elements
this.drawCursor();
this.drawDebugInfo();
}
}
