Ursina Engine Workshop · Verified Edition

THE BACKROOMS

파이썬 기초 → 3D 공포 게임 완성 · 8주 실습 커리큘럼
8회차 × 90분 Python 3.x + Ursina 최신 검증된 코드 초급 → 중급
01Ursina 설치 & 첫 3D 큐브기초+
Ursina를 설치하고, 3D 큐브를 화면에 띄우고, 색상·크기·위치를 직접 바꿔본다.
1Ursina(어시나)가 뭔지 알아보기
Ursina는 파이썬으로 3D 게임을 만들 수 있게 해주는 게임 엔진입니다. Panda3D 위에 얹은 간단한 래퍼라 실행 속도도 괜찮아요.
게임 엔진 = "레고 블록 세트". 바닥·벽·캐릭터 부품이 준비되어 있어서 조립만 하면 게임이 됩니다.
이름 유래: 라틴어 '곰(Ursus)'에서 왔어요. 간기능 개선제 "우루사"랑 같은 어원!
2터미널(CMD) 열기
Windows: 시작 메뉴에서 "cmd" 검색 → 명령 프롬프트 클릭
Mac: Spotlight(⌘+Space)에서 "터미널" 검색
터미널 = 컴퓨터에게 글자로 명령을 내리는 도구. 마우스 대신 타자로 일하는 거예요.
3pip install로 설치
터미널에 아래 명령어를 직접 타이핑하고 Enter:
pip install ursina
pip = 파이썬 부품을 인터넷에서 자동으로 내려받는 도구. "배달 앱"처럼 주문하면 가져옵니다.
안 되면? → python -m pip install ursina
그래도 안 되면? → 파이썬 설치할 때 "Add Python to PATH" 체크 안 한 것. 파이썬 재설치!
Python 3.10 ~ 3.12 권장. 3.13은 일부 의존성 문제 있음.
4설치 확인
VS Code(또는 IDLE)에서 test.py 파일을 만들고:
from ursina import * print("우루사 설치 완료! 🐻")
파일 이름을 "ursina.py"로 절대 짓지 마세요! 라이브러리와 이름 충돌!
5빈 3D 창 띄우기
backrooms.py 파일을 새로 만들고 천천히 타이핑:
from ursina import * app = Ursina() # 아직 아무것도 없는 빈 세상 app.run()
Ursina() = "게임 세상을 준비해!", app.run() = "이제 보여줘!"
실행하면 회색 빈 창이 뜹니다. 이게 우리가 채울 3D 세상!
6큐브(상자) 하나 띄우기
app.run() 위에 한 줄 추가:
from ursina import * app = Ursina() # 🆕 큐브 하나 + 카메라 위치 cube = Entity(model='cube', color=color.orange) EditorCamera() # 마우스로 화면 회전 가능 app.run()
Entity = "3D 세계에 존재하는 물체". model='cube'는 "상자 모양". EditorCamera는 확인용 자유 시점 카메라.
EditorCamera() 없으면 카메라가 큐브 안에 박혀서 안 보일 수 있음. 우클릭 드래그로 돌리기, 휠로 확대.
7크기·색상·위치 바꿔보기
아래 값들을 자유롭게 수정하고 실행해 보세요!
cube = Entity( model='cube', color=color.red, # orange, blue, green, yellow... scale=(2, 1, 3), # (가로, 세로, 깊이) position=(0, 1, 0), # (x좌우, y상하, z앞뒤) )
X = 왼쪽↔오른쪽   Y = 아래↔위   Z = 앞↔뒤. 3D 좌표의 기본!
scale=(10, 0.1, 10)으로 하면? → 넓고 납작한 바닥이 됩니다!
8회전 애니메이션 — update() 맛보기
app.run() 바로 위에 함수를 추가:
# 매 프레임마다 자동 실행되는 함수 def update(): cube.rotation_y += time.dt * 50 app.run()
컴퓨터는 1초에 60번 화면을 다시 그립니다. update()는 그때마다 자동 호출! "매 순간 큐브를 조금씩 돌려라".
time.dt 곱하는 이유: 프레임마다 같은 "속도"로 돌게. 안 곱하면 빠른 컴퓨터일수록 더 빨리 돌음.
🎓 time.dt * 50 더 자세히 (궁금한 사람만)

만약 time.dt 없이 cube.rotation_y += 1 만 쓰면?

컴퓨터마다 FPS(초당 프레임)가 달라요. 좋은 PC는 1초에 144번 update() 실행, 느린 노트북은 30번.

144 FPS PC → 1초에 144도 회전 30 FPS 노트북 → 1초에 30도 회전 같은 게임인데 컴퓨터마다 속도가 다른 버그!

해결책: time.dt

time.dt = "이전 프레임에서 지금까지 걸린 시간 (초)"

144 FPS → time.dt ≈ 0.007초 60 FPS → time.dt ≈ 0.016초 30 FPS → time.dt ≈ 0.033초

cube.rotation_y += time.dt * 50 을 계산해보면:

144 FPS: 0.007 × 50 = 0.35도씩 × 144번 = 1초에 50도 ✓ 60 FPS : 0.016 × 50 = 0.83도씩 × 60번 = 1초에 50도 ✓ 30 FPS : 0.033 × 50 = 1.66도씩 × 30번 = 1초에 50도 ✓

어떤 컴퓨터에서든 1초에 정확히 50도 회전!

time.dt * 숫자 에서 숫자는 "초당 몇 도 / 몇 미터 움직일지". 50이면 초속 50도, 100이면 초속 100도.

공식처럼 외울 것:

• 움직임·속도·회전 관련 = 무조건 time.dt 곱하기
• 한 번만 일어나는 일(점프 트리거, 클릭 등) = 안 곱함

9다른 모양도 써보기
Entity(model='sphere', color=color.blue, x=3) # 구 Entity(model='plane', color=color.green, y=-1) # 평면(바닥용, 위쪽만 보임) Entity(model='quad', color=color.red, x=-3) # 사각 판
quad는 카메라 정면 쪽을 보는 평면(UI/스프라이트용), plane은 바닥처럼 누운 평면.
10중요한 발견 — 3D는 "앞뒤"가 있다!
아래처럼 회전시켜보면 한쪽 면이 사라지는 현상을 직접 볼 수 있어요.
# plane을 180도 뒤집어보기 p1 = Entity(model='plane', color=color.green, x=-2, y=1) p2 = Entity(model='plane', color=color.red, x=2, y=1, rotation_x=180) # 뒤집힘 → 안 보임! # quad도 뒤에서 보면 안 보임 q = Entity(model='quad', color=color.yellow, z=3) # 카메라 앞에서 보면 노란 사각형 # 뒤로 돌아가면(마우스 우클릭 드래그) 투명!
3D에선 각 면에 "앞면(front)""뒷면(back)"이 있어요. 기본적으로 앞면만 그립니다. 성능상 이점(픽셀 절반만 그림)!
해결법: double_sided=True 추가하면 양면 다 보임.
# 양면 다 보이게 Entity(model='plane', color=color.red, x=2, y=1, rotation_x=180, double_sided=True) # ← 이제 뒤집혀도 보임
나중에 천장을 만들 때(2회차) plane을 180도 뒤집는 이유가 바로 이것! "아래쪽이 앞면이 되도록" 돌리는 거예요. cube는 6면 모두 기본으로 보임(닫힌 도형).
11여러 큐브 + for문 미리보기
for i in range(5): Entity(model='cube', color=color.random_color(), x=i*2, y=0.5)
for문 한 줄이면 큐브 5개가 자동으로 나란히! 이게 미로 자동생성의 기초입니다.
색상 랜덤: color.random_color(). 원본에 있던 color.random()은 구버전 API.
12정리 & Q&A
✅ pip install → ✅ Entity 만들기 → ✅ scale/position/color → ✅ update() → ✅ double_sided → ✅ for문
결과물: 회전하는 큐브 + 다양한 도형 + for문으로 자동 생성
큐브 3개를 서로 다른 위치·색상·크기로 만들고, 각각 다른 속도·축으로 회전시켜 보세요.
02첫 번째 방 만들기 — WASD로 걷기기초+
역할별로 색이 다른 벽·바닥·천장·기둥·통로를 만들어 "방"을 완성하고, 1인칭으로 직접 걸어본다. (백룸 색감은 4회차에서!)
1전체 뼈대 (복붙해서 시작)
이번 회차는 이 뼈대에서 시작합니다. backrooms.py 전체 교체:
from ursina import * from ursina.prefabs.first_person_controller import FirstPersonController app = Ursina() # 여기에 방을 만들 겁니다 app.run()
FirstPersonController는 ursina.prefabs.first_person_controller 모듈에서 임포트. 경로 한 글자라도 틀리면 안 됨.
2학습용 색상 팔레트 — 역할별로 확 다르게!
이번 단계에선 구조가 보이는 게 더 중요해요. 벽·바닥·천장·기둥·내부벽을 다 다른 색으로!
WALL = color.orange # 외벽 FLOOR = color.blue # 바닥 CEILING = color.yellow # 천장 PILLAR = color.black # 기둥 INNER = color.pink # 내부 통로 벽 H = 3.2 # 벽 높이
색을 똑같이 "노란색 계열"로 통일하면 예뻐 보이지만, 이번 단계에선 어떤 게 벽이고 천장인지 한눈에 구분이 돼야 학습이 쉬워요.
백룸 분위기(노란색 통일)는 4회차에서 다시 돌아갑니다. 그땐 안개랑 같이 쓰니까 구분 없어도 OK!
3바닥 만들기
ground = Entity( model='plane', scale=(20, 1, 20), # (x, y, z) — plane이라도 이렇게 명시 권장 color=FLOOR, collider='box', texture='white_cube', texture_scale=(20, 20) )
collider='box' = "이 물체는 단단해서 통과 못 해". 안 쓰면 바닥 뚫고 떨어집니다!
공식 예제 패턴 그대로. scale=20 단일값도 되지만 (20,1,20)이 명시적이고 collider 박스 크기도 예측 가능.
texture='white_cube' 넣으면 타일 무늬가 생겨서 걷는 게 보여요. 떼도 됨.
4벽 4개 세우기
# 앞벽 (z=+10) Entity(model='cube', scale=(20, H, 0.2), color=WALL, position=(0, H/2, 10), collider='box') # 뒷벽 (z=-10) Entity(model='cube', scale=(20, H, 0.2), color=WALL, position=(0, H/2, -10), collider='box') # 왼쪽 (x=-10) Entity(model='cube', scale=(0.2, H, 20), color=WALL, position=(-10, H/2, 0), collider='box') # 오른쪽 (x=+10) Entity(model='cube', scale=(0.2, H, 20), color=WALL, position=(10, H/2, 0), collider='box')
scale=(20, 3.2, 0.2) = "가로 20m, 세로 3.2m, 두께 0.2m"인 벽. y=H/2로 바닥에 딱 붙음.
5천장 덮기 — 1회차 double_sided 기억나요?
Entity(model='plane', scale=(20,1,20), color=CEILING, y=H, rotation_x=180)
plane은 기본적으로 "위쪽"이 앞면. 천장은 아래에서 봐야 하니까 rotation_x=180으로 뒤집어서 앞면이 아래를 향하게 합니다.
만약 뒤집지 않고 그대로 y=H에 놓으면? → 방 안에서 천장이 안 보임! (뒷면이라 투명) 1회차 double_sided 실험에서 본 그 현상.
61인칭 컨트롤러 (중요!)
player = FirstPersonController( y=2, # 공중에서 시작 (바닥에 안 박히게) origin_y=-.5, # 발 기준 원점 position=(0, 2, 0) # 방 한가운데 )
WASD=이동, 마우스=시선, Space=점프, ESC=마우스 잠금 해제.
가장 흔한 버그: FirstPersonController()만 쓰면 플레이어가 바닥이랑 겹쳐서 끝없이 떨어짐. y=2, origin_y=-.5는 공식 예제 표준 패턴.
🎓 origin_y=-.5y=2 가 왜 필요한지 (궁금한 사람만)

origin(원점)이 뭐냐?

Entity의 origin위치 기준점이에요. origin=(x, y, z) 튜플인데 보통 y축 하나만 바꾸니까 origin_y 축약형을 씁니다 (둘 다 똑같이 작동).

큐브 하나로 예를 들면:

기본값 origin_y=0: origin_y=-0.5: ┌─────┐ ← y=0.5 ┌─────┐ ← y=1.0 │ │ │ │ │ ● │ ← y=0 (중심=position) │ │ │ │ │ │ └─────┘ ← y=-0.5 ●─────┘ ← y=0 (바닥=position) position=(0,0,0) 이면 position=(0,0,0) 이면 몸 중심이 y=0 몸의 바닥이 y=0

FirstPersonController 에 적용하면?

FirstPersonController는 키 height=2m 인 박스 콜라이더.

기본값 (origin_y=0) 으로 y=0 스폰하면:

• 플레이어의 몸 중심이 바닥 높이(y=0)에 위치
• 즉 허리 아래는 바닥 아래로 들어가 있음 → collider끼리 겹침
• 물리 엔진이 "겹쳤으니 빼내야지!" 하다가 아래쪽으로 튕기면 → 무한 낙하

origin_y=-0.5 로 바꾸면:

• 기준점이 플레이어의 이 됨
position.y = 0 = 발이 y=0 = 바닥에 딱 서있는 상태
position.y = 2 = 발이 공중 2m에 떠있는 상태

그래서 y=2 까지 추가하는 이유:

• 공중에서 시작 → 중력으로 자연스럽게 바닥까지 떨어지며 착지
• 시작하자마자 땅에 박혀있지 않으니 물리 엔진이 안 미쳐버림
• 2m는 안전 마진 (0.1m 만 띄워도 되지만 2m가 확실)

한 줄 요약:

origin_y=-0.5 = "좌표를 허리 말고 기준으로 계산해줘"
y=2 = "발이 공중 2m 에서 시작 → 중력으로 착지"

비유: 원본 기본값은 "줄타기 광대가 허리를 바닥 높이에 맞추고 시작" → 발이 바닥 아래로 들어가서 뚫림. origin_y=-.5, y=2 = "발이 2층 높이에서 점프 → 1층 바닥에 안전 착지".

7기둥 세우기 (for문)
for i in range(5): Entity(model='cube', scale=(0.5,H,0.5), color=PILLAR, position=(i*4-8, H/2, 0), collider='box')
검은 기둥 5개가 일렬로! 외벽(주황)과 구분돼서 한눈에 보임.
8내부 벽으로 통로 만들기
방 안쪽에 벽을 추가해서 좁은 통로를 만들어봅니다.
Entity(model='cube', scale=(8,H,0.2), color=INNER, position=(-2, H/2, 4), collider='box') Entity(model='cube', scale=(0.2,H,6), color=INNER, position=(3, H/2, -2), collider='box')
핑크색 내부벽이라 외벽(주황)과 바로 구분됨. 길을 막는 구조물 위치·길이를 자유롭게 바꿔보세요!
9ESC 종료 기능 — 확실한 방법
import sys def input(key): if key == 'escape': sys.exit()
원본 가이드의 application.quit()가 환경에 따라 안 먹는 경우가 있어요. sys.exit()는 파이썬 내장이라 100% 작동.
FirstPersonController는 ESC 누르면 기본적으로 마우스 잠금만 풀어요. 이 input() 함수를 덮어쓰면 실제 프로그램 종료!
다른 방법들 (참고용):
quit() — Python 내장, 됨
application.quit() — Ursina 제공, 환경 따라 됨/안됨
app.userExit() — Panda3D 방식, 됨
10자유 실험 & 정리
벽 위치·개수를 자유롭게 바꾸며 나만의 백룸을 만들어 봅니다. 방 2개를 문으로 연결해 보세요.
결과물: 역할별로 색이 다른 벽·바닥·천장·기둥·내부벽 방 + WASD로 이동 + ESC로 종료
방 3개를 만들어서 문(빈 공간)으로 연결해 보세요.
03미로 자동 생성 — for문의 마법기초+
손으로 벽을 치는 대신, 코드가 알아서 미로를 만들어주게 한다.
12D 리스트 = 지도
1은 벽, 0은 빈 공간. 리스트로 지도를 그립니다.
game_map = [ [1,1,1,1,1,1,1,1], [1,0,0,0,0,0,0,1], [1,0,1,1,0,1,0,1], [1,0,0,1,0,0,0,1], [1,1,0,0,0,1,0,1], [1,0,0,1,0,0,0,1], [1,1,1,1,1,1,1,1], ]
모눈종이에 색칠하듯이, 1을 칠하면 벽이 생기고 0은 비워두는 겁니다.
22중 for문으로 벽 자동 배치
CELL = 4 # 한 칸 크기 (미터) H = 3.2 WALL = color.rgb(194,186,137) FLOOR = color.rgb(160,150,110) walls = [] # 나중에 최적화용으로 저장 for z in range(len(game_map)): for x in range(len(game_map[z])): wx, wz = x*CELL, z*CELL # 바닥 타일 Entity(model='plane', color=FLOOR, position=(wx,0,wz), scale=(CELL,1,CELL), collider='box') # 천장 타일 Entity(model='plane', color=WALL, position=(wx,H,wz), scale=(CELL,1,CELL), rotation_x=180) # 벽이면 if game_map[z][x] == 1: w = Entity(model='cube', color=WALL, position=(wx,H/2,wz), scale=(CELL,H,CELL), collider='box') walls.append(w)
2중 for문 = "가로 한 줄씩, 그 안에서 칸 하나씩" 읽는 구조. 엑셀의 행과 열!
3플레이어 시작 위치 (맵 안에!)
player = FirstPersonController( y=2, origin_y=-.5, position=(1*CELL, 2, 1*CELL) # 열린 칸 (1,1) )
반드시 game_map에서 0인 위치에 스폰. (0,0)은 벽이라 박힘. (1,1)부터 안전.
4맵 데이터 직접 수정 실험
game_map의 1과 0을 바꿔서 넓은 홀, 좁은 복도, L자 통로 등 다양한 구조를 만들어 봅니다.
5DFS 미로 자동 생성
이제 컴퓨터가 알아서 미로를 만들게 합니다!
import random import sys sys.setrecursionlimit(10000) # 큰 미로 대비 SIZE = 13 # 홀수여야 테두리가 깔끔 maze = [[1]*SIZE for _ in range(SIZE)] visited = [[False]*SIZE for _ in range(SIZE)] def carve(x, y): visited[y][x] = True maze[y][x] = 0 dirs = [(0,1),(0,-1),(1,0),(-1,0)] random.shuffle(dirs) for dx, dy in dirs: nx, ny = x+dx*2, y+dy*2 if 0 <= nx < SIZE and 0 <= ny < SIZE: if not visited[ny][nx]: maze[y+dy][x+dx] = 0 carve(nx, ny) random.seed(42) carve(1, 1)
DFS = "한 방향으로 갈 수 있는 데까지 간 다음, 막히면 되돌아와서 다른 길을 뚫는" 방식.
seed(42) 숫자를 바꾸면 매번 다른 미로! 빼면 실행할 때마다 랜덤.
setrecursionlimit 안 늘리면 SIZE 30 이상에서 RecursionError 날 수 있음.
6game_map → maze로 교체
위에 game_map[z][x] 이 두 곳을 maze[z][x]로, len(game_map)SIZE로 바꿉니다.
7SIZE 키워보기 & 정리
SIZE를 17, 21, 25로 키워서 거대한 백룸을 체험합니다. (홀수 유지!)
SIZE 30+ 이면 느려질 수 있어요. 8회차에서 최적화를 배웁니다!
8walls 리스트 확인
print(f"벽 {len(walls)}개 생성됨")
나중에 "멀리 있는 벽 끄기" 최적화에 쓸 리스트. 회차 8에서 활용.
결과물: 실행할 때마다 새로운 미로가 자동 생성되는 백룸
seed()를 빼서 매번 다른 미로 + SIZE를 21로 해서 탐험!
04조명 & 안개 — 공포 분위기 연출기초+
안개로 시야를 가리고, 천장 형광등(자체 발광 큐브)으로 백룸 특유의 "끝없는 복도" 분위기를 만든다.
1Ursina 조명의 현실
먼저 중요한 사실 하나. Ursina의 기본 Entity는 조명에 반응하지 않습니다.
PointLight, AmbientLight를 만들어도 Entity에 shader=lit_with_shadows_shader를 붙이지 않으면 아무 효과가 없어요. 그리고 그림자는 DirectionalLight만 지원됩니다.
그래서 백룸 느낌은 "진짜 빛"이 아니라 안개 + 어두운 색 + 자체 발광 패널의 조합으로 만듭니다. 훨씬 가볍고 더 무서워요.
2배경색 어둡게
window.color = color.rgb(15,13,8) # 창 배경
이게 하늘이 안 보이는 쪽의 "무(無)"의 색. 거의 까맣게.
3안개(Fog) 효과 — 핵심!
scene.fog_color = color.rgb(50,45,25) scene.fog_density = 0.035
안개 = "멀리 있는 물체를 흐릿하게". 백룸 영상에서 복도 끝이 안 보이는 게 이것!
fog_density는 두 형식 지원:
0.035 (float) → 지수형 안개, 값이 클수록 진함
(10, 50) (튜플) → 선형 안개, near/far 거리 지정
4벽·바닥 색 살짝 어둡게
WALL = color.rgb(130,120,70) # 원래 194 → 130 으로 낮춤 FLOOR = color.rgb(95,85,55) # 원래 160 → 95
픽셀 값을 낮추는 게 "조명을 어둡게 한 것"과 비슷한 효과. 가장 간단하고 확실!
5천장 형광등 — unlit 자체 발광
회차 3의 2중 for문 안, 천장 타일 만드는 곳 옆에 형광등을 추가합니다.
for z in range(SIZE): for x in range(SIZE): if maze[z][x] == 0 and random.random() < 0.25: wx, wz = x*CELL, z*CELL Entity(model='cube', color=color.rgb(255,250,200), position=(wx, H-0.05, wz), scale=(0.3, 0.05, 1.5), unlit=True) # ← 이게 핵심!
unlit=True = "이 물체는 어두운 조명 무시하고 항상 밝게". 진짜 빛을 내진 않지만, 안개 너머로 "저 멀리 형광등이 빛나는" 느낌이 확실히 남.
확률 0.25 → 0.1로 바꾸면 형광등이 적어져서 더 어둡고 무서워집니다!
6(옵션) 진짜 조명 쓰고 싶다면
정말 동적 조명을 쓰려면 모든 Entity에 shader를 적용하고 DirectionalLight를 씁니다.
from ursina.shaders import lit_with_shadows_shader # 모든 벽/바닥에 shader 추가해야 빛이 먹힘 Entity(..., shader=lit_with_shadows_shader) # 머리 위 은은한 방향광 sun = DirectionalLight(shadows=True, color=color.rgba(120,110,70,180)) sun.look_at(Vec3(-0.3,-1,-0.3)) AmbientLight(color=color.rgba(40,35,20,255))
shader 쓰면 FPS가 많이 떨어질 수 있어요. 미로가 크면 학생들 컴퓨터에선 버벅일 가능성. 백룸 분위기엔 "안개+unlit 패널" 방식이 더 효과적이고 가벼움.
7FPS 카운터
window.fps_counter.enabled = True
오른쪽 상단에 FPS 숫자가 뜸. 60 이상이면 OK. 30 아래면 최적화 필요.
8안개 밀도 실험
fog_density를 0.02 ~ 0.08 범위에서 바꿔보며 분위기와 FPS를 비교.
0.08이면 바로 앞도 흐릿함 → 진짜 무서움. 0.02면 멀리까지 보임 → 덜 무서움.
9레드룸 변형 & 정리
fog_color와 벽 색을 빨간 계열로 바꿔서 완전히 다른 분위기(Level !)를 만들어봅니다.
scene.fog_color = color.rgb(80,15,15) WALL = color.rgb(130,40,40)
결과물: 안개가 자욱하고 천장 형광등만 빛나는, 정말 으스스한 백룸
빨간 조명 "레드룸"과 파란 조명 "블루룸" 버전을 각각 만들어 보세요.
05사운드 & 깜빡임 — 심리적 공포중급+
효과음과 안개 깜빡임으로 "여기 뭔가 있다"는 불안감을 만든다.
1효과음 파일 준비
freesound.org에서 아래 검색 → .wav 또는 .ogg 다운:
hum.wav — "fluorescent hum" 또는 "ambient drone" step.wav — "footstep carpet" scare.wav — "horror sting" chase.wav — "chase music loop" death.wav — "game over scream" pickup.wav — "paper pickup"
파일은 backrooms.py같은 폴더에 둡니다. 혹은 assets/ 서브폴더도 가능 (경로 맞추기).
게임에서 소리는 공포감의 70%를 차지합니다. 호러 영화도 소리 끄면 안 무서워요!
2배경 소리 재생
hum = Audio('hum', loop=True, autoplay=True, volume=0.3)
Ursina Audio는 확장자 없이 이름만 써도 .wav/.ogg/.mp3를 자동 탐색. Audio('hum.wav')로 명시해도 됨.
3발걸음 소리
step_timer = 0 def update(): global step_timer moving = held_keys['w'] or held_keys['a'] \ or held_keys['s'] or held_keys['d'] if moving: step_timer += time.dt if step_timer > 0.5: Audio('step', volume=0.15, autoplay=True, auto_destroy=True) step_timer = 0 else: step_timer = 0
auto_destroy=True 중요! 안 쓰면 매 발걸음마다 Audio 객체가 쌓여서 메모리 누수.
4안개 깜빡임
# update() 안에 추가 if random.random() < 0.002: scene.fog_density = random.uniform(0.04, 0.08) else: scene.fog_density = lerp(scene.fog_density, 0.035, time.dt*3)
lerp(A, B, 속도) = A에서 B로 "부드럽게" 변하게 하는 함수. 갑자기 확 바뀌면 부자연스러우니까!
0.002 확률 → 평균 500프레임(8초)마다 한 번 깜빡. 숫자 키우면 더 자주.
5특정 구역 점프스케어
scare_played = False # update() 안에서 global scare_played if not scare_played: if distance(player.position, Vec3(20,1,20)) < 3: Audio('scare', volume=0.5, autoplay=True, auto_destroy=True) scare_played = True
distance() 는 Entity나 Vec3를 받음. player.position으로 명시하는 게 안전. scare_played는 반드시 global!
6점프스케어 여러 개 — 리스트로 관리
scare_points = [ [Vec3(20,1,20), False], [Vec3(36,1,8), False], [Vec3(12,1,40), False], ] # update() 안에서 for sp in scare_points: if not sp[1] and distance(player.position, sp[0]) < 3: Audio('scare', volume=0.5, autoplay=True, auto_destroy=True) sp[1] = True # 이 구역은 다 썼음
이렇게 하면 global 안 써도 되고, 구역 추가도 리스트에 한 줄이면 끝!
7통합 & 분위기 조절
깜빡임 확률, 안개, 볼륨, scare 구역 위치 등을 조절하며 최적의 공포감을 찾아봅니다.
결과물: 형광등 소리 + 깜빡임 + 발소리 + 3곳 점프스케어가 있는 백룸
달릴 때(Shift)는 발소리 간격을 0.3으로 줄여서 빠르게 울리게 만들어 보세요.
06VHS 카메라 효과 — Found Footage중급+
화면에 스캔라인·비네팅·REC·타임스탬프를 씌워 "90년대 캠코더" 느낌을 만든다.
1camera.ui란?
parent=camera.ui로 만든 Entity는 항상 화면 위에 고정됩니다. 3D 공간이 아니라 UI 레이어.
스마트폰 카메라 "필터"처럼 실제 풍경 위에 효과만 덧씌우는 거예요.
camera.ui 좌표는 -0.5 ~ +0.5 (가로세로 모두). z값 작을수록(음수) 위에 덮음.
2비네팅 (가장자리 어둡게)
vignette = Entity( parent=camera.ui, model='quad', scale=(2, 1), color=color.rgba(0,0,0, 120), z=-1 )
Ursina의 color.rgba(r,g,b,a)는 값 중 하나라도 1보다 크면 "0~255 스케일"로 자동 인식. 120 = 약 47% 불투명.
3스캔라인 (가로줄)
for i in range(80): Entity( parent=camera.ui, model='quad', scale=(2, 0.002), y=-0.5 + i*0.0125, color=color.rgba(0,0,0, 25), z=-2 )
80개면 조금 느려질 수 있어요. 40개로 줄여도 비슷한 효과.
4타임스탬프 & REC
timestamp = Text( text='JUL 04 1991 PM 09:42', position=(0.3, -0.45), scale=0.8, color=color.rgba(255,255,255, 180) ) rec = Text( text='● REC', position=(-0.82, 0.45), scale=1, color=color.red )
Text는 기본적으로 camera.ui에 붙음. parent 생략 가능.
5REC 깜빡이기
# update()에 추가 rec.enabled = int(time.time()*2) % 2 == 0
Entity에 visible도 있지만 enabled가 더 확실함 (렌더 + 로직 둘 다 끔).
6화면 떨림 & 노이즈 바
# 노이즈 바 (밝은 가로줄이 위아래로 이동) noise_bar = Entity( parent=camera.ui, model='quad', scale=(2, 0.01), color=color.rgba(255,255,255, 20), z=-3 ) # update() 안에서 noise_bar.y += time.dt * 0.3 if noise_bar.y > 0.6: noise_bar.y = -0.6 # 카메라 살짝 떨림 (드물게) if random.random() < 0.005: camera.shake(duration=0.1, magnitude=0.5)
camera.shake()가 Ursina 내장 함수. 직접 camera.y += 하면 1인칭 카메라와 충돌함.
7F키 토글 & 자유 실험
vhs_on = True def input(key): global vhs_on if key == 'f': vhs_on = not vhs_on vignette.enabled = vhs_on timestamp.enabled = vhs_on rec.enabled = vhs_on noise_bar.enabled = vhs_on
8정리 & 전체 통합 테스트
1~6회차 코드를 하나로 합쳐서 미로+조명+사운드+VHS가 모두 동작하는지 확인합니다.
결과물: VHS 캠코더 느낌 화면 + 스캔라인 + REC + 노이즈
타임스탬프의 시간이 실시간으로 바뀌게 만들어 보세요. (힌트: time.strftime())
07괴생명체 만들기 & 추적 AI중급+
괴물 클래스를 만들고, 플레이어를 감지하면 쫓아오는 AI를 구현한다.
1클래스(class) 복습
class = "설계도". Monster 설계도를 만들면 괴물을 몇 마리든 찍어낼 수 있어요.
2Monster 클래스 — Entity 상속
class Monster(Entity): def __init__(self, **kwargs): super().__init__( model='cube', scale=(0.4, 2.8, 0.4), color=color.rgb(20,18,15), collider='box', **kwargs ) # 머리 (자식 Entity) self.head = Entity( parent=self, model='sphere', scale=0.6, y=0.6, color=color.rgb(15,13,10) ) self.speed = 2 self.chasing = False monster = Monster(position=(20, 1.4, 20))
scale y=2.8, position y=1.4 → 바닥에 딱 서 있음 (중심이 바닥에서 1.4m 위).
3거리 감지 & 추격
Entity의 update(self) 메서드는 자동으로 매 프레임 실행됩니다.
# Monster 클래스 안에 추가 def update(self): dist = distance(self.position, player.position) if dist < 15: # 플레이어 쪽 바라보기 (y축만 회전) self.look_at(player) self.rotation_x = 0 self.rotation_z = 0 # 방향 구해서 이동 d = (player.position - self.position).normalized() self.position += d * self.speed * time.dt self.chasing = True else: self.chasing = False
normalized() = 방향만 남기고 거리를 1로 만드는 함수. "어느 쪽으로?"만!
self.look_at(player) 후 rotation_x, rotation_z를 0으로 → 앞으로 안 기울어짐. 안 하면 바닥에 누움.
4시야 차단 — raycast()
벽 뒤에 있으면 못 봐야죠. raycast로 시선에 벽이 있는지 확인합니다.
def update(self): dist = distance(self.position, player.position) if dist < 15: d = (player.position - self.position).normalized() ray = raycast( self.position, d, distance=15, ignore=[self, self.head] ) if ray.hit and ray.entity == player: self.look_at(player) self.rotation_x = 0 self.rotation_z = 0 self.position += d * self.speed * time.dt self.chasing = True return self.chasing = False
raycast = "레이저 포인터를 쏴서 뭐에 맞는지 확인". 벽에 먼저 맞으면 못 본 거!
ignore=[self, self.head] — 자기 자신(몸통 + 머리) 무시. 안 쓰면 "내가 나한테 맞음" 에러 상태.
5잡히면 게임 오버
# update() 끝부분에 if dist < 1.5: player.enabled = False mouse.locked = False Text( text='YOU DIED', scale=5, origin=(0,0), color=color.red ) Audio('death', autoplay=True, auto_destroy=True)
6추적 BGM — 거리에 따라 볼륨
# 파일 상단에 전역으로 chase_music = Audio('chase', loop=True, autoplay=False, volume=0) # Monster.update() 안에서, chasing 상태에 따라 if self.chasing: chase_music.volume = lerp(chase_music.volume, 0.5, time.dt*2) if not chase_music.playing: chase_music.play() else: chase_music.volume = lerp(chase_music.volume, 0, time.dt)
괴물 여러 마리면 각자 volume을 바꿔서 충돌. chase_music을 클래스 밖에 하나만 두고 가장 가까운 괴물 기준으로 관리하는 게 낫지만, 일단 한 마리 기준으론 OK.
7괴물 2마리 배치
m1 = Monster(position=(20, 1.4, 20)) m2 = Monster(position=(36, 1.4, 8)) m2.speed = 3 # 두 번째는 더 빠르게!
꼭 미로의 열린 칸 (maze[z][x]==0) 에 스폰해야 함. 벽 안에 스폰하면 못 움직임.
8난이도 조절 & 테스트
speed(2→4), 감지거리(15→25) 등을 바꿔보며 적절한 난이도를 찾습니다.
결과물: 시야에 들어오면 쫓아오는 괴물 + 추적 BGM + 게임 오버
시간이 지날수록 괴물 speed가 증가하게 만들어 보세요.
08게임 완성 & .exe 빌드심화+
스태미나·메모 수집·탈출 엔딩을 넣어 게임을 완성하고, .exe로 빌드하여 배포한다.
1스태미나(체력) 시스템
stamina = 100 bar = Entity( parent=camera.ui, model='quad', scale=(0.3, 0.02), position=(-0.6, -0.45), color=color.green, origin=(-0.5, 0) # 왼쪽 끝 기준으로 스케일 ) # update()에서 global stamina if held_keys['shift'] and stamina > 0: player.speed = 8 stamina -= time.dt * 20 else: player.speed = 5 # FirstPersonController 기본값 stamina = min(100, stamina + time.dt*10) bar.scale_x = 0.3 * stamina / 100 bar.color = color.green if stamina > 30 else color.red
2메모 아이템 배치 & 줍기
found = 0 total = 5 note_ui = Text( text='메모: 0/5', position=(-0.85, 0.45) ) notes = [] for pos in [(8,1,12),(20,1,4),(32,1,24), (12,1,36),(28,1,16)]: n = Entity( model='quad', scale=0.4, color=color.white, position=pos, billboard=True # 항상 카메라를 바라봄 ) notes.append(n) # update()에서 global found for n in notes: if n.enabled and distance(player.position, n.position) < 2: n.enabled = False found += 1 note_ui.text = f'메모: {found}/{total}' Audio('pickup', autoplay=True, auto_destroy=True)
billboard=True는 Entity 기본 파라미터. 쿼드가 항상 카메라 쪽을 향함.
3탈출 엔딩
escaped = False # update() 안에서 global escaped if found >= total and not escaped: escaped = True Text(text='ESCAPED!', scale=5, origin=(0,0), color=color.green) player.enabled = False mouse.locked = False
4거리 기반 최적화
# update()에서, walls는 3회차에서 만든 리스트 for w in walls: w.enabled = distance(player.position, w.position) < 30
30m 밖의 벽은 어차피 안개에 가려 안 보여요. 안 그리면 FPS 올라감!
매 프레임 모든 벽 체크하면 그것 자체가 느릴 수 있음. 0.5초마다 한 번 체크하는 걸로 바꾸면 더 빠름.
5코드 파일 분리 (선택)
backrooms/ ├── main.py # 메인 + Ursina() + app.run() ├── maze.py # 미로 생성 함수 ├── monster.py # Monster 클래스 ├── effects.py # VHS, 사운드 ├── hum.wav ├── step.wav ├── chase.wav ├── scare.wav ├── death.wav └── pickup.wav
사운드 파일은 .py 파일들과 같은 폴더에. assets/ 서브폴더 쓰면 경로 수정 필요.
6.exe 빌드
pip install pyinstaller pyinstaller --onefile --noconsole main.py
dist/main.exe 옆에 .wav 파일들을 반드시 복사하세요! 또는 --add-data 옵션 사용.
# Windows (세미콜론), Mac/Linux (콜론) pyinstaller --onefile --noconsole ^ --add-data "hum.wav;." ^ --add-data "step.wav;." ^ main.py
Ursina 내장 모델/텍스처도 포함해야 할 수도 있음. 빌드 후 안 되면 --collect-all ursina 추가.
7itch.io 업로드
✅ itch.io 계정 생성 ✅ New Project → Downloadable ✅ dist 폴더를 .zip으로 압축해서 업로드 ✅ 스크린샷 3장 + 설명 ✅ Publish → 세상에 공개! 🎉
88주 회고 & 다음 스텝
🎓 8주간 배운 것: Entity, for문, class, update(), raycast, 안개, Audio, AI 추격, 빌드
🚀 다음 도전: Blender로 커스텀 괴물 모델, 멀티 레벨, 온라인 리더보드, lit_with_shadows_shader로 진짜 동적 조명
결과물: 스태미나 + 메모수집 + 괴물 + VHS + 탈출엔딩 — 완전한 백룸 게임 .exe 🎉