diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..5ed77b3 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..59e60bd Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..3aa2b96 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..13127ff 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..db6f5a8 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..aff832d 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,55 @@ from django.contrib import admin -# Register your models here. +from .models import ( + Character, + Choice, + InventoryItem, + Item, + Quest, + Scene, + StoryEntry, +) + + +@admin.register(Scene) +class SceneAdmin(admin.ModelAdmin): + list_display = ("title", "slug") + search_fields = ("title", "slug") + + +@admin.register(Choice) +class ChoiceAdmin(admin.ModelAdmin): + list_display = ("scene", "text", "required_skill", "difficulty") + list_filter = ("required_skill",) + search_fields = ("text",) + + +@admin.register(Character) +class CharacterAdmin(admin.ModelAdmin): + list_display = ("name", "background", "level") + list_filter = ("background",) + search_fields = ("name",) + + +@admin.register(Quest) +class QuestAdmin(admin.ModelAdmin): + list_display = ("title", "character", "status") + list_filter = ("status",) + + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ("name", "slot", "power") + list_filter = ("slot",) + + +@admin.register(InventoryItem) +class InventoryItemAdmin(admin.ModelAdmin): + list_display = ("character", "item", "equipped") + list_filter = ("equipped",) + + +@admin.register(StoryEntry) +class StoryEntryAdmin(admin.ModelAdmin): + list_display = ("character", "scene", "outcome", "created_at") + list_filter = ("outcome",) diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..be0eb10 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,28 @@ +from django import forms + +from .models import Character, Choice + + +class CharacterCreateForm(forms.Form): + name = forms.CharField( + max_length=80, + widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Имя ведьмака"}), + ) + background = forms.ChoiceField( + choices=Character.BACKGROUND_CHOICES, + widget=forms.Select(attrs={"class": "form-select"}), + ) + + +class ChoiceForm(forms.Form): + choice = forms.ModelChoiceField( + queryset=Choice.objects.none(), + empty_label=None, + widget=forms.RadioSelect, + ) + + def __init__(self, *args, **kwargs): + choice_queryset = kwargs.pop("choice_queryset", Choice.objects.none()) + super().__init__(*args, **kwargs) + self.fields["choice"].queryset = choice_queryset + self.fields["choice"].widget.attrs.update({"class": "choice-radio"}) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..e2356f4 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 5.2.7 on 2026-03-05 10:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Character', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=80)), + ('background', models.CharField(choices=[('warden', 'Warden of the North'), ('scour', 'Scour of the Roads'), ('alchemist', 'Herb-born Alchemist')], max_length=20)), + ('level', models.PositiveIntegerField(default=1)), + ('vigor', models.PositiveIntegerField(default=3)), + ('focus', models.PositiveIntegerField(default=3)), + ('alchemy', models.PositiveIntegerField(default=3)), + ('perk_points', models.PositiveIntegerField(default=1)), + ], + ), + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('slot', models.CharField(choices=[('weapon', 'Weapon'), ('armor', 'Armor'), ('trinket', 'Trinket')], max_length=20)), + ('power', models.PositiveIntegerField(default=0)), + ('description', models.TextField()), + ], + ), + migrations.CreateModel( + name='Scene', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=120)), + ('slug', models.SlugField(unique=True)), + ('body', models.TextField()), + ], + options={ + 'ordering': ['id'], + }, + ), + migrations.CreateModel( + name='InventoryItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('equipped', models.BooleanField(default=False)), + ('character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to='core.character')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='core.item')), + ], + options={ + 'ordering': ['id'], + }, + ), + migrations.CreateModel( + name='Quest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=140)), + ('summary', models.TextField()), + ('status', models.CharField(choices=[('active', 'Active'), ('complete', 'Complete'), ('failed', 'Failed')], default='active', max_length=20)), + ('character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quests', to='core.character')), + ], + options={ + 'ordering': ['id'], + }, + ), + migrations.CreateModel( + name='Choice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.CharField(max_length=200)), + ('required_skill', models.CharField(blank=True, choices=[('vigor', 'Vigor'), ('focus', 'Focus'), ('alchemy', 'Alchemy')], max_length=20)), + ('difficulty', models.PositiveIntegerField(blank=True, null=True)), + ('reward_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reward_choices', to='core.item')), + ('fail_scene', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fail_from', to='core.scene')), + ('next_scene', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_choices', to='core.scene')), + ('scene', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='core.scene')), + ('success_scene', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='success_from', to='core.scene')), + ], + options={ + 'ordering': ['id'], + }, + ), + migrations.AddField( + model_name='character', + name='current_scene', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.scene'), + ), + migrations.CreateModel( + name='StoryEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('choice_text', models.CharField(max_length=200)), + ('outcome', models.CharField(choices=[('success', 'Success'), ('fail', 'Fail'), ('neutral', 'Neutral')], max_length=20)), + ('roll', models.PositiveIntegerField(blank=True, null=True)), + ('total', models.PositiveIntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('character', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_entries', to='core.character')), + ('scene', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='story_entries', to='core.scene')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/core/migrations/0002_seed_story.py b/core/migrations/0002_seed_story.py new file mode 100644 index 0000000..63beceb --- /dev/null +++ b/core/migrations/0002_seed_story.py @@ -0,0 +1,127 @@ +from django.db import migrations + + +def seed_story(apps, schema_editor): + Scene = apps.get_model("core", "Scene") + Choice = apps.get_model("core", "Choice") + Item = apps.get_model("core", "Item") + + if Scene.objects.exists(): + return + + starter_item = Item.objects.create( + name="Серебряный знак Стылого Волка", + slot="trinket", + power=1, + description="Амулет, усиливающий чутьё на нечисть.", + ) + blade = Item.objects.create( + name="Клинок мокрых троп", + slot="weapon", + power=3, + description="Лёгкий меч, выкованный для болотных дуэлей.", + ) + + prologue = Scene.objects.create( + title="Угольный тракт", + slug="prologue", + body=( + "Над трактом висит туман, а трактирщик шепчет о пропавших караванах. " + "Вам предлагают золото и место у огня, если вы проверите болото." + ), + ) + crossroads = Scene.objects.create( + title="Перепутье болот", + slug="crossroads", + body=( + "Дорога дробится на три тропы. В одной слышен шёпот, в другой — скрежет стали, " + "третья уходит в заросли, где поблескивает огонёк." + ), + ) + lair = Scene.objects.create( + title="Логово на мели", + slug="lair", + body=( + "Вы находите гнездо твари раньше, чем она замечает вас. Пахнет железом и болотной " + "травой. Время решить, как закончится охота." + ), + ) + ambush = Scene.objects.create( + title="Засада в камышах", + slug="ambush", + body=( + "Тварь поднимается прямо из воды. Ваша спина касается холодного дерева, а туман " + "съедает пути отступления." + ), + ) + village = Scene.objects.create( + title="Поселение Гнилых крыш", + slug="village", + body=( + "В деревне тихо. Староста готов говорить только после того, как вы разделите " + "с ним кружку чёрного настоя." + ), + ) + + Choice.objects.create( + scene=prologue, + text="Принять заказ и выдвинуться к болоту.", + next_scene=crossroads, + ) + Choice.objects.create( + scene=prologue, + text="Сначала расспросить старосту о пропавших.", + next_scene=village, + ) + Choice.objects.create( + scene=crossroads, + text="Выслеживать тварь по следам (Фокус 12).", + required_skill="focus", + difficulty=12, + success_scene=lair, + fail_scene=ambush, + reward_item=blade, + ) + Choice.objects.create( + scene=crossroads, + text="Зажечь факелы и идти напролом.", + next_scene=ambush, + ) + Choice.objects.create( + scene=village, + text="Попросить алхимические сведения (Алхимия 12).", + required_skill="alchemy", + difficulty=12, + success_scene=crossroads, + fail_scene=prologue, + ) + Choice.objects.create( + scene=ambush, + text="Отступить к тракту и перегруппироваться.", + next_scene=prologue, + ) + Choice.objects.create( + scene=lair, + text="Проверить добычу и вернуться с трофеями.", + next_scene=prologue, + reward_item=starter_item, + ) + + +def unseed_story(apps, schema_editor): + Scene = apps.get_model("core", "Scene") + Choice = apps.get_model("core", "Choice") + Item = apps.get_model("core", "Item") + Choice.objects.all().delete() + Scene.objects.all().delete() + Item.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_story, unseed_story), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..57141f3 Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0002_seed_story.cpython-311.pyc b/core/migrations/__pycache__/0002_seed_story.cpython-311.pyc new file mode 100644 index 0000000..fde6ec2 Binary files /dev/null and b/core/migrations/__pycache__/0002_seed_story.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..e5499a1 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,172 @@ from django.db import models -# Create your models here. + +class Scene(models.Model): + title = models.CharField(max_length=120) + slug = models.SlugField(unique=True) + body = models.TextField() + + class Meta: + ordering = ["id"] + + def __str__(self) -> str: + return self.title + + +class Item(models.Model): + SLOT_WEAPON = "weapon" + SLOT_ARMOR = "armor" + SLOT_TRINKET = "trinket" + SLOT_CHOICES = [ + (SLOT_WEAPON, "Weapon"), + (SLOT_ARMOR, "Armor"), + (SLOT_TRINKET, "Trinket"), + ] + + name = models.CharField(max_length=120) + slot = models.CharField(max_length=20, choices=SLOT_CHOICES) + power = models.PositiveIntegerField(default=0) + description = models.TextField() + + def __str__(self) -> str: + return self.name + + +class Choice(models.Model): + SKILL_VIGOR = "vigor" + SKILL_FOCUS = "focus" + SKILL_ALCHEMY = "alchemy" + SKILL_CHOICES = [ + (SKILL_VIGOR, "Vigor"), + (SKILL_FOCUS, "Focus"), + (SKILL_ALCHEMY, "Alchemy"), + ] + + scene = models.ForeignKey(Scene, related_name="choices", on_delete=models.CASCADE) + text = models.CharField(max_length=200) + next_scene = models.ForeignKey( + Scene, + related_name="incoming_choices", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + required_skill = models.CharField(max_length=20, choices=SKILL_CHOICES, blank=True) + difficulty = models.PositiveIntegerField(null=True, blank=True) + success_scene = models.ForeignKey( + Scene, + related_name="success_from", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + fail_scene = models.ForeignKey( + Scene, + related_name="fail_from", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + reward_item = models.ForeignKey( + Item, + related_name="reward_choices", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + class Meta: + ordering = ["id"] + + def __str__(self) -> str: + return f"{self.scene.title}: {self.text}" + + +class Character(models.Model): + BACKGROUND_WARDEN = "warden" + BACKGROUND_SCOUR = "scour" + BACKGROUND_ALCHEMIST = "alchemist" + BACKGROUND_CHOICES = [ + (BACKGROUND_WARDEN, "Warden of the North"), + (BACKGROUND_SCOUR, "Scour of the Roads"), + (BACKGROUND_ALCHEMIST, "Herb-born Alchemist"), + ] + + name = models.CharField(max_length=80) + background = models.CharField(max_length=20, choices=BACKGROUND_CHOICES) + level = models.PositiveIntegerField(default=1) + vigor = models.PositiveIntegerField(default=3) + focus = models.PositiveIntegerField(default=3) + alchemy = models.PositiveIntegerField(default=3) + perk_points = models.PositiveIntegerField(default=1) + current_scene = models.ForeignKey( + Scene, on_delete=models.SET_NULL, null=True, blank=True + ) + + def __str__(self) -> str: + return self.name + + +class Quest(models.Model): + STATUS_ACTIVE = "active" + STATUS_COMPLETE = "complete" + STATUS_FAILED = "failed" + STATUS_CHOICES = [ + (STATUS_ACTIVE, "Active"), + (STATUS_COMPLETE, "Complete"), + (STATUS_FAILED, "Failed"), + ] + + character = models.ForeignKey( + Character, related_name="quests", on_delete=models.CASCADE + ) + title = models.CharField(max_length=140) + summary = models.TextField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE) + + class Meta: + ordering = ["id"] + + def __str__(self) -> str: + return self.title + + +class InventoryItem(models.Model): + character = models.ForeignKey( + Character, related_name="inventory", on_delete=models.CASCADE + ) + item = models.ForeignKey(Item, related_name="inventory_items", on_delete=models.CASCADE) + equipped = models.BooleanField(default=False) + + class Meta: + ordering = ["id"] + + def __str__(self) -> str: + return f"{self.character.name} - {self.item.name}" + + +class StoryEntry(models.Model): + OUTCOME_SUCCESS = "success" + OUTCOME_FAIL = "fail" + OUTCOME_NEUTRAL = "neutral" + OUTCOME_CHOICES = [ + (OUTCOME_SUCCESS, "Success"), + (OUTCOME_FAIL, "Fail"), + (OUTCOME_NEUTRAL, "Neutral"), + ] + + character = models.ForeignKey( + Character, related_name="story_entries", on_delete=models.CASCADE + ) + scene = models.ForeignKey(Scene, related_name="story_entries", on_delete=models.CASCADE) + choice_text = models.CharField(max_length=200) + outcome = models.CharField(max_length=20, choices=OUTCOME_CHOICES) + roll = models.PositiveIntegerField(null=True, blank=True) + total = models.PositiveIntegerField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.character.name} - {self.choice_text}" diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..0a8feca 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,11 +1,16 @@ - + - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} + + {% block title %}Темная тропа{% endblock %} + {% if page_description %} + + {% elif project_description %} + {% endif %} + {% if project_description %} {% endif %} @@ -13,13 +18,52 @@ {% endif %} + + + + {% load static %} {% block head %}{% endblock %} - + + + {% block content %}{% endblock %} + + + diff --git a/core/templates/core/character_create.html b/core/templates/core/character_create.html new file mode 100644 index 0000000..9ef7dee --- /dev/null +++ b/core/templates/core/character_create.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block content %} +
+
+

