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 %}
+
+
+
Создать героя
+
Выберите происхождение и начните охоту за тайной Чёрной Трясины.
+
+
+
+{% 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 %}
+
+
+
Как работает выбор
+
+ Каждая сцена — это диалог. Вы выбираете действие, и система бросает проверку навыка.
+ Успех открывает лучшие сцены и редкий лут, провал запускает опасные ветки.
+
+
+
Перейти к сцене →
+
+
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
-
-{% 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 }}
+
+
+
+{% 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 }}
+
+
+
+{% 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 %}
+
+ {{ quest.title }}
+ {{ quest.summary }}
+ {{ quest.get_status_display }}
+ Подробнее →
+
+ {% 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);
+}