Загрузка данных
from ursina import *
from ursina.prefabs.health_bar import HealthBar
import random
app = Ursina(title="The Witcher 3D: White Orchard Prologue", borderless=False)
window.size = (1200, 720)
window.fps_counter.enabled = True
# ----------------- СЮЖЕТ И ДАННЫЕ КВЕСТА -----------------
quest_state = {
"stage": 0, # 0: Пролог, 1: Поговорить со старостой, 2: Убить чудовище, 3: Сдать квест
"cinematic": True,
"cinematic_timer": 0.0
}
# ----------------- ОКРУЖЕНИЕ И СВЕТ -----------------
DirectionalLight(y=10, z=-10, shadows=True, rotation=(45, -45, 0))
AmbientLight(color=color.rgba(120, 120, 130, 200))
scene.fog_density = 0.02
scene.fog_color = color.rgb(40, 45, 50)
# Земля (Белый Сад / Велен)
ground = Entity(
model='plane',
scale=(160, 1, 160),
color=color.rgb(45, 60, 40),
texture='white_cube',
texture_scale=(80, 80),
collider='box'
)
# Декорации: Деревья, Камни, Костер
trees = []
for _ in range(35):
tx = random.uniform(-60, 60)
tz = random.uniform(-60, 60)
if abs(tx) > 8 or abs(tz) > 8:
# Ствол
Entity(model='cylinder', color=color.rgb(60, 40, 25), scale=(0.8, 6, 0.8), position=(tx, 3, tz), collider='box')
# Крона
Entity(model='cone', color=color.rgb(30, 55, 30), scale=(5, 7, 5), position=(tx, 7, tz))
# Деревенская изба
house = Entity(model='cube', color=color.rgb(80, 55, 35), scale=(8, 5, 10), position=(14, 2.5, 12), collider='box')
roof = Entity(model='cone', color=color.rgb(110, 85, 45), scale=(11, 4, 13), position=(14, 6.5, 12), rotation=(0, 45, 0))
# Костер привала
campfire = Entity(model='sphere', color=color.orange, scale=(0.8, 0.4, 0.8), position=(0, 0.2, 0))
# ----------------- 3D МОДЕЛЬ ГЕРАЛЬТА -----------------
class WitcherPlayer(Entity):
def __init__(self):
super().__init__(position=(0, 1, 0))
# Тело (Доспех Волка)
self.body = Entity(parent=self, model='cube', scale=(0.9, 1.4, 0.5), color=color.rgb(50, 45, 40), position=(0, 0, 0))
# Голова и седые волосы
self.head = Entity(parent=self, model='sphere', scale=0.55, color=color.rgb(220, 190, 160), position=(0, 1.0, 0))
self.hair = Entity(parent=self, model='sphere', scale=(0.58, 0.35, 0.6), color=color.rgb(240, 240, 245), position=(0, 1.15, -0.05))
# Два меча за спиной (Стальной и Серебряный)
self.steel_scabbard = Entity(parent=self.body, model='cube', scale=(0.08, 1.6, 0.08), color=color.gray, position=(-0.25, 0.2, -0.32), rotation=(0, 0, -25))
self.silver_scabbard = Entity(parent=self.body, model='cube', scale=(0.08, 1.6, 0.08), color=color.cyan, position=(0.25, 0.2, -0.32), rotation=(0, 0, 25))
# Клинок в руке (для ударов)
self.sword_hand = Entity(parent=self, model='cube', scale=(0.08, 1.4, 0.08), color=color.white, position=(0.6, 0.2, 0.5), rotation=(80, 0, 0), enabled=False)
# Характеристики
self.hp = 200
self.max_hp = 200
self.stamina = 100
self.speed = 7
self.quen_active = False
self.is_attacking = False
self.attack_timer = 0.0
# Визуальный щит Квен
self.quen_sphere = Entity(parent=self, model='sphere', scale=2.8, color=color.rgba(255, 215, 0, 70), enabled=False)
def attack(self):
if not self.is_attacking and self.stamina >= 15:
self.is_attacking = True
self.attack_timer = 0.35
self.stamina -= 15
self.sword_hand.enabled = True
self.sword_hand.animate_rotation((15, 60, -45), duration=0.15)
invoke(self.reset_sword, delay=0.25)
def reset_sword(self):
self.sword_hand.rotation = (80, 0, 0)
self.sword_hand.enabled = False
self.is_attacking = False
def cast_igni(self):
if self.stamina >= 35:
self.stamina -= 35
# Сноп пламени
for i in range(8):
fire = Entity(
model='sphere',
color=color.orange,
scale=0.4,
position=self.position + Vec3(0, 0.8, 0) + self.forward * 1.2,
collider='sphere'
)
spread = Vec3(random.uniform(-0.3, 0.3), random.uniform(-0.1, 0.1), random.uniform(-0.2, 0.2))
fire.animate_position(fire.position + (self.forward + spread) * 9, duration=0.45, curve=curve.linear)
destroy(fire, delay=0.5)
def cast_quen(self):
if self.stamina >= 40:
self.stamina -= 40
self.quen_active = True
self.quen_sphere.enabled = True
player = WitcherPlayer()
# ----------------- КАМЕРА 3-ГО ЛИЦА -----------------
class ThirdPersonCamera:
def __init__(self, target):
self.target = target
self.distance = 7
self.height = 2.8
self.pitch = 18
self.yaw = 0
def update(self):
if quest_state["cinematic"]:
return # Во время катсцены камера управляется сценарием
# Вращение орбиты мыши
self.yaw += mouse.velocity[0] * 120
self.pitch = clamp(self.pitch - mouse.velocity[1] * 80, -10, 60)
# Вычисление координат камеры в сферической системе
rad_yaw = math.radians(self.yaw)
rad_pitch = math.radians(self.pitch)
cx = self.target.x - self.distance * math.sin(rad_yaw) * math.cos(rad_pitch)
cz = self.target.z - self.distance * math.cos(rad_yaw) * math.cos(rad_pitch)
cy = self.target.y + self.height + self.distance * math.sin(rad_pitch)
camera.position = Vec3(cx, cy, cz)
camera.look_at(self.target.position + Vec3(0, 1.5, 0))
cam_controller = ThirdPersonCamera(player)
# ----------------- 3D МОНСТР: УТОПЕЦ -----------------
class Drowner3D(Entity):
def __init__(self, pos):
super().__init__(
model='cube',
scale=(1.1, 1.6, 0.8),
color=color.rgb(40, 110, 120),
position=pos,
collider='box'
)
# Глаза утопца
Entity(parent=self, model='sphere', scale=0.15, color=color.red, position=(-0.25, 0.4, 0.45))
Entity(parent=self, model='sphere', scale=0.15, color=color.red, position=(0.25, 0.4, 0.45))
self.hp = 80
self.speed = 3.6
self.attack_cd = 0.0
self.alive = True
def update(self):
if not self.alive or quest_state["cinematic"]:
return
self.attack_cd = max(0, self.attack_cd - time.dt)
dist = distance(self.position, player.position)
# Преследование
if 1.8 < dist < 30:
self.look_at_2d(player.position)
self.position += self.forward * self.speed * time.dt
# Атака
if dist <= 2.2 and self.attack_cd == 0:
self.attack_cd = 1.4
if player.quen_active:
player.quen_active = False
player.quen_sphere.enabled = False
print_hud_message("КВЕН ПОГЛОТИЛ УДАР!")
else:
player.hp -= 25
print_hud_message("УРОН: -25 HP!")
drowner = Drowner3D(pos=(28, 1, 26))
# ----------------- NPC: СТАРОСТА -----------------
npc_elder = Entity(model='cube', scale=(0.9, 1.4, 0.5), color=color.rgb(160, 140, 110), position=(11, 1, 8), collider='box')
Entity(parent=npc_elder, model='sphere', scale=0.5, color=color.rgb(210, 180, 150), position=(0, 0.9, 0))
# Иконка квеста
quest_marker = Entity(parent=npc_elder, model='sphere', scale=0.3, color=color.yellow, position=(0, 1.8, 0))
# ----------------- ИНТЕРФЕЙС / HUD И КАТСЦЕНЫ -----------------
fade_overlay = Entity(parent=camera.ui, model='quad', scale=(2, 2), color=color.black, z=-1)
cinematic_title = Text(
text="ПРОЛОГ: БЕЛЫЙ САД\nВедьмаки никогда не спят спокойно...",
origin=(0, 0),
scale=1.8,
color=color.gold,
enabled=True
)
dialog_box = WindowPanel(
title='Староста деревни',
content=(
Text("Ведьмак! К востоку от мельницы объявился Утопец-людоед.\nЗащити деревню, и 100 крон твои!"),
Button(text='1. Беру заказ на чудовище.', color=color.azure),
Button(text='2. Отказаться.', color=color.gray)
),
enabled=False
)
hud_text = Text(text="", position=(-0.85, 0.45), scale=1.2, color=color.white)
status_msg = Text(text="", origin=(0, 0), position=(0, 0.25), scale=1.5, color=color.red, enabled=False)
def print_hud_message(msg):
status_msg.text = msg
status_msg.enabled = True
invoke(setattr, status_msg, 'enabled', False, delay=1.5)
# ----------------- ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ -----------------
def update():
# 1. Режиссура катсцены
if quest_state["cinematic"]:
quest_state["cinematic_timer"] += time.dt
t = quest_state["cinematic_timer"]
# Плавное движение кинокамеры
camera.position = Vec3(-6 + t * 0.8, 3 - t * 0.1, -8 + t * 0.6)
camera.look_at(campfire.position + Vec3(0, 0.5, 0))
# Выход из затемнения
if t < 2.0:
fade_overlay.color = color.rgba(0, 0, 0, int(255 * (1 - t / 2.0)))
else:
fade_overlay.color = color.rgba(0, 0, 0, 0)
# Окончание пролога
if t >= 5.0:
quest_state["cinematic"] = False
cinematic_title.enabled = False
fade_overlay.enabled = False
mouse.locked = True
quest_state["stage"] = 1
return
# 2. Управление персонажем
cam_controller.update()
# Восстановление энергии
if player.stamina < 100:
player.stamina = min(100, player.stamina + 20 * time.dt)
# Движение с привязкой к направлению камеры
move_dir = Vec3(
(held_keys['d'] - held_keys['a']),
0,
(held_keys['w'] - held_keys['s'])
).normalized()
if move_dir.length() > 0:
rad = math.radians(cam_controller.yaw)
forward = Vec3(math.sin(rad), 0, math.cos(rad))
right = Vec3(math.cos(rad), 0, -math.sin(rad))
world_dir = forward * move_dir.z + right * move_dir.x
player.position += world_dir * player.speed * time.dt
# Поворот модельки Геральта лицом по направлению движения
player.rotation_y = math.degrees(math.atan2(world_dir.x, world_dir.z))
# Ведьмачье чутье (ПКМ): сгущение тумана и подсветка врага
if mouse.right:
scene.fog_density = 0.06
scene.fog_color = color.rgb(15, 10, 10)
drowner.color = color.rgb(255, 60, 0)
else:
scene.fog_density = 0.02
scene.fog_color = color.rgb(40, 45, 50)
drowner.color = color.rgb(40, 110, 120)
# Интерактив с NPC (Клавиша E)
dist_to_npc = distance(player.position, npc_elder.position)
if dist_to_npc < 3.5:
if quest_state["stage"] == 1:
hud_text.text = "[E] Говорить со старостой"
if held_keys['e'] and not dialog_box.enabled:
dialog_box.enabled = True
mouse.locked = False
elif quest_state["stage"] == 3:
hud_text.text = "[E] Забрать награду за чудовище"
if held_keys['e']:
print_hud_message("ЗАКАЗ ВЫПОЛНЕН: +100 КРОН!")
quest_state["stage"] = 4
quest_marker.enabled = False
else:
if dialog_box.enabled:
dialog_box.enabled = False
mouse.locked = True
hud_text.text = f"HP: {int(player.hp)}/200 | Энергия: {int(player.stamina)} | Меч: Серебряный"
def input(key):
if quest_state["cinematic"]:
if key == 'space': # Пропуск катсцены
quest_state["cinematic_timer"] = 5.0
return
# Удар мечом на ЛКМ
if key == 'left mouse down':
player.attack()
# Проверка попадания
if distance(player.position, drowner.position) < 3.2 and drowner.alive:
drowner.hp -= 35
print_hud_message("УДАР СЕРЕБРОМ: -35 HP!")
if drowner.hp <= 0:
drowner.alive = False
drowner.rotation_x = 90
drowner.y = 0.4
print_hud_message("УТОПЕЦ ПОВЕРЖЕН! Вернитесь к старосте.")
quest_state["stage"] = 3
# Знаки Ведьмака
if key == '1':
player.cast_igni()
if distance(player.position, drowner.position) < 8.0 and drowner.alive:
drowner.hp -= 25
print_hud_message("ЗНАК ИГНИ: ОЖОГ -25 HP!")
if key == '2':
player.cast_quen()
# Диалог
if dialog_box.enabled:
if key == '1':
quest_state["stage"] = 2
dialog_box.enabled = False
mouse.locked = True
print_hud_message("КВЕСТ ПОЛУЧЕН: Выследите Утопца на болоте!")
app.run()