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
"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:
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 is comprised of three sections:
- Some simple UI elements to control the UI.
- SVG definitions for hardcoded gradients and the grid pattern.
- The surface object and objects overlayed on the grid.
Note that script
element in the header defines AppConfig
as the starting module.
<!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).
The program enters in the AppConfig
module as specified in the HTML script, instantiates the App
object and executes the run
method.
import { App } from "./App"
require(['App'],
() => {
const appMain = new App();
appMain.run();
}
);
The App
class is quite simple:
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
:
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 main work is performed in the controllers. We'll start with the SVG controllers.
The SvgElementController
is the abstract
base class for all controllers, maintaining state information and providing a couple useful methods.
import { Constants } from "../Constants";
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;
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);
el.setAttributeNS(null, "id", eid);
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;
}
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:
const eid = id ?? this.uuidv4();
This unique ID is essential for obtaining the controller associated with the SVG element.
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.
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:
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
:
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
:
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.
The FixedObjectController
is a derived class for objects that are not moveable by the user, for example, the green hexagon and the two "stars":
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.
import { Constants } from "../Constants";
import { Controllers } from "./Controllers";
import { SvgElementController } from "./SvgElementController";
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();
}
}
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.
import { FixedObjectController } from "./FixedObjectController";
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;
}
}
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.
Objects that are paths, rather than say a circle
or rect
element, must be translated as they have no x,y
coordinate.
import { SvgElementController } from "./SvgElementController";
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 {
}
}
The MouseController
handles mouse events for the surface and all objects.
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();
const controller = Controllers.appController.getObjectBeingDragged(el);
if (controller.isDragging) {
controller.update(e);
controller.translate();
} else {
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:
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:
public getObjectBeingDragged(el: HTMLElement): SvgElementController {
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:
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:
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.
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:
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
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:
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);
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 {
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:
this.registerSvgElementController(Constants.SVG_SURFACE_ID, new SurfaceController());
Controllers.mouseController.wireUpEvents(Constants.SVG_SURFACE_ID);
Wiring up the UI buttons:
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.
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:
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:
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
The pointing down triangle, adding M 80,80 L 40,160 L 0 80
And the horizontal required to join the down triangle with the up triangle, adding M 0,159 L 80,159
, resulting in:
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:
Which requires adding the connecting line: M 90,40 130,40
therefore rendering a shape that actually has a width of 130.
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:
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");
this.createSpaceship(92, 62, 45, "spinningShip");
this.createSpaceship(287, 182, 170, "movingShip");
}
The AppController
also encapsulates the methods for creating the four kinds of objects.
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;
}
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;
}
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:
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:
This is annoying and I don't have a good explanation for why the fill process doesn't result in an exact coordinate match.
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.
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);
ctrl.x = x;
ctrl.y = y;
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;
The user can spin one of the ships:
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":
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:
Ending (further right and down):
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."
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.
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!
- 29th January, 2023: Initial version