Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
ecf8b89881 Auto commit: 2026-03-05T11:04:00.570Z 2026-03-05 11:04:00 +00:00
25 changed files with 2015 additions and 178 deletions

Binary file not shown.

View File

@ -1,3 +1,55 @@
from django.contrib import admin 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",)

28
core/forms.py Normal file
View File

@ -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"})

View File

@ -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'],
},
),
]

View File

@ -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),
]

View File

@ -1,3 +1,172 @@
from django.db import models 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}"

View File

@ -1,11 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% if project_description %} <title>{% block title %}Темная тропа{% endblock %}</title>
{% if page_description %}
<meta name="description" content="{{ page_description }}">
{% elif project_description %}
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{{ project_description }}">
{% endif %}
{% if project_description %}
<meta property="og:description" content="{{ project_description }}"> <meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}"> <meta property="twitter:description" content="{{ project_description }}">
{% endif %} {% endif %}
@ -13,13 +18,52 @@
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
{% load static %} {% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body class="bg-noir text-light">
<header class="site-nav">
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'home' %}">
Темная тропа
<span class="brand-tag">Dark Path RPG</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center">
<li class="nav-item"><a class="nav-link" href="{% url 'story' %}">Сюжет</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'quest_list' %}">Журнал</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'inventory' %}">Инвентарь</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'character_create' %}">Новый герой</a></li>
<li class="nav-item"><a class="nav-link nav-link-admin" href="/admin/" target="_blank" rel="noopener">Админка</a></li>
</ul>
</div>
</div>
</nav>
</header>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<footer class="site-footer">
<div class="container">
<div class="footer-inner">
<div>
<strong>Темная тропа</strong> — интерактивная история в духе ведьмачьих хроник.
</div>
<div class="footer-meta">Собрано на Django · Развивайте мир через админку</div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main class="container narrow-section">
<div class="page-head">
<h1>Создать героя</h1>
<p class="muted">Выберите происхождение и начните охоту за тайной Чёрной Трясины.</p>
</div>
<div class="glass-card form-card">
<form method="post" class="form-grid">
{% csrf_token %}
<div>
<label class="form-label" for="{{ form.name.id_for_label }}">Имя героя</label>
{{ form.name }}
{% if form.name.errors %}
<div class="form-error">{{ form.name.errors|striptags }}</div>
{% endif %}
</div>
<div>
<label class="form-label" for="{{ form.background.id_for_label }}">Происхождение</label>
{{ form.background }}
{% if form.background.errors %}
<div class="form-error">{{ form.background.errors|striptags }}</div>
{% endif %}
</div>
<div class="form-hint">
<strong>Подсказка:</strong> выбор происхождения задаёт стартовые навыки.
</div>
<button type="submit" class="btn btn-primary btn-lg">Сотворить героя</button>
</form>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main class="container">
<div class="page-head split-head">
<div>
<h1>{{ character.name }}</h1>
<p class="muted">Происхождение: {{ character.get_background_display }}</p>
</div>
<div class="head-actions">
<a class="btn btn-outline-light" href="{% url 'story' %}">Продолжить сюжет</a>
<a class="btn btn-outline-light" href="{% url 'inventory' %}">Инвентарь</a>
</div>
</div>
<section class="profile-grid">
<div class="glass-card">
<h3>Характеристики</h3>
<div class="stat-grid">
<div>
<p class="stat-label">Уровень</p>
<p class="stat-value">Lv {{ character.level }}</p>
</div>
<div>
<p class="stat-label">Ви́гор</p>
<p class="stat-value">{{ character.vigor }}</p>
</div>
<div>
<p class="stat-label">Фокус</p>
<p class="stat-value">{{ character.focus }}</p>
</div>
<div>
<p class="stat-label">Алхимия</p>
<p class="stat-value">{{ character.alchemy }}</p>
</div>
<div>
<p class="stat-label">Перки</p>
<p class="stat-value">{{ character.perk_points }}</p>
</div>
</div>
<p class="muted">Откройте ветки перков в следующих итерациях.</p>
</div>
<div class="glass-card">
<h3>Экипировка</h3>
{% if equipped %}
<ul class="info-list">
{% for entry in equipped %}
<li>
<span>{{ entry.item.name }}</span>
<span class="badge bg-dark-subtle text-light">{{ entry.item.get_slot_display }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Экипировка пока не выбрана.</p>
{% endif %}
<a class="text-link" href="{% url 'inventory' %}">Управлять лутом →</a>
</div>
<div class="glass-card">
<h3>Последние решения</h3>
{% if recent_entries %}
<ul class="timeline">
{% for entry in recent_entries %}
<li>
<span class="timeline-title">{{ entry.scene.title }}</span>
<span class="timeline-meta">{{ entry.choice_text }}</span>
<span class="badge badge-outcome badge-{{ entry.outcome }}">{{ entry.get_outcome_display }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Вы ещё не сделали выборов. Начните пролог.</p>
{% endif %}
</div>
</section>
</main>
{% endblock %}

View File

@ -1,145 +1,133 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}{{ page_title }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
: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);
}
* {
box-sizing: border-box;
}
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;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<main> <main>
<div class="card"> <section class="hero">
<h1>Analyzing your requirements and generating your app…</h1> <div class="container hero-grid">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="hero-text">
<span class="sr-only">Loading…</span> <span class="eyebrow">Мрачное фэнтези · интерактивная проза</span>
</div> <h1>Ваш выбор — клинок, который меняет судьбу.</h1>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> <p class="hero-lead">
<p class="hint">This page will refresh automatically as the plan is implemented.</p> Пройдите историю в духе «Ведьмака»: выбирайте действия прямо в диалоге, проходите проверки навыков,
<p class="runtime"> собирайте добычу и отслеживайте квесты в удобном журнале.
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p> </p>
<div class="hero-actions">
{% if character %}
<a class="btn btn-primary btn-lg" href="{% url 'story' %}">Продолжить сюжет</a>
<a class="btn btn-outline-light btn-lg" href="{% url 'character_detail' character.id %}">Профиль героя</a>
{% else %}
<a class="btn btn-primary btn-lg" href="{% url 'character_create' %}">Создать героя</a>
<a class="btn btn-outline-light btn-lg" href="{% url 'story' %}">Посмотреть пролог</a>
{% endif %}
</div> </div>
<div class="hero-tags">
<span>Выборы и последствия</span>
<span>Сцены с проверками</span>
<span>Лут и экипировка</span>
<span>Перки и развитие</span>
</div>
</div>
<div class="hero-card">
<div class="glass-card">
<h3>Статус похода</h3>
{% if character %}
<div class="stat-grid">
<div>
<p class="stat-label">Герой</p>
<p class="stat-value">{{ character.name }}</p>
</div>
<div>
<p class="stat-label">Уровень</p>
<p class="stat-value">Lv {{ character.level }}</p>
</div>
<div>
<p class="stat-label">Ви́гор</p>
<p class="stat-value">{{ character.vigor }}</p>
</div>
<div>
<p class="stat-label">Фокус</p>
<p class="stat-value">{{ character.focus }}</p>
</div>
<div>
<p class="stat-label">Алхимия</p>
<p class="stat-value">{{ character.alchemy }}</p>
</div>
</div>
<a class="btn btn-outline-light w-100" href="{% url 'story' %}">Открыть текущую сцену</a>
{% else %}
<p class="muted">
Создайте персонажа, чтобы открыть сюжет, журнал заданий и инвентарь.
</p>
<a class="btn btn-outline-light w-100" href="{% url 'character_create' %}">Начать пролог</a>
{% endif %}
</div>
<div class="hero-ornament"></div>
</div>
</div>
</section>
<section class="container section-grid">
<div class="section-card">
<h2>Текущие задания</h2>
{% if character %}
{% if quests %}
<ul class="info-list">
{% for quest in quests %}
<li>
<a href="{% url 'quest_detail' quest.id %}">{{ quest.title }}</a>
<span class="badge bg-dark-subtle text-light">{{ quest.get_status_display }}</span>
</li>
{% endfor %}
</ul>
<a class="text-link" href="{% url 'quest_list' %}">Открыть весь журнал →</a>
{% else %}
<p class="muted">В журнале пока пусто — начните сюжет, чтобы получить первое задание.</p>
{% endif %}
{% else %}
<p class="muted">Создайте героя, чтобы получать квесты и отслеживать последствия.</p>
{% endif %}
</div>
<div class="section-card">
<h2>Инвентарь и экипировка</h2>
{% if character %}
{% if inventory %}
<ul class="info-list">
{% for entry in inventory %}
<li>
<span>{{ entry.item.name }}</span>
<span class="badge bg-dark-subtle text-light">{{ entry.item.get_slot_display }}</span>
</li>
{% endfor %}
</ul>
<a class="text-link" href="{% url 'inventory' %}">Управлять лутом →</a>
{% else %}
<p class="muted">Пока нет предметов. Пройдите пролог, чтобы получить добычу.</p>
{% endif %}
{% else %}
<p class="muted">Лут и экипировка появятся после создания персонажа.</p>
{% endif %}
</div>
<div class="section-card">
<h2>Как работает выбор</h2>
<p class="muted">
Каждая сцена — это диалог. Вы выбираете действие, и система бросает проверку навыка.
Успех открывает лучшие сцены и редкий лут, провал запускает опасные ветки.
</p>
<div class="stat-grid">
<div>
<p class="stat-label">Бросок</p>
<p class="stat-value">d20 + навык</p>
</div>
<div>
<p class="stat-label">Порог</p>
<p class="stat-value">Сцена</p>
</div>
</div>
<a class="text-link" href="{% url 'story' %}">Перейти к сцене →</a>
</div>
</section>
</main> </main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main class="container">
<div class="page-head split-head">
<div>
<h1>Инвентарь</h1>
<p class="muted">Сортируйте трофеи и экипируйте нужные предметы.</p>
</div>
<div class="head-actions">
<a class="btn btn-outline-light" href="{% url 'story' %}">К сюжету</a>
</div>
</div>
<section class="inventory-grid">
{% if items %}
{% for entry in items %}
<article class="glass-card item-card">
<h3>{{ entry.item.name }}</h3>
<p class="muted">{{ entry.item.description }}</p>
<div class="item-meta">
<span class="badge bg-dark-subtle text-light">{{ entry.item.get_slot_display }}</span>
{% if entry.equipped %}
<span class="badge badge-outcome badge-success">Экипировано</span>
{% endif %}
</div>
<a class="text-link" href="{% url 'item_detail' entry.id %}">Подробнее →</a>
</article>
{% endfor %}
{% else %}
<div class="glass-card">
<h3>Инвентарь пуст</h3>
<p class="muted">Пройдите пролог, чтобы получить первые трофеи.</p>
<a class="text-link" href="{% url 'story' %}">Открыть сюжет →</a>
</div>
{% endif %}
</section>
</main>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main class="container narrow-section">
<div class="page-head split-head">
<div>
<h1>{{ inventory_item.item.name }}</h1>
<p class="muted">{{ inventory_item.item.get_slot_display }}</p>
</div>
<div class="head-actions">
<a class="btn btn-outline-light" href="{% url 'inventory' %}">Назад к инвентарю</a>
</div>
</div>
<div class="glass-card">
<h3>Описание</h3>
<p class="muted">{{ inventory_item.item.description }}</p>
<div class="quest-meta">
<div>
<p class="stat-label">Сила</p>
<p class="stat-value">+{{ inventory_item.item.power }}</p>
</div>
<div>
<p class="stat-label">Статус</p>
{% if inventory_item.equipped %}
<p class="stat-value">Экипировано</p>
{% else %}
<p class="stat-value">В запасе</p>
{% endif %}
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main class="container narrow-section">
<div class="page-head split-head">
<div>
<h1>{{ quest.title }}</h1>
<p class="muted">Статус: {{ quest.get_status_display }}</p>
</div>
<div class="head-actions">
<a class="btn btn-outline-light" href="{% url 'quest_list' %}">Назад к журналу</a>
</div>
</div>
<div class="glass-card">
<h3>Описание</h3>
<p class="muted">{{ quest.summary }}</p>
<div class="quest-meta">
<div>
<p class="stat-label">Герой</p>
<p class="stat-value">{{ quest.character.name }}</p>
</div>
<div>
<p class="stat-label">Уровень</p>
<p class="stat-value">Lv {{ quest.character.level }}</p>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main class="container">
<div class="page-head split-head">
<div>
<h1>Журнал заданий</h1>
<p class="muted">Следите за основными и побочными ветками истории.</p>
</div>
<div class="head-actions">
<a class="btn btn-outline-light" href="{% url 'story' %}">Вернуться к сюжету</a>
</div>
</div>
<section class="quest-grid">
{% if quests %}
{% for quest in quests %}
<article class="glass-card quest-card">
<h3>{{ quest.title }}</h3>
<p class="muted">{{ quest.summary }}</p>
<span class="badge badge-status badge-{{ quest.status }}">{{ quest.get_status_display }}</span>
<a class="text-link" href="{% url 'quest_detail' quest.id %}">Подробнее →</a>
</article>
{% endfor %}
{% else %}
<div class="glass-card">
<h3>Пока нет заданий</h3>
<p class="muted">Пройдите первую сцену, чтобы получить квесты.</p>
<a class="text-link" href="{% url 'story' %}">Открыть сюжет →</a>
</div>
{% endif %}
</section>
</main>
{% endblock %}

