Загрузка данных
================================================================
=== ФАЙЛ: 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())
{
// ============ ПОДМИР: мир истории ============
// Требование №4 + указ «Фикс мыши» (0.4.26-2): на входе
// мышь и ввод возвращаются ПРИНУДИТЕЛЬНО. Курсор был скрыт
// на загрузочной катсцене ходьбы по лесу (там нечего
// кликать), здесь же — кликабельный диалог.
Main.cursorScale = 1f;
// Игровой канал ввода/зума: экранные матрицы загрузки больше
// не действуют — мир рисуется мировым зумом движка.
PlayerInput.SetZoom_World();
if (!WorldFlagsSystem.IntroDone)
{
CampfireScene.Begin(); // ночная сцена «Привал» у костра
}
else
{
Main.NewText("Где-то за краем мира что-то заметило, что ты пришёл.",
255, 190, 90);
}
return;
}
// ============ ОСНОВНОЙ мир: чистая ваниль ============
// Мир уже полностью загрузился (OnEnterWorld — после всего).
// Единственное наше действие: история начинается переносом.
// Звук стройки режется системно (SoundChannelGuard), шов
// перелёта накрыт занавесом — всё внутри EnterStory.
if (!WorldFlagsSystem.IntroDone)
{
StoryDirector.EnterStory();
}
}
}
}
================================================================
=== ФАЙЛ: 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 ShatteredGenesis.Content.Subworlds;
// ============================================================
// StoryDirector — КЛЕЙ ГЛУБОКОЙ ИНТЕГРАЦИИ каркаса истории.
//
// КАНОН 0.4.26 «Глубокая интеграция» (указ режиссёра №0.4.26-2):
// * ОСНОВНОЙ мир — ЧИСТАЯ ваниль. Никакой графики, звука,
// фризов и капсул на его генерации. Точка.
// * Вся эпоха костылей (зануление Main.soundVolume, полосатые
// прямоугольники-заплатки) — уничтожена. Их заменили:
// - звук стройки подмира → SoundChannelGuard (MonoMod-детур
// на терминус SoundEngine.PlaySound — режет только канал
// SoundType.Sound, громкости игрока не существует для нас);
// - виньетка экрана загрузки → шейдер SleepVeil через
// ShaderManager Luminance (не 24 полоски MagicPixel);
// - искры у костра → частицы Luminance Particle/ParticleManager
// (в мире; на экране загрузки они физически не тикают —
// PartiсleManager живёт в PostUpdateDusts, исходник сверен);
// - сон в финале — фильтр SleepFade через ManagedScreenFilter
// Luminance (мировой экранный конвейер) + чернь.
// * DAYBREAK — жёсткая зависимость: его модуль хуков собран на
// том же слое MonoMod, на котором сидит SoundChannelGuard.
// Аудио-API в релизной сборке DAYBREAK отсутствует (сверено
// с официальным списком фич), поэтому аудио закрыто движком.
//
// Здесь осталось только то, что направляет ИСТОРИЮ:
// EnterStory() — перенос в подмир-обманку (шов + окно тишины);
// 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;
public override void Load()
{
On_Main.Draw += LoadCurtain;
}
public override void Unload()
{
On_Main.Draw -= LoadCurtain;
}
public static void RaiseCurtain() => hopCurtain = true;
public static void LiftCurtain() => hopCurtain = false;
// ===================== СЮЖЕТНЫЙ ПЕРЕНОС =====================
// Единственная кнопка «бросить игрока в мир истории».
// Зовётся из AnomalyPlayer (первый заход) и из отладочной /sgworld.
public static void EnterStory()
{
// Окно тишины: с этого момента и до landingSound (DecoyWorld.OnEnter)
// канал размещения тайлов глушит SoundChannelGuard на уровне
// движка. Громкости игрока при этом не существует для нас вообще.
DecoyWorld.StorySilenceActive = true;
RaiseCurtain(); // чёрный шов перелёта — между мирами, не в мире
SubworldSystem.Enter<DecoyWorld>();
}
// ===================== Update-канал: страховки =====================
public override void PostUpdateEverything()
{
// Сидим в меню БЕЗ генерации — сырые сюжетные каналы (0.4.24)
// обязаны молчать: иначе шаги/угли проклятого сна гудели бы
// в меню после выхода из подмира. Заодно опускаем окно тишины —
// в меню звук кликов (тоже SoundType.Sound) должен жить.
if (Main.gameMenu && !WorldGen.generatingWorld)
{
DecoyWorld.StorySilenceActive = false;
StoryVoice.StepsStop();
StoryVoice.AmbientStop();
StoryVoice.Stop();
}
}
// ===================== Занавес шва =====================
// ОДНА обязанность: чёрный шов перелёта в подмир. Рисуем ПОСЛЕ
// 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/SoundChannelGuard.cs
================================================================
using System;
using System.Reflection;
using Microsoft.Xna.Framework;
using MonoMod.RuntimeDetour;
using ReLogic.Utilities; // SlotId — терминус PlaySound в 1.4.4 возвращает его
using Terraria.Audio;
using Terraria.ID; // SoundType
using Terraria.ModLoader;
using ShatteredGenesis.Content.Subworlds;
// ============================================================
// SoundChannelGuard — ГЛУБОКИЙ страж звука (канон 0.4.26-2).
//
// БЫЛО (костыль, уничтожен): обнуление Main.soundVolume/ambientVolume
// на время генерации — радиомолчание для ВСЕГО, включая меню,
// плюс ручной спасчейн конфига игрока.
//
// СТАЛО: системный перехват на уровне движка. В tML 1.4.4 любой звук
// (копка, постановка блоков, интерфейс, эмбиент) в конце концов
// проходит через ОДИН терминус (сверено с исходником ветки 1.4.4,
// файл SoundEngine.TML.cs: легаси-перегрузки int/LegacySoundStyle
// являются внутренними редиректами и все льются сюда):
// public static SlotId PlaySound(in SoundStyle style,
// Vector2? position = null, SoundUpdateCallback? callback = null)
//
// Мы вешаем MonoMod-детур на этот терминус (тот же слой, на котором
// собрана модульная система хуков DAYBREAK: в релизной сборке
// DAYBREAK аудио-API нет — сверено с официальным списком фич,
// поэтому перехват делаем руками движка).
//
// Пока открыто окно стройки подмира (DecoyWorld.StorySilenceActive),
// сглатываются ТОЛЬКО звуки канала SoundType.Sound — это канал
// «копать/ставить/ломать». Музыка и эмбиент УМЫШЛЕННО живы —
// это не глушилка, это хирургия.
//
// Отказоустойчивость: любой сбой рефлексии/детюра пишется в лог
// и НЕ роняет игру — просто звуки стройки будут слышны, как раньше.
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
public class SoundChannelGuard : ModSystem
{
// Трамплин в оригинальный терминус PlaySound.
// Сигнатура повторяет его точь-в-точь (in SoundStyle — ByRef).
public delegate SlotId PlaySoundOrig(in SoundStyle style,
Vector2? position, SoundUpdateCallback updateCallback);
// Живой хук (удерживаем ссылку, чтобы сборщик мусора не утащил).
static Hook playSoundHook;
public override void Load()
{
try
{
// Ищем терминус по сигнатуре, а не по имени-одиночке:
// перегрузок PlaySound несколько, нужен именно public
// (in SoundStyle, Vector2?, SoundUpdateCallback) → SlotId.
MethodInfo terminus = null;
foreach (var m in typeof(SoundEngine).GetMethods(
BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "PlaySound")
continue;
var ps = m.GetParameters();
if (ps.Length != 3 || m.ReturnType != typeof(SlotId))
continue;
// in-параметр в CIL — это SoundStyle& (ByRef).
Type p0 = ps[0].ParameterType;
if (p0.IsByRef && p0.GetElementType() == typeof(SoundStyle)
&& ps[1].ParameterType == typeof(Vector2?)
&& ps[2].ParameterType == typeof(SoundUpdateCallback))
{
terminus = m;
break;
}
}
if (terminus == null)
{
Mod.Logger.Warn("[SG] SoundChannelGuard: терминус PlaySound не найден — " +
"звуки стройки глушить нечем (сбора журнала хватит техотделу).");
return;
}
playSoundHook = new Hook(terminus,
typeof(SoundChannelGuard).GetMethod(nameof(GenSoundDetour),
BindingFlags.NonPublic | BindingFlags.Static));
}
catch (Exception ex)
{
// Страж не встал — игра живёт дальше без глушения.
Mod.Logger.Warn($"[SG] SoundChannelGuard: детур не применился: {ex.Message}");
playSoundHook = null;
}
}
public override void Unload()
{
playSoundHook?.Dispose();
playSoundHook = null;
}
// Сам детур: первый параметр — трамплин в оригинальный код.
static SlotId GenSoundDetour(PlaySoundOrig orig, in SoundStyle style,
Vector2? position, SoundUpdateCallback updateCallback)
{
// Окно стройки подмира открыто и звук принадлежит каналу
// размещения тайлов → в «инвалид-слот», движку тишина.
// SoundType.Ambient и SoundType.Music проходят нарочно.
if (DecoyWorld.StorySilenceActive && style.Type == SoundType.Sound)
return SlotId.Invalid;
return orig(in style, position, updateCallback);
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/StoryVoice.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework.Audio;
using ReLogic.Content;
using Terraria.ModLoader; // ModContent — без него CS0103 (урок 0.4.8.1)
// StoryVoice — дикторские дорожки и эмбиент сюжетного пролога (голос режиссёра).
// Восемь реплик — по одной на слайд. Имена файлов: StoryLine1 … StoryLine8.
// Если файла нет — слайд просто работает текстом-подписью.
// Длина слайда подстраивается под длину записи автоматически.
//
// КАНОН 0.4.24 «Глухое полотно» — ПЕРЕЕЗД НА СЫРОЙ ЗВУК:
// Раньше всё играло через SoundEngine (SoundStyle), а движок умножает
// нашу громкость на ползунки игрока (Main.soundVolume). Но эти ползунки
// Раньше мы глушили генерацию занулением громкости (WorldAudioGuard, баг №2) —
// уничтожено в 0.4.26-2: канал блоков режет SoundChannelGuard на терминусе движка,
// чтобы не было слышно, как генератор стучит блоками заливкой жидкостей.
// Получился бы парадокс: прибили мир — прибили и сказ.
// Решение: сюжетный звук играет сырыми SoundEffectInstance — XNA-инстансам
// ползунки Terraria безразличны. Блоки молчат, Сказитель звучит.
// Плата: наша громкость фиксирована (голос 1.0, угли 0.14, шаги 0.3),
// а инстансы не приглушаются при сворачивании игры (сцена короткая — терпимо;
// прицельная полировка запланирована на этап 0.5).
namespace ShatteredGenesis.Common.Systems
{
public static class StoryVoice
{
static readonly string[] Paths = new string[StoryDirector.SlideCount];
static bool initialized;
static bool[] loaded;
static int[] ticks;
// Кэш прощупанных файлов: путь → SoundEffect ИЛИ null (файла нет).
// Повторное обращение мгновенно и бесшумно (урок 0.4.21: ни попыток
// каждый кадр, ни спама в лог — одноразовость теперь держит кэш,
// а не флаги-заслонки, они упразднены).
static readonly Dictionary<string, SoundEffect> fxCache =
new Dictionary<string, SoundEffect>();
// Три живых канала: реплика (StoryLine/именные), эмбиент (угли), шаги.
static SoundEffectInstance lineInst, ambientInst, stepsInst;
// фоновое потрескивание костра + шаги по лесу (канон 0.4.18)
const string AmbientPath = "ShatteredGenesis/Assets/Sounds/FireCrackle";
const string StepsPath = "ShatteredGenesis/Assets/Sounds/ForestSteps";
// Кэш именных реплик (CampArrive и т.п.): имя → длина в тиках, 0 = нет.
static readonly Dictionary<string, int> namedTicks =
new Dictionary<string, int>();
static StoryVoice()
{
loaded = new bool[StoryDirector.SlideCount];
ticks = new int[StoryDirector.SlideCount];
for (int i = 0; i < StoryDirector.SlideCount; i++)
Paths[i] = $"ShatteredGenesis/Assets/Sounds/StoryLine{i + 1}";
}
// Проверка наличия ассета БЕЗ запроса (урок 0.4.21.1: в новых
// сборках tML Request несуществующего файла у некоторых игроков
// выбрасывает модалку «Failed to load asset» даже из-под try/catch —
// поэтому щупаем только существующее через Mod.HasAsset).
public static bool AssetExists(string relPath)
{
try
{
Mod mod = ModLoader.GetMod("ShatteredGenesis");
return mod != null && mod.HasAsset(relPath);
}
catch (Exception)
{
return false;
}
}
// Единая точка добычи звука: путь без расширения → SoundEffect|null.
// Путь вида "ShatteredGenesis/Assets/Sounds/Имя".
static SoundEffect GetFx(string relNoExt)
{
if (fxCache.TryGetValue(relNoExt, out SoundEffect fx))
return fx; // уже знаем ответ (возможно, «файла нет» — null)
// AssetExists ждёт путь С расширением и БЕЗ префикса мода.
string rel = relNoExt.StartsWith("ShatteredGenesis/")
? relNoExt.Substring("ShatteredGenesis/".Length)
: relNoExt;
if (!AssetExists(rel + ".ogg"))
{
fxCache[relNoExt] = null; // реплика ещё не записана
return null;
}
try
{
fxCache[relNoExt] = ModContent.Request<SoundEffect>(
relNoExt, AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception)
{
fxCache[relNoExt] = null; // бронежилет — молчим достойно
}
return fxCache[relNoExt];
}
// Запуск сырого канала. Защита от перезапуска живого лупа встроена.
static void StartChannel(ref SoundEffectInstance inst, SoundEffect fx,
float volume, bool loop)
{
if (fx == null)
return; // файла нет — канал молчит, сцена идёт текстом
if (inst != null && !inst.IsDisposed)
return; // уже гудит — лупы не перезапускаем каждый кадр
try
{
inst = fx.CreateInstance();
inst.Volume = volume; // громкость сюжетного канала (сырая!)
inst.IsLooped = loop;
inst.Play();
}
catch (Exception)
{
inst = null;
}
}
static void StopChannel(ref SoundEffectInstance inst)
{
if (inst == null)
return;
try { if (!inst.IsDisposed) inst.Stop(); } catch (Exception) { }
try { inst.Dispose(); } catch (Exception) { }
inst = null;
}
// Вызывается перед стартом истории (главный поток, можно грузить ассеты).
public static void Prepare()
{
if (initialized)
return;
initialized = true;
for (int i = 0; i < StoryDirector.SlideCount; i++)
{
SoundEffect fx = GetFx(Paths[i]);
loaded[i] = fx != null;
if (loaded[i])
ticks[i] = (int)Math.Ceiling(fx.Duration.TotalSeconds * 60.0);
}
}
public static bool Has(int slide)
=> slide >= 0 && slide < StoryDirector.SlideCount && loaded[slide];
// Сколько тиков жить слайду: голос + вдох, либо стандарт.
public static int SlideTicksFor(int slide, int fallback)
=> Has(slide) ? ticks[slide] + 35 : fallback;
public static void Play(int slide)
{
if (!Has(slide))
return;
Stop(); // предыдущая реплика гасится вручную (урок 0.4.8.2)
StartChannel(ref lineInst, GetFx(Paths[slide]), 1f, false);
}
public static void Stop() => StopChannel(ref lineInst);
// ----- именные реплики сцены (CampArrive и т.п.) -----
public static bool HasName(string name)
{
if (namedTicks.ContainsKey(name))
return namedTicks[name] > 0;
SoundEffect fx = GetFx("ShatteredGenesis/Assets/Sounds/" + name);
namedTicks[name] = fx != null
? (int)Math.Ceiling(fx.Duration.TotalSeconds * 60.0) : 0; // 0 = текстовая реплика
return namedTicks[name] > 0;
}
public static int TicksForName(string name)
=> HasName(name) ? namedTicks[name] + 35 : 0;
public static void PlayName(string name)
{
if (!HasName(name))
return;
Stop(); // одна голова — один голос
StartChannel(ref lineInst,
GetFx("ShatteredGenesis/Assets/Sounds/" + name), 1f, false);
}
// ----- эмбиенты сцены -----
// Угли костра, тихий луп (по сценарию: «слышно потрескивание»).
public static void AmbientStart()
=> StartChannel(ref ambientInst, GetFx(AmbientPath), 0.14f, true);
public static void AmbientStop() => StopChannel(ref ambientInst);
// Шаги по лесу — петля пролога (стройка мира и путь к привалу).
public static void StepsStart()
=> StartChannel(ref stepsInst, GetFx(StepsPath), 0.3f, true);
public static void StepsStop() => StopChannel(ref stepsInst);
}
}
================================================================
=== ФАЙЛ: Common/Systems/WorldFlagsSystem.cs
================================================================
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;
// WorldFlagsSystem — память мира. Хранит сюжетные флаги,
// которые должны переживать перезаходы:
// IntroDone — интро уже показывали (не начинаем заставку снова)
// InfusionDone — Вселение произошло (стена потухла, автовхода больше нет)
//
// 0.4.24: + санитария спавна. Баг №3: на первом создании мира городские
// НПС дублировались (два Гида у костра — лишний сюжетный персонаж).
// DedupeGuides оставляет строго одного, дублей растворяет.
namespace ShatteredGenesis.Common.Systems
{
public class WorldFlagsSystem : ModSystem
{
public static bool IntroDone;
public static bool InfusionDone;
// Сохранение мира — просто кладём наши флаги в общий контейнер.
public override void SaveWorldData(TagCompound tag)
{
tag["sg_introDone"] = IntroDone;
tag["sg_infusionDone"] = InfusionDone;
}
// Загрузка мира — достаём обратно (TryGet на случай старых сохранений).
public override void LoadWorldData(TagCompound tag)
{
IntroDone = tag.TryGet("sg_introDone", out bool intro) && intro;
InfusionDone = tag.TryGet("sg_infusionDone", out bool infusion) && infusion;
}
// Хук сразу после создания мира (баг №3): первый заход должен
// увидеть ОДНОГО Гида, а не близнецов.
public override void PostWorldGen() => DedupeGuides();
// Санитар: растворяет всех активных Гидов, кроме первого.
// Зовётся из 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; // дубликат растворяется без звука и дыма
}
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/CampfireScene.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using ReLogic.Content;
using ReLogic.Graphics;
using SubworldLibrary;
using Terraria;
using Terraria.GameContent;
using Terraria.ModLoader;
using Terraria.UI;
using ShatteredGenesis.Content.Subworlds;
using ShatteredGenesis.Content.Particles; // частицы Luminance 0.4.26-2
// ============================================================
// CampfireScene — сцена «Привал» (канон 0.4.20).
// Пролог перестал быть фильмом: теперь это ЖИВОЙ ДИАЛОГ.
//
// Хроника сцены:
// 0) пока мир строился — слышно, как идём по лесу (StoryDirector,
// чёрный экран + луп ForestSteps);
// 1) в мире — темнота, шаги, реплика прибытия «Фу-ух, ну давай тут
// остановимся…» (CampArrive);
// 2) разгорается костёр: двое силуэтов (CampScene.png), ЖИВЫЕ искры
// летят вверх (частицы рисуются кодом — не картинка);
// 3) диалог с выбором ответов. Все ветки СХОДЯТСЯ к одному сказу —
// разные слова, один маршрут (указ режиссёра);
// 4) когда сказитель погружается в быль — за панелью проступают
// янтарные слайды-«видения» (Slide1…8, слайд 5 — анимация);
// 5) финал «Спи давай…» → чернь → обманка.
//
// Управление: ЛКМ — дальше / выбор ответа; Esc — пропуск сцены (отладка).
// Реплики сказителя = StoryLine1..8 + именные (CampArrive и будущие).
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
public class CampfireScene : ModSystem
{
// ----- узел диалога -----
class DNode
{
public string Text; // что говорит сказитель
public string[] Choices; // варианты ответа игрока (null = монолог)
public int[] Next; // куда идти (по индексу выбора / [0] для монолога)
public int Slide = -1; // -1 костёр; 0..7 — слайд-«видение» за панелью
public string Voice; // именная реплика (CampArrive…) или null
public int SlideVoice = -1; // индекс StoryLineN (0..7) или -1
}
static DNode[] nodes;
static bool active;
static int ticks; // тики внутри фазы
static int phase; // 0 = шаги в темноте, 1 = костёр разгорается, 2 = диалог
static int nodeIndex = -1;
static int nodeTicks;
static int clickCooldown;
// кроссфейды фона: костёр / слайд-видение
static float campAlpha; // текущая видимость костровой сцены
static float visionAlpha; // текущая видимость слайда
static int visionSlide = -1;
// КАНОН 0.4.26-2: кустарных массивов искр БОЛЬШЕ НЕТ — частицы
// живут на движке Luminance (Particle/ParticleManager, папка
// Content/Particles/). Здесь — только «рубильник»: если вызов
// движка один раз упал, дальше не зовём вообще (сцену не рушим
// из-за эффектов).
static bool luminanceParticlesOk = true;
// ФАЗА СНА (финал «спи давай»): мировой экранный фильтр
// SleepFade Luminance + чернь поверх сцены. Альфа растёт по тикам,
// на полной темноте приходит утро (Finish).
static float sleepAlpha;
static bool sleepFilterTried;
static bool sleepFilterBroken;
static Luminance.Core.Graphics.ManagedScreenFilter sleepFilter;
// ассеты
static Texture2D campArt;
static Texture2D[] slideArt;
static Texture2D[][] slideFrames;
static bool prevMouse;
static bool prevEsc;
static Rectangle[] choiceRects = new Rectangle[0];
public static bool Active => active;
// ----- старт / стоп -----
public static void Begin(bool force = false)
{
if (active && !force)
return;
BuildNodes();
LoadArt();
StoryVoice.Prepare(); // измеряем дикторские StoryLine1..8
active = true;
phase = 0;
ticks = 0;
nodeIndex = -1;
campAlpha = 0f;
visionAlpha = 0f;
visionSlide = -1;
sleepAlpha = 0f; // фаза сна ещё не начиналась (0.4.26-2)
StoryVoice.StepsStart(); // шаги продолжаются после стройки мира
// Канон 0.4.26 «Перенос»: сцена живёт ВНУТРИ подмира — и она
// НОЧНАЯ («а то темно как-то»). Утро наступит по окончании —
// его назначает Finish. Ванильное время основного мира тронуть
// нельзя (он чистая ваниль), поэтому ночь ставим только здесь.
Main.dayTime = false;
Main.time = 0f; // только-только стемнело
// Канон 0.4.24 (баг №4): в диалоге игрок КЛИКАЕТ по ответам —
// мышь возвращаем принудительно (масштаб мог остаться обнулён
// после загрузочных экранов, где курсор не рисуется).
Main.cursorScale = 1f;
}
static void Finish()
{
active = false;
StoryVoice.Stop();
StoryVoice.StepsStop();
StoryVoice.AmbientStop();
WorldFlagsSystem.IntroDone = true;
// Канон 0.4.26 «Перенос»: подмир и ЕСТЬ мир истории — бросаться
// больше некуда. «Спи давай» отзвучало — над поляной встаёт
// первое утро обманки.
Main.dayTime = true;
Main.time = 26000;
}
// ----- сценарий диалога (черновик техотдела, режиссёр правит) -----
// ВАЖНО: все ветки возвращаются в главный русло — разговор меняет
// слова, но не маршрут. Числа в Next — индексы узлов.
static void BuildNodes()
{
if (nodes != null)
return;
DNode N(string text, string[] choices, int[] next, int slide = -1, int voiceSlide = -1, string voice = null)
=> new DNode { Text = text, Choices = choices, Next = next, Slide = slide, SlideVoice = voiceSlide, Voice = voice };
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 }, slide: 0, voiceSlide: 0),
// --- БЫЛЬ: кузня мира ---
/* 5*/ N("Ну и они выковали себе место. Свой собственный мир — самый первый, настоящий. Чистый свет, как чистый холст для художника. Сидели в тишине, кайфовали. Безмятежность полная.",
null, new[] { 6 }, slide: 1, voiceSlide: 1),
/* 6*/ N("Красиво, да?",
new[] { "Красиво…", "Скучновато звучит." }, new[] { 7, 8 }),
/* 7*/ N("То-то. Красота — она долго не живёт.",
null, new[] { 9 }),
/* 8*/ N("Скука тоже долго не живёт. От бесцельной тишины и тыкают куда не надо.",
null, new[] { 9 }),
// --- БЫЛЬ: касание ---
/* 9*/ N("Один из них — Ваэлор его звали, Сеятель типа — решил поумничать: пальцем по холсту проведу, по приколу. Коснулся. Обычное, блин, касание пальца — а мир возьми, да и отзовись!",
null, new[] { 10 }, slide: 2, voiceSlide: 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 }, slide: 3, voiceSlide: 3),
// --- БЫЛЬ: жор и трещина (анимация растворения) ---
/*15*/ N("Чтобы расти, эта тварь начала жрать свет, на котором мир держался. Жрала, пока нити не лопнули — хрусь! — мир пополам, от неба до костей земных. А вырвавшийся свет взбесился и начал забирать самих создателей: один растворился… второй… третий… Остался один-единственный. Последний Архон.",
null, new[] { 16 }, slide: 4, voiceSlide: 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 }, slide: 5, voiceSlide: 5),
// --- БЫЛЬ: пророчество ---
/*21*/ N("А перед тем, как самому исчезнуть, он оставил в свете пророчество: мол, придёт чудак, который вообще не боится смерти. Ни черта не боится! И соткан он будет из тех же нитей света.",
null, new[] { 22 }, slide: 6, voiceSlide: 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 }, slide: 7, voiceSlide: 7), // -1 = занавес
};
}
// ----- ассеты -----
static void LoadArt()
{
if (campArt != null)
return;
// УРОК 0.4.21.1: слепой Request несуществующего ассета на новых
// сборках tML может показать модалку «Failed to load asset».
// Щупаем только то, что реально лежит в моде (HasAsset-проверка).
if (StoryVoice.AssetExists("Assets/Slides/CampScene.png"))
{
try
{
campArt = ModContent.Request<Texture2D>(
"ShatteredGenesis/Assets/Slides/CampScene", AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception) { campArt = null; }
}
slideArt = new Texture2D[StoryDirector.SlideCount];
slideFrames = new Texture2D[StoryDirector.SlideCount][];
for (int i = 0; i < StoryDirector.SlideCount; i++)
{
// кадры анимации SlideN_0.. — грузим пока файлы существуют
var frames = new List<Texture2D>();
for (int f = 0; f < 16; f++)
{
if (!StoryVoice.AssetExists($"Assets/Slides/Slide{i + 1}_{f}.png"))
break;
try
{
frames.Add(ModContent.Request<Texture2D>(
$"ShatteredGenesis/Assets/Slides/Slide{i + 1}_{f}", AssetRequestMode.ImmediateLoad).Value);
}
catch (Exception) { break; }
}
if (frames.Count > 1) { slideFrames[i] = frames.ToArray(); continue; }
if (!StoryVoice.AssetExists($"Assets/Slides/Slide{i + 1}.png"))
continue;
try
{
slideArt[i] = ModContent.Request<Texture2D>(
$"ShatteredGenesis/Assets/Slides/Slide{i + 1}", AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception) { slideArt[i] = null; }
}
}
// [УНИЧТОЖЕНО 0.4.26-2] InitEmbers: ручные массивы частиц выкошены —
// искры и светляки теперь классы EmberParticle/FireflyParticle на
// движке Luminance (Content/Particles/), движком же и рисуются —
// в МИРОВОМ проходе DrawDust, не поверх UI нашим кодом.
// Прямоугольник, в который вписан арт (одинаков для костра и слайдов).
static void ArtRect(int sw, int sh, int w, int h, out Vector2 pos, out Vector2 size)
{
float fit = Math.Min(sw / (float)w, sh * 0.62f / h);
size = new Vector2(w, h) * fit;
pos = new Vector2(sw / 2f - size.X / 2f, sh * 0.40f - size.Y / 2f);
}
// ----- излучатель частиц Luminance (канон 0.4.26-2) -----
// Искры и светляки рождаются в МИРОВЫХ координатах — точно над
// нарисованным огнём: считаем, в каком экранном месте лежит
// очаг арта (та же геометрия, что ArtRect в отрисовке), и
// переводим её в мир через Main.screenPosition. Дальше движок
// частиц делает всё сам: тикает оськи физикой и рисует их в
// мировом проходе (поэтому в фазах >0 чернь сцены опускается
// до полумрака — искры проступают сквозь него, как настоящие).
void EmitCampfireParticles()
{
if (!luminanceParticlesOk)
return;
try
{
ArtRect(Main.screenWidth, Main.screenHeight, 1600, 900,
out Vector2 apos, out Vector2 asize);
Vector2 fireScreen = new Vector2(
apos.X + asize.X * 0.5f, apos.Y + asize.Y * 0.82f);
Vector2 fireWorld = Main.screenPosition + fireScreen;
// Искры: короткоживущие янтарные брызги из очага.
if (campAlpha > 0.25f && ticks % 3 == 0)
{
new EmberParticle(
fireWorld + new Vector2(
Main.rand.NextFloat(-14f, 14f),
Main.rand.NextFloat(-4f, 2f)),
new Vector2(
Main.rand.NextFloat(-0.25f, 0.25f),
-0.55f - Main.rand.NextFloat(0.85f)),
new Color(255, 226, 140),
120 + Main.rand.Next(110))
.Spawn();
}
// Светляки: редкие ленивые огни над вечерней поляной.
if (campAlpha > 0.25f && ticks % 53 == 0)
{
Vector2 meadow = fireWorld + new Vector2(
Main.rand.NextFloat(-520f, 520f),
Main.rand.NextFloat(-260f, -60f));
new FireflyParticle(
meadow,
new Vector2(Main.rand.NextFloat(-0.12f, 0.12f),
Main.rand.NextFloat(0.04f, 0.16f)),
480 + Main.rand.Next(400))
.Spawn();
}
}
catch (Exception)
{
// Движок частиц почему-то не встал — сцену не рушим,
// просто живём без эффектов до конца показа.
luminanceParticlesOk = false;
}
}
// ===================== КАТСЦЕНА СНА (канон 0.4.26-2) =====================
// Финальная реплика («…спи давай. Завтра день тяжёлый.») уводит
// сцену в фазу 3: мировую картинку гасит экранный фильтр Luminance
// SleepFade (конвейер ManagedScreenFilter — фильтр «активируется»
// каждый кадр, а менеджер сам плавно наращивает его opacity),
// поверх UI доползает чернь sleepAlpha. На полной темноте сцена
// закрывается — над обманкой встаёт первое утро.
void StartSleep()
{
phase = 3;
ticks = 0;
nodeIndex = -1; // панель диалога гаснет
sleepAlpha = 0f;
StoryVoice.Stop(); // сказ закончен
StoryVoice.StepsStop(); // шаги давно смолкли — страховка
StoryVoice.AmbientStop();// и угли с потрескиванием догорели
}
// Активация фильтра сна — на каждом тике фазы 3 (менеджер сам
// гасит opacity, если фильтр не активировать — нам это не надо).
void ActivateSleepFilter()
{
if (sleepFilterBroken)
return;
if (!sleepFilterTried)
{
sleepFilterTried = true;
try
{
if (!Luminance.Core.Graphics.ShaderManager.TryGetFilter(
"ShatteredGenesis.SleepFade", out sleepFilter))
sleepFilter = null; // фильтр ещё компилится — есть чернь
}
catch (Exception)
{
sleepFilterBroken = true;
return;
}
}
if (sleepFilter == null)
return; // без фильтра катсцена сна всё равно честная: чернь есть
try
{
// Виньетка сна: края вянут быстрее центра (см. SleepFade.fx).
sleepFilter.TrySetParameter("vignetteAspect", Main.screenWidth / (float)Main.screenHeight);
sleepFilter.Activate();
}
catch (Exception)
{
sleepFilterBroken = true;
}
}
// ----- жизненный цикл -----
public override void PostUpdateEverything()
{
if (!active)
return;
ticks++;
clickCooldown = Math.Max(0, clickCooldown - 1);
// Сцена — это кино: игрок не дёргается.
if (Main.LocalPlayer != null)
Main.LocalPlayer.frozen = true;
// Указ режиссёра 0.4.25.5: в катсцене мышь ВИДНА и живая —
// клики по ответам и есть вход в диалог. Страхуем каждый тик.
Main.cursorScale = 1f;
Main.cursorOverride = 0;
int sw = Main.screenWidth, sh = Main.screenHeight;
// Фазы до диалога.
if (phase == 0)
{
// чернь + шаги; на 20-м тике — реплика прибытия
if (ticks == 20)
StoryVoice.PlayName("CampArrive");
int wait = Math.Max(240, StoryVoice.TicksForName("CampArrive") + 60);
if (ticks > wait)
{
phase = 1;
ticks = 0;
}
}
else if (phase == 1)
{
// костёр разгорается, шаги стихают
campAlpha = Math.Min(1f, campAlpha + 0.02f);
EmitCampfireParticles();
if (ticks == 40)
{
StoryVoice.StepsStop();
StoryVoice.AmbientStart(); // угли!
}
if (ticks > 90)
{
EnterNode(0);
phase = 2;
}
}
else if (phase == 2)
{
// диалог: фоны и частицы живут
EmitCampfireParticles();
float campTarget = (visionSlide >= 0) ? 0.25f : 1f;
campAlpha += (campTarget - campAlpha) * 0.08f;
visionAlpha += ((visionSlide >= 0 ? 1f : 0f) - visionAlpha) * 0.08f;
HandleClicks();
}
else if (phase == 3)
{
// КАТСЦЕНА СНА: мир гаснет фильтром Luminance, поверх —
// чернь. ~3.6 секунды до полной темноты, затем утро.
sleepAlpha = Math.Min(1f, ticks / 170f);
ActivateSleepFilter();
if (ticks >= 220)
Finish(); // первое утро обманки
}
// Esc — пропуск сцены (отладочный жест техотдела)
bool esc = Main.keyState.IsKeyDown(Keys.Escape);
if (esc && !prevEsc)
Finish();
prevEsc = esc;
}
void EnterNode(int index)
{
// -1 («…спи давай») — больше не мгновенный выход: уходим в
// катсцену сна (фаза 3), которая сама доведёт до утра.
if (index < 0) { StartSleep(); return; }
nodeIndex = index;
nodeTicks = 0;
clickCooldown = 12; // защита от сквозного протыкивания
var node = nodes[index];
visionSlide = node.Slide;
if (node.SlideVoice >= 0)
StoryVoice.Play(node.SlideVoice); // реплика былей (StoryLineN)
else if (node.Voice != null)
StoryVoice.PlayName(node.Voice); // именная реплика
}
void HandleClicks()
{
bool click = Main.mouseLeft;
bool pressed = click && !prevMouse;
prevMouse = click;
if (!pressed || clickCooldown > 0 || nodeIndex < 0)
return;
var node = nodes[nodeIndex];
if (node.Choices != null)
{
// клик по кнопке-варианту
Point m = new Point(Main.mouseX, Main.mouseY);
for (int i = 0; i < node.Choices.Length && i < choiceRects.Length; i++)
if (choiceRects[i].Contains(m))
{
EnterNode(node.Next[i]);
return;
}
}
else
{
EnterNode(node.Next[0]);
}
}
// ----- отрисовка -----
public override void ModifyInterfaceLayers(List<GameInterfaceLayer> layers)
{
if (!active)
return;
layers.Add(new LegacyGameInterfaceLayer("ShatteredGenesis: Campfire", () =>
{
DrawScene(Main.spriteBatch);
return true;
}, InterfaceScaleType.UI));
}
void DrawScene(SpriteBatch sb)
{
int sw = Main.screenWidth, sh = Main.screenHeight;
// 1. ночь над миром: глухая чернь только в фазе шагов (0);
// дальше — тяжёлый полумрак: ЖИВЫЕ искры Luminance рисуются
// в МИРОВОМ проходе (под нашим UI-слоем), и сквозь полумрак
// они проступают огненным аддитивом, как настоящие. Фаза сна
// добирает чернь до полной (sleepAlpha).
float dark = phase == 0
? 1f
: Math.Max(sleepAlpha, 1f - 0.45f * campAlpha);
sb.Draw(TextureAssets.MagicPixel.Value, new Rectangle(0, 0, sw, sh),
Color.Black * dark);
if (phase == 0)
return; // только звук шагов
// 2. костровая сцена
if (campArt != null && campAlpha > 0.003f)
{
Vector2 pos, size;
ArtRect(sw, sh, campArt.Width, campArt.Height, out pos, out size);
sb.Draw(campArt, pos, null, Color.White * campAlpha, 0f, Vector2.Zero,
size / new Vector2(campArt.Width, campArt.Height), SpriteEffects.None, 0f);
}
// 3. [искры]: их больше нет в UI — живые частицы Luminance
// рисуются в мировом проходе и светят сквозь полумрак.
// 4. слайд-«видение» за панелью (в были)
if (visionSlide >= 0 && visionAlpha > 0.003f)
{
Texture2D art = CurrentSlideTexture(visionSlide);
if (art != null)
{
Vector2 pos, size;
ArtRect(sw, sh, art.Width, art.Height, out pos, out size);
// тусклый ореол-туман под видением
sb.Draw(art, pos, null, Color.White * visionAlpha, 0f, Vector2.Zero,
size / new Vector2(art.Width, art.Height), SpriteEffects.None, 0f);
}
}
// 5. диалоговая панель
if (phase == 2 && nodeIndex >= 0)
DrawDialoguePanel(sb, sw, sh);
// 6. одеяло сна (фаза 3): чернь НАД ВСЕМ — над панелью,
// над видениями, над костром. Мировую картинку в это время
// заодно гасит фильтр SleepFade (ActivateSleepFilter).
if (sleepAlpha > 0.003f)
sb.Draw(TextureAssets.MagicPixel.Value, new Rectangle(0, 0, sw, sh),
Color.Black * sleepAlpha);
}
Texture2D CurrentSlideTexture(int slide)
{
if (slideFrames != null && slide < slideFrames.Length
&& slideFrames[slide] != null && slideFrames[slide].Length > 0)
{
var fr = slideFrames[slide];
return fr[(ticks / 38) % fr.Length]; // гифка крутится под диалогом
}
return slideArt != null && slide < slideArt.Length ? slideArt[slide] : null;
}
// [УНИЧТОЖЕНО 0.4.26-2] DrawEmbers: UI-рисование искр поверх сцены
// заменено настоящими частицами Luminance (EmberParticle) — они
// живут в МИРЕ, у них есть физика, затухание и аддитивное свечение.
void DrawDialoguePanel(SpriteBatch sb, int sw, int sh)
{
var node = nodes[nodeIndex];
DynamicSpriteFont font = FontAssets.MouseText.Value;
float textScale = 1.35f;
// переносы строк вручную (не зависим от WordwrapString сюрпризов)
int panelW = (int)(sw * 0.74f);
int margin = 18;
var lines = WrapText(font, node.Text, panelW - margin * 2, textScale);
int lineH = (int)(font.LineSpacing * textScale) + 2;
int nameH = (int)(font.LineSpacing * 1.15f) + 4;
int choicesH = node.Choices != null ? node.Choices.Length * 40 + 10 : 26;
int panelH = margin + nameH + lines.Count * lineH + choicesH + margin;
int panelX = (sw - panelW) / 2;
int panelY = sh - panelH - 26;
Texture2D px = TextureAssets.MagicPixel.Value;
// тело панели + янтарная каёмка
sb.Draw(px, new Rectangle(panelX - 2, panelY - 2, panelW + 4, panelH + 4), new Color(122, 90, 20) * 0.9f);
sb.Draw(px, new Rectangle(panelX, panelY, panelW, panelH), new Color(7, 6, 3) * 0.94f);
// имя: кто говорит
sb.DrawString(font, "Сказитель", new Vector2(panelX + margin, panelY + margin),
new Color(255, 196, 60), 0f, Vector2.Zero, 1.15f, SpriteEffects.None, 0f);
// текст реплики
float ty = panelY + margin + nameH;
foreach (var line in lines)
{
sb.DrawString(font, line, new Vector2(panelX + margin, ty),
Color.White, 0f, Vector2.Zero, textScale, SpriteEffects.None, 0f);
ty += lineH;
}
// варианты ответа / подсказка «дальше»
float by = ty + 8;
if (node.Choices != null)
{
var rects = new List<Rectangle>();
for (int i = 0; i < node.Choices.Length; i++)
{
string label = node.Choices[i];
Vector2 m = font.MeasureString(label) * 1.15f;
int bw = (int)m.X + 26;
Rectangle r = new Rectangle(panelX + margin, (int)by, bw, 32);
rects.Add(r);
bool hover = r.Contains(new Point(Main.mouseX, Main.mouseY));
sb.Draw(px, r, hover ? new Color(122, 90, 20) : new Color(61, 46, 10) * 0.9f);
sb.DrawString(font, label, new Vector2(r.X + 13, r.Y + 7),
hover ? new Color(255, 244, 205) : new Color(255, 196, 60),
0f, Vector2.Zero, 1.15f, SpriteEffects.None, 0f);
by += 40;
}
choiceRects = rects.ToArray();
}
else
{
sb.DrawString(font, "(клик — дальше)", new Vector2(panelX + panelW - margin - 170, by + 4),
new Color(160, 140, 100), 0f, Vector2.Zero, 1.0f, SpriteEffects.None, 0f);
}
}
// Ручной перенос текста под ширину панели.
static List<string> WrapText(DynamicSpriteFont font, string text, int maxWidth, float scale)
{
var result = new List<string>();
string line = "";
foreach (var w in text.Split(' '))
{
string probe = line.Length == 0 ? w : line + " " + w;
if (font.MeasureString(probe).X * scale > maxWidth && line.Length > 0)
{
result.Add(line);
line = w;
}
else
{
line = probe;
}
}
if (line.Length > 0)
result.Add(line);
return result;
}
}
}
================================================================
=== ФАЙЛ: 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
{
// Тумблер режима (команда /sgmode): true = настоящая ваниль в обманке.
public static bool UseVanillaPipeline = true;
// Слепок проходов и чертёж. 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)
{
var list = new List<GenPass>(passes);
if (finisher != null)
list.Add(finisher);
return list;
}
}
}
================================================================
=== ФАЙЛ: 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)); // блёклое грязно-синее небо
}
}
}
================================================================
=== ФАЙЛ: 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.DataStructures;
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;
// 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;
// РАЗРАБОТКА: без кеша — свежая генерация на каждый вход.
// Перед релизом вернуть true (обманка станет «Скорлупой»).
public override bool ShouldSave => false;
// Ведём себя как обычный мир (солнце, жидкости, НПС).
public override bool NormalUpdates => true;
// Песок по номеру: константа Sand/SandBlock переименовывается между
// сборками tML (у заказчика 2026.6.3.4 её нет), а ID 53 вечен —
// номера тайлов не меняются, на них завязан формат сохранений.
private const ushort SandTile = 53;
// Глубина «подземелья» по ТЗ: от поверхности вниз 500 блоков земли,
// засеянной камнем; дальше — чистый камень до дна.
private const int DirtBandDepth = 500;
// Радиус табу вокруг спавна: здесь пещеры НЕ выходят на поверхность.
private const int SpawnQuarantine = 450;
// Высота поляны спавна = уровень моря («поверхность — то, что на
// уровне моря»). Океаны льют воду ровно до этой отметки.
private int flatTopY;
// Холмы-горбы: (центр, амплитуда, полуширина). Считаются один раз.
private readonly List<(double cx, double amp, double half)> hills =
new List<(double, double, double)>();
// Границы биомных полос (в процентах ширины — по карте-референсу режиссёра)
private int SnowEnd => Width * 12 / 100; // слева: снег
private int CrimsonCenter => Width * 30 / 100; // левый алый язык
private const int CrimsonRadius = 70;
private int DesertStart => Width * 55 / 100; // пустыня
private int DesertEnd => Width * 70 / 100;
private int JungleStart => Width * 85 / 100; // справа: джунгли
// Чертёж для Subworld Library: перед генерацией библиотека спрашивает
// Config — отдаём ванильный WorldGenConfiguration из слепка, и она
// сама кормит каждого прохода его персональным куском чертежа.
public override WorldGenConfiguration Config => VanillaGenSnapshot.Config;
public override List<GenPass> Tasks
{
get
{
// ЭКСПЕРИМЕНТ 0.3.4.4 «Чертёж и станок»: настоящие ванильные
// проходы внутри под-мира, теперь с полноценным чертежом.
// Нет слепка / режиссёр переключил режим — ручная копия 0.3.4.1.
if (VanillaGenSnapshot.UseVanillaPipeline && VanillaGenSnapshot.Ready)
{
return VanillaGenSnapshot.BuildTaskList(
new PassLegacy("…ставит Гида…", PlaceGuideAndSpawn, 5f));
}
return HandmadeTasks;
}
}
// Ручная копия 0.3.4.1 — наш собственный конвейер.
// Запасной вариант + база для A/B-сравнения с настоящей ванилью.
private List<GenPass> HandmadeTasks => new List<GenPass>
{
new PassLegacy("…лепит холмы…", GenerateBase, 160f),
new PassLegacy("…месит глину с камнем…", GenerateStonePatches, 60f),
new PassLegacy("…роет подкопы…", GenerateCaverns, 200f),
new PassLegacy("…прячет металлы…", GenerateOres, 80f),
new PassLegacy("…штукатурит тьму…", GenerateWalls, 140f),
new PassLegacy("…ставит озеро…", GenerateLake, 20f),
new PassLegacy("…льёт моря…", GenerateOceans, 35f),
new PassLegacy("…красит земли…", PaintSurface, 70f),
new PassLegacy("…пичкает траву…", GeneratePlants, 20f),
new PassLegacy("…сажает деревья…", GenerateTrees, 45f),
new PassLegacy("…ставит Гида…", PlaceGuideAndSpawn, 5f),
};
// ===================== ЭКРАН ПЕРЕНОСА (канон 0.4.26) =====================
// Subworld Library зовёт DrawSetup/DrawMenu каждый кадр, пока ЭТОТ
// подмир строится (или читается с диска). Дефолтный DrawMenu просто
// писал ванильный Main.statusText по центру — ту самую «Настройку
// жидкостей… 34%», что режиссёр поймал на скрине.
//
// 0.4.26 «Перенос»: экран — это КАТСЦЕНА ХОДЬБЫ ПО ЛЕСУ. Широкая
// лента-силуэт леса (ForestWalk.png) натянута на высоту кадра и
// ПОЛЗЁТ вбок с походочной тряской; шаги — сырой канал StoryVoice;
// курсор на загрузке СКРЫТ (указ №4) — вернёт его вход в подмир.
//
// Мы экран ПОДМЕНЯЕМ ЦЕЛИКОМ: своя чернь, свой арт (костёр сна),
// свои строки-«шёпот» по таймеру и живые угольки. Отдельного
// update-хука у экрана загрузки нет — DrawMenu одновременно и
// «часы», и кисть (~60 вызовов в секунду), поэтому таймер диалога
// крутится прямо внутри неё.
// Таймер одного показа экрана (тик = один кадр отрисовки).
static int loadTicks;
// ОКНО ТИШИНЫ (канон 0.4.26-2): открывается StoryDirector.EnterStory()
// и закрывается в OnEnter(). Пока оно открыто, SoundChannelGuard
// глушит канал SoundType.Sound на терминусе движка — громкость
// игрока при этом НЕ трогает никто.
public static bool StorySilenceActive;
// Пелена Luminance-шейдера SleepVeil на экране загрузки.
// Граф пелены: кэш + честный отказ (шейдер ещё компилится /
// не нашёлся → экран идёт без виньетки, игра не падает).
static bool veilTried;
static bool veilBroken;
static Luminance.Core.Graphics.ManagedShader veilShader;
// Арт грузим лениво и ОДИН раз — и только если файл существует
// (урок 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;
// Один пробный заход в реестр Luminance (ShaderManager.cs,
// сверено: ключ = "ИмяМода.ИмяФайла" без расширения).
if (!veilTried)
{
veilTried = true;
try
{
if (!Luminance.Core.Graphics.ShaderManager.TryGetShader(
"ShatteredGenesis.SleepVeil", out veilShader))
veilShader = null; // шейдер ещё компилится — пелена позже
}
catch (Exception)
{
veilBroken = true;
return;
}
}
if (veilShader == null)
{
// Пробуем достать на каждом кадре — монитор Luminance может
// докомпилировать .fx в любой момент.
try
{
Luminance.Core.Graphics.ShaderManager.TryGetShader(
"ShatteredGenesis.SleepVeil", out veilShader);
}
catch (Exception)
{
veilBroken = true;
return;
}
if (veilShader == null)
return; // не страшно: есть чернь и арт — пелена роскошь
}
var gd = Main.instance.GraphicsDevice;
try
{
veilShader.TrySetParameter("screenSize",
new Vector2(gd.Viewport.Width, gd.Viewport.Height));
veilShader.TrySetParameter("opacity", artAlpha);
veilShader.TrySetParameter("globalTime", Main.GlobalTimeWrappedHourly);
// Immediate + наш эффект, в ФИЗИЧЕСКИХ пикселях: один квад
// на весь вьюпорт — идеальная укрытость при любом UI-скейле.
Main.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend,
SamplerState.LinearClamp, DepthStencilState.None,
RasterizerState.CullNone, veilShader.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 не зовём),
// гасить нечего. Кадр целиком наш.
loadTicks++; // ← единые «часы» этого экрана и его диалога
// Шаги начинаются с первых кадров леса (сырой канал StoryVoice —
// заглушке стройки не подвластен); сцена «Привал» подхватит тот
// же канал без щелчка — дорога не обрывается.
if (loadTicks == 5)
StoryVoice.StepsStart();
TryLoadArt();
SpriteBatch sb = Main.spriteBatch;
// Размер кадра — из ЖЕЛЕЗА (вьюпорт ÷ UI-масштаб), не из
// Main.screenWidth: тот у режиссёра врал (скрин: правый край
// и низ просвечивали миром).
LogicalScreen(out int sw, out int sh);
// АРТ вплывает из черни за ~0.75 сек (45 тиков).
// Меняй сюда, если захочется резче/мягче.
float artA = Math.Min(1f, loadTicks / 45f);
if (loadArt != null)
{
// ЛЕСНАЯ ЛЕНТА (канон 0.4.26): натянута по высоте кадра,
// шире кадра — есть ход для панорамы. Лента ползёт вбок
// медленным косинусом (мягкий ход туда-обратно, без рывка
// зацикливания), а вертикаль слегка пружинит — «шаговая
// тряска», как глаз идущего человека.
float k = sh / (float)loadArt.Height;
int w = (int)(loadArt.Width * k);
int range = Math.Max(0, w - sw);
float travel = 0.5f - 0.5f * (float)Math.Cos(loadTicks * 0.004f); // 0..1, полный цикл ~26 сек
int ox = (int)(travel * range);
float bob = (float)Math.Sin(loadTicks * 0.13f) * 3f;
sb.Draw(loadArt,
new Rectangle(-ox, -6 + (int)bob, w, sh + 12),
Color.White * artA);
}
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;
LogicalScreen(out int sw, out int sh);
// ---- ДИАЛОГ ПО ТАЙМЕРУ ----
// Текущая строка = последняя, чей тик уже наступил. Строки не
// стекают в чат и не гаснут: шёпот сменяется на экране сам.
string line = null;
int lineAt = 0;
foreach (var (at, text) in LoadScript)
{
if (loadTicks >= at)
{
line = text;
lineAt = at;
}
}
if (line == null)
return; // первые полсекунды — чистая чернь с угольками
// ПРОЗРАЧНОСТЬ СТРОКИ: 40 тиков на плавный вплыв (0 → 1).
float a = Math.Min(1f, (loadTicks - lineAt) / 40f);
// Последняя строка после вплыва «дышит» бесконечно — экран
// живой, сколько бы мир ни строился (быстро/медленно — неважно).
int lastAt = LoadScript[LoadScript.Length - 1].at;
if (lineAt == lastAt && loadTicks - lineAt > 90)
{
a *= 0.72f + 0.28f * (float)Math.Sin((loadTicks - lineAt - 90) * 0.04f);
}
DrawCentered(sb, line, sh * 0.80f, a, sw);
}
// Логический размер экрана по железу (баг №1): пиксели вьюпорта,
// поделённые на UI-масштаб, с запасом на округление рамки.
static void LogicalScreen(out int sw, out int sh)
{
var vp = Main.instance.GraphicsDevice.Viewport;
float ui = Main.UIScale;
if (ui <= 0.01f)
ui = 1f;
sw = (int)(vp.Width / ui + 1f);
sh = (int)(vp.Height / ui + 1f);
}
// Ленивая загрузка арта экрана переноса (один пробный заход).
static void TryLoadArt()
{
if (loadArtTried)
return;
loadArtTried = true;
if (!StoryVoice.AssetExists("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, int 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, int sw, int 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, int sw, int 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;
}
// ===================== ЭТАП 1. Террейн =====================
// Ванильный почерк: длинные плавные валы, где-то почти равнина,
// где-то холмисто; одиночные горбы; ступеньки не выше блока.
private void GenerateBase(GenerationProgress progress, GameConfiguration config)
{
double surfaceBase = Height * 0.30;
Main.worldSurface = surfaceBase;
// Геологическая отметка: каменный (кавернальный) слой начинается
// там, где кончается 500-рядная земляная толща.
Main.rockLayer = surfaceBase + DirtBandDepth - 250; // стены/мобы — чуть раньше
GenVars.worldSurface = surfaceBase;
GenVars.worldSurfaceHigh = surfaceBase + 26;
GenVars.worldSurfaceLow = surfaceBase - 26;
GenVars.rockLayerHigh = surfaceBase + 180; // руды «подземелья» до сюда
GenVars.rockLayerLow = surfaceBase + DirtBandDepth; // руды «каверн» отсюда
// Холмы-горбы: пара десятков, не у спавна и не в океанах.
// ВАЖНО: чистим список — обманка не кешируется, проходы гоняются заново.
hills.Clear();
int hillCount = Width / 130;
for (int i = 0; i < hillCount; i++)
{
double cx = WorldGen.genRand.Next(240, Width - 240);
if (Math.Abs(cx - Width / 2) < 160)
continue; // спавн-поляна остаётся ровной
double amp = WorldGen.genRand.Next(8, 19) * (WorldGen.genRand.NextBool() ? 1 : -1);
double half = WorldGen.genRand.Next(40, 121);
hills.Add((cx, amp, half));
}
flatTopY = (int)(surfaceBase + TerrainAt(Width / 2));
int prevTop = flatTopY;
for (int x = 0; x < Width; x++)
{
int top = (int)(surfaceBase + TerrainAt(x));
// Ровная поляна у спавна (±12), как в ванили.
if (Math.Abs(x - Width / 2) < 12)
top = flatTopY;
// Ступеньки не выше одного блока на колонку.
if (top < prevTop - 1) top = prevTop - 1;
if (top > prevTop + 1) top = prevTop + 1;
prevTop = top;
int topsoil = 5 + WorldGen.genRand.Next(4); // дёрн под травой
for (int y = top; y < Height; y++)
{
ushort t;
if (y < top + topsoil)
t = TileID.Dirt; // верхняя лапландия
else if (y < top + DirtBandDepth)
t = TileID.Dirt; // подземелье: земля (камень добавит этап 2)
else
t = TileID.Stone; // ниже 500 — чистый камень
WorldGen.PlaceTile(x, y, t, true, true);
}
if (x % 200 == 0)
progress.Set(x / (double)Width);
}
progress.Set(1.0);
}
// Рельеф: три несоизмеримые октавы + огибающая долин + холмы-горбы.
private double TerrainAt(int x)
{
// Огибающая: медленно дышит между «равнина» (0.1) и «холмы» (1.0).
double envelope = 0.55 + 0.45 * Math.Sin(x * 0.0016 + 1.3);
double wave = Math.Sin(x * 0.0031) * 16.0
+ Math.Sin(x * 0.0113 + 2.1) * 7.0
+ Math.Sin(x * 0.047 + 0.7) * 2.5;
wave *= envelope;
// Холмы-горбы (гауссовы колокола).
double bumps = 0.0;
for (int i = 0; i < hills.Count; i++)
{
var h = hills[i];
double d = (x - h.cx) / h.half;
bumps += h.amp * Math.Exp(-d * d);
}
return wave + bumps;
}
// ===================== ЭТАП 2. Камень в земляной толще =====================
// «Земля, смешанная с камнем»: пятна и линзы камня в 500-рядной
// толще; к низу толщи камня больше — мягкий переход к каменному слою.
private void GenerateStonePatches(GenerationProgress progress, GameConfiguration config)
{
int area = Width * Height;
// Крупные пятна, равномерно по толще.
int blobs = (int)(area * 6.0E-05); // ~230
for (int i = 0; i < blobs; i++)
{
int x = WorldGen.genRand.Next(30, Width - 30);
int top = FindSurfaceY(x);
int y = top + 15 + WorldGen.genRand.Next(DirtBandDepth - 60);
WorldGen.TileRunner(x, y,
5.0 + WorldGen.genRand.NextDouble() * 9.0,
WorldGen.genRand.Next(4, 11),
TileID.Stone, true,
(WorldGen.genRand.NextDouble() - 0.5) * 0.6,
(WorldGen.genRand.NextDouble() - 0.5) * 0.3);
}
// Мелкая крошка — и ею же «подмёсываем» низ толщи.
int crumbs = (int)(area * 1.0E-04); // ~380
for (int i = 0; i < crumbs; i++)
{
int x = WorldGen.genRand.Next(20, Width - 20);
int top = FindSurfaceY(x);
// Квадрат корня смещает часть крошки поближе к каменному слою.
double depth = 20.0 + Math.Pow(WorldGen.genRand.NextDouble(), 0.7) * (DirtBandDepth - 40);
int y = top + (int)depth;
WorldGen.TileRunner(x, y,
2.0 + WorldGen.genRand.NextDouble() * 3.0,
WorldGen.genRand.Next(3, 7),
TileID.Stone, true);
}
progress.Set(1.0);
}
// ===================== ЭТАП 3. Пещеры =====================
// Устья — РЕДКИЕ и только вне SpawnQuarantine (ТЗ режиссёра:
// «возле спавна вопше не должны»). Остальные ходы слепые:
// начинаются в толще и не выходят на поверхность.
private void GenerateCaverns(GenerationProgress progress, GameConfiguration config)
{
// 3.1. Редкие устья — далеко от спавна, кроме океанов.
for (int x = 120; x < Width - 120; x += WorldGen.genRand.Next(70, 111))
{
if (Math.Abs(x - Width / 2) < SpawnQuarantine)
continue;
int y = FindSurfaceY(x);
WorldGen.TileRunner(x, y,
1.6 + WorldGen.genRand.NextDouble(),
WorldGen.genRand.Next(40, 90),
-1, false,
(WorldGen.genRand.NextDouble() - 0.5) * 1.5,
1.5 + WorldGen.genRand.NextDouble() * 0.7);
}
progress.Set(0.2);
// 3.2. Слепые ходы земляной толщи (короче и тоньше).
int dirtWorms = (Width * Height) / 13700; // ~280
for (int i = 0; i < dirtWorms; i++)
{
int x = WorldGen.genRand.Next(40, Width - 40);
int top = FindSurfaceY(x);
// Под спавном стартуем глубже — не всплыть случайно.
int minDepth = Math.Abs(x - Width / 2) < SpawnQuarantine ? 30 : 12;
int y = top + minDepth + WorldGen.genRand.Next(DirtBandDepth - 60);
double dir = WorldGen.genRand.NextBool() ? 1.0 : -1.0;
WorldGen.TileRunner(x, y,
1.8 + WorldGen.genRand.NextDouble() * 1.6,
WorldGen.genRand.Next(25, 70),
-1, false,
dir * (0.3 + WorldGen.genRand.NextDouble() * 0.8),
(WorldGen.genRand.NextDouble() - 0.5) * 0.5);
if (i % 70 == 0)
progress.Set(0.2 + i / (double)dirtWorms * 0.35);
}
// 3.3. Длинные ходы каменного слоя (основная сеть).
int stoneWorms = (Width * Height) / 10100; // ~380
for (int i = 0; i < stoneWorms; i++)
{
int x = WorldGen.genRand.Next(40, Width - 40);
int y = WorldGen.genRand.Next((int)GenVars.rockLayerLow, Height - 120);
double dir = WorldGen.genRand.NextBool() ? 1.0 : -1.0;
WorldGen.TileRunner(x, y,
2.5 + WorldGen.genRand.NextDouble() * 2.5,
WorldGen.genRand.Next(40, 110),
-1, false,
dir * (0.4 + WorldGen.genRand.NextDouble()),
(WorldGen.genRand.NextDouble() - 0.5) * 0.6);
if (i % 70 == 0)
progress.Set(0.55 + i / (double)stoneWorms * 0.35);
}
// 3.4. Залы-пузыри в камне.
for (int i = 0; i < 22; i++)
{
int x = WorldGen.genRand.Next(60, Width - 60);
int y = WorldGen.genRand.Next((int)GenVars.rockLayerLow, Height - 90);
WorldGen.TileRunner(x, y,
6.0 + WorldGen.genRand.NextDouble() * 4.0,
WorldGen.genRand.Next(3, 8),
-1, false, 0.0, 0.0);
}
progress.Set(1.0);
}
// ===================== ЭТАП 4. Руды по ванильной формуле =====================
// Механика «Shinies» из оригинала: три цикла по глубинным поясам,
// количество = площадь мира × коэффициент, жила = TileRunner с
// ванильными силами/длинами. Канон (медь, из декомпила):
// 6E-05 у поверхности (сила 3-6, длина 2-6),
// 8E-05 в подземелье (сила 3-7, длина 3-7),
// 0.0002 в кавернах (сила 4-9, длина 4-8).
// Режиссёр просил «более распространённые руды» — берём медную
// плотность за планку и спускаем по ценности: пара железа ×0.85,
// серебра ×0.5, золота ×0.3.
private void GenerateOres(GenerationProgress progress, GameConfiguration config)
{
VeinPair(TileID.Copper, TileID.Tin, 6.0E-05, 8.0E-05, 20.0E-05);
progress.Set(0.3);
VeinPair(TileID.Iron, TileID.Lead, 5.1E-05, 6.8E-05, 17.0E-05);
progress.Set(0.55);
VeinPair(TileID.Silver, TileID.Tungsten, 3.0E-05, 4.0E-05, 10.0E-05);
progress.Set(0.8);
VeinPair(TileID.Gold, TileID.Platinum, 1.8E-05, 2.4E-05, 6.0E-05);
// Первые кирки не в муках: медь на глубине у спавна (в толще!).
for (int i = 0; i < 5; i++)
{
int x = Width / 2 + WorldGen.genRand.Next(-300, 300);
int y = FindSurfaceY(x) + WorldGen.genRand.Next(60, 200);
WorldGen.TileRunner(x, y, 3.0, WorldGen.genRand.Next(4, 8), TileID.Copper, true);
}
progress.Set(1.0);
}
// Один поясной веер жил для пары руд (мир сам выбирает «свою» —
// у нас монетка: половина жил ванильной альтернативы, как в разных мирах).
//
// 0.3.4.1: ГЛУБИНЫ ОТ ФАКТИЧЕСКОЙ ЗЕМЛИ (FindSurfaceY), а не от
// GenVars-полос. Ваниль может позволить себе абсолютный Y: её полосы
// замерены по готовому террейну. Мы мерить забыли — и жилы улетели
// в небо («рудные столпы»). Больше не улетят: минимум 4 блока почвы
// над верхней жилой, как и положено честной ванили.
private void VeinPair(ushort a, ushort b, double surfMul, double underMul, double cavMul)
{
int area = Width * Height;
// Пояс у поверхности: 4-55 блоков глубины, мелкие жилы.
int surfN = (int)(area * surfMul);
for (int i = 0; i < surfN; i++)
{
int x = WorldGen.genRand.Next(10, Width - 10);
int top = FindSurfaceY(x);
int y = top + WorldGen.genRand.Next(4, 55);
WorldGen.TileRunner(x, y,
WorldGen.genRand.Next(3, 6), WorldGen.genRand.Next(2, 6),
WorldGen.genRand.NextBool() ? a : b, true);
}
// Пояс подземелья: 55-190 глубины, жилы побольше.
int underN = (int)(area * underMul);
for (int i = 0; i < underN; i++)
{
int x = WorldGen.genRand.Next(10, Width - 10);
int top = FindSurfaceY(x);
int y = top + WorldGen.genRand.Next(55, 190);
WorldGen.TileRunner(x, y,
WorldGen.genRand.Next(3, 7), WorldGen.genRand.Next(3, 7),
WorldGen.genRand.NextBool() ? a : b, true);
}
// Кавернальный пояс: ниже каменной границы, жилы длинные.
int cavN = (int)(area * cavMul);
for (int i = 0; i < cavN; i++)
{
int x = WorldGen.genRand.Next(10, Width - 10);
int top = FindSurfaceY(x);
int depthMax = Height - 25 - top;
if (depthMax <= DirtBandDepth + 5)
continue; // колонка мелкая — жиле негде развернуться
int y = top + WorldGen.genRand.Next(DirtBandDepth, depthMax);
WorldGen.TileRunner(x, y,
WorldGen.genRand.Next(4, 9), WorldGen.genRand.Next(4, 8),
WorldGen.genRand.NextBool() ? a : b, true);
}
}
// ===================== ЭТАП 5. Фоновые стены =====================
private void GenerateWalls(GenerationProgress progress, GameConfiguration config)
{
for (int x = 2; x < Width - 2; x++)
{
int top = FindSurfaceY(x);
bool snow = x < SnowEnd;
bool crimson = Math.Abs(x - CrimsonCenter) < CrimsonRadius;
bool jungle = x >= JungleStart;
for (int y = top + 1; y < Height - 2; y++)
{
ushort wall;
if (snow && y >= top + 14)
{
wall = WallID.IceUnsafe; // снег: ледяные стены
}
else if (crimson && y >= top + 6)
{
wall = WallID.CrimstoneUnsafe; // алый камень в недрах языка
}
else if (jungle && y < top + 45)
{
wall = WallID.JungleUnsafe; // джунглевая растительность
}
else if (y < Main.rockLayer)
{
wall = WallID.DirtUnsafe; // честный дёрн подземелья
if (y < top + 12 && !snow && !crimson && !jungle)
{
if (WorldGen.genRand.NextBool(7)) wall = WallID.GrassUnsafe;
else if (WorldGen.genRand.NextBool(9)) wall = WallID.FlowerUnsafe;
}
}
else if (y < GenVars.rockLayerLow)
{
wall = WallID.Cave6Unsafe; // Rocky Dirt — переход
}
else
{
wall = WallID.Cave7Unsafe; // Old Stone — каверны
}
// mute=true: без пересчёта рамки на каждом кирпиче
// (0.3.3.2: без этого флага генерация думала вечность).
WorldGen.PlaceWall(x, y, wall, true);
}
if (x % 200 == 0)
progress.Set(x / (double)Width);
}
progress.Set(1.0);
}
// ===================== ЭТАП 6. Озеро =====================
private void GenerateLake(GenerationProgress progress, GameConfiguration config)
{
int lakeX = WorldGen.genRand.Next(Width / 4, Width * 3 / 4);
if (Math.Abs(lakeX - Width / 2) < 80)
lakeX += 160;
int surfaceY = FindSurfaceY(lakeX);
int rx = WorldGen.genRand.Next(14, 22);
int ry = WorldGen.genRand.Next(6, 10);
for (int x = lakeX - rx; x <= lakeX + rx; x++)
{
for (int y = surfaceY - 2; y <= surfaceY + ry; y++)
{
double dx = (x - lakeX) / (double)rx;
double dy = (y - surfaceY) / (double)ry;
if (dx * dx + dy * dy <= 1.0)
{
WorldGen.KillTile(x, y, false, false, true);
WorldGen.PlaceLiquid(x, y, (byte)LiquidID.Water, 255);
}
}
}
progress.Set(1.0);
}
// ===================== ЭТАП 7. Океаны у краёв =====================
// Вода льётся ровно до уровня моря (= высоте спавн-поляны).
private void GenerateOceans(GenerationProgress progress, GameConfiguration config)
{
foreach (int side in new[] { 0, 1 }) // 0 = левый край, 1 = правый
{
int span = 45;
for (int k = 0; k < span; k++)
{
int x = side == 0 ? 4 + k : Width - 5 - k;
int depth = 14 - k / 4; // у кромки глубже, к берегу мельчает
if (depth < 2) break;
int top = FindSurfaceY(x);
// Выкапываем чашу вместе со старыми стенами.
for (int y = top; y < top + depth; y++)
{
WorldGen.KillTile(x, y, false, false, true);
WorldGen.KillWall(x, y);
}
// Песчаное дно и отмель.
WorldGen.PlaceTile(x, top + depth - 1, SandTile, true, true);
WorldGen.PlaceTile(x, top + depth - 2, SandTile, true, true);
WorldGen.PlaceTile(x, top + depth - 3, SandTile, true, true);
// Заливаем до уровня моря.
for (int y = Math.Max(flatTopY, top); y < top + depth - 1; y++)
WorldGen.PlaceLiquid(x, y, (byte)LiquidID.Water, 255);
}
// Песчаный пляж — мягкий заход со стороны суши.
for (int k = span; k < span + 12; k++)
{
int x = side == 0 ? 4 + k : Width - 5 - k;
int top = FindSurfaceY(x);
WorldGen.PlaceTile(x, top, SandTile, true, true);
WorldGen.PlaceTile(x, top + 1, SandTile, true, true);
}
}
progress.Set(1.0);
}
// ===================== ЭТАП 8. Покраска поверхности и недр =====================
private void PaintSurface(GenerationProgress progress, GameConfiguration config)
{
for (int x = 0; x < Width; x++)
{
// Полосы океанов не трогаем — там пляж, песок и вода.
if (x < 60 || x > Width - 60)
continue;
int top = FindSurfaceY(x);
ushort surf = TileID.Grass;
if (x < SnowEnd)
{
surf = TileID.SnowBlock;
ReplaceStack(x, top, 6, 14, TileID.SnowBlock, TileID.IceBlock);
}
else if (Math.Abs(x - CrimsonCenter) < CrimsonRadius)
{
surf = TileID.CrimsonGrass;
// Алая чума уходит вглубь: верх толщи — в багрянец.
for (int dy = 1; dy <= 24; dy++)
{
int t = Main.tile[x, top + dy].TileType;
if (t == TileID.Stone || t == TileID.Dirt)
WorldGen.PlaceTile(x, top + dy, TileID.Crimstone, true, true);
}
}
else if (x >= DesertStart && x < DesertEnd)
{
surf = SandTile;
// Слоёвка ванильной пустыни: песок → спёкшийся песок → песчаник.
ReplaceStack(x, top, 8, 16, SandTile, TileID.HardenedSand);
for (int dy = 17; dy <= 24; dy++)
if (Main.tile[x, top + dy].TileType == TileID.Dirt)
WorldGen.PlaceTile(x, top + dy, TileID.Sandstone, true, true);
}
else if (x >= JungleStart)
{
surf = TileID.JungleGrass;
// Джунглевые недра — сплошная грязь до 45 блоков вниз.
for (int dy = 1; dy <= 45; dy++)
if (Main.tile[x, top + dy].TileType == TileID.Dirt)
WorldGen.PlaceTile(x, top + dy, TileID.Mud, true, true);
}
WorldGen.PlaceTile(x, top, surf, true, true);
WorldGen.SquareTileFrame(x, top, true);
}
progress.Set(1.0);
}
// Заменяет столбик земли/камня под поверхностью двумя слоями:
// upper сверху, lower под ним.
private static void ReplaceStack(int x, int top, int upperDepth, int lowerDepth, ushort upper, ushort lower)
{
for (int dy = 1; dy <= lowerDepth; dy++)
{
int t = Main.tile[x, top + dy].TileType;
if (t != TileID.Dirt && t != TileID.Stone)
continue;
WorldGen.PlaceTile(x, top + dy, dy <= upperDepth ? upper : lower, true, true);
}
}
// ===================== ЭТАП 9. Растительность-поросль =====================
private void GeneratePlants(GenerationProgress progress, GameConfiguration config)
{
for (int x = 6; x < Width - 6; x++)
{
bool crimson = Math.Abs(x - CrimsonCenter) < CrimsonRadius;
// Лес травой, алый язык — багряной ботвой.
if (IsForestColumn(x) && WorldGen.genRand.NextBool(3))
{
int top = FindSurfaceY(x);
WorldGen.PlaceTile(x, top - 1, TileID.Plants, true, true, -1, WorldGen.genRand.Next(9));
}
else if (crimson && WorldGen.genRand.NextBool(4))
{
int top = FindSurfaceY(x);
WorldGen.PlaceTile(x, top - 1, TileID.CrimsonPlants, true, true, -1, WorldGen.genRand.Next(9));
}
}
progress.Set(1.0);
}
// ===================== ЭТАП 10. Деревья =====================
private void GenerateTrees(GenerationProgress progress, GameConfiguration config)
{
// Гарантированная пара у спавна — первый взгляд должен быть «ваниль».
TryGrowTree(Width / 2 - 22);
TryGrowTree(Width / 2 + 25);
for (int x = 70; x < Width - 70; x += WorldGen.genRand.Next(5, 11))
{
if (!IsForestColumn(x))
continue;
if (Math.Abs(x - Width / 2) < 16)
continue;
TryGrowTree(x);
}
progress.Set(1.0);
}
private void TryGrowTree(int x)
{
int surfaceY = FindSurfaceY(x);
for (int dy = -3; dy <= 3; dy++)
{
if (WorldGen.GrowTree(x, surfaceY + dy))
break;
}
}
// ===================== ЭТАП 11. Гид и точка появления =====================
private void PlaceGuideAndSpawn(GenerationProgress progress, GameConfiguration config)
{
int spawnX = Width / 2;
int surfaceY = FindSurfaceY(spawnX);
Main.spawnTileX = spawnX;
Main.spawnTileY = surfaceY - 3;
NPC.NewNPC(new EntitySource_WorldGen(), (spawnX + 2) * 16, (surfaceY - 3) * 16, NPCID.Guide);
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;
}
// Лесная колонка — для деревьев и травы: не снег, не алый язык,
// не пустыня, не джунгли, не пляжи.
private bool IsForestColumn(int x)
{
if (x < SnowEnd + 10) return false;
if (Math.Abs(x - CrimsonCenter) < CrimsonRadius + 10) return false;
if (x >= DesertStart && x < DesertEnd) return false;
if (x >= JungleStart - 10) return false;
if (x < 70 || x > Width - 70) return false;
return true;
}
public override void OnEnter()
{
// Мир истории готов: занавес перелёта опускаем (его поднимал
// AnomalyPlayer перед броском сюда — канон 0.4.26).
StoryDirector.LiftCurtain();
loadTicks = 0; // лесная катсцена отработала — «часы» дороги на ноль
// Курсор вернёт AnomalyPlayer (требование №4); здесь — дубль-
// страховка, дёшевая.
Main.cursorScale = 1f;
// Баг №3 (канон 0.4.24): на спавне не терпим двух Гидов.
WorldFlagsSystem.DedupeGuides();
// Стройка окончена — закрываем окно тишины: канал блоков снова
// слышен. (Все звуки, что уже просочились, за окном резал
// SoundChannelGuard — конфиг игрока никто не трогал вообще.)
StorySilenceActive = false;
// ВРЕМЯ ТУТ БОЛЬШЕ НЕ СТАВИМ: сцену ночи назначает
// CampfireScene.Begin, а первое утро — её Finish (0.4.26).
// Слепок (проходы + чертёж) снимается при создании основного мира.
// Зашли в старый мир — крутим ручную копию и честно предупреждаем.
if (VanillaGenSnapshot.UseVanillaPipeline && !VanillaGenSnapshot.Ready)
{
Main.NewText("Слепок ванили пуст — кручу ручную копию 0.3.4.1. " +
"Для настоящей ванили создай НОВЫЙ основной мир.", 255, 170, 170);
}
}
}
}
================================================================
=== ФАЙЛ: 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/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;
StoryVoice.StepsStop(); // титул = тишина: шаги прошлого захода гасим
StoryVoice.AmbientStop(); // угли тоже: титул не должен потрескивать
StoryVoice.Stop(); // и ни одна сюжетная реплика не доживает сюда
}
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 (!StoryVoice.AssetExists("Assets/Slides/CampScene.png"))
return; // нет арта — титул честно чёрный
try
{
art = ModContent.Request<Texture2D>(
"ShatteredGenesis/Assets/Slides/CampScene",
AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception)
{
art = null; // бронежилет
}
}
}
}
================================================================
=== ФАЙЛ: 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: поднимаем сцену «Привал» заново. (Esc — пропуск)");
}
}
}
================================================================
=== ФАЙЛ: 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 (SubworldSystem.IsActive<DecoyWorld>())
{
caller.Reply("…возвращаемся в Первомир…");
SubworldSystem.Exit();
}
else
{
caller.Reply("…проваливаемся в мир-обманку…");
// Канон 0.4.26-2: звук стройки режет системный страж
// (SoundChannelGuard), перенос ведёт единая кнопка истории.
StoryDirector.EnterStory();
}
}
}
}
================================================================
=== ФАЙЛ: Common/Commands/DecoyModeCommand.cs
================================================================
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;
// Отладочная команда: /sgmode
// Переключает режим мира-обманки между двумя конвейерами:
// - настоящий ванильный (слепок VanillaGenSnapshot) — эксперимент 0.3.4.2;
// - ручная копия 0.3.4.1 — наша собственная генерация.
// Инструмент A/B-теста режиссёра: зашёл в одну версию, вышел, /sgmode, зашёл в другую.
namespace ShatteredGenesis.Common.Commands
{
public class DecoyModeCommand : ModCommand
{
public override string Command => "sgmode";
// Команда работает в чате внутри мира.
public override CommandType Type => CommandType.World;
public override void Action(CommandCaller caller, string input, string[] args)
{
VanillaGenSnapshot.UseVanillaPipeline = !VanillaGenSnapshot.UseVanillaPipeline;
if (VanillaGenSnapshot.UseVanillaPipeline)
{
if (VanillaGenSnapshot.Ready)
{
caller.Reply($"Режим: НАСТОЯЩАЯ ВАНИЛЬ (слепок {VanillaGenSnapshot.Count} проходов + чертёж). Вход — /sgworld");
}
else
{
caller.Reply("Режим: ваниль, но слепка НЕТ. Создай НОВЫЙ основной мир — " +
"слепок снимается в момент его генерации. До тех пор /sgworld соберёт ручную копию.");
}
}
else
{
caller.Reply("Режим: РУЧНАЯ КОПИЯ (0.3.4.1). Вход — /sgworld");
}
}
}
}
================================================================
=== ФАЙЛ: 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();
}
}
================================================================
=== ФАЙЛ: 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();
}
}
================================================================
=== ФАЙЛ: 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"
}
}