落沙游戏(Sand Simulation / Particle Sand Game)是一种经典的物理模拟类像素级沙盒游戏,玩家可以放置不同类型的“粒子”(如沙子、水、火、岩石等),它们根据简单的物理规则(重力、碰撞、扩散、燃烧等)自主演化。这类游戏逻辑清晰、视觉反馈强,非常适合用 Python + PyGame 实现原型。
下面我将为你开发一个**轻量、可运行、带注释、模块化**的落沙游戏(支持:沙粒 `SAND`、静态岩石 `ROCK`、流动水 `WATER`、可燃火 `FIRE`),使用 PyGame 渲染,支持鼠标拖拽放置/擦除,支持粒子类型切换。
---
### ✅ 功能概览:
- 800×600 窗口,16×16 像素粒子网格(50×37 粒子格)
- 四种粒子类型(沙、水、火、岩石),每种有独特行为
- 沙:受重力下落,遇障碍则向左/右下堆叠
- 水:受重力下落,下落失败则左右扩散(优先左→右→上?不,水应水平流动),遇阻则堆高
- 火:向上蔓延(模拟热气上升),接触沙/水会熄灭,接触火会增强(暂简化为随机传播+自熄)
- 岩石:不可移动,永久固定
- 鼠标左键放置当前粒子,右键擦除(置为空 `AIR`)
- 键盘 `1~4` 切换粒子类型(1=沙, 2=水, 3=火, 4=岩石)
- 按空格暂停/继续模拟
> ⚠️ 注意:本实现采用**逐帧更新单层粒子**(非多线程),避免状态竞争;所有更新基于**上一帧快照**(即“同步更新”,避免同一帧内A影响B再影响C的连锁误判)。
---
### 🧩 完整可运行代码(Python + PyGame)
```python
import pygame
import random
import sys
# -------------------------------
# 🔧 配置常量
# -------------------------------
WIDTH, HEIGHT = 800, 600
CELL_SIZE = 16
GRID_W = WIDTH // CELL_SIZE # 50
GRID_H = HEIGHT // CELL_SIZE # 37
FPS = 60
# 粒子类型编码(整数,便于数组存储)
AIR = 0
SAND = 1
WATER = 2
FIRE = 3
ROCK = 4
# 颜色定义(RGB)
COLORS = {
AIR: ( 30, 30, 30), # 深灰背景
SAND: (220, 190, 130), # 沙黄
WATER:( 60, 120, 200), # 深蓝
FIRE: (255, 80, 20), # 橙红
ROCK: (100, 100, 100), # 灰石
}
# -------------------------------
# 🧱 粒子世界类(核心模拟逻辑)
# -------------------------------
class SandWorld:
def __init__(self):
self.grid = [[AIR for _ in range(GRID_W)] for _ in range(GRID_H)]
self.paused = False
self.current_type = SAND # 默认画沙
def in_bounds(self, x, y):
return 0 <= x < GRID_W and 0 <= y < GRID_H
def update(self):
if self.paused:
return
# 创建上一帧的快照(避免原地修改导致连锁错误)
old = [[self.grid[y][x] for x in range(GRID_W)] for y in range(GRID_H)]
# 从底部向上更新(避免沙子下落后又被同一帧再次处理)
# 更稳健做法:从下到上、从左到右遍历(但沙子只需检查下方,水需检查下方+左右)
# 我们采用「从底行向上逐行」更新(y 从 GRID_H-2 到 0),确保下方已稳定
for y in range(GRID_H - 2, -1, -1): # 从倒数第二行开始(最后一行无下方)
for x in range(GRID_W):
cell = old[y][x]
if cell == AIR or cell == ROCK:
continue
# === 沙子:下落 → 左下 → 右下 → 停止 ===
if cell == SAND:
# 1. 正下方
if self.in_bounds(x, y+1) and old[y+1][x] == AIR:
self.grid[y+1][x] = SAND
self.grid[y][x] = AIR
continue
# 2. 左下方
if self.in_bounds(x-1, y+1) and old[y+1][x-1] == AIR:
self.grid[y+1][x-1] = SAND
self.grid[y][x] = AIR
continue
# 3. 右下方
if self.in_bounds(x+1, y+1) and old[y+1][x+1] == AIR:
self.grid[y+1][x+1] = SAND
self.grid[y][x] = AIR
continue
# === 水:优先下落 → 若不行,则左右流动(随机顺序更自然)===
elif cell == WATER:
# 下方
if self.in_bounds(x, y+1) and old[y+1][x] == AIR:
self.grid[y+1][x] = WATER
self.grid[y][x] = AIR
continue
# 左右扩散(先左后右,或随机选一)
dirs = [(x-1, y), (x+1, y)]
random.shuffle(dirs)
for nx, ny in dirs:
if self.in_bounds(nx, ny) and old[ny][nx] == AIR:
self.grid[ny][nx] = WATER
self.grid[y][x] = AIR
break
# === 火:向上蔓延(热空气),并随机向邻近传播(沙/水会熄灭它)===
elif cell == FIRE:
# 自熄:若下方是水或沙(概率性),或周围无燃料 → 熄灭
# 简化规则:火存在3帧后自动熄灭(加计时器太重,改用「随机存活」)
if random.random() < 0.05: # 5% 每帧自熄
self.grid[y][x] = AIR
continue
# 向上传播(最常见)
if self.in_bounds(x, y-1) and old[y-1][x] == AIR:
self.grid[y-1][x] = FIRE
self.grid[y][x] = AIR
continue
# 向四周随机传播(仅对 AIR)
neighbors = [(x-1,y), (x+1,y), (x,y-1), (x,y+1)]
random.shuffle(neighbors)
for nx, ny in neighbors:
if self.in_bounds(nx, ny) and old[ny][nx] == AIR:
self.grid[ny][nx] = FIRE
self.grid[y][x] = AIR
break
def set_cell(self, x, y, value):
if self.in_bounds(x, y):
self.grid[y][x] = value
def get_cell(self, x, y):
if self.in_bounds(x, y):
return self.grid[y][x]
return AIR
# -------------------------------
# 🎮 主游戏循环
# -------------------------------
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("🪨 落沙模拟器 — Sand Simulator")
clock = pygame.time.Clock()
world = SandWorld()
font = pygame.font.SysFont(None, 24)
# 鼠标拖拽状态
drawing = False
last_pos = None
while True:
mouse_x, mouse_y = pygame.mouse.get_pos()
grid_x, grid_y = mouse_x // CELL_SIZE, mouse_y // CELL_SIZE
# --- 处理事件 ---
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
world.paused = not world.paused
elif event.key == pygame.K_1:
world.current_type = SAND
elif event.key == pygame.K_2:
world.current_type = WATER
elif event.key == pygame.K_3:
world.current_type = FIRE
elif event.key == pygame.K_4:
world.current_type = ROCK
elif event.key == pygame.K_c:
# 清屏
for y in range(GRID_H):
for x in range(GRID_W):
world.grid[y][x] = AIR
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1: # 左键
drawing = True
world.set_cell(grid_x, grid_y, world.current_type)
elif event.button == 3: # 右键擦除
drawing = True
world.set_cell(grid_x, grid_y, AIR)
elif event.type == pygame.MOUSEBUTTONUP:
if event.button in (1, 3):
drawing = False
last_pos = None
elif event.type == pygame.MOUSEMOTION and drawing:
# 连续绘制(画线式填充)
if last_pos:
# Bresenham 线段插值(简化:直接填充端点+中点)
start_x, start_y = last_pos
end_x, end_y = grid_x, grid_y
dx = abs(end_x - start_x)
dy = abs(end_y - start_y)
sx = 1 if start_x < end_x else -1
sy = 1 if start_y < end_y else -1
err = dx - dy
while True:
world.set_cell(start_x, start_y,
AIR if pygame.mouse.get_pressed()[2] else world.current_type)
if start_x == end_x and start_y == end_y:
break
e2 = 2 * err
if e2 > -dy:
err -= dy
start_x += sx
if e2 < dx:
err += dx
start_y += sy
last_pos = (grid_x, grid_y)
# --- 更新世界 ---
world.update()
# --- 渲染 ---
screen.fill(COLORS[AIR]) # 背景
# 绘制所有粒子
for y in range(GRID_H):
for x in range(GRID_W):
cell = world.grid[y][x]
if cell != AIR:
rect = pygame.Rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE)
pygame.draw.rect(screen, COLORS[cell], rect)
# 可选:加边框提升颗粒感
pygame.draw.rect(screen, (30,30,30), rect, 1)
# 绘制 UI 提示
status = f"Type: {['AIR','SAND','WATER','FIRE','ROCK'][world.current_type]} | SPACE: {'PAUSED' if world.paused else 'RUNNING'} | RClick: Erase"
txt = font.render(status, True, (220,220,220))
screen.blit(txt, (10, 10))
# 绘制当前粒子图标(小预览)
icon_rect = pygame.Rect(10, HEIGHT-40, 32, 32)
pygame.draw.rect(screen, COLORS[world.current_type], icon_rect)
pygame.draw.rect(screen, (50,50,50), icon_rect, 2)
pygame.display.flip()
clock.tick(FPS)
if __name__ == "__main__":
main()
```
---
### 📌 代码解释说明:
| 模块 | 说明 |
|------|------|
| **`COLORS` 映射** | 使用整数 ID(`SAND=1`)而非字符串,节省内存且索引快;颜色直观易调。 |
| **`SandWorld.update()`** | 核心模拟函数。关键点:<br>✅ 使用 `old` 快照避免“同一帧内粒子相互影响”(如沙A下落压住沙B,B又立刻下落——这是错误的);<br>✅ 沙子按「下→左下→右下」顺序尝试,符合真实堆叠;<br>✅ 水加入 `random.shuffle` 实现左右流动随机性,避免对称卡死;<br>✅ 火焰设计为「向上为主 + 随机四向」+「低概率自熄」,平衡表现力与性能。 |
| **鼠标绘制优化** | 支持拖拽画线(Bresenham 算法插值),比逐点更平滑;右键擦除与左键放置共享同一套逻辑。 |
| **UI 交互** | `K1~K4` 切类型、空格暂停、`Kc` 清屏,符合用户直觉;右下角显示当前粒子图标。 |
> 💡 提示:首次运行需安装依赖:
> ```bash
> pip install pygame
> ```
---
### 🌟 进阶建议(你后续可拓展的方向):
- 添加「粘液」(缓慢下落+拉伸)、「蒸汽」(向上快速消散)
- 引入「温度」系统,让火加热邻近沙子变玻璃(新粒子)
- 支持保存/加载 `.sand` 地图(用 `json` 序列化 `grid`)
- 加入音效(沙粒落地 `tick`、水声 `splash`)
- 使用 NumPy 加速网格计算(适合万级粒子)
---