feat(misc): improve wheel beta spin animation

This commit is contained in:
2026-07-06 13:12:22 +07:00
parent 9b728aa41e
commit 924bc6322d
4 changed files with 211 additions and 9 deletions
+44
View File
@@ -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
+17 -9
View File
@@ -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 {
@@ -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
@@ -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)
}