(Feat) Add Cooking and Workbench

This commit is contained in:
2026-08-01 00:03:53 +02:00
parent 3f3ae1f5fa
commit d50ef564ba
360 changed files with 5316 additions and 67 deletions
@@ -0,0 +1,840 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CookingStation.h"
#include "Components/PointLightComponent.h"
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/World.h"
#include "InventoryComponent.h"
#include "ItemDataAsset.h"
#include "Kismet/GameplayStatics.h"
#include "NiagaraComponent.h"
#include "TimerManager.h"
#define LOCTEXT_NAMESPACE "CookingStation"
ACookingStation::ACookingStation()
{
// Tout passe par des timers : la station n'a rien a faire chaque frame.
PrimaryActorTick.bCanEverTick = false;
BaseMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BaseMesh"));
SetRootComponent(BaseMesh);
// Le foyer est un obstacle physique, contrairement a un ramassable : on
// n'entre pas dans un feu de camp.
BaseMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
InteractionSphere = CreateDefaultSubobject<USphereComponent>(TEXT("InteractionSphere"));
InteractionSphere->SetupAttachment(BaseMesh);
InteractionSphere->InitSphereRadius(90.f);
InteractionSphere->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
InteractionSphere->SetCollisionResponseToAllChannels(ECR_Ignore);
InteractionSphere->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
FireEffect = CreateDefaultSubobject<UNiagaraComponent>(TEXT("FireEffect"));
FireEffect->SetupAttachment(BaseMesh);
// Surtout pas d'auto-activation : un feu neuf n'a pas de bois, il ne doit
// pas flamber une frame avant que BeginPlay ne le coupe.
FireEffect->SetAutoActivate(false);
FireLight = CreateDefaultSubobject<UPointLightComponent>(TEXT("FireLight"));
FireLight->SetupAttachment(BaseMesh);
FireLight->SetRelativeLocation(FVector(0.f, 0.f, 40.f));
// Movable, sinon rien ne fonctionne : une lumiere Static -- le defaut d'un
// composant lumiere -- est cuite dans le lightmap, et l'allumer ou
// l'eteindre a l'execution n'a strictement aucun effet a l'ecran.
FireLight->SetMobility(EComponentMobility::Movable);
FireLight->SetIntensity(3000.f);
FireLight->SetAttenuationRadius(600.f);
FireLight->SetLightColor(FLinearColor(1.f, 0.55f, 0.2f));
// Une lumiere ponctuelle projette ses ombres sur SIX faces de cubemap, a
// chaque frame et pour chaque objet a portee. Sur un feu de camp qu'on
// posera par dizaines, c'est le poste le plus cher du systeme, et de loin.
// Coche-la sur le feu principal du campement si tu la veux, pas partout.
FireLight->CastShadows = false;
FireLight->SetVisibility(false);
SlotMeshes.Reserve(MaxCookingSlots);
for (int32 Index = 0; Index < MaxCookingSlots; ++Index)
{
const FName ComponentName(*FString::Printf(TEXT("CookSlot_%d"), Index));
UStaticMeshComponent* SlotMesh = CreateDefaultSubobject<UStaticMeshComponent>(ComponentName);
SlotMesh->SetupAttachment(BaseMesh);
SlotMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// Visible dans l'editeur pour que tu puisses positionner l'emplacement a
// la souris avec un mesh de reperage ; le jeu le cache au demarrage tant
// qu'il n'y a rien dessus.
SlotMesh->SetHiddenInGame(false);
SlotMeshes.Add(SlotMesh);
}
}
void ACookingStation::BeginPlay()
{
Super::BeginPlay();
Slots.SetNum(MaxCookingSlots);
// Les meshes de reperage poses dans le Blueprint n'ont plus rien a faire la :
// RefreshSlotMesh remet chaque emplacement sur son contenu reel, donc rien.
for (int32 Index = 0; Index < MaxCookingSlots; ++Index)
{
RefreshSlotMesh(Index);
}
// Avant tout usage du carburant : c'est ce recensement qui fixe la capacite.
CacheFuelIndicators();
if (StartingFuelItem && StartingFuelItem->bIsFuel)
{
const int32 Count = FMath::Min(StartingFuelCount, GetFuelCapacity());
for (int32 Index = 0; Index < Count; ++Index)
{
FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef();
NewFuel.Item = StartingFuelItem;
NewFuel.RemainingSeconds = FMath::Max(1.f, StartingFuelItem->FuelDuration);
}
}
RefreshFuelIndicators();
// Le grill est vide au demarrage, donc le feu reste eteint quoi qu'il
// arrive. On passe quand meme par le point de decision unique plutot que
// d'ecrire un cas particulier de plus.
ApplyFireVisuals(false);
UpdateFireState();
}
void ACookingStation::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// Les timers portent un delegue vers this. Les laisser tourner apres la
// destruction de l'acteur est le genre de detail qui ne se voit qu'au
// changement de niveau, sous la forme d'un crash sans pile lisible.
if (UWorld* World = GetWorld())
{
FTimerManager& TimerManager = World->GetTimerManager();
TimerManager.ClearTimer(FuelTimer);
for (FCookingSlotState& Slot : Slots)
{
TimerManager.ClearTimer(Slot.Timer);
}
}
Super::EndPlay(EndPlayReason);
}
// ----------------------------------------------------------------------
// Carburant et allumage
// ----------------------------------------------------------------------
float ACookingStation::GetFuelRemaining() const
{
float Total = 0.f;
for (int32 Index = 0; Index < Fuel.Num(); ++Index)
{
// Feu allume, c'est le timer qui fait autorite pour la buche en cours :
// son RemainingSeconds n'est rafraichi qu'a l'extinction.
if (Index == 0 && bIsLit)
{
Total += FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(FuelTimer));
}
else
{
Total += Fuel[Index].RemainingSeconds;
}
}
return Total;
}
void ACookingStation::SyncFuelFromTimer()
{
if (bIsLit && Fuel.Num() > 0)
{
Fuel[0].RemainingSeconds = FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(FuelTimer));
}
}
void ACookingStation::StartFuelTimer()
{
GetWorldTimerManager().ClearTimer(FuelTimer);
if (!bIsLit || Fuel.Num() == 0)
{
return;
}
const float Duration = FMath::Max(0.05f, Fuel[0].RemainingSeconds);
GetWorldTimerManager().SetTimer(FuelTimer, this, &ACookingStation::HandleFuelConsumed, Duration, false);
}
bool ACookingStation::Light()
{
if (bIsLit)
{
return true;
}
if (Fuel.Num() == 0)
{
return false;
}
bIsLit = true;
StartFuelTimer();
// Reprise, pas redemarrage : chaque cuisson repart exactement la ou elle
// s'etait figee quand le feu s'est eteint.
for (const FCookingSlotState& Slot : Slots)
{
GetWorldTimerManager().UnPauseTimer(Slot.Timer);
}
ApplyFireVisuals(true);
UGameplayStatics::PlaySoundAtLocation(this, LightSound, GetActorLocation());
OnStationChanged.Broadcast();
return true;
}
void ACookingStation::Extinguish()
{
if (!bIsLit)
{
return;
}
SyncFuelFromTimer();
GetWorldTimerManager().ClearTimer(FuelTimer);
bIsLit = false;
for (const FCookingSlotState& Slot : Slots)
{
GetWorldTimerManager().PauseTimer(Slot.Timer);
}
ApplyFireVisuals(false);
UGameplayStatics::PlaySoundAtLocation(this, ExtinguishSound, GetActorLocation());
OnStationChanged.Broadcast();
}
bool ACookingStation::HasPendingWork() const
{
for (const FCookingSlotState& Slot : Slots)
{
float Unused = 0.f;
if (!Slot.IsEmpty() && GetNextStage(Slot.Item, Unused))
{
return true;
}
}
return false;
}
void ACookingStation::UpdateFireState()
{
// Un objet DEJA cuit qui peut encore bruler compte comme du travail : sans
// ca le feu s'arreterait pile a la fin de la cuisson et la sur-cuisson
// n'arriverait jamais. Oublier sa viande doit rester punitif.
if (Fuel.Num() > 0 && HasPendingWork())
{
Light();
}
else
{
Extinguish();
}
}
void ACookingStation::HandleFuelConsumed()
{
if (Fuel.Num() > 0)
{
// RemoveAt et non un index de lecture qui avance : le foyer doit
// toujours se lire "les N premieres buches sont pleines", c'est ce qui
// rend l'affichage trivial et sans etat supplementaire.
Fuel.RemoveAt(0);
}
RefreshFuelIndicators();
if (Fuel.Num() == 0)
{
Extinguish();
return;
}
StartFuelTimer();
OnStationChanged.Broadcast();
}
int32 ACookingStation::FindFuelSource(const UInventoryComponent* Inventory, UItemDataAsset*& OutItem)
{
OutItem = nullptr;
if (!Inventory)
{
return INDEX_NONE;
}
// La main d'abord : c'est le seul moyen pour le joueur de designer ce qu'il
// accepte de bruler. Sans cette priorite, remplir le feu piocherait la
// premiere chose venue et ses planches partiraient avant ses branches.
const FInventorySlot Selected = Inventory->GetSelectedSlot();
if (Selected.Item && Selected.Item->bIsFuel)
{
OutItem = Selected.Item;
return Inventory->GetSelectedHotbarIndex();
}
const TArray<FInventorySlot>& InventorySlots = Inventory->GetSlots();
for (int32 Index = 0; Index < InventorySlots.Num(); ++Index)
{
if (!InventorySlots[Index].IsEmpty() && InventorySlots[Index].Item->bIsFuel)
{
OutItem = InventorySlots[Index].Item;
return Index;
}
}
return INDEX_NONE;
}
bool ACookingStation::AddFuelFrom(int32 InventorySlotIndex, AActor* Interactor)
{
UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (!Inventory || Fuel.Num() >= GetFuelCapacity())
{
return false;
}
// Un seul exemplaire, jamais la pile : c'est le joueur qui decide combien
// de bois il engage, buche par buche.
FInventorySlot Taken;
if (!Inventory->TakeFromSlot(InventorySlotIndex, 1, Taken) || Taken.IsEmpty())
{
return false;
}
FFuelSlotState& NewFuel = Fuel.AddDefaulted_GetRef();
NewFuel.Item = Taken.Item;
NewFuel.RemainingSeconds = FMath::Max(1.f, Taken.Item->FuelDuration);
RefreshFuelIndicators();
// Du bois arrive alors qu'une cuisson attendait : c'est ce qui relance le
// feu. Si rien n'attend, la buche patiente sans se consumer.
UpdateFireState();
UGameplayStatics::PlaySoundAtLocation(this, PlaceItemSound, GetActorLocation());
OnStationChanged.Broadcast();
return true;
}
void ACookingStation::ApplyFireVisuals(bool bActive)
{
if (bActive)
{
FireEffect->Activate(/*bReset=*/true);
}
else
{
FireEffect->Deactivate();
}
FireLight->SetVisibility(bActive);
}
void ACookingStation::CacheFuelIndicators()
{
TArray<UStaticMeshComponent*> Found;
GetComponents<UStaticMeshComponent>(Found);
FuelIndicators.Reset();
for (UStaticMeshComponent* Component : Found)
{
if (Component->ComponentHasTag(FuelIndicatorTag))
{
FuelIndicators.Add(Component);
}
}
// GetComponents ne promet aucun ordre. Sans tri, la buche qui disparait en
// premier changerait d'une compilation du Blueprint a l'autre -- un bug
// qu'on ne reproduit jamais deux fois pareil.
//
// TArray::Sort DEREFERENCE les elements des qu'il s'agit de pointeurs,
// TObjectPtr compris : le predicat recoit les composants, pas les pointeurs.
//
// Tri alphabetique, donc Log_10 passerait AVANT Log_2 : au-dela de neuf
// buches, nomme-les Log_01, Log_02...
FuelIndicators.Sort([](const UStaticMeshComponent& A, const UStaticMeshComponent& B)
{
return A.GetName().Compare(B.GetName()) < 0;
});
}
void ACookingStation::RefreshFuelIndicators()
{
for (int32 Index = 0; Index < FuelIndicators.Num(); ++Index)
{
const bool bOccupied = Fuel.IsValidIndex(Index);
FuelIndicators[Index]->SetVisibility(bOccupied);
if (bOccupied && bFuelIndicatorsUseItemMesh && Fuel[Index].Item)
{
Fuel[Index].Item->ApplyWorldVisualsTo(FuelIndicators[Index]);
}
}
}
// ----------------------------------------------------------------------
// Cuisson
// ----------------------------------------------------------------------
UItemDataAsset* ACookingStation::GetNextStage(const UItemDataAsset* Item, float& OutDuration)
{
OutDuration = 0.f;
if (!Item)
{
return nullptr;
}
// Le test "!= Item" n'est pas de la paranoia : un resultat qui pointe sur
// l'objet lui-meme fait tourner le timer indefiniment sans que rien ne
// change a l'ecran. La viande "cuit" en boucle, reste crue, et le feu ne
// s'arrete jamais puisqu'il croit avoir du travail. Vu en jeu, ca ressemble
// a un timer casse, et on cherche du cote du code pendant une heure.
//
// La cuisson d'abord : un objet cru qui porterait aussi bCanBurn doit
// devenir cuit avant de se gacher, jamais l'inverse.
if (Item->bCanBeCooked && Item->CookedResult && Item->CookedResult != Item)
{
OutDuration = Item->CookDuration;
return Item->CookedResult;
}
if (Item->bCanBurn && Item->BurntResult && Item->BurntResult != Item)
{
OutDuration = Item->BurnDuration;
return Item->BurntResult;
}
return nullptr;
}
void ACookingStation::StartSlotTimer(int32 SlotIndex)
{
FCookingSlotState& Slot = Slots[SlotIndex];
GetWorldTimerManager().ClearTimer(Slot.Timer);
if (Slot.IsEmpty())
{
return;
}
float Duration = 0.f;
if (!GetNextStage(Slot.Item, Duration))
{
// Un objet qui se DIT transformable sans avoir d'etape suivante
// exploitable n'est pas une fin de chaine, c'est une donnee cassee.
// Sans ce message il resterait inerte sur le grill, et rien a l'ecran
// ne dirait que le probleme est dans l'asset et pas dans le feu.
if (Slot.Item->bCanBeCooked || Slot.Item->bCanBurn)
{
UE_LOG(LogTemp, Warning, TEXT("ACookingStation : %s se declare transformable mais n'a aucune etape suivante valide (resultat vide, ou pointant sur lui-meme). Il restera inerte sur le feu."),
*GetNameSafe(Slot.Item));
}
// Maillon final -- un steak brule -- : il reste sur le grill sans plus
// rien faire, et sans consommer un timer pour ne rien faire.
return;
}
Duration = FMath::Max(0.05f, Duration / CookSpeedMultiplier);
const FTimerDelegate Finished = FTimerDelegate::CreateUObject(this, &ACookingStation::HandleSlotFinished, SlotIndex);
GetWorldTimerManager().SetTimer(Slot.Timer, Finished, Duration, false);
// Poser quelque chose sur un feu eteint est autorise : le compte a rebours
// existe, il attend simplement qu'on allume.
if (!bIsLit)
{
GetWorldTimerManager().PauseTimer(Slot.Timer);
}
}
void ACookingStation::HandleSlotFinished(int32 SlotIndex)
{
if (!Slots.IsValidIndex(SlotIndex))
{
return;
}
FCookingSlotState& Slot = Slots[SlotIndex];
float Unused = 0.f;
UItemDataAsset* NextItem = GetNextStage(Slot.Item, Unused);
if (!NextItem)
{
return;
}
Slot.Item = NextItem;
RefreshSlotMesh(SlotIndex);
// Enchainement automatique : le steak cuit qui porte bCanBurn se remet a
// compter tout seul vers le brule. C'est ce qui fait que le joueur doit
// revenir chercher sa viande au lieu de l'oublier sur le feu.
StartSlotTimer(SlotIndex);
// Apres StartSlotTimer : si ce maillon etait le dernier, il n'y a plus rien
// a transformer et le feu s'arrete de lui-meme.
UpdateFireState();
OnStationChanged.Broadcast();
}
float ACookingStation::GetSlotTimeRemaining(int32 SlotIndex) const
{
if (!Slots.IsValidIndex(SlotIndex))
{
return 0.f;
}
// GetTimerRemaining renvoie -1 quand le handle ne designe aucun timer actif.
return FMath::Max(0.f, GetWorldTimerManager().GetTimerRemaining(Slots[SlotIndex].Timer));
}
bool ACookingStation::CanTakeFromSlot(int32 SlotIndex) const
{
if (!Slots.IsValidIndex(SlotIndex) || Slots[SlotIndex].IsEmpty())
{
return false;
}
if (bAllowTakingRawItems)
{
return true;
}
const UItemDataAsset* Item = Slots[SlotIndex].Item;
// Critere porte par l'objet lui-meme, sans etat a maintenir : tant qu'il
// sait encore CUIRE, il est cru, donc engage. Des qu'il ne sait plus que
// bruler, c'est qu'il a fini sa cuisson et qu'on peut le sauver.
//
// Consequence voulue : reposer une viande cuite sur le feu ne la reverrouille
// pas, alors qu'un booleen "a deja transforme" l'aurait fait.
if (!Item->bCanBeCooked)
{
return true;
}
// Second filet : un objet dont la chaine est cassee ne cuira jamais. Le
// laisser verrouille le sequestrerait pour toujours sur le grill, avec pour
// seul recours de recharger la partie.
float Unused = 0.f;
return GetNextStage(Item, Unused) == nullptr;
}
void ACookingStation::RefreshSlotMesh(int32 SlotIndex)
{
UStaticMeshComponent* SlotMesh = SlotMeshes[SlotIndex];
const FCookingSlotState& Slot = Slots[SlotIndex];
if (Slot.IsEmpty() || !Slot.Item->WorldMesh)
{
SlotMesh->SetStaticMesh(nullptr);
SlotMesh->SetVisibility(false);
return;
}
// Mesh ET materiau : c'est souvent le materiau seul qui distingue le cru du
// cuit quand les deux etapes partagent la meme geometrie.
Slot.Item->ApplyWorldVisualsTo(SlotMesh);
SlotMesh->SetRelativeScale3D(FVector(CookingMeshScale));
SlotMesh->SetVisibility(true);
}
// ----------------------------------------------------------------------
// Visee et decision
// ----------------------------------------------------------------------
int32 ACookingStation::ResolveAimedSlot(const AActor* Interactor) const
{
if (!Interactor)
{
return INDEX_NONE;
}
FVector ViewLocation;
FRotator ViewRotation;
// GetActorEyesViewPoint : sur un pawn possede, il renvoie le point de vue du
// controller, donc exactement ce que le joueur voit. On evite ainsi de
// dependre d'AFpsPlayer -- n'importe quel acteur pourra cuisiner.
Interactor->GetActorEyesViewPoint(ViewLocation, ViewRotation);
const FVector ViewDirection = ViewRotation.Vector();
const float MinDot = FMath::Cos(FMath::DegreesToRadians(SlotAimHalfAngle));
int32 BestIndex = INDEX_NONE;
float BestDot = MinDot;
const int32 UsedSlots = FMath::Min(CookingSlotCount, MaxCookingSlots);
for (int32 Index = 0; Index < UsedSlots; ++Index)
{
const FVector ToSlot = SlotMeshes[Index]->GetComponentLocation() - ViewLocation;
if (ToSlot.IsNearlyZero())
{
continue;
}
// Produit scalaire et non distance a l'ecran : c'est l'ecart ANGULAIRE
// au centre du viseur qui compte, et il ne demande ni projection ni
// acces au viewport. Sous le cone, aucun emplacement n'est vise et le
// joueur s'adresse au foyer lui-meme.
const float Dot = FVector::DotProduct(ToSlot.GetSafeNormal(), ViewDirection);
if (Dot > BestDot)
{
BestDot = Dot;
BestIndex = Index;
}
}
return BestIndex;
}
UInventoryComponent* ACookingStation::GetInventoryOf(AActor* Interactor)
{
return Interactor ? Interactor->FindComponentByClass<UInventoryComponent>() : nullptr;
}
FCookingInteraction ACookingStation::ResolveAction(AActor* Interactor) const
{
FCookingInteraction Result;
Result.SlotIndex = ResolveAimedSlot(Interactor);
// Slots n'est dimensionne qu'au BeginPlay : un trace d'interaction qui
// arriverait avant lirait hors du tableau.
if (!Slots.IsValidIndex(Result.SlotIndex))
{
Result.SlotIndex = INDEX_NONE;
}
const UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (Inventory)
{
Result.HeldItem = Inventory->GetSelectedSlot().Item;
Result.FuelSourceSlot = FindFuelSource(Inventory, Result.FuelItem);
}
// 1. Un emplacement vise et occupe par quelque chose qu'on a le droit de
// reprendre. Recuperer passe avant tout le reste, sinon on ne pourrait
// jamais sortir sa viande en ayant du bois en main.
if (CanTakeFromSlot(Result.SlotIndex))
{
Result.Action = ECookingAction::TakeItem;
return Result;
}
// 2. Un emplacement vise et libre, quelque chose de cuisinable en main.
if (Result.SlotIndex != INDEX_NONE && Slots[Result.SlotIndex].IsEmpty()
&& Result.HeldItem && Result.HeldItem->CanBePlacedOnFire())
{
Result.Action = ECookingAction::PlaceItem;
return Result;
}
// 3. Du bois quelque part dans l'inventaire et de la place dans le foyer.
// Pas besoin de l'avoir en main : la main sert a CHOISIR le combustible,
// pas a autoriser le geste.
if (Result.FuelSourceSlot != INDEX_NONE && Fuel.Num() < GetFuelCapacity())
{
Result.Action = ECookingAction::AddFuel;
return Result;
}
// Pas de quatrieme cas : allumer n'est plus un geste du joueur.
return Result;
}
// ----------------------------------------------------------------------
// Actions
// ----------------------------------------------------------------------
bool ACookingStation::PlaceItemFromHand(int32 SlotIndex, AActor* Interactor)
{
UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (!Inventory)
{
return false;
}
// Un seul exemplaire, meme si la pile en contient vingt : un emplacement de
// grill porte une piece, comme dans Raft.
FInventorySlot Taken;
if (!Inventory->TakeFromSlot(Inventory->GetSelectedHotbarIndex(), 1, Taken) || Taken.IsEmpty())
{
return false;
}
FCookingSlotState& Slot = Slots[SlotIndex];
Slot.Item = Taken.Item;
Slot.RemainingUses = Taken.RemainingUses;
RefreshSlotMesh(SlotIndex);
StartSlotTimer(SlotIndex);
// Poser quelque chose EST ce qui declenche le feu, s'il a du bois.
UpdateFireState();
UGameplayStatics::PlaySoundAtLocation(this, PlaceItemSound, SlotMeshes[SlotIndex]->GetComponentLocation());
OnStationChanged.Broadcast();
return true;
}
bool ACookingStation::TakeItemToInventory(int32 SlotIndex, AActor* Interactor)
{
UInventoryComponent* Inventory = GetInventoryOf(Interactor);
if (!Inventory)
{
return false;
}
FCookingSlotState& Slot = Slots[SlotIndex];
// On tente l'ajout AVANT de vider l'emplacement. Inventaire plein : la
// nourriture reste sur le feu -- et continue donc de cuire, ce qui est la
// bonne sanction plutot que de la faire disparaitre.
const int32 Leftover = Inventory->AddItem(Slot.Item, 1, Slot.RemainingUses);
if (Leftover > 0)
{
return false;
}
GetWorldTimerManager().ClearTimer(Slot.Timer);
Slot.Item = nullptr;
Slot.RemainingUses = -1;
RefreshSlotMesh(SlotIndex);
// Le grill vide, plus rien ne justifie de bruler du bois.
UpdateFireState();
OnStationChanged.Broadcast();
return true;
}
// ----------------------------------------------------------------------
// IInteractable
// ----------------------------------------------------------------------
bool ACookingStation::CanInteract_Implementation(AActor* Interactor) const
{
// Toujours vrai : meme sans rien a faire, le poste doit rester visable pour
// que le prompt puisse annoncer son etat. C'est la seule interface qu'a le
// joueur pour savoir ou en est sa cuisson et combien de bois il lui reste.
// Le fait qu'une action soit possible ou non se dit par
// IsInteractionAvailable, qui n'efface pas le texte.
return true;
}
FText ACookingStation::GetInteractionPrompt_Implementation(AActor* Interactor) const
{
const FCookingInteraction Interaction = ResolveAction(Interactor);
switch (Interaction.Action)
{
case ECookingAction::TakeItem:
{
const FText ItemName = Slots[Interaction.SlotIndex].Item->DisplayName;
const float Remaining = GetSlotTimeRemaining(Interaction.SlotIndex);
// Exactement la formulation d'un objet au sol : une fois cuit, ce n'est
// plus une manipulation de four, c'est un ramassage.
if (!bShowRemainingTimeInPrompt || Remaining <= 0.f || !bIsLit)
{
return FText::Format(LOCTEXT("TakeDone", "Pick up {0}"), ItemName);
}
return FText::Format(LOCTEXT("TakeDoneTimed", "Pick up {0} ({1}s)"),
ItemName, FText::AsNumber(FMath::CeilToInt(Remaining)));
}
case ECookingAction::PlaceItem:
return FText::Format(LOCTEXT("PlaceOnFire", "Cook {0}"), Interaction.HeldItem->DisplayName);
case ECookingAction::AddFuel:
return FText::Format(LOCTEXT("AddFuel", "Add {0} to the fire"), Interaction.FuelItem->DisplayName);
default:
break;
}
// Aucune action possible : aucun texte. Tout ce qu'un commentaire d'etat
// dirait -- ca cuit, il manque du bois, le foyer est plein -- se lit deja
// sur les flammes, sur le nombre de buches et sur le mesh de la nourriture.
// L'ecrire en plus, c'est reconstruire une interface par-dessus un monde
// qui se suffit a lui-meme.
return FText::GetEmpty();
}
void ACookingStation::Interact_Implementation(AActor* Interactor)
{
// Meme fonction que le prompt : ce que le joueur vient de lire est
// exactement ce qui va se produire.
const FCookingInteraction Interaction = ResolveAction(Interactor);
switch (Interaction.Action)
{
case ECookingAction::TakeItem:
TakeItemToInventory(Interaction.SlotIndex, Interactor);
break;
case ECookingAction::PlaceItem:
PlaceItemFromHand(Interaction.SlotIndex, Interactor);
break;
case ECookingAction::AddFuel:
AddFuelFrom(Interaction.FuelSourceSlot, Interactor);
break;
default:
break;
}
}
void ACookingStation::OnBeginFocus_Implementation()
{
if (bHighlightOnFocus)
{
BaseMesh->SetRenderCustomDepth(true);
}
}
void ACookingStation::OnEndFocus_Implementation()
{
if (bHighlightOnFocus)
{
BaseMesh->SetRenderCustomDepth(false);
}
}
#undef LOCTEXT_NAMESPACE
@@ -19,8 +19,8 @@ void UCraftingComponent::InitializeComponent()
{
Super::InitializeComponent();
// Les mains sont toujours disponibles. Les postes s'ajoutent par-dessus.
AvailableStations.Add(ECraftingStation::Hands);
// Les mains sont toujours disponibles, et rien ne peut les retirer.
StationRefCounts.Add(ECraftingStation::Hands, 1);
if (const AActor* Owner = GetOwner())
{
@@ -40,6 +40,12 @@ void UCraftingComponent::InitializeComponent()
}
}
bool UCraftingComponent::HasStation(ECraftingStation Station) const
{
const int32* Count = StationRefCounts.Find(Station);
return Count && *Count > 0;
}
void UCraftingComponent::AddAvailableStation(ECraftingStation Station)
{
if (Station == ECraftingStation::Count)
@@ -47,12 +53,13 @@ void UCraftingComponent::AddAvailableStation(ECraftingStation Station)
return;
}
bool bAlreadyThere = false;
AvailableStations.Add(Station, &bAlreadyThere);
int32& Count = StationRefCounts.FindOrAdd(Station);
++Count;
// On ne diffuse que sur un vrai changement : deux volumes d'etabli qui se
// chevauchent rafraichiraient l'interface deux fois pour rien.
if (!bAlreadyThere)
// On ne diffuse que sur la transition 0 -> 1. Entrer dans le rayon d'un
// deuxieme etabli ne change rien de visible, inutile de reconstruire la
// grille de recettes pour ca.
if (Count == 1)
{
OnAvailableStationsChanged.Broadcast();
}
@@ -67,8 +74,17 @@ void UCraftingComponent::RemoveAvailableStation(ECraftingStation Station)
return;
}
if (AvailableStations.Remove(Station) > 0)
int32* Count = StationRefCounts.Find(Station);
if (!Count || *Count <= 0)
{
return;
}
--(*Count);
if (*Count <= 0)
{
StationRefCounts.Remove(Station);
OnAvailableStationsChanged.Broadcast();
}
}
@@ -94,7 +110,7 @@ void UCraftingComponent::GatherRecipes(TArray<UCraftingRecipeDataAsset*>& OutRec
continue;
}
if (!AvailableStations.Contains(Recipe->RequiredStation))
if (!HasStation(Recipe->RequiredStation))
{
continue;
}
@@ -130,7 +146,7 @@ bool UCraftingComponent::CanCraft(const UCraftingRecipeDataAsset* Recipe, ECraft
return false;
}
if (!AvailableStations.Contains(Recipe->RequiredStation))
if (!HasStation(Recipe->RequiredStation))
{
OutReason = ECraftFailureReason::StationUnavailable;
return false;
@@ -0,0 +1,49 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "CraftingStation.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/CollisionProfile.h"
#include "FpsPlayerController.h"
#include "GameFramework/Pawn.h"
#define LOCTEXT_NAMESPACE "CraftingStation"
ACraftingStation::ACraftingStation()
{
PrimaryActorTick.bCanEverTick = false;
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
SetRootComponent(Mesh);
// Explicite plutot que de compter sur le profil par defaut : ce composant a
// deux roles indissociables, empecher le joueur de traverser le meuble et
// bloquer le canal Visibility pour que le trace d'interaction le trouve.
// Un profil qui laisserait passer Visibility rendrait le poste inutilisable
// sans qu'aucune erreur ne le signale.
Mesh->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
InteractionPrompt = LOCTEXT("UseStation", "Utiliser l'établi");
}
FText ACraftingStation::GetInteractionPrompt_Implementation(AActor* Interactor) const
{
return InteractionPrompt;
}
void ACraftingStation::Interact_Implementation(AActor* Interactor)
{
// L'ecran vit sur le controller, pas sur le pawn : c'est lui qu'on cherche.
const APawn* InteractingPawn = Cast<APawn>(Interactor);
AFpsPlayerController* PlayerController = InteractingPawn ? Cast<AFpsPlayerController>(InteractingPawn->GetController()) : nullptr;
if (!PlayerController)
{
return;
}
PlayerController->OpenCraftingAtStation(StationType);
}
#undef LOCTEXT_NAMESPACE
@@ -14,6 +14,7 @@
#include "InputCoreTypes.h"
#include "InventoryComponent.h"
#include "InteractionPromptWidget.h"
#include "CraftingComponent.h"
#include "InventoryScreenWidget.h"
#include "Kismet/GameplayStatics.h"
#include "PauseMenuWidget.h"
@@ -420,6 +421,47 @@ void AFpsPlayerController::ToggleCrafting()
SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true);
}
void AFpsPlayerController::OpenCraftingAtStation(ECraftingStation Station)
{
if (Station == ECraftingStation::Count)
{
return;
}
// Deja ouvert sur un autre poste : on rend le precedent avant d'accorder le
// nouveau, sinon le compteur du composant ne redescendrait jamais.
ReleaseActiveStation();
if (UCraftingComponent* Crafting = GetPawnCrafting())
{
Crafting->AddAvailableStation(Station);
ActiveStation = Station;
}
SetInventoryScreenOpen(true, /*bShowCraftingTab=*/true);
}
void AFpsPlayerController::ReleaseActiveStation()
{
if (ActiveStation == ECraftingStation::Count)
{
return;
}
if (UCraftingComponent* Crafting = GetPawnCrafting())
{
Crafting->RemoveAvailableStation(ActiveStation);
}
ActiveStation = ECraftingStation::Count;
}
UCraftingComponent* AFpsPlayerController::GetPawnCrafting() const
{
const AFpsPlayer* PlayerPawn = Cast<AFpsPlayer>(GetPawn());
return PlayerPawn ? PlayerPawn->GetCraftingComponent() : nullptr;
}
void AFpsPlayerController::SetInventoryScreenOpen(bool bOpen, bool bShowCraftingTab)
{
if (!InventoryScreen)
@@ -450,6 +492,15 @@ void AFpsPlayerController::SetInventoryScreenOpen(bool bOpen, bool bShowCrafting
bInventoryOpen = bOpen;
// Une fermeture, quelle qu'en soit la cause -- Tab, Echap, la mort --
// rend le poste. Le faire ici et nulle part ailleurs garantit qu'aucun
// chemin de sortie ne laisse l'etabli accessible depuis l'autre bout de
// la carte.
if (!bInventoryOpen)
{
ReleaseActiveStation();
}
if (bInventoryOpen)
{
if (bShowCraftingTab)
@@ -57,6 +57,7 @@ void UInteractionComponent::UpdateFocus()
if (!GetViewPoint(ViewLocation, ViewRotation))
{
SetFocusedActor(nullptr);
RefreshPrompt();
return;
}
@@ -84,6 +85,10 @@ void UInteractionComponent::UpdateFocus()
SetFocusedActor(Candidate);
// Apres SetFocusedActor, jamais avant : l'ordre garantit que l'UI recoit
// d'abord "la cible a change", puis le texte correspondant.
RefreshPrompt();
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
@@ -161,13 +166,26 @@ void UInteractionComponent::TryInteract()
UpdateFocus();
}
FText UInteractionComponent::GetFocusedPrompt() const
void UInteractionComponent::RefreshPrompt()
{
AActor* Target = FocusedActor.Get();
if (!IsValid(Target))
// Un seul FText::Format par trace, soit 20 fois par seconde et seulement
// quand on vise quelque chose. Recalculer ici plutot que dans le getter
// evite qu'un widget qui interroge le prompt plusieurs fois par frame
// paie le format autant de fois.
const FText NewPrompt = IsValid(Target)
? IInteractable::Execute_GetInteractionPrompt(Target, GetOwner())
: FText::GetEmpty();
// EqualTo et non IdenticalTo : on compare ce qui s'affiche, pas l'instance.
// Deux FText::Format successifs produisent toujours deux instances
// differentes, IdenticalTo redeclencherait donc l'UI a chaque trace.
if (CachedPrompt.EqualTo(NewPrompt))
{
return FText::GetEmpty();
return;
}
return IInteractable::Execute_GetInteractionPrompt(Target);
CachedPrompt = NewPrompt;
OnPromptChanged.Broadcast(CachedPrompt);
}
@@ -40,16 +40,16 @@ void UInteractionPromptWidget::BindToOwningPawn()
if (BoundComponent.IsValid())
{
BoundComponent->OnFocusChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandleFocusChanged);
BoundComponent->OnPromptChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandlePromptChanged);
}
BoundComponent = NewComponent;
if (BoundComponent.IsValid())
{
// Abonnement au delegue : le widget ne se reveille que quand la cible
// Abonnement au delegue : le widget ne se reveille que quand le texte
// change vraiment. Pas de Tick, pas de polling.
BoundComponent->OnFocusChanged.AddDynamic(this, &UInteractionPromptWidget::HandleFocusChanged);
BoundComponent->OnPromptChanged.AddDynamic(this, &UInteractionPromptWidget::HandlePromptChanged);
}
else
{
@@ -65,14 +65,14 @@ void UInteractionPromptWidget::NativeDestruct()
// vers un widget detruit -- typiquement au changement de niveau.
if (BoundComponent.IsValid())
{
BoundComponent->OnFocusChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandleFocusChanged);
BoundComponent->OnPromptChanged.RemoveDynamic(this, &UInteractionPromptWidget::HandlePromptChanged);
}
BoundComponent.Reset();
Super::NativeDestruct();
}
void UInteractionPromptWidget::HandleFocusChanged(AActor* NewFocus, AActor* OldFocus)
void UInteractionPromptWidget::HandlePromptChanged(const FText& NewPrompt)
{
Refresh();
}
@@ -91,6 +91,9 @@ void UInteractionPromptWidget::Refresh()
if (PromptText)
{
// Un prompt non vide est toujours une action realisable : les cibles ne
// renvoient jamais de texte purement informatif, donc la touche a
// toujours sa place devant.
PromptText->SetText(FText::Format(PromptFormat, GetInteractKeyDisplayName(), Prompt));
}
@@ -0,0 +1,76 @@
// Fill out your copyright notice in the Description page of Project Settings.
#include "ItemDataAsset.h"
#include "Components/StaticMeshComponent.h"
#if WITH_EDITOR
#include "Misc/DataValidation.h"
#endif
#define LOCTEXT_NAMESPACE "ItemData"
void UItemDataAsset::ApplyWorldVisualsTo(UStaticMeshComponent* Component) const
{
if (!Component)
{
return;
}
Component->SetStaticMesh(WorldMesh);
// SetMaterial(0, nullptr) ne "retire" pas le materiau, il pose un slot vide
// et le mesh devient gris damier. Sans override, on ne touche a rien et le
// mesh garde le sien.
if (WorldMeshMaterial)
{
Component->SetMaterial(0, WorldMeshMaterial);
}
}
#if WITH_EDITOR
EDataValidationResult UItemDataAsset::IsDataValid(FDataValidationContext& Context) const
{
EDataValidationResult Result = Super::IsDataValid(Context);
if (bCanBeCooked && !CookedResult)
{
Context.AddError(LOCTEXT("CookNoResult", "'Can Be Cooked' is checked but no 'Cooked Result' is assigned: this item would sit on the fire forever without ever changing."));
Result = EDataValidationResult::Invalid;
}
if (bCanBurn && !BurntResult)
{
Context.AddError(LOCTEXT("BurnNoResult", "'Can Burn' is checked but no 'Burnt Result' is assigned."));
Result = EDataValidationResult::Invalid;
}
// Une chaine qui reboucle sur elle-meme ne plante pas, elle tourne : la
// station re-arme un timer identique a chaque etape et le joueur voit un
// objet qui ne finit jamais de cuire. Impossible a diagnostiquer en jeu.
if (CookedResult == this)
{
Context.AddError(LOCTEXT("CookSelfResult", "'Cooked Result' points back to this asset: the item would cook into itself, endlessly."));
Result = EDataValidationResult::Invalid;
}
if (BurntResult == this)
{
Context.AddError(LOCTEXT("BurnSelfResult", "'Burnt Result' points back to this asset: the item would burn into itself, endlessly."));
Result = EDataValidationResult::Invalid;
}
// Un objet a charges ne s'empile pas (voir GetEffectiveMaxStack). Le poser
// sur le feu reste possible, mais la charge entamee suit l'exemplaire et le
// resultat en herite : autant que ce soit un choix, pas une surprise.
if (MaxUses > 1 && bCanBeCooked)
{
Context.AddWarning(LOCTEXT("CookableWithCharges", "This item has multiple uses AND can be cooked. The remaining charges follow the item through cooking, which is rarely what you want for food."));
}
return Result;
}
#endif
#undef LOCTEXT_NAMESPACE
@@ -45,24 +45,25 @@ void APickupItem::OnConstruction(const FTransform& Transform)
// pour les objets jetes, puisque OnConstruction s'execute a FinishSpawning.
if (bUseMeshFromItemData && ItemData && ItemData->WorldMesh)
{
Mesh->SetStaticMesh(ItemData->WorldMesh);
ItemData->ApplyWorldVisualsTo(Mesh);
}
}
FText APickupItem::GetInteractionPrompt_Implementation() const
FText APickupItem::GetInteractionPrompt_Implementation(AActor* Interactor) const
{
// Interactor ne sert pas ici : ramasser dit la meme chose a tout le monde.
if (!ItemData)
{
return LOCTEXT("PickupUnconfigured", "Objet non configure");
return LOCTEXT("PickupUnconfigured", "Unconfigured item");
}
if (Quantity > 1)
{
return FText::Format(LOCTEXT("PickupPromptStack", "Ramasser {0} x{1}"),
return FText::Format(LOCTEXT("PickupPromptStack", "Pick up {0} x{1}"),
ItemData->DisplayName, FText::AsNumber(Quantity));
}
return FText::Format(LOCTEXT("PickupPrompt", "Ramasser {0}"), ItemData->DisplayName);
return FText::Format(LOCTEXT("PickupPrompt", "Pick up {0}"), ItemData->DisplayName);
}
bool APickupItem::CanInteract_Implementation(AActor* Interactor) const