AI 탐구노트

직소퍼즐, 집중과 휴식이 함께 할 수 있는 게임 본문

DIY 테스트

직소퍼즐, 집중과 휴식이 함께 할 수 있는 게임

42morrow 2025. 1. 8. 14:43

 

직소(Jigsaw)퍼즐이란

직소 퍼즐은 작은 조각들을 맞춰 하나의 그림을 완성하는 퍼즐 게임입니다. '실톱'을 의미하는 '직소(jigsaw)'라는 단어가 의미하듯 나무판 위에 그림을 그린 후 이를 잘라내어 만든 것에서 이름이 유래되었다고 합니다. 그래서, 초기에는 나무판을 이용한 경우가 대부분이었고 최근에는 두꺼운 종이에 압력을 가해 만드는 방식이 많이 사용되고 있습니다. 

 

직소퍼즐은 언제 어떻게 만들어졌나요?

기원은 1760년대 영국으로 알려져 있습니다. 런던의 지도 제작자인 존 스필스버리(John Spilsbury)라는 사람이 나무판에 지도를 붙여 학생들의 지리학 공부를 위한 교육용 도구로 만들었다고 하죠. 

 

직소퍼즐은 어떻게 생겼나요?

각각의 조각들을 대부분 주변의 그림 조각들과 연결할 수 있는 모양으로 잘려져 있는 것이 많습니다. 연결 부분을 암과 수로 구분하면 각각 구멍(holes), 탭(tabs)이라고 합니다. 물론 퍼즐 종류에 따라 이런 것들을 따로 두지 않는 경우도 있고 탭이나 홀의 모양이 일정하지 않고 천차만별인 것들도 있습니다. 퍼즐 조각이 동일하게 사각형 형태로 생긴(아래 그림 예시) 것을 정규형, 그렇지 않고 자유롭게 되어 있는 것을 비정규형(irregular) 형태로 구분하기도 합니다. 

사진: 시중에 파는 직소퍼즐은 대부분 이렇게 생겼습니다. 출처(Unsplash의 Sigmund)

 

 

 

직소퍼즐은 맞추기 어려운가요?

그림의 복잡성과 조각 수에 따라 난이도가 달라지는데요, 최종 결과물은 같은 크기인데 그만큼 잘게 나눠지면 어려워지는 것은 당연하겠죠. 거기에 밤 바다에 떠 있는 배 형상처럼 바다 색이 주변 조각들과 전부 같아 보일 때 그 난이도는 극악에 속합니다. 저도 예전에 타이타닉 1000 피스 짜리를 맞춰봤었는데 이 문제 때문에 꼴딱 밤을 샜었습니다. -_-; 실제로 현재 판매 중인 것 가운데 가장 규모가 큰 것은 51,3000조각에 이른다고 합니다. (출처: 나무위키)

 

직소퍼즐이 인기가 있는 이유는 뭘까요?

 

직소퍼즐은 단순히 조각들을 맞추는 게임 그 이상입니다. 작은 조각들을 맞춰 큰 그림을 완성해 가는 과정에서 사람들은 자신만의 속도와 방식을 찾아하죠. 그리고 사람들의 '집중력'과 '몰입감'을 이끌어 냅니다. 완성 후에는 아싸~ 하는 '성취감'도 주죠. 한마디로 다른 것에 정신을 팔 여유를 주지도 않고 해결하고 나면 도전 성공의 즐거움과 자신감을 준다는 겁니다. 게다가 손과 눈을 계속해서 움직이고 위치를 찾기 위해 뇌를 자극해야 하므로 긴장을 푸는 효과도 있습니다. 

 

또 한가지 인기가 있는 이유를 개인적으로 생각해 보면, 혼자서 푸는 직소퍼즐도 재미있지만 여럿이 모여서 함께 하는 직소퍼즐도 훌륭한 도구인 것 같습니다. 서로 대화하고 협동하면서 장시간을 함께 한다는 것은 아무래도 끈끈한 전우애(?)를 느끼게 하지 않나 싶거든요. 

 

직소퍼즐 만들어보기

 

지금부터는 컴퓨터에서 할 수 있는 직소퍼즐을 한번 만들어 보겠습니다. 다만, 난이도를 고려해서 정규형으로 하고, 탭과 홀을 가지지 않고 그냥 직사각형 형태의 퍼즐조각을 갖도록 제한을 둘 겁니다. 

 

