pygame.Rect를 선언하고 pygame.draw.rect()로 그리는 건 이미 할 수 있다고 가정합니다.
각 챕터의 코드는 바로 이전 챕터의 전체 코드 + 이번 챕터에서 새로 추가되는 부분입니다.
코드 안에서 파란색으로 밑칠된 줄 이 이번 챕터에서 새로 생긴 부분이니, 그 부분이 왜 필요한지에 집중해서 읽으세요.
1책상 여러 개, 리스트로 관리하기
사각형 하나는 만들 줄 알죠. 그런데 교실에는 책상이 20개, 30개씩 있습니다. 하나하나 변수를 만들면 너무 번거로우니, 리스트에 담아서 for문으로 한꺼번에 관리합니다.
import pygame # 파이게임 기능들을 가져오기
pygame.init() # 파이게임 시작 준비
screen = pygame.display.set_mode((1100, 720)) # 창 만들기 (이 크기를 앞으로 계속 씁니다)
clock = pygame.time.Clock() # 화면 그리는 속도를 조절할 시계
# 책상들을 담을 리스트
desks = []
# 4행 5열 교실 만들기 (row=몇 번째 줄, col=몇 번째 칸)
for row in range(4):
for col in range(5):
x = 80 + col * 90 # 칸(col)이 늘어날수록 오른쪽으로
y = 100 + row * 70 # 줄(row)이 늘어날수록 아래로
desks.append(pygame.Rect(x, y, 70, 50))
running = True
while running: # 창이 열려있는 동안 계속 반복
for event in pygame.event.get(): # 방금 일어난 이벤트를 하나씩 확인
if event.type == pygame.QUIT: # 창 닫기 버튼을 눌렀으면
running = False # 반복문을 멈추고 종료 준비
screen.fill((253, 248, 240)) # 매 프레임 화면을 밝은 색으로 지우고 새로 그리기 시작
# 리스트에 담긴 책상을 하나씩 꺼내서 그리기
for d in desks:
pygame.draw.rect(screen, (255, 255, 255), d) # 흰색으로 채우기
pygame.draw.rect(screen, (60, 60, 60), d, 2) # 회색 테두리 그리기
pygame.display.flip() # 지금까지 그린 내용을 실제 화면에 보여주기
clock.tick(60) # 1초에 최대 60번만 반복 (너무 빨리 돌지 않게)
pygame.quit() # 창 닫고 파이게임 종료
row는 몇 번째 줄인지, col은 몇 번째 칸인지를 나타냅니다. x, y를 row/col에 비례해서 계산하면 격자 모양으로 자동 배치됩니다.
2책상에 번호와 이름 담기 (딕셔너리)
pygame.Rect는 위치와 크기만 담을 수 있습니다. 그런데 우리는 책상마다 번호와 앉은 학생 이름도 같이 기억해야 합니다. 이럴 때는 여러 정보를 한 번에 담는 딕셔너리를 씁니다. (희망 순위 "prefs"는 7번 챕터에서 쓸 건데, 책상을 만드는 곳은 여기 한 곳뿐이라 미리 같이 넣어둡니다.)
import pygame
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20) # 글자를 그릴 때 쓸 글꼴 준비
clock = pygame.time.Clock()
desks = []
next_id = 1 # 다음 책상에 붙일 번호 (책상을 만들 때마다 1씩 늘어남)
for row in range(4):
for col in range(5):
x = 80 + col * 90
y = 100 + row * 70
desks.append({
"id": next_id, # 책상 번호
"rect": pygame.Rect(x, y, 70, 50), # 위치/크기
"name": "", # 앉은 학생 이름 (아직 없음)
"prefs": [None, None, None] # 1/2/3순위 (7번 챕터에서 사용)
})
next_id += 1 # 다음 책상은 번호를 하나 더 높여서
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
screen.fill((253, 248, 240))
# 그리는 부분: 사각형 위에 번호 글자까지 얹어서 그린다
for d in desks:
pygame.draw.rect(screen, (255, 255, 255), d["rect"])
pygame.draw.rect(screen, (60, 60, 60), d["rect"], 2)
num_img = font.render(str(d["id"]), True, (100, 100, 100)) # 번호를 글자 이미지로 만들고
screen.blit(num_img, (d["rect"].x + 4, d["rect"].y + 2)) # 책상 왼쪽 위에 붙이기
pygame.display.flip()
clock.tick(60)
pygame.quit()
이제 책상 하나는 {"id": 1, "rect": ..., "name": "", "prefs": [None, None, None]} 처럼 생겼습니다. d["id"], d["rect"]처럼 대괄호로 필요한 정보만 꺼내 씁니다. 앞으로 나오는 모든 코드는 이 "딕셔너리로 된 책상"을 기준으로 합니다.
"name"에 미리 아무 이름이나 넣어두고, if d["name"]: 조건으로 이름이 있을 때만 화면 가운데에 이름을 표시해보세요.
3마우스로 책상 클릭하기
MOUSEBUTTONDOWN 이벤트가 오면, 모든 책상을 돌면서 collidepoint()로 "마우스가 이 책상 위에 있나?"를 확인합니다. event.button == 1을 지금부터 붙여두는 이유는, 다음 챕터에서 오른쪽 클릭(3번 버튼)에 다른 기능을 넣을 것이기 때문입니다.
import pygame
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20)
clock = pygame.time.Clock()
desks = []
next_id = 1
for row in range(4):
for col in range(5):
x = 80 + col * 90
y = 100 + row * 70
desks.append({"id": next_id, "rect": pygame.Rect(x, y, 70, 50), "name": "", "prefs": [None, None, None]})
next_id += 1
selected_id = None # 지금 선택된 책상 번호 (아직 아무것도 안 골랐으면 None)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 왼쪽 버튼을 눌렀을 때, 그 자리에 있는 책상을 찾아 선택
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
for d in desks:
if d["rect"].collidepoint(event.pos): # 마우스 좌표가 이 책상 안에 있나?
selected_id = d["id"]
screen.fill((253, 248, 240))
for d in desks:
if d["id"] == selected_id:
color = (255, 220, 120) # 선택된 책상 -> 노란색
else:
color = (255, 255, 255) # 나머지 -> 흰색
pygame.draw.rect(screen, color, d["rect"])
pygame.draw.rect(screen, (60, 60, 60), d["rect"], 2)
num_img = font.render(str(d["id"]), True, (100, 100, 100))
screen.blit(num_img, (d["rect"].x + 4, d["rect"].y + 2))
pygame.display.flip()
clock.tick(60)
pygame.quit()
for d in desks:를 for d in reversed(desks):로 바꾸면 결과가 어떻게 달라지는지 실험해보세요.
4오른쪽 클릭으로 추가/삭제하기
event.button으로 어떤 버튼을 눌렀는지 구분할 수 있습니다. 1은 왼쪽 클릭, 3은 오른쪽 클릭입니다.
import pygame
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20)
clock = pygame.time.Clock()
desks = []
next_id = 1
for row in range(4):
for col in range(5):
x = 80 + col * 90
y = 100 + row * 70
desks.append({"id": next_id, "rect": pygame.Rect(x, y, 70, 50), "name": "", "prefs": [None, None, None]})
next_id += 1
selected_id = None
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
for d in desks:
if d["rect"].collidepoint(event.pos):
selected_id = d["id"]
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3: # 3 = 오른쪽 버튼
clicked = None
for d in desks:
if d["rect"].collidepoint(event.pos):
clicked = d # 우클릭한 자리에 책상이 있으면 기억해두기
if clicked:
desks.remove(clicked) # 책상 위에서 우클릭 -> 리스트에서 빼서 삭제
if selected_id == clicked["id"]:
selected_id = None # 선택된 책상을 지웠다면 선택도 해제
else:
desks.append({ # 빈 곳에서 우클릭 -> 새 책상을 리스트에 추가
"id": next_id,
# -35, -25는 책상 크기(70,50)의 절반. 클릭 지점이 책상 가운데 오도록
"rect": pygame.Rect(event.pos[0] - 35, event.pos[1] - 25, 70, 50),
"name": "",
"prefs": [None, None, None]
})
next_id += 1
screen.fill((253, 248, 240))
for d in desks:
if d["id"] == selected_id:
color = (255, 220, 120)
else:
color = (255, 255, 255)
pygame.draw.rect(screen, color, d["rect"])
pygame.draw.rect(screen, (60, 60, 60), d["rect"], 2)
num_img = font.render(str(d["id"]), True, (100, 100, 100))
screen.blit(num_img, (d["rect"].x + 4, d["rect"].y + 2))
pygame.display.flip()
clock.tick(60)
pygame.quit()
같은 MOUSEBUTTONDOWN 이벤트라도, 3번 챕터의 왼쪽 클릭 코드와 이번에 추가한 오른쪽 클릭 코드가 event.button으로 나뉘기 때문에 서로 부딪히지 않습니다.
5드래그로 책상 옮기기 (+ 클릭과 구분하기)
드래그는 세 가지 이벤트가 이어지는 동작입니다: 누르기(MOUSEBUTTONDOWN) → 움직이기(MOUSEMOTION) → 떼기(MOUSEBUTTONUP). 그런데 3번 챕터의 "왼쪽 클릭 = 선택"과 그냥 합치면, 드래그를 시작하는 순간에도 선택이 되어버려서 어색합니다. 그래서 "눌렀다가 거의 안 움직이고 뗐을 때만 클릭", "일정 거리 이상 움직이면 드래그"로 구분하도록 3번 챕터의 코드를 여기서 바꿉니다.
import pygame
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20)
clock = pygame.time.Clock()
# 두 점 사이의 거리. 마우스가 얼마나 움직였는지 재는 데 쓴다
def dist(a, b):
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
desks = []
next_id = 1
for row in range(4):
for col in range(5):
x = 80 + col * 90
y = 100 + row * 70
desks.append({"id": next_id, "rect": pygame.Rect(x, y, 70, 50), "name": "", "prefs": [None, None, None]})
next_id += 1
selected_id = None
drag = None # 지금 드래그 중인 책상 (없으면 None)
drag_off = (0, 0) # 클릭한 지점과 책상 모서리 사이의 거리
click_candidate = None # 왼쪽 버튼을 누른 순간 잡은 책상 (아직 클릭인지 드래그인지 모름)
down_pos = None # 왼쪽 버튼을 누른 순간의 마우스 좌표
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 왼쪽 버튼을 누른 순간에는 아직 선택하지 않는다. 어떤 책상을 잡았는지만 기억
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
for d in desks:
if d["rect"].collidepoint(event.pos):
down_pos = event.pos
click_candidate = d
drag_off = (d["rect"].x - event.pos[0], d["rect"].y - event.pos[1])
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
clicked = None
for d in desks:
if d["rect"].collidepoint(event.pos):
clicked = d
if clicked:
desks.remove(clicked)
if selected_id == clicked["id"]:
selected_id = None
else:
desks.append({
"id": next_id,
"rect": pygame.Rect(event.pos[0] - 35, event.pos[1] - 25, 70, 50),
"name": "",
"prefs": [None, None, None]
})
next_id += 1
# 마우스가 움직이는 동안: 일정 거리(6픽셀) 이상 움직였으면 그때부터 '드래그'로 인정
elif event.type == pygame.MOUSEMOTION and click_candidate and down_pos and event.buttons[0]:
if not drag and dist(event.pos, down_pos) > 6:
drag = click_candidate
if drag:
drag["rect"].x = event.pos[0] + drag_off[0]
drag["rect"].y = event.pos[1] + drag_off[1]
# 왼쪽 버튼을 뗀 순간: 드래그했으면 드래그 종료, 아니었으면 그제서야 '클릭'으로 선택
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
if drag:
drag = None
elif click_candidate:
selected_id = click_candidate["id"]
click_candidate = None
down_pos = None
screen.fill((253, 248, 240))
for d in desks:
if d["id"] == selected_id:
color = (255, 220, 120)
else:
color = (255, 255, 255)
pygame.draw.rect(screen, color, d["rect"])
pygame.draw.rect(screen, (60, 60, 60), d["rect"], 2)
num_img = font.render(str(d["id"]), True, (100, 100, 100))
screen.blit(num_img, (d["rect"].x + 4, d["rect"].y + 2))
pygame.display.flip()
clock.tick(60)
pygame.quit()
왜 drag_off이 필요할까요? 책상의 왼쪽 위 모서리를 마우스 위치로 그냥 옮겨버리면, 클릭한 순간 책상이 마우스 쪽으로 "휙" 튀어버립니다. 처음 클릭했을 때 "마우스와 책상 모서리 사이의 거리"를 기억해뒀다가 계속 더해주면, 잡은 지점 그대로 자연스럽게 따라옵니다.
6이라는 숫자를 30으로 바꾸면 어떻게 달라지나요?
6글자를 입력받는 상자 만들기
학생 이름을 입력받으려면 텍스트 입력창이 필요합니다. 이번 챕터는 잠깐 책상 프로그램에서 벗어나, 입력창 자체를 재사용 가능한 함수 3개로 따로 만들어봅니다. 7번 챕터에서 이 함수들을 그대로 가져다 씁니다.
import pygame
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20)
clock = pygame.time.Clock()
pygame.key.start_text_input() # 이 줄이 있어야 한글 입력이 제대로 됨
# 입력창 하나 = {"rect": 위치/크기, "text": 입력된 글자, "active": 선택 여부, "numeric": 숫자만 허용?}
def make_textbox(rect, numeric=False):
return {"rect": pygame.Rect(rect), "text": "", "active": False, "numeric": numeric}
# 입력창 하나에 이벤트 하나를 적용 (활성화/글자 추가/삭제)
def handle_textbox(box, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
box["active"] = box["rect"].collidepoint(event.pos)
elif box["active"] and event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
box["text"] = box["text"][:-1]
elif box["active"] and event.type == pygame.TEXTINPUT:
if not box["numeric"] or event.text.isdigit():
box["text"] += event.text
# 입력창 하나를 그리기
def draw_textbox(s, font, box):
pygame.draw.rect(s, (255, 255, 255), box["rect"])
color = (60, 140, 220) if box["active"] else (150, 150, 150)
pygame.draw.rect(s, color, box["rect"], 2)
t = font.render(box["text"], True, (20, 20, 20))
s.blit(t, (box["rect"].x + 6, box["rect"].y + (box["rect"].h - t.get_height()) // 2))
input_box = make_textbox((300, 300, 200, 36))
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
handle_textbox(input_box, event)
screen.fill((253, 248, 240))
draw_textbox(screen, font, input_box)
pygame.display.flip()
clock.tick(60)
pygame.quit()
왜 KEYDOWN만으로는 안 될까요? 한글은 자음+모음을 조합해서 한 글자를 완성합니다(ㄱ+ㅏ+ㅇ=강). KEYDOWN은 자모 하나하나가 눌린 순간을 알려주지만, TEXTINPUT은 운영체제가 조합을 끝낸 완성된 글자를 한 번에 넘겨줍니다.
make_textbox()를 한 번 더 호출해서 입력창을 하나 더 만들고, 두 번째 입력창은 numeric=True로 만들어서 숫자만 입력되는지 확인해보세요.
7책상 클릭 → 이름/희망순위 입력 패널
5번 챕터(드래그와 클릭 구분)와 6번 챕터(입력창 함수)를 합칩니다. 3번 챕터부터 써온 "클릭하면 노란색으로 선택"을 이제 "클릭하면 입력 패널이 열린다"로 바꿉니다.
import pygame
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20)
clock = pygame.time.Clock()
pygame.key.start_text_input()
def dist(a, b):
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
# 6번 챕터에서 만든 입력창 함수 3개, 그대로 가져옴
def make_textbox(rect, numeric=False):
return {"rect": pygame.Rect(rect), "text": "", "active": False, "numeric": numeric}
def handle_textbox(box, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
box["active"] = box["rect"].collidepoint(event.pos)
elif box["active"] and event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
box["text"] = box["text"][:-1]
elif box["active"] and event.type == pygame.TEXTINPUT:
if not box["numeric"] or event.text.isdigit():
box["text"] += event.text
def draw_textbox(s, font, box):
pygame.draw.rect(s, (255, 255, 255), box["rect"])
color = (60, 140, 220) if box["active"] else (150, 150, 150)
pygame.draw.rect(s, color, box["rect"], 2)
t = font.render(box["text"], True, (20, 20, 20))
s.blit(t, (box["rect"].x + 6, box["rect"].y + (box["rect"].h - t.get_height()) // 2))
desks = []
next_id = 1
for row in range(4):
for col in range(5):
x = 80 + col * 90
y = 100 + row * 70
desks.append({"id": next_id, "rect": pygame.Rect(x, y, 70, 50), "name": "", "prefs": [None, None, None]})
next_id += 1
drag = None
drag_off = (0, 0)
click_candidate = None
down_pos = None
# 책상을 클릭하면 뜨는 패널: 이름 입력창 1개 + 순위 입력창 3개
panel_desk = None # 지금 패널이 열려있는 책상 (닫혀있으면 None)
name_box = make_textbox((850, 40, 150, 30))
p_boxes = [make_textbox((850, 40 + 40 * (i + 1), 150, 30), True) for i in range(3)]
save_btn = pygame.Rect(850, 200, 90, 32)
cancel_btn = pygame.Rect(960, 200, 90, 32)
def open_panel(d):
global panel_desk
panel_desk = d
name_box["text"] = d["name"]
for box, val in zip(p_boxes, d["prefs"]):
box["text"] = str(val) if val else ""
def save_panel():
global panel_desk
panel_desk["name"] = name_box["text"].strip()
panel_desk["prefs"] = [int(b["text"]) if b["text"].strip().isdigit() else None for b in p_boxes]
panel_desk = None
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 패널이 열려있는 동안에는 입력창/저장/취소만 처리하고, 책상 클릭/드래그는 건너뛴다
if panel_desk:
handle_textbox(name_box, event)
for b in p_boxes:
handle_textbox(b, event)
if event.type == pygame.MOUSEBUTTONDOWN and save_btn.collidepoint(event.pos):
save_panel()
elif event.type == pygame.MOUSEBUTTONDOWN and cancel_btn.collidepoint(event.pos):
panel_desk = None
continue
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
for d in desks:
if d["rect"].collidepoint(event.pos):
down_pos = event.pos
click_candidate = d
drag_off = (d["rect"].x - event.pos[0], d["rect"].y - event.pos[1])
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
clicked = None
for d in desks:
if d["rect"].collidepoint(event.pos):
clicked = d
if clicked:
desks.remove(clicked)
else:
desks.append({
"id": next_id,
"rect": pygame.Rect(event.pos[0] - 35, event.pos[1] - 25, 70, 50),
"name": "",
"prefs": [None, None, None]
})
next_id += 1
elif event.type == pygame.MOUSEMOTION and click_candidate and down_pos and event.buttons[0]:
if not drag and dist(event.pos, down_pos) > 6:
drag = click_candidate
if drag:
drag["rect"].x = event.pos[0] + drag_off[0]
drag["rect"].y = event.pos[1] + drag_off[1]
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
if drag:
drag = None
elif click_candidate:
open_panel(click_candidate) # 3~5번 챕터의 '노란색 선택' 대신, 이제 패널을 연다
click_candidate = None
down_pos = None
screen.fill((253, 248, 240))
for d in desks:
pygame.draw.rect(screen, (255, 255, 255), d["rect"])
pygame.draw.rect(screen, (60, 60, 60), d["rect"], 2)
num_img = font.render(str(d["id"]), True, (100, 100, 100))
screen.blit(num_img, (d["rect"].x + 4, d["rect"].y + 2))
if d["name"]:
name_img = font.render(d["name"], True, (20, 20, 20))
screen.blit(name_img, name_img.get_rect(center=d["rect"].center))
if panel_desk:
for box in [name_box] + p_boxes:
draw_textbox(screen, font, box)
pygame.draw.rect(screen, (70, 150, 90), save_btn)
pygame.draw.rect(screen, (170, 80, 70), cancel_btn)
pygame.display.flip()
clock.tick(60)
pygame.quit()
name_box에 입력하던 내용이 사라지고 패널이 닫히는지 확인하세요. 저장하지 않고 취소했으니 책상의 원래 이름은 그대로 남아있어야 합니다.
8자리 만족도 점수 매기기
여기서 잠깐 화면 만들기를 멈추고, 계산 로직만 따로 만들어봅니다. 함수만 검증되면 9번 챕터에서 지금까지 만든 프로그램에 그대로 이어붙일 겁니다. 규칙을 말로 먼저 정리해봅시다.
- 정확히 1순위 책상에 앉으면 100점, 2순위는 80점, 3순위는 60점
- 정확히 그 자리는 아니지만 바로 근처(주변 자리)면, 그 순위 점수에서 10점을 뺀다
- 둘 다 아니면 0점
"주변 자리"인지 판단하려면 두 책상 사이의 거리가 필요합니다. 5번 챕터에서 만든 dist() 함수를 그대로 재사용합니다.
import pygame
pygame.init() # 화면은 안 띄우지만, pygame.Rect를 쓰려면 여전히 필요
# 5번 챕터에서 만든 거리 함수 (그대로 가져옴)
def dist(a, b):
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
NEAR = 100 # 이 거리(픽셀) 안이면 '주변 자리'로 인정
SCORE = {1: 100, 2: 80, 3: 60} # 1/2/3순위 자리에 앉았을 때 기본 점수
# prefs: 학생의 [1순위, 2순위, 3순위] 책상 번호
# desk: 지금 점수를 매기려는 책상
# by_id: {책상번호: 책상} 딕셔너리 (번호로 책상을 빨리 찾기 위함)
def satisfaction(prefs, desk, by_id):
best = 0 # 지금까지 찾은 가장 높은 점수
for rank, pid in enumerate(prefs, 1): # rank: 몇 순위인지(1,2,3), pid: 그 순위 책상 번호
if pid is None or pid not in by_id:
continue # 순위를 안 적었거나 없는 책상 번호면 건너뛰기
base = SCORE[rank] # 이 순위의 기본 점수
target = by_id[pid] # 희망한 책상 자체
if desk["id"] == pid:
best = max(best, base) # 정확히 그 책상 -> 기본 점수 그대로
elif dist(desk["rect"].center, target["rect"].center) <= NEAR:
best = max(best, base - 10) # 주변 자리 -> 기본 점수에서 10점 감점
return best # 여러 순위에 동시에 해당되면 그중 가장 높은 점수를 채택
# 테스트용 책상 6개 (1~6번, 나란히 배치)
desks = []
for i in range(6):
desks.append({"id": i + 1, "rect": pygame.Rect(80 + i * 90, 100, 70, 50), "name": ""})
by_id = {d["id"]: d for d in desks} # 번호로 바로 책상을 찾기 위한 딕셔너리
# 1순위=5번인 학생이 6번(5번 옆자리)에 앉았을 때 점수는?
prefs = [5, None, None]
print(satisfaction(prefs, by_id[6], by_id)) # -> 90 (100 - 10) 이 나와야 정상
by_id는 {책상번호: 책상딕셔너리} 형태로, 번호만 알아도 그 책상을 바로 찾을 수 있게 만든 것입니다. {d["id"]: d for d in desks}처럼 컴프리헨션으로 한 줄에 만들 수 있습니다.
prefs를 [5, None, None]에서 [1, None, None]으로 바꾸면 몇 점이 나올까요? (5번과 아예 상관없는 자리이므로 0점이 나와야 정상입니다)
9컴퓨터가 최적 배치를 찾게 하기
학생마다 만족도를 계산할 수 있으니, 이제 "전체 만족도 합이 가장 큰" 배정을 찾을 차례입니다. 이건 할당 문제(Assignment Problem)이고, 헝가리안 알고리즘으로 풉니다. 8번 챕터의 함수를 7번 챕터의 프로그램에 이어붙이고, "배치 계산" 버튼도 하나 추가합니다.
import pygame
from scipy.optimize import linear_sum_assignment
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20)
clock = pygame.time.Clock()
pygame.key.start_text_input()
def dist(a, b):
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
def make_textbox(rect, numeric=False):
return {"rect": pygame.Rect(rect), "text": "", "active": False, "numeric": numeric}
def handle_textbox(box, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
box["active"] = box["rect"].collidepoint(event.pos)
elif box["active"] and event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
box["text"] = box["text"][:-1]
elif box["active"] and event.type == pygame.TEXTINPUT:
if not box["numeric"] or event.text.isdigit():
box["text"] += event.text
def draw_textbox(s, font, box):
pygame.draw.rect(s, (255, 255, 255), box["rect"])
color = (60, 140, 220) if box["active"] else (150, 150, 150)
pygame.draw.rect(s, color, box["rect"], 2)
t = font.render(box["text"], True, (20, 20, 20))
s.blit(t, (box["rect"].x + 6, box["rect"].y + (box["rect"].h - t.get_height()) // 2))
# 8번 챕터에서 만든 만족도 계산 함수 (그대로 가져옴)
NEAR = 100
SCORE = {1: 100, 2: 80, 3: 60}
def satisfaction(prefs, desk, by_id):
best = 0
for rank, pid in enumerate(prefs, 1):
if pid is None or pid not in by_id:
continue
base = SCORE[rank]
target = by_id[pid]
if desk["id"] == pid:
best = max(best, base)
elif dist(desk["rect"].center, target["rect"].center) <= NEAR:
best = max(best, base - 10)
return best
# 만족도 합이 최대가 되는 배정을 찾아서, 콘솔에 출력한다
def compute(desks):
students = [d for d in desks if d["name"].strip()]
if not students:
return
by_id = {d["id"]: d for d in desks}
# cost[학생번호][책상번호] = 그 학생을 그 책상에 앉혔을 때의 '비용'
# linear_sum_assignment는 최소화만 하므로, 만족도에 -를 붙여서 최대화 문제로 바꾼다
cost = [[-satisfaction(s["prefs"], d, by_id) for d in desks] for s in students]
rows, cols = linear_sum_assignment(cost)
for r, c in zip(rows, cols): # rows[i], cols[i]가 한 쌍 = 학생과 배정된 책상
print(students[r]["name"], "->", desks[c]["id"], "번 책상")
desks = []
next_id = 1
for row in range(4):
for col in range(5):
x = 80 + col * 90
y = 100 + row * 70
desks.append({"id": next_id, "rect": pygame.Rect(x, y, 70, 50), "name": "", "prefs": [None, None, None]})
next_id += 1
drag = None
drag_off = (0, 0)
click_candidate = None
down_pos = None
panel_desk = None
name_box = make_textbox((850, 40, 150, 30))
p_boxes = [make_textbox((850, 40 + 40 * (i + 1), 150, 30), True) for i in range(3)]
save_btn = pygame.Rect(850, 200, 90, 32)
cancel_btn = pygame.Rect(960, 200, 90, 32)
calc_btn = pygame.Rect(20, 660, 140, 40) # "배치 계산" 버튼
def open_panel(d):
global panel_desk
panel_desk = d
name_box["text"] = d["name"]
for box, val in zip(p_boxes, d["prefs"]):
box["text"] = str(val) if val else ""
def save_panel():
global panel_desk
panel_desk["name"] = name_box["text"].strip()
panel_desk["prefs"] = [int(b["text"]) if b["text"].strip().isdigit() else None for b in p_boxes]
panel_desk = None
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if panel_desk:
handle_textbox(name_box, event)
for b in p_boxes:
handle_textbox(b, event)
if event.type == pygame.MOUSEBUTTONDOWN and save_btn.collidepoint(event.pos):
save_panel()
elif event.type == pygame.MOUSEBUTTONDOWN and cancel_btn.collidepoint(event.pos):
panel_desk = None
continue
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if calc_btn.collidepoint(event.pos):
compute(desks) # 버튼을 누르면 결과를 콘솔(터미널)에 출력
else:
for d in desks:
if d["rect"].collidepoint(event.pos):
down_pos = event.pos
click_candidate = d
drag_off = (d["rect"].x - event.pos[0], d["rect"].y - event.pos[1])
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
clicked = None
for d in desks:
if d["rect"].collidepoint(event.pos):
clicked = d
if clicked:
desks.remove(clicked)
else:
desks.append({
"id": next_id,
"rect": pygame.Rect(event.pos[0] - 35, event.pos[1] - 25, 70, 50),
"name": "",
"prefs": [None, None, None]
})
next_id += 1
elif event.type == pygame.MOUSEMOTION and click_candidate and down_pos and event.buttons[0]:
if not drag and dist(event.pos, down_pos) > 6:
drag = click_candidate
if drag:
drag["rect"].x = event.pos[0] + drag_off[0]
drag["rect"].y = event.pos[1] + drag_off[1]
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
if drag:
drag = None
elif click_candidate:
open_panel(click_candidate)
click_candidate = None
down_pos = None
screen.fill((253, 248, 240))
for d in desks:
pygame.draw.rect(screen, (255, 255, 255), d["rect"])
pygame.draw.rect(screen, (60, 60, 60), d["rect"], 2)
num_img = font.render(str(d["id"]), True, (100, 100, 100))
screen.blit(num_img, (d["rect"].x + 4, d["rect"].y + 2))
if d["name"]:
name_img = font.render(d["name"], True, (20, 20, 20))
screen.blit(name_img, name_img.get_rect(center=d["rect"].center))
if panel_desk:
for box in [name_box] + p_boxes:
draw_textbox(screen, font, box)
pygame.draw.rect(screen, (70, 150, 90), save_btn)
pygame.draw.rect(screen, (170, 80, 70), cancel_btn)
pygame.draw.rect(screen, (70, 150, 90), calc_btn)
calc_label = font.render("배치 계산", True, (255, 255, 255))
screen.blit(calc_label, calc_label.get_rect(center=calc_btn.center))
pygame.display.flip()
clock.tick(60)
pygame.quit()
지금까지 만든 프로그램에서 책상 3~4개에 이름과 순위를 입력하고 "배치 계산"을 눌러보면, 터미널에 배정 결과가 출력됩니다. 이걸 화면에 예쁘게 그려주는 게 10번 챕터입니다.
10행/열을 입력받는 교실 설정 화면
지금까지는 range(4), range(5)로 교실 크기가 코드에 고정돼 있었습니다. 이제 6번 챕터의 입력창을 재활용해서, 프로그램을 실행하면 행/열을 직접 입력받고, "생성" 버튼을 눌러야 그 크기로 교실이 만들어지도록 바꿉니다.
화면이 두 종류(설정 화면, 편집 화면)가 됐으니, 지금이 어느 화면인지 기억해둘 변수 state가 필요합니다. state 값에 따라 이벤트 처리와 그리기를 if/elif로 나눕니다.
import pygame
from scipy.optimize import linear_sum_assignment
pygame.init()
screen = pygame.display.set_mode((1100, 720))
font = pygame.font.SysFont("malgungothic", 20)
clock = pygame.time.Clock()
pygame.key.start_text_input()
def dist(a, b):
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
def make_textbox(rect, numeric=False):
return {"rect": pygame.Rect(rect), "text": "", "active": False, "numeric": numeric}
def handle_textbox(box, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
box["active"] = box["rect"].collidepoint(event.pos)
elif box["active"] and event.type == pygame.KEYDOWN and event.key == pygame.K_BACKSPACE:
box["text"] = box["text"][:-1]
elif box["active"] and event.type == pygame.TEXTINPUT:
if not box["numeric"] or event.text.isdigit():
box["text"] += event.text
def draw_textbox(s, font, box):
pygame.draw.rect(s, (255, 255, 255), box["rect"])
color = (60, 140, 220) if box["active"] else (150, 150, 150)
pygame.draw.rect(s, color, box["rect"], 2)
t = font.render(box["text"], True, (20, 20, 20))
s.blit(t, (box["rect"].x + 6, box["rect"].y + (box["rect"].h - t.get_height()) // 2))
NEAR = 100
SCORE = {1: 100, 2: 80, 3: 60}
def satisfaction(prefs, desk, by_id):
best = 0
for rank, pid in enumerate(prefs, 1):
if pid is None or pid not in by_id:
continue
base = SCORE[rank]
target = by_id[pid]
if desk["id"] == pid:
best = max(best, base)
elif dist(desk["rect"].center, target["rect"].center) <= NEAR:
best = max(best, base - 10)
return best
def compute(desks):
students = [d for d in desks if d["name"].strip()]
if not students:
return
by_id = {d["id"]: d for d in desks}
cost = [[-satisfaction(s["prefs"], d, by_id) for d in desks] for s in students]
rows, cols = linear_sum_assignment(cost)
for r, c in zip(rows, cols):
print(students[r]["name"], "->", desks[c]["id"], "번 책상")
# 처음엔 교실이 비어있고, "setup" 화면부터 시작한다
desks = []
next_id = 1
state = "setup" # "setup"(행/열 입력) 또는 "edit"(편집)
# 설정 화면용 입력창 2개 + 생성 버튼 (6번 챕터의 make_textbox 재사용)
row_box = make_textbox((500, 300, 80, 36), True)
col_box = make_textbox((500, 360, 80, 36), True)
create_btn = pygame.Rect(480, 420, 120, 40)
drag = None
drag_off = (0, 0)
click_candidate = None
down_pos = None
panel_desk = None
name_box = make_textbox((850, 40, 150, 30))
p_boxes = [make_textbox((850, 40 + 40 * (i + 1), 150, 30), True) for i in range(3)]
save_btn = pygame.Rect(850, 200, 90, 32)
cancel_btn = pygame.Rect(960, 200, 90, 32)
calc_btn = pygame.Rect(20, 660, 140, 40)
def open_panel(d):
global panel_desk
panel_desk = d
name_box["text"] = d["name"]
for box, val in zip(p_boxes, d["prefs"]):
box["text"] = str(val) if val else ""
def save_panel():
global panel_desk
panel_desk["name"] = name_box["text"].strip()
panel_desk["prefs"] = [int(b["text"]) if b["text"].strip().isdigit() else None for b in p_boxes]
panel_desk = None
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# ---- 설정 화면: 행/열을 입력받아서 생성 버튼을 누르면 교실을 만든다 ----
elif state == "setup":
handle_textbox(row_box, event)
handle_textbox(col_box, event)
if event.type == pygame.MOUSEBUTTONDOWN and create_btn.collidepoint(event.pos):
r = int(row_box["text"] or 0) # 숫자가 없으면 0으로 취급
c = int(col_box["text"] or 0)
if r > 0 and c > 0:
next_id = 1
desks = []
for row in range(r):
for col in range(c):
x = 80 + col * 90
y = 100 + row * 70
desks.append({"id": next_id, "rect": pygame.Rect(x, y, 70, 50), "name": "", "prefs": [None, None, None]})
next_id += 1
state = "edit" # 교실이 만들어졌으니 편집 화면으로 전환
# ---- 편집 화면: 9번 챕터에서 만든 내용을 그대로, "elif state == 'edit':" 안으로 옮겼다 ----
elif state == "edit":
if panel_desk:
handle_textbox(name_box, event)
for b in p_boxes:
handle_textbox(b, event)
if event.type == pygame.MOUSEBUTTONDOWN and save_btn.collidepoint(event.pos):
save_panel()
elif event.type == pygame.MOUSEBUTTONDOWN and cancel_btn.collidepoint(event.pos):
panel_desk = None
continue
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if calc_btn.collidepoint(event.pos):
compute(desks)
else:
for d in desks:
if d["rect"].collidepoint(event.pos):
down_pos = event.pos
click_candidate = d
drag_off = (d["rect"].x - event.pos[0], d["rect"].y - event.pos[1])
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
clicked = None
for d in desks:
if d["rect"].collidepoint(event.pos):
clicked = d
if clicked:
desks.remove(clicked)
else:
desks.append({
"id": next_id,
"rect": pygame.Rect(event.pos[0] - 35, event.pos[1] - 25, 70, 50),
"name": "",
"prefs": [None, None, None]
})
next_id += 1
elif event.type == pygame.MOUSEMOTION and click_candidate and down_pos and event.buttons[0]:
if not drag and dist(event.pos, down_pos) > 6:
drag = click_candidate
if drag:
drag["rect"].x = event.pos[0] + drag_off[0]
drag["rect"].y = event.pos[1] + drag_off[1]
elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
if drag:
drag = None
elif click_candidate:
open_panel(click_candidate)
click_candidate = None
down_pos = None
screen.fill((253, 248, 240))
if state == "setup":
title = font.render("행/열 숫자를 입력하세요", True, (30, 30, 30))
screen.blit(title, (420, 250))
screen.blit(font.render("행", True, (100, 100, 100)), (460, 308))
draw_textbox(screen, font, row_box)
screen.blit(font.render("열", True, (100, 100, 100)), (460, 368))
draw_textbox(screen, font, col_box)
pygame.draw.rect(screen, (70, 130, 200), create_btn)
create_label = font.render("생성", True, (255, 255, 255))
screen.blit(create_label, create_label.get_rect(center=create_btn.center))
elif state == "edit":
for d in desks:
pygame.draw.rect(screen, (255, 255, 255), d["rect"])
pygame.draw.rect(screen, (60, 60, 60), d["rect"], 2)
num_img = font.render(str(d["id"]), True, (100, 100, 100))
screen.blit(num_img, (d["rect"].x + 4, d["rect"].y + 2))
if d["name"]:
name_img = font.render(d["name"], True, (20, 20, 20))
screen.blit(name_img, name_img.get_rect(center=d["rect"].center))
if panel_desk:
for box in [name_box] + p_boxes:
draw_textbox(screen, font, box)
pygame.draw.rect(screen, (70, 150, 90), save_btn)
pygame.draw.rect(screen, (170, 80, 70), cancel_btn)
pygame.draw.rect(screen, (70, 150, 90), calc_btn)
calc_label = font.render("배치 계산", True, (255, 255, 255))
screen.blit(calc_label, calc_label.get_rect(center=calc_btn.center))
pygame.display.flip()
clock.tick(60)
pygame.quit()
이벤트 처리와 그리기 부분이 if state == "setup": ... elif state == "edit": ... 구조로 나뉜 게 핵심입니다. 화면이 늘어날 때마다(11번 챕터의 "결과 화면"처럼) elif state == "result":를 하나 더 추가하면 됩니다.
if r > 0 and c > 0: 조건 덕분에 아무 일도 일어나지 않는 걸 확인하세요. 이 조건을 지우면 어떻게 될까요?
11마무리 — 결과 화면 꾸미기
10번 챕터까지의 코드에 아래 내용을 순서대로 이어붙이면, 지금 이 사이트의 "코드 보기"에 있는 완성 코드와 정확히 같아집니다. 전부 이미 배운 도구(딕셔너리, 함수, 컴프리헨션, 텍스트 입력창)를 재활용하는 것이라 새로운 개념은 없습니다.
추가되는 것 3가지
- 교실 외곽선 + 칠판 — 책상들을 감싸는 사각형 하나(
room_rect())와, 그 위쪽에 작은 초록 막대(draw_room()) 그리는 함수 두 개뿐입니다. - 결과 화면 — 9번 챕터의
print()대신,state에"result"를 하나 더 추가해서, 기존 배치와 바뀐 배치를 좌우로 나란히 그리고(draw_layout()), 만족도에 따라 파란/빨간 색을 칠합니다. 평균 만족도는 막대그래프(draw_chart()) 두 개로 비교합니다. - 적용 버튼 — 계산된 결과를 실제
desks리스트에 반영해서 자리를 진짜로 바꿉니다.
이 부분은 코드 양이 많아서 여기서 한 줄씩 다시 설명하기보다, 완성된 데모에서 "코드 보기" 버튼을 눌러 room_rect, draw_room, draw_layout, draw_chart 네 함수와 state == "result" 분기 부분을 찾아 10번 챕터 코드에 하나씩 옮겨 붙여보는 걸 추천합니다. 함수 이름과 딕셔너리 구조가 지금까지와 완전히 똑같아서, 어디에 무엇을 붙여야 할지 어렵지 않게 알아볼 수 있을 겁니다.
여기까지 오셨다면
1번 챕터의 리스트 하나로 시작해서, 11번 챕터의 완성 코드까지 — 새 기능이 필요할 때마다 이전 코드에 조금씩 이어붙이는 방식으로 실제 데모 프로그램을 전부 직접 만든 것입니다.