Try   HackMD

Scratch Card

What is it?

Excerpt from Cambridge Dictionary, a scratch card is as defined below.

scratch card

a small card that you can buy to try to win a prize, with a thin layer that you rub off in order to see if you have winning numbers on it.

Feature

A scratch card which has 3 * 3 reward image list, the final reward for user will appears 3 times, as for other reward, they will appears 2 times or below.

Un-scratch Scratched
customized scratch card 1
customized scratch card 2

Survey

How to create a scratch card component step by step?

  1. Binding event for different device (ducktype is fine): Mobile: touchstart, touchmove, touchend Desktop: mousedown, mousemove, mouseup
  2. Load brush image and remember to set globalCompositeOperation as destination-out while drawing brush image on canvas.
  3. Calculate transparent (alpha value) pixels in 2d context / all pixels in 2d context to define whether to show up all card.

Library (Source Code)

Here are some nice libraries to learn how to implement scratch card feature.

Conclusion

It's fine to use library if a scratch card contains single image to calculate show-up mechanism. However, if it contains a 3 * 3 grid images to calculate show-up mechanism (e.g. same image shows up 3 times), then I'd better customize it by myself.

Implmentation

I won't explain all the details to build a scratch card since there are already multiple articles and libraries to teach how to build a simple scratch card step by step.

Let's focus on the tricky part of the 3 * 3 grid images scratch card.

Template

Scratch wrapper will be covered by foreground canvas, and background template can be customized.

<div ref="scratchWrapper" class="scratch-card">
  <div class="scratch-card__background">
    <slot />
  </div>
  <canvas ref="scratchCanvas" class="scratch-card__foreground" />
</div>

Use Promise to Load Image

If multiple image sources are required, it's good to use a promise to make sure an image before canvas initialization.

const loadImage = (src: string): Promise<HTMLImageElement> =>
  new Promise((resolve) => {
    const image = new Image()
    image.onload = () => {
      resolve(image)
    }
    // Prevent CanvasRenderingContext2D.getImageData() failed due to cross-origin data
    image.crossOrigin = 'anonymous'
    image.src = src
  })
import { defineComponent } from 'vue'

import scratch from '~/assets/img/scratch-card/scratch.png'
import brush from '~/assets/img/scratch-card/brush.png'

export default defineComponent({
  async setup() {
    const [scratchImage, brushImage] = await Promise.all([
      loadImage(scratch),
      loadImage(brush),
    ])

    // do some canvas initialization
    // ...
  },
})

Calculate Area

How to calculate whether each grid is opened

  1. Use a throttle to prevent too much calculation on move event.
  2. Get 2d context imageData.
  3. Get 2d context alpha data.
  4. Identify grid all pixels and transparent pixels.
  5. Calculate open ratio of each grid.
  6. Define whether each grid is opened or not.

p.s. For the grid index calculation, it's almost the same with leetcode problem 36. Valid Sudoku but the grid is larger.

import { defineComponent, ref } from 'vue'
import type { Ref } from 'vue'
import type { Item } from '@/types/scratch-card'

export default defineComponent({
    const THROTTLE_MILLISECONDS = 400

    const items: Ref<Array<Item>> = ref(Array.from(Array(9), (_, index) => {
        cost id = index < 3 ? -1 : index
        return {
            image: `IMAGE_URL_${index % 3}`,
            title: 'IMAGE_TITLE',
            id
        }
    })

    // Scratch card foreground canvas ref
    const scratchCanvas: Ref<HTMLCanvasElement> = ref(null)

    // Step 1
    const calculateArea = throttle((): void => {
        // Step 2
        const context = scratchCanvas.value.getContext('2d')!
        const imageData: Ref<> = context.getImageData(0, 0, scratchCanvas.width, scratchCanvas.height)

        // Step 3

        // Data is stored as a one-dimensional array in the RGBA order, with integer values between 0 and 255 (inclusive)
        const RGBA_LENGTH = 4
        const alphas = imageData.data.filter((_item, index) => (index + 1) % RGBA_LENGTH === 0)

        const SUB_EDGE_PER_EDGE = 3
        const pixelsPerEdge = Math.sqrt(alphas.length)
        const pixelsPerSubEdge = pixelsPerEdge / SUB_EDGE_PER_EDGE

        const INDEX_OF_TRANSPARENT_PIXELS = 0
        const INDEX_OF_ALL_PIXELS = 1
        // Alpha treshold to judege whether an alpha value is transparent or not
        const TRANSPARENT_ALPHA_TRESHOLD = 75
        // Ratio threshold to judge whether a grid is opened
        const OPEN_RATIO_TRESHOLD = 0.5
        const isRewardOpen = alphas
            // Step 4
            .reduce(
                (counts, alpha, alphaIndex) => {
                    // Transform alpha index to [x, y] in pixel grid
                    const remainder = alphaIndex % pixelsPerEdge
                    const quotient = (alphaIndex - remainder) / pixelsPerEdge

                    // Transform [x, y] in pixel grid to grid index
                    const countIndex =
                        Math.floor(quotient / pixelsPerSubEdge) * SUB_EDGE_PER_EDGE +
                        Math.floor(remainder / pixelsPerSubEdge)

                    counts[INDEX_OF_ALL_PIXELS]++
                    if (alpha <= TRANSPARENT_ALPHA_TRESHOLD) {
                      counts[INDEX_OF_TRANSPARENT_PIXELS]++
                    }

                    return counts
                },
                Array.from(Array(items.value.length), () => ([0, 0]))
            )
            // Step 5
            .map(([transparentPixels, allPixels]) => transparentPixels / allPixels)
            // Step 6
            .map((ratio) => ratio >= OPEN_RATIO_TRESHOLD)

        // use isRewardOpen to calculate max times for same image occurs
        // ...
    }, THROTTLE_MILLISECONDS)


})

Keep Scratch Trace After Resizing

How to keep scratch trace:

  1. If canvas width is equal to wrapper width and canvas height is equal to wrapper height, don't do anything.
  2. Create a data url and load image based on old canvas
  3. (Resize canvas and paint initial canvas)
  4. Append trace on resized canvas
import { defineComponent, ref } from 'vue'
import type { Ref } from 'vue'

export default defineComponent({
    const scratchWrapper: Ref<HTMLDivElement> = ref(null)
    const scratchCanvas: Ref<HTMLCanvasElement> = ref(null)

    const onResize = async (_event: UIEvent): Promise<void> => {
        // Step 1
        if (
            scratchCanvas.value.width === scratchWrapper.value.clientWidth &&
            scratchCanvas.value.height === scratchWrapper.value.clientHeight
        ) {
            return
        }

        // Step 2
        const context = scratchCanvas.value.getContext('2d')!
        const dataUrl = scratchCanvas.value.toDataURL('image/png')
        const image = await loadImage(dataUrl)

        // Step 3
        // ...

        context.save()

        // Step 4
        context.globalCompositeOperation = 'destination-in'
        context.drawImage(image, 0, 0, scratchCanvas.value.width, scratchCanvas.value.height)

        context.restore()
    }
})

Reference

tags: Work Scratch Card Vue3