XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

DeathMod

【Terraria】DeathMod (now aka Soul Harvest)

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/DeathMod

修复服务器领域收割并重做待斩刀光

b237181
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

9 个文件 +350 -172
Modified Common/MyPlayer.cs +127 -43
@@ -40,6 +40,9 @@ public class MyPlayer : ModPlayer
40 40 private ulong deathWingTrailUpdateTick = ulong.MaxValue;
41 41 private int deathDomainTimer;
42 42 private int deathDomainVolleyCounter;
43 private readonly Dictionary<int, PendingDeathDomainCut> deathDomainPendingCuts = [];
44
45 private readonly record struct PendingDeathDomainCut(float Rotation, int ProjectileIndex);
43 46
44 47 public override void Initialize()
45 48 {
@@ -56,6 +59,7 @@ public class MyPlayer : ModPlayer
56 59 ActiveDeathNecklace = null;
57 60 deathDomainTimer = 0;
58 61 deathDomainVolleyCounter = 0;
62 deathDomainPendingCuts.Clear();
59 63 }
60 64
61 65 public override void ResetEffects()
@@ -344,10 +348,14 @@ public class MyPlayer : ModPlayer
344 348
345 349 private void UpdateDeathDomain()
346 350 {
347 DeathNecklace? necklace = ActiveDeathNecklace;
351 // UpdateAccessory normally supplies this reference. The direct equipment scan
352 // keeps dedicated-server attacks stable on ticks where accessory recalculation
353 // did not expose the ModItem instance before PostUpdate.
354 DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace();
348 355 if (!DeathDomainEnabled || necklace is null || Player.dead)
349 356 {
350 357 deathDomainTimer = 0;
358 ClearDeathDomainTelegraphs();
351 359 return;
352 360 }
353 361
@@ -356,51 +364,145 @@ public class MyPlayer : ModPlayer
356 364 if (Main.netMode == NetmodeID.MultiplayerClient)
357 365 return;
358 366
359 Item heldItem = Player.HeldItem;
360 NormalSickle? sickle = heldItem.ModItem as NormalSickle;
361 bool isLegacyDeath = heldItem.ModItem is LegacyDeath;
362 if (sickle is null && !isLegacyDeath)
367 if (Player.HeldItem.ModItem is not (NormalSickle or LegacyDeath))
363 368 {
364 deathDomainTimer = 0;
369 ClearDeathDomainTelegraphs();
365 370 return;
366 371 }
367 372
373 List<NPC> targets = FindDeathDomainTargets(necklace);
374 float visualMastery = MathHelper.Clamp((necklace.MasteryScore - 5f) / 65f, 0f, 1f);
375 UpdateDeathDomainTelegraphs(targets, necklace, visualMastery);
368 376 deathDomainTimer++;
369 377 if (deathDomainTimer < necklace.SpawnInterval)
370 378 return;
371 379 deathDomainTimer = 0;
372 380
373 List<NPC> targets = FindDeathDomainTargets(necklace);
374 381 if (targets.Count == 0)
382 {
383 ClearDeathDomainTelegraphs();
375 384 return;
385 }
376 386
377 387 deathDomainVolleyCounter++;
378 388 bool requiem = necklace.IsFullScreenDomain && deathDomainVolleyCounter % 4 == 0;
379 float visualMastery = MathHelper.Clamp((necklace.MasteryScore - 5f) / 65f, 0f, 1f);
380 389 foreach (NPC target in targets)
381 390 {
391 float rotation = deathDomainPendingCuts.TryGetValue(target.whoAmI, out PendingDeathDomainCut pending)
392 ? pending.Rotation
393 : Main.rand.NextFloat(MathHelper.TwoPi);
394 SpawnDeathDomainHarvestSlash(target, necklace, visualMastery, requiem, rotation);
395
396 // Resolve damage after broadcasting the visual so a lethal first cut is
397 // still shown to every client.
398 ApplyDeathDomainHarvest(target, necklace);
399 }
400 ClearDeathDomainTelegraphs();
401 }
402
403 private void UpdateDeathDomainTelegraphs(List<NPC> targets, DeathNecklace necklace, float visualMastery)
404 {
405 HashSet<int> activeTargets = [];
406 foreach (NPC target in targets)
407 activeTargets.Add(target.whoAmI);
408
409 List<int> staleTargets = [];
410 foreach (int npcIndex in deathDomainPendingCuts.Keys)
411 if (!activeTargets.Contains(npcIndex))
412 staleTargets.Add(npcIndex);
413 foreach (int npcIndex in staleTargets)
414 RemoveDeathDomainTelegraph(npcIndex);
415
416 int remainingFrames = Math.Max(1, necklace.SpawnInterval - deathDomainTimer);
417 foreach (NPC target in targets)
418 {
419 if (deathDomainPendingCuts.ContainsKey(target.whoAmI))
420 continue;
421
422 float rotation = Main.rand.NextFloat(MathHelper.TwoPi);
382 423 int projectileIndex = Projectile.NewProjectile(
383 Player.GetSource_Misc("DeathMod:DeathDomain"),
424 Player.GetSource_Misc("DeathMod:DeathDomainTelegraph"),
384 425 target.Center,
385 426 Vector2.Zero,
386 ModContent.ProjectileType<DeathDomainHarvestSlashProjectile>(),
427 ModContent.ProjectileType<DeathDomainHarvestTelegraphProjectile>(),
387 428 0,
388 429 0f,
389 430 Player.whoAmI,
390 431 target.whoAmI,
391 necklace.SlashCount,
392 visualMastery + (requiem ? 2f : 0f));
432 remainingFrames,
433 visualMastery);
393 434 if (projectileIndex >= 0 && projectileIndex < Main.maxProjectiles)
394 435 {
395 Projectile visual = Main.projectile[projectileIndex];
396 visual.rotation = Main.rand.NextFloat(MathHelper.TwoPi);
397 visual.scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f);
398 visual.netUpdate = true;
436 Projectile telegraph = Main.projectile[projectileIndex];
437 telegraph.rotation = rotation;
438 telegraph.scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f);
439 SynchronizeDeathDomainVisual(projectileIndex);
399 440 }
441 deathDomainPendingCuts[target.whoAmI] = new PendingDeathDomainCut(rotation, projectileIndex);
442 }
443 }
400 444
401 // Resolve damage after broadcasting the visual so a lethal first cut is
402 // still shown to every client.
403 ApplyDeathDomainHarvest(target, necklace, sickle);
445 private void SpawnDeathDomainHarvestSlash(NPC target, DeathNecklace necklace, float visualMastery, bool requiem, float rotation)
446 {
447 int projectileIndex = Projectile.NewProjectile(
448 Player.GetSource_Misc("DeathMod:DeathDomain"),
449 target.Center,
450 Vector2.Zero,
451 ModContent.ProjectileType<DeathDomainHarvestSlashProjectile>(),
452 0,
453 0f,
454 Player.whoAmI,
455 target.whoAmI,
456 necklace.SlashCount,
457 visualMastery + (requiem ? 2f : 0f));
458 if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
459 return;
460
461 Projectile visual = Main.projectile[projectileIndex];
462 visual.rotation = rotation;
463 visual.scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f);
464 SynchronizeDeathDomainVisual(projectileIndex);
465 }
466
467 private static void SynchronizeDeathDomainVisual(int projectileIndex)
468 {
469 if (Main.netMode != NetmodeID.Server)
470 return;
471
472 // Domain visuals are short lived, so send the complete state in their spawn
473 // tick rather than waiting for the periodic projectile synchronization pass.
474 NetMessage.SendData(MessageID.SyncProjectile, -1, -1, null, projectileIndex);
475 Main.projectile[projectileIndex].netUpdate = false;
476 }
477
478 private void ClearDeathDomainTelegraphs()
479 {
480 if (deathDomainPendingCuts.Count == 0)
481 return;
482
483 foreach (PendingDeathDomainCut pending in deathDomainPendingCuts.Values)
484 KillDeathDomainTelegraph(pending.ProjectileIndex);
485 deathDomainPendingCuts.Clear();
486 }
487
488 private void RemoveDeathDomainTelegraph(int npcIndex)
489 {
490 if (!deathDomainPendingCuts.Remove(npcIndex, out PendingDeathDomainCut pending))
491 return;
492 KillDeathDomainTelegraph(pending.ProjectileIndex);
493 }
494
495 private void KillDeathDomainTelegraph(int projectileIndex)
496 {
497 if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles)
498 return;
499
500 Projectile projectile = Main.projectile[projectileIndex];
501 if (projectile.active
502 && projectile.owner == Player.whoAmI
503 && projectile.ModProjectile is DeathDomainHarvestTelegraphProjectile)
504 {
505 projectile.Kill();
404 506 }
405 507 }
406 508
@@ -412,13 +514,16 @@ public class MyPlayer : ModPlayer
412 514 float radiusSquared = radius * radius;
413 515 foreach (NPC npc in Main.ActiveNPCs)
414 516 {
415 if (npc.friendly || npc.dontTakeDamage || npc.lifeMax <= 5 || !npc.CanBeChasedBy(Player))
517 // chaseable is intentionally not used: vanilla NPCs toggle it during
518 // movement and transition states, which made dedicated-server harvests
519 // appear to skip otherwise valid enemies from one cycle to the next.
520 if (npc.friendly || npc.immortal || npc.dontTakeDamage || npc.lifeMax <= 5 || npc.life <= 0)
416 521 continue;
417 522
418 523 NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].active
419 524 ? Main.npc[npc.realLife]
420 525 : npc;
421 if (target.friendly || target.dontTakeDamage || target.lifeMax <= 5
526 if (target.friendly || target.immortal || target.dontTakeDamage || target.lifeMax <= 5 || target.life <= 0
422 527 || selectedTargets.Contains(target.whoAmI)
423 528 || Vector2.DistanceSquared(Player.Center, target.Center) > radiusSquared)
424 529 {
@@ -431,7 +536,7 @@ public class MyPlayer : ModPlayer
431 536 return targets;
432 537 }
433 538
434 private void ApplyDeathDomainHarvest(NPC target, DeathNecklace necklace, NormalSickle? sickle)
539 private void ApplyDeathDomainHarvest(NPC target, DeathNecklace necklace)
435 540 {
436 541 bool bossOrBossPart = target.boss
437 542 || NPCID.Sets.ShouldBeCountedAsBoss[target.type]
@@ -444,20 +549,6 @@ public class MyPlayer : ModPlayer
444 549
445 550 for (int slash = 0; slash < necklace.SlashCount && target.active && target.life > 0; slash++)
446 551 {
447 if (sickle is not null)
448 {
449 MyGlobalProjectile.ApplyStatusEffects(
450 target,
451 Player.whoAmI,
452 sickle.LifeStealLevel,
453 sickle.OnHitDebuffType,
454 sickle.OnHitDebuffDuration,
455 sickle.FatedStacksPerHit,
456 sickle.FatedMaximumStacks,
457 sickle.FatedDurationFrames);
458 }
459
460 int lifeBeforeHit = target.life;
461 552 NPC.HitInfo harvestHit = new()
462 553 {
463 554 Damage = slashDamage,
@@ -470,13 +561,6 @@ public class MyPlayer : ModPlayer
470 561 target.StrikeNPC(harvestHit, fromNet: false, noPlayerInteraction: false);
471 562 if (Main.netMode == NetmodeID.Server)
472 563 NetMessage.SendStrikeNPC(target, in harvestHit);
473
474 if (sickle is not null)
475 {
476 int actualDamage = Math.Min(Math.Max(0, lifeBeforeHit), slashDamage);
477 int healedLife = Player.GetModPlayer<MyPlayer>().TryLifeSteal(actualDamage, sickle.LifeStealLevel);
478 LifeStealVisuals.Spawn(target.Center, Player.whoAmI, sickle.LifeStealLevel, healedLife);
479 }
480 564 }
481 565 }
482 566
Modified Localization/en-US.hjson +2 -1
@@ -3,7 +3,7 @@ Mods: {
3 3 Items: {
4 4 DeathNecklace: {
5 5 DisplayName: Death Necklace
6 Tooltip: Equip to toggle a death domain; while holding a DeathMod sickle, multislash harvests strike every enemy inside
6 Tooltip: Equip to toggle a death domain; multislash harvests strike every enemy inside and apply only Death Mark
7 7 }
8 8
9 9 NormalSickle: {
@@ -111,6 +111,7 @@ Mods: {
111 111
112 112 Projectiles: {
113 113 DomainSoulBladeProjectile.DisplayName: Death Domain Echo
114 DeathDomainHarvestTelegraphProjectile.DisplayName: Pending Cut Mark
114 115 DeathDomainHarvestSlashProjectile.DisplayName: Death Domain Harvest Cut
115 116 BoneWispProjectile.DisplayName: Bone Wisp
116 117 BloodCrescentProjectile.DisplayName: Blood Covenant Crescent
Modified Localization/zh-Hans.hjson +2 -1
@@ -3,7 +3,7 @@ Mods: {
3 3 Items: {
4 4 DeathNecklace: {
5 5 DisplayName: 死亡项链
6 Tooltip: 佩戴后可开关死亡领域;手持本模组镰刀时,以多重收割斩同时处决领域内所有敌人
6 Tooltip: 佩戴后可开关死亡领域;多重收割斩同时攻击领域内所有敌人,仅施加死亡标记
7 7 }
8 8
9 9 NormalSickle: {
@@ -111,6 +111,7 @@ Mods: {
111 111
112 112 Projectiles: {
113 113 DomainSoulBladeProjectile.DisplayName: 死亡领域回响
114 DeathDomainHarvestTelegraphProjectile.DisplayName: 待斩刻线
114 115 DeathDomainHarvestSlashProjectile.DisplayName: 死亡领域收割斩
115 116 BoneWispProjectile.DisplayName: 亡骨幽光
116 117 BloodCrescentProjectile.DisplayName: 血契弧刃
Modified Projectiles/DeathDomainHarvestSlashProjectile.cs +74 -123
@@ -11,25 +11,22 @@ using Terraria.ModLoader;
11 11 namespace DeathMod.Projectiles;
12 12
13 13 /// <summary>
14 /// A purely visual, networked harvest cut. One projectile renders every staggered
15 /// slash for a target so a crowded domain does not need dozens of damaging entities.
16 /// Damage is resolved authoritatively by <see cref="Common.MyPlayer"/>.
14 /// A straight, double-pointed execution blade: crimson at the edge and completely
15 /// black through its core. Damage remains server-authoritative in Common.MyPlayer.
17 16 /// </summary>
18 17 public class DeathDomainHarvestSlashProjectile : ModProjectile
19 18 {
20 private const int SlashActiveFrames = 18;
19 private const int SlashActiveFrames = 16;
21 20 private const int SlashDelayFrames = 5;
22 private const int EndLingerFrames = 8;
21 private const int EndLingerFrames = 7;
22 private const int MaximumSlashCount = 5;
23 23
24 24 private int TargetIndex => (int)Projectile.ai[0];
25 private int SlashCount => Math.Clamp((int)Projectile.ai[1], 1, DeathNecklaceMaxSlashCount);
25 private int SlashCount => Math.Clamp((int)Projectile.ai[1], 1, MaximumSlashCount);
26 26 private bool IsRequiem => Projectile.ai[2] >= 2f;
27 27 private float Mastery => MathHelper.Clamp(IsRequiem ? Projectile.ai[2] - 2f : Projectile.ai[2], 0f, 1f);
28 28 private int TotalLifetime => SlashActiveFrames + (SlashCount - 1) * SlashDelayFrames + EndLingerFrames;
29 29
30 // Avoid a content dependency from the visual layer back into the item type.
31 private const int DeathNecklaceMaxSlashCount = 5;
32
33 30 public override string Texture => "Terraria/Images/Projectile_0";
34 31
35 32 public override void SetStaticDefaults()
@@ -59,8 +56,6 @@ public class DeathDomainHarvestSlashProjectile : ModProjectile
59 56
60 57 public override void SendExtraAI(BinaryWriter writer)
61 58 {
62 // Rotation and scale are selected from the victim on the authoritative side;
63 // vanilla projectile sync does not guarantee either visual field.
64 59 writer.Write(Projectile.rotation);
65 60 writer.Write(Projectile.scale);
66 61 }
@@ -88,7 +83,7 @@ public class DeathDomainHarvestSlashProjectile : ModProjectile
88 83 continue;
89 84
90 85 emittedMask |= bit;
91 SpawnSlashBurst(slash);
86 SpawnExecutionBurst();
92 87 }
93 88 Projectile.localAI[0] = emittedMask;
94 89 }
@@ -99,129 +94,90 @@ public class DeathDomainHarvestSlashProjectile : ModProjectile
99 94 for (int slash = 0; slash < SlashCount; slash++)
100 95 {
101 96 float progress = (age - slash * SlashDelayFrames) / (float)SlashActiveFrames;
102 if (progress <= 0f || progress >= 1.12f)
97 if (progress <= 0f || progress >= 1f)
103 98 continue;
104
105 // Three curved echoes make the cut feel like a blade moving through space,
106 // instead of a straight MagicPixel beam.
107 DrawSlash(slash, progress - 0.18f, 0.12f, echo: true);
108 DrawSlash(slash, progress - 0.11f, 0.22f, echo: true);
109 DrawSlash(slash, progress - 0.055f, 0.38f, echo: true);
110 DrawSlash(slash, progress, 1f, echo: false);
99 DrawExecutionBlade(progress, slash);
111 100 }
112 101 return false;
113 102 }
114 103
115 private void DrawSlash(int slash, float progress, float opacity, bool echo)
104 private void DrawExecutionBlade(float progress, int slash)
116 105 {
117 if (progress <= 0f || progress >= 1f)
118 return;
119
120 float smoothHead = Smooth01(progress / 0.48f);
121 float smoothTail = Smooth01((progress - 0.30f) / 0.70f);
122 if (smoothHead <= smoothTail)
123 return;
124
125 float pulse = (float)Math.Sin(MathHelper.Pi * progress);
126 float brightness = pulse * opacity;
127 float widthScale = 0.86f + Mastery * 0.48f + (IsRequiem ? 0.22f : 0f);
128 float spread = slash - (SlashCount - 1) * 0.5f;
129 float rotation = Projectile.rotation
130 + spread * 0.31f
131 + DeterministicWave(slash, 1.71f) * 0.17f;
132 int bendDirection = ((Projectile.identity + slash) & 1) == 0 ? 1 : -1;
133 float sweep = MathHelper.Lerp(-24f, 24f, Smooth01(progress)) * Projectile.scale;
134
135 const int segments = 24;
136 Vector2 previous = GetArcPoint(slash, smoothTail, rotation, bendDirection, sweep);
137 for (int segment = 1; segment <= segments; segment++)
138 {
139 float t = MathHelper.Lerp(smoothTail, smoothHead, segment / (float)segments);
140 Vector2 current = GetArcPoint(slash, t, rotation, bendDirection, sweep);
141 float along = (t - smoothTail) / Math.Max(0.001f, smoothHead - smoothTail);
142 float taper = 0.30f + 0.70f * (float)Math.Sin(MathHelper.Pi * MathHelper.Clamp(along, 0f, 1f));
143
144 Color outer = IsRequiem
145 ? Color.Lerp(new Color(64, 0, 52, 0), new Color(130, 20, 220, 0), along)
146 : Color.Lerp(new Color(45, 0, 15, 0), new Color(145, 0, 36, 0), along);
147 Color blade = IsRequiem
148 ? Color.Lerp(new Color(230, 16, 70, 0), new Color(85, 225, 255, 0), along)
149 : Color.Lerp(new Color(170, 0, 30, 0), new Color(255, 48, 95, 0), along);
150 Color core = Color.Lerp(new Color(255, 112, 142, 0), Color.White, 0.58f + 0.32f * along);
151
152 DrawSegment(previous, current, outer * (brightness * 0.72f), 20f * widthScale * taper);
153 DrawSegment(previous, current, blade * brightness, 10f * widthScale * taper);
154 if (!echo)
155 DrawSegment(previous, current, core * (brightness * 0.92f), 3.2f * widthScale * taper);
156
157 previous = current;
158 }
106 float appear = Smooth01(progress / 0.16f);
107 float disappear = 1f - Smooth01((progress - 0.52f) / 0.48f);
108 float strength = appear * disappear;
109 float impact = (float)Math.Pow(Math.Max(0f, Math.Sin(MathHelper.Pi * progress)), 0.58f);
110 float repetitionPulse = 0.94f + 0.06f * (float)Math.Sin((slash + 1) * 2.7f);
111 float length = (126f + Mastery * 76f + (IsRequiem ? 46f : 0f)) * Projectile.scale * repetitionPulse;
112 float width = (28f + Mastery * 18f + (IsRequiem ? 12f : 0f)) * Projectile.scale * impact;
113 Vector2 center = Projectile.Center - Main.screenPosition;
114
115 // Three passes over the exact same tapered silhouette produce one integrated
116 // blade rather than a bundle of separate straight laser strips.
117 DrawTaperedBladeLayer(center, length, width * 1.52f,
118 new Color(92, 0, 22, 0) * (strength * 0.54f));
119 DrawTaperedBladeLayer(center, length, width,
120 new Color(255, 8, 48, 255) * strength);
121 DrawTaperedBladeLayer(center, length * 0.985f, width * 0.62f,
122 Color.Black * Math.Min(1f, strength * 1.32f));
123
124 Vector2 direction = Projectile.rotation.ToRotationVector2();
125 Vector2 normal = direction.RotatedBy(MathHelper.PiOver2);
126 float endpointDistance = length * 0.5f;
127 float spark = (8f + Mastery * 5f) * Projectile.scale * strength;
128 DrawSegment(center + direction * endpointDistance - normal * spark,
129 center + direction * endpointDistance + normal * spark,
130 new Color(255, 12, 52, 0) * strength,
131 1.6f + Mastery);
132 DrawSegment(center - direction * endpointDistance - normal * spark,
133 center - direction * endpointDistance + normal * spark,
134 new Color(255, 12, 52, 0) * strength,
135 1.6f + Mastery);
136 }
159 137
160 if (echo)
138 private void DrawTaperedBladeLayer(Vector2 center, float length, float maximumWidth, Color color)
139 {
140 if (maximumWidth <= 0.05f)
161 141 return;
162 142
163 Vector2 head = GetArcPoint(slash, smoothHead, rotation, bendDirection, sweep);
164 Vector2 beforeHead = GetArcPoint(slash, Math.Max(smoothTail, smoothHead - 0.018f), rotation, bendDirection, sweep);
165 Vector2 tangent = (head - beforeHead).SafeNormalize(Vector2.UnitX);
166 Vector2 normal = tangent.RotatedBy(MathHelper.PiOver2);
167 float sparkLength = (13f + Mastery * 8f + (IsRequiem ? 7f : 0f)) * Projectile.scale * pulse;
168 DrawSegment(head - normal * sparkLength, head + normal * sparkLength, new Color(255, 30, 80, 0) * brightness, 4.6f * widthScale);
169 DrawSegment(head - normal * sparkLength * 0.62f, head + normal * sparkLength * 0.62f, Color.White * brightness, 1.5f * widthScale);
170
171 // A second, shorter crescent rides beside the main edge and gives the harvest
172 // slash a forged-blade silhouette rather than a single luminous stroke.
173 float edgeOffset = bendDirection * (5f + Mastery * 3f) * Projectile.scale;
174 Vector2 edgePrevious = GetArcPoint(slash, smoothTail + (smoothHead - smoothTail) * 0.18f, rotation, bendDirection, sweep + edgeOffset);
175 for (int segment = 1; segment <= 12; segment++)
143 Vector2 direction = Projectile.rotation.ToRotationVector2();
144 Vector2 start = center - direction * length * 0.5f;
145 const int pieces = 36;
146 for (int piece = 0; piece < pieces; piece++)
176 147 {
177 float t = MathHelper.Lerp(
178 smoothTail + (smoothHead - smoothTail) * 0.18f,
179 smoothHead - (smoothHead - smoothTail) * 0.10f,
180 segment / 12f);
181 Vector2 current = GetArcPoint(slash, t, rotation, bendDirection, sweep + edgeOffset);
182 DrawSegment(edgePrevious, current, new Color(255, 44, 110, 0) * (brightness * 0.75f), 2.2f * widthScale);
183 edgePrevious = current;
148 float t0 = piece / (float)pieces;
149 float t1 = (piece + 1f) / pieces;
150 float middle = (t0 + t1) * 0.5f;
151 float taper = (float)Math.Pow(Math.Max(0f, Math.Sin(MathHelper.Pi * middle)), 0.64f);
152 DrawSegment(
153 start + direction * (length * t0),
154 start + direction * (length * t1 + 0.8f),
155 color,
156 Math.Max(0.15f, maximumWidth * taper));
184 157 }
185 158 }
186 159
187 private Vector2 GetArcPoint(int slash, float t, float rotation, int bendDirection, float sweep)
188 {
189 float scale = Projectile.scale;
190 float length = (104f + Mastery * 62f + (IsRequiem ? 36f : 0f)) * scale;
191 float bend = (38f + Mastery * 22f + (IsRequiem ? 10f : 0f)) * scale;
192 float seedWave = DeterministicWave(slash, 4.19f);
193 float x = MathHelper.Lerp(-length * 0.5f, length * 0.5f, t);
194 float y = bendDirection * (-(float)Math.Sin(MathHelper.Pi * t) * bend
195 + (float)Math.Sin(MathHelper.TwoPi * t + seedWave) * 3.5f * scale);
196 Vector2 local = new(x, y + sweep);
197 return Projectile.Center - Main.screenPosition + local.RotatedBy(rotation);
198 }
199
200 private void SpawnSlashBurst(int slash)
160 private void SpawnExecutionBurst()
201 161 {
202 float spread = slash - (SlashCount - 1) * 0.5f;
203 float rotation = Projectile.rotation + spread * 0.31f + DeterministicWave(slash, 1.71f) * 0.17f;
204 int bendDirection = ((Projectile.identity + slash) & 1) == 0 ? 1 : -1;
205 int count = 8 + (int)(Mastery * 7f) + (IsRequiem ? 4 : 0);
162 Vector2 direction = Projectile.rotation.ToRotationVector2();
163 Vector2 normal = direction.RotatedBy(MathHelper.PiOver2);
164 float length = (126f + Mastery * 76f + (IsRequiem ? 46f : 0f)) * Projectile.scale;
165 int count = 10 + (int)(Mastery * 8f) + (IsRequiem ? 5 : 0);
206 166 for (int i = 0; i < count; i++)
207 167 {
208 float t = (i + Main.rand.NextFloat()) / count;
209 // GetArcPoint is screen-space because it is also used by PreDraw.
210 Vector2 worldPosition = GetArcPoint(slash, t, rotation, bendDirection, -20f * Projectile.scale) + Main.screenPosition;
211 Vector2 radial = (worldPosition - Projectile.Center).SafeNormalize(Vector2.UnitY);
212 Vector2 velocity = radial.RotatedByRandom(0.7f) * Main.rand.NextFloat(1.2f, 4.8f);
213 int dustType = i % 3 switch
214 {
215 0 => DustID.Blood,
216 1 => DustID.Shadowflame,
217 _ => DustID.AncientLight
218 };
219 Color color = IsRequiem && i % 3 == 2
220 ? new Color(75, 220, 255)
221 : Color.Lerp(new Color(130, 0, 30), new Color(255, 45, 100), Main.rand.NextFloat());
222 Dust dust = Dust.NewDustPerfect(worldPosition, dustType, velocity, 70, color, Main.rand.NextFloat(0.75f, 1.35f));
168 float along = Main.rand.NextFloat(-0.5f, 0.5f) * length;
169 Vector2 position = Projectile.Center + direction * along;
170 Vector2 velocity = normal * Main.rand.NextFloat(-4.8f, 4.8f) + direction * Main.rand.NextFloat(-0.8f, 0.8f);
171 int dustType = i % 3 == 0 ? DustID.Shadowflame : DustID.RedTorch;
172 Dust dust = Dust.NewDustPerfect(
173 position,
174 dustType,
175 velocity,
176 75,
177 i % 3 == 0 ? new Color(30, 0, 12) : new Color(255, 12, 54),
178 Main.rand.NextFloat(0.75f, 1.35f));
223 179 dust.noGravity = true;
224 dust.fadeIn = 0.4f;
180 dust.fadeIn = 0.35f;
225 181 }
226 182 }
227 183
@@ -244,11 +200,6 @@ public class DeathDomainHarvestSlashProjectile : ModProjectile
244 200 0f);
245 201 }
246 202
247 private float DeterministicWave(int slash, float salt)
248 {
249 return (float)Math.Sin((Projectile.identity + 1) * 0.73f + slash * salt);
250 }
251
252 203 private static float Smooth01(float value)
253 204 {
254 205 value = MathHelper.Clamp(value, 0f, 1f);
Added Projectiles/DeathDomainHarvestTelegraphProjectile.cs +141 -0
@@ -0,0 +1,141 @@
1 using Microsoft.Xna.Framework;
2 using Microsoft.Xna.Framework.Graphics;
3 using System;
4 using System.IO;
5 using Terraria;
6 using Terraria.DataStructures;
7 using Terraria.GameContent;
8 using Terraria.ID;
9 using Terraria.ModLoader;
10
11 namespace DeathMod.Projectiles;
12
13 /// <summary>
14 /// The thin crimson cut line placed on every enemy awaiting the next domain harvest.
15 /// Its rotation is reused by the actual blade so the execution lands exactly on the
16 /// warning rather than choosing a new direction at the last moment.
17 /// </summary>
18 public class DeathDomainHarvestTelegraphProjectile : ModProjectile
19 {
20 private int TargetIndex => (int)Projectile.ai[0];
21 private int TelegraphFrames => Math.Clamp((int)Projectile.ai[1], 1, 180);
22 private float Mastery => MathHelper.Clamp(Projectile.ai[2], 0f, 1f);
23
24 public override string Texture => "Terraria/Images/Projectile_0";
25
26 public override void SetStaticDefaults()
27 {
28 ProjectileID.Sets.DrawScreenCheckFluff[Type] = 3600;
29 }
30
31 public override void SetDefaults()
32 {
33 Projectile.width = 2;
34 Projectile.height = 2;
35 Projectile.friendly = false;
36 Projectile.hostile = false;
37 Projectile.tileCollide = false;
38 Projectile.ignoreWater = true;
39 Projectile.penetrate = -1;
40 Projectile.timeLeft = 120;
41 Projectile.netImportant = true;
42 }
43
44 public override void OnSpawn(IEntitySource source)
45 {
46 Projectile.timeLeft = TelegraphFrames + 6;
47 }
48
49 public override bool? CanDamage() => false;
50
51 public override void SendExtraAI(BinaryWriter writer)
52 {
53 writer.Write(Projectile.rotation);
54 writer.Write(Projectile.scale);
55 }
56
57 public override void ReceiveExtraAI(BinaryReader reader)
58 {
59 Projectile.rotation = reader.ReadSingle();
60 Projectile.scale = reader.ReadSingle();
61 }
62
63 public override void AI()
64 {
65 if (TargetIndex >= 0 && TargetIndex < Main.maxNPCs && Main.npc[TargetIndex].active)
66 Projectile.Center = Main.npc[TargetIndex].Center;
67
68 if (Main.dedServ || !Main.rand.NextBool(18))
69 return;
70
71 float halfLength = GetLength() * 0.5f;
72 Vector2 direction = Projectile.rotation.ToRotationVector2();
73 Vector2 end = Projectile.Center + direction * (Main.rand.NextBool() ? halfLength : -halfLength);
74 Dust dust = Dust.NewDustPerfect(
75 end,
76 DustID.RedTorch,
77 direction.RotatedByRandom(0.45f) * Main.rand.NextFloat(0.25f, 1.2f),
78 95,
79 new Color(255, 15, 50),
80 Main.rand.NextFloat(0.45f, 0.8f));
81 dust.noGravity = true;
82 }
83
84 public override bool PreDraw(ref Color lightColor)
85 {
86 float remaining = MathHelper.Clamp((Projectile.timeLeft - 5f) / Math.Max(1f, TelegraphFrames), 0f, 1f);
87 float urgency = 1f - remaining;
88 float pulse = 0.72f + (float)Math.Sin(Main.GlobalTimeWrappedHourly * (7f + urgency * 9f) + Projectile.identity) * 0.20f;
89 float length = GetLength();
90 Vector2 center = Projectile.Center - Main.screenPosition;
91
92 DrawTaperedLine(center, Projectile.rotation, length, (6f + Mastery * 2f) * Projectile.scale,
93 new Color(120, 0, 25, 0) * (0.24f + urgency * 0.22f));
94 DrawTaperedLine(center, Projectile.rotation, length, (1.35f + urgency * 0.85f) * Projectile.scale,
95 new Color(255, 8, 48, 0) * pulse);
96 return false;
97 }
98
99 private float GetLength()
100 {
101 return (118f + Mastery * 66f) * Projectile.scale;
102 }
103
104 private static void DrawTaperedLine(Vector2 center, float rotation, float length, float maximumWidth, Color color)
105 {
106 Vector2 direction = rotation.ToRotationVector2();
107 Vector2 start = center - direction * length * 0.5f;
108 const int pieces = 28;
109 for (int piece = 0; piece < pieces; piece++)
110 {
111 float t0 = piece / (float)pieces;
112 float t1 = (piece + 1f) / pieces;
113 float middle = (t0 + t1) * 0.5f;
114 float taper = (float)Math.Pow(Math.Max(0f, Math.Sin(MathHelper.Pi * middle)), 0.62f);
115 DrawSegment(
116 start + direction * (length * t0),
117 start + direction * (length * t1 + 0.8f),
118 color,
119 Math.Max(0.15f, maximumWidth * taper));
120 }
121 }
122
123 private static void DrawSegment(Vector2 start, Vector2 end, Color color, float width)
124 {
125 Vector2 segment = end - start;
126 if (segment.LengthSquared() < 0.01f)
127 return;
128
129 Texture2D pixel = TextureAssets.MagicPixel.Value;
130 Main.EntitySpriteDraw(
131 pixel,
132 start,
133 null,
134 color,
135 segment.ToRotation(),
136 new Vector2(0f, pixel.Height * 0.5f),
137 new Vector2(segment.Length() / pixel.Width, width / pixel.Height),
138 SpriteEffects.None,
139 0f);
140 }
141 }
Modified README.md +1 -1
@@ -102,7 +102,7 @@ Projectile count, projectile lifetime, percentage penetration, hit cooldown, and
102 102
103 103 After entering Hardmode, craft Wings of Death from Souls of Flight and train flight time, horizontal speed, acceleration, and vertical power. Max all four branches to unlock infinite flight. The Death Robe can be trained in defense, maximum life, regeneration, mobility, minions, sentries, placement range, lava immunity, utility immunities, and 15 separately purchased debuff immunities.
104 104
105 The Death Necklace unfolds a small field while equipped and enabled with the **Toggle Death Domain** keybind (`O` by default). While holding a mod sickle, every harvest cycle strikes all enemies in the field with layered crescent cuts instead of manifesting sickle projectiles. Each cut begins at 1% of a normal enemy's maximum life and 0.01% of a boss's maximum life; the slash-percentage branch raises those values to 10% and 0.1%, while Multislash grows from one to five cuts per cycle. Radius and frequency are the other two branches. Max all four and unlock **Deathly Sovereignty** to cover the screen with a moving soul-field background; every fourth harvest receives the more intense Death Requiem presentation.
105 The Death Necklace unfolds a small field while equipped and enabled with the **Toggle Death Domain** keybind (`O` by default). While holding a mod sickle, every enemy entering the field receives a thin crimson cut mark at a random angle. When the harvest cycle expires, a straight double-pointed blade—red at its edge and pure black through its center—executes along that exact line instead of manifesting sickle projectiles. Domain cuts apply Death Mark for soul harvesting but do not inherit Fated, life steal, form debuffs, or other sickle hit effects. Each cut begins at 1% of a normal enemy's maximum life and 0.01% of a boss's maximum life; the slash-percentage branch raises those values to 10% and 0.1%, while Multislash grows from one to five cuts per cycle. Radius and frequency are the other two branches. Max all four and unlock **Deathly Sovereignty** to cover the screen with a moving soul-field background; every fourth harvest receives the more intense Death Requiem presentation.
106 106
107 107 ## Soul and boss rules
108 108
Modified README.zh-Hans.md +1 -1
@@ -80,7 +80,7 @@ Soul Harvest(内部模组名 `DeathMod`)是一款围绕死神镰刀、人物
80 80
81 81 困难模式后可以制作死亡之翼,分别提升飞行时间、水平速度、加速度与垂直能力,四项满级后解锁无限飞行。死神长袍则可提升防御、生命、回复、机动、仆从、哨兵、放置距离、熔岩免疫及功能免疫;15 种减益免疫必须逐项购买。
82 82
83 佩戴死亡项链后,按“开关死亡领域”快捷键(默认 `O`)展开小范围领域。手持本模组镰刀时,每轮收割会以层叠弧月斩同时攻击领域内所有敌人,不再显化镰刀弹幕。每段斩击初始削去普通敌人 1% 最大生命、Boss 0.01% 最大生命;“斩击百分比”分支最高将其提升至普通敌人 10%、Boss 0.1%,而“多重斩击”可由每轮一斩提升至五斩。另有领域半径与收割频率两条分支;四项满级后点亮“死寂主权”,领域将覆盖全屏并显示流动灵魂背景,每第四轮收割会呈现更强烈的“死亡安魂曲”特效。
83 佩戴死亡项链后,按“开关死亡领域”快捷键(默认 `O`)展开小范围领域。手持本模组镰刀时,每个进入领域的敌人都会从随机角度出现一条纤细的猩红待斩刻线;收割计时结束后,外缘猩红、中心纯黑、两端尖锐且中段宽阔的直线劈斩会沿原刻线落下,不再显化镰刀弹幕。领域斩击只会施加用于灵魂收割的死亡标记,不继承命定、吸血、形态 Debuff 或其他镰刀命中效果。每段斩击初始削去普通敌人 1% 最大生命、Boss 0.01% 最大生命;“斩击百分比”分支最高将其提升至普通敌人 10%、Boss 0.1%,而“多重斩击”可由每轮一斩提升至五斩。另有领域半径与收割频率两条分支;四项满级后点亮“死寂主权”,领域将覆盖全屏并显示流动灵魂背景,每第四轮收割会呈现更强烈的“死亡安魂曲”特效。
84 84
85 85 ## 灵魂与多人规则
86 86
Modified docs/PLAY_GUIDE.md +1 -1
@@ -192,7 +192,7 @@ The following 15 immunities are separate 1-essence purchases: On Fire, Poisoned,
192 192
193 193 ### Death Necklace
194 194
195 Equip the necklace and press **Toggle Death Domain** (`O` by default). Its initial field reaches 10 tiles. While a DeathMod sickle is held, every harvest cycle applies a polished, staggered crescent slash to every enemy inside instead of spawning that sickle's projectiles. The four altar branches improve radius, frequency, slash percentage, and Multislash.
195 Equip the necklace and press **Toggle Death Domain** (`O` by default). Its initial field reaches 10 tiles. While a DeathMod sickle is held, enemies entering the domain receive a thin crimson warning line at a stable random angle. At harvest time, a straight, double-pointed red-and-black execution blade falls on that exact mark instead of spawning the sickle's projectiles. These domain cuts apply Death Mark but no Fated stacks, life steal, form debuff, or other inherited on-hit effect. The four altar branches improve radius, frequency, slash percentage, and Multislash.
196 196
197 197 Slash percentage starts at 1% maximum life per cut against normal enemies and 0.01% against bosses, reaching 10% and 0.1% at level 10. Multislash starts at one cut per cycle and reaches five; each cut resolves its percentage separately against every target. Max all four branches to unlock **Deathly Sovereignty** for 30 Soul Essence. It expands the field to the full screen, adds a moving soul-and-rune background, and gives every fourth harvest the intensified Death Requiem presentation.
198 198
Modified docs/PLAY_GUIDE.zh-Hans.md +1 -1
@@ -192,7 +192,7 @@ Boss 不会直接掉落灵魂精华;请通过灵魂罐将奖励凝聚为精华
192 192
193 193 ### 死亡项链
194 194
195 佩戴后按“开关死亡领域”快捷键(默认 `O`)。初始领域半径为 10 格;手持本模组镰刀时,每轮收割会以精细的层叠弧月斩同时攻击领域内所有敌人,不再复现镰刀弹幕。祭坛提供领域半径、收割频率、斩击百分比、多重斩击四条独立分支。
195 佩戴后按“开关死亡领域”快捷键(默认 `O`)。初始领域半径为 10 格;手持本模组镰刀时,敌人进入领域便会从稳定的随机角度出现一条纤细猩红待斩刻线。收割时,外缘猩红、中心纯黑、两端尖锐的直线处决刀光会沿原刻线落下,不再复现镰刀弹幕。领域斩击只施加死亡标记,不继承命定层数、吸血、形态 Debuff 或其他命中效果。祭坛提供领域半径、收割频率、斩击百分比、多重斩击四条独立分支。
196 196
197 197 每段斩击初始对普通敌人造成其最大生命 1% 的伤害、对 Boss 造成 0.01%,斩击百分比 10 级时分别达到 10% 和 0.1%。多重斩击从每轮一斩提升至最多五斩,每段都会对所有领域内目标单独结算。四项满级后可消耗 30 灵魂精华点亮“死寂主权”:领域扩张至整个屏幕并出现流动灵魂与符文背景;每第四轮收割会呈现强化的“死亡安魂曲”视觉效果。
198 198