body { margin: 0; }
const TAU = 2 * Math.PI;

class Actor extends Phaser.GameObjects.Sprite {
  constructor(scene, x, y, texture) {
    super(scene, x, y, texture);
    scene.add.existing(this);
    this.halfWidth;
    this.halfHeight;
  }

  getBottomLeft(output) {
    if (!output) {
      output = new Phaser.Math.Vector2();
    }
    output.x = Math.floor(this.x - this.halfWidth);
    output.y = Math.floor(this.y + this.halfHeight);
    return output;
  }

  getBottomRight(output) {
    if (!output) {
      output = new Phaser.Math.Vector2();
    }
    output.x = Math.floor(this.x + this.halfWidth - 1);
    output.y = Math.floor(this.y + this.halfHeight);
    return output;
  }

  getRect(output) {
    if (!output) {
      output = new Phaser.Geom.Rectangle();
    }
    output.set(
      this.x - this.halfWidth,
      this.y - this.halfHeight,
      2 * this.halfWidth,
      2 * this.halfHeight
    );
    return output;
  }

  isAir(coord) {
    return this.scene.canvas.getPixel(coord.x, coord.y).a === 0;
  }

  checkBelow() {
    const bottomleft = this.getBottomLeft();
    const bottomright = this.getBottomRight();

    let leftbelow = 0;
    while (leftbelow < 3) {
      if (!this.isAir(bottomleft)) break;

      leftbelow++;
      bottomleft.add(Phaser.Math.Vector2.DOWN);
    }

    let rightbelow = 0;
    while (rightbelow < 3) {
      if (!this.isAir(bottomright)) break;

      rightbelow++;
      bottomright.add(Phaser.Math.Vector2.DOWN);
    }

    return {
      isOnGround: leftbelow === 0 || rightbelow === 0,
      leftbelow: leftbelow,
      rightbelow: rightbelow,
      maxbelow: Math.max(leftbelow, rightbelow),
      minbelow: Math.min(leftbelow, rightbelow)
    };
  }
}

class Lemming extends Actor {
  constructor(scene, x, y) {
    super(scene, x, y, "lemming");

    this.direction = 1;
    this.velocity = 0.2;
    this.climbheight = 2;
    this.status = Lemming.Status.Falling;
    this.statusBelow = this.checkBelow();
    this.statusFront;
    this.debug = false;

    this.on("animationcomplete-dig", this.onDigComplete, this);
    this.once("animationcomplete-explode", this.onExplodeComplete, this);
  }

  update(t, d) {
    this.statusBelow = this.checkBelow();

    const prevStatus = this.status;

    if (this.statusBelow.isOnGround) {
      if (prevStatus === Lemming.Status.Falling) {
        this.status = Lemming.Status.Walking;
      }
    } else {
      this.status = Lemming.Status.Falling;
    }

    switch (this.status) {
      case Lemming.Status.Falling:
        this.disableInteractive();
        this.y += 0.5;
        if (this.statusBelow.leftbelow > 2 && this.statusBelow.rightbelow > 2) {
          this.play("fall", true);
        }
        break;
      case Lemming.Status.Walking:
        this.setInteractive();
        this.play("walk", true);
        this.velocity = 0.2;
        this.halfWidth = 3;
        this.halfHeight = 6;
        this.statusFront = this.checkFront(this.direction);
        if (this.statusFront.canProceed) {
          this.x += this.velocity * this.direction;
          // rise up to new ground level
          this.y -= this.statusFront.height;
        } else {
          this.direction *= -1; // turn the lemming around if the ground in front is too high to climb
          this.setFlipX(this.direction === 1 ? false : true);
        }
        break;
      case Lemming.Status.Digging:
        this.disableInteractive();
        this.halfWidth = 6;
        this.play("dig", true);
        break;
      case Lemming.Status.Exploding:
        this.disableInteractive();
        this.play("explode", true);
        break;
    }

    if (this.debug) {
      const bl = this.getBottomLeft();
      const br = this.getBottomRight();
      const ctx = this.scene.debugCanvas.context;
      const { leftbelow, rightbelow, minbelow, maxbelow } = this.statusBelow;

      ctx.fillStyle = this.statusBelow.isOnGround ? "red" : "yellow";
      ctx.fillRect(bl.x, bl.y, 1, 1 + leftbelow);
      ctx.fillRect(br.x, br.y, 1, 1 + rightbelow);
      ctx.fillText(this.status, this.x, this.y);
      // ctx.fillText(`${leftbelow} ${rightbelow}`, this.x, this.y);
    }
  }

  checkFront(direction) {
    let positioninfront = new Phaser.Math.Vector2();
    let height = 0;
    let canProceed = false;
    // find the height of the ground in front of a lemming
    // up to the maximum height a lemming can climb
    while (!canProceed && height <= this.climbheight) {
      // the pixel 'in front' of a lemming will depend on the direction it's traveling
      if (direction === 1) {
        // facing right
        const { x, y } = this.getBottomRight();
        positioninfront.set(x + 1, y - 1 - height);
      } else {
        // facing left
        const { x, y } = this.getBottomLeft();
        positioninfront.set(x - 1, y - 1 - height);
      }
      if (this.isAir(positioninfront)) canProceed = true;

      height++;
    }

    if (this.debug) {
      const ctx = this.scene.debugCanvas.context;
      ctx.fillStyle = canProceed ? "#0f0" : "#f00";
      ctx.fillRect(positioninfront.x, positioninfront.y, 1, 1);
    }

    return { canProceed: canProceed, height: height - 1 };
  }

