Загрузка данных


================================================================
  SHATTERED GENESIS — «Расколотый Первомир»
  ПОЛНЫЙ ОТЧЁТ КОДА · v0.4.26.11
  G11: нативный курсор, независимая панель, физический 3x2 костёр
  Дата пересборки отчёта: 2026-08-12
================================================================

================================================================
=== ФАЙЛ: Assets/Atlases/StoryParticles.json
================================================================
{
  "frames": {
    "Ember": {
      "frame": {
        "x": 0,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "rotated": false,
      "trimmed": false,
      "spriteSourceSize": {
        "x": 0,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "sourceSize": {
        "w": 16,
        "h": 16
      }
    },
    "Firefly": {
      "frame": {
        "x": 16,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "rotated": false,
      "trimmed": false,
      "spriteSourceSize": {
        "x": 0,
        "y": 0,
        "w": 16,
        "h": 16
      },
      "sourceSize": {
        "w": 16,
        "h": 16
      }
    }
  },
  "meta": {
    "app": "free-tex-packer (human-made by SG techdept)",
    "image": "StoryParticles.png",
    "size": {
      "w": 32,
      "h": 16
    },
    "scale": "1"
  }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/CampDarkness.fx
================================================================
// CampDarkness.fx — абсолютная сюжетная ночь вокруг привала.
// Luminance автоматически регистрирует фильтр как
// "ShatteredGenesis.CampDarkness" и подаёт uImage0/opacity/screenSize.

sampler uImage0 : register(s0);

float4x4 MatrixTransform;
float2 screenSize;
float opacity;
float globalTime;

float2 lightCenter; // координаты костра в UV экрана
float lightRadius;  // радиус по высоте экрана
float darkness;     // 0..1

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput DarknessVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float4 DarknessPS(float2 uv : TEXCOORD0) : COLOR0
{
    float4 world = tex2D(uImage0, uv);

    // Круг остаётся кругом на широком мониторе.
    float2 delta = uv - lightCenter;
    delta.x *= screenSize.x / max(screenSize.y, 1.0);
    float distanceFromFire = length(delta);

    // Внутри костра изображение живёт, на мягкой кромке быстро исчезает,
    // за пределами outer — математический ноль, а не полупрозрачная виньетка.
    float inner = lightRadius * 0.58;
    float outer = lightRadius;
    float visibility = 1.0 - smoothstep(inner, outer, distanceFromFire);

    // Небольшое дыхание света не расширяет внешний абсолютный ноль.
    float breath = 0.94 + 0.06 * sin(globalTime * 8.0);
    float core = 1.0 - smoothstep(0.0, max(inner, 0.001), distanceFromFire);
    float3 warm = float3(1.0, 0.34, 0.055) * core * 0.10 * breath;

    float3 extinguished = world.rgb * visibility * breath + warm * visibility;
    float strength = saturate(opacity * darkness);
    world.rgb = lerp(world.rgb, extinguished, strength);
    return world;
}

technique CampDarknessTechnique
{
    pass AutoloadPass
    {
        VertexShader = compile vs_2_0 DarknessVS();
        PixelShader = compile ps_2_0 DarknessPS();
    }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/SleepFade.fx
================================================================
// ============================================================
// SleepFade.fx — экранный ФИЛЬТР сна (финал сцены «Привал»).
// Канон 0.4.26 «Глубокая интеграция»: затемнение засыпания идёт
// через экранный конвейер Luminance ManagedScreenFilter
// (AutoloadedEffects/Filters → регистрируется как
// "ShatteredGenesis.SleepFade").
//
// УСТАВ ФИЛЬТРОВ LUMINANCE (ManagedScreenFilter.cs, сверено):
//   * фильтр активируется каждый кадр вызовом Activate();
//   * Opacity растёт/падает сама (0.015 за тик) и подаётся в шейдер
//     АВТОМАТИЧЕСКИМ параметром "opacity";
//   * "screenSize" и "globalTime" ставятся менеджером сами;
//   * экран мира приезжает текстурой в слоте 0 (uImage0);
//   * пасс техники ОБЯЗАН зваться AutoloadPass.
// ============================================================

sampler uImage0 : register(s0);

float4x4 MatrixTransform;
float2 screenSize;
float opacity;      // авто: текущая сила фильтра (0..1, догоняется менеджером)
float globalTime;   // авто: мировые часы

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput FadeVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float4 FadePS(float2 uv : TEXCOORD0) : COLOR0
{
    float4 color = tex2D(uImage0, uv);

    // Засыпание: гасим яркость неравномерно — края вянут быстрее
    // (сон начинается с периферии зрения), центр держится дольше.
    float2 c = uv - 0.5;
    c.x *= screenSize.x / screenSize.y;
    float d = length(c);

    float dim = opacity * (0.45 + 0.55 * saturate(d * 1.7));
    float flicker = 1.0 - 0.02 * sin(globalTime * 7.0) * opacity; // конвульсия век

    color.rgb *= (1.0 - saturate(dim)) * flicker;
    return color;
}

technique FadeTechnique
{
    pass AutoloadPass
    {
        VertexShader = compile vs_2_0 FadeVS();
        PixelShader = compile ps_2_0 FadePS();
    }
}

================================================================
=== ФАЙЛ: AutoloadedEffects/Shaders/SleepVeil.fx
================================================================
// ============================================================
// SleepVeil.fx — пелена экрана загрузки подмира.
// Канон 0.4.26 «Глубокая интеграция»: виньетка НЕ полосами
// прямоугольников, а одним экранным проходом шейдера через
// Luminance ShaderManager (AutoloadedEffects/Shaders).
//
// Путь внутри мода выбран ПРАВИЛОМ Luminance (ShaderManager.cs,
// сверено с исходником): всё, что лежит в "AutoloadedEffects/Shaders",
// библиотека сама компилирует и регистрирует под именем
// "ShatteredGenesis.SleepVeil".
//
// Параметры ставит DecoyWorld.DrawSetup через TrySetParameter.
// ============================================================

float4x4 MatrixTransform;   // конвенция SpriteBatch: движок сам кладёт сюда матрицу
float2 screenSize;          // физические пиксели вьюпорта (коррекция искажения круга)
float opacity;              // сила пелены (0..1) — вместе с вплытием арта
float globalTime;           // часы «дыхания» углей

struct VertexOutput
{
    float4 Position : POSITION0;
    float2 TexCoord : TEXCOORD0;
};

VertexOutput VeilVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
    VertexOutput o;
    o.Position = mul(position, MatrixTransform);
    o.TexCoord = texCoord;
    return o;
}

float4 VeilPS(float2 uv : TEXCOORD0) : COLOR0
{
    // Радиальная виньетка: чем дальше от центра — тем глуше ночь.
    // Коррекция по горизонтали, чтобы овал не растягивался монитором.
    float2 c = uv - 0.5;
    c.x *= screenSize.x / screenSize.y;
    float d = length(c) * 1.5;

    float veil = saturate(d * d);                     // мягкий квадратичный край
    float breath = 0.05 + 0.04 * sin(globalTime * 1.65); // дыхание углей в темноте
    float a = opacity * saturate(veil * (0.94 + breath));

    // Тёплый отсвет очага снизу — костёр сна где-то за краем кадра.
    float hearth = saturate(1.0 - uv.y);
    float3 col = float3(0.910, 0.345, 0.047) * (hearth * hearth) * 0.22;

    return float4(col, a);
}

technique VeilTechnique
{
    pass AutoloadPass   // имя пасса — по уставу Luminance (ManagedShader.DefaultPassName)
    {
        VertexShader = compile vs_2_0 VeilVS();
        PixelShader = compile ps_2_0 VeilPS();
    }
}

================================================================
=== ФАЙЛ: Common/Commands/DecoyTestCommand.cs
================================================================
using SubworldLibrary;
using Terraria;
using Terraria.ModLoader;
using ShatteredGenesis.Content.Subworlds;
using ShatteredGenesis.Common.Systems;

// Отладочная команда для M2: /sgworld
// Если мы в Первомире — прыгаем в мир-обманку.
// Если в обманке — возвращаемся в Первомир.
// Позже её заменит настоящий ивент «Вселение»; команда останется как инструмент тестера.
namespace ShatteredGenesis.Common.Commands
{
    public class DecoyTestCommand : ModCommand
    {
        public override string Command => "sgworld";

        // Команда работает в чате внутри мира.
        public override CommandType Type => CommandType.World;

        public override void Action(CommandCaller caller, string input, string[] args)
        {
            if (WorldFlagsSystem.ShellDestroyed)
            {
                caller.Reply("Скорлупа разрушена Вселением — пути в обманку больше нет.");
                return;
            }

            if (SubworldSystem.IsActive<DecoyWorld>())
            {
                caller.Reply("…возвращаемся в Первомир…");
                SubworldSystem.Exit();
            }
            else
            {
                caller.Reply("…проваливаемся в мир-обманку…");
                // Перенос ведёт единая кнопка истории; звуки генерации
                // подавляет ограниченный временем SubworldAudioSilencer.
                StoryDirector.EnterStory();
            }
        }
    }
}

================================================================
=== ФАЙЛ: Common/Commands/StoryReplayCommand.cs
================================================================
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;

// Команда /sgstory — прогоняет сюжетный пролог с самого начала.
// Отладочный инструмент сценарной группы: поправил текст, записал реплику,
// Build + Reload → /sgstory → смотрим сцену целиком, мира не трогая.
namespace ShatteredGenesis.Common.Commands
{
    public class StoryReplayCommand : ModCommand
    {
        public override CommandType Type => CommandType.Chat | CommandType.World;
        public override string Command => "sgstory";
        public override string Description => "Прогнать сюжетный пролог заново (отладка сцен)";

        public override void Action(CommandCaller caller, string input, string[] args)
        {
            CampfireScene.Begin(force: true);
            caller.Reply("Shattered Genesis: поднимаем сцену «Привал» заново.");
        }
    }
}

================================================================
=== ФАЙЛ: Common/Players/AnomalyPlayer.cs
================================================================
using SubworldLibrary;
using Terraria;
using Terraria.GameInput;
using Terraria.ModLoader;
using Terraria.ModLoader.IO; // TagCompound для сохранения данных игрока
using ShatteredGenesis.Common.Systems;
using ShatteredGenesis.Content.Subworlds;

// AnomalyPlayer — персонаж-Аномалия.
// Хранит метку Вселения (IsAnomaly) и направляет сюжетный перенос.
//
// КАНОН 0.4.26 «Глубокая интеграция» — разводим миры по полочкам:
//   ОСНОВНОЙ мир — ВАНИЛЬ БЕЗ ЕДИНОЙ ПРАВКИ. Мы здесь только
//     бросаем игрока в мир истории на первом заходе (и то — уже
//     после того, как ваниль полностью закончила загрузку).
//   ПОДМИР — мир истории: вход = принудительное возвращение мыши
//     и ввода (требование №4: на загрузочной катсцене курсор скрыт,
//     а диалог «Привал» кликается мышью), потом — сцена у костра.
//
// Убито навсегда: WorldAudioGuard, фиксация игрока, чёрные фоны
// основного мира. Всё это заменено глубокими каналами (StoryDirector).
namespace ShatteredGenesis.Common.Players
{
    public class AnomalyPlayer : ModPlayer
    {
        // Метка Вселения. Сейчас ничего не делает механически,
        // но уже живёт и сохраняется вместе с персонажем.
        public bool IsAnomaly;

        public override void SaveData(TagCompound tag)
        {
            tag["sg_isAnomaly"] = IsAnomaly;
        }

        public override void LoadData(TagCompound tag)
        {
            IsAnomaly = tag.TryGet("sg_isAnomaly", out bool anomaly) && anomaly;
        }

        // Движок зовёт каждый раз при входе в мир (и в подмир тоже).
        public override void OnEnterWorld()
        {
            // Баг №3 (канон 0.4.24): ни в одном мире не терпим двух Гидов.
            WorldFlagsSystem.DedupeGuides();

            // Водяной знак сборки — чтобы на скринах всегда была версия.
            Main.NewText($"Shattered Genesis v{Mod.Version} загружен.", 140, 140, 140);

            if (SubworldSystem.AnyActive())
            {
                // Подмир уже полностью загружен: только теперь удаляем
                // неслышимые остатки генерации и возвращаем громкость игрока.
                SubworldAudioSilencer.End();

                // ============ ПОДМИР: мир истории ============
                // Требование №4 + указ «Фикс мыши» (0.4.26-2): на входе
                // мышь и ввод возвращаются ПРИНУДИТЕЛЬНО. Курсор был скрыт
                // на загрузочной катсцене ходьбы по лесу (там нечего
                // кликать), здесь же — кликабельный диалог.
                Main.cursorScale = 1f;

                // Игровой канал ввода/зума: экранные матрицы загрузки больше
                // не действуют — мир рисуется мировым зумом движка.
                PlayerInput.SetZoom_World();

                if (!WorldFlagsSystem.IntroDone)
                {
                    CampfireScene.Begin(); // ночная сцена «Привал» у костра
                }
                else
                {
                    Main.NewText("Где-то за краем мира что-то заметило, что ты пришёл.",
                        255, 190, 90);
                }
                return;
            }

            // ============ ОСНОВНОЙ мир: чистая ваниль ============
            // Мир уже полностью загрузился (OnEnterWorld — после всего).
            // Единственное наше действие: история начинается переносом —
            // и только ОДИН раз (интро ещё не видно, Скорлупа не разрушена).
            // Кольца перезаходов нет каноном 0.4.26-4: флаги монотонны.
            if (!WorldFlagsSystem.IntroDone && !WorldFlagsSystem.ShellDestroyed)
            {
                StoryDirector.EnterStory();
            }
        }

        // Первый заслон ввода: движок вызывает его сразу после опроса клавиш.
        // Финальная страховка остаётся в CampfireScene.PostUpdateEverything.
        public override void SetControls()
        {
            if (CampfireScene.PlayerLocked)
                CampfireScene.ApplyPlayerLock(Player);
        }

        public override void PreUpdateMovement()
        {
            if (CampfireScene.PlayerLocked)
                CampfireScene.ApplyPlayerLock(Player);
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/CampfireScene.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics;
using SubworldLibrary;
using Terraria;
using Terraria.DataStructures;
using Terraria.GameContent;
using Terraria.GameInput;
using Terraria.Graphics;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.UI.Chat;
using ShatteredGenesis.Content.NPCs;
using ShatteredGenesis.Content.Particles;
using ShatteredGenesis.Content.Subworlds;
using ShatteredGenesis.Content.Tiles;

// ============================================================
// CampfireScene — реальная сцена «Привал» внутри DecoyWorld.
//
// Законы сцены:
//   * игрок с первого кадра сидит на физическом CampLog и не может встать;
//   * вокруг — глубокая ночь и фильтр Luminance CampDarkness: внешний мир
//     становится абсолютной чернотой, виден только круг нашего костра;
//   * Сказитель — настоящий ModNPC. ПКМ по нему начинает диалог;
//   * при диалоге ванильный UI скрыт, камера плавно сводит игрока и NPC;
//   * на узле GuideArrivalNode из темноты приходит настоящий NPCID.Guide;
//   * после 27-го узла Сказитель уходит и растворяется, камера возвращается,
//     управление отдаётся игроку, Гид остаётся сидеть у гаснущего костра.
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
    public sealed class CampfireScene : ModSystem
    {
        enum ScenePhase
        {
            Inactive,
            AwaitingInteraction,
            Dialogue,
            StorytellerLeaving,
            CameraRelease,
            Complete
        }

        sealed class DNode
        {
            public string Text;
            public string[] Choices;
            public int[] Next;
            public int Slide = -1;
        }

        // Узлы 0..26. На этом узле в круг света входит Гид.
        public const int GuideArrivalNode = 20;

        static ScenePhase phase;
        static DNode[] nodes;
        static int nodeIndex = -1;
        static int clickCooldown;
        static int finalTicks;
        static int completeTicks;

        static bool previousLeft;
        static bool previousRight;
        static Rectangle[] choiceRects = Array.Empty<Rectangle>();

        static int storytellerIndex = -1;
        static int guideIndex = -1;
        static bool storytellerGone;
        static bool guideSeated;
        static int guideArrivalTicks;

        static float cinematicZoom = 1f;
        static float fireStrength;
        static bool darknessFilterDead;

        static bool hideUiCaptured;
        static bool previousHideUi;

        static Point playerSeatTile;
        static Point guideSeatTile;
        static Vector2 campfireWorld;
        static Vector2 playerSeatWorld;
        static Vector2 storytellerAnchor;
        static Vector2 guideApproachStart;

        static bool sceneArtTried;
        static Texture2D[] slideArt;
        static Texture2D[][] slideFrames;

        public static bool Active => phase != ScenePhase.Inactive;
        public static bool SuppressHostileSpawns => Active
            && SubworldSystem.IsActive<DecoyWorld>();
        public static bool PlayerLocked => phase is ScenePhase.AwaitingInteraction
            or ScenePhase.Dialogue or ScenePhase.StorytellerLeaving
            or ScenePhase.CameraRelease;
        public static bool MarkerVisible => phase == ScenePhase.AwaitingInteraction;
        public static bool StorytellerShouldLeave => phase == ScenePhase.StorytellerLeaving;
        public static Vector2 StorytellerAnchor => storytellerAnchor;

        public override void Load()
        {
            if (!Main.dedServ)
                On_Main.Draw += DrawCinematicInterface;
        }

        public override void Unload()
        {
            if (!Main.dedServ)
                On_Main.Draw -= DrawCinematicInterface;

            ResetScene(false);
            slideArt = null;
            slideFrames = null;
            nodes = null;
            sceneArtTried = false;
        }

        public override void OnWorldUnload() => ResetScene(false);

        // -------------------- запуск и геометрия --------------------

        public static void Begin(bool force = false)
        {
            if (!SubworldSystem.IsActive<DecoyWorld>())
                return;
            if (Active && !force)
                return;

            ResetScene(false);
            BuildNodes();
            LoadSceneArt();
            SetupSceneGeometry();
            RemoveVanillaGuides();
            PurgeHostileNPCs();
            SpawnStoryteller();

            Main.dayTime = false;
            Main.time = 16200;
            Main.raining = false;
            Main.windSpeedCurrent = 0f;
            Main.windSpeedTarget = 0f;

            phase = ScenePhase.AwaitingInteraction;
            fireStrength = 0f;
            cinematicZoom = 1f;
            nodeIndex = -1;
            clickCooldown = 0;
            finalTicks = 0;
            completeTicks = 0;
            storytellerGone = false;
            guideIndex = -1;
            guideSeated = false;
            guideArrivalTicks = 0;
            darknessFilterDead = false;
            previousLeft = Main.mouseLeft;
            previousRight = Main.mouseRight;

            ApplyPlayerLock(Main.LocalPlayer);
        }

        static void SetupSceneGeometry()
        {
            int centerX = Math.Clamp(Main.spawnTileX, 60, Main.maxTilesX - 60);
            int centerGround = FindSurfaceY(centerX);

            int playerX = centerX - 5;
            int playerGround = FindSurfaceY(playerX);
            playerSeatTile = new Point(playerX, playerGround - 1);

            int guideX = centerX + 6;
            int guideGround = FindSurfaceY(guideX);
            guideSeatTile = new Point(guideX, guideGround - 1);

            EnsureStoryCampfire(centerX, centerGround);
            campfireWorld = FindStoryCampfireWorld(centerX, centerGround);
            playerSeatWorld = new Vector2(playerX * 16f + 8f, playerGround * 16f);
            storytellerAnchor = new Vector2((centerX + 3) * 16f + 8f,
                FindSurfaceY(centerX + 3) * 16f);

            int guideStartX = Math.Clamp(centerX + 35, 20, Main.maxTilesX - 20);
            guideApproachStart = new Vector2(guideStartX * 16f + 8f,
                FindSurfaceY(guideStartX) * 16f);

            PlaceCampLog(playerSeatTile);
            PlaceCampLog(guideSeatTile);
        }

        static void EnsureStoryCampfire(int centerX, int groundY)
        {
            int type = ModContent.TileType<StoryCampfireTile>();
            if (HasStoryCampfireNear(centerX, groundY))
                return;

            // Миграционная страховка для уже сохранённых Скорлуп G9/G10.
            // В новых мирах этот же объект ставит PlaceSceneSpawn.
            for (int x = centerX - 1; x <= centerX + 1; x++)
            {
                for (int y = groundY - 2; y < groundY; y++)
                    WorldGen.KillTile(x, y, false, false, true);

                if (!Framing.GetTileSafely(x, groundY).HasTile)
                    WorldGen.PlaceTile(x, groundY, TileID.Dirt, true, true);
            }

            WorldGen.PlaceObject(centerX, groundY - 1, type, true);
            if (Main.netMode == NetmodeID.Server)
                NetMessage.SendTileSquare(-1, centerX, groundY - 1, 5);
        }

        static bool HasStoryCampfireNear(int centerX, int groundY)
        {
            int type = ModContent.TileType<StoryCampfireTile>();
            for (int x = centerX - 5; x <= centerX + 5; x++)
            {
                for (int y = groundY - 5; y <= groundY + 1; y++)
                {
                    Tile tile = Framing.GetTileSafely(x, y);
                    if (tile.HasTile && tile.TileType == type)
                        return true;
                }
            }
            return false;
        }

        static Vector2 FindStoryCampfireWorld(int centerX, int groundY)
        {
            int type = ModContent.TileType<StoryCampfireTile>();
            for (int x = centerX - 5; x <= centerX + 5; x++)
            {
                for (int y = groundY - 5; y <= groundY + 1; y++)
                {
                    Tile tile = Framing.GetTileSafely(x, y);
                    if (!tile.HasTile || tile.TileType != type)
                        continue;

                    int topX = x - tile.TileFrameX / 18 % 3;
                    int topY = y - tile.TileFrameY / 18 % 2;
                    return new Vector2(topX * 16f + 24f, (topY + 2) * 16f);
                }
            }

            return new Vector2(centerX * 16f + 8f, groundY * 16f);
        }

        static Vector2 CampfireFlameWorld => campfireWorld + new Vector2(0f, -20f);

        static int FindSurfaceY(int x)
        {
            int start = Math.Clamp(Main.spawnTileY - 90, 10, Main.maxTilesY - 20);
            for (int y = start; y < Main.maxTilesY - 20; y++)
            {
                Tile tile = Framing.GetTileSafely(x, y);
                if (tile.HasTile && Main.tileSolid[tile.TileType])
                    return y;
            }
            return Math.Clamp(Main.spawnTileY + 3, 20, Main.maxTilesY - 20);
        }

        static void PlaceCampLog(Point tilePosition)
        {
            if (!WorldGen.InWorld(tilePosition.X, tilePosition.Y, 10))
                return;

            int type = ModContent.TileType<CampLog>();
            Tile tile = Framing.GetTileSafely(tilePosition.X, tilePosition.Y);
            if (tile.HasTile && tile.TileType != type)
                WorldGen.KillTile(tilePosition.X, tilePosition.Y, false, false, true);

            WorldGen.PlaceTile(tilePosition.X, tilePosition.Y, type, true, true);
            WorldGen.SquareTileFrame(tilePosition.X, tilePosition.Y, true);

            if (Main.netMode == NetmodeID.Server)
                NetMessage.SendTileSquare(-1, tilePosition.X, tilePosition.Y, 1);
        }

        static void RemoveVanillaGuides()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            for (int i = 0; i < Main.maxNPCs; i++)
            {
                NPC npc = Main.npc[i];
                if (npc.active && npc.type == NPCID.Guide)
                {
                    npc.active = false;
                    npc.netUpdate = true;
                }
            }
        }

        // Последняя страховка поверх spawn pool: если враг уже существовал
        // к моменту старта сцены или был создан сторонним событием, удаляем его.
        public override void PreUpdateEntities()
        {
            if (SuppressHostileSpawns)
                PurgeHostileNPCs();
        }

        static void PurgeHostileNPCs()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            for (int i = 0; i < Main.maxNPCs; i++)
            {
                NPC npc = Main.npc[i];
                if (!npc.active || npc.friendly)
                    continue;

                npc.active = false;
                if (Main.netMode == NetmodeID.Server)
                    NetMessage.SendData(MessageID.SyncNPC, -1, -1, null, i);
            }
        }

        static void SpawnStoryteller()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            storytellerIndex = NPC.NewNPC(
                new EntitySource_Misc("ShatteredGenesis:CampfireScene"),
                (int)storytellerAnchor.X,
                (int)storytellerAnchor.Y,
                ModContent.NPCType<StoryTeller>());

            if (TryGetStoryteller(out NPC storyteller))
            {
                storyteller.Bottom = storytellerAnchor;
                storyteller.netUpdate = true;
            }
        }

        // -------------------- блокировка игрока --------------------

        public static void ApplyPlayerLock(Player player)
        {
            if (!PlayerLocked || player == null || !player.active)
                return;

            player.controlLeft = false;
            player.controlRight = false;
            player.controlJump = false;
            player.controlUp = false;
            player.controlDown = false;
            player.controlHook = false;
            player.controlMount = false;
            player.controlUseItem = false;
            player.controlUseTile = false;
            player.controlThrow = false;
            player.velocity = Vector2.Zero;
            player.fallStart = (int)(player.position.Y / 16f);
            player.direction = 1;

            if (WorldGen.InWorld(playerSeatTile.X, playerSeatTile.Y, 10))
            {
                Tile seat = Framing.GetTileSafely(playerSeatTile.X, playerSeatTile.Y);
                if (seat.HasTile && seat.TileType == ModContent.TileType<CampLog>())
                {
                    if (!player.sitting.isSitting)
                        player.sitting.SitDown(player, playerSeatTile.X, playerSeatTile.Y);
                    return;
                }
            }

            // Аварийная страховка: даже если другой мод уничтожил бревно,
            // персонаж не телепортируется прочь из мизансцены.
            player.Bottom = playerSeatWorld;
        }

        // -------------------- взаимодействие и диалог --------------------

        static void UpdateInteraction()
        {
            bool right = Main.mouseRight;
            bool pressed = right && !previousRight;
            previousRight = right;
            previousLeft = Main.mouseLeft;

            if (!TryGetStoryteller(out NPC storyteller))
                return;

            bool hovered = storyteller.Hitbox.Contains(Main.MouseWorld.ToPoint());
            if (hovered)
            {
                Main.LocalPlayer.mouseInterface = true;
                Main.LocalPlayer.noThrow = 2;
                Main.cursorOverride = 2;
            }

            if (pressed && hovered)
                StartDialogue();
        }

        static void StartDialogue()
        {
            if (phase != ScenePhase.AwaitingInteraction)
                return;

            // Команда повтора может войти сюда после частичной перезагрузки
            // статики. Тексты обязаны существовать до первого кадра букв.
            BuildNodes();

            phase = ScenePhase.Dialogue;
            hideUiCaptured = true;
            previousHideUi = Main.hideUI;
            Main.hideUI = true;
            Main.playerInventory = false;
            Main.mapFullscreen = false;

            // Курсор остаётся полностью системным. Мы лишь возвращаем ему
            // нормальный масштаб/стиль и отдаём мышь интерфейсному каналу.
            Main.LocalPlayer.mouseInterface = true;
            Main.cursorScale = 1f;
            Main.cursorOverride = 2;

            choiceRects = Array.Empty<Rectangle>();
            EnterNode(0);
        }

        static void HandleDialogueInput()
        {
            bool left = Main.mouseLeft;
            bool pressed = left && !previousLeft;
            previousLeft = left;
            previousRight = Main.mouseRight;

            clickCooldown = Math.Max(0, clickCooldown - 1);
            if (!pressed || clickCooldown > 0 || nodeIndex < 0)
                return;

            DNode node = nodes[nodeIndex];
            if (node.Choices != null)
            {
                Point mouse = GetUiMousePoint();
                for (int i = 0; i < node.Choices.Length && i < choiceRects.Length; i++)
                {
                    if (choiceRects[i].Contains(mouse))
                    {
                        EnterNode(node.Next[i]);
                        return;
                    }
                }
            }
            else
            {
                EnterNode(node.Next[0]);
            }
        }

        static void EnterNode(int index)
        {
            if (index < 0)
            {
                BeginFinale();
                return;
            }

            nodeIndex = index;
            clickCooldown = 10;
            choiceRects = Array.Empty<Rectangle>();

            if (index == GuideArrivalNode && guideIndex < 0)
                SpawnGuideFromDarkness();
        }

        static void BeginFinale()
        {
            nodeIndex = -1;
            phase = ScenePhase.StorytellerLeaving;
            finalTicks = 0;
            choiceRects = Array.Empty<Rectangle>();
            Main.hideUI = true;
        }

        // -------------------- Гид --------------------

        static void SpawnGuideFromDarkness()
        {
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            guideIndex = NPC.NewNPC(
                new EntitySource_Misc("ShatteredGenesis:GuideArrival"),
                (int)guideApproachStart.X,
                (int)guideApproachStart.Y,
                NPCID.Guide);

            if (TryGetGuide(out NPC guide))
            {
                guide.Bottom = guideApproachStart;
                guide.alpha = 255;
                guide.noGravity = true;
                guide.noTileCollide = true;
                guide.dontTakeDamage = true;
                guide.aiStyle = NPCAIStyleID.FaceClosestPlayer;
                guide.velocity = Vector2.Zero;
                guide.direction = -1;
                guide.spriteDirection = -1;
                guide.netUpdate = true;
            }

            guideArrivalTicks = 0;
            guideSeated = false;
        }

        static void UpdateGuideArrival()
        {
            if (!TryGetGuide(out NPC guide))
                return;

            if (guideSeated)
            {
                KeepGuideSeated(guide);
                return;
            }

            guideArrivalTicks++;
            float t = MathHelper.Clamp(guideArrivalTicks / 260f, 0f, 1f);
            float eased = MathHelper.SmoothStep(0f, 1f, t);
            Vector2 seatBottom = new Vector2(guideSeatTile.X * 16f + 8f,
                (guideSeatTile.Y + 1) * 16f);

            guide.Bottom = Vector2.Lerp(guideApproachStart, seatBottom, eased);
            guide.velocity = Vector2.Zero;
            guide.alpha = (int)MathHelper.Lerp(255f, 0f,
                MathHelper.Clamp(t * 1.35f, 0f, 1f));
            guide.direction = -1;
            guide.spriteDirection = -1;
            guide.noGravity = true;
            guide.noTileCollide = true;
            guide.dontTakeDamage = true;
            guide.aiStyle = NPCAIStyleID.FaceClosestPlayer;

            if (t >= 1f)
            {
                guideSeated = true;
                KeepGuideSeated(guide);
            }
        }

        static void KeepGuideSeated(NPC guide)
        {
            guide.noGravity = false;
            guide.noTileCollide = false;
            guide.dontTakeDamage = false;
            guide.aiStyle = NPCAIStyleID.Passive; // стандартный ИИ городского NPC
            guide.SitDown(guideSeatTile, out int direction, out Vector2 bottom);
            guide.Bottom = bottom;
            guide.direction = direction;
            guide.spriteDirection = direction;
            guide.velocity = Vector2.Zero;
            guide.ai[0] = 5f; // ванильное состояние «сидит»
            guide.ai[1] = 900f;
        }

        static bool TryGetGuide(out NPC guide)
        {
            guide = null;
            if (guideIndex < 0 || guideIndex >= Main.maxNPCs)
                return false;

            NPC candidate = Main.npc[guideIndex];
            if (!candidate.active || candidate.type != NPCID.Guide)
                return false;

            guide = candidate;
            return true;
        }

        // -------------------- Сказитель --------------------

        public static bool TryGetStoryteller(out NPC storyteller)
        {
            storyteller = null;
            if (storytellerIndex < 0 || storytellerIndex >= Main.maxNPCs)
                return false;

            NPC candidate = Main.npc[storytellerIndex];
            if (!candidate.active || candidate.type != ModContent.NPCType<StoryTeller>())
                return false;

            storyteller = candidate;
            return true;
        }

        public static void NotifyStorytellerGone()
        {
            storytellerGone = true;
            storytellerIndex = -1;
        }

        // -------------------- главный тик --------------------

        public override void PostUpdateEverything()
        {
            if (!Active)
                return;

            if (!SubworldSystem.IsActive<DecoyWorld>())
            {
                ResetScene(false);
                return;
            }

            bool running = phase != ScenePhase.Complete;
            if (running)
            {
                Main.dayTime = false;
                Main.time = 16200;
                Main.raining = false;
                Main.windSpeedCurrent = 0f;
                Main.windSpeedTarget = 0f;
            }

            if (PlayerLocked)
                ApplyPlayerLock(Main.LocalPlayer);

            if (phase == ScenePhase.AwaitingInteraction)
                UpdateInteraction();
            else if (phase == ScenePhase.Dialogue)
                HandleDialogueInput();
            else
            {
                previousLeft = Main.mouseLeft;
                previousRight = Main.mouseRight;
            }

            if (guideIndex >= 0)
                UpdateGuideArrival();

            if (phase == ScenePhase.Dialogue)
            {
                Main.hideUI = true;
                Main.LocalPlayer.mouseInterface = true;
                Main.LocalPlayer.noThrow = 2;
                Main.cursorScale = 1f;
                Main.cursorOverride = 2;
            }

            if (phase == ScenePhase.StorytellerLeaving)
            {
                Main.hideUI = true;
                finalTicks++;
                if (!TryGetStoryteller(out _) && finalTicks > 30)
                    storytellerGone = true;

                if (storytellerGone && guideSeated && finalTicks > 90)
                {
                    phase = ScenePhase.CameraRelease;
                    finalTicks = 0;
                }
            }
            else if (phase == ScenePhase.CameraRelease)
            {
                Main.hideUI = true;
                finalTicks++;
                if (finalTicks > 120 && Math.Abs(cinematicZoom - 1f) < 0.015f)
                    CompleteScene();
            }
            else if (phase == ScenePhase.Complete)
            {
                completeTicks++;
                fireStrength = MathHelper.Lerp(fireStrength, 0f, 0.006f);
                if (completeTicks > 720 || fireStrength < 0.01f)
                    phase = ScenePhase.Inactive;
            }

            float zoomTarget = phase switch
            {
                ScenePhase.Dialogue => 1.62f,
                ScenePhase.StorytellerLeaving => 1.55f,
                _ => 1f
            };
            cinematicZoom = MathHelper.Lerp(cinematicZoom, zoomTarget, 0.055f);
            if (Math.Abs(cinematicZoom - zoomTarget) < 0.001f)
                cinematicZoom = zoomTarget;

            float fireTarget = phase switch
            {
                ScenePhase.StorytellerLeaving => 0.35f,
                ScenePhase.CameraRelease => 0.20f,
                ScenePhase.Complete => 0.10f,
                _ => 1f
            };
            if (phase != ScenePhase.Complete)
                fireStrength = MathHelper.Lerp(fireStrength, fireTarget, 0.045f);

            UpdateCampLightAndParticles();
            if (running)
                ActivateCampDarkness();
        }

        static void CompleteScene()
        {
            phase = ScenePhase.Complete;
            completeTicks = 0;
            nodeIndex = -1;
            WorldFlagsSystem.IntroDone = true;
            RestoreNativeUi();

            Player player = Main.LocalPlayer;
            player.frozen = false;
            player.velocity = Vector2.Zero;
        }

        static void ResetScene(bool deactivateActors)
        {
            RestoreNativeUi();

            if (deactivateActors && TryGetStoryteller(out NPC storyteller))
                storyteller.active = false;

            phase = ScenePhase.Inactive;
            nodeIndex = -1;
            storytellerIndex = -1;
            guideIndex = -1;
            storytellerGone = false;
            guideSeated = false;
            cinematicZoom = 1f;
            fireStrength = 0f;
            choiceRects = Array.Empty<Rectangle>();
        }

        static void RestoreNativeUi()
        {
            if (hideUiCaptured)
                Main.hideUI = previousHideUi;
            else if (phase != ScenePhase.Inactive)
                Main.hideUI = false;

            hideUiCaptured = false;
        }

        // -------------------- свет и фильтр --------------------

        static void UpdateCampLightAndParticles()
        {
            if (fireStrength <= 0.001f)
                return;

            float flicker = 0.90f + 0.10f * (float)Math.Sin(Main.GameUpdateCount * 0.19f);
            Vector2 flameWorld = CampfireFlameWorld;
            Lighting.AddLight(flameWorld,
                new Vector3(1.20f, 0.42f, 0.08f) * fireStrength * flicker);

            if (phase != ScenePhase.Complete && Main.GameUpdateCount % 4 == 0)
            {
                try
                {
                    new EmberParticle(
                        flameWorld + new Vector2(Main.rand.NextFloat(-10f, 10f), -5f),
                        new Vector2(Main.rand.NextFloat(-0.22f, 0.22f),
                            Main.rand.NextFloat(-1.35f, -0.55f)),
                        new Color(255, 222, 126),
                        90 + Main.rand.Next(90)).Spawn();
                }
                catch (Exception)
                {
                    // Частицы декоративны и не имеют права прервать сцену.
                }
            }
        }

        static void ActivateCampDarkness()
        {
            if (darknessFilterDead)
                return;

            var filter = StoryDirector.CampDarknessFilter;
            if (filter == null)
                return;

            try
            {
                Vector2 lightOnScreen = CampfireFlameWorld - Main.screenPosition;
                Vector2 uv = new Vector2(
                    MathHelper.Clamp(lightOnScreen.X / Math.Max(1f, Main.screenWidth), 0f, 1f),
                    MathHelper.Clamp(lightOnScreen.Y / Math.Max(1f, Main.screenHeight), 0f, 1f));

                float radius = 0.18f + 0.13f * fireStrength;
                filter.TrySetParameter("lightCenter", uv);
                filter.TrySetParameter("lightRadius", radius);
                filter.TrySetParameter("darkness", 1f);
                filter.Activate();
            }
            catch (Exception)
            {
                darknessFilterDead = true;
            }
        }

        // -------------------- кинематографичная камера --------------------

        public override void ModifyScreenPosition()
        {
            if (!Active || cinematicZoom <= 1.002f)
                return;

            Player player = Main.LocalPlayer;
            Vector2 second = campfireWorld;
            if (TryGetStoryteller(out NPC storyteller))
                second = storyteller.Center;
            else if (TryGetGuide(out NPC guide))
                second = guide.Center;

            Vector2 focus = (player.Center + second) * 0.5f;
            Vector2 desired = focus - new Vector2(Main.screenWidth, Main.screenHeight) * 0.5f;
            Main.screenPosition = Vector2.Lerp(Main.screenPosition, desired, 0.12f);
        }

        public override void ModifyTransformMatrix(ref SpriteViewMatrix Transform)
        {
            if (!Active || cinematicZoom <= 1.002f)
                return;

            // GameViewMatrix и есть матрица, переданная сюда движком.
            // Умножаем её штатный пользовательский zoom, а не заменяем его.
            Transform.Zoom = Transform.Zoom * cinematicZoom;
        }

        // Костёр и брёвна теперь являются настоящими ModTile-объектами.
        // Отдельного PostDrawTiles и плоских world-space картинок больше нет.

        static void LoadSceneArt()
        {
            if (sceneArtTried)
                return;
            sceneArtTried = true;

            slideArt = new Texture2D[StoryDirector.SlideCount];
            slideFrames = new Texture2D[StoryDirector.SlideCount][];
            for (int i = 0; i < StoryDirector.SlideCount; i++)
            {
                List<Texture2D> frames = new List<Texture2D>();
                for (int f = 0; f < 16; f++)
                {
                    Texture2D frame = RequestTexture(
                        $"Assets/Slides/Slide{i + 1}_{f}.png",
                        $"ShatteredGenesis/Assets/Slides/Slide{i + 1}_{f}");
                    if (frame == null)
                        break;
                    frames.Add(frame);
                }

                if (frames.Count > 1)
                    slideFrames[i] = frames.ToArray();
                else
                    slideArt[i] = RequestTexture(
                        $"Assets/Slides/Slide{i + 1}.png",
                        $"ShatteredGenesis/Assets/Slides/Slide{i + 1}");
            }
        }

        static Texture2D RequestTexture(string relativeWithExtension, string assetPath)
        {
            if (!ModAssetHelper.Exists(relativeWithExtension))
                return null;
            try
            {
                return ModContent.Request<Texture2D>(assetPath,
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                return null;
            }
        }

        // -------------------- UI поверх скрытого интерфейса Terraria --------------------

        void DrawCinematicInterface(On_Main.orig_Draw orig, Main self, GameTime gameTime)
        {
            orig(self, gameTime);

            if (phase != ScenePhase.Dialogue || nodeIndex < 0 || Main.dedServ)
                return;

            // Main.hideUI отключает интерфейсный проход Terraria целиком,
            // поэтому панель рисуется после готового кадра. Курсор при этом
            // не подменяется: его в том же UI-batch рисует Main.DrawCursor.
            Main.LocalPlayer.mouseInterface = true;
            Main.cursorScale = 1f;
            Main.cursorOverride = 2;

            SpriteBatch sb = Main.spriteBatch;
            bool begun = false;
            try
            {
                PlayerInput.SetZoom_UI();
                sb.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend,
                    SamplerState.LinearClamp, DepthStencilState.None,
                    RasterizerState.CullNone, null, Main.UIScaleMatrix);
                begun = true;

                DrawCinematicPanel(sb);

                // Это штатная функция Terraria и штатные cursor textures.
                // Она вызывается после панели, поэтому стрелка всегда сверху.
                Main.DrawCursor(Vector2.Zero);

                sb.End();
                begun = false;
            }
            catch (Exception)
            {
                if (begun)
                {
                    try { sb.End(); } catch (Exception) { }
                }
            }
            finally
            {
                PlayerInput.SetZoom_World();
            }
        }

        static void DrawCinematicPanel(SpriteBatch spriteBatch)
        {
            Viewport viewport = Main.instance.GraphicsDevice.Viewport;
            float uiScale = Math.Max(Main.UIScale, 0.0001f);
            float screenWidth = viewport.Width / uiScale;
            float screenHeight = viewport.Height / uiScale;

            DrawVision(spriteBatch, screenWidth, screenHeight);
            DrawDialoguePanel(spriteBatch);
        }

        static Point GetUiMousePoint()
        {
            // Никакого ручного деления координат мыши: инвертируем ровно
            // ту матрицу, которой нарисованы кнопки. Клик и hover совпадают.
            Matrix inverseUi = Matrix.Invert(Main.UIScaleMatrix);
            Vector2 logical = Vector2.Transform(
                new Vector2(Main.mouseX, Main.mouseY), inverseUi);
            return logical.ToPoint();
        }

        static void DrawVision(SpriteBatch sb, float screenWidth, float screenHeight)
        {
            if (nodeIndex < 0 || nodes[nodeIndex].Slide < 0)
                return;

            int slide = nodes[nodeIndex].Slide;
            Texture2D texture = null;
            if (slideFrames != null && slide < slideFrames.Length
                && slideFrames[slide] != null && slideFrames[slide].Length > 0)
            {
                Texture2D[] frames = slideFrames[slide];
                texture = frames[(int)(Main.GameUpdateCount / 38 % (uint)frames.Length)];
            }
            else if (slideArt != null && slide < slideArt.Length)
            {
                texture = slideArt[slide];
            }

            if (texture == null)
                return;

            float maxWidth = screenWidth * 0.56f;
            float maxHeight = screenHeight * 0.48f;
            float scale = Math.Min(maxWidth / texture.Width, maxHeight / texture.Height);
            Vector2 size = new Vector2(texture.Width, texture.Height) * scale;
            Rectangle destination = new Rectangle(
                (int)(screenWidth * 0.5f - size.X * 0.5f),
                (int)(screenHeight * 0.08f),
                (int)size.X, (int)size.Y);

            sb.Draw(TextureAssets.MagicPixel.Value,
                new Rectangle(destination.X - 3, destination.Y - 3,
                    destination.Width + 6, destination.Height + 6),
                new Color(124, 79, 18) * 0.55f);
            sb.Draw(texture, destination, Color.White * 0.58f);
        }

        static void DrawDialoguePanel(SpriteBatch sb)
        {
            Viewport viewport = Main.instance.GraphicsDevice.Viewport;
            float uiScale = Math.Max(Main.UIScale, 0.0001f);
            float screenWidth = viewport.Width / uiScale;
            float screenHeight = viewport.Height / uiScale;

            DNode node = nodes[nodeIndex];
            DynamicSpriteFont font = FontAssets.MouseText.Value;
            Texture2D pixel = TextureAssets.MagicPixel.Value;
            Point mouse = GetUiMousePoint();

            const float textScale = 1.12f;
            const int margin = 20;
            int panelWidth = Math.Max(300, (int)(screenWidth * 0.78f));
            panelWidth = Math.Min(panelWidth, Math.Max(300, (int)screenWidth - 24));
            float usableWidth = panelWidth - margin * 2f;

            Vector2 textScaleVector = new Vector2(textScale);
            Vector2 textBounds = ChatManager.GetStringSize(
                font, node.Text, textScaleVector, usableWidth);

            int nameHeight = (int)(font.LineSpacing * 1.10f) + 5;
            int choicesHeight = node.Choices != null
                ? node.Choices.Length * 40 + 10
                : 32;
            int panelHeight = margin + nameHeight + (int)Math.Ceiling(textBounds.Y)
                + choicesHeight + margin;

            int panelX = (int)((screenWidth - panelWidth) * 0.5f);
            int panelY = Math.Max(12,
                (int)Math.Floor(screenHeight - panelHeight - 18f));

            Rectangle border = new Rectangle(
                panelX - 3, panelY - 3, panelWidth + 6, panelHeight + 6);
            Rectangle body = new Rectangle(panelX, panelY, panelWidth, panelHeight);
            sb.Draw(pixel, border, new Color(134, 86, 18) * 0.92f);
            sb.Draw(pixel, body, new Color(4, 3, 2) * 0.96f);

            sb.DrawString(font, "Сказитель",
                new Vector2(panelX + margin, panelY + margin),
                new Color(255, 190, 55), 0f, Vector2.Zero,
                1.12f, SpriteEffects.None, 0f);

            Vector2 textPosition = new Vector2(
                panelX + margin, panelY + margin + nameHeight);
            ChatManager.DrawColorCodedStringWithShadow(sb, font, node.Text,
                textPosition, Color.White, 0f, Vector2.Zero,
                textScaleVector, usableWidth);

            float buttonY = textPosition.Y + textBounds.Y + 9f;
            if (node.Choices != null)
            {
                List<Rectangle> rects = new List<Rectangle>();
                for (int i = 0; i < node.Choices.Length; i++)
                {
                    string label = node.Choices[i];
                    Vector2 measured = font.MeasureString(label) * 1.08f;
                    int buttonWidth = Math.Min((int)usableWidth, (int)measured.X + 28);
                    Rectangle rect = new Rectangle(
                        panelX + margin, (int)buttonY, buttonWidth, 32);
                    rects.Add(rect);

                    bool hovered = rect.Contains(mouse);
                    sb.Draw(pixel, rect, hovered
                        ? new Color(132, 84, 18)
                        : new Color(58, 39, 10) * 0.95f);
                    sb.DrawString(font, label,
                        new Vector2(rect.X + 14, rect.Y + 7),
                        hovered ? new Color(255, 242, 199) : new Color(255, 190, 55),
                        0f, Vector2.Zero, 1.08f, SpriteEffects.None, 0f);
                    buttonY += 40f;
                }
                choiceRects = rects.ToArray();
            }
            else
            {
                const string hint = "ЛКМ — продолжить";
                Vector2 hintSize = font.MeasureString(hint);
                sb.DrawString(font, hint,
                    new Vector2(panelX + panelWidth - margin - hintSize.X,
                        buttonY + 4f),
                    new Color(158, 137, 96), 0f, Vector2.Zero,
                    1f, SpriteEffects.None, 0f);
                choiceRects = Array.Empty<Rectangle>();
            }
        }

        // -------------------- 27 узлов исходного сказа --------------------

        static void BuildNodes()
        {
            if (nodes != null)
                return;

            DNode N(string text, string[] choices, int[] next, int slide = -1)
                => new DNode { Text = text, Choices = choices, Next = next, Slide = slide };

            nodes = new DNode[]
            {
                /* 0*/ N("Слушай… ты вообще веришь в сказки? Ну, в эти древние байки, которые безумцы на заборах царапают?",
                    new[] { "Верю.", "Не верю.", "Смотря каким." }, new[] { 1, 2, 3 }),
                /* 1*/ N("Ха. Тогда тебе будет легче слушать. Садись поближе — расскажу, время всё равно есть.", null, new[] { 4 }),
                /* 2*/ N("Я вот тоже не верил. Пока сам не увидел то, о чём живые вообще говорить боятся. Садись поближе.", null, new[] { 4 }),
                /* 3*/ N("Разумный ответ. Но в этой сказке старейшины клялись, что всё — правда. Садись, слушай.", null, new[] { 4 }),
                /* 4*/ N("Давным-давно, чёрт знает когда, из абсолютного ниоткуда пришли Они. Семеро. Исполины: рваные плащи из чистой тени, рогатые венцы, а вместо лиц — пустота. И самое дурацкое: им места нигде не хватало. Бесконечность вокруг — а им, видишь ли, тесно!", null, new[] { 5 }, 0),
                /* 5*/ N("Ну и они выковали себе место. Свой собственный мир — самый первый, настоящий. Чистый свет, как чистый холст для художника. Сидели в тишине, кайфовали. Безмятежность полная.", null, new[] { 6 }, 1),
                /* 6*/ N("Красиво, да?", new[] { "Красиво…", "Скучновато звучит." }, new[] { 7, 8 }),
                /* 7*/ N("То-то. Красота — она долго не живёт.", null, new[] { 9 }),
                /* 8*/ N("Скука тоже долго не живёт. От бесцельной тишины и тыкают куда не надо.", null, new[] { 9 }),
                /* 9*/ N("Один из них — Ваэлор его звали, Сеятель типа — решил поумничать: пальцем по холсту проведу, по приколу. Коснулся. Обычное, блин, касание пальца — а мир возьми, да и отзовись!", null, new[] { 10 }, 2),
                /*10*/ N("Ты бы на его месте тыкнул?", new[] { "Тыкнул бы.", "Ни за что.", "Пальцем?! Серьёзно?" }, new[] { 11, 12, 13 }),
                /*11*/ N("Вот! Все бы тыкнули. Потому и рассказывать есть что.", null, new[] { 14 }),
                /*12*/ N("Так говорят все. Сидя у тёплого костра — все у нас осторожные.", null, new[] { 14 }),
                /*13*/ N("Пальцем, пальцем. У таких, как они, палец — что молот судьбы.", null, new[] { 14 }),
                /*14*/ N("Из того касания вылезло то, что назвалось жизнью. Настоящей: меняется, дышит, дохнет… И любила она себя больше всего на свете. На создателей своих ей было абсолютно плевать.", null, new[] { 15 }, 3),
                /*15*/ N("Чтобы расти, эта тварь начала жрать свет, на котором мир держался. Жрала, пока нити не лопнули — хрусь! — мир пополам, от неба до костей земных. А вырвавшийся свет взбесился и начал забирать самих создателей: один растворился… второй… третий… Остался один-единственный. Последний Архон.", null, new[] { 16 }, 4),
                /*16*/ N("Страшно слушать такое?", new[] { "Да.", "Нет.", "Я бы убежал давно." }, new[] { 17, 18, 19 }),
                /*17*/ N("И правильно делаешь. Тогда страшно было даже небу.", null, new[] { 20 }),
                /*18*/ N("Смелый. Ну-ну. Посмотрим, что скажешь у границы мира.", null, new[] { 20 }),
                /*19*/ N("Бежали все. Да бежать было некуда: кругом — их же собственный свет.", null, new[] { 20 }),
                /*20*/ N("Последний оказался поумнее братьев: собрал остатки безумного света и укрыл им рану мира — стеной от края до края. Чтобы никто и никогда эту рану не нашёл.", null, new[] { 21 }, 5),
                /*21*/ N("А перед тем, как самому исчезнуть, он оставил в свете пророчество: мол, придёт чудак, который вообще не боится смерти. Ни черта не боится! И соткан он будет из тех же нитей света.", null, new[] { 22 }, 6),
                /*22*/ N("Веришь в пророчества?", new[] { "Верю.", "Нет.", "В хорошие — верю." }, new[] { 23, 24, 25 }),
                /*23*/ N("Тогда слушай внимательно, это про тебя может быть.", null, new[] { 26 }),
                /*24*/ N("Я тоже не очень. Но эта штука… живая. Проверено.", null, new[] { 26 }),
                /*25*/ N("А плохих пророчеств и нет. Смотря для кого.", null, new[] { 26 }),
                /*26*/ N("Этот Последний… он до сих пор ищет. Неугасающую душу, которая не кончается, сколько её ни убивай. …Слушай… а может, это он тебя ищет, а? Ну ладно, чё ты вылупился — спи давай. Завтра день тяжёлый.", null, new[] { -1 }, 7),
            };
        }
    }

    // Натуральный spawn блокируется штатными GlobalNPC-хуками. Мы не трогаем
    // Main.maxNetNPCs: это глобальная ёмкость NPC, её изменение сломало бы
    // самого Сказителя и динамическое появление Гида.
    public sealed class CampfireSpawnSuppression : GlobalNPC
    {
        public override void EditSpawnRate(Player player,
            ref int spawnRate, ref int maxSpawns)
        {
            if (!CampfireScene.SuppressHostileSpawns)
                return;

            spawnRate = int.MaxValue;
            maxSpawns = 0;
        }

        public override void EditSpawnPool(
            IDictionary<int, float> pool, NPCSpawnInfo spawnInfo)
        {
            if (CampfireScene.SuppressHostileSpawns)
                pool.Clear();
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/FirstWorldGenSystem.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using SubworldLibrary;
using Terraria;
using Terraria.GameContent.Generation;
using Terraria.ID;
using Terraria.IO;
using Terraria.ModLoader;
using Terraria.WorldBuilding;

// FirstWorldGenSystem — система генерации Первомира.
// Версия M1.5 «Пепел и трещины, черновик №1».
// Генерация разбита на этапы (passes) — они видны на экране создания мира,
// поэтому подписаны лоровыми фразами.
namespace ShatteredGenesis.Common.Systems
{
    public class FirstWorldGenSystem : ModSystem
    {
        public override void ModifyWorldGenTasks(List<GenPass> tasks, ref double totalWeight)
        {
            // ЭКСПЕРИМЕНТ: если генерация идёт ВНУТРИ под-мира — не лезем,
            // там нужен чистый ванильный список.
            if (SubworldSystem.AnyActive())
                return;

            // ЭКСПЕРИМЕНТ 0.3.4.4: хватаем слепок ванильного конвейера ВМЕСТЕ
            // с чертежом (GenVars.configuration уже наполнен движком к этому
            // моменту). Порядок критичен: слепок ПЕРЕД расчисткой.
            VanillaGenSnapshot.Capture(tasks, GenVars.configuration);

            tasks.Clear(); // ванили нет. Совсем.

            // ЭТАП 0.4 «Занавес»: подписи проходов ПЕРЕОДЕТЫ под ваниль.
            // Игрок видит обычный экран создания мира — и не должен догадаться,
            // что генерируется Первомир. (Код генерации не изменён ни на байт.)
            tasks.Add(new PassLegacy("Создание ландшафта…", GenerateBase, 300f));
            tasks.Add(new PassLegacy("Генерация пещер…", GenerateCaves, 100f));
            tasks.Add(new PassLegacy("Размещение рудных жил…", GenerateSeams, 60f));
            tasks.Add(new PassLegacy("Завершающие штрихи…", GenerateCrater, 20f));
        }

        // ---------------- ЭТАП 1. Террейн ----------------
        private void GenerateBase(GenerationProgress progress, GameConfiguration config)
        {
            double surfaceBase = Main.maxTilesY * 0.35;
            Main.worldSurface = surfaceBase;
            Main.rockLayer = Main.maxTilesY * 0.55;
            GenVars.worldSurface = surfaceBase;
            GenVars.worldSurfaceHigh = surfaceBase;
            GenVars.worldSurfaceLow = surfaceBase + 5;
            GenVars.rockLayerHigh = Main.rockLayer;
            GenVars.rockLayerLow = Main.rockLayer + 5;

            for (int x = 0; x < Main.maxTilesX; x++)
            {
                double wave = Math.Sin(x * 0.012) * 10.0 + Math.Sin(x * 0.045) * 3.0;
                int top = (int)(surfaceBase + wave) + WorldGen.genRand.Next(-1, 2);

                for (int y = top; y < Main.maxTilesY; y++)
                {
                    // Верхняя корка — пепел (пахнет мёртвым миром), ниже — камень.
                    // Это ВРЕМЕННЫЕ ванильные тайлы-заменители; свои спрайты придут позже.
                    ushort tileType = (y < top + 4) ? TileID.Ash : TileID.Stone;
                    WorldGen.PlaceTile(x, y, tileType, true, true);
                }

                if (x % 200 == 0)
                    progress.Set(x / (double)Main.maxTilesX);
            }
            progress.Set(1.0);
        }

        // ---------------- ЭТАП 2. Пещеры ----------------
        private void GenerateCaves(GenerationProgress progress, GameConfiguration config)
        {
            // Количество туннелей масштабируем от площади мира.
            int tunnels = (Main.maxTilesX * Main.maxTilesY) / 9000;

            for (int i = 0; i < tunnels; i++)
            {
                int x = WorldGen.genRand.Next(40, Main.maxTilesX - 40);
                int y = WorldGen.genRand.Next((int)Main.worldSurface + 10, Main.maxTilesY - 60);

                // TileRunner с type=-1 и addTile=false — это экскаватор: выкапывает, а не строит.
                WorldGen.TileRunner(
                    x, y,
                    WorldGen.genRand.NextDouble() * 4.0 + 3.0,   // толщина
                    WorldGen.genRand.Next(20, 80),               // длина «следа»
                    -1, false,
                    WorldGen.genRand.NextDouble() * 6 - 3,
                    WorldGen.genRand.NextDouble() * 6 - 3);

                if (i % 10 == 0)
                    progress.Set(i / (double)tunnels);
            }

            // Несколько шахт-выходов на поверхность, иначе пещеры будут запечатаны.
            for (int i = 0; i < 6; i++)
            {
                int x = WorldGen.genRand.Next(100, Main.maxTilesX - 100);
                int ys = FindSurfaceY(x);
                WorldGen.TileRunner(x, ys, 2.5, 60, -1, false, 0.2, 1.0);
            }
            progress.Set(1.0);
        }

        // ---------------- ЭТАП 3. Тлеющие жилы ----------------
        private void GenerateSeams(GenerationProgress progress, GameConfiguration config)
        {
            // Топазовые самоцветные блоки светятся тёплым янтарным светом —
            // это наши временные «тлеющие швы» в мёртвом камне.
            int veins = Main.maxTilesX / 25;

            for (int i = 0; i < veins; i++)
            {
                int x = WorldGen.genRand.Next(30, Main.maxTilesX - 30);
                int y = WorldGen.genRand.Next((int)Main.worldSurface + 15, Main.maxTilesY - 50);
                int length = WorldGen.genRand.Next(4, 15);

                for (int k = 0; k < length; k++)
                {
                    WorldGen.PlaceTile(x, y, TileID.TopazGemspark, true, true);
                    x += WorldGen.genRand.Next(-1, 2); // жила дрожит в стороны
                    y += 1;                            // и в целом ползёт вниз
                    if (x < 5 || x > Main.maxTilesX - 5 || y > Main.maxTilesY - 10)
                        break;
                }
            }
            progress.Set(1.0);
        }

        // ---------------- ЭТАП 4. Кратер и спавн ----------------
        private void GenerateCrater(GenerationProgress progress, GameConfiguration config)
        {
            int craterX = Main.maxTilesX / 2;
            int surfaceY = FindSurfaceY(craterX);
            int craterRadius = 14;

            for (int x = craterX - craterRadius; x <= craterX + craterRadius; x++)
            {
                for (int y = surfaceY - 8; y <= surfaceY + 5; y++)
                {
                    double dist = Math.Sqrt((x - craterX) * (x - craterX) + (y - surfaceY) * (y - surfaceY));
                    if (dist < craterRadius * 0.8)
                        WorldGen.KillTile(x, y, false, false, true);
                }
            }

            Main.spawnTileX = craterX;
            Main.spawnTileY = surfaceY + 3;
            progress.Set(1.0);
        }

        // ---------------- Помощники ----------------
        // Находит верхний твёрдый тайл в колонке x.
        private int FindSurfaceY(int x)
        {
            for (int y = 0; y < Main.maxTilesY; y++)
            {
                if (Main.tile[x, y].HasTile && Main.tileSolid[Main.tile[x, y].TileType])
                    return y;
            }
            return (int)Main.worldSurface;
        }

        // ---------------- Атмосфера: приглушённый пепельный свет ----------------
        // Этот хук движок вызывает каждый кадр, спрашивая «какого цвета солнечный свет?».
        // Мы не ЗАМЕНЯЕМ свет, а тонируем его (умножаем) — так ночь всё равно темнеет.
        public override void ModifySunLightColor(ref Color tileColor, ref Color backgroundColor)
        {
            // В под-мире-обманке свет должен оставаться честно ванильным.
            if (SubworldSystem.AnyActive())
                return;

            tileColor = new Color(
                (int)(tileColor.R * 0.82f),
                (int)(tileColor.G * 0.88f),
                (int)(tileColor.B * 1.00f)); // холоднее, мертвее

            backgroundColor = new Color(
                (int)(backgroundColor.R * 0.55f),
                (int)(backgroundColor.G * 0.60f),
                (int)(backgroundColor.B * 0.85f)); // блёклое грязно-синее небо
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/ModAssetHelper.cs
================================================================
using System;
using Terraria.ModLoader;

namespace ShatteredGenesis.Common.Systems
{
    // Проверяет наличие необязательного ассета до Request, чтобы отсутствие
    // картинки не приводило к ошибке загрузки мода.
    internal static class ModAssetHelper
    {
        public static bool Exists(string relativePath)
        {
            try
            {
                Mod mod = ModLoader.GetMod("ShatteredGenesis");
                return mod != null && mod.HasAsset(relativePath);
            }
            catch (Exception)
            {
                return false;
            }
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/StoryDirector.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Graphics; // DrawString под DynamicSpriteFont живёт тут (урок 0.4.23.1)
using Terraria;
using Terraria.GameContent;
using Terraria.ModLoader;
using SubworldLibrary;
using Luminance.Core.Graphics; // ManagedShader / ManagedScreenFilter / ShaderManager
using ShatteredGenesis.Content.Subworlds;

// ============================================================
// StoryDirector — КЛЕЙ ГЛУБОКОЙ ИНТЕГРАЦИИ каркаса истории.
//
// КАНОН 0.4.26 «Глубокая интеграция» (указ режиссёра №0.4.26-2/3):
//   * ОСНОВНОЙ мир — ЧИСТАЯ ваниль. Никакой графики, звука,
//     фризов и капсул на его генерации. Точка.
//   * Нестабильный перехват SoundEngine через RuntimeDetour удалён в G6.
//     Звуки стройки подмира закрывает SubworldAudioSilencer: перед
//     переходом он запоминает громкость эффектов и окружения, на время
//     загрузки удерживает их в нуле и возвращает при входе. Настройки
//     на диск не записываются, музыка не затрагивается. Остальные системы:
//       - виньетка экрана загрузки → шейдер SleepVeil через
//         ShaderManager Luminance (не 24 полоски MagicPixel);
//       - искры у костра → частицы Luminance Particle/ParticleManager
//         (в мире; на экране загрузки они физически не тикают —
//         PartiсleManager живёт в PostUpdateDusts, исходник сверен);
//       - сон в финале — фильтр SleepFade через ManagedScreenFilter
//         Luminance (мировой экранный конвейер) + чернь;
//       - часы у катсцен → Main.GameUpdateCount (аппаратные 60 Гц);
//       - геометрия кадра → движковые UI-поля без округлений;
//       - перенос текста → ChatManager (системный конвейер чата).
//   * DAYBREAK остаётся жёсткой зависимостью других систем мода;
//     для подавления звука генерации он больше не используется.
//
// Здесь живёт направление ИСТОРИИ:
//   EnterStory()      — перенос в подмир-обманку (шов + окно тишины);
//   Veil/Sleep-кэш    — ОДИН забор эффектов Luminance после их загрузки
//                       (аудит №3: в отрисовке — ни одного поиска
//                       по словарям, только .Activate()/Apply);
//   DrawStamp()       — печать сборки на наших экранах (урок 0.4.25.3);
//   занавес шва       — ровно на кадры перелёта, не дольше;
//   окно тишины       — действует только во время перехода в Скорлупу.
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
    public class StoryDirector : ModSystem
    {
        // Число слайдов-«видений», используемых текстовой сценой.
        public const int SlideCount = 8;

        // Поднят занавес поверх перелёта в подмир (между Raise в
        // AnomalyPlayer и Lift в DecoyWorld.OnEnter).
        static bool hopCurtain;

        // ===================== КЭШ ЭФФЕКТОВ LUMINANCE (аудит №3) =====================
        // ОДИН раз забираем ссылки после того, как библиотека собрала
        // эффекты (PostSetupContent идёт ПОСЛЕ Luminance.PostSetupContent —
        // порядок гарантирован жёсткой зависимостью). В отрисовке
        // обращения по строковым словарям ЗАПРЕЩЕНЫ — только готовые ссылки.
        public static ManagedShader VeilShader;        // пелена экрана загрузки
        public static ManagedScreenFilter SleepFilter; // фильтр старого перехода сна
        public static ManagedScreenFilter CampDarknessFilter; // абсолютная ночь привала

        // Luminance пересобирает .fx АСИНХРОННО (ShaderRecompilationMonitor,
        // сверено): на момент PostSetupContent .fxc может ещё не существовать.
        // Дозабор — раз в секунду и только пока ссылка пуста: это не
        // «каждокадровый перебор», а один словарный запрос в 60 тиков.
        static void TryCacheEffects()
        {
            if (VeilShader == null)
            {
                try { ShaderManager.TryGetShader("ShatteredGenesis.SleepVeil", out VeilShader); }
                catch (Exception) { /* реестр ещё не создан — дождёмся */ }
            }

            if (SleepFilter == null)
            {
                try { ShaderManager.TryGetFilter("ShatteredGenesis.SleepFade", out SleepFilter); }
                catch (Exception) { /* аналогично */ }
            }

            if (CampDarknessFilter == null)
            {
                try { ShaderManager.TryGetFilter("ShatteredGenesis.CampDarkness", out CampDarknessFilter); }
                catch (Exception) { /* фильтр ещё компилируется */ }
            }
        }

        public override void PostSetupContent() => TryCacheEffects();

        public override void Load()
        {
            On_Main.Draw += LoadCurtain;
        }

        public override void Unload()
        {
            On_Main.Draw -= LoadCurtain;

            // Ссылки на эффекты Luminance умирают вместе с нами.
            VeilShader = null;
            SleepFilter = null;
            CampDarknessFilter = null;
        }

        public static void RaiseCurtain() => hopCurtain = true;
        public static void LiftCurtain() => hopCurtain = false;

        // ===================== СЮЖЕТНЫЙ ПЕРЕНОС =====================
        // Единственная кнопка «бросить игрока в мир истории».
        // Зовётся из AnomalyPlayer (первый заход) и из отладочной /sgworld.
        public static void EnterStory()
        {
            // Печать Вселения (канон 0.4.26-4): Скорлупа разрушена ивентом —
            // ни автоперенос, ни отладочный вход её больше не откроют.
            if (WorldFlagsSystem.ShellDestroyed)
                return;

            // Запоминаем настройки игрока и глушим эффекты/окружение ДО
            // запуска генератора. OnEnter вернёт значения после загрузки.
            SubworldAudioSilencer.Begin();
            RaiseCurtain(); // чёрный шов перелёта — между мирами, не в мире

            try
            {
                // Enter возвращает false, если переход даже не стартовал.
                // В этом случае OnEnterWorld не придёт и звук возвращаем сами.
                if (!SubworldSystem.Enter<DecoyWorld>())
                {
                    LiftCurtain();
                    SubworldAudioSilencer.End();
                }
            }
            catch
            {
                // Синхронный отказ перехода не должен оставить игру без звука.
                LiftCurtain();
                SubworldAudioSilencer.End();
                throw;
            }
        }

        // ===================== Update-канал: страховки =====================
        public override void PostUpdateEverything()
        {
            // Редкий дозабор эффектов Luminance (аудит №3) — раз в секунду
            // по аппаратным апдейтам движка, и только пока кэш пуст.
            if ((VeilShader == null || SleepFilter == null || CampDarknessFilter == null)
                && Main.GameUpdateCount % 60 == 0)
            {
                TryCacheEffects();
            }

            // Страховка аварийного возврата и тайм-аут живут внутри
            // SubworldAudioSilencer; здесь не дублируем его состояние.
        }

        // ===================== Занавес шва =====================
        // ОДНА обязанность: чёрный шов перелёта в подмир. Рисуем ПОСЛЕ
        // orig (кадр движка закончен, батч свободен — урок 0.4.25.2).
        // Во время генераций не лезем никуда: создание основного мира —
        // чистая ваниль; стройка подмира — DecoyWorld.DrawSetup.
        void LoadCurtain(On_Main.orig_Draw orig, Main self, GameTime gameTime)
        {
            orig(self, gameTime);

            if (!hopCurtain || WorldGen.generatingWorld)
                return;

            Main.instance.GraphicsDevice.Clear(Color.Black);
        }

        // ===================== Печать сборки (урок 0.4.25.3) =====================
        // На каждом нашем кадре — строка с версией: по скриншоту сразу
        // видно, КАКАЯ сборка реально запущена у режиссёра.
        static string stamp;

        public static void DrawStamp(SpriteBatch sb, float screenW, float screenH)
        {
            if (stamp == null)
            {
                try
                {
                    Mod mod = ModLoader.GetMod("ShatteredGenesis");
                    stamp = mod != null
                        ? $"Shattered Genesis v{mod.Version}"
                        : "Shattered Genesis (dev)";
                }
                catch (Exception)
                {
                    stamp = "Shattered Genesis (dev)";
                }
            }

            var font = FontAssets.MouseText.Value;
            const float scale = 0.85f;
            Vector2 size = font.MeasureString(stamp) * scale;
            Vector2 pos = new Vector2(8f, screenH - size.Y - 8f); // левый нижний угол

            sb.DrawString(font, stamp, pos + new Vector2(1f, 1f),
                Color.Black * 0.8f, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, stamp, pos,
                new Color(140, 140, 140) * 0.55f, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/SubworldAudioSilencer.cs
================================================================
using System;
using Terraria;
using Terraria.Audio;
using Terraria.ModLoader;

namespace ShatteredGenesis.Common.Systems
{
    // Заглушает переход и всю загрузку Скорлупы. Значения громкости
    // игрока сохраняются в памяти и возвращаются только в OnEnterWorld,
    // когда подмир уже полностью создан и игрок действительно вошёл.
    // Никакой рефлексии, RuntimeDetour и записи настроек на диск.
    public sealed class SubworldAudioSilencer : ModSystem
    {
        static bool active;
        static float savedSoundVolume;
        static float savedAmbientVolume;
        static DateTime startedAtUtc;

        public static bool Active => active;

        public static void Begin()
        {
            if (!active)
            {
                savedSoundVolume = Main.soundVolume;
                savedAmbientVolume = Main.ambientVolume;
                startedAtUtc = DateTime.UtcNow;
                active = true;
            }

            ApplyMute();
            StopTransientSounds();
        }

        public static void End()
        {
            if (!active)
                return;

            // Сначала убираем уже созданные, но пока неслышимые звуки
            // генератора, и лишь затем возвращаем настройки игрока.
            StopTransientSounds();
            Main.soundVolume = savedSoundVolume;
            Main.ambientVolume = savedAmbientVolume;

            active = false;
        }

        public override void PostUpdateEverything()
        {
            if (!active)
                return;

            // Некоторые этапы генерации могут трогать общие поля звука.
            // Пока переход не закончен, удерживаем эффект и эмбиент в нуле.
            ApplyMute();

            // Не восстанавливаем звук по Main.gameMenu: Subworld Library
            // держит меню открытым ещё ДО начала генерации. Ранний таймер
            // как раз возвращал громкость перед установкой блоков.

            // Последняя страховка от зависшего стороннего перехода.
            if (DateTime.UtcNow - startedAtUtc > TimeSpan.FromMinutes(10))
                End();
        }

        public override void Unload() => End();

        static void ApplyMute()
        {
            Main.soundVolume = 0f;
            Main.ambientVolume = 0f;
        }

        static void StopTransientSounds()
        {
            try { SoundEngine.StopTrackedSounds(); } catch (Exception) { }
            try { SoundEngine.StopAmbientSounds(); } catch (Exception) { }
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/VanillaGenSnapshot.cs
================================================================
using System.Collections.Generic;
using Terraria.WorldBuilding;

// VanillaGenSnapshot — слепок настоящего ванильного конвейера генерации
// вместе с его чертежом.
//
// История эксперимента:
//  * 0.3.4.2 «слепок» — задачи без конфигурации → NRE в TerrainPass (строке
//    нужен GameConfiguration с чертежом размеров мира).
//  * 0.3.4.3 «весь станок» — WorldGen.GenerateWorld внутри под-мира →
//    споткнулся о внутреннюю бухгалтерию (WorldGen.cs ~1300).
//  * 0.3.4.4 «чертёж и станок» — Snapshot + Config: у Subworld есть родной
//    слот Config, а готовый ванильный WorldGenConfiguration доступен в
//    GenVars.configuration в момент создания основного мира. Хватаем оба
//    одним движением — и Subworld Library сама кормит каждому проходу его
//    кусок чертежа по имени.
namespace ShatteredGenesis.Common.Systems
{
    public static class VanillaGenSnapshot
    {
        // Скорлупа всегда использует настоящий ванильный конвейер.
        // Отладочный переключатель на ручную копию удалён в 0.4.26.7.

        // Слепок проходов и чертёж. null — мир с модом не создавался в сессии.
        private static List<GenPass> passes;
        private static WorldGenConfiguration config;

        public static bool Ready => passes != null && passes.Count > 0 && config != null;
        public static int Count => passes?.Count ?? 0;

        // Чертёж для слота Subworld.Config — Subworld Library вызывает его
        // до генерации и раздаёт проходам их персональные куски.
        public static WorldGenConfiguration Config => config;

        // Снимаем КОПИЮ списка + ссылку на чертёж. Вызывается из
        // FirstWorldGenSystem ПЕРЕД расчисткой задач под Первомир.
        public static void Capture(List<GenPass> tasks, WorldGenConfiguration currentConfig)
        {
            passes = new List<GenPass>(tasks);
            config = currentConfig;
        }

        // Свежая копия слепка + наш финишный проход (спавн, Гид).
        public static List<GenPass> BuildTaskList(GenPass finisher)
        {
            if (!Ready)
                throw new System.InvalidOperationException(
                    "[ShatteredGenesis] Ванильный конвейер не захвачен. " +
                    "Создай новый основной мир после Build + Reload.");

            var list = new List<GenPass>(passes);
            if (finisher != null)
                list.Add(finisher);
            return list;
        }
    }
}

================================================================
=== ФАЙЛ: Common/Systems/WorldFlagsSystem.cs
================================================================
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;

// WorldFlagsSystem — память мира. Хранит сюжетные флаги,
// которые должны переживать перезаходы:
//   IntroDone     — интро «Привал» показали (не заставляем смотреть снова)
//   InfusionDone  — Вселение произошло (игрока выкинуло в Первомир)
//   ShellDestroyed — обманка-Скорлупа разрушена ивентом (вход запечатан)
//
// КАНОН 0.4.26-4 «Вечная Скорлупа» — ЛЕЧЕНИЕ КОЛЬЦА (баг, пойманный
// режиссёром): раньше загрузка мира СТИРАЛА память значением из файла.
// Сцена кончалась в подмире (файл основного мира этого ещё не знал) —
// возврат в основной мир откатывал флаг на false, и игрока снова
// бросало на генерацию. Бесконечное кольцо.
// Теперь: флаги МОНОТОННЫ — «истина» никогда не откатывается загрузкой;
// новые миры сбрасываются отдельно (PostWorldGen / отсутствие ключа).
namespace ShatteredGenesis.Common.Systems
{
    public class WorldFlagsSystem : ModSystem
    {
        public static bool IntroDone;
        public static bool InfusionDone;
        public static bool ShellDestroyed; // Скорлупа разрушена Вселением

        // Сохранение мира — просто кладём наши флаги в общий контейнер.
        public override void SaveWorldData(TagCompound tag)
        {
            tag["sg_introDone"] = IntroDone;
            tag["sg_infusionDone"] = InfusionDone;
            tag["sg_shellDestroyed"] = ShellDestroyed;
        }

        // Загрузка мира — МОНОТОННО (канон 0.4.26-4): пройденная история
        // не откатывается. Если ключей нет вообще — мир впервые входит в
        // память (или очень старый сейв) → история начинается с чистого листа.
        public override void LoadWorldData(TagCompound tag)
        {
            bool hasKey = tag.ContainsKey("sg_introDone");

            bool intro = tag.TryGet("sg_introDone", out bool i) && i;
            bool infusion = tag.TryGet("sg_infusionDone", out bool f) && f;
            bool shell = tag.TryGet("sg_shellDestroyed", out bool s) && s;

            if (!hasKey)
            {
                IntroDone = false;
                InfusionDone = false;
                ShellDestroyed = false;
                return;
            }

            IntroDone = IntroDone || intro;
            InfusionDone = InfusionDone || infusion;
            ShellDestroyed = ShellDestroyed || shell;
        }

        // Хук сразу после создания мира: новорождённый мир начинает
        // историю с чистого листа (и не может нести чужой Скорлупы).
        public override void PostWorldGen()
        {
            IntroDone = false;
            InfusionDone = false;
            ShellDestroyed = false;
            DedupeGuides(); // баг №3: один Гид, не близнецы
        }

        // ===================== РАЗРУШЕНИЕ СКОРЛУПЫ =====================
        // Зовётся ивентом Вселения: игрока выкинуло в Первомир — обманке
        // конец. Печать вечна: ни автоперенос, ни /sgworld её больше
        // не откроют. (Физическое стирание файлов подмира подвесим сюда
        // на этапе строительства самого ивента — через проверенный API
        // библиотеки, по нашему уставу без выдумок.)
        public static void DestroyShell()
        {
            InfusionDone = true;
            ShellDestroyed = true;
        }

        // Санитар: растворяет всех активных Гидов, кроме первого.
        // Зовётся из PostWorldGen и страховочно при входе в мир/обманку.
        public static void DedupeGuides()
        {
            // Клиент мультиплеера не правит НПС — придёт синхронизация хоста.
            if (Main.netMode == NetmodeID.MultiplayerClient)
                return;

            bool kept = false;
            for (int i = 0; i < Main.maxNPCs; i++)
            {
                NPC npc = Main.npc[i];
                if (!npc.active || npc.type != NPCID.Guide)
                    continue;

                if (!kept)
                {
                    kept = true; // первый по списку остаётся жить
                    continue;
                }

                npc.active = false; // дубликат растворяется без звука и дыма
            }
        }
    }
}

================================================================
=== ФАЙЛ: Content/Menus/GenesisMenu.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics; // DrawString под DynamicSpriteFont живёт тут (урок 0.4.23.1)
using Terraria;
using Terraria.GameContent;
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;

// GenesisMenu — кастомное меню ТИТУЛЬНОГО экрана (канон 0.4.25 «Капсула»).
//
// ЧЕСТНАЯ ГРАНИЦА МЕХАНИКИ tML (записано техотделом для летописи):
// ModMenu — это кожа титульного экрана и ТОЛЬКО его. Метод OnSelected()
// срабатывает, когда игра переключает ТИТУЛ на это меню — «моментом
// генерации мира» он быть не может: экраны создания мира рисует ванильный
// Main.DrawMenu, а подмир — Subworld Library. Поэтому архитектура у нас
// трёхслойная, и у каждого слоя свой законный приёмник:
//   титул          → GenesisMenu (этот файл);
//   создание мира  → Капсула в StoryDirector (ванильный экран не вызывается);
//   стройка подмира → DecoyWorld.DrawSetup (законный override библиотеки).
namespace ShatteredGenesis.Content.Menus
{
    public class GenesisMenu : ModMenu
    {
        public override string DisplayName => "Shattered Genesis";

        // Хронометр титула (дыхание костра). Тикает в Update-канале.
        static int ticks;

        static bool artTried;
        static Texture2D art;

        // Вызывается, когда ТИТУЛ переключают на наше меню.
        public override void OnSelected()
        {
            ticks = 0;
        }

        public override void OnDeselected() { }

        // Update-канал титула: хронометр живёт здесь, а не в отрисовке.
        public override void Update(bool isOnTitleScreen) => ticks++;

        // Рисование в ModMenu этой сборки tML: метода Draw(SpriteBatch)
        // НЕ СУЩЕСТВУЕТ (урок 0.4.25.1, CS0115; сверено с исходником ветки
        // 1.4.4). Законные точки кисти — PreDrawLogo и PostDrawLogo: обе
        // дёргаются уже в открытый движком батч (UI-пространство).
        // Свой титул рисуем в PostDrawLogo; стандартный лого при этом гасим.
        public override bool PreDrawLogo(SpriteBatch spriteBatch, ref Vector2 logoDrawCenter,
            ref float logoRotation, ref float logoScale, ref Color drawColor)
            => false; // чужой лого на нашем плакате не нужен

        public override void PostDrawLogo(SpriteBatch sb, Vector2 logoDrawCenter,
            float logoRotation, float logoScale, Color drawColor)
        {
            TryArt();

            // Батч открыт в UI-масштабе — меряем логические единицы:
            // пиксели вьюпорта ÷ Main.UIScale (точная геометрия).
            var vp = Main.instance.GraphicsDevice.Viewport;
            float ui = Main.UIScale;
            if (ui <= 0.01f)
                ui = 1f;
            int sw = (int)(vp.Width / ui + 1f);
            int sh = (int)(vp.Height / ui + 1f);

            // Чернь титула. Ванильный фон меню накрывается полностью.
            sb.Draw(TextureAssets.MagicPixel.Value,
                new Rectangle(-2, -2, sw + 4, sh + 4), Color.Black);

            if (art != null)
            {
                // Вписываем костёр целиком, по центру; дыхание — как у углей.
                float k = Math.Min(sw / (float)art.Width, sh / (float)art.Height);
                int w = (int)(art.Width * k);
                int h = (int)(art.Height * k);
                float alpha = 0.55f + 0.2f * (float)Math.Sin(ticks * 0.02f);
                sb.Draw(art, new Rectangle((sw - w) / 2, (sh - h) / 2, w, h),
                    Color.White * alpha);
            }

            // Титульная вязь — янтарём, там, где обычно сидит лого.
            // Горизонтальную середину берём из якоря движка logoDrawCenter.X
            // (Main.screenWidth в менюшных кадрах заказчику врал — урок 0.4.24).
            var font = FontAssets.DeathText.Value;
            DrawLine(sb, font, "SHATTERED GENESIS",
                logoDrawCenter.X, logoDrawCenter.Y, 0.55f, new Color(255, 196, 60));
            DrawLine(sb, font, "— Расколотый Первомир —",
                logoDrawCenter.X, logoDrawCenter.Y + 52f, 0.30f, new Color(196, 150, 38));

            StoryDirector.DrawStamp(sb, sw, sh); // печать сборки (урок 0.4.25.3)
        }

        // Строка по центру кадра с чёрной каймой (читается на любом фоне).
        static void DrawLine(SpriteBatch sb, ReLogic.Graphics.DynamicSpriteFont font,
                             string text, float centerX, float centerY, float scale, Color color)
        {
            Vector2 size = font.MeasureString(text) * scale;
            Vector2 pos = new Vector2(centerX - size.X * 0.5f,
                                      centerY - size.Y * 0.5f);
            Color shade = Color.Black * 0.9f;
            sb.DrawString(font, text, pos + new Vector2(2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(-2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, 2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, -2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos, color, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
        }

        // Арт щупаем строго через HasAsset (урок 0.4.21.1: слепой Request
        // несуществующего файла на новых сборках tML — фатальная модалка).
        static void TryArt()
        {
            if (artTried)
                return;
            artTried = true;

            if (!ModAssetHelper.Exists("Assets/Slides/CampScene.png"))
                return; // нет арта — титул честно чёрный

            try
            {
                art = ModContent.Request<Texture2D>(
                    "ShatteredGenesis/Assets/Slides/CampScene",
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                art = null; // бронежилет
            }
        }
    }
}

================================================================
=== ФАЙЛ: Content/NPCs/StoryTeller.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using Terraria;
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;

namespace ShatteredGenesis.Content.NPCs
{
    // Сказитель существует только внутри сцены у костра. Это не городской NPC:
    // он не открывает ванильное окно чата, не получает урон и не уходит сам.
    public sealed class StoryTeller : ModNPC
    {
        static Texture2D markerTexture;
        static bool markerLoadTried;

        public override void SetStaticDefaults()
        {
            Main.npcFrameCount[Type] = 4;
        }

        public override void SetDefaults()
        {
            NPC.width = 34;
            NPC.height = 54;
            NPC.friendly = true;
            NPC.dontTakeDamage = true;
            NPC.lifeMax = 250;
            NPC.damage = 0;
            NPC.defense = 0;
            NPC.aiStyle = -1;
            NPC.noGravity = true;
            NPC.noTileCollide = true;
            NPC.netAlways = true;
            NPC.alpha = 0;
        }

        public override bool CheckActive() => false;
        public override bool CanChat() => false;

        public override void AI()
        {
            if (!CampfireScene.Active)
            {
                NPC.active = false;
                return;
            }

            if (!CampfireScene.StorytellerShouldLeave)
            {
                NPC.Bottom = CampfireScene.StorytellerAnchor;
                NPC.velocity = Vector2.Zero;
                NPC.direction = -1;
                NPC.spriteDirection = -1;
                NPC.alpha = Math.Max(0, NPC.alpha - 8);
                return;
            }

            // Не исчезаем по щелчку: медленно уходим от костра и растворяемся
            // в абсолютной темноте. Движок применит velocity после AI.
            NPC.noGravity = true;
            NPC.noTileCollide = true;
            NPC.velocity = new Vector2(0.48f, 0f);
            NPC.direction = 1;
            NPC.spriteDirection = 1;
            NPC.alpha = Math.Min(255, NPC.alpha + 3);

            if (NPC.alpha >= 255)
            {
                NPC.active = false;
                CampfireScene.NotifyStorytellerGone();
            }
        }

        public override void FindFrame(int frameHeight)
        {
            NPC.frameCounter += CampfireScene.StorytellerShouldLeave ? 0.20 : 0.10;
            int frame = (int)NPC.frameCounter % Main.npcFrameCount[Type];
            NPC.frame.Y = frame * frameHeight;
        }

        public override void PostDraw(SpriteBatch spriteBatch, Vector2 screenPos, Color drawColor)
        {
            if (!CampfireScene.MarkerVisible || NPC.alpha >= 240)
                return;

            TryLoadMarker();
            if (markerTexture == null)
                return;

            const int frameCount = 4;
            int frameHeight = markerTexture.Height / frameCount;
            int frame = (int)(Main.GameUpdateCount / 8 % frameCount);
            Rectangle source = new Rectangle(0, frame * frameHeight,
                markerTexture.Width, frameHeight);

            float pulse = 0.88f + 0.12f * (float)Math.Sin(Main.GameUpdateCount * 0.13f);
            Vector2 position = NPC.Top - screenPos + new Vector2(0f, -18f);
            Vector2 origin = new Vector2(source.Width * 0.5f, source.Height);

            // Два прохода дают читаемое янтарное свечение даже в фильтре тьмы.
            spriteBatch.Draw(markerTexture, position, source,
                new Color(255, 126, 18) * (0.30f * pulse),
                0f, origin, 1.32f, SpriteEffects.None, 0f);
            spriteBatch.Draw(markerTexture, position, source,
                Color.White * pulse,
                0f, origin, 1f, SpriteEffects.None, 0f);
        }

        static void TryLoadMarker()
        {
            if (markerLoadTried)
                return;
            markerLoadTried = true;

            try
            {
                markerTexture = ModContent.Request<Texture2D>(
                    "ShatteredGenesis/Assets/UI/StoryMarker",
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                markerTexture = null;
            }
        }

        public override void Unload()
        {
            markerTexture = null;
            markerLoadTried = false;
        }
    }
}

================================================================
=== ФАЙЛ: Content/Particles/EmberParticle.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Luminance.Core.Graphics;

// ============================================================
// EmberParticle — искра сюжетного костра на движке Luminance.
//
// КАНОН 0.4.26 «Глубокая интеграция»: кустарная мини-система частиц
// CampfireScene (массивы ex/ey/evx…) УДАЛЕНА. Теперь искры — настоящие
// частицы: тикают в PartiсleManager.PostUpdateDusts, рисуются в
// On_Main.DrawDust — то есть ГЛУБОКО внутри мирового пайплайна,
// а не поверх UI нашим кодом.
//
// Текстура — с общего атласа Luminance (Assets/Atlases/StoryParticles):
//   AtlasTextureName обязан начинаться с "ИмяМода." — иначе атлас
//   её не найдёт (правило библиотеки, сверено с исходником
//   Particle.cs / AtlasManager.cs).
//
// БРОНЯ: если атлас не прочитался (атласная JSON-разметка — внешний
// формат), Texture будет null — рисуем ничего, игру не роняем.
// В логе Luminance при этом сам напишет, чего не нашла.
// ============================================================
namespace ShatteredGenesis.Content.Particles
{
    public sealed class EmberParticle : Particle
    {
        // Имя субтекстуры на атласе: шаблон "<Мод>.<Имя>" (канон библиотеки).
        public override string AtlasTextureName => "ShatteredGenesis.Ember";

        // Искра светится — аддитивное смешивание, как у пламени.
        public override BlendState BlendState => BlendState.Additive;

        public EmberParticle(Vector2 position, Vector2 velocity, Color color, int lifetimeTicks)
        {
            Position = position;
            Velocity = velocity;
            DrawColor = color;
            Lifetime = lifetimeTicks; // PartiсleManager сравнивает Time >= Lifetime — в тиках
            Scale = Vector2.One;
            Rotation = Main.rand.NextFloat(-MathHelper.Pi, MathHelper.Pi);
            RotationSpeed = Main.rand.NextFloat(-0.03f, 0.03f);
        }

        public override void Update()
        {
            // Искра гаснет: замедляется, чуть тянется вбок от жара,
            // и краснеет по мере старения (жар → тлеющий рубин).
            Velocity *= 0.965f;
            Velocity += new Vector2(
                (float)Math.Sin(Time * 0.11 + Position.X) * 0.025f, -0.010f);

            Rotation += RotationSpeed;

            float lifeLeft = 1f - LifetimeRatio;
            Opacity = lifeLeft * lifeLeft; // квадратичное остывание — живее
            DrawColor = Color.Lerp(
                new Color(120, 34, 8),    // почти остывший уголёк
                new Color(255, 226, 140), // только что сорвалась с полена
                lifeLeft);
            Scale = Vector2.One * (0.55f + 0.45f * lifeLeft);
        }

        public override void Draw(SpriteBatch spriteBatch)
        {
            // БРОНЯ: атласа нет — не рисуем и не падаем.
            if (Texture is null)
                return;

            base.Draw(spriteBatch);
        }
    }
}

================================================================
=== ФАЙЛ: Content/Particles/FireflyParticle.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Luminance.Core.Graphics;

// ============================================================
// FireflyParticle — ночной светляк над поляной привала.
// Тот же канон 0.4.26 «Глубокая интеграция»: движок частиц
// Luminance (Particle/ParticleManager), атлас StoryParticles.
// Плывёт ленивой синусоидой, мерцает, живёт дольше искры.
// ============================================================
namespace ShatteredGenesis.Content.Particles
{
    public sealed class FireflyParticle : Particle
    {
        public override string AtlasTextureName => "ShatteredGenesis.Firefly";

        // Светляк светит сам по себе — аддитив, но слабенький.
        public override BlendState BlendState => BlendState.Additive;

        readonly float swaySeed;

        public FireflyParticle(Vector2 position, Vector2 velocity, int lifetimeTicks)
        {
            Position = position;
            Velocity = velocity;
            DrawColor = new Color(214, 255, 150); // зеленовато-янтарный огонёк
            Lifetime = lifetimeTicks;
            Scale = Vector2.One * 0.8f;
            swaySeed = Main.rand.NextFloat(100f);
        }

        public override void Update()
        {
            // Ленивый дрейф + мерцание: огонёк то гаснет, то разгорается.
            Velocity *= 0.98f;
            Velocity += new Vector2(
                (float)Math.Sin(Time * 0.045 + swaySeed) * 0.05f,
                (float)Math.Cos(Time * 0.037 + swaySeed * 1.7f) * 0.03f);

            float shimmer = 0.55f + 0.45f * (float)Math.Sin(Time * 0.14 + swaySeed * 2.3f);
            float fade = (float)Math.Sin(LifetimeRatio * Math.PI); // вплыл — догорел
            Opacity = shimmer * fade;
        }

        public override void Draw(SpriteBatch spriteBatch)
        {
            if (Texture is null)
                return; // броня на сломанный атлас

            base.Draw(spriteBatch);
        }
    }
}

================================================================
=== ФАЙЛ: Content/Subworlds/DecoyWorld.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics; // урок 0.4.23.1: без него SpriteBatch.DrawString НЕ видит
                        // перегрузки под DynamicSpriteFont (ReLogic кладёт их сюда) —
                        // CS1503 «не преобразовать в SpriteFont». CampfireScene знал.
using SubworldLibrary;
using Terraria;
using Terraria.GameContent;
using Terraria.GameContent.Generation;
using Terraria.GameInput;
using Terraria.ID;
using Terraria.IO;
using Terraria.ModLoader;
using Terraria.WorldBuilding;
using ShatteredGenesis.Common.Systems;
using ShatteredGenesis.Content.Tiles;

// DecoyWorld — мир-обманка (subworld). «Скорлупа» Первомира.
//
// v0.3.4 «Слоёвка Строителя» — переработка по ТЗ режиссёра:
//  * ландшафт приближен к ванильному: октавы + огибающая долин + холмы-горбы;
//  * пещерные устья НЕ появляются у спавна (за 450 блоков от центра — табу);
//  * слои: поверхность = уровень моря; подземелье до 500 — земля с камнем;
//    ниже — чистый камень;
//  * руды — по каноничной ванильной формуле «Shinies»: три глубинных пояса,
//    счёт от площади мира (6E-05 / 8E-05 / 0.0002 для меди), силы и длины
//    TileRunner как в оригинале.
//
// Скорлупа сохраняется после первого создания. Генератор — только настоящий
// ванильный конвейер, захваченный при создании основного мира.
namespace ShatteredGenesis.Content.Subworlds
{
    public class DecoyWorld : Subworld
    {
        public override int Width => 3200;
        public override int Height => 1200;

        // КАНОН 0.4.26-4 «Вечная Скорлупа» (указ режиссёра): обманка
        // СОЗДАЁТСЯ ОДИН РАЗ и навсегда прикреплена к своему основному миру —
        // библиотека держит её файлы рядом с сейвом мира. Никакой
        // перегенерации при заходах: кольца «бесконечного создания» нет.
        // Живёт до ивента Вселения — там её печать вскроется
        // (WorldFlagsSystem.DestroyShell).
        public override bool ShouldSave => true;

        // Ведём себя как обычный мир (солнце, жидкости, НПС).
        public override bool NormalUpdates => true;

        // Чертёж для Subworld Library: перед генерацией библиотека спрашивает
        // Config — отдаём ванильный WorldGenConfiguration из слепка, и она
        // сама кормит каждого прохода его персональным куском чертежа.
        public override WorldGenConfiguration Config => VanillaGenSnapshot.Config;

        public override List<GenPass> Tasks
        {
            get
            {
                // Единственный законный режим: полный список ванильных проходов
                // вместе с оригинальным WorldGenConfiguration. Старого ручного
                // генератора и скрытого переключателя больше нет.
                return VanillaGenSnapshot.BuildTaskList(
                    new PassLegacy("…готовит привал…", PlaceSceneSpawn, 5f));
            }
        }

        // ===================== ЭКРАН ПЕРЕНОСА (канон 0.4.26) =====================
        // Subworld Library зовёт DrawSetup/DrawMenu каждый кадр, пока ЭТОТ
        // подмир строится (или читается с диска). Дефолтный DrawMenu просто
        // писал ванильный Main.statusText по центру — ту самую «Настройку
        // жидкостей… 34%», что режиссёр поймал на скрине.
        //
        // 0.4.26 «Перенос»: экран — это КАТСЦЕНА ХОДЬБЫ ПО ЛЕСУ. Широкая
        // лента-силуэт леса (ForestWalk.png) натянута на высоту кадра и
        // ПОЛЗЁТ вбок с походочной тряской; сцена временно немая.
        // курсор на загрузке СКРЫТ (указ №4) — вернёт его вход в подмир.
        //
        // Мы экран ПОДМЕНЯЕМ ЦЕЛИКОМ: своя чернь, свой арт (костёр сна),
        // свои строки-«шёпот» по таймеру и живые угольки. Отдельного
        // update-хука у экрана загрузки нет — DrawMenu одновременно и
        // «часы», и кисть (~60 вызовов в секунду), поэтому таймер диалога
        // крутится прямо внутри неё.

        // КАНОН 0.4.26-3 (аудит №1): ручной счётчик кадров в отрисовке
        // УНИЧТОЖЕН — на 144Гц мониторах он ускорял тайминги катсцены.
        // Вместо него аппаратные часы движка Main.GameUpdateCount:
        // тикает ровно 60 раз в секунду на ЛЮБОЙ герцовке и FPS.
        static uint clockStart;      // GameUpdateCount на первом кадре показа
        static bool clockStarted;    // защёлка «часы заведены»

        // Тики текущего показа экрана (1 тик = строго 1/60 сек, всегда).
        static int LoadTicks
        {
            get
            {
                // Первый кадр показа «прилипает» к часам движка.
                if (!clockStarted)
                {
                    clockStarted = true;
                    clockStart = Main.GameUpdateCount;
                }
                return (int)(Main.GameUpdateCount - clockStart);
            }
        }

        // Пелена загрузки: сам шейдер кэширован в StoryDirector (аудит №3).
        // Здесь — только «предохранитель рисовки»: если Begin с эффектом
        // один раз упал — пелену больше не рисуем (лес и чернь остаются).
        static bool veilBroken;

        // Арт грузим лениво и ОДИН раз — и только если файл существует
        // (урок 0.4.21.1: слепой Request отсутствующего ассета на новых
        // сборках tML выкидывает фатальную модалку даже из-под try/catch).
        static bool loadArtTried;
        static Texture2D loadArt;

        // Сценарий экрана: (с какого тика вплывает строка, текст).
        // ПАУЗЫ В ТИКАХ: 60 тиков ≈ 1 секунда. Тон — дорога к привалу;
        // последняя строка предвещает CampArrive («давай тут остановимся»).
        static readonly (int at, string text)[] LoadScript = new (int, string)[]
        {
            (30,  "…идём потихоньку. Ночь густая — глаз выколи…"),
            (380, "…за деревьями шуршит. Не зверь — ветер. Наверное…"),
            (760, "…погодь, впереди просвет. Там поляна — привал устроим…"),
        };

        // Угольки над «костром сна»: автономная мини-система частиц этого
        // экрана. Свой Random — генерацию мира не сдвигаем.
        const int LoadEmberCount = 26;
        static float[] emberX, emberY, emberV, emberLife;
        static readonly Random emberRand = new Random();

        // Полный перехват отрисовки экрана подмира: копия дефолтного
        // DrawSetup библиотеки, но БЕЗ курсора — кино не должно «светить
        // мышью» посреди сна.
        // БРОНЯ (урок 0.4.25.2, фатальный вылет у режиссёра): в сборке
        // 2026.6 движок может звать меню-отрисовку ВНУТРИ уже открытого
        // spriteBatch — повторный Begin кидает InvalidOperationException
        // «Begin has been called before End». Ловим и рисуем в чужой
        // батч: картинка жива, вылета нет. Свой Begin — закрываем,
        // чужой — не трогаем.
        public override void DrawSetup(GameTime gameTime)
        {
            PlayerInput.SetZoom_UI();

            Main.instance.GraphicsDevice.Clear(Color.Black); // глухая чернь-основа

            // СЕГМЕНТ 1: лесная катсцена (арт + угли + печать) — альфа-батч
            // в UI-пространстве.
            DrawSegment(() => DrawMenu(gameTime));

            // СЕГМЕНТ 2: пелена ночи — один экранный проход шейдером
            // Luminance (SleepVeil). Никаких полосатых прямоугольников:
            // квад натянут на ВЕСЬ физический вьюпорт, поэтому ни при
            // каком UI-масштабе не остаётся ни одной светлой полоски.
            float artAlpha = Math.Min(1f, LoadTicks / 45f);
            DrawVeil(artAlpha);

            // СЕГМЕНТ 3: шёпот дороги поверх пелены.
            DrawSegment(() => DrawLoadText());

            // Указ №4 (0.4.26): курсор на загрузке СКРЫТ — тут ничего не
            // кликают. Его вернёт вход в подмир (AnomalyPlayer/OnEnter).
        }

        // Законный конвой любого батч-сегмента этого экрана (урок 0.4.25.2:
        // движок может звать нас из-под ЧУЖОГО открытого SpriteBatch —
        // повторный Begin тогда кидает InvalidOperationException).
        // Сегмент сгорает целиком, рисуем сквозь чужой батч; свой Begin
        // закрываем, чужой не трогаем никогда.
        static void DrawSegment(Action draw)
        {
            bool ourBatch = true;
            try
            {
                Main.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend,
                    SamplerState.LinearClamp, DepthStencilState.None,
                    Main.Rasterizer, null, Main.UIScaleMatrix);
            }
            catch (InvalidOperationException)
            {
                ourBatch = false;
            }

            draw();

            if (ourBatch)
                Main.spriteBatch.End();
        }

        // ПЕЛЕНА НОЧИ через Luminance ShaderManager.
        // Шейдеру нужен СВОЙ эффект в Begin — отдельный immediate-сегмент
        // в ФИЗИЧЕСКИХ пикселях (Matrix.Identity + полный вьюпорт).
        static void DrawVeil(float artAlpha)
        {
            if (veilBroken)
                return;

            // КЭШ (аудит №3): ссылку на шейдер выдал StoryDirector после
            // загрузки эффектов Luminance. Здесь — ни одного словарного
            // поиска: только готовая ссылка и чистая рисовка.
            var veil = StoryDirector.VeilShader;
            if (veil == null)
                return; // шейдер ещё компилится — есть чернь и лес

            var gd = Main.instance.GraphicsDevice;
            try
            {
                veil.TrySetParameter("screenSize",
                    new Vector2(gd.Viewport.Width, gd.Viewport.Height));
                veil.TrySetParameter("opacity", artAlpha);
                veil.TrySetParameter("globalTime", Main.GlobalTimeWrappedHourly);

                // Immediate + наш эффект, в ФИЗИЧЕСКИХ пикселях: один квад
                // на весь вьюпорт — идеальная укрытость при любом UI-скейле.
                Main.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend,
                    SamplerState.LinearClamp, DepthStencilState.None,
                    RasterizerState.CullNone, veil.WrappedEffect, Matrix.Identity);
                Main.spriteBatch.Draw(TextureAssets.MagicPixel.Value,
                    new Rectangle(0, 0, gd.Viewport.Width, gd.Viewport.Height), Color.White);
                Main.spriteBatch.End();
            }
            catch (Exception)
            {
                // Шейдер в бою оказался бракованным — отключаем навсегда,
                // игру не роняем. (Батч движка на этой мине не пострадает:
                // End либо выполнился, либо Begin не открывался.)
                veilBroken = true;
            }
        }

        // Вызывается DrawSetup на каждом кадре стройки подмира.
        // base.DrawMenu НЕ зовём нарочно: именно в нём живёт ванильный
        // статус-текст. Не позвали — ванили на экране нет совсем.
        public override void DrawMenu(GameTime gameTime)
        {
            // Канон 0.4.25: статус-текст больше НЕ глушим — ванильный
            // источник на этом экране не вызывается вообще (base не зовём),
            // гасить нечего. Кадр целиком наш. ВРЕМЯ — аппаратное: часы
            // движка GameUpdateCount (аудит №1), а не счёт кадров отрисовки.
            int t = LoadTicks;

            TryLoadArt();

            SpriteBatch sb = Main.spriteBatch;

            // Геометрия кадра — движковая (аудит №2): UI-трансформ уже в
            // батче (SetZoom_UI в DrawSetup), размеры читаем float'ами из
            // системных же полей — без ручных «+1 на глаз».
            UiFrame(out float sw, out float sh);

            // АРТ вплывает из черни за ~0.75 сек (45 аппаратных тиков).
            // Меняй сюда, если захочется резче/мягче.
            float artA = Math.Min(1f, t / 45f);

            if (loadArt != null)
            {
                // ЛЕСНАЯ ЛЕНТА (канон 0.4.26): натянута по высоте кадра,
                // шире кадра — есть ход для панорамы. Лента ползёт вбок
                // медленным косинусом, вертикаль пружинит («шаговая
                // тряска»). Рисуем ЧИСТЫМИ float: точную обрезку кадра
                // делает GPU, поэтому ни одной микро-щели по краям —
                // ни при растяжении, ни при смене UI-масштаба.
                float stripH = sh;
                float stripW = loadArt.Width * (stripH / loadArt.Height);
                float range = Math.Max(0f, stripW - sw);
                float travel = 0.5f - 0.5f * (float)Math.Cos(t * 0.004f); // 0..1, цикл ~26 сек
                float ox = travel * range;
                float bob = (float)Math.Sin(t * 0.13f) * 3f;

                sb.Draw(loadArt, new Vector2(-ox, bob), null, Color.White * artA,
                    0f, Vector2.Zero,
                    new Vector2(stripW / loadArt.Width, stripH / loadArt.Height),
                    SpriteEffects.None, 0f);
            }

            DrawLoadEmbers(sb, sw, sh);     // угольки летят над сном
            StoryDirector.DrawStamp(sb, sw, sh); // печать сборки (урок 0.4.25.3)
        }

        // СЕГМЕНТ ШЁПОТА (канон 0.4.26-2): строки дороги кладутся ПОСЛЕ
        // пелены, чтобы виньетка их не душила (порядок сегментов задан
        // в DrawSetup). Свой батч — собственный, через DrawSegment.
        static void DrawLoadText()
        {
            SpriteBatch sb = Main.spriteBatch;
            UiFrame(out float sw, out float sh); // движковая геометрия (аудит №2)
            int t = LoadTicks;                   // аппаратные часы (аудит №1)

            // ---- ДИАЛОГ ПО ТАЙМЕРУ ----
            // Текущая строка = последняя, чей тик уже наступил. Строки не
            // стекают в чат и не гаснут: шёпот сменяется на экране сам.
            string line = null;
            int lineAt = 0;
            foreach (var (at, text) in LoadScript)
            {
                if (t >= at)
                {
                    line = text;
                    lineAt = at;
                }
            }

            if (line == null)
                return; // первые полсекунды — чистая чернь с угольками

            // ПРОЗРАЧНОСТЬ СТРОКИ: 40 аппаратных тиков на плавный вплыв.
            float a = Math.Min(1f, (t - lineAt) / 40f);

            // Последняя строка после вплыва «дышит» бесконечно — экран
            // живой, сколько бы мир ни строился (быстро/медленно — неважно).
            int lastAt = LoadScript[LoadScript.Length - 1].at;
            if (lineAt == lastAt && t - lineAt > 90)
            {
                a *= 0.72f + 0.28f * (float)Math.Sin((t - lineAt - 90) * 0.04f);
            }

            DrawCentered(sb, line, sh * 0.80f, a, sw);
        }

        // КАНОН 0.4.26-3 (аудит №2): LogicalScreen с «+1 на глаз» УНИЧТОЖЕН.
        // Геометрию кадра диктуют системные поля движка, читаемые в float:
        // вьюпорт делится на тот же Main.UIScale, что движок сам вкладывает
        // в трансформ батча (PlayerInput.SetZoom_UI в DrawSetup). Никакого
        // округления — никаких микро-щелей: точную обрезку делает GPU.
        static void UiFrame(out float sw, out float sh)
        {
            var vp = Main.instance.GraphicsDevice.Viewport;
            float ui = Main.UIScale;
            if (ui <= 0.0001f)
                ui = 1f; // предохранитель от нуля, а не «запас на пиксель»
            sw = vp.Width / ui;
            sh = vp.Height / ui;
        }

        // Ленивая загрузка арта экрана переноса (один пробный заход).
        static void TryLoadArt()
        {
            if (loadArtTried)
                return;
            loadArtTried = true;

            if (!ModAssetHelper.Exists("Assets/Slides/ForestWalk.png"))
                return; // арта нет — экран идёт строками на черни, не беда

            try
            {
                loadArt = ModContent.Request<Texture2D>(
                    "ShatteredGenesis/Assets/Slides/ForestWalk",
                    AssetRequestMode.ImmediateLoad).Value;
            }
            catch (Exception)
            {
                loadArt = null; // бронежилет: без арта экран всё равно работает
            }
        }

        // Строка-шёпот по центру: фирменный янтарь + чёрная кайма по четырём
        // осям — читается и поверх арта. scale подобран под DeathText.
        static void DrawCentered(SpriteBatch sb, string text, float y, float alpha, float sw)
        {
            var font = FontAssets.DeathText.Value;
            const float scale = 0.42f;

            Vector2 size = font.MeasureString(text) * scale;
            Vector2 pos = new Vector2(
                sw * 0.5f - size.X * 0.5f, y - size.Y * 0.5f);

            Color shade = Color.Black * (alpha * 0.9f);
            sb.DrawString(font, text, pos + new Vector2(2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(-2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, 2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
            sb.DrawString(font, text, pos + new Vector2(0f, -2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);

            sb.DrawString(font, text, pos,
                new Color(255, 196, 60) * alpha, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
        }

        // [УНИЧТОЖЕНО 0.4.26-2] DrawVignette-полосками: 24 прямоугольника
        // MagicPixel с запасами — как раз тот «поверхностный костыль»,
        // что запретил режиссёр. Виньетка теперь — шейдер SleepVeil через
        // Luminance (сегмент 2 в DrawSetup): один точный проход по кругу,
        // ни одной светлой полосы при любом UI-масштабе.

        // Угольки: всплывают из «очага» в центральном низу экрана, гуляют
        // синусом, догорают рубиновым и рождаются снова. Тикают в DrawMenu.
        static void DrawLoadEmbers(SpriteBatch sb, float sw, float sh)
        {
            if (emberX == null)
            {
                emberX = new float[LoadEmberCount];
                emberY = new float[LoadEmberCount];
                emberV = new float[LoadEmberCount];
                emberLife = new float[LoadEmberCount];
                for (int i = 0; i < LoadEmberCount; i++)
                    RespawnEmber(i, sw, sh, true); // первый раз — уже в воздухе
            }

            Texture2D px = TextureAssets.MagicPixel.Value;
            for (int i = 0; i < LoadEmberCount; i++)
            {
                emberLife[i] -= 0.005f;   // один уголёк живёт ≈ 3 секунды
                emberY[i] -= emberV[i];   // всегда вверх, над остывающим сном
                emberX[i] += (float)Math.Sin((LoadTicks + i * 13) * 0.05) * 0.4f;

                if (emberLife[i] <= 0f || emberY[i] < sh * 0.08f)
                    RespawnEmber(i, sw, sh, false);

                float life = Math.Max(0f, Math.Min(1f, emberLife[i]));
                // Живой кал → тлеющий рубин (по мере угасания), как в сцене.
                Color c = Color.Lerp(new Color(196, 90, 20),
                                     new Color(255, 226, 140), life) * (life * 0.85f);
                sb.Draw(px, new Rectangle((int)emberX[i], (int)emberY[i], 3, 3), c);
            }
        }

        static void RespawnEmber(int i, float sw, float sh, bool anywhere)
        {
            // Очаг — центральный низ экрана: там, где в арте стоит костёр.
            emberX[i] = sw * 0.38f + (float)emberRand.NextDouble() * sw * 0.24f;
            emberY[i] = anywhere
                ? sh * (0.30f + (float)emberRand.NextDouble() * 0.50f)
                : sh * (0.78f + (float)emberRand.NextDouble() * 0.12f);
            emberV[i] = 0.6f + (float)emberRand.NextDouble() * 1.2f;
            emberLife[i] = 0.4f + (float)emberRand.NextDouble() * 0.6f;
        }

        // Ручные этапы 0.3.4.1 удалены в 0.4.26.7. Скорлупу строит
        // только захваченный конвейер самой Terraria.

        // ===================== Финиш: точка появления и площадка сцены =====================
        private void PlaceSceneSpawn(GenerationProgress progress, GameConfiguration config)
        {
            int spawnX = Width / 2;
            int surfaceY = FindSurfaceY(spawnX);

            Main.spawnTileX = spawnX;
            Main.spawnTileY = surfaceY - 3;

            // Физическая 3x2 структура привала вшивается прямо в Main.tile.
            // Origin костра — нижняя центральная клетка (spawnX, surfaceY-1).
            for (int x = spawnX - 4; x <= spawnX + 4; x++)
            {
                for (int y = surfaceY - 5; y < surfaceY; y++)
                    WorldGen.KillTile(x, y, false, false, true);

                if (!Framing.GetTileSafely(x, surfaceY).HasTile)
                    WorldGen.PlaceTile(x, surfaceY, TileID.Dirt, true, true);
            }

            bool campfirePlaced = WorldGen.PlaceObject(
                spawnX,
                surfaceY - 1,
                ModContent.TileType<StoryCampfireTile>(),
                true);

            if (!campfirePlaced)
                Mod.Logger.Warn("[SG] StoryCampfireTile не встал при генерации Скорлупы; " +
                    "CampfireScene повторит размещение при входе.");

            // Гида здесь больше не создаём: в сцене сначала только игрок и
            // Сказитель. Настоящий NPCID.Guide приходит из темноты на узле 20.
            progress.Set(1.0);
        }

        // ===================== Помощники =====================
        private int FindSurfaceY(int x)
        {
            for (int y = 0; y < Height; y++)
            {
                if (Main.tile[x, y].HasTile && Main.tileSolid[Main.tile[x, y].TileType])
                    return y;
            }
            return (int)Main.worldSurface;
        }

        public override void OnLoad()
        {
            // OnLoad вызывается библиотекой после чтения/создания файла
            // Скорлупы. Сцена всегда начинается в одной и той же глубокой ночи.
            Main.dayTime = false;
            Main.time = 16200;
            Main.raining = false;
            Main.windSpeedCurrent = 0f;
            Main.windSpeedTarget = 0f;
        }

        public override void OnEnter()
        {
            // Переход начался: занавес перелёта опускаем (его поднимал
            // AnomalyPlayer перед броском сюда — канон 0.4.26).
            StoryDirector.LiftCurtain();

            clockStarted = false; // лесная катсцена отработала — аппаратные
                                  // часы следующего показа стартуют заново

            // Курсор вернёт AnomalyPlayer (требование №4); здесь — дубль-
            // страховка, дёшевая.
            Main.cursorScale = 1f;

            // Баг №3 (канон 0.4.24): на спавне не терпим двух Гидов.
            WorldFlagsSystem.DedupeGuides();

            // Звук здесь НЕ возвращаем: Subworld Library вызывает OnEnter
            // ещё до чтения/создания файла подмира. Восстановление выполняет
            // AnomalyPlayer.OnEnterWorld после полной загрузки Скорлупы.

            // ВРЕМЯ ТУТ БОЛЬШЕ НЕ СТАВИМ: сцену ночи назначает
            // CampfireScene.Begin, а первое утро — её Finish (0.4.26).

        }
    }
}

================================================================
=== ФАЙЛ: Content/Tiles/CampLog.cs
================================================================
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ObjectData;

namespace ShatteredGenesis.Content.Tiles
{
    // Одноклеточный невысокий сегмент бревна. Большой художественный силуэт
    // рисует CampfireScene, а этот тайл даёт Terraria законную точку сидения.
    public sealed class CampLog : ModTile
    {
        public override void SetStaticDefaults()
        {
            Main.tileFrameImportant[Type] = true;
            Main.tileNoAttach[Type] = true;
            Main.tileLavaDeath[Type] = false;
            TileID.Sets.CanBeSatOnForPlayers[Type] = true;
            TileID.Sets.CanBeSatOnForNPCs[Type] = true;
            TileID.Sets.DisableSmartCursor[Type] = true;

            DustType = DustID.WoodFurniture;
            AdjTiles = new int[] { TileID.Chairs };
            AddMapEntry(new Color(76, 43, 22), Language.GetText("MapObject.Chair"));

            TileObjectData.newTile.CopyFrom(TileObjectData.Style1x1);
            TileObjectData.newTile.CoordinateHeights = new int[] { 16 };
            TileObjectData.addTile(Type);
        }

        public override void ModifySittingTargetInfo(int i, int j,
            ref TileRestingInfo info)
        {
            info.AnchorTilePosition = new Point(i, j);
            info.TargetDirection = i < Main.spawnTileX ? 1 : -1;
            info.VisualOffset = new Vector2(0f, 2f);
        }
    }
}

================================================================
=== ФАЙЛ: Content/Tiles/StoryCampfireTile.cs
================================================================
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ObjectData;

namespace ShatteredGenesis.Content.Tiles
{
    // Физический сюжетный костёр. Это настоящий 3x2 multitile, который
    // сохраняется внутри файла Скорлупы и анимируется штатным TileDrawing.
    public sealed class StoryCampfireTile : ModTile
    {
        public const int FrameHeight = 36;
        public const int AnimationFrames = 4;

        public override void SetStaticDefaults()
        {
            Main.tileLighted[Type] = true;
            Main.tileFrameImportant[Type] = true;
            Main.tileWaterDeath[Type] = true;
            Main.tileLavaDeath[Type] = true;
            TileID.Sets.Campfire[Type] = true;
            TileID.Sets.DisableSmartCursor[Type] = true;

            DustType = -1;
            AdjTiles = new int[] { TileID.Campfire };
            AddMapEntry(new Color(242, 103, 16),
                Language.GetText("ItemName.Campfire"));

            TileObjectData.newTile.CopyFrom(
                TileObjectData.GetTileData(TileID.Campfire, 0));
            TileObjectData.newTile.StyleLineSkip = AnimationFrames;
            TileObjectData.addTile(Type);
        }

        public override void AnimateTile(ref int frame, ref int frameCounter)
        {
            if (++frameCounter >= 6)
            {
                frameCounter = 0;
                frame = (frame + 1) % AnimationFrames;
            }
        }

        public override void AnimateIndividualTile(int type, int i, int j,
            ref int frameXOffset, ref int frameYOffset)
        {
            frameYOffset = Main.tileFrame[type] * FrameHeight;
        }

        public override void NearbyEffects(int i, int j, bool closer)
        {
            if (!closer)
                Main.SceneMetrics.HasCampfire = true;
        }

        public override void ModifyLight(int i, int j,
            ref float r, ref float g, ref float b)
        {
            float pulse = 0.92f + 0.08f *
                (float)System.Math.Sin(Main.GameUpdateCount * 0.17f);
            r = 1.15f * pulse;
            g = 0.42f * pulse;
            b = 0.08f * pulse;
        }
    }
}

================================================================
=== ФАЙЛ: ShatteredGenesis.cs
================================================================
using Terraria.ModLoader;

// Это главный класс мода — его «сердце».
// tModLoader находит этот класс сам: он должен называться так же, как папка мода,
// и наследоваться от Mod.
//
// Пока он пустой — и это нормально: вся настоящая работа делается
// в других файлах (системы, игроки, предметы). Сюда мы вернёмся,
// когда нужно будет регистрировать глобальные вещи мода.
namespace ShatteredGenesis
{
    public class ShatteredGenesis : Mod
    {
    }
}

================================================================
=== ФАЙЛ: ShatteredGenesis.csproj
================================================================
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">

  <!-- Это файл проекта. Не редактируй его — tModLoader сам всё сделает через него.
       Важно: строка Import указывает на файл tModLoader.targets, который появляется
       в папке ModSources после первого запуска tModLoader. -->
  <Import Project="..\tModLoader.targets" />

  <PropertyGroup>
    <!-- Внутреннее имя мода — так его видит игра. Меняется только переименованием папки. -->
    <AssemblyName>ShatteredGenesis</AssemblyName>
    <RootNamespace>ShatteredGenesis</RootNamespace>
    <!-- Современный tModLoader работает на .NET 8. Если при сборке будет ошибка
         про версию net8.0 — напиши мне, подгоню под твою версию tML. -->
    <TargetFramework>net8.0</TargetFramework>
    <PlatformTarget>AnyCPU</PlatformTarget>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

</Project>

================================================================
=== ФАЙЛ: build.txt
================================================================
displayName = Shattered Genesis — Расколотый Первомир
author = Shattered Genesis Team
version = 0.4.26.11
# Жёсткие зависимости (указ режиссёра 0.4.26-2): библиотеки на полную мощность.
# Luminance — шейдеры/фильтры/частицы; DAYBREAK — слой системных хуков;
# SubworldLibrary — подмиры. Без них игра откажется грузить мод — и это правильно.
modReferences = Luminance, Daybreak, SubworldLibrary

================================================================
=== ФАЙЛ: description.txt
================================================================
Ты думал, что начал обычную игру.
Ты рубил деревья, разговаривал с Гидом, слушал птиц.

А потом увидел свет у края мира — и коснулся его.

Shattered Genesis — тотальная конверсия: новый мир, новая история,
двенадцать тронов и тайна, которую не знает никто.

Ранняя сборка 0.1 — каркас мира. Содержимое появляется глава за главой.