实现一个四宫格迷你数独验证码

1,448 阅读8分钟

数独简介

数独是一种被大家熟知的每一行、每一列、每一个粗线宫内的数字不重复的数字游戏。例如下图是一个九宫格数独。玩家需要根据9×9盘面上的已知数字,推理出所有剩余空格的数字,并满足每一行、每一列、每一个粗线宫(3*3)内的数字均含1-9,不重复。

grid.png

数独的组成元素

: 水平方向的每一横行有九格,每一横行称为行(Row)

row.png

: 垂直方向的每一纵列有九格,每一纵列称为列(Column)

column.png

: 行与列的粗线相交组成的区域,称为宫(Block)

block.png

提示数: 在九宫格的格位填上一些数字,做为填数判断的线索,称为提示数(Clue)。

实现逻辑

一般数独是指9×9标准数独,数独还有种类繁多的变种。比如我们经常看到的简单的迷你数独:四宫格数独、六宫格数独。考虑到作为验证码使用,不能让用户花费过程的时间。这里选择使用简单的四宫格数独。

这里先说下实现步骤,我们在前端画出4*4的4宫格,然后创建一个完整的数独谜题,随机去掉里面(1~7)个格子的数作为迷底(Answer),剩下的保留做为谜面提示数, 用户在使用候选数(Candidates) 1、2、3、4填入后,获取填入结果(Solution),和迷底数进行比对。

生成数独的逻辑:

  1. 建一个一个候选数数组candidates: [1,2,3,4]
  2. 1、2、3、4四个洗牌,生成一个打乱的基础数组base
  3. 创建一个数独:
   // 思路: base下标是一个数独,那么base的值也是一个数独
   // 因为base是candidates洗牌得到的,所以base的值的数独也会是一个洗牌的结果
   [
     base[0], base[1], base[2], base[3],
     base[2], base[3], base[0], base[1],
     base[1], base[0], base[3], base[2],
     base[3], base[2], base[1], base[0]
   ]

vue3实现

数独展示组件

模板部分:

<template>
  <div class="sudoku">
    <div class="grid">
      <template v-for="(digit, index) in clue" :key="index">
        <div :class="{ 'item-input': props.src[index] == 0}" @click="itemClick(index)">
          {{ digit || '' }}
        </div>
      </template>
    </div>
    <div class="input" v-show="inputState.visible" :style="inputStyle">
      <div class="input-number" v-for="i in 4" :key="i" @click="setVal(i)">{{ i }}</div>
    </div>
  </div>
</template>

ts代码部分:

import { reactive, watch } from 'vue';
import { ref } from 'vue'

interface Props {
  src: number[]
  modelValue?: Record<number, number>
}

const props = defineProps<Props>()

const clue = ref([...props.src])
const solution = ref({ ...props.modelValue })

watch(
  () => props.src,
  () => {
    clue.value = [...props.src]
    solution.value = {}
  }, {
    deep: true
  }
)

const inputStyle = reactive({ left: '0px', top: '0px' })

const inputState = reactive({
  visible: false,
  index: -1
})

const itemClick = (index: number) => {
  inputState.index = index
  inputState.visible = true
  const colIndex = index % 4
  const rowIndex = Math.floor(index / 4)
  inputStyle.left = `${colIndex * (80 + 1) + 5}px`
  inputStyle.top = `${rowIndex * (80 + 1) + 5}px`
}

const emit = defineEmits(['update:modelValue', 'change'])

const setVal = (val: number) => {
  clue.value[inputState.index] = val
  solution.value[inputState.index] = val
  emit('update:modelValue', solution.value)
  emit('change', solution.value, inputState.index, val)
  inputState.visible = false
}

CSS部分:

*{
    box-sizing: border-box;
 }

.sudoku{
  position: relative;
  width: 333px;
  height: 333px;
  margin: 20px auto;
  user-select: none;
}

.grid{
  position: absolute;
  z-index: 3;
  display: grid;
  width: 100%;
  height: 100%;
  border: 5px solid #000;
  margin: 0 auto;
  background-color: #000;
  gap: 1px;
  grid-template-columns: repeat(4,minmax(0,1fr));
  inset: 0;
}