  onDigComplete() {
    const left = this.getBottomLeft();
    const right = this.getBottomRight();
    const ctx = this.scene.canvas.context;

    ctx.fillRect(left.x, left.y - 2, right.x - left.x, 3);

    this.scene.canvas.update();
    console.count('update');

    this.y += 1;
  }

  onExplodeComplete() {
    Phaser.Utils.Array.Remove(this.scene.lemmings, this);

    const ctx = this.scene.canvas.context;

    ctx.beginPath();
    ctx.arc(this.x, this.y, 16, 0, TAU);
    ctx.fill();

    this.scene.canvas.update();
    console.count('update');

    this.destroy();
  }

  onPointerDown(pointer) {
    if (!this.statusBelow.isOnGround) return;

    if (pointer.event.shiftKey) {
      this.status = Lemming.Status.Exploding;
    } else {
      this.status = Lemming.Status.Digging;
    }
  }
}

Lemming.Status = {
  Walking: 1,
  Falling: 2,
  Digging: 3,
  Exploding: 4
};

class Game extends Phaser.Scene {
  constructor() {
    super({ key: "Game" });

    this.lemmings = [];
    this.max_lemmings = 10;
    this.startPos = new Phaser.Math.Vector2(100, 100);
    this.timer = 0;
    this.interval = 10;
  }

  preload() {
    this.load.setBaseURL("https://raw.githubusercontent.com/cedarcantab/LEMMINGS/main/");
    this.load.image("level1", "assets/level1.png");
    this.load.spritesheet("lemming", "assets/lemming.png", {
      frameWidth: 16,
      frameHeight: 16
    });
    this.load.spritesheet("cursors", "assets/cursors.png", {
      frameWidth: 29,
      frameHeight: 29
    });
  }

  create() {
    this.debugCanvas = this.textures.createCanvas("debug", 512, 256);
    this.debugCanvas.context.font = "small-caption";

    this.canvas = this.textures.createCanvas("canvastexture", 512, 256);
    this.canvas.drawFrame("level1", "__BASE", 0, 0);
    this.canvas.context.globalCompositeOperation = "destination-out";
    this.add.image(0, 0, "canvastexture").setOrigin(0).setAlpha(1);

    const cam = this.cameras.main;
    cam.setBounds(0, 0, 512, 256);
    cam.setScroll(115, 0);

    this.createAnims();

    this.cursor = this.add.image(270, 100, "cursors", 3).setScale(0.5);
    this.add.image(0, 0, "debug").setOrigin(0, 0);

    const timer = this.time.addEvent({
      delay: 1000,
      startAt: 1000,
      callback: this.spawn,
      callbackScope: this,
      repeat: this.max_lemmings - 1
    });

    this.input.on("pointermove", (pointer) => {
      this.cursor.x = pointer.worldX;
      this.cursor.y = pointer.worldY;
    });

    this.input.on("gameobjectover", (pointer, gameObject) => {
      this.cursor.setFrame(2);
    });
    this.input.on("gameobjectout", (pointer, gameObject) => {
      this.cursor.setFrame(3);
    });
    this.input.on("gameobjectdown", (pointer, gameObject) => {
      gameObject.onPointerDown(pointer);
    });
  }

  spawn() {
    const lemming = new Lemming(this, 220, 40).setInteractive();
    this.lemmings.push(lemming);
  }

  createAnims() {
    this.anims.create({
      key: "walk",
      frames: this.anims.generateFrameNumbers("lemming", { start: 0, end: 8 }),
      frameRate: 10,
      repeat: -1
    });
    this.anims.create({
      key: "fall",
      frames: this.anims.generateFrameNumbers("lemming", {
        start: 32,
        end: 35
      }),
      frameRate: 10,
      repeat: -1
    });
    this.anims.create({
      key: "dig",
      frames: this.anims.generateFrameNumbers("lemming", {
        start: 128,
        end: 135
      }),
      frameRate: 10
    });
    this.anims.create({
      key: "explode",
      frames: this.anims.generateFrameNumbers("lemming", {
        start: 208,
        end: 221
      }),
      frameRate: 10
    });
  }

  update(t, d) {
    this.debugCanvas.clear();
    this.lemmings.forEach((lemming) => lemming.update(t, d));
    this.debugCanvas.refresh();
  }
}

const game = new Phaser.Game({
  // type: Phaser.CANVAS,
  width: 320,
  height: 200,
  zoom: 4,
  roundPixels: true,
  // backgroundColor: 0xff00ff,
  scene: [Game]
});
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/phaser/3.55.2/phaser.min.js