XFEstudio/DeathMod
Knowledge cutoff: 2024-06
You are an AI programming assistant. When asked for your name, you must respond with "GitHub Copilot". Follow the user's requirements carefully & to the letter. Your expertise is strictly limited to software development topics. Follow Microsoft content policies. Avoid content that violates copyrights. For questions not related to software development, simply give a reminder that you are an AI programming assistant. Keep your answers short and to the point. Do not include any preamble, explanations, or apologies. Do not repeat or paraphrase the user's question. Do not include any URLs or links. Do not include any code unless the user explicitly asks for code. Do not include any lists unless the user explicitly asks for a list. Do not include any table unless the user explicitly asks for a table. Do not include any headings or titles unless the user explicitly asks for a heading. Do not include any formatting (such as bold, italics, or blockquotes). Do not include any text except the answer.
960b403
代码差异
@@ -37,6 +37,15 @@ internal sealed class DeathDomainBackdropTextureSystem : ModSystem
return layers[index] ??= CreateLayer(index);
}
internal static void Prewarm()
{
if (Main.dedServ)
return;
for (int layer = 0; layer < layers.Length; layer++)
_ = GetLayer(layer);
}
/// <summary>
/// Returns the single world-space frame used by a fully manifested Death
/// Domain. Every smaller mask (necklace aperture, weapon wound and wing
@@ -54,6 +54,8 @@ public class MyPlayer : ModPlayer
private int swingComboDirection = 1;
private bool serverSoulBalanceInitialized;
private bool serverReaperProgressionInitialized;
private int clientCharacterStateUploadDelay;
private int clientCharacterStateUploadAttempts;
private bool reaperDeathStateCleared;
private bool reaperLegacyMigrationChecked;
private int reaperLegacyMigrationVersion;
@@ -122,6 +124,8 @@ public class MyPlayer : ModPlayer
deathWingDomainVolleyCounter = 0;
serverSoulBalanceInitialized = false;
serverReaperProgressionInitialized = false;
clientCharacterStateUploadDelay = 0;
clientCharacterStateUploadAttempts = 0;
DeathDomainProgression.Reset();
ReaperProgression.Reset();
VisibleReaperForm = ReaperFormId.Base;
@@ -204,6 +208,16 @@ public class MyPlayer : ModPlayer
public override void OnEnterWorld()
{
ClearReaperCombatRuntime(sync: false);
if (Main.netMode == NetmodeID.MultiplayerClient
&& Player.whoAmI == Main.myPlayer)
{
// ModPlayer.SyncPlayer is not guaranteed to be invoked client-side
// after every join/reload. Explicitly retry the character-owned
// progression upload so the server cannot remain permanently in
// its uninitialized state and reject combat, forms and the domain.
clientCharacterStateUploadDelay = 1;
clientCharacterStateUploadAttempts = 0;
}
if (Main.netMode == NetmodeID.Server || Player.whoAmI != Main.myPlayer || reaperLegacyMigrationChecked)
return;
@@ -225,6 +239,8 @@ public class MyPlayer : ModPlayer
public override void PostUpdate()
{
UpdateClientCharacterStateUpload();
if (Main.netMode != NetmodeID.Server && Player.whoAmI == Main.myPlayer && pendingLegacyMigrationEssence > 0)
TryDeliverPendingLegacyMigrationRefund();
@@ -281,6 +297,32 @@ public class MyPlayer : ModPlayer
DeathWingsActiveLastTick = DeathWingsActiveThisTick;
}
private void UpdateClientCharacterStateUpload()
{
if (Main.netMode != NetmodeID.MultiplayerClient
|| Player.whoAmI != Main.myPlayer
|| clientCharacterStateUploadAttempts >= 3)
{
return;
}
if (clientCharacterStateUploadDelay > 0)
{
clientCharacterStateUploadDelay--;
if (clientCharacterStateUploadDelay > 0)
return;
}
SoulHarvest.SendReaperProgression(Player);
clientCharacterStateUploadAttempts++;
clientCharacterStateUploadDelay = clientCharacterStateUploadAttempts switch
{
1 => 30,
2 => 90,
_ => 0
};
}
private void TryDeliverPendingLegacyMigrationRefund()
{
if (pendingLegacyMigrationEssence <= 0)
@@ -962,7 +1004,13 @@ public class MyPlayer : ModPlayer
ulong now = Main.GameUpdateCount;
if (lastReaperFormSwitchRequestTick != 0 && now - lastReaperFormSwitchRequestTick < 10UL)
return false;
{
// Wheel input can commit twice while closing. Treat an identical
// request as an idempotent acknowledgement instead of reporting a
// misleading progression/prerequisite failure to the client.
if (queuedReaperForm == form || ReaperProgression.CurrentForm == form)
return true;
}
lastReaperFormSwitchRequestTick = now;
if (IsReaperActionBusy())
@@ -1166,10 +1214,28 @@ public class MyPlayer : ModPlayer
}
else if (deathInvocationHeld && Main.GameUpdateCount % 5UL == (ulong)(Player.whoAmI % 5))
{
int controllerType = ModContent.ProjectileType<ReaperActionControllerProjectile>();
bool invocationExists = Player.ownedProjectileCounts[controllerType] > 0;
if (Main.netMode == NetmodeID.MultiplayerClient)
SoulHarvest.SendReaperSpecialAim(Player, cursorOffset);
else
{
// A press can reach the server during the last primary-swing
// frame and be rejected legitimately. While Death's invocation
// key remains held, retry the start at a bounded cadence until
// its controller is replicated; afterwards packets only update
// the cursor target.
if (invocationExists)
SoulHarvest.SendReaperSpecialAim(Player, cursorOffset);
else
SoulHarvest.SendReaperAction(Player, ultimate: false, cursorOffset);
}
else if (invocationExists)
{
ReaperCombatService.TryUpdateSpecialTarget(Player, cursorOffset);
}
else
{
ReaperCombatService.TryStartSpecial(Player, cursorOffset);
}
}
if (SoulHarvest.UltimateKeybind?.JustPressed == true)
@@ -41,35 +41,31 @@ public static class ReaperCombatService
if (Main.netMode != NetmodeID.Server
|| !player.active
|| player.dead
|| player.itemAnimation <= 0
|| player.itemTime <= 0
|| player.HeldItem.ModItem is not NormalSickle
|| !player.GetModPlayer<MyPlayer>().ServerCharacterStateReady
|| player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] > 0
|| projectile.owner != player.whoAmI)
{
return false;
}
// The projectile packet can arrive after the very short item-use timer
// has already reached zero, especially once Death reaches its high-speed
// cap. Authorization is bounded by the authoritative per-owner cadence.
// Do not reject merely because the previous projectile is still alive on
// its harmless cleanup frame: at Death's fast cadence that frame overlaps
// the next legal request and used to turn one rejection into a 180-frame
// client authority wait.
int owner = player.whoAmI;
if (ReaperCombatSystem.HasActivePrimaryProjectile[owner])
{
int activeIndex = ReaperCombatSystem.ActivePrimaryProjectile[owner];
if (activeIndex >= 0 && activeIndex < Main.maxProjectiles)
{
Projectile active = Main.projectile[activeIndex];
if (active.active && active.whoAmI != projectile.whoAmI && active.owner == owner
&& active.ModProjectile is SickleSwingProjectile { ServerValidated: true })
{
return false;
}
}
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
}
ulong now = Main.GameUpdateCount;
if (now < ReaperCombatSystem.NextPrimaryAuthorizationTick[owner])
return false;
// The cadence check above already admits at most one request for this
// owner in the current window. Replace the diagnostic active pointer with
// the newly authorized swing; the old projectile will finish naturally.
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
phase = TakeNextPrimaryPhase(player, snapshot, out deathTempo);
int baseDuration = ReaperCombatRegistry.GetUseTime(snapshot.Form, snapshot.Stage, phase);
float effectiveAttackSpeed = snapshot.AttackSpeedMultiplier * deathTempo;
@@ -186,6 +182,7 @@ public static class ReaperCombatService
modPlayer.RefundFailedReaperSpecial();
return false;
}
CancelPrimarySwingsForAction(player);
return true;
}
@@ -256,6 +253,7 @@ public static class ReaperCombatService
modPlayer.RefundFailedReaperUltimate(form);
return false;
}
CancelPrimarySwingsForAction(player);
player.GetModPlayer<ReaperCombatPlayer>().BeginUltimate(form);
modPlayer.SyncReaperCombatRuntime();
return true;
@@ -573,12 +571,28 @@ public static class ReaperCombatService
&& !player.dead
&& !player.CCed
&& !player.noItems
&& player.itemAnimation <= 0
&& player.itemTime <= 0
&& player.ownedProjectileCounts[ModContent.ProjectileType<SickleSwingProjectile>()] <= 0
&& player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()] <= 0;
}
private static void CancelPrimarySwingsForAction(Player player)
{
int swingType = ModContent.ProjectileType<SickleSwingProjectile>();
foreach (Projectile projectile in Main.ActiveProjectiles)
{
if (projectile.owner == player.whoAmI && projectile.type == swingType)
projectile.Kill();
}
int owner = player.whoAmI;
if (owner >= 0 && owner < Main.maxPlayers)
{
ReaperCombatSystem.HasActivePrimaryProjectile[owner] = false;
ReaperCombatSystem.ActivePrimaryProjectile[owner] = -1;
}
player.itemAnimation = 0;
player.itemTime = 0;
}
private static void SpawnLegacyProjectile<T>(IEntitySource source, int owner, Vector2 position, Vector2 velocity,
in SickleCombatSnapshot snapshot, float damageFactor, ReaperHitKind kind, int phase,
float ai0 = 0f, float ai1 = 0f, float ai2 = 0f) where T : ModProjectile
@@ -30,6 +30,26 @@ internal sealed class ReaperCrescentPrimitiveTextureSystem : ModSystem
private static Texture2D? deathDomainBlade;
private static Texture2D? deathDomainRiftCrescent;
public override void PostSetupContent()
{
if (Main.dedServ)
return;
// These procedural atlases used to be built synchronously by the first
// Blood/Death swing. Prewarm them on the graphics thread after content
// setup so combat never pays several million samples and GPU uploads in
// a single frame. Dedicated servers remain completely texture-free.
Main.QueueMainThreadAction(() =>
{
DeathDomainBackdropTextureSystem.Prewarm();
for (int frame = 0; frame < BloodLifecycleFrames; frame++)
{
_ = GetBloodCrescent(frame);
_ = GetDeathDomainRimCrescent(frame);
}
});
}
internal static void DrawFormCrescent(
ReaperFormId form,
Vector2 center,
@@ -18,6 +18,7 @@ public sealed class ReaperActionControllerProjectile : ModProjectile
private const int BaseDashStartTick = 3;
private const int BaseDashTravelFrames = 7;
private const int VoidSpecialTeleportTick = 2;
private const int VoidSpecialVisualEndTick = 14;
private const int VoidUltimateBlackHoleTick = 32;
private SickleCombatSnapshot snapshot;
private Vector2 aim;
@@ -727,7 +728,11 @@ public sealed class ReaperActionControllerProjectile : ModProjectile
case ReaperFormId.Void:
if (timer == VoidSpecialTeleportTick)
ExecuteVoidTeleportSpecial(player);
FinishAt(VoidSpecialTeleportTick);
// Keep the replicated controller alive long enough for clients to
// receive and render the cast. Killing it on the same tick as the
// server-side teleport made Void the only special with no held
// weapon, sound or opening presentation in multiplayer.
FinishAt(VoidSpecialVisualEndTick);
break;
}
}
@@ -994,11 +999,23 @@ public sealed class ReaperActionControllerProjectile : ModProjectile
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
Vector2 requestedTarget = player.Center + specialTargetOffset;
if (!ReaperMovementHelper.TeleportToTarget(player, requestedTarget, 2000f,
out Vector2 start, out Vector2 end))
Vector2 start = player.Center;
Vector2 requestedTarget = start + specialTargetOffset;
bool teleported = ReaperMovementHelper.TeleportToTarget(player,
requestedTarget, 2000f, out start, out Vector2 end);
// A blocked or very close cursor destination must not turn the whole
// special into a silent no-op. Keep the server-authoritative movement
// safety rule, but still tear the selected world-space path when there is
// nowhere safe to place the player.
if (!teleported)
{
return;
Vector2 fallback = requestedTarget - start;
float fallbackLength = Math.Min(2000f, fallback.Length());
Vector2 fallbackAxis = fallback.SafeNormalize(aim);
if (fallbackLength <= 4f)
fallbackLength = 160f;
end = start + fallbackAxis * fallbackLength;
}
Vector2 path = end - start;
@@ -1207,7 +1224,7 @@ public sealed class ReaperActionControllerProjectile : ModProjectile
ReaperFormId.Base => 28,
ReaperFormId.Bone => snapshot.Stage >= ReaperStage.StageIII ? 44 : snapshot.Stage >= ReaperStage.StageII ? 34 : 25,
ReaperFormId.Infernal => snapshot.Stage >= ReaperStage.StageIII ? 42 : snapshot.Stage >= ReaperStage.StageII ? 32 : 18,
ReaperFormId.Void => VoidSpecialTeleportTick,
ReaperFormId.Void => VoidSpecialVisualEndTick,
ReaperFormId.Death => 50,
_ => 30
};
@@ -1705,10 +1722,24 @@ public sealed class ReaperActionControllerProjectile : ModProjectile
private void DrawVoidSpecial(Texture2D pixel, Vector2 grip)
{
// The held blade now carves its own tip-locked scene tear and every spin
// creates an actual persistent rift projectile. Do not overlay coordinate
// boxes or pre-drawn straight cuts here: those divorced the visual from
// the weapon pose and made the attack feel instantaneous.
float reveal = SmoothStep(MathHelper.Clamp(timer / 5f, 0f, 1f));
float fade = 1f - SmoothStep(MathHelper.Clamp(
(timer - 8f) / Math.Max(1f, VoidSpecialVisualEndTick - 8f), 0f, 1f));
float opacity = reveal * fade;
if (opacity <= 0.001f)
return;
// A compact world-sampled opening surrounds the held blade while the
// authoritative persistent path arrives. It uses the same Void backdrop
// renderer as ordinary and ultimate rifts, so there is no unrelated box
// or flat purple placeholder.
Vector2 bladeAxis = visualWeaponAngle.ToRotationVector2();
Vector2 openingCenter = grip + bladeAxis * (92f + snapshot.StageNumber * 7f);
ReaperVoidBackdropRenderer.DrawPersistentRift(Main.spriteBatch,
openingCenter, bladeAxis, 118f + snapshot.StageNumber * 18f,
20f + snapshot.StageNumber * 5f,
Projectile.identity * 397 ^ Projectile.owner * 31,
reveal, opacity * 0.88f);
}
private void DrawCoordinateGate(Texture2D pixel, Vector2 center, Vector2 forward,