.grid > div{
  position: relative;
  width: 80px;
  height: 80px;
  background-color: #fff;
  color: rgb(148 163 184);
  font-size: 26px;
  line-height: 80px;
  text-align: center;
}

.grid > div:nth-child(4n+2)::before {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 2px;
  background-color: #000;
  content: '';
}

.grid > div:nth-child(4n+3)::after{
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 2px;
  background-color: #000;
  content: '';
}

.grid > div:nth-child(5)::before,
.grid > div:nth-child(6)::after,
.grid > div:nth-child(7)::before,
.grid > div:nth-child(8)::before{
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 2px;
  background-color: #000;
  content: '';
}

.grid > div:nth-child(9)::before,
.grid > div:nth-child(10)::after,
.grid > div:nth-child(11)::before,
.grid > div:nth-child(12)::before{
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  height: 2px;
  background-color: #000;
  content: '';
}


.grid .item-input{
  background-color: #e6f7ff;
  color: #000;
  cursor: pointer;
}

.sudoku .bg{
  position: absolute;
  z-index: 2;
  width: 100%;
  inset: 0;
}

.sudoku .input{
  position: absolute;
  z-index: 4;
  display: grid;
  width: 80px;
  height: 80px;
  margin: 0 auto;
  background-color: #fff;
  box-shadow: 0 0 10px #000;
  grid-template-columns: repeat(2,minmax(0,1fr));
}

.sudoku .input-number{
  width: 40px;
  height: 40px;
  border: 1px solid #000;
  background-color: #e6f7ff;
  cursor: pointer;
  line-height: 40px;
  text-align: center;
}

引用组件及生成数独

  <sudoku :src="digits.src" v-model="form.sudoku"/>
// 洗牌函数
const shuffle = (candidates: number[]): number[] => {
  let res:number[] = [], random: number = -1
  while (candidates.length > 0) {
    random = Math.floor(Math.random() * candidates.length)
    res.push(candidates[random])
    candidates.splice(random, 1)
  }
  return res
}
 
// 用于存放数独谜面和谜底
interface Digits {
  clue: number[], // 谜面提示数
  answer:Record<number, number> // 谜底,键值对应的是“{ 位置: 数值 }”
}

const digits = reactive<Digits>({
  clue: [],
  answer: {}
})

interface Form{
  sudoku: Record<number, number>
}

const form = reactive<Form>({ sudoku: {} })

// 重新生成一个数独游戏
const onReTry = () => {
  creatClueAndAnswer()
}

// 生成数独
const creatClueAndAnswer = () => {
  const candidates: number[] = [1, 2, 3, 4]
  const base = shuffle([...candidates])
  const clue = [
    base[0], base[1], base[2], base[3],
    base[2], base[3], base[0], base[1],
    base[1], base[0], base[3], base[2],
    base[3], base[2], base[1], base[0]
  ]
  const answer:Record<number, number> = {}
  const resetArr: number[] = []
  // 随机挖掉1~7个位置, 用于填空, 并记录答案
  while (resetArr.length < 7) {
    const random = Math.floor(Math.round(Math.random() * 15))
    // if (!resetArr.includes(random)) {
    //   resetArr.push(random)
    // }
    resetArr.push(random) // 可能反复在某个位置挖呀挖呀挖!
  }
  resetArr.forEach(pos => {
    if (!answer[pos]) {
      answer[pos] = clue[pos]
      clue[pos] = 0 // 第pos个格子挖空, 赋值为0表示挖空
    }
  })
  digits.clue = clue
  digits.answer = answer
}

// 填写完成后提交答案
const onSubmit = () => {
  let passed = true
  if (!form.sudoku) {
    alert('请填写数字')
    return
  }
  Object.entries(digits.answer).forEach(item => {
    if (form.sudoku![parseInt(item[0])] !== item[1]) {
      passed = false
    }
  })
  if (passed) {
    alert('正确')
  } else {
    alert('错误')
  }
}

