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