🪑 자리배치 프로그램 — 전체 코드
seat-demo-code.py · pygame + scipy(헝가리안 알고리즘)
실행하려면 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()