pip install ursinapython -m pip install ursinafrom ursina import *
print("우루사 설치 완료! 🐻")from ursina import *
app = Ursina()
# 아직 아무것도 없는 빈 세상
app.run()app.run() 위에 한 줄 추가:from ursina import *
app = Ursina()
# 🆕 큐브 하나 + 카메라 위치
cube = Entity(model='cube', color=color.orange)
EditorCamera() # 마우스로 화면 회전 가능
app.run()cube = Entity(
model='cube',
color=color.red, # orange, blue, green, yellow...
scale=(2, 1, 3), # (가로, 세로, 깊이)
position=(0, 1, 0), # (x좌우, y상하, z앞뒤)
)app.run() 바로 위에 함수를 추가:# 매 프레임마다 자동 실행되는 함수
def update():
cube.rotation_y += time.dt * 50
app.run()time.dt * 50 더 자세히 (궁금한 사람만)만약 time.dt 없이 cube.rotation_y += 1 만 쓰면?
컴퓨터마다 FPS(초당 프레임)가 달라요. 좋은 PC는 1초에 144번 update() 실행, 느린 노트북은 30번.
해결책: time.dt
time.dt = "이전 프레임에서 지금까지 걸린 시간 (초)"
cube.rotation_y += time.dt * 50 을 계산해보면:
→ 어떤 컴퓨터에서든 1초에 정확히 50도 회전!
즉 time.dt * 숫자 에서 숫자는 "초당 몇 도 / 몇 미터 움직일지". 50이면 초속 50도, 100이면 초속 100도.
공식처럼 외울 것:
• 움직임·속도·회전 관련 = 무조건 time.dt 곱하기
• 한 번만 일어나는 일(점프 트리거, 클릭 등) = 안 곱함
Entity(model='sphere', color=color.blue, x=3) # 구
Entity(model='plane', color=color.green, y=-1) # 평면(바닥용, 위쪽만 보임)
Entity(model='quad', color=color.red, x=-3) # 사각 판# 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) # 카메라 앞에서 보면 노란 사각형
# 뒤로 돌아가면(마우스 우클릭 드래그) 투명!double_sided=True 추가하면 양면 다 보임.# 양면 다 보이게
Entity(model='plane', color=color.red,
x=2, y=1, rotation_x=180,
double_sided=True) # ← 이제 뒤집혀도 보임for i in range(5):
Entity(model='cube', color=color.random_color(),
x=i*2, y=0.5)color.random_color(). 원본에 있던 color.random()은 구버전 API.from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
app = Ursina()
# 여기에 방을 만들 겁니다
app.run()ursina.prefabs.first_person_controller 모듈에서 임포트. 경로 한 글자라도 틀리면 안 됨.WALL = color.orange # 외벽
FLOOR = color.blue # 바닥
CEILING = color.yellow # 천장
PILLAR = color.black # 기둥
INNER = color.pink # 내부 통로 벽
H = 3.2 # 벽 높이ground = Entity(
model='plane',
scale=(20, 1, 20), # (x, y, z) — plane이라도 이렇게 명시 권장
color=FLOOR,
collider='box',
texture='white_cube',
texture_scale=(20, 20)
)scale=20 단일값도 되지만 (20,1,20)이 명시적이고 collider 박스 크기도 예측 가능.# 앞벽 (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')Entity(model='plane', scale=(20,1,20), color=CEILING,
y=H, rotation_x=180)player = FirstPersonController(
y=2, # 공중에서 시작 (바닥에 안 박히게)
origin_y=-.5, # 발 기준 원점
position=(0, 2, 0) # 방 한가운데
)FirstPersonController()만 쓰면 플레이어가 바닥이랑 겹쳐서 끝없이 떨어짐. y=2, origin_y=-.5는 공식 예제 표준 패턴.origin_y=-.5 와 y=2 가 왜 필요한지 (궁금한 사람만)origin(원점)이 뭐냐?
Entity의 origin은 위치 기준점이에요. origin=(x, y, z) 튜플인데 보통 y축 하나만 바꾸니까 origin_y 축약형을 씁니다 (둘 다 똑같이 작동).
큐브 하나로 예를 들면:
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층 바닥에 안전 착지".
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')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')import sys
def input(key):
if key == 'escape':
sys.exit()application.quit()가 환경에 따라 안 먹는 경우가 있어요. sys.exit()는 파이썬 내장이라 100% 작동.input() 함수를 덮어쓰면 실제 프로그램 종료!quit() — Python 내장, 됨application.quit() — Ursina 제공, 환경 따라 됨/안됨app.userExit() — Panda3D 방식, 됨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],
]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)player = FirstPersonController(
y=2, origin_y=-.5,
position=(1*CELL, 2, 1*CELL) # 열린 칸 (1,1)
)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)game_map[z][x] 이 두 곳을 maze[z][x]로, len(game_map)은 SIZE로 바꿉니다.print(f"벽 {len(walls)}개 생성됨")shader=lit_with_shadows_shader를 붙이지 않으면 아무 효과가 없어요. 그리고 그림자는 DirectionalLight만 지원됩니다.window.color = color.rgb(15,13,8) # 창 배경scene.fog_color = color.rgb(50,45,25)
scene.fog_density = 0.0350.035 (float) → 지수형 안개, 값이 클수록 진함(10, 50) (튜플) → 선형 안개, near/far 거리 지정WALL = color.rgb(130,120,70) # 원래 194 → 130 으로 낮춤
FLOOR = color.rgb(95,85,55) # 원래 160 → 95for 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 = "이 물체는 어두운 조명 무시하고 항상 밝게". 진짜 빛을 내진 않지만, 안개 너머로 "저 멀리 형광등이 빛나는" 느낌이 확실히 남.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))window.fps_counter.enabled = Truescene.fog_color = color.rgb(80,15,15)
WALL = color.rgb(130,40,40)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/ 서브폴더도 가능 (경로 맞추기).hum = Audio('hum', loop=True, autoplay=True, volume=0.3)Audio('hum.wav')로 명시해도 됨.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 = 0auto_destroy=True 중요! 안 쓰면 매 발걸음마다 Audio 객체가 쌓여서 메모리 누수.# 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)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 = Trueplayer.position으로 명시하는 게 안전. scare_played는 반드시 global!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 # 이 구역은 다 썼음parent=camera.ui로 만든 Entity는 항상 화면 위에 고정됩니다. 3D 공간이 아니라 UI 레이어.vignette = Entity(
parent=camera.ui, model='quad',
scale=(2, 1),
color=color.rgba(0,0,0, 120),
z=-1
)color.rgba(r,g,b,a)는 값 중 하나라도 1보다 크면 "0~255 스케일"로 자동 인식. 120 = 약 47% 불투명.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
)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
)# update()에 추가
rec.enabled = int(time.time()*2) % 2 == 0visible도 있지만 enabled가 더 확실함 (렌더 + 로직 둘 다 끔).# 노이즈 바 (밝은 가로줄이 위아래로 이동)
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인칭 카메라와 충돌함.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_ontime.strftime())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))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 = Falselook_at(player) 후 rotation_x, rotation_z를 0으로 → 앞으로 안 기울어짐. 안 하면 바닥에 누움.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 = Falseignore=[self, self.head] — 자기 자신(몸통 + 머리) 무시. 안 쓰면 "내가 나한테 맞음" 에러 상태.# 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)# 파일 상단에 전역으로
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)m1 = Monster(position=(20, 1.4, 20))
m2 = Monster(position=(36, 1.4, 8))
m2.speed = 3 # 두 번째는 더 빠르게!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.redfound = 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 기본 파라미터. 쿼드가 항상 카메라 쪽을 향함.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# update()에서, walls는 3회차에서 만든 리스트
for w in walls:
w.enabled = distance(player.position, w.position) < 30backrooms/
├── main.py # 메인 + Ursina() + app.run()
├── maze.py # 미로 생성 함수
├── monster.py # Monster 클래스
├── effects.py # VHS, 사운드
├── hum.wav
├── step.wav
├── chase.wav
├── scare.wav
├── death.wav
└── pickup.wavpip install pyinstaller
pyinstaller --onefile --noconsole main.py--add-data 옵션 사용.# Windows (세미콜론), Mac/Linux (콜론)
pyinstaller --onefile --noconsole ^
--add-data "hum.wav;." ^
--add-data "step.wav;." ^
main.py--collect-all ursina 추가.✅ itch.io 계정 생성
✅ New Project → Downloadable
✅ dist 폴더를 .zip으로 압축해서 업로드
✅ 스크린샷 3장 + 설명
✅ Publish → 세상에 공개! 🎉