The JavaScript below implements a very simple turn-based tactics game. To see it in action, copy it into an .html file and open that file in a web browser that runs JavaScript. Or, for an online version, visit http://thiscouldbebetter.neocities.org/tacticsgame.html.
There are two teams, each with two units. The active unit can move, attack, or pass, by either clicking the buttons with the mouse or pressing the keyboard shortcuts given on those buttons. Attacks affect only the grid cell directly in front of the attacking unit. Each unit has only one hit point. The game continues until only one team remains.
UPDATE 2018/08/08 – Added unit types with multiple moves and variable range and damage and an obstacle terrain type. Added a status panel for the selected unit. Refactored slightly to match my more recent code standards. I have also moved the code into a Github repository available at “https://github.com/thiscouldbebetter/TacticsGame“.
<html> <body> <div id="divMain" /> <script type="text/javascript"> // main function main() { var actionMovePerform = function(direction) { var world = Globals.Instance.world; var moverActive = world.moverActive(); var targetPos = moverActive.targetPos; if (targetPos == null) { var moverOrientation = moverActive.orientation; if (moverOrientation.equals(direction) == true) { var moverPosNext = moverActive.pos.clone().add ( direction ).trimToRangeMax ( world.map.sizeInCellsMinusOnes ); var terrain = world.map.terrainAtPos(moverPosNext); var movePointsToTraverse = terrain.movePointsToTraverse; if (moverActive.movePoints >= movePointsToTraverse) { if (world.moverAtPos(moverPosNext) == null) { moverActive.pos.overwriteWith ( moverPosNext ); moverActive.movePoints -= movePointsToTraverse; } } } moverOrientation.overwriteWith ( direction ); } else { var targetPosNext = targetPos.clone().add ( direction ).trimToRangeMax ( world.map.sizeInCellsMinusOnes ); var targetDisplacementNext = targetPosNext.clone().subtract ( moverActive.pos ); var targetDistanceNext = targetDisplacementNext.magnitude(); if (targetDistanceNext <= moverActive.defn().attackRange) { targetPos.overwriteWith(targetPosNext) } } } var actions = [ new Action ( "Attack", "F", // keyCode function perform() { var world = Globals.Instance.world; var moverActive = world.moverActive(); if (moverActive.targetPos == null) { moverActive.targetPos = moverActive.pos.clone().add ( moverActive.orientation ); } else { var moverTarget = world.moverAtPos ( moverActive.targetPos ); if (moverTarget != null) { moverTarget.integrity -= moverActive.defn().attackDamage; } moverActive.movePoints = 0; moverActive.targetPos = null; } } ), new Action ( "Down", "S", // keyCode function perform() { actionMovePerform(new Coords(0, 1)); } ), new Action ( "Left", "A", // keyCode function perform() { actionMovePerform(new Coords(-1, 0)); } ), new Action ( "Right", "D", // keyCode function perform() { actionMovePerform(new Coords(1, 0)); } ), new Action ( "Up", "W", // keyCode function perform() { actionMovePerform(new Coords(0, -1)); } ), new Action ( "Pass", "P", // keyCode function perform() { var world = Globals.Instance.world; var moverActive = world.moverActive(); moverActive.movePoints = 0; } ), ]; var actionNamesStandard = [ "Attack", "Up", "Down", "Left", "Right", "Pass" ]; var moverDefns = [ new MoverDefn ( "Slugger", "A", 3, // integrityMax 1, // movePointsPerTurn 1, // attackRange 2, // attackDamage actionNamesStandard ), new MoverDefn ( "Sniper", "B", 2, // integrityMax 1, // movePointsPerTurn 3, // attackRange 1, // attackDamage actionNamesStandard ), new MoverDefn ( "Sprinter", "C", 1, // integrityMax 3, // movePointsPerTurn 1, // attackRange 1, // attackDamage actionNamesStandard ), ]; var mapTerrains = [ new MapTerrain("Open", ".", 1, "White"), new MapTerrain("Blocked", "x", 100, "LightGray"), ]; var map = new Map ( new Coords(20, 20), // cellSizeInPixels new Coords(20, 20), // pos mapTerrains, // cellsAsStrings [ "........", "....x...", "....x...", "....x...", "........", "...xxx..", "........", "........", ] ); var factions = [ new Faction("Green", "LightGreen"), new Faction("Red", "Pink"), ]; var world = new World ( actions, moverDefns, map, factions, // movers [ new Mover ( "Slugger", // defnName "Green", // faction new Coords(1, 0), // orientation new Coords(1, 1) // pos ), new Mover ( "Sniper", // defnName "Red", // faction new Coords(1, 0), // orientation new Coords(3, 1) // pos ), new Mover ( "Sprinter", // defnName "Red", // faction new Coords(1, 0), // orientation new Coords(1, 3) // pos ), new Mover ( "Sniper", // defnName "Green", // faction new Coords(1, 0), // orientation new Coords(3, 3) // pos ), new Mover ( "Slugger", // defnName "Red", // faction new Coords(1, 0), // orientation new Coords(5, 3) // pos ), new Mover ( "Sprinter", // defnName "Green", // faction new Coords(1, 0), // orientation new Coords(5, 1) // pos ), ] ); Globals.Instance.initialize ( new Display(new Coords(300, 200)), world ); } // extensions function ArrayExtensions() { // extension class } { Array.prototype.addLookups = function(keyName) { for (var i = 0; i < this.length; i++) { var item = this[i]; var key = item[keyName]; this[key] = item; } return this; } Array.prototype.remove = function(itemToRemove) { var indexToRemoveAt = this.indexOf(itemToRemove); if (indexToRemoveAt != -1) { this.removeAt(indexToRemoveAt); } } Array.prototype.removeAt = function(indexToRemoveAt) { this.splice ( indexToRemoveAt, 1 ); } } // classes function Action(name, keyCode, perform) { this.name = name; this.keyCode = keyCode; this.perform = perform; } { Action.prototype.toControl = function() { var returnValue = new ControlButton ( "button" + this.name, // name this.name + " (" + this.keyCode + ")", // text new Coords(50, 12), // size new Coords(), // pos this.perform.bind(this) ); return returnValue; } } function Camera(viewSize, pos) { this.viewSize = viewSize; this.pos = pos; } function Control() { // static class } { Control.doesControlContainPos = function(control, posToCheck) { var posToCheckRelative = posToCheck.clone().subtract ( control.posAbsolute() ); var returnValue = posToCheckRelative.isInRangeMax ( control.size ); return returnValue; } Control.controlPosAbsolute = function(control) { var returnValue = (control.parent == null ? new Coords(0, 0) : control.parent.posAbsolute()); returnValue.add(control.pos); return returnValue; } Control.toControlsMany = function ( controllables, posOfFirst, spacing ) { var returnValues = []; for (var i = 0; i < controllables.length; i++) { var controllable = controllables[i]; var control = controllable.toControl(); control.pos.overwriteWith ( spacing ).multiplyScalar ( i ).add ( posOfFirst ); returnValues.push(control); } return returnValues; } } function ControlButton(name, text, size, pos, click) { this.name = name; this.text = text; this.size = size; this.pos = pos; this.click = click; } { ControlButton.prototype.containsPos = function(posToCheck) { return Control.doesControlContainPos(this, posToCheck); } ControlButton.prototype.draw = function(display) { var posAbsolute = this.posAbsolute(); display.drawRectangle ( posAbsolute, this.size, display.colorFore, display.colorBack ); display.drawTextAtPos ( this.text, posAbsolute ); } ControlButton.prototype.mouseClick = function(mouseClickPosAbsolute) { if (this.containsPos(mouseClickPosAbsolute) == true) { this.click(); } } ControlButton.prototype.posAbsolute = function() { return Control.controlPosAbsolute(this); } } function ControlContainer(name, size, pos, children) { this.name = name; this.size = size; this.pos = pos; this.children = children; for (var i = 0; i < this.children.length; i++) { var child = this.children[i]; child.parent = this; } } { ControlContainer.prototype.containsPos = function(posToCheck) { return Control.doesControlContainPos(this, posToCheck); } ControlContainer.prototype.draw = function(display) { display.drawRectangle ( this.posAbsolute(), this.size, display.colorFore, // border display.colorBack // fill ); var children = this.children; for (var i = 0; i < children.length; i++) { var child = children[i]; child.draw(display); } } ControlContainer.prototype.mouseClick = function(mouseClickPosAbsolute) { if (this.containsPos(mouseClickPosAbsolute) == true) { for (var i = 0; i < this.children.length; i++) { var child = this.children[i]; child.mouseClick(mouseClickPosAbsolute); } } } ControlContainer.prototype.posAbsolute = function() { return Control.controlPosAbsolute(this); } } function ControlLabel(name, pos, text) { this.name = name; this.pos = pos; this.text = text; } { ControlLabel.prototype.containsPos = function(posToCheck) { return Control.doesControlContainPos(this, posToCheck); } ControlLabel.prototype.draw = function(display) { display.drawTextAtPos ( this.text, this.posAbsolute() ); } ControlLabel.prototype.posAbsolute = function() { return Control.controlPosAbsolute(this); } } function ControlLabelDynamic(name, pos, textFunction) { this.name = name; this.pos = pos; this.textFunction = textFunction; } { ControlLabelDynamic.prototype.containsPos = function(posToCheck) { return Control.doesControlContainPos(this, posToCheck); } ControlLabelDynamic.prototype.draw = function(display) { display.drawTextAtPos ( this.text(), this.posAbsolute() ); } ControlLabelDynamic.prototype.posAbsolute = function() { return Control.controlPosAbsolute(this); } ControlLabelDynamic.prototype.text = function() { return this.textFunction(); } } function Coords(x, y) { this.x = x; this.y = y; } { Coords.prototype.add = function(other) { this.x += other.x; this.y += other.y; return this; } Coords.prototype.clone = function() { return new Coords(this.x, this.y); } Coords.prototype.divide = function(other) { this.x /= other.x; this.y /= other.y; return this; } Coords.prototype.divideScalar = function(scalar) { this.x /= scalar; this.y /= scalar; return this; } Coords.prototype.equals = function(other) { var returnValue = ( this.x == other.x && this.y == other.y ); return returnValue; } Coords.prototype.isInRangeMax = function(rangeMax) { var returnValue = ( this.x >= 0 && this.x <= rangeMax.x && this.y >= 0 && this.y <= rangeMax.y ); return returnValue; } Coords.prototype.magnitude = function() { return Math.sqrt(this.x * this.x + this.y * this.y); } Coords.prototype.multiply = function(other) { this.x *= other.x; this.y *= other.y; return this; } Coords.prototype.multiplyScalar = function(scalar) { this.x *= scalar; this.y *= scalar; return this; } Coords.prototype.overwriteWith = function(other) { this.x = other.x; this.y = other.y; return this; } Coords.prototype.overwriteWithXY = function(x, y) { this.x = x; this.y = y; return this; } Coords.prototype.subtract = function(other) { this.x -= other.x; this.y -= other.y; return this; } Coords.prototype.trimToRangeMax = function(rangeMax) { if (this.x < 0) { this.x = 0; } else if (this.x > rangeMax.x) { this.x = rangeMax.x; } if (this.y < 0) { this.y = 0; } else if (this.y > rangeMax.y) { this.y = rangeMax.y; } return this; } } function Display(viewSize) { this.viewSize = viewSize; this.fontHeight = 10; this.colorBack = "White"; this.colorFore = "LightGray"; this.colorHighlight = "Gray"; // temporary variables this._drawPos = new Coords(); this._drawPos2 = new Coords(); this._mapCellPos = new Coords(); this._zeroes = new Coords(0, 0); } { Display.prototype.clear = function() { this.drawRectangle ( this._zeroes, this.viewSize, this.colorFore, // border this.colorBack // fill ); } Display.prototype.drawCircle = function ( center, radius, colorBorder, colorFill ) { this.graphics.beginPath(); this.graphics.arc ( center.x, center.y, radius, 0, Math.PI * 2 ); this.graphics.fillStyle = colorFill; this.graphics.fill(); this.graphics.strokeStyle = colorBorder; this.graphics.stroke(); } Display.prototype.drawLine = function ( posFrom, posTo, color ) { this.graphics.beginPath(); this.graphics.moveTo(posFrom.x, posFrom.y); this.graphics.lineTo(posTo.x, posTo.y); this.graphics.strokeStyle = color; this.graphics.stroke(); } Display.prototype.drawRectangle = function ( pos, size, colorBorder, colorFill ) { this.graphics.fillStyle = colorFill; this.graphics.fillRect ( pos.x, pos.y, size.x, size.y ); this.graphics.strokeStyle = colorBorder; this.graphics.strokeRect ( pos.x, pos.y, size.x, size.y ); } Display.prototype.drawTextAtPos = function(text, pos, color) { if (color == null) { color = this.colorFore; } this.graphics.fillStyle = color; this.graphics.fillText ( text, pos.x, pos.y + this.fontHeight ); } Display.prototype.initialize = function() { this.canvas = document.createElement("canvas"); this.canvas.width = this.viewSize.x; this.canvas.height = this.viewSize.y; var divMain = document.getElementById("divMain"); divMain.appendChild(this.canvas); this.graphics = this.canvas.getContext("2d"); this.graphics.font = "" + this.fontHeight + "px sans-serif"; } } function Faction(name, color) { this.name = name; this.color = color; } function Globals() { // do nothing } { // instance Globals.Instance = new Globals(); Globals.prototype.initialize = function(display, world) { this.world = world; this.display = display; this.display.initialize(); this.inputHelper = new InputHelper(); this.inputHelper.initialize(); this.world.initialize(); } Globals.prototype.update = function() { this.world.update(); } } function InputHelper() { // do nothing } { InputHelper.prototype.initialize = function() { this.isMouseClicked = false; this.mousePos = new Coords(); document.onkeydown = this.handleEventKeyDown.bind(this); document.onkeyup = this.handleEventKeyUp.bind(this); var canvas = Globals.Instance.display.canvas; canvas.onmousedown = this.handleEventMouseDown.bind(this); canvas.onmouseup = this.handleEventMouseUp.bind(this); } // events InputHelper.prototype.handleEventKeyDown = function(event) { this.keyCodePressed = String.fromCharCode(event.keyCode); Globals.Instance.update(); } InputHelper.prototype.handleEventKeyUp = function(event) { this.keyCodePressed = null; } InputHelper.prototype.handleEventMouseDown = function(event) { this.isMouseClicked = true; this.mousePos.overwriteWithXY ( event.offsetX, event.offsetY ); Globals.Instance.update(); } InputHelper.prototype.handleEventMouseUp = function(event) { this.isMouseClicked = false; } } function Map(cellSizeInPixels, pos, terrains, cellsAsStrings) { this.cellSizeInPixels = cellSizeInPixels; this.pos = pos; this.terrains = terrains; this.cellsAsStrings = cellsAsStrings; this.cellSizeInPixelsHalf = this.cellSizeInPixels.clone().divideScalar(2); this.terrains.addLookups("codeChar"); this.sizeInCells = new Coords ( this.cellsAsStrings[0].length, this.cellsAsStrings.length ); this.sizeInCellsMinusOnes = this.sizeInCells.clone().subtract ( new Coords(1, 1) ); } { Map.prototype.terrainAtPos = function(cellPos) { var terrainChar = this.cellsAsStrings[cellPos.y][cellPos.x]; var terrain = this.terrains[terrainChar]; return terrain; } // drawable Map.prototype.draw = function(display) { var map = this; var sizeInCells = map.sizeInCells; var mapCellSizeInPixels = map.cellSizeInPixels; var cellPos = display._mapCellPos; var drawPos = display._drawPos; for (var y = 0; y < sizeInCells.y; y++) { cellPos.y = y; for (var x = 0; x < sizeInCells.x; x++) { cellPos.x = x; var cellTerrain = map.terrainAtPos ( cellPos ); drawPos.overwriteWith ( cellPos ).multiply ( mapCellSizeInPixels ).add ( map.pos ); display.drawRectangle ( drawPos, mapCellSizeInPixels, this.colorFore, // border cellTerrain.color // fill ); } } } } function MapTerrain(name, codeChar, movePointsToTraverse, color) { this.name = name; this.codeChar = codeChar; this.movePointsToTraverse = movePointsToTraverse; this.color = color; } function Mover(defnName, factionName, orientation, pos) { this.defnName = defnName; this.factionName = factionName; this.orientation = orientation; this.pos = pos; } { Mover.prototype.defn = function() { return Globals.Instance.world.moverDefns[this.defnName]; } Mover.prototype.faction = function() { return Globals.Instance.world.factions[this.factionName]; } Mover.prototype.name = function() { return this.factionName + " " + this.defnName; } Mover.prototype.initialize = function() { var defn = this.defn(); this.integrity = defn.integrityMax; this.movePoints = defn.movePointsPerTurn; } // drawable Mover.prototype.draw = function(display, map, isMoverActive) { var mover = this; var moverDefn = mover.defn(); var mapCellSizeInPixels = map.cellSizeInPixels; var mapCellSizeInPixelsHalf = map.cellSizeInPixelsHalf; var drawPos = display._drawPos; var drawPos2 = display._drawPos2; drawPos.overwriteWith ( mover.pos ).multiply ( mapCellSizeInPixels ).add ( map.pos ).add ( mapCellSizeInPixelsHalf ); var radius = mapCellSizeInPixelsHalf.x; var colorStroke = (isMoverActive == true ? display.colorHighlight : display.colorFore); display.drawCircle ( drawPos, radius, colorStroke, mover.faction().color ); drawPos2.overwriteWith ( mover.orientation ).multiplyScalar ( radius ).add ( drawPos ); display.drawLine(drawPos, drawPos2, colorStroke); drawPos.subtract(mapCellSizeInPixelsHalf); display.drawTextAtPos(" " + moverDefn.codeChar, drawPos, colorStroke); if (isMoverActive == true) { if (this.targetPos != null) { drawPos.overwriteWith ( this.targetPos ).multiply ( mapCellSizeInPixels ).add ( map.pos ).add ( mapCellSizeInPixelsHalf ); display.drawCircle(drawPos, radius / 2, colorStroke, "Red"); } } } } function MoverDefn ( name, codeChar, integrityMax, movePointsPerTurn, attackRange, attackDamage, actionNamesAvailable ) { this.name = name; this.codeChar = codeChar; this.integrityMax = integrityMax; this.movePointsPerTurn = movePointsPerTurn; this.attackRange = attackRange; this.attackDamage = attackDamage; this.actionNamesAvailable = actionNamesAvailable; } { MoverDefn.prototype.actionsAvailable = function() { var returnValues = []; var actionsAll = Globals.Instance.world.actions; for (var i = 0; i < this.actionNamesAvailable.length; i++) { var actionName = this.actionNamesAvailable[i]; var action = actionsAll[actionName]; returnValues.push(action); } return returnValues; } } function World(actions, moverDefns, map, factions, movers) { this.actions = actions; this.moverDefns = moverDefns; this.map = map; this.movers = movers; this.factions = factions; this.actions.addLookups("name"); this.factions.addLookups("name"); this.moverDefns.addLookups("name"); this.moversToRemove = []; } { World.prototype.moverActive = function() { var returnValue = null; if (this.indexOfMoverActive != null) { returnValue = this.movers[this.indexOfMoverActive]; } return returnValue; } World.prototype.moverActiveAdvanceIfNeeded = function() { var moverActive = this.moverActive(); if (moverActive == null) { this.moversReplenish(); this.indexOfMoverActive = 0; moverActive = this.moverActive(); } else if (moverActive.movePoints <= 0) { this.indexOfMoverActive++; if (this.indexOfMoverActive >= this.movers.length) { this.moversReplenish(); this.indexOfMoverActive = 0; } moverActive = this.moverActive(); } return moverActive; } World.prototype.moverAtPos = function(posToCheck) { var returnValue = null; for (var i = 0; i < this.movers.length; i++) { var mover = this.movers[i]; if (mover.pos.equals(posToCheck) == true) { returnValue = mover; break; } } return returnValue; } World.prototype.moversReplenish = function() { for (var i = 0; i < this.movers.length; i++) { var mover = this.movers[i]; mover.movePoints = mover.defn().movePointsPerTurn; } } World.prototype.initialize = function() { for (var i = 0; i < this.movers.length; i++) { var mover = this.movers[i]; mover.initialize(); } var moverActive = this.moverActiveAdvanceIfNeeded(); this.containerMain = new ControlContainer ( "containerMain", new Coords(90, 180), // size new Coords(200, 10), // pos [ new ControlContainer ( "containerActions", new Coords(70, 90), // size new Coords(10, 10), // pos // children Control.toControlsMany ( moverActive.defn().actionsAvailable(), new Coords(10, 10), // posFirst new Coords(0, 12) // spacing ) ), new ControlContainer ( "containerSelection", new Coords(70, 60), // size new Coords(10, 110), // pos // children [ new ControlLabelDynamic ( "labelFaction", // name new Coords(5, 5), // pos function textFunction() { return Globals.Instance.world.moverActive().factionName } ), new ControlLabelDynamic ( "labelDefnName", // name new Coords(5, 15), // pos function textFunction() { return Globals.Instance.world.moverActive().defnName } ), new ControlLabelDynamic ( "labelIntegrity", // name new Coords(5, 25), // pos function textFunction() { var moverActive = Globals.Instance.world.moverActive(); var moverDefn = moverActive.defn(); return "Health:" + moverActive.integrity + "/" + moverDefn.integrityMax; } ), new ControlLabelDynamic ( "labelIntegrity", // name new Coords(5, 35), // pos function textFunction() { var moverActive = Globals.Instance.world.moverActive(); var moverDefn = moverActive.defn(); return "Moves:" + moverActive.movePoints + "/" + moverDefn.movePointsPerTurn; } ) ] ), ] ); this.update(); } World.prototype.update = function() { this.update_Input(); this.update_MoversIntegrityCheck(); this.moverActiveAdvanceIfNeeded(); this.update_VictoryCheck(); this.draw(Globals.Instance.display); } World.prototype.update_Input = function() { var inputHelper = Globals.Instance.inputHelper; if (inputHelper.isMouseClicked == true) { inputHelper.isMouseClicked = false; this.containerMain.mouseClick ( inputHelper.mousePos ); } else if (inputHelper.keyCodePressed != null) { var moverActive = this.moverActive(); if (moverActive != null) { var keyCodePressed = inputHelper.keyCodePressed; var moverActions = moverActive.defn().actionsAvailable(); for (var i = 0; i < moverActions.length; i++) { var moverAction = moverActions[i]; if (moverAction.keyCode == keyCodePressed) { moverAction.perform(); break; } } } } } World.prototype.update_MoversIntegrityCheck = function() { this.moversToRemove.length = 0; for (var i = 0; i < this.movers.length; i++) { var mover = this.movers[i]; if (mover.integrity <= 0) { this.moversToRemove.push(mover); } } for (var i = 0; i < this.moversToRemove.length; i++) { var mover = this.moversToRemove[i]; this.movers.remove(mover); } } World.prototype.update_VictoryCheck = function() { var factionNamesPresent = []; for (var i = 0; i < this.movers.length; i++) { var mover = this.movers[i]; var moverFactionName = mover.factionName; if (factionNamesPresent[moverFactionName] == null) { factionNamesPresent[moverFactionName] = moverFactionName; factionNamesPresent.push(moverFactionName); if (factionNamesPresent.length > 1) { break; } } } if (factionNamesPresent.length < 2) { var factionNameVictorious = factionNamesPresent[0]; alert("The " + factionNameVictorious + " team wins!"); } } // drawable World.prototype.draw = function(display) { var world = this; display.clear(); var map = world.map; map.draw(display); var movers = this.movers; for (var i = 0; i < movers.length; i++) { var mover = movers[i]; mover.draw ( display, map, false // isMoverActive ); } var mover = world.moverActive(); mover.draw ( display, map, true // isMoverActive ); world.containerMain.draw(display, display._zeroes); } } // run main(); </script> </body> </html>