1

I'm creating a wheel of fortune game, and I need the wheel to stop on a specific item from the sectors array.

As soon as the user clicks "Spin", the backend will return me an index of what item the spinner should land on.

Jsfiddle

const sectors = [
  {color:"#b0f", label:"100"} ,
  {color:"#f0b", label:"5", },
  {color:"#bf0", label:"500"},
];

// Generate random float in range min-max:
const rand = (m, M) => Math.random() * (M - m) + m;

const tot = sectors.length;
const elSpin = document.querySelector("#spin");
const ctx = document.querySelector("#wheel").getContext`2d`;

   ctx.canvas.width = 600;
    ctx.canvas.height = 600;
    
    const dia = ctx.canvas.width;

    
const rad = dia / 2;
const PI = Math.PI;
const TAU = 2 * PI;
const arc = TAU / sectors.length;
const friction = 0.991;  // 0.995=soft, 0.99=mid, 0.98=hard
const angVelMin = 0.002; // Below that number will be treated as a stop
let angVelMax = 0; // Random ang.vel. to acceletare to 
let angVel = 0;    // Current angular velocity
let ang = 0;       // Angle rotation in radians
let isSpinning = false;
let isAccelerating = false;

//* Get index of current sector */
const getIndex = () => Math.floor(tot - ang / TAU * tot) % tot;

   const loadImage = (ctx, sector, ang) => {
      const img = new Image();
      img.onload = function () {
        ctx.drawImage(img, rad - 50, 10); // Or at whatever offset you like
      };
      img.src = sector.image;
    };
    
//* Draw sectors and prizes texts to canvas */
const drawSector = (sector, i) => {
  const ang = arc * i;
  ctx.save();
  // COLOR
  ctx.beginPath();
  ctx.fillStyle = sector.color;
  ctx.moveTo(rad, rad);
  ctx.arc(rad, rad, rad, ang, ang + arc);
  ctx.lineTo(rad, rad);
  ctx.fill();
  // TEXT
  ctx.translate(rad, rad);
  ctx.rotate(ang + arc / 2);
  ctx.textAlign = "right";
  ctx.fillStyle = "#fff";
  ctx.font = "bold 30px sans-serif";
  loadImage(ctx, sector, ang);
  ctx.fillText(sector.label, rad - 10, 10);
  //
  ctx.restore();
};

//* CSS rotate CANVAS Element */
const rotate = () => {
  const sector = sectors[getIndex()];
  ctx.canvas.style.transform = `rotate(${ang - PI / 2}rad)`;
  elSpin.textContent = !angVel ? "SPIN" : sector.label;
  elSpin.style.background = sector.color;
};

const frame = () => {

  if (!isSpinning) return;

  if (angVel >= angVelMax) isAccelerating = false;

  // Accelerate
  if (isAccelerating) {
    angVel ||= angVelMin; // Initial velocity kick
    angVel *= 1.06; // Accelerate
  }
  
  // Decelerate
  else {
    isAccelerating = false;
    angVel *= friction; // Decelerate by friction  

    // SPIN END:
    if (angVel < angVelMin) {
      isSpinning = false;
      angVel = 0; 
    }
  }

  ang += angVel; // Update angle
  ang %= TAU;    // Normalize angle
  rotate();      // CSS rotate!
};

const engine = () => {
  frame();
  requestAnimationFrame(engine)
};

elSpin.addEventListener("click", () => {
  if (isSpinning) return;
  isSpinning = true;
  isAccelerating = true;
  angVelMax = rand(0.25, 0.40);
});

// INIT!
sectors.forEach(drawSector);
rotate(); // Initial rotation
engine(); // Start engine!
#wheelOfFortune {
  display: inline-flex;
  position: relative;
  /* height: 720px;
  width: 720px; */
  overflow: hidden;
}

#wheel {
  display: block;
}

#spin {
  font: 1.5rem/0 sans-serif;
  user-select: none;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  top: 50%;
  left: 50%;
  width: 30%;
  height: 30%;
  margin: -15%;
  background: #fff;
  color: #fff;
  box-shadow: 0 0 0 8px currentColor, 0 0px 15px 5px rgba(0, 0, 0, 0.6);
  border-radius: 50%;
  transition: 0.8s;
}

#spin::after {
  content: '';
  position: absolute;
  top: -17px;
  border: 10px solid transparent;
  border-bottom-color: currentColor;
  border-top: none;
}
<div id="wheelOfFortune">
  <canvas id="wheel" width="300" height="300"></canvas>
  <div id="spin">SPIN asd asd asd as dasd as dasd asd asd as d</div>
</div>

Where do I start to get this done? The spinner needs to gradually slow down, not stop instantly.

I tried to specify a winningAngle variable, and when it matches the current angle, it stops.

However, it didn't work as it stopped instantly.

Roko C. Buljan
  • 196,159
  • 39
  • 305
  • 313
nudua
  • 23
  • 3

1 Answers1

0

Tl;Dr:
ease-animate your wheel of fortune element using CSS transition and rotate, or JS's Animations API.

enter image description here


The code (originally answered here: How to Draw a Wheel of Fortune) is using requestAnimationFrame (to handle acceleration, deceleration etc.). This is not necessarily needed.

Instead:

get the wheel's current absolute angle

// Generate random float in range min-max:
const rand = (m, M) => Math.random() * (M - m) + m;
// Fix negative modulo stackoverflow.com/a/71167019/383904
const mod = (n, m) => (n % m + m) % m;

let ang = 0;

// function spin (index) { .....
const angAbs = mod(ang, TAU)

get the server-sent "prize index" and calculate that index's absolute angle

let angNew = arc * index

since you don't want to end the spin exactly on the sector edge, subtract same random 0toarc degrees:

angNew -= rand(0, arc)

fix negative degrees using a helper modulo function

angNew = mod(angNew, TAU)

Now that we have the two angles (angAbs and angNew) get the angles difference:

const angDiff = mod(angNew - angAbs, TAU)

Now you can easily add that angDiff to ang and animate, but let's add some additional random number of turns (full revolutions) first:

const rev = TAU * Math.floor(rand(4, 6)) // extra 4 or 5 full turns

// Finally update-increment ang
ang += angDiff + rev;

and animate!

const spinAnimation = elWheel.animate([{ rotate: `${ang}rad` }], {
  duration: rand(4000, 5000),
  easing: "cubic-bezier(0.2, 0, 0.1, 1)",
  fill: "forwards"
});

spinAnimation.addEventListener("finish", () => {
  // Wheel stopped
});

PS:

  • in the above code TAU is (Math.PI * 2)
  • arc is TAU / totalSectors
  • By default the angle in the web world is pointing 0 right (east) and advances clockwise. If your wheel pin/needle is located on the top (north) add that angle offset at the moment you need to rotate the wheel: elWheel.animate([{ rotate: `${ang + angOffset}rad` }], { where angOffset might be a constant like const angOffset = TAU * 0.75; // needle is north.

Tip: if you want to track the wheel spin animation progress you'll need to use requestAnimationFrame regardless, since the Animation instance does not have a direct "animationTick" event callback.

// update() { function called at ~60fps using requestAnimationFrame 

const currentProgress = spinAnimation.effect.getComputedTiming().progress;
// value from 0 to 1  
// Get the angle:
const angDiff = ang - oldAng;
const angCurr = angDiff * currentProgress;
const angAbs = mod(oldAng + angCurr, TAU); 
console.log(angAbs);
Roko C. Buljan
  • 196,159
  • 39
  • 305
  • 313