Add MVP crafting HUD

This commit is contained in:
2026-05-17 17:57:17 -07:00
parent ef658a380e
commit 3509641df8
10 changed files with 254 additions and 10 deletions
+3 -1
View File
@@ -568,7 +568,9 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
data assets, using wood, fiber, and hide as MVP ingredients for later data assets, using wood, fiber, and hide as MVP ingredients for later
placed-storage/container systems. placed-storage/container systems.
- [x] Add bandage or basic treatment recipe. - [x] Add bandage or basic treatment recipe.
- [ ] Add crafting UI. - [x] Add crafting UI. Added a compact crafting HUD panel that lists known MVP
recipes, shows current ingredient counts, highlights craftable rows, and
preloads the Agrarian player Blueprint with primitive recipe assets.
- [x] Add multiplayer authority checks. - [x] Add multiplayer authority checks.
- [~] Add crafting debug tools. - [~] Add crafting debug tools.
+6
View File
@@ -147,6 +147,12 @@ array, shows occupied slots, total carried weight, and a short visible stack
list, and leaves mutation actions on the existing server-authoritative commands list, and leaves mutation actions on the existing server-authoritative commands
and RPCs until a full UMG inventory screen is introduced. and RPCs until a full UMG inventory screen is introduced.
The MVP crafting UI is a compact `AAgrarianDebugHUD` crafting panel. The
Agrarian player Blueprint preloads primitive `KnownRecipeAssets`, and the HUD
reads those recipes plus the replicated inventory to show craftable status and
ingredient counts. Interactive UMG recipe browsing, hotkeys, and queued
crafting controls are deferred until the primitive loop settles.
Inventory persistence saves `UAgrarianInventoryComponent::Items` into Inventory persistence saves `UAgrarianInventoryComponent::Items` into
`FAgrarianSavedPlayer::Inventory` and restores through `FAgrarianSavedPlayer::Inventory` and restores through
`UAgrarianInventoryComponent::RestoreSavedItems`. Restore broadcasts `UAgrarianInventoryComponent::RestoreSavedItems`. Restore broadcasts
@@ -10,6 +10,17 @@ DEST_CHARACTER = f"{DEST_ROOT}/BP_AgrarianPlayerCharacter"
DEST_CONTROLLER = f"{DEST_ROOT}/BP_AgrarianPlayerController" DEST_CONTROLLER = f"{DEST_ROOT}/BP_AgrarianPlayerController"
DEST_GAME_MODE = f"{DEST_ROOT}/BP_AgrarianGameMode" DEST_GAME_MODE = f"{DEST_ROOT}/BP_AgrarianGameMode"
KNOWN_RECIPE_ASSETS = [
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_Campfire",
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveFrame",
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveWallPanel",
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveRoofPanel",
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveShelter",
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_BasicTool",
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_SimpleContainer",
"/Game/Agrarian/DataAssets/Recipes/DA_Recipe_Bandage",
]
def ensure_directory(path): def ensure_directory(path):
if not unreal.EditorAssetLibrary.does_directory_exist(path): if not unreal.EditorAssetLibrary.does_directory_exist(path):
@@ -40,6 +51,13 @@ def load_blueprint_class(path):
return generated_class return generated_class
def load_required_asset(path):
asset = unreal.EditorAssetLibrary.load_asset(path)
if not asset:
raise RuntimeError(f"Required asset not found: {path}")
return asset
def main(): def main():
ensure_directory(DEST_ROOT) ensure_directory(DEST_ROOT)
@@ -49,11 +67,21 @@ def main():
character_class = load_blueprint_class(DEST_CHARACTER) character_class = load_blueprint_class(DEST_CHARACTER)
controller_class = load_blueprint_class(DEST_CONTROLLER) controller_class = load_blueprint_class(DEST_CONTROLLER)
character_cdo = unreal.get_default_object(character_class)
game_mode_cdo = unreal.get_default_object(game_mode_bp.generated_class()) game_mode_cdo = unreal.get_default_object(game_mode_bp.generated_class())
game_mode_cdo.set_editor_property("default_pawn_class", character_class) game_mode_cdo.set_editor_property("default_pawn_class", character_class)
game_mode_cdo.set_editor_property("player_controller_class", controller_class) game_mode_cdo.set_editor_property("player_controller_class", controller_class)
crafting_component = character_cdo.get_editor_property("crafting_component")
if not crafting_component:
raise RuntimeError("BP_AgrarianPlayerCharacter is missing CraftingComponent")
crafting_component.set_editor_property(
"known_recipe_assets",
[load_required_asset(path) for path in KNOWN_RECIPE_ASSETS],
)
unreal.BlueprintEditorLibrary.compile_blueprint(character_bp)
unreal.EditorAssetLibrary.save_loaded_asset(character_bp) unreal.EditorAssetLibrary.save_loaded_asset(character_bp)
unreal.EditorAssetLibrary.save_loaded_asset(controller_bp) unreal.EditorAssetLibrary.save_loaded_asset(controller_bp)
unreal.EditorAssetLibrary.save_loaded_asset(game_mode_bp) unreal.EditorAssetLibrary.save_loaded_asset(game_mode_bp)
+23 -1
View File
@@ -4,6 +4,16 @@ import unreal
CHARACTER_BLUEPRINT_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianPlayerCharacter" CHARACTER_BLUEPRINT_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianPlayerCharacter"
CONTROLLER_BLUEPRINT_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianPlayerController" CONTROLLER_BLUEPRINT_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianPlayerController"
GAME_MODE_BLUEPRINT_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianGameMode" GAME_MODE_BLUEPRINT_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianGameMode"
EXPECTED_RECIPE_IDS = [
"campfire",
"primitive_frame",
"primitive_wall_panel",
"primitive_roof_panel",
"primitive_shelter",
"basic_tool",
"simple_container",
"bandage",
]
def class_path(value): def class_path(value):
@@ -48,6 +58,19 @@ def main():
character_cdo = unreal.get_default_object(character_class) character_cdo = unreal.get_default_object(character_class)
if not character_cdo: if not character_cdo:
failures.append(f"{CHARACTER_BLUEPRINT_PATH} CDO missing") failures.append(f"{CHARACTER_BLUEPRINT_PATH} CDO missing")
else:
crafting_component = character_cdo.get_editor_property("crafting_component")
recipe_assets = crafting_component.get_editor_property("known_recipe_assets") if crafting_component else []
recipe_ids = [
str(asset.get_editor_property("recipe").get_editor_property("recipe_id"))
for asset in recipe_assets
if asset
]
if recipe_ids != EXPECTED_RECIPE_IDS:
failures.append(
"BP_AgrarianPlayerCharacter known recipes expected "
f"{EXPECTED_RECIPE_IDS}, got {recipe_ids}"
)
controller_cdo = unreal.get_default_object(controller_class) controller_cdo = unreal.get_default_object(controller_class)
if not controller_cdo: if not controller_cdo:
@@ -63,4 +86,3 @@ def main():
main() main()
+63
View File
@@ -0,0 +1,63 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXPECTED = {
ROOT / "Source" / "AgrarianGame" / "AgrarianDebugHUD.h": [
"bool bShowCraftingHUD = true;",
"int32 MaxCraftingPanelRows = 8;",
"void DrawCraftingPanel",
],
ROOT / "Source" / "AgrarianGame" / "AgrarianDebugHUD.cpp": [
"DrawCraftingPanel(AgrarianCharacter",
"CraftingComponent->GetKnownRecipes(Recipes)",
"CraftingComponent->CanCraft(Recipe.RecipeId, FailureReason)",
"InventoryComponent->GetItemCount(Ingredient.ItemId)",
"CRAFTING %d recipes",
],
ROOT / "Source" / "AgrarianGame" / "AgrarianCraftingComponent.h": [
"void GetKnownRecipes(TArray<FAgrarianRecipe>& OutRecipes) const;",
],
ROOT / "Source" / "AgrarianGame" / "AgrarianCraftingComponent.cpp": [
"void UAgrarianCraftingComponent::GetKnownRecipes",
"RecipeAsset->Recipe.RecipeId != NAME_None",
"OutRecipes.Add(RecipeAsset->Recipe)",
],
ROOT / "Scripts" / "setup_agrarian_player_blueprints.py": [
"KNOWN_RECIPE_ASSETS",
"DA_Recipe_SimpleContainer",
"known_recipe_assets",
],
ROOT / "Scripts" / "verify_agrarian_player_blueprints.py": [
"EXPECTED_RECIPE_IDS",
"simple_container",
"known_recipe_assets",
],
ROOT / "Docs" / "TechnicalDesignDocument.md": [
"The MVP crafting UI is a compact `AAgrarianDebugHUD` crafting panel",
"`KnownRecipeAssets`",
],
ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md": [
"- [x] Add crafting UI.",
"compact crafting HUD panel",
],
}
def main():
missing = []
for path, snippets in EXPECTED.items():
text = path.read_text(encoding="utf-8")
for snippet in snippets:
if snippet not in text:
missing.append(f"{path.relative_to(ROOT)} missing {snippet!r}")
if missing:
raise RuntimeError("Crafting UI verification failed: " + "; ".join(missing))
print("PASS: crafting UI HUD panel and known recipe wiring are present.")
if __name__ == "__main__":
main()
@@ -133,6 +133,27 @@ bool UAgrarianCraftingComponent::FindRecipe(FName RecipeId, FAgrarianRecipe& Out
return false; return false;
} }
void UAgrarianCraftingComponent::GetKnownRecipes(TArray<FAgrarianRecipe>& OutRecipes) const
{
OutRecipes.Reset();
for (const UAgrarianRecipeDataAsset* RecipeAsset : KnownRecipeAssets)
{
if (RecipeAsset && RecipeAsset->Recipe.RecipeId != NAME_None)
{
OutRecipes.Add(RecipeAsset->Recipe);
}
}
for (const FAgrarianRecipe& Recipe : KnownRecipes)
{
if (Recipe.RecipeId != NAME_None)
{
OutRecipes.Add(Recipe);
}
}
}
UAgrarianInventoryComponent* UAgrarianCraftingComponent::GetInventory() const UAgrarianInventoryComponent* UAgrarianCraftingComponent::GetInventory() const
{ {
return GetOwner() ? GetOwner()->FindComponentByClass<UAgrarianInventoryComponent>() : nullptr; return GetOwner() ? GetOwner()->FindComponentByClass<UAgrarianInventoryComponent>() : nullptr;
@@ -48,6 +48,9 @@ public:
UFUNCTION(BlueprintCallable, Category = "Agrarian|Crafting") UFUNCTION(BlueprintCallable, Category = "Agrarian|Crafting")
bool FindRecipe(FName RecipeId, FAgrarianRecipe& OutRecipe) const; bool FindRecipe(FName RecipeId, FAgrarianRecipe& OutRecipe) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Crafting")
void GetKnownRecipes(TArray<FAgrarianRecipe>& OutRecipes) const;
protected: protected:
UAgrarianInventoryComponent* GetInventory() const; UAgrarianInventoryComponent* GetInventory() const;
void FailCraft(FName RecipeId, const FText& Reason); void FailCraft(FName RecipeId, const FText& Reason);
+96 -5
View File
@@ -1,6 +1,7 @@
// Copyright Pacificao. All Rights Reserved. // Copyright Pacificao. All Rights Reserved.
#include "AgrarianDebugHUD.h" #include "AgrarianDebugHUD.h"
#include "AgrarianCraftingComponent.h"
#include "AgrarianGameCharacter.h" #include "AgrarianGameCharacter.h"
#include "AgrarianInventoryComponent.h" #include "AgrarianInventoryComponent.h"
#include "AgrarianSurvivalComponent.h" #include "AgrarianSurvivalComponent.h"
@@ -24,7 +25,8 @@ void AAgrarianDebugHUD::DrawHUD()
DrawInteractionPrompt(AgrarianCharacter); DrawInteractionPrompt(AgrarianCharacter);
DrawCriticalStats(AgrarianCharacter->GetSurvivalComponent()); DrawCriticalStats(AgrarianCharacter->GetSurvivalComponent());
DrawInventoryPanel(AgrarianCharacter); const float InventoryBottomY = DrawInventoryPanel(AgrarianCharacter);
DrawCraftingPanel(AgrarianCharacter, InventoryBottomY + 16.0f);
if (bShowDebugHUD) if (bShowDebugHUD)
{ {
@@ -110,17 +112,17 @@ void AAgrarianDebugHUD::DrawCriticalStats(const UAgrarianSurvivalComponent* Surv
DrawScaledLine(FString::Printf(TEXT("Sickness %3.0f"), Survival.SicknessSeverity), X, Y, CriticalStatsTextScale, StatusColor(Survival.SicknessSeverity, true)); DrawScaledLine(FString::Printf(TEXT("Sickness %3.0f"), Survival.SicknessSeverity), X, Y, CriticalStatsTextScale, StatusColor(Survival.SicknessSeverity, true));
} }
void AAgrarianDebugHUD::DrawInventoryPanel(const AAgrarianGameCharacter* AgrarianCharacter) float AAgrarianDebugHUD::DrawInventoryPanel(const AAgrarianGameCharacter* AgrarianCharacter)
{ {
if (!bShowInventoryHUD || !AgrarianCharacter || !Canvas) if (!bShowInventoryHUD || !AgrarianCharacter || !Canvas)
{ {
return; return 32.0f;
} }
const UAgrarianInventoryComponent* InventoryComponent = AgrarianCharacter->GetInventoryComponent(); const UAgrarianInventoryComponent* InventoryComponent = AgrarianCharacter->GetInventoryComponent();
if (!InventoryComponent) if (!InventoryComponent)
{ {
return; return 32.0f;
} }
const float Scale = FMath::Max(0.25f, InventoryTextScale); const float Scale = FMath::Max(0.25f, InventoryTextScale);
@@ -152,7 +154,7 @@ void AAgrarianDebugHUD::DrawInventoryPanel(const AAgrarianGameCharacter* Agraria
if (InventoryComponent->Items.IsEmpty()) if (InventoryComponent->Items.IsEmpty())
{ {
DrawText(TEXT("Empty"), FColor::Silver, X, Y, nullptr, Scale, false); DrawText(TEXT("Empty"), FColor::Silver, X, Y, nullptr, Scale, false);
return; return (32.0f + PanelHeight);
} }
for (int32 Index = 0; Index < VisibleRows; ++Index) for (int32 Index = 0; Index < VisibleRows; ++Index)
@@ -175,6 +177,95 @@ void AAgrarianDebugHUD::DrawInventoryPanel(const AAgrarianGameCharacter* Agraria
false); false);
Y += LineHeight; Y += LineHeight;
} }
return (32.0f + PanelHeight);
}
void AAgrarianDebugHUD::DrawCraftingPanel(const AAgrarianGameCharacter* AgrarianCharacter, float TopY)
{
if (!bShowCraftingHUD || !AgrarianCharacter || !Canvas)
{
return;
}
const UAgrarianCraftingComponent* CraftingComponent = AgrarianCharacter->GetCraftingComponent();
const UAgrarianInventoryComponent* InventoryComponent = AgrarianCharacter->GetInventoryComponent();
if (!CraftingComponent || !InventoryComponent)
{
return;
}
TArray<FAgrarianRecipe> Recipes;
CraftingComponent->GetKnownRecipes(Recipes);
const float Scale = FMath::Max(0.25f, InventoryTextScale);
const float PanelWidth = 420.0f * Scale;
const float X = FMath::Max(32.0f, Canvas->ClipX - PanelWidth - 32.0f);
float Y = FMath::Max(32.0f, TopY);
const int32 VisibleRows = Recipes.IsEmpty()
? 1
: FMath::Min(Recipes.Num(), FMath::Max(1, MaxCraftingPanelRows));
const float LineHeight = 18.0f * Scale;
const float PanelHeight = (56.0f * Scale) + (VisibleRows * LineHeight);
DrawRect(FLinearColor(0.02f, 0.025f, 0.02f, 0.72f), X - (12.0f * Scale), Y - (10.0f * Scale), PanelWidth, PanelHeight);
DrawText(
FString::Printf(TEXT("CRAFTING %d recipes"), Recipes.Num()),
FColor(160, 220, 140),
X,
Y,
nullptr,
Scale,
false);
Y += 24.0f * Scale;
if (Recipes.IsEmpty())
{
DrawText(TEXT("No known recipes"), FColor::Silver, X, Y, nullptr, Scale, false);
return;
}
for (int32 Index = 0; Index < VisibleRows; ++Index)
{
const FAgrarianRecipe& Recipe = Recipes[Index];
FText FailureReason;
const bool bCanCraft = CraftingComponent->CanCraft(Recipe.RecipeId, FailureReason);
FString RecipeName = Recipe.DisplayName.IsEmpty() ? Recipe.RecipeId.ToString() : Recipe.DisplayName.ToString();
if (RecipeName.Len() > 18)
{
RecipeName = RecipeName.Left(15) + TEXT("...");
}
FString IngredientSummary;
for (const FAgrarianItemStack& Ingredient : Recipe.Ingredients)
{
if (!IngredientSummary.IsEmpty())
{
IngredientSummary += TEXT(" ");
}
IngredientSummary += FString::Printf(
TEXT("%s %d/%d"),
*Ingredient.ItemId.ToString(),
InventoryComponent->GetItemCount(Ingredient.ItemId),
Ingredient.Quantity);
}
if (IngredientSummary.Len() > 28)
{
IngredientSummary = IngredientSummary.Left(25) + TEXT("...");
}
DrawText(
FString::Printf(TEXT("%02d %-18s %-28s"), Index + 1, *RecipeName, *IngredientSummary),
bCanCraft ? FColor(225, 235, 220) : FColor(170, 150, 125),
X,
Y,
nullptr,
Scale,
false);
Y += LineHeight;
}
} }
void AAgrarianDebugHUD::DrawPlayerStatus(const AAgrarianGameCharacter* AgrarianCharacter, float X, float& Y) void AAgrarianDebugHUD::DrawPlayerStatus(const AAgrarianGameCharacter* AgrarianCharacter, float X, float& Y)
+9 -1
View File
@@ -7,6 +7,7 @@
#include "AgrarianDebugHUD.generated.h" #include "AgrarianDebugHUD.generated.h"
class UAgrarianInventoryComponent; class UAgrarianInventoryComponent;
class UAgrarianCraftingComponent;
class UAgrarianSurvivalComponent; class UAgrarianSurvivalComponent;
UCLASS() UCLASS()
@@ -26,6 +27,9 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD")
bool bShowInventoryHUD = true; bool bShowInventoryHUD = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD")
bool bShowCraftingHUD = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD", meta = (ClampMin = "0.25")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD", meta = (ClampMin = "0.25"))
float TextScale = 1.0f; float TextScale = 1.0f;
@@ -38,6 +42,9 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD", meta = (ClampMin = "1", ClampMax = "12")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD", meta = (ClampMin = "1", ClampMax = "12"))
int32 MaxInventoryPanelRows = 6; int32 MaxInventoryPanelRows = 6;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD", meta = (ClampMin = "1", ClampMax = "12"))
int32 MaxCraftingPanelRows = 8;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|HUD")
bool bShowInteractionPrompt = true; bool bShowInteractionPrompt = true;
@@ -47,7 +54,8 @@ public:
protected: protected:
void DrawInteractionPrompt(const class AAgrarianGameCharacter* AgrarianCharacter); void DrawInteractionPrompt(const class AAgrarianGameCharacter* AgrarianCharacter);
void DrawCriticalStats(const UAgrarianSurvivalComponent* SurvivalComponent); void DrawCriticalStats(const UAgrarianSurvivalComponent* SurvivalComponent);
void DrawInventoryPanel(const class AAgrarianGameCharacter* AgrarianCharacter); float DrawInventoryPanel(const class AAgrarianGameCharacter* AgrarianCharacter);
void DrawCraftingPanel(const class AAgrarianGameCharacter* AgrarianCharacter, float TopY);
void DrawPlayerStatus(const class AAgrarianGameCharacter* AgrarianCharacter, float X, float& Y); void DrawPlayerStatus(const class AAgrarianGameCharacter* AgrarianCharacter, float X, float& Y);
void DrawSurvival(const UAgrarianSurvivalComponent* SurvivalComponent, float X, float& Y); void DrawSurvival(const UAgrarianSurvivalComponent* SurvivalComponent, float X, float& Y);
void DrawInventory(const UAgrarianInventoryComponent* InventoryComponent, float X, float& Y); void DrawInventory(const UAgrarianInventoryComponent* InventoryComponent, float X, float& Y);