12
\$\begingroup\$

Background

After learning Kotlin for Advent of Code in December, I started looking into cross-compiling Kotlin for both the JVM and to JavaScript. Then I wrote a game server in Kotlin and also a simple game implementation of a game known as The Royal Game of Ur. Game logic by itself doesn't do much good though without a beautiful client to play it with (few people likes sending data manually). So I decided to make one in what have become my favorite JavaScript framework (everyone must have one, right?).

The repository containing both the client and the server can be found here: https://github.com/Zomis/Server2

Play the game

You can now play The Royal Game of UR with a server (a simple AI just making a random move is also available to play against) or without a server. (If you can't get the server to work, play the version without a server).

Please note that these will be updated continuously and may not reflect the code in this question.

Rules of The Royal Game of Ur

Or rather, my rules.

Two players are fighting to be the first player who races all their 7 pieces to the exit.

The pieces walk like this:

v<<1 E<<    1 = First tile
>>>>>>>|    E = Exit
^<<2 E<<
  • Only player 1 can use the top row, only player 2 can use the bottom row. Both players share the middle row.
  • The first tile for Player 1 is the '1' in the top row. Player 2's first tile is the '2' in the bottom row.
  • Players take turns in rolling the four boolean dice. Then you move a piece a number of steps that equals the sum of these four booleans.
  • Five tiles are marked with flowers. When a piece lands on a flower the player get to roll again.
  • As long as a tile is on a flower another piece may not knock it out (only relevant for the middle flower).

Main Questions

  • Do I have too many / too few components? I am aiming to make several other games in Vue so I like to make things re-useable.
  • How are my Vue skills?
  • Can anything be done better with regards to how I am using Vue?
  • I am nowhere near a UX-designer, but how is the user experience?
  • Any other feedback also welcome.

Code

Some code that is not included below:

  • require("../../../games-js/web/games-js"): This is the Kotlin code for the game model. This is code that has been transpiled to JavaScript from Kotlin.
  • import Socket from "../socket": This is an utility class for handling the potential WebSocket connection. The code below is checking if the Socket is connected and can handle both scenarios.

RoyalGameOfUR.vue

<template>
  <div>
    <h1>{{ game }} : {{ gameId }}</h1>
    <div>
      <div>{{ gameOverMessage }}</div>
    </div>
    <div class="board-parent">
      <UrPlayerView v-bind:game="ur" v-bind:playerIndex="0"
        :gamePieces="gamePieces"
        :onPlaceNewHighlight="onPlaceNewHighlight"
        :mouseleave="mouseleave"
        :onPlaceNew="placeNew" />

      <div class="ur-board">
        <div class="ur-pieces-bg">
          <div v-for="idx in 20" class="piece piece-bg">
          </div>
          <div class="piece-black" style="grid-area: 1 / 5 / 2 / 7"></div>
          <div class="piece-black" style="grid-area: 3 / 5 / 4 / 7"></div>
        </div>
        <div class="ur-pieces-flowers">
          <UrFlower :x="0" :y="0" />
          <UrFlower :x="3" :y="1" />
          <UrFlower :x="0" :y="2" />
          <UrFlower :x="6" :y="0" />
          <UrFlower :x="6" :y="2" />
        </div>

        <div class="ur-pieces-player">
          <transition name="fade">
            <UrPiece v-if="destination !== null" :piece="destination" class="piece highlighted"
            :mouseover="doNothing" :mouseleave="doNothing"
            :class="{['piece-' + destination.player]: true}">
            </UrPiece>
          </transition>
          <UrPiece v-for="piece in playerPieces"
            :key="piece.key"
            class="piece"
            :mouseover="mouseover" :mouseleave="mouseleave"
            :class="{['piece-' + piece.player]: true, 'moveable':
              ur.isMoveTime && piece.player == ur.currentPlayer &&
              ur.canMove_qt1dr2$(ur.currentPlayer, piece.position, ur.roll)}"
            :piece="piece"
            :onclick="onClick">
          </UrPiece>
        </div>
      </div>
      <UrPlayerView v-bind:game="ur" v-bind:playerIndex="1"
       :gamePieces="gamePieces"
       :onPlaceNewHighlight="onPlaceNewHighlight"
       :mouseleave="mouseleave"
       :onPlaceNew="placeNew" />
      <UrRoll :roll="lastRoll" :usable="ur.roll < 0 && canControlCurrentPlayer" :onDoRoll="onDoRoll" />
    </div>
  </div>
