案例

数独

代码地址

视频(上) 视频(中) 视频(下)

试玩地址

画边框

首先需要画出 9 * 9 的边框 数独边框

用到的节点是 godot 内置的 Line2D 通过两个点确定一条直线, add_point 方法给 Line2D 对象添加起点和终点 例如要画一条竖线

var line = Line2D.new()
line.add_point(Vector2(0, 0))
line.add_point(Vector2(100, 0))

数独由10条竖线, 10条横线组成, 定义好每个格的宽高之后, 就可以循环画 10 条竖线, 10 条横线

const height = 800
const width = 800

var y = 0
for row in range(9 + 1):
	var line = Line2D.new()
	line.add_point(Vector2(0, y))
	line.add_point(Vector2(width, y))
	if row % 3 == 0:
		line.width = 8
	else:
		line.width = 5
	add_child(line)
	y += gap

var x = 0
for col in range(9 + 1):
	var line = Line2D.new()
	line.add_point(Vector2(x, 0))
	line.add_point(Vector2(x, height))
	if col % 3 == 0:
		line.width = 8
	else:
		line.width = 3
	add_child(line)
	x += gap

需要注意的是, 每三行/列的线宽度是 8, 其他的宽度是 3, 用来区分九宫格的边界.

计算单元格的坐标

如果要在某个单元格中输入数字, 那么就需要知道单元格对应屏幕上的坐标是什么, 这样才能把数字设置到正确的位置上. 因此需要有一个函数根据输入的行列位置, 返回屏幕坐标

const gap = height / 9

func grid_idx_to_position(row, col):
	var x = col * gap + gap / 2
	var y = row * gap + gap / 2
	return Vector2(x, y)

col * gap 就可以定位到某一列的开始位置, 再加上 gap, 即半个单元格的像素. 这样返回的坐标就是单元格的正中间, 而不是左上角

用户操作的时候, 会用鼠标点击某个屏幕坐标, 这时候就需要知道鼠标点击坐标对应的行列是多少, 才可以在对应的单元格进行操作. 因此还需要一个根据输入屏幕坐标, 返回单元格行列的函数

func position_to_grid_idx(position):
	var row = int(position.y / gap)
	var col = int(position.x / gap)
	return [row, col]

画初始数字

首先设置好初始题目是什么样, 哪些位置有预设好的数字, 哪些位置是空的

const puzzles = [
	[
		[1,0,0,0,0,0,0,0,3],
		[0,9,0,0,6,0,0,2,0],
		[7,0,0,0,4,8,6,9,0],
		[0,8,0,5,0,1,9,0,0],
		[0,0,0,6,0,4,0,0,0],
		[0,0,5,9,0,7,0,1,0],
		[0,4,1,2,7,0,0,0,9],
		[0,7,0,0,1,0,0,6,0],
		[3,0,0,0,0,0,0,0,8],
	],
	[
		[3,0,0,0,0,0,1,0,0],
		[2,0,0,8,6,0,7,0,0],
		[0,7,0,0,0,4,6,2,0],
		[7,0,0,0,8,2,0,3,0],
		[0,0,0,4,0,6,0,0,0],
		[0,8,0,9,7,0,0,0,6],
		[0,3,7,5,0,0,0,6,0],
		[0,0,5,0,2,8,0,0,9],
		[0,0,8,0,0,0,0,0,1]
	],
	[
		[3,0,0,0,0,0,1,0,0],
		[2,0,0,8,6,0,7,0,0],
		[0,7,0,0,0,4,6,2,0],
		[7,0,0,0,8,2,0,3,0],
		[0,0,0,4,0,6,0,0,0],
		[0,8,0,9,7,0,0,0,6],
		[0,3,7,5,0,0,0,6,0],
		[0,0,5,0,2,8,0,0,9],
		[0,0,8,0,0,0,0,0,1]
	],
	[
		[0,9,0,0,0,3,0,0,0],
		[8,0,0,5,6,2,9,4,0],
		[0,6,4,9,7,0,0,0,0],
		[3,0,0,6,9,0,5,0,0],
		[9,0,0,0,0,0,0,0,4],
		[0,0,6,0,3,7,0,0,9],
		[0,0,0,0,2,6,3,1,0],
		[0,8,3,7,1,5,0,0,6],
		[0,0,0,3,0,0,0,7,0]
	]
]
const puzzle = puzzles[0]

