Загрузка данных
from ursina import *
import math
import random
app = Ursina(title="The Witcher 3D: Chronicles of Velen", borderless=False)
window.size = (1200, 750)
window.color = color.rgb(110, 145, 175) # Чистое небо без слепящего тумана
# =====================================================================
# НАСТРОЙКИ СВЕТА И СЦЕНЫ
# =====================================================================
sun = DirectionalLight(y=25, z=-20, shadows=True, rotation=(45, -45, 0))
ambient = AmbientLight(color=color.rgba(140, 140, 150, 200))
# Земля (Ландшафт Велена)
terrain = Entity(
model='plane',
scale=(300, 1, 300),
color=color.rgb(55, 75, 45),
texture='white_cube',
texture_scale=(150, 150),
collider='box'
)
# =====================================================================
# ДЕКОРАЦИИ И ПОСТРОЙКИ (ИЗБЫ, ДЕРЕВЬЯ, РУИНЫ)
# =====================================================================
class House3D(Entity):
def __init__(self, pos):
super().__init__(position=pos)
# Сруб
self.walls = Entity(parent=self, model='cube', scale=(8, 4.5, 9), color=color.rgb(75, 50, 32), position=(0, 2.25, 0), collider='box')
# Крыша
self.roof = Entity(parent=self, model='cone', scale=(10, 3.8, 11), color=color.rgb(130, 105, 55), position=(0, 5.8, 0), rotation=(0, 45, 0))
# Дверь
self.door = Entity(parent=self, model='cube', scale=(1.8, 2.8, 0.2), color=color.rgb(40, 25, 15), position=(0, 1.4, -4.55))
class Tree3D(Entity):
def __init__(self, pos):
super().__init__(position=pos)
# Ствол
self.trunk = Entity(parent=self, model='cylinder', scale=(0.7, 5, 0.7), color=color.rgb(55, 38, 25), position=(0, 2.5, 0), collider='box')
# Листва (два яруса)
self.leaves1 = Entity(parent=self, model='cone', scale=(4.5, 4.5, 4.5), color=color.rgb(28, 48, 28), position=(0, 5.5, 0))
self.leaves2 = Entity(parent=self, model='cone', scale=(3.5, 4.0, 3.5), color=color.rgb(32, 55, 32), position=(0, 7.5, 0))
# Генерация деревни и леса
houses = [House3D(pos=(14, 0, 16)), House3D(pos=(-16, 0, 20)), House3D(pos=(18, 0, -18))]
for _ in range(40):
tx = random.uniform(-110, 110)
tz = random.uniform(-110, 110)
if abs(tx) > 15 or abs(tz) > 15:
Tree3D(pos=(tx, 0, tz))
# =====================================================================
# МОДЕЛЬ ГЕРАЛЬТА (3-Е ЛИЦО)
# =====================================================================
class WitcherPlayer(Entity):
def __init__(self):
super().__init__(position=(0, 0, 0))
# Тело в доспехе Школы Волка
self.torso = Entity(parent=self, model='cube', scale=(0.85, 1.35, 0.45), color=color.rgb(45, 40, 35), position=(0, 1.35, 0))
self.vest = Entity(parent=self.torso, model='cube', scale=(1.02, 0.65, 1.05), color=color.rgb(75, 45, 30), position=(0, 0.1, 0))
# Голова и седые волосы
self.head = Entity(parent=self, model='sphere', scale=0.48, color=color.rgb(220, 185, 155), position=(0, 2.25, 0))
self.hair = Entity(parent=self.head, model='sphere', scale=(1.08, 0.6, 1.1), color=color.rgb(240, 240, 245), position=(0, 0.25, -0.05))
# Ноги
self.leg_l = Entity(parent=self, model='cube', scale=(0.3, 0.8, 0.35), color=color.rgb(25, 20, 15), position=(-0.22, 0.4, 0))
self.leg_r = Entity(parent=self, model='cube', scale=(0.3, 0.8, 0.35), color=color.rgb(25, 20, 15), position=(0.22, 0.4, 0))
# Мечи за спиной (Стальной и Серебряный)
self.sheath_steel = Entity(parent=self.torso, model='cube', scale=(0.06, 1.6, 0.06), color=color.gray, position=(-0.22, 0.2, -0.28), rotation=(0, 0, -25))
self.sheath_silver = Entity(parent=self.torso, model='cube', scale=(0.06, 1.6, 0.06), color=color.cyan, position=(0.22, 0.2, -0.28), rotation=(0, 0, 25))
# Боевой клинок в правой руке
self.blade = Entity(parent=self, model='cube', scale=(0.06, 1.5, 0.06), color=color.white, position=(0.65, 1.2, 0.5), rotation=(80, 0, 0), enabled=False)
# Сфера знака Квен
self.quen_sphere = Entity(parent=self, model='sphere', scale=3.2, color=color.rgba(255, 215, 0, 80), position=(0, 1.5, 0), enabled=False)
# Характеристики
self.hp = 220
self.max_hp = 220
self.stamina = 100
self.max_stamina = 100
self.toxicity = 0
self.speed = 8.5
# Инвентарь
self.coins = 150
self.potions = 3
self.herbs = 4
self.sword = "SILVER" # "SILVER" или "STEEL"
self.quen_active = False
self.is_attacking = False
self.dodge_timer = 0.0
self.step = 0.0
def attack(self):
if not self.is_attacking and self.stamina >= 12:
self.is_attacking = True
self.stamina -= 12
self.blade.color = color.cyan if self.sword == "SILVER" else color.rgb(220, 200, 160)
self.blade.enabled = True
self.blade.animate_rotation((15, 60, -45), duration=0.14)
invoke(self.reset_blade, delay=0.22)
def reset_blade(self):
self.blade.rotation = (80, 0, 0)
self.blade.enabled = False
self.is_attacking = False
def drink_potion(self):
if self.potions > 0 and self.toxicity <= 80:
self.potions -= 1
self.hp = min(self.max_hp, self.hp + 65)
self.toxicity += 30
show_banner("+65 HP (Ласточка выпита)", color.green)
elif self.toxicity > 80:
show_banner("Слишком высокая токсичность!", color.red)
player = WitcherPlayer()
# =====================================================================
# ОРБИТАЛЬНАЯ КАМЕРА 3-ГО ЛИЦА
# =====================================================================
class OrbitCamera:
def __init__(self, target):
self.target = target
self.dist = 7.0
self.height = 2.4
self.yaw = 0.0
self.pitch = 18.0
def update(self):
# Вращение орбиты мышью
self.yaw += mouse.velocity[0] * 120
self.pitch = clamp(self.pitch - mouse.velocity[1] * 75, -5, 55)
rad_y = math.radians(self.yaw)
rad_p = math.radians(self.pitch)
cx = self.target.x - self.dist * math.sin(rad_y) * math.cos(rad_p)
cz = self.target.z - self.dist * math.cos(rad_y) * math.cos(rad_p)
cy = self.target.y + self.height + self.dist * math.sin(rad_p)
camera.position = Vec3(cx, cy, cz)
camera.look_at(self.target.position + Vec3(0, 1.6, 0))
cam = OrbitCamera(player)
mouse.locked = True
# =====================================================================
# ВРАГИ: УТОПЕЦ И БОСС ЛЕШИЙ
# =====================================================================
class Drowner3D(Entity):
def __init__(self, pos):
super().__init__(position=pos)
self.body = Entity(parent=self, model='cube', scale=(0.9, 1.5, 0.7), color=color.rgb(40, 115, 125), position=(0, 0.75, 0), collider='box')
# Красные светящиеся глаза
Entity(parent=self.body, model='sphere', scale=0.14, color=color.red, position=(-0.25, 0.4, 0.38))
Entity(parent=self.body, model='sphere', scale=0.14, color=color.red, position=(0.25, 0.4, 0.38))
self.hp = 85
self.max_hp = 85
self.speed = 3.6
self.attack_cd = 0.0
self.slow_timer = 0.0
self.stun_timer = 0.0
self.alive = True
def update(self):
if not self.alive: return
if self.stun_timer > 0:
self.stun_timer -= time.dt
return
if self.slow_timer > 0: self.slow_timer -= time.dt
spd = self.speed * 0.4 if self.slow_timer > 0 else self.speed
self.attack_cd = max(0.0, self.attack_cd - time.dt)
dist = distance(self.position, player.position)
if 2.0 < dist < 28:
self.look_at_2d(player.position)
self.position += self.forward * spd * time.dt
if dist <= 2.2 and self.attack_cd <= 0:
self.attack_cd = 1.3
if player.quen_active:
player.quen_active = False
player.quen_sphere.enabled = False
show_banner("КВЕН ПОГЛОТИЛ УДАР!", color.yellow)
else:
player.hp -= 24
show_banner("УРОН: -24 HP!", color.red)
class AncientLeshenBoss(Entity):
def __init__(self, pos):
super().__init__(position=pos)
# Массивное тело из древесной коры
self.body = Entity(parent=self, model='cube', scale=(1.8, 3.4, 1.2), color=color.rgb(40, 26, 18), position=(0, 1.7, 0), collider='box')
# Череп оленя и рога
self.skull = Entity(parent=self, model='cone', scale=(1.2, 1.6, 1.0), color=color.rgb(220, 215, 190), position=(0, 3.4, 0.3), rotation=(90, 0, 0))
Entity(parent=self.skull, model='cylinder', scale=(0.08, 2.2, 0.08), color=color.rgb(25, 18, 12), position=(-0.6, 0.8, 0), rotation=(0, 0, -40))
Entity(parent=self.skull, model='cylinder', scale=(0.08, 2.2, 0.08), color=color.rgb(25, 18, 12), position=(0.6, 0.8, 0), rotation=(0, 0, 40))
self.hp = 650
self.max_hp = 650
self.speed = 2.4
self.teleport_cd = 8.0
self.root_cd = 4.5
self.slow_timer = 0.0
self.alive = True
def update(self):
if not self.alive: return
self.teleport_cd -= time.dt
self.root_cd -= time.dt
dist = distance(self.position, player.position)
# Телепортация за спину игрока
if self.teleport_cd <= 0 and dist < 35:
self.teleport_cd = 9.0
ang = random.uniform(0, 6.28)
self.position = player.position + Vec3(math.cos(ang) * 9, 0, math.sin(ang) * 9)
show_banner("Леший растворился в тумане!", color.smoke)
# Вызов корней под ногами
if self.root_cd <= 0 and dist < 30:
self.root_cd = 5.5
spawn_roots(player.position)
show_banner("КОРНИ ЗЕМЛИ!", color.orange)
if dist > 3.0:
self.look_at_2d(player.position)
self.position += self.forward * self.speed * time.dt
# Ловушки корней Лешего
active_roots = []
def spawn_roots(target_pos):
root = Entity(model='cone', scale=(2.2, 3.5, 2.2), color=color.rgb(45, 65, 30), position=target_pos, collider='box')
active_roots.append({"entity": root, "timer": 2.2})
# =====================================================================
# NPC: СТАРОСТА И ТРАВНИЦА
# =====================================================================
class NPC3D(Entity):
def __init__(self, pos, name, role):
super().__init__(position=pos)
self.name = name
self.role = role
col = color.rgb(160, 140, 110) if role == "elder" else color.rgb(45, 115, 75)
self.body = Entity(parent=self, model='cube', scale=(0.85, 1.4, 0.45), color=col, position=(0, 0.7, 0), collider='box')
self.head = Entity(parent=self, model='sphere', scale=0.5, color=color.rgb(215, 185, 155), position=(0, 1.6, 0))
# Восклицательный знак квеста
self.quest_icon = Entity(parent=self, model='sphere', scale=0.25, color=color.yellow, position=(0, 2.2, 0))
npc_elder = NPC3D(pos=(10, 0, 12), name="Староста Богдан", role="elder")
npc_tomira = NPC3D(pos=(-12, 0, 16), name="Травница Томира", role="herbalist")
drowner = Drowner3D(pos=(35, 0, 30))
leshen = AncientLeshenBoss(pos=(80, 0, 75))
# =====================================================================
# СИСТЕМА КВЕСТОВ, ДИАЛОГОВ И БЕСТИАРИЙ
# =====================================================================
quest_data = {
"leshen": {"name": "Заказ: Древний Хозяин Велена", "stage": 0, "desc": "Убейте Древнего Лешего в восточной глуши."},
"herbs": {"name": "Сбор трав для Томиры", "stage": 0, "desc": "Принесите Томире 3 ласточкины травы."}
}
bestiary_text = (
"1. УТОПЕЦ (Трупоед):\n"
"Слабости: Серебряный меч, Знак Игни (горит), Знак Аард.\n\n"
"2. ДРЕВНИЙ ЛЕШИЙ (Реликт):\n"
"Слабости: Серебряный меч, Игни (крайне восприимчив к огню), Ирден (ловушка замедления).\n\n"
"3. ДЕЗЕРТИРЫ (Люди):\n"
"Слабости: Стальной меч, Знак Аксий (гипноз)."
)
# =====================================================================
# ИНТЕРФЕЙС / HUD И ОКНА МЕНЮ
# =====================================================================
hud_bg = Entity(parent=camera.ui, model='quad', scale=(0.55, 0.12), position=(-0.58, 0.42), color=color.rgba(20, 20, 20, 190))
hud_bars = Text(text="", position=(-0.83, 0.46), scale=1.1, color=color.white)
banner = Text(text="", origin=(0, 0), position=(0, 0.28), scale=1.6, color=color.yellow, enabled=False)
def show_banner(msg, col=color.yellow):
banner.text = msg
banner.color = col
banner.enabled = True
invoke(setattr, banner, 'enabled', False, delay=2.0)
# Диалоговое окно
dialog_box = WindowPanel(
title='Диалог',
content=(
Text(text="", id='dialog_text'),
Button(text='1. Принять заказ / Выполнить', color=color.azure, id='btn_1'),
Button(text='2. Отказаться', color=color.gray, id='btn_2')
),
enabled=False
)
# Окно Меню (Инвентарь / Бестиарий / Квесты)
menu_panel = WindowPanel(
title='ВЕДЬМАЧИЙ ГРИМУАР',
content=(
Text(text="[1] Инвентарь [2] Бестиарий [3] Квесты", color=color.gold),
Text(text="", id='menu_content'),
Button(text='Закрыть [I / ESC]', color=color.dark_gray)
),
enabled=False
)
menu_tab = "INV"
# =====================================================================
# ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ
# =====================================================================
def update():
# Регенерация выносливости
if player.stamina < player.max_stamina:
player.stamina = min(player.max_stamina, player.stamina + 22 * time.dt)
# Спад токсичности
if player.toxicity > 0:
player.toxicity = max(0, player.toxicity - 2.5 * time.dt)
# Обновление камеры
if not menu_panel.enabled and not dialog_box.enabled:
cam.update()
# Перемещение персонажа относительно камеры
if not menu_panel.enabled and not dialog_box.enabled:
move_dir = Vec3(
(held_keys['d'] - held_keys['a']),
0,
(held_keys['w'] - held_keys['s'])
).normalized()
if move_dir.length() > 0:
player.step += time.dt * 12
rad = math.radians(cam.yaw)
fwd = Vec3(math.sin(rad), 0, math.cos(rad))
rgt = Vec3(math.cos(rad), 0, -math.sin(rad))
world_dir = fwd * move_dir.z + rgt * move_dir.x
spd = player.speed * 0.6 if mouse.right else player.speed
player.position += world_dir * spd * time.dt
player.rotation_y = math.degrees(math.atan2(world_dir.x, world_dir.z))
# Анимация покачивания ног
player.leg_l.rotation_x = math.sin(player.step) * 28
player.leg_r.rotation_x = -math.sin(player.step) * 28
else:
player.leg_l.rotation_x = 0
player.leg_r.rotation_x = 0
# Ведьмачье чутье (ПКМ)
if mouse.right and not menu_panel.enabled:
window.color = color.rgb(30, 25, 20)
drowner.body.color = color.red
leshen.body.color = color.rgb(255, 80, 0)
else:
window.color = color.rgb(110, 145, 175)
drowner.body.color = color.rgb(40, 115, 125)
leshen.body.color = color.rgb(40, 26, 18)
# Логика корней босса
for r in active_roots[:]:
r["timer"] -= time.dt
if distance(r["entity"].position, player.position) < 2.0 and r["timer"] > 0:
if player.quen_active:
player.quen_active = False
player.quen_sphere.enabled = False
show_banner("КВЕН РАЗБИТ КОРНЯМИ!", color.yellow)
else:
player.hp -= 40 * time.dt
show_banner("ШИПЫ ЛЕСА: -40 HP!", color.red)
if r["timer"] <= 0:
destroy(r["entity"])
active_roots.remove(r)
# Обновление HUD
hud_bars.text = (
f"HP: {int(player.hp)}/{player.max_hp} | ЭНЕРГИЯ: {int(player.stamina)}/{player.max_stamina}\n"
f"ТОКСИЧНОСТЬ: {int(player.toxicity)}% | МЕЧ [R]: {player.sword}\n"
f"КРОНЫ: {player.coins} | ЛАСТОЧКА [F]: {player.potions} шт."
)
# =====================================================================
# ВВОД И УПРАВЛЕНИЕ БОЕМ / ДИАЛОГАМИ
# =====================================================================
def input(key):
global menu_tab
# Открытие меню [I]
if key == 'i':
menu_panel.enabled = not menu_panel.enabled
mouse.locked = not menu_panel.enabled
update_menu_content()
if menu_panel.enabled:
if key == '1': menu_tab = "INV"; update_menu_content()
if key == '2': menu_tab = "BESTIARY"; update_menu_content()
if key == '3': menu_tab = "QUESTS"; update_menu_content()
if key == 'escape':
menu_panel.enabled = False
mouse.locked = True
return
# Диалоги
if dialog_box.enabled:
if key in ('1', 'e'):
dialog_box.content[1].on_click()
elif key in ('2', 'escape'):
dialog_box.enabled = False
mouse.locked = True
return
# Атака мечом [ЛКМ]
if key == 'left mouse down':
player.attack()
# Проверка удара по утопцу
if distance(player.position, drowner.position) < 3.2 and drowner.alive:
mult = 1.0 if player.sword == "SILVER" else 0.25
dmg = int(35 * mult)
drowner.hp -= dmg
show_banner(f"УДАР МЕЧОМ: -{dmg} HP!" if mult == 1.0 else f"-{dmg} HP (Нужно Серебро!)", color.white)
if drowner.hp <= 0:
drowner.alive = False
drowner.body.rotation_x = 90
drowner.y = 0.2
show_banner("УТОПЕЦ ПОВЕРЖЕН! +18 Крон", color.gold)
player.coins += 18
# Проверка удара по Лешему
if distance(player.position, leshen.position) < 4.2 and leshen.alive:
mult = 1.0 if player.sword == "SILVER" else 0.2
dmg = int(45 * mult)
leshen.hp -= dmg
show_banner(f"УДАР ПО ЛЕШЕМУ: -{dmg} HP!", color.gold)
if leshen.hp <= 0:
leshen.alive = False
leshen.body.rotation_x = 90
leshen.y = 0.4
quest_data["leshen"]["stage"] = 2
show_banner("ЛЕШИЙ ПОВЕРЖЕН! Вернитесь к Старосте.", color.gold)
# Смена меча [R]
if key == 'r':
player.sword = "STEEL" if player.sword == "SILVER" else "SILVER"
show_banner(f"Обнажен {player.sword} меч", color.cyan if player.sword == "SILVER" else color.orange)
# Эликсир Ласточка [F]
if key == 'f':
player.drink_potion()
# --- ЗНАКИ ВЕДЬМАКА ---
# 1. ИГНИ (Струя огня)
if key == '1' and player.stamina >= 35:
player.stamina -= 35
for i in range(12):
f = Entity(model='sphere', color=color.orange, scale=0.45, position=player.position + Vec3(0, 1.4, 0) + player.forward * 1.2)
f.animate_position(f.position + (player.forward + Vec3(random.uniform(-0.3, 0.3), 0, random.uniform(-0.3, 0.3))) * 11, duration=0.4)
destroy(f, delay=0.45)
if distance(player.position, leshen.position) < 11 and leshen.alive:
leshen.hp -= 60
show_banner("ИГНИ: ОЖОГ ЛЕШЕГО -60 HP!", color.orange)
if distance(player.position, drowner.position) < 10 and drowner.alive:
drowner.hp -= 40
show_banner("ИГНИ: УТОПЕЦ ГОРИТ -40 HP!", color.orange)
# 2. КВЕН (Щит)
if key == '2' and player.stamina >= 40:
player.stamina -= 40
player.quen_active = True
player.quen_sphere.enabled = True
show_banner("ЗНАК КВЕН АКТИВЕН", color.yellow)
# 3. ААРД (Телекинез)
if key == '3' and player.stamina >= 35:
player.stamina -= 35
wave = Entity(model='cone', scale=(4, 1, 4), color=color.cyan, position=player.position + Vec3(0, 1, 0), rotation=(90, player.rotation_y, 0))
wave.animate_position(wave.position + player.forward * 8, duration=0.3)
destroy(wave, delay=0.35)
if distance(player.position, drowner.position) < 8 and drowner.alive:
drowner.position += player.forward * 4.5
drowner.hp -= 20
drowner.stun_timer = 2.0
show_banner("ААРД: СТАН И ОТБРАСЫВАНИЕ!", color.cyan)
# 4. ИРДЕН (Ловушка)
if key == '4' and player.stamina >= 35:
player.stamina -= 35
trap = Entity(model='circle', scale=8, color=color.rgba(180, 50, 240, 100), position=player.position + Vec3(0, 0.05, 0), rotation=(90, 0, 0))
destroy(trap, delay=6.0)
show_banner("ИРДЕН: ЗОНА ЗАМЕДЛЕНИЯ", color.magenta)
# 5. АКСИЙ (Гипноз)
if key == '5' and player.stamina >= 35:
player.stamina -= 35
if distance(player.position, drowner.position) < 9 and drowner.alive:
drowner.stun_timer = 4.0
show_banner("АКСИЙ: УТОПЕЦ ОДУРМАНЕН!", color.green)
# Взаимодействие с NPC [E]
if key == 'e':
if distance(player.position, npc_elder.position) < 3.8:
open_elder_dialog()
elif distance(player.position, npc_tomira.position) < 3.8:
open_tomira_dialog()
def open_elder_dialog():
mouse.locked = False
dialog_box.enabled = True
st = quest_data["leshen"]["stage"]
if st == 0:
dialog_box.content[0].text = "Ведьмак! В восточной чаще завёлся древний Леший.\nУбей его, и 250 крон твои!"
def accept():
quest_data["leshen"]["stage"] = 1
dialog_box.enabled = False
mouse.locked = True
show_banner("ЗАКАЗ ВЗЯТ: Найдите Лешего на востоке!", color.gold)
dialog_box.content[1].text = "1. Беру заказ [250 крон]"
dialog_box.content[1].on_click = accept
elif st == 2:
dialog_box.content[0].text = "Слава богам, ты сразил чудовище!\nВот твоя награда, Белый Волк."
def reward():
quest_data["leshen"]["stage"] = 3
player.coins += 250
npc_elder.quest_icon.enabled = False
dialog_box.enabled = False
mouse.locked = True
show_banner("ЗАКАЗ ВЫПОЛНЕН! +250 КРОН", color.gold)
dialog_box.content[1].text = "1. Забрать награду (250 крон)"
dialog_box.content[1].on_click = reward
else:
dialog_box.content[0].text = "Ступай на восток, включи чутье и ищи логово Лешего!"
def close():
dialog_box.enabled = False
mouse.locked = True
dialog_box.content[1].text = "1. Уже иду."
dialog_box.content[1].on_click = close
def open_tomira_dialog():
mouse.locked = False
dialog_box.enabled = True
dialog_box.content[0].text = "Здравствуй, Геральт. Могу сварить 'Ласточку' за 40 крон."
def buy():
if player.coins >= 40:
player.coins -= 40
player.potions += 1
show_banner("+1 Ласточка куплена", color.green)
else:
show_banner("Недостаточно крон!", color.red)
dialog_box.enabled = False
mouse.locked = True
dialog_box.content[1].text = "1. Купить 'Ласточку' (-40 крон)"
dialog_box.content[1].on_click = buy
def update_menu_content():
if menu_tab == "INV":
menu_panel.content[1].text = (
f"• Кроны: {player.coins} золотых\n"
f"• Эликсиры 'Ласточка': {player.potions} шт. [Клавиша F]\n"
f"• Травы для алхимии: {player.herbs} шт.\n"
f"• Серебряный клинок Волка (+45 по монстрам)\n"
f"• Стальной клинок Новиграда (+35 по людям)"
)
elif menu_tab == "BESTIARY":
menu_panel.content[1].text = bestiary_text
elif menu_tab == "QUESTS":
q_lines = []
for qid, q in quest_data.items():
status = "ВЫПОЛНЕН" if q["stage"] == 3 else ("В ПРОЦЕССЕ" if q["stage"] > 0 else "НЕ ВЗЯТ")
q_lines.append(f"• {q['name']} — [{status}]\n {q['desc']}")
menu_panel.content[1].text = "\n\n".join(q_lines)
app.run()