</template>

<script>
import Socket from "../socket";
import UrPlayerView from "./ur/UrPlayerView";
import UrPiece from "./ur/UrPiece";
import UrRoll from "./ur/UrRoll";
import UrFlower from "./ur/UrFlower";

var games = require("../../../games-js/web/games-js");
if (typeof games["games-js"] !== "undefined") {
  // This is needed when doing a production build, but is not used for `npm run dev` locally.
  games = games["games-js"];
}
let urgame = new games.net.zomis.games.ur.RoyalGameOfUr_init();
console.log(urgame.toString());

function piecesToObjects(array, playerIndex) {
  var playerPieces = array[playerIndex].filter(i => i > 0 && i < 15);
  var arrayCopy = []; // Convert Int32Array to Object array
  playerPieces.forEach(it => arrayCopy.push(it));

  function mapping(position) {
    var y = playerIndex == 0 ? 0 : 2;
    if (position > 4 && position < 13) {
      y = 1;
    }
    var x =
      y == 1
        ? position - 5
        : position <= 4 ? 4 - position : 4 + 8 + 8 - position;
    return {
      x: x,
      y: y,
      player: playerIndex,
      key: playerIndex + "_" + position,
      position: position
    };
  }
  for (var i = 0; i < arrayCopy.length; i++) {
    arrayCopy[i] = mapping(arrayCopy[i]);
  }
  return arrayCopy;
}

