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