실행하려면
pip install pygame scipy 후 이 코드를 .py 파일로 저장해서 실행하세요.
코드는 seat-demo-code.py가 수정될 때마다 이 페이지에도 그대로 반영됩니다.
import sys, random, pygame
from scipy.optimize import linear_sum_assignment
# ---------- 기본 설정값 ----------
W, H = 1100, 720
DESK_W, DESK_H = 70, 50
NEAR = DESK_W * 1.4 # 이 거리(픽셀) 안에 있으면 "주변 자리"로 인정
SCORE = {1: 100, 2: 80, 3: 60} # 1/2/3순위 자리에 앉았을 때 기본 만족도(%)
BG, INK, SUB = (245, 244, 238), (30, 30, 30), (100, 100, 100)
BLUE, RED, GRAY = (90, 140, 220), (215, 95, 90), (225, 225, 225) # 만족/불만족/빈자리 색
def dist(a, b):
"""두 점(x, y) 사이의 직선 거리를 구한다. '주변 자리' 판정에 사용."""
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5
def room_rect(desks):
"""
현재 책상들을 모두 포함하는 '교실' 사각형을 구한다.
책상 바깥으로 여백을 좀 더 주고, 위쪽은 칠판이 들어갈 자리만큼 더 넉넉히 남긴다.
"""
if not desks:
return pygame.Rect(60, 80, 400, 300)
x0 = min(d["rect"].x for d in desks) - 40
y0 = max(min(d["rect"].y for d in desks) - 70, 50) # 상단 안내 문구 영역은 침범하지 않도록
x1 = max(d["rect"].right for d in desks) + 40
y1 = max(d["rect"].bottom for d in desks) + 40
return pygame.Rect(x0, y0, x1 - x0, y1 - y0)
def draw_room(surf, room, small_font, scale=1.0):
"""교실 외곽선과 칠판을 그린다. scale은 결과 화면처럼 축소해서 그릴 때 쓴다."""
pygame.draw.rect(surf, (90, 70, 40), room, max(1, round(3 * scale)), border_radius=4)
board_w, board_h = room.w * 0.35, max(6, 14 * scale)
board_x = room.x + (room.w - board_w) / 2
board_y = room.y + 10 * scale
board = pygame.Rect(board_x, board_y, board_w, board_h)
pygame.draw.rect(surf, (30, 70, 40), board, border_radius=3)
if scale > 0.5: # 너무 작게 축소되면 글자는 생략
label = small_font.render("칠판", True, (255, 255, 255))
surf.blit(label, label.get_rect(center=board.center))
# ---------- 책상 ----------
# 책상 하나 = {"id": 번호, "rect": 위치/크기, "name": 앉은 학생 이름, "prefs": [1,2,3순위 책상 번호]}
next_desk_id = 1
def new_desk(x, y):
"""책상 딕셔너리를 하나 새로 만든다. 번호는 자동으로 증가(삭제해도 재사용하지 않음)."""
global next_desk_id
d = {
"id": next_desk_id,
"rect": pygame.Rect(x, y, DESK_W, DESK_H),
"name": "",
"prefs": [None, None, None],
}
next_desk_id += 1
return d
def draw_desk(s, font, small, d, fill=None):
"""책상 사각형 + 번호 + (있다면) 학생 이름을 그린다."""
pygame.draw.rect(s, fill or (255, 255, 255), d["rect"], border_radius=6)
pygame.draw.rect(s, (60, 60, 60), d["rect"], 2, border_radius=6)
s.blit(small.render(str(d["id"]), True, SUB), (d["rect"].x + 4, d["rect"].y + 2))
if d["name"]:
t = font.render(d["name"], True, INK)
s.blit(t, t.get_rect(center=d["rect"].center))
# ---------- 텍스트 입력창 ----------
# 입력창 하나 = {"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:
# TEXTINPUT을 써야 한글 등 조합 문자가 제대로 입력된다 (KEYDOWN만으로는 조합 전 자모가 들어옴)
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"])
pygame.draw.rect(s, (60, 140, 220) if box["active"] else (150, 150, 150), box["rect"], 2)
t = font.render(box["text"], True, INK)
s.blit(t, (box["rect"].x + 6, box["rect"].y + (box["rect"].h - t.get_height()) // 2))
# ---------- 만족도 계산 & 최적 배정 ----------
def satisfaction(prefs, desk, by_id):
"""
한 학생의 희망 순위(prefs)를 기준으로, 특정 책상(desk)에 앉았을 때의 만족도를 계산한다.
- 정확히 그 순위의 책상이면 100/80/60
- 그 책상의 '주변 자리'면 순위 점수 - 10
- 여러 조건에 동시에 해당하면 더 높은 점수를 채택
- 어느 것에도 해당 안 하면 0
"""
best = 0
for rank, pid in enumerate(prefs, 1):
if pid is None or pid not in by_id:
continue
base = SCORE[rank]
p = by_id[pid]
if desk["id"] == pid:
best = max(best, base)
elif dist(desk["rect"].center, p["rect"].center) <= NEAR:
best = max(best, base - 10)
return best
def random_fill(desks):
"""
아무도 이름/순위를 입력하지 않은 상태로 '배치 계산'을 눌렀을 때,
모든 책상에 임시 학생 이름과 무작위 1/2/3순위를 채워 넣어 데모를 바로 돌려볼 수 있게 한다.
"""
ids = [d["id"] for d in desks]
for d in desks:
d["name"] = f"학생{d['id']}"
d["prefs"] = random.sample(ids, min(3, len(ids)))
def compute(desks):
"""
현재 책상 배치를 바탕으로 '전체 만족도 합이 최대'가 되는 자리 배정을 계산한다.
- 이름이 입력된 책상 = 배정 대상 학생
- 만족도 표(학생 x 책상)를 만들고, 헝가리안 알고리즘(linear_sum_assignment)으로
각 학생을 서로 다른 책상에 1:1로 배정하되 합이 최대가 되는 조합을 찾는다.
- 실제로 desks를 건드리지 않고, 결과만 딕셔너리로 돌려준다 (미리보기용).
반환값이 None이면 이름이 입력된 학생이 하나도 없다는 뜻.
"""
students = [d for d in desks if d["name"].strip()]
if not students:
return None
by_id = {d["id"]: d for d in desks}
# 지금 그대로 앉아있을 때(변경 전)의 만족도
before_sat = {s["id"]: satisfaction(s["prefs"], s, by_id) for s in students}
# cost[i][j] = i번째 학생을 j번째 책상에 앉혔을 때의 '비용'.
# linear_sum_assignment는 비용 최소화만 지원하므로, 만족도에 -를 붙여서 최대화 문제로 변환한다.
cost = [[-satisfaction(s["prefs"], d, by_id) for d in desks] for s in students]
rows, cols = linear_sum_assignment(cost)
# 배정 결과를 학생 딕셔너리가 아니라 값(이름/순위 리스트)으로 미리 복사해둔다.
# 딕셔너리를 그대로 참조하면, 나중에 여러 학생이 서로 자리를 맞바꿀 때
# 먼저 바뀐 학생의 이름이 덮어써져 뒤에서 잘못된 값을 읽는 버그가 생길 수 있다.
names, prefs, after_sat = {}, {}, {}
for r, c in zip(rows, cols):
student, target = students[r], desks[c]
names[target["id"]] = student["name"]
prefs[target["id"]] = list(student["prefs"])
after_sat[target["id"]] = satisfaction(student["prefs"], target, by_id)
n = len(students)
return {
"before_names": {d["id"]: d["name"] for d in desks}, "before_sat": before_sat,
"after_names": names, "after_prefs": prefs, "after_sat": after_sat,
"before_avg": sum(before_sat.values()) / n, "after_avg": sum(after_sat.values()) / n,
}
def color(sat):
"""만족도 점수를 파랑(만족)/빨강(불만족) 색으로 변환한다. 0보다 크면 만족으로 취급."""
return BLUE if sat and sat > 0 else RED
def buttons_draw(surf, font, buttons):
"""(사각형, 라벨, 배경색) 리스트를 받아 버튼들을 한 번에 그린다."""
for rect, label, bg in buttons:
pygame.draw.rect(surf, bg, rect, border_radius=8)
t = font.render(label, True, (255, 255, 255))
surf.blit(t, t.get_rect(center=rect.center))
def buttons_hit(buttons, pos):
"""클릭 좌표(pos)가 버튼 목록 중 어떤 버튼 위인지 찾아 그 라벨을 반환한다. 없으면 None."""
for rect, label, _ in buttons:
if rect.collidepoint(pos):
return label
return None
# ---------- 화면/프로그램 상태 (전역 변수) ----------
pygame.init()
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption("교실 자리 배치 데모")
clock = pygame.time.Clock()
font = pygame.font.SysFont("malgungothic", 20)
small = pygame.font.SysFont("malgungothic", 15)
big = pygame.font.SysFont("malgungothic", 26)
state = "setup" # "setup"(행/열 입력) -> "edit"(배치 편집) -> "result"(결과 확인)
desks = []
result = None # compute()의 결과가 담기는 곳 (result 화면에서 사용)
# setup 화면: 행/열 입력창 + 생성 버튼
row_box = make_textbox((W // 2 - 40, 300, 80, 36), True)
col_box = make_textbox((W // 2 - 40, 360, 80, 36), True)
setup_btns = [(pygame.Rect(W // 2 - 60, 420, 120, 40), "생성", (70, 130, 200))]
# edit 화면 하단 툴바 버튼
edit_btns = [
(pygame.Rect(20, H - 60, 140, 40), "배치 계산", (70, 150, 90)),
(pygame.Rect(180, H - 60, 140, 40), "다시 시작", (150, 90, 90)),
]
# result 화면 하단 버튼
result_btns = [
(pygame.Rect(20, H - 60, 140, 40), "적용", (70, 130, 200)),
(pygame.Rect(180, H - 60, 140, 40), "뒤로", (120, 120, 120)),
]
# 책상 클릭 시 뜨는 입력 패널 (이름 + 1/2/3순위)
px, py = W - 250, 40
name_box = make_textbox((px + 80, py, 150, 30))
p_boxes = [make_textbox((px + 80, py + 40 * (i + 1), 150, 30), True) for i in range(3)]
panel_rect = pygame.Rect(px - 20, py - 30, 300, 230) # 패널 전체 영역(클릭 판정용)
panel_btns = [
(pygame.Rect(px, py + 165, 90, 32), "저장", (70, 150, 90)),
(pygame.Rect(px + 110, py + 165, 90, 32), "취소", (170, 80, 70)),
]
panel_desk = None # 지금 패널이 열려 있는 대상 책상 (None이면 패널 닫힘)
# 드래그 상태값 (클릭인지 드래그인지 구분하기 위한 변수들)
drag = None
drag_off = (0, 0)
click_candidate = None
down_pos = None
def desk_at(pos):
"""좌표(pos)에 있는 책상을 찾는다. 여러 개 겹쳐 있으면 가장 위(나중에 추가된) 것을 우선한다."""
for d in reversed(desks):
if d["rect"].collidepoint(pos):
return d
return None
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():
"""패널의 입력값을 실제 책상(panel_desk)에 반영하고 패널을 닫는다."""
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
# ---------- 이벤트 처리 ----------
def handle_setup(event):
global state, desks, next_desk_id
handle_textbox(row_box, event)
handle_textbox(col_box, event)
if event.type == pygame.MOUSEBUTTONDOWN and buttons_hit(setup_btns, event.pos) == "생성":
r = int(row_box["text"] or 0)
c = int(col_box["text"] or 0)
if r > 0 and c > 0:
# 행 x 열 개수만큼 격자로 책상을 생성 (번호는 1번부터 새로 매김)
next_desk_id = 1
desks = [new_desk(80 + cc * (DESK_W + 20), 100 + rr * (DESK_H + 20))
for rr in range(r) for cc in range(c)]
state = "edit"
def handle_edit(event):
global state, desks, panel_desk, drag, drag_off, click_candidate, down_pos, result
# 패널이 열려있을 때: 패널 밖 클릭이 아니면 이벤트를 패널 쪽으로 넘긴다
if panel_desk:
if not (event.type == pygame.MOUSEBUTTONDOWN and not panel_rect.collidepoint(event.pos)):
handle_textbox(name_box, event)
for b in p_boxes:
handle_textbox(b, event)
if event.type == pygame.MOUSEBUTTONDOWN:
hit = buttons_hit(panel_btns, event.pos)
if hit == "저장":
save_panel()
elif hit == "취소":
panel_desk = None
return
if event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 1:
hit = buttons_hit(edit_btns, event.pos)
if hit == "배치 계산":
if desks and not any(d["name"].strip() for d in desks):
random_fill(desks) # 아무도 입력 안 했으면 전부 무작위로 채워서 데모 진행
result = compute(desks) # 최적 배정 미리 계산 (아직 적용은 안 함)
if result:
state = "result"
return
if hit == "다시 시작":
state = "setup"
desks = []
return
# 툴바가 아니면 책상을 클릭/드래그 시작한 것인지 확인
d = desk_at(event.pos)
if d:
down_pos = event.pos
click_candidate = d
drag_off = (d["rect"].x - event.pos[0], d["rect"].y - event.pos[1])
elif event.button == 3:
# 우클릭: 책상 위면 삭제, 빈 공간이면 새 책상 추가
d = desk_at(event.pos)
if d:
desks.remove(d)
elif event.pos[1] < H - 80:
desks.append(new_desk(event.pos[0] - DESK_W // 2, event.pos[1] - DESK_H // 2))
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"].topleft = (event.pos[0] + drag_off[0], 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
def handle_result(event):
global state, desks
if event.type == pygame.MOUSEBUTTONDOWN:
hit = buttons_hit(result_btns, event.pos)
if hit == "적용":
# 계산해둔 결과를 실제 책상들에 반영(이름/순위 갱신)
for d in desks:
d["name"] = result["after_names"].get(d["id"], "")
d["prefs"] = result["after_prefs"].get(d["id"], [None, None, None])
state = "edit"
elif hit == "뒤로":
state = "edit" # 적용하지 않고 편집 화면으로만 복귀
# ---------- 화면 그리기 ----------
def draw_setup():
s = screen
t = big.render("교실 행/열을 입력하세요", True, INK)
s.blit(t, t.get_rect(center=(W // 2, 220)))
for label, box in (("행(row)", row_box), ("열(col)", col_box)):
s.blit(font.render(label, True, SUB), (box["rect"].x - 90, box["rect"].y + 6))
draw_textbox(s, font, box)
buttons_draw(s, font, setup_btns)
def draw_edit():
s = screen
s.blit(small.render(
"좌클릭: 이름/순위 입력 · 드래그: 이동 · 우클릭(책상): 삭제 · 우클릭(빈 곳): 추가",
True, SUB), (20, 20))
if desks:
draw_room(s, room_rect(desks), small)
for d in desks:
draw_desk(s, font, small, d)
buttons_draw(s, font, edit_btns)
if panel_desk:
pygame.draw.rect(s, (250, 250, 245), panel_rect, border_radius=10)
pygame.draw.rect(s, (120, 120, 120), panel_rect, 2, border_radius=10)
s.blit(font.render(f"책상 #{panel_desk['id']}", True, INK),
(panel_rect.x + 10, panel_rect.y + 4))
for label, box in zip(["이름", "1순위", "2순위", "3순위"], [name_box] + p_boxes):
s.blit(small.render(label, True, SUB), (box["rect"].x - 70, box["rect"].y + 6))
draw_textbox(s, small, box)
buttons_draw(s, small, panel_btns)
def draw_layout(rect, title, names, sats):
"""
책상 배치 하나를 주어진 영역(rect) 안에 맞춰(축소/이동) 그린다.
names/sats: {책상id: 이름/만족도} — before/after 둘 다 같은 함수로 그리기 위한 매개변수.
"""
s = screen
pygame.draw.rect(s, (250, 250, 245), rect, border_radius=10)
pygame.draw.rect(s, (120, 120, 120), rect, 2, border_radius=10)
s.blit(font.render(title, True, INK), (rect.x + 16, rect.y + 10))
# 교실 범위(책상 + 여백)를 구해서, 패널 안에 딱 맞도록 축소 비율(scale) 계산
room = room_rect(desks)
x0, y0 = room.x, room.y
scale = min((rect.w - 40) / room.w, (rect.h - 70) / room.h, 1.0)
ax, ay = rect.x + 20, rect.y + 50
scaled_room = pygame.Rect(ax, ay, room.w * scale, room.h * scale)
draw_room(s, scaled_room, small, scale=scale)
for d in desks:
name = names.get(d["id"], "")
box = pygame.Rect(ax + (d["rect"].x - x0) * scale, ay + (d["rect"].y - y0) * scale,
max(6, d["rect"].w * scale), max(6, d["rect"].h * scale))
pygame.draw.rect(s, color(sats.get(d["id"])) if name else GRAY, box, border_radius=3)
pygame.draw.rect(s, (60, 60, 60), box, 1, border_radius=3)
if name and scale > 0.55: # 너무 작게 축소되면 이름은 생략(글자가 안 보이므로)
if scale > 0.8:
# 공간이 충분하면 이름 + 만족도 숫자를 두 줄로 표시
t = small.render(name, True, (255, 255, 255))
s.blit(t, t.get_rect(center=(box.centerx, box.centery - 8)))
st = small.render(f"{sats.get(d['id'], 0)}%", True, (255, 255, 255))
s.blit(st, st.get_rect(center=(box.centerx, box.centery + 8)))
else:
t = small.render(name, True, (255, 255, 255))
s.blit(t, t.get_rect(center=box.center))
def draw_chart(rect, before, after):
"""기존 평균 만족도 vs 변경 후 평균 만족도를 막대 2개로 비교해서 보여준다."""
s = screen
pygame.draw.rect(s, (250, 250, 245), rect, border_radius=10)
pygame.draw.rect(s, (120, 120, 120), rect, 2, border_radius=10)
top_pad, bottom_pad = 28, 26 # 위쪽 값(%) 라벨, 아래쪽 항목 라벨이 들어갈 여백
base = rect.bottom - bottom_pad # 막대가 서는 바닥선
max_h = rect.h - top_pad - bottom_pad
for i, (label, val) in enumerate([("기존", before), ("변경 후", after)]):
bx = rect.x + 60 + i * 160
bh = int(max_h * val / 100)
pygame.draw.rect(s, (90, 150, 110) if i else (170, 170, 190), (bx, base - bh, 60, bh), border_radius=4)
val_t = small.render(f"{val:.1f}%", True, INK)
s.blit(val_t, (bx, base - bh - val_t.get_height() - 4)) # 실제 글자 높이만큼 띄워서 막대 위에 배치
s.blit(small.render(label, True, SUB), (bx, base + 6))
def draw_result():
s = screen
r = result
# 범례(만족/불만족/빈자리 색 안내)
for i, (c, label) in enumerate([(BLUE, "만족"), (RED, "불만족"), (GRAY, "빈 자리")]):
x = 20 + i * 110
pygame.draw.rect(s, c, (x, 20, 16, 16), border_radius=3)
s.blit(small.render(label, True, SUB), (x + 22, 19))
left = pygame.Rect(20, 60, W // 2 - 30, H - 190)
right = pygame.Rect(W // 2 + 10, 60, W // 2 - 30, H - 190)
draw_layout(left, f"기존 자리배치 (평균 만족도 {r['before_avg']:.1f}%)",
r["before_names"], r["before_sat"])
draw_layout(right, f"변경된 자리배치 (평균 만족도 {r['after_avg']:.1f}%)",
r["after_names"], r["after_sat"])
draw_chart(pygame.Rect(20, H - 190, W - 40, 110), r["before_avg"], r["after_avg"])
buttons_draw(s, font, result_btns)
# ---------- 메인 루프 ----------
pygame.key.start_text_input() # 한글 등 조합 문자 입력(TEXTINPUT 이벤트)을 받기 위해 필요
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif state == "setup":
handle_setup(event)
elif state == "edit":
handle_edit(event)
elif state == "result":
handle_result(event)
screen.fill(BG)
if state == "setup":
draw_setup()
elif state == "edit":
draw_edit()
elif state == "result":
draw_result()
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()