개발자입니다
[클린 코드 파이썬] 14장: 실전 프로젝트: 하노이 탑과 사목 게임 본문
하노이 탑
"""하노이의 탑, Al Sweigart al@inventwithpython.com
원반 이동 퍼즐 게임.
이 코드는 https://nostarch.com/big-book-small-python-programming 에서 볼 수 있습니다.
태그: 간단한, 게임, 퍼즐"""
import copy
import sys
TOTAL_DISKS = 5 # 더 많은 원반은 더 어려운 퍼즐을 의미합니다.
# 모든 원반을 A 타워에 시작합니다:
COMPLETE_TOWER = list(range(TOTAL_DISKS, 0, -1))
def main():
print("""하노이의 탑, Al Sweigart al@inventwithpython.com
원반을 하나씩 다른 타워로 옮기세요. 큰 원반은 작은 원반 위에 놓을 수 없습니다.
더 많은 정보는 https://en.wikipedia.org/wiki/Tower_of_Hanoi 에서 확인하세요.
"""
)
# 타워를 설정합니다. 리스트의 끝이 타워의 꼭대기입니다.
towers = {'A': copy.copy(COMPLETE_TOWER), 'B': [], 'C': []}
while True: # 단일 턴을 실행합니다.
# 타워와 원반을 표시합니다:
displayTowers(towers)
# 사용자에게 이동할 타워를 묻습니다:
fromTower, toTower = askForPlayerMove(towers)
# fromTower의 맨 위 원반을 toTower로 이동합니다:
disk = towers[fromTower].pop()
towers[toTower].append(disk)
# 사용자가 퍼즐을 해결했는지 확인합니다:
if COMPLETE_TOWER in (towers['B'], towers['C']):
displayTowers(towers) # 마지막으로 타워를 표시합니다.
print('퍼즐을 해결했습니다! 잘하셨습니다!')
sys.exit()
def askForPlayerMove(towers):
"""사용자에게 이동할 타워를 묻습니다. (fromTower, toTower)를 반환합니다."""
while True: # 유효한 이동을 입력할 때까지 계속 묻습니다.
print('이동할 "from" 타워와 "to" 타워의 문자를 입력하세요, 또는 QUIT을 입력하세요.')
print('(예: AB는 A 타워에서 B 타워로 원반을 이동합니다.)')
response = input('> ').upper().strip()
if response == 'QUIT':
print('게임을 플레이해주셔서 감사합니다!')
sys.exit()
# 사용자가 유효한 타워 문자를 입력했는지 확인합니다:
if response not in ('AB', 'AC', 'BA', 'BC', 'CA', 'CB'):
print('AB, AC, BA, BC, CA, 또는 CB 중 하나를 입력하세요.')
continue # 사용자에게 다시 이동할 타워를 묻습니다.
# 가독성을 높이기 위한 변수 이름 설정:
fromTower, toTower = response[0], response[1]
if len(towers[fromTower]) == 0:
# "from" 타워는 비어있으면 안 됩니다:
print('선택한 타워에 원반이 없습니다.')
continue # 사용자에게 다시 이동할 타워를 묻습니다.
elif len(towers[toTower]) == 0:
# 원반은 비어 있는 "to" 타워로 옮길 수 있습니다:
return fromTower, toTower
elif towers[toTower][-1] < towers[fromTower][-1]:
print('큰 원반을 작은 원반 위에 놓을 수 없습니다.')
continue # 사용자에게 다시 이동할 타워를 묻습니다.
else:
# 유효한 이동이므로 선택한 타워를 반환합니다:
return fromTower, toTower
def displayTowers(towers):
"""현재 상태를 표시합니다."""
# 세 개의 타워를 표시합니다:
for level in range(TOTAL_DISKS, -1, -1):
for tower in (towers['A'], towers['B'], towers['C']):
if level >= len(tower):
displayDisk(0) # 원반이 없는 기둥을 표시합니다.
else:
displayDisk(tower[level]) # 원반을 표시합니다.
print()
# 타워 레이블 A, B, C를 표시합니다.
emptySpace = ' ' * (TOTAL_DISKS)
print('{0} A{0}{0} B{0}{0} C\n'.format(emptySpace))
def displayDisk(width):
"""주어진 너비의 원반을 표시합니다. 너비가 0이면 원반이 없음을 의미합니다."""
emptySpace = ' ' * (TOTAL_DISKS - width)
if width == 0:
# 원반이 없는 기둥 부분을 표시합니다:
print(emptySpace + '||' + emptySpace, end='')
else:
# 원반을 표시합니다:
disk = '@' * width
numLabel = str(width).rjust(2, '_')
print(emptySpace + disk + numLabel + disk + emptySpace, end='')
# 프로그램이 실행될 때 (임포트되지 않고), 게임을 실행합니다:
if __name__ == '__main__':
main()
상수를 파일의 맨 위쪽에 정의하여 한데 묶고 전역 변수로 만든다.
함수에도 독스트링이 존재할 수 있다. def 문 아래의 main()에 대한 독스트링에 주목하자.
일반적으로 "AB", "AC" 등을 매직 값으로 하드코딩하는 구현 방식은 악습으로 여겨지며, 여기서 구현한 방식은 프로그램의 기둥이 세 개만 존재하는 경우에 유효하다. 하지만 TOTAL_DISKS 상수를 변경해 원판 수를 조정하고 싶을 수는 있어도, 게임에 기둥을 더 추가할 가능성은 매우 낮다. 이런 경우라면 조건 행에 가능한 모든 기둥 간 움직임을 명시하는 방식도 괜찮다.
print('{emptySpace} A{emptySpace}{emptySpace} B{emptySpace}{emptySpace} C\n') 과 같은 f-문자열을 사용하지 않고, format() 문자열 메소드를 사용한다. 이렇게 하면 연관된 문자열에 {0}이 나타나는 곳이면 어디서든 동일한 emptySpace 인수를 사용할 수 있게 되어 f-문자열 버전보다 더 짧고 가독성 높은 코드가 만들어진다.
사목 게임
"""Four in a Row, Al Sweigart al@inventwithpython.com
'Connect Four'와 유사한, 네 개를 연속으로 맞추는 타일 떨어뜨리기 게임.
이 코드는 https://nostarch.com/big-book-small-python-programming 에서 볼 수 있습니다.
태그: 큰, 게임, 보드 게임, 2인용"""
import sys
# 보드판을 표시할 때 사용할 상수:
EMPTY_SPACE = '.' # 빈 공간을 나타내는 점은 스페이스보다 세기 쉽습니다.
PLAYER_X = 'X'
PLAYER_O = 'O'
# 주의: BOARD_WIDTH가 변경되면 displayBoard() 및 COLUMN_LABELS를 업데이트해야 합니다.
BOARD_WIDTH = 7
BOARD_HEIGHT = 6
COLUMN_LABELS = ('1', '2', '3', '4', '5', '6', '7')
assert len(COLUMN_LABELS) == BOARD_WIDTH
def main():
print("""Four in a Row, Al Sweigart al@inventwithpython.com
두 플레이어가 번갈아가며 타일을 일곱 개 열 중 하나에 떨어뜨려
가로, 세로 또는 대각선으로 네 개를 연속으로 맞추는 게임입니다.
""")
# 새로운 게임을 시작합니다:
gameBoard = getNewBoard()
playerTurn = PLAYER_X
while True: # 각 플레이어의 턴을 실행합니다.
# 보드를 표시하고 플레이어의 이동을 받습니다:
displayBoard(gameBoard)
playerMove = askForPlayerMove(playerTurn, gameBoard)
gameBoard[playerMove] = playerTurn
# 승리 또는 무승부 여부를 확인합니다:
if isWinner(playerTurn, gameBoard):
displayBoard(gameBoard) # 마지막으로 보드를 표시합니다.
print('플레이어 ' + playerTurn + '이 승리했습니다!')
sys.exit()
elif isFull(gameBoard):
displayBoard(gameBoard) # 마지막으로 보드를 표시합니다.
print('무승부입니다!')
sys.exit()
# 플레이어의 턴을 변경합니다:
if playerTurn == PLAYER_X:
playerTurn = PLAYER_O
elif playerTurn == PLAYER_O:
playerTurn = PLAYER_X
def getNewBoard():
"""네 개 맞추기 보드를 나타내는 딕셔너리를 반환합니다.
키는 (columnIndex, rowIndex) 두 개의 정수 튜플이며,
값은 'X', 'O' 또는 '.' (빈 공간)을 나타내는 문자열입니다."""
board = {}
for columnIndex in range(BOARD_WIDTH):
for rowIndex in range(BOARD_HEIGHT):
board[(columnIndex, rowIndex)] = EMPTY_SPACE
return board
def displayBoard(board):
"""화면에 보드와 타일을 표시합니다."""
'''format() 문자열 메서드에 전달할 리스트를 준비합니다.
이 리스트는 왼쪽에서 오른쪽, 위에서 아래로 모든 보드의
타일(및 빈 공간)을 포함합니다:'''
tileChars = []
for rowIndex in range(BOARD_HEIGHT):
for columnIndex in range(BOARD_WIDTH):
tileChars.append(board[(columnIndex, rowIndex)])
# 보드를 표시합니다:
print("""
1234567
+-------+
|{}{}{}{}{}{}{}|
|{}{}{}{}{}{}{}|
|{}{}{}{}{}{}{}|
|{}{}{}{}{}{}{}|
|{}{}{}{}{}{}{}|
|{}{}{}{}{}{}{}|
+-------+""".format(*tileChars))
def askForPlayerMove(playerTile, board):
"""플레이어가 타일을 떨어뜨릴 보드 열을 선택하게 합니다.
타일이 떨어질 (column, row)의 튜플을 반환합니다."""
while True: # 유효한 이동을 입력할 때까지 계속 묻습니다.
print('플레이어 {}, 열을 입력하거나 QUIT을 입력하세요:'.format(playerTile))
response = input('> ').upper().strip()
if response == 'QUIT':
print('게임을 플레이해주셔서 감사합니다!')
sys.exit()
if response not in COLUMN_LABELS:
print('{}에서 선택한 숫자를 입력하세요.'.format(BOARD_WIDTH))
continue # 플레이어에게 다시 묻습니다.
columnIndex = int(response) - 1 # 인덱스를 0 기준으로 변환.
# 열이 가득 차 있으면 다시 이동을 묻습니다:
if board[(columnIndex, 0)] != EMPTY_SPACE:
print('해당 열이 가득 찼습니다. 다른 열을 선택하세요.')
continue # 플레이어에게 다시 묻습니다.
# 맨 아래부터 시작해 첫 번째 빈 공간을 찾습니다.
for rowIndex in range(BOARD_HEIGHT - 1, -1, -1):
if board[(columnIndex, rowIndex)] == EMPTY_SPACE:
return (columnIndex, rowIndex)
def isFull(board):
"""`board`에 빈 공간이 없으면 True를 반환하고, 그렇지 않으면 False를 반환합니다."""
for rowIndex in range(BOARD_HEIGHT):
for columnIndex in range(BOARD_WIDTH):
if board[(columnIndex, rowIndex)] == EMPTY_SPACE:
return False # 빈 공간을 찾으면 False를 반환.
return True # 모든 공간이 가득 찼습니다.
def isWinner(playerTile, board):
"""`playerTile`이 `board`에서 네 개를 연속으로 맞췄으면 True를 반환하고,
그렇지 않으면 False를 반환합니다."""
# 전체 보드를 살펴보며 네 개 연속을 찾습니다:
for columnIndex in range(BOARD_WIDTH - 3):
for rowIndex in range(BOARD_HEIGHT):
# 오른쪽으로 가로 네 개 맞추기 확인:
tile1 = board[(columnIndex, rowIndex)]
tile2 = board[(columnIndex + 1, rowIndex)]
tile3 = board[(columnIndex + 2, rowIndex)]
tile4 = board[(columnIndex + 3, rowIndex)]
if tile1 == tile2 == tile3 == tile4 == playerTile:
return True
for columnIndex in range(BOARD_WIDTH):
for rowIndex in range(BOARD_HEIGHT - 3):
# 아래로 세로 네 개 맞추기 확인:
tile1 = board[(columnIndex, rowIndex)]
tile2 = board[(columnIndex, rowIndex + 1)]
tile3 = board[(columnIndex, rowIndex + 2)]
tile4 = board[(columnIndex, rowIndex + 3)]
if tile1 == tile2 == tile3 == tile4 == playerTile:
return True
for columnIndex in range(BOARD_WIDTH - 3):
for rowIndex in range(BOARD_HEIGHT - 3):
# 오른쪽 아래로 대각선 네 개 맞추기 확인:
tile1 = board[(columnIndex, rowIndex)]
tile2 = board[(columnIndex + 1, rowIndex + 1)]
tile3 = board[(columnIndex + 2, rowIndex + 2)]
tile4 = board[(columnIndex + 3, rowIndex + 3)]
if tile1 == tile2 == tile3 == tile4 == playerTile:
return True
# 왼쪽 아래로 대각선 네 개 맞추기 확인:
tile1 = board[(columnIndex + 3, rowIndex)]
tile2 = board[(columnIndex + 2, rowIndex + 1)]
tile3 = board[(columnIndex + 1, rowIndex + 2)]
tile4 = board[(columnIndex, rowIndex + 3)]
if tile1 == tile2 == tile3 == tile4 == playerTile:
return True
return False
# 프로그램이 실행될 때 (임포트되지 않고), 게임을 실행합니다:
if __name__ == '__main__':
main()
PLAYER_X와 PLAYER_O 상수를 정의하면 프로그램 전체에 걸쳐 "X"와 "O"라는 문자열을 사용할 필요가 없어지므로 에러를 더 쉽게 잡을 수 있다.
프로그램이 실행되는 동안 상수는 변경되지 않아야 한다. 그러나 프로그래머는 다음 버전의 프로그램에서 상수 값을 변경할 수도 있다. 따라서, BOARD_WIDTH 값을 변경하는 경우, BOARD_TEMPLATE와 COLUMN_LABELS 상수도 갱신해야 한다는 메모를 프로그래머들에게 남긴다.
게임이 끝나지 않았다면, 다음 코드가 playerTurn을 다른 플레이어로 설정한다.
# 플레이어의 턴을 변경합니다:
if playerTurn == PLAYER_X:
playerTurn = PLAYER_O
elif playerTurn == PLAYER_O:
playerTurn = PLAYER_X
elif문을 조건 없이 단순 else 문으로 만들 수도 있었다. 그러나 "명시적인 것이 암시적인 것보다 낫다"는 파이썬의 선 지침을 떠올려보자.
'Python > 클린 코드, 이제는 파이썬이다' 카테고리의 다른 글
[클린코드 파이썬] 13장: 빅오를 활용한 알고리즘 성능 분석과 개선 (3) | 2024.09.06 |
---|---|
[클린코드 파이썬] 11장: 주석과 타입 힌트 (2) | 2024.09.04 |
[클린코드 파이썬] 10장: 파이썬다운 함수 만들기 (0) | 2024.09.03 |
[클린코드 파이썬] 8장: 파이썬에서 빠지기 쉬운 함정들 (0) | 2024.09.02 |
[클린코드 파이썬] 6장: 파이썬다운 코드를 작성하는 법 (5) | 2024.09.01 |