You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

纯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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 00:36:19