454 lines
14 KiB
Go
454 lines
14 KiB
Go
package jackbox
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/gif"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gonutz/gofont"
|
|
)
|
|
|
|
//go:embed FiraMono-Bold.ttf
|
|
var firaMono embed.FS
|
|
|
|
const (
|
|
imageWidth = 300
|
|
imageHeight = 300
|
|
padding = 15
|
|
)
|
|
|
|
// Color pairs for room codes
|
|
var colorPairs = [][2]color.RGBA{
|
|
{{0xDD, 0xCC, 0x77, 255}, {0x44, 0xAA, 0x99, 255}}, // a) #DDCC77, #44AA99
|
|
{{0xFE, 0xFE, 0x62, 255}, {0xD3, 0x5F, 0xB7, 255}}, // b) #FEFE62, #D35FB7
|
|
{{0x64, 0x8F, 0xFF, 255}, {0xFF, 0xB0, 0x00, 255}}, // c) #648FFF, #FFB000
|
|
{{229, 212, 232, 255}, {217, 241, 213, 255}}, // d) RGB values
|
|
}
|
|
|
|
// State for color rotation
|
|
var (
|
|
colorIndex int
|
|
letterGetColor bool // true = letters get Color1, false = letters get Color2
|
|
colorMutex sync.Mutex
|
|
)
|
|
|
|
func init() {
|
|
// Seed with current time to get different starting point each run
|
|
now := time.Now()
|
|
colorIndex = int(now.Unix()) % len(colorPairs)
|
|
letterGetColor = (now.UnixNano() % 2) == 0
|
|
}
|
|
|
|
// GenerateRoomCodeImage creates an animated GIF with the room code and game title
|
|
// Black background, colored Fira Mono text
|
|
// Colors rotate through predefined pairs and alternate letter/number assignment
|
|
func GenerateRoomCodeImage(roomCode, gameTitle string) ([]byte, error) {
|
|
// Get and advance color state
|
|
colorMutex.Lock()
|
|
currentPair := colorPairs[colorIndex]
|
|
currentLetterGetColor1 := letterGetColor
|
|
|
|
// Advance for next call
|
|
letterGetColor = !letterGetColor
|
|
if !letterGetColor {
|
|
// Only advance to next color pair when we've used both orientations
|
|
colorIndex = (colorIndex + 1) % len(colorPairs)
|
|
}
|
|
colorMutex.Unlock()
|
|
|
|
// Determine which color goes to letters vs numbers
|
|
var letterColor, numberColor color.RGBA
|
|
if currentLetterGetColor1 {
|
|
letterColor = currentPair[0]
|
|
numberColor = currentPair[1]
|
|
} else {
|
|
letterColor = currentPair[1]
|
|
numberColor = currentPair[0]
|
|
}
|
|
|
|
// Static text color for game title and labels is always off-white #EEEEEE (hardcoded in drawing code)
|
|
// Choose a random color from the pair for the separator
|
|
separatorColor := currentPair[0]
|
|
if time.Now().UnixNano()%2 == 1 {
|
|
separatorColor = currentPair[1]
|
|
}
|
|
|
|
// Load Fira Mono Bold font
|
|
fontData, err := firaMono.ReadFile("FiraMono-Bold.ttf")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
font, err := gofont.Read(bytes.NewReader(fontData))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
black := color.RGBA{0, 0, 0, 255}
|
|
|
|
// Layout from top to bottom:
|
|
// 1. Game title (at top, staticTextColor)
|
|
// 2. Room code (center, largest, letterColor/numberColor)
|
|
// 3. "Room Code" label (below code, staticTextColor)
|
|
// 4. "Jackbox.tv 🎮 coming up next!" (at bottom, staticTextColor)
|
|
|
|
// Calculate layout from bottom up to maximize room code size
|
|
|
|
// 4. Bottom text "Jackbox.tv :: coming up next!"
|
|
// Split into parts so we can color the separator differently
|
|
bottomTextLeft := "Jackbox.tv "
|
|
bottomTextSeparator := "::"
|
|
bottomTextRight := " coming up next!"
|
|
bottomTextSize := 16
|
|
font.HeightInPixels = bottomTextSize
|
|
// Measure full bottom text for positioning
|
|
fullBottomText := bottomTextLeft + bottomTextSeparator + bottomTextRight
|
|
bottomTextWidth, bottomTextHeight := font.Measure(fullBottomText)
|
|
bottomTextX := (imageWidth - bottomTextWidth) / 2
|
|
bottomTextY := imageHeight - 20 - bottomTextHeight
|
|
|
|
// Calculate positions for each part
|
|
leftWidth, _ := font.Measure(bottomTextLeft)
|
|
sepWidth, _ := font.Measure(bottomTextSeparator)
|
|
|
|
// 3. "Room Code" label (above bottom text)
|
|
labelText := "^ Room Code ^"
|
|
labelSize := 21 // Increased by 5% from 20
|
|
font.HeightInPixels = labelSize
|
|
labelWidth, labelHeight := font.Measure(labelText)
|
|
labelX := (imageWidth - labelWidth) / 2
|
|
labelY := bottomTextY - 10 - labelHeight
|
|
|
|
// 2. Room code in center (largest text)
|
|
// Calculate available vertical space for the room code
|
|
// We'll reserve space at the top for the game title (calculate after room code)
|
|
tempTopMargin := 60 // Temporary estimate for title + spacing
|
|
availableTop := tempTopMargin
|
|
availableBottom := labelY - 20
|
|
availableHeight := availableBottom - availableTop
|
|
availableWidth := imageWidth - 60
|
|
|
|
// Find the largest font size that fits the room code
|
|
bestSize := 30
|
|
for size := 150; size >= 30; size -= 5 {
|
|
font.HeightInPixels = size
|
|
width, height := font.Measure(roomCode)
|
|
|
|
if width <= availableWidth && height <= availableHeight {
|
|
bestSize = size
|
|
break
|
|
}
|
|
}
|
|
|
|
// Calculate actual room code position
|
|
font.HeightInPixels = bestSize
|
|
_, codeHeight := font.Measure(roomCode)
|
|
|
|
// Room code is ALWAYS 4 characters, monospace font
|
|
// Calculate width of a single character and spread them evenly
|
|
singleCharWidth, _ := font.Measure("X") // Use X as reference for monospace width
|
|
totalCodeWidth := singleCharWidth * 4
|
|
|
|
codeX := (imageWidth - totalCodeWidth) / 2
|
|
codeY := availableTop + (availableHeight-codeHeight)/2
|
|
|
|
// 1. Game title at top - find largest font size that fits in remaining space
|
|
// Available space is from top of image to top of room code
|
|
maxTitleWidth := imageWidth - 40 // Leave 20px padding on each side
|
|
maxTitleHeight := codeY - 30 // Space from top (20px) to room code top (with 10px gap)
|
|
gameTitleSize := 14
|
|
|
|
// Helper function to split title into two lines at nearest whitespace to middle
|
|
splitTitle := func(title string) (string, string) {
|
|
words := strings.Fields(title)
|
|
if len(words) <= 1 {
|
|
return title, ""
|
|
}
|
|
|
|
// Find the split point closest to the middle
|
|
totalLen := len(title)
|
|
midPoint := totalLen / 2
|
|
bestSplit := 0
|
|
bestDist := totalLen
|
|
|
|
currentLen := 0
|
|
for i := 0; i < len(words)-1; i++ {
|
|
currentLen += len(words[i]) + 1 // +1 for space
|
|
dist := currentLen - midPoint
|
|
if dist < 0 {
|
|
dist = -dist
|
|
}
|
|
if dist < bestDist {
|
|
bestDist = dist
|
|
bestSplit = i + 1
|
|
}
|
|
}
|
|
|
|
line1 := strings.Join(words[:bestSplit], " ")
|
|
line2 := strings.Join(words[bestSplit:], " ")
|
|
return line1, line2
|
|
}
|
|
|
|
// Try single line first, starting at larger size
|
|
titleLines := []string{gameTitle}
|
|
var gameTitleHeight int
|
|
singleLineFits := false
|
|
|
|
for size := 40; size >= 28; size -= 2 {
|
|
font.HeightInPixels = size
|
|
titleWidth, titleHeight := font.Measure(gameTitle)
|
|
if titleWidth <= maxTitleWidth && titleHeight <= maxTitleHeight {
|
|
gameTitleSize = size
|
|
gameTitleHeight = titleHeight
|
|
singleLineFits = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// If single line doesn't fit at 28px or larger, try splitting into two lines
|
|
if !singleLineFits {
|
|
line1, line2 := splitTitle(gameTitle)
|
|
if line2 != "" {
|
|
titleLines = []string{line1, line2}
|
|
|
|
// Recalculate from maximum size with two lines - might fit larger now!
|
|
gameTitleSize = 14 // Reset to minimum
|
|
for size := 40; size >= 14; size -= 2 {
|
|
font.HeightInPixels = size
|
|
line1Width, line1Height := font.Measure(line1)
|
|
line2Width, line2Height := font.Measure(line2)
|
|
|
|
maxLineWidth := line1Width
|
|
if line2Width > maxLineWidth {
|
|
maxLineWidth = line2Width
|
|
}
|
|
totalHeight := line1Height + line2Height + 5 // 5px gap between lines
|
|
|
|
if maxLineWidth <= maxTitleWidth && totalHeight <= maxTitleHeight {
|
|
gameTitleSize = size
|
|
gameTitleHeight = totalHeight
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
// Single word that's too long, just shrink it
|
|
for size := 27; size >= 14; size -= 2 {
|
|
font.HeightInPixels = size
|
|
titleWidth, titleHeight := font.Measure(gameTitle)
|
|
if titleWidth <= maxTitleWidth && titleHeight <= maxTitleHeight {
|
|
gameTitleSize = size
|
|
gameTitleHeight = titleHeight
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate Y position (center vertically in available space)
|
|
gameTitleY := 20 + (codeY-30-20-gameTitleHeight)/2
|
|
|
|
// Calculate character positions - evenly spaced for 4 characters
|
|
// Room code is ALWAYS 4 characters, monospace font
|
|
charPositions := make([]int, 4)
|
|
for i := 0; i < 4; i++ {
|
|
charPositions[i] = codeX + (i * singleCharWidth)
|
|
}
|
|
|
|
// Create animated GIF frames
|
|
var frames []*image.Paletted
|
|
var delays []int
|
|
|
|
// Palette: Must include ALL colors used in the image
|
|
// - Black (background)
|
|
// - Shades for numberColor (room code animation)
|
|
// - Shades for letterColor (room code animation)
|
|
// - #EEEEEE (static text)
|
|
// - separatorColor (:: separator)
|
|
palette := make([]color.Color, 256)
|
|
|
|
// Index 0: Pure black (background)
|
|
palette[0] = color.RGBA{0, 0, 0, 255}
|
|
|
|
// Index 1: #EEEEEE (static text - game title, labels, bottom text)
|
|
palette[1] = color.RGBA{0xEE, 0xEE, 0xEE, 255}
|
|
|
|
// Index 2: separatorColor (:: separator)
|
|
palette[2] = separatorColor
|
|
|
|
// Indices 3-128: black to numberColor (for number animation)
|
|
for i := 0; i < 126; i++ {
|
|
progress := float64(i) / 125.0
|
|
r := uint8(progress * float64(numberColor.R))
|
|
g := uint8(progress * float64(numberColor.G))
|
|
b := uint8(progress * float64(numberColor.B))
|
|
palette[3+i] = color.RGBA{r, g, b, 255}
|
|
}
|
|
|
|
// Indices 129-255: black to letterColor (for letter animation)
|
|
for i := 0; i < 127; i++ {
|
|
progress := float64(i) / 126.0
|
|
r := uint8(progress * float64(letterColor.R))
|
|
g := uint8(progress * float64(letterColor.G))
|
|
b := uint8(progress * float64(letterColor.B))
|
|
palette[129+i] = color.RGBA{r, g, b, 255}
|
|
}
|
|
|
|
// Helper function to determine if a character is a letter
|
|
isLetter := func(ch rune) bool {
|
|
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
|
|
}
|
|
|
|
// Animation parameters
|
|
initialPauseFrames := 25 // Initial pause before animation starts (2.5 seconds at 10fps)
|
|
fadeFrames := 10 // Number of frames for fade-in (1 second at 10fps)
|
|
pauseFrames := 30 // Frames to pause between characters (3 seconds at 10fps)
|
|
frameDelay := 10 // 10/100 second = 0.1s per frame (10 fps)
|
|
|
|
// Helper function to draw a frame and convert to paletted
|
|
drawFrame := func(charIndex int, fadeProgress float64) *image.Paletted {
|
|
// Draw to RGBA first for proper alpha blending
|
|
rgba := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
|
|
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{black}, image.Point{}, draw.Src)
|
|
|
|
// STEP 1: Draw room code FIRST with animation (colored letters/numbers)
|
|
font.HeightInPixels = bestSize
|
|
|
|
// Draw all previous characters (fully visible)
|
|
for i := 0; i < charIndex; i++ {
|
|
ch := rune(roomCode[i])
|
|
if isLetter(ch) {
|
|
font.R, font.G, font.B, font.A = letterColor.R, letterColor.G, letterColor.B, 255
|
|
} else {
|
|
font.R, font.G, font.B, font.A = numberColor.R, numberColor.G, numberColor.B, 255
|
|
}
|
|
font.Write(rgba, string(roomCode[i]), charPositions[i], codeY)
|
|
}
|
|
|
|
// Draw current character (fading in) using manual alpha blending
|
|
if charIndex < len(roomCode) && fadeProgress > 0 {
|
|
// Draw the character to a temporary image at full opacity
|
|
tempImg := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
|
|
draw.Draw(tempImg, tempImg.Bounds(), &image.Uniform{color.RGBA{0, 0, 0, 0}}, image.Point{}, draw.Src)
|
|
|
|
ch := rune(roomCode[charIndex])
|
|
if isLetter(ch) {
|
|
font.R, font.G, font.B, font.A = letterColor.R, letterColor.G, letterColor.B, 255
|
|
} else {
|
|
font.R, font.G, font.B, font.A = numberColor.R, numberColor.G, numberColor.B, 255
|
|
}
|
|
font.Write(tempImg, string(roomCode[charIndex]), charPositions[charIndex], codeY)
|
|
|
|
// Manually blend the character onto the main image with fadeProgress alpha
|
|
targetAlpha := uint8(fadeProgress * 255)
|
|
for y := 0; y < imageHeight; y++ {
|
|
for x := 0; x < imageWidth; x++ {
|
|
srcColor := tempImg.RGBAAt(x, y)
|
|
if srcColor.A > 0 {
|
|
// Apply fade alpha to the source color
|
|
dstColor := rgba.RGBAAt(x, y)
|
|
|
|
// Alpha blending formula
|
|
alpha := uint32(srcColor.A) * uint32(targetAlpha) / 255
|
|
invAlpha := 255 - alpha
|
|
|
|
r := (uint32(srcColor.R)*alpha + uint32(dstColor.R)*invAlpha) / 255
|
|
g := (uint32(srcColor.G)*alpha + uint32(dstColor.G)*invAlpha) / 255
|
|
b := (uint32(srcColor.B)*alpha + uint32(dstColor.B)*invAlpha) / 255
|
|
|
|
rgba.SetRGBA(x, y, color.RGBA{uint8(r), uint8(g), uint8(b), 255})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// STEP 2: Draw static text elements ON TOP (always visible, same on every frame)
|
|
|
|
// 1. Game title at top (off-white #EEEEEE) - may be 1 or 2 lines
|
|
font.HeightInPixels = gameTitleSize
|
|
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
|
|
|
if len(titleLines) == 1 {
|
|
// Single line - use pre-calculated position
|
|
lineWidth, _ := font.Measure(titleLines[0])
|
|
lineX := (imageWidth - lineWidth) / 2
|
|
font.Write(rgba, titleLines[0], lineX, gameTitleY)
|
|
} else {
|
|
// Two lines
|
|
line1Width, line1Height := font.Measure(titleLines[0])
|
|
line2Width, _ := font.Measure(titleLines[1])
|
|
|
|
line1X := (imageWidth - line1Width) / 2
|
|
line2X := (imageWidth - line2Width) / 2
|
|
|
|
font.Write(rgba, titleLines[0], line1X, gameTitleY)
|
|
font.Write(rgba, titleLines[1], line2X, gameTitleY+line1Height+5) // 5px gap
|
|
}
|
|
|
|
// 3. "^ Room Code ^" label (off-white #EEEEEE)
|
|
font.HeightInPixels = labelSize
|
|
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
|
font.Write(rgba, labelText, labelX, labelY)
|
|
|
|
// 4. Bottom text with colored separator
|
|
font.HeightInPixels = bottomTextSize
|
|
|
|
// Left part (off-white #EEEEEE)
|
|
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
|
font.Write(rgba, bottomTextLeft, bottomTextX, bottomTextY)
|
|
|
|
// Separator (separatorColor - from the color pair)
|
|
font.R, font.G, font.B, font.A = separatorColor.R, separatorColor.G, separatorColor.B, 255
|
|
font.Write(rgba, bottomTextSeparator, bottomTextX+leftWidth, bottomTextY)
|
|
|
|
// Right part (off-white #EEEEEE)
|
|
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
|
|
font.Write(rgba, bottomTextRight, bottomTextX+leftWidth+sepWidth, bottomTextY)
|
|
|
|
// Convert RGBA to paletted
|
|
paletted := image.NewPaletted(rgba.Bounds(), palette)
|
|
draw.FloydSteinberg.Draw(paletted, rgba.Bounds(), rgba, image.Point{})
|
|
return paletted
|
|
}
|
|
|
|
// Generate initial pause frames (just label, no characters)
|
|
for i := 0; i < initialPauseFrames; i++ {
|
|
frames = append(frames, drawFrame(0, 0))
|
|
delays = append(delays, frameDelay)
|
|
}
|
|
|
|
// Generate frames
|
|
for charIndex := 0; charIndex < len(roomCode); charIndex++ {
|
|
// Fade-in frames for current character
|
|
for fadeFrame := 0; fadeFrame < fadeFrames; fadeFrame++ {
|
|
fadeProgress := float64(fadeFrame+1) / float64(fadeFrames)
|
|
frames = append(frames, drawFrame(charIndex, fadeProgress))
|
|
delays = append(delays, frameDelay)
|
|
}
|
|
|
|
// Pause frames (hold current state with character fully visible)
|
|
for pauseFrame := 0; pauseFrame < pauseFrames; pauseFrame++ {
|
|
frames = append(frames, drawFrame(charIndex+1, 0))
|
|
delays = append(delays, frameDelay)
|
|
}
|
|
}
|
|
|
|
// Encode as GIF (loop forever since LoopCount is unreliable)
|
|
var buf bytes.Buffer
|
|
err = gif.EncodeAll(&buf, &gif.GIF{
|
|
Image: frames,
|
|
Delay: delays,
|
|
// Omit LoopCount entirely - let it loop forever (most reliable)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|