Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecf8b89881 |
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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",)
|
||||
|
||||
28
core/forms.py
Normal file
28
core/forms.py
Normal 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"})
|
||||
113
core/migrations/0001_initial.py
Normal file
113
core/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
127
core/migrations/0002_seed_story.py
Normal file
127
core/migrations/0002_seed_story.py
Normal 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),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0002_seed_story.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0002_seed_story.cpython-311.pyc
Normal file
Binary file not shown.
171
core/models.py
171
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}"
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Темная тропа{% endblock %}</title>
|
||||
{% if page_description %}
|
||||
<meta name="description" content="{{ page_description }}">
|
||||
{% elif project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_description %}
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
@ -13,13 +18,52 @@
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% 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 %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</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 %}
|
||||
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
35
core/templates/core/character_create.html
Normal file
35
core/templates/core/character_create.html
Normal 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 %}
|
||||
79
core/templates/core/character_detail.html
Normal file
79
core/templates/core/character_detail.html
Normal 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 %}
|
||||
@ -1,145 +1,133 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ project_name }}{% 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 title %}{{ page_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="hero">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<span class="eyebrow">Мрачное фэнтези · интерактивная проза</span>
|
||||
<h1>Ваш выбор — клинок, который меняет судьбу.</h1>
|
||||
<p class="hero-lead">
|
||||
Пройдите историю в духе «Ведьмака»: выбирайте действия прямо в диалоге, проходите проверки навыков,
|
||||
собирайте добычу и отслеживайте квесты в удобном журнале.
|
||||
</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 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>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<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>
|
||||
</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>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
41
core/templates/core/inventory.html
Normal file
41
core/templates/core/inventory.html
Normal 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 %}
|
||||
36
core/templates/core/item_detail.html
Normal file
36
core/templates/core/item_detail.html
Normal 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 %}
|
||||
32
core/templates/core/quest_detail.html
Normal file
32
core/templates/core/quest_detail.html
Normal 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 %}
|
||||
36
core/templates/core/quest_list.html
Normal file
36
core/templates/core/quest_list.html
Normal 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 %}
|
||||
70
core/templates/core/story.html
Normal file
70
core/templates/core/story.html
Normal 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 %}
|
||||
18
core/urls.py
18
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/<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"),
|
||||
]
|
||||
|
||||
248
core/views.py
248
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": "Подробности предмета и его эффектов.",
|
||||
},
|
||||
)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user