Browse Source

Updated Game to allow continuous play

master
Levi Olson 5 years ago
parent
commit
ebddc1aaec
13 changed files with 566 additions and 557 deletions
  1. +14
    -131
      cache/cache.go
  2. +32
    -3
      cache/structs.go
  3. +14
    -3
      dist/bundle.js
  4. +20
    -23
      dist/index.html
  5. +20
    -22
      dist/join.html
  6. +31
    -16
      dist/main.css
  7. +72
    -40
      main.go
  8. +47
    -49
      src/classes.js
  9. +268
    -223
      src/index.js
  10. +13
    -8
      src/services.js
  11. +35
    -18
      src/style.scss
  12. +0
    -21
      structs.go
  13. BIN
      tictactoe-api

+ 14
- 131
cache/cache.go View File

@ -1,28 +1,17 @@
package cache package cache
import ( import (
"fmt"
"runtime" "runtime"
"time" "time"
uuid "github.com/satori/go.uuid"
) )
// Expired returns true if the item has expired
func (item Item) Expired() bool {
if item.Expiration == 0 {
return false
}
return time.Now().UnixNano() > item.Expiration
}
// NewCache returns a simple in-memory cache // NewCache returns a simple in-memory cache
func NewCache(defaultExp, cleanupInterval time.Duration) *Cache { func NewCache(defaultExp, cleanupInterval time.Duration) *Cache {
items := make(map[uuid.UUID]Item)
items := make(map[string]Item)
return newCacheWithJanitor(defaultExp, cleanupInterval, items) return newCacheWithJanitor(defaultExp, cleanupInterval, items)
} }
func newCacheWithJanitor(defaultExp, cleanupInterval time.Duration, items map[uuid.UUID]Item) *Cache {
func newCacheWithJanitor(defaultExp, cleanupInterval time.Duration, items map[string]Item) *Cache {
cache := newCache(defaultExp, items) cache := newCache(defaultExp, items)
Cache := &Cache{cache} Cache := &Cache{cache}
if cleanupInterval > 0 { if cleanupInterval > 0 {
@ -32,7 +21,7 @@ func newCacheWithJanitor(defaultExp, cleanupInterval time.Duration, items map[uu
return Cache return Cache
} }
func newCache(de time.Duration, m map[uuid.UUID]Item) *cache {
func newCache(de time.Duration, m map[string]Item) *cache {
if de == 0 { if de == 0 {
de = -1 de = -1
} }
@ -46,7 +35,7 @@ func newCache(de time.Duration, m map[uuid.UUID]Item) *cache {
// Add an item to the cache, replacing any existing item. If the duration is 0 // Add an item to the cache, replacing any existing item. If the duration is 0
// (DefaultExpiration), the cache's default expiration time is used. If it is -1 // (DefaultExpiration), the cache's default expiration time is used. If it is -1
// (NoExpiration), the item never expires. // (NoExpiration), the item never expires.
func (c *cache) Set(k uuid.UUID, x interface{}, d time.Duration) {
func (c *cache) Set(k string, x Game, d time.Duration) {
// "Inlining" of set // "Inlining" of set
var e int64 var e int64
if d == DefaultExpiration { if d == DefaultExpiration {
@ -57,7 +46,7 @@ func (c *cache) Set(k uuid.UUID, x interface{}, d time.Duration) {
} }
c.mu.Lock() c.mu.Lock()
c.items[k] = Item{ c.items[k] = Item{
Object: x,
Game: x,
Expiration: e, Expiration: e,
} }
// TODO: Calls to mu.Unlock are currently not deferred because defer // TODO: Calls to mu.Unlock are currently not deferred because defer
@ -65,7 +54,7 @@ func (c *cache) Set(k uuid.UUID, x interface{}, d time.Duration) {
c.mu.Unlock() c.mu.Unlock()
} }
func (c *cache) set(k uuid.UUID, x interface{}, d time.Duration) {
func (c *cache) set(k string, x Game, d time.Duration) {
var e int64 var e int64
if d == DefaultExpiration { if d == DefaultExpiration {
d = c.defaultExpiration d = c.defaultExpiration
@ -74,159 +63,53 @@ func (c *cache) set(k uuid.UUID, x interface{}, d time.Duration) {
e = time.Now().Add(d).UnixNano() e = time.Now().Add(d).UnixNano()
} }
c.items[k] = Item{ c.items[k] = Item{
Object: x,
Game: x,
Expiration: e, Expiration: e,
} }
} }
// Add an item to the cache, replacing any existing item, using the default // Add an item to the cache, replacing any existing item, using the default
// expiration. // expiration.
func (c *cache) SetDefault(k uuid.UUID, x interface{}) {
func (c *cache) SetDefault(k string, x Game) {
c.Set(k, x, DefaultExpiration) c.Set(k, x, DefaultExpiration)
} }
// Add an item to the cache only if an item doesn't already exist for the given
// key, or if the existing item has expired. Returns an error otherwise.
func (c *cache) Add(k uuid.UUID, x interface{}, d time.Duration) error {
c.mu.Lock()
_, found := c.get(k)
if found {
c.mu.Unlock()
return fmt.Errorf("Item %s already exists", k)
}
c.set(k, x, d)
c.mu.Unlock()
return nil
}
// Set a new value for the cache key only if it already exists, and the existing
// item hasn't expired. Returns an error otherwise.
func (c *cache) Replace(k uuid.UUID, x interface{}, d time.Duration) error {
c.mu.Lock()
_, found := c.get(k)
if !found {
c.mu.Unlock()
return fmt.Errorf("Item %s doesn't exist", k)
}
c.set(k, x, d)
c.mu.Unlock()
return nil
}
// Get an item from the cache. Returns the item or nil, and a bool indicating // Get an item from the cache. Returns the item or nil, and a bool indicating
// whether the key was found. // whether the key was found.
func (c *cache) Get(k uuid.UUID) (interface{}, bool) {
c.mu.RLock()
// "Inlining" of get and Expired
item, found := c.items[k]
if !found {
c.mu.RUnlock()
return nil, false
}
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock()
return nil, false
}
}
c.mu.RUnlock()
return item.Object, true
}
// GetWithExpiration returns an item and its expiration time from the cache.
// It returns the item or nil, the expiration time if one is set (if the item
// never expires a zero value for time.Time is returned), and a bool indicating
// whether the key was found.
func (c *cache) GetWithExpiration(k uuid.UUID) (interface{}, time.Time, bool) {
func (c *cache) Get(k string) (Game, bool) {
c.mu.RLock() c.mu.RLock()
// "Inlining" of get and Expired // "Inlining" of get and Expired
item, found := c.items[k] item, found := c.items[k]
if !found { if !found {
c.mu.RUnlock() c.mu.RUnlock()
return nil, time.Time{}, false
return item.Game, false
} }
if item.Expiration > 0 { if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration { if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock() c.mu.RUnlock()
return nil, time.Time{}, false
return item.Game, false
} }
// Return the item and the expiration time
c.mu.RUnlock()
return item.Object, time.Unix(0, item.Expiration), true
} }
// If expiration <= 0 (i.e. no expiration time set) then return the item
// and a zeroed time.Time
c.mu.RUnlock() c.mu.RUnlock()
return item.Object, time.Time{}, true
}
func (c *cache) get(k uuid.UUID) (interface{}, bool) {
item, found := c.items[k]
if !found {
return nil, false
}
// "Inlining" of Expired
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
return nil, false
}
}
return item.Object, true
}
func (c *cache) delete(k uuid.UUID) (interface{}, bool) {
if c.onEvicted != nil {
if v, found := c.items[k]; found {
delete(c.items, k)
return v.Object, true
}
}
delete(c.items, k)
return nil, false
return item.Game, true
} }
type keyAndValue struct { type keyAndValue struct {
key uuid.UUID
key string
value interface{} value interface{}
} }
// Delete all expired items from the cache. // Delete all expired items from the cache.
func (c *cache) DeleteExpired() { func (c *cache) DeleteExpired() {
var evictedItems []keyAndValue
now := time.Now().UnixNano() now := time.Now().UnixNano()
c.mu.Lock() c.mu.Lock()
for k, v := range c.items { for k, v := range c.items {
// "Inlining" of expired // "Inlining" of expired
if v.Expiration > 0 && now > v.Expiration { if v.Expiration > 0 && now > v.Expiration {
ov, evicted := c.delete(k)
if evicted {
evictedItems = append(evictedItems, keyAndValue{k, ov})
}
delete(c.items, k)
} }
} }
c.mu.Unlock() c.mu.Unlock()
for _, v := range evictedItems {
c.onEvicted(v.key, v.value)
}
}
// Sets an (optional) function that is called with the key and value when an
// item is evicted from the cache. (Including when it is deleted manually, but
// not when it is overwritten.) Set to nil to disable.
func (c *cache) OnEvicted(f func(uuid.UUID, interface{})) {
c.mu.Lock()
c.onEvicted = f
c.mu.Unlock()
}
// Delete all items from the cache.
func (c *cache) Flush() {
c.mu.Lock()
c.items = map[uuid.UUID]Item{}
c.mu.Unlock()
} }
type janitor struct { type janitor struct {

+ 32
- 3
cache/structs.go View File

@ -9,10 +9,39 @@ import (
// Item for caching // Item for caching
type Item struct { type Item struct {
Object interface{}
Game Game
Expiration int64 Expiration int64
} }
// Game object for binding with JSON POST body
type Game struct {
ID string `json:"id"`
Player1 *Player `json:"player1"`
Player2 *Player `json:"player2"`
Turn *uuid.UUID `json:"turn,omitempty"`
Draw *bool `json:"draw,omitempty"`
Winner *uuid.UUID `json:"winner,omitempty"`
Matrix Matrix `json:"matrix"`
Tally []*Tally `json:"tally,omitempty"`
NextGame string `json:"next_game"`
PrevGame string `json:"previous_game"`
}
// Tally is a log of games won
type Tally struct {
Player Player `json:"player"`
Matrix Matrix `json:"matrix"`
}
// Matrix is the game board and player uuids
type Matrix [9]*uuid.UUID
// Player object for binding with JSON POST body
type Player struct {
UUID uuid.UUID `json:"id"`
Name string `json:"name,omitempty"`
}
// Cache is a simple in-memory cache for storing things // Cache is a simple in-memory cache for storing things
type Cache struct { type Cache struct {
*cache *cache
@ -20,9 +49,9 @@ type Cache struct {
type cache struct { type cache struct {
defaultExpiration time.Duration defaultExpiration time.Duration
items map[uuid.UUID]Item
items map[string]Item
mu sync.RWMutex mu sync.RWMutex
onEvicted func(uuid.UUID, interface{})
onEvicted func(string, interface{})
janitor *janitor janitor *janitor
} }

+ 14
- 3
dist/bundle.js
File diff suppressed because it is too large
View File


+ 20
- 23
dist/index.html View File

@ -11,6 +11,26 @@
<div class=wrapper> <div class=wrapper>
<h1>TicTacToe</h1> <h1>TicTacToe</h1>
<h2 id=player class=hide></h2> <h2 id=player class=hide></h2>
<div id=status></div>
<div id=gameTable class=hide>
<table>
<tr>
<td id=pos_0 class=tile>T</td>
<td id=pos_1 class=tile>I</td>
<td id=pos_2 class=tile>C</td>
</tr>
<tr>
<td id=pos_3 class=tile>T</td>
<td id=pos_4 class=tile>A</td>
<td id=pos_5 class=tile>C</td>
</tr>
<tr>
<td id=pos_6 class=tile>T</td>
<td id=pos_7 class=tile>O</td>
<td id=pos_8 class=tile>E</td>
</tr>
</table>
</div>
<form id=startGameForm> <form id=startGameForm>
<label for=name id=nameLabel>To start a new game, enter your name:</label> <label for=name id=nameLabel>To start a new game, enter your name:</label>
<input type=text name=name id=name placeholder="Enter your name/nickname" size=64> <input type=text name=name id=name placeholder="Enter your name/nickname" size=64>
@ -25,31 +45,8 @@
</div> </div>
</div> </div>
</div> </div>
<div id=status class=hide></div>
<div id=waiting class=hide>Waiting for opponent</div>
<div id=turn class=hide>Your Turn</div>
<div id=winner class=hide>You WIN!!!</div>
</div> </div>
<div id=gameTable class=hide>
<table>
<tr>
<td id=pos_0 class=tile>T</td>
<td id=pos_1 class=tile>I</td>
<td id=pos_2 class=tile>C</td>
</tr>
<tr>
<td id=pos_3 class=tile>T</td>
<td id=pos_4 class=tile>A</td>
<td id=pos_5 class=tile>C</td>
</tr>
<tr>
<td id=pos_6 class=tile>T</td>
<td id=pos_7 class=tile>O</td>
<td id=pos_8 class=tile>E</td>
</tr>
</table>
</div>
<script src="/static/bundle.js"></script> <script src="/static/bundle.js"></script>
</body> </body>

+ 20
- 22
dist/join.html View File

@ -11,33 +11,31 @@
<div class=wrapper> <div class=wrapper>
<h1>TicTacToe</h1> <h1>TicTacToe</h1>
<h2 id=player class=hide></h2> <h2 id=player class=hide></h2>
<div id=status></div>
<div id=gameTable class=hide>
<table>
<tr>
<td id=pos_0 class=tile>T</td>
<td id=pos_1 class=tile>I</td>
<td id=pos_2 class=tile>C</td>
</tr>
<tr>
<td id=pos_3 class=tile>T</td>
<td id=pos_4 class=tile>A</td>
<td id=pos_5 class=tile>C</td>
</tr>
<tr>
<td id=pos_6 class=tile>T</td>
<td id=pos_7 class=tile>O</td>
<td id=pos_8 class=tile>E</td>
</tr>
</table>
</div>
<form id=joinGameForm> <form id=joinGameForm>
<label for=name id=nameLabel>To join, enter your name:</label> <label for=name id=nameLabel>To join, enter your name:</label>
<input type=text name=name id=name placeholder="Enter your name/nickname" size=64> <input type=text name=name id=name placeholder="Enter your name/nickname" size=64>
<button type=submit id=join>Join Game</button> <button type=submit id=join>Join Game</button>
</form> </form>
<div id=status class=hide></div>
<div id=turn class=hide></div>
<div id=winner class=hide>You WIN!!!</div>
</div>
<div id=gameTable class=hide>
<table>
<tr>
<td id=pos_0 class=tile>T</td>
<td id=pos_1 class=tile>I</td>
<td id=pos_2 class=tile>C</td>
</tr>
<tr>
<td id=pos_3 class=tile>T</td>
<td id=pos_4 class=tile>A</td>
<td id=pos_5 class=tile>C</td>
</tr>
<tr>
<td id=pos_6 class=tile>T</td>
<td id=pos_7 class=tile>O</td>
<td id=pos_8 class=tile>E</td>
</tr>
</table>
</div> </div>
<script src="/static/bundle.js"></script> <script src="/static/bundle.js"></script>
</body> </body>

+ 31
- 16
dist/main.css View File

@ -1,28 +1,43 @@
@import url(https://fonts.googleapis.com/css?family=Lato); @import url(https://fonts.googleapis.com/css?family=Lato);
body {
font-family: 'Lato', sans-serif; }
h1 {
font: normal normal normal 32px/40px 'Lato', sans-serif; }
h2 {
font: normal normal normal 24px/30px 'Lato', sans-serif; }
.record {
font: normal normal normal 16px/30px 'Lato', sans-serif; }
.record #log, .record #inprogress {
font-size: 10px; }
form {
padding-top: 20px; }
form input#name {
font: normal normal normal 14px/30px 'Lato', sans-serif;
height: 30px;
display: block; }
form button#start, form button#join {
font: normal normal normal 14px/30px 'Lato', sans-serif;
margin: 20px 0;
height: 30px;
background: #4d16e4;
border: none;
border-radius: 2px;
color: white; }
input#shareLink { input#shareLink {
font-family: monospace; font-family: monospace;
cursor: pointer; cursor: pointer;
width: 400px; } width: 400px; }
span#copySuccess {
display: none;
color: green;
font-weight: bold; }
span#copyError {
display: none;
color: red;
font-weight: bold; }
.link { .link {
color: blue; } color: blue; }
#status { #status {
font: normal normal normal 16px/20px 'Lato', sans-serif;
color: red; }
#turn, #waiting {
font: normal normal normal 16px/20px 'Lato', sans-serif; }
font: normal normal bold 16px/20px 'Lato', sans-serif; }
#gameTable tr td { #gameTable tr td {
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
@ -43,7 +58,7 @@ span#copyError {
border-bottom: none; } border-bottom: none; }
.hide { .hide {
display: none; }
display: none !important; }
.tooltip { .tooltip {
position: relative; position: relative;

+ 72
- 40
main.go View File

@ -1,17 +1,17 @@
package main package main
import ( import (
"errors"
"log" "log"
"math/rand"
"net/http" "net/http"
"os" "os"
Cache "tictactoe-api/cache"
"time" "time"
cache "tictactoe-api/cache"
"github.com/gin-contrib/multitemplate" "github.com/gin-contrib/multitemplate"
"github.com/gin-gonic/autotls" "github.com/gin-gonic/autotls"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
uuid "github.com/satori/go.uuid"
) )
func setupRender() multitemplate.Renderer { func setupRender() multitemplate.Renderer {
@ -21,10 +21,20 @@ func setupRender() multitemplate.Renderer {
return r return r
} }
const charBytes = "abcdefghijkmnopqrstuvwxyz023456789"
func generateRandom(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = charBytes[rand.Int63()%int64(len(charBytes))]
}
return string(b)
}
func main() { func main() {
hub := newHub() hub := newHub()
go hub.run() go hub.run()
cache := cache.NewCache(time.Minute*10, time.Minute)
cache := Cache.NewCache(time.Minute*10, time.Minute)
router := gin.Default() router := gin.Default()
router.HTMLRender = setupRender() router.HTMLRender = setupRender()
router.Static("/static", "dist") router.Static("/static", "dist")
@ -44,7 +54,7 @@ func main() {
// //
// //
router.GET("/join/:gameUUID", func(c *gin.Context) {
router.GET("/join", func(c *gin.Context) {
c.HTML(http.StatusOK, "join", gin.H{ c.HTML(http.StatusOK, "join", gin.H{
"title": "Play Game", "title": "Play Game",
}) })
@ -57,23 +67,38 @@ func main() {
}) })
router.POST("/game", func(c *gin.Context) { router.POST("/game", func(c *gin.Context) {
var game Game
var game Cache.Game
err := c.BindJSON(&game) err := c.BindJSON(&game)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
log.Printf("Error Binding Request %s\n", err.Error())
}
count := 0
generate:
game.ID = generateRandom(6)
_, found := cache.Get(game.ID)
if found {
count = count + 1
log.Printf("GAME FOUND, trying again: %s\n", game.ID)
if count >= 3 {
err = errors.New("Could not generate a new game (too many games in progress)")
} else {
goto generate
}
} }
game.Turn = game.Players[0].UUID
cache.Set(game.UUID, game, 0)
c.JSON(http.StatusOK, game)
})
router.GET("/game/:gameUUID", func(c *gin.Context) {
gameUUID, err := uuid.FromString(c.Params.ByName("gameUUID"))
if err != nil { if err != nil {
log.Printf("UUID Parse Failed: %s\n", err)
c.JSON(http.StatusConflict, gin.H{
"error": err.Error(),
})
} else {
game.Turn = &game.Player1.UUID
cache.Set(game.ID, game, 0)
c.JSON(http.StatusOK, game)
} }
game, found := cache.Get(gameUUID)
})
router.GET("/game/:gameID", func(c *gin.Context) {
gameID := c.Params.ByName("gameID")
game, found := cache.Get(gameID)
if !found { if !found {
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
return return
@ -81,39 +106,46 @@ func main() {
c.JSON(http.StatusOK, game) c.JSON(http.StatusOK, game)
}) })
router.POST("/game/:gameUUID/player/:playerUUID", func(c *gin.Context) {
gameUUID, err := uuid.FromString(c.Params.ByName("gameUUID"))
if err != nil {
log.Printf("UUID Game Parse Failed: %s\n", err)
}
playerUUID, err := uuid.FromString(c.Params.ByName("playerUUID"))
if err != nil {
log.Printf("UUID Player Parse Failed: %s\n", err)
}
game, found := cache.Get(gameUUID)
router.POST("/game/:gameID", func(c *gin.Context) {
gameID := c.Params.ByName("gameID")
game, found := cache.Get(gameID)
if !found { if !found {
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
return return
} }
_game := game.(Game)
var player Player
err = c.BindJSON(&player)
var updateGame Cache.Game
err := c.BindJSON(&updateGame)
if err != nil { if err != nil {
log.Printf("Error: %s\n", err) log.Printf("Error: %s\n", err)
} }
if len(_game.Players) > 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "TicTacToe can only be played with two players."})
return
if updateGame.ID != "" {
game.ID = updateGame.ID
} }
_game.Players = append(_game.Players, &Player{
UUID: &playerUUID,
Name: player.Name,
})
cache.Set(gameUUID, _game, 0)
c.JSON(http.StatusOK, _game)
if updateGame.Player1 != nil {
game.Player1 = updateGame.Player1
}
if updateGame.Player2 != nil {
game.Player2 = updateGame.Player2
}
if updateGame.Turn != nil {
game.Turn = updateGame.Turn
}
if (Cache.Matrix{}) != updateGame.Matrix {
game.Matrix = updateGame.Matrix
}
// if updateGame.Draw != nil {
// game.Draw = updateGame.Draw
// }
// if updateGame.Winner != nil {
// game.Winner = updateGame.Winner
// }
// if updateGame.Tally != nil {
// game.Tally = append(game.Tally, updateGame.Tally[0])
// }
cache.Set(gameID, game, 0)
c.JSON(http.StatusOK, game)
}) })
// //

+ 47
- 49
src/classes.js View File

@ -4,14 +4,12 @@ import * as HTTP from './services.js'
import { timingSafeEqual } from 'crypto'; import { timingSafeEqual } from 'crypto';
class BaseUtils { class BaseUtils {
constructor() {
this.id = this.getUUID()
}
constructor() {}
getUUID () { getUUID () {
function s4 () { function s4 () {
return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1)
} }
return (s4() + s4() + '-' + s4() + '-4' + s4().slice(1) + '-8' + s4().slice(1) + '-' + s4() + s4() + s4()).toUpperCase()
return (s4() + s4() + '-' + s4() + '-4' + s4().slice(1) + '-8' + s4().slice(1) + '-' + s4() + s4() + s4())
} }
setId (id) { setId (id) {
this.id = id this.id = id
@ -40,77 +38,77 @@ class BaseUtils {
export class Player extends BaseUtils { export class Player extends BaseUtils {
constructor(id, name) { constructor(id, name) {
super() super()
if (id)
// if `id` and `name` are passed in, we don't want to set cookies
if (id) {
this.id = id this.id = id
if (name)
} else {
this.setId(id || this.getCookie("player_id") || this.getUUID())
}
if (name) {
this.name = name this.name = name
return this
}
setId (id) {
this.id = id
} else {
this.setName(name || this.getCookie("player_name"))
}
return this return this
} }
getId () { getId () {
return this.id
return this.id.toLowerCase()
} }
setName (name) {
this.name = name
setId (id) {
this.id = id
this.setCookie("player_id", id)
return this return this
} }
getName () { getName () {
return this.name return this.name
} }
loadFromCookies() {
this.setId(this.getCookie("player_id") || this.getId())
this.setName(this.getCookie("player_name"))
return this
}
setCookies() {
this.setCookie("player_id", this.id)
this.setCookie("player_name", this.name)
setName (name) {
this.name = name
this.setCookie("player_name", name)
return this return this
} }
} }
export class Game extends BaseUtils { export class Game extends BaseUtils {
constructor(player) {
constructor() {
super() super()
this.setId(this.getGameIdFromUrl() || this.id)
this.players = [player]
this.turn = player.uuid
this.winner = undefined
this.draw = undefined
this.setId((new URLSearchParams(window.location.search)).get('id'))
this.player1 = undefined // person who started the game
this.player2 = undefined // person who joined the game
this.winner = undefined // player.id
this.draw = undefined // true/false
this.matrix = [ this.matrix = [
null, null, null,
null, null, null, // player.id
null, null, null, null, null, null,
null, null, null null, null, null
] ]
this.blocked = false
this.next_game = undefined
return this return this
} }
getId () {
getId() {
return this.id return this.id
} }
getGameIdFromUrl() {
console.debug("getGameIdFromUrl")
let path = document.location.pathname.split("/")
console.debug(path)
if (path && path.length > 1) {
let uuid = path[path.length - 1] || ""
console.debug(uuid)
let matches = uuid.match("^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$")
return matches && matches.length ? path[path.length - 1] : undefined
}
return undefined
setId(id) {
console.debug("Set Game ID", id)
if (id)
history.replaceState(null, "", "?id=" + id)
this.id = id
return this
} }
getOpponentsName(myId) {
let opponent = this.players.filter(x => x.id !== myId)[0]
console.debug("getOpponentsName()", opponent.name)
return opponent.name
setTurn (player_id) {
console.debug("Set Game Turn", player_id)
this.turn = player_id
return this
}
getOpponent() {
let player_id = this.getCookie("player_id")
return this.player1.id == player_id ? this.player2 : this.player1
} }
setOpponentsTurn(myId) {
let opponent = this.players.filter(x => x.id !== myId)[0]
console.debug("setOpponentsTurn()", opponent.id)
this.turn = opponent.id
setOpponentsTurn() {
let opponent = this.getOpponent()
this.setTurn(opponent.id)
return this return this
} }
logResult(winnersName) {
this.winners.push(winnersName)
}
} }

+ 268
- 223
src/index.js View File

@ -3,16 +3,7 @@
import {Player, Game} from "./classes.js" import {Player, Game} from "./classes.js"
import * as HTTP from "./services.js" import * as HTTP from "./services.js"
import style from './style.scss' import style from './style.scss'
let conn
let gameOver = false
let player = new Player()
player.loadFromCookies()
player.setCookies()
let game = new Game(player)
console.debug(player)
console.debug(game)
import { join } from "path";
/** /**
* Initialize DOM variables * Initialize DOM variables
@ -21,32 +12,57 @@ let name = document.getElementById('name')
let nameLabel = document.getElementById('nameLabel') let nameLabel = document.getElementById('nameLabel')
let playerH1 = document.getElementById('player') let playerH1 = document.getElementById('player')
let status = document.getElementById('status') let status = document.getElementById('status')
let waiting = document.getElementById('waiting')
let turn = document.getElementById('turn')
let winner = document.getElementById('winner')
let draw = document.getElementById('draw')
let gameTable = document.getElementById('gameTable') let gameTable = document.getElementById('gameTable')
let instructions = document.getElementById('instructions') let instructions = document.getElementById('instructions')
let input = document.getElementById('shareLink')
let shareLink = document.getElementById('shareLink')
let tooltip = document.getElementById("tooltip") let tooltip = document.getElementById("tooltip")
let startGameForm = document.getElementById('startGameForm') let startGameForm = document.getElementById('startGameForm')
let joinGameForm = document.getElementById('joinGameForm') let joinGameForm = document.getElementById('joinGameForm')
let opponentTileColor = "#992222" let opponentTileColor = "#992222"
let tileColor = "#229922" let tileColor = "#229922"
let gameOver = false
let firstgame = true
/**
* Setup
*/
function _setup() {
if (player.getName()) {
name.value = player.getName()
name.classList.add("hide")
nameLabel.classList.add("hide")
playerH1.innerText = "Hi " + player.getName()
playerH1.classList.remove("hide")
}
let conn
let player = new Player()
console.debug(player)
if (player.getName()) {
name.value = player.getName()
_hideConfig()
_showGreeting()
}
let game = new Game()
console.debug(game)
if (game.getId()) {
firstgame = false
// we need to fetch the game data and resume the game
_retrieveGameFromServer().then(resp => {
console.debug("_retrieveGameFromServer() success", resp)
game.player1 = new Player(resp.player1.id, resp.player1.name)
game.turn = resp.turn
game.matrix = resp.matrix
if (!resp.player2 || typeof resp.player2 === "undefined") {
_updateGameOnServer({
player2: player
}).then(resp => {
console.debug("_updateGame() success", resp)
console.debug(game)
}).catch(err => {
console.debug("_updateGame() err", err)
})
} else {
game.player2 = new Player(resp.player2.id, resp.player2.name)
}
if (document.location.pathname != "/join") {
_renderGame()
_showGame()
}
console.debug(game)
}).catch(err => {
console.debug("_retrieveGameFromServer() error", err)
})
} }
_setup()
export function Copy (context) { export function Copy (context) {
@ -61,7 +77,7 @@ export function TooltipBlur () {
if (window.WebSocket) { if (window.WebSocket) {
conn = new WebSocket("wss://" + document.location.host + "/ws")
conn = new WebSocket("ws://" + document.location.host + "/ws")
conn.onclose = function (evt) { conn.onclose = function (evt) {
console.debug("Websocket Closed") console.debug("Websocket Closed")
} }
@ -75,52 +91,49 @@ if (window.WebSocket) {
console.error("Error parsing", data) console.error("Error parsing", data)
} }
console.debug("Message", data) console.debug("Message", data)
if (data.sender === player.getId()) {
console.debug("My own message received and ignored")
if (data.sender.toUpperCase() === player.getId().toUpperCase()) {
// console.debug("My own message received and ignored")
return
}
if (data.game_id.toUpperCase() !== game.getId().toUpperCase()) {
// console.debug("Not my game")
return return
} }
switch(data.event) { switch(data.event) {
case "joining": case "joining":
console.debug("Event: joining") console.debug("Event: joining")
instructions.classList.add("hide") instructions.classList.add("hide")
game.players.push(data.player)
console.debug(game)
_yourTurn()
_safeRetrieve()
_showGame() _showGame()
break break
case "new":
console.debug("Event: new")
let newGame = confirm("Would you like to play again?")
if (newGame) {
history.replaceState(null, "", "?id=" + data.new_game_id)
game.setId(data.new_game_id)
_retrieveGameFromServer().then(resp => {
console.debug("_retrieveGameFromServer() success", resp)
game.player1 = new Player(resp.player1.id, resp.player1.name)
game.player2 = new Player(resp.player2.id, resp.player2.name)
game.matrix = resp.matrix
game.turn = resp.turn
console.debug(game)
gameOver = false
_renderGame()
}).catch(err => {
console.debug("_retrieveGameFromServer() error", err)
})
}
break
case "move": case "move":
console.debug("Event: move") console.debug("Event: move")
game.matrix[data.index] = data.player
_updateTiles(data.index)
_yourTurn()
console.debug(game.matrix)
_safeRetrieve()
break break
case "winner": case "winner":
console.debug("Event: winner") console.debug("Event: winner")
game.matrix[data.index] = data.player
_updateTiles(data.index)
gameOver = true
status.classList.add("hide")
turn.classList.add("hide")
winner.innerText = "You lose... " + game.getOpponentsName(player.getId()) + " Wins!"
winner.classList.remove("hide")
_highlightBoard(data.positions)
_dimBoard()
if (startGameForm)
startGameForm.classList.remove("hide")
break
case "draw":
console.debug("Event: draw")
game.matrix[data.index] = data.player
_updateTiles(data.index)
gameOver = true
status.classList.add("hide")
turn.classList.add("hide")
winner.innerText = "Its a draw!"
winner.classList.remove("hide")
_dimBoard()
if (startGameForm)
startGameForm.classList.remove("hide")
game.winner = data.sender
_safeRetrieve()
break break
} }
} }
@ -138,24 +151,38 @@ if (startGameForm) {
if (!_validateForm()) { if (!_validateForm()) {
return false return false
} }
_createGame().then(resp => {
name.classList.add("hide")
nameLabel.classList.add("hide")
startGameForm.classList.add("hide")
console.debug("_createGame() success", resp)
game.turn = resp.turn
game.matrix = resp.matrix
console.debug("Game:", game)
input.value = _getUrl() + "/join/" + game.getId()
instructions.classList.remove("hide")
_waiting()
// _yourTurn()
// _showGame()
}).catch(err => {
console.debug("_createGame() error", err)
status.innerText = err.message.error
status.classList.remove("hide")
})
if (firstgame) {
_safeCreate()
firstgame = false
} else {
_createGame().then(resp => {
console.debug("_createGame() success", resp)
history.replaceState(null, "", "?id=" + resp.id)
let old_game_id = game.getId()
game.setId(resp.id)
_updateGameOnServer({
player2: game.player2,
turn: game.player1.id == game.winner ? game.player2.id : game.player1.id,
previous_game: old_game_id
}).then(resp => {
console.debug("_updateGameOnServer() success", resp)
gameOver = false
game.turn = resp.turn
game.matrix = resp.matrix
conn.send(JSON.stringify({
sender: player.getId(),
game_id: old_game_id,
event: "new",
new_game_id: resp.id
}))
_renderGame()
}).catch(err => {
console.debug("_updateGameOnServer() err", err)
})
}).catch(err => {
console.debug("_createGame() err", err)
})
}
}) })
} }
if (joinGameForm) { if (joinGameForm) {
@ -164,171 +191,188 @@ if (joinGameForm) {
if (!_validateForm()) { if (!_validateForm()) {
return false return false
} }
_addPlayer().then(resp => {
name.classList.add("hide")
nameLabel.classList.add("hide")
joinGameForm.classList.add("hide")
console.debug("_addPlayer() success", resp)
conn.send(JSON.stringify({
sender: player.getId(),
game_id: game.getId(),
event: "joining",
player: {
id: player.getId(),
name: player.getName()
}
}))
game.players.push(new Player(resp.players[0].id, resp.players[0].name))
_theirTurn()
_showGame()
}).catch(err => {
console.debug("_addPlayer() error", err)
status.innerText = err.message.error
status.classList.remove("hide")
})
conn.send(JSON.stringify({
sender: player.getId(),
game_id: game.getId(),
event: "joining"
}))
joinGameForm.classList.add("hide")
_theirTurn()
_showGame()
}) })
} }
if (gameTable) {
document.querySelectorAll('.tile').forEach(element => {
element.addEventListener('click', event => {
if (gameOver) {
console.debug("Game over")
event.preventDefault()
return
}
let pos = event.target.id.split('_')[1]
// validate its my turn
if (game.turn !== player.getId()) {
console.debug("Not my turn")
event.preventDefault()
return
}
// validate position available
if (game.matrix[pos]) {
console.debug("Tile not free")
event.preventDefault()
return
}
// set move
game.matrix[pos] = player.getId()
// show tile selected
event.target.style.backgroundColor = tileColor
// calculate if 3 tiles in a row
let three = _threeInARow()
if (three) {
console.debug("You win!")
_highlightBoard(three)
_dimBoard()
gameOver = true
winner.innerText = "You Win!!!"
winner.classList.remove("hide")
status.classList.add("hide")
turn.classList.add("hide")
conn.send(JSON.stringify({
sender: player.getId(),
game_id: game.getId(),
event: "winner",
index: pos,
player: player.getId(),
winner: player.getId(),
positions: three
}))
if (startGameForm)
startGameForm.classList.remove("hide")
event.preventDefault()
return
}
if (_draw()) {
console.debug("Draw!")
_dimBoard()
gameOver = true
winner.innerText = "Its a draw!"
winner.classList.remove("hide")
status.classList.add("hide")
turn.classList.add("hide")
conn.send(JSON.stringify({
sender: player.getId(),
game_id: game.getId(),
event: "draw",
index: pos,
player: player.getId(),
winner: player.getId()
}))
if (startGameForm)
startGameForm.classList.remove("hide")
event.preventDefault()
return
}
function tileListener (event) {
if (gameOver) {
event.preventDefault()
return false
}
let pos = event.target.id.split('_')[1]
// validate its my turn
if (game.turn !== player.getId()) {
console.debug("Not my turn")
event.preventDefault()
return
}
// validate position available
if (game.matrix[pos]) {
console.debug("Tile not free")
event.preventDefault()
return
}
// set move
game.matrix[pos] = player.getId()
// send move via socket
conn.send(JSON.stringify({
sender: player.getId(),
game_id: game.getId(),
event: "move",
index: pos,
player: player.getId()
}))
// calculate if 3 tiles in a row
let [done, winner, positions] = _threeInARow()
_theirTurn()
})
_updateGameOnServer({
id: game.getId(),
player1: game.player1,
player2: game.player2,
turn: game.player1.id == game.turn ? game.player2.id : game.player1.id,
winner: winner,
matrix: game.matrix
}).then(resp => {
console.debug("_updateGameOnServer() success", resp)
conn.send(JSON.stringify({
sender: player.getId(),
game_id: game.getId(),
event: done ? "winner" : "move"
}))
game.turn = resp.turn
_renderGame()
}).catch(err => {
console.debug("_updateGameOnServer() error", err)
})
}
if (gameTable) {
document.querySelectorAll('.tile').forEach(element => {
element.addEventListener('click', tileListener)
}) })
} }
/** /**
* END Document Listeners * END Document Listeners
*/ */
function _validateForm() { function _validateForm() {
if (!name.value) { if (!name.value) {
status.innerText = "Name is required." status.innerText = "Name is required."
status.classList.remove("hide")
status.style.color = "#FF0000"
return false return false
} }
status.style.color = "#000000"
player.setName(name.value) player.setName(name.value)
player.setCookies()
status.classList.add("hide")
_greeting()
_hideConfig()
_showGreeting()
return true return true
} }
function _getUrl() {
return document.location.protocol + '//' + document.location.host
function _showInstructions() {
shareLink.value = _getUrl() + "/join?id=" + game.getId()
instructions.classList.remove("hide")
}
function _hideInstructions () {
instructions.classList.add("hide")
} }
function _greeting() {
function _showGreeting() {
if (player.getName()) { if (player.getName()) {
playerH1.innerText = "Hi " + player.getName() playerH1.innerText = "Hi " + player.getName()
playerH1.classList.remove("hide") playerH1.classList.remove("hide")
} }
} }
function _createGame() {
let url = _getUrl()
return HTTP.POST(`${url}/game`, JSON.stringify(game))
function _showGame () {
gameTable.classList.remove("hide")
}
function _resumeGame () {
startGameForm.classList.add("hide")
_showGame()
} }
function _addPlayer() {
function _hideConfig () {
name.classList.add("hide")
nameLabel.classList.add("hide")
}
function _renderGame() {
console.debug("Render Game Board")
_dimBoard(true)
for (let i = 0; i < game.matrix.length; i++) {
let playerAt = game.matrix
if (playerAt[i] === player.getId()) {
document.getElementById('pos_' + i).style.backgroundColor = tileColor
} else if (playerAt[i] === game.getOpponent().getId()) {
document.getElementById('pos_' + i).style.backgroundColor = opponentTileColor
} else {
document.getElementById('pos_' + i).style.backgroundColor = "unset"
}
}
if (game.turn == player.getId()) {
_yourTurn()
} else {
_theirTurn()
}
let [done, winner, positions] = _threeInARow()
if (done) {
gameOver = true
game.winner = winner
_dimBoard()
_highlightBoard(positions)
if (winner == player.getId()) {
status.innerText = "You Win!!!"
} else {
status.innerText = "You lose... " + game.getOpponent().getName() + " Wins!"
}
}
if (_draw()) {
gameOver = true
_dimBoard()
status.innerText = "Its a draw!"
}
}
function _getUrl () {
return document.location.protocol + '//' + document.location.host
}
function _safeCreate() {
_createGame().then(resp => {
console.debug("_createGame() success", resp)
game.setId(resp.id)
game.player1 = new Player(resp.player1.id, resp.player1.name)
console.debug(game)
_showInstructions()
}).catch(err => {
console.debug("_createGame() err", err)
})
}
function _createGame () {
let url = _getUrl() let url = _getUrl()
let gid = game.getId()
let pid = player.getId()
return HTTP.POST(`${url}/game/${gid}/player/${pid}`, JSON.stringify({ name: document.getElementById('name').value }))
return HTTP.POST(`${url}/game`, JSON.stringify({ player1: player }))
} }
function _showGame() {
gameTable.classList.remove("hide")
function _updateGameOnServer(updatePayload) {
let url = _getUrl() + "/game/" + game.getId()
return HTTP.POST(url, JSON.stringify(updatePayload))
}
function _safeRetrieve() {
_retrieveGameFromServer().then(resp => {
console.debug("_retrieveGameFromServer() success", resp)
game.player1 = new Player(resp.player1.id, resp.player1.name)
game.player2 = new Player(resp.player2.id, resp.player2.name)
game.matrix = resp.matrix || [null, null, null, null, null, null, null, null, null]
game.turn = resp.turn
_renderGame()
console.debug(game)
}).catch(err => {
console.debug("_retrieveGameFromServer() error", err)
})
} }
function _waiting() {
waiting.innerText = "Waiting for opponent"
waiting.classList.remove("hide")
function _retrieveGameFromServer() {
let url = _getUrl() + "/game/" + game.getId()
return HTTP.GET(url)
} }
function _yourTurn () { function _yourTurn () {
if (waiting)
waiting.classList.add("hide")
turn.innerText = "Your Turn"
turn.classList.remove("hide")
status.innerText = "Your Turn"
game.turn = player.getId() game.turn = player.getId()
} }
function _theirTurn () { function _theirTurn () {
game.setOpponentsTurn(player.getId())
turn.innerText = _possessivizer(game.getOpponentsName(player.getId())) + " Turn"
turn.classList.remove("hide")
status.innerText = _possessivizer(game.getOpponent().getName()) + " Turn"
} }
function _possessivizer(name) { function _possessivizer(name) {
if (name.charAt(name.length - 1) === "s") { if (name.charAt(name.length - 1) === "s") {
@ -337,28 +381,25 @@ function _possessivizer(name) {
return name + "'s" return name + "'s"
} }
} }
function _updateTiles(index) {
document.getElementById('pos_' + index).style.backgroundColor = opponentTileColor
}
function _threeInARow() { function _threeInARow() {
for (let i = 0; i <= 8; i) { for (let i = 0; i <= 8; i) {
if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 1] && game.matrix[i] === game.matrix[i + 2]) ) { if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 1] && game.matrix[i] === game.matrix[i + 2]) ) {
return [i, i + 1, i + 2]
return [true, game.matrix[i], [i, i + 1, i + 2]]
} }
i = i + 3 i = i + 3
} }
for (let i = 0; i <= 2; i++) { for (let i = 0; i <= 2; i++) {
if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 3] && game.matrix[i] === game.matrix[i + 6]) ) { if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 3] && game.matrix[i] === game.matrix[i + 6]) ) {
return [i, i + 3, i + 6]
return [true, game.matrix[i], [i, i + 3, i + 6]]
} }
} }
if (game.matrix[0] && (game.matrix[0] === game.matrix[4] && game.matrix[0] === game.matrix[8])) { if (game.matrix[0] && (game.matrix[0] === game.matrix[4] && game.matrix[0] === game.matrix[8])) {
return [0, 4, 8]
return [true, game.matrix[0], [0, 4, 8]]
} }
if (game.matrix[2] && (game.matrix[2] === game.matrix[4] && game.matrix[2] === game.matrix[6])) { if (game.matrix[2] && (game.matrix[2] === game.matrix[4] && game.matrix[2] === game.matrix[6])) {
return [2, 4, 6]
return [true, game.matrix[2], [2, 4, 6]]
} }
return false
return [false, null, null]
} }
function _draw() { function _draw() {
for (let i = 0; i < game.matrix.length; i++) { for (let i = 0; i < game.matrix.length; i++) {
@ -368,10 +409,14 @@ function _draw() {
} }
return true return true
} }
function _dimBoard() {
document.querySelectorAll('td').forEach(el => el.classList.add("dim"))
function _dimBoard(reverse) {
if (reverse) {
document.querySelectorAll('td').forEach(el => el.classList.remove("dim"))
document.querySelectorAll('td').forEach(el => el.classList.remove("highlight"))
} else {
document.querySelectorAll('td').forEach(el => el.classList.add("dim"))
}
} }
function _highlightBoard (positions) { function _highlightBoard (positions) {
for (let pos of positions) { for (let pos of positions) {
document.querySelector('#pos_' + pos).classList.add("highlight") document.querySelector('#pos_' + pos).classList.add("highlight")

+ 13
- 8
src/services.js View File

@ -1,14 +1,19 @@
'use strict' 'use strict'
// GET HTTP call // GET HTTP call
export function GET (theUrl, callback) {
var req = new XMLHttpRequest()
req.onreadystatechange = function () {
if (req.readyState == 4 && req.status == 200)
callback(req.responseText)
}
req.open("GET", theUrl, true)
req.send(null)
export function GET (theUrl) {
return new Promise((resolve, reject) => {
var req = new XMLHttpRequest()
req.onreadystatechange = function () {
if (req.readyState == 4 && req.status == 200) {
resolve(_parseOrNot(req.responseText))
} else if (req.readyState == 4) {
reject({ status: req.status, message: _parseOrNot(req.responseText) })
}
}
req.open("GET", theUrl, true)
req.send(null)
})
} }
// POST HTTP call // POST HTTP call

+ 35
- 18
src/style.scss View File

@ -1,31 +1,48 @@
@import url('https://fonts.googleapis.com/css?family=Lato'); @import url('https://fonts.googleapis.com/css?family=Lato');
body {
font-family: 'Lato', sans-serif;
}
h1 {
font: normal normal normal 32px/40px 'Lato', sans-serif;
}
h2 {
font: normal normal normal 24px/30px 'Lato', sans-serif;
}
.record {
font: normal normal normal 16px/30px 'Lato', sans-serif;
#log, #inprogress {
font-size: 10px;
}
}
form {
padding-top: 20px;
input#name {
font: normal normal normal 14px/30px 'Lato', sans-serif;
height: 30px;
display: block;
}
button#start, button#join {
font: normal normal normal 14px/30px 'Lato', sans-serif;
margin: 20px 0;
height: 30px;
background: rgb(77, 22, 228);
border: none;
border-radius: 2px;
color: white;
}
}
input#shareLink { input#shareLink {
font-family: monospace; font-family: monospace;
cursor: pointer; cursor: pointer;
width: 400px; width: 400px;
} }
span {
&#copySuccess {
display: none;
color: green;
font-weight: bold;
}
&#copyError {
display: none;
color: red;
font-weight: bold;
}
}
.link { .link {
color: blue; color: blue;
} }
#status { #status {
font: normal normal normal 16px/20px 'Lato', sans-serif;
color: red;
}
#turn, #waiting {
font: normal normal normal 16px/20px 'Lato', sans-serif;
font: normal normal bold 16px/20px 'Lato', sans-serif;
} }
#gameTable { #gameTable {
tr { tr {
@ -57,7 +74,7 @@ span {
} }
.hide { .hide {
display: none;
display: none !important;
} }
.tooltip { .tooltip {

+ 0
- 21
structs.go View File

@ -1,21 +0,0 @@
package main
import (
uuid "github.com/satori/go.uuid"
)
// Game object for binding with JSON POST body
type Game struct {
UUID uuid.UUID `json:"id" binding:"required"`
Players []*Player `json:"players"`
Turn *uuid.UUID `json:"turn"`
Draw *bool `json:"draw,omitempty"`
Winner *uuid.UUID `json:"winner,omitempty"`
Matrix [9]*uuid.UUID `json:"matrix" binding:"min=9,max=9"`
}
// Player object for binding with JSON POST body
type Player struct {
UUID *uuid.UUID `json:"id"`
Name string `json:"name,omitempty"`
}

BIN
tictactoe-api View File


Loading…
Cancel
Save