export default {
  name: "RoyalGameOfUR",
  props: ["yourIndex", "game", "gameId"],
  data() {
    return {
      highlighted: null,
      lastRoll: 0,
      gamePieces: [],
      playerPieces: [],
      lastMove: 0,
      ur: urgame,
      gameOverMessage: null
    };
  },
  created() {
    if (this.yourIndex < 0) {
      Socket.send(
        `v1:{ "type": "observer", "game": "${this.game}", "gameId": "${
          this.gameId
        }", "observer": "start" }`
      );
    }
    Socket.$on("type:PlayerEliminated", this.messageEliminated);
    Socket.$on("type:GameMove", this.messageMove);
    Socket.$on("type:GameState", this.messageState);
    Socket.$on("type:IllegalMove", this.messageIllegal);
    this.playerPieces = this.calcPlayerPieces();
  },
  beforeDestroy() {
    Socket.$off("type:PlayerEliminated", this.messageEliminated);
    Socket.$off("type:GameMove", this.messageMove);
    Socket.$off("type:GameState", this.messageState);
    Socket.$off("type:IllegalMove", this.messageIllegal);
  },
  components: {
    UrPlayerView,
    UrRoll,
    UrFlower,
    UrPiece
  },
  methods: {
    doNothing: function() {},
    action: function(name, data) {
      if (Socket.isConnected()) {
        let json = `v1:{ "game": "UR", "gameId": "${
          this.gameId
        }", "type": "move", "moveType": "${name}", "move": ${data} }`;
        Socket.send(json);
      } else {
        console.log(
          "Before Action: " + name + ":" + data + " - " + this.ur.toString()
        );
        if (name === "roll") {
          let rollResult = this.ur.doRoll();
          this.rollUpdate(rollResult);
        } else {
          console.log(
            "move: " + name + " = " + data + " curr " + this.ur.currentPlayer
          );
          var moveResult = this.ur.move_qt1dr2$(
            this.ur.currentPlayer,
            data,
            this.ur.roll
          );
          console.log("result: " + moveResult);
          this.playerPieces = this.calcPlayerPieces();
        }
        console.log(this.ur.toString());
      }
    },
    placeNew: function(playerIndex) {
      if (this.canPlaceNew) {
        this.action("move", 0);
      }
    },
    onClick: function(piece) {
      if (piece.player !== this.ur.currentPlayer) {
        return;
      }
      if (!this.ur.isMoveTime) {
        return;
      }
      console.log("OnClick in URView: " + piece.x + ", " + piece.y);
      this.action("move", piece.position);
    },
    messageEliminated(e) {
      console.log(`Recieved eliminated: ${JSON.stringify(e)}`);
      this.gameOverMessage = e;
    },
    messageMove(e) {
      console.log(`Recieved move: ${e.moveType}: ${e.move}`);
      if (e.moveType == "move") {
        this.ur.move_qt1dr2$(this.ur.currentPlayer, e.move, this.ur.roll);
      }
      this.playerPieces = this.calcPlayerPieces();
      // A move has been done - check if it is my turn.
      console.log("After Move: " + this.ur.toString());
    },
    messageState(e) {
      console.log(`MessageState: ${e.roll}`);
      if (typeof e.roll !== "undefined") {
        this.ur.doRoll_za3lpa$(e.roll);
        this.rollUpdate(e.roll);
      }
      console.log("AfterState: " + this.ur.toString());
    },
    messageIllegal(e) {
      console.log("IllegalMove: " + JSON.stringify(e));
    },
    rollUpdate(rollValue) {
      this.lastRoll = rollValue;
    },
    onDoRoll() {
      this.action("roll", -1);
    },
    onPlaceNewHighlight(playerIndex) {
      if (playerIndex !== this.ur.currentPlayer) {
        return;
      }
      this.highlighted = { player: playerIndex, position: 0 };
    },
    mouseover(piece) {
      if (piece.player !== this.ur.currentPlayer) {
        return;
      }
      this.highlighted = piece;
    },
    mouseleave() {
      this.highlighted = null;
    },
    calcPlayerPieces() {
      let pieces = this.ur.piecesCopy;
      this.gamePieces = this.ur.piecesCopy;
      let obj0 = piecesToObjects(pieces, 0);
      let obj1 = piecesToObjects(pieces, 1);
      let result = [];
      for (var i = 0; i < obj0.length; i++) {
        result.push(obj0[i]);
      }
      for (var i = 0; i < obj1.length; i++) {
        result.push(obj1[i]);
      }
      console.log(result);
      return result;
    }
  },
  computed: {
    canControlCurrentPlayer: function() {
      return this.ur.currentPlayer == this.yourIndex || !Socket.isConnected();
    },
    destination: function() {
      if (this.highlighted === null) {
        return null;
      }
      if (!this.ur.isMoveTime) {
        return null;
      }
      if (
        !this.ur.canMove_qt1dr2$(
          this.ur.currentPlayer,
          this.highlighted.position,
          this.ur.roll
        )
      ) {
        return null;
      }
      let resultPosition = this.highlighted.position + this.ur.roll;
      let result = piecesToObjects(
        [[resultPosition], [resultPosition]],
        this.highlighted.player
      );
      return result[0];
    },
    canPlaceNew: function() {
      return (
        this.canControlCurrentPlayer &&
        this.ur.canMove_qt1dr2$(this.ur.currentPlayer, 0, this.ur.roll)
      );
    }
  }
};
</script>

<style>
.piece-0 {
  background-color: blue;
}

.ur-pieces-player .piece {
  margin: auto;
  width: 48px;
  height: 48px;
}

.piece-1 {
  background-color: red;
}

.piece-flower {
  opacity: 0.5;
  background-image: url('../assets/ur/flower.svg');
  margin: auto;
}

.board-parent {
  position: relative;
}

.piece-bg {
  background-color: white;
  border: 1px solid black;
}

.ur-board {
  position: relative;
  width: 512px;
  height: 192px;
  min-width: 512px;
  min-height: 192px;
  overflow: hidden;
  border: 12px solid #6D5720;
  border-radius: 12px;
  margin: auto;
}