// 系统解答
const onSolve = () => {
  Object.entries(digits.answer).forEach(item => {
    digits.clue[parseInt(item[0])] = item[1]
  })
}

// 初始化生成数独游戏
onBeforeMount(() => {
  creatClueAndAnswer()
})

效果

数独效果图

纯js版

CCS和Vue版本一致,html和js代码部分如下:

  <div class="sudoku">
    <div class="grid">
      <div></div><div></div><div></div><div></div>
      <div></div><div></div><div></div><div></div>
      <div></div><div></div><div></div><div></div>
      <div></div><div></div><div></div><div></div>
    </div>
    <div class="input" style="display:none">
      <div class="input-number" onclick="setVal(1)">1</div>
      <div class="input-number" onclick="setVal(2)">2</div>
      <div class="input-number" onclick="setVal(3)">3</div>
      <div class="input-number" onclick="setVal(4)">4</div>
    </div>
  </div>
  <div style="margin: 10px auto 20px; text-align: center;">
    <button type="button" onclick="onSubmit()"><span>提 交</span></button>
    <button type="button" style="margin-left: 8px;" onclick="onSolve()"><span>答 案</span></button>
    <button type="button" style="margin-left: 8px;" onclick="creatClueAndAnswer"><span>再来一次</span></button>
  </div>
<script>
const shuffle = (candidates) => {
  let res = [];
  while (candidates.length > 0) {
    const random = Math.floor(Math.random() * candidates.length);
    res.push(candidates[random]);
    candidates.splice(random, 1);
  }
  return res;
}

const creatClueAndAnswer = () => {
  const candidates = [1, 2, 3, 4];
  const base = shuffle([...candidates]);
  const clue = [
    base[0], base[1], base[2], base[3],
    base[2], base[3], base[0], base[1],
    base[1], base[0], base[3], base[2],
    base[3], base[2], base[1], base[0]
  ];
  const answer = {};
  const resetArr = [];
  // 随机挖掉1~7个位置, 用于填空, 并记录答案
  while (resetArr.length < 7) {
    const random = Math.floor(Math.round(Math.random() * 15));
    resetArr.push(random); // 可能反复在某个位置挖呀挖呀挖!
  }
  resetArr.forEach(pos => {
    if (!answer[pos]) {
      answer[pos] = clue[pos];
      clue[pos] = 0; // 第pos个格子挖空, 赋值为0表示挖空
    }
  })
  return { clue, answer };
}

const inputDom =  document.querySelector('.input');
const units = document.querySelectorAll('.grid div');

const setVal = (val) => {
  const indexStr = inputDom.getAttribute('data-index');
  const index = parseInt(indexStr);
  units[index].innerText = val;
  inputDom.setAttribute('style', 'display: none');
}

const itemClick = (index, val) => {
  inputDom.setAttribute('style',`left: ${index % 4 * (80 + 1) + 5}px; top: ${Math.floor(index / 4) * (80 + 1) + 5}px`);
  inputDom.setAttribute('data-index', index);
}

const digits = creatClueAndAnswer();

const setClue = () => {
  units.forEach((unit, i) => {
    if (digits.clue[i] !== 0) {
      unit.innerText = digits.clue[i];
    } else {
      unit.setAttribute('class', 'item-input');
      unit.setAttribute('onclick', `itemClick(${i}, ${digits.clue[i]})`);
    }
  })
}

setClue();

const onSolve = () => {
  Object.entries(digits.answer).forEach(item => {
    digits.clue[parseInt(item[0])] = item[1]
  })
  setClue();
}

const onSubmit = () => {
  const solution = {};
  units.forEach((unit, index) => {
    if (unit.getAttribute('class') === 'item-input') {
      solution[index] = parseInt(unit.innerText);
    }
  })

  let passed = true;
  Object.entries(digits.answer).forEach(item => {
    if (solution[parseInt(item[0])] !== item[1]) {
      passed = false;
    }
  })
  if (passed) {
    alert('正确');
  } else {
    alert('错误');
  }
}
</script>