这里设置了3个题目, 然后选用了第0个. puzzle 用一个二维数组, puzzle[i][j] 就表示了第 i 行, 第 j 列的数字, 如果是 0 就表示没数字, 因为数独不会出现 0, 所以可以用 0 来表示空白.

在游戏初始化的时候, 需要遍历 puzzle 里面的每个元素, 把数字画到单元格里

func draw_puzzle():
	for row_idx in len(puzzle):
		var row = puzzle[row_idx]
		for col_idx in len(row):
			var cell = row[col_idx]
			if cell != 0:
				add_fix_number(row_idx, col_idx, cell)

func add_fix_number(row, col, number):
	var label = Label.new()
	label.text = str(number)
	label.set_position(grid_idx_to_position(row, col) + magic)
	add_child(label)

处理用户输入

操作方式是, 用户先用鼠标点击某个单元格, 然后输入数字, 就会往对应的单元格填写输入的数字. 因此需要先用 select_idx 变量记录用户点击了哪个单元格

if event is InputEventMouseButton && event.is_pressed():
	var position = event.position
	select_idx = position_to_grid_idx(position)

接下来如果用户按了键盘数字键, 就把对应的数字加到单元格

if event is InputEventKey && event.is_pressed():
	var number = event.keycode - KEY_0
	if 1 <= number && number <= 9:
		add_number(select_idx[0], select_idx[1], number)

func add_number(row, col, number):
	state[row][col] = number
	var label = Label.new()
	label.text = str(number)
	var position = grid_idx_to_position(row, col) + magic	
	label.set_position(position)
	add_child(label)

用 state 和 label 记录游戏状态

目前有一个 bug, 如果用户点击一个单元格之后, 输入不同的数字, 之前的数字不会被覆盖, 而是不断往上叠加. 因此需要有状态记录用户哪些位置输入过数字. 初始化 state 和 labels 两个数组, 都是 9x9 的, state 初始化成和 puzzle 一样, 把 puzzle 里面预设的数字先复制到 state, labels 初始化为空的.

state = puzzle.duplicate(true)
	
labels = [
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
	[null,null,null,null,null,null,null,null,null],
]

labels 是用于记录所有 new 出来的 Label 节点, 当一个单元格的数字被覆盖时, 需要找到 labels 里面对应的内容, 调用 free()

# 添加 label 的时候, 在 labels 里面记录
func add_number(row, col, number):
	state[row][col] = number
	var label = Label.new()
	label.text = str(number)
	var position = grid_idx_to_position(row, col) + magic	
	label.set_position(position)
	add_child(label)
	labels[row][col] = label # 注意这里

# 如果之前已经有填过数字在当前单元格, 就先清空再输入
func input_number(row, col, number):
	if state[row][col] == 0:
		add_number(row, col, number)
	else:
		if labels[row][col]:
			labels[row][col].free() # 注意这里
		add_number(row, col, number)

标记功能

每个单元格里面, 如果还不明确最终要填的数字是什么, 可以先打上标记, 标记可以有多个, 标记填的是这个单元格可能是什么数字. 最终的效果要做成这样, 其中 1, 9, 7 是预设的数字, 其他那些单元格填写了 9 个小数字的, 那些小数字都是标记. sudoku_note

标记数字和普通数字一样, 需要能输入和删除. 标记的交互方式是在输入数字时, 按下 shift 键.

if event is InputEventKey && event is InputEventWithModifiers && event.is_pressed():
	var number = event.keycode - KEY_0
	if 1 <= number && number <= 9:
		input_number(select_idx[0], select_idx[1], number, event.shift_pressed)

判断点击事件的 shift_pressed 属性, 就可以知道用户想输入的是普通数字还是标记, input_number 需要加多个参数

func input_number(row, col, number, is_note):
	if is_note:
		if notes[row][col][number-1] == null:
			add_note(row, col, number)
		else:
			remove_note(row, col, number)
	else:
		if state[row][col] == 0:
			add_number(row, col, number)
		else:
			if labels[row][col]:
				labels[row][col].free()
			add_number(row, col, number)

注意这里有一个新的变量 notes, notes 和 labels 类似, 都是用来记录已经填了的 label 节点, 删除的时候需要找出来调用 free. notes 不同的点在于, notes 是一个三维的数组, notes[i][j][k] 表示第 i 行, 第 j 列, 是否有 k 这个数字标记. 因为一个单元格可以有多个标记, 所有多了一个维度. 以下是 notes 的初始化部分

