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 *
app = Ursina()
# 바닥 (확인용)
ground = Entity(model='plane', scale=(20,1,20),
color=color.dark_gray, y=-1)
# 회전하는 큐브 3개 (각자 다른 색·위치·크기)
cube1 = Entity(model='cube', color=color.orange,
position=(-3, 0.5, 0), scale=1)
cube2 = Entity(model='cube', color=color.azure,
position=(0, 1, 0), scale=(1.5, 2, 1))
cube3 = Entity(model='sphere', color=color.lime,
position=(3, 0.5, 0), scale=0.8)
# 자유 시점 카메라 (마우스 우클릭 드래그로 회전, 휠로 확대)
EditorCamera()
def update():
# 각자 다른 속도·축으로 회전
cube1.rotation_y += time.dt * 50
cube2.rotation_x += time.dt * 80
cube3.rotation_z += time.dt * 120
app.run()
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 방식, 됨from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import sys
app = Ursina()
# === 학습용 색상 팔레트 (역할별로 확 다르게!) ===
WALL = color.orange # 외벽
FLOOR = color.blue # 바닥
CEILING = color.yellow # 천장
PILLAR = color.black # 기둥
INNER = color.pink # 내부 통로 벽
H = 3.2 # 벽 높이
# === 바닥 ===
Entity(model='plane', scale=(20,1,20), color=FLOOR,
collider='box', texture='white_cube', texture_scale=(20,20))
# === 외벽 4개 ===
Entity(model='cube', scale=(20,H,0.2), color=WALL,
position=(0, H/2, 10), collider='box') # 앞벽
Entity(model='cube', scale=(20,H,0.2), color=WALL,
position=(0, H/2, -10), collider='box') # 뒷벽
Entity(model='cube', scale=(0.2,H,20), color=WALL,
position=(-10, H/2, 0), collider='box') # 왼쪽
Entity(model='cube', scale=(0.2,H,20), color=WALL,
position=(10, H/2, 0), collider='box') # 오른쪽
# === 천장 (rotation_x=180 으로 뒤집어서 아래쪽이 앞면) ===
Entity(model='plane', scale=(20,1,20), color=CEILING,
y=H, rotation_x=180)
# === 기둥 5개 (일렬) ===
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')
# === 1인칭 컨트롤러 ===
player = FirstPersonController(
y=2, origin_y=-.5,
position=(0, 2, 0)
)
# === ESC 종료 ===
def input(key):
if key == 'escape':
sys.exit()
app.run()
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)}개 생성됨")from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import random, sys
sys.setrecursionlimit(10000)
app = Ursina()
# ========== DFS 미로 자동 생성 ==========
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)
# ========== 미로 그리기 ==========
CELL = 4
H = 3.2
WALL = color.rgb(194,186,137)
FLOOR = color.rgb(160,150,110)
walls = [] # 8회차 최적화용
for z in range(SIZE):
for x in range(SIZE):
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 maze[z][x] == 1:
w = Entity(model='cube', color=WALL,
position=(wx, H/2, wz),
scale=(CELL, H, CELL), collider='box')
walls.append(w)
print(f"벽 {len(walls)}개 생성됨")
# ========== 플레이어 (열린 칸에 스폰) ==========
player = FirstPersonController(
y=2, origin_y=-.5,
position=(1*CELL, 2, 1*CELL)
)
def input(key):
if key == 'escape':
sys.exit()
app.run()
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)from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import random, sys
sys.setrecursionlimit(10000)
app = Ursina()
# ========== 분위기 ==========
window.color = color.rgb(15,13,8) # 창 배경 거의 검정
window.fps_counter.enabled = True
scene.fog_color = color.rgb(50,45,25) # 백룸 누런 안개
scene.fog_density = 0.035 # 진할수록 시야 짧음
# ========== DFS 미로 ==========
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 and not visited[ny][nx]:
maze[y+dy][x+dx] = 0
carve(nx, ny)
random.seed(42)
carve(1, 1)
# ========== 미로 + 형광등 ==========
CELL = 4
H = 3.2
WALL = color.rgb(130,120,70) # 조명 어두운 느낌으로 RGB 낮춤
FLOOR = color.rgb(95,85,55)
walls = []
for z in range(SIZE):
for x in range(SIZE):
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 maze[z][x] == 1:
w = Entity(model='cube', color=WALL,
position=(wx, H/2, wz),
scale=(CELL, H, CELL), collider='box')
walls.append(w)
# 빈 칸에 25% 확률로 천장 형광등 (unlit = 자체 발광)
elif random.random() < 0.25:
Entity(model='cube',
color=color.rgb(255,250,200),
position=(wx, H-0.05, wz),
scale=(0.3, 0.05, 1.5),
unlit=True)
# ========== 플레이어 ==========
player = FirstPersonController(
y=2, origin_y=-.5,
position=(1*CELL, 2, 1*CELL)
)
def input(key):
if key == 'escape':
sys.exit()
app.run()
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 # 이 구역은 다 썼음# ============================================================
# 필요 파일 (backrooms.py 와 같은 폴더):
# hum.wav, step.wav, scare.wav
# freesound.org 등에서 받아서 이름만 맞춰주세요.
# 파일이 없으면 Audio() 줄을 주석 처리하면 나머지는 정상 동작합니다.
# ============================================================
from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import random, sys
sys.setrecursionlimit(10000)
app = Ursina()
# ========== 분위기 ==========
window.color = color.rgb(15,13,8)
window.fps_counter.enabled = True
scene.fog_color = color.rgb(50,45,25)
scene.fog_density = 0.035
BASE_FOG = 0.035
# ========== 미로 ==========
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 and not visited[ny][nx]:
maze[y+dy][x+dx] = 0
carve(nx, ny)
random.seed(42)
carve(1, 1)
CELL = 4
H = 3.2
WALL = color.rgb(130,120,70)
FLOOR = color.rgb(95,85,55)
walls = []
for z in range(SIZE):
for x in range(SIZE):
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 maze[z][x] == 1:
w = Entity(model='cube', color=WALL,
position=(wx, H/2, wz),
scale=(CELL, H, CELL), collider='box')
walls.append(w)
elif random.random() < 0.25:
Entity(model='cube', color=color.rgb(255,250,200),
position=(wx, H-0.05, wz),
scale=(0.3, 0.05, 1.5), unlit=True)
player = FirstPersonController(
y=2, origin_y=-.5,
position=(1*CELL, 2, 1*CELL)
)
# ========== 사운드 ==========
hum = Audio('hum', loop=True, autoplay=True, volume=0.3)
# ========== 점프스케어 구역 (위치, 발동여부) ==========
scare_points = [
[Vec3(5*CELL, 1, 5*CELL), False],
[Vec3(9*CELL, 1, 3*CELL), False],
[Vec3(3*CELL, 1, 9*CELL), False],
]
# ========== 발걸음 타이머 ==========
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
interval = 0.3 if held_keys['shift'] else 0.5
if step_timer > interval:
Audio('step', volume=0.15, autoplay=True, auto_destroy=True)
step_timer = 0
else:
step_timer = 0
# --- 안개 깜빡임 ---
if random.random() < 0.002:
scene.fog_density = random.uniform(0.04, 0.08)
else:
scene.fog_density = lerp(scene.fog_density, BASE_FOG, time.dt*3)
# --- 점프스케어 구역 ---
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 # 한 번만
def input(key):
if key == 'escape':
sys.exit()
app.run()
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_on# ============================================================
# 필요 파일: hum.wav, step.wav, scare.wav
# ============================================================
from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import random, sys, time as pytime
sys.setrecursionlimit(10000)
app = Ursina()
window.color = color.rgb(15,13,8)
window.fps_counter.enabled = True
scene.fog_color = color.rgb(50,45,25)
scene.fog_density = 0.035
BASE_FOG = 0.035
# ========== 미로 ==========
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 and not visited[ny][nx]:
maze[y+dy][x+dx] = 0
carve(nx, ny)
random.seed(42)
carve(1, 1)
CELL = 4
H = 3.2
WALL = color.rgb(130,120,70)
FLOOR = color.rgb(95,85,55)
walls = []
for z in range(SIZE):
for x in range(SIZE):
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 maze[z][x] == 1:
w = Entity(model='cube', color=WALL,
position=(wx, H/2, wz),
scale=(CELL, H, CELL), collider='box')
walls.append(w)
elif random.random() < 0.25:
Entity(model='cube', color=color.rgb(255,250,200),
position=(wx, H-0.05, wz),
scale=(0.3, 0.05, 1.5), unlit=True)
player = FirstPersonController(
y=2, origin_y=-.5,
position=(1*CELL, 2, 1*CELL)
)
# ========== 사운드 ==========
hum = Audio('hum', loop=True, autoplay=True, volume=0.3)
# ========== VHS 효과 ==========
vhs_on = True
# 비네팅 (가장자리 어둡게)
vignette = Entity(parent=camera.ui, model='quad',
scale=(2, 1),
color=color.rgba(0,0,0, 120),
z=-1)
# 스캔라인 40개
for i in range(40):
Entity(parent=camera.ui, model='quad',
scale=(2, 0.002),
y=-0.5 + i*0.025,
color=color.rgba(0,0,0, 25),
z=-2)
# 노이즈 바 (밝은 가로줄이 위아래로 이동)
noise_bar = Entity(parent=camera.ui, model='quad',
scale=(2, 0.01),
color=color.rgba(255,255,255, 20),
z=-3)
# 타임스탬프 & REC
timestamp = Text(text='', position=(0.25, -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)
# ========== 점프스케어 ==========
scare_points = [
[Vec3(5*CELL, 1, 5*CELL), False],
[Vec3(9*CELL, 1, 3*CELL), False],
[Vec3(3*CELL, 1, 9*CELL), False],
]
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
interval = 0.3 if held_keys['shift'] else 0.5
if step_timer > interval:
Audio('step', volume=0.15, autoplay=True, auto_destroy=True)
step_timer = 0
else:
step_timer = 0
# --- 안개 깜빡임 ---
if random.random() < 0.002:
scene.fog_density = random.uniform(0.04, 0.08)
else:
scene.fog_density = lerp(scene.fog_density, BASE_FOG, time.dt*3)
# --- 점프스케어 ---
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
# --- VHS REC 깜빡임 ---
rec.enabled = vhs_on and (int(pytime.time()*2) % 2 == 0)
# --- 노이즈 바 이동 ---
noise_bar.y += time.dt * 0.3
if noise_bar.y > 0.6:
noise_bar.y = -0.6
# --- 실시간 타임스탬프 ---
timestamp.text = pytime.strftime('JUL 04 1991 %H:%M:%S')
# --- 가끔 화면 떨림 ---
if random.random() < 0.005:
camera.shake(duration=0.1, magnitude=0.5)
def input(key):
if key == 'escape':
sys.exit()
if key == 'f':
# VHS 토글
global vhs_on
vhs_on = not vhs_on
vignette.enabled = vhs_on
timestamp.enabled = vhs_on
rec.enabled = vhs_on
noise_bar.enabled = vhs_on
app.run()
time.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 # 두 번째는 더 빠르게!# ============================================================
# 필요 파일: hum.wav, step.wav, scare.wav, chase.wav, death.wav
# ============================================================
from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import random, sys, time as pytime
sys.setrecursionlimit(10000)
app = Ursina()
window.color = color.rgb(15,13,8)
window.fps_counter.enabled = True
scene.fog_color = color.rgb(50,45,25)
scene.fog_density = 0.035
BASE_FOG = 0.035
# ========== 미로 ==========
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 and not visited[ny][nx]:
maze[y+dy][x+dx] = 0
carve(nx, ny)
random.seed(42)
carve(1, 1)
CELL = 4
H = 3.2
WALL = color.rgb(130,120,70)
FLOOR = color.rgb(95,85,55)
walls = []
for z in range(SIZE):
for x in range(SIZE):
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 maze[z][x] == 1:
w = Entity(model='cube', color=WALL,
position=(wx, H/2, wz),
scale=(CELL, H, CELL), collider='box')
walls.append(w)
elif random.random() < 0.25:
Entity(model='cube', color=color.rgb(255,250,200),
position=(wx, H-0.05, wz),
scale=(0.3, 0.05, 1.5), unlit=True)
player = FirstPersonController(
y=2, origin_y=-.5,
position=(1*CELL, 2, 1*CELL)
)
# ========== 사운드 ==========
hum = Audio('hum', loop=True, autoplay=True, volume=0.3)
chase_music = Audio('chase', loop=True, autoplay=False, volume=0)
# ========== 괴물 클래스 ==========
game_over = False
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
)
# 머리
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
def update(self):
if game_over:
return
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
# 잡으면 게임오버
if dist < 1.5:
trigger_game_over()
return
self.chasing = False
def trigger_game_over():
global game_over
if game_over:
return
game_over = True
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)
# 미로의 열린 칸 좌표 모으기
open_cells = [(x,z) for z in range(SIZE) for x in range(SIZE)
if maze[z][x] == 0 and (x,z) != (1,1)]
# 괴물 2마리 (플레이어와 충분히 떨어진 곳에)
random.shuffle(open_cells)
far_cells = [(x,z) for (x,z) in open_cells
if abs(x-1) + abs(z-1) > 6]
spawns = far_cells[:2] if len(far_cells) >= 2 else open_cells[:2]
m1 = Monster(position=(spawns[0][0]*CELL, 1.4, spawns[0][1]*CELL))
m2 = Monster(position=(spawns[1][0]*CELL, 1.4, spawns[1][1]*CELL))
m2.speed = 3
monsters = [m1, m2]
# ========== 점프스케어 ==========
scare_points = [
[Vec3(5*CELL, 1, 5*CELL), False],
[Vec3(9*CELL, 1, 3*CELL), False],
]
step_timer = 0
def update():
global step_timer
if game_over:
return
# --- 발걸음 ---
moving = held_keys['w'] or held_keys['a'] \
or held_keys['s'] or held_keys['d']
if moving:
step_timer += time.dt
interval = 0.3 if held_keys['shift'] else 0.5
if step_timer > interval:
Audio('step', volume=0.15, autoplay=True, auto_destroy=True)
step_timer = 0
else:
step_timer = 0
# --- 안개 깜빡임 ---
if random.random() < 0.002:
scene.fog_density = random.uniform(0.04, 0.08)
else:
scene.fog_density = lerp(scene.fog_density, BASE_FOG, time.dt*3)
# --- 점프스케어 ---
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
# --- 추격 BGM (가장 가까운 괴물 기준) ---
any_chasing = any(m.chasing for m in monsters)
if any_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)
def input(key):
if key == 'escape':
sys.exit()
app.run()
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 → 세상에 공개! 🎉# ============================================================
# 완전체. 필요 파일:
# hum.wav, step.wav, scare.wav, chase.wav, death.wav, pickup.wav
# 모두 backrooms.py 와 같은 폴더에 두세요.
# ============================================================
from ursina import *
from ursina.prefabs.first_person_controller import FirstPersonController
import random, sys, time as pytime
sys.setrecursionlimit(10000)
app = Ursina()
# ============================================================
# 1. 분위기
# ============================================================
window.color = color.rgb(15,13,8)
window.fps_counter.enabled = True
scene.fog_color = color.rgb(50,45,25)
scene.fog_density = 0.035
BASE_FOG = 0.035
# ============================================================
# 2. DFS 미로
# ============================================================
SIZE = 17 # 17~21 권장
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 and not visited[ny][nx]:
maze[y+dy][x+dx] = 0
carve(nx, ny)
carve(1, 1) # seed 없음 → 매번 다른 미로
# ============================================================
# 3. 미로 + 형광등 배치
# ============================================================
CELL = 4
H = 3.2
WALL = color.rgb(130,120,70)
FLOOR = color.rgb(95,85,55)
walls = []
for z in range(SIZE):
for x in range(SIZE):
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 maze[z][x] == 1:
w = Entity(model='cube', color=WALL,
position=(wx, H/2, wz),
scale=(CELL, H, CELL), collider='box')
walls.append(w)
elif random.random() < 0.25:
Entity(model='cube', color=color.rgb(255,250,200),
position=(wx, H-0.05, wz),
scale=(0.3, 0.05, 1.5), unlit=True)
# 열린 칸 좌표 모음
open_cells = [(x,z) for z in range(SIZE) for x in range(SIZE)
if maze[z][x] == 0 and (x,z) != (1,1)]
# ============================================================
# 4. 플레이어
# ============================================================
player = FirstPersonController(
y=2, origin_y=-.5,
position=(1*CELL, 2, 1*CELL)
)
# ============================================================
# 5. 사운드
# ============================================================
hum = Audio('hum', loop=True, autoplay=True, volume=0.3)
chase_music = Audio('chase', loop=True, autoplay=False, volume=0)
# ============================================================
# 6. VHS UI
# ============================================================
vhs_on = True
vignette = Entity(parent=camera.ui, model='quad',
scale=(2, 1),
color=color.rgba(0,0,0, 120), z=-1)
for i in range(40):
Entity(parent=camera.ui, model='quad',
scale=(2, 0.002),
y=-0.5 + i*0.025,
color=color.rgba(0,0,0, 25), z=-2)
noise_bar = Entity(parent=camera.ui, model='quad',
scale=(2, 0.01),
color=color.rgba(255,255,255, 20), z=-3)
timestamp = Text(text='', position=(0.25, -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)
# ============================================================
# 7. 스태미나 바
# ============================================================
stamina = 100
stamina_bar = Entity(parent=camera.ui, model='quad',
scale=(0.3, 0.02),
position=(-0.6, -0.45),
color=color.green,
origin=(-0.5, 0))
# ============================================================
# 8. 메모 수집
# ============================================================
TOTAL_NOTES = 5
found = 0
note_ui = Text(text=f'메모: 0/{TOTAL_NOTES}',
position=(-0.85, 0.45),
color=color.rgb(255,240,150))
notes = []
# 플레이어(1,1)에서 멀리 떨어진 열린 칸 5개에 배치
note_candidates = [(x,z) for (x,z) in open_cells
if abs(x-1) + abs(z-1) > 4]
random.shuffle(note_candidates)
for (nx, nz) in note_candidates[:TOTAL_NOTES]:
n = Entity(model='quad', scale=0.6,
color=color.rgb(255,250,200),
position=(nx*CELL, 1.2, nz*CELL),
billboard=True, unlit=True)
notes.append(n)
# ============================================================
# 9. 괴물 AI
# ============================================================
game_over = False
escaped = False
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
)
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
def update(self):
if game_over or escaped:
return
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
if dist < 1.5:
trigger_game_over()
return
self.chasing = False
def trigger_game_over():
global game_over
if game_over or escaped:
return
game_over = True
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)
def trigger_escape():
global escaped
if escaped or game_over:
return
escaped = True
player.enabled = False
mouse.locked = False
Text(text='ESCAPED!', scale=5, origin=(0,0), color=color.green)
# 괴물 2마리, 플레이어와 멀리
far_cells = [(x,z) for (x,z) in open_cells
if abs(x-1) + abs(z-1) > 6]
random.shuffle(far_cells)
spawns = far_cells[:2] if len(far_cells) >= 2 else open_cells[:2]
monsters = [
Monster(position=(spawns[0][0]*CELL, 1.4, spawns[0][1]*CELL)),
Monster(position=(spawns[1][0]*CELL, 1.4, spawns[1][1]*CELL))
]
monsters[1].speed = 3
# ============================================================
# 10. 점프스케어
# ============================================================
scare_points = [[Vec3(c[0]*CELL,1,c[1]*CELL), False]
for c in random.sample(open_cells, 3)]
# ============================================================
# 11. 메인 update
# ============================================================
step_timer = 0
wall_check_timer = 0
def update():
global step_timer, stamina, found, wall_check_timer
if game_over or escaped:
return
# --- 스태미나 + 달리기 ---
if held_keys['shift'] and stamina > 0:
player.speed = 8
stamina -= time.dt * 20
else:
player.speed = 5
stamina = min(100, stamina + time.dt*10)
stamina_bar.scale_x = 0.3 * stamina / 100
stamina_bar.color = color.green if stamina > 30 else color.red
# --- 발걸음 ---
moving = held_keys['w'] or held_keys['a'] \
or held_keys['s'] or held_keys['d']
if moving:
step_timer += time.dt
interval = 0.3 if held_keys['shift'] else 0.5
if step_timer > interval:
Audio('step', volume=0.15, autoplay=True, auto_destroy=True)
step_timer = 0
else:
step_timer = 0
# --- 안개 깜빡임 ---
if random.random() < 0.002:
scene.fog_density = random.uniform(0.04, 0.08)
else:
scene.fog_density = lerp(scene.fog_density, BASE_FOG, time.dt*3)
# --- 점프스케어 ---
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
# --- 메모 수집 ---
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_NOTES}'
Audio('pickup', autoplay=True, auto_destroy=True)
if found >= TOTAL_NOTES:
trigger_escape()
# --- 추격 BGM ---
any_chasing = any(m.chasing for m in monsters)
if any_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)
# --- VHS REC 깜빡임 + 노이즈 바 + 타임스탬프 ---
rec.enabled = vhs_on and (int(pytime.time()*2) % 2 == 0)
noise_bar.y += time.dt * 0.3
if noise_bar.y > 0.6:
noise_bar.y = -0.6
timestamp.text = pytime.strftime('JUL 04 1991 %H:%M:%S')
if random.random() < 0.005:
camera.shake(duration=0.1, magnitude=0.5)
# --- 거리 기반 최적화 (0.5초마다 1번만 체크) ---
wall_check_timer += time.dt
if wall_check_timer > 0.5:
wall_check_timer = 0
for w in walls:
w.enabled = distance(player.position, w.position) < 30
def input(key):
global vhs_on
if key == 'escape':
sys.exit()
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
app.run()