feat(misc): polish wheel beta animation

This commit is contained in:
2026-07-06 13:48:51 +07:00
parent 8529452d9a
commit e4b24f03e4
4 changed files with 240 additions and 60 deletions
+11 -4
View File
@@ -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)
}
+45 -17
View File
@@ -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)
@@ -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
}
@@ -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)
}