Создать героя

+

Выберите происхождение и начните охоту за тайной Чёрной Трясины.

+
+
+
+ {% csrf_token %} +
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors|striptags }}
+ {% endif %} +
+
+ + {{ form.background }} + {% if form.background.errors %} +
{{ form.background.errors|striptags }}
+ {% endif %} +
+
+ Подсказка: выбор происхождения задаёт стартовые навыки. +
+ +
+
+
+{% endblock %} diff --git a/core/templates/core/character_detail.html b/core/templates/core/character_detail.html new file mode 100644 index 0000000..aa5607b --- /dev/null +++ b/core/templates/core/character_detail.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block content %} +
+
+
+

{{ character.name }}

+

Происхождение: {{ character.get_background_display }}

+
+ +
+ +
+
+

Характеристики

+
+
+

Уровень

+

Lv {{ character.level }}

+
+
+

Ви́гор

+

{{ character.vigor }}

+
+
+

Фокус

+

{{ character.focus }}

+
+
+

Алхимия

+

{{ character.alchemy }}

+
+
+

Перки

+

{{ character.perk_points }}

+
+
+

Откройте ветки перков в следующих итерациях.

+
+
+

Экипировка

+ {% if equipped %} +
    + {% for entry in equipped %} +
  • + {{ entry.item.name }} + {{ entry.item.get_slot_display }} +
  • + {% endfor %} +
