Skip to content

Instantly share code, notes, and snippets.

@anderoonies
Last active September 19, 2023 18:27
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save anderoonies/20fa9eb8ade544d07cc4a65f4a56d4d6 to your computer and use it in GitHub Desktop.
Save anderoonies/20fa9eb8ade544d07cc4a65f4a56d4d6 to your computer and use it in GitHub Desktop.

note: This was written before I did a deep-dive on for loops, which I've explained here: https://gist.github.com/anderoonies/3478a7a873c8832ebc695a17024e9f31

This post uses slower for loops than ideal, but consistently throughout.

Embark

I first started working on a roguelike from scratch about 3 months ago, written in TypeScript and transpiled to Lua. I was using my JavaScript implementation of Brogue's dungeon architect. I ran into some performance bottlenecks—dungeon generation was taking more than 10 seconds per level and movement was taking about 500ms per input—and put it away.

To see if rewriting it in C could unclog those performance bottlenecks, I tried porting BrogueCE (written in C) to the Playdate. With pretty minimal changes I was able to get Brogue running in the Playdate Simulator, but on the Playdate it timed out during dungeon generation.

simulator_brogue

Anyway, Brogue itself would never be a great fit for the Playdate. A lot of the game's appeal is in its rich, colorful graphics, and a lot of the core gameplay and puzzles are designed around being able to see the entire dungeon at once. I wanted to design the game for Playdate, making the most of its sharp 1-bit graphics and limited controls.

To do that, I'm going back to TypeScript, which I'm more comfortable with, and making sure performance never gets out of hand.

Profiling drawing

Drawing the dungeon happens constantly, so to make sure I was starting with a good base, I wanted to profile a few different approaches.

I'll compare:

  1. drawing each tile in the dungeon as an individual sprite
  2. drawing each tile as an image directly without the overhead of sprites
  3. drawing the entire dungeon as a single sprite using a tilemap.

Drawing tiles with sprites

In my first draft, every tile in the dungeon, even ones that were off-screen, was represented by a sprite. Whenever the player moved, every sprite would be shifted in the opposite direction, essentially sliding the dungeon around underneath the player. For a 40x24 dungeon, this was about 1000 calls to sprite.moveTo every time the player moved, even though only a fraction of the sprites being manipulated are actually visible on the screen.

To profile this naïve approach that I know is bad, I initialized a 40x24 array of sprites, then shifted the entire grid 20px left then 20px right using moveTo.

wiggle

The Playdate can move these sprites about 22 times in 5 seconds, or around 228 milliseconds per update.

try {
  require("CoreLibs/sprites");
  require("CoreLibs/graphics");
} catch (e) {}

const gfx = playdate.graphics;
const images = [
  gfx.image.new("assets/at"),
  gfx.image.new("assets/door_1"),
  gfx.image.new("assets/floor_1"),
  gfx.image.new("assets/potion"),
  gfx.image.new("assets/staff"),
  gfx.image.new("assets/sword"),
  gfx.image.new("assets/stairsup"),
  gfx.image.new("assets/stairsdown"),
  gfx.image.new("assets/sword"),
  gfx.image.new("assets/wall"),
];

function gridWithDimensions<T>(
  width: number,
  height: number,
  initializer: (row: number, col: number) => T
): Array<Array<T>> {
  // lua bridging stuff
  const rows: Array<Array<T>> = [];
  for (let rowI = 0; rowI < height; rowI++) {
    rows[rowI] = [];
    for (let colI = 0; colI < width; colI++) {
      rows[rowI][colI] = initializer(rowI, colI);
    }
  }
  return rows;
}

const sprites = gridWithDimensions(40, 24, (rowI, colI) => {
  const img = images[colI % images.length];
  const sprite = gfx.sprite.new(img);
  sprite.moveTo(colI * 20, rowI * 20);
  sprite.add();
  return sprite;
});

playdate.AButtonDown = () => {
  playdate.resetElapsedTime();
  let nIterations = 0;
  let left = true;
  while (playdate.getElapsedTime() < 5) {
    sprites.forEach((row) =>
      row.forEach((col) => {
        col.moveBy((left ? -1 : 1) * 20, 0);
      })
    );
    left = !left;
    gfx.sprite.update();
    nIterations++;
  }
  print(nIterations);
};

