A year or so ago, I wrote a checkers game for the Universal Windows Platform. Now, I wrote a web-based version of it hosted at http://checkerstreasury.azurewebsites.net. This is the first of a series questions about my best practices.
Related to Checkers Board Editor, but web-based.
The requirements of this system are to allow the user to place checkers on a board and get the FEN value of the board for a given variant, which they can then use to create a game from that position. They can also choose which player makes the starting move and choose common board positions to start modifying from.
- Pieces can be moved by drag/drop.
- A piece dropped on an invalid square will be removed from the board.
- A piece dropped on a valid square will be placed on that square, replacing any existing piece.
- Pieces can be moved by clicking.
- A clicked piece always becomes the selected piece, regardless of if there is a selected piece.
- If there is a selected board piece and an invalid square is clicked, the selected piece is removed from the board.
- If there is a selected board piece and a valid square without an existing piece is selected, the piece will be moved to that square and unselected.
- If a template piece is selected, any selected board piece will be unselected.
- If a valid square is clicked and a template piece is selected, the template piece will be added to the board at that position.
- If an invalid square is clicked and a template piece is selected, nothing happens and the template square remains selected.
- When the board state changes, the FEN textbox below the board will be updated.
- When the Copy button next to the FEN textbox is clicked, the FEN value will be selected, copied to the clipboard, and "Copied!" will be shown beneath the textbox for 5 seconds.
- Selected board and template pieces are shown with a golden border.
- I am aware that I am violating several accessibility guidelines at the moment. I am in the process of correcting this, and will post a follow-up question later specifically focusing on accessibility across the whole website.
First, the MVC View files:
BoardEditor.cshtml
@using CheckersWebsite.Enums
@using CheckersWebsite.Facade
@using CheckersWebsite.ViewModels
@using CheckersWebsite.Resources
<div>
<div class="row board-editor">
<div class="col-lg-1"></div>
<div class="col-lg-1 piece-col column">
<img id="piece-black-checker" class="template-piece" src="@Url.Content($"~/images/SteelTheme/BlackChecker.png")" onmousedown="{ $('.selected-add').removeClass('selected-add'); $('#piece-black-checker').addClass('selected-add'); }" />
<img id="piece-black-king" class="template-piece" src="@Url.Content($"~/images/SteelTheme/BlackKing.png")" onmousedown="{ $('.selected-add').removeClass('selected-add'); $('#piece-black-king').addClass('selected-add'); }" />
<img id="piece-white-checker" class="template-piece" src="@Url.Content($"~/images/SteelTheme/WhiteChecker.png")" onmousedown="{ $('.selected-add').removeClass('selected-add'); $('#piece-white-checker').addClass('selected-add'); }" />
<img id="piece-white-king" class="template-piece" src="@Url.Content($"~/images/SteelTheme/WhiteKing.png")" onmousedown="{ $('.selected-add').removeClass('selected-add'); $('#piece-white-king').addClass('selected-add'); }" />
</div>
<div class="col-lg-6 column board-col">
@{
var controller = GameController.FromVariant(Variant.AmericanCheckers);
var boardVM = new BoardViewModel
{
GameBoard = controller.Board.GameBoard
};
}
@Html.Partial("~/Views/Controls/CheckersBoardEditor.cshtml", boardVM)
<div class="input-group">
<div class="input-group-addon"><label for="white-weight">FEN</label></div>
<input id="fen-board-editor" class="form-control" type="text" readonly value="@controller.Fen" />
<span class="input-group-btn">
<button id="copy-fen" class="btn btn-default" type="button">
<span class="glyphicon glyphicon-copy"></span>
<span class="sr-only">Copy FEN</span>
</button>
</span>
</div>
<div id="copied">
<span>Copied!</span>
</div>
</div>
<div class="col-lg-3 column">
<div class="form-group">
<label for="variant">Variant</label>
@Html.DropDownList("variant-board-editor", Enum.GetNames(typeof(Variant)).Select(s =>
new SelectListItem
{
Text = Resources.ResourceManager.GetString(s),
Value = s
}
),
new { @class = "select form-control", id = "variant-board-editor" })
</div>
<div class="form-group">
<label for="player">Starting Player</label>
@Html.DropDownList("player-board-editor", Enum.GetNames(typeof(Player)).Select(s =>
new SelectListItem
{
Text = Resources.ResourceManager.GetString(s),
Value = s
}
),
new { @class = "select form-control", id = "player-board-editor" })
</div>
<div class="form-group">
<label for="position-board-editor">Position</label>
<select id="position-board-editor" class="select form-control">
<option value="default">Default</option>
<option value="empty">Empty</option>
</select>
</div>
</div>
</div>
</div>
<script>
$(function () {
boardEditorInit();
$('#variant-board-editor').change(updateBoardEditor);
$('#position-board-editor').change(updateBoardEditor);
$('#player-board-editor').change(GetFEN);
$('#copy-fen').click(function () {
$('#fen-board-editor').select();
document.execCommand("copy");
$('#copied').fadeIn('slow', function () {
$('#copied').delay(5000).fadeOut();
});
});
});
</script>
And it's partial view, CheckersBoardEditor.cshtml
@using CheckersWebsite.Enums
@using CheckersWebsite.ViewModels
@model BoardViewModel
@{
var orientation = Player.Black;
var theme = (Theme)ViewData["theme"];
int GetAdjustedIndex(int value)
{
return orientation == Player.White ? value : 7 - value;
}
}
<div class="board" id="board-editor-board-container" orientation="@orientation">
@for (var row = 0; row < 8; row++)
{
for (var col = 0; col < 8; col++)
{
<img class="square @(GetAdjustedIndex(col) % 2 == GetAdjustedIndex(row) % 2 ? "" : "drop-target")"
id="square@(GetAdjustedIndex(row))@(GetAdjustedIndex(col))"
src="@Url.Content($"~/images/{theme}Theme/{(GetAdjustedIndex(col) % 2 == GetAdjustedIndex(row) % 2 ? "Light" : "Dark")}{theme}.png")"
style="grid-row: @(GetAdjustedIndex(row) + 1); grid-column: @(GetAdjustedIndex(col) + 1)" />
var piece = Model[row, col];
if (piece != null)
{
<img id="piece@(GetAdjustedIndex(row))@(GetAdjustedIndex(col))"
class="piece"
player="@piece.Player.ToString()"
pieceType="@piece.PieceType.ToString()"
src="@Url.Content($"~/images/{theme}Theme/{piece.Player}{piece.PieceType}.png")"
style="grid-row: @(GetAdjustedIndex(row) + 1); grid-column: @(GetAdjustedIndex(col) + 1)" />
}
}
}
</div>
boardEditor.ts
/// <reference path="../Scripts/typings/jquery/jquery.d.ts"/>
var boardEditorTrueCoords = null;
var boardEditorGrabPoint = null;
var boardEditorDragTarget = null;
var boardEditorGrabScreenCoords = null;
var boardEditorGrabClientCoords = null;
function boardEditorInit() {
if ($('.board-editor').length === 1) {
$('*').on('mousedown', boardEditorGrab);
$('*').on('dragend', boardEditorDrop);
$('*').on('mouseup', boardEditorDrop);
$('*').on('click', boardEditorClick);
}
}
function boardEditorGrab(evt) {
var targetElement = evt.target;
if (!boardEditorDragTarget && evt.target.id.startsWith('piece') && $('.selected-add').length === 0) {
if (!$(targetElement).hasClass('selected-add')) {
$('.selected-add').removeClass('selected-add');
}
$('.selected').removeClass('selected');
$('.drag').removeClass('drag');
if (!$(targetElement).hasClass('template-piece')) {
$(`#${targetElement.id}`).addClass('selected drag')
}
boardEditorGrabScreenCoords = {
x: evt.screenX,
y: evt.screenY
};
boardEditorGrabClientCoords = {
x: evt.clientX,
y: evt.clientY
};
boardEditorDragTarget = targetElement;
boardEditorGrabPoint = {
x: evt.clientX,
y: evt.clientY
};
}
};
function boardEditorClick(evt) {
if (!boardEditorDragTarget && evt.target.id.startsWith('square') && $('.selected').length !== 0) {
boardEditorDragTarget = $('.selected')[0];
boardEditorDrop(evt);
return false;
}
return true;
}
function boardEditorDrop(evt) {
if (boardEditorDragTarget && $('.selected-add').length === 0) {
boardEditorMovePiece(evt);
GetFEN();
} else if ($('.selected-add').length !== 0) {
boardEditorAddPieceToBoard(evt);
GetFEN();
}
boardEditorDragTarget = null;
};
function boardEditorMovePiece(evt) {
var dropClientCoords = {
x: boardEditorGrabClientCoords.x + (evt.screenX - boardEditorGrabScreenCoords.x),
y: boardEditorGrabClientCoords.y + (evt.screenY - boardEditorGrabScreenCoords.y)
};
var boundingSquare = getBoundingSquare(dropClientCoords);
if (boundingSquare) {
var coord = boundingSquare.id.replace('square', '');
var startCoord = evt.target.id.replace('piece', '');
var row = parseInt(coord[0]);
var col = parseInt(coord[1]);
var startRow = parseInt(startCoord[0]);
var startCol = parseInt(startCoord[1]);
if (coord === startCoord) {
$('.drag').removeClass('drag');
return;
}
$(`#piece${row}${col}`).remove();
$(boardEditorDragTarget).attr('id', `piece${row}${col}`);
$(boardEditorDragTarget).css('grid-row', `${row + 1}`);
$(boardEditorDragTarget).css('grid-column', `${col + 1}`);
} else {
$(boardEditorDragTarget).remove();
}
}
function boardEditorAddPieceToBoard(evt) {
var dropClientCoords: { x; y };
if (evt.type === 'dragend') {
var dropScreenCoords = {
x: evt.screenX,
y: evt.screenY
};
dropClientCoords = {
x: boardEditorGrabClientCoords.x + (evt.screenX - boardEditorGrabScreenCoords.x),
y: boardEditorGrabClientCoords.y + (evt.screenY - boardEditorGrabScreenCoords.y)
};
} else {
dropClientCoords = {
x: evt.clientX,
y: evt.clientY
};
}
var boundingSquare = getBoundingSquare(dropClientCoords);
if (boundingSquare) {
var player: string;
var pieceType: string;
switch ($('.selected-add').attr('id')) {
case 'piece-black-checker':
player = "Black";
pieceType = "Checker";
break;
case 'piece-black-king':
player = "Black";
pieceType = "King";
break;
case 'piece-white-checker':
player = "White";
pieceType = "Checker";
break;
case 'piece-white-king':
player = "White";
pieceType = "King";
break;
}
var coord = boundingSquare.id.replace('square', '');
var row = parseInt(coord[0]);
var col = parseInt(coord[1]);
$(`#piece${row}${col}`).remove();
var newPiece = `<img id="piece${row}${col}" class="piece" player="${player}" pieceType="${pieceType}" src="/images/SteelTheme/${player}${pieceType}.png" style="grid-row: ${row + 1}; grid-column: ${col + 1}" />`;
$('.board').append(newPiece);
$('.selected-add').removeClass('selected-add');
}
}
function getBoundingSquare(dropClientCoords) {
var squares = $('.drop-target');
for (var i = 0; i < squares.length; i++) {
var el = squares[i];
var boundingRect = el.getBoundingClientRect();
if (boundingRect.left <= dropClientCoords.x &&
boundingRect.right >= dropClientCoords.x &&
boundingRect.top <= dropClientCoords.y &&
boundingRect.bottom >= dropClientCoords.y) {
return el;
}
}
return null;
}
The Model:
public class BoardViewModel
{
public Piece[,] GameBoard { get; set; }
public static BoardViewModel EmptyBoard() =>
new BoardViewModel { GameBoard = Checkers.Board.emptyBoardList().ToArray() };
public static BoardViewModel DefaultBoard(Variant variant) =>
new BoardViewModel { GameBoard = Checkers.Board.defaultBoard.ToArray() };
public Piece this[int row, int column] => GameBoard[row, column];
}
public class PieceValueViewModel
{
public string ID { get; set; }
public Player Player { get; set; }
public PieceType Piece { get; set; }
public Coord Coord { get; set; }
}
And the Controller:
public class BoardEditorController : Controller
{
private Theme GetThemeOrDefault()
{
if (Request.Cookies.Keys.All(a => a != "theme"))
{
return Theme.Steel;
}
return Enum.Parse(typeof(Theme), Request.Cookies["theme"]) as Theme? ?? Theme.Steel;
}
public IActionResult GetBoard(Variant variant, BoardEditorPosition position)
{
ViewData.Add("theme", GetThemeOrDefault());
var board = position == BoardEditorPosition.Empty ? Board.EmptyBoard() : Board.DefaultBoard(variant);
return PartialView("~/Views/Controls/CheckersBoardEditor.cshtml", board);
}
public IActionResult GetFEN(Variant variant, Player startingPlayer, List<PieceValueViewModel> pieces)
{
var numberMap = new Dictionary<char, int>
{
['0'] = 0,
['1'] = 1,
['2'] = 2,
['3'] = 3,
['4'] = 4,
['5'] = 5,
['6'] = 6,
['7'] = 7,
['8'] = 8,
['9'] = 9
};
pieces = pieces.Select(s => {
var coord = s.ID.Replace("piece", "");
return new PieceValueViewModel
{
Coord = new Coord
{
Row = numberMap[coord[0]],
Column = numberMap[coord[1]]
},
Piece = s.Piece,
Player = s.Player
};
}).ToList();
var board = Board.EmptyBoard();
pieces.ForEach(f =>
{
board.GameBoard[f.Coord.Row, f.Coord.Column] = new Piece(f.Player, f.Piece);
});
var controller = new GameController(variant, board, startingPlayer);
return Content(controller.Fen);
}
}