+ {% else %} +

Экипировка пока не выбрана.

+ {% endif %} + Управлять лутом → +
+
+

Последние решения

+ {% if recent_entries %} +
    + {% for entry in recent_entries %} +
  • + {{ entry.scene.title }} + {{ entry.choice_text }} + {{ entry.get_outcome_display }} +
  • + {% endfor %} +
+ {% else %} +

Вы ещё не сделали выборов. Начните пролог.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..fe5e62e 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,133 @@ {% extends "base.html" %} +{% load static %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}{{ page_title }}{% endblock %} {% block content %}
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+ Мрачное фэнтези · интерактивная проза +

Ваш выбор — клинок, который меняет судьбу.

+

+ Пройдите историю в духе «Ведьмака»: выбирайте действия прямо в диалоге, проходите проверки навыков, + собирайте добычу и отслеживайте квесты в удобном журнале. +

+ +
+ Выборы и последствия + Сцены с проверками + Лут и экипировка + Перки и развитие +
+
+
+
+

Статус похода

+ {% if character %} +
+
+

Герой

+

{{ character.name }}

+
+
+

Уровень

+

Lv {{ character.level }}

+
+
+

Ви́гор

+

{{ character.vigor }}

+
+
+

Фокус

+

{{ character.focus }}

+
+
+

