Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Typescript

SVG Grids: Squares, Triangles, Hexagons with Scrolling, Sprites and Simple Animation Examples

4.86/5 (4 votes)
29 Jan 2023CPOL10 min read 7.3K   101  
Create square, triangle, and hexagon grids with scrolling, animation, and sprite dragging
In this article, I demonstrate square, triangle, and hexagon grids. For the hexagon grids, I add some objects like stars, attributes, and a couple spaceships. The SVG (Scalable Vector Graphics) surface is scrollable and the demo illustrates fixed objects, moveable objects, and some simple animation.

Contents

Introduction - SVG Grids: Squares, Triangles, Hexagons

Image 1

"Scalable Vector Graphics (SVG) is an XML-based vector image format for defining two-dimensional graphics, having support for interactivity and animation. The SVG specification is an open standard developed by the World Wide Web Consortium since 1999." -- read more here.

I've always been interested in grid-based games, like Star Fleet Battles which uses a hexagonal map, or for example, the Klingon's screen that is a triangle grid:

Image 2

In this article, I demonstrate square, triangle, and hexagon grids. For the hexagon grids, I add some objects like stars, attributes, and a couple spaceships.

The features of this demo are:

  • The surface is scrollable with mouse-down dragging.
  • Some objects are fixed, like the hexagons that are green or have a red border.
  • Some objects are moveable, like the spaceships.
  • Some simple animation.

This is all basic stuff, the main challenges were:

  • Dragging items in which the user moves the mouse quickly and therefore the mouse move events change from occurring on the object to instead, the surface or another object.
  • Selecting an object were the SVG path results in the click event occurring on the surface because one is actually clicking on the "transparent" part of the SVG element.

A couple other points about the code:

  • Because I intend to do more with this, the demo application is actually a .NET 7 ASP.NET Core Web API application. There are no endpoint APIs.
  • The front-end is implemented in TypeScript.
  • Require.js is used as the module loader using the AMD format/syntax.
  • Animation is done programmatically rather than using the animate SVG element.

The HTML

The HTML is comprised of three sections:

  1. Some simple UI elements to control the UI.
  2. SVG definitions for hardcoded gradients and the grid pattern.
  3. The surface object and objects overlayed on the grid.

Note that script element in the header defines AppConfig as the starting module.

HTML
<!DOCTYPE html>

<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>TypeScript HTML App</title>
  <script data-main="js/AppConfig" type="text/javascript" src="lib/require.js"></script>
</head>
<body>
  <div>
    <input id="rbSquares" type="radio" name="gridType" checked />
    <label for="squares">Squares</label>
    <input id="rbTriangles" type="radio" name="gridType" />
    <label for="triangles">Triangles</label>
    <input id="rbHexagons" type="radio" name="gridType" checked />
    <label for="hexagons">Hexagons</label>
    <button id="btnSpin">Spin</button>
    <button id="btnMove">Move</button>
  </div>
  <div style="margin-top:20px">
    <svg id="svg" width="400" height="400" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <radialGradient id="radialBlueGradient">
          <stop offset="0%" stop-color="blue" />
          <stop offset="100%" stop-color="white" />
        </radialGradient>
        <radialGradient id="radialRedGradient">
          <stop offset="0%" stop-color="red" />
          <stop offset="100%" stop-color="white" />
        </radialGradient>
        <pattern id="largeGrid" width="80" height="80" patternUnits="userSpaceOnUse">
          <path id="gridPath" d="M 0 0" fill="none" stroke="gray" />
        </pattern>
      </defs>
      <g id="surface" transform="translate(0, 0)" x="0" y="0" width="400" height="400">
        <rect id="grid" width="0" height="0" fill="url(#largeGrid)" />
      </g>
      <g id="objectGroup">
      </g>
    </svg>
  </div>
</body>
</html>

