diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 0b53928..7c5e200 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -3,7 +3,9 @@ package misc import ( "bytes" "context" + "image" "image/gif" + "math" "strings" "testing" "time" @@ -290,6 +292,51 @@ func TestWheelOfNamesBeta_CurrentOptionTracksPointer(t *testing.T) { } } +func TestWheelOfNamesBeta_PointerPointsIntoWheel(t *testing.T) { + img := renderWheelBetaFrame([]string{"Alice", "Bob"}, 0, finalWheelRotation(2, 0), false) + cx := wheelBetaSize / 2 + tipY := wheelBetaSize/2 - wheelBetaRadius + if got := img.ColorIndexAt(cx, tipY); got != 1 { + t.Fatalf("pointer tip color = %d, want 1", got) + } + if got := img.ColorIndexAt(cx+5, tipY-10); got != 1 { + t.Fatalf("pointer shoulder color = %d, want 1", got) + } + if got := img.ColorIndexAt(cx+5, tipY); got == 1 { + t.Fatalf("pointer tip is too wide at color index %d", got) + } + if got := img.ColorIndexAt(cx+12, tipY+20); got == 1 { + t.Fatalf("pointer widens below wheel edge at color index %d", got) + } +} + +func TestWheelOfNamesBeta_DrawsOptionLabelsInsideSlices(t *testing.T) { + options := []string{"Student", "Teacher", "Parent", "Staff"} + rotation := 0.0 + img := renderWheelBetaFrame(options, 0, rotation, false) + segment := 2 * math.Pi / float64(len(options)) + angle := rotation + segment/2 + centerX := wheelBetaSize/2 + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius))) + baselineY := wheelBetaSize/2 + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius))) + wheelBetaSliceLabelBaselineOffset + bounds := image.Rect(centerX-28, baselineY-14, centerX+28, baselineY+2) + if got := countColorIndex(img, bounds, 1); got == 0 { + t.Fatalf("slice label dark pixels = %d, want > 0", got) + } +} + +func countColorIndex(img *image.Paletted, bounds image.Rectangle, colorIndex byte) int { + bounds = bounds.Intersect(img.Bounds()) + count := 0 + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if img.ColorIndexAt(x, y) == colorIndex { + count++ + } + } + } + return count +} + func TestWheelOfNamesBeta_SendsAnimationWithoutSpoilingCaption(t *testing.T) { rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnamesbeta Alice")) diff --git a/internal/modules/misc/wheelofnames_beta.go b/internal/modules/misc/wheelofnames_beta.go index ee89cab..ade5638 100644 --- a/internal/modules/misc/wheelofnames_beta.go +++ b/internal/modules/misc/wheelofnames_beta.go @@ -8,11 +8,6 @@ import ( "image/draw" "image/gif" "math" - "strings" - - "golang.org/x/image/font" - "golang.org/x/image/font/basicfont" - "golang.org/x/image/math/fixed" ) const ( @@ -102,14 +97,15 @@ func renderWheelBetaFrame(options []string, winner int, rotation float64, reveal } } + drawWheelSliceLabels(img, options, rotation) drawCircle(img, cx, cy, 24, 2) drawCircle(img, cx, cy, 18, 1) - drawPointer(img, cx, cy-wheelBetaRadius-10) + drawPointer(img, cx, cy-wheelBetaRadius) drawCenteredText(img, "WHEELOFNAMES BETA", cy+wheelBetaRadius+34, 1) label := "CURRENT" value := asciiWheelText(options[currentWheelBetaIndex(len(options), rotation)], 28) if reveal { - label = "WINNER" + label = "RESULT" value = asciiWheelText(options[winner], 28) } drawStatusBand(img, label, value) @@ -133,66 +129,3 @@ func normalizeAngle(theta float64) float64 { } return theta } - -func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) { - r2 := radius * radius - for y := cy - radius; y <= cy+radius; y++ { - for x := cx - radius; x <= cx+radius; x++ { - dx, dy := x-cx, y-cy - if dx*dx+dy*dy <= r2 { - img.SetColorIndex(x, y, colorIndex) - } - } - } -} - -func drawPointer(img *image.Paletted, cx, tipY int) { - for y := 0; y < 34; y++ { - half := y / 2 - for x := cx - half; x <= cx+half; x++ { - img.SetColorIndex(x, tipY+y, 1) - } - } -} - -func drawStatusBand(img *image.Paletted, label, value string) { - for y := 230; y < 282; y++ { - for x := 28; x < wheelBetaSize-28; x++ { - img.SetColorIndex(x, y, 2) - } - } - drawCenteredText(img, label, 250, 1) - drawCenteredText(img, value, 270, 1) -} - -func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) { - face := basicfont.Face7x13 - width := font.MeasureString(face, text).Ceil() - x := (wheelBetaSize - width) / 2 - drawer := font.Drawer{ - Dst: img, - Src: image.NewUniform(wheelBetaPalette[colorIndex]), - Face: face, - Dot: fixed.P(x, baselineY), - } - drawer.DrawString(text) -} - -func asciiWheelText(s string, limit int) string { - var b strings.Builder - for _, r := range s { - if b.Len() >= limit { - break - } - if r < 32 || r > 126 { - b.WriteByte('?') - continue - } - b.WriteRune(r) - } - out := strings.TrimSpace(b.String()) - if out == "" { - return "winner" - } - return out -} diff --git a/internal/modules/misc/wheelofnames_beta_drawing.go b/internal/modules/misc/wheelofnames_beta_drawing.go new file mode 100644 index 0000000..0b94d1e --- /dev/null +++ b/internal/modules/misc/wheelofnames_beta_drawing.go @@ -0,0 +1,114 @@ +package misc + +import ( + "image" + "math" + "strings" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +const ( + wheelBetaSliceLabelRadius = 64 + wheelBetaSliceLabelBaselineOffset = 5 +) + +func drawWheelSliceLabels(img *image.Paletted, options []string, rotation float64) { + if len(options) == 0 { + return + } + cx, cy := wheelBetaSize/2, wheelBetaSize/2 + segment := 2 * math.Pi / float64(len(options)) + limit := wheelBetaSliceLabelLimit(len(options)) + for idx, option := range options { + angle := rotation + (float64(idx)+0.5)*segment + centerX := cx + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius))) + baselineY := cy + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius))) + wheelBetaSliceLabelBaselineOffset + text := asciiWheelText(option, limit) + drawCenteredTextAt(img, text, centerX+1, baselineY+1, 2) + drawCenteredTextAt(img, text, centerX, baselineY, 1) + } +} + +func wheelBetaSliceLabelLimit(optionCount int) int { + switch { + case optionCount <= 2: + return 14 + case optionCount <= 4: + return 11 + case optionCount <= 6: + return 8 + default: + return 6 + } +} + +func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) { + r2 := radius * radius + for y := cy - radius; y <= cy+radius; y++ { + for x := cx - radius; x <= cx+radius; x++ { + dx, dy := x-cx, y-cy + if dx*dx+dy*dy <= r2 { + img.SetColorIndex(x, y, colorIndex) + } + } + } +} + +func drawPointer(img *image.Paletted, cx, tipY int) { + for y := 0; y < 34; y++ { + half := y / 2 + rowY := tipY - y + for x := cx - half; x <= cx+half; x++ { + img.SetColorIndex(x, rowY, 1) + } + } +} + +func drawStatusBand(img *image.Paletted, label, value string) { + for y := 230; y < 282; y++ { + for x := 28; x < wheelBetaSize-28; x++ { + img.SetColorIndex(x, y, 2) + } + } + drawCenteredText(img, label, 250, 1) + drawCenteredText(img, value, 270, 1) +} + +func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) { + drawCenteredTextAt(img, text, wheelBetaSize/2, baselineY, colorIndex) +} + +func drawCenteredTextAt(img *image.Paletted, text string, centerX, baselineY int, colorIndex byte) { + face := basicfont.Face7x13 + width := font.MeasureString(face, text).Ceil() + x := centerX - width/2 + drawer := font.Drawer{ + Dst: img, + Src: image.NewUniform(wheelBetaPalette[colorIndex]), + Face: face, + Dot: fixed.P(x, baselineY), + } + drawer.DrawString(text) +} + +func asciiWheelText(s string, limit int) string { + var b strings.Builder + for _, r := range s { + if b.Len() >= limit { + break + } + if r < 32 || r > 126 { + b.WriteByte('?') + continue + } + b.WriteRune(r) + } + out := strings.TrimSpace(b.String()) + if out == "" { + return "winner" + } + return out +}