Алхимия

+

{{ character.alchemy }}

+
+
+ Открыть текущую сцену + {% else %} +

+ Создайте персонажа, чтобы открыть сюжет, журнал заданий и инвентарь. +

+ Начать пролог + {% endif %} +
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
+ + +
+
+

Текущие задания

+ {% if character %} + {% if quests %} +
    + {% for quest in quests %} +
  • + {{ quest.title }} + {{ quest.get_status_display }} +
  • + {% endfor %} +
+ Открыть весь журнал → + {% else %} +

В журнале пока пусто — начните сюжет, чтобы получить первое задание.

+ {% endif %} + {% else %} +

Создайте героя, чтобы получать квесты и отслеживать последствия.

+ {% endif %} +
+
+

Инвентарь и экипировка

+ {% if character %} + {% if inventory %} +
    + {% for entry in inventory %} +
  • + {{ entry.item.name }} + {{ entry.item.get_slot_display }} +
  • + {% endfor %} +
+ Управлять лутом → + {% else %} +

Пока нет предметов. Пройдите пролог, чтобы получить добычу.

+ {% endif %} + {% else %} +

Лут и экипировка появятся после создания персонажа.

+ {% endif %} +
+
+

Как работает выбор

+

+ Каждая сцена — это диалог. Вы выбираете действие, и система бросает проверку навыка. + Успех открывает лучшие сцены и редкий лут, провал запускает опасные ветки. +

+
+
+

Бросок

+

d20 + навык

+
+
+

Порог

+

Сцена

+
+
+ Перейти к сцене → +
+
- -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/core/templates/core/inventory.html b/core/templates/core/inventory.html new file mode 100644 index 0000000..6185473 --- /dev/null +++ b/core/templates/core/inventory.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block content %} +
+
+
+

Инвентарь

+

Сортируйте трофеи и экипируйте нужные предметы.

+
+ +
+ +
+ {% if items %} + {% for entry in items %} +
+

{{ entry.item.name }}

+

{{ entry.item.description }}

+
+ {{ entry.item.get_slot_display }} + {% if entry.equipped %} + Экипировано + {% endif %} +
+ Подробнее → +
+ {% endfor %} + {% else %} +
+

Инвентарь пуст

+

Пройдите пролог, чтобы получить первые трофеи.

+ Открыть сюжет → +
+ {% endif %} +
+
+{% endblock %} diff --git a/core/templates/core/item_detail.html b/core/templates/core/item_detail.html new file mode 100644 index 0000000..acb7f9b --- /dev/null +++ b/core/templates/core/item_detail.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block content %} +
+
+
+

{{ inventory_item.item.name }}

+

{{ inventory_item.item.get_slot_display }}

+
+ +
+ +
+

Описание

+

{{ inventory_item.item.description }}

+
+
+

Сила

+

+{{ inventory_item.item.power }}

+
+
+

Статус

+ {% if inventory_item.equipped %} +

Экипировано

+ {% else %} +

В запасе

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/core/templates/core/quest_detail.html b/core/templates/core/quest_detail.html new file mode 100644 index 0000000..3da8d97 --- /dev/null +++ b/core/templates/core/quest_detail.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block content %} +
+
+
+

{{ quest.title }}

+

Статус: {{ quest.get_status_display }}

+
+ +
+ +
+

Описание

+

{{ quest.summary }}

+
+
+

Герой

+

{{ quest.character.name }}

+
+
+

Уровень

+

Lv {{ quest.character.level }}

+
+
+
+
+{% endblock %} diff --git a/core/templates/core/quest_list.html b/core/templates/core/quest_list.html new file mode 100644 index 0000000..8ee0afa --- /dev/null +++ b/core/templates/core/quest_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block content %} +
+
+
+

Журнал заданий

+

Следите за основными и побочными ветками истории.

