From 924bc6322d68bcbfc757f9cf3ffe6c2e1b5b143f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 6 Jul 2026 13:12:22 +0700 Subject: [PATCH] feat(misc): improve wheel beta spin animation --- internal/modules/misc/handlers_test.go | 44 ++++++ internal/modules/misc/wheelofnames_beta.go | 26 ++-- .../modules/misc/wheelofnames_beta_drawing.go | 20 +++ .../misc/wheelofnames_beta_spin_profile.go | 130 ++++++++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 internal/modules/misc/wheelofnames_beta_spin_profile.go diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 59889ca..557c164 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -6,6 +6,7 @@ import ( "image" "image/gif" "math" + "math/rand/v2" "strings" "testing" "time" @@ -310,6 +311,49 @@ func TestWheelOfNamesBeta_CurrentOptionTracksPointer(t *testing.T) { } } +func TestWheelOfNamesBeta_RandomSpinProfileKeepsWinnerUnderPointer(t *testing.T) { + rng := rand.New(rand.NewPCG(1, 2)) + for optionCount := 2; optionCount <= 10; optionCount++ { + for winner := 0; winner < optionCount; winner++ { + for spin := 0; spin < 20; spin++ { + profile := newWheelBetaSpinProfile(optionCount, winner, rng) + if got := currentWheelBetaIndex(optionCount, profile.finalRotation); got != winner { + t.Fatalf("optionCount=%d winner=%d spin=%d final index = %d", optionCount, winner, spin, got) + } + if got := profile.rotationAt(1); math.Abs(got-profile.finalRotation) > 1e-9 { + t.Fatalf("rotationAt(1) = %f, want final %f", got, profile.finalRotation) + } + } + } + } +} + +func TestWheelOfNamesBeta_SpinProfileVariesBetweenSpins(t *testing.T) { + rng := rand.New(rand.NewPCG(10, 20)) + first := newWheelBetaSpinProfile(5, 2, rng) + second := newWheelBetaSpinProfile(5, 2, rng) + if first.startRotation == second.startRotation && + first.finalRotation == second.finalRotation && + first.accelEnd == second.accelEnd && + first.coastEnd == second.coastEnd && + first.wobblePhase == second.wobblePhase { + t.Fatalf("spin profiles did not vary: %+v", first) + } +} + +func TestWheelOfNamesBeta_SpinProfileProgressIsMonotonic(t *testing.T) { + rng := rand.New(rand.NewPCG(30, 40)) + profile := newWheelBetaSpinProfile(6, 4, rng) + prev := -1.0 + for i := 0; i <= 100; i++ { + progress := profile.progressAt(float64(i) / 100) + if progress < prev { + t.Fatalf("progress at step %d = %f, previous %f", i, progress, prev) + } + prev = progress + } +} + func TestWheelOfNamesBeta_PointerPointsIntoWheel(t *testing.T) { img := renderWheelBetaFrame([]string{"Alice", "Bob"}, 0, finalWheelRotation(2, 0), false) cx := wheelBetaSize / 2 diff --git a/internal/modules/misc/wheelofnames_beta.go b/internal/modules/misc/wheelofnames_beta.go index 964b1dd..87b59a3 100644 --- a/internal/modules/misc/wheelofnames_beta.go +++ b/internal/modules/misc/wheelofnames_beta.go @@ -47,17 +47,13 @@ func renderWheelOfNamesBetaGIF(options []string, winner int) ([]byte, error) { frames := make([]*image.Paletted, 0, wheelBetaSpinFrames+wheelBetaHoldFrames) delays := make([]int, 0, wheelBetaSpinFrames+wheelBetaHoldFrames) - finalRotation := finalWheelRotation(len(options), winner) - startRotation := finalRotation - 8*2*math.Pi + profile := newWheelBetaSpinProfile(len(options), winner, nil) for i := 0; i < wheelBetaSpinFrames; i++ { t := float64(i) / float64(wheelBetaSpinFrames-1) - remaining := 1 - t - progress := 1 - remaining*remaining*remaining - rotation := startRotation + (finalRotation-startRotation)*progress - frames = append(frames, renderWheelBetaFrame(options, winner, rotation, false)) + frames = append(frames, renderWheelBetaFrameWithStatus(options, winner, profile.rotationAt(t), false, profile.statusAt(t))) delays = append(delays, wheelBetaSpinDelay) } - resultFrame := renderWheelBetaFrame(options, winner, finalRotation, true) + resultFrame := renderWheelBetaFrame(options, winner, profile.finalRotation, true) for i := 0; i < wheelBetaHoldFrames; i++ { frames = append(frames, resultFrame) delays = append(delays, wheelBetaHoldDelay) @@ -81,6 +77,10 @@ func renderWheelOfNamesBetaGIF(options []string, winner int) ([]byte, error) { } func renderWheelBetaFrame(options []string, winner int, rotation float64, reveal bool) *image.Paletted { + return renderWheelBetaFrameWithStatus(options, winner, rotation, reveal, "") +} + +func renderWheelBetaFrameWithStatus(options []string, winner int, rotation float64, reveal bool, status string) *image.Paletted { rect := image.Rect(0, 0, wheelBetaSize, wheelBetaSize) img := image.NewPaletted(rect, wheelBetaPalette) draw.Draw(img, rect, image.NewUniform(wheelBetaPalette[0]), image.Point{}, draw.Src) @@ -101,12 +101,16 @@ func renderWheelBetaFrame(options []string, winner int, rotation float64, reveal } } + drawWheelRim(img, cx, cy) drawWheelSliceLabels(img, options, rotation) drawCircle(img, cx, cy, 24, 2) drawCircle(img, cx, cy, 18, 1) drawPointer(img, cx, cy-wheelBetaRadius) drawCenteredText(img, "WHEELOFNAMES BETA", cy+wheelBetaRadius+34, 1) - label := "CURRENT" + label := status + if label == "" { + label = "CURRENT" + } value := asciiWheelText(options[currentWheelBetaIndex(len(options), rotation)], 28) if reveal { label = "RESULT" @@ -117,8 +121,12 @@ func renderWheelBetaFrame(options []string, winner int, rotation float64, reveal } func finalWheelRotation(optionCount, winner int) float64 { + return finalWheelRotationWithOffset(optionCount, winner, 0) +} + +func finalWheelRotationWithOffset(optionCount, winner int, sliceOffset float64) float64 { segment := 2 * math.Pi / float64(optionCount) - return -math.Pi/2 - (float64(winner)+0.5)*segment + return -math.Pi/2 - (float64(winner)+0.5+sliceOffset)*segment } func currentWheelBetaIndex(optionCount int, rotation float64) int { diff --git a/internal/modules/misc/wheelofnames_beta_drawing.go b/internal/modules/misc/wheelofnames_beta_drawing.go index 0b94d1e..c789ea3 100644 --- a/internal/modules/misc/wheelofnames_beta_drawing.go +++ b/internal/modules/misc/wheelofnames_beta_drawing.go @@ -57,6 +57,26 @@ func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) { } } +func drawWheelRim(img *image.Paletted, cx, cy int) { + drawCircleOutline(img, cx, cy, wheelBetaRadius, 3, 1) + drawCircleOutline(img, cx, cy, wheelBetaRadius-6, 1, 2) +} + +func drawCircleOutline(img *image.Paletted, cx, cy, radius, thickness int, colorIndex byte) { + outer := radius * radius + innerRadius := radius - thickness + inner := innerRadius * innerRadius + for y := cy - radius; y <= cy+radius; y++ { + for x := cx - radius; x <= cx+radius; x++ { + dx, dy := x-cx, y-cy + d2 := dx*dx + dy*dy + if d2 <= outer && d2 >= inner { + img.SetColorIndex(x, y, colorIndex) + } + } + } +} + func drawPointer(img *image.Paletted, cx, tipY int) { for y := 0; y < 34; y++ { half := y / 2 diff --git a/internal/modules/misc/wheelofnames_beta_spin_profile.go b/internal/modules/misc/wheelofnames_beta_spin_profile.go new file mode 100644 index 0000000..b32434f --- /dev/null +++ b/internal/modules/misc/wheelofnames_beta_spin_profile.go @@ -0,0 +1,130 @@ +package misc + +import ( + "math" + "math/rand/v2" +) + +const ( + wheelBetaMinRevolutions = 7 + wheelBetaRandomRevolutionRange = 5 + wheelBetaMaxLandingOffset = 0.34 +) + +type wheelBetaSpinProfile struct { + startRotation float64 + finalRotation float64 + accelEnd float64 + coastEnd float64 + wobbleAmplitude float64 + wobbleCycles float64 + wobblePhase float64 +} + +func newWheelBetaSpinProfile(optionCount, winner int, rng *rand.Rand) wheelBetaSpinProfile { + segment := 2 * math.Pi / float64(optionCount) + landingOffset := (wheelBetaRandFloat64(rng)*2 - 1) * wheelBetaMaxLandingOffset + finalRotation := finalWheelRotationWithOffset(optionCount, winner, landingOffset) + revolutions := wheelBetaMinRevolutions + wheelBetaRandIntN(rng, wheelBetaRandomRevolutionRange) + accelEnd := 0.12 + wheelBetaRandFloat64(rng)*0.1 + coastEnd := accelEnd + 0.18 + wheelBetaRandFloat64(rng)*0.18 + if coastEnd > 0.58 { + coastEnd = 0.58 + } + return wheelBetaSpinProfile{ + startRotation: finalRotation - float64(revolutions)*2*math.Pi, + finalRotation: finalRotation, + accelEnd: accelEnd, + coastEnd: coastEnd, + wobbleAmplitude: math.Min(segment*0.075, 0.09) * (0.55 + wheelBetaRandFloat64(rng)*0.45), + wobbleCycles: 3.5 + wheelBetaRandFloat64(rng)*2.5, + wobblePhase: wheelBetaRandFloat64(rng) * 2 * math.Pi, + } +} + +func (p wheelBetaSpinProfile) rotationAt(t float64) float64 { + t = clampWheelBetaProgress(t) + progress := p.progressAt(t) + rotation := p.startRotation + (p.finalRotation-p.startRotation)*progress + return rotation + p.wobbleAt(t) +} + +func (p wheelBetaSpinProfile) statusAt(t float64) string { + switch { + case t < p.accelEnd: + return "BUILDING SPEED" + case t < p.coastEnd: + return "FULL SPIN" + case t < 0.82: + return "HEAVY SLOWDOWN" + case t < 0.96: + return "LOCKING IN" + default: + return "LAST TICK" + } +} + +func (p wheelBetaSpinProfile) progressAt(t float64) float64 { + t = clampWheelBetaProgress(t) + accelEnd := p.accelEnd + coastEnd := p.coastEnd + if accelEnd <= 0 || coastEnd <= accelEnd || coastEnd >= 1 { + return 1 - math.Pow(1-t, 3) + } + + accelArea := accelEnd * 0.5 + coastArea := coastEnd - accelEnd + decelDuration := 1 - coastEnd + decelArea := decelDuration * 0.25 + totalArea := accelArea + coastArea + decelArea + + var distance float64 + switch { + case t < accelEnd: + u := t / accelEnd + distance = accelEnd * (u*u*u - 0.5*u*u*u*u) + case t < coastEnd: + distance = accelArea + (t - accelEnd) + default: + u := (t - coastEnd) / decelDuration + distance = accelArea + coastArea + decelDuration*(u-1.5*u*u+u*u*u-0.25*u*u*u*u) + } + return distance / totalArea +} + +func (p wheelBetaSpinProfile) wobbleAt(t float64) float64 { + if t <= p.coastEnd || t >= 1 { + return 0 + } + u := (t - p.coastEnd) / (1 - p.coastEnd) + envelope := math.Sin(u*math.Pi) * math.Pow(1-u, 1.4) + return p.wobbleAmplitude * envelope * math.Sin(p.wobblePhase+u*p.wobbleCycles*2*math.Pi) +} + +func clampWheelBetaProgress(t float64) float64 { + switch { + case t < 0: + return 0 + case t > 1: + return 1 + default: + return t + } +} + +func wheelBetaRandFloat64(rng *rand.Rand) float64 { + if rng != nil { + return rng.Float64() + } + return rand.Float64() +} + +func wheelBetaRandIntN(rng *rand.Rand, n int) int { + if n <= 0 { + return 0 + } + if rng != nil { + return rng.IntN(n) + } + return rand.IntN(n) +}