Browse Source

Initial working tictactoe

master
Levi Olson 5 years ago
commit
11e04a2f3d
23 changed files with 10300 additions and 0 deletions
  1. +1
    -0
      .gitignore
  2. +21
    -0
      LICENSE
  3. +34
    -0
      README.md
  4. +261
    -0
      cache/cache.go
  5. +36
    -0
      cache/structs.go
  6. +137
    -0
      client.go
  7. +1952
    -0
      dist/bundle.js
  8. BIN
      dist/favicon.ico
  9. +56
    -0
      dist/index.html
  10. +44
    -0
      dist/join.html
  11. +78
    -0
      dist/main.css
  12. +53
    -0
      hub.go
  13. +128
    -0
      main.go
  14. +6753
    -0
      package-lock.json
  15. +35
    -0
      package.json
  16. +116
    -0
      src/classes.js
  17. +375
    -0
      src/index.js
  18. +41
    -0
      src/services.js
  19. +96
    -0
      src/style.scss
  20. +21
    -0
      structs.go
  21. BIN
      tictactoe-api
  22. +29
    -0
      tictactoe-api.service
  23. +33
    -0
      webpack.config.js

+ 1
- 0
.gitignore View File

@ -0,0 +1 @@
node_modules

+ 21
- 0
LICENSE View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Levi Olson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 34
- 0
README.md View File

@ -0,0 +1,34 @@
# TicTacToe API Interface
A Golang Server that opens a unique socket for two users to play TicTacToe online.
## Firewall
sudo ufw status
sudo ufw allow OpenSSH
sudo ufw allow https
sudo ufw enable
## Install Go
curl -C - https://dl.google.com/go/go1.11.4.linux-amd64.tar.gz -o go1.11.4.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.11.4.linux-amd64.tar.gz
echo "export PATH=$PATH:/usr/local/go/bin"
go version
## Letsencrypt
sudo add-apt-repository ppa:certbot/certbot
sudo apt-get update
sudo apt-get install python-certbot-nginx
certbot certonly --standalone -d weather.l3vi.co
## Systemd
sudo cp tictactoe-api.service /lib/systemd/system/.
ls -al /lib/systemd/system
sudo chmod 755 /lib/systemd/system/tictactoe-api.service
sudo systemctl enable tictactoe-api.service
sudo systemctl start tictactoe-api.service
sudo journalctl -f -u tictactoe-api
# or
sudo systemctl status tictactoe-api.service

+ 261
- 0
cache/cache.go View File