+
+ +
+ +
+ {% if quests %} + {% for quest in quests %} + + {% endfor %} + {% else %} +
+

Пока нет заданий

+

Пройдите первую сцену, чтобы получить квесты.

+ Открыть сюжет → +
+ {% endif %} +
+
+{% endblock %} diff --git a/core/templates/core/story.html b/core/templates/core/story.html new file mode 100644 index 0000000..9873603 --- /dev/null +++ b/core/templates/core/story.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %}{{ page_title }}{% endblock %} + +{% block content %} +
+
+
+

Сюжет

+

Ваш путь: {{ character.name }} · Lv {{ character.level }}

+
+ +
+ + {% if not scene %} +
+

Сцены ещё не настроены

+

Добавьте сцены и варианты выбора в админке, чтобы запустить сюжет.

+ Перейти в админку → +
+ {% else %} +
+
+ Сцена +

{{ scene.title }}

+

{{ scene.body }}

+ {% if last_entry %} +
+
+ Ваш выбор: {{ last_entry.choice_text }} +
+
+ {{ last_entry.get_outcome_display }} + {% if last_entry.roll %} + Бросок: {{ last_entry.roll }} · Итог: {{ last_entry.total }} + {% endif %} +
+
+ {% endif %} +
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 6299e3d..dac779c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,23 @@ from django.urls import path -from .views import home +from .views import ( + character_create, + character_detail, + home, + inventory_view, + item_detail, + quest_detail, + quest_list, + story_view, +) urlpatterns = [ path("", home, name="home"), + path("character/create/", character_create, name="character_create"), + path("character//", character_detail, name="character_detail"), + path("story/", story_view, name="story"), + path("quests/", quest_list, name="quest_list"), + path("quests//", quest_detail, name="quest_detail"), + path("inventory/", inventory_view, name="inventory"), + path("inventory/item//", item_detail, name="item_detail"), ] diff --git a/core/views.py b/core/views.py index c9aed12..71b8c83 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,239 @@ -import os -import platform +import random -from django import get_version as django_version -from django.shortcuts import render -from django.utils import timezone +from django.shortcuts import get_object_or_404, redirect, render + +from .forms import CharacterCreateForm, ChoiceForm +from .models import Character, Choice, InventoryItem, Item, Quest, Scene, StoryEntry + + +BACKGROUND_STATS = { + Character.BACKGROUND_WARDEN: {"vigor": 5, "focus": 3, "alchemy": 2}, + Character.BACKGROUND_SCOUR: {"vigor": 4, "focus": 4, "alchemy": 2}, + Character.BACKGROUND_ALCHEMIST: {"vigor": 2, "focus": 4, "alchemy": 5}, +} + + +def get_active_character(request): + character_id = request.session.get("active_character_id") + if character_id: + try: + return Character.objects.get(id=character_id) + except Character.DoesNotExist: + request.session.pop("active_character_id", None) + return None def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() + character = get_active_character(request) + quests = character.quests.all()[:3] if character else [] + inventory = character.inventory.select_related("item")[:3] if character else [] context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + "page_title": "Темная тропа — интерактивная RPG", + "page_description": "Мрачное текстовое RPG-приключение в духе ведьмачьих саг: выбор, проверки навыков, журнал заданий и лут.", + "character": character, + "quests": quests, + "inventory": inventory, } return render(request, "core/index.html", context) + + +def character_create(request): + if request.method == "POST": + form = CharacterCreateForm(request.POST) + if form.is_valid(): + background = form.cleaned_data["background"] + stats = BACKGROUND_STATS.get(background, {"vigor": 3, "focus": 3, "alchemy": 3}) + starting_scene = Scene.objects.first() + character = Character.objects.create( + name=form.cleaned_data["name"], + background=background, + vigor=stats["vigor"], + focus=stats["focus"], + alchemy=stats["alchemy"], + current_scene=starting_scene, + ) + request.session["active_character_id"] = character.id + starter_item = Item.objects.first() + if starter_item: + InventoryItem.objects.create( + character=character, item=starter_item, equipped=True + ) + Quest.objects.create( + character=character, + title="Шепот Чёрной Трясины", + summary="Разузнать, кто тревожит болота и почему местные боятся ночи.", + ) + return redirect("character_detail", pk=character.id) + else: + form = CharacterCreateForm() + + return render( + request, + "core/character_create.html", + { + "form": form, + "page_title": "Создать героя", + "page_description": "Соберите персонажа и начните мрачное приключение.", + }, + ) + + +def character_detail(request, pk): + character = get_object_or_404(Character, pk=pk) + request.session["active_character_id"] = character.id + equipped = character.inventory.select_related("item").filter(equipped=True) + recent_entries = character.story_entries.select_related("scene")[:4] + + return render( + request, + "core/character_detail.html", + { + "character": character, + "equipped": equipped, + "recent_entries": recent_entries, + "page_title": f"{character.name} — профиль героя", + "page_description": "Профиль персонажа, характеристики и недавние выборы.", + }, + ) + + +def story_view(request): + character = get_active_character(request) + if not character: + return redirect("character_create") + + scene = character.current_scene or Scene.objects.first() + if not scene: + return render( + request, + "core/story.html", + { + "character": character, + "scene": None, + "form": None, + "page_title": "Сюжет", + "page_description": "Пока нет сцен. Добавьте их через админку.", + }, + ) + + last_entry_id = request.session.pop("last_entry_id", None) + last_entry = None + if last_entry_id: + last_entry = StoryEntry.objects.filter( + id=last_entry_id, character=character + ).select_related("scene").first() + + if request.method == "POST": + form = ChoiceForm(request.POST, choice_queryset=scene.choices.all()) + if form.is_valid(): + choice = form.cleaned_data["choice"] + outcome = StoryEntry.OUTCOME_NEUTRAL + roll = None + total = None + next_scene = choice.next_scene or scene + + if choice.required_skill and choice.difficulty: + roll = random.randint(1, 20) + skill_value = getattr(character, choice.required_skill, 0) + total = roll + skill_value + success = total >= choice.difficulty + outcome = StoryEntry.OUTCOME_SUCCESS if success else StoryEntry.OUTCOME_FAIL + next_scene = choice.success_scene if success else choice.fail_scene + next_scene = next_scene or choice.next_scene or scene + + entry = StoryEntry.objects.create( + character=character, + scene=scene, + choice_text=choice.text, + outcome=outcome, + roll=roll, + total=total, + ) + + if choice.reward_item and outcome != StoryEntry.OUTCOME_FAIL: + InventoryItem.objects.get_or_create( + character=character, item=choice.reward_item + ) + + character.current_scene = next_scene + character.save(update_fields=["current_scene"]) + request.session["last_entry_id"] = entry.id + return redirect("story") + else: + form = ChoiceForm(choice_queryset=scene.choices.all()) + + return render( + request, + "core/story.html", + { + "character": character, + "scene": scene, + "form": form, + "last_entry": last_entry, + "page_title": "Сюжет", + "page_description": "Выбирайте действия и наблюдайте последствия.", + }, + ) + + +def quest_list(request): + character = get_active_character(request) + quests = character.quests.all() if character else [] + return render( + request, + "core/quest_list.html", + { + "character": character, + "quests": quests, + "page_title": "Журнал заданий", + "page_description": "Список текущих и завершённых заданий.", + }, + ) + + +def quest_detail(request, pk): + character = get_active_character(request) + quest = get_object_or_404(Quest, pk=pk) + return render( + request, + "core/quest_detail.html", + { + "character": character, + "quest": quest, + "page_title": quest.title, + "page_description": "Детали задания и его статус.", + }, + ) + + +def inventory_view(request): + character = get_active_character(request) + items = ( + character.inventory.select_related("item") if character else InventoryItem.objects.none() + ) + return render( + request, + "core/inventory.html", + { + "character": character, + "items": items, + "page_title": "Инвентарь", + "page_description": "Лут, экипировка и свойства предметов.", + }, + ) + + +def item_detail(request, pk): + character = get_active_character(request) + inventory_item = get_object_or_404(InventoryItem, pk=pk) + return render( + request, + "core/item_detail.html", + { + "character": character, + "inventory_item": inventory_item, + "page_title": inventory_item.item.name, + "page_description": "Подробности предмета и его эффектов.", + }, + ) diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..2cb68e6 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,391 @@ /* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +:root { + --noir-900: #0b0f12; + --noir-800: #12171c; + --noir-700: #1a2128; + --ash-200: #d7d2c8; + --ash-100: #f2efe8; + --accent-gold: #d4a84b; + --accent-crimson: #b7423b; + --accent-teal: #4aa3a1; + --glass: rgba(20, 26, 32, 0.75); +} + +body { + font-family: "Inter", system-ui, -apple-system, sans-serif; + background: radial-gradient(circle at top, #1a2026, #0b0f12 55%); + color: var(--ash-100); + min-height: 100vh; +} + +h1, h2, h3, .navbar-brand { + font-family: "Cormorant Garamond", serif; + letter-spacing: 0.02em; +} + +.site-nav { + background: linear-gradient(120deg, rgba(14, 18, 22, 0.95), rgba(20, 26, 32, 0.75)); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + position: sticky; + top: 0; + z-index: 100; +} + +.navbar-brand { + font-weight: 700; + font-size: 1.4rem; + color: var(--ash-100); +} + +.brand-tag { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.16em; +} + +.nav-link { + font-weight: 500; + color: rgba(255, 255, 255, 0.8); +} + +.nav-link:hover, +.nav-link:focus { + color: var(--accent-gold); +} + +.nav-link-admin { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 999px; + padding: 0.35rem 1rem; +} + +.hero { + padding: 5rem 0 3rem; + position: relative; + overflow: hidden; +} + +.hero::before { + content: ""; + position: absolute; + inset: -20% 0 0; + background: radial-gradient(circle, rgba(212, 168, 75, 0.15), transparent 55%); + z-index: 0; +} + +.hero-grid { + display: grid; + gap: 2.5rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + position: relative; + z-index: 1; +} + +.hero-text h1 { + font-size: clamp(2.6rem, 3vw + 1.6rem, 3.8rem); + margin-bottom: 1rem; +} + +.hero-lead { + color: rgba(255, 255, 255, 0.82); + font-size: 1.1rem; + line-height: 1.7; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 2rem 0 1.5rem; +} + +.hero-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.hero-tags span { + background: rgba(255, 255, 255, 0.08); + padding: 0.35rem 0.9rem; + border-radius: 999px; + font-size: 0.85rem; +} + +.glass-card { + background: var(--glass); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + padding: 2rem; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(16px); +} + +.hero-card { + position: relative; +} + +.hero-ornament { + position: absolute; + width: 140px; + height: 140px; + border-radius: 24px; + background: linear-gradient(135deg, rgba(74, 163, 161, 0.35), rgba(183, 66, 59, 0.25)); + right: -30px; + bottom: -30px; + filter: blur(2px); +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 0.7rem; + color: var(--accent-gold); +} + +.section-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + padding-bottom: 3rem; +} + +.section-card { + background: rgba(18, 23, 28, 0.75); + border-radius: 18px; + padding: 1.8rem; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.stat-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: rgba(255, 255, 255, 0.55); + margin-bottom: 0.35rem; +} + +.stat-value { + font-size: 1.3rem; + font-weight: 600; +} + +.muted { + color: rgba(255, 255, 255, 0.68); +} + +.text-link { + color: var(--accent-gold); + text-decoration: none; + font-weight: 600; +} + +.text-link:hover { + color: var(--accent-teal); +} + +.btn-primary { + background: linear-gradient(120deg, var(--accent-gold), #f0c76a); + border: none; + color: #1c1406; + font-weight: 600; + box-shadow: 0 12px 30px rgba(212, 168, 75, 0.35); +} + +.btn-outline-light { + border-color: rgba(255, 255, 255, 0.35); +} + +.page-head { + margin: 3rem 0 2rem; +} + +.split-head { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + justify-content: space-between; + align-items: center; +} + +.head-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.form-card { + max-width: 540px; +} + +.form-grid { + display: grid; + gap: 1.2rem; +} + +.form-error { + color: var(--accent-crimson); + font-size: 0.85rem; + margin-top: 0.35rem; +} + +.form-hint { + background: rgba(255, 255, 255, 0.06); + padding: 0.8rem 1rem; + border-radius: 12px; + font-size: 0.9rem; +} + +.profile-grid, +.story-grid, +.quest-grid, +.inventory-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.story-layout { + padding-bottom: 3rem; +} + +.story-scene h2 { + margin: 0.8rem 0 1rem; +} + +.story-body { + font-size: 1.05rem; + line-height: 1.75; +} + +.choice-form { + display: grid; + gap: 1.2rem; +} + +.choice-list { + display: grid; + gap: 0.8rem; +} + +.choice-item { + display: flex; + gap: 0.75rem; + align-items: flex-start; + padding: 0.8rem 1rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.choice-item input[type="radio"] { + margin-top: 0.25rem; +} + +.result-card { + margin-top: 1.5rem; + padding: 1rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.06); +} + +.result-meta { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + margin-top: 0.5rem; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); +} + +.info-list, +.timeline { + list-style: none; + padding: 0; + margin: 1rem 0; + display: grid; + gap: 0.75rem; +} + +.info-list li, +.timeline li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.04); + padding: 0.7rem 1rem; + border-radius: 12px; +} + +.timeline-title { + font-weight: 600; +} + +.timeline-meta { + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; +} + +.badge-outcome { + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.75rem; +} + +.badge-success { + background: rgba(74, 163, 161, 0.25); + color: var(--accent-teal); +} + +.badge-fail { + background: rgba(183, 66, 59, 0.25); + color: var(--accent-crimson); +} + +.badge-neutral { + background: rgba(212, 168, 75, 0.2); + color: var(--accent-gold); +} + +.badge-status { + background: rgba(255, 255, 255, 0.1); + color: var(--ash-200); +} + +.narrow-section { + padding-bottom: 3rem; +} + +.quest-meta, +.item-meta { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + margin-top: 1rem; +} + +.site-footer { + margin-top: 4rem; + padding: 2.5rem 0 3rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(8, 10, 12, 0.8); +} + +.footer-inner { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; + color: rgba(255, 255, 255, 0.6); } diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..2cb68e6 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,391 @@ - +/* Custom styles for the application */ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --noir-900: #0b0f12; + --noir-800: #12171c; + --noir-700: #1a2128; + --ash-200: #d7d2c8; + --ash-100: #f2efe8; + --accent-gold: #d4a84b; + --accent-crimson: #b7423b; + --accent-teal: #4aa3a1; + --glass: rgba(20, 26, 32, 0.75); } + body { - margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; + font-family: "Inter", system-ui, -apple-system, sans-serif; + background: radial-gradient(circle at top, #1a2026, #0b0f12 55%); + color: var(--ash-100); min-height: 100vh; - text-align: center; +} + +h1, h2, h3, .navbar-brand { + font-family: "Cormorant Garamond", serif; + letter-spacing: 0.02em; +} + +.site-nav { + background: linear-gradient(120deg, rgba(14, 18, 22, 0.95), rgba(20, 26, 32, 0.75)); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + position: sticky; + top: 0; + z-index: 100; +} + +.navbar-brand { + font-weight: 700; + font-size: 1.4rem; + color: var(--ash-100); +} + +.brand-tag { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.16em; +} + +.nav-link { + font-weight: 500; + color: rgba(255, 255, 255, 0.8); +} + +.nav-link:hover, +.nav-link:focus { + color: var(--accent-gold); +} + +.nav-link-admin { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 999px; + padding: 0.35rem 1rem; +} + +.hero { + padding: 5rem 0 3rem; + position: relative; overflow: hidden; +} + +.hero::before { + content: ""; + position: absolute; + inset: -20% 0 0; + background: radial-gradient(circle, rgba(212, 168, 75, 0.15), transparent 55%); + z-index: 0; +} + +.hero-grid { + display: grid; + gap: 2.5rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + position: relative; + z-index: 1; +} + +.hero-text h1 { + font-size: clamp(2.6rem, 3vw + 1.6rem, 3.8rem); + margin-bottom: 1rem; +} + +.hero-lead { + color: rgba(255, 255, 255, 0.82); + font-size: 1.1rem; + line-height: 1.7; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 2rem 0 1.5rem; +} + +.hero-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.hero-tags span { + background: rgba(255, 255, 255, 0.08); + padding: 0.35rem 0.9rem; + border-radius: 999px; + font-size: 0.85rem; +} + +.glass-card { + background: var(--glass); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + padding: 2rem; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(16px); +} + +.hero-card { position: relative; } + +.hero-ornament { + position: absolute; + width: 140px; + height: 140px; + border-radius: 24px; + background: linear-gradient(135deg, rgba(74, 163, 161, 0.35), rgba(183, 66, 59, 0.25)); + right: -30px; + bottom: -30px; + filter: blur(2px); +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 0.7rem; + color: var(--accent-gold); +} + +.section-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + padding-bottom: 3rem; +} + +.section-card { + background: rgba(18, 23, 28, 0.75); + border-radius: 18px; + padding: 1.8rem; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.stat-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: rgba(255, 255, 255, 0.55); + margin-bottom: 0.35rem; +} + +.stat-value { + font-size: 1.3rem; + font-weight: 600; +} + +.muted { + color: rgba(255, 255, 255, 0.68); +} + +.text-link { + color: var(--accent-gold); + text-decoration: none; + font-weight: 600; +} + +.text-link:hover { + color: var(--accent-teal); +} + +.btn-primary { + background: linear-gradient(120deg, var(--accent-gold), #f0c76a); + border: none; + color: #1c1406; + font-weight: 600; + box-shadow: 0 12px 30px rgba(212, 168, 75, 0.35); +} + +.btn-outline-light { + border-color: rgba(255, 255, 255, 0.35); +} + +.page-head { + margin: 3rem 0 2rem; +} + +.split-head { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + justify-content: space-between; + align-items: center; +} + +.head-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.form-card { + max-width: 540px; +} + +.form-grid { + display: grid; + gap: 1.2rem; +} + +.form-error { + color: var(--accent-crimson); + font-size: 0.85rem; + margin-top: 0.35rem; +} + +.form-hint { + background: rgba(255, 255, 255, 0.06); + padding: 0.8rem 1rem; + border-radius: 12px; + font-size: 0.9rem; +} + +.profile-grid, +.story-grid, +.quest-grid, +.inventory-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.story-layout { + padding-bottom: 3rem; +} + +.story-scene h2 { + margin: 0.8rem 0 1rem; +} + +.story-body { + font-size: 1.05rem; + line-height: 1.75; +} + +.choice-form { + display: grid; + gap: 1.2rem; +} + +.choice-list { + display: grid; + gap: 0.8rem; +} + +.choice-item { + display: flex; + gap: 0.75rem; + align-items: flex-start; + padding: 0.8rem 1rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.choice-item input[type="radio"] { + margin-top: 0.25rem; +} + +.result-card { + margin-top: 1.5rem; + padding: 1rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.06); +} + +.result-meta { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + margin-top: 0.5rem; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); +} + +.info-list, +.timeline { + list-style: none; + padding: 0; + margin: 1rem 0; + display: grid; + gap: 0.75rem; +} + +.info-list li, +.timeline li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.04); + padding: 0.7rem 1rem; + border-radius: 12px; +} + +.timeline-title { + font-weight: 600; +} + +.timeline-meta { + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; +} + +.badge-outcome { + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.75rem; +} + +.badge-success { + background: rgba(74, 163, 161, 0.25); + color: var(--accent-teal); +} + +.badge-fail { + background: rgba(183, 66, 59, 0.25); + color: var(--accent-crimson); +} + +.badge-neutral { + background: rgba(212, 168, 75, 0.2); + color: var(--accent-gold); +} + +.badge-status { + background: rgba(255, 255, 255, 0.1); + color: var(--ash-200); +} + +.narrow-section { + padding-bottom: 3rem; +} + +.quest-meta, +.item-meta { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + margin-top: 1rem; +} + +.site-footer { + margin-top: 4rem; + padding: 2.5rem 0 3rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(8, 10, 12, 0.8); +} + +.footer-inner { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; + color: rgba(255, 255, 255, 0.6); +}