服务端交互方式实现

到了这里,作者又思考要怎么和服务端交互,当然上面的creatClueAndAnswer生成数独谜面和谜底的函数在服务端实现,然后将谜面数组传递到前端肯定是可以的。但是这相当于明文传输,答案通过简单计算就可以获取。

作者的想法是如果服务端将谜面塞入一张图片里面,再将一个临时key和图片给到前端展示,前端在提交验证带上一个临时key和答案(solution), 服务端通过临时key对应的谜底(answer)和提交答案(solution)进行比对, 如果比对成功则返回一个已通过验证id, 提交表单时再把验证id一起提交上去。

怎么将图片塞到当前的四宫格里面呢,作者想到,将一个空白透明的四宫格图片作为背景图,然后将服务端生成的图片作为背景图之上的图片,然后图片只是每个格子都放一个div,每个格子都可以点击填充数字(这里没有想到怎么告诉前端哪些是有提示数(Clue)的格子,哪些是可以填数的格子)。然后前端就能拿到对应的答案了。

服务端

下面是服务端使用golang的实现代码:

package main

import (
    "bytes"
    "encoding/base64"
    "fmt"
    "image"
    "image/color"
    "image/draw"
    "image/png"
    "math/rand"
    "os"
)

func main() {
    // candidates.png是候选数的雪碧图, 分辨率为480*160
    // 考虑到一些Retina屏展示,这里使用了2倍的图片
    b, err := CreateSudoku("./candidates.png")
    if err != nil {
         panic(err)
    }
    
    // 图片可以以base64的方式返回给前端
    imgBase64 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(b.Bytes())
    fmt.Println(imgBase64)

   /**
    * 如果需要生成图片的方式,可以使用这个
    * outFile, err := os.Create("clue.png")
    * if err != nil {
    *     panic(err)
    * }
    * defer outFile.Close()
    * 
    * b.WriteTo(outFile)
    */
}

// CreateSudoku 创建数独
func CreateSudoku(candidatesPath string) (buffer *bytes.Buffer, err error) {
    f, err := os.Open(candidatesPath)
    if err != nil {
        return
    }
    
    defer f.Close()

    baseImg, _, err := image.Decode(f)
    if err != nil {
        return
    }

    // 这个就是最终发送给前端的图片了
    // 同样,这里也是四宫格的2倍
    img := image.NewRGBA(image.Rect(0, 0, 666, 666))

    b := baseImg.Bounds()
    base := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
    draw.Draw(base, base.Bounds(), baseImg, b.Min, draw.Src)

    // 挖空部分用一个具有背景色的格子涂抹
    riddleImg := image.NewRGBA(image.Rect(0, 0, 160, 160))
    for x := 0; x < 160; x++ {
        for y := 0; y < 160; y++ {
            riddleImg.Set(x, y, color.RGBA{224, 242, 254, 255})
        }
    }

    spArr := [4]image.Point{
        image.Pt(0, 0),   // 获选数是1的雪碧图的定位
        image.Pt(160, 0), // 获选数是2的雪碧图的定位
        image.Pt(320, 0), // 获选数是3的雪碧图的定位
        image.Pt(480, 0), // 获选数是4的雪碧图的定位
    }

    digits := createDigits()
    ra := creatAnswer(digits)
    for i, digit := range digits {
        if _, ok := ra[i]; ok {
            draw.Draw(img, getR(i), riddleImg, image.Pt(0, 0), draw.Over)
        } else {
            draw.Draw(img, getR(i), base, spArr[digit-1], draw.Over)
        }
    }

    buffer = bytes.NewBuffer(nil)
    err = png.Encode(buffer, img)

    return
}

// getR 根据index获取draw.Draw的第二个参数,让候选数塞入图片相应的位置
func getR(index int) image.Rectangle {
    border := 2
    gap := 10
    row := index / 4
    col := index % 4
    x0 := col * (160 + border)
    y0 := row * (160 + border)
    x1 := (col + 1) * (160 + border)
    y1 := (row + 1) * (160 + border)
    
    // 粗线宫间距微调
    if col == 2 {
        x1 -= gap
    } else if col == 3 {
        x0 -= gap
    }

    if row == 2 {
        y1 -= gap
    } else if row == 3 {
        y0 -= gap
    }

    return image.Rect(x0, y0, x1, y1)
}