.ur-pieces-flowers {
  z-index: 60;
}

.ur-pieces-flowers, .ur-pieces-player,
 .ur-pieces-bg {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  grid-template-rows: repeat(3, 1fr);
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.ur-pieces-player .piece {
  z-index: 70;
}

.piece {
  background-size: cover;
  z-index: 40;
  width: 100%;
  height: 100%;
}

.piece-black {
  background-color: #7f7f7f;
}

.player-view {
  width: 512px;
  height: 50px;
  margin: auto;
  display: flex;
  flex-flow: row;
  justify-content: space-between;
  align-items: center;
}

.side {
  display: flex;
  flex-flow: row;
}

.piece.highlighted {
  opacity: 0.5;
  box-shadow: 0 0 10px 8px black;
}

.side-out {
  flex-flow: row-reverse;
}

.moveable {
  cursor: pointer;
  animation: glow 1s infinite alternate;
}

@keyframes glow {
  from {
    box-shadow: 0 0 10px -10px #aef4af;
  }
  to {
    box-shadow: 0 0 10px 10px #aef4af;
  }
}

.fade-enter-active, .fade-leave-active {
  transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
  opacity: 0;
}

</style>

UrFlower.vue

<template>
  <div class="piece piece-flower"
     v-bind:style="{ 'grid-area': (y+1) + '/' + (x+1) }">
  </div>
</template>
<script>
export default {
  name: "UrFlower",
  props: ["x", "y"]
};
</script>

UrPiece.vue

<template>
  <transition name="fade">
  <div class="piece"
    v-on:click="click(piece)"
    :class="piece.id"
    @mouseover="mouseover(piece)" @mouseleave="mouseleave()"
     v-bind:style="{ gridArea: (piece.y+1) + '/' + (piece.x+1) }">
  </div>
  </transition>
</template>
<script>
export default {
  name: "UrPiece",
  props: ["piece", "onclick", "mouseover", "mouseleave"],
  methods: {
    click: function(piece) {
      console.log(piece);
      this.onclick(piece);
    }
  }
};
</script>

UrPlayerView.vue

<template>
  <div class="player-view">
    <div class="side side-remaining">
      <div class="number">{{ remaining }}</div>
      <div class="pieces-container">
        <div v-for="n in remaining" class="piece-small pointer"
          :class="{ ['piece-' + playerIndex]: true, moveable: canPlaceNew && n == remaining }"
          @mouseover="onPlaceNewHighlight(playerIndex)" @mouseleave="mouseleave()"
          style="position: absolute; top: 6px;"
          :style="{ left: (n-1)*12 + 'px' }" v-on:click="placeNew()">
        </div>
      </div>
    </div>
    <transition name="fade">
      <div class="player-active-indicator" v-if="game.currentPlayer == playerIndex"></div>
    </transition>
    <div class="side side-out">
      <div class="number">{{ out }}</div>
      <div class="pieces-container">
        <div v-for="n in out" class="piece-small"
          :class="['piece-' + playerIndex]"
          style="position: absolute; top: 6px;"
          :style="{ right: (n-1)*12 + 'px' }">
        </div>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: "UrPlayerView",
  props: [
    "game",
    "playerIndex",
    "onPlaceNew",
    "gamePieces",
    "onPlaceNewHighlight",
    "mouseleave"
  ],
  data() {
    return {};
  },
  methods: {
    placeNew: function() {
      this.onPlaceNew(this.playerIndex);
    }
  },
  computed: {
    remaining: function() {
      return this.gamePieces[this.playerIndex].filter(i => i === 0).length;
    },
    out: function() {
      return this.gamePieces[this.playerIndex].filter(i => i === 15).length;
    },
    canPlaceNew: function() {
      return (
        this.game.currentPlayer == this.playerIndex &&
        this.game.isMoveTime &&
        this.game.canMove_qt1dr2$(this.playerIndex, 0, this.game.roll)
      );
    }
  }
};
</script>
<style scoped>
.player-active-indicator {
  background: black;
  border-radius: 100%;
  width: 20px;
  height: 20px;
}