playdate.update = () => {
};

export default {};

This is straightforward, but unoptimized, because it's calling moveTo on sprites that are off-screen. A more efficient approach would be to only call sprite.moveTo for tiles that will be, or just were, visible. If we assume that the player can only move 1 tile at a time, only tiles that are within 1 tile of the viewport need to be updated.

To do this, I define an updateRect that surrounds the viewport with an extra tile on every side. Movement shifts the upper left corner of the viewport, then for every sprite in the updateRect, if it's currently in the viewport it's set visible with setVisible, and hidden otherwise.

This approach wouldn't work if the player ended up somewhere else with a scroll of teleportation, but I can worry about that later.

import { clamp, gridWithDimensions } from "./utils";

try {
  require("CoreLibs/sprites");
  require("CoreLibs/graphics");
} catch (e) {}

const gfx = playdate.graphics;
const dungeon = [
  "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "xoooooooooooooooooodoooooxoooooooooooooooooodooooox",
  // etc...
];
const tileSize = 20;
const images: { [key: string]: playdate.Image } = {
  x: gfx.image.new("assets/wall"),
  o: gfx.image.new("assets/floor"),
  d: gfx.image.new("assets/door_2"),
};

const viewportWidth = playdate.display.getWidth() / tileSize;
const viewportHeight = playdate.display.getHeight() / tileSize;
let viewportX = 0;
let viewportY = 0;

const inViewport = (x: number, y: number) => {
  return (
    x < viewportX + viewportWidth &&
    x >= viewportX &&
    y < viewportY + viewportHeight &&
    y >= viewportY
  );
};

const inBounds = (x: number, y: number) => {
  return x >= 0 && x < dungeon[0].length && y >= 0 && y < dungeon.length;
};

const tileSprites = gridWithDimensions(
  dungeon[0].length,
  dungeon.length,
  (rowI, colI) => {
    const char = dungeon[rowI][colI];
    const img = images[char];
    const sprite = gfx.sprite.new(img);
    sprite.setCenter(0, 0);
    sprite.moveTo(colI * 20, rowI * 20);
    sprite.add();
    sprite.setVisible(inViewport(colI, rowI));
    return sprite;
  }
);

type Rectangle = {
  left: number;
  right: number;
  top: number;
  bottom: number;
};

const redrawSprites = (dx: number, dy: number) => {
  let updateRect: Rectangle = {
    top: viewportY + dy,
    bottom: viewportY + viewportHeight + dy,
    left: viewportX + dx,
    right: viewportX + viewportWidth + dx,
  };
  viewportX = clamp(viewportX + dx, 0, dungeon[0].length - viewportWidth);
  viewportY = clamp(viewportY + dy, 0, dungeon.length - viewportHeight);
  for (let y = updateRect.top - 1; y <= updateRect.bottom; y++) {
    for (let x = updateRect.left - 1; x <= updateRect.right; x++) {
      if (!inBounds(x, y)) {
        continue;
      }
      const sprite = tileSprites[y][x];
      const displayX = x - viewportX;
      const displayY = y - viewportY;
      if (inViewport(x, y)) {
        sprite.moveTo(displayX * tileSize, displayY * tileSize);
        sprite.setVisible(true);
      } else {
        sprite.setVisible(false);
      }
    }
  }
  gfx.sprite.update();
};
playdate.upButtonDown = () => {
  redrawSprites(0, -1);
};
playdate.leftButtonDown = () => {
  redrawSprites(-1, 0);
};
playdate.rightButtonDown = () => {
  redrawSprites(1, 0);
};
playdate.downButtonDown = () => {
  redrawSprites(0, 1);
};
playdate.update = () => {
};

export default {};

The performance of redrawing the dungeon this way should also scale well with the size of the dungeon, since only sprites around the viewport are being updated.

To profile this approach, I center the viewport in a dungeon of sprites, then wiggle the camera around. Each movement of the camera is 1 redraw. I tested this with dungeons of size 40x20, 60x40, and 100x50. In all cases, the Playdate could perform 36 redraws in 5 seconds, about 140ms per draw.

We can take it a step further by removing the sprites from the global sprite list rather than marking them as hidden, which gets us up to 56 redraws in 5 seconds, 89ms per draw.