// createDigits 创建数独
func createDigits() []int {
    base := []int{1, 2, 3, 4}
    rand.Shuffle(4, func(i, j int) {
        base[i], base[j] = base[j], base[i]
    })

    return []int{
        base[0], base[1], base[2], base[3],
        base[2], base[3], base[0], base[1],
        base[1], base[0], base[3], base[2],
        base[3], base[2], base[1], base[0],
    }
}

// creatAnswer 创建谜底
func creatAnswer(digits []int) map[int]int {
    ra := make(map[int]int)
    for i := 1; i <= 7; i++ {
        index := rand.Intn(16)
        ra[index] = digits[index]
    }

    return ra
}

前端

前端代码需要做一些改变, 值的填充使用雪碧图:

.sudoku{
  position: relative;
  width: 330px;
  height: 330px;
  margin: 20px auto;
  user-select: none;
}

.grid{
  position: absolute;
  z-index: 3;
  display: grid;
  width: 100%;
  height: 100%;
  border: 5px solid #000;
  border-top-width: 4px;
  border-right-width: 4px;
  margin: 0 auto;
  grid-template-columns: repeat(4,minmax(0,1fr));
  inset: 0;
}

.grid > div{
  position: relative;
  width: 80px;
  height: 80px;
  border-top: 1px solid #000;
  border-right: 1px solid #000;
  color: rgb(148 163 184);
  font-size: 26px;
  line-height: 80px;
  text-align: center;
}

.grid > div:nth-child(4n+2)::before {
  position: absolute;
  top: -1px;
  right: -2px;
  bottom: -1px;
  width: 5px;
  background-color: #000;
  content: '';
}

.grid > div:nth-child(5)::before,
.grid > div:nth-child(6)::after,
.grid > div:nth-child(7)::before,
.grid > div:nth-child(8)::before{
  position: absolute;
  right: -1px;
  bottom: -2px;
  left: -1px;
  height: 5px;
  background-color: #000;
  content: '';
}

.grid .item-value {
  background-image: url('../assets/sudoku/candidates.png');
  background-size: auto 100%;
}

.grid .item-input{
  color: #000;
  cursor: pointer;
}

.sudoku .input{
  position: absolute;
  z-index: 4;
  display: grid;
  width: 80px;
  height: 80px;
  margin: 0 auto;
  box-shadow: 0 0 10px #000;
  grid-template-columns: repeat(2,minmax(0,1fr));
}

.sudoku .input-number{
  width: 40px;
  height: 40px;
  border: 1px solid #000;
  background-color: #e6f7ff;
  background-image: url('../assets/sudoku/candidates.png');
  background-size: auto 100%;
  cursor: pointer;
  line-height: 40px;
  text-align: center;
}

.sudoku .input-number.no-1, .grid .item-value-1{
  background-position: 0 0;
}

.sudoku .input-number.no-2, .grid .item-value-2{
  background-position: calc(100% / 3) 0;
}

.sudoku .input-number.no-3, .grid .item-value-3{
  background-position: calc(100% / 3 * 2) 0;
}

.sudoku .input-number.no-4, .grid .item-value-4{
  background-position: 100% 0;
}

.sudoku .img{
  position: absolute;
  z-index: 2;
  width: 100%;
  height: 100%;
  inset: 0;
}

vue的template的调整:

<template>
  <div class="sudoku">
    <div class="grid">
      <template v-for="(digit, index) in digits" :key="index">
        <div :class="[ digit > 0 ? `item-value item-value-${digit}` : '' ]" @click="itemClick(index)">
        </div>
      </template>
    </div>
    <img class="img" :src="props.src"/>
    <div class="input" v-show="inputState.visible" :style="inputStyle">
      <div :class="['input-number', `no-${i}` ]" v-for="i in 4" :key="i" @click="setVal(i)"></div>
    </div>
  </div>
