DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Electron Adventures: Episode 85: Roulette Wheel

Sometimes I want to play board games, and there's just a little fluffy difficulty. Whenever someone rolls dice, my cat wants to chase the dice. She also sometimes thinks that pieces moved on a board are cat toys.

So I thought, why not do those things on screen instead? All the other game components like cards can be physical, but the ones that the cat wants to chase would move to a screen. Now this would likely be a tablet screen, not a desktop screen, so technically none of this needs Electron, but let's have some fun anyway.

Rolling

First, dice rolling. There's a million app that do that, but they mostly look boring. I think on a small screen roulette wheel looks a lot better than physical dice.

So here's the plan:

  • we draw roulette wheel with 6 parts
  • then we spin is when user clicks

SVG Arcs

Our roulette will consist of 6 arcs. A natural way to describe an arc would be:

  • there's a circle with center at CX and CY and radius R
  • draw an arc from StartAngle to EndAngle

Well, that's how arcs work everywhere except in SVG. SVG uses some ridiculous system, and they even had to include an appendix how to deal with that.

Here's what SVG arcs want:

  • arc's StartX and StartY
  • arc's EndX and EndY
  • RadiusX and RadiusY and EllipseRotation of the ellipse on which the arc is - for circles these are R, R, 0
  • that does not uniquely identify the center, so there's two extra boolean flags to which center that implies

Rotating SVG objects

The next part to consider is that we want to spin the wheel. The natural way to describe the spin would be with spin center point CX, CY - or just always spin object around its center. And of course SVG does no such thing, the only rotations it does are around the center of the 0, 0 point. So to rotate anything you need to:

  • move object from X, Y to 0, 0 (translate by -X, -Y)
  • rotate it
  • move object back to X, Y (translate by X, Y)

Or alternatively we could have all objects drawns with their centers at 0, 0, and only places in proper places with translate logic. This makes rotations work in the simple way.

src/Arc.svelte

So knowing that, let's write some components. Let's start with one that creates an Arc. As we'll only pass increasing angles, we don't need to do anything funny with the second boolean flag, but we could potentially have a big one, so we need to calculate the first boolean flag.

If you use this component in any actual app, you'd probably change the styling or export it to passable props, but this will do.

<script>
  export let cx=0, cy=0, r, a0, a1

  let x0 = cx + Math.sin(a0 * 2 * Math.PI / 360.0) * r
  let y0 = cy + Math.cos(a0 * 2 * Math.PI / 360.0) * r
  let x1 = cx + Math.sin(a1 * 2 * Math.PI / 360.0) * r
  let y1 = cy + Math.cos(a1 * 2 * Math.PI / 360.0) * r

  let arcSweep = (a1 - a0) <= 180 ? 0 : 1

  let d = `
    M ${cx} ${cy}
    L ${x0} ${y0}
    A ${r} ${r} 0 ${arcSweep} 0 ${x1} ${y1}
    Z
  `
</script>

<path {d}/>

<style>
  path {
    fill: green;
    stroke-width: 2;
    stroke: #fff;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/ArcLabel.svelte

For rotated text we center it around 0, 0 point, then rotate it, then move it to the right place.

We shift the angle by 180-a, as the top of the wheel is on bottom of the screen (in the usual 2D coordinates X goes down not up). Of course we can rotate the whole thing in any way we want.

<script>
  export let cx=0, cy=0, r, a, text

  let x = cx + Math.sin(a * 2 * Math.PI / 360.0) * r
  let y = cy + Math.cos(a * 2 * Math.PI / 360.0) * r
</script>

<g transform={`translate(${x},${y}) rotate(${180-a})`}>
  <text x={0} y={0} text-anchor="middle">{text}</text>
</g>

<style>
  text {
    font-size: 24px;
    font-family: sans-serif;
    fill: red;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

src/RouletteWheel.svelte

Now we can finally create the wheel.

<script>
  import Arc from "./Arc.svelte"
  import ArcLabel from "./ArcLabel.svelte"

  export let options
  export let r
  export let onRolled

  let sliceCount = options.length
  let sliceSize = 360 / sliceCount

  let angle = sliceSize / 2
  let rolledOption

  function randint(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min
  }

  function randfloat(min, max) {
    return Math.random() * (max - min) + min
  }

  function roundUp(x, z) {
    return Math.ceil(x / z) * z
  }

  function click() {
    let roll = randint(0, sliceCount-1)
    let rollPlace = randfloat(0.2*sliceSize, 0.8*sliceSize)
    let finalAngle = roll * sliceSize + rollPlace
    let spins = randint(2, 3)
    angle = roundUp(angle, 360) + spins * 360 + finalAngle
    rolledOption = options[roll]
  }

  function transitionend() {
    onRolled(rolledOption)
  }
</script>

<g transform={`rotate(${angle})`} on:click={click} on:transitionend={transitionend}>
  {#each options as opt, i}
    <Arc r={r} a0={sliceSize*i} a1={sliceSize*(i+1)} />
  {/each}
  {#each options as opt, i}
    <ArcLabel r={r*2.0/3.0} a={sliceSize*(i+0.5)} text={opt} />
  {/each}
</g>

<style>
g {
  transition: 3s ease-out;
}
</style>
Enter fullscreen mode Exit fullscreen mode

There are a few interesting things here.

First we only trigger notification when animation ends with with transitionend event, not when user clicks. We know it will take 3s, but it's cleaner to use actual event.

And for the actual angle, we avoid angles too close to the lines so it's always clear which slice is selected. Only angles from 20% to 80% of the slice are possible, there's 20% margin on each end of each slice that we cannot get.

angle normally goes 0 to 360, but actually we want much higher numbers. What's the difference between angle of rotation of 30 and 360*5+30? The end result is the same, but in the latter case browser will spin the wheel five times before it finally gets to the right one. Those angles might eventually get huge, and might need some normalization step, but we don't do that, we just assume it won't be needed it our case.

And we use ease-out predefined transition, so transition starts fast, and slows down as the end, like a real wheel. Other common transitions like ease or linear feel very wrong in this case.

src/App.svelte

And finally an App component fitting it all together.

<script>
  import RouletteWheel from "./RouletteWheel.svelte"

  function onRolled(opt) {
    console.log(opt)
  }
</script>

<div>
  <svg height="400" width="400">
    <g transform="translate(200,200)">
      <RouletteWheel
        r={150}
        options={[1,2,3,4,5,6,7,8,9,10,11,12]}
        onRolled={onRolled}
      />
    </g>
    <polygon points="200 360 210 370 190 370"/>
  </svg>
</div>

<style>
:global(body) {
  background-color: #444;
  color: #fff;
  margin: 0;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  user-select: none;
}

svg {
  display: block;
}

polygon {
  fill: yellow;
}
</style>
Enter fullscreen mode Exit fullscreen mode

We pass list of options to RouletteWheel (which would normally be [1,2,3,4,5,6], but really we could put some letters or emojis or short words there). In this version all are of the same width for simplicity.

Then there's a pointer triangle, and some styling to center the wheel and mark text on it as not selectable, as that can lead to visual glitches.

Results

Here's the results:

Episode 85 Screenshot

We'll get to creating the game board soon, but first we need a small detour for making our Electron app cooperate with the operating system better.

As usual, all the code for the episode is here.

Top comments (0)