wiggly (Like this, but faster)

Drawing tiles as images

The main performance benefit of using sprites is that the Playdate can selectively redraw rectangles for sprites that have changed. However, because nearly every move causes the entire visible dungeon to redraw, I was curious if this optimization was useful.

To test, I draw a 12x20 grid of 20px square images that randomly change to another image every update.

On device, 240 20px images can be drawn 185 times in 5 seconds, or around 27 milliseconds per draw.

drawimage

try {
  require("CoreLibs/sprites");
  require("CoreLibs/graphics");
} catch (e) {}

const gfx = playdate.graphics;
const shadow = gfx.image.new("assets/shadow.png");
const images = [
  gfx.image.new("assets/at"),
  gfx.image.new("assets/door_1"),
  gfx.image.new("assets/floor_1"),
  gfx.image.new("assets/potion"),
  gfx.image.new("assets/staff"),
  gfx.image.new("assets/sword"),
  gfx.image.new("assets/stairsup"),
  gfx.image.new("assets/stairsdown"),
  gfx.image.new("assets/sword"),
  gfx.image.new("assets/wall"),
];

const refresh = () => {
  gfx.clear();
  for (let row = 0; row < 12; row++) {
    for (let col = 0; col < 20; col++) {
      const idx = Math.floor(Math.random() * images.length);
      const img = images[idx];
      img.draw(col * 20, row * 20);
    }
  }
};

playdate.AButtonDown = () => {
  playdate.resetElapsedTime();
  let nIterations = 0;
  while (playdate.getElapsedTime() < 5) {
    refresh();
    nIterations++;
  }
  print(nIterations);
};

playdate.update = () => {};

export default {};

This seems clearly faster than drawing with sprites, but sprites do give useful functionality like selective redrawing and animators.

Drawing tiles with a tilemap

To be honest, I did not see the section in the SDK documentation on tilemaps until I'd tried my hardest to optimize these two previous approaches. Tilemaps seem to be designed for handling problems like this efficiently and simply by combining a number of images into a single entity which can be moved around as one. Rather than needing to operate on each tile in the dungeon separately, a tilemap containing all of the tiles can be moved at once.

Let's dig into how tilemaps work!

Tilemaps are essentially an array of indices into an imagetable. First, I'll construct an imagetable that will hold the tile images:

type TileDefinition = {
  tilemapIndex: number;
  image: playdate.Image;
};

const dungeonTiles: { [key: string]: TileDefinition } = {
  x: {
    tilemapIndex: 1,
    image: gfx.image.new("assets/wall"),
  },
  o: {
    tilemapIndex: 2,
    image: gfx.image.new("assets/empty"),
  },
  d: {
    tilemapIndex: 3,
    image: gfx.image.new("assets/door_2"),
  },
};

const imageTable = gfx.imagetable.new(
  3, // 3 tiles for now
  3, // 3x1 imagetable
  400 // 20x20 image
);
Object.keys(dungeonTiles).forEach((key) => {
  const { tilemapIndex, image } = dungeonTiles[key];
  imageTable.setImage(tilemapIndex, image);
});

Then I'll construct a one-dimensional array of the indices into that tilemap that represent the dungeon:

const dungeonDefinition = [
  "xxxxx",
  "xooox",
  "xooox",
  "xxxdx"
];

const oneDimensionalIndexArray = [];

for (let row = 0; row < dungeonDefinition.length; row++) {
  for (let col = 0; col < dungeonDefinition[row].length; col++) {
    oneDimensionalIndexArray.push(
      dungeonTiles[dungeonDefinition[row][col]].tilemapIndex
    );
  }
}

const tilemap = gfx.tilemap.new();
tilemap.setImageTable(imageTable);
tilemap.setTiles(
  oneDimensionalIndexArray,
  dungeonDefinition[0].length
);

Then just draw the tilemap with tilemap.draw(0, 0).

Tada!

tilemap

Shifting the tilemap can either be done by setting a drawing offset with graphics.setDrawOffset(x, y) or by changing the position the tilemap is drawn at. Setting a draw offset would also automatically move monsters or items in the dungeon, so it seems prudent to start using that now.

I'm implementing this tilemap as a subclass of sprite, which allows it to define a rectangle that should be redrawn when graphics.sprite.update() is called.

