数独简介
数独是一种被大家熟知的每一行、每一列、每一个粗线宫内的数字不重复的数字游戏。例如下图是一个九宫格数独。玩家需要根据9×9盘面上的已知数字,推理出所有剩余空格的数字,并满足每一行、每一列、每一个粗线宫(3*3)内的数字均含1-9,不重复。
数独的组成元素
行: 水平方向的每一横行有九格,每一横行称为行(Row)
列: 垂直方向的每一纵列有九格,每一纵列称为列(Column)
宫: 行与列的粗线相交组成的区域,称为宫(Block)
提示数: 在九宫格的格位填上一些数字,做为填数判断的线索,称为提示数(Clue)。
实现逻辑
一般数独是指9×9标准数独,数独还有种类繁多的变种。比如我们经常看到的简单的迷你数独:四宫格数独、六宫格数独。考虑到作为验证码使用,不能让用户花费过程的时间。这里选择使用简单的四宫格数独。
这里先说下实现步骤,我们在前端画出4*4的4宫格,然后创建一个完整的数独谜题,随机去掉里面(1~7)个格子的数作为迷底(Answer),剩下的保留做为谜面提示数, 用户在使用候选数(Candidates) 1、2、3、4填入后,获取填入结果(Solution),和迷底数进行比对。
生成数独的逻辑:
- 建一个一个候选数数组
candidates:[1,2,3,4]。 - 用
1、2、3、4四个洗牌,生成一个打乱的基础数组base。 - 创建一个数独:
// 思路: 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的官网一样,生成一个图片,供用户标识哪些是同一类,然后提交进行验证。