</template>

ts代码部分微调

import { ref, reactive, watch } from 'vue'

interface Props {
  src: string
  modelValue?: Record<number, number>
}

const props = defineProps<Props>()

const digits = ref(new Array(16).fill(0))
const value = ref({ ...props.modelValue })

watch(
  () => props.src,
  () => {
    digits.value = new Array(16).fill(0)
    value.value = {}
  }
)

const inputStyle = reactive({ left: '0px', top: '0px' })

const inputState = reactive({
  visible: false,
  index: -1
})

const itemClick = (index: number) => {
  inputState.index = index
  inputState.visible = true
  const colIndex = index % 4
  const rowIndex = Math.floor(index / 4)
  inputStyle.left = `${colIndex * (80 + 1) + 4}px`
  inputStyle.top = `${rowIndex * (80 + 1) + 4}px`
}

const emit = defineEmits(['update:modelValue', 'change'])

const setVal = (val: number) => {
  digits.value[inputState.index] = val
  value.value[inputState.index] = val
  emit('update:modelValue', value.value)
  emit('change', value.value, inputState.index, val)
  inputState.visible = false
}

纯js的html和js部分代码变动后如下:

<div class="sudoku">
    <div class="grid">
      <div></div><div></div><div></div><div></div>
      <div></div><div></div><div></div><div></div>
      <div></div><div></div><div></div><div></div>
      <div></div><div></div><div></div><div></div>
    </div>
    <div class="input" style="display:none">
      <div class="input-number no-1" onclick="setVal(1)"></div>
      <div class="input-number no-2" onclick="setVal(2)"></div>
      <div class="input-number no-3" onclick="setVal(3)"></div>
      <div class="input-number no-4" onclick="setVal(4)"></div>
    </div>
    <img class="img" src="../"/>
  </div>
  <div style="margin: 10px auto 20px; text-align: center;">
    <button type="button" onclick="onSubmit()"><span>提 交</span></button>
    <button type="button" style="margin-left: 8px;" onclick="creatSudoku"><span>再来一次</span></button>
  </div>
  <script>
  const shuffle = (candidates) => {
    let res = [];
    while (candidates.length > 0) {
      const random = Math.floor(Math.random() * candidates.length);
      res.push(candidates[random]);
      candidates.splice(random, 1);
    }
    return res;
  }

  const inputDom =  document.querySelector('.input');
  const units = document.querySelectorAll('.grid div');

  const setVal = (val) => {
    const indexStr = inputDom.getAttribute('data-index');
    const index = parseInt(indexStr);
    units[index].setAttribute('data-value', val);
    units[index].setAttribute('class', `item-value item-value-${val}`);
    inputDom.setAttribute('style', 'display: none');
  }

  const itemClick = (index, val) => {
    inputDom.setAttribute('style',`left: ${index % 4 * (80 + 1) + 4}px; top: ${Math.floor(index / 4) * (80 + 1) + 4}px`);
    inputDom.setAttribute('data-index', index);
  }

  const clue = new Array(16).fill(0);

  const setClue = () => {
    units.forEach((unit, i) => {
      unit.setAttribute('class', 'item-input');
      unit.setAttribute('onclick', `itemClick(${i}, ${clue[i]})`);
    })
  };

  setClue();

  const creatSudoku = () => {
  // 请求服务端
  };

  creatSudoku();

  const onSubmit = () => {
    const solution = {};
    units.forEach((unit, index) => {
      const val = unit.getAttribute('data-value');
      if (val) {
        solution[index] = parseInt(val);
      }
    })
    // 提交 solution
    console.log(solution);
  };
  </script>

思维发散

我们完全可以将candidates.png这个图片换掉,换成1、2、3、4对应任意的图标。或者,我们生成图片的时候随机使用一种candidates.png,然后下发图片的同时把这个图片一起下发。

更多发散:谜面和谜底就像是加密算法,我们也可以像12306的官网一样,生成一个图片,供用户标识哪些是同一类,然后提交进行验证。