diff --git a/Plugins.json b/Plugins.json index 20739dd93..372ab2f7d 100644 --- a/Plugins.json +++ b/Plugins.json @@ -3212,5 +3212,28 @@ "Path": "ShortCommand.dll", "Dependencies": [], "HotReload": true + }, + { + "Name": "GroundCraft", + "Version": "1.1.0.0", + "Author": "愚蠢", + "Description": { + "en-US": "Craft items by dropping ingredients together, with JSON recipes, conditions, exact matching and spiral animations", + "de-DE": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "it-IT": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "fr-FR": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "es-ES": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "ru-RU": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "zh-CN": "把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "pt-BR": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "pl-PL": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "ja-JP": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "ko-KR": "通过把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画", + "zh-Hant": "把掉落物丟在一起自動合成,支援 JSON 配方、條件、精確匹配和螺旋動畫" + }, + "AssemblyName": "GroundCraft", + "Path": "GroundCraft.dll", + "Dependencies": [], + "HotReload": true } -] \ No newline at end of file +] diff --git a/src/GroundCraft/GroundCraft.Animation.cs b/src/GroundCraft/GroundCraft.Animation.cs new file mode 100644 index 000000000..1fd59eef8 --- /dev/null +++ b/src/GroundCraft/GroundCraft.Animation.cs @@ -0,0 +1,283 @@ +using Microsoft.Xna.Framework; + +using Terraria; +using Terraria.ID; +using TerrariaApi.Server; + +using TShockAPI; + +namespace GroundCraft; + +public sealed partial class GroundCraft +{ + private void UpdateCraftAnimations() + { + for (int i = _craftAnimations.Count - 1; i >= 0; i--) + { + CraftAnimation animation = _craftAnimations[i]; + animation.Age++; + + if (!AnimationItemsStillValid(animation)) + { + CancelCraftAnimation(animation); + _craftAnimations.RemoveAt(i); + continue; + } + + int duration = AnimationDuration(animation); + float progress = Math.Clamp(animation.Age / (float)duration, 0f, 1f); + Vector2 target = CraftTarget(animation); + + for (int j = 0; j < animation.Ingredients.Count; j++) + MoveAnimatedIngredient(animation, animation.Ingredients[j], j, animation.Ingredients.Count, progress, target, animation.Age, duration); + + if (animation.Age < duration) + continue; + + CompleteCraftAnimation(animation, target); + _craftAnimations.RemoveAt(i); + } + } + + private static int AnimationDuration(CraftAnimation animation) + { + return animation.IsZenith ? ZenithAnimationTicks : CraftAnimationTicks; + } + + private static Vector2 CraftTarget(CraftAnimation animation) + { + float lift = animation.IsZenith ? ZenithAnimationLiftPixels : CraftAnimationLiftPixels; + return animation.Center + new Vector2(0f, -lift); + } + + private static bool AnimationItemsStillValid(CraftAnimation animation) + { + foreach (AnimatedIngredient ingredient in animation.Ingredients) + { + if (ingredient.Index < 0 || ingredient.Index >= Main.item.Length) + return false; + + WorldItem item = Main.item[ingredient.Index]; + if (!item.active || item.type != ingredient.Type || item.stack != ingredient.Stack) + return false; + } + + return true; + } + + private static void MoveAnimatedIngredient( + CraftAnimation animation, + AnimatedIngredient ingredient, + int order, + int total, + float progress, + Vector2 target, + int age, + int duration) + { + WorldItem item = Main.item[ingredient.Index]; + Vector2 visualCenter = AnimatedCenter(animation, ingredient, order, total, progress, target); + int syncEvery = AnimationSyncInterval(animation); + float lookaheadTicks = Math.Max(1, syncEvery); + float lookaheadProgress = Math.Min(progress + lookaheadTicks / duration, 1f); + Vector2 predictedCenter = AnimatedCenter(animation, ingredient, order, total, lookaheadProgress, target); + + item.position = visualCenter - new Vector2(ingredient.Width / 2f, ingredient.Height / 2f); + item.velocity = (predictedCenter - visualCenter) / lookaheadTicks; + LockAnimatedItem(item); + + if (animation.IsZenith) + SpawnZenithItemTrail(visualCenter, item.velocity, order, age); + + if (age == 1 || age % syncEvery == 0) + SyncItemNoGrab(ingredient.Index); + } + + private static int AnimationSyncInterval(CraftAnimation animation) + { + return animation.IsZenith ? ZenithAnimationSyncEveryTicks : CraftAnimationSyncEveryTicks; + } + + private static Vector2 AnimatedCenter(CraftAnimation animation, AnimatedIngredient ingredient, int order, int total, float progress, Vector2 target) + { + return animation.IsZenith + ? ZenithAnimatedCenter(animation, ingredient, order, total, progress, target) + : DefaultAnimatedCenter(ingredient, order, total, progress, target); + } + + private static Vector2 DefaultAnimatedCenter(AnimatedIngredient ingredient, int order, int total, float progress, Vector2 target) + { + float eased = EaseOutCubic(progress); + float phase = MathHelper.TwoPi * (CraftAnimationTurns * progress + order / Math.Max(1f, total)); + float radius = CraftAnimationOrbitRadiusPixels * (1f - eased); + Vector2 spiral = new(MathF.Cos(phase) * radius, MathF.Sin(phase) * radius * 0.55f); + return Vector2.Lerp(ingredient.StartCenter, target, eased) + spiral; + } + + private static Vector2 ZenithAnimatedCenter(CraftAnimation animation, AnimatedIngredient ingredient, int order, int total, float progress, Vector2 target) + { + float eased = EaseInOutCubic(progress); + float gather = EaseOutCubic(Math.Clamp(progress * 1.6f, 0f, 1f)); + float phase = MathHelper.TwoPi * (ZenithAnimationTurns * progress + order / Math.Max(1f, total)); + float radius = ZenithAnimationOrbitRadiusPixels * (1f - progress * progress); + Vector2 spiral = new(MathF.Cos(phase) * radius, MathF.Sin(phase) * radius * 0.42f); + float parabola = ZenithAnimationParabolaPixels * 4f * progress * (1f - progress); + Vector2 orbitCenter = Vector2.Lerp(animation.Center, target, eased) + new Vector2(0f, -parabola); + Vector2 orbitPoint = orbitCenter + spiral; + + return Vector2.Lerp(ingredient.StartCenter, orbitPoint, gather); + } + + private static float EaseOutCubic(float value) + { + float inverse = 1f - value; + return 1f - inverse * inverse * inverse; + } + + private static float EaseInOutCubic(float value) + { + return value < 0.5f + ? 4f * value * value * value + : 1f - MathF.Pow(-2f * value + 2f, 3f) / 2f; + } + + private void CompleteCraftAnimation(CraftAnimation animation, Vector2 target) + { + foreach (AnimatedIngredient ingredient in animation.Ingredients) + { + WorldItem item = Main.item[ingredient.Index]; + item.TurnToAir(true); + ClearConsumedItem(ingredient.Index); + _lockedItemIndexes.Remove(ingredient.Index); + _stableScans.Remove(ingredient.Index); + } + + SpawnDeferredLeftovers(animation); + SpawnItem(animation.Recipe.OutputType, animation.OutputStack, target); + if (animation.IsZenith) + SpawnZenithFinale(target); + else + SpawnCraftEffect(target); + + _runtime.CraftBatches++; + _runtime.Crafts += animation.CraftCount; + TShock.Log.ConsoleInfo(GetString($"[GroundCraft] 螺旋融合完成:{ItemName(animation.Recipe.OutputType)} x{animation.OutputStack}。")); + NotifyNearby(target, animation.Recipe, animation.OutputStack, animation.ConsumedStacks); + } + + private void CancelCraftAnimation(CraftAnimation animation) + { + foreach (AnimatedIngredient ingredient in animation.Ingredients) + { + _lockedItemIndexes.Remove(ingredient.Index); + if (ingredient.Index < 0 || ingredient.Index >= Main.item.Length) + continue; + + WorldItem item = Main.item[ingredient.Index]; + if (!item.active) + continue; + + item.noGrabDelay = 0; + item.playerIndexTheItemIsReservedFor = 255; + item.velocity = Vector2.Zero; + SyncItem(ingredient.Index); + NetMessage.SendData(MessageID.ItemOwner, -1, -1, null, ingredient.Index); + } + + SpawnDeferredLeftovers(animation); + } + + private static void SpawnDeferredLeftovers(CraftAnimation animation) + { + foreach (DeferredLeftover leftover in animation.Leftovers) + SpawnItem(leftover.Type, leftover.Stack, leftover.Center); + } + + private static void SpawnZenithItemTrail(Vector2 center, Vector2 velocity, int order, int age) + { + if ((age + order * ZenithTrailOrderOffsetTicks) % ZenithTrailEveryTicks != 0) + return; + + Vector2 trailPoint = center; + if (velocity.LengthSquared() > 0.01f) + trailPoint -= Vector2.Normalize(velocity) * 14f; + + NetMessage.SendData(MessageID.PoofOfSmoke, -1, -1, null, (int)trailPoint.X, trailPoint.Y); + } + + private void ReleaseCraftAnimations() + { + foreach (CraftAnimation animation in _craftAnimations) + CancelCraftAnimation(animation); + + _craftAnimations.Clear(); + _lockedItemIndexes.Clear(); + } + + private void OnGetData(GetDataEventArgs args) + { + if (args.Handled || !TryReadLockedItemIndex(args, out int index)) + return; + + if (!_lockedItemIndexes.Contains(index)) + return; + + args.Handled = true; + ReassertLockedItem(index); + } + + private static bool TryReadLockedItemIndex(GetDataEventArgs args, out int index) + { + index = -1; + int msgId = (int)args.MsgID; + return msgId switch + { + MessageID.SyncItem => TryReadItemIndexAt(args, 0, out index), + MessageID.ItemOwner => TryReadItemIndexAt(args, 0, out index), + MessageID.ReleaseItemOwnership => TryReadItemIndexAt(args, 0, out index), + MessageID.SyncItemDespawn => TryReadItemIndexAt(args, 0, out index), + _ => false + }; + } + + private static bool TryReadItemIndexAt(GetDataEventArgs args, int payloadOffset, out int index) + { + index = -1; + const int itemIndexSize = sizeof(short); + if (payloadOffset < 0 || args.Length < payloadOffset + itemIndexSize || args.Index + payloadOffset + itemIndexSize > args.Msg.readBuffer.Length) + return false; + + index = BitConverter.ToInt16(args.Msg.readBuffer, args.Index + payloadOffset); + return index >= 0 && index < Main.maxItems && index < Main.item.Length; + } + + private static void LockAnimatedItem(int index) + { + WorldItem item = Main.item[index]; + LockAnimatedItem(item); + } + + private static void LockAnimatedItem(WorldItem item) + { + item.noGrabDelay = CraftAnimationNoGrabDelay; + item.playerIndexTheItemIsReservedFor = 255; + item.ownIgnore = -1; + item.ownTime = 0; + item.beingGrabbed = false; + item.keepTime = Math.Max(item.keepTime, 10); + } + + private void ReassertLockedItem(int index) + { + if (index < 0 || index >= Main.item.Length) + return; + + WorldItem item = Main.item[index]; + if (!item.active) + return; + + LockAnimatedItem(item); + SyncItemNoGrab(index); + NetMessage.SendData(MessageID.ItemOwner, -1, -1, null, index); + } +} diff --git a/src/GroundCraft/GroundCraft.Commands.cs b/src/GroundCraft/GroundCraft.Commands.cs index e83eb9276..bf3f36094 100644 --- a/src/GroundCraft/GroundCraft.Commands.cs +++ b/src/GroundCraft/GroundCraft.Commands.cs @@ -18,6 +18,8 @@ private void GroundCraftInfo(CommandArgs args) float clusterRadiusTiles; int requiredStableScans; int maxCraftsPerClusterPerScan; + bool requireExactIngredientTypes; + bool animateConsumedItems; lock (_stateLock) { enabled = _config.Enabled; @@ -25,10 +27,13 @@ private void GroundCraftInfo(CommandArgs args) clusterRadiusTiles = _config.ClusterRadiusTiles; requiredStableScans = _config.RequiredStableScans; maxCraftsPerClusterPerScan = _config.MaxCraftsPerClusterPerScan; + requireExactIngredientTypes = _config.RequireExactIngredientTypes; + animateConsumedItems = _config.AnimateConsumedItems; } args.Player.SendInfoMessage(GetString($"地上合成:{(enabled ? "已启用" : "已关闭")},启用配方 {recipeCount} 条。")); args.Player.SendInfoMessage(GetString($"材料丢在 {clusterRadiusTiles:0.##} 格内,静止 {requiredStableScans} 次扫描后尝试合成;每堆每次最多合成 {maxCraftsPerClusterPerScan} 批。")); + args.Player.SendInfoMessage(GetString($"精确材料匹配={(requireExactIngredientTypes ? "开启" : "关闭")},螺旋动画={(animateConsumedItems ? "开启" : "关闭")}。")); args.Player.SendInfoMessage(GetString("命令:/gcrecipes [页码|搜索]、/gcenv、/gcaudit、/gcreload。")); args.Player.SendInfoMessage(GetString($"配置:{ConfigPath};配方:{RecipesPath}。")); } @@ -105,7 +110,7 @@ private void GroundCraftAudit(CommandArgs args) args.Player.SendInfoMessage(GetString($"读取={_audit.Seen},禁用={_audit.Disabled},格式通过={_audit.ImportAccepted},安全通过={_audit.SafetyAccepted},启用={_audit.ActiveRecipes}。")); SendReasons(args.Player, GetString("导入拒绝"), _audit.ImportRejects); SendReasons(args.Player, GetString("安全拒绝"), _audit.SafetyRejects); - args.Player.SendInfoMessage(GetString($"运行统计:扫描={_runtime.Scans},材料堆={_runtime.Clusters},合成批次={_runtime.CraftBatches},合成次数={_runtime.Crafts},未匹配={_runtime.NoMatches},条件不符={_runtime.ConditionMisses}。")); + args.Player.SendInfoMessage(GetString($"运行统计:扫描={_runtime.Scans},材料堆={_runtime.Clusters},合成批次={_runtime.CraftBatches},合成次数={_runtime.Crafts},未匹配={_runtime.NoMatches},额外材料拒绝={_runtime.ExtraItemTypeRejects},条件不符={_runtime.ConditionMisses}。")); } } diff --git a/src/GroundCraft/GroundCraft.Crafting.cs b/src/GroundCraft/GroundCraft.Crafting.cs index f1fbacc5c..be1776c30 100644 --- a/src/GroundCraft/GroundCraft.Crafting.cs +++ b/src/GroundCraft/GroundCraft.Crafting.cs @@ -21,6 +21,8 @@ private void OnGameUpdate(EventArgs args) return; _ticks++; + UpdateCraftAnimations(); + if (_ticks % Math.Max(1, _config.ScanIntervalTicks) != 0) return; @@ -67,6 +69,12 @@ private List CollectStableDrops() for (int i = 0; i < max; i++) { WorldItem item = Main.item[i]; + if (_lockedItemIndexes.Contains(i)) + { + _stableScans.Remove(i); + continue; + } + if (!IsUsableDrop(item)) { _stableScans.Remove(i); @@ -127,6 +135,37 @@ private bool TryCraftCluster(List cluster) { Vector2 center = AverageCenter(cluster); EnvironmentSnapshot snapshot = ProbeEnvironment(center); + bool rejectedForExtraTypes = false; + + if (_config.AnimateConsumedItems) + { + Dictionary available = CountItems(cluster); + if (available.Count == 0) + return false; + + foreach (DropRecipe recipe in _recipes) + { + if (!RecipeCanCraft(recipe, available, snapshot, center, ref rejectedForExtraTypes, out Vector2 stationCenter, out int craftCount)) + continue; + + Vector2 craftCenter = IsZenithRecipe(recipe) ? stationCenter : center; + if (!TryStartCraftAnimation(cluster, recipe, craftCount, craftCenter, out _)) + { + _runtime.ConsumeFailures++; + return false; + } + + int outputStack = recipe.OutputStack * craftCount; + TShock.Log.ConsoleInfo(GetString($"[GroundCraft] {recipe.Id} 在 {craftCenter.X / 16f:0}, {craftCenter.Y / 16f:0} 开始合成 {ItemName(recipe.OutputType)} x{outputStack}。")); + return true; + } + + if (rejectedForExtraTypes) + _runtime.ExtraItemTypeRejects++; + + return false; + } + bool craftedAny = false; int maxRecipePasses = Math.Max(1, _recipes.Count); HashSet craftedRecipeSignatures = new(StringComparer.Ordinal); @@ -143,25 +182,10 @@ private bool TryCraftCluster(List cluster) if (craftedRecipeSignatures.Contains(recipe.Signature)) continue; - if (!HasIngredients(available, recipe)) - continue; - - if (!ConditionsMatch(recipe, snapshot)) - { - _runtime.ConditionMisses++; - continue; - } - - if (!HasRequiredStation(recipe, center)) - { - _runtime.StationMisses++; - continue; - } - - int craftCount = GetCraftCount(available, recipe); - if (craftCount <= 0) + if (!RecipeCanCraft(recipe, available, snapshot, center, ref rejectedForExtraTypes, out Vector2 stationCenter, out int craftCount)) continue; + Vector2 craftCenter = IsZenithRecipe(recipe) ? stationCenter : center; if (!ConsumeIngredients(cluster, recipe, craftCount, out Dictionary consumedStacks)) { _runtime.ConsumeFailures++; @@ -169,12 +193,17 @@ private bool TryCraftCluster(List cluster) } int outputStack = recipe.OutputStack * craftCount; - SpawnItem(recipe.OutputType, outputStack, center); + SpawnItem(recipe.OutputType, outputStack, craftCenter); + if (IsZenithRecipe(recipe)) + SpawnZenithFinale(craftCenter); + else + SpawnCraftEffect(craftCenter); + _runtime.CraftBatches++; _runtime.Crafts += craftCount; - TShock.Log.ConsoleInfo(GetString($"[GroundCraft] {recipe.Id} 在 {center.X / 16f:0}, {center.Y / 16f:0} 合成 {ItemName(recipe.OutputType)} x{outputStack}。")); - NotifyNearby(center, recipe, outputStack, consumedStacks); + TShock.Log.ConsoleInfo(GetString($"[GroundCraft] {recipe.Id} 在 {craftCenter.X / 16f:0}, {craftCenter.Y / 16f:0} 合成 {ItemName(recipe.OutputType)} x{outputStack}。")); + NotifyNearby(craftCenter, recipe, outputStack, consumedStacks); craftedRecipeSignatures.Add(recipe.Signature); craftedAny = true; craftedThisPass = true; @@ -185,9 +214,49 @@ private bool TryCraftCluster(List cluster) break; } + if (rejectedForExtraTypes) + _runtime.ExtraItemTypeRejects++; + return craftedAny; } + private bool RecipeCanCraft( + DropRecipe recipe, + IReadOnlyDictionary available, + EnvironmentSnapshot snapshot, + Vector2 center, + ref bool rejectedForExtraTypes, + out Vector2 stationCenter, + out int craftCount) + { + stationCenter = center; + craftCount = 0; + + if (!HasIngredients(available, recipe)) + return false; + + if (_config.RequireExactIngredientTypes && !HasExactIngredientTypes(available, recipe)) + { + rejectedForExtraTypes = true; + return false; + } + + if (!ConditionsMatch(recipe, snapshot)) + { + _runtime.ConditionMisses++; + return false; + } + + if (!TryGetCraftingStationCenter(recipe, center, out stationCenter)) + { + _runtime.StationMisses++; + return false; + } + + craftCount = GetCraftCount(available, recipe); + return craftCount > 0; + } + private static Dictionary CountItems(IEnumerable cluster) { Dictionary counts = new(); @@ -211,6 +280,14 @@ private static bool HasIngredients(IReadOnlyDictionary available, Drop return true; } + private static bool HasExactIngredientTypes(IReadOnlyDictionary available, DropRecipe recipe) + { + if (available.Count != recipe.Ingredients.Count) + return false; + + return available.Keys.All(recipe.Ingredients.ContainsKey); + } + private int GetCraftCount(IReadOnlyDictionary available, DropRecipe recipe) { int craftCount = int.MaxValue; @@ -231,6 +308,78 @@ private int GetCraftCount(IReadOnlyDictionary available, DropRecipe re return Math.Min(craftCount, Math.Max(1, _config.MaxCraftsPerClusterPerScan)); } + private bool TryStartCraftAnimation( + IEnumerable cluster, + DropRecipe recipe, + int craftCount, + Vector2 center, + out Dictionary consumedStacks) + { + consumedStacks = new Dictionary(); + Dictionary remaining = recipe.Ingredients.ToDictionary(p => p.Key, p => p.Value * craftCount); + List plan = new(); + + foreach (DropRef drop in cluster.OrderBy(d => d.Index)) + { + WorldItem item = drop.Item; + if (!item.active || item.type <= 0 || item.stack <= 0) + continue; + + if (!remaining.TryGetValue(item.type, out int needed) || needed <= 0) + continue; + + int taken = Math.Min(item.stack, needed); + remaining[item.type] = needed - taken; + plan.Add(new IngredientTake(drop, item.type, taken)); + } + + if (remaining.Values.Any(v => v > 0) || plan.Count == 0 || !AnimationPlanStillValid(plan)) + return false; + + List animatedIngredients = new(); + List deferredLeftovers = new(); + foreach (IngredientTake take in plan) + { + DropRef drop = take.Drop; + WorldItem item = Main.item[drop.Index]; + AddCount(consumedStacks, take.Type, take.Stack); + + int itemType = take.Type; + int leftoverStack = item.stack - take.Stack; + Vector2 respawnCenter = drop.Center; + int width = item.width; + int height = item.height; + + item.stack = take.Stack; + _lockedItemIndexes.Add(drop.Index); + LockAnimatedItem(drop.Index); + SyncItemNoGrab(drop.Index); + animatedIngredients.Add(new AnimatedIngredient(drop.Index, itemType, take.Stack, respawnCenter, width, height)); + + if (leftoverStack > 0) + deferredLeftovers.Add(new DeferredLeftover(itemType, leftoverStack, respawnCenter)); + } + + _craftAnimations.Add(new CraftAnimation(recipe, center, recipe.OutputStack * craftCount, craftCount, IsZenithRecipe(recipe), consumedStacks, animatedIngredients, deferredLeftovers)); + return true; + } + + private static bool AnimationPlanStillValid(IEnumerable plan) + { + foreach (IngredientTake take in plan) + { + int index = take.Drop.Index; + if (index < 0 || index >= Main.item.Length || index >= Main.maxItems) + return false; + + WorldItem item = Main.item[index]; + if (!item.active || item.type != take.Type || item.stack < take.Stack || item.width <= 0 || item.height <= 0) + return false; + } + + return true; + } + private bool ConsumeIngredients(IEnumerable cluster, DropRecipe recipe, int craftCount, out Dictionary consumedStacks) { consumedStacks = new Dictionary(); @@ -315,6 +464,11 @@ private static void SyncItem(int index) NetMessage.SendData(MessageID.SyncItem, -1, -1, null, index); } + private static void SyncItemNoGrab(int index) + { + NetMessage.SendData(MessageID.SyncItem, -1, -1, null, index, 1f); + } + private void ClearConsumedItem(int index) { if (!_config.ClearClientGhostItems) @@ -329,8 +483,9 @@ private void ClearConsumedItem(int index) NetMessage.SendData(MessageID.SyncItem, -1, -1, null, index); } - private bool HasRequiredStation(DropRecipe recipe, Vector2 center) + private bool TryGetCraftingStationCenter(DropRecipe recipe, Vector2 center, out Vector2 stationCenter) { + stationCenter = center; if (recipe.RequiredTiles.Length == 0) return true; @@ -347,13 +502,21 @@ private bool HasRequiredStation(DropRecipe recipe, Vector2 center) ITile tile = Framing.GetTileSafely(x, y); if (tile.active() && TileMatchesAny(recipe.RequiredTiles, tile.type)) + { + stationCenter = new Vector2(x * 16f + 8f, y * 16f + 8f); return true; + } } } return false; } + private static bool IsZenithRecipe(DropRecipe recipe) + { + return recipe.OutputType == ItemID.Zenith; + } + private static bool TileMatchesAny(IReadOnlyCollection requiredTiles, int actualTile) { foreach (int requiredTile in requiredTiles) diff --git a/src/GroundCraft/GroundCraft.Effects.cs b/src/GroundCraft/GroundCraft.Effects.cs new file mode 100644 index 000000000..e838bb18c --- /dev/null +++ b/src/GroundCraft/GroundCraft.Effects.cs @@ -0,0 +1,23 @@ +using Microsoft.Xna.Framework; +using Terraria; +using Terraria.ID; + +namespace GroundCraft; + +public sealed partial class GroundCraft +{ + private void SpawnCraftEffect(Vector2 center) + { + NetMessage.SendData(MessageID.SpecialFX, -1, -1, null, 2, (int)center.X, (int)center.Y, 0f, 2); + } + + private static void SpawnZenithFinale(Vector2 center) + { + for (int color = 0; color < 3; color++) + { + float angle = MathHelper.TwoPi * color / 3f; + Vector2 point = center + new Vector2(MathF.Cos(angle) * 18f, MathF.Sin(angle) * 10f); + NetMessage.SendData(MessageID.SpecialFX, -1, -1, null, 2, (int)point.X, (int)point.Y, 0f, color); + } + } +} diff --git a/src/GroundCraft/GroundCraft.Models.cs b/src/GroundCraft/GroundCraft.Models.cs index 980b5c62d..4121c933f 100644 --- a/src/GroundCraft/GroundCraft.Models.cs +++ b/src/GroundCraft/GroundCraft.Models.cs @@ -13,6 +13,33 @@ public sealed partial class GroundCraft { private sealed record DropRef(int Index, WorldItem Item, Vector2 Center); + private sealed record IngredientTake(DropRef Drop, int Type, int Stack); + + private sealed record AnimatedIngredient(int Index, int Type, int Stack, Vector2 StartCenter, int Width, int Height); + + private sealed record DeferredLeftover(int Type, int Stack, Vector2 Center); + + private sealed class CraftAnimation( + DropRecipe Recipe, + Vector2 Center, + int OutputStack, + int CraftCount, + bool IsZenith, + Dictionary ConsumedStacks, + List Ingredients, + List Leftovers) + { + public DropRecipe Recipe { get; } = Recipe; + public Vector2 Center { get; } = Center; + public int OutputStack { get; } = OutputStack; + public int CraftCount { get; } = CraftCount; + public bool IsZenith { get; } = IsZenith; + public Dictionary ConsumedStacks { get; } = ConsumedStacks; + public List Ingredients { get; } = Ingredients; + public List Leftovers { get; } = Leftovers; + public int Age { get; set; } + } + private sealed record EnvironmentSnapshot(string Layer, HashSet Biomes, HashSet Liquids); private sealed record RecipeCandidate( @@ -94,6 +121,7 @@ private sealed class RuntimeStats public long Crafts { get; set; } public long CraftBatches { get; set; } public long NoMatches { get; set; } + public long ExtraItemTypeRejects { get; set; } public long StationMisses { get; set; } public long ConditionMisses { get; set; } public long ConsumeFailures { get; set; } @@ -110,7 +138,9 @@ public sealed class GroundCraftConfig public int StationSearchRadiusTiles { get; set; } = 7; public int EnvironmentSearchRadiusTiles { get; set; } = 8; public int BiomePlayerProbeRadiusTiles { get; set; } = 40; - public int MaxCraftsPerClusterPerScan { get; set; } = 1; + public int MaxCraftsPerClusterPerScan { get; set; } = 25; + public bool RequireExactIngredientTypes { get; set; } = true; + public bool AnimateConsumedItems { get; set; } = true; public int NotifyRadiusTiles { get; set; } = 24; public bool NotifyPlayers { get; set; } = true; public bool NotifyConsumedItems { get; set; } = true; @@ -167,6 +197,7 @@ public static RecipeFile Default() Recipe("campfire_ground", ItemID.Campfire, 1, new[] { I(ItemID.Wood, 10), I(ItemID.Torch, 5) }), Recipe("glass_near_furnace", ItemID.Glass, 4, new[] { I(ItemID.SandBlock, 4), I(ItemID.Torch, 1) }, new[] { (int)TileID.Furnaces }), Recipe("bottle_near_workbench", ItemID.Bottle, 2, new[] { I(ItemID.Glass, 2), I(ItemID.Wood, 1) }, new[] { (int)TileID.WorkBenches }), + Recipe("water_candle_ground", ItemID.WaterCandle, 1, new[] { I(ItemID.Torch, 3), I(ItemID.BottledWater, 1), I(ItemID.FallenStar, 1) }, new[] { (int)TileID.WorkBenches }), Recipe("silk_near_loom", ItemID.Silk, 1, new[] { I(ItemID.Cobweb, 7), I(ItemID.Wood, 1) }, new[] { (int)TileID.Loom }), Recipe("snow_brick_in_snow", ItemID.SnowBrick, 5, new[] { I(ItemID.SnowBlock, 4), I(ItemID.IceBlock, 1) }, conditions: new ConditionsSpec { Biomes = new List { "Snow" } }), Recipe("cloud_in_sky", ItemID.Cloud, 8, new[] { I(ItemID.Feather, 1), I(ItemID.BottledWater, 1) }, conditions: new ConditionsSpec { Layers = new List { "Sky" } }), @@ -175,10 +206,14 @@ public static RecipeFile Default() Recipe("cactus_in_desert", ItemID.Cactus, 4, new[] { I(ItemID.SandBlock, 6), I(ItemID.BottledWater, 1) }, conditions: new ConditionsSpec { Biomes = new List { "Desert" } }), Recipe("glowing_mushroom_in_mushroom_biome", ItemID.GlowingMushroom, 3, new[] { I(ItemID.MudBlock, 6), I(ItemID.Moonglow, 1) }, conditions: new ConditionsSpec { Biomes = new List { "Mushroom" } }), Recipe("enchanted_nightcrawler_surface", ItemID.EnchantedNightcrawler, 1, new[] { I(ItemID.Worm, 1), I(ItemID.FallenStar, 1) }, conditions: new ConditionsSpec { Layers = new List { "Surface" } }), + Recipe("life_crystal_ground", ItemID.LifeCrystal, 1, new[] { I(ItemID.GoldBar, 8), I(ItemID.FallenStar, 5), I(ItemID.HealingPotion, 3) }, new[] { (int)TileID.Anvils }), + Recipe("wormhole_potion_ground", ItemID.WormholePotion, 1, new[] { I(ItemID.BottledWater, 1), I(ItemID.Blinkroot, 1), I(ItemID.FallenStar, 1), I(ItemID.RecallPotion, 1) }, new[] { (int)TileID.Bottles }), Recipe("suspicious_eye_before_eye", ItemID.SuspiciousLookingEye, 1, new[] { I(ItemID.Lens, 6), I(ItemID.FallenStar, 2) }, conditions: new ConditionsSpec { BossProgress = new BossProgressSpec { NoneDowned = new List { "EyeOfCthulhu" } } }), + Recipe("bloody_tear_graveyard", 4271, 1, new[] { I(ItemID.Deathweed, 3), I(ItemID.Lens, 2), I(ItemID.FallenStar, 1), I(ItemID.BottledWater, 1) }, new[] { (int)TileID.DemonAltar }, conditions: new ConditionsSpec { Biomes = new List { "Graveyard" } }), Recipe("worm_food_corruption_before_evil_boss", ItemID.WormFood, 1, new[] { I(ItemID.RottenChunk, 15), I(ItemID.VilePowder, 10) }, conditions: new ConditionsSpec { Biomes = new List { "Corruption" }, BossProgress = new BossProgressSpec { NoneDowned = new List { "EaterOfWorldsOrBrain" } } }), Recipe("bloody_spine_crimson_before_evil_boss", ItemID.BloodySpine, 1, new[] { I(ItemID.Vertebrae, 15), I(ItemID.ViciousPowder, 10) }, conditions: new ConditionsSpec { Biomes = new List { "Crimson" }, BossProgress = new BossProgressSpec { NoneDowned = new List { "EaterOfWorldsOrBrain" } } }), Recipe("hellstone_bar_underworld_hellforge", ItemID.HellstoneBar, 1, new[] { I(ItemID.Hellstone, 3), I(ItemID.Obsidian, 1) }, new[] { (int)TileID.Hellforge }, new ConditionsSpec { Layers = new List { "Underworld" } }), + Recipe("zenith_ground_mythril_anvil", ItemID.Zenith, 1, new[] { I(ItemID.CopperShortsword, 1), I(ItemID.EnchantedSword, 1), I(ItemID.Starfury, 1), I(ItemID.BeeKeeper, 1), I(ItemID.Seedler, 1), I(ItemID.TheHorsemansBlade, 1), I(ItemID.InfluxWaver, 1), I(ItemID.TerraBlade, 1), I(ItemID.Meowmere, 1), I(ItemID.StarWrath, 1) }, new[] { (int)TileID.MythrilAnvil }), Recipe("disabled_example_after_any_mech", ItemID.CrystalShard, 2, new[] { I(ItemID.PixieDust, 4), I(ItemID.FallenStar, 1) }, conditions: new ConditionsSpec { BossProgress = new BossProgressSpec { Hardmode = true, AllDowned = new List { "MechBossAny" } } }, enabled: false) } }; diff --git a/src/GroundCraft/GroundCraft.cs b/src/GroundCraft/GroundCraft.cs index d8ff9b7e1..4ec682781 100644 --- a/src/GroundCraft/GroundCraft.cs +++ b/src/GroundCraft/GroundCraft.cs @@ -15,6 +15,20 @@ public sealed partial class GroundCraft : TerrariaPlugin private const int Anywhere = -1; private const string DefaultPlayerPermission = "tshock.canchat"; private const string DefaultAdminPermission = "groundcraft.admin"; + private const int CraftAnimationTicks = 72; + private const int CraftAnimationSyncEveryTicks = 1; + private const int CraftAnimationNoGrabDelay = 120; + private const float CraftAnimationLiftPixels = 104f; + private const float CraftAnimationOrbitRadiusPixels = 30f; + private const float CraftAnimationTurns = 1.65f; + private const int ZenithAnimationTicks = 210; + private const int ZenithAnimationSyncEveryTicks = 2; + private const float ZenithAnimationLiftPixels = 240f; + private const float ZenithAnimationOrbitRadiusPixels = 136f; + private const float ZenithAnimationTurns = 4.5f; + private const float ZenithAnimationParabolaPixels = 76f; + private const int ZenithTrailEveryTicks = 30; + private const int ZenithTrailOrderOffsetTicks = 3; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -29,17 +43,20 @@ public sealed partial class GroundCraft : TerrariaPlugin private readonly Dictionary _stableScans = new(); private readonly RuntimeStats _runtime = new(); private readonly object _stateLock = new(); + private readonly List _craftAnimations = new(); + private readonly HashSet _lockedItemIndexes = new(); private GroundCraftConfig _config = GroundCraftConfig.Default(); private List _recipes = new(); private RecipeAudit _audit = new(); private int _ticks; private bool _hooked; + private bool _netHooked; public override string Name => "GroundCraft"; public override string Author => "愚蠢"; public override string Description => GetString("地上合成:把掉落物丢在一起,根据 JSON 配方和环境/进度条件自动合成。"); - public override Version Version => new(1, 0, 0); + public override Version Version => new(1, 1, 0); private static string DataDirectory => Path.Combine(TShock.SavePath, "GroundCraft"); private static string ConfigPath => Path.Combine(DataDirectory, "config.json"); @@ -77,6 +94,8 @@ public override void Initialize() ServerApi.Hooks.GameUpdate.Register(this, OnGameUpdate); _hooked = true; + ServerApi.Hooks.NetGetData.Register(this, OnGetData, int.MinValue); + _netHooked = true; } protected override void Dispose(bool disposing) @@ -88,6 +107,13 @@ protected override void Dispose(bool disposing) ServerApi.Hooks.GameUpdate.Deregister(this, OnGameUpdate); _hooked = false; } + if (_netHooked) + { + ServerApi.Hooks.NetGetData.Deregister(this, OnGetData); + _netHooked = false; + } + + ReleaseCraftAnimations(); foreach (Command command in _commands) Commands.ChatCommands.Remove(command); diff --git a/src/GroundCraft/README.en-US.md b/src/GroundCraft/README.en-US.md index 3ca290b7e..bdb076312 100644 --- a/src/GroundCraft/README.en-US.md +++ b/src/GroundCraft/README.en-US.md @@ -24,6 +24,8 @@ Files are generated under `tshock/GroundCraft/`. Use `/gcreload` to reload both JSON files without restarting the server. +`requireExactIngredientTypes` is enabled by default. Extra dropped item types in the same cluster will prevent a similar recipe from firing accidentally. `animateConsumedItems` is also enabled by default: consumed drops are locked as unpickable visual items, spiral upward, and only then turn into the output. Zenith recipes use a taller and wider dedicated animation. In animation mode, each item cluster starts at most one craft animation per scan, and `maxCraftsPerClusterPerScan` limits the batch count inside that animation. If animation is disabled, the plugin continues trying other matching recipes during the same scan. + ## Recipe Conditions Supported condition groups: @@ -40,10 +42,19 @@ Supported condition groups: - Coin recipes are rejected by default. - Input-output self loops are rejected by default. - Ambiguous recipes with the same inputs, stations and conditions but different outputs are rejected. +- Exact ingredient type matching is enabled by default to avoid accidental similar-recipe crafts. - Consumed world items are cleared and synchronized to avoid stale client-side ghost drops. ## Changelog +### v1.1.0 + +- Added a spiral fusion animation. Consumed drops are locked while animating and the output is spawned at completion. +- Added a taller, wider dedicated Zenith animation around a Mythril/Orichalcum Anvil. +- Added default recipe examples for Water Candle, Life Crystal, Wormhole Potion, Bloody Tear and Zenith. +- Enabled exact ingredient type matching by default to reduce similar-recipe mistakes. +- Increased the default batch limit to 25 crafts per cluster per scan. + ### v1.0.0 - Initial version: JSON recipes, JSON conditions, layer/biome/liquid checks, boss progress checks, hot reload, recipe audit and consumed drop cleanup. diff --git a/src/GroundCraft/README.md b/src/GroundCraft/README.md index daebc12db..803a220f2 100644 --- a/src/GroundCraft/README.md +++ b/src/GroundCraft/README.md @@ -34,7 +34,9 @@ "stationSearchRadiusTiles": 7, "environmentSearchRadiusTiles": 8, "biomePlayerProbeRadiusTiles": 40, - "maxCraftsPerClusterPerScan": 1, + "maxCraftsPerClusterPerScan": 25, + "requireExactIngredientTypes": true, + "animateConsumedItems": true, "notifyRadiusTiles": 24, "notifyPlayers": true, "notifyConsumedItems": true, @@ -47,6 +49,8 @@ } ``` +`requireExactIngredientTypes` 默认开启。材料堆中如果混入配方外物品,不会触发相似配方,避免掉落物合成错乱。`animateConsumedItems` 默认开启,被消耗的真实掉落物会在不可拾取状态下螺旋上升,动画完成后才生成产物;天顶剑配方会使用更高、更大的专属环绕动画。动画模式下每个材料堆每次扫描只会启动一个合成动画,`maxCraftsPerClusterPerScan` 表示该动画内同一配方最多批量合成多少批;关闭动画后会在同一次扫描中继续尝试其它可用配方。 + ## 配方 JSON `requiredTiles` 使用 Terraria 图格 ID;空数组表示任意地点。`conditions` 可同时限制层级、生物群系、附近液体和 Boss 进度。 @@ -92,10 +96,19 @@ - 默认拒绝钱币配方。 - 默认拒绝材料和产物相同的循环配方。 - 同材料、同工作站、同条件但产物不同的配方会被拒绝,避免歧义。 +- 默认要求材料堆的物品种类与配方完全一致,避免相似配方误触发。 - 合成时会清除被消耗的掉落物并同步消失包;若客户端仍短暂显示旧材料,会提示玩家那是残影,实际已不存在。 ## 更新日志 +### v1.1.0 + +- 新增螺旋融合动画:合成材料动画期间不可拾取,完成后生成产物并清理客户端残影。 +- 天顶剑默认配方使用秘银/山铜砧,并带有更高、更大的专属环绕动画和最终 Fairy 收束。 +- 默认配方补充水蜡烛、生命水晶、虫洞药水、血泪和天顶剑示例。 +- 默认开启精确材料种类匹配,减少相似配方导致的误合成。 +- 默认批量合成上限调整为每堆每轮 25 批。 + ### v1.0.0 - 初版:JSON 配方、JSON 条件、地形/环境判断、Boss 进度判断、热重载、配方审核、旧掉落物残影清理。 diff --git a/src/GroundCraft/examples/config.example.json b/src/GroundCraft/examples/config.example.json index 0b5145265..003c9cadc 100644 --- a/src/GroundCraft/examples/config.example.json +++ b/src/GroundCraft/examples/config.example.json @@ -7,7 +7,9 @@ "stationSearchRadiusTiles": 7, "environmentSearchRadiusTiles": 8, "biomePlayerProbeRadiusTiles": 40, - "maxCraftsPerClusterPerScan": 1, + "maxCraftsPerClusterPerScan": 25, + "requireExactIngredientTypes": true, + "animateConsumedItems": true, "notifyRadiusTiles": 24, "notifyPlayers": true, "notifyConsumedItems": true, diff --git a/src/GroundCraft/manifest.json b/src/GroundCraft/manifest.json index b632b5caa..ef96e45be 100644 --- a/src/GroundCraft/manifest.json +++ b/src/GroundCraft/manifest.json @@ -1,8 +1,8 @@ { "README.en-US": { - "Description": "Craft items by dropping ingredients together, with JSON recipes and conditions" + "Description": "Craft items by dropping ingredients together, with JSON recipes, conditions, exact matching and spiral animations" }, "README": { - "Description": "把掉落物丢在一起自动合成,支持 JSON 配方与条件" + "Description": "把掉落物丢在一起自动合成,支持 JSON 配方、条件、精确匹配和螺旋动画" } }