View File

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<main class="container story-layout">
<div class="page-head split-head">
<div>
<h1>Сюжет</h1>
<p class="muted">Ваш путь: {{ character.name }} · Lv {{ character.level }}</p>
</div>
<div class="head-actions">
<a class="btn btn-outline-light" href="{% url 'quest_list' %}">Журнал</a>
<a class="btn btn-outline-light" href="{% url 'inventory' %}">Инвентарь</a>
</div>
</div>
{% if not scene %}
<div class="glass-card">
<h3>Сцены ещё не настроены</h3>
<p class="muted">Добавьте сцены и варианты выбора в админке, чтобы запустить сюжет.</p>
<a class="text-link" href="/admin/" target="_blank" rel="noopener">Перейти в админку →</a>
</div>
{% else %}
<div class="story-grid">
<section class="glass-card story-scene">
<span class="eyebrow">Сцена</span>
<h2>{{ scene.title }}</h2>
<p class="story-body">{{ scene.body }}</p>
{% if last_entry %}
<div class="result-card">
<div>
<strong>Ваш выбор:</strong> {{ last_entry.choice_text }}
</div>
<div class="result-meta">
<span class="badge badge-outcome badge-{{ last_entry.outcome }}">{{ last_entry.get_outcome_display }}</span>
{% if last_entry.roll %}
<span>Бросок: {{ last_entry.roll }} · Итог: {{ last_entry.total }}</span>
{% endif %}
</div>
</div>
{% endif %}
</section>
<aside class="glass-card story-choices">
<h3>Выберите действие</h3>
<form method="post" class="choice-form">
{% csrf_token %}
{% if form %}
<div class="choice-list">
{% for field in form.choice %}
<label class="choice-item">
{{ field.tag }}
<span class="choice-text">{{ field.choice_label }}</span>
</label>
{% endfor %}
</div>
{% if form.choice.errors %}
<div class="form-error">{{ form.choice.errors|striptags }}</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">Подтвердить</button>
{% else %}
<p class="muted">Для этой сцены нет вариантов выбора.</p>
{% endif %}
</form>
</aside>
</div>
{% endif %}
</main>
{% endblock %}