@ -0,0 +1,261 @@
package cache
import (
"fmt"
"runtime"
"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
func NewCache(defaultExp, cleanupInterval time.Duration) *Cache {
items := make(map[uuid.UUID]Item)
return newCacheWithJanitor(defaultExp, cleanupInterval, items)
}
func newCacheWithJanitor(defaultExp, cleanupInterval time.Duration, items map[uuid.UUID]Item) *Cache {
cache := newCache(defaultExp, items)
Cache := &Cache{cache}
if cleanupInterval > 0 {
runJanitor(cache, cleanupInterval)
runtime.SetFinalizer(Cache, stopJanitor)
}
return Cache
}
func newCache(de time.Duration, m map[uuid.UUID]Item) *cache {
if de == 0 {
de = -1
}
c := &cache{
defaultExpiration: de,
items: m,
}
return c
}
// 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
// (NoExpiration), the item never expires.
func (c *cache) Set(k uuid.UUID, x interface{}, d time.Duration) {
// "Inlining" of set
var e int64
if d == DefaultExpiration {
d = c.defaultExpiration
}
if d > 0 {
e = time.Now().Add(d).UnixNano()
}
c.mu.Lock()
c.items[k] = Item{
Object: x,
Expiration: e,
}
// TODO: Calls to mu.Unlock are currently not deferred because defer
// adds ~200 ns (as of go1.)
c.mu.Unlock()
}
func (c *cache) set(k uuid.UUID, x interface{}, d time.Duration) {
var e int64
if d == DefaultExpiration {
d = c.defaultExpiration
}
if d > 0 {
e = time.Now().Add(d).UnixNano()
}
c.items[k] = Item{
Object: x,
Expiration: e,
}
}
// Add an item to the cache, replacing any existing item, using the default
// expiration.
func (c *cache) SetDefault(k uuid.UUID, x interface{}) {
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
// 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) {
c.mu.RLock()
// "Inlining" of get and Expired
item, found := c.items[k]
if !found {
c.mu.RUnlock()
return nil, time.Time{}, false
}
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock()
return nil, time.Time{}, 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()
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
}
type keyAndValue struct {
key uuid.UUID
value interface{}
}
// Delete all expired items from the cache.
func (c *cache) DeleteExpired() {
var evictedItems []keyAndValue
now := time.Now().UnixNano()
c.mu.Lock()
for k, v := range c.items {
// "Inlining" of expired
if v.Expiration > 0 && now > v.Expiration {
ov, evicted := c.delete(k)
if evicted {
evictedItems = append(evictedItems, keyAndValue{k, ov})
}
}
}
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 {
Interval time.Duration
stop chan bool
}
func (j *janitor) Run(c *cache) {
ticker := time.NewTicker(j.Interval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-j.stop:
ticker.Stop()
return
}
}
}
func stopJanitor(c *Cache) {
c.janitor.stop <- true
}
func runJanitor(c *cache, ci time.Duration) {
j := &janitor{
Interval: ci,
stop: make(chan bool),
}
c.janitor = j
go j.Run(c)
}

+ 36
- 0
cache/structs.go View File

@ -0,0 +1,36 @@
package cache
import (
"sync"
"time"
uuid "github.com/satori/go.uuid"
)
// Item for caching
type Item struct {
Object interface{}
Expiration int64
}
// Cache is a simple in-memory cache for storing things
type Cache struct {
*cache
}
type cache struct {
defaultExpiration time.Duration
items map[uuid.UUID]Item
mu sync.RWMutex
onEvicted func(uuid.UUID, interface{})
janitor *janitor
}
const (
// NoExpiration For use with functions that take an expiration time.
NoExpiration time.Duration = -1
// DefaultExpiration For use with functions that take an expiration time.
// Equivalent to passing in the same expiration duration as was given to
// New() or NewFrom() when the cache was created (e.g. 5 minutes.)
DefaultExpiration time.Duration = time.Hour * 1
)

+ 137
- 0
client.go View File

@ -0,0 +1,137 @@
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 512
)
var (
newline = []byte{'\n'}
space = []byte{' '}
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// Client is a middleman between the websocket connection and the hub.
type Client struct {
hub *Hub
// The websocket connection.
conn *websocket.Conn
// Buffered channel of outbound messages.
send chan []byte
}
// readPump pumps messages from the websocket connection to the hub.
//
// The application runs readPump in a per-connection goroutine. The application
// ensures that there is at most one reader on a connection by executing all
// reads from this goroutine.
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("error: %v", err)
}
break
}
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
c.hub.broadcast <- message
}
}
// writePump pumps messages from the hub to the websocket connection.
//
// A goroutine running writePump is started for each connection. The
// application ensures that there is at most one writer to a connection by
// executing all writes from this goroutine.
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The hub closed the channel.
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
// Add queued chat messages to the current websocket message.
n := len(c.send)
for i := 0; i < n; i++ {
w.Write(newline)
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// serveWs handles websocket requests from the peer.
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client.hub.register <- client
// Allow collection of memory referenced by the caller by doing all work in
// new goroutines.
go client.writePump()
go client.readPump()
}

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


BIN
dist/favicon.ico View File

Before After

