10
\$\begingroup\$

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.

Checkers Board Editor

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);
    }
}
\$\endgroup\$
1
\$\begingroup\$

In this method you do not use the variant parameter.

public static BoardViewModel DefaultBoard(Variant variant) =>
    new BoardViewModel { GameBoard = Checkers.Board.defaultBoard.ToArray() };

This below should use the Any extension method instead of All and reverse your condition.

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;
}

//becomes
private Theme GetThemeOrDefault()
{
    if (Request.Cookies.Keys.Any(a => a = "theme"))
    {
        return Enum.Parse(typeof(Theme), Request.Cookies["theme"]) as Theme? ?? Theme.Steel;

    }
    return Theme.Steel;
}

You don't gain much by doing this but you won't go through all the cookies to check if one of them is equal to theme (Well I mean, it is possible you'll have to but this would be the scenario where the theme cookie is the last one in the list)

Otherwise your code looks pretty good, but there are some things that are hard to review considering that you are using "domain objects" (The Checkers.Board class which we don't have).

If you want to get funky you could switch your dictionary implementation to this (there is no real gain except if you want to get to learn more about Linq):

int ZeroChar = 48;
Enumerable.Range(0, 9).ToDictionary(x => (char)(x + ZeroChar), x => x);

I'm also a little concerned about the fact that a Piece's ID is a string, but I don't have the implementation so I can't dig more into it.

\$\endgroup\$
  • \$\begingroup\$ Whoops, I did forget to add the JS method for your last comment. That ID is the generated HTML element ID for the piece, which identifies it as a piece and which row/column it is on. P.S. I don't really need to learn more about Linq, but that is just ugly, and I'm about 99% sure it would slow things down far more than switching from All to Any would speed things up--especially considering that I only have 2 cookies in use ATM. \$\endgroup\$ – Hosch250 May 8 '18 at 13:35
  • \$\begingroup\$ @DainIronfootIII could you happen to show me the JS code you are using? \$\endgroup\$ – FreezePhoenix May 8 '18 at 13:38
  • \$\begingroup\$ @DainIronfootIII Considering we're generating a 9 number sequence it wouldn't slow down much, but I do get your point. It was more of a "hipster dev" suggestion. I'm aware that switching from All to Any won't change much, but well, your code was pretty fine some something had to be reviewed. :p \$\endgroup\$ – IEatBagels May 8 '18 at 13:41
  • \$\begingroup\$ @FreezePhoenix It's the .ts files in this directory: github.com/Hosch250/CheckersWebsite/tree/master/CheckersWebsite/…. The .js files are the generated JS. The specific function is github.com/Hosch250/CheckersWebsite/blob/master/CheckersWebsite/…. \$\endgroup\$ – Hosch250 May 8 '18 at 13:42
  • 2
    \$\begingroup\$ Comments are not for extended discussion. If you have more questions about jQuery/JavaScript/TypeScript and why they are being used, please take it into a chatroom. \$\endgroup\$ – Malachi May 8 '18 at 14:10

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.