<footer><div id=version></div></footer>
html, body {
  height: 100%;
}

body {
  margin: 0;
  padding: 0;
  background: #111;
  color: #eee;
  font: caption;
}

#version {
  position: absolute;
  left: 5px;
  top: 5px;
}
/* global Phaser */

// Disable mask and draw geometry
const DEBUG = false;

// How many walls
const WALLS = 10;

// Colors
const BLACK = 0;
const WHITE = 0xffffff;
const FILL_COLOR = BLACK;
const DEBUG_STROKE_COLOR = WHITE;
const DEBUG_FILL_COLOR = 0xff0000;

// Shortcuts
const { Circle, Line, Point, Rectangle } = Phaser.Geom;
const { EPSILON } = Phaser.Math;
const { Extend } = Line;
const { ContainsPoint } = Rectangle;
const { LineToLine } = Phaser.Geom.Intersects;

function preload () {
  this.load.image('block', 'assets/sprites/128x128.png');
  this.load.image('clown', 'assets/sprites/clown.png');
  this.load.image('background', 'assets/tests/grave/background.png');
  this.load.image('light', 'assets/particles/yellow.png');
}

function create () {
  this.add.grid(0, 0, 1024, 768, 64, 64, BLACK, 0, WHITE).setOrigin(0, 0).setAlpha(0.1);
  
  const background = this.add.image(0, 0, 'background').setOrigin(0, 0);

  const clown = this.add.image(0, 0, 'clown').setRandomPosition();

  const walls = this.add.group({
    key: 'block',
    quantity: WALLS,
    visible: false,
    setScale: { x: 0.25, y: 0.25, stepX: 0.05, stepY: 0.05 }
  });

  Phaser.Actions.PlaceOnCircle(walls.getChildren(), new Circle(512, 384, 256));

  const graphics = this.make.graphics({ lineStyle: { color: DEBUG_STROKE_COLOR, width: 0.5 } });

  let mask;

  if (DEBUG) {
    mask = null;
    graphics.setAlpha(0.5);
    this.add.existing(graphics);
  } else {
    mask = new Phaser.Display.Masks.GeometryMask(this, graphics);
  }
  
  const light = this.add.image(400, 300, 'light')
    .setBlendMode(1)
    .setScale(1);

  // Place and mask walls.
  walls.getChildren().forEach((wall) => {
    wall.setMask(mask);
  });

  // Mask objects and background.
  clown.setMask(mask);
  background.setMask(mask);
  light.setMask(mask);

  // Rectangles, will form the edges
  const rects = [
    ...walls.getChildren().map(getSpriteRect),
    // Outer boundary.
    new Rectangle(0, 0, 1024, 768)
  ];

  // Convert rectangles into edges (line segments)
  const edges = rects.flatMap(getRectEdges);

  // Convert rectangles into vertices
  const vertices = rects.flatMap(getRectVertices);

  // One ray will be sent through each vertex
  const rays = vertices.map(() => new Line());

  // Draw the mask once
  draw(graphics, calc(light, vertices, edges, rays), rays, edges);

  // And again each time the pointer moves
  this.input.on('pointermove', (pointer) => {
    light.setPosition(pointer.x, pointer.y);
    draw(graphics, calc(light, vertices, edges, rays), rays, edges);
  });
}

// Draw the mask shape, from vertices
function draw (graphics, vertices, rays, edges) {
  graphics
    .clear()
    .fillStyle(FILL_COLOR)
    .fillPoints(vertices, true);

  if (DEBUG) {
    rays.forEach((ray) => {
      graphics.strokeLineShape(ray);
    });
    edges.forEach((edge) => {
      graphics.strokeLineShape(edge);
    });

    graphics.fillStyle(DEBUG_FILL_COLOR);

    vertices.forEach((vert) => {
      graphics.fillPointShape(vert, 4);
    });
  }
}