.number {
  margin: 2px;
  font-weight: bold;
  font-size: 2em;
}

.piece-small {
  background-size: cover;
  width: 24px;
  height: 24px;
  border: 1px solid black;
}

.pieces-container {
  position: relative;
}
</style>

UrRoll.vue

<template>
  <div class="ur-roll">
    <div class="ur-dice" @click="onclick()" :class="{ moveable: usable }">
      <div v-for="i in 4" class="ur-die">
        <div v-if="rolls[i - 1]" class="ur-die-filled"></div>
      </div>
    </div>
    <span>{{ roll }}</span>
  </div>

</template>
<script>
function shuffle(array) {
  // https://stackoverflow.com/a/2450976/1310566
  var currentIndex = array.length,
    temporaryValue,
    randomIndex;

  // While there remain elements to shuffle...
  while (0 !== currentIndex) {
    // Pick a remaining element...
    randomIndex = Math.floor(Math.random() * currentIndex);
    currentIndex -= 1;

    // And swap it with the current element.
    temporaryValue = array[currentIndex];
    array[currentIndex] = array[randomIndex];
    array[randomIndex] = temporaryValue;
  }

  return array;
}

export default {
  name: "UrRoll",
  props: ["roll", "usable", "onDoRoll"],
  data() {
    return { rolls: [false, false, false, false] };
  },
  watch: {
    roll: function(newValue, oldValue) {
      console.log("Set roll to " + newValue);
      if (newValue < 0) {
        return;
      }
      this.rolls.fill(false);
      this.rolls.fill(true, 0, newValue);
      console.log(this.rolls);
      shuffle(this.rolls);
      console.log("After shuffle:");
      console.log(this.rolls);
    }
  },
  methods: {
    onclick: function() {
      this.onDoRoll();
    }
  }
};
</script>
<style scoped>
.ur-roll {
  margin-top: 10px;
}

.ur-roll span {
  font-size: 2em;
  font-weight: bold;
}

.ur-dice {
  width: 320px;
  height: 64px;
  margin: 5px auto 5px auto;
  display: flex;
  justify-content: space-between;
}

.ur-die-filled {
  background: black;
  border-radius: 100%;
  width: 20%;
  height: 20%;
}

.ur-die {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 64px;
  border: 1px solid black;
  border-radius: 12px;
}
</style>
\$\endgroup\$
8
\$\begingroup\$

warning: cheesy meme with bad pun below - if you don't like those, then please skip it...

Ermagherd
2

Question responses

Do I have too many / too few components? I am aiming to make several other games in Vue so I like to make things re-useable.

I think the current components are divided well. The existing components make sense.

How are my Vue skills?

Usage of Vue looks good. There are a few general JS aspects that I have feedback for (see below, under last "question") but usage of Vue components and other constructs looks good.

Can anything be done better with regards to how I am using Vue?

Bearing in mind I am not an expert VueJS user and have only been working with it on small projects in the past year, I can't really think of anything... If you really wanted you could consider using slots somehow, or an Event bus if the components became more separated but that might not be nessary since everything is contained in the main RoyalGameOfUR component.

If I think of anything else, I will surely update this answer.

I am nowhere near a UX-designer, but how is the user experience?

The layout of the game components is okay, though it would be helpful to have more text prompting the user what to do, or at least the rules and game play instructions somewhere (e.g. in a text box, linked to another page, etc.). In the same vain, I see an uncaught exception in the console if the user clicks the dice when it isn't time to roll. One could catch the exception and alert the user about what happened.

Any other feedback also welcome.

Feedback

Wow that is a really elegant application! Well done! I haven't used the grid styles yet but hope to in the future.

I did notice that after rolling the dice, when selecting a piece from a stack, it doesn't matter which player is the current player - I can click on either stack (though only a piece from the current player's stack will get moved).

I did notice an error once about this.onclick is not defined but I didn't observe the path to reproduce it. If I see it again I will let you know.