The svg element consists of a defs section which define two radial gradients and a pattern element for the tiles of the grid. The path, represented by the d attribute is programmatically determined, which is why in the HTML, it is simply M 0 0. For a tutorial on paths, see: Paths - SVG: Scalable Vector Graphics | MDN (mozilla.org)

In addition, there are two g elements. A g element is simply a container used to group SVG elements so that transformations, such as translations, rotations, scaling, etc. can be applied to all elements in the group. In this demonstration application, the surface is represented as one "group" with a repeating fill, as determined by the pattern definition largeGrid. The second g element is where fixed and moveable objects are added. Note that the rendering of objects is determined by their order -- there is no z-axis control. One has to manipulate the element ordering directly if one wants to move an element up or down in the rendering order.

For a full list of SVG elements, please refer to SVG element reference - SVG: Scalable Vector Graphics | MDN (mozilla.org).

Basic Application Startup

The program enters in the AppConfig module as specified in the HTML script, instantiates the App object and executes the run method.

TypeScript
import { App } from "./App"

require(['App'],
  () => {
    const appMain = new App();
    appMain.run();
  }
);

The App class is quite simple:

TypeScript
import { Controllers } from "./Controllers/Controllers";

export class App {

  public run() {
    Controllers.appController.init();
    Controllers.appController.selectHexagons();
    Controllers.appController.createObjects();
  }
}

This class makes use of a container called Controllers:

TypeScript
import { AppController } from "./AppController"
import { MouseController } from "./MouseController";

export class Controllers {
  public static appController = new AppController();
  public static mouseController = new MouseController();
}

The Controllers class is used by other controllers to access methods across controllers.

The SVG Controllers

The main work is performed in the controllers. We'll start with the SVG controllers.

SvgElementController

The SvgElementController is the abstract base class for all controllers, maintaining state information and providing a couple useful methods.

TypeScript
import { Constants } from "../Constants";

// Do not reference object or surface controllers here, as this creates a circular 
// dependency and you get TypeError: Class extends value undefined is not a function 
// or null
// type SvgInHtml = HTMLElement & SVGElement;

export abstract class SvgElementController {
  public elementID = "";
  public scale = "";
  public rotate = "";
  public x = 0;
  public y = 0;
  public w = 0;
  public r = 0;
  public h = 0;

  // These four properties are used by the surface.
  public dx = 0;
  public dy = 0;
  public tx = 0;
  public ty = 0;

  public isDragging = false;

  abstract update(e: MouseEvent);
  abstract translate();
  abstract set(e: MouseEvent);
  abstract onMouseLeave();
  abstract init(el: HTMLElement);

  public stopDrag(): void {
    this.isDragging = false;
  }

  public static createElement(name: string, attributes: {}, id?: string): HTMLElement {
    const el = this.intCreateElement(name, attributes, id) as HTMLElement;
    const svgObjects = document.getElementById(Constants.SVG_OBJECTS);
    svgObjects.appendChild(el);

    return el;
  }

  private static intCreateElement(elementName, attributes, id?: string) {
    const eid = id ?? this.uuidv4();
    var el = document.createElementNS(Constants.SVG_NS, elementName);

    // Create a unique ID for the element so we can acquire the correct shape controller
    // when the user drags the shape, or use the provided ID which the caller 
    // guarantees as unique.
    el.setAttributeNS(null, "id", eid);

    // Add the attributes to the element.
    Object.entries(attributes).map(([key, val]) => {
      if (key == "href") {
        el.setAttributeNS("http://www.w3.org/1999/xlink", key, val as string);
      } else {
        el.setAttributeNS(null, key, val as string);
      }
    });

    return el;
  }

  // From SO: 
  //<a href="https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript">
  //https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript</a>
  private static uuidv4(): string {
    return (1e7.toString() + -1e3.toString() + -4e3.toString() + 
           -8e3.toString() + -1e11.toString()).replace(/[018]/g,
      c => (c as any ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> 
          (c as any) / 4).toString(16))
  }
}