+ 56
- 0
dist/index.html View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>TicTacToe :: {{ .title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" media="screen" href="/static/main.css" />
</head>
<body>
<div class=wrapper>
<h1>TicTacToe</h1>
<h2 id=player class=hide></h2>
<form id=startGameForm>
<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>
<button type=submit id=start>Start a new game</button>
</form>
<div id=instructions class=hide>
<p>Share this link with a friend to begin:</p>
<div class=link>
<div class=tooltip>
<span class=tooltiptext id=tooltip>Copy</span>
<input type=text id=shareLink onclick="ui.Copy(this)" onmouseout="ui.TooltipBlur()" readonly=readonly>
</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 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>
</body>
</html>

+ 44
- 0
dist/join.html View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>TicTacToe :: {{ .title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" media="screen" href="/static/main.css" />
</head>
<body>
<div class=wrapper>
<h1>TicTacToe</h1>
<h2 id=player class=hide></h2>
<form id=joinGameForm>
<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>
<button type=submit id=join>Join Game</button>
</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>
<script src="/static/bundle.js"></script>
</body>
</html>

+ 78
- 0
dist/main.css View File

@ -0,0 +1,78 @@
@import url(https://fonts.googleapis.com/css?family=Lato);
input#shareLink {
font-family: monospace;
cursor: pointer;
width: 400px; }
span#copySuccess {
display: none;
color: green;
font-weight: bold; }
span#copyError {
display: none;
color: red;
font-weight: bold; }
.link {
color: blue; }
#status {
font: normal normal normal 16px/20px 'Lato', sans-serif;
color: red; }
#turn, #waiting {
font: normal normal normal 16px/20px 'Lato', sans-serif; }
#gameTable tr td {
border-bottom: 1px solid #333;
border-right: 1px solid #333;
height: 160px;
width: 160px;
text-align: center; }
#gameTable tr td:last-child {
border-right: none; }
#gameTable tr td.dim {
opacity: 0.3; }
#gameTable tr td.dim.highlight {
opacity: 1; }
#gameTable tr td.highlight {
opacity: 1; }
#gameTable tr:last-child td {
border-bottom: none; }
.hide {
display: none; }
.tooltip {
position: relative;
display: inline-block; }
.tooltip .tooltiptext {
visibility: hidden;
width: 140px;
background-color: #555;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 1;
bottom: 150%;
left: 50%;
margin-left: -75px;
opacity: 0;
transition: opacity 0.3s; }
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #555 transparent transparent transparent; }
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1; }

+ 53
- 0
hub.go View File

@ -0,0 +1,53 @@
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
// Hub maintains the set of active clients and broadcasts messages to the
// clients.
type Hub struct {
// Registered clients.
clients map[*Client]bool
// Inbound messages from the clients.
broadcast chan []byte
// Register requests from the clients.
register chan *Client
// Unregister requests from clients.
unregister chan *Client
}
func newHub() *Hub {
return &Hub{
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]bool),
}
}
func (h *Hub) run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case message := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}

+ 128
- 0
main.go View File

@ -0,0 +1,128 @@
package main
import (
"log"
"net/http"
"os"
"time"
cache "tictactoe-api/cache"
"github.com/gin-contrib/multitemplate"
"github.com/gin-gonic/autotls"
"github.com/gin-gonic/gin"
uuid "github.com/satori/go.uuid"
)
func setupRender() multitemplate.Renderer {
r := multitemplate.NewRenderer()
r.AddFromFiles("index", "dist/index.html")
r.AddFromFiles("join", "dist/join.html")
return r
}
func main() {
hub := newHub()
go hub.run()
cache := cache.NewCache(time.Minute*10, time.Minute)
router := gin.Default()
router.HTMLRender = setupRender()
router.Static("/static", "dist")
router.GET("/favicon.ico", func(c *gin.Context) {
c.File("dist/favicon.ico")
})
router.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "pong")
})
router.Any("/ws", func(c *gin.Context) {
serveWs(hub, c.Writer, c.Request)
})
//
//
//
router.GET("/join/:gameUUID", func(c *gin.Context) {
c.HTML(http.StatusOK, "join", gin.H{
"title": "Play Game",
})
})
router.GET("/game", func(c *gin.Context) {
c.HTML(http.StatusOK, "index", gin.H{
"title": "Start New Game",
})
})
router.POST("/game", func(c *gin.Context) {
var game Game
err := c.BindJSON(&game)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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 {
log.Printf("UUID Parse Failed: %s\n", err)
}
game, found := cache.Get(gameUUID)
if !found {
c.Status(http.StatusNotFound)
return
}
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)
if !found {
c.Status(http.StatusNotFound)
return
}
_game := game.(Game)
var player Player
err = c.BindJSON(&player)
if err != nil {
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
}
_game.Players = append(_game.Players, &Player{
UUID: &playerUUID,
Name: player.Name,
})
cache.Set(gameUUID, _game, 0)
c.JSON(http.StatusOK, _game)
})
//
//
//
if os.Getenv("GIN_MODE") == "release" {
log.Fatal(autotls.Run(router, "tictactoe.l3vi.co"))
} else {
log.Fatal(router.Run(":80"))
}
}

