纯Tkinter实现沙粒模拟程序的两类bug排查与修复
Tkinter沙粒模拟程序Bug修复
问题根因说明
1. 拖拽生成沙粒时鼠标列颜色固定
根因出在渲染逻辑:原渲染逻辑仅在格子的块类型发生变化(空气变沙/沙变空气/沙变墙)时才会重绘矩形,完全没有检测颜色变化。按住鼠标生成沙粒时,鼠标所在格子每帧都会被重新赋值为沙粒,前后帧的块类型始终是SAND,哪怕给颜色数组写入了新的随机色,渲染逻辑也会直接跳过该格子的重绘,一直显示第一次生成沙粒时的颜色。
2. 沙堆侧边从底部向上异常堆叠
两个核心问题导致该现象:
- 斜向滑动的判断条件写错了:沙粒要滑到左/右下方的格子,只需要目标斜下格子是空气即可,原代码额外要求同高度的左/右格子也是空气,这个条件完全多余,直接导致沙粒无法顺着沙堆斜坡正常滚落,卡在坡边出现异常堆叠。
- 遍历沙粒处理斜向滑动时,左滑判断完成后没有中断当前沙粒的处理流程,会继续执行右滑判断,导致沙粒可能一帧内连续移动两次,出现不符合重力逻辑的位移。
其他潜在缺陷
- 每帧用
deepcopy拷贝整个二维网格,分辨率高时性能损耗极大,帧率会骤降。 - 渲染时用
find_enclosed遍历全画布元素找对应格子的矩形,画布元素多了之后卡顿明显。 - 生成沙粒/墙时没有判断目标格子是否为空,直接覆盖原有块,会误删已经生成的沙粒或墙体。
- 新绘制的矩形全部用
tag_lower塞到画布最底层,后续UI元素会被沙粒挡住,容易出现层级错乱。 - Tkinter的
after方法最小精度约16ms,对应最高60帧,设置100的目标FPS没有实际意义,反而会导致逻辑堆积。 - 存在冗余代码:
updateParticles里定义的color = choice(['red', 'white', 'blue'])完全没有被使用。
修复后完整代码
from tkinter import * from random import choice, random from colorsys import hsv_to_rgb CELLSIZE = 8 # 可自行调整格子尺寸,值越小沙粒视觉效果越细腻 AIR = 0 WALL = 1 SAND = 2 BG = '#cef' SANDCOLOR = (45, 45, 86) WALLCOLOR = (224, 37, 34) TARGETFPS = 60 # 调整为Tkinter可稳定达到的帧率上限 def randomColor(h, s, v): h, s, v = (h / 360), s / 100, v / 100 s += (random() - 0.5) * 0.1 v += (random() - 0.5) * 0.1 s = max(0, min(1, s)) v = max(0, min(1, v)) r, g, b = [round(i * 255) for i in hsv_to_rgb(h, s, v)] return '#%02x%02x%02x' % (r, g, b) class App: def __init__(self): global WIDTH, HEIGHT self.master = Tk() self.master.title('Sand Simulation') self.master.resizable(0, 0) self.master.attributes('-fullscreen', True) WIDTH = self.master.winfo_screenwidth() // CELLSIZE HEIGHT = self.master.winfo_screenheight() // CELLSIZE Width, Height = WIDTH * CELLSIZE, HEIGHT * CELLSIZE self.canvas = Canvas(self.master, width=Width, height=Height, bg=BG, highlightthickness=0) self.canvas.pack() self.map = [[AIR] * WIDTH for _ in range(HEIGHT)] self.colors = [[BG] * WIDTH for _ in range(HEIGHT)] self.items = [[None] * WIDTH for _ in range(HEIGHT)] # 存储每个格子对应的画布元素ID,替代find_enclosed提升性能 self.positions = [] # 从下往上、从左到右生成遍历序列,避免沙粒一帧掉落多格 for y in range(HEIGHT-1, -1, -1): for x in range(WIDTH): self.positions.append([x, y]) self.dragging, self.dragX, self.dragY = False, 0, 0 self.canvas.bind('<Button-1>', self.mouseDown) self.canvas.bind('<B1-Motion>', self.mouseDrag) self.canvas.bind('<ButtonRelease-1>', self.mouseUp) self.images = [PhotoImage().blank(), PhotoImage().blank(), PhotoImage().blank(), PhotoImage().blank()] self.sandButton = self.canvas.create_image(125, 125, anchor='center', image=self.images[1]) self.wallButton = self.canvas.create_image(125, 325, anchor='center', image=self.images[2]) self.drawingMode = 'SAND' self.master.after(round(1 / TARGETFPS * 1000), self.frame) self.master.mainloop() def swapBlocks(self, x1, y1, x2, y2): # 交换两个格子的块类型和颜色 self.map[y1][x1], self.map[y2][x2] = self.map[y2][x2], self.map[y1][x1] self.colors[y1][x1], self.colors[y2][x2] = self.colors[y2][x2], self.colors[y1][x1] def mouseDown(self, event): if 50 < event.x < 200 and 50 < event.y < 200: self.drawingMode = 'SAND' self.canvas.itemconfig(self.sandButton, image=self.images[1]) self.canvas.itemconfig(self.wallButton, image=self.images[2]) elif 50 < event.x < 200 and 250 < event.y < 400: self.drawingMode = 'WALL' self.canvas.itemconfig(self.sandButton, image=self.images[0]) self.canvas.itemconfig(self.wallButton, image=self.images[3]) else: self.dragging = True self.dragX = event.x // CELLSIZE self.dragY = event.y // CELLSIZE self.dragX = max(0, min(WIDTH-1, self.dragX)) self.dragY = max(0, min(HEIGHT-1, self.dragY)) def mouseDrag(self, event): self.dragX = event.x // CELLSIZE self.dragY = event.y // CELLSIZE self.dragX = max(0, min(WIDTH-1, self.dragX)) self.dragY = max(0, min(HEIGHT-1, self.dragY)) def mouseUp(self, event): self.dragging = False def updateParticles(self): if self.dragging: tx, ty = self.dragX, self.dragY # 仅当目标格子为空时才生成新块,避免覆盖原有内容 if self.map[ty][tx] == AIR: if self.drawingMode == 'SAND': self.map[ty][tx] = SAND self.colors[ty][tx] = randomColor(*SANDCOLOR) elif self.drawingMode == 'WALL': self.map[ty][tx] = WALL self.colors[ty][tx] = randomColor(*WALLCOLOR) for x, y in self.positions: block = self.map[y][x] if block != SAND: continue # 正下方为空则直接下落 if y < HEIGHT - 1 and self.map[y+1][x] == AIR: self.swapBlocks(x, y, x, y+1) continue # 处理斜向滑动逻辑 can_go_left = False can_go_right = False # 检查左下方是否可移动 if x > 0 and y < HEIGHT -1 and self.map[y+1][x-1] == AIR: can_go_left = True # 检查右下方是否可移动 if x < WIDTH-1 and y < HEIGHT-1 and self.map[y+1][x+1] == AIR: can_go_right = True if can_go_left and can_go_right: # 两侧都可移动时随机选方向 self.swapBlocks(x, y, x-1, y+1) if choice([True, False]) else self.swapBlocks(x, y, x+1, y+1) elif can_go_left: self.swapBlocks(x, y, x-1, y+1) elif can_go_right: self.swapBlocks(x, y, x+1, y+1) def renderMap(self): # 移除高开销的deepcopy操作,直接遍历格子更新渲染内容 for y in range(HEIGHT): for x in range(WIDTH): current_block = self.map[y][x] current_color = self.colors[y][x] x1, y1 = x * CELLSIZE, y * CELLSIZE x2, y2 = x1 + CELLSIZE, y1 + CELLSIZE item = self.items[y][x] if current_block == AIR: # 当前格为空气时删除已有元素 if item is not None: self.canvas.delete(item) self.items[y][x] = None else: # 当前格为沙/墙时,无元素则新建,有元素则检测颜色更新 if item is None: rect = self.canvas.create_rectangle(x1, y1, x2, y2, outline='', fill=current_color) self.items[y][x] = rect else: if self.canvas.itemcget(item, 'fill') != current_color: self.canvas.itemconfig(item, fill=current_color) # 将UI按钮置顶,避免被沙粒遮挡 self.canvas.tag_raise(self.sandButton) self.canvas.tag_raise(self.wallButton) self.canvas.update_idletasks() self.canvas.update() def frame(self): self.updateParticles() self.renderMap() self.master.after(round(1 / TARGETFPS * 1000), self.frame) def main(): app = App() if __name__ == '__main__': main()
修复效果说明
- 拖拽生成沙粒时颜色随机变化正常,不会出现整列颜色固定的问题。
- 沙粒沿斜坡滚落逻辑符合物理预期,沙堆会自然形成稳定的安息角,不会出现侧边从下往上堆叠的异常。
- 移除了高开销的深拷贝和全画布元素查找操作,大分辨率下运行流畅度提升明显。
- 新增格子非空判断,生成沙粒/墙体时不会误覆盖已有内容。
- UI按钮自动置顶,不会被沙粒遮挡。
内容的提问来源于stack exchange,提问作者Bituvo
相关产品推荐
相关产品推荐

