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>