initial commit
This commit is contained in:
commit
ac9aa86d33
14 changed files with 1021 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/go-fish
|
||||
.idea
|
||||
26
Makefile
Normal file
26
Makefile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
SHELL := /bin/sh
|
||||
|
||||
APP := go-fish
|
||||
BIN := $(APP)
|
||||
|
||||
.PHONY: all build run clean test vet fmt
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
go build -o $(BIN) ./cmd/$(APP)/
|
||||
|
||||
run:
|
||||
go run ./cmd/$(APP)/
|
||||
|
||||
clean:
|
||||
rm -f $(BIN)
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
71
README.md
Normal file
71
README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Go Fish
|
||||
|
||||
A web-based implementation of the classic Go Fish card game.
|
||||
|
||||
## Overview
|
||||
|
||||
A single-page web application where players can join a waiting room, find opponents,
|
||||
and play Go Fish against each other. Players already in a game are marked as
|
||||
unavailable in the waiting room.
|
||||
|
||||
## Features
|
||||
|
||||
- **Waiting room**: View other online players, unavailable players marked in red
|
||||
- **Matchmaking**: Click on available players to start a game
|
||||
- **Go Fish gameplay**: Ask opponents for ranks, draw from deck if unavailable
|
||||
- **Book collection**: Collect sets of 4 cards (books)
|
||||
- **Win condition**: First player to collect 13 books wins
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.21 or later
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repo-url> go-fish
|
||||
cd go-fish
|
||||
|
||||
# Build the application
|
||||
make build
|
||||
|
||||
# Or run directly
|
||||
make run
|
||||
```
|
||||
|
||||
The application will start on `http://localhost:3000`
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
Or build and run manually:
|
||||
|
||||
```bash
|
||||
./go-fish
|
||||
```
|
||||
|
||||
## How to Play
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:3000`
|
||||
2. Click "Join Waiting Room" to enter the waiting area
|
||||
3. See other players - those with green backgrounds are available
|
||||
4. Players already in a game appear with red backgrounds
|
||||
5. Click on an available player to be matched and start a game
|
||||
6. Use the rank buttons (A, 2, 3, ..., K) to ask your opponent for cards
|
||||
7. Collect sets of 4 matching cards (books)
|
||||
8. First to 13 books wins!
|
||||
|
||||
## Game Rules
|
||||
|
||||
- Each player starts with 7 cards from a standard 52-card deck
|
||||
- On your turn, ask your opponent for a specific rank (A, 2-10, J, Q, K)
|
||||
- If the opponent has the rank, they must give you all cards of that rank
|
||||
- If the opponent doesn't have the rank, say "Go Fish" and you draw from the deck
|
||||
- Collect 4 of a kind to score a book
|
||||
- First player to 13 books wins
|
||||
306
cmd/go-fish/main.go
Normal file
306
cmd/go-fish/main.go
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"text/template"
|
||||
|
||||
"github.com/danyel/go-fish/internal/game"
|
||||
"github.com/danyel/go-fish/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
type JoinResponse struct {
|
||||
PlayerID string `json:"playerId"`
|
||||
}
|
||||
|
||||
type AskRequest struct {
|
||||
PlayerID string `json:"playerId"`
|
||||
OpponentID string `json:"opponentId"`
|
||||
Rank string `json:"rank"`
|
||||
}
|
||||
|
||||
type AskResponse struct {
|
||||
Status string `json:"status"`
|
||||
MyHand []string `json:"myHand"`
|
||||
MyBookRanks []string `json:"myBookRanks"`
|
||||
OpponentBookRanks []string `json:"opponentBookRanks"`
|
||||
GameOver bool `json:"gameOver"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
s := store.NewPlayerStore()
|
||||
|
||||
mux := chi.NewMux()
|
||||
mux.Use(middleware.Logger)
|
||||
mux.Use(middleware.Recoverer)
|
||||
|
||||
mux.Get("/", homeHandler)
|
||||
mux.Get("/waiting", waitingHandler(s))
|
||||
mux.Get("/play/{id}", playHandler(s))
|
||||
mux.Post("/join", joinHandler(s))
|
||||
mux.Post("/ask", askHandler(s))
|
||||
|
||||
fmt.Printf("Starting go-fish on :3000\n")
|
||||
http.ListenAndServe(":3000", mux)
|
||||
}
|
||||
|
||||
func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl := template.Must(template.ParseFiles("web/templates/index.html"))
|
||||
tmpl.Execute(w, nil)
|
||||
}
|
||||
|
||||
func waitingHandler(s *store.PlayerStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
players := s.GetAvailablePlayers()
|
||||
hasBotOption := len(players) <= 1
|
||||
tmpl := template.Must(template.ParseFiles("web/templates/waiting.html"))
|
||||
data := struct {
|
||||
Players []*game.Player
|
||||
HasBotOption bool
|
||||
}{Players: players, HasBotOption: hasBotOption}
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
}
|
||||
|
||||
func playHandler(s *store.PlayerStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
_ = id
|
||||
p, ok := s.GetPlayer(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
available := s.GetAvailablePlayers()
|
||||
var opponent *game.Player
|
||||
for _, ap := range available {
|
||||
if ap.ID != p.ID {
|
||||
opponent = ap
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var isBot bool
|
||||
var g *game.Game
|
||||
|
||||
if opponent == nil && len(available) == 1 {
|
||||
isBot = true
|
||||
botID := generateID()
|
||||
bot := game.NewPlayer(botID, "Bot")
|
||||
s.AddPlayer(botID, "Bot")
|
||||
opponent = bot
|
||||
s.MarkPlaying(p.ID)
|
||||
s.MarkPlaying(botID)
|
||||
g = game.NewGame(p, opponent)
|
||||
g.ID = "game-" + p.ID + "-" + botID
|
||||
} else if opponent != nil {
|
||||
isBot = false
|
||||
s.MarkPlaying(p.ID)
|
||||
s.MarkPlaying(opponent.ID)
|
||||
g = game.NewGame(p, opponent)
|
||||
g.ID = "game-" + p.ID + "-" + opponent.ID
|
||||
} else {
|
||||
isBot = true
|
||||
botID := generateID()
|
||||
bot := game.NewPlayer(botID, "Bot")
|
||||
s.AddPlayer(botID, "Bot")
|
||||
opponent = bot
|
||||
s.MarkPlaying(p.ID)
|
||||
s.MarkPlaying(botID)
|
||||
g = game.NewGame(p, opponent)
|
||||
g.ID = "game-" + p.ID + "-" + botID
|
||||
}
|
||||
|
||||
s.CreateGame(p.ID, g)
|
||||
|
||||
for i := 0; i < 7; i++ {
|
||||
if len(g.Deck) > 0 {
|
||||
g.Player1.Draw(g.DrawCard())
|
||||
g.Player2.Draw(g.DrawCard())
|
||||
}
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.ParseFiles("web/templates/play.html"))
|
||||
data := struct {
|
||||
Game *game.Game
|
||||
Player *game.Player
|
||||
IsBot bool
|
||||
}{Game: g, Player: p, IsBot: isBot}
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
}
|
||||
|
||||
func joinHandler(s *store.PlayerStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
name = "Player"
|
||||
}
|
||||
id := generateID()
|
||||
s.AddPlayer(id, name)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "player_id",
|
||||
Value: id,
|
||||
Path: "/",
|
||||
})
|
||||
respondJSON(w, http.StatusOK, JoinResponse{PlayerID: id})
|
||||
}
|
||||
}
|
||||
|
||||
func askHandler(s *store.PlayerStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req AskRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
g, ok := s.GetGame(req.PlayerID)
|
||||
if !ok {
|
||||
respondJSON(w, http.StatusNotFound, AskResponse{Message: "Game not found"})
|
||||
return
|
||||
}
|
||||
|
||||
rank := game.RankAce
|
||||
switch req.Rank {
|
||||
case "A":
|
||||
rank = game.RankAce
|
||||
case "2":
|
||||
rank = game.Rank2
|
||||
case "3":
|
||||
rank = game.Rank3
|
||||
case "4":
|
||||
rank = game.Rank4
|
||||
case "5":
|
||||
rank = game.Rank5
|
||||
case "6":
|
||||
rank = game.Rank6
|
||||
case "7":
|
||||
rank = game.Rank7
|
||||
case "8":
|
||||
rank = game.Rank8
|
||||
case "9":
|
||||
rank = game.Rank9
|
||||
case "10":
|
||||
rank = game.Rank10
|
||||
case "J":
|
||||
rank = game.RankJack
|
||||
case "Q":
|
||||
rank = game.RankQueen
|
||||
case "K":
|
||||
rank = game.RankKing
|
||||
}
|
||||
|
||||
asker := g.Player1
|
||||
target := g.Player2
|
||||
if g.Current == 2 {
|
||||
asker = g.Player2
|
||||
target = g.Player1
|
||||
}
|
||||
|
||||
var cardsRemoved []game.Card
|
||||
|
||||
if !asker.HasCard(rank) {
|
||||
card := g.DrawCard()
|
||||
target.Draw(card)
|
||||
} else {
|
||||
cardsRemoved = asker.RemoveCard(rank)
|
||||
if len(cardsRemoved) > 0 {
|
||||
asker.Hand = append(asker.Hand, cardsRemoved...)
|
||||
if asker.HasBook(rank) {
|
||||
g.BookPile = append(g.BookPile, []game.Card{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gameOver := false
|
||||
message := ""
|
||||
|
||||
myHand := asker.Hand
|
||||
oppHand := target.Hand
|
||||
|
||||
myBookCount := countBooks(g.BookPile)
|
||||
oppBookCount := countOpponentBooks(g.BookPile)
|
||||
|
||||
if len(myHand) == 0 && len(oppHand) == 0 {
|
||||
gameOver = true
|
||||
message = "Game over! No more cards."
|
||||
}
|
||||
|
||||
if myBookCount >= 13 || oppBookCount >= 13 {
|
||||
gameOver = true
|
||||
if myBookCount >= 13 && oppBookCount >= 13 {
|
||||
message = "Draw! Both players have 13 books."
|
||||
} else if myBookCount >= 13 {
|
||||
message = "You win! You have 13 books."
|
||||
} else {
|
||||
message = "You lose! Opponent has 13 books."
|
||||
}
|
||||
}
|
||||
|
||||
s.CreateGame(req.PlayerID, g)
|
||||
|
||||
respondJSON(w, http.StatusOK, AskResponse{
|
||||
Status: "success",
|
||||
MyHand: cardStrings(myHand),
|
||||
MyBookRanks: bookRanks(g.BookPile),
|
||||
OpponentBookRanks: bookRanks(g.BookPile),
|
||||
GameOver: gameOver,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func countBooks(books [][]game.Card) int {
|
||||
count := 0
|
||||
for _, book := range books {
|
||||
if len(book) > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countOpponentBooks(books [][]game.Card) int {
|
||||
count := 0
|
||||
for _, book := range books {
|
||||
if len(book) > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func bookRanks(books [][]game.Card) []string {
|
||||
var ranks []string
|
||||
for _, book := range books {
|
||||
if len(book) > 0 {
|
||||
ranks = append(ranks, book[0].Rank.String())
|
||||
}
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
func cardStrings(cards []game.Card) []string {
|
||||
var result []string
|
||||
for _, c := range cards {
|
||||
result = append(result, c.Rank.String()+c.Suit.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, code int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("player-%d", rand.Intn(10000))
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module github.com/danyel/go-fish
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/go-chi/chi/v5 v5.0.0
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg=
|
||||
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
|
||||
30
internal/game/bot.go
Normal file
30
internal/game/bot.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package game
|
||||
|
||||
import "math/rand"
|
||||
|
||||
type Bot struct {
|
||||
Player
|
||||
}
|
||||
|
||||
func NewBot(id, name string) *Bot {
|
||||
return &Bot{
|
||||
Player: *NewPlayer(id, name),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) ChooseRank(hand []Card) Rank {
|
||||
// Bot simply picks a random card rank from its hand
|
||||
if len(hand) == 0 {
|
||||
return RankAce
|
||||
}
|
||||
idx := rand.Intn(len(hand))
|
||||
return hand[idx].Rank
|
||||
}
|
||||
|
||||
func (b *Bot) ChooseCardToAsk(availableRanks []Rank) Rank {
|
||||
if len(availableRanks) > 0 {
|
||||
idx := rand.Intn(len(availableRanks))
|
||||
return availableRanks[idx]
|
||||
}
|
||||
return RankAce
|
||||
}
|
||||
111
internal/game/cards.go
Normal file
111
internal/game/cards.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package game
|
||||
|
||||
import "math/rand"
|
||||
|
||||
type Rank int
|
||||
|
||||
const (
|
||||
RankAce Rank = iota
|
||||
Rank2
|
||||
Rank3
|
||||
Rank4
|
||||
Rank5
|
||||
Rank6
|
||||
Rank7
|
||||
Rank8
|
||||
Rank9
|
||||
Rank10
|
||||
RankJack
|
||||
RankQueen
|
||||
RankKing
|
||||
)
|
||||
|
||||
func (r Rank) String() string {
|
||||
switch r {
|
||||
case RankAce:
|
||||
return "A"
|
||||
case Rank2:
|
||||
return "2"
|
||||
case Rank3:
|
||||
return "3"
|
||||
case Rank4:
|
||||
return "4"
|
||||
case Rank5:
|
||||
return "5"
|
||||
case Rank6:
|
||||
return "6"
|
||||
case Rank7:
|
||||
return "7"
|
||||
case Rank8:
|
||||
return "8"
|
||||
case Rank9:
|
||||
return "9"
|
||||
case Rank10:
|
||||
return "10"
|
||||
case RankJack:
|
||||
return "J"
|
||||
case RankQueen:
|
||||
return "Q"
|
||||
case RankKing:
|
||||
return "K"
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
type Suit int
|
||||
|
||||
const (
|
||||
SuitClubs Suit = iota
|
||||
SuitDiamonds
|
||||
SuitHearts
|
||||
SuitSpades
|
||||
)
|
||||
|
||||
func (s Suit) String() string {
|
||||
switch s {
|
||||
case SuitClubs:
|
||||
return "♣"
|
||||
case SuitDiamonds:
|
||||
return "♦"
|
||||
case SuitHearts:
|
||||
return "♥"
|
||||
case SuitSpades:
|
||||
return "♠"
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
Rank Rank
|
||||
Suit Suit
|
||||
}
|
||||
|
||||
func NewDeck() []Card {
|
||||
var deck []Card
|
||||
for suit := SuitClubs; suit <= SuitSpades; suit++ {
|
||||
for rank := RankAce; rank <= RankKing; rank++ {
|
||||
deck = append(deck, Card{Rank: rank, Suit: suit})
|
||||
}
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
func Shuffle(deck []Card) []Card {
|
||||
result := make([]Card, len(deck))
|
||||
copy(result, deck)
|
||||
for i := len(result) - 1; i > 0; i-- {
|
||||
j := rand.Intn(i + 1)
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var faceCards = map[Rank]bool{
|
||||
RankJack: true,
|
||||
RankQueen: true,
|
||||
RankKing: true,
|
||||
}
|
||||
|
||||
func IsFaceCard(r Rank) bool {
|
||||
return faceCards[r]
|
||||
}
|
||||
81
internal/game/game.go
Normal file
81
internal/game/game.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package game
|
||||
|
||||
type GameState int
|
||||
|
||||
const (
|
||||
StateWaiting GameState = iota
|
||||
StatePlaying
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
Player1 *Player
|
||||
Player2 *Player
|
||||
Current int // 1 or 2, whose turn it is
|
||||
State GameState
|
||||
Deck []Card
|
||||
BookPile [][]Card
|
||||
}
|
||||
|
||||
func NewGame(p1, p2 *Player) *Game {
|
||||
deck := Shuffle(NewDeck())
|
||||
return &Game{
|
||||
ID: "",
|
||||
Player1: p1,
|
||||
Player2: p2,
|
||||
Current: 1,
|
||||
State: StatePlaying,
|
||||
Deck: deck,
|
||||
BookPile: [][]Card{},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) DrawCard() Card {
|
||||
if len(g.Deck) == 0 {
|
||||
// Reshuffle book pile
|
||||
var all []Card
|
||||
for _, book := range g.BookPile {
|
||||
all = append(all, book...)
|
||||
}
|
||||
if len(all) > 0 {
|
||||
g.Deck = Shuffle(all)
|
||||
g.BookPile = [][]Card{}
|
||||
}
|
||||
}
|
||||
card := g.Deck[0]
|
||||
g.Deck = g.Deck[1:]
|
||||
return card
|
||||
}
|
||||
|
||||
func (g *Game) Ask(target *Player, rank Rank) bool {
|
||||
if !target.HasCard(rank) {
|
||||
// Player draws from deck
|
||||
card := g.DrawCard()
|
||||
target.Draw(card)
|
||||
return false // turn passes
|
||||
}
|
||||
// Target gives the card
|
||||
removed := target.RemoveCard(rank)
|
||||
if len(removed) > 0 {
|
||||
// Add to asker's hand
|
||||
g.Player1.Hand = append(g.Player1.Hand, removed...)
|
||||
// Check for book
|
||||
if g.Player1.HasBook(rank) {
|
||||
// Find and remove the 4 cards from hand
|
||||
var book []Card
|
||||
var remaining []Card
|
||||
count := 0
|
||||
for _, c := range g.Player1.Hand {
|
||||
if c.Rank == rank && count < 4 {
|
||||
book = append(book, c)
|
||||
count++
|
||||
} else {
|
||||
remaining = append(remaining, c)
|
||||
}
|
||||
}
|
||||
g.Player1.Hand = remaining
|
||||
g.BookPile = append(g.BookPile, book)
|
||||
}
|
||||
}
|
||||
return true // same player continues
|
||||
}
|
||||
56
internal/game/player.go
Normal file
56
internal/game/player.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package game
|
||||
|
||||
type Player struct {
|
||||
ID string
|
||||
Name string
|
||||
Hand []Card
|
||||
Booked [][]Card // sets of 4 cards
|
||||
Connected bool
|
||||
}
|
||||
|
||||
func NewPlayer(id, name string) *Player {
|
||||
return &Player{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Hand: []Card{},
|
||||
Booked: [][]Card{},
|
||||
Connected: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) Draw(card Card) {
|
||||
p.Hand = append(p.Hand, card)
|
||||
}
|
||||
|
||||
func (p *Player) RemoveCard(rank Rank) []Card {
|
||||
var removed []Card
|
||||
var remaining []Card
|
||||
for _, c := range p.Hand {
|
||||
if c.Rank == rank && len(removed) < 4 {
|
||||
removed = append(removed, c)
|
||||
} else {
|
||||
remaining = append(remaining, c)
|
||||
}
|
||||
}
|
||||
p.Hand = remaining
|
||||
return removed
|
||||
}
|
||||
|
||||
func (p *Player) HasCard(rank Rank) bool {
|
||||
for _, c := range p.Hand {
|
||||
if c.Rank == rank {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Player) HasBook(rank Rank) bool {
|
||||
count := 0
|
||||
for _, c := range p.Hand {
|
||||
if c.Rank == rank {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count == 4
|
||||
}
|
||||
80
internal/store/store.go
Normal file
80
internal/store/store.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"github.com/danyel/go-fish/internal/game"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PlayerStore struct {
|
||||
mu sync.Mutex
|
||||
players map[string]*game.Player
|
||||
games map[string]*game.Game // keyed by player ID
|
||||
}
|
||||
|
||||
func NewPlayerStore() *PlayerStore {
|
||||
return &PlayerStore{
|
||||
players: make(map[string]*game.Player),
|
||||
games: make(map[string]*game.Game),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlayerStore) AddPlayer(id, name string) *game.Player {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p := game.NewPlayer(id, name)
|
||||
s.players[id] = p
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *PlayerStore) GetPlayer(id string) (*game.Player, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, ok := s.players[id]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
func (s *PlayerStore) GetAvailablePlayers() []*game.Player {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var available []*game.Player
|
||||
for _, p := range s.players {
|
||||
if p.Connected && len(p.Hand) < 7 {
|
||||
available = append(available, p)
|
||||
}
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
func (s *PlayerStore) RemovePlayer(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.players, id)
|
||||
delete(s.games, id)
|
||||
}
|
||||
|
||||
func (s *PlayerStore) MarkPlaying(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, ok := s.players[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
p.Connected = false // player is now in a game
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *PlayerStore) GetGame(id string) (*game.Game, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
g, ok := s.games[id]
|
||||
return g, ok
|
||||
}
|
||||
|
||||
func (s *PlayerStore) CreateGame(playerID string, g *game.Game) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Store game under the player's ID so ask handler can find it
|
||||
s.games[playerID] = g
|
||||
// Also store under game ID
|
||||
s.games[g.ID] = g
|
||||
}
|
||||
37
web/templates/index.html
Normal file
37
web/templates/index.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Go Fish - Welcome</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
h1 { text-align: center; margin-bottom: 2rem; color: #f0f0f0; }
|
||||
.btn { padding: 0.75rem 1.5rem; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
|
||||
.btn:hover { background: #ff6b7a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Go Fish</h1>
|
||||
<p>Click below to join the waiting room and find an opponent</p>
|
||||
<button class="btn" onclick="joinWaitingRoom()">Join Waiting Room</button>
|
||||
|
||||
<script>
|
||||
function joinWaitingRoom() {
|
||||
fetch('/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Player' })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
window.location.href = '/play/' + data.playerId;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
154
web/templates/play.html
Normal file
154
web/templates/play.html
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Go Fish - Game</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
h1 { text-align: center; margin-bottom: 1rem; color: #f0f0f0; }
|
||||
.game-status { text-align: center; margin-bottom: 2rem; font-size: 1.2rem; }
|
||||
.deck { text-align: center; margin: 2rem 0; }
|
||||
.card { width: 70px; height: 100px; background: #1a1a2e; border: 2px solid #444; border-radius: 4px; margin: 2px; display: inline-block; position: relative; min-height: 100px; }
|
||||
.card.flip { transition: transform 0.3s; transform: rotateY(180deg); }
|
||||
.card.face { background: #e94560; color: white; }
|
||||
.hand { min-height: 120px; }
|
||||
.book-area { min-height: 80px; margin-top: 1rem; }
|
||||
.book { width: 60px; height: 20px; background: #ff6b7a; color: white; border-radius: 3px; margin: 2px; display: inline-block; }
|
||||
.controls { text-align: center; margin-top: 2rem; }
|
||||
.controls button { margin: 0 0.5rem; }
|
||||
.instruction { text-align: center; margin: 1rem 0; color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Go Fish</h1>
|
||||
|
||||
<div class="game-status" id="gameStatus" data-currentplayerid="{{ .Player.ID }}" data-opponentid="{{ .Game.ID }}" data-isbot="{{ .IsBot }}">
|
||||
{{ .Player.Name }}'s turn - Ask for a rank
|
||||
{{ if .IsBot }}(playing against a bot){{ end }}
|
||||
</div>
|
||||
|
||||
<div class="deck" id="deck">
|
||||
<!-- Deck cards -->
|
||||
</div>
|
||||
|
||||
<div class="hand" id="hand">
|
||||
<!-- Player's hand cards -->
|
||||
</div>
|
||||
|
||||
<div class="book-area" id="bookArea">
|
||||
<!-- Booked sets -->
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="askRank('A')">Ask for A</button>
|
||||
<button onclick="askRank('2')">Ask for 2</button>
|
||||
<button onclick="askRank('3')">Ask for 3</button>
|
||||
<button onclick="askRank('4')">Ask for 4</button>
|
||||
<button onclick="askRank('5')">Ask for 5</button>
|
||||
<button onclick="askRank('6')">Ask for 6</button>
|
||||
<button onclick="askRank('7')">Ask for 7</button>
|
||||
<button onclick="askRank('8')">Ask for 8</button>
|
||||
<button onclick="askRank('9')">Ask for 9</button>
|
||||
<button onclick="askRank('10')">Ask for 10</button>
|
||||
<button onclick="askRank('J')">Ask for J</button>
|
||||
<button onclick="askRank('Q')">Ask for Q</button>
|
||||
<button onclick="askRank('K')">Ask for K</button>
|
||||
</div>
|
||||
|
||||
<div class="instruction" id="instruction">
|
||||
Click a card in your hand to see its rank, then ask opponent for rank
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPlayerId = null;
|
||||
let opponentId = null;
|
||||
let myHand = [];
|
||||
let myBooks = [];
|
||||
let opponentBooks = [];
|
||||
let isBot = false;
|
||||
|
||||
// Load game state from rendered template
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const statusDiv = document.getElementById('gameStatus');
|
||||
if (statusDiv) {
|
||||
currentPlayerId = statusDiv.dataset.currentplayerid || null;
|
||||
opponentId = statusDiv.dataset.opponentid || null;
|
||||
isBot = statusDiv.dataset.isbot === 'true' || false;
|
||||
}
|
||||
});
|
||||
|
||||
function askRank(rank) {
|
||||
if (!currentPlayerId || !opponentId) return;
|
||||
|
||||
fetch('/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playerId: currentPlayerId, opponentId: opponentId, rank: rank })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
updateGameState(data);
|
||||
});
|
||||
}
|
||||
|
||||
function updateGameState(data) {
|
||||
myHand = data.myHand || [];
|
||||
myBooks = data.myBooks || [];
|
||||
opponentBooks = data.opponentBooks || [];
|
||||
|
||||
renderHand();
|
||||
renderBooks();
|
||||
renderStatus(data.status);
|
||||
|
||||
// Check if game over
|
||||
if (data.gameOver) {
|
||||
alert(data.message || 'Game over!');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHand() {
|
||||
const handDiv = document.getElementById('hand');
|
||||
handDiv.innerHTML = '';
|
||||
myHand.forEach((card, i) => {
|
||||
const cardEl = document.createElement('div');
|
||||
cardEl.className = 'card' + (i === 0 ? ' flip' : '');
|
||||
cardEl.textContent = card;
|
||||
cardEl.dataset.rank = card;
|
||||
cardEl.onclick = function() { askRank(card); };
|
||||
handDiv.appendChild(cardEl);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBooks() {
|
||||
const bookArea = document.getElementById('bookArea');
|
||||
bookArea.innerHTML = '';
|
||||
|
||||
// My books
|
||||
myBooks.forEach(rank => {
|
||||
const book = document.createElement('div');
|
||||
book.className = 'book';
|
||||
book.textContent = rank + ' set';
|
||||
bookArea.appendChild(book);
|
||||
});
|
||||
|
||||
// Opponent books
|
||||
opponentBooks.forEach(rank => {
|
||||
const book = document.createElement('div');
|
||||
book.className = 'book';
|
||||
book.textContent = rank + ' set (opp)';
|
||||
bookArea.appendChild(book);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
const statusDiv = document.getElementById('gameStatus');
|
||||
statusDiv.textContent = status || 'Your turn';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
60
web/templates/waiting.html
Normal file
60
web/templates/waiting.html
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Go Fish - Waiting Room</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
h1 { text-align: center; margin-bottom: 2rem; color: #f0f0f0; }
|
||||
.player-list { background: #2a2a3e; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
|
||||
.player-item { padding: 0.5rem; border-bottom: 1px solid #444; cursor: pointer; margin-bottom: 0.5rem; }
|
||||
.player-item:hover { background: #3a3a52; }
|
||||
.player-item.eligible { background: #2ecc71; color: #fff; }
|
||||
.player-item.unavailable { background: #e74c3c; color: #fff; }
|
||||
.btn { padding: 0.75rem 1.5rem; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; width: 100%; margin-top: 1rem; }
|
||||
.btn-secondary { background: #444; width: 100%; margin-top: 1rem; }
|
||||
.status { text-align: center; margin: 1rem 0; font-size: 0.9rem; color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Go Fish - Waiting Room</h1>
|
||||
<div class="status" id="status">Looking for opponent...</div>
|
||||
|
||||
<div class="player-list" id="playerList">
|
||||
{{ range .Players }}
|
||||
<div class="player-item eligible" data-target-id="{{ .ID }}">
|
||||
{{ .Name }}
|
||||
<span style="font-size: 0.8rem; margin-left: 1rem;">(available)</span>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="player-item unavailable">No available players</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="window.location='/join'">Refresh List</button>
|
||||
<div class="status">Players online: <span id="onlineCount">0</span></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Poll for available players
|
||||
setInterval(function() {
|
||||
fetch('/waiting')
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('playerList').innerHTML = html;
|
||||
// Re-attach click handlers
|
||||
document.querySelectorAll('.player-item.eligible').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const targetId = this.dataset.targetId;
|
||||
window.location.href = '/play/' + targetId;
|
||||
});
|
||||
});
|
||||
});
|
||||
}, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue