C:\SOO\Python\day06\game_3d_third_person.py 기준으로, 위에서부터 순서대로 설명할게요. 1. 초기 설정 (1~34줄) pygame.display.set_mode((WIDTH, HEIGHT), pygame.DOUBLEBUF | pygame.OPENGL) pygame은 원래 2D 그림판(Surface) 역할만 하는데, OPENGL 플래그를 주면 pygame은 "창을 띄우고 입력을 받는 역할"만 하고, 실제 그리기는 OpenGL이 담당하게 됩니다. DOUBLEBUF는 화면 깜빡임 방지용(더블 버퍼링)이에요. pygame.mouse.set_visible(False) pygame.event.set_grab(True) pygame.mouse.get_rel() 마우스 커서를 숨기고 창 안에 가둔 다음(FPS 게임 특유의 "마우스룩"), 창이 뜨자마자 쌓여있는 마우스 이동값을 한 번 읽어서 버려요(안 하면 시작하자마자 시야가 홱 돌아감). 2. draw_sphere() (69~85줄) — 구를 손으로 만들기 OpenGL엔 "공 그리기" 함수가 없어서 위도/경도(latitude/longitude) 방식으로 직접 삼각형 띠를 이어붙여요. lat0 = math.pi * (-0.5 + i / stacks) # 위도: -90도 ~ +90도를 stacks개로 나눔 z0, zr0 = math.sin(lat0), math.cos(lat0) # 그 위도에서의 높이(z0)와 반지름(zr0) 지구본을 떠올리면 됩니다. stacks는 위도선 개수(가로 띠), slices는 경도선 개수(세로 조각). 각 위도 띠 사이를 GL_QUAD_STRIP(사각형 띠)으로 이어 붙이면 공 모양이 나옵니다. glNormal3f(x*zr0, y*zr0, z0) glVertex3f(radius*x*zr0, radius*y*zr0, radius*z0) glVertex3f는 실제 정점 좌표, glNormal3f는 "이 점이 어느 방향을 바라보는 면인가"를 나타내는 법선(normal) 벡터예요. 구는 표면의 법선이 항상 중심에서 바깥으로 향하는 방향과 같아서, 정규화된 좌표(x,y,z)를 그대로 법선으로 씁니다. 이 법선이 있어야 조명(GL_LIGHTING)이 어느 면은 밝게, 어느 면은 어둡게 계산해서 입체감이 생겨요. 3. draw_room() (88~117줄) — 방(정육면체) 그리기 glDisable(GL_LIGHTING) ... glEnable(GL_LIGHTING) 바닥 격자선/테두리는 GL_LINES로 그리는데, 선에는 법선이 없어서 조명 계산에 넣으면 이상하게(까맣게) 나올 수 있어요. 그래서 선 그릴 때만 조명을 잠깐 끄고, 색을 있는 그대로(glColor3f) 출력한 뒤 다시 켭니다. 바닥 격자는 step(3유닛) 간격으로 가로선/세로선을 반복해서 긋고, 큐브 테두리는 바닥 4개 모서리 + 천장 4개 모서리 + 그걸 잇는 기둥 4개, 총 12개 선을 그립니다. 4. draw_text() (141~164줄) — 2D 글자를 3D 화면 위에 얹기 OpenGL은 텍스트를 모르기 때문에, pygame 폰트로 글자를 그린 이미지를 픽셀 데이터로 바꿔서 화면에 그대로 찍어 붙입니다. glMatrixMode(GL_PROJECTION) glPushMatrix(); glLoadIdentity(); glOrtho(0, WIDTH, 0, HEIGHT, -1, 1) 평소엔 원근 투영(gluPerspective, 멀리 있으면 작게 보임)을 쓰지만, 글자는 화면에 평평하게 고정돼야 하니 잠깐 "직교 투영"(ortho, 원근 없이 화면 픽셀 좌표 그대로)으로 바꿔서 그리고, 다 그리면 glPopMatrix()로 원래 원근 투영으로 되돌립니다. glPushMatrix/glPopMatrix는 "지금 상태 저장해두고, 나중에 그 지점으로 복귀"하는 스택이에요. 5. spawn_ball() (167~186줄) face = random.choice(["ceil", "+x", "-x", "+z", "-z"]) 바닥(-y 면)만 제외한 5개 면 중 하나를 고르고, 그 면 위의 무작위 좌표에서 공을 생성해요. target = [random.uniform(-H, H), random.uniform(0, 6), random.uniform(-H, H)] dx, dy, dz = target[0]-pos[0], target[1]-pos[1], target[2]-pos[2] length = math.sqrt(dx*dx+dy*dy+dz*dz) or 1.0 return [pos[0], pos[1], pos[2], dx/length, dy/length, dz/length] "목표 지점(바닥 근처)을 하나 정하고, 생성 위치에서 그 목표까지의 방향을 구해서 길이 1로 정규화"하는 거예요. 이렇게 하면 공이 매 프레임 방향 × 속도만큼 이동해도 항상 직선으로 날아갑니다. [x,y,z, dirx,diry,dirz] 6개 값 하나짜리 리스트가 공 하나입니다. 6. 메인 루프 — 입력 (211~220줄) pygame.event.get()으로 큐에 쌓인 이벤트(창닫기, 키입력)를 하나씩 처리합니다. 스페이스바는 player_y <= PLAYER_RADIUS(바닥에 붙어있을 때)일 때만 반응해서 이중 점프를 막아요. 7. 마우스룩 → 이동 방향 (224~231줄) yaw += mdx * MOUSE_SENS pitch -= mdy * MOUSE_SENS get_rel()로 받은 마우스 이동량(픽셀 단위)에 감도를 곱해서 각도(라디안)에 누적. yaw는 좌우 회전, pitch는 상하 회전이에요. fx, fz = math.sin(yaw), -math.cos(yaw) rx, rz = math.cos(yaw), math.sin(yaw) yaw 각도로부터 "앞 방향"과 "오른쪽 방향" 벡터를 구합니다. 각도 0일 때 앞은 (0,-1) 방향(−Z축), 오른쪽은 (1,0) 방향(+X축)이 되도록 삼각함수로 맞춘 거예요. 여기서 pitch(상하 시선)는 일부러 안 씁니다 — 이동은 바닥(XZ 평면) 위에서만 일어나야 하니까요(위를 쳐다봐도 걷는 방향은 안 바뀌게). 8. WASD 합산 (233~249줄) mvx = mvz = 0.0 if keys[pygame.K_w]: mvx += fx; mvz += fz if keys[pygame.K_d]: mvx += rx; mvz += rz ... mlen = math.hypot(mvx, mvz) if mlen > 0: player_x += mvx/mlen * PLAYER_SPEED 여러 키를 동시에 누르면(예: W+D) 벡터가 더해져서 대각선 방향이 되는데, 그 길이가 1보다 커질 수 있어서(대각선이 더 빨라지는 버그) mlen으로 나눠 다시 길이 1로 정규화한 뒤 속도를 곱합니다. 그 다음 limit으로 방 경계 밖으로 못 나가게 막아요. 9. 중력/점프 (251~258줄) vel_y -= GRAVITY # 매 프레임 속도가 조금씩 줄어듦(중력 가속) player_y += vel_y # 그 속도만큼 위치 이동 if player_y <= PLAYER_RADIUS: player_y = PLAYER_RADIUS vel_y = 0.0 스페이스바를 누르면 vel_y = JUMP_SPEED로 순간 위로 튀어오르고, 이후 매 프레임 중력만큼 vel_y가 줄어들어(결국 음수가 됨) 포물선을 그리며 떨어집니다. 바닥에 닿으면 위치를 바닥에 딱 붙이고 속도를 0으로 리셋. 10. 공 이동·충돌·제거 (265~283줄) b[0] += b[3]*ball_speed # x += dirx * speed ... ddx, ddy, ddz = b[0]-player_x, b[1]-player_y, b[2]-player_z if math.sqrt(ddx*ddx+ddy*ddy+ddz*ddz) < PLAYER_RADIUS+BALL_RADIUS: game_over = True 공의 저장된 방향×속도만큼 매 프레임 이동시키고, 플레이어와의 3차원 거리(피타고라스)를 구해서 "두 반지름의 합"보다 가까우면(=구끼리 겹치면) 게임오버. in_bounds 체크로 방 밖으로 완전히 나간 공은 리스트에서 제거해 메모리/연산 낭비를 막습니다. 11. 카메라 (오빗 카메라, 289~301줄) target_x, target_y, target_z = player_x, player_y + 0.4, player_z fx3 = math.sin(yaw) * math.cos(pitch) fy3 = math.sin(pitch) fz3 = -math.cos(yaw) * math.cos(pitch) cam_x = target_x - fx3 * CAMERA_DISTANCE (fx3,fy3,fz3)는 "지금 보고 있는 방향"을 나타내는 3D 단위벡터예요(구면좌표를 직교좌표로 바꾸는 공식). 1인칭이면 카메라를 이 방향으로 두면 되지만, 3인칭이라 캐릭터(target)를 기준으로 반대 방향(-) 으로 CAMERA_DISTANCE만큼 떨어뜨려서 캐릭터 뒤에서 캐릭터를 보게 만들었어요. 그래서 위를 보면(pitch 증가) fy3가 커지고, 카메라는 target_y - fy3*거리로 오히려 아래로 내려가면서 캐릭터 위쪽 풍경을 보여주는 식이 됩니다. gluLookAt(cam_x, cam_y, cam_z, target_x, target_y, target_z, 0, 1, 0) "카메라는 여기 있고, 저기를 바라보고, 위쪽은 이 방향이다"를 한 번에 설정하는 OpenGL 함수예요. 12. 렌더링 순서 (298~316줄) glClear → gluLookAt(카메라 설정) → 방 → 플레이어 구 → 공들 → HUD 텍스트 → pygame.display.flip()(그린 걸 화면에 표시). GL_DEPTH_TEST가 켜져 있어서 그리는 순서와 상관없이 카메라에 가까운 물체가 자동으로 앞에 나오도록 OpenGL이 알아서 처리해줍니다. --- 궁금한 부분(예: 왜 사원수 대신 삼각함수만 쓰는지, 왜 GL_QUAD_STRIP을 쓰는지 등)이 있으면 짚어서 더 파고들어볼게요.