class DungeonSprite extends playdate.graphics.sprite {
  lastX: number = 0;
  lastY: number = 0;
  drawX: number = 0;
  drawY: number = 0;
  tilemap: playdate.graphics.TileMap;

  // ...
  
  draw(x: number, y: number, width: number, height: number): void {
    this.tilemap.draw(0, 0);
  }

  update() {
    if (this.drawX !== this.lastX || this.drawY !== this.lastY) {
      gfx.setDrawOffset(this.drawX, this.drawY);
      gfx.sprite.addDirtyRect(0, 0, 400, 240);
      this.lastX = this.drawX;
      this.lastY = this.drawY;
    }
  }
}

To profile this, I shift the dungeon sprite by 20px and redraw it, following the same wiggling pattern as above. When it's redrawn with graphics.sprite.update(), it is the only active sprite, which makes for a very fast update.

I've tested this with a 40x20 tile dungeon and a 80x40 tile dungeon. Both were able to update 488 times in 5 seconds, or 10.24 milliseconds per draw.

try {
  require("CoreLibs/sprites");
  require("CoreLibs/graphics");
} catch (e) {}

const gfx = playdate.graphics;
const dungeonDefinition = [
  "xxxxxxxxxxxxxxx", // ...etc
];

type TileDefinition = {
  tilemapIndex: number;
  image: playdate.Image;
};

const dungeonTiles: { [key: string]: TileDefinition } = {
  x: {
    tilemapIndex: 1,
    image: gfx.image.new("assets/wall"),
  },
  o: {
    tilemapIndex: 2,
    image: gfx.image.new("assets/empty"),
  },
  d: {
    tilemapIndex: 3,
    image: gfx.image.new("assets/door_2"),
  },
};

class Dungeon extends playdate.graphics.sprite {
  tilemap: playdate.graphics.TileMap;
  lastX: number = 0;
  lastY: number = 0;
  drawX: number = 0;
  drawY: number = 0;
  constructor() {
    super();
    const imageTable = gfx.imagetable.new(
      3, // 3 tiles for now
      3, // 3x1 imagetable
      400 // 20x20 image
    );
    Object.keys(dungeonTiles).forEach((key) => {
      const { tilemapIndex, image } = dungeonTiles[key];
      imageTable.setImage(tilemapIndex, image);
    });

    const oneDimensionalIndexArray = [];

    for (let row = 0; row < dungeonDefinition.length; row++) {
      for (let col = 0; col < dungeonDefinition[row].length; col++) {
        oneDimensionalIndexArray.push(
          dungeonTiles[dungeonDefinition[row][col]].tilemapIndex
        );
      }
    }

    this.tilemap = gfx.tilemap.new();
    this.tilemap.setImageTable(imageTable);
    this.tilemap.setTiles(
      oneDimensionalIndexArray,
      dungeonDefinition[0].length
    );
    this.setBounds(
      0,
      0,
      dungeonDefinition[0].length * 20,
      dungeonDefinition.length * 20
    );
  }

  shift(dx: number, dy: number) {
    this.drawX += dx * 20;
    this.drawY += dy * 20;
  }

  draw(x: number, y: number, width: number, height: number): void {
    this.tilemap.draw(0, 0);
  }

  update() {
    if (this.drawX !== this.lastX || this.drawY !== this.lastY) {
      gfx.setDrawOffset(this.drawX, this.drawY);
      gfx.sprite.addDirtyRect(0, 0, 400, 240);
      this.lastX = this.drawX;
      this.lastY = this.drawY;
    }
  }
}

const dungeon = new Dungeon();
dungeon.add();

playdate.leftButtonDown = () => {
  dungeon.shift(1, 0);
};
playdate.rightButtonDown = () => {
  dungeon.shift(-1, 0);
};
playdate.upButtonDown = () => {
  dungeon.shift(0, 1);
};
playdate.downButtonDown = () => {
  dungeon.shift(0, -1);
};

playdate.update = () => {
  gfx.sprite.update();
};

const refresh = () => {
  dungeon.shift(-1, 0);
  gfx.sprite.update();
  dungeon.shift(0, 1);
  gfx.sprite.update();
  dungeon.shift(0, -1);
  gfx.sprite.update();
  dungeon.shift(1, 0);
  gfx.sprite.update();
};

