~whereswaldon/pointstar

6ce31da671c411c5894943afae14404c660f8e4f — Chris Waldon 1 year, 7 months ago d4484fe
feat: UI connects to server and can play
6 files changed, 226 insertions(+), 161 deletions(-)

A client/main.go
R skin.go => client/skin.go
R uistate.go => client/uistate.go
M gamestate/gamestate.go
D main.go
M server/main.go
A client/main.go => client/main.go +112 -0
@@ 0,0 1,112 @@
package main

import (
	"context"
	"log"

	"gioui.org/app"
	"gioui.org/font/gofont"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/widget/material"
	"nhooyr.io/websocket"

	"git.sr.ht/~whereswaldon/pointstar/gamestate"
	"git.sr.ht/~whereswaldon/pointstar/server/protocol"
)

func main() {
	protocol.Register()
	go func() {
		w := app.NewWindow()
		if err := eventLoop(w); err != nil {
			log.Fatal(err)
		}
	}()
	app.Main()
}

type WSWorker struct {
	Address    string
	FromServer chan interface{}
	ToServer   chan interface{}
}

func (w *WSWorker) Run(window *app.Window) {
	ctx := context.Background()
	socket, _, err := websocket.Dial(ctx, "ws://localhost:8080", nil)
	if err != nil {
		log.Printf("websocket error: %v", err)
	}
	p := &protocol.PlayerConn{Conn: socket, Context: ctx}
	go func() {
		for {
			if msgs, err := p.Recv(); err != nil {
				log.Printf("failed receiving from server: %v", err)
			} else {
				for _, msg := range msgs {
					log.Printf("relaying message %v to front end", msg)
					window.Invalidate()
					w.FromServer <- msg
				}
			}
		}
	}()
	for {
		select {
		case msg := <-w.ToServer:
			log.Printf("Got message for server (type %T): %v", msg, msg)
			if err := p.Send(msg); err != nil {
				log.Printf("Failed sending set name: %v", err)
			}
		}
	}
}

func eventLoop(w *app.Window) error {
	game := &gamestate.GameState{}
	ui := UIState{
		PlayerNum: -1, // indicate that it isn't set yet
	}
	fromServer := make(chan interface{}, 1)
	toServer := make(chan interface{}, 1)
	ui.Worker = &WSWorker{
		Address:    "ws://localhost:8080",
		FromServer: fromServer,
		ToServer:   toServer,
	}
	go ui.Worker.Run(w)

	gofont.Register()
	th := material.NewTheme()
	skin := &Skin{th}
	gtx := layout.NewContext(w.Queue())
	for {
		switch e := (<-w.Events()).(type) {
		case system.DestroyEvent:
			return e.Err
		case system.FrameEvent:
			select {
			case msg := <-fromServer:
				log.Printf("front end read %v from server", msg)
				// receive a new game state if one is available
				switch message := msg.(type) {
				case protocol.AssignPlayerNumber:
					log.Println("assigned player number")
					ui.PlayerNum = message.Number
				case *gamestate.GameState:
					log.Println("game state updated")
					game = message
				case protocol.ErrorMessage:
					log.Printf("server sent error: %s", message.Message)
				default:
					log.Printf("Unknown message from server (type %T): %v", message, message)
				}
			default:
			}
			gtx.Reset(e.Config, e.Size)
			skin.LayoutAll(game, &ui, gtx)
			e.Frame(gtx.Ops)
		}
	}
}

R skin.go => client/skin.go +93 -23
@@ 9,6 9,7 @@ import (
	"gioui.org/widget"
	"gioui.org/widget/material"
	"git.sr.ht/~whereswaldon/pointstar/gamestate"
	"git.sr.ht/~whereswaldon/pointstar/server/protocol"
)

// Skin provides methods for rendering the game UI


@@ 16,25 17,92 @@ type Skin struct {
	*material.Theme
}

func (s *Skin) LayoutAll(g *gamestate.GameState, u *UIState, gtx *layout.Context) {
	switch g.Phase {
	case gamestate.Lobby:
		s.LayoutLobby(g, u, gtx)
	default:
		s.LayoutTable(g, u, gtx)
	}
}

