สร้างดันเจี้ยนสุ่มด้วย Python: คู่มือ Procedural Generation จากศูนย์ถึงใช้จริง
เกมดังอย่าง Spelunky และ The Binding of Isaac สร้างแผนที่ใหม่ทุกครั้งที่เล่น บทความนี้พาคุณเขียนอัลกอริทึมสร้างดันเจี้ยนด้วย Python ทีละบรรทัด พร้อมนำไปใช้ในเกมจริง
สารบัญ

คุณรู้หรือไม่ว่าแผนที่ในเกมอย่าง Spelunky, The Binding of Isaac และ Diablo ไม่ได้ถูกวาดด้วยมือนักออกแบบแม้แต่ชิ้นเดียว ทุกครั้งที่ผู้เล่นกดเริ่มเกมใหม่ โลกทั้งใบถูกสร้างขึ้นจากอัลกอริทึมในเสี้ยววินาที นี่คือพลังของ Procedural Generation เทคนิคที่เปลี่ยนเกมเล็กๆ ให้มีเนื้อหาไม่รู้จบ
บทความนี้จะพาคุณเขียน Python procedural generation dungeon ทีละขั้น ตั้งแต่แนวคิด ไปจนถึงโค้ดที่รันได้จริงและนำไปเชื่อมกับเอนจินเกมได้ทันที
Procedural Generation คืออะไร และทำไมเกมดังใช้กัน
Procedural Generation คือการสร้างเนื้อหาด้วยชุดคำสั่งและกฎทางคณิตศาสตร์ แทนการวาดหรือออกแบบทุกอย่างด้วยมือ เนื้อหาที่สร้างได้มีตั้งแต่แผนที่, เลเวล, ตัวละคร NPC, ไอเทม ไปจนถึงดนตรีและเนื้อเรื่อง
เกมดังๆ ที่ใช้เทคนิคนี้มีดังนี้
- Minecraft — สร้างโลกมหาศาลไม่มีที่สิ้นสุดด้วย Perlin Noise
- Spelunky — ดันเจี้ยนทุกชั้นสุ่มทุกครั้ง แต่ยังความสมดุลของความท้าทาย
- The Binding of Isaac — ห้องและไอเทมสุ่ม สร้างความเล่นซ้ำได้สูง
- Diablo series — แผนที่ดันเจี้ยนเปลี่ยนทุกครั้ง ช่วยให้เล่นซ้ำไม่เบื่อ
- Rogue (1980) — ต้นตำรับของวงการ roguelike ใช้การสุ่มตั้งแต่ยุคแรก
เหตุผลที่นักพัฒนาเลือกใช้ Procedural Generation มี 3 ข้อหลัก
- ประหยัดเวลาและทรัพยากร — ไม่ต้องวาดแผนที่นับร้อยชั้นด้วยมือ
- เพิ่มค่าการเล่นซ้ำ (Replayability) — ผู้เล่นได้ประสบการณ์ใหม่ทุกครั้ง
- ขนาดเกมเล็กลง — เก็บเป็นอัลกอริทึมและ seed แทนไฟล์แผนที่ขนาดใหญ่
อย่างไรก็ตาม Procedural Generation ไม่ใช่การสุ่มแบบไร้ทิศทาง กฎที่ดีต้องควบคุมความสมดุล ทำให้แผนที่สวยงาม เล่นได้จริง และท้าทายพอเหมาะ
เตรียมสภาพแวดล้อมก่อนเริ่มเขียน
บทความนี้ใช้ Python 3.9 ขึ้นไป ไม่ต้องติดตั้งไลบรารีเพิ่มเติม เพราะเราจะเริ่มจากการแสดงผลแบบ ASCII ก่อน แล้วค่อยขยับไปใช้ไลบรารีกราฟิกในขั้นตอนหลัง
โครงสร้างโปรเจกต์แนะนำ
dungeon_gen/
├── main.py
├── cellular_automata.py
├── bsp_tree.py
└── utils.py
เริ่มจากสร้างไฟล์ utils.py สำหรับฟังก์ชันที่ใช้ร่วมกัน
# utils.py
import random
def create_grid(width, height, fill_char='#'):
"""สร้างกริด 2 มิติเต็มด้วยผนัง"""
return [[fill_char for _ in range(width)] for _ in range(height)]
def print_grid(grid):
"""แสดงผลกริดเป็น ASCII"""
for row in grid:
print(''.join(row))
print()
def count_neighbors(grid, x, y, target_char):
"""นับเซลล์ข้างเคียงที่เป็น target_char (รวมมุมเฉียง)"""
count = 0
for dy in [-1, 0, 1]:
for dx in [-1, 0, 1]:
if dx == 0 and dy == 0:
continue
nx, ny = x + dx, y + dy
if 0 <= ny < len(grid) and 0 <= nx < len(grid[0]):
if grid[ny][nx] == target_char:
count += 1
else:
count += 1 # นับขอบเขตเป็นผนัง
return count
ฟังก์ชัน count_neighbors ใช้กฎ "ขอบเขตคือผนัง" คือเซลล์นอกกริดถือว่าเป็นผนังเสมอ กฎนี้ป้องกันปัญหาที่ถ้ำเปิดออกขอบแผนที่
อัลกอริทึมที่ 1: Cellular Automata สร้างถ้ำสุ่ม
Cellular Automata ได้แรงบันดาลใจจากเกมแห่งชีวิตของ John Conway แนวคิดคือเริ่มจากกริดที่สุ่มเติมผนังและพื้น แล้วปรับแต่งซ้ำๆ ตามกฎง่ายๆ จนกลายเป็นรูปถ้ำที่ดูเป็นธรรมชาติ
กฎของ Cellular Automata สำหรับถ้ำ
- เริ่มต้นเติมผนังสุ่มประมาณ 45% ของพื้นที่ทั้งหมด
- ในแต่ละรอบ ถ้าเซลล์พื้นมีผนังข้างเคียงมากกว่า 4 ตัว ให้กลายเป็นผนัง
- ถ้าเซลล์ผนังมีผนังข้างเคียงน้อยกว่า 4 ตัว ให้กลายเป็นพื้น
- ทำซ้ำ 4-6 รอบ จนรูปร่างคงที่
กฎนี้ทำให้พื้นที่เปิดกระจายตัวและผนังเกาะกลุ่มกัน เกิดถ้ำที่มีทางเดินโค้งงอ เป็นธรรมชาติ
โค้ด Cellular Automata แบบเต็ม
# cellular_automata.py
from utils import create_grid, print_grid, count_neighbors
import random
def generate_cave(width, height, wall_chance=0.45, iterations=5, seed=None):
if seed is not None:
random.seed(seed)
# ขั้นที่ 1: เติมผนังสุ่ม
grid = create_grid(width, height, '.')
for y in range(height):
for x in range(width):
# ขอบทุกด้านเป็นผนังเสมอ
if x == 0 or y == 0 or x == width - 1 or y == height - 1:
grid[y][x] = '#'
elif random.random() < wall_chance:
grid[y][x] = '#'
# ขั้นที่ 2: ปรับแต่งซ้ำตามกฎ
for _ in range(iterations):
new_grid = [row[:] for row in grid]
for y in range(1, height - 1):
for x in range(1, width - 1):
walls = count_neighbors(grid, x, y, '#')
if grid[y][x] == '#':
# ผนังที่มีพื้นข้างเคียงมาก กลายเป็นพื้น
if walls < 4:
new_grid[y][x] = '.'
else:
# พื้นที่มีผนังข้างเคียงมาก กลายเป็นผนัง
if walls >= 4:
new_grid[y][x] = '#'
grid = new_grid
return grid
if __name__ == '__main__':
cave = generate_cave(60, 25, seed=42)
print_grid(cave)
ผลลัพธ์ที่ได้
############################################################
#............###.....##...........####....###..............#
#...####.....##.....###...........##....####...........####
#...###............####............#.....##...........#####
#....##............####............#.....##...........#####
#.....#............####............#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
#.....#............####...##.......#.....##...........#####
############################################################
ผลลัพธ์ข้างต้นแสดงถ้ำที่มีโพรงเปิดและผนังเกาะกลุ่ม ลองเปลี่ยน seed จะได้รูปทรงต่างออกไป แต่ยังคงลักษณะถ้ำเหมือนเดิม นี่คือจุดแข็งของ Cellular Automata ควบคุมรูปแบบได้ด้วยพารามิเตอร์เพียงไม่กี่ตัว
ปัญหาที่พบบ่อยและวิธีแก้
ปัญหาใหญ่ที่สุดของ Cellular Automata คือถ้ำอาจแบ่งเป็นส่วนที่ไม่เชื่อมกัน ผู้เล่นเข้าไม่ถึงบางพื้นที่ วิธีแก้คือใช้ Flood Fill ตรวจสอบและเชื่อมส่วนที่แยกกัน
def flood_fill(grid, start_x, start_y):
"""หาเซลล์พื้นทั้งหมดที่เชื่อมกับจุดเริ่มต้น"""
visited = set()
stack = [(start_x, start_y)]
while stack:
x, y = stack.pop()
if (x, y) in visited:
continue
if grid[y][x] != '.':
continue
visited.add((x, y))
for dx, dy in [(0,1),(0,-1),(1,0),(-1,0)]:
stack.append((x + dx, y + dy))
return visited
def connect_regions(grid):
"""เชื่อมพื้นที่ที่แยกกันด้วยทางเดิน"""
regions = []
checked = set()
for y in range(len(grid)):
for x in range(len(grid[0])):
if grid[y][x] == '.' and (x, y) not in checked:
region = flood_fill(grid, x, y)
checked.update(region)
regions.append(region)
# เชื่อมแต่ละ region เข้าด้วยกัน
for i in range(1, len(regions)):
# ลากเส้นจากจุดกลาง region ก่อนหน้า ไป region ปัจจุบัน
x1, y1 = min(regions[i-1])
x2, y2 = min(regions[i])
carve_path(grid, x1, y1, x2, y2)
def carve_path(grid, x1, y1, x2, y2):
"""เจาะทางเดินจาก (x1,y1) ไป (x2,y2)"""
while x1 != x2 or y1 != y2:
grid[y1][x1] = '.'
if x1 < x2:
x1 += 1
elif x1 > x2:
x1 -= 1
elif y1 < y2:
y1 += 1
elif y1 > y2:
y1 -= 1
อัลกอริทึมที่ 2: BSP Tree แบ่งห้องดันเจี้ยน
Cellular Automata เหมาะกับถ้ำ แต่ถ้าต้องการดันเจี้ยนที่มีห้องชัดเจนและทางเดินเชื่อม BSP Tree (Binary Space Partitioning) คือตัวเลือกที่ดีกว่า
BSP Tree แบ่งพื้นที่สี่เหลี่ยมออกเป็นสองส่วนซ้ำๆ จนได้สี่เหลี่ยมเล็ก แล้วสร้างห้องในแต่ละสี่เหลี่ยม สุดท้ายเชื่อมห้องด้วยทางเดินตามโครงสร้างต้นไม้
โครงสร้าง BSP Tree
พื้นที่ทั้งหมด
├── ซ้าย
│ ├── ซ้าย → ห้อง A
│ └── ขวา → ห้อง B
└── ขวา
├── ซ้าย → ห้อง C
└── ขวา → ห้อง D
ทางเดินเชื่อม A-B และ C-D ก่อน แล้วเชื่อมกลุ่มซ้ายกับกลุ่มขวาทีหลัง ทำให้ทุกห้องเข้าถึงได้
โค้ด BSP Tree แบบเต็ม
# bsp_tree.py
from utils import create_grid, print_grid
import random
class Room:
def __init__(self, x, y, w, h):
self.x = x
self.y = y
self.w = w
self.h = h
self.cx = x + w // 2
self.cy = y + h // 2
class BSPNode:
def __init__(self, x, y, w, h):
self.x = x
self.y = y
self.w = w
self.h = h
self.left = None
self.right = None
self.room = None
def split(self, min_size=8, depth=0, max_depth=5):
if depth >= max_depth:
self.create_room()
return
# เลือกทิศทางการแบ่ง
split_h = self.w > self.h
if split_h:
# แบ่งแนวตั้ง
split_pos = random.randint(min_size, self.w - min_size)
self.left = BSPNode(self.x, self.y, split_pos, self.h)
self.right = BSPNode(self.x + split_pos, self.y,
self.w - split_pos, self.h)
else:
# แบ่งแนวนอน
split_pos = random.randint(min_size, self.h - min_size)
self.left = BSPNode(self.x, self.y, self.w, split_pos)
self.right = BSPNode(self.x, self.y + split_pos,
self.w, self.h - split_pos)
self.left.split(min_size, depth + 1, max_depth)
self.right.split(min_size, depth + 1, max_depth)
def create_room(self):
# สร้างห้องเล็กกว่าพื้นที่เพื่อเว้นผนัง
pad = 1
room_w = random.randint(4, self.w - 2 * pad)
room_h = random.randint(4, self.h - 2 * pad)
room_x = self.x + random.randint(pad, self.w - room_w - pad)
room_y = self.y + random.randint(pad, self.h - room_h - pad)
self.room = Room(room_x, room_y, room_w, room_h)
def get_rooms(node):
"""เก็บห้องทั้งหมดจากต้นไม้"""
rooms = []
if node.room:
rooms.append(node.room)
if node.left:
rooms.extend(get_rooms(node.left))
if node.right:
rooms.extend(get_rooms(node.right))
return rooms
def connect_rooms(grid, node):
"""เชื่อมห้องตามโครงสร้าง BSP"""
if node.room:
return node.room
left_room = connect_rooms(grid, node.left)
right_room = connect_rooms(grid, node.right)
# ลากทางเดินระหว่างกลุ่มซ้ายและขวา
carve_corridor(grid, left_room.cx, left_room.cy,
right_room.cx, right_room.cy)
# คืนห้องที่ใกล้กว่าสำหรับการเชื่อมระดับบน
return left_room
def carve_corridor(grid, x1, y1, x2, y2):
"""เจาะทางเดินแบบ L-shape"""
if random.random() < 0.5:
# แนวนอนก่อน แล้วแนวตั้ง
carve_h_line(grid, x1, x2, y1)
carve_v_line(grid, y1, y2, x2)
else:
carve_v_line(grid, y1, y2, x1)
carve_h_line(grid, x1, x2, y2)
def carve_h_line(grid, x1, x2, y):
for x in range(min(x1, x2), max(x1, x2) + 1):
grid[y][x] = '.'
def carve_v_line(grid, y1, y2, x):
for y in range(min(y1, y2), max(y1, y2) + 1):
grid[y][x] = '.'
def generate_dungeon(width, height, seed=None):
if seed is not None:
random.seed(seed)
grid = create_grid(width, height, '#')
root = BSPNode(1, 1, width - 2, height - 2)
root.split(min_size=8, max_depth=4)
# เจาะห้อง
for room in get_rooms(root):
for y in range(room.y, room.y + room.h):
for x in range(room.x, room.x + room.w):
grid[y][x] = '.'
# เชื่อมทางเดิน
connect_rooms(grid, root)
return grid
if __name__ == '__main__':
dungeon = generate_dungeon(60, 25, seed=7)
print_grid(dungeon)
ผลลัพธ์ที่ได้
############################################################
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
#..........#####...........####............................#
############################################################
ผลลัพธ์จะแตกต่างกันตาม seed แต่ละ seed ให้โครงสร้างห้องและทางเดินที่ไม่ซ้ำกัน ทุกห้องเชื่อมกันเสมอเพราะการเชื่อมทางเดินทำตามโครงสร้างต้นไม้
เปรียบเทียบสองอัลกอริทึม
| คุณสมบัติ | Cellular Automata | BSP Tree |
|---|---|---|
| รูปแบบผลลัพธ์ | ถ้ำเป็นธรรมชาติ | ห้องและทางเดินเป็นระเบียบ |
| การันตีการเชื่อม | ต้องเช็คเพิ่ม | การันตีโดยโครงสร้าง |
| ความซับซ้อน | ต่ำ | ปานกลาง |
| เหมาะกับ | เกมสำรวจถ้ำ, เลเวลธรรมชาติ | เกม roguelike, ดันเจี้ยนมีห้อง |
| ควบคุมขนาดห้อง | ยาก | ง่าย |
วิธีนำแผนที่ไปใช้ในเกมจริง
ถึงตรงนี้คุณมีอัลกอริทึมสร้างแผนที่เป็นกริด ASCII แล้ว ขั้นต่อไปคือนำไปใช้ในเกมจริง มี 3 วิธีหลัก
วิธีที่ 1: ใช้กับ Pygame
Pygame เป็นไลบรารีทำเกม 2D ยอดนิยมของ Python แปลงกริดเป็น Tilemap ได้ง่าย
import pygame
TILE_SIZE = 32
WALL_COLOR = (60, 60, 60)
FLOOR_COLOR = (120, 100, 80)
def render_dungeon(screen, grid):
for y, row in enumerate(grid):
for x, cell in enumerate(row):
color = WALL_COLOR if cell == '#' else FLOOR_COLOR
rect = pygame.Rect(x * TILE_SIZE, y * TILE_SIZE,
TILE_SIZE, TILE_SIZE)
pygame.draw.rect(screen, color, rect)
# ในเกมลูป
dungeon = generate_dungeon(60, 25, seed=42)
screen = pygame.display.set_mode((60 * TILE_SIZE, 25 * TILE_SIZE))
render_dungeon(screen, dungeon)
pygame.display.flip()
วิธีที่ 2: ส่งออกเป็น JSON ใช้กับเอนจินอื่น
ถ้าเกมของคุณใช้ Godot, Unity หรือ Unreal ส่งออกแผนที่เป็น JSON แล้วโหลดในเอนจิน
import json
def export_to_json(grid, filename='dungeon.json'):
data = {
'width': len(grid[0]),
'height': len(grid),
'tiles': grid,
'legend': {'#': 'wall', '.': 'floor'}
}
with open(filename, 'w') as f:
json.dump(data, f, indent=2)
ใน Godot อ่าน JSON แล้วสร้าง TileMap ได้โดยตรง ใน Unity ใช้ JsonUtility และสร้าง GameObject ตามกริด
วิธีที่ 3: ใช้กับ TCOD (Python roguelike library)
TCOD เป็นไลบรารีสำหรับทำเกม roguelike โดยเฉพาะ รองรับทั้งการแสดงผลและเส้นทาง
import tcod
def render_with_tcod(grid):
width = len(grid[0])
height = len(grid)
console = tcod.console.Console(width, height)
for y, row in enumerate(grid):
for x, cell in enumerate(row):
if cell == '#':
console.print(x, y, '#', fg=(100, 100, 100))
else:
console.print(x, y, '.', fg=(200, 180, 150))
return console
เทคนิคระดับสูงเพิ่มเติม
เมื่อเข้าใจพื้นฐานแล้ว ลองขยายไปสู่เทคนิคที่ทำให้แผนที่น่าสนใจยิ่งขึ้น
1. ใส่ประตูและกับดัก
หลังสร้างทางเดินแล้ว สุ่มวางประตูที่จุดเชื่อมระหว่างห้องและทางเดิน
def place_doors(grid, rooms):
for room in rooms:
# ตรวจสอบขอบห้อง
for x in range(room.x, room.x + room.w):
if grid[room.y - 1][x] == '.':
grid[room.y - 1][x] = '+' # ประตู
2. วางบันไดเชื่อมชั้น
เลือกห้องสุดท้ายในโครงสร้าง BSP วางบันไดลงชั้นถัดไป
def place_stairs(grid, rooms):
last_room = rooms[-1]
grid[last_room.cy][last_room.cx] = '>' # บันไดลง
3. ใช้ Seed ระบบเพื่อสร้างโลกที่แชร์กันได้
ถ้าผู้เล่นอยากแชร์แผนที่ให้เพื่อน แค่ส่ง seed ตัวเลขเดียว แล้วเพื่อนรันอัลกอริทึมเดียวกันก็ได้แผนที่เหมือนกันทุกจุด
# ผู้เล่น A สร้างแผนที่
dungeon = generate_dungeon(60, 25, seed=12345)
# ผู้เล่น B รับ seed 12345 แล้วรันใหม่
dungeon = generate_dungeon(60, 25, seed=12345)
# ได้แผนที่เหมือนกันทุกจุด
4. ผสมอัลกอริทึม
ใช้ BSP Tree สร้างโครงห้อง แล้วใช้ Cellular Automata ปรับผนังในแต่ละห้องให้ดูเป็นธรรมชาติ เทคนิคนี้ใช้ในเกมอย่าง Caves of Qud และ Dwarf Fortress
สรุป
Python procedural generation dungeon เป็นทักษะที่คุ้มค่าสำหรับนักพัฒนาเกม คุณได้เรียนรู้สองอัลกอริทึมหลัก ได้โค้ดที่รันได้จริง และรู้วิธีนำไปใช้กับเอนจินเกมยอดนิยม
จุดสำคัญที่ต้องจำ
- Cellular Automata เหมาะกับถ้ำและพื้นที่เป็นธรรมชาติ
- BSP Tree เหมาะกับดันเจี้ยนที่มีห้องและทางเดินเป็นระเบียบ
- ใช้ seed เพื่อสร้างแผนที่ที่ทำซ้ำได้และแชร์กันได้
- ทดสอบการเชื่อมของแผนที่เสมอ เพื่อให้ผู้เล่นเข้าถึงทุกพื้นที่ได้
- ส่งออกเป็น JSON เพื่อใช้กับเอนจินเกมอื่นได้
ขั้นตอนถัดไป
ลองนำโค้ดในบทความนี้ไปรันและปรับพารามิเตอร์ดู จากนั้นลองท้าทายตัวเองด้วยโจทย์ต่อไปนี้
- เพิ่มการวางไอเทมและมอนสเตอร์ในห้อง
- คำนวณระยะทางจากจุดเริ่มต้นไปบันได เพื่อควบคุมความยาก
- สร้างแผนที่หลายชั้นที่เชื่อมกันด้วยบันได
- ผสม Cellular Automata กับ BSP Tree ในแผนที่เดียว
ถ้าบทความนี้มีประโยชน์ แชร์ให้เพื่อนนักพัฒนา และติดตามชุมชนของเราเพื่ออ่านบทความใหม่ๆ ในหมวดภาษาโปรแกรม หรือถ้าคุณลองเขียนแล้วได้ผลลัพธ์น่าสนใจ โพสต์ในชุมชนเพื่อแลกเปลี่ยนกันได้เลย
เนื้อหาที่จัดทำโดยมี AI ช่วยจะมีป้ายกำกับ "เรียบเรียงโดยมี AI ช่วย" เพื่อให้คุณทราบอย่างชัดเจน เราถือว่าความโปร่งใสเรื่องการใช้ AI เป็นสิ่งสำคัญต่อความไว้วางใจของผู้อ่าน
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — มาเป็นคนแรกกันเถอะ!