notes = []
for row in range(9):
	notes.append([])
	for col in range(9):
		var cell = [null,null,null,null,null,null,null,null,null]
		notes[row].append(cell)

清楚标记就是把 notes 里面对应的 label 找出来 free

func remove_note(row, col, number):
	var label = notes[row][col][number-1]
	if label != null:
		label.free()

自定义字体字号

首先需要在网上找 ttf 类型的字体文件, 放到项目里面, 我放到了 assets/fonts 里面. 创建一个 resource, 类型选 FontFile, 把 ttf 拖动到 path create_resource drag_font

然后在代码里面加载这些字体问题. 在这里我用了两个字体, 一个加粗和额外加粗, 分别给用户填的数字和预设数字使用. 标记就用默认的字体.

const bold_font = preload("res://assets/fonts/BoldFont.tres")
const extra_bold_font = preload("res://assets/fonts/ExtraBoldFont.tres")

# 预设数字加粗, 调大字号
func add_fix_number(row, col, number):
	var label = Label.new()
	label.text = str(number)
	label.set_position(grid_idx_to_position(row, col) + magic)
	label.add_theme_font_size_override("font_size", 32)
	label.set("theme_override_fonts/font", extra_bold_font)
	add_child(label)

# 用户输入数字加粗, 调大字号
func add_number(row, col, number):
	state[row][col] = number
	var label = Label.new()
	label.text = str(number)
	var position = grid_idx_to_position(row, col) + magic	
	label.set_position(position)
	label.set("theme_override_fonts/font", bold_font)
	label.add_theme_font_size_override("font_size", 32)
	add_child(label)
	labels[row][col] = label

到目前为止, 所有UI的部分就做完了.

排除法

解数独用的方式是, 先对每个单元格都生成所有的标记, 即每个空白的单元格, 可能填写的都是 9 个数字里面的任何一个. 接着使用排除法, 排除法有很多种, 例如行排除, 如果同一行有 1, 那么标记就需要把 1 删除.

func prune_by_row():
	for row in range(9):
		for number in range(1, 10):
			if row_has(row, number):
				clear_row(row, number)

func row_has(row, number):
	"""
	判断第 row 行是否有 number
	"""
	for col in range(9):
		if get_number(row, col) == number:
			return true
	return false

func clear_row(row, number):
	"""
	把第 row 行上面的 number 标记都删除
	"""
	for col in range(9):
		remove_note(row, col, number)

同理还可以列排除

func prune_by_col():
	for col in range(9):
		for number in range(1, 10):
			if col_has(col, number):
				clear_col(col, number)

func col_has(col, number):
	"""
	判断第 col 列是否有 number
	"""
	for row in range(9):
		if get_number(row, col) == number:
			return true
	return false

func clear_col(col, number):
	for row in range(9):
		remove_note(row, col, number)

九宫格排除也是类似的, 不过稍微麻烦一点的是, 需要知道当前九宫格里面, 都有那些单元格. 数独里面一共只有 9 个九宫格, 所以可以把每个九宫格里面有那些单元格都提前写死.

func set_blocks():
	blocks = []
	for block in range(9):
		var rows
		var cols
		match block:
			0:
				rows = [0,1,2]
				cols = [0,1,2]
			1:
				rows = [0,1,2]
				cols = [3,4,5]
			2:
				rows = [0,1,2]
				cols = [6,7,8]
			3:
				rows = [3,4,5]
				cols = [0,1,2]
			4:
				rows = [3,4,5]
				cols = [3,4,5]
			5:
				rows = [3,4,5]
				cols = [6,7,8]
			6:
				rows = [6,7,8]
				cols = [0,1,2]
			7:
				rows = [6,7,8]
				cols = [3,4,5]
			8:
				rows = [6,7,8]
				cols = [6,7,8]
		blocks.append({"rows": rows, "cols": cols})

然后就可以用 blocks 来判断当前 block 里面是否已经出现过某个数字了

func block_has(block, number):
	var rows = blocks[block]["rows"]
	var cols = blocks[block]["cols"]
	
	for row in rows:
		for col in cols:
			if get_number(row, col) == number:
				return true
	return false