func (s *Skin) LayoutLobby(g *gamestate.GameState, u *UIState, gtx *layout.Context) {
	layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
		layout.Flexed(.5, func() {}),
		layout.Rigid(func() {
			layout.Flex{Axis: layout.Vertical}.Layout(gtx,
				layout.Flexed(.5, func() {}),
				layout.Rigid(func() {
					if u.PlayerNum == -1 {
						clicked := false
						for u.NameSubmit.Clicked(gtx) {
							clicked = true
						}
						if clicked {
							u.Worker.ToServer <- protocol.SetPlayerName{Name: u.NameEditor.Text()}
						}
						layout.Flex{Axis: layout.Vertical}.Layout(gtx,
							layout.Flexed(.5, func() {}),
							layout.Rigid(func() {
								s.Theme.Editor("Username").Layout(gtx, &u.NameEditor)
							}),
							layout.Rigid(func() {
								s.Theme.Button("Submit").Layout(gtx, &u.NameSubmit)
							}),
							layout.Flexed(.5, func() {}))
					} else {
						clicked := false
						for u.StartButton.Clicked(gtx) {
							clicked = true
						}
						if clicked {
							u.Worker.ToServer <- protocol.StartGame{}
						}
						layout.Flex{Axis: layout.Vertical}.Layout(gtx,
							layout.Rigid(func() {
								msg := "waiting for other players..."
								s.Theme.H1(msg).Layout(gtx)
							}),
							layout.Rigid(func() {
								players := ""
								for _, p := range g.Players {
									players += p.Name + " "
								}
								s.Theme.H2(players + " have joined").Layout(gtx)
							}),
							layout.Rigid(func() {
								if u.PlayerNum == 0 {
									s.Theme.Button("Start game").Layout(gtx, &u.StartButton)
								} else {
									s.Theme.H6("The first player to join can start the game at any time.").Layout(gtx)
								}
							}))
					}
				}),
				layout.Flexed(.5, func() {}))
		}),
		layout.Flexed(.5, func() {}))
}

// LayoutTable renders the entire game interface into the provided context
func (s *Skin) LayoutTable(g *gamestate.GameState, u *UIState, gtx *layout.Context) {
	for i := range u.PlayButtons[:] {
		clicked := false
		for u.PlayButtons[i].Clicked(gtx) {
			clicked = true
	if g.IsPlayersTurn(u.PlayerNum) {
		for i := range u.PlayButtons[:] {
			clicked := false
			for u.PlayButtons[i].Clicked(gtx) {
				clicked = true
			}
			if clicked {
				u.Worker.ToServer <- protocol.PlayCard{Index: i}
			}
		}
		if clicked {
			g.Players[0].PlayCardAt(i)
			g.Advance()
		passed := false
		for u.PassButton.Clicked(gtx) {
			passed = true
		}
		if passed {
			u.Worker.ToServer <- protocol.PassTurn{}
		}
	}
	passed := false
	for u.PassButton.Clicked(gtx) {
		passed = true
	}
	if passed {
		g.Players[0].IsPassing = true
		g.Advance()
	}
	var children []layout.FlexChild
	// populate the slice with functions to lay out each player's hand


@@ 47,7 115,7 @@ func (s *Skin) LayoutTable(g *gamestate.GameState, u *UIState, gtx *layout.Conte
	}
	children = append(children, layout.Flexed(1, func() {}))
	children = append(children, layout.Rigid(func() {
		s.LayoutHand(g.Players[0], u.PlayButtons[:], &u.PassButton, gtx)
		s.LayoutHand(g.Players[u.PlayerNum], g.IsPlayersTurn(u.PlayerNum), u.PlayButtons[:], &u.PassButton, gtx)
	}))
	// lay out all of the rows
	layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)


@@ 89,7 157,7 @@ func (s *Skin) LayoutPlayed(player gamestate.Player, gtx *layout.Context) {

// LayoutHand lays out the cards in a player's hand with buttons to play each card
// and to pass the turn.
func (s *Skin) LayoutHand(player gamestate.Player, playButtons []widget.Button, passButton *widget.Button, gtx *layout.Context) {
func (s *Skin) LayoutHand(player gamestate.Player, isPlayersTurn bool, playButtons []widget.Button, passButton *widget.Button, gtx *layout.Context) {
	var children []layout.FlexChild
	hand := player.Hand
	children = append(children, layout.Flexed(.5, func() {}))


@@ 105,7 173,7 @@ func (s *Skin) LayoutHand(player gamestate.Player, playButtons []widget.Button, 
						})
					}),
					layout.Rigid(func() {
						if !player.IsPassing {
						if isPlayersTurn {
							s.Theme.Button("Play").Layout(gtx, button)
						}
					}),


@@ 113,11 181,13 @@ func (s *Skin) LayoutHand(player gamestate.Player, playButtons []widget.Button, 
			})
		}))
	}
	children = append(children, layout.Rigid(func() {
		layout.UniformInset(unit.Dp(8)).Layout(gtx, func() {
			s.Theme.Button("Pass").Layout(gtx, passButton)
		})
	}))
	if isPlayersTurn {
		children = append(children, layout.Rigid(func() {
			layout.UniformInset(unit.Dp(8)).Layout(gtx, func() {
				s.Theme.Button("Pass").Layout(gtx, passButton)
			})
		}))
	}
	children = append(children, layout.Flexed(.5, func() {}))
	layout.Flex{Axis: layout.Horizontal}.Layout(gtx, children...)
}

