package web import ( "bytes" _ "embed" "fmt" "image" "image/color" "image/draw" "image/png" "math" "net/http" "sync" "time" "golang.org/x/image/font" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" "golang.org/x/image/vector" ) // The card that shows up when somebody pastes the casino into a chat window. // // It is drawn here, in Go, rather than checked in as a picture, because the // casino has two names on a clock and the share card keeps the joke: paste the // link in daylight and you get Casinopolis on green felt; paste it after six and // the neon is on and the sign says Casino Night Zone. Same tables, different room, // same rule as roomAt() everywhere else. // // The clock that decides is the *server's*, because an unfurl bot has no evening // of its own. That's the one place the room rule can't be the player's own clock. //go:embed assets/fonts/Fredoka-SemiBold.ttf var fredokaTTF []byte // Share cards are 1200x630: the size every unfurler crops to and the one thing // about Open Graph that everybody agrees on. const ( ogWidth = 1200 ogHeight = 630 ) // ogPalette is a room's colours, lifted from the CSS block of the same name in // input.css. Two copies of a palette is a thing that drifts, so if you retune a // room, retune it here as well — TestTheShareCardKnowsBothRooms will not catch a // colour, only a missing room. type ogPalette struct { bg color.RGBA // the room, behind the table feltA color.RGBA // the felt, lit feltC color.RGBA // the felt, in shadow ink color.RGBA // type accent color.RGBA // the sign, and the lamp over the table } var ogPalettes = map[string]ogPalette{ roomDay.Slug: { bg: rgb(0x16, 0x21, 0x1c), feltA: rgb(0x2f, 0x7d, 0x5b), feltC: rgb(0x1c, 0x4d, 0x3c), ink: rgb(0xf6, 0xec, 0xd8), accent: rgb(0xf2, 0xb5, 0x3d), }, roomNight.Slug: { bg: rgb(0x14, 0x0f, 0x2e), feltA: rgb(0x4a, 0x2f, 0xa8), feltC: rgb(0x24, 0x16, 0x59), ink: rgb(0xf2, 0xec, 0xff), accent: rgb(0xff, 0xcc, 0x2f), }, } // A card is drawn once per room and then kept. There are two of them, they never // change, and an unfurl bot is not worth a rasterizer. var ( ogOnce sync.Once ogCards map[string][]byte // room slug -> PNG ogErr error ) func (s *Server) handleGamesOG(w http.ResponseWriter, r *http.Request) { if !s.gamesReady() { http.NotFound(w, r) return } // No requirePlayer here, and that is the whole point: the thing fetching this // is a chat server's link preview, which has never signed in and never will. ogOnce.Do(func() { ogCards, ogErr = drawShareCards() }) if ogErr != nil { http.Error(w, "share card unavailable", http.StatusInternalServerError) return } room := roomAt(time.Now().Hour()) png := ogCards[room.Slug] w.Header().Set("Content-Type", "image/png") // Long enough that a busy channel isn't redrawing it, short enough that the // room actually turns over: the lights come on at six and the card follows // within the hour. w.Header().Set("Cache-Control", "public, max-age=900") w.Header().Set("Content-Length", fmt.Sprint(len(png))) http.ServeContent(w, r, "og.png", time.Time{}, bytes.NewReader(png)) } // drawShareCards renders every room's card up front. If the font is broken every // card is broken, so they fail together or not at all. func drawShareCards() (map[string][]byte, error) { f, err := opentype.Parse(fredokaTTF) if err != nil { return nil, fmt.Errorf("parse fredoka: %w", err) } out := make(map[string][]byte, len(ogPalettes)) for _, rm := range []room{roomDay, roomNight} { img, err := drawShareCard(f, rm, ogPalettes[rm.Slug]) if err != nil { return nil, err } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return nil, fmt.Errorf("encode %s: %w", rm.Slug, err) } out[rm.Slug] = buf.Bytes() } return out, nil } func drawShareCard(f *opentype.Font, rm room, p ogPalette) (image.Image, error) { title, err := face(f, 96) if err != nil { return nil, err } line, err := face(f, 33) if err != nil { return nil, err } small, err := face(f, 25) if err != nil { return nil, err } defer title.Close() defer line.Close() defer small.Close() img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight)) draw.Draw(img, img.Bounds(), &image.Uniform{p.bg}, image.Point{}, draw.Src) // The table: a rounded rect of felt, lit from the top and falling into shadow // at the rail, which is the same trick the room's CSS plays with a gradient. table := func(rz *vector.Rasterizer) { roundRect(rz, 36, 36, ogWidth-72, ogHeight-72, 44, nil) } fillPath(img, verticalGradient(p.feltA, p.feltC), table) // The lamp over it. Nothing else in the picture explains where the light is // coming from, and a felt with no lamp reads as a green rectangle. It is // painted through the table's own shape, because light that spills onto the // floor outside the rail is not a lamp, it's a mistake. fillPath(img, lamp(ogWidth/2, 40, 520, p.accent, 0.26), table) // The sign over the door. centerText(img, title, p.accent, rm.Name, ogHeight/2-42) centerText(img, line, alpha(p.ink, 0.92), "Blackjack, Hold'em, UNO, Trivia, Hangman, Solitaire", ogHeight/2+26) centerText(img, small, alpha(p.ink, 0.62), "Played for real gogobee euros", ogHeight/2+78) // Bottom left: two cards, one face down and one face up, because a casino that // shows you only the backs is a casino that isn't dealing. drawCardBack(img, p, 118, 430, -13) drawCardFace(img, 196, 420, 7) // Bottom right: what you're playing for. chips := []color.RGBA{p.accent, rgb(0xd9, 0x4f, 0x4f), rgb(0xf6, 0xf1, 0xe6), p.accent} for i, c := range chips { drawChip(img, 1042, float32(536-i*23), 46, c, p) } // The address, small, in the corner. A share card is also a signpost. rightText(img, small, alpha(p.ink, 0.45), "games.parodia.dev", ogWidth-70, 96) return img, nil } // ---- shapes ----------------------------------------------------------------- // // Everything below draws through a vector.Rasterizer the size of the whole card, // which is wasteful and completely fine: it happens twice, at boot, forever. // xform moves a point before it's rasterized. It is how anything here gets to sit // at an angle — font.Drawer can't rotate, but a path can be rotated on its way in. type xform func(x, y float32) (float32, float32) func rotate(cx, cy, deg float32) xform { rad := float64(deg) * math.Pi / 180 sin, cos := float32(math.Sin(rad)), float32(math.Cos(rad)) return func(x, y float32) (float32, float32) { dx, dy := x-cx, y-cy return cx + dx*cos - dy*sin, cy + dx*sin + dy*cos } } func (t xform) at(x, y float32) (float32, float32) { if t == nil { return x, y } return t(x, y) } // fillPath rasterizes a path and paints src through it. func fillPath(dst *image.RGBA, src image.Image, path func(*vector.Rasterizer)) { rz := vector.NewRasterizer(ogWidth, ogHeight) path(rz) rz.Draw(dst, dst.Bounds(), src, image.Point{}) } func fill(dst *image.RGBA, c color.Color, path func(*vector.Rasterizer)) { fillPath(dst, &image.Uniform{c}, path) } // roundRect lays a rounded rectangle into the rasterizer, optionally through a // transform, so the same helper draws a table and a card held at an angle. func roundRect(rz *vector.Rasterizer, x, y, w, h, r float32, t xform) { move := func(px, py float32) { rz.MoveTo(t.at(px, py)) } line := func(px, py float32) { rz.LineTo(t.at(px, py)) } quad := func(cx, cy, px, py float32) { qx, qy := t.at(cx, cy) ex, ey := t.at(px, py) rz.QuadTo(qx, qy, ex, ey) } move(x+r, y) line(x+w-r, y) quad(x+w, y, x+w, y+r) line(x+w, y+h-r) quad(x+w, y+h, x+w-r, y+h) line(x+r, y+h) quad(x, y+h, x, y+h-r) line(x, y+r) quad(x, y, x+r, y) rz.ClosePath() } func circle(rz *vector.Rasterizer, cx, cy, r float32) { // Four cubics, the usual 0.5523 magic number for a circle out of beziers. const k = 0.5523 rz.MoveTo(cx+r, cy) rz.CubeTo(cx+r, cy+r*k, cx+r*k, cy+r, cx, cy+r) rz.CubeTo(cx-r*k, cy+r, cx-r, cy+r*k, cx-r, cy) rz.CubeTo(cx-r, cy-r*k, cx-r*k, cy-r, cx, cy-r) rz.CubeTo(cx+r*k, cy-r, cx+r, cy-r*k, cx+r, cy) rz.ClosePath() } // hexagon is Pete's mark — the same six-sided badge as the favicon, which is what // the back of the house's cards is printed with. func hexagon(rz *vector.Rasterizer, cx, cy, r float32, t xform) { for i := 0; i < 6; i++ { ang := float64(i)*math.Pi/3 - math.Pi/2 x := cx + r*float32(math.Cos(ang)) y := cy + r*float32(math.Sin(ang)) px, py := t.at(x, y) if i == 0 { rz.MoveTo(px, py) } else { rz.LineTo(px, py) } } rz.ClosePath() } const ( cardW = 132 cardH = 186 ) // drawCardBack is the house's card: dark, with the hexagon on it. func drawCardBack(dst *image.RGBA, p ogPalette, x, y, deg float32) { t := rotate(x+cardW/2, y+cardH/2, deg) fill(dst, color.RGBA{0, 0, 0, 70}, func(rz *vector.Rasterizer) { roundRect(rz, x+5, y+8, cardW, cardH, 14, t) }) fill(dst, alpha(p.ink, 0.96), func(rz *vector.Rasterizer) { roundRect(rz, x, y, cardW, cardH, 14, t) }) fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) { roundRect(rz, x+9, y+9, cardW-18, cardH-18, 9, t) }) fill(dst, p.accent, func(rz *vector.Rasterizer) { hexagon(rz, x+cardW/2, y+cardH/2, 34, t) }) fill(dst, darken(p.feltC, 0.55), func(rz *vector.Rasterizer) { hexagon(rz, x+cardW/2, y+cardH/2, 20, t) }) } // drawCardFace is the one that's been turned over: an ace of hearts, near enough. func drawCardFace(dst *image.RGBA, x, y, deg float32) { t := rotate(x+cardW/2, y+cardH/2, deg) red := rgb(0xd9, 0x3b, 0x3b) fill(dst, color.RGBA{0, 0, 0, 80}, func(rz *vector.Rasterizer) { roundRect(rz, x+5, y+9, cardW, cardH, 14, t) }) fill(dst, rgb(0xfb, 0xf7, 0xef), func(rz *vector.Rasterizer) { roundRect(rz, x, y, cardW, cardH, 14, t) }) heart(dst, red, x+cardW/2, y+cardH/2+4, 40, t) // The pip in the corner, which is how you know it's a card and not a tile. heart(dst, red, x+24, y+30, 11, t) } // heart, as two lobes and a point. func heart(dst *image.RGBA, c color.RGBA, cx, cy, r float32, t xform) { fill(dst, c, func(rz *vector.Rasterizer) { move := func(px, py float32) { rz.MoveTo(t.at(px, py)) } cube := func(ax, ay, bx, by, px, py float32) { a1, a2 := t.at(ax, ay) b1, b2 := t.at(bx, by) e1, e2 := t.at(px, py) rz.CubeTo(a1, a2, b1, b2, e1, e2) } bottom := cy + r*0.95 move(cx, bottom) cube(cx-r*1.15, cy+r*0.18, cx-r*1.0, cy-r*0.95, cx, cy-r*0.28) cube(cx+r*1.0, cy-r*0.95, cx+r*1.15, cy+r*0.18, cx, bottom) rz.ClosePath() }) } // drawChip is a chip seen face on: a disc, a ring of notches around the rim, and a // pale centre. Stack four and it reads as money. func drawChip(dst *image.RGBA, cx, cy, r float32, c color.RGBA, p ogPalette) { fill(dst, color.RGBA{0, 0, 0, 60}, func(rz *vector.Rasterizer) { circle(rz, cx+3, cy+5, r) }) fill(dst, c, func(rz *vector.Rasterizer) { circle(rz, cx, cy, r) }) // Six notches on the rim, in the room's ink, the way a real chip is edge-marked. for i := 0; i < 6; i++ { ang := float64(i) * math.Pi / 3 nx := cx + (r-7)*float32(math.Cos(ang)) ny := cy + (r-7)*float32(math.Sin(ang)) fill(dst, alpha(p.ink, 0.85), func(rz *vector.Rasterizer) { circle(rz, nx, ny, 6) }) } fill(dst, alpha(p.ink, 0.28), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-15) }) fill(dst, darken(c, 0.82), func(rz *vector.Rasterizer) { circle(rz, cx, cy, r-21) }) } // lamp is the light over the table: the accent colour, brightest under the bulb // and gone by the rail. It's an image rather than a paint, so it can be laid down // through the table's shape and stop at the felt's edge. // // Note the alpha() on the way out. color.RGBA is *alpha-premultiplied*, and the // first version of this wrote the raw channels next to a low alpha — which is not // a dim honey glow, it's an invalid colour, and image/draw ran it straight past // 255 and wrapped it. The card came out with a blue dome over a green stripe. If // something here ever turns an impossible colour, look for a premultiply first. func lamp(cx, cy, radius float32, c color.RGBA, strength float64) image.Image { img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight)) for y := 0; y < ogHeight; y++ { for x := 0; x < ogWidth; x++ { dx, dy := float64(float32(x)-cx), float64(float32(y)-cy) d := math.Sqrt(dx*dx+dy*dy) / float64(radius) if d >= 1 { continue } // Squared falloff: a lamp is bright under itself and dim at the edges. img.SetRGBA(x, y, alpha(c, strength*(1-d)*(1-d))) } } return img } // verticalGradient is the felt: lit at the top, in shadow at the rail. func verticalGradient(top, bottom color.RGBA) image.Image { g := image.NewRGBA(image.Rect(0, 0, 1, ogHeight)) for y := 0; y < ogHeight; y++ { f := float64(y) / float64(ogHeight-1) g.SetRGBA(0, y, color.RGBA{ R: lerp(top.R, bottom.R, f), G: lerp(top.G, bottom.G, f), B: lerp(top.B, bottom.B, f), A: 255, }) } // One column, stretched sideways forever: the felt doesn't change across the table. return &stretched{g} } type stretched struct{ src *image.RGBA } func (s *stretched) ColorModel() color.Model { return s.src.ColorModel() } func (s *stretched) Bounds() image.Rectangle { return image.Rect(0, 0, ogWidth, ogHeight) } func (s *stretched) At(x, y int) color.Color { return s.src.At(0, y) } // ---- type ------------------------------------------------------------------- func face(f *opentype.Font, size float64) (font.Face, error) { return opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull}) } func centerText(dst *image.RGBA, f font.Face, c color.Color, s string, baseline int) { w := font.MeasureString(f, s) drawText(dst, f, c, s, (ogWidth-w.Round())/2, baseline) } func rightText(dst *image.RGBA, f font.Face, c color.Color, s string, right, baseline int) { w := font.MeasureString(f, s) drawText(dst, f, c, s, right-w.Round(), baseline) } func drawText(dst *image.RGBA, f font.Face, c color.Color, s string, x, baseline int) { d := &font.Drawer{ Dst: dst, Src: &image.Uniform{c}, Face: f, Dot: fixed.P(x, baseline), } d.DrawString(s) } // ---- colour ----------------------------------------------------------------- func rgb(r, g, b uint8) color.RGBA { return color.RGBA{r, g, b, 255} } // alpha returns c at a fraction of its opacity, pre-multiplied, which is what // image/draw wants. func alpha(c color.RGBA, f float64) color.RGBA { return color.RGBA{ R: uint8(float64(c.R) * f), G: uint8(float64(c.G) * f), B: uint8(float64(c.B) * f), A: uint8(255 * f), } } func darken(c color.RGBA, f float64) color.RGBA { return color.RGBA{ R: uint8(float64(c.R) * f), G: uint8(float64(c.G) * f), B: uint8(float64(c.B) * f), A: c.A, } } func lerp(a, b uint8, f float64) uint8 { return uint8(float64(a) + (float64(b)-float64(a))*f) }