const profile = () => {
  playdate.resetElapsedTime();
  let nIterations = 0;
  while (playdate.getElapsedTime() < 5) {
    refresh();
    nIterations += 4;
  }
  print(nIterations);
};
playdate.AButtonDown = () => {
  profile();
};

export default {};

Summary

Here's each approach mentioned above and how it performed.

Approach Updates/5 seconds Milliseconds/update
1 sprite per tile, 800 sprites 22 227
1 sprite per tile, updateRect, 800 sprites 56 89
1 sprite per tile, updateRect, 2400 sprites 56 89
1 sprite per tile, updateRect, 5000 sprites 56 89
tile as image 185 27
tilemap, 40x20 dungeon 488 10
tilemap, 80x40 dungeon 488 10

What I do with the shadows

A key element in roguelikes is light and shadow; the player has an implied headlamp on their head that shines in all directions, illuminating the dungeon.

In my first draft of this game, in order to implement shadows, I blended an image with a black square at different opacities using the SDK's image.blendWithImage function:

shadowgradient

I wanted to understand how badly performance degrades when drawing shadows.

In either approach, drawing tiles as sprites or as plain images, the shadowed images still need to be blended the same number of times. Because of that, I'm just comparing drawing shadows to drawing tiles as images, not as sprites.

I profiled each of the dithering algorithms mentioned here, blending the images each time they were drawn rather than baking the dithered images. This is to simulate the dynamic lighting I'm hoping to achieve in the game; rather than having a single dark value for each tile, I want to be able to calculate light on-the-fly based on light from torches and the player's lantern.

"Performance" here, again, is a measure of how many times a 12x20 grid of 20px images can be drawn in 5 seconds, this time blended with an all-black image at an opacity based on its distance from the center of the grid. I performed 3 runs, and averaged them. Remember, the Playdate could draw the grid of images about 185 times in 5 seconds.

Dithering Algorithm Performance Image
kDitherTypeNone 19.67 none
kDitherTypeDiagonalLine 18 diagonalline
kDitherTypeVerticalLine 18 verticalline
kDitherTypeHorizontalLine 18 horizontalline
kDitherTypeScreen 18 screen
kDitherTypeBayer2x2 (no relation) 18 bayer2x2
kDitherTypeBayer4x4 18 bayer4x4
kDitherTypeBayer8x8 17.67 bayer8x8
kDitherTypeFloydSteinberg 16.67 floydsteinberg
kDitherTypeBurkes 16 burkes
kDitherTypeAtkinson 16 atkinson
try {
  require("CoreLibs/sprites");
  require("CoreLibs/graphics");
} catch (e) {}

const gfx = playdate.graphics;
const ditherTypes = [
  gfx.image.kDitherTypeNone,
  gfx.image.kDitherTypeDiagonalLine,
  gfx.image.kDitherTypeVerticalLine,
  gfx.image.kDitherTypeHorizontalLine,
  gfx.image.kDitherTypeScreen,
  gfx.image.kDitherTypeBayer2x2,
  gfx.image.kDitherTypeBayer4x4,
  gfx.image.kDitherTypeBayer8x8,
  gfx.image.kDitherTypeFloydSteinberg,
  gfx.image.kDitherTypeBurkes,
  gfx.image.kDitherTypeAtkinson,
];

const shadow = gfx.image.new("assets/shadow.png");
const dungeon = [
  "xxxxxxxxxxxxxxxxxxxx",
  "xoooooooooooooooooox",
  "xoooooooooooooooooox",
  "xxxxxxxxxxxxdxxxxxxx",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xooooodoooooooooooox",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xxxxxxxxxxxxxxxxxxxx",
];
const images: { [key: string]: playdate.Image } = {
  x: gfx.image.new("assets/wall"),
  o: gfx.image.new("assets/floor"),
  d: gfx.image.new("assets/door_2"),
};

const map = (v: number, a: number, b: number, c: number, d: number) => {
  return ((v - a) / (b - a)) * (d - c) + c;
};

const refresh = (ditherType: playdate.graphics.image.DitherType) => {
  gfx.clear();
  for (let row = 0; row < 12; row++) {
    for (let col = 0; col < 20; col++) {
      const char = dungeon[row][col];
      const img = images[char].blendWithImage(
        shadow,
        map(Math.sqrt((col - 10) ** 2 + (row - 6) ** 2), 0, 12, 1, 0),
        ditherType
      );
      img.draw(col * 20, row * 20);
    }
  }
};

let ditherTypeIndex = 0;
const test = () => {
  const ditherType = ditherTypes[ditherTypeIndex];
  let nIterations = 0;
  playdate.resetElapsedTime();
  while (playdate.getElapsedTime() < 5) {
    refresh(ditherType);
    nIterations++;
  }
  print(`DitherType: ${ditherType}, iter: ${nIterations}`);
};

playdate.AButtonDown = () => {
  test();
};

playdate.upButtonDown = () => {
  ditherTypeIndex =
    (ditherTypeIndex + ditherTypes.length - 1) % ditherTypes.length;
};
playdate.downButtonDown = () => {
  ditherTypeIndex = (ditherTypeIndex + 1) % ditherTypes.length;
};

playdate.update = () => {};

export default {};

Everything here is roughly the same, and roughly bad, taking about 300ms for each draw. It's about 10x slower to draw a shadowed dungeon than an unshadowed one. Blending images is slow, and should be done as little as possible.

Also, you can see that Floyd-Steinberg, Burkes, and Atkinson behave oddly. The darkest values are at about 60% opacity. There's some interesting behavior when you blend with a clear image that doesn't happen when blending with a white image. Here's two gradients of each, one blending a black tile with my wall tile and one blending a black tile with a clear tile:

weirdoes

[
  gfx.image.kDitherTypeFloydSteinberg,
  gfx.image.kDitherTypeBurkes,
  gfx.image.kDitherTypeAtkinson,
].forEach((ditherType, idx) => {
  const y = 60 + idx * 40;
  for (let x = 20; x < 380; x += 20) {
    const opacity = map(x, 20, 380, 1, 0);
    wall.blendWithImage(shadow, opacity, ditherType).draw(x, y);
    empty.blendWithImage(shadow, opacity, ditherType).draw(x, y + 20);
  }
});

Prebaking

A potential shortcut asks whether blending these tiles with a black square might be roughly equivalent to drawing a pre-baked, blended black square overtop of the tile. Prebaking limits the range of opacity options, but I can pregenerate a semi-transparent shadow tile for 10 different opacities, then draw it on top of the tile.

This shortcut would mean that shadowing a tile only requires an additional call to image.draw(), which is faster than blending a new image.

Here's how each of those dithering algorithms appear when drawing an overlapping pre-baked shadow, and the code used to generate them.

Dithering Algorithm Image
kDitherTypeNone none
kDitherTypeDiagonalLine diagonalline
kDitherTypeVerticalLine verticalline
kDitherTypeHorizontalLine horizontalline
kDitherTypeScreen screen
kDitherTypeBayer2x2 (no relation) bayer2x2
kDitherTypeBayer4x4 bayer4x4
kDitherTypeBayer8x8 bayer8x8
kDitherTypeFloydSteinberg floydsteinberg
kDitherTypeBurkes burkes
kDitherTypeAtkinson atkinson
try {
  require("CoreLibs/sprites");
  require("CoreLibs/graphics");
} catch (e) {}

const gfx = playdate.graphics;
const ditherTypes = [
  gfx.image.kDitherTypeNone,
  gfx.image.kDitherTypeDiagonalLine,
  gfx.image.kDitherTypeVerticalLine,
  gfx.image.kDitherTypeHorizontalLine,
  gfx.image.kDitherTypeScreen,
  gfx.image.kDitherTypeBayer2x2,
  gfx.image.kDitherTypeBayer4x4,
  gfx.image.kDitherTypeBayer8x8,
  gfx.image.kDitherTypeFloydSteinberg,
  gfx.image.kDitherTypeBurkes,
  gfx.image.kDitherTypeAtkinson,
];