// Place the rays, calculate and return intersections.
function calc (source, vertices, edges, rays) {
  const sx = source.x;
  const sy = source.y;

  // Sort clockwise …
  return sortClockwise(
    // each ray-edge intersection, or the ray's endpoint if no intersection
    rays.map((ray, i) => {
      // placing the ray between the source and one vertex …
      ray.setTo(sx, sy, vertices[i].x, vertices[i].y);

      // extended through the wall vertex
      Extend(ray, 0, 1000);

      // placing its endpoint at the intersection with an edge, if any
      edges.forEach((edge) => getRayToEdge(ray, edge));

      // the new endpoint
      return ray.getPointB();
    }),
    source
  );
}

function getSpriteRect (sprite) {
  const {displayWidth, displayHeight} = sprite;
  
  return new Rectangle(
    sprite.x - sprite.originX * displayWidth,
    sprite.y - sprite.originY * displayHeight,
    displayWidth,
    displayHeight
  );
}

function getRectEdges (rect) {
  return [
    rect.getLineA(),
    rect.getLineB(),
    rect.getLineC(),
    rect.getLineD()
  ];
}

function getRectVertices (rect) {
  const { left, top, right, bottom } = rect;

  const left1 = left + EPSILON;
  const top1 = top + EPSILON;
  const right1 = right - EPSILON;
  const bottom1 = bottom - EPSILON;
  const left2 = left - EPSILON;
  const top2 = top - EPSILON;
  const right2 = right + EPSILON;
  const bottom2 = bottom + EPSILON;

  return [
    new Point(left1, top1),
    new Point(right1, top1),
    new Point(right1, bottom1),
    new Point(left1, bottom1),
    new Point(left2, top2),
    new Point(right2, top2),
    new Point(right2, bottom2),
    new Point(left2, bottom2)
  ];
}

// If a ray intersects with an edge, place the ray endpoint there and return the intersection.
function getRayToEdge (ray, edge, out) {
  if (!out) out = new Point();

  if (LineToLine(ray, edge, out)) {
    ray.x2 = out.x;
    ray.y2 = out.y;

    return out;
  }

  return null;
}

function sortClockwise (points, center) {
  // Adapted from <https://stackoverflow.com/a/6989383/822138> (ciamej)

  var cx = center.x;
  var cy = center.y;

  var sort = function (a, b) {
    if (a.x - cx >= 0 && b.x - cx < 0) {
      return -1;
    }

    if (a.x - cx < 0 && b.x - cx >= 0) {
      return 1;
    }

    if (a.x - cx === 0 && b.x - cx === 0) {
      if (a.y - cy >= 0 || b.y - cy >= 0) {
        return (a.y > b.y) ? 1 : -1;
      }

      return (b.y > a.y) ? 1 : -1;
    }

    // Compute the cross product of vectors (center -> a) * (center -> b)
    var det = (a.x - cx) * -(b.y - cy) - (b.x - cx) * -(a.y - cy);

    if (det < 0) {
      return -1;
    }

    if (det > 0) {
      return 1;
    }

    // Points a and b are on the same line from the center
    // Check which point is closer to the center
    var d1 = (a.x - cx) * (a.x - cx) + (a.y - cy) * (a.y - cy);
    var d2 = (b.x - cx) * (b.x - cx) + (b.y - cy) * (b.y - cy);

    return (d1 > d2) ? -1 : 1;
  };

  return points.sort(sort);
}

// eslint-disable-next-line no-unused-vars
function pointInRectangles (point, rects) {
  return rects.some((rect) => ContainsPoint(rect, point));
}

const config = {
  pixelArt: true,
  scene: {
    preload: preload,
    create: create
  },
  loader: {
    baseURL: 'https://labs.phaser.io',
    crossOrigin: 'anonymous'
  }
};

document.getElementById('version').textContent = 'Phaser v' + Phaser.VERSION;

// eslint-disable-next-line no-new
new Phaser.Game(config);
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdn.jsdelivr.net/npm/phaser@3.55.2/dist/phaser.js
  2. https://cdn.jsdelivr.net/npm/colors.css@3.0.0/js/colors.js