1) 코드 생성 요구사항

모니터와 마우스로 하는 게임아라 대략 아래와 같은 게임 요구사항이 있을 것 같습니다. 

  • pygame 패키지를 이용할 것 (react, node 등 구성이 복잡해 지는 것을 막기 위함)
  • 사용할 사진과 크기(rows, cols)는 변수로 설정할 것
  • 화면은 좌(퍼즐판), 우(퍼즐조각 랜덤 배치) 형태로 할 것
  • 퍼즐판에는 퍼즐조각이 배치될 위치가 표시될 것
  • 퍼즐조각을 드래그 & 드랍하면 해당 위치의 퍼즐판 격자에 자동으로 배치될 것
  • 퍼즐조각이 다 맞춰지면 성공으로 판정하고 다시 시작할 지 여부를 물을 것
  • 퍼즐 크기에 따라 시간 제한을 두고 시간 내에 종료하지 못하면 실패한 것으로 간주할 것 (예: 3x3 3분, 4x4 5분, 5x5 10분)
  • 키 이벤트를 설정할 것 : 'h' 키는 힌트로 원 퍼즐 이미지를 보여줌, 'q'는 종료, 'i'는 이미지 선택 & 로딩, 'r'은 리셋, 's'는 일시정지

 

2) 코드

 

위의 요구사항에 맞춰 생성된 코드입니다. 이 또한 이런저런 시행착오를 많이 거쳐야 했습니다. T^T

from time import time
from tkinter import Tk, filedialog
import pygame
import sys
import random
from pygame.locals import *

from i6 import TIME_COLOR

# 게임 초기화
pygame.init()
screen_width, screen_height = 1280, 720
screen = pygame.display.set_mode((screen_width, screen_height))  # 화면 크기 고정
pygame.display.set_caption("직소 퍼즐 게임")

# 색상
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
HIGHLIGHT_COLOR = (255, 0, 0)
BOARD_COLOR = (200, 200, 200)

# 퍼즐판 영역
def calculate_board_size(image_width, image_height):
    scale_factor = min(screen_height / image_height, (screen_width * 0.7) / image_width)
    return int(image_width * scale_factor), int(image_height * scale_factor)

# 이미지 로드
def load_image(file_path, size=None):
    img = pygame.image.load(file_path)
    if size:
        img = pygame.transform.scale(img, size)
    return img

# 이미지 조각 나누기
def split_image(image, rows, cols, piece_width, piece_height):
    pieces = []
    for row in range(rows):
        for col in range(cols):
            rect = pygame.Rect(col * piece_width, row * piece_height, piece_width, piece_height)
            piece = image.subsurface(rect).copy()
            pieces.append((piece, rect, (col, row)))  # 정답 위치 정보 포함
    return pieces

# 조각 섞기
def shuffle_pieces(pieces, board_width):
    random.shuffle(pieces)
    for _, rect, _ in pieces:
        rect.topleft = (
            random.randint(board_width + 20, screen_width - rect.width - 20),
            random.randint(20, screen_height - rect.height - 20)
        )
    return pieces

# 퍼즐판 외곽선 그리기
def draw_board(board_x, board_y, board_width, board_height, rows, cols, piece_width, piece_height):
    for row in range(rows):
        for col in range(cols):
            rect = pygame.Rect(
                board_x + col * piece_width,
                board_y + row * piece_height,
                piece_width,
                piece_height,
            )
            pygame.draw.rect(screen, BLACK, rect, 3)

# 힌트창 표시
def show_hint_window(image_path, hint_width, hint_height):
    hint_window = pygame.display.set_mode((hint_width, hint_height), pygame.NOFRAME)
    pygame.display.set_caption("Hint Window")
    hint_image = load_image(image_path, (hint_width, hint_height))
    hint_window.blit(hint_image, (0, 0))
    pygame.display.flip()

    # 힌트창 이벤트 루프
    while True:
        for event in pygame.event.get():
            if event.type == QUIT or event.type == KEYDOWN:  # 아무 키나 누르면 닫힘
                return

# 이미지 선택 창 열기
def select_image():
    Tk().withdraw()  # Tkinter GUI 숨기기
    file_path = filedialog.askopenfilename(
        title="Select an Image",
        filetypes=[("Image Files", "*.png;*.jpg;*.jpeg;*.bmp")]
    )
    if file_path:
        return file_path
    return None