+ 6753
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 35
- 0
package.json View File

@ -0,0 +1,35 @@
{
"name": "tictactoe-api",
"version": "1.0.0",
"description": "A Golang Server that opens a unique socket for two users to play TicTacToe online.",
"private": true,
"scripts": {
"build": "webpack --watch"
},
"babel": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": true
}
}
]
]
},
"author": "Levi Olson",
"license": "MIT",
"devDependencies": {
"@babel/cli": "^7.2.3",
"@babel/core": "^7.2.2",
"@babel/preset-env": "^7.2.3",
"css-loader": "^2.1.0",
"mini-css-extract-plugin": "^0.5.0",
"node-sass": "^4.11.0",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"webpack": "^4.28.1",
"webpack-cli": "^3.2.1"
}
}

+ 116
- 0
src/classes.js View File

@ -0,0 +1,116 @@
'use strict'
import * as HTTP from './services.js'
import { timingSafeEqual } from 'crypto';
class BaseUtils {
constructor() {
this.id = this.getUUID()
}
getUUID () {
function s4 () {
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()
}
setId (id) {
this.id = id
return this
}
setCookie (cname, cvalue) {
document.cookie = cname + "=" + cvalue + ";path=/"
return this
}
getCookie (cname) {
let name = cname + "="
let decodedCookie = decodeURIComponent(document.cookie)
let ca = decodedCookie.split(';')
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
while (c.charAt(0) == ' ') {
c = c.substring(1)
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length)
}
}
return ""
}
}
export class Player extends BaseUtils {
constructor(id, name) {
super()
if (id)
this.id = id
if (name)
this.name = name
return this
}
setId (id) {
this.id = id
return this
}
getId () {
return this.id
}
setName (name) {
this.name = name
return this
}
getName () {
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)
return this
}
}
export class Game extends BaseUtils {
constructor(player) {
super()
this.setId(this.getGameIdFromUrl() || this.id)
this.players = [player]
this.turn = player.uuid
this.winner = undefined
this.draw = undefined
this.matrix = [
null, null, null,
null, null, null,
null, null, null
]
this.blocked = false
return this
}
getId () {
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
}
getOpponentsName(myId) {
let opponent = this.players.filter(x => x.id !== myId)[0]
console.debug("getOpponentsName()", opponent.name)
return opponent.name
}
setOpponentsTurn(myId) {
let opponent = this.players.filter(x => x.id !== myId)[0]
console.debug("setOpponentsTurn()", opponent.id)
this.turn = opponent.id
return this
}
}

+ 375
- 0
src/index.js View File

@ -0,0 +1,375 @@
'use strict'
import {Player, Game} from "./classes.js"
import * as HTTP from "./services.js"
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)
/**
* Initialize DOM variables
*/
let name = document.getElementById('name')
let nameLabel = document.getElementById('nameLabel')
let playerH1 = document.getElementById('player')
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 instructions = document.getElementById('instructions')
let input = document.getElementById('shareLink')
let tooltip = document.getElementById("tooltip")
let startGameForm = document.getElementById('startGameForm')
let joinGameForm = document.getElementById('joinGameForm')
let opponentTileColor = "#992222"
let tileColor = "#229922"
/**
* 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")
}
}
_setup()
export function Copy (context) {
console.debug(context.innerText)
context.select()
document.execCommand("Copy")
tooltip.innerHTML = "Copied!"
}
export function TooltipBlur () {
tooltip.innerHTML = "Copy"
}
if (window.WebSocket) {
conn = new WebSocket("ws://" + document.location.host + "/ws")
conn.onclose = function (evt) {
console.debug("Websocket Closed")
}
conn.onmessage = function (evt) {
let messages = evt.data.split('\n')
for (var i = 0; i < messages.length; i++) {
let data = messages[i]
try {
data = JSON.parse(data)
} catch (e) {
console.error("Error parsing", data)
}
console.debug("Message", data)
if (data.sender === player.getId()) {
console.debug("My own message received and ignored")
return
}
switch(data.event) {
case "joining":
console.debug("Event: joining")
instructions.classList.add("hide")
game.players.push(data.player)
console.debug(game)
_yourTurn()
_showGame()
break
case "move":
console.debug("Event: move")
game.matrix[data.index] = data.player
_updateTiles(data.index)
_yourTurn()
console.debug(game.matrix)
break
case "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")
break
}
}
}
} else {
console.error("TicTacToe can only be played in a browser that supports a WebSocket connection.")
}
/**
* BEGIN Document Listeners
*/
if (startGameForm) {
startGameForm.addEventListener('submit', event => {
event.preventDefault()
if (!_validateForm()) {
return false
}
_createGame().then(resp => {
startGameForm.classList.add("hide")
console.debug("_createGame() success", resp)
game.turn = resp.turn
game.matrix = resp.matrix
console.debug("Game:", game)
input.value = "http://localhost/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 (joinGameForm) {
joinGameForm.addEventListener('submit', event => {
event.preventDefault()
if (!_validateForm()) {
return false
}
_addPlayer().then(resp => {
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")
})
})
}
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
}
// send move via socket
conn.send(JSON.stringify({
sender: player.getId(),
game_id: game.getId(),
event: "move",
index: pos,
player: player.getId()
}))
_theirTurn()
})
})
}
/**
* END Document Listeners
*/
function _validateForm() {
if (!name.value) {
status.innerText = "Name is required."
status.classList.remove("hide")
return false
}
player.setName(name.value)
player.setCookies()
status.classList.add("hide")
_greeting()
return true
}
function _getUrl() {
return document.location.protocol + '//' + document.location.host
}
function _greeting() {
if (player.getName()) {
playerH1.innerText = "Hi " + player.getName()
playerH1.classList.remove("hide")
}
}
function _createGame() {
let url = _getUrl()
return HTTP.POST(`${url}/game`, JSON.stringify(game))
}
function _addPlayer() {
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 }))
}
function _showGame() {
gameTable.classList.remove("hide")
}
function _waiting() {
waiting.innerText = "Waiting for opponent"
waiting.classList.remove("hide")
}
function _yourTurn () {
if (waiting)
waiting.classList.add("hide")
turn.innerText = "Your Turn"
turn.classList.remove("hide")
game.turn = player.getId()
}
function _theirTurn () {
game.setOpponentsTurn(player.getId())
turn.innerText = _possessivizer(game.getOpponentsName(player.getId())) + " Turn"
turn.classList.remove("hide")
}
function _possessivizer(name) {
if (name.charAt(name.length - 1) === "s") {
return name + "'"
} else {
return name + "'s"
}
}
function _updateTiles(index) {
document.getElementById('pos_' + index).style.backgroundColor = opponentTileColor
}
function _threeInARow() {
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]) ) {
return [i, i + 1, i + 2]
}
i = i + 3
}
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]) ) {
return [i, i + 3, i + 6]
}
}
if (game.matrix[0] && (game.matrix[0] === game.matrix[4] && game.matrix[0] === game.matrix[8])) {
return [0, 4, 8]
}
if (game.matrix[2] && (game.matrix[2] === game.matrix[4] && game.matrix[2] === game.matrix[6])) {
return [2, 4, 6]
}
return false
}
function _draw() {
for (let i = 0; i < game.matrix.length; i++) {
if (!game.matrix[i]) {
return false
}
}
return true
}
function _dimBoard() {
document.querySelectorAll('td').forEach(el => el.classList.add("dim"))
}
function _highlightBoard (positions) {
for (let pos of positions) {
document.querySelector('#pos_' + pos).classList.add("highlight")
}
}