const shadow = gfx.image.new("assets/shadow.png");
const empty = gfx.image.new(20, 20, gfx.kColorClear);
const dungeon = [
  "xxxxxxxxxxxxxxxxxxxx",
  "xoooooooooooooooooox",
  "xoooooooooooooooooox",
  "xxxxxxxxxxxxdxxxxxxx",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xooooodoooooooooooox",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xoooooxoooooooooooox",
  "xxxxxxxxxxxxxxxxxxxx",
];
const images: { [key: string]: playdate.Image } = {
  x: gfx.image.new("assets/wall"),
  o: gfx.image.new("assets/floor"),
  d: gfx.image.new("assets/door_2"),
};

const map = (v: number, a: number, b: number, c: number, d: number) => {
  return ((v - a) / (b - a)) * (d - c) + c;
};

function arrayWithLength<T>(
  length: number,
  initializer: (idx: number) => T
): Array<T> {
  const arr: Array<T> = [];
  for (let i = 0; i < length; i++) {
    arr[i] = initializer(i);
  }
  return arr;
}

let ditherTypeIndex = 0;
let prebakedShadows = arrayWithLength(10, (idx: number) => {
  return empty.blendWithImage(shadow, idx / 10, ditherTypes[ditherTypeIndex]);
});
const refresh = () => {
  gfx.clear();
  for (let row = 0; row < 12; row++) {
    for (let col = 0; col < 20; col++) {
      const char = dungeon[row][col];
      const img = images[char];
      const darkness = Math.floor(
        map(
          Math.sqrt((col - 10) ** 2 + (row - 6) ** 2),
          0,
          12,
          prebakedShadows.length - 1,
          0
        )
      );
      img.draw(col * 20, row * 20);
      const shadow = prebakedShadows[darkness];
      shadow.draw(col * 20, row * 20);
    }
  }
};

const test = () => {
  const ditherType = ditherTypes[ditherTypeIndex];
  prebakedShadows = prebakedShadows.map((_, i) => {
    return empty.blendWithImage(shadow, i / 10, ditherType);
  });
  let nIterations = 0;
  playdate.resetElapsedTime();
  while (playdate.getElapsedTime() < 5) {
    refresh();
    nIterations++;
  }
  print(`DitherType: ${ditherType}, iter: ${nIterations}`);
};

playdate.AButtonDown = () => {
  test();
};

playdate.upButtonDown = () => {
  ditherTypeIndex =
    (ditherTypeIndex + ditherTypes.length - 1) % ditherTypes.length;
};
playdate.downButtonDown = () => {
  ditherTypeIndex = (ditherTypeIndex + 1) % ditherTypes.length;
};

playdate.update = () => {};

export default {};

You can see these vary slightly in appearance. Here are a few of these pulled out for comparison:

Algorithm Blended on the Fly Prebaked
Bayer2x2 bayer2x2 bayer2x2
Bayer4x4 bayer4x4 bayer4x4

These results could be tuned by making darkness a non-linear function of distance. I'm assuming I can get the result I want with some tuning, and just looking at performance for now.

The performance for these is all roughly 86 draws in 5 seconds, or 58 milliseconds per draw. Performance doesnt vary based on the dithering algorithm, since the shadow images are prebaked.

Summary

Drawing with sprites was slower than drawing directly with images; updating a viewport of sprite tiles took about 60ms, while updating a viewport of image tiles took about 27ms. Adding shadows roughly doubled this time to

Tradeoffs

It's also important to consider that sprites give a lot more flexibility for cute things like animations. Most tiles probably won't have these, but the flexibility is important.

bounce

try {
  require("CoreLibs/sprites");
  require("CoreLibs/graphics");
  require("CoreLibs/frameTimer");
} catch (e) {}

const gfx = playdate.graphics;
const playerSprite = gfx.sprite.new(gfx.image.new("assets/at").scaledImage(4));
playerSprite.setCenter(0.5, 0.5);
playerSprite.moveTo(200, 120);
playerSprite.add();

const animationTimer = playdate.frameTimer.new(
  30,
  0,
  0.125,
  playdate.easingFunctions.inOutSine
);
animationTimer.easingAmplitude = 1;
animationTimer.repeats = true;
animationTimer.reverses = true;
animationTimer.updateCallback = () => {
  playerSprite.setCenter(0.5, animationTimer.value);
};

playdate.update = () => {
  gfx.sprite.update();
  playdate.frameTimer.updateTimers();
};

export default {};
@anderoonies
Copy link
Author

Also, here's what the (prebaked!) shadows look like in the current build:
waklin

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment