From e4b24f03e4ba51ffe5f01077dbadbf493ab030d4 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 6 Jul 2026 13:48:51 +0700 Subject: [PATCH] feat(misc): polish wheel beta animation --- internal/modules/misc/handlers_test.go | 15 +- internal/modules/misc/wheelofnames_beta.go | 62 +++++-- .../modules/misc/wheelofnames_beta_drawing.go | 171 +++++++++++++++++- .../misc/wheelofnames_beta_spin_profile.go | 52 +++--- 4 files changed, 240 insertions(+), 60 deletions(-) diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 557c164..9f0657f 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -285,9 +285,13 @@ func TestWheelOfNamesBeta_RenderGIFTiming(t *testing.T) { if equalPalettedFrames(decoded.Image[wheelBetaSpinFrames-1], decoded.Image[wheelBetaSpinFrames]) { t.Fatalf("first result frame matches last spin frame, want visible RESULT transition") } - for i := wheelBetaSpinFrames + 1; i < len(decoded.Image); i++ { - if !equalPalettedFrames(decoded.Image[wheelBetaSpinFrames], decoded.Image[i]) { - t.Fatalf("result hold frame %d differs from first result frame", i) + if equalPalettedFrames(decoded.Image[wheelBetaSpinFrames], decoded.Image[wheelBetaSpinFrames+1]) { + t.Fatalf("first celebration frame matches second celebration frame, want visible result burst") + } + firstStableHoldFrame := wheelBetaSpinFrames + wheelBetaCelebrateFrames + for i := firstStableHoldFrame + 1; i < len(decoded.Image); i++ { + if !equalPalettedFrames(decoded.Image[firstStableHoldFrame], decoded.Image[i]) { + t.Fatalf("stable result hold frame %d differs from frame %d", i, firstStableHoldFrame) } } if decoded.LoopCount != -1 { @@ -335,7 +339,7 @@ func TestWheelOfNamesBeta_SpinProfileVariesBetweenSpins(t *testing.T) { if first.startRotation == second.startRotation && first.finalRotation == second.finalRotation && first.accelEnd == second.accelEnd && - first.coastEnd == second.coastEnd && + first.decelSharpness == second.decelSharpness && first.wobblePhase == second.wobblePhase { t.Fatalf("spin profiles did not vary: %+v", first) } @@ -364,6 +368,9 @@ func TestWheelOfNamesBeta_PointerPointsIntoWheel(t *testing.T) { if got := img.ColorIndexAt(cx+5, tipY-10); got != 1 { t.Fatalf("pointer shoulder color = %d, want 1", got) } + if got := img.ColorIndexAt(cx, tipY-12); got != wheelBetaSliceColorIndexes[0] { + t.Fatalf("pointer body color = %d, want current slice color %d", got, wheelBetaSliceColorIndexes[0]) + } if got := img.ColorIndexAt(cx+5, tipY); got == 1 { t.Fatalf("pointer tip is too wide at color index %d", got) } diff --git a/internal/modules/misc/wheelofnames_beta.go b/internal/modules/misc/wheelofnames_beta.go index 87b59a3..734fcb2 100644 --- a/internal/modules/misc/wheelofnames_beta.go +++ b/internal/modules/misc/wheelofnames_beta.go @@ -11,15 +11,26 @@ import ( ) const ( - wheelBetaSize = 320 - wheelBetaRadius = 118 - wheelBetaSpinDuration = 7 - wheelBetaHoldDuration = 3 - wheelBetaSpinDelay = 20 - wheelBetaSpinFrames = wheelBetaSpinDuration * 100 / wheelBetaSpinDelay - wheelBetaHoldFrames = wheelBetaHoldDuration * 100 / wheelBetaSpinDelay - wheelBetaHoldDelay = wheelBetaSpinDelay - wheelBetaDuration = wheelBetaSpinDuration + wheelBetaHoldDuration + wheelBetaSize = 320 + wheelBetaRadius = 118 + wheelBetaSpinDuration = 7 + wheelBetaHoldDuration = 3 + wheelBetaSpinDelay = 20 + wheelBetaSpinFrames = wheelBetaSpinDuration * 100 / wheelBetaSpinDelay + wheelBetaHoldFrames = wheelBetaHoldDuration * 100 / wheelBetaSpinDelay + wheelBetaHoldDelay = wheelBetaSpinDelay + wheelBetaDuration = wheelBetaSpinDuration + wheelBetaHoldDuration + wheelBetaCelebrateFrames = 8 +) + +const ( + wheelBetaBackgroundColorIndex byte = 0 + wheelBetaInkColorIndex byte = 1 + wheelBetaPaperColorIndex byte = 2 + wheelBetaShadowColorIndex byte = 10 + wheelBetaBevelColorIndex byte = 11 + wheelBetaHighlightColorIndex byte = 12 + wheelBetaSparkColorIndex byte = 13 ) var wheelBetaPalette = color.Palette{ @@ -33,6 +44,10 @@ var wheelBetaPalette = color.Palette{ color.RGBA{R: 147, G: 103, B: 196, A: 255}, color.RGBA{R: 52, G: 197, B: 197, A: 255}, color.RGBA{R: 235, G: 117, B: 164, A: 255}, + color.RGBA{R: 204, G: 212, B: 224, A: 255}, + color.RGBA{R: 82, G: 94, B: 111, A: 255}, + color.RGBA{R: 255, G: 244, B: 206, A: 255}, + color.RGBA{R: 255, G: 218, B: 89, A: 255}, } var wheelBetaSliceColorIndexes = []byte{3, 4, 5, 6, 7, 8, 9} @@ -53,9 +68,12 @@ func renderWheelOfNamesBetaGIF(options []string, winner int) ([]byte, error) { frames = append(frames, renderWheelBetaFrameWithStatus(options, winner, profile.rotationAt(t), false, profile.statusAt(t))) delays = append(delays, wheelBetaSpinDelay) } - resultFrame := renderWheelBetaFrame(options, winner, profile.finalRotation, true) for i := 0; i < wheelBetaHoldFrames; i++ { - frames = append(frames, resultFrame) + celebrateStep := i + if celebrateStep >= wheelBetaCelebrateFrames { + celebrateStep = -1 + } + frames = append(frames, renderWheelBetaFrameWithCelebration(options, winner, profile.finalRotation, true, "", celebrateStep)) delays = append(delays, wheelBetaHoldDelay) } @@ -81,11 +99,17 @@ func renderWheelBetaFrame(options []string, winner int, rotation float64, reveal } func renderWheelBetaFrameWithStatus(options []string, winner int, rotation float64, reveal bool, status string) *image.Paletted { + return renderWheelBetaFrameWithCelebration(options, winner, rotation, reveal, status, -1) +} + +func renderWheelBetaFrameWithCelebration(options []string, winner int, rotation float64, reveal bool, status string, celebrateStep int) *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) + draw.Draw(img, rect, image.NewUniform(wheelBetaPalette[wheelBetaBackgroundColorIndex]), image.Point{}, draw.Src) cx, cy := wheelBetaSize/2, wheelBetaSize/2 + drawWheelDropShadow(img, cx, cy) + segment := 2 * math.Pi / float64(len(options)) r2 := wheelBetaRadius * wheelBetaRadius for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ { @@ -101,17 +125,21 @@ func renderWheelBetaFrameWithStatus(options []string, winner int, rotation float } } + currentIndex := currentWheelBetaIndex(len(options), rotation) + pointerColor := wheelBetaSliceColorIndexes[currentIndex%len(wheelBetaSliceColorIndexes)] + drawWheelLighting(img, cx, cy) drawWheelRim(img, cx, cy) + drawWinnerCelebration(img, celebrateStep) 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) + drawCircle(img, cx, cy, 24, wheelBetaPaperColorIndex) + drawCircle(img, cx, cy, 18, wheelBetaInkColorIndex) + drawPointer(img, cx, cy-wheelBetaRadius, pointerColor) + drawCenteredText(img, "WHEELOFNAMES BETA", cy+wheelBetaRadius+34, wheelBetaInkColorIndex) label := status if label == "" { label = "CURRENT" } - value := asciiWheelText(options[currentWheelBetaIndex(len(options), rotation)], 28) + value := asciiWheelText(options[currentIndex], 28) if reveal { label = "RESULT" value = asciiWheelText(options[winner], 28) diff --git a/internal/modules/misc/wheelofnames_beta_drawing.go b/internal/modules/misc/wheelofnames_beta_drawing.go index c789ea3..7ed7092 100644 --- a/internal/modules/misc/wheelofnames_beta_drawing.go +++ b/internal/modules/misc/wheelofnames_beta_drawing.go @@ -57,9 +57,62 @@ func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) { } } +func drawWheelDropShadow(img *image.Paletted, cx, cy int) { + rx := wheelBetaRadius + 9 + ry := wheelBetaRadius + 5 + shadowCY := cy + 8 + limit := rx * rx * ry * ry + for y := shadowCY - ry; y <= shadowCY+ry; y++ { + for x := cx - rx; x <= cx+rx; x++ { + dx := x - cx + dy := y - shadowCY + if dx*dx*ry*ry+dy*dy*rx*rx <= limit { + setWheelBetaPixel(img, x, y, wheelBetaShadowColorIndex) + } + } + } +} + +func drawWheelLighting(img *image.Paletted, cx, cy int) { + outer := wheelBetaRadius * wheelBetaRadius + edgeStart := wheelBetaRadius - 13 + edge := edgeStart * edgeStart + for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ { + for x := cx - wheelBetaRadius; x <= cx+wheelBetaRadius; x++ { + dx, dy := x-cx, y-cy + d2 := dx*dx + dy*dy + if d2 > outer || d2 < edge { + continue + } + if dx+dy > wheelBetaRadius/3 { + img.SetColorIndex(x, y, wheelBetaBevelColorIndex) + } + if dx+dy < -wheelBetaRadius { + img.SetColorIndex(x, y, wheelBetaHighlightColorIndex) + } + } + } + + drawHighlightOval(img, cx-30, cy-50, 44, 18) +} + +func drawHighlightOval(img *image.Paletted, cx, cy, rx, ry int) { + limit := rx * rx * ry * ry + for y := cy - ry; y <= cy+ry; y++ { + for x := cx - rx; x <= cx+rx; x++ { + dx := x - cx + dy := y - cy + if dx*dx*ry*ry+dy*dy*rx*rx <= limit { + setWheelBetaPixel(img, x, y, wheelBetaHighlightColorIndex) + } + } + } +} + func drawWheelRim(img *image.Paletted, cx, cy int) { - drawCircleOutline(img, cx, cy, wheelBetaRadius, 3, 1) - drawCircleOutline(img, cx, cy, wheelBetaRadius-6, 1, 2) + drawCircleOutline(img, cx, cy, wheelBetaRadius, 3, wheelBetaInkColorIndex) + drawCircleOutline(img, cx, cy, wheelBetaRadius-5, 1, wheelBetaBevelColorIndex) + drawCircleOutline(img, cx, cy, wheelBetaRadius-8, 1, wheelBetaHighlightColorIndex) } func drawCircleOutline(img *image.Paletted, cx, cy, radius, thickness int, colorIndex byte) { @@ -77,24 +130,72 @@ func drawCircleOutline(img *image.Paletted, cx, cy, radius, thickness int, color } } -func drawPointer(img *image.Paletted, cx, tipY int) { +func drawPointer(img *image.Paletted, cx, tipY int, fillColorIndex byte) { 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) + setWheelBetaPixel(img, x, rowY, wheelBetaInkColorIndex) + } + } + for y := 3; y < 30; y++ { + half := y/2 - 2 + if half < 0 { + continue + } + rowY := tipY - y + for x := cx - half; x <= cx+half; x++ { + setWheelBetaPixel(img, x, rowY, fillColorIndex) } } } +func drawWinnerCelebration(img *image.Paletted, step int) { + if step < 0 { + return + } + + cx, cy := wheelBetaSize/2, wheelBetaSize/2 + phase := step % wheelBetaCelebrateFrames + ringRadius := 34 + phase*5 + if ringRadius < wheelBetaRadius-8 { + drawCircleOutline(img, cx, cy, ringRadius, 1, wheelBetaSparkColorIndex) + } + + for i := 0; i < 14; i++ { + angle := (float64(i)/14)*2*math.Pi + float64(phase)*0.31 + inner := float64(wheelBetaRadius + 9 + phase%3) + outer := inner + 7 + float64(phase%4) + x1 := cx + int(math.Round(math.Cos(angle)*inner)) + y1 := cy + int(math.Round(math.Sin(angle)*inner)) + x2 := cx + int(math.Round(math.Cos(angle)*outer)) + y2 := cy + int(math.Round(math.Sin(angle)*outer)) + colorIndex := wheelBetaSliceColorIndexes[(i+phase)%len(wheelBetaSliceColorIndexes)] + if i%5 == 0 { + colorIndex = wheelBetaSparkColorIndex + } + drawPalettedLine(img, x1, y1, x2, y2, colorIndex) + drawSpark(img, x2, y2, colorIndex) + } +} + 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) + for y := 235; y < 286; y++ { + for x := 31; x < wheelBetaSize-25; x++ { + img.SetColorIndex(x, y, wheelBetaShadowColorIndex) } } - drawCenteredText(img, label, 250, 1) - drawCenteredText(img, value, 270, 1) + for y := 230; y < 282; y++ { + for x := 28; x < wheelBetaSize-28; x++ { + img.SetColorIndex(x, y, wheelBetaPaperColorIndex) + } + } + for x := 28; x < wheelBetaSize-28; x++ { + img.SetColorIndex(x, 230, wheelBetaHighlightColorIndex) + img.SetColorIndex(x, 281, wheelBetaBevelColorIndex) + } + drawCenteredText(img, label, 250, wheelBetaInkColorIndex) + drawCenteredText(img, value, 270, wheelBetaInkColorIndex) } func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) { @@ -132,3 +233,55 @@ func asciiWheelText(s string, limit int) string { } return out } + +func drawSpark(img *image.Paletted, cx, cy int, colorIndex byte) { + for y := cy - 1; y <= cy+1; y++ { + for x := cx - 1; x <= cx+1; x++ { + if x == cx || y == cy { + setWheelBetaPixel(img, x, y, colorIndex) + } + } + } +} + +func drawPalettedLine(img *image.Paletted, x0, y0, x1, y1 int, colorIndex byte) { + dx := absInt(x1 - x0) + sx := -1 + if x0 < x1 { + sx = 1 + } + dy := -absInt(y1 - y0) + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx + dy + for { + setWheelBetaPixel(img, x0, y0, colorIndex) + if x0 == x1 && y0 == y1 { + return + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + +func setWheelBetaPixel(img *image.Paletted, x, y int, colorIndex byte) { + if image.Pt(x, y).In(img.Rect) { + img.SetColorIndex(x, y, colorIndex) + } +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} diff --git a/internal/modules/misc/wheelofnames_beta_spin_profile.go b/internal/modules/misc/wheelofnames_beta_spin_profile.go index 4f958bc..efdaa86 100644 --- a/internal/modules/misc/wheelofnames_beta_spin_profile.go +++ b/internal/modules/misc/wheelofnames_beta_spin_profile.go @@ -15,7 +15,7 @@ type wheelBetaSpinProfile struct { startRotation float64 finalRotation float64 accelEnd float64 - coastEnd float64 + decelSharpness float64 wobbleAmplitude float64 wobbleCycles float64 wobblePhase float64 @@ -26,16 +26,12 @@ func newWheelBetaSpinProfile(optionCount, winner int, rng *rand.Rand) wheelBetaS 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 - } + accelEnd := 0.20 + wheelBetaRandFloat64(rng)*0.1 return wheelBetaSpinProfile{ startRotation: finalRotation - float64(revolutions)*2*math.Pi, finalRotation: finalRotation, accelEnd: accelEnd, - coastEnd: coastEnd, + decelSharpness: 3.6 + wheelBetaRandFloat64(rng)*1.8, 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, @@ -53,10 +49,8 @@ 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" + return "DECELERATING" case t < 0.96: return "LOCKING IN" default: @@ -66,38 +60,36 @@ func (p wheelBetaSpinProfile) statusAt(t float64) string { func (p wheelBetaSpinProfile) progressAt(t float64) float64 { t = clampWheelBetaProgress(t) + if t == 0 || t == 1 { + return t + } + accelEnd := p.accelEnd - coastEnd := p.coastEnd - if accelEnd <= 0 || coastEnd <= accelEnd || coastEnd >= 1 { + if accelEnd <= 0 || accelEnd >= 1 { remaining := 1 - t return 1 - remaining*remaining*remaining } - 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: + accelDistance := accelEnd * 0.72 + if 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 accelDistance * u * u } - return distance / totalArea + + sharpness := p.decelSharpness + if sharpness <= 0 { + sharpness = 4 + } + u := (t - accelEnd) / (1 - accelEnd) + decelProgress := (1 - math.Exp(-sharpness*u)) / (1 - math.Exp(-sharpness)) + return accelDistance + (1-accelDistance)*decelProgress } func (p wheelBetaSpinProfile) wobbleAt(t float64) float64 { - if t <= p.coastEnd || t >= 1 { + if t <= p.accelEnd || t >= 1 { return 0 } - u := (t - p.coastEnd) / (1 - p.coastEnd) + u := (t - p.accelEnd) / (1 - p.accelEnd) 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) }