games: a table of bots you have to beat to the last card
UNO, played for chips. You stake once, sit down against one to three bots, and going out first pays the table: 2.2x heads up, 3.6x against a full house. Anybody else going out first takes the stake. The table size is the tier, because it is the only dial UNO has. The bots move inside ApplyMove. A game with opponents is normally where you reach for a socket, and the plan says solo UNO must not — so one request plays your move and every bot turn behind it, and hands back the whole lap as a script the felt plays in order. The RNG is in the state rather than an argument to it: the bots choose and a spent deck reshuffles, so the engine needs randomness mid-game, and there is no generator alive across requests to pass in. The seed rides in the state and each step derives its own. The game still replays exactly as it fell. The zero value of Color is Wild, and that is the whole point of it: a wild played with the colour field missing from the JSON must be refused, not quietly played as a red one. It was red for an hour. The browser never sees a bot's card — not the deck, not a hand, not the face of a card a bot drew, which is most of the deck. Seats cross the wire as a name and a count. The multiples are measured, not guessed: playing the first legal card you hold wins 43/32/27% of the time against these bots, so the tiers price that to lose about 8% a game and leave good play worth roughly the house's edge. PeteFX.flyNode is the throw with the chip taken out of it, so a card can be thrown across the felt the same way. fly() is now that with a chip in it. Not yet driven in a browser, which in this room means not yet finished. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/games/klondike"
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,6 @@ type gameTeaser struct {
|
||||
|
||||
var comingSoon = []gameTeaser{
|
||||
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
||||
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
|
||||
}
|
||||
|
||||
// betDenominations are the chips you build a bet out of.
|
||||
@@ -77,6 +77,7 @@ type gamesPage struct {
|
||||
FullDeck int
|
||||
Quizzes []trivia.Tier // trivia's three difficulties
|
||||
Rungs int // how long the trivia ladder is
|
||||
Tables []uno.Tier // uno's three tables, and how many bots sit at each
|
||||
}
|
||||
|
||||
// casinoRoutes hangs every table off the mux.
|
||||
@@ -91,6 +92,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /games/hangman", s.handleHangman)
|
||||
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
|
||||
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
|
||||
mux.HandleFunc("GET /games/uno", s.handleUno)
|
||||
|
||||
mux.HandleFunc("GET /api/games/table", s.handleTable)
|
||||
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
|
||||
@@ -107,6 +109,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
|
||||
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
|
||||
|
||||
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
|
||||
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
|
||||
}
|
||||
|
||||
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
||||
@@ -139,6 +144,7 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
|
||||
FullDeck: klondike.FullDeck,
|
||||
Quizzes: trivia.Tiers,
|
||||
Rungs: trivia.Rungs,
|
||||
Tables: uno.Tiers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,3 +182,10 @@ func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.render(w, "trivia", s.gamesPage(r))
|
||||
}
|
||||
|
||||
func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requirePlayer(w, r) {
|
||||
return
|
||||
}
|
||||
s.render(w, "uno", s.gamesPage(r))
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"pete/internal/games/hangman"
|
||||
"pete/internal/games/klondike"
|
||||
"pete/internal/games/trivia"
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
@@ -193,6 +194,9 @@ type tableView struct {
|
||||
Trivia *triviaView `json:"trivia,omitempty"`
|
||||
TrivEvents []trivia.Event `json:"triv_events,omitempty"`
|
||||
|
||||
Uno *unoView `json:"uno,omitempty"`
|
||||
UnoEvents []unoEventView `json:"uno_events,omitempty"`
|
||||
|
||||
Rake float64 `json:"rake_pct"`
|
||||
}
|
||||
|
||||
@@ -253,6 +257,13 @@ func (s *Server) table(user string) (tableView, error) {
|
||||
// twenty seconds finds the countdown exactly where they left it.
|
||||
tv := viewTrivia(g, time.Now())
|
||||
v.Trivia = &tv
|
||||
case gameUno:
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
uv := viewUno(g)
|
||||
v.Uno = &uv
|
||||
default:
|
||||
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
||||
}
|
||||
@@ -509,6 +520,7 @@ const (
|
||||
gameHangman = "hangman"
|
||||
gameSolitaire = "solitaire"
|
||||
gameTrivia = "trivia"
|
||||
gameUno = "uno"
|
||||
)
|
||||
|
||||
// finished is what commit needs to know about a game it's writing back: enough
|
||||
|
||||
284
internal/web/games_uno.go
Normal file
284
internal/web/games_uno.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// UNO, played for chips against bots.
|
||||
//
|
||||
// The seam is the same as every other table, but there is one thing here that no
|
||||
// other table has: opponents. The obvious way to give a browser opponents is a
|
||||
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
|
||||
// goes up, and what comes back is the player's move *plus every bot turn it
|
||||
// handed off to*, as a script of events. One request, one round of the table.
|
||||
//
|
||||
// What the browser is allowed to see: its own hand, the card in play, the colour
|
||||
// in play, and how many cards each bot is holding. Not the deck, not a bot's
|
||||
// hand, not even the face of a card a bot drew. That last one is most of the
|
||||
// deck, and it is the thing that would turn a game of counting cards into a game
|
||||
// of reading the network tab.
|
||||
|
||||
// unoCardView is one card, ready to draw. The browser gets the colour and the
|
||||
// face as words, not as the engine's integers — the same bargain the blackjack
|
||||
// table makes, and for the same reason: the browser draws faces, not logic.
|
||||
type unoCardView struct {
|
||||
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
|
||||
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
|
||||
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
|
||||
}
|
||||
|
||||
func viewUnoCard(c uno.Card) unoCardView {
|
||||
return unoCardView{
|
||||
Color: c.Color.String(),
|
||||
Value: c.Value.String(),
|
||||
Wild: c.IsWild(),
|
||||
}
|
||||
}
|
||||
|
||||
// unoSeatView is one seat at the table: a name, and a number of cards. A bot's
|
||||
// cards are a *count*. There is no field here for what they are.
|
||||
type unoSeatView struct {
|
||||
Name string `json:"name"`
|
||||
Cards int `json:"cards"`
|
||||
You bool `json:"you"`
|
||||
Uno bool `json:"uno"` // down to one card
|
||||
}
|
||||
|
||||
// unoView is a game as its player may see it.
|
||||
type unoView struct {
|
||||
Tier uno.Tier `json:"tier"`
|
||||
Seats []unoSeatView `json:"seats"`
|
||||
|
||||
Hand []unoCardView `json:"hand"` // yours, and only yours
|
||||
Playable []int `json:"playable"` // which of them can legally go down
|
||||
Top unoCardView `json:"top"` // the card in play
|
||||
Color string `json:"color"` // the colour in play, which a wild renames
|
||||
Deck int `json:"deck"` // cards left to draw
|
||||
|
||||
Turn int `json:"turn"`
|
||||
Dir int `json:"dir"`
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Pays int64 `json:"pays"` // what going out right now would actually pay
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Winner int `json:"winner"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
}
|
||||
|
||||
func viewUno(g uno.State) unoView {
|
||||
v := unoView{
|
||||
Tier: g.Tier,
|
||||
Top: viewUnoCard(g.Top()),
|
||||
Color: g.Color.String(),
|
||||
Deck: g.Left(),
|
||||
Turn: g.Turn,
|
||||
Dir: g.Dir,
|
||||
Bet: g.Bet,
|
||||
Pays: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Winner: -1,
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
}
|
||||
for i, n := range g.Counts() {
|
||||
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
|
||||
if i == uno.You {
|
||||
seat.Name = "You"
|
||||
} else if i-1 < len(g.Bots) {
|
||||
seat.Name = g.Bots[i-1]
|
||||
}
|
||||
v.Seats = append(v.Seats, seat)
|
||||
if n == 0 {
|
||||
v.Winner = i
|
||||
}
|
||||
}
|
||||
for _, c := range g.Hands[uno.You] {
|
||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||
}
|
||||
v.Playable = g.Playable()
|
||||
if v.Playable == nil {
|
||||
v.Playable = []int{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// unoEventView is one beat of the script the table plays back: a card going
|
||||
// down, a seat eating a +4, the turn coming round. The engine's own events carry
|
||||
// engine types, so they are re-rendered here rather than shipped raw — and this
|
||||
// is also the wall where a bot's drawn card is dropped on the floor.
|
||||
type unoEventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Seat int `json:"seat"`
|
||||
Card *unoCardView `json:"card,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Left int `json:"left,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func viewUnoEvents(evs []uno.Event) []unoEventView {
|
||||
out := make([]unoEventView, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, Text: e.Text}
|
||||
if e.Color != uno.Wild {
|
||||
v.Color = e.Color.String()
|
||||
}
|
||||
if e.Card != nil {
|
||||
// The engine only ever attaches a card to an event the seat is entitled
|
||||
// to see it in — a card played face up, or one *you* drew. This check is
|
||||
// the belt to that pair of braces: a bot's draw never carries a face,
|
||||
// whatever the engine thinks it's doing.
|
||||
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
|
||||
if e.Seat == uno.You {
|
||||
c := viewUnoCard(*e.Card)
|
||||
v.Card = &c
|
||||
}
|
||||
} else {
|
||||
c := viewUnoCard(*e.Card)
|
||||
v.Card = &c
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleUnoStart takes the bet and deals. Same order as every other table: the
|
||||
// chips are staked first, in the same statement that checks they exist, so two
|
||||
// deals fired at once cannot bet the same chip.
|
||||
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
return
|
||||
}
|
||||
tier, err := uno.TierBySlug(req.Tier)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: uno stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
if err != nil {
|
||||
// The game never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: uno deal", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistUno(w, user, g, evs, seed1, seed2, true)
|
||||
}
|
||||
|
||||
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
|
||||
// The bots' turns come back with it.
|
||||
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var move uno.Move
|
||||
if err := decodeJSON(r, &move); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := storage.LoadLiveHand(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: uno load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameUno {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||
return
|
||||
}
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable uno game", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next, evs, err := uno.ApplyMove(g, move)
|
||||
if err != nil {
|
||||
// The refusals a player can actually cause, said in words rather than as
|
||||
// "that move isn't legal here" — which, in a game with this many rules, is
|
||||
// the table refusing to explain itself.
|
||||
msg := "that move isn't legal here"
|
||||
switch {
|
||||
case errors.Is(err, uno.ErrCantPlay):
|
||||
msg = "that card doesn't go on this one"
|
||||
case errors.Is(err, uno.ErrNeedColor):
|
||||
msg = "pick a colour for the wild"
|
||||
case errors.Is(err, uno.ErrMustPlayNow):
|
||||
msg = "play the card you drew, or pass"
|
||||
case errors.Is(err, uno.ErrCantPass):
|
||||
msg = "draw first, then you can pass"
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
}
|
||||
|
||||
// persistUno writes the game back and answers the browser.
|
||||
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal uno", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
done := g.Phase == uno.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameUno, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished game is gone from storage, so the table has none to show — but the
|
||||
// browser still needs the final board to land the verdict on.
|
||||
if done {
|
||||
uv := viewUno(g)
|
||||
v.Uno = &uv
|
||||
}
|
||||
v.UnoEvents = viewUnoEvents(evs)
|
||||
writeJSON(w, v)
|
||||
}
|
||||
147
internal/web/games_uno_test.go
Normal file
147
internal/web/games_uno_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The one thing this table cannot get wrong: the stake leaves the stack, and no
|
||||
// card a player is not entitled to see leaves the server. At UNO that is not one
|
||||
// hole card — it is the deck and every bot's hand, which is most of the game.
|
||||
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 100, "tier": "full"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("start = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
}
|
||||
if v.Game != gameUno || v.Uno == nil {
|
||||
t.Fatalf("start returned no uno game: game=%q", v.Game)
|
||||
}
|
||||
|
||||
g := v.Uno
|
||||
if len(g.Hand) != uno.HandSize {
|
||||
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
|
||||
}
|
||||
if len(g.Seats) != 4 {
|
||||
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
|
||||
}
|
||||
// A bot is a name and a number. There is no field here that could carry a
|
||||
// card, which is the point — this is a compile-time guarantee, and the test
|
||||
// exists to make deleting it loud.
|
||||
for i, seat := range g.Seats {
|
||||
if i == 0 {
|
||||
if !seat.You || seat.Name != "You" {
|
||||
t.Errorf("seat 0 should be you: %+v", seat)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seat.You || seat.Name == "" {
|
||||
t.Errorf("seat %d should be a named bot: %+v", i, seat)
|
||||
}
|
||||
if seat.Cards != uno.HandSize {
|
||||
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
|
||||
}
|
||||
}
|
||||
if g.Top.Value == "" {
|
||||
t.Error("no card in play")
|
||||
}
|
||||
if g.Turn != 0 {
|
||||
t.Errorf("you play first, turn is %d", g.Turn)
|
||||
}
|
||||
}
|
||||
|
||||
// A move plays your turn and every bot turn behind it, and the script that comes
|
||||
// back never carries a bot's drawn card.
|
||||
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 100, "tier": "table"}))
|
||||
if v.Uno == nil {
|
||||
t.Fatal("no game")
|
||||
}
|
||||
|
||||
// Draw, which is legal from any hand: the turn passes to the bots and comes
|
||||
// back, unless the card drawn happens to be playable.
|
||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "draw"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("draw = %d, want 200", code)
|
||||
}
|
||||
if v.Uno == nil {
|
||||
t.Fatal("the move returned no game")
|
||||
}
|
||||
if len(v.UnoEvents) == 0 {
|
||||
t.Fatal("a move that happened sent back no events")
|
||||
}
|
||||
if v.Uno.Phase != "drawn" && v.Uno.Turn != 0 {
|
||||
t.Errorf("the bots should have played back round to you, turn is %d", v.Uno.Turn)
|
||||
}
|
||||
for _, e := range v.UnoEvents {
|
||||
if (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != 0 && e.Card != nil {
|
||||
t.Fatalf("a bot's drawn card crossed the wire: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// You cannot play a wild without naming a colour — and the zero value of the
|
||||
// colour field is not red, so a move that simply omits it is refused rather than
|
||||
// quietly played as one.
|
||||
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
|
||||
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
|
||||
// doesn't take long, and this is the only way to get one without rigging the
|
||||
// deck through a door the server doesn't have.
|
||||
var wild = -1
|
||||
for try := 0; try < 40 && wild < 0; try++ {
|
||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 10, "tier": "duel"}))
|
||||
if v.Uno == nil {
|
||||
t.Fatal("no game")
|
||||
}
|
||||
for i, c := range v.Uno.Hand {
|
||||
if c.Wild {
|
||||
wild = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if wild < 0 {
|
||||
// Abandon this deal and take another. The live row is keyed on the
|
||||
// player, so it has to go before the next start can be seated.
|
||||
if err := storage.ClearLiveHand(testPlayer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if wild < 0 {
|
||||
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
|
||||
}
|
||||
|
||||
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "play", "index": wild}))
|
||||
if code != 400 {
|
||||
t.Fatalf("a wild with no colour = %d, want 400", code)
|
||||
}
|
||||
|
||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
|
||||
if code != 200 {
|
||||
t.Fatalf("a wild played as green = %d, want 200", code)
|
||||
}
|
||||
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
|
||||
// The bots have played since, so the colour may have moved on — what must
|
||||
// not happen is the wild going down as anything we didn't ask for.
|
||||
t.Logf("colour in play after the bots: %s", v.Uno.Color)
|
||||
}
|
||||
}
|
||||
@@ -1421,6 +1421,346 @@ html[data-phase="night"] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---- uno ------------------------------------------------------------------
|
||||
The card is the whole look: a coloured rectangle with a white oval laid
|
||||
across it at an angle. Drop the oval and it reads as a button, so the oval
|
||||
is not decoration — it is what makes the thing a card.
|
||||
|
||||
The bots are drawn from a *count*. That is all the server sends, and the fan
|
||||
of backs up there is generated from a number rather than from cards, because
|
||||
there are no cards to draw. */
|
||||
|
||||
/* The felt takes a tint of the colour in play. After a wild, the card on the
|
||||
pile is not the colour you have to follow — this is the table saying so. */
|
||||
.pete-uno[data-c="red"] { --play: #d64545; }
|
||||
.pete-uno[data-c="blue"] { --play: #3d7fd6; }
|
||||
.pete-uno[data-c="yellow"] { --play: #e0b02c; }
|
||||
.pete-uno[data-c="green"] { --play: #46a86b; }
|
||||
.pete-uno::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(80% 55% at 50% 45%, var(--play, transparent), transparent 70%);
|
||||
opacity: 0.16;
|
||||
transition: opacity 0.5s ease, background 0.5s ease;
|
||||
}
|
||||
|
||||
.pete-uno-card {
|
||||
position: relative;
|
||||
height: var(--uno-h, 6.4rem);
|
||||
width: var(--uno-w, 4.3rem);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.28rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,0.22), 0 6px 14px rgba(0,0,0,0.18);
|
||||
flex: none;
|
||||
}
|
||||
.pete-uno-face {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 0.38rem;
|
||||
overflow: hidden;
|
||||
background: var(--uno, #2b2118);
|
||||
color: #fff;
|
||||
}
|
||||
.pete-uno-card[data-c="red"] { --uno: #d64545; }
|
||||
.pete-uno-card[data-c="blue"] { --uno: #3d7fd6; }
|
||||
.pete-uno-card[data-c="yellow"] { --uno: #e0b02c; }
|
||||
.pete-uno-card[data-c="green"] { --uno: #46a86b; }
|
||||
.pete-uno-card[data-c="wild"] { --uno: #2b2118; }
|
||||
|
||||
/* A wild that has been played wears the colour it was called as — the pile has
|
||||
to show what you must follow, not what was printed. */
|
||||
.pete-uno-card[data-named="red"] { box-shadow: 0 0 0 3px #d64545, 0 3px 0 rgba(0,0,0,0.22); }
|
||||
.pete-uno-card[data-named="blue"] { box-shadow: 0 0 0 3px #3d7fd6, 0 3px 0 rgba(0,0,0,0.22); }
|
||||
.pete-uno-card[data-named="yellow"] { box-shadow: 0 0 0 3px #e0b02c, 0 3px 0 rgba(0,0,0,0.22); }
|
||||
.pete-uno-card[data-named="green"] { box-shadow: 0 0 0 3px #46a86b, 0 3px 0 rgba(0,0,0,0.22); }
|
||||
|
||||
/* The oval. Tilted, because it is tilted on the card in your hand. */
|
||||
.pete-uno-oval {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 78%;
|
||||
width: 62%;
|
||||
border-radius: 50%;
|
||||
transform: rotate(-24deg);
|
||||
background: #fff;
|
||||
color: var(--uno, #2b2118);
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 0 rgba(0,0,0,0.10);
|
||||
}
|
||||
.pete-uno-card[data-c="wild"] .pete-uno-oval { color: #2b2118; font-size: 1.15rem; }
|
||||
|
||||
.pete-uno-corner {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.35);
|
||||
}
|
||||
.pete-uno-corner[data-at="tl"] { top: 0.22rem; left: 0.28rem; }
|
||||
.pete-uno-corner[data-at="br"] { bottom: 0.22rem; right: 0.28rem; transform: rotate(180deg); }
|
||||
|
||||
/* The four quadrants of a wild, under the oval. */
|
||||
.pete-uno-wheel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
conic-gradient(#d64545 0 25%, #e0b02c 0 50%, #46a86b 0 75%, #3d7fd6 0);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* The back. Every card you are not entitled to see is one of these. */
|
||||
.pete-uno-card-back { padding: 0.28rem; }
|
||||
.pete-uno-back {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 0.38rem;
|
||||
background:
|
||||
radial-gradient(120% 80% at 50% 0%, rgba(255,255,255,0.16), transparent 60%),
|
||||
#2b2118;
|
||||
}
|
||||
.pete-uno-back::after {
|
||||
content: "UNO";
|
||||
display: block;
|
||||
transform: rotate(-24deg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: #e0b02c;
|
||||
color: #2b2118;
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Your hand. It fans, it wraps, and a card you can play stands up out of it —
|
||||
being shown what goes is the game teaching you, and the server still decides. */
|
||||
.pete-uno-hand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 6.8rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
.pete-uno-hand .pete-uno-card {
|
||||
animation: pete-uno-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
animation-delay: calc(var(--i, 0) * 45ms);
|
||||
transition: transform 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
.pete-uno-hand .pete-uno-card[data-on="1"] {
|
||||
cursor: pointer;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
.pete-uno-hand .pete-uno-card[data-on="1"]:hover { transform: translateY(-1.1rem) scale(1.03); }
|
||||
.pete-uno-hand .pete-uno-card[data-on="0"] { filter: brightness(0.62) saturate(0.7); }
|
||||
@keyframes pete-uno-in {
|
||||
from { transform: translateY(2rem) rotate(6deg); opacity: 0; }
|
||||
to { transform: translateY(-0.5rem); opacity: 1; }
|
||||
}
|
||||
|
||||
/* A seat: a fan of backs, a name, a count. The fan overlaps, so eight cards
|
||||
read as a hand rather than a row. */
|
||||
.pete-uno-seat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
position: relative;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 1rem;
|
||||
transition: background 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.pete-uno-seat[data-turn="1"] {
|
||||
background: rgba(255,255,255,0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.pete-uno-seat[data-uno="1"] .pete-uno-count { color: #ffd76e; }
|
||||
|
||||
.pete-uno-fan {
|
||||
display: flex;
|
||||
--uno-h: 3.2rem;
|
||||
--uno-w: 2.15rem;
|
||||
}
|
||||
.pete-uno-fan .pete-uno-card {
|
||||
margin-left: -1.35rem;
|
||||
transform: rotate(calc((var(--i, 0) - (var(--n, 1) - 1) / 2) * 4deg));
|
||||
transform-origin: bottom center;
|
||||
box-shadow: 0 2px 0 rgba(0,0,0,0.22);
|
||||
}
|
||||
.pete-uno-fan .pete-uno-card:first-child { margin-left: 0; }
|
||||
.pete-uno-fan .pete-uno-back::after { font-size: 0.4rem; padding: 0.06rem 0.22rem; }
|
||||
|
||||
.pete-uno-name {
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
.pete-uno-count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255,255,255,0.55);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The deck you draw from, and the pile you play onto. */
|
||||
.pete-uno-deck {
|
||||
position: relative;
|
||||
--uno-h: 6.4rem;
|
||||
--uno-w: 4.3rem;
|
||||
height: var(--uno-h);
|
||||
width: var(--uno-w);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.28rem;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 3px 0 rgba(0,0,0,0.22),
|
||||
0.28rem 0.28rem 0 -0.06rem rgba(255,255,255,0.35),
|
||||
0.5rem 0.5rem 0 -0.12rem rgba(255,255,255,0.18);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pete-uno-deck:not(:disabled):hover { transform: translateY(-3px); }
|
||||
.pete-uno-deck:disabled { opacity: 0.75; }
|
||||
.pete-uno-deck-count {
|
||||
position: absolute;
|
||||
bottom: -0.55rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pete-uno-shuffle { animation: pete-uno-shuffle 0.42s ease; }
|
||||
@keyframes pete-uno-shuffle {
|
||||
0%, 100% { transform: none; }
|
||||
30% { transform: translateY(-4px) rotate(-3deg); }
|
||||
65% { transform: translateY(2px) rotate(2deg); }
|
||||
}
|
||||
|
||||
.pete-uno-discard {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 6.4rem;
|
||||
width: 4.3rem;
|
||||
border-radius: 0.55rem;
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.14);
|
||||
}
|
||||
.pete-uno-land { animation: pete-uno-land 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
@keyframes pete-uno-land {
|
||||
from { transform: scale(1.14) rotate(-4deg); }
|
||||
to { transform: none; }
|
||||
}
|
||||
|
||||
.pete-uno-colour {
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
.pete-uno-colour[data-c="red"] { background: #d64545; }
|
||||
.pete-uno-colour[data-c="blue"] { background: #3d7fd6; }
|
||||
.pete-uno-colour[data-c="yellow"] { background: #e0b02c; color: #2b2118; }
|
||||
.pete-uno-colour[data-c="green"] { background: #46a86b; }
|
||||
|
||||
/* A word thrown at a seat: SKIPPED, UNO!, +4. */
|
||||
.pete-uno-badge {
|
||||
position: absolute;
|
||||
top: -0.4rem;
|
||||
left: 50%;
|
||||
z-index: 5;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #2b2118;
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,0.2);
|
||||
animation: pete-uno-badge 0.9s ease forwards;
|
||||
}
|
||||
.pete-uno-badge[data-tone="bad"] { background: #cc3d4a; color: #fff; }
|
||||
.pete-uno-badge[data-tone="uno"] { background: #e0b02c; }
|
||||
@keyframes pete-uno-badge {
|
||||
0% { transform: translate(-50%, 0.4rem) scale(0.7); opacity: 0; }
|
||||
25% { transform: translate(-50%, -0.5rem) scale(1.06); opacity: 1; }
|
||||
75% { transform: translate(-50%, -0.9rem) scale(1); opacity: 1; }
|
||||
100% { transform: translate(-50%, -1.6rem) scale(0.95); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Naming a colour. It covers the felt because until it's answered there is no
|
||||
legal move on the table underneath it. */
|
||||
.pete-uno-wild {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(10, 20, 15, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.pete-uno-wild-box {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 1.25rem;
|
||||
background: rgba(20, 40, 30, 0.92);
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.35);
|
||||
text-align: center;
|
||||
}
|
||||
.pete-uno-swatch {
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 0.75rem;
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--sw, #666);
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,0.3);
|
||||
transition: transform 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
.pete-uno-swatch:hover { transform: translateY(-2px); filter: brightness(1.08); }
|
||||
.pete-uno-swatch:active { transform: translateY(1px); }
|
||||
.pete-uno-swatch[data-c="red"] { --sw: #d64545; }
|
||||
.pete-uno-swatch[data-c="blue"] { --sw: #3d7fd6; }
|
||||
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
|
||||
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
|
||||
|
||||
/* A phone. The cards get smaller so a hand of ten still fits without the felt
|
||||
scrolling sideways, which is the thing to check at 390px with a game on it. */
|
||||
@media (max-width: 639px) {
|
||||
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
|
||||
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
|
||||
.pete-uno-discard { height: 5.2rem; width: 3.5rem; }
|
||||
.pete-uno-hand .pete-uno-card { padding: 0.22rem; }
|
||||
.pete-uno-oval { font-size: 1.15rem; }
|
||||
.pete-uno-seat { padding: 0.35rem 0.45rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pete-card,
|
||||
.pete-card::after,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -73,19 +73,22 @@
|
||||
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
|
||||
}
|
||||
|
||||
// fly moves one chip from one place to another and resolves when it lands.
|
||||
// flyNode moves *anything* from one place to another and resolves when it
|
||||
// lands: a chip, a playing card, a card face down.
|
||||
//
|
||||
// The arc is the point. A straight line between two rects reads as a UI
|
||||
// transition; a chip that rises, travels and drops reads as a throw. WAAPI does
|
||||
// this in one keyframe list because it can interpolate a midpoint — a CSS
|
||||
// transition; something that rises, travels and drops reads as a throw. WAAPI
|
||||
// does this in one keyframe list because it can interpolate a midpoint — a CSS
|
||||
// transition can't, which is why this isn't a class toggle.
|
||||
function fly(from, to, opts) {
|
||||
//
|
||||
// The node is yours: build it, hand it over, and it is gone when the promise
|
||||
// resolves. Nothing in here knows or cares what it was.
|
||||
function flyNode(node, from, to, opts) {
|
||||
opts = opts || {};
|
||||
var a = centre(from);
|
||||
var b = centre(to);
|
||||
var el = disc(opts.denom || 25);
|
||||
el.className += " pete-fly";
|
||||
stage().appendChild(el);
|
||||
node.classList.add("pete-fly");
|
||||
stage().appendChild(node);
|
||||
|
||||
var dur = opts.duration || 420;
|
||||
if (reduced) dur = 1;
|
||||
@@ -96,12 +99,14 @@
|
||||
var midX = (a.x + b.x) / 2;
|
||||
var midY = (a.y + b.y) / 2 - lift;
|
||||
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
|
||||
var s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
|
||||
var s1 = opts.toScale == null ? 1 : opts.toScale;
|
||||
|
||||
var anim = el.animate(
|
||||
var anim = node.animate(
|
||||
[
|
||||
{ transform: t(a.x, a.y, 0.85, 0), opacity: 1, offset: 0 },
|
||||
{ transform: t(midX, midY, 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
|
||||
{ transform: t(b.x, b.y, 1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
|
||||
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
|
||||
{ transform: t(midX, midY, (s0 + s1) / 2 * 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
|
||||
{ transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
|
||||
],
|
||||
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
|
||||
);
|
||||
@@ -109,10 +114,17 @@
|
||||
return anim.finished
|
||||
.catch(function () {})
|
||||
.then(function () {
|
||||
el.remove();
|
||||
node.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// fly throws one chip. It is flyNode with a chip in it, which is what it always
|
||||
// was — UNO wanted the same throw with a card in it, so the throw moved out.
|
||||
function fly(from, to, opts) {
|
||||
opts = opts || {};
|
||||
return flyNode(disc(opts.denom || 25), from, to, opts);
|
||||
}
|
||||
|
||||
function t(x, y, scale, rot) {
|
||||
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
|
||||
}
|
||||
@@ -313,6 +325,7 @@
|
||||
disc: disc,
|
||||
jitter: jitter,
|
||||
fly: fly,
|
||||
flyNode: flyNode,
|
||||
flyMany: flyMany,
|
||||
spot: spot,
|
||||
burst: burst,
|
||||
|
||||
686
internal/web/static/js/uno.js
Normal file
686
internal/web/static/js/uno.js
Normal file
@@ -0,0 +1,686 @@
|
||||
// The UNO table.
|
||||
//
|
||||
// Same bargain as every other table in the room: the browser holds no game. It
|
||||
// sends one move, and what comes back is that move *and every bot turn it handed
|
||||
// off to*, as a script of events. So a round trip here is a whole lap of the
|
||||
// table, and this file's main job is to play that lap back slowly enough that
|
||||
// you can see what happened to you.
|
||||
//
|
||||
// Two rules carried over from the tables that came before, both load-bearing:
|
||||
//
|
||||
// The number under the pile is a readout of the pile (PeteFX.spot owns that), and
|
||||
// the chip bar does not move until the chips that justify it have landed. So a
|
||||
// settling game holds the money back until the payout has physically swept home
|
||||
// — a counter that pays you before the last card goes down has told you the
|
||||
// ending.
|
||||
//
|
||||
// And the browser never learns a bot's hand. It gets a *count*. Every fan of
|
||||
// backs you see up there is drawn from a number, because a number is all that
|
||||
// crossed the wire.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-uno]");
|
||||
if (!root) return;
|
||||
|
||||
var FX = window.PeteFX;
|
||||
|
||||
var seatsEl = root.querySelector("[data-seats]");
|
||||
var handEl = root.querySelector("[data-hand]");
|
||||
var deckEl = root.querySelector("[data-deck]");
|
||||
var deckCntEl = root.querySelector("[data-deck-count]");
|
||||
var discardEl = root.querySelector("[data-discard]");
|
||||
var colourEl = root.querySelector("[data-colour]");
|
||||
var turnEl = root.querySelector("[data-turn-label]");
|
||||
var countEl = root.querySelector("[data-count-label]");
|
||||
var verdictEl = root.querySelector("[data-verdict]");
|
||||
var paysEl = root.querySelector("[data-pays]");
|
||||
var meterEl = root.querySelector("[data-meter]");
|
||||
var tableEl = root.querySelector("[data-table-name]");
|
||||
|
||||
var wildEl = root.querySelector("[data-wild]");
|
||||
var betting = root.querySelector("[data-betting]");
|
||||
var playing = root.querySelector("[data-playing]");
|
||||
var drawBtn = root.querySelector("[data-draw]");
|
||||
var passBtn = root.querySelector("[data-pass]");
|
||||
var betAmount = root.querySelector("[data-bet-amount]");
|
||||
var startBtn = root.querySelector("[data-start]");
|
||||
var msgEl = root.querySelector("[data-table-msg]");
|
||||
var gameMsgEl = root.querySelector("[data-game-msg]");
|
||||
|
||||
var purseEl = document.querySelector("[data-chips]");
|
||||
var spotEl = root.querySelector("[data-spot]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
var spot = FX.spot({
|
||||
spot: spotEl,
|
||||
stack: root.querySelector("[data-stack]"),
|
||||
total: root.querySelector("[data-spot-total]"),
|
||||
});
|
||||
|
||||
var bet = 0; // what you're building between games
|
||||
var busy = false;
|
||||
var game = null; // the game as the server last described it
|
||||
var tier = "table";
|
||||
var pendingWild = -1; // the wild you clicked, waiting on a colour
|
||||
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
|
||||
function say(text, tone, where) {
|
||||
var el = where || msgEl;
|
||||
if (!el) return;
|
||||
if (!text) { el.classList.add("hidden"); return; }
|
||||
el.textContent = text;
|
||||
el.classList.remove("hidden");
|
||||
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
||||
}
|
||||
|
||||
// ---- drawing the cards -----------------------------------------------------
|
||||
|
||||
// GLYPHS are what goes in the middle of an action card. The face the engine
|
||||
// sends is a word ("skip", "+2"); this is the drawing of it.
|
||||
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
|
||||
|
||||
// card builds one UNO card. The oval across the middle at an angle is the whole
|
||||
// look of the thing — without it a card reads as a coloured button.
|
||||
function card(c, opts) {
|
||||
opts = opts || {};
|
||||
var el = document.createElement(opts.button ? "button" : "div");
|
||||
if (opts.button) el.type = "button";
|
||||
el.className = "pete-uno-card";
|
||||
el.dataset.c = c.wild ? "wild" : c.color;
|
||||
|
||||
var face = document.createElement("span");
|
||||
face.className = "pete-uno-face";
|
||||
|
||||
var oval = document.createElement("span");
|
||||
oval.className = "pete-uno-oval";
|
||||
oval.textContent = GLYPHS[c.value] || c.value;
|
||||
face.appendChild(oval);
|
||||
|
||||
// The corners, as printed.
|
||||
["tl", "br"].forEach(function (at) {
|
||||
var corner = document.createElement("span");
|
||||
corner.className = "pete-uno-corner";
|
||||
corner.dataset.at = at;
|
||||
corner.textContent = GLYPHS[c.value] || c.value;
|
||||
corner.setAttribute("aria-hidden", "true");
|
||||
face.appendChild(corner);
|
||||
});
|
||||
|
||||
// A wild is four quadrants of colour — and once it has been played as a
|
||||
// colour, that colour is the one it wears on the pile.
|
||||
if (c.wild) {
|
||||
var wheel = document.createElement("span");
|
||||
wheel.className = "pete-uno-wheel";
|
||||
face.appendChild(wheel);
|
||||
if (c.color && c.color !== "wild") el.dataset.named = c.color;
|
||||
}
|
||||
|
||||
el.appendChild(face);
|
||||
el.setAttribute("aria-label", label(c));
|
||||
return el;
|
||||
}
|
||||
|
||||
function label(c) {
|
||||
var v = c.value === "+2" ? "draw two" :
|
||||
c.value === "+4" ? "wild draw four" :
|
||||
c.value === "wild" ? "wild" : c.value;
|
||||
if (c.wild) return v + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
|
||||
return c.color + " " + v;
|
||||
}
|
||||
|
||||
function back() {
|
||||
var el = document.createElement("div");
|
||||
el.className = "pete-uno-card pete-uno-card-back";
|
||||
var b = document.createElement("span");
|
||||
b.className = "pete-uno-back";
|
||||
el.appendChild(b);
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---- the board -------------------------------------------------------------
|
||||
|
||||
// FAN is the most backs a bot's hand draws, however many it holds. Past this
|
||||
// the fan is unreadable and the number beside it is doing the work anyway.
|
||||
var FAN = 8;
|
||||
|
||||
function renderSeats(v) {
|
||||
seatsEl.innerHTML = "";
|
||||
if (!v) return;
|
||||
v.seats.forEach(function (s, i) {
|
||||
if (s.you) return; // you are the hand at the bottom, not a seat up here
|
||||
var el = document.createElement("div");
|
||||
el.className = "pete-uno-seat";
|
||||
el.dataset.seat = String(i);
|
||||
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
|
||||
if (s.uno) el.dataset.uno = "1";
|
||||
|
||||
var fan = document.createElement("div");
|
||||
fan.className = "pete-uno-fan";
|
||||
var show = Math.min(s.cards, FAN);
|
||||
for (var n = 0; n < show; n++) {
|
||||
var b = back();
|
||||
b.style.setProperty("--i", n);
|
||||
b.style.setProperty("--n", show);
|
||||
fan.appendChild(b);
|
||||
}
|
||||
el.appendChild(fan);
|
||||
|
||||
var name = document.createElement("p");
|
||||
name.className = "pete-uno-name";
|
||||
name.textContent = s.name;
|
||||
el.appendChild(name);
|
||||
|
||||
var count = document.createElement("p");
|
||||
count.className = "pete-uno-count";
|
||||
count.dataset.count = "";
|
||||
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
|
||||
el.appendChild(count);
|
||||
|
||||
seatsEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }
|
||||
|
||||
// Where a card flies to or from, for a seat. Yours is the hand; a bot's is its
|
||||
// fan; the deck is the deck.
|
||||
function seatAnchor(i) {
|
||||
if (i === 0) return handEl;
|
||||
var el = seatEl(i);
|
||||
return el ? el.querySelector(".pete-uno-fan") : deckEl;
|
||||
}
|
||||
|
||||
function renderHand(v) {
|
||||
handEl.innerHTML = "";
|
||||
if (!v || !v.hand) return;
|
||||
var playable = {};
|
||||
(v.playable || []).forEach(function (i) { playable[i] = true; });
|
||||
var yours = v.turn === 0 && v.phase !== "done";
|
||||
|
||||
v.hand.forEach(function (c, i) {
|
||||
var el = card(c, { button: true });
|
||||
el.style.setProperty("--i", i);
|
||||
el.dataset.at = String(i);
|
||||
var ok = yours && playable[i];
|
||||
el.dataset.on = ok ? "1" : "0";
|
||||
el.disabled = !ok || busy;
|
||||
if (ok) el.addEventListener("click", function () { pick(i, c); });
|
||||
handEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPiles(v) {
|
||||
discardEl.innerHTML = "";
|
||||
if (!v) {
|
||||
deckCntEl.textContent = "0";
|
||||
colourEl.textContent = "";
|
||||
root.querySelector(".pete-uno").dataset.c = "";
|
||||
return;
|
||||
}
|
||||
deckCntEl.textContent = String(v.deck);
|
||||
var top = card(v.top);
|
||||
top.classList.add("pete-uno-top");
|
||||
discardEl.appendChild(top);
|
||||
|
||||
// The colour in play, which after a wild is not the colour of the card you
|
||||
// are looking at. So it is said in words, and the felt takes a tint of it —
|
||||
// this is the single most missable fact on the table.
|
||||
colourEl.textContent = v.color;
|
||||
colourEl.dataset.c = v.color;
|
||||
feltEl.dataset.c = v.color;
|
||||
}
|
||||
|
||||
var feltEl = root.querySelector(".pete-uno");
|
||||
|
||||
function renderRail(v) {
|
||||
if (!v) {
|
||||
paysEl.textContent = "—";
|
||||
meterEl.dataset.cold = "1";
|
||||
tableEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
paysEl.textContent = (v.pays || 0).toLocaleString();
|
||||
meterEl.dataset.cold = v.phase === "done" ? "1" : "0";
|
||||
tableEl.textContent = v.tier.name + " · " + v.tier.base.toFixed(1) + "×";
|
||||
}
|
||||
|
||||
function renderTurn(v) {
|
||||
if (!v || v.phase === "done") {
|
||||
turnEl.textContent = "";
|
||||
countEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
var yours = v.turn === 0;
|
||||
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
|
||||
if (yours && v.phase === "drawn") who = "Play it, or keep it";
|
||||
turnEl.textContent = who;
|
||||
turnEl.dataset.you = yours ? "1" : "0";
|
||||
var n = v.hand.length;
|
||||
countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
|
||||
}
|
||||
|
||||
// ---- phases ----------------------------------------------------------------
|
||||
|
||||
var VERDICTS = {
|
||||
won: "You went out! 🎉",
|
||||
lost: "Beaten to it.",
|
||||
stuck: "Nobody could move.",
|
||||
};
|
||||
|
||||
function verdict(v) {
|
||||
var text = VERDICTS[v.outcome] || "";
|
||||
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
|
||||
text = v.seats[v.winner].name + " went out first.";
|
||||
}
|
||||
if (!text) { verdictEl.classList.add("hidden"); return; }
|
||||
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
|
||||
}
|
||||
|
||||
function setPhase(v) {
|
||||
game = v;
|
||||
var live = !!v && v.phase !== "done";
|
||||
betting.classList.toggle("hidden", live);
|
||||
playing.classList.toggle("hidden", !live);
|
||||
hideWild();
|
||||
|
||||
var yours = live && v.turn === 0;
|
||||
if (drawBtn) {
|
||||
drawBtn.disabled = !yours || v.phase === "drawn" || busy;
|
||||
drawBtn.classList.toggle("hidden", !!(live && v.phase === "drawn"));
|
||||
}
|
||||
if (passBtn) {
|
||||
passBtn.classList.toggle("hidden", !(live && v.phase === "drawn"));
|
||||
passBtn.disabled = !yours || busy;
|
||||
}
|
||||
if (deckEl) deckEl.disabled = !yours || v.phase === "drawn" || busy;
|
||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// paint puts the board up with no animation: the resume path, after a reload or
|
||||
// a redeploy. Whatever the server says is on the table is on the table.
|
||||
function paint(v) {
|
||||
renderSeats(v);
|
||||
renderPiles(v);
|
||||
renderHand(v);
|
||||
renderRail(v);
|
||||
renderTurn(v);
|
||||
spot.render(v && v.phase !== "done" ? v.bet : 0);
|
||||
setPhase(v);
|
||||
}
|
||||
|
||||
// ---- the script ------------------------------------------------------------
|
||||
|
||||
// throwCard sends a card from one place to another across the felt.
|
||||
function throwCard(node, from, to, opts) {
|
||||
opts = opts || {};
|
||||
return FX.flyNode(node, from, to, {
|
||||
duration: opts.duration || 380,
|
||||
lift: opts.lift == null ? 0.7 : opts.lift,
|
||||
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
|
||||
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
|
||||
delay: opts.delay || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// bump keeps a bot's fan honest during the script: the server's count is the
|
||||
// truth, but between events the fan has to grow and shrink as cards move, or
|
||||
// the flight lands on a pile that hasn't changed.
|
||||
function bump(seat, left) {
|
||||
if (seat === 0 || left == null) return;
|
||||
var el = seatEl(seat);
|
||||
if (!el) return;
|
||||
var fan = el.querySelector(".pete-uno-fan");
|
||||
var count = el.querySelector("[data-count]");
|
||||
if (count) count.textContent = left + (left === 1 ? " card" : " cards");
|
||||
if (!fan) return;
|
||||
var show = Math.min(left, FAN);
|
||||
while (fan.children.length > show) fan.removeChild(fan.lastChild);
|
||||
while (fan.children.length < show) fan.appendChild(back());
|
||||
Array.prototype.forEach.call(fan.children, function (b, i) {
|
||||
b.style.setProperty("--i", i);
|
||||
b.style.setProperty("--n", fan.children.length);
|
||||
});
|
||||
el.dataset.uno = left === 1 ? "1" : "0";
|
||||
}
|
||||
|
||||
function spotlight(seat) {
|
||||
seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) {
|
||||
el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0";
|
||||
});
|
||||
}
|
||||
|
||||
// badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say
|
||||
// these things; the badge is what makes them land on somebody.
|
||||
function badge(seat, text, tone) {
|
||||
var host = seat === 0 ? handEl : seatEl(seat);
|
||||
if (!host || reduced) return Promise.resolve();
|
||||
var b = document.createElement("span");
|
||||
b.className = "pete-uno-badge";
|
||||
b.dataset.tone = tone || "";
|
||||
b.textContent = text;
|
||||
host.appendChild(b);
|
||||
return new Promise(function (r) {
|
||||
setTimeout(function () { b.remove(); r(); }, 900);
|
||||
});
|
||||
}
|
||||
|
||||
// play walks the server's script. On a live game the money is already right —
|
||||
// your stake left your pile when you pressed Deal, and it's on the spot — so
|
||||
// the chip bar can catch up immediately. On a settling one it waits.
|
||||
function play(view, money) {
|
||||
var events = view.uno_events || [];
|
||||
var final = view.uno;
|
||||
var settles = !!final && final.phase === "done";
|
||||
var chain = Promise.resolve();
|
||||
|
||||
if (!settles) money();
|
||||
|
||||
events.forEach(function (e, n) {
|
||||
chain = chain.then(function () {
|
||||
switch (e.kind) {
|
||||
case "deal":
|
||||
// The deal isn't animated card by card: seven cards to each of four
|
||||
// seats is 28 flights and a player who wants to play. The hand fans in
|
||||
// on its own (CSS), which reads as being dealt without taking as long.
|
||||
paint(final);
|
||||
return wait(320);
|
||||
|
||||
case "play": {
|
||||
spotlight(e.seat);
|
||||
var node = card(e.card);
|
||||
var from = seatAnchor(e.seat);
|
||||
// Your own card leaves the hand it was in, so the hand has to lose it
|
||||
// before the flight or the card is briefly in two places.
|
||||
if (e.seat === 0) {
|
||||
var live = handEl.querySelector('.pete-uno-card[data-on="1"][data-at]');
|
||||
if (live) live.style.visibility = "hidden";
|
||||
}
|
||||
bump(e.seat, e.left);
|
||||
return throwCard(node, from, discardEl, { index: n })
|
||||
.then(function () {
|
||||
discardEl.innerHTML = "";
|
||||
var top = card(e.card);
|
||||
top.classList.add("pete-uno-top", "pete-uno-land");
|
||||
discardEl.appendChild(top);
|
||||
if (e.color) {
|
||||
colourEl.textContent = e.color;
|
||||
colourEl.dataset.c = e.color;
|
||||
feltEl.dataset.c = e.color;
|
||||
}
|
||||
return wait(e.seat === 0 ? 120 : 300);
|
||||
});
|
||||
}
|
||||
|
||||
case "draw":
|
||||
case "forced": {
|
||||
spotlight(e.seat);
|
||||
var to = seatAnchor(e.seat);
|
||||
var backs = [];
|
||||
for (var i = 0; i < Math.min(e.n, 4); i++) {
|
||||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
||||
}
|
||||
var punished = e.kind === "forced";
|
||||
if (punished) badge(e.seat, "+" + e.n, "bad");
|
||||
return Promise.all(backs).then(function () {
|
||||
bump(e.seat, e.left);
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
// Your own drawn card comes face up — it's yours, and the server
|
||||
// sent its face for exactly this.
|
||||
if (e.seat === 0 && e.card) return wait(160);
|
||||
return wait(punished ? 380 : 180);
|
||||
});
|
||||
}
|
||||
|
||||
case "skip":
|
||||
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
|
||||
|
||||
case "reverse":
|
||||
feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
|
||||
return badge(e.seat, "Reverse").then(function () { return wait(60); });
|
||||
|
||||
case "uno":
|
||||
return badge(e.seat, "UNO!", "uno");
|
||||
|
||||
case "reshuffle":
|
||||
deckEl.classList.add("pete-uno-shuffle");
|
||||
return wait(420).then(function () {
|
||||
deckEl.classList.remove("pete-uno-shuffle");
|
||||
});
|
||||
|
||||
case "pass":
|
||||
spotlight(e.seat);
|
||||
return wait(140);
|
||||
|
||||
case "settle":
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return chain.then(function () {
|
||||
if (!final) { paint(null); money(); return; }
|
||||
|
||||
if (!settles) {
|
||||
paint(final);
|
||||
return;
|
||||
}
|
||||
|
||||
// Over: the board settles, the money moves, and only then does the bar
|
||||
// catch up.
|
||||
renderSeats(final);
|
||||
renderPiles(final);
|
||||
renderHand(final);
|
||||
renderTurn(final);
|
||||
renderRail(final);
|
||||
verdict(final);
|
||||
playing.classList.add("hidden");
|
||||
return settleChips(final)
|
||||
.then(money)
|
||||
.then(function () { return standing(final.bet); })
|
||||
.then(function () { setPhase(final); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
function settleChips(final) {
|
||||
var payout = final.payout || 0;
|
||||
if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||
|
||||
var back = payout - final.bet;
|
||||
return spot
|
||||
.pour(houseEl, back, { gap: 60 })
|
||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||
}
|
||||
|
||||
// standing puts the stake back on the spot for the next game, the way every
|
||||
// other table in the room leaves your bet up. pour grows the pile from what it
|
||||
// is told is already there, so the amount is never set first — that bug printed
|
||||
// double the stake under trivia's chips for a day.
|
||||
function standing(amount) {
|
||||
var money = window.PeteGames.view();
|
||||
if (!amount || !money || money.chips < amount) {
|
||||
bet = 0;
|
||||
showBet();
|
||||
return;
|
||||
}
|
||||
bet = amount;
|
||||
showBet();
|
||||
return spot.pour(purseEl, amount);
|
||||
}
|
||||
|
||||
// ---- moves ------------------------------------------------------------------
|
||||
|
||||
function pick(i, c) {
|
||||
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
|
||||
if (c.wild) { askColour(i); return; }
|
||||
move({ kind: "play", index: i });
|
||||
}
|
||||
|
||||
function askColour(i) {
|
||||
pendingWild = i;
|
||||
wildEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideWild() {
|
||||
pendingWild = -1;
|
||||
if (wildEl) wildEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
// COLOURS maps the colour a player names to the number the engine calls it.
|
||||
// Wild is 0 there on purpose — a move with no colour on it must not quietly
|
||||
// mean red — so these start at 1 and this table is the only place that knows.
|
||||
var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 };
|
||||
|
||||
root.querySelectorAll("[data-colour-pick]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy || pendingWild < 0) return;
|
||||
var i = pendingWild;
|
||||
var c = COLOURS[b.dataset.colourPick];
|
||||
hideWild();
|
||||
move({ kind: "play", index: i, color: c });
|
||||
});
|
||||
});
|
||||
|
||||
var cancelWild = root.querySelector("[data-colour-cancel]");
|
||||
if (cancelWild) cancelWild.addEventListener("click", hideWild);
|
||||
|
||||
function move(body) {
|
||||
send("/api/games/uno/move", body, gameMsgEl);
|
||||
}
|
||||
|
||||
if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); });
|
||||
if (deckEl) deckEl.addEventListener("click", function () { drawOne(); });
|
||||
if (passBtn) {
|
||||
passBtn.addEventListener("click", function () {
|
||||
if (busy || !game || game.turn !== 0 || game.phase !== "drawn") return;
|
||||
move({ kind: "pass" });
|
||||
});
|
||||
}
|
||||
|
||||
function drawOne() {
|
||||
if (busy || !game || game.phase !== "play" || game.turn !== 0) return;
|
||||
move({ kind: "draw" });
|
||||
}
|
||||
|
||||
// ---- talking to the table ----------------------------------------------------
|
||||
|
||||
function send(path, body, where) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
say("", null, where);
|
||||
lock();
|
||||
return window.PeteGames.post(path, body)
|
||||
.then(function (view) {
|
||||
busy = false;
|
||||
return play(view, function () { window.PeteGames.apply(view); });
|
||||
})
|
||||
.catch(function (err) {
|
||||
busy = false;
|
||||
say(err.message, "bad", where);
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v && v.uno) paint(v.uno);
|
||||
else { paint(null); spot.render(0); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// lock takes the table away while a move is in flight, so a second click can't
|
||||
// send a second move against a game the first one is about to change.
|
||||
function lock() {
|
||||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { b.disabled = true; });
|
||||
if (drawBtn) drawBtn.disabled = true;
|
||||
if (passBtn) passBtn.disabled = true;
|
||||
if (deckEl) deckEl.disabled = true;
|
||||
}
|
||||
|
||||
// ---- betting ------------------------------------------------------------------
|
||||
|
||||
function showBet() {
|
||||
betAmount.textContent = bet.toLocaleString();
|
||||
var money = window.PeteGames.view();
|
||||
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
||||
}
|
||||
|
||||
function pickTier(slug) {
|
||||
tier = slug;
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
|
||||
});
|
||||
showBet();
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
pickTier(b.dataset.tier);
|
||||
});
|
||||
});
|
||||
|
||||
// The chip you click is the chip that flies. Scoped to buttons: the bare
|
||||
// [data-chip] spans in the rack are the house's, and the house is not betting.
|
||||
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
var d = parseInt(btn.dataset.chip, 10);
|
||||
var money = window.PeteGames.view();
|
||||
if (money && bet + d > money.chips) {
|
||||
say("You haven't got that many chips.", "bad");
|
||||
return;
|
||||
}
|
||||
bet += d;
|
||||
showBet();
|
||||
|
||||
var target = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
|
||||
// The stake sits on the spot for the whole game: it is what's riding on you
|
||||
// going out first.
|
||||
send("/api/games/uno/start", { bet: bet, tier: tier });
|
||||
});
|
||||
}
|
||||
|
||||
pickTier(tier);
|
||||
|
||||
var resumed = false;
|
||||
window.PeteGames.onUpdate(function (v) {
|
||||
if (!resumed) {
|
||||
resumed = true;
|
||||
if (v.uno) {
|
||||
paint(v.uno);
|
||||
if (v.uno.phase === "done") verdict(v.uno);
|
||||
} else {
|
||||
paint(null);
|
||||
}
|
||||
}
|
||||
showBet();
|
||||
});
|
||||
})();
|
||||
@@ -90,6 +90,22 @@
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/games/uno"
|
||||
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🎴</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-display text-xl font-bold">UNO</h3>
|
||||
<p class="text-sm text-[color:var(--ink)]/60">Go out first, take the table.</p>
|
||||
</div>
|
||||
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
|
||||
One to three bots, and the more of them there are the more it pays: up to 3.6× for
|
||||
beating a full table. Anybody else going out first takes your stake.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
{{range .Soon}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
178
internal/web/templates/uno.html
Normal file
178
internal/web/templates/uno.html
Normal file
@@ -0,0 +1,178 @@
|
||||
{{define "title"}}UNO · {{.Room.Name}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<div class="space-y-6" data-uno>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
||||
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
<span class="sr-only">Back to the casino</span>
|
||||
</a>
|
||||
<h1 class="font-display text-3xl font-bold">UNO</h1>
|
||||
</div>
|
||||
<p class="text-sm text-[color:var(--ink)]/50">Go out first, or it was somebody else's night</p>
|
||||
</div>
|
||||
|
||||
{{template "_chipbar" .}}
|
||||
|
||||
<!-- The felt. The board takes the room and the money lives in a rail beside it:
|
||||
there is no corner free on this table for the house rack to sit in. -->
|
||||
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
|
||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
|
||||
|
||||
<div class="min-w-0 space-y-5">
|
||||
|
||||
<!-- The bots. Each one is a name, a fan of backs, and a count — which is
|
||||
all you are ever told about them, and all a real opponent shows you. -->
|
||||
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
|
||||
|
||||
<!-- The middle: what you draw from, and what you play onto. -->
|
||||
<div class="flex items-center justify-center gap-5 sm:gap-8">
|
||||
<button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card">
|
||||
<span class="pete-uno-back" aria-hidden="true"></span>
|
||||
<span data-deck-count class="pete-uno-deck-count">0</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
|
||||
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Your hand. -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<p data-turn-label class="text-xs font-bold uppercase tracking-wider text-white/50" aria-live="polite"></p>
|
||||
<p data-count-label class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||
</div>
|
||||
<div data-hand class="pete-uno-hand" aria-label="Your hand"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[2.75rem] items-center justify-center">
|
||||
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The rail. -->
|
||||
<aside class="pete-rail">
|
||||
<div class="pete-rack" data-at="rail" data-house aria-hidden="true">
|
||||
<span data-chip="500" style="--stack: 5"></span>
|
||||
<span data-chip="100" style="--stack: 7"></span>
|
||||
<span data-chip="25" style="--stack: 4"></span>
|
||||
<span data-chip="5" style="--stack: 6"></span>
|
||||
</div>
|
||||
|
||||
<div class="pete-spot" data-spot>
|
||||
<span class="pete-spot-label">Bet</span>
|
||||
<div class="pete-stack" data-stack></div>
|
||||
<span data-spot-total class="pete-spot-total hidden"></span>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="pete-meter" data-meter>
|
||||
<span class="pete-meter-label">Pays</span>
|
||||
<span data-pays class="pete-meter-value">—</span>
|
||||
</div>
|
||||
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Naming a colour for a wild. It sits over the felt because until it is
|
||||
answered there is no legal move on the table underneath it. -->
|
||||
<div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour">
|
||||
<div class="pete-uno-wild-box">
|
||||
<p class="font-display text-lg font-bold text-white">Pick a colour</p>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2.5">
|
||||
<button type="button" data-colour-pick="red" class="pete-uno-swatch" data-c="red">Red</button>
|
||||
<button type="button" data-colour-pick="blue" class="pete-uno-swatch" data-c="blue">Blue</button>
|
||||
<button type="button" data-colour-pick="yellow" class="pete-uno-swatch" data-c="yellow">Yellow</button>
|
||||
<button type="button" data-colour-pick="green" class="pete-uno-swatch" data-c="green">Green</button>
|
||||
</div>
|
||||
<button type="button" data-colour-cancel class="mt-3 text-xs font-semibold text-white/50 hover:text-white/80 transition">Play something else</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playing: shown while a game is live. -->
|
||||
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<p class="text-sm text-[color:var(--ink)]/50">
|
||||
Click a card that lights up. Nothing lights up? Draw one.
|
||||
</p>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button type="button" data-draw
|
||||
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
|
||||
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Draw
|
||||
</button>
|
||||
<button type="button" data-pass
|
||||
class="hidden rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Keep it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||
</section>
|
||||
|
||||
<!-- Betting: shown between games. -->
|
||||
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{{range .Tables}}
|
||||
<button type="button" data-tier="{{.Slug}}"
|
||||
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-display text-lg font-bold">{{.Name}}</span>
|
||||
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
|
||||
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
|
||||
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
|
||||
</p>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
|
||||
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{range .Denominations}}
|
||||
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
|
||||
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
|
||||
<span>{{.}}</span>
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" data-bet-clear
|
||||
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-start
|
||||
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
|
||||
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
|
||||
Deal
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
|
||||
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
|
||||
</p>
|
||||
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/casino-fx.js" defer></script>
|
||||
<script src="/static/js/games.js" defer></script>
|
||||
<script src="/static/js/uno.js" defer></script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user