+ 41
- 0
src/services.js View File

@ -0,0 +1,41 @@
'use strict'
// 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)
}
// POST HTTP call
export function POST (theUrl, data) {
return new Promise((resolve, reject) => {
let 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("POST", theUrl, true)
if (data) {
req.setRequestHeader("Content-Type", "application/json")
}
req.send(data)
})
}
function _parseOrNot(str) {
let data = str
try {
data = JSON.parse(str)
} catch (e) {
console.debug("Error Parsing Response JSON:", str)
}
return data
}

+ 96
- 0
src/style.scss View File

@ -0,0 +1,96 @@
@import url('https://fonts.googleapis.com/css?family=Lato');
input#shareLink {
font-family: monospace;
cursor: pointer;
width: 400px;
}
span {
&#copySuccess {
display: none;
color: green;
font-weight: bold;
}
&#copyError {
display: none;
color: red;
font-weight: bold;
}
}
.link {
color: blue;
}
#status {
font: normal normal normal 16px/20px 'Lato', sans-serif;
color: red;
}
#turn, #waiting {
font: normal normal normal 16px/20px 'Lato', sans-serif;
}
#gameTable {
tr {
td {
border-bottom: 1px solid #333;
border-right: 1px solid #333;
height: 160px;
width: 160px;
text-align: center;
&:last-child {
border-right: none;
}
&.dim {
opacity: 0.3;
&.highlight {
opacity: 1;
}
}
&.highlight {
opacity: 1;
}
}
&:last-child {
td {
border-bottom: none;
}
}
}
}
.hide {
display: none;
}
.tooltip {
position: relative;
display: inline-block;
.tooltiptext {
visibility: hidden;
width: 140px;
background-color: #555;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 1;
bottom: 150%;
left: 50%;
margin-left: -75px;
opacity: 0;
transition: opacity 0.3s;
&::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #555 transparent transparent transparent;
}
}
&:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
}

+ 21
- 0
structs.go View File

@ -0,0 +1,21 @@
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


+ 29
- 0
tictactoe-api.service View File

@ -0,0 +1,29 @@
[Unit]
Description=TicTacToe API service
ConditionPathExists=/root/go/src/tictactoe-api
After=network.target
[Service]
Type=simple
User=root
Group=root
LimitNOFILE=1024
Environment="GIN_MODE=release"
Restart=on-failure
RestartSec=10
WorkingDirectory=/root/go/src/tictactoe-api
ExecStart=/bin/bash -c "/root/go/src/tictactoe-api/tictactoe-api"
# make sure log directory exists and owned by syslog
PermissionsStartOnly=true
ExecStartPre=/bin/mkdir -p /var/log/tictactoeapi
ExecStartPre=/bin/chown syslog:adm /var/log/tictactoeapi
ExecStartPre=/bin/chmod 755 /var/log/tictactoeapi
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=tictactoeapi
[Install]
WantedBy=multi-user.target

+ 33
- 0
webpack.config.js View File

@ -0,0 +1,33 @@
const path = require('path');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
mode: 'development',
entry: ['./src/index.js'],
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
library: 'ui'
},
module: {
rules: [
{
test: /\.(sa|sc|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
],
}
]
},
target: "web",
plugins: [
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: '[name].css',
chunkFilename: '[id].css',
})
]
};

Loading…
Cancel
Save