The useful method here is the static method createElement which we will see used to create the various SVG elements in the demo. It wraps an internal method, intCreateElement, which is responsible for programmatically creating the HTML element with the attributes specified by the caller.

Note that creating an element assigns a unique GUID as the element ID, unless specified by the caller:

TypeScript
const eid = id ?? this.uuidv4();

This unique ID is essential for obtaining the controller associated with the SVG element.

SurfaceController

The surface controller is responsible for translating the surface as the user it. The concept here is that the rectangle containing the surface extends beyond the viewable region such that the translation can be performed modulus the grid width and height, giving the illusion of a surface extending infinitely in all directions.

TypeScript
import { Constants } from "../Constants";
import { Controllers } from "./Controllers";
import { SvgElementController } from "./SvgElementController";

export class SurfaceController extends SvgElementController {
  public init(el: HTMLElement): void { }

  public update(e: MouseEvent): void {
    this.dx = e.clientX - this.x;
    this.dy = e.clientY - this.y;
    this.x = e.clientX;
    this.y = e.clientY;
  }

  public set(e: MouseEvent): void {
    this.x = e.clientX;
    this.y = e.clientY;
  }

  public translate(): void {
    this.tx += this.dx;
    this.ty += this.dy;
    const dxa = this.tx % Controllers.appController.gridCellW;
    const dya = this.ty % Controllers.appController.gridCellH;

    const svgSurface = document.getElementById(Constants.SVG_SURFACE_ID) 
                      as any as SVGElement;
    svgSurface.setAttribute("transform", `translate(${dxa},${dya})`);

    const svgObjects = document.getElementById(Constants.SVG_OBJECTS);
    svgObjects.setAttribute("transform", `translate(${this.tx},${this.ty})`);
  }

  public onMouseLeave(): void {
    Controllers.appController.getController(this.elementID).isDragging = false;
  }
}

We see the modulus operation here:

TypeScript
const dxa = this.tx % Controllers.appController.gridCellW;
const dya = this.ty % Controllers.appController.gridCellH;

and we note that the translate method updates the surface by the modulus dxa, dya:

TypeScript
const svgSurface = document.getElementById(Constants.SVG_SURFACE_ID) as any as SVGElement;
svgSurface.setAttribute("transform", `translate(${dxa},${dya})`);

and the objects on the surface, by the absolute coordinate transform tx, ty:

TypeScript
const svgObjects = document.getElementById(Constants.SVG_OBJECTS);
svgObjects.setAttribute("transform", `translate(${this.tx},${this.ty})`);

Thus the surface grid appears infinite and the objects are translated based on the absolute coordinates of the grid.

Each SVG element has the opportunity to handle mouse events according to its requirements. For example, circles use the attributes cx, cy, and paths require a translation to move the object rather than setting absolute x, y coordinates.

FixedObjectController

The FixedObjectController is a derived class for objects that are not moveable by the user, for example, the green hexagon and the two "stars":

Image 3

The important point here is that when the user clicks on one of these objects, the mouse move events occur on that object, not the surface. Because we don't want to change the location of the fixed object, instead we drag the surface by passing the mouse event calls to the surface controller.

TypeScript
import { Constants } from "../Constants";
import { Controllers } from "./Controllers";
import { SvgElementController } from "./SvgElementController";

// Dragging a fixed object is like dragging the surface, 
// so route all methods to the surface controller.
export class FixedObjectController extends SvgElementController {
  public init(el: HTMLElement): void { }

  public update(e: MouseEvent): void {
    const ctrl = Controllers.appController.getController(Constants.SVG_SURFACE_ID);
    ctrl.update(e);
  }

  public set(e: MouseEvent): void {
    const ctrl = Controllers.appController.getController(Constants.SVG_SURFACE_ID);
    ctrl.set(e);
  }

  public translate(): void {
    const ctrl = Controllers.appController.getController(Constants.SVG_SURFACE_ID);
    ctrl.translate();
  }