# 퍼즐게임 실행
def run_game(image_path='3.jpg', rows=3, cols=3, time_limit=3):
    global screen  # screen을 전역 변수로 사용

    # 설정
    original_image = pygame.image.load(image_path)
    original_width, original_height = original_image.get_size()

    # 퍼즐판 크기 계산
    board_width, board_height = calculate_board_size(original_width, original_height)
    board_x, board_y = 0, (screen_height - board_height) // 2

    # 조각 크기 설정
    piece_width = board_width // cols
    piece_height = board_height // rows

    # 이미지 로드 및 크기 조정
    image = load_image(image_path, (board_width, board_height))
    pieces = split_image(image, rows, cols, piece_width, piece_height)
    shuffled_pieces = shuffle_pieces(pieces, board_width)

    dragging_piece = None
    offset_x, offset_y = 0, 0

    # 퍼즐 화면을 저장할 Surface
    puzzle_surface = pygame.Surface((screen_width, screen_height))


    # 제한 시간
    clock = pygame.time.Clock()
    completed = False

    start_time = time()
    paused = False

    pause_start_time = None  # pause 시작 시간을 저장
    pause_duration = 0  # 총 pause 시간

    while True:

        # 경과 시간 및 남은 시간 계산
        elapsed_time = int(time() - start_time - pause_duration)
        remaining_time = max(0, time_limit - elapsed_time)

        if paused:
            time_text = "Paused"
            if pause_start_time is None:  # pause가 시작되었을 때
                pause_start_time = time()
        else:
            time_text = f"Time Left: {remaining_time // 60}:{remaining_time % 60:02d}"
            if pause_start_time is not None:  # pause가 끝났을 때
                # 총 pause 시간을 계산
                pause_duration += time() - pause_start_time
                pause_start_time = None


        screen.fill(WHITE)
        pygame.draw.rect(screen, BOARD_COLOR, (board_x, board_y, board_width, board_height))  # 퍼즐판 배경
        draw_board(board_x, board_y, board_width, board_height, rows, cols, piece_width, piece_height)

        # 시간 표시
        font = pygame.font.SysFont(None, 40)
        time_surface = font.render(time_text, True, TIME_COLOR)
        screen.blit(time_surface, (screen_width - 250, 20))

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()

            # 드래그 시작
            elif event.type == MOUSEBUTTONDOWN:
                for i in range(len(shuffled_pieces) - 1, -1, -1):  # 리스트 뒤에서부터 검사
                    piece, rect, _ = shuffled_pieces[i]
                    if rect.collidepoint(event.pos):
                        dragging_piece = shuffled_pieces.pop(i)  # 선택된 조각 제거
                        offset_x = rect.x - event.pos[0]
                        offset_y = rect.y - event.pos[1]
                        shuffled_pieces.append(dragging_piece)  # 선택된 조각을 리스트 끝으로 이동
                        break

            # 드래그 종료
            elif event.type == MOUSEBUTTONUP:
                if dragging_piece:
                    _, rect, (correct_col, correct_row) = dragging_piece
                    # 마우스 위치 기반 그리드 계산
                    col = (event.pos[0] - board_x) // piece_width
                    row = (event.pos[1] - board_y) // piece_height
                    if 0 <= col < cols and 0 <= row < rows:
                        correct_x = board_x + col * piece_width
                        correct_y = board_y + row * piece_height
                        rect.topleft = (correct_x, correct_y)
                    dragging_piece = None

            # 힌트창 표시
            elif event.type == KEYDOWN:
                if event.key == K_q:  # 종료
                    pygame.quit()
                    sys.exit()
                elif event.key == K_r:  # 리셋
                    return "reset"
                elif event.key == K_i:  # 이미지 변경
                    new_image = select_image()
                    if new_image:
                        return new_image
                elif event.key == K_s:  # 일시정지
                    paused = not paused
                if event.key == pygame.K_h:  # 'h' 키로 힌트창 표시
                    # 현재 퍼즐 화면 저장
                    puzzle_surface.blit(screen, (0, 0))

                    # 힌트창 크기 = 퍼즐판 크기
                    show_hint_window(image_path, board_width, board_height)

                    # 힌트창 종료 후 기존 화면 복원
                    screen = pygame.display.set_mode((screen_width, screen_height))
                    pygame.display.set_caption("직소 퍼즐 게임")
                    screen.blit(puzzle_surface, (0, 0))

        # 게임 일시정지 상태 처리
        if paused:
            pygame.display.flip()
            clock.tick(30)
            continue


        # 드래그 상태 업데이트
        if dragging_piece:
            _, rect, _ = dragging_piece
            rect.topleft = (pygame.mouse.get_pos()[0] + offset_x, pygame.mouse.get_pos()[1] + offset_y)

        # 퍼즐 조각 그리기
        for piece, rect, _ in shuffled_pieces:
            screen.blit(piece, rect.topleft)

        # 정답 검사
        completed = all(
            rect.topleft == (
                board_x + correct_col * piece_width,
                board_y + correct_row * piece_height
            )
            for _, rect, (correct_col, correct_row) in shuffled_pieces
        )
        if completed:
            font = pygame.font.SysFont(None, 80)
            text = font.render("Game Completed!", True, HIGHLIGHT_COLOR)
            text_rect = text.get_rect(center=(screen_width // 2, screen_height // 2))
            screen.blit(text, text_rect)
            pygame.display.flip()
            pygame.time.wait(3000)
            break

        # 제한 시간 초과 처리
        if remaining_time <= 0:
            font = pygame.font.SysFont(None, 80)
            text = font.render("Time Over!", True, HIGHLIGHT_COLOR)
            text_rect = text.get_rect(center=(screen_width // 2, screen_height // 2))
            screen.blit(text, text_rect)
            pygame.display.flip()
            pygame.time.wait(3000)
            return "lose"

        pygame.display.flip()
        clock.tick(30)

# 결과 처리 및 재시작 여부 묻기
def ask_restart(message):
    global screen
    font = pygame.font.SysFont(None, 40)
    text_surface = font.render(message, True, BLACK)
    text_rect = text_surface.get_rect(center=(screen_width // 2, screen_height // 2))
    screen.fill(WHITE)
    screen.blit(text_surface, text_rect)
    pygame.display.flip()

    while True:
        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                sys.exit()
            elif event.type == KEYDOWN:
                if event.key == K_y:  # 다시 시작
                    return True
                elif event.key == K_n:  # 종료
                    return False

def main():
    image_path = "unsplash_01.jpg"  # 기본 이미지 파일

    while True:
        result = run_game(image_path, cols=3,rows=3, time_limit=180)

        # 결과 메시지 처리
        if result == "win":
            message = "You Win! Press Y to Restart or N to Quit."
        elif result == "lose":
            message = "Time Over! You Lose! Press Y to Restart or N to Quit."
        elif result == "reset":
            continue  # 게임 리셋
        else:  # 이미지 변경
            image_path = result
            continue

        # 재시작 여부 묻기
        if not ask_restart(message):
            break

    pygame.quit()
    sys.exit()


if __name__ == "__main__":
    main()

 

 

3) 실행 결과

위 코드를 실행하면 다음과 같이 진행됩니다. 투박하지만 게임하는데 불편함은 따로 없습니다. 크기를 1280x720으로 가로 방향으로 고정해뒀는데 사용하게 될 이미지에 따라 자동으로 변경하는 로직을 넣으면 좀 더 편할 것 같긴 합니다. 

 

영상 : 게임 플레이 예시 (사진 출처 : Unsplash 의 John Jennings)

 

그림 : 또 다른 사진을 이용한 예시 (사진출처 : 사진: Unsplash의 Brigitta Schneiter)

 

 

정리하며

직소퍼즐은 주로 어렸을 때 많이 해 봤을 것 같습니다. 저도 아이가 이 퍼즐을 좋아해서 전체 퍼즐판이 사람 키만하고 퍼즐 조각 하나나 어른 손바닥만한 것을 사서 함께 가지고 놀던 (^^;) 기억이 있습니다. 이처럼 직소퍼즐은 나이나 장소, 혼자서거나 혹은 다른 이들과 함께이거나 상관없이, 긴장을 늦추고 시간을 보내며 추억을 쌓아갈 수 있는 좋은 게임인 것 같습니다. 마음 속에 걱정거리나 풀리지 않는 난감한 고민들이 있을 때, 이를 제쳐두고 직소퍼즐 큰 것을 하나 펼쳐 놓으세요. 그림이 완성될 때쯤이면 어느새 맑은 정신으로 돌아와 그 고민거리들을 멀리서 지켜볼 수 있는 여유가 생길 것입니다. V^^