View File

@ -1,7 +1,23 @@
from django.urls import path 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 = [ urlpatterns = [
path("", home, name="home"), path("", home, name="home"),
path("character/create/", character_create, name="character_create"),
path("character/<int:pk>/", character_detail, name="character_detail"),
path("story/", story_view, name="story"),
path("quests/", quest_list, name="quest_list"),
path("quests/<int:pk>/", quest_detail, name="quest_detail"),
path("inventory/", inventory_view, name="inventory"),
path("inventory/item/<int:pk>/", item_detail, name="item_detail"),
] ]

View File

@ -1,25 +1,239 @@
import os import random
import platform
from django import get_version as django_version from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import render
from django.utils import timezone 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): def home(request):
"""Render the landing screen with loader and environment details.""" character = get_active_character(request)
host_name = request.get_host().lower() quests = character.quests.all()[:3] if character else []
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" inventory = character.inventory.select_related("item")[:3] if character else []
now = timezone.now()
context = { context = {
"project_name": "New Style", "page_title": "Темная тропа — интерактивная RPG",
"agent_brand": agent_brand, "page_description": "Мрачное текстовое RPG-приключение в духе ведьмачьих саг: выбор, проверки навыков, журнал заданий и лут.",
"django_version": django_version(), "character": character,
"python_version": platform.python_version(), "quests": quests,
"current_time": now, "inventory": inventory,
"host_name": host_name,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) 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": "Подробности предмета и его эффектов.",
},
)

View File

@ -1,4 +1,391 @@
/* Custom styles for the application */ /* Custom styles for the application */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --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);
} }

View File

@ -1,21 +1,391 @@
/* Custom styles for the application */
:root { :root {
--bg-color-start: #6a11cb; --noir-900: #0b0f12;
--bg-color-end: #2575fc; --noir-800: #12171c;
--text-color: #ffffff; --noir-700: #1a2128;
--card-bg-color: rgba(255, 255, 255, 0.01); --ash-200: #d7d2c8;
--card-border-color: rgba(255, 255, 255, 0.1); --ash-100: #f2efe8;
--accent-gold: #d4a84b;
--accent-crimson: #b7423b;
--accent-teal: #4aa3a1;
--glass: rgba(20, 26, 32, 0.75);
} }
body { body {
margin: 0; font-family: "Inter", system-ui, -apple-system, sans-serif;
font-family: 'Inter', sans-serif; background: radial-gradient(circle at top, #1a2026, #0b0f12 55%);
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); color: var(--ash-100);
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh; 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; 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; 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);
}