Загрузка данных
================================================================
SHATTERED GENESIS — «Расколотый Первомир»
ПОЛНЫЙ ОТЧЁТ КОДА · v0.4.26.11
G11: нативный курсор, независимая панель, физический 3x2 костёр
Дата пересборки отчёта: 2026-08-12
================================================================
================================================================
=== ФАЙЛ: Assets/Atlases/StoryParticles.json
================================================================
{
"frames": {
"Ember": {
"frame": {
"x": 0,
"y": 0,
"w": 16,
"h": 16
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 16,
"h": 16
},
"sourceSize": {
"w": 16,
"h": 16
}
},
"Firefly": {
"frame": {
"x": 16,
"y": 0,
"w": 16,
"h": 16
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 16,
"h": 16
},
"sourceSize": {
"w": 16,
"h": 16
}
}
},
"meta": {
"app": "free-tex-packer (human-made by SG techdept)",
"image": "StoryParticles.png",
"size": {
"w": 32,
"h": 16
},
"scale": "1"
}
}
================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/CampDarkness.fx
================================================================
// CampDarkness.fx — абсолютная сюжетная ночь вокруг привала.
// Luminance автоматически регистрирует фильтр как
// "ShatteredGenesis.CampDarkness" и подаёт uImage0/opacity/screenSize.
sampler uImage0 : register(s0);
float4x4 MatrixTransform;
float2 screenSize;
float opacity;
float globalTime;
float2 lightCenter; // координаты костра в UV экрана
float lightRadius; // радиус по высоте экрана
float darkness; // 0..1
struct VertexOutput
{
float4 Position : POSITION0;
float2 TexCoord : TEXCOORD0;
};
VertexOutput DarknessVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
VertexOutput o;
o.Position = mul(position, MatrixTransform);
o.TexCoord = texCoord;
return o;
}
float4 DarknessPS(float2 uv : TEXCOORD0) : COLOR0
{
float4 world = tex2D(uImage0, uv);
// Круг остаётся кругом на широком мониторе.
float2 delta = uv - lightCenter;
delta.x *= screenSize.x / max(screenSize.y, 1.0);
float distanceFromFire = length(delta);
// Внутри костра изображение живёт, на мягкой кромке быстро исчезает,
// за пределами outer — математический ноль, а не полупрозрачная виньетка.
float inner = lightRadius * 0.58;
float outer = lightRadius;
float visibility = 1.0 - smoothstep(inner, outer, distanceFromFire);
// Небольшое дыхание света не расширяет внешний абсолютный ноль.
float breath = 0.94 + 0.06 * sin(globalTime * 8.0);
float core = 1.0 - smoothstep(0.0, max(inner, 0.001), distanceFromFire);
float3 warm = float3(1.0, 0.34, 0.055) * core * 0.10 * breath;
float3 extinguished = world.rgb * visibility * breath + warm * visibility;
float strength = saturate(opacity * darkness);
world.rgb = lerp(world.rgb, extinguished, strength);
return world;
}
technique CampDarknessTechnique
{
pass AutoloadPass
{
VertexShader = compile vs_2_0 DarknessVS();
PixelShader = compile ps_2_0 DarknessPS();
}
}
================================================================
=== ФАЙЛ: AutoloadedEffects/Filters/SleepFade.fx
================================================================
// ============================================================
// SleepFade.fx — экранный ФИЛЬТР сна (финал сцены «Привал»).
// Канон 0.4.26 «Глубокая интеграция»: затемнение засыпания идёт
// через экранный конвейер Luminance ManagedScreenFilter
// (AutoloadedEffects/Filters → регистрируется как
// "ShatteredGenesis.SleepFade").
//
// УСТАВ ФИЛЬТРОВ LUMINANCE (ManagedScreenFilter.cs, сверено):
// * фильтр активируется каждый кадр вызовом Activate();
// * Opacity растёт/падает сама (0.015 за тик) и подаётся в шейдер
// АВТОМАТИЧЕСКИМ параметром "opacity";
// * "screenSize" и "globalTime" ставятся менеджером сами;
// * экран мира приезжает текстурой в слоте 0 (uImage0);
// * пасс техники ОБЯЗАН зваться AutoloadPass.
// ============================================================
sampler uImage0 : register(s0);
float4x4 MatrixTransform;
float2 screenSize;
float opacity; // авто: текущая сила фильтра (0..1, догоняется менеджером)
float globalTime; // авто: мировые часы
struct VertexOutput
{
float4 Position : POSITION0;
float2 TexCoord : TEXCOORD0;
};
VertexOutput FadeVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
VertexOutput o;
o.Position = mul(position, MatrixTransform);
o.TexCoord = texCoord;
return o;
}
float4 FadePS(float2 uv : TEXCOORD0) : COLOR0
{
float4 color = tex2D(uImage0, uv);
// Засыпание: гасим яркость неравномерно — края вянут быстрее
// (сон начинается с периферии зрения), центр держится дольше.
float2 c = uv - 0.5;
c.x *= screenSize.x / screenSize.y;
float d = length(c);
float dim = opacity * (0.45 + 0.55 * saturate(d * 1.7));
float flicker = 1.0 - 0.02 * sin(globalTime * 7.0) * opacity; // конвульсия век
color.rgb *= (1.0 - saturate(dim)) * flicker;
return color;
}
technique FadeTechnique
{
pass AutoloadPass
{
VertexShader = compile vs_2_0 FadeVS();
PixelShader = compile ps_2_0 FadePS();
}
}
================================================================
=== ФАЙЛ: AutoloadedEffects/Shaders/SleepVeil.fx
================================================================
// ============================================================
// SleepVeil.fx — пелена экрана загрузки подмира.
// Канон 0.4.26 «Глубокая интеграция»: виньетка НЕ полосами
// прямоугольников, а одним экранным проходом шейдера через
// Luminance ShaderManager (AutoloadedEffects/Shaders).
//
// Путь внутри мода выбран ПРАВИЛОМ Luminance (ShaderManager.cs,
// сверено с исходником): всё, что лежит в "AutoloadedEffects/Shaders",
// библиотека сама компилирует и регистрирует под именем
// "ShatteredGenesis.SleepVeil".
//
// Параметры ставит DecoyWorld.DrawSetup через TrySetParameter.
// ============================================================
float4x4 MatrixTransform; // конвенция SpriteBatch: движок сам кладёт сюда матрицу
float2 screenSize; // физические пиксели вьюпорта (коррекция искажения круга)
float opacity; // сила пелены (0..1) — вместе с вплытием арта
float globalTime; // часы «дыхания» углей
struct VertexOutput
{
float4 Position : POSITION0;
float2 TexCoord : TEXCOORD0;
};
VertexOutput VeilVS(float4 position : POSITION0, float2 texCoord : TEXCOORD0)
{
VertexOutput o;
o.Position = mul(position, MatrixTransform);
o.TexCoord = texCoord;
return o;
}
float4 VeilPS(float2 uv : TEXCOORD0) : COLOR0
{
// Радиальная виньетка: чем дальше от центра — тем глуше ночь.
// Коррекция по горизонтали, чтобы овал не растягивался монитором.
float2 c = uv - 0.5;
c.x *= screenSize.x / screenSize.y;
float d = length(c) * 1.5;
float veil = saturate(d * d); // мягкий квадратичный край
float breath = 0.05 + 0.04 * sin(globalTime * 1.65); // дыхание углей в темноте
float a = opacity * saturate(veil * (0.94 + breath));
// Тёплый отсвет очага снизу — костёр сна где-то за краем кадра.
float hearth = saturate(1.0 - uv.y);
float3 col = float3(0.910, 0.345, 0.047) * (hearth * hearth) * 0.22;
return float4(col, a);
}
technique VeilTechnique
{
pass AutoloadPass // имя пасса — по уставу Luminance (ManagedShader.DefaultPassName)
{
VertexShader = compile vs_2_0 VeilVS();
PixelShader = compile ps_2_0 VeilPS();
}
}
================================================================
=== ФАЙЛ: Common/Commands/DecoyTestCommand.cs
================================================================
using SubworldLibrary;
using Terraria;
using Terraria.ModLoader;
using ShatteredGenesis.Content.Subworlds;
using ShatteredGenesis.Common.Systems;
// Отладочная команда для M2: /sgworld
// Если мы в Первомире — прыгаем в мир-обманку.
// Если в обманке — возвращаемся в Первомир.
// Позже её заменит настоящий ивент «Вселение»; команда останется как инструмент тестера.
namespace ShatteredGenesis.Common.Commands
{
public class DecoyTestCommand : ModCommand
{
public override string Command => "sgworld";
// Команда работает в чате внутри мира.
public override CommandType Type => CommandType.World;
public override void Action(CommandCaller caller, string input, string[] args)
{
if (WorldFlagsSystem.ShellDestroyed)
{
caller.Reply("Скорлупа разрушена Вселением — пути в обманку больше нет.");
return;
}
if (SubworldSystem.IsActive<DecoyWorld>())
{
caller.Reply("…возвращаемся в Первомир…");
SubworldSystem.Exit();
}
else
{
caller.Reply("…проваливаемся в мир-обманку…");
// Перенос ведёт единая кнопка истории; звуки генерации
// подавляет ограниченный временем SubworldAudioSilencer.
StoryDirector.EnterStory();
}
}
}
}
================================================================
=== ФАЙЛ: Common/Commands/StoryReplayCommand.cs
================================================================
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;
// Команда /sgstory — прогоняет сюжетный пролог с самого начала.
// Отладочный инструмент сценарной группы: поправил текст, записал реплику,
// Build + Reload → /sgstory → смотрим сцену целиком, мира не трогая.
namespace ShatteredGenesis.Common.Commands
{
public class StoryReplayCommand : ModCommand
{
public override CommandType Type => CommandType.Chat | CommandType.World;
public override string Command => "sgstory";
public override string Description => "Прогнать сюжетный пролог заново (отладка сцен)";
public override void Action(CommandCaller caller, string input, string[] args)
{
CampfireScene.Begin(force: true);
caller.Reply("Shattered Genesis: поднимаем сцену «Привал» заново.");
}
}
}
================================================================
=== ФАЙЛ: Common/Players/AnomalyPlayer.cs
================================================================
using SubworldLibrary;
using Terraria;
using Terraria.GameInput;
using Terraria.ModLoader;
using Terraria.ModLoader.IO; // TagCompound для сохранения данных игрока
using ShatteredGenesis.Common.Systems;
using ShatteredGenesis.Content.Subworlds;
// AnomalyPlayer — персонаж-Аномалия.
// Хранит метку Вселения (IsAnomaly) и направляет сюжетный перенос.
//
// КАНОН 0.4.26 «Глубокая интеграция» — разводим миры по полочкам:
// ОСНОВНОЙ мир — ВАНИЛЬ БЕЗ ЕДИНОЙ ПРАВКИ. Мы здесь только
// бросаем игрока в мир истории на первом заходе (и то — уже
// после того, как ваниль полностью закончила загрузку).
// ПОДМИР — мир истории: вход = принудительное возвращение мыши
// и ввода (требование №4: на загрузочной катсцене курсор скрыт,
// а диалог «Привал» кликается мышью), потом — сцена у костра.
//
// Убито навсегда: WorldAudioGuard, фиксация игрока, чёрные фоны
// основного мира. Всё это заменено глубокими каналами (StoryDirector).
namespace ShatteredGenesis.Common.Players
{
public class AnomalyPlayer : ModPlayer
{
// Метка Вселения. Сейчас ничего не делает механически,
// но уже живёт и сохраняется вместе с персонажем.
public bool IsAnomaly;
public override void SaveData(TagCompound tag)
{
tag["sg_isAnomaly"] = IsAnomaly;
}
public override void LoadData(TagCompound tag)
{
IsAnomaly = tag.TryGet("sg_isAnomaly", out bool anomaly) && anomaly;
}
// Движок зовёт каждый раз при входе в мир (и в подмир тоже).
public override void OnEnterWorld()
{
// Баг №3 (канон 0.4.24): ни в одном мире не терпим двух Гидов.
WorldFlagsSystem.DedupeGuides();
// Водяной знак сборки — чтобы на скринах всегда была версия.
Main.NewText($"Shattered Genesis v{Mod.Version} загружен.", 140, 140, 140);
if (SubworldSystem.AnyActive())
{
// Подмир уже полностью загружен: только теперь удаляем
// неслышимые остатки генерации и возвращаем громкость игрока.
SubworldAudioSilencer.End();
// ============ ПОДМИР: мир истории ============
// Требование №4 + указ «Фикс мыши» (0.4.26-2): на входе
// мышь и ввод возвращаются ПРИНУДИТЕЛЬНО. Курсор был скрыт
// на загрузочной катсцене ходьбы по лесу (там нечего
// кликать), здесь же — кликабельный диалог.
Main.cursorScale = 1f;
// Игровой канал ввода/зума: экранные матрицы загрузки больше
// не действуют — мир рисуется мировым зумом движка.
PlayerInput.SetZoom_World();
if (!WorldFlagsSystem.IntroDone)
{
CampfireScene.Begin(); // ночная сцена «Привал» у костра
}
else
{
Main.NewText("Где-то за краем мира что-то заметило, что ты пришёл.",
255, 190, 90);
}
return;
}
// ============ ОСНОВНОЙ мир: чистая ваниль ============
// Мир уже полностью загрузился (OnEnterWorld — после всего).
// Единственное наше действие: история начинается переносом —
// и только ОДИН раз (интро ещё не видно, Скорлупа не разрушена).
// Кольца перезаходов нет каноном 0.4.26-4: флаги монотонны.
if (!WorldFlagsSystem.IntroDone && !WorldFlagsSystem.ShellDestroyed)
{
StoryDirector.EnterStory();
}
}
// Первый заслон ввода: движок вызывает его сразу после опроса клавиш.
// Финальная страховка остаётся в CampfireScene.PostUpdateEverything.
public override void SetControls()
{
if (CampfireScene.PlayerLocked)
CampfireScene.ApplyPlayerLock(Player);
}
public override void PreUpdateMovement()
{
if (CampfireScene.PlayerLocked)
CampfireScene.ApplyPlayerLock(Player);
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/CampfireScene.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics;
using SubworldLibrary;
using Terraria;
using Terraria.DataStructures;
using Terraria.GameContent;
using Terraria.GameInput;
using Terraria.Graphics;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.UI.Chat;
using ShatteredGenesis.Content.NPCs;
using ShatteredGenesis.Content.Particles;
using ShatteredGenesis.Content.Subworlds;
using ShatteredGenesis.Content.Tiles;
// ============================================================
// CampfireScene — реальная сцена «Привал» внутри DecoyWorld.
//
// Законы сцены:
// * игрок с первого кадра сидит на физическом CampLog и не может встать;
// * вокруг — глубокая ночь и фильтр Luminance CampDarkness: внешний мир
// становится абсолютной чернотой, виден только круг нашего костра;
// * Сказитель — настоящий ModNPC. ПКМ по нему начинает диалог;
// * при диалоге ванильный UI скрыт, камера плавно сводит игрока и NPC;
// * на узле GuideArrivalNode из темноты приходит настоящий NPCID.Guide;
// * после 27-го узла Сказитель уходит и растворяется, камера возвращается,
// управление отдаётся игроку, Гид остаётся сидеть у гаснущего костра.
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
public sealed class CampfireScene : ModSystem
{
enum ScenePhase
{
Inactive,
AwaitingInteraction,
Dialogue,
StorytellerLeaving,
CameraRelease,
Complete
}
sealed class DNode
{
public string Text;
public string[] Choices;
public int[] Next;
public int Slide = -1;
}
// Узлы 0..26. На этом узле в круг света входит Гид.
public const int GuideArrivalNode = 20;
static ScenePhase phase;
static DNode[] nodes;
static int nodeIndex = -1;
static int clickCooldown;
static int finalTicks;
static int completeTicks;
static bool previousLeft;
static bool previousRight;
static Rectangle[] choiceRects = Array.Empty<Rectangle>();
static int storytellerIndex = -1;
static int guideIndex = -1;
static bool storytellerGone;
static bool guideSeated;
static int guideArrivalTicks;
static float cinematicZoom = 1f;
static float fireStrength;
static bool darknessFilterDead;
static bool hideUiCaptured;
static bool previousHideUi;
static Point playerSeatTile;
static Point guideSeatTile;
static Vector2 campfireWorld;
static Vector2 playerSeatWorld;
static Vector2 storytellerAnchor;
static Vector2 guideApproachStart;
static bool sceneArtTried;
static Texture2D[] slideArt;
static Texture2D[][] slideFrames;
public static bool Active => phase != ScenePhase.Inactive;
public static bool SuppressHostileSpawns => Active
&& SubworldSystem.IsActive<DecoyWorld>();
public static bool PlayerLocked => phase is ScenePhase.AwaitingInteraction
or ScenePhase.Dialogue or ScenePhase.StorytellerLeaving
or ScenePhase.CameraRelease;
public static bool MarkerVisible => phase == ScenePhase.AwaitingInteraction;
public static bool StorytellerShouldLeave => phase == ScenePhase.StorytellerLeaving;
public static Vector2 StorytellerAnchor => storytellerAnchor;
public override void Load()
{
if (!Main.dedServ)
On_Main.Draw += DrawCinematicInterface;
}
public override void Unload()
{
if (!Main.dedServ)
On_Main.Draw -= DrawCinematicInterface;
ResetScene(false);
slideArt = null;
slideFrames = null;
nodes = null;
sceneArtTried = false;
}
public override void OnWorldUnload() => ResetScene(false);
// -------------------- запуск и геометрия --------------------
public static void Begin(bool force = false)
{
if (!SubworldSystem.IsActive<DecoyWorld>())
return;
if (Active && !force)
return;
ResetScene(false);
BuildNodes();
LoadSceneArt();
SetupSceneGeometry();
RemoveVanillaGuides();
PurgeHostileNPCs();
SpawnStoryteller();
Main.dayTime = false;
Main.time = 16200;
Main.raining = false;
Main.windSpeedCurrent = 0f;
Main.windSpeedTarget = 0f;
phase = ScenePhase.AwaitingInteraction;
fireStrength = 0f;
cinematicZoom = 1f;
nodeIndex = -1;
clickCooldown = 0;
finalTicks = 0;
completeTicks = 0;
storytellerGone = false;
guideIndex = -1;
guideSeated = false;
guideArrivalTicks = 0;
darknessFilterDead = false;
previousLeft = Main.mouseLeft;
previousRight = Main.mouseRight;
ApplyPlayerLock(Main.LocalPlayer);
}
static void SetupSceneGeometry()
{
int centerX = Math.Clamp(Main.spawnTileX, 60, Main.maxTilesX - 60);
int centerGround = FindSurfaceY(centerX);
int playerX = centerX - 5;
int playerGround = FindSurfaceY(playerX);
playerSeatTile = new Point(playerX, playerGround - 1);
int guideX = centerX + 6;
int guideGround = FindSurfaceY(guideX);
guideSeatTile = new Point(guideX, guideGround - 1);
EnsureStoryCampfire(centerX, centerGround);
campfireWorld = FindStoryCampfireWorld(centerX, centerGround);
playerSeatWorld = new Vector2(playerX * 16f + 8f, playerGround * 16f);
storytellerAnchor = new Vector2((centerX + 3) * 16f + 8f,
FindSurfaceY(centerX + 3) * 16f);
int guideStartX = Math.Clamp(centerX + 35, 20, Main.maxTilesX - 20);
guideApproachStart = new Vector2(guideStartX * 16f + 8f,
FindSurfaceY(guideStartX) * 16f);
PlaceCampLog(playerSeatTile);
PlaceCampLog(guideSeatTile);
}
static void EnsureStoryCampfire(int centerX, int groundY)
{
int type = ModContent.TileType<StoryCampfireTile>();
if (HasStoryCampfireNear(centerX, groundY))
return;
// Миграционная страховка для уже сохранённых Скорлуп G9/G10.
// В новых мирах этот же объект ставит PlaceSceneSpawn.
for (int x = centerX - 1; x <= centerX + 1; x++)
{
for (int y = groundY - 2; y < groundY; y++)
WorldGen.KillTile(x, y, false, false, true);
if (!Framing.GetTileSafely(x, groundY).HasTile)
WorldGen.PlaceTile(x, groundY, TileID.Dirt, true, true);
}
WorldGen.PlaceObject(centerX, groundY - 1, type, true);
if (Main.netMode == NetmodeID.Server)
NetMessage.SendTileSquare(-1, centerX, groundY - 1, 5);
}
static bool HasStoryCampfireNear(int centerX, int groundY)
{
int type = ModContent.TileType<StoryCampfireTile>();
for (int x = centerX - 5; x <= centerX + 5; x++)
{
for (int y = groundY - 5; y <= groundY + 1; y++)
{
Tile tile = Framing.GetTileSafely(x, y);
if (tile.HasTile && tile.TileType == type)
return true;
}
}
return false;
}
static Vector2 FindStoryCampfireWorld(int centerX, int groundY)
{
int type = ModContent.TileType<StoryCampfireTile>();
for (int x = centerX - 5; x <= centerX + 5; x++)
{
for (int y = groundY - 5; y <= groundY + 1; y++)
{
Tile tile = Framing.GetTileSafely(x, y);
if (!tile.HasTile || tile.TileType != type)
continue;
int topX = x - tile.TileFrameX / 18 % 3;
int topY = y - tile.TileFrameY / 18 % 2;
return new Vector2(topX * 16f + 24f, (topY + 2) * 16f);
}
}
return new Vector2(centerX * 16f + 8f, groundY * 16f);
}
static Vector2 CampfireFlameWorld => campfireWorld + new Vector2(0f, -20f);
static int FindSurfaceY(int x)
{
int start = Math.Clamp(Main.spawnTileY - 90, 10, Main.maxTilesY - 20);
for (int y = start; y < Main.maxTilesY - 20; y++)
{
Tile tile = Framing.GetTileSafely(x, y);
if (tile.HasTile && Main.tileSolid[tile.TileType])
return y;
}
return Math.Clamp(Main.spawnTileY + 3, 20, Main.maxTilesY - 20);
}
static void PlaceCampLog(Point tilePosition)
{
if (!WorldGen.InWorld(tilePosition.X, tilePosition.Y, 10))
return;
int type = ModContent.TileType<CampLog>();
Tile tile = Framing.GetTileSafely(tilePosition.X, tilePosition.Y);
if (tile.HasTile && tile.TileType != type)
WorldGen.KillTile(tilePosition.X, tilePosition.Y, false, false, true);
WorldGen.PlaceTile(tilePosition.X, tilePosition.Y, type, true, true);
WorldGen.SquareTileFrame(tilePosition.X, tilePosition.Y, true);
if (Main.netMode == NetmodeID.Server)
NetMessage.SendTileSquare(-1, tilePosition.X, tilePosition.Y, 1);
}
static void RemoveVanillaGuides()
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
for (int i = 0; i < Main.maxNPCs; i++)
{
NPC npc = Main.npc[i];
if (npc.active && npc.type == NPCID.Guide)
{
npc.active = false;
npc.netUpdate = true;
}
}
}
// Последняя страховка поверх spawn pool: если враг уже существовал
// к моменту старта сцены или был создан сторонним событием, удаляем его.
public override void PreUpdateEntities()
{
if (SuppressHostileSpawns)
PurgeHostileNPCs();
}
static void PurgeHostileNPCs()
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
for (int i = 0; i < Main.maxNPCs; i++)
{
NPC npc = Main.npc[i];
if (!npc.active || npc.friendly)
continue;
npc.active = false;
if (Main.netMode == NetmodeID.Server)
NetMessage.SendData(MessageID.SyncNPC, -1, -1, null, i);
}
}
static void SpawnStoryteller()
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
storytellerIndex = NPC.NewNPC(
new EntitySource_Misc("ShatteredGenesis:CampfireScene"),
(int)storytellerAnchor.X,
(int)storytellerAnchor.Y,
ModContent.NPCType<StoryTeller>());
if (TryGetStoryteller(out NPC storyteller))
{
storyteller.Bottom = storytellerAnchor;
storyteller.netUpdate = true;
}
}
// -------------------- блокировка игрока --------------------
public static void ApplyPlayerLock(Player player)
{
if (!PlayerLocked || player == null || !player.active)
return;
player.controlLeft = false;
player.controlRight = false;
player.controlJump = false;
player.controlUp = false;
player.controlDown = false;
player.controlHook = false;
player.controlMount = false;
player.controlUseItem = false;
player.controlUseTile = false;
player.controlThrow = false;
player.velocity = Vector2.Zero;
player.fallStart = (int)(player.position.Y / 16f);
player.direction = 1;
if (WorldGen.InWorld(playerSeatTile.X, playerSeatTile.Y, 10))
{
Tile seat = Framing.GetTileSafely(playerSeatTile.X, playerSeatTile.Y);
if (seat.HasTile && seat.TileType == ModContent.TileType<CampLog>())
{
if (!player.sitting.isSitting)
player.sitting.SitDown(player, playerSeatTile.X, playerSeatTile.Y);
return;
}
}
// Аварийная страховка: даже если другой мод уничтожил бревно,
// персонаж не телепортируется прочь из мизансцены.
player.Bottom = playerSeatWorld;
}
// -------------------- взаимодействие и диалог --------------------
static void UpdateInteraction()
{
bool right = Main.mouseRight;
bool pressed = right && !previousRight;
previousRight = right;
previousLeft = Main.mouseLeft;
if (!TryGetStoryteller(out NPC storyteller))
return;
bool hovered = storyteller.Hitbox.Contains(Main.MouseWorld.ToPoint());
if (hovered)
{
Main.LocalPlayer.mouseInterface = true;
Main.LocalPlayer.noThrow = 2;
Main.cursorOverride = 2;
}
if (pressed && hovered)
StartDialogue();
}
static void StartDialogue()
{
if (phase != ScenePhase.AwaitingInteraction)
return;
// Команда повтора может войти сюда после частичной перезагрузки
// статики. Тексты обязаны существовать до первого кадра букв.
BuildNodes();
phase = ScenePhase.Dialogue;
hideUiCaptured = true;
previousHideUi = Main.hideUI;
Main.hideUI = true;
Main.playerInventory = false;
Main.mapFullscreen = false;
// Курсор остаётся полностью системным. Мы лишь возвращаем ему
// нормальный масштаб/стиль и отдаём мышь интерфейсному каналу.
Main.LocalPlayer.mouseInterface = true;
Main.cursorScale = 1f;
Main.cursorOverride = 2;
choiceRects = Array.Empty<Rectangle>();
EnterNode(0);
}
static void HandleDialogueInput()
{
bool left = Main.mouseLeft;
bool pressed = left && !previousLeft;
previousLeft = left;
previousRight = Main.mouseRight;
clickCooldown = Math.Max(0, clickCooldown - 1);
if (!pressed || clickCooldown > 0 || nodeIndex < 0)
return;
DNode node = nodes[nodeIndex];
if (node.Choices != null)
{
Point mouse = GetUiMousePoint();
for (int i = 0; i < node.Choices.Length && i < choiceRects.Length; i++)
{
if (choiceRects[i].Contains(mouse))
{
EnterNode(node.Next[i]);
return;
}
}
}
else
{
EnterNode(node.Next[0]);
}
}
static void EnterNode(int index)
{
if (index < 0)
{
BeginFinale();
return;
}
nodeIndex = index;
clickCooldown = 10;
choiceRects = Array.Empty<Rectangle>();
if (index == GuideArrivalNode && guideIndex < 0)
SpawnGuideFromDarkness();
}
static void BeginFinale()
{
nodeIndex = -1;
phase = ScenePhase.StorytellerLeaving;
finalTicks = 0;
choiceRects = Array.Empty<Rectangle>();
Main.hideUI = true;
}
// -------------------- Гид --------------------
static void SpawnGuideFromDarkness()
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
guideIndex = NPC.NewNPC(
new EntitySource_Misc("ShatteredGenesis:GuideArrival"),
(int)guideApproachStart.X,
(int)guideApproachStart.Y,
NPCID.Guide);
if (TryGetGuide(out NPC guide))
{
guide.Bottom = guideApproachStart;
guide.alpha = 255;
guide.noGravity = true;
guide.noTileCollide = true;
guide.dontTakeDamage = true;
guide.aiStyle = NPCAIStyleID.FaceClosestPlayer;
guide.velocity = Vector2.Zero;
guide.direction = -1;
guide.spriteDirection = -1;
guide.netUpdate = true;
}
guideArrivalTicks = 0;
guideSeated = false;
}
static void UpdateGuideArrival()
{
if (!TryGetGuide(out NPC guide))
return;
if (guideSeated)
{
KeepGuideSeated(guide);
return;
}
guideArrivalTicks++;
float t = MathHelper.Clamp(guideArrivalTicks / 260f, 0f, 1f);
float eased = MathHelper.SmoothStep(0f, 1f, t);
Vector2 seatBottom = new Vector2(guideSeatTile.X * 16f + 8f,
(guideSeatTile.Y + 1) * 16f);
guide.Bottom = Vector2.Lerp(guideApproachStart, seatBottom, eased);
guide.velocity = Vector2.Zero;
guide.alpha = (int)MathHelper.Lerp(255f, 0f,
MathHelper.Clamp(t * 1.35f, 0f, 1f));
guide.direction = -1;
guide.spriteDirection = -1;
guide.noGravity = true;
guide.noTileCollide = true;
guide.dontTakeDamage = true;
guide.aiStyle = NPCAIStyleID.FaceClosestPlayer;
if (t >= 1f)
{
guideSeated = true;
KeepGuideSeated(guide);
}
}
static void KeepGuideSeated(NPC guide)
{
guide.noGravity = false;
guide.noTileCollide = false;
guide.dontTakeDamage = false;
guide.aiStyle = NPCAIStyleID.Passive; // стандартный ИИ городского NPC
guide.SitDown(guideSeatTile, out int direction, out Vector2 bottom);
guide.Bottom = bottom;
guide.direction = direction;
guide.spriteDirection = direction;
guide.velocity = Vector2.Zero;
guide.ai[0] = 5f; // ванильное состояние «сидит»
guide.ai[1] = 900f;
}
static bool TryGetGuide(out NPC guide)
{
guide = null;
if (guideIndex < 0 || guideIndex >= Main.maxNPCs)
return false;
NPC candidate = Main.npc[guideIndex];
if (!candidate.active || candidate.type != NPCID.Guide)
return false;
guide = candidate;
return true;
}
// -------------------- Сказитель --------------------
public static bool TryGetStoryteller(out NPC storyteller)
{
storyteller = null;
if (storytellerIndex < 0 || storytellerIndex >= Main.maxNPCs)
return false;
NPC candidate = Main.npc[storytellerIndex];
if (!candidate.active || candidate.type != ModContent.NPCType<StoryTeller>())
return false;
storyteller = candidate;
return true;
}
public static void NotifyStorytellerGone()
{
storytellerGone = true;
storytellerIndex = -1;
}
// -------------------- главный тик --------------------
public override void PostUpdateEverything()
{
if (!Active)
return;
if (!SubworldSystem.IsActive<DecoyWorld>())
{
ResetScene(false);
return;
}
bool running = phase != ScenePhase.Complete;
if (running)
{
Main.dayTime = false;
Main.time = 16200;
Main.raining = false;
Main.windSpeedCurrent = 0f;
Main.windSpeedTarget = 0f;
}
if (PlayerLocked)
ApplyPlayerLock(Main.LocalPlayer);
if (phase == ScenePhase.AwaitingInteraction)
UpdateInteraction();
else if (phase == ScenePhase.Dialogue)
HandleDialogueInput();
else
{
previousLeft = Main.mouseLeft;
previousRight = Main.mouseRight;
}
if (guideIndex >= 0)
UpdateGuideArrival();
if (phase == ScenePhase.Dialogue)
{
Main.hideUI = true;
Main.LocalPlayer.mouseInterface = true;
Main.LocalPlayer.noThrow = 2;
Main.cursorScale = 1f;
Main.cursorOverride = 2;
}
if (phase == ScenePhase.StorytellerLeaving)
{
Main.hideUI = true;
finalTicks++;
if (!TryGetStoryteller(out _) && finalTicks > 30)
storytellerGone = true;
if (storytellerGone && guideSeated && finalTicks > 90)
{
phase = ScenePhase.CameraRelease;
finalTicks = 0;
}
}
else if (phase == ScenePhase.CameraRelease)
{
Main.hideUI = true;
finalTicks++;
if (finalTicks > 120 && Math.Abs(cinematicZoom - 1f) < 0.015f)
CompleteScene();
}
else if (phase == ScenePhase.Complete)
{
completeTicks++;
fireStrength = MathHelper.Lerp(fireStrength, 0f, 0.006f);
if (completeTicks > 720 || fireStrength < 0.01f)
phase = ScenePhase.Inactive;
}
float zoomTarget = phase switch
{
ScenePhase.Dialogue => 1.62f,
ScenePhase.StorytellerLeaving => 1.55f,
_ => 1f
};
cinematicZoom = MathHelper.Lerp(cinematicZoom, zoomTarget, 0.055f);
if (Math.Abs(cinematicZoom - zoomTarget) < 0.001f)
cinematicZoom = zoomTarget;
float fireTarget = phase switch
{
ScenePhase.StorytellerLeaving => 0.35f,
ScenePhase.CameraRelease => 0.20f,
ScenePhase.Complete => 0.10f,
_ => 1f
};
if (phase != ScenePhase.Complete)
fireStrength = MathHelper.Lerp(fireStrength, fireTarget, 0.045f);
UpdateCampLightAndParticles();
if (running)
ActivateCampDarkness();
}
static void CompleteScene()
{
phase = ScenePhase.Complete;
completeTicks = 0;
nodeIndex = -1;
WorldFlagsSystem.IntroDone = true;
RestoreNativeUi();
Player player = Main.LocalPlayer;
player.frozen = false;
player.velocity = Vector2.Zero;
}
static void ResetScene(bool deactivateActors)
{
RestoreNativeUi();
if (deactivateActors && TryGetStoryteller(out NPC storyteller))
storyteller.active = false;
phase = ScenePhase.Inactive;
nodeIndex = -1;
storytellerIndex = -1;
guideIndex = -1;
storytellerGone = false;
guideSeated = false;
cinematicZoom = 1f;
fireStrength = 0f;
choiceRects = Array.Empty<Rectangle>();
}
static void RestoreNativeUi()
{
if (hideUiCaptured)
Main.hideUI = previousHideUi;
else if (phase != ScenePhase.Inactive)
Main.hideUI = false;
hideUiCaptured = false;
}
// -------------------- свет и фильтр --------------------
static void UpdateCampLightAndParticles()
{
if (fireStrength <= 0.001f)
return;
float flicker = 0.90f + 0.10f * (float)Math.Sin(Main.GameUpdateCount * 0.19f);
Vector2 flameWorld = CampfireFlameWorld;
Lighting.AddLight(flameWorld,
new Vector3(1.20f, 0.42f, 0.08f) * fireStrength * flicker);
if (phase != ScenePhase.Complete && Main.GameUpdateCount % 4 == 0)
{
try
{
new EmberParticle(
flameWorld + new Vector2(Main.rand.NextFloat(-10f, 10f), -5f),
new Vector2(Main.rand.NextFloat(-0.22f, 0.22f),
Main.rand.NextFloat(-1.35f, -0.55f)),
new Color(255, 222, 126),
90 + Main.rand.Next(90)).Spawn();
}
catch (Exception)
{
// Частицы декоративны и не имеют права прервать сцену.
}
}
}
static void ActivateCampDarkness()
{
if (darknessFilterDead)
return;
var filter = StoryDirector.CampDarknessFilter;
if (filter == null)
return;
try
{
Vector2 lightOnScreen = CampfireFlameWorld - Main.screenPosition;
Vector2 uv = new Vector2(
MathHelper.Clamp(lightOnScreen.X / Math.Max(1f, Main.screenWidth), 0f, 1f),
MathHelper.Clamp(lightOnScreen.Y / Math.Max(1f, Main.screenHeight), 0f, 1f));
float radius = 0.18f + 0.13f * fireStrength;
filter.TrySetParameter("lightCenter", uv);
filter.TrySetParameter("lightRadius", radius);
filter.TrySetParameter("darkness", 1f);
filter.Activate();
}
catch (Exception)
{
darknessFilterDead = true;
}
}
// -------------------- кинематографичная камера --------------------
public override void ModifyScreenPosition()
{
if (!Active || cinematicZoom <= 1.002f)
return;
Player player = Main.LocalPlayer;
Vector2 second = campfireWorld;
if (TryGetStoryteller(out NPC storyteller))
second = storyteller.Center;
else if (TryGetGuide(out NPC guide))
second = guide.Center;
Vector2 focus = (player.Center + second) * 0.5f;
Vector2 desired = focus - new Vector2(Main.screenWidth, Main.screenHeight) * 0.5f;
Main.screenPosition = Vector2.Lerp(Main.screenPosition, desired, 0.12f);
}
public override void ModifyTransformMatrix(ref SpriteViewMatrix Transform)
{
if (!Active || cinematicZoom <= 1.002f)
return;
// GameViewMatrix и есть матрица, переданная сюда движком.
// Умножаем её штатный пользовательский zoom, а не заменяем его.
Transform.Zoom = Transform.Zoom * cinematicZoom;
}
// Костёр и брёвна теперь являются настоящими ModTile-объектами.
// Отдельного PostDrawTiles и плоских world-space картинок больше нет.
static void LoadSceneArt()
{
if (sceneArtTried)
return;
sceneArtTried = true;
slideArt = new Texture2D[StoryDirector.SlideCount];
slideFrames = new Texture2D[StoryDirector.SlideCount][];
for (int i = 0; i < StoryDirector.SlideCount; i++)
{
List<Texture2D> frames = new List<Texture2D>();
for (int f = 0; f < 16; f++)
{
Texture2D frame = RequestTexture(
$"Assets/Slides/Slide{i + 1}_{f}.png",
$"ShatteredGenesis/Assets/Slides/Slide{i + 1}_{f}");
if (frame == null)
break;
frames.Add(frame);
}
if (frames.Count > 1)
slideFrames[i] = frames.ToArray();
else
slideArt[i] = RequestTexture(
$"Assets/Slides/Slide{i + 1}.png",
$"ShatteredGenesis/Assets/Slides/Slide{i + 1}");
}
}
static Texture2D RequestTexture(string relativeWithExtension, string assetPath)
{
if (!ModAssetHelper.Exists(relativeWithExtension))
return null;
try
{
return ModContent.Request<Texture2D>(assetPath,
AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception)
{
return null;
}
}
// -------------------- UI поверх скрытого интерфейса Terraria --------------------
void DrawCinematicInterface(On_Main.orig_Draw orig, Main self, GameTime gameTime)
{
orig(self, gameTime);
if (phase != ScenePhase.Dialogue || nodeIndex < 0 || Main.dedServ)
return;
// Main.hideUI отключает интерфейсный проход Terraria целиком,
// поэтому панель рисуется после готового кадра. Курсор при этом
// не подменяется: его в том же UI-batch рисует Main.DrawCursor.
Main.LocalPlayer.mouseInterface = true;
Main.cursorScale = 1f;
Main.cursorOverride = 2;
SpriteBatch sb = Main.spriteBatch;
bool begun = false;
try
{
PlayerInput.SetZoom_UI();
sb.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend,
SamplerState.LinearClamp, DepthStencilState.None,
RasterizerState.CullNone, null, Main.UIScaleMatrix);
begun = true;
DrawCinematicPanel(sb);
// Это штатная функция Terraria и штатные cursor textures.
// Она вызывается после панели, поэтому стрелка всегда сверху.
Main.DrawCursor(Vector2.Zero);
sb.End();
begun = false;
}
catch (Exception)
{
if (begun)
{
try { sb.End(); } catch (Exception) { }
}
}
finally
{
PlayerInput.SetZoom_World();
}
}
static void DrawCinematicPanel(SpriteBatch spriteBatch)
{
Viewport viewport = Main.instance.GraphicsDevice.Viewport;
float uiScale = Math.Max(Main.UIScale, 0.0001f);
float screenWidth = viewport.Width / uiScale;
float screenHeight = viewport.Height / uiScale;
DrawVision(spriteBatch, screenWidth, screenHeight);
DrawDialoguePanel(spriteBatch);
}
static Point GetUiMousePoint()
{
// Никакого ручного деления координат мыши: инвертируем ровно
// ту матрицу, которой нарисованы кнопки. Клик и hover совпадают.
Matrix inverseUi = Matrix.Invert(Main.UIScaleMatrix);
Vector2 logical = Vector2.Transform(
new Vector2(Main.mouseX, Main.mouseY), inverseUi);
return logical.ToPoint();
}
static void DrawVision(SpriteBatch sb, float screenWidth, float screenHeight)
{
if (nodeIndex < 0 || nodes[nodeIndex].Slide < 0)
return;
int slide = nodes[nodeIndex].Slide;
Texture2D texture = null;
if (slideFrames != null && slide < slideFrames.Length
&& slideFrames[slide] != null && slideFrames[slide].Length > 0)
{
Texture2D[] frames = slideFrames[slide];
texture = frames[(int)(Main.GameUpdateCount / 38 % (uint)frames.Length)];
}
else if (slideArt != null && slide < slideArt.Length)
{
texture = slideArt[slide];
}
if (texture == null)
return;
float maxWidth = screenWidth * 0.56f;
float maxHeight = screenHeight * 0.48f;
float scale = Math.Min(maxWidth / texture.Width, maxHeight / texture.Height);
Vector2 size = new Vector2(texture.Width, texture.Height) * scale;
Rectangle destination = new Rectangle(
(int)(screenWidth * 0.5f - size.X * 0.5f),
(int)(screenHeight * 0.08f),
(int)size.X, (int)size.Y);
sb.Draw(TextureAssets.MagicPixel.Value,
new Rectangle(destination.X - 3, destination.Y - 3,
destination.Width + 6, destination.Height + 6),
new Color(124, 79, 18) * 0.55f);
sb.Draw(texture, destination, Color.White * 0.58f);
}
static void DrawDialoguePanel(SpriteBatch sb)
{
Viewport viewport = Main.instance.GraphicsDevice.Viewport;
float uiScale = Math.Max(Main.UIScale, 0.0001f);
float screenWidth = viewport.Width / uiScale;
float screenHeight = viewport.Height / uiScale;
DNode node = nodes[nodeIndex];
DynamicSpriteFont font = FontAssets.MouseText.Value;
Texture2D pixel = TextureAssets.MagicPixel.Value;
Point mouse = GetUiMousePoint();
const float textScale = 1.12f;
const int margin = 20;
int panelWidth = Math.Max(300, (int)(screenWidth * 0.78f));
panelWidth = Math.Min(panelWidth, Math.Max(300, (int)screenWidth - 24));
float usableWidth = panelWidth - margin * 2f;
Vector2 textScaleVector = new Vector2(textScale);
Vector2 textBounds = ChatManager.GetStringSize(
font, node.Text, textScaleVector, usableWidth);
int nameHeight = (int)(font.LineSpacing * 1.10f) + 5;
int choicesHeight = node.Choices != null
? node.Choices.Length * 40 + 10
: 32;
int panelHeight = margin + nameHeight + (int)Math.Ceiling(textBounds.Y)
+ choicesHeight + margin;
int panelX = (int)((screenWidth - panelWidth) * 0.5f);
int panelY = Math.Max(12,
(int)Math.Floor(screenHeight - panelHeight - 18f));
Rectangle border = new Rectangle(
panelX - 3, panelY - 3, panelWidth + 6, panelHeight + 6);
Rectangle body = new Rectangle(panelX, panelY, panelWidth, panelHeight);
sb.Draw(pixel, border, new Color(134, 86, 18) * 0.92f);
sb.Draw(pixel, body, new Color(4, 3, 2) * 0.96f);
sb.DrawString(font, "Сказитель",
new Vector2(panelX + margin, panelY + margin),
new Color(255, 190, 55), 0f, Vector2.Zero,
1.12f, SpriteEffects.None, 0f);
Vector2 textPosition = new Vector2(
panelX + margin, panelY + margin + nameHeight);
ChatManager.DrawColorCodedStringWithShadow(sb, font, node.Text,
textPosition, Color.White, 0f, Vector2.Zero,
textScaleVector, usableWidth);
float buttonY = textPosition.Y + textBounds.Y + 9f;
if (node.Choices != null)
{
List<Rectangle> rects = new List<Rectangle>();
for (int i = 0; i < node.Choices.Length; i++)
{
string label = node.Choices[i];
Vector2 measured = font.MeasureString(label) * 1.08f;
int buttonWidth = Math.Min((int)usableWidth, (int)measured.X + 28);
Rectangle rect = new Rectangle(
panelX + margin, (int)buttonY, buttonWidth, 32);
rects.Add(rect);
bool hovered = rect.Contains(mouse);
sb.Draw(pixel, rect, hovered
? new Color(132, 84, 18)
: new Color(58, 39, 10) * 0.95f);
sb.DrawString(font, label,
new Vector2(rect.X + 14, rect.Y + 7),
hovered ? new Color(255, 242, 199) : new Color(255, 190, 55),
0f, Vector2.Zero, 1.08f, SpriteEffects.None, 0f);
buttonY += 40f;
}
choiceRects = rects.ToArray();
}
else
{
const string hint = "ЛКМ — продолжить";
Vector2 hintSize = font.MeasureString(hint);
sb.DrawString(font, hint,
new Vector2(panelX + panelWidth - margin - hintSize.X,
buttonY + 4f),
new Color(158, 137, 96), 0f, Vector2.Zero,
1f, SpriteEffects.None, 0f);
choiceRects = Array.Empty<Rectangle>();
}
}
// -------------------- 27 узлов исходного сказа --------------------
static void BuildNodes()
{
if (nodes != null)
return;
DNode N(string text, string[] choices, int[] next, int slide = -1)
=> new DNode { Text = text, Choices = choices, Next = next, Slide = slide };
nodes = new DNode[]
{
/* 0*/ N("Слушай… ты вообще веришь в сказки? Ну, в эти древние байки, которые безумцы на заборах царапают?",
new[] { "Верю.", "Не верю.", "Смотря каким." }, new[] { 1, 2, 3 }),
/* 1*/ N("Ха. Тогда тебе будет легче слушать. Садись поближе — расскажу, время всё равно есть.", null, new[] { 4 }),
/* 2*/ N("Я вот тоже не верил. Пока сам не увидел то, о чём живые вообще говорить боятся. Садись поближе.", null, new[] { 4 }),
/* 3*/ N("Разумный ответ. Но в этой сказке старейшины клялись, что всё — правда. Садись, слушай.", null, new[] { 4 }),
/* 4*/ N("Давным-давно, чёрт знает когда, из абсолютного ниоткуда пришли Они. Семеро. Исполины: рваные плащи из чистой тени, рогатые венцы, а вместо лиц — пустота. И самое дурацкое: им места нигде не хватало. Бесконечность вокруг — а им, видишь ли, тесно!", null, new[] { 5 }, 0),
/* 5*/ N("Ну и они выковали себе место. Свой собственный мир — самый первый, настоящий. Чистый свет, как чистый холст для художника. Сидели в тишине, кайфовали. Безмятежность полная.", null, new[] { 6 }, 1),
/* 6*/ N("Красиво, да?", new[] { "Красиво…", "Скучновато звучит." }, new[] { 7, 8 }),
/* 7*/ N("То-то. Красота — она долго не живёт.", null, new[] { 9 }),
/* 8*/ N("Скука тоже долго не живёт. От бесцельной тишины и тыкают куда не надо.", null, new[] { 9 }),
/* 9*/ N("Один из них — Ваэлор его звали, Сеятель типа — решил поумничать: пальцем по холсту проведу, по приколу. Коснулся. Обычное, блин, касание пальца — а мир возьми, да и отзовись!", null, new[] { 10 }, 2),
/*10*/ N("Ты бы на его месте тыкнул?", new[] { "Тыкнул бы.", "Ни за что.", "Пальцем?! Серьёзно?" }, new[] { 11, 12, 13 }),
/*11*/ N("Вот! Все бы тыкнули. Потому и рассказывать есть что.", null, new[] { 14 }),
/*12*/ N("Так говорят все. Сидя у тёплого костра — все у нас осторожные.", null, new[] { 14 }),
/*13*/ N("Пальцем, пальцем. У таких, как они, палец — что молот судьбы.", null, new[] { 14 }),
/*14*/ N("Из того касания вылезло то, что назвалось жизнью. Настоящей: меняется, дышит, дохнет… И любила она себя больше всего на свете. На создателей своих ей было абсолютно плевать.", null, new[] { 15 }, 3),
/*15*/ N("Чтобы расти, эта тварь начала жрать свет, на котором мир держался. Жрала, пока нити не лопнули — хрусь! — мир пополам, от неба до костей земных. А вырвавшийся свет взбесился и начал забирать самих создателей: один растворился… второй… третий… Остался один-единственный. Последний Архон.", null, new[] { 16 }, 4),
/*16*/ N("Страшно слушать такое?", new[] { "Да.", "Нет.", "Я бы убежал давно." }, new[] { 17, 18, 19 }),
/*17*/ N("И правильно делаешь. Тогда страшно было даже небу.", null, new[] { 20 }),
/*18*/ N("Смелый. Ну-ну. Посмотрим, что скажешь у границы мира.", null, new[] { 20 }),
/*19*/ N("Бежали все. Да бежать было некуда: кругом — их же собственный свет.", null, new[] { 20 }),
/*20*/ N("Последний оказался поумнее братьев: собрал остатки безумного света и укрыл им рану мира — стеной от края до края. Чтобы никто и никогда эту рану не нашёл.", null, new[] { 21 }, 5),
/*21*/ N("А перед тем, как самому исчезнуть, он оставил в свете пророчество: мол, придёт чудак, который вообще не боится смерти. Ни черта не боится! И соткан он будет из тех же нитей света.", null, new[] { 22 }, 6),
/*22*/ N("Веришь в пророчества?", new[] { "Верю.", "Нет.", "В хорошие — верю." }, new[] { 23, 24, 25 }),
/*23*/ N("Тогда слушай внимательно, это про тебя может быть.", null, new[] { 26 }),
/*24*/ N("Я тоже не очень. Но эта штука… живая. Проверено.", null, new[] { 26 }),
/*25*/ N("А плохих пророчеств и нет. Смотря для кого.", null, new[] { 26 }),
/*26*/ N("Этот Последний… он до сих пор ищет. Неугасающую душу, которая не кончается, сколько её ни убивай. …Слушай… а может, это он тебя ищет, а? Ну ладно, чё ты вылупился — спи давай. Завтра день тяжёлый.", null, new[] { -1 }, 7),
};
}
}
// Натуральный spawn блокируется штатными GlobalNPC-хуками. Мы не трогаем
// Main.maxNetNPCs: это глобальная ёмкость NPC, её изменение сломало бы
// самого Сказителя и динамическое появление Гида.
public sealed class CampfireSpawnSuppression : GlobalNPC
{
public override void EditSpawnRate(Player player,
ref int spawnRate, ref int maxSpawns)
{
if (!CampfireScene.SuppressHostileSpawns)
return;
spawnRate = int.MaxValue;
maxSpawns = 0;
}
public override void EditSpawnPool(
IDictionary<int, float> pool, NPCSpawnInfo spawnInfo)
{
if (CampfireScene.SuppressHostileSpawns)
pool.Clear();
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/FirstWorldGenSystem.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using SubworldLibrary;
using Terraria;
using Terraria.GameContent.Generation;
using Terraria.ID;
using Terraria.IO;
using Terraria.ModLoader;
using Terraria.WorldBuilding;
// FirstWorldGenSystem — система генерации Первомира.
// Версия M1.5 «Пепел и трещины, черновик №1».
// Генерация разбита на этапы (passes) — они видны на экране создания мира,
// поэтому подписаны лоровыми фразами.
namespace ShatteredGenesis.Common.Systems
{
public class FirstWorldGenSystem : ModSystem
{
public override void ModifyWorldGenTasks(List<GenPass> tasks, ref double totalWeight)
{
// ЭКСПЕРИМЕНТ: если генерация идёт ВНУТРИ под-мира — не лезем,
// там нужен чистый ванильный список.
if (SubworldSystem.AnyActive())
return;
// ЭКСПЕРИМЕНТ 0.3.4.4: хватаем слепок ванильного конвейера ВМЕСТЕ
// с чертежом (GenVars.configuration уже наполнен движком к этому
// моменту). Порядок критичен: слепок ПЕРЕД расчисткой.
VanillaGenSnapshot.Capture(tasks, GenVars.configuration);
tasks.Clear(); // ванили нет. Совсем.
// ЭТАП 0.4 «Занавес»: подписи проходов ПЕРЕОДЕТЫ под ваниль.
// Игрок видит обычный экран создания мира — и не должен догадаться,
// что генерируется Первомир. (Код генерации не изменён ни на байт.)
tasks.Add(new PassLegacy("Создание ландшафта…", GenerateBase, 300f));
tasks.Add(new PassLegacy("Генерация пещер…", GenerateCaves, 100f));
tasks.Add(new PassLegacy("Размещение рудных жил…", GenerateSeams, 60f));
tasks.Add(new PassLegacy("Завершающие штрихи…", GenerateCrater, 20f));
}
// ---------------- ЭТАП 1. Террейн ----------------
private void GenerateBase(GenerationProgress progress, GameConfiguration config)
{
double surfaceBase = Main.maxTilesY * 0.35;
Main.worldSurface = surfaceBase;
Main.rockLayer = Main.maxTilesY * 0.55;
GenVars.worldSurface = surfaceBase;
GenVars.worldSurfaceHigh = surfaceBase;
GenVars.worldSurfaceLow = surfaceBase + 5;
GenVars.rockLayerHigh = Main.rockLayer;
GenVars.rockLayerLow = Main.rockLayer + 5;
for (int x = 0; x < Main.maxTilesX; x++)
{
double wave = Math.Sin(x * 0.012) * 10.0 + Math.Sin(x * 0.045) * 3.0;
int top = (int)(surfaceBase + wave) + WorldGen.genRand.Next(-1, 2);
for (int y = top; y < Main.maxTilesY; y++)
{
// Верхняя корка — пепел (пахнет мёртвым миром), ниже — камень.
// Это ВРЕМЕННЫЕ ванильные тайлы-заменители; свои спрайты придут позже.
ushort tileType = (y < top + 4) ? TileID.Ash : TileID.Stone;
WorldGen.PlaceTile(x, y, tileType, true, true);
}
if (x % 200 == 0)
progress.Set(x / (double)Main.maxTilesX);
}
progress.Set(1.0);
}
// ---------------- ЭТАП 2. Пещеры ----------------
private void GenerateCaves(GenerationProgress progress, GameConfiguration config)
{
// Количество туннелей масштабируем от площади мира.
int tunnels = (Main.maxTilesX * Main.maxTilesY) / 9000;
for (int i = 0; i < tunnels; i++)
{
int x = WorldGen.genRand.Next(40, Main.maxTilesX - 40);
int y = WorldGen.genRand.Next((int)Main.worldSurface + 10, Main.maxTilesY - 60);
// TileRunner с type=-1 и addTile=false — это экскаватор: выкапывает, а не строит.
WorldGen.TileRunner(
x, y,
WorldGen.genRand.NextDouble() * 4.0 + 3.0, // толщина
WorldGen.genRand.Next(20, 80), // длина «следа»
-1, false,
WorldGen.genRand.NextDouble() * 6 - 3,
WorldGen.genRand.NextDouble() * 6 - 3);
if (i % 10 == 0)
progress.Set(i / (double)tunnels);
}
// Несколько шахт-выходов на поверхность, иначе пещеры будут запечатаны.
for (int i = 0; i < 6; i++)
{
int x = WorldGen.genRand.Next(100, Main.maxTilesX - 100);
int ys = FindSurfaceY(x);
WorldGen.TileRunner(x, ys, 2.5, 60, -1, false, 0.2, 1.0);
}
progress.Set(1.0);
}
// ---------------- ЭТАП 3. Тлеющие жилы ----------------
private void GenerateSeams(GenerationProgress progress, GameConfiguration config)
{
// Топазовые самоцветные блоки светятся тёплым янтарным светом —
// это наши временные «тлеющие швы» в мёртвом камне.
int veins = Main.maxTilesX / 25;
for (int i = 0; i < veins; i++)
{
int x = WorldGen.genRand.Next(30, Main.maxTilesX - 30);
int y = WorldGen.genRand.Next((int)Main.worldSurface + 15, Main.maxTilesY - 50);
int length = WorldGen.genRand.Next(4, 15);
for (int k = 0; k < length; k++)
{
WorldGen.PlaceTile(x, y, TileID.TopazGemspark, true, true);
x += WorldGen.genRand.Next(-1, 2); // жила дрожит в стороны
y += 1; // и в целом ползёт вниз
if (x < 5 || x > Main.maxTilesX - 5 || y > Main.maxTilesY - 10)
break;
}
}
progress.Set(1.0);
}
// ---------------- ЭТАП 4. Кратер и спавн ----------------
private void GenerateCrater(GenerationProgress progress, GameConfiguration config)
{
int craterX = Main.maxTilesX / 2;
int surfaceY = FindSurfaceY(craterX);
int craterRadius = 14;
for (int x = craterX - craterRadius; x <= craterX + craterRadius; x++)
{
for (int y = surfaceY - 8; y <= surfaceY + 5; y++)
{
double dist = Math.Sqrt((x - craterX) * (x - craterX) + (y - surfaceY) * (y - surfaceY));
if (dist < craterRadius * 0.8)
WorldGen.KillTile(x, y, false, false, true);
}
}
Main.spawnTileX = craterX;
Main.spawnTileY = surfaceY + 3;
progress.Set(1.0);
}
// ---------------- Помощники ----------------
// Находит верхний твёрдый тайл в колонке x.
private int FindSurfaceY(int x)
{
for (int y = 0; y < Main.maxTilesY; y++)
{
if (Main.tile[x, y].HasTile && Main.tileSolid[Main.tile[x, y].TileType])
return y;
}
return (int)Main.worldSurface;
}
// ---------------- Атмосфера: приглушённый пепельный свет ----------------
// Этот хук движок вызывает каждый кадр, спрашивая «какого цвета солнечный свет?».
// Мы не ЗАМЕНЯЕМ свет, а тонируем его (умножаем) — так ночь всё равно темнеет.
public override void ModifySunLightColor(ref Color tileColor, ref Color backgroundColor)
{
// В под-мире-обманке свет должен оставаться честно ванильным.
if (SubworldSystem.AnyActive())
return;
tileColor = new Color(
(int)(tileColor.R * 0.82f),
(int)(tileColor.G * 0.88f),
(int)(tileColor.B * 1.00f)); // холоднее, мертвее
backgroundColor = new Color(
(int)(backgroundColor.R * 0.55f),
(int)(backgroundColor.G * 0.60f),
(int)(backgroundColor.B * 0.85f)); // блёклое грязно-синее небо
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/ModAssetHelper.cs
================================================================
using System;
using Terraria.ModLoader;
namespace ShatteredGenesis.Common.Systems
{
// Проверяет наличие необязательного ассета до Request, чтобы отсутствие
// картинки не приводило к ошибке загрузки мода.
internal static class ModAssetHelper
{
public static bool Exists(string relativePath)
{
try
{
Mod mod = ModLoader.GetMod("ShatteredGenesis");
return mod != null && mod.HasAsset(relativePath);
}
catch (Exception)
{
return false;
}
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/StoryDirector.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Graphics; // DrawString под DynamicSpriteFont живёт тут (урок 0.4.23.1)
using Terraria;
using Terraria.GameContent;
using Terraria.ModLoader;
using SubworldLibrary;
using Luminance.Core.Graphics; // ManagedShader / ManagedScreenFilter / ShaderManager
using ShatteredGenesis.Content.Subworlds;
// ============================================================
// StoryDirector — КЛЕЙ ГЛУБОКОЙ ИНТЕГРАЦИИ каркаса истории.
//
// КАНОН 0.4.26 «Глубокая интеграция» (указ режиссёра №0.4.26-2/3):
// * ОСНОВНОЙ мир — ЧИСТАЯ ваниль. Никакой графики, звука,
// фризов и капсул на его генерации. Точка.
// * Нестабильный перехват SoundEngine через RuntimeDetour удалён в G6.
// Звуки стройки подмира закрывает SubworldAudioSilencer: перед
// переходом он запоминает громкость эффектов и окружения, на время
// загрузки удерживает их в нуле и возвращает при входе. Настройки
// на диск не записываются, музыка не затрагивается. Остальные системы:
// - виньетка экрана загрузки → шейдер SleepVeil через
// ShaderManager Luminance (не 24 полоски MagicPixel);
// - искры у костра → частицы Luminance Particle/ParticleManager
// (в мире; на экране загрузки они физически не тикают —
// PartiсleManager живёт в PostUpdateDusts, исходник сверен);
// - сон в финале — фильтр SleepFade через ManagedScreenFilter
// Luminance (мировой экранный конвейер) + чернь;
// - часы у катсцен → Main.GameUpdateCount (аппаратные 60 Гц);
// - геометрия кадра → движковые UI-поля без округлений;
// - перенос текста → ChatManager (системный конвейер чата).
// * DAYBREAK остаётся жёсткой зависимостью других систем мода;
// для подавления звука генерации он больше не используется.
//
// Здесь живёт направление ИСТОРИИ:
// EnterStory() — перенос в подмир-обманку (шов + окно тишины);
// Veil/Sleep-кэш — ОДИН забор эффектов Luminance после их загрузки
// (аудит №3: в отрисовке — ни одного поиска
// по словарям, только .Activate()/Apply);
// DrawStamp() — печать сборки на наших экранах (урок 0.4.25.3);
// занавес шва — ровно на кадры перелёта, не дольше;
// окно тишины — действует только во время перехода в Скорлупу.
// ============================================================
namespace ShatteredGenesis.Common.Systems
{
public class StoryDirector : ModSystem
{
// Число слайдов-«видений», используемых текстовой сценой.
public const int SlideCount = 8;
// Поднят занавес поверх перелёта в подмир (между Raise в
// AnomalyPlayer и Lift в DecoyWorld.OnEnter).
static bool hopCurtain;
// ===================== КЭШ ЭФФЕКТОВ LUMINANCE (аудит №3) =====================
// ОДИН раз забираем ссылки после того, как библиотека собрала
// эффекты (PostSetupContent идёт ПОСЛЕ Luminance.PostSetupContent —
// порядок гарантирован жёсткой зависимостью). В отрисовке
// обращения по строковым словарям ЗАПРЕЩЕНЫ — только готовые ссылки.
public static ManagedShader VeilShader; // пелена экрана загрузки
public static ManagedScreenFilter SleepFilter; // фильтр старого перехода сна
public static ManagedScreenFilter CampDarknessFilter; // абсолютная ночь привала
// Luminance пересобирает .fx АСИНХРОННО (ShaderRecompilationMonitor,
// сверено): на момент PostSetupContent .fxc может ещё не существовать.
// Дозабор — раз в секунду и только пока ссылка пуста: это не
// «каждокадровый перебор», а один словарный запрос в 60 тиков.
static void TryCacheEffects()
{
if (VeilShader == null)
{
try { ShaderManager.TryGetShader("ShatteredGenesis.SleepVeil", out VeilShader); }
catch (Exception) { /* реестр ещё не создан — дождёмся */ }
}
if (SleepFilter == null)
{
try { ShaderManager.TryGetFilter("ShatteredGenesis.SleepFade", out SleepFilter); }
catch (Exception) { /* аналогично */ }
}
if (CampDarknessFilter == null)
{
try { ShaderManager.TryGetFilter("ShatteredGenesis.CampDarkness", out CampDarknessFilter); }
catch (Exception) { /* фильтр ещё компилируется */ }
}
}
public override void PostSetupContent() => TryCacheEffects();
public override void Load()
{
On_Main.Draw += LoadCurtain;
}
public override void Unload()
{
On_Main.Draw -= LoadCurtain;
// Ссылки на эффекты Luminance умирают вместе с нами.
VeilShader = null;
SleepFilter = null;
CampDarknessFilter = null;
}
public static void RaiseCurtain() => hopCurtain = true;
public static void LiftCurtain() => hopCurtain = false;
// ===================== СЮЖЕТНЫЙ ПЕРЕНОС =====================
// Единственная кнопка «бросить игрока в мир истории».
// Зовётся из AnomalyPlayer (первый заход) и из отладочной /sgworld.
public static void EnterStory()
{
// Печать Вселения (канон 0.4.26-4): Скорлупа разрушена ивентом —
// ни автоперенос, ни отладочный вход её больше не откроют.
if (WorldFlagsSystem.ShellDestroyed)
return;
// Запоминаем настройки игрока и глушим эффекты/окружение ДО
// запуска генератора. OnEnter вернёт значения после загрузки.
SubworldAudioSilencer.Begin();
RaiseCurtain(); // чёрный шов перелёта — между мирами, не в мире
try
{
// Enter возвращает false, если переход даже не стартовал.
// В этом случае OnEnterWorld не придёт и звук возвращаем сами.
if (!SubworldSystem.Enter<DecoyWorld>())
{
LiftCurtain();
SubworldAudioSilencer.End();
}
}
catch
{
// Синхронный отказ перехода не должен оставить игру без звука.
LiftCurtain();
SubworldAudioSilencer.End();
throw;
}
}
// ===================== Update-канал: страховки =====================
public override void PostUpdateEverything()
{
// Редкий дозабор эффектов Luminance (аудит №3) — раз в секунду
// по аппаратным апдейтам движка, и только пока кэш пуст.
if ((VeilShader == null || SleepFilter == null || CampDarknessFilter == null)
&& Main.GameUpdateCount % 60 == 0)
{
TryCacheEffects();
}
// Страховка аварийного возврата и тайм-аут живут внутри
// SubworldAudioSilencer; здесь не дублируем его состояние.
}
// ===================== Занавес шва =====================
// ОДНА обязанность: чёрный шов перелёта в подмир. Рисуем ПОСЛЕ
// orig (кадр движка закончен, батч свободен — урок 0.4.25.2).
// Во время генераций не лезем никуда: создание основного мира —
// чистая ваниль; стройка подмира — DecoyWorld.DrawSetup.
void LoadCurtain(On_Main.orig_Draw orig, Main self, GameTime gameTime)
{
orig(self, gameTime);
if (!hopCurtain || WorldGen.generatingWorld)
return;
Main.instance.GraphicsDevice.Clear(Color.Black);
}
// ===================== Печать сборки (урок 0.4.25.3) =====================
// На каждом нашем кадре — строка с версией: по скриншоту сразу
// видно, КАКАЯ сборка реально запущена у режиссёра.
static string stamp;
public static void DrawStamp(SpriteBatch sb, float screenW, float screenH)
{
if (stamp == null)
{
try
{
Mod mod = ModLoader.GetMod("ShatteredGenesis");
stamp = mod != null
? $"Shattered Genesis v{mod.Version}"
: "Shattered Genesis (dev)";
}
catch (Exception)
{
stamp = "Shattered Genesis (dev)";
}
}
var font = FontAssets.MouseText.Value;
const float scale = 0.85f;
Vector2 size = font.MeasureString(stamp) * scale;
Vector2 pos = new Vector2(8f, screenH - size.Y - 8f); // левый нижний угол
sb.DrawString(font, stamp, pos + new Vector2(1f, 1f),
Color.Black * 0.8f, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, stamp, pos,
new Color(140, 140, 140) * 0.55f, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/SubworldAudioSilencer.cs
================================================================
using System;
using Terraria;
using Terraria.Audio;
using Terraria.ModLoader;
namespace ShatteredGenesis.Common.Systems
{
// Заглушает переход и всю загрузку Скорлупы. Значения громкости
// игрока сохраняются в памяти и возвращаются только в OnEnterWorld,
// когда подмир уже полностью создан и игрок действительно вошёл.
// Никакой рефлексии, RuntimeDetour и записи настроек на диск.
public sealed class SubworldAudioSilencer : ModSystem
{
static bool active;
static float savedSoundVolume;
static float savedAmbientVolume;
static DateTime startedAtUtc;
public static bool Active => active;
public static void Begin()
{
if (!active)
{
savedSoundVolume = Main.soundVolume;
savedAmbientVolume = Main.ambientVolume;
startedAtUtc = DateTime.UtcNow;
active = true;
}
ApplyMute();
StopTransientSounds();
}
public static void End()
{
if (!active)
return;
// Сначала убираем уже созданные, но пока неслышимые звуки
// генератора, и лишь затем возвращаем настройки игрока.
StopTransientSounds();
Main.soundVolume = savedSoundVolume;
Main.ambientVolume = savedAmbientVolume;
active = false;
}
public override void PostUpdateEverything()
{
if (!active)
return;
// Некоторые этапы генерации могут трогать общие поля звука.
// Пока переход не закончен, удерживаем эффект и эмбиент в нуле.
ApplyMute();
// Не восстанавливаем звук по Main.gameMenu: Subworld Library
// держит меню открытым ещё ДО начала генерации. Ранний таймер
// как раз возвращал громкость перед установкой блоков.
// Последняя страховка от зависшего стороннего перехода.
if (DateTime.UtcNow - startedAtUtc > TimeSpan.FromMinutes(10))
End();
}
public override void Unload() => End();
static void ApplyMute()
{
Main.soundVolume = 0f;
Main.ambientVolume = 0f;
}
static void StopTransientSounds()
{
try { SoundEngine.StopTrackedSounds(); } catch (Exception) { }
try { SoundEngine.StopAmbientSounds(); } catch (Exception) { }
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/VanillaGenSnapshot.cs
================================================================
using System.Collections.Generic;
using Terraria.WorldBuilding;
// VanillaGenSnapshot — слепок настоящего ванильного конвейера генерации
// вместе с его чертежом.
//
// История эксперимента:
// * 0.3.4.2 «слепок» — задачи без конфигурации → NRE в TerrainPass (строке
// нужен GameConfiguration с чертежом размеров мира).
// * 0.3.4.3 «весь станок» — WorldGen.GenerateWorld внутри под-мира →
// споткнулся о внутреннюю бухгалтерию (WorldGen.cs ~1300).
// * 0.3.4.4 «чертёж и станок» — Snapshot + Config: у Subworld есть родной
// слот Config, а готовый ванильный WorldGenConfiguration доступен в
// GenVars.configuration в момент создания основного мира. Хватаем оба
// одним движением — и Subworld Library сама кормит каждому проходу его
// кусок чертежа по имени.
namespace ShatteredGenesis.Common.Systems
{
public static class VanillaGenSnapshot
{
// Скорлупа всегда использует настоящий ванильный конвейер.
// Отладочный переключатель на ручную копию удалён в 0.4.26.7.
// Слепок проходов и чертёж. null — мир с модом не создавался в сессии.
private static List<GenPass> passes;
private static WorldGenConfiguration config;
public static bool Ready => passes != null && passes.Count > 0 && config != null;
public static int Count => passes?.Count ?? 0;
// Чертёж для слота Subworld.Config — Subworld Library вызывает его
// до генерации и раздаёт проходам их персональные куски.
public static WorldGenConfiguration Config => config;
// Снимаем КОПИЮ списка + ссылку на чертёж. Вызывается из
// FirstWorldGenSystem ПЕРЕД расчисткой задач под Первомир.
public static void Capture(List<GenPass> tasks, WorldGenConfiguration currentConfig)
{
passes = new List<GenPass>(tasks);
config = currentConfig;
}
// Свежая копия слепка + наш финишный проход (спавн, Гид).
public static List<GenPass> BuildTaskList(GenPass finisher)
{
if (!Ready)
throw new System.InvalidOperationException(
"[ShatteredGenesis] Ванильный конвейер не захвачен. " +
"Создай новый основной мир после Build + Reload.");
var list = new List<GenPass>(passes);
if (finisher != null)
list.Add(finisher);
return list;
}
}
}
================================================================
=== ФАЙЛ: Common/Systems/WorldFlagsSystem.cs
================================================================
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;
// WorldFlagsSystem — память мира. Хранит сюжетные флаги,
// которые должны переживать перезаходы:
// IntroDone — интро «Привал» показали (не заставляем смотреть снова)
// InfusionDone — Вселение произошло (игрока выкинуло в Первомир)
// ShellDestroyed — обманка-Скорлупа разрушена ивентом (вход запечатан)
//
// КАНОН 0.4.26-4 «Вечная Скорлупа» — ЛЕЧЕНИЕ КОЛЬЦА (баг, пойманный
// режиссёром): раньше загрузка мира СТИРАЛА память значением из файла.
// Сцена кончалась в подмире (файл основного мира этого ещё не знал) —
// возврат в основной мир откатывал флаг на false, и игрока снова
// бросало на генерацию. Бесконечное кольцо.
// Теперь: флаги МОНОТОННЫ — «истина» никогда не откатывается загрузкой;
// новые миры сбрасываются отдельно (PostWorldGen / отсутствие ключа).
namespace ShatteredGenesis.Common.Systems
{
public class WorldFlagsSystem : ModSystem
{
public static bool IntroDone;
public static bool InfusionDone;
public static bool ShellDestroyed; // Скорлупа разрушена Вселением
// Сохранение мира — просто кладём наши флаги в общий контейнер.
public override void SaveWorldData(TagCompound tag)
{
tag["sg_introDone"] = IntroDone;
tag["sg_infusionDone"] = InfusionDone;
tag["sg_shellDestroyed"] = ShellDestroyed;
}
// Загрузка мира — МОНОТОННО (канон 0.4.26-4): пройденная история
// не откатывается. Если ключей нет вообще — мир впервые входит в
// память (или очень старый сейв) → история начинается с чистого листа.
public override void LoadWorldData(TagCompound tag)
{
bool hasKey = tag.ContainsKey("sg_introDone");
bool intro = tag.TryGet("sg_introDone", out bool i) && i;
bool infusion = tag.TryGet("sg_infusionDone", out bool f) && f;
bool shell = tag.TryGet("sg_shellDestroyed", out bool s) && s;
if (!hasKey)
{
IntroDone = false;
InfusionDone = false;
ShellDestroyed = false;
return;
}
IntroDone = IntroDone || intro;
InfusionDone = InfusionDone || infusion;
ShellDestroyed = ShellDestroyed || shell;
}
// Хук сразу после создания мира: новорождённый мир начинает
// историю с чистого листа (и не может нести чужой Скорлупы).
public override void PostWorldGen()
{
IntroDone = false;
InfusionDone = false;
ShellDestroyed = false;
DedupeGuides(); // баг №3: один Гид, не близнецы
}
// ===================== РАЗРУШЕНИЕ СКОРЛУПЫ =====================
// Зовётся ивентом Вселения: игрока выкинуло в Первомир — обманке
// конец. Печать вечна: ни автоперенос, ни /sgworld её больше
// не откроют. (Физическое стирание файлов подмира подвесим сюда
// на этапе строительства самого ивента — через проверенный API
// библиотеки, по нашему уставу без выдумок.)
public static void DestroyShell()
{
InfusionDone = true;
ShellDestroyed = true;
}
// Санитар: растворяет всех активных Гидов, кроме первого.
// Зовётся из PostWorldGen и страховочно при входе в мир/обманку.
public static void DedupeGuides()
{
// Клиент мультиплеера не правит НПС — придёт синхронизация хоста.
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
bool kept = false;
for (int i = 0; i < Main.maxNPCs; i++)
{
NPC npc = Main.npc[i];
if (!npc.active || npc.type != NPCID.Guide)
continue;
if (!kept)
{
kept = true; // первый по списку остаётся жить
continue;
}
npc.active = false; // дубликат растворяется без звука и дыма
}
}
}
}
================================================================
=== ФАЙЛ: Content/Menus/GenesisMenu.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics; // DrawString под DynamicSpriteFont живёт тут (урок 0.4.23.1)
using Terraria;
using Terraria.GameContent;
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;
// GenesisMenu — кастомное меню ТИТУЛЬНОГО экрана (канон 0.4.25 «Капсула»).
//
// ЧЕСТНАЯ ГРАНИЦА МЕХАНИКИ tML (записано техотделом для летописи):
// ModMenu — это кожа титульного экрана и ТОЛЬКО его. Метод OnSelected()
// срабатывает, когда игра переключает ТИТУЛ на это меню — «моментом
// генерации мира» он быть не может: экраны создания мира рисует ванильный
// Main.DrawMenu, а подмир — Subworld Library. Поэтому архитектура у нас
// трёхслойная, и у каждого слоя свой законный приёмник:
// титул → GenesisMenu (этот файл);
// создание мира → Капсула в StoryDirector (ванильный экран не вызывается);
// стройка подмира → DecoyWorld.DrawSetup (законный override библиотеки).
namespace ShatteredGenesis.Content.Menus
{
public class GenesisMenu : ModMenu
{
public override string DisplayName => "Shattered Genesis";
// Хронометр титула (дыхание костра). Тикает в Update-канале.
static int ticks;
static bool artTried;
static Texture2D art;
// Вызывается, когда ТИТУЛ переключают на наше меню.
public override void OnSelected()
{
ticks = 0;
}
public override void OnDeselected() { }
// Update-канал титула: хронометр живёт здесь, а не в отрисовке.
public override void Update(bool isOnTitleScreen) => ticks++;
// Рисование в ModMenu этой сборки tML: метода Draw(SpriteBatch)
// НЕ СУЩЕСТВУЕТ (урок 0.4.25.1, CS0115; сверено с исходником ветки
// 1.4.4). Законные точки кисти — PreDrawLogo и PostDrawLogo: обе
// дёргаются уже в открытый движком батч (UI-пространство).
// Свой титул рисуем в PostDrawLogo; стандартный лого при этом гасим.
public override bool PreDrawLogo(SpriteBatch spriteBatch, ref Vector2 logoDrawCenter,
ref float logoRotation, ref float logoScale, ref Color drawColor)
=> false; // чужой лого на нашем плакате не нужен
public override void PostDrawLogo(SpriteBatch sb, Vector2 logoDrawCenter,
float logoRotation, float logoScale, Color drawColor)
{
TryArt();
// Батч открыт в UI-масштабе — меряем логические единицы:
// пиксели вьюпорта ÷ Main.UIScale (точная геометрия).
var vp = Main.instance.GraphicsDevice.Viewport;
float ui = Main.UIScale;
if (ui <= 0.01f)
ui = 1f;
int sw = (int)(vp.Width / ui + 1f);
int sh = (int)(vp.Height / ui + 1f);
// Чернь титула. Ванильный фон меню накрывается полностью.
sb.Draw(TextureAssets.MagicPixel.Value,
new Rectangle(-2, -2, sw + 4, sh + 4), Color.Black);
if (art != null)
{
// Вписываем костёр целиком, по центру; дыхание — как у углей.
float k = Math.Min(sw / (float)art.Width, sh / (float)art.Height);
int w = (int)(art.Width * k);
int h = (int)(art.Height * k);
float alpha = 0.55f + 0.2f * (float)Math.Sin(ticks * 0.02f);
sb.Draw(art, new Rectangle((sw - w) / 2, (sh - h) / 2, w, h),
Color.White * alpha);
}
// Титульная вязь — янтарём, там, где обычно сидит лого.
// Горизонтальную середину берём из якоря движка logoDrawCenter.X
// (Main.screenWidth в менюшных кадрах заказчику врал — урок 0.4.24).
var font = FontAssets.DeathText.Value;
DrawLine(sb, font, "SHATTERED GENESIS",
logoDrawCenter.X, logoDrawCenter.Y, 0.55f, new Color(255, 196, 60));
DrawLine(sb, font, "— Расколотый Первомир —",
logoDrawCenter.X, logoDrawCenter.Y + 52f, 0.30f, new Color(196, 150, 38));
StoryDirector.DrawStamp(sb, sw, sh); // печать сборки (урок 0.4.25.3)
}
// Строка по центру кадра с чёрной каймой (читается на любом фоне).
static void DrawLine(SpriteBatch sb, ReLogic.Graphics.DynamicSpriteFont font,
string text, float centerX, float centerY, float scale, Color color)
{
Vector2 size = font.MeasureString(text) * scale;
Vector2 pos = new Vector2(centerX - size.X * 0.5f,
centerY - size.Y * 0.5f);
Color shade = Color.Black * 0.9f;
sb.DrawString(font, text, pos + new Vector2(2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos + new Vector2(-2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos + new Vector2(0f, 2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos + new Vector2(0f, -2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos, color, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
}
// Арт щупаем строго через HasAsset (урок 0.4.21.1: слепой Request
// несуществующего файла на новых сборках tML — фатальная модалка).
static void TryArt()
{
if (artTried)
return;
artTried = true;
if (!ModAssetHelper.Exists("Assets/Slides/CampScene.png"))
return; // нет арта — титул честно чёрный
try
{
art = ModContent.Request<Texture2D>(
"ShatteredGenesis/Assets/Slides/CampScene",
AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception)
{
art = null; // бронежилет
}
}
}
}
================================================================
=== ФАЙЛ: Content/NPCs/StoryTeller.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using Terraria;
using Terraria.ModLoader;
using ShatteredGenesis.Common.Systems;
namespace ShatteredGenesis.Content.NPCs
{
// Сказитель существует только внутри сцены у костра. Это не городской NPC:
// он не открывает ванильное окно чата, не получает урон и не уходит сам.
public sealed class StoryTeller : ModNPC
{
static Texture2D markerTexture;
static bool markerLoadTried;
public override void SetStaticDefaults()
{
Main.npcFrameCount[Type] = 4;
}
public override void SetDefaults()
{
NPC.width = 34;
NPC.height = 54;
NPC.friendly = true;
NPC.dontTakeDamage = true;
NPC.lifeMax = 250;
NPC.damage = 0;
NPC.defense = 0;
NPC.aiStyle = -1;
NPC.noGravity = true;
NPC.noTileCollide = true;
NPC.netAlways = true;
NPC.alpha = 0;
}
public override bool CheckActive() => false;
public override bool CanChat() => false;
public override void AI()
{
if (!CampfireScene.Active)
{
NPC.active = false;
return;
}
if (!CampfireScene.StorytellerShouldLeave)
{
NPC.Bottom = CampfireScene.StorytellerAnchor;
NPC.velocity = Vector2.Zero;
NPC.direction = -1;
NPC.spriteDirection = -1;
NPC.alpha = Math.Max(0, NPC.alpha - 8);
return;
}
// Не исчезаем по щелчку: медленно уходим от костра и растворяемся
// в абсолютной темноте. Движок применит velocity после AI.
NPC.noGravity = true;
NPC.noTileCollide = true;
NPC.velocity = new Vector2(0.48f, 0f);
NPC.direction = 1;
NPC.spriteDirection = 1;
NPC.alpha = Math.Min(255, NPC.alpha + 3);
if (NPC.alpha >= 255)
{
NPC.active = false;
CampfireScene.NotifyStorytellerGone();
}
}
public override void FindFrame(int frameHeight)
{
NPC.frameCounter += CampfireScene.StorytellerShouldLeave ? 0.20 : 0.10;
int frame = (int)NPC.frameCounter % Main.npcFrameCount[Type];
NPC.frame.Y = frame * frameHeight;
}
public override void PostDraw(SpriteBatch spriteBatch, Vector2 screenPos, Color drawColor)
{
if (!CampfireScene.MarkerVisible || NPC.alpha >= 240)
return;
TryLoadMarker();
if (markerTexture == null)
return;
const int frameCount = 4;
int frameHeight = markerTexture.Height / frameCount;
int frame = (int)(Main.GameUpdateCount / 8 % frameCount);
Rectangle source = new Rectangle(0, frame * frameHeight,
markerTexture.Width, frameHeight);
float pulse = 0.88f + 0.12f * (float)Math.Sin(Main.GameUpdateCount * 0.13f);
Vector2 position = NPC.Top - screenPos + new Vector2(0f, -18f);
Vector2 origin = new Vector2(source.Width * 0.5f, source.Height);
// Два прохода дают читаемое янтарное свечение даже в фильтре тьмы.
spriteBatch.Draw(markerTexture, position, source,
new Color(255, 126, 18) * (0.30f * pulse),
0f, origin, 1.32f, SpriteEffects.None, 0f);
spriteBatch.Draw(markerTexture, position, source,
Color.White * pulse,
0f, origin, 1f, SpriteEffects.None, 0f);
}
static void TryLoadMarker()
{
if (markerLoadTried)
return;
markerLoadTried = true;
try
{
markerTexture = ModContent.Request<Texture2D>(
"ShatteredGenesis/Assets/UI/StoryMarker",
AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception)
{
markerTexture = null;
}
}
public override void Unload()
{
markerTexture = null;
markerLoadTried = false;
}
}
}
================================================================
=== ФАЙЛ: Content/Particles/EmberParticle.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Luminance.Core.Graphics;
// ============================================================
// EmberParticle — искра сюжетного костра на движке Luminance.
//
// КАНОН 0.4.26 «Глубокая интеграция»: кустарная мини-система частиц
// CampfireScene (массивы ex/ey/evx…) УДАЛЕНА. Теперь искры — настоящие
// частицы: тикают в PartiсleManager.PostUpdateDusts, рисуются в
// On_Main.DrawDust — то есть ГЛУБОКО внутри мирового пайплайна,
// а не поверх UI нашим кодом.
//
// Текстура — с общего атласа Luminance (Assets/Atlases/StoryParticles):
// AtlasTextureName обязан начинаться с "ИмяМода." — иначе атлас
// её не найдёт (правило библиотеки, сверено с исходником
// Particle.cs / AtlasManager.cs).
//
// БРОНЯ: если атлас не прочитался (атласная JSON-разметка — внешний
// формат), Texture будет null — рисуем ничего, игру не роняем.
// В логе Luminance при этом сам напишет, чего не нашла.
// ============================================================
namespace ShatteredGenesis.Content.Particles
{
public sealed class EmberParticle : Particle
{
// Имя субтекстуры на атласе: шаблон "<Мод>.<Имя>" (канон библиотеки).
public override string AtlasTextureName => "ShatteredGenesis.Ember";
// Искра светится — аддитивное смешивание, как у пламени.
public override BlendState BlendState => BlendState.Additive;
public EmberParticle(Vector2 position, Vector2 velocity, Color color, int lifetimeTicks)
{
Position = position;
Velocity = velocity;
DrawColor = color;
Lifetime = lifetimeTicks; // PartiсleManager сравнивает Time >= Lifetime — в тиках
Scale = Vector2.One;
Rotation = Main.rand.NextFloat(-MathHelper.Pi, MathHelper.Pi);
RotationSpeed = Main.rand.NextFloat(-0.03f, 0.03f);
}
public override void Update()
{
// Искра гаснет: замедляется, чуть тянется вбок от жара,
// и краснеет по мере старения (жар → тлеющий рубин).
Velocity *= 0.965f;
Velocity += new Vector2(
(float)Math.Sin(Time * 0.11 + Position.X) * 0.025f, -0.010f);
Rotation += RotationSpeed;
float lifeLeft = 1f - LifetimeRatio;
Opacity = lifeLeft * lifeLeft; // квадратичное остывание — живее
DrawColor = Color.Lerp(
new Color(120, 34, 8), // почти остывший уголёк
new Color(255, 226, 140), // только что сорвалась с полена
lifeLeft);
Scale = Vector2.One * (0.55f + 0.45f * lifeLeft);
}
public override void Draw(SpriteBatch spriteBatch)
{
// БРОНЯ: атласа нет — не рисуем и не падаем.
if (Texture is null)
return;
base.Draw(spriteBatch);
}
}
}
================================================================
=== ФАЙЛ: Content/Particles/FireflyParticle.cs
================================================================
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Luminance.Core.Graphics;
// ============================================================
// FireflyParticle — ночной светляк над поляной привала.
// Тот же канон 0.4.26 «Глубокая интеграция»: движок частиц
// Luminance (Particle/ParticleManager), атлас StoryParticles.
// Плывёт ленивой синусоидой, мерцает, живёт дольше искры.
// ============================================================
namespace ShatteredGenesis.Content.Particles
{
public sealed class FireflyParticle : Particle
{
public override string AtlasTextureName => "ShatteredGenesis.Firefly";
// Светляк светит сам по себе — аддитив, но слабенький.
public override BlendState BlendState => BlendState.Additive;
readonly float swaySeed;
public FireflyParticle(Vector2 position, Vector2 velocity, int lifetimeTicks)
{
Position = position;
Velocity = velocity;
DrawColor = new Color(214, 255, 150); // зеленовато-янтарный огонёк
Lifetime = lifetimeTicks;
Scale = Vector2.One * 0.8f;
swaySeed = Main.rand.NextFloat(100f);
}
public override void Update()
{
// Ленивый дрейф + мерцание: огонёк то гаснет, то разгорается.
Velocity *= 0.98f;
Velocity += new Vector2(
(float)Math.Sin(Time * 0.045 + swaySeed) * 0.05f,
(float)Math.Cos(Time * 0.037 + swaySeed * 1.7f) * 0.03f);
float shimmer = 0.55f + 0.45f * (float)Math.Sin(Time * 0.14 + swaySeed * 2.3f);
float fade = (float)Math.Sin(LifetimeRatio * Math.PI); // вплыл — догорел
Opacity = shimmer * fade;
}
public override void Draw(SpriteBatch spriteBatch)
{
if (Texture is null)
return; // броня на сломанный атлас
base.Draw(spriteBatch);
}
}
}
================================================================
=== ФАЙЛ: Content/Subworlds/DecoyWorld.cs
================================================================
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using ReLogic.Content;
using ReLogic.Graphics; // урок 0.4.23.1: без него SpriteBatch.DrawString НЕ видит
// перегрузки под DynamicSpriteFont (ReLogic кладёт их сюда) —
// CS1503 «не преобразовать в SpriteFont». CampfireScene знал.
using SubworldLibrary;
using Terraria;
using Terraria.GameContent;
using Terraria.GameContent.Generation;
using Terraria.GameInput;
using Terraria.ID;
using Terraria.IO;
using Terraria.ModLoader;
using Terraria.WorldBuilding;
using ShatteredGenesis.Common.Systems;
using ShatteredGenesis.Content.Tiles;
// DecoyWorld — мир-обманка (subworld). «Скорлупа» Первомира.
//
// v0.3.4 «Слоёвка Строителя» — переработка по ТЗ режиссёра:
// * ландшафт приближен к ванильному: октавы + огибающая долин + холмы-горбы;
// * пещерные устья НЕ появляются у спавна (за 450 блоков от центра — табу);
// * слои: поверхность = уровень моря; подземелье до 500 — земля с камнем;
// ниже — чистый камень;
// * руды — по каноничной ванильной формуле «Shinies»: три глубинных пояса,
// счёт от площади мира (6E-05 / 8E-05 / 0.0002 для меди), силы и длины
// TileRunner как в оригинале.
//
// Скорлупа сохраняется после первого создания. Генератор — только настоящий
// ванильный конвейер, захваченный при создании основного мира.
namespace ShatteredGenesis.Content.Subworlds
{
public class DecoyWorld : Subworld
{
public override int Width => 3200;
public override int Height => 1200;
// КАНОН 0.4.26-4 «Вечная Скорлупа» (указ режиссёра): обманка
// СОЗДАЁТСЯ ОДИН РАЗ и навсегда прикреплена к своему основному миру —
// библиотека держит её файлы рядом с сейвом мира. Никакой
// перегенерации при заходах: кольца «бесконечного создания» нет.
// Живёт до ивента Вселения — там её печать вскроется
// (WorldFlagsSystem.DestroyShell).
public override bool ShouldSave => true;
// Ведём себя как обычный мир (солнце, жидкости, НПС).
public override bool NormalUpdates => true;
// Чертёж для Subworld Library: перед генерацией библиотека спрашивает
// Config — отдаём ванильный WorldGenConfiguration из слепка, и она
// сама кормит каждого прохода его персональным куском чертежа.
public override WorldGenConfiguration Config => VanillaGenSnapshot.Config;
public override List<GenPass> Tasks
{
get
{
// Единственный законный режим: полный список ванильных проходов
// вместе с оригинальным WorldGenConfiguration. Старого ручного
// генератора и скрытого переключателя больше нет.
return VanillaGenSnapshot.BuildTaskList(
new PassLegacy("…готовит привал…", PlaceSceneSpawn, 5f));
}
}
// ===================== ЭКРАН ПЕРЕНОСА (канон 0.4.26) =====================
// Subworld Library зовёт DrawSetup/DrawMenu каждый кадр, пока ЭТОТ
// подмир строится (или читается с диска). Дефолтный DrawMenu просто
// писал ванильный Main.statusText по центру — ту самую «Настройку
// жидкостей… 34%», что режиссёр поймал на скрине.
//
// 0.4.26 «Перенос»: экран — это КАТСЦЕНА ХОДЬБЫ ПО ЛЕСУ. Широкая
// лента-силуэт леса (ForestWalk.png) натянута на высоту кадра и
// ПОЛЗЁТ вбок с походочной тряской; сцена временно немая.
// курсор на загрузке СКРЫТ (указ №4) — вернёт его вход в подмир.
//
// Мы экран ПОДМЕНЯЕМ ЦЕЛИКОМ: своя чернь, свой арт (костёр сна),
// свои строки-«шёпот» по таймеру и живые угольки. Отдельного
// update-хука у экрана загрузки нет — DrawMenu одновременно и
// «часы», и кисть (~60 вызовов в секунду), поэтому таймер диалога
// крутится прямо внутри неё.
// КАНОН 0.4.26-3 (аудит №1): ручной счётчик кадров в отрисовке
// УНИЧТОЖЕН — на 144Гц мониторах он ускорял тайминги катсцены.
// Вместо него аппаратные часы движка Main.GameUpdateCount:
// тикает ровно 60 раз в секунду на ЛЮБОЙ герцовке и FPS.
static uint clockStart; // GameUpdateCount на первом кадре показа
static bool clockStarted; // защёлка «часы заведены»
// Тики текущего показа экрана (1 тик = строго 1/60 сек, всегда).
static int LoadTicks
{
get
{
// Первый кадр показа «прилипает» к часам движка.
if (!clockStarted)
{
clockStarted = true;
clockStart = Main.GameUpdateCount;
}
return (int)(Main.GameUpdateCount - clockStart);
}
}
// Пелена загрузки: сам шейдер кэширован в StoryDirector (аудит №3).
// Здесь — только «предохранитель рисовки»: если Begin с эффектом
// один раз упал — пелену больше не рисуем (лес и чернь остаются).
static bool veilBroken;
// Арт грузим лениво и ОДИН раз — и только если файл существует
// (урок 0.4.21.1: слепой Request отсутствующего ассета на новых
// сборках tML выкидывает фатальную модалку даже из-под try/catch).
static bool loadArtTried;
static Texture2D loadArt;
// Сценарий экрана: (с какого тика вплывает строка, текст).
// ПАУЗЫ В ТИКАХ: 60 тиков ≈ 1 секунда. Тон — дорога к привалу;
// последняя строка предвещает CampArrive («давай тут остановимся»).
static readonly (int at, string text)[] LoadScript = new (int, string)[]
{
(30, "…идём потихоньку. Ночь густая — глаз выколи…"),
(380, "…за деревьями шуршит. Не зверь — ветер. Наверное…"),
(760, "…погодь, впереди просвет. Там поляна — привал устроим…"),
};
// Угольки над «костром сна»: автономная мини-система частиц этого
// экрана. Свой Random — генерацию мира не сдвигаем.
const int LoadEmberCount = 26;
static float[] emberX, emberY, emberV, emberLife;
static readonly Random emberRand = new Random();
// Полный перехват отрисовки экрана подмира: копия дефолтного
// DrawSetup библиотеки, но БЕЗ курсора — кино не должно «светить
// мышью» посреди сна.
// БРОНЯ (урок 0.4.25.2, фатальный вылет у режиссёра): в сборке
// 2026.6 движок может звать меню-отрисовку ВНУТРИ уже открытого
// spriteBatch — повторный Begin кидает InvalidOperationException
// «Begin has been called before End». Ловим и рисуем в чужой
// батч: картинка жива, вылета нет. Свой Begin — закрываем,
// чужой — не трогаем.
public override void DrawSetup(GameTime gameTime)
{
PlayerInput.SetZoom_UI();
Main.instance.GraphicsDevice.Clear(Color.Black); // глухая чернь-основа
// СЕГМЕНТ 1: лесная катсцена (арт + угли + печать) — альфа-батч
// в UI-пространстве.
DrawSegment(() => DrawMenu(gameTime));
// СЕГМЕНТ 2: пелена ночи — один экранный проход шейдером
// Luminance (SleepVeil). Никаких полосатых прямоугольников:
// квад натянут на ВЕСЬ физический вьюпорт, поэтому ни при
// каком UI-масштабе не остаётся ни одной светлой полоски.
float artAlpha = Math.Min(1f, LoadTicks / 45f);
DrawVeil(artAlpha);
// СЕГМЕНТ 3: шёпот дороги поверх пелены.
DrawSegment(() => DrawLoadText());
// Указ №4 (0.4.26): курсор на загрузке СКРЫТ — тут ничего не
// кликают. Его вернёт вход в подмир (AnomalyPlayer/OnEnter).
}
// Законный конвой любого батч-сегмента этого экрана (урок 0.4.25.2:
// движок может звать нас из-под ЧУЖОГО открытого SpriteBatch —
// повторный Begin тогда кидает InvalidOperationException).
// Сегмент сгорает целиком, рисуем сквозь чужой батч; свой Begin
// закрываем, чужой не трогаем никогда.
static void DrawSegment(Action draw)
{
bool ourBatch = true;
try
{
Main.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend,
SamplerState.LinearClamp, DepthStencilState.None,
Main.Rasterizer, null, Main.UIScaleMatrix);
}
catch (InvalidOperationException)
{
ourBatch = false;
}
draw();
if (ourBatch)
Main.spriteBatch.End();
}
// ПЕЛЕНА НОЧИ через Luminance ShaderManager.
// Шейдеру нужен СВОЙ эффект в Begin — отдельный immediate-сегмент
// в ФИЗИЧЕСКИХ пикселях (Matrix.Identity + полный вьюпорт).
static void DrawVeil(float artAlpha)
{
if (veilBroken)
return;
// КЭШ (аудит №3): ссылку на шейдер выдал StoryDirector после
// загрузки эффектов Luminance. Здесь — ни одного словарного
// поиска: только готовая ссылка и чистая рисовка.
var veil = StoryDirector.VeilShader;
if (veil == null)
return; // шейдер ещё компилится — есть чернь и лес
var gd = Main.instance.GraphicsDevice;
try
{
veil.TrySetParameter("screenSize",
new Vector2(gd.Viewport.Width, gd.Viewport.Height));
veil.TrySetParameter("opacity", artAlpha);
veil.TrySetParameter("globalTime", Main.GlobalTimeWrappedHourly);
// Immediate + наш эффект, в ФИЗИЧЕСКИХ пикселях: один квад
// на весь вьюпорт — идеальная укрытость при любом UI-скейле.
Main.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend,
SamplerState.LinearClamp, DepthStencilState.None,
RasterizerState.CullNone, veil.WrappedEffect, Matrix.Identity);
Main.spriteBatch.Draw(TextureAssets.MagicPixel.Value,
new Rectangle(0, 0, gd.Viewport.Width, gd.Viewport.Height), Color.White);
Main.spriteBatch.End();
}
catch (Exception)
{
// Шейдер в бою оказался бракованным — отключаем навсегда,
// игру не роняем. (Батч движка на этой мине не пострадает:
// End либо выполнился, либо Begin не открывался.)
veilBroken = true;
}
}
// Вызывается DrawSetup на каждом кадре стройки подмира.
// base.DrawMenu НЕ зовём нарочно: именно в нём живёт ванильный
// статус-текст. Не позвали — ванили на экране нет совсем.
public override void DrawMenu(GameTime gameTime)
{
// Канон 0.4.25: статус-текст больше НЕ глушим — ванильный
// источник на этом экране не вызывается вообще (base не зовём),
// гасить нечего. Кадр целиком наш. ВРЕМЯ — аппаратное: часы
// движка GameUpdateCount (аудит №1), а не счёт кадров отрисовки.
int t = LoadTicks;
TryLoadArt();
SpriteBatch sb = Main.spriteBatch;
// Геометрия кадра — движковая (аудит №2): UI-трансформ уже в
// батче (SetZoom_UI в DrawSetup), размеры читаем float'ами из
// системных же полей — без ручных «+1 на глаз».
UiFrame(out float sw, out float sh);
// АРТ вплывает из черни за ~0.75 сек (45 аппаратных тиков).
// Меняй сюда, если захочется резче/мягче.
float artA = Math.Min(1f, t / 45f);
if (loadArt != null)
{
// ЛЕСНАЯ ЛЕНТА (канон 0.4.26): натянута по высоте кадра,
// шире кадра — есть ход для панорамы. Лента ползёт вбок
// медленным косинусом, вертикаль пружинит («шаговая
// тряска»). Рисуем ЧИСТЫМИ float: точную обрезку кадра
// делает GPU, поэтому ни одной микро-щели по краям —
// ни при растяжении, ни при смене UI-масштаба.
float stripH = sh;
float stripW = loadArt.Width * (stripH / loadArt.Height);
float range = Math.Max(0f, stripW - sw);
float travel = 0.5f - 0.5f * (float)Math.Cos(t * 0.004f); // 0..1, цикл ~26 сек
float ox = travel * range;
float bob = (float)Math.Sin(t * 0.13f) * 3f;
sb.Draw(loadArt, new Vector2(-ox, bob), null, Color.White * artA,
0f, Vector2.Zero,
new Vector2(stripW / loadArt.Width, stripH / loadArt.Height),
SpriteEffects.None, 0f);
}
DrawLoadEmbers(sb, sw, sh); // угольки летят над сном
StoryDirector.DrawStamp(sb, sw, sh); // печать сборки (урок 0.4.25.3)
}
// СЕГМЕНТ ШЁПОТА (канон 0.4.26-2): строки дороги кладутся ПОСЛЕ
// пелены, чтобы виньетка их не душила (порядок сегментов задан
// в DrawSetup). Свой батч — собственный, через DrawSegment.
static void DrawLoadText()
{
SpriteBatch sb = Main.spriteBatch;
UiFrame(out float sw, out float sh); // движковая геометрия (аудит №2)
int t = LoadTicks; // аппаратные часы (аудит №1)
// ---- ДИАЛОГ ПО ТАЙМЕРУ ----
// Текущая строка = последняя, чей тик уже наступил. Строки не
// стекают в чат и не гаснут: шёпот сменяется на экране сам.
string line = null;
int lineAt = 0;
foreach (var (at, text) in LoadScript)
{
if (t >= at)
{
line = text;
lineAt = at;
}
}
if (line == null)
return; // первые полсекунды — чистая чернь с угольками
// ПРОЗРАЧНОСТЬ СТРОКИ: 40 аппаратных тиков на плавный вплыв.
float a = Math.Min(1f, (t - lineAt) / 40f);
// Последняя строка после вплыва «дышит» бесконечно — экран
// живой, сколько бы мир ни строился (быстро/медленно — неважно).
int lastAt = LoadScript[LoadScript.Length - 1].at;
if (lineAt == lastAt && t - lineAt > 90)
{
a *= 0.72f + 0.28f * (float)Math.Sin((t - lineAt - 90) * 0.04f);
}
DrawCentered(sb, line, sh * 0.80f, a, sw);
}
// КАНОН 0.4.26-3 (аудит №2): LogicalScreen с «+1 на глаз» УНИЧТОЖЕН.
// Геометрию кадра диктуют системные поля движка, читаемые в float:
// вьюпорт делится на тот же Main.UIScale, что движок сам вкладывает
// в трансформ батча (PlayerInput.SetZoom_UI в DrawSetup). Никакого
// округления — никаких микро-щелей: точную обрезку делает GPU.
static void UiFrame(out float sw, out float sh)
{
var vp = Main.instance.GraphicsDevice.Viewport;
float ui = Main.UIScale;
if (ui <= 0.0001f)
ui = 1f; // предохранитель от нуля, а не «запас на пиксель»
sw = vp.Width / ui;
sh = vp.Height / ui;
}
// Ленивая загрузка арта экрана переноса (один пробный заход).
static void TryLoadArt()
{
if (loadArtTried)
return;
loadArtTried = true;
if (!ModAssetHelper.Exists("Assets/Slides/ForestWalk.png"))
return; // арта нет — экран идёт строками на черни, не беда
try
{
loadArt = ModContent.Request<Texture2D>(
"ShatteredGenesis/Assets/Slides/ForestWalk",
AssetRequestMode.ImmediateLoad).Value;
}
catch (Exception)
{
loadArt = null; // бронежилет: без арта экран всё равно работает
}
}
// Строка-шёпот по центру: фирменный янтарь + чёрная кайма по четырём
// осям — читается и поверх арта. scale подобран под DeathText.
static void DrawCentered(SpriteBatch sb, string text, float y, float alpha, float sw)
{
var font = FontAssets.DeathText.Value;
const float scale = 0.42f;
Vector2 size = font.MeasureString(text) * scale;
Vector2 pos = new Vector2(
sw * 0.5f - size.X * 0.5f, y - size.Y * 0.5f);
Color shade = Color.Black * (alpha * 0.9f);
sb.DrawString(font, text, pos + new Vector2(2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos + new Vector2(-2f, 0f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos + new Vector2(0f, 2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos + new Vector2(0f, -2f), shade, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
sb.DrawString(font, text, pos,
new Color(255, 196, 60) * alpha, 0f, Vector2.Zero, scale, SpriteEffects.None, 0f);
}
// [УНИЧТОЖЕНО 0.4.26-2] DrawVignette-полосками: 24 прямоугольника
// MagicPixel с запасами — как раз тот «поверхностный костыль»,
// что запретил режиссёр. Виньетка теперь — шейдер SleepVeil через
// Luminance (сегмент 2 в DrawSetup): один точный проход по кругу,
// ни одной светлой полосы при любом UI-масштабе.
// Угольки: всплывают из «очага» в центральном низу экрана, гуляют
// синусом, догорают рубиновым и рождаются снова. Тикают в DrawMenu.
static void DrawLoadEmbers(SpriteBatch sb, float sw, float sh)
{
if (emberX == null)
{
emberX = new float[LoadEmberCount];
emberY = new float[LoadEmberCount];
emberV = new float[LoadEmberCount];
emberLife = new float[LoadEmberCount];
for (int i = 0; i < LoadEmberCount; i++)
RespawnEmber(i, sw, sh, true); // первый раз — уже в воздухе
}
Texture2D px = TextureAssets.MagicPixel.Value;
for (int i = 0; i < LoadEmberCount; i++)
{
emberLife[i] -= 0.005f; // один уголёк живёт ≈ 3 секунды
emberY[i] -= emberV[i]; // всегда вверх, над остывающим сном
emberX[i] += (float)Math.Sin((LoadTicks + i * 13) * 0.05) * 0.4f;
if (emberLife[i] <= 0f || emberY[i] < sh * 0.08f)
RespawnEmber(i, sw, sh, false);
float life = Math.Max(0f, Math.Min(1f, emberLife[i]));
// Живой кал → тлеющий рубин (по мере угасания), как в сцене.
Color c = Color.Lerp(new Color(196, 90, 20),
new Color(255, 226, 140), life) * (life * 0.85f);
sb.Draw(px, new Rectangle((int)emberX[i], (int)emberY[i], 3, 3), c);
}
}
static void RespawnEmber(int i, float sw, float sh, bool anywhere)
{
// Очаг — центральный низ экрана: там, где в арте стоит костёр.
emberX[i] = sw * 0.38f + (float)emberRand.NextDouble() * sw * 0.24f;
emberY[i] = anywhere
? sh * (0.30f + (float)emberRand.NextDouble() * 0.50f)
: sh * (0.78f + (float)emberRand.NextDouble() * 0.12f);
emberV[i] = 0.6f + (float)emberRand.NextDouble() * 1.2f;
emberLife[i] = 0.4f + (float)emberRand.NextDouble() * 0.6f;
}
// Ручные этапы 0.3.4.1 удалены в 0.4.26.7. Скорлупу строит
// только захваченный конвейер самой Terraria.
// ===================== Финиш: точка появления и площадка сцены =====================
private void PlaceSceneSpawn(GenerationProgress progress, GameConfiguration config)
{
int spawnX = Width / 2;
int surfaceY = FindSurfaceY(spawnX);
Main.spawnTileX = spawnX;
Main.spawnTileY = surfaceY - 3;
// Физическая 3x2 структура привала вшивается прямо в Main.tile.
// Origin костра — нижняя центральная клетка (spawnX, surfaceY-1).
for (int x = spawnX - 4; x <= spawnX + 4; x++)
{
for (int y = surfaceY - 5; y < surfaceY; y++)
WorldGen.KillTile(x, y, false, false, true);
if (!Framing.GetTileSafely(x, surfaceY).HasTile)
WorldGen.PlaceTile(x, surfaceY, TileID.Dirt, true, true);
}
bool campfirePlaced = WorldGen.PlaceObject(
spawnX,
surfaceY - 1,
ModContent.TileType<StoryCampfireTile>(),
true);
if (!campfirePlaced)
Mod.Logger.Warn("[SG] StoryCampfireTile не встал при генерации Скорлупы; " +
"CampfireScene повторит размещение при входе.");
// Гида здесь больше не создаём: в сцене сначала только игрок и
// Сказитель. Настоящий NPCID.Guide приходит из темноты на узле 20.
progress.Set(1.0);
}
// ===================== Помощники =====================
private int FindSurfaceY(int x)
{
for (int y = 0; y < Height; y++)
{
if (Main.tile[x, y].HasTile && Main.tileSolid[Main.tile[x, y].TileType])
return y;
}
return (int)Main.worldSurface;
}
public override void OnLoad()
{
// OnLoad вызывается библиотекой после чтения/создания файла
// Скорлупы. Сцена всегда начинается в одной и той же глубокой ночи.
Main.dayTime = false;
Main.time = 16200;
Main.raining = false;
Main.windSpeedCurrent = 0f;
Main.windSpeedTarget = 0f;
}
public override void OnEnter()
{
// Переход начался: занавес перелёта опускаем (его поднимал
// AnomalyPlayer перед броском сюда — канон 0.4.26).
StoryDirector.LiftCurtain();
clockStarted = false; // лесная катсцена отработала — аппаратные
// часы следующего показа стартуют заново
// Курсор вернёт AnomalyPlayer (требование №4); здесь — дубль-
// страховка, дёшевая.
Main.cursorScale = 1f;
// Баг №3 (канон 0.4.24): на спавне не терпим двух Гидов.
WorldFlagsSystem.DedupeGuides();
// Звук здесь НЕ возвращаем: Subworld Library вызывает OnEnter
// ещё до чтения/создания файла подмира. Восстановление выполняет
// AnomalyPlayer.OnEnterWorld после полной загрузки Скорлупы.
// ВРЕМЯ ТУТ БОЛЬШЕ НЕ СТАВИМ: сцену ночи назначает
// CampfireScene.Begin, а первое утро — её Finish (0.4.26).
}
}
}
================================================================
=== ФАЙЛ: Content/Tiles/CampLog.cs
================================================================
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ObjectData;
namespace ShatteredGenesis.Content.Tiles
{
// Одноклеточный невысокий сегмент бревна. Большой художественный силуэт
// рисует CampfireScene, а этот тайл даёт Terraria законную точку сидения.
public sealed class CampLog : ModTile
{
public override void SetStaticDefaults()
{
Main.tileFrameImportant[Type] = true;
Main.tileNoAttach[Type] = true;
Main.tileLavaDeath[Type] = false;
TileID.Sets.CanBeSatOnForPlayers[Type] = true;
TileID.Sets.CanBeSatOnForNPCs[Type] = true;
TileID.Sets.DisableSmartCursor[Type] = true;
DustType = DustID.WoodFurniture;
AdjTiles = new int[] { TileID.Chairs };
AddMapEntry(new Color(76, 43, 22), Language.GetText("MapObject.Chair"));
TileObjectData.newTile.CopyFrom(TileObjectData.Style1x1);
TileObjectData.newTile.CoordinateHeights = new int[] { 16 };
TileObjectData.addTile(Type);
}
public override void ModifySittingTargetInfo(int i, int j,
ref TileRestingInfo info)
{
info.AnchorTilePosition = new Point(i, j);
info.TargetDirection = i < Main.spawnTileX ? 1 : -1;
info.VisualOffset = new Vector2(0f, 2f);
}
}
}
================================================================
=== ФАЙЛ: Content/Tiles/StoryCampfireTile.cs
================================================================
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;
using Terraria.ObjectData;
namespace ShatteredGenesis.Content.Tiles
{
// Физический сюжетный костёр. Это настоящий 3x2 multitile, который
// сохраняется внутри файла Скорлупы и анимируется штатным TileDrawing.
public sealed class StoryCampfireTile : ModTile
{
public const int FrameHeight = 36;
public const int AnimationFrames = 4;
public override void SetStaticDefaults()
{
Main.tileLighted[Type] = true;
Main.tileFrameImportant[Type] = true;
Main.tileWaterDeath[Type] = true;
Main.tileLavaDeath[Type] = true;
TileID.Sets.Campfire[Type] = true;
TileID.Sets.DisableSmartCursor[Type] = true;
DustType = -1;
AdjTiles = new int[] { TileID.Campfire };
AddMapEntry(new Color(242, 103, 16),
Language.GetText("ItemName.Campfire"));
TileObjectData.newTile.CopyFrom(
TileObjectData.GetTileData(TileID.Campfire, 0));
TileObjectData.newTile.StyleLineSkip = AnimationFrames;
TileObjectData.addTile(Type);
}
public override void AnimateTile(ref int frame, ref int frameCounter)
{
if (++frameCounter >= 6)
{
frameCounter = 0;
frame = (frame + 1) % AnimationFrames;
}
}
public override void AnimateIndividualTile(int type, int i, int j,
ref int frameXOffset, ref int frameYOffset)
{
frameYOffset = Main.tileFrame[type] * FrameHeight;
}
public override void NearbyEffects(int i, int j, bool closer)
{
if (!closer)
Main.SceneMetrics.HasCampfire = true;
}
public override void ModifyLight(int i, int j,
ref float r, ref float g, ref float b)
{
float pulse = 0.92f + 0.08f *
(float)System.Math.Sin(Main.GameUpdateCount * 0.17f);
r = 1.15f * pulse;
g = 0.42f * pulse;
b = 0.08f * pulse;
}
}
}
================================================================
=== ФАЙЛ: ShatteredGenesis.cs
================================================================
using Terraria.ModLoader;
// Это главный класс мода — его «сердце».
// tModLoader находит этот класс сам: он должен называться так же, как папка мода,
// и наследоваться от Mod.
//
// Пока он пустой — и это нормально: вся настоящая работа делается
// в других файлах (системы, игроки, предметы). Сюда мы вернёмся,
// когда нужно будет регистрировать глобальные вещи мода.
namespace ShatteredGenesis
{
public class ShatteredGenesis : Mod
{
}
}
================================================================
=== ФАЙЛ: ShatteredGenesis.csproj
================================================================
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<!-- Это файл проекта. Не редактируй его — tModLoader сам всё сделает через него.
Важно: строка Import указывает на файл tModLoader.targets, который появляется
в папке ModSources после первого запуска tModLoader. -->
<Import Project="..\tModLoader.targets" />
<PropertyGroup>
<!-- Внутреннее имя мода — так его видит игра. Меняется только переименованием папки. -->
<AssemblyName>ShatteredGenesis</AssemblyName>
<RootNamespace>ShatteredGenesis</RootNamespace>
<!-- Современный tModLoader работает на .NET 8. Если при сборке будет ошибка
про версию net8.0 — напиши мне, подгоню под твою версию tML. -->
<TargetFramework>net8.0</TargetFramework>
<PlatformTarget>AnyCPU</PlatformTarget>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
================================================================
=== ФАЙЛ: build.txt
================================================================
displayName = Shattered Genesis — Расколотый Первомир
author = Shattered Genesis Team
version = 0.4.26.11
# Жёсткие зависимости (указ режиссёра 0.4.26-2): библиотеки на полную мощность.
# Luminance — шейдеры/фильтры/частицы; DAYBREAK — слой системных хуков;
# SubworldLibrary — подмиры. Без них игра откажется грузить мод — и это правильно.
modReferences = Luminance, Daybreak, SubworldLibrary
================================================================
=== ФАЙЛ: description.txt
================================================================
Ты думал, что начал обычную игру.
Ты рубил деревья, разговаривал с Гидом, слушал птиц.
А потом увидел свет у края мира — и коснулся его.
Shattered Genesis — тотальная конверсия: новый мир, новая история,
двенадцать тронов и тайна, которую не знает никто.
Ранняя сборка 0.1 — каркас мира. Содержимое появляется глава за главой.