  public onMouseLeave(): void {
    const ctrl = Controllers.appController.getController(Constants.SVG_SURFACE_ID);
    ctrl.onMouseLeave();
  }
}

CircleController

Basic circles are used as "stars" in the demo. Therefore, the CircleController is simply an instance of a FixedObjectController, so the only thing is does is initialize our x,y properties with the circle's cx,cy attributes.

TypeScript
import { FixedObjectController } from "./FixedObjectController";

// Because circles use cx,cy, not x,y
export class CircleController extends FixedObjectController {
  public init(el: HTMLElement): void {
    this.x = parseInt(el.getAttribute("cx")) - this.r / 2;
    this.y = parseInt(el.getAttribute("cy")) - this.r / 2;
    this.w = this.r;
    this.h = this.r;
  }

  // If it's not a fixed object.
  /*
  public update(e: MouseEvent): void {
    this.x += e.movementX;
    this.y += e.movementY;
  }

  public translate(): void {
    const svgObject = document.getElementById(this.elementID);
    svgObject.setAttribute("cx", this.x.toString());
    svgObject.setAttribute("cy", this.y.toString());
  }

  public onMouseLeave(): void {
    // If the user moves the mouse to fast, we will get a mouse leave on the object.
    // So we do nothing here.
  }
  */
}

As the commented out code illustrates, if we wanted the circles to be moveable, we would include the additional code to update the physical location of the circle and to convert between our x,y properties and the cx,cy attributes. Also note that the x,y coordinates are calculated from the center of the circle and its radius.

PathController

Objects that are paths, rather than say a circle or rect element, must be translated as they have no x,y coordinate.

TypeScript
import { SvgElementController } from "./SvgElementController";

// Because paths need to be translated.
export class PathController extends SvgElementController {
  public init(el: HTMLElement): void {
    this.x = parseInt(el.getAttribute("x"));
    this.y = parseInt(el.getAttribute("y"));
  }

  public set(e: MouseEvent) { }

  public update(e: MouseEvent): void {
    this.x += e.movementX;
    this.y += e.movementY;
  }

  public translate(): void {
    const svgObject = document.getElementById(this.elementID);
    svgObject.setAttribute("transform", 
             `translate(${this.x},${this.y}) ${this.scale} ${this.rotate}`);
  }

  public onMouseLeave(): void {
    // If the user moves the mouse to fast, we will get a mouse leave on the object.
    // So we do nothing here.
  }
}

The MouseController

The MouseController handles mouse events for the surface and all objects.

TypeScript
import { Controllers } from "./Controllers";

export class MouseController {
  public wireUpEvents(elName: string) {
    const svgElement = document.getElementById(elName);
    svgElement.addEventListener("mousedown", e => this.mouseDown(e, svgElement));
    svgElement.addEventListener("mouseup", e => this.mouseUp(e, svgElement));
    svgElement.addEventListener("mousemove", e => this.mouseMove(e, svgElement));
    svgElement.addEventListener("mouseleave", e => this.mouseLeave(e, svgElement));
  }

  public mouseDown(e: MouseEvent, el: HTMLElement): void {
    e.preventDefault();
    Controllers.appController.getActualController
               (e.clientX, e.clientY, el.id).isDragging = true;
  }

  public mouseUp(e: MouseEvent, el: HTMLElement): void {
    e.preventDefault();
    Controllers.appController.clearDraggingForAllObjects();
  }

  public mouseLeave(e: MouseEvent, el: HTMLElement): void {
    e.preventDefault();
    Controllers.appController.getActualController
               (e.clientX, e.clientY, el.id).onMouseLeave();
  }

  public mouseMove(e: MouseEvent, el: HTMLElement): void {
    e.preventDefault();

    // This handles dragging an object when the user moves the mouse to fast
    // and the mouseMove events start coming in on the surface not the object.
    const controller = Controllers.appController.getObjectBeingDragged(el);

    if (controller.isDragging) {
      controller.update(e);
      controller.translate();
    } else {
      // Update our current mouse (x, y) 
      // so the grid doesn't jump when we start dragging.
      controller.set(e);
    }
  }
}

