Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion Plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
]
283 changes: 283 additions & 0 deletions src/GroundCraft/GroundCraft.Animation.cs
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): 取消合成动画时似乎会导致配方材料堆叠数量被复制。

TryStartCraftAnimation 会将每个材料物品的 stack 减少到被消耗的数量,并把多余的部分存放在 DeferredLeftover 中。完成时,CompleteCraftAnimation 会移除这些材料物品(TurnToAir),并生成这些剩余物品,所以总的堆叠数量是正确的。

CancelCraftAnimation 则是把减少过堆叠的物品保留在世界中(只解锁/重置它们),同时仍然调用 SpawnDeferredLeftovers。因此在取消之后,世界中会同时存在被消耗的堆叠和剩余的堆叠,相当于复制了物品。

取消操作要么应该恢复原始堆叠并跳过生成剩余物品,要么像完成路径那样移除减少后的物品,只生成对应数量的堆叠。另一种方案是将任何对 stack 的修改推迟到完成时再执行,这样完成和取消路径都会更简单,并且不会产生重复的物品。

Original comment in English

issue (bug_risk): Cancelling a craft animation appears to duplicate ingredient stacks.

TryStartCraftAnimation reduces each ingredient item’s stack to the consumed amount and stores the remainder in DeferredLeftover. On completion, CompleteCraftAnimation removes the ingredient items (TurnToAir) and spawns the leftovers, so total stacks stay correct.

CancelCraftAnimation instead leaves the reduced-stack items in the world (only unlocking/resetting them) and still calls SpawnDeferredLeftovers. So after cancellation you have both the consumed stacks and the leftovers, effectively duplicating items.

Cancellation should either restore the original stacks and skip spawning leftovers, or mirror completion by removing the reduced items and only spawning the appropriate stacks. Alternatively, defer any stack modification until completion so both completion and cancellation paths are simpler and non-duplicating.

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);
}
}
7 changes: 6 additions & 1 deletion src/GroundCraft/GroundCraft.Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,22 @@ private void GroundCraftInfo(CommandArgs args)
float clusterRadiusTiles;
int requiredStableScans;
int maxCraftsPerClusterPerScan;
bool requireExactIngredientTypes;
bool animateConsumedItems;
lock (_stateLock)
{
enabled = _config.Enabled;
recipeCount = _recipes.Count;
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}。"));
}
Expand Down Expand Up @@ -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}。"));
}
}

Expand Down
Loading
Loading