Suggestions

JS

let & const

I see the code utilizes let in a few places but otherwise just var. It would be wise to start using const anywhere a value is stored but never re-assigned - then use let if re-assignment is necessary. Using var outside of a function declares a global variable1...I only spot one of those in your post (i.e. var games) but if there were other places where you wanted a variable in another file called games then this could lead to unintentional value over-writing.

Array copying

In piecesToObjects(), I see these lines:

var arrayCopy = []; // Convert Int32Array to Object array
playerPieces.forEach(it => arrayCopy.push(it));

You could utilize Array.from() to copy the array, then use array.map() to call mapping() instead of using the for loop. Originally I was thinking that the forEach could be eliminated but there is a need to get a regular array instead of the typed array (i.e. Int32Array). If the array being copied (i.e. array) was a regular array, then you likely could just use .map() - see this jsPerf to see how much quicker that mapping could be.

return Array.from(playerPieces).map(mapping);

And that function mapping could be pulled out of piecesToObjects if playerIndex is accepted as the first parameter, and then playerIndex can be sent on each iteration using Function.bind() - i.e. using a partially applied function.

return Array.from(playerPieces).map(mapping.bind(null, playerIndex));

Nested Ternary operator

Bearing in mind that this might just be maintained by you, if somebody else wanted to update the code, that person might find the line below less readable than several normal if blocks. My former supervisor had a rule: no more than one ternary operator in one expression - especially if it made the line longer than ~100 characters.

var x =
  y == 1
    ? position - 5
    : position <= 4 ? 4 - position : 4 + 8 + 8 - position;

Something a little more readable might be:

var x;
if (y == 1) {
    x = position - 5;
}
else {
    x = position <= 4 ? 4 - position : 4 + 8 + 8 - position;
}

0-based Flower grid areas

Why add 1 to the x and y values in UrFlower's template? Perhaps you are so used to 0-based indexes and wanted to keep those values in the markup orthogonal with your ways... Those flowers could be put unto an array and looped over using v-for... but for 5 flowers that might be too excessive...

CSS

Inline style vs CSS

There are static inline style attributes in UrPlayerView.vue - e.g. :

div v-for="n in remaining" class="piece-small pointer"
      :class="{ ['piece-' + playerIndex]: true, moveable: canPlaceNew && n == remaining }"
      @mouseover="onPlaceNewHighlight(playerIndex)" @mouseleave="mouseleave()"
      style="position: absolute; top: 6px;"

and

<div v-for="n in out" class="piece-small"
      :class="['piece-' + playerIndex]"
      style="position: absolute; top: 6px;" 

The position and top styles could be put into the existing ruleset for .piece-small...

1https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var#Description

\$\endgroup\$
  • 1
    \$\begingroup\$ I think the Vue <style scoped> is not the same as a HTML 5 <style scoped>. Vue adds some data-things to only apply the style to things from the same component. vue-loader.vuejs.org/en/features/scoped-css.html \$\endgroup\$ – Simon Forsberg Apr 6 '18 at 8:28
  • \$\begingroup\$ Ah - my colleague setup the vue loader config before I started working on the project and I must have skipped over that section when learning about vue loader. Good correction! \$\endgroup\$ – Sᴀᴍ Onᴇᴌᴀ Apr 6 '18 at 18:24
1
\$\begingroup\$

[1]

I have become more acquainted with since I gave the answer back in April, and realize now that because you are using features like const, let and arrow functions, other es-6 features could be used as well.

For instance, the following lines in the shuffle() function:

// And swap it with the current element.
temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;

Could be simplified to a single line using (Array) destructuring assignment

[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];

I also suggested using Array.from() to copy the array of pieces in piecesToObjects(), and while that is part of the es-6 standard, the spread syntax could be used instead of calling that function. Instead of a final statement like

return Array.from(playerPieces).map(mapping);

You should be able to use that spread syntax:

return [...playerPieces].map(mapping);
\$\endgroup\$

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.