R uistate.go => client/uistate.go +6 -0
@@ 9,4 9,10 @@ import (
type UIState struct {
	PlayButtons [gamestate.MaxHandSize]widget.Button
	PassButton  widget.Button
	StartButton widget.Button
	NameEditor  widget.Editor
	NameSubmit  widget.Button
	PlayerNum   int

	Worker *WSWorker
}

M gamestate/gamestate.go => gamestate/gamestate.go +4 -1
@@ 95,6 95,9 @@ func (g *GameState) DealCards() {
	}
}

func (g *GameState) IsPlayersTurn(playerNum int) bool {
	return g.Phase == WaitingOnPlayer && playerNum == g.Turn
}
func (g *GameState) TakeTurn(playerNum int, playsCardAt int) {
	if g.Phase == WaitingOnPlayer && g.Turn == playerNum {
		player := &g.Players[playerNum]


@@ 115,7 118,7 @@ func (g *GameState) nextTurn() {
	next = (g.Turn + 1) % len(g.Players)
	playersTried := 1
	for g.Players[next].IsPassing && playersTried < len(g.Players) {
		next = (g.Turn + 1) % len(g.Players)
		next = (next + 1) % len(g.Players)
		playersTried++
	}
	if playersTried == len(g.Players) {

D main.go => main.go +0 -43
@@ 1,43 0,0 @@
package main

import (
	"log"

	"gioui.org/app"
	"gioui.org/font/gofont"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/widget/material"

	"git.sr.ht/~whereswaldon/pointstar/gamestate"
)

func main() {
	go func() {
		w := app.NewWindow()
		if err := eventLoop(w); err != nil {
			log.Fatal(err)
		}
	}()
	app.Main()
}

func eventLoop(w *app.Window) error {
	game := gamestate.GameState{}
	ui := UIState{}
	game.SetupWithPlayers(4)
	gofont.Register()
	th := material.NewTheme()
	skin := &Skin{th}
	gtx := layout.NewContext(w.Queue())
	for {
		switch e := (<-w.Events()).(type) {
		case system.DestroyEvent:
			return e.Err
		case system.FrameEvent:
			gtx.Reset(e.Config, e.Size)
			skin.LayoutTable(&game, &ui, gtx)
			e.Frame(gtx.Ops)
		}
	}
}

M server/main.go => server/main.go +11 -94
@@ 2,98 2,17 @@ package main

import (
	"context"
	"encoding/gob"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"sync"
	"time"

	"git.sr.ht/~whereswaldon/pointstar/gamestate"
	. "git.sr.ht/~whereswaldon/pointstar/server/protocol"
	"nhooyr.io/websocket"
)

/*
Protocol

To play, a player opens a websocket to the server and sends a
SetPlayerName message with their name. If the server responds
with a AssignPlayerNumber, they have joined the game.

Once all desired players have joined, player 0 sends a StartGame
message to the server and the game begins
*/

type AssignPlayerNumber struct {
	Number int
}

type SetPlayerName struct {
	Name string
}

type ErrorMessage struct {
	Message string
}

type StartGame struct {
}

type PassTurn struct {
}

type PlayCard struct {
	Index int
}

type PlayerConn struct {
	*websocket.Conn
	context.Context
}

func (p *PlayerConn) Send(msg interface{}) error {
	writer, err := p.Conn.Writer(p.Context, websocket.MessageBinary)
	if err != nil {
		return fmt.Errorf("failed sending message: %w", err)
	}
	defer writer.Close()
	encoder := gob.NewEncoder(writer)
	if err := encoder.Encode(&msg); err != nil {
		return fmt.Errorf("failed encoding message: %w", err)
	}
	return nil
}

func (p *PlayerConn) Recv() ([]interface{}, error) {
	var out []interface{}
	_, reader, err := p.Conn.Reader(p.Context)
	if err != nil {
		return nil, fmt.Errorf("failed reading socket: %w", err)
	}
	decoder := gob.NewDecoder(reader)
	for {
		var msg interface{}
		if err := decoder.Decode(&msg); err != nil {
			if errors.Is(err, io.EOF) {
				break
			}
			return nil, fmt.Errorf("failed decoding message: %w", err)
		}
		out = append(out, msg)
	}
	return out, nil
}

func (p *PlayerConn) TakeTurn(playerNum int, gs *gamestate.GameState) error {
	player := gs.Players[playerNum]
	if len(player.Hand) > 0 {
		return p.Send(PlayCard{0})
	}
	return p.Send(PassTurn{})
}

type GameServer struct {
	sync.Mutex
	PlayerConns []*PlayerConn


@@ 115,6 34,7 @@ func (g *GameServer) AddPlayer(p *PlayerConn, name string) error {
					log.Println(err)
					return
				}
				log.Printf("Got message %v", msgs)
				for _, msg := range msgs {
					if err := g.HandleMessage(playerNum, msg); err != nil {
						log.Println(err)


@@ 201,13 121,8 @@ func main() {
	game := gamestate.GameState{}
	game.SetupWithPlayers(4)
	gs := GameServer{}
	gob.Register(AssignPlayerNumber{})
	gob.Register(gamestate.GameState{})
	gob.Register(SetPlayerName{})
	gob.Register(StartGame{})
	gob.Register(ErrorMessage{})
	gob.Register(PassTurn{})
	gob.Register(PlayCard{})
	Register()

	fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c, err := websocket.Accept(w, r, nil)
		if err != nil {


@@ 219,6 134,7 @@ func main() {
		if err != nil {
			log.Println(err)
		}
		log.Println("Accepted websocket connection")
		if msg, ok := msgs[0].(SetPlayerName); ok {
			if err := gs.AddPlayer(p, msg.Name); err != nil {
				log.Println(err)


@@ 231,7 147,7 @@ func main() {
		}
	})

	for i := 0; i < 5; i++ {
	for i := 0; i < 3; i++ {
		go LaunchAIPlayer(fmt.Sprintf("Player %d", i))
	}



@@ 240,7 156,7 @@ func main() {
}

func LaunchAIPlayer(name string) {
	time.Sleep(time.Second)
	time.Sleep(time.Second * 10)
	ctx := context.Background()
	logPrint := func(format string, args ...interface{}) {
		log.Printf("%s:"+format, append([]interface{}{name}, args...)...)


@@ 250,7 166,7 @@ func LaunchAIPlayer(name string) {
		logPrint("websocket error: %v", err)
	}
	p := &PlayerConn{Conn: socket, Context: ctx}
	if err := p.Send(SetPlayerName{name}); err != nil {
	if err := p.Send(SetPlayerName{Name: name}); err != nil {
		logPrint("error setting player name: %v", err)
	}
	playerNum := 0


@@ 279,10 195,11 @@ func LaunchAIPlayer(name string) {
			for _, msg := range msgs {
				logPrint("%v", msg)
				switch message := msg.(type) {
				case gamestate.GameState:
				case *gamestate.GameState:
					gs := message
					if gs.Phase == gamestate.WaitingOnPlayer && gs.Turn == playerNum {
						if err := p.TakeTurn(playerNum, &gs); err != nil {
						time.Sleep(time.Second)
						if err := p.TakeTurn(playerNum, gs); err != nil {
							logPrint("error taking turn: %v", err)
						}
					}