The "trick" here is to handle the situation when the user is dragging a moveable object and moves the mouse fast enough that the mouse pointer leaves the object. In this case, the mouse move events start to fire on the surface or potentially another object! This is actually resolved in the AppController as the AppController maintains the list of objects, which we see here:

TypeScript
const controller = Controllers.appController.getObjectBeingDragged(el);

As a sneak peak, what the AppController does is return either the object being dragged, or of no object is being dragged, the controller instance currently associated with the HTML element receiving the mouse mouse event:

TypeScript
public getObjectBeingDragged(el: HTMLElement): SvgElementController {
  // Return the controller being dragged, and if nothing is being dragged, 
  // return the element's controller.
  const controller = Object.entries(this.objects).find
                    (([key, val]) => val.isDragging)?.[1] 
                     ?? Controllers.appController.getController(el.id);

  return controller;
}

Note that the mouse controller leaves it up to each object controller to determine how the mouse leave and mouse movement is handled:

TypeScript
controller.update(e);
controller.translate();

This allows the object controller to work with the different SVG elements and how they are moved.

Also note the joys of closure, which allows us to pass in the SVG element to the handler:

TypeScript
const svgElement = document.getElementById(elName);
svgElement.addEventListener("mousedown", e => this.mouseDown(e, svgElement));

Technically, I could pass in the controller to the wireUpEvents and refactor the code in the future for this, as it avoids the getController call in the handlers.

Objects with Holes

Another issue that needs to be solved here is, what object is being clicked on when the path of the object has "holes" that cause the mouse down (and other events) to fire on the surface, not the object? This is handled by the call:

TypeScript
Controllers.appController.getActualController(e.clientX, e.clientY, el.id)

This call locates the object based on its dimensions, taking into account object and surface transformations and is described in detail in the AppController section.

The AppController

The AppController is a bit of a kitchen sink and for something more than just a demo, would be less of a kitchen sync. Its primary purpose is to manage the list of objects, including the surface:

TypeScript
type uuid = string;

export interface IObjectInfoMap {
  [key: uuid]: SvgElementController;
}

export class AppController {
  public gridCellW = 80;
  public gridCellH = 160;
  public objects: IObjectInfoMap = {};

  public init() {
    this.registerSvgElementController(Constants.SVG_SURFACE_ID, new SurfaceController());
    Controllers.mouseController.wireUpEvents(Constants.SVG_SURFACE_ID);
    this.wireUpUIButtons();
  }

  public getController(name: string): SvgElementController {
    return this.objects[name];
  }

  public getActualController
        (mx: number, my: number, name: string): SvgElementController {
    let ctrl = this.objects[name];
    const sfc = this.getController(Constants.SVG_SURFACE_ID);

    // If the coordinate is contained within a non-surface object, return it instead.
    let obj = Object.entries(this.objects).filter
    (([key, val]) => key !== Constants.SVG_SURFACE_ID).find(([key, val]) =>
    val.x + sfc.tx + val.tx <= mx &&
    val.x + sfc.tx + val.tx + val.w >= mx &&
    val.y + sfc.ty + val.ty <= my &&
    val.y + sfc.ty + val.ty + val.h >= my);

    return obj?.[1] ?? ctrl;
  }

  public registerSvgElementController
 (name: string, svgController: SvgElementController): SvgElementController {
    svgController.elementID = name;
    this.objects[name] = svgController;

    return svgController;
  }

  public clearDraggingForAllObjects(): void {
    Object.entries(this.objects).forEach(([key, val]) => val.stopDrag());
  }

  public getObjectBeingDragged(el: HTMLElement): SvgElementController {
    // Return the controller being dragged, and if nothing is being dragged, 
    // return the element's controller.
    const controller = Object.entries(this.objects).find
   (([key, val]) => val.isDragging)?.[1] ?? 
   Controllers.appController.getController(el.id);

    return controller;
  }

Notice getActualController, which determines the object controller based on the name of the element on which the mouse down occurs, and can be overridden, due to "holes" in the path, as determined by the object whose extends are within the mouse down coordinates.

But it does a few more things too, like initializing the surface controller and its mouse events:

TypeScript
this.registerSvgElementController(Constants.SVG_SURFACE_ID, new SurfaceController());
Controllers.mouseController.wireUpEvents(Constants.SVG_SURFACE_ID);

Wiring up the UI buttons:

TypeScript
public wireUpUIButtons(): void {
  document.getElementById("rbSquares").addEventListener
  ("click", _ => this.selectSquares());
  document.getElementById("rbTriangles").addEventListener
          ("click", _ => this.selectTriangles());
  document.getElementById("rbHexagons").addEventListener
          ("click", _ => this.selectHexagons());

  document.getElementById("btnSpin").addEventListener("click", _ => this.spin());
  document.getElementById("btnMove").addEventListener("click", _ => this.move());
}

Initializing the different grid paths and attributes requires setting the horizontal and vertical overflow based on the objects dimensions. For example, squares are 80x80, so the actual drawing area must have an x,y of -80,-80 and the dimensions are the object's width * 2 + viewer width, which is 80 * 2 + 400 = 560.

TypeScript
const elg = document.getElementById("largeGrid");
elg.setAttribute("width", "80");
elg.setAttribute("height", "80");

const elsg = document.getElementById("grid");
elsg.setAttribute("x", "-80");
elsg.setAttribute("y", "-80");
elsg.setAttribute("width", "560");
elsg.setAttribute("height", "560");

Squares:

Image 4

TypeScript
public selectSquares(): void {
  this.clearChildren();
  const elgp = document.getElementById("gridPath");
  elgp.setAttribute("d", "M 0 0 H 80 V 80");
  elgp.setAttribute("stroke-width", "2");

  const elg = document.getElementById("largeGrid");
  elg.setAttribute("width", "80");
  elg.setAttribute("height", "80");

  const elsg = document.getElementById("grid");
  elsg.setAttribute("x", "-80");
  elsg.setAttribute("y", "-160");
  elsg.setAttribute("width", "560");
  elsg.setAttribute("height", "660");

  this.gridCellW = 80;
}

It is interesting to note that to render a square grid, the path only specifies the top and right edges:

Image 5

Relying on the repeated fill to provide the left and bottom edges.

Triangles:

Triangles are bit odd - the path actually describes two triangles and therefore the height is 160. The pointing up triangle with M 40,0 L 80,80 L 0,80 z

Image 6

The pointing down triangle, adding M 80,80 L 40,160 L 0 80

Image 7

And the horizontal required to join the down triangle with the up triangle, adding M 0,159 L 80,159, resulting in:

Image 8

TypeScript
public selectTriangles(): void {
  this.clearChildren();
  const elgp = document.getElementById("gridPath");
  elgp.setAttribute("d", "M 40,0 L 80,80 L 0,80 z M 80,
                    80 L 40,160 L 0 80 M 0,159 L 80,159");
  elgp.setAttribute("stroke-width", "1");

  const elg = document.getElementById("largeGrid");
  elg.setAttribute("width", "80");
  elg.setAttribute("height", "160");

  const elsg = document.getElementById("grid");
  elsg.setAttribute("x", "-80");
  elsg.setAttribute("y", "-160");
  elsg.setAttribute("width", "640");
  elsg.setAttribute("height", "720");

  this.gridCellW = 80;
  this.gridCellH = 160;
}

Hexagons:

Hexagons are also interesting in that we require a connecting line to fill in the "null space" to simulate the hexagon not actually drawn. Therefore, with just the hexagon path M 0 40 L 22.5 0 L 67.5 0 L 90 40 L 67.5 80 L 22.5 80 Z, we see:

Image 9

Which requires adding the connecting line: M 90,40 130,40 therefore rendering a shape that actually has a width of 130.

Image 10

TypeScript
public selectHexagons(): void {
  const elgp = document.getElementById("gridPath");
  elgp.setAttribute
  ("d", "M 0 40 L 22.5 0 L 67.5 0 L 90 40 L 67.5 80 L 22.5 80 Z M 90,40 130,40");
  elgp.setAttribute("stroke-width", "1");

  const elg = document.getElementById("largeGrid");
  elg.setAttribute("width", "130");
  elg.setAttribute("height", "80");

  const elsg = document.getElementById("grid");
  elsg.setAttribute("x", "-130");
  elsg.setAttribute("y", "-80");
  elsg.setAttribute("width", "660");
  elsg.setAttribute("height", "560");

  this.gridCellW = 130;
  this.gridCellH = 80;

  this.createObjects();
}

Creating the objects that are placed (only for purposes of this demo) on the hexagon grid:

TypeScript
public createObjects() {
  let hcoord = this.hexCoord(1, 0);
  this.createFilledHexagon(hcoord[0], hcoord[1], "lightgreen");

  hcoord = this.hexCoord(7, 3);
  this.createBorderHexagon(hcoord[0], hcoord[1], "red", 5);
  hcoord = this.hexCoord(8, 4);
  this.createBorderHexagon(hcoord[0], hcoord[1], "red", 5);
  hcoord = this.hexCoord(9, 3);
  this.createBorderHexagon(hcoord[0], hcoord[1], "red", 5);

  this.createStar(175, 200, 15, "radialBlueGradient");
  this.createStar(110, 240, 30, "radialRedGradient");

  // Moveable objects.
  this.createSpaceship(92, 62, 45, "spinningShip");
  this.createSpaceship(287, 182, 170, "movingShip");
}

Creating the Objects

The AppController also encapsulates the methods for creating the four kinds of objects.

A Star

Image 11

TypeScript
private createStar(x: number, y: number, r: number, gradient: string): 
                  SvgElementController {
  const el = SvgElementController.createElement("circle", { r: `${r}`, 
  fill: `url(#${gradient})`, stroke: "none", "stroke-width": 1, cx: `${x}`, cy: `${y}` });
  const ctrl = Controllers.appController.registerSvgElementController
              (el.id, new CircleController());
  ctrl.init(el);
  Controllers.mouseController.wireUpEvents(el.id);

  return ctrl;
}

A Filled Hexagon

Image 12

TypeScript
private createFilledHexagon(x: number, y: number, color: string): SvgElementController {
  const el = SvgElementController.createElement("path", 
  { d: "M 0 40 L 22.5 0 L 67.5 0 L 90 40 L 67.5 80 L 22.5 80 Z", 
  stroke: "none", "stroke-width": 1, fill: `${color}`, width: "130", height: "80" });
  el.setAttribute("transform", `translate(${x},${y})`);
  const ctrl = Controllers.appController.registerSvgElementController
              (el.id, new FixedObjectController());
  ctrl.init(el);
  Controllers.mouseController.wireUpEvents(el.id);

  return ctrl;
}

An Outlined Hexagon

Image 13

TypeScript
private createOutlinedHexagon(x: number, y: number, color: string, sw: number): 
  SvgElementController {
  const el = SvgElementController.createElement("path", 
  { d: "M 0 40 L 22.5 0 L 67.5 0 L 90 40 L 67.5 80 L 22.5 80 Z", 
   stroke: `${color}`, "stroke-width": `${sw}`, fill: `none`, 
   width: "130", height: "80" });
  el.setAttribute("transform", `translate(${x},${y})`);
  const ctrl = Controllers.appController.registerSvgElementController
              (el.id, new FixedObjectController());
  ctrl.init(el);
  Controllers.mouseController.wireUpEvents(el.id);

  return ctrl;
}

Oddly, while the hexCoord method should give us the precise location of a hex on the grid:

TypeScript
private hexCoord(x: number, y: number): [number, number] {
  const hx = x * 65;
  const hy = y * 80 + (x % 2 * 40);

  return [hx, hy];
}

If I set the stroke width to 1, we see that we do not have an exact match of the overlayed hex:

Image 14

This is annoying and I don't have a good explanation for why the fill process doesn't result in an exact coordinate match.

A Spaceship

Image 15

The SVG for this has been clipped, as it's huge. The reference for this SVG came from https://freesvg.org/simple-spaceship-vector-image.

TypeScript
// https://freesvg.org/simple-spaceship-vector-image
private createSpaceship
        (x: number, y: number, r: number, id: string): SvgElementController {
  const el = SvgElementController.createElement(
    "path",
    { d: "m1378 1340.5 etc..." },
    id);
  el.setAttribute("transform", 
                 `translate(${x},${y}) scale(0.025) rotate(${r}, 750, 700)`);
  const ctrl = Controllers.appController.registerSvgElementController
              (el.id, new PathController());
  ctrl.init(el);

  // Because these aren't part of the path attributes.
  ctrl.x = x;
  ctrl.y = y;

  // Magic numbers.
  ctrl.w = 48;
  ctrl.h = 48;
  ctrl.tx = 6;
  ctrl.ty = 41;

  ctrl.scale = "scale(0.025)";
  ctrl.rotate = `rotate(${r}, 750, 700)`;
  Controllers.mouseController.wireUpEvents(el.id);

  return ctrl;

Animating the Ships

The user can spin one of the ships:

TypeScript
private spin(): void {
  const spinningShip = this.getController("spinningShip");

  var animate = new Interval(5, () => {
    const r = (animate.counter + 45) % 360;
    spinningShip.rotate = `rotate(${r}, 750, 700)`;
    spinningShip.translate();
  }, 360).start();
}

And the user can click on the Move button which translates both the surface and the ship so that it moves through "enemy territory":

TypeScript
private move(): void {
  const movingShip = this.getController("movingShip");

  new Interval(20, () => {
    const sfc = Controllers.appController.getController(Constants.SVG_SURFACE_ID);
    sfc.dx = -1;
    sfc.dy = -0.5;

    ++movingShip.x;
    movingShip.y += 0.63;

    sfc.translate();
    movingShip.translate();
  }, 320).start();
}

Starting:

Image 16

Ending (further right and down):

Image 17

Animation using the Interval Class

The Interval class is a simple wrapper for the setInterval JavaScript function and is used for executing a callback. In our case, the callbacks perform some simple animation -- spinning one of the spaceships 360 degrees and moving the other spaceships through "enemy territory."

TypeScript
export class Interval {
  public counter = 0;
  private id: number;
  private ms: number;
  private callback: () => void;
  private stopCount?: number;

  constructor(ms: number, callback: () => void, stopCount?: number) {
    this.ms = ms;
    this.callback = callback;
    this.stopCount = stopCount;
  }

  public start(): Interval {
    this.id = setInterval(() => {
      ++this.counter;
      this.callback();

      if (this.stopCount && this.stopCount == this.counter) {
        this.stop();
      }
    }, this.ms);

    return this;
  }

  public stop(): Interval {
    clearInterval(this.id);

    return this;
  }
}

Animation is done programmatically rather than using the animate SVG element. It's simpler to work with code rather than manipulating the DOM to add and remove animate elements. More importantly, as is the case here, we often need to update the object's state such as its position on the grid.

Conclusion

There we have it:

  • An infinite surface
  • Fixed location objects
  • Draggable objects
  • Animations
  • Square, triangle, and hexagonal grids

There are a few refactoring activities to consider and of course, a lot more fun work to make this into a library for grid-based games!

History

  • 29th January, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)