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 }