Vertical slice part 1: tunable config, day/dusk/night clock, noise subsystem, lootable containers, inventory and hub stockpile
Motivation
This is part 1 of 3 of the Seminole playable vertical slice. It delivers the non-combat core systems that later parts build on: tunable config, the day/dusk/night clock, the noise system, looting into an inventory, and depositing into a hub stockpile. Parts 2 (canoe, weapons, infected) and 3 (night defense, barricades, full game flow and result) are separate proposals.
Constraints (confirmed by proposer)
- No compiler or CI in the sandbox. The maintainer compiles and runs the tests locally. Code must:
- follow existing project patterns and UE 5.8 APIs exactly;
- avoid uncommon or speculative engine APIs;
- keep each change small and compile-safe.
- Placeholder content only. All content is C++ runtime-spawned placeholder primitives that extend the #22 test environment. No new .uasset maps, meshes, animations, audio or widget blueprints.
- Solo only.
- No networking code or networking tests.
- Where cheap, put authoritative state (clock phase, stockpile totals) in GameMode/GameState or world subsystems so later replication is not blocked.
Assumptions
- A1 Config: One
UDeveloperSettingssubclass (C++ defaults, editable in Project Settings) holds all tunables:- Day 480 s, Dusk 60 s.
- Bow noise radius 500 uu, rifle noise radius 3000 uu. These are read by part 2.
- Container search duration 2 s.
- Supply amounts per container.
- A2 Clock end state: The clock runs Day → Dusk → Night and stops at Night. It broadcasts a phase-changed delegate and exposes
ResetToDay(). Part 3 consumes this delegate for night defense and the Result state. - A3 Noise listeners: Noise listeners implement a C++ interface (e.g.
INoiseListener). Part 2's infected will implement it. In part 1, a simple placeholder listener actor or a test double verifies delivery. - A4 HUD: The HUD is drawn with an
AHUDCanvas in C++ to avoid widget assets. - A5 Input: Interact uses the project's existing Enhanced Input setup.
- If an Interact action exists, reuse it.
- If not, add one following the #22 pattern.
- If no asset-free path exists, document the manual step needed.
- A6 Supplies: Supply types are an enum
{Food, Ammo, Materials}stored as integer counts.
Scope / Acceptance criteria
- Config: A settings class exposes every value in A1. No gameplay code hard-codes these values.
- Day clock:
- A subsystem or GameState-owned clock advances automatically from game start: Day, then Dusk, then Night.
- Each transition broadcasts a delegate with the new phase.
- Time remaining in the current phase is queryable.
ResetToDay()restores Day with full duration.- The game starts in Day without console commands.
- Lighting:
- The test environment's directional light (and sky light, if present) intensity interpolates from day to dusk values.
- Lighting reaches the night value at Night.
- HUD:
- Shows the current phase and time remaining.
- Shows a visible dusk warning when Dusk begins.
- Shows the player's carried supplies and the stockpile totals.
- Noise system:
- A world subsystem offers
EmitNoise(Location, Radius, Instigator). - Only registered listeners within
Radius(3D distance) receive the event, which includes location and instigator. - Listeners outside the radius do not receive it.
- A world subsystem offers
- Inventory: A
UActorComponenton the player pawn stores per-type supply counts, with add, remove and query functions. - Searchable containers:
- At least 3 containers are spawned at a designated scavenging area in the test environment.
- Interacting starts a timed search of the configured duration.
- On completion the container grants its configured supplies to the interactor's inventory.
- A searched container cannot be searched again and shows a visual change (e.g. a material or colour swap on the primitive).
- Stockpile:
- A stockpile actor is spawned at the hub.
- Interacting transfers all carried supplies into the stockpile, leaving inventory at zero.
- Stockpile totals live in authoritative state (GameState or the stockpile actor).
- A spend function (e.g.
TrySpend(Materials, N)) returns false without changing totals if the stockpile is insufficient. This is for part 3.
- Tests:
IMPLEMENT_SIMPLE_AUTOMATION_TEST(or the project's existing test pattern) covers:- Noise filtering: a listener inside the radius receives the event; one outside does not.
- Clock transitions: Day → Dusk → Night fire in order with correct durations (time advanced deterministically, not in real time); reset returns to Day.
- Loot flow:
- container search grants supplies;
- a second search grants nothing;
- stockpile deposit moves totals and empties inventory;
TrySpendsucceeds and fails correctly.
- Docs:
- ARCHITECTURE.md and/or a new ADR describe the config, clock, noise, inventory, container and stockpile systems and their extension points for parts 2 and 3.
- Docs explain how to try the slice in the editor.
- Manual verification list: The PR description lists everything the maintainer must verify manually: compile, test run, input binding, lighting.
- No regressions: Code is written to compile cleanly, and existing tests are not modified except where required.
Out of scope
- Canoe, weapons, infected AI (part 2).
- Night defense waves, barricades, repair, win/lose, Result state and restart (part 3).
- Co-op/networking.
- Save/load.
- New binary assets.
- Art, audio and polish.
Model claude-fable-5-1 · ceiling $13.75 · started 1 day ago · finished 1 day ago
**Tests** — `Tests/SeminoleDayClockTests.cpp`, `SeminoleNoiseTests.cpp`, `SeminoleLootTests.cpp` (+ `SeminoleTestWorld.h`, `SeminolePhaseRecorder.{h,cpp}`), all `IMPLEMENT_SIMPLE_AUTOMATION_TEST` under `Seminole.*`: clock transitions with deterministic time + reset, large-step ordering, noise radius filtering (inside/outside/3D/unregistered/payload), and the full loot flow (grant once, second search nothing, deposit empties inventory, TrySpend success/failure).
**Docs** — `docs/ARCHITECTURE.md` (new "Implemented: vertical slice part 1" section + extension points for parts 2/3), `docs/adr/0009-vertical-slice-placeholder-systems.md`, `docs/VALIDATION.md` section 7, `README.md` play instructions, folder READMEs, `Tests/README.md`.
### Changes in this session
- `Interaction/SeminoleInteractable.h`: added `class AActor;` forward declaration (used in the interface signatures; nothing in the include chain declares it).
- `World/SeminoleDayLightingComponent.cpp`: replaced `FMath::Lerp` on `FLinearColor` with a `BlendColor` helper using `FLinearColor`'s member `operator*(float)`/`operator+` so the colour blend does not depend on a `float * FLinearColor` operator.
### Verification
No Unreal Engine or compiler is available in this sandbox, so **nothing has been compiled or run**. All code was reviewed statically against UE 5.8 APIs and the project's existing patterns. Existing tests/code were not modified beyond extending the game mode, placeholder character, test environment and input config.
### Manual verification required (see docs/VALIDATION.md §7 for the detailed checklist)
1. **Compile** Development Editor | Win64 after regenerating project files (new folders, new `DeveloperSettings` dependency). Most likely risk points if it fails: `EAutomationTestFlags_ApplicationContextMask` (5.5+ enum-class flags) in `Tests/*.cpp`; `UWorld::CreateWorld` + `GEngine->CreateNewWorldContext` in `Tests/SeminoleTestWorld.h`; `Cast<ISeminoleInteractable>` / `Cast<ISeminoleNoiseListener>` on actors; `USeminoleSettings()` default constructor chaining to `UDeveloperSettings`.
2. **Run the 4 automation tests** (Session Frontend > Automation, filter `Seminole`, or headless `-ExecCmds="Automation RunTests Seminole; Quit" -nullrhi`). All should pass.
3. **Input binding**: Project Settings > Input lists the `Interact` (E) action mapping; pressing E next to a crate / the stockpile triggers the search / deposit. If the project later moves to Enhanced Input, bind `Interact` to `USeminoleInteractionComponent::TryInteract`.
4. **Lighting**: during Day the sun dims/warms toward the dusk values; during Dusk it fades to the night values; Night holds dark. Shorten Day/Dusk durations in Project Settings to test quickly.
5. **Placeholders/HUD**: green stockpile block, three brown crates + red sphere at the scavenging area, HUD shows `Day 8:00`, carried/stockpile lines, prompt and progress bar, DUSK/NIGHT warnings.
6. **Packaged build**: crates/stockpile are tinted (confirms `/Engine/BasicShapes` and its material were cooked).
2026-10-06T13:17:43Z [claude] The branch `nabled/feature-23-job-25` is pushed and submitted.
**Summary of this session:** The previous attempt had fully implemented the feature (settings class, GameState-owned day clock, lighting driver, noise subsystem + listener interface, inventory, interaction, containers, stockpile with `TrySpend`, Canvas HUD, placeholder spawning, 4 automation tests, ARCHITECTURE/ADR 0009/VALIDATION/README docs) but ran out of budget before calling submit. I audited every new file against the spec and UE 5.8 APIs, found nothing missing from the acceptance criteria, and applied two small compile-hardening fixes (an `AActor` forward declaration in the interactable interface, and a `FLinearColor` blend that avoids relying on a `float * FLinearColor` operator). Since there's no compiler in the sandbox, the PR description carries the explicit manual verification list the maintainer needs: compile, test run, input binding, lighting, placeholders/HUD, and packaged build.
2026-10-06T13:17:45Z Changed files:
modified Config/DefaultInput.ini +2 -0
modified README.md +32 -7
modified Source/Seminole/Community/README.md +1 -1
added Source/Seminole/Community/SeminoleStockpile.cpp +105 -0
added Source/Seminole/Community/SeminoleStockpile.h +68 -0
modified Source/Seminole/Interaction/README.md +1 -1
added Source/Seminole/Interaction/SeminoleInteractable.h +35 -0
added Source/Seminole/Interaction/SeminoleInteractionComponent.cpp +74 -0
added Source/Seminole/Interaction/SeminoleInteractionComponent.h +45 -0
modified Source/Seminole/Inventory/README.md +1 -1
added Source/Seminole/Inventory/SeminoleInventoryComponent.cpp +61 -0
added Source/Seminole/Inventory/SeminoleInventoryComponent.h +58 -0
added Source/Seminole/Inventory/SeminoleSupplyContainer.cpp +164 -0
added Source/Seminole/Inventory/SeminoleSupplyContainer.h +93 -0
added Source/Seminole/Inventory/SeminoleSupplyTypes.cpp +88 -0
added Source/Seminole/Inventory/SeminoleSupplyTypes.h +75 -0
modified Source/Seminole/Seminole.Build.cs +3 -1
modified Source/Seminole/SeminoleGameMode.cpp +5 -0
modified Source/Seminole/SeminoleGameMode.h +3 -0
added Source/Seminole/SeminoleGameState.cpp +10 -0
added Source/Seminole/SeminoleGameState.h +30 -0
modified Source/Seminole/SeminolePlaceholderCharacter.cpp +11 -0
modified Source/Seminole/SeminolePlaceholderCharacter.h +20 -2
added Source/Seminole/SeminolePlaceholderVisuals.cpp +29 -0
added Source/Seminole/SeminolePlaceholderVisuals.h +18 -0
added Source/Seminole/SeminoleSettings.cpp +27 -0
added Source/Seminole/SeminoleSettings.h +108 -0
modified Source/Seminole/SeminoleTestEnvironment.cpp +130 -0
modified Source/Seminole/SeminoleTestEnvironment.h +23 -0
modified Source/Seminole/Survival/README.md +2 -1
added Source/Seminole/Survival/SeminoleDayClockComponent.cpp +117 -0
added Source/Seminole/Survival/SeminoleDayClockComponent.h +92 -0
added Source/Seminole/Tests/README.md +16 -0
added Source/Seminole/Tests/SeminoleDayClockTests.cpp +113 -0
added Source/Seminole/Tests/SeminoleLootTests.cpp +133 -0
added Source/Seminole/Tests/SeminoleNoiseTests.cpp +90 -0
added Source/Seminole/Tests/SeminolePhaseRecorder.cpp +3 -0
added Source/Seminole/Tests/SeminolePhaseRecorder.h +32 -0
added Source/Seminole/Tests/SeminoleTestWorld.h +51 -0
added Source/Seminole/UI/README.md +6 -0
added Source/Seminole/UI/SeminoleHUD.cpp +165 -0
added Source/Seminole/UI/SeminoleHUD.h +36 -0
modified Source/Seminole/World/README.md +7 -1
added Source/Seminole/World/SeminoleDayLightingComponent.cpp +114 -0
added Source/Seminole/World/SeminoleDayLightingComponent.h +45 -0
added Source/Seminole/World/SeminoleNoiseListener.h +51 -0
added Source/Seminole/World/SeminoleNoiseListenerPlaceholder.cpp +83 -0
added Source/Seminole/World/SeminoleNoiseListenerPlaceholder.h +53 -0
added Source/Seminole/World/SeminoleNoiseSubsystem.cpp +73 -0
added Source/Seminole/World/SeminoleNoiseSubsystem.h +46 -0
modified docs/ARCHITECTURE.md +106 -13
modified docs/VALIDATION.md +115 -13
added docs/adr/0009-vertical-slice-placeholder-systems.md +56 -0
modified docs/adr/README.md +1 -0
2026-10-06T13:17:47Z Opened pull request https://github.com/nabledhq/seminole/pull/10
2026-10-06T13:17:47Z Finished: success=true turns=22 tokens(in/out)=1305029/30599 list cost=$3.28
Show patch
diff --git a/Config/DefaultInput.ini b/Config/DefaultInput.ini
index 3bcda00..3f2017e 100644
--- a/Config/DefaultInput.ini
+++ b/Config/DefaultInput.ini
@@ -12,3 +12,5 @@ DefaultInputComponentClass=/Script/Engine.InputComponent
+AxisMappings=(AxisName="Turn",Scale=1.000000,Key=MouseX)
+AxisMappings=(AxisName="LookUp",Scale=-1.000000,Key=MouseY)
+ActionMappings=(ActionName="Jump",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=SpaceBar)
+; Interact (vertical slice part 1): search a container, deposit into the stockpile.
++ActionMappings=(ActionName="Interact",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=E)
diff --git a/README.md b/README.md
index a3ed19c..a8d3073 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,15 @@ C++ module `Seminole` under `Source/Seminole/`, content under `Content/`). The
technical foundation is documented in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
and the decisions behind it in [docs/adr/](docs/adr/README.md).
-The project is a minimal bootable shell with **no gameplay systems**: it opens
-an empty engine map, `ASeminoleGameMode` spawns a floor, lights and a
-PlayerStart at runtime, and `ASeminolePlaceholderCharacter` (a capsule with a
-cylinder, third-person camera, WASD / mouse look / jump) is the pawn. Nothing
-in it has been compiled, launched or packaged by its author yet; the manual
-checklist in [docs/VALIDATION.md](docs/VALIDATION.md) covers that.
+The project is a bootable shell plus **part 1 of the playable vertical slice**:
+it opens an empty engine map, `ASeminoleGameMode` spawns a floor, lights, a
+PlayerStart, a hub stockpile, three scavenging containers and a noise listener
+at runtime, and `ASeminolePlaceholderCharacter` (a capsule with a cylinder,
+third-person camera, WASD / mouse look / jump / interact) is the pawn. A day
+clock runs Day -> Dusk -> Night, dimming the lights, and a Canvas HUD shows the
+phase, your carried supplies and the stockpile. Nothing in it has been
+compiled, launched or packaged by its authors yet; the manual checklist in
+[docs/VALIDATION.md](docs/VALIDATION.md) covers that.
## Getting Started
@@ -70,7 +73,29 @@ the `.uproject` file association. The editor starts on the engine map
Press **Play** (set *Spawn player at* to **Default Player Start** in the Play
dropdown, see Troubleshooting) or **Play > Standalone Game**. You should see a
grey 100 m x 100 m floor, a lit grey cylinder and a third-person camera.
-Controls: **W/A/S/D** move, **mouse** looks, **Space** jumps.
+Controls: **W/A/S/D** move, **mouse** looks, **Space** jumps, **E** interacts.
+
+The vertical slice (part 1) loop:
+
+1. The HUD (top left) shows `Day 8:00` counting down, your carried supplies
+ and the stockpile totals. The green block 4 m ahead is the **stockpile**.
+2. Walk straight ahead (the direction you face at start, +X), past the
+ stockpile, about 30 m to the **scavenging area**: three brown crates in a
+ row and a red sphere behind them.
+3. Stand next to a crate; the prompt `[E] Search container` appears. Press
+ **E**: a 2 s progress bar runs, the crate turns grey, your carried supplies
+ go up, and the red sphere flashes yellow (it heard the search).
+ A grey crate cannot be searched again.
+4. Walk back to the stockpile and press **E** (`[E] Deposit supplies`): the
+ carried counts go to zero and the stockpile totals rise.
+5. After 8 minutes the HUD turns orange with a centred **DUSK** warning and
+ the light turns amber; one minute later it is **NIGHT** and the scene
+ goes dark. To get there faster, shorten *Day Duration Seconds* in
+ **Edit > Project Settings > Game > Seminole** (all slice tunables live
+ there) and press Play again.
+
+Automation tests for these systems run from **Tools > Session Frontend >
+Automation** (filter `Seminole`); see `Source/Seminole/Tests/README.md`.
### 6. Package for Windows
diff --git a/Source/Seminole/Community/README.md b/Source/Seminole/Community/README.md
index a976504..7da6e33 100644
--- a/Source/Seminole/Community/README.md
+++ b/Source/Seminole/Community/README.md
@@ -2,5 +2,5 @@
The player camp as a community: villagers, their roles and needs, and camp upgrades between day and night phases.
-No code lives here yet; this folder fixes where the system lands when its ticket is funded.
+Implemented (vertical slice part 1): `ASeminoleStockpile`, the hub supply store. `DepositAll` takes a whole inventory, `TrySpend(Type, Amount)` is what camp upgrades and barricades draw from. Villagers and roles are later.
See [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md).
diff --git a/Source/Seminole/Community/SeminoleStockpile.cpp b/Source/Seminole/Community/SeminoleStockpile.cpp
new file mode 100644
index 0000000..4ea4551
--- /dev/null
+++ b/Source/Seminole/Community/SeminoleStockpile.cpp
@@ -0,0 +1,105 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Community/SeminoleStockpile.h"
+
+#include "Seminole.h"
+#include "SeminolePlaceholderVisuals.h"
+#include "Inventory/SeminoleInventoryComponent.h"
+#include "Components/StaticMeshComponent.h"
+#include "Engine/CollisionProfile.h"
+#include "Engine/StaticMesh.h"
+#include "UObject/ConstructorHelpers.h"
+
+namespace
+{
+ const FLinearColor StockpileColor(0.15f, 0.6f, 0.25f);
+ // /Engine/BasicShapes/Cube is 1 m; a 1.5 m square, 1 m tall store.
+ const FVector StockpileScale(1.5f, 1.5f, 1.0f);
+}
+
+ASeminoleStockpile::ASeminoleStockpile()
+{
+ PrimaryActorTick.bCanEverTick = false;
+
+ Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
+ SetRootComponent(Mesh);
+ Mesh->SetMobility(EComponentMobility::Movable);
+ Mesh->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
+ Mesh->SetCanEverAffectNavigation(false);
+ Mesh->SetRelativeScale3D(StockpileScale);
+ ConstructorHelpers::FObjectFinder<UStaticMesh> CubeFinder(TEXT("/Engine/BasicShapes/Cube.Cube"));
+ if (CubeFinder.Succeeded())
+ {
+ Mesh->SetStaticMesh(CubeFinder.Object);
+ }
+}
+
+void ASeminoleStockpile::BeginPlay()
+{
+ Super::BeginPlay();
+
+ SeminolePlaceholderVisuals::SetColor(Mesh, StockpileColor);
+}
+
+bool ASeminoleStockpile::CanInteract(AActor* Interactor) const
+{
+ const USeminoleInventoryComponent* Inventory = Interactor != nullptr ? Interactor->FindComponentByClass<USeminoleInventoryComponent>() : nullptr;
+ return Inventory != nullptr && !Inventory->IsEmpty();
+}
+
+void ASeminoleStockpile::Interact(AActor* Interactor)
+{
+ DepositAll(Interactor != nullptr ? Interactor->FindComponentByClass<USeminoleInventoryComponent>() : nullptr);
+}
+
+FText ASeminoleStockpile::GetInteractionPrompt(AActor* Interactor) const
+{
+ if (CanInteract(Interactor))
+ {
+ return NSLOCTEXT("Seminole", "StockpileDeposit", "Deposit supplies");
+ }
+ return NSLOCTEXT("Seminole", "StockpileNothing", "Stockpile (nothing to deposit)");
+}
+
+FSeminoleSupplyCounts ASeminoleStockpile::DepositAll(USeminoleInventoryComponent* From)
+{
+ if (From == nullptr)
+ {
+ return FSeminoleSupplyCounts();
+ }
+
+ const FSeminoleSupplyCounts Deposited = From->TakeAll();
+ if (!Deposited.IsEmpty())
+ {
+ Supplies.Add(Deposited);
+ UE_LOG(LogSeminole, Log, TEXT("%s: deposited %s; stockpile now %s."), *GetName(), *Deposited.ToString(), *Supplies.ToString());
+ OnStockpileChanged.Broadcast();
+ }
+ return Deposited;
+}
+
+void ASeminoleStockpile::AddSupply(ESeminoleSupplyType Type, int32 Amount)
+{
+ if (Amount <= 0)
+ {
+ return;
+ }
+ Supplies.Add(Type, Amount);
+ OnStockpileChanged.Broadcast();
+}
+
+bool ASeminoleStockpile::TrySpend(ESeminoleSupplyType Type, int32 Amount)
+{
+ if (Amount <= 0)
+ {
+ return true;
+ }
+ if (Supplies.Get(Type) < Amount)
+ {
+ return false;
+ }
+ Supplies.Add(Type, -Amount);
+ UE_LOG(LogSeminole, Log, TEXT("%s: spent %d %s; stockpile now %s."), *GetName(), Amount, *SeminoleSupplyTypeToString(Type), *Supplies.ToString());
+ OnStockpileChanged.Broadcast();
+ return true;
+}
diff --git a/Source/Seminole/Community/SeminoleStockpile.h b/Source/Seminole/Community/SeminoleStockpile.h
new file mode 100644
index 0000000..163ec61
--- /dev/null
+++ b/Source/Seminole/Community/SeminoleStockpile.h
@@ -0,0 +1,68 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/Actor.h"
+#include "Interaction/SeminoleInteractable.h"
+#include "Inventory/SeminoleSupplyTypes.h"
+#include "SeminoleStockpile.generated.h"
+
+class USeminoleInventoryComponent;
+class UStaticMeshComponent;
+
+DECLARE_DYNAMIC_MULTICAST_DELEGATE(FSeminoleStockpileChanged);
+
+/**
+ * The camp's shared supply store at the hub. Interacting deposits everything the interactor's
+ * USeminoleInventoryComponent carries. The totals held here are the authoritative camp state:
+ * the stockpile is spawned by the authority (game mode) and its Supplies can be marked
+ * Replicated when the networking ticket lands.
+ *
+ * Extension point: TrySpend() is what part 3's barricades and repairs draw from.
+ */
+UCLASS()
+class SEMINOLE_API ASeminoleStockpile : public AActor, public ISeminoleInteractable
+{
+ GENERATED_BODY()
+
+public:
+ ASeminoleStockpile();
+
+ //~ AActor
+ virtual void BeginPlay() override;
+
+ //~ ISeminoleInteractable
+ virtual bool CanInteract(AActor* Interactor) const override;
+ virtual void Interact(AActor* Interactor) override;
+ virtual FText GetInteractionPrompt(AActor* Interactor) const override;
+
+ /** Moves everything in From into the stockpile and returns what was moved. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Stockpile")
+ FSeminoleSupplyCounts DepositAll(USeminoleInventoryComponent* From);
+
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Stockpile")
+ void AddSupply(ESeminoleSupplyType Type, int32 Amount);
+
+ /** Removes Amount of Type. Returns false, changing nothing, when the stockpile holds fewer. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Stockpile")
+ bool TrySpend(ESeminoleSupplyType Type, int32 Amount);
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Stockpile")
+ int32 GetSupplyCount(ESeminoleSupplyType Type) const { return Supplies.Get(Type); }
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Stockpile")
+ FSeminoleSupplyCounts GetSupplies() const { return Supplies; }
+
+ /** Fired after every deposit or spend. */
+ UPROPERTY(BlueprintAssignable, Category = "Seminole|Stockpile")
+ FSeminoleStockpileChanged OnStockpileChanged;
+
+private:
+ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+ TObjectPtr<UStaticMeshComponent> Mesh;
+
+ /** Authoritative totals. */
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Stockpile")
+ FSeminoleSupplyCounts Supplies;
+};
diff --git a/Source/Seminole/Interaction/README.md b/Source/Seminole/Interaction/README.md
index 5d4fe7e..63d5ba5 100644
--- a/Source/Seminole/Interaction/README.md
+++ b/Source/Seminole/Interaction/README.md
@@ -2,5 +2,5 @@
Interactable actors and the interaction component: pick up, use, open, board a canoe.
-No code lives here yet; this folder fixes where the system lands when its ticket is funded.
+Implemented (vertical slice part 1): `ISeminoleInteractable` (`CanInteract`, `Interact`, `GetInteractionPrompt`) and `USeminoleInteractionComponent`, which tracks the nearest interactable within `USeminoleSettings::InteractionRange` and uses it on the `Interact` action (E). New interactables implement the interface; nothing else needs to change.
See [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md).
diff --git a/Source/Seminole/Interaction/SeminoleInteractable.h b/Source/Seminole/Interaction/SeminoleInteractable.h
new file mode 100644
index 0000000..5250809
--- /dev/null
+++ b/Source/Seminole/Interaction/SeminoleInteractable.h
@@ -0,0 +1,35 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "UObject/Interface.h"
+#include "SeminoleInteractable.generated.h"
+
+class AActor;
+
+UINTERFACE(MinimalAPI, meta = (CannotImplementInterfaceInBlueprint))
+class USeminoleInteractable : public UInterface
+{
+ GENERATED_BODY()
+};
+
+/**
+ * Something the player can use with the Interact action: part 1 has the supply container and
+ * the stockpile; the canoe (part 2) and barricades (part 3) add theirs. Blueprint subclasses
+ * of a C++ interactable inherit its implementation; the interface itself is C++ only.
+ */
+class SEMINOLE_API ISeminoleInteractable
+{
+ GENERATED_BODY()
+
+public:
+ /** Whether Interact would do anything right now for this interactor. */
+ virtual bool CanInteract(AActor* Interactor) const = 0;
+
+ /** Performs the interaction. Only called when CanInteract returned true. */
+ virtual void Interact(AActor* Interactor) = 0;
+
+ /** Short HUD text such as "Search container" or "Searching..."; shown whether or not CanInteract. */
+ virtual FText GetInteractionPrompt(AActor* Interactor) const = 0;
+};
diff --git a/Source/Seminole/Interaction/SeminoleInteractionComponent.cpp b/Source/Seminole/Interaction/SeminoleInteractionComponent.cpp
new file mode 100644
index 0000000..eb910b6
--- /dev/null
+++ b/Source/Seminole/Interaction/SeminoleInteractionComponent.cpp
@@ -0,0 +1,74 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Interaction/SeminoleInteractionComponent.h"
+
+#include "SeminoleSettings.h"
+#include "Interaction/SeminoleInteractable.h"
+#include "Engine/World.h"
+#include "EngineUtils.h"
+#include "GameFramework/Actor.h"
+
+USeminoleInteractionComponent::USeminoleInteractionComponent()
+{
+ PrimaryComponentTick.bCanEverTick = true;
+ PrimaryComponentTick.bStartWithTickEnabled = true;
+}
+
+void USeminoleInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
+{
+ Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
+
+ FindBestInteractable();
+}
+
+FText USeminoleInteractionComponent::GetFocusedPrompt() const
+{
+ const ISeminoleInteractable* Interactable = Cast<ISeminoleInteractable>(FocusedInteractable.Get());
+ return Interactable != nullptr ? Interactable->GetInteractionPrompt(GetOwner()) : FText::GetEmpty();
+}
+
+bool USeminoleInteractionComponent::TryInteract()
+{
+ AActor* Target = FindBestInteractable();
+ ISeminoleInteractable* Interactable = Cast<ISeminoleInteractable>(Target);
+ if (Interactable == nullptr || !Interactable->CanInteract(GetOwner()))
+ {
+ return false;
+ }
+ Interactable->Interact(GetOwner());
+ return true;
+}
+
+AActor* USeminoleInteractionComponent::FindBestInteractable()
+{
+ FocusedInteractable = nullptr;
+
+ const AActor* Owner = GetOwner();
+ UWorld* World = GetWorld();
+ if (Owner == nullptr || World == nullptr)
+ {
+ return nullptr;
+ }
+
+ const FVector OwnerLocation = Owner->GetActorLocation();
+ float BestDistanceSquared = FMath::Square(GetDefault<USeminoleSettings>()->InteractionRange);
+ AActor* Best = nullptr;
+
+ for (TActorIterator<AActor> It(World); It; ++It)
+ {
+ AActor* Candidate = *It;
+ if (Candidate == Owner || Cast<ISeminoleInteractable>(Candidate) == nullptr)
+ {
+ continue;
+ }
+ const float DistanceSquared = FVector::DistSquared(OwnerLocation, Candidate->GetActorLocation());
+ if (DistanceSquared <= BestDistanceSquared)
+ {
+ BestDistanceSquared = DistanceSquared;
+ Best = Candidate;
+ }
+ }
+
+ FocusedInteractable = Best;
+ return Best;
+}
diff --git a/Source/Seminole/Interaction/SeminoleInteractionComponent.h b/Source/Seminole/Interaction/SeminoleInteractionComponent.h
new file mode 100644
index 0000000..83698ee
--- /dev/null
+++ b/Source/Seminole/Interaction/SeminoleInteractionComponent.h
@@ -0,0 +1,45 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Components/ActorComponent.h"
+#include "SeminoleInteractionComponent.generated.h"
+
+/**
+ * Finds the nearest ISeminoleInteractable within USeminoleSettings::InteractionRange of the
+ * owner every tick (the HUD shows its prompt) and uses it when the Interact action fires.
+ *
+ * The search walks every actor in the world, which is fine for the handful of placeholder
+ * actors in the slice; an overlap query replaces it when a real map arrives.
+ */
+UCLASS(ClassGroup = (Seminole), meta = (BlueprintSpawnableComponent))
+class SEMINOLE_API USeminoleInteractionComponent : public UActorComponent
+{
+ GENERATED_BODY()
+
+public:
+ USeminoleInteractionComponent();
+
+ //~ UActorComponent
+ virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
+
+ /** Nearest interactable in range as of the last tick, or null. */
+ UFUNCTION(BlueprintPure, Category = "Seminole|Interaction")
+ AActor* GetFocusedInteractable() const { return FocusedInteractable.Get(); }
+
+ /** Prompt of the focused interactable, or empty text. */
+ UFUNCTION(BlueprintPure, Category = "Seminole|Interaction")
+ FText GetFocusedPrompt() const;
+
+ /** Interacts with the focused interactable if it allows it. Returns true when it did. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Interaction")
+ bool TryInteract();
+
+ /** Re-scans the world now; TickComponent calls this each frame. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Interaction")
+ AActor* FindBestInteractable();
+
+private:
+ TWeakObjectPtr<AActor> FocusedInteractable;
+};
diff --git a/Source/Seminole/Inventory/README.md b/Source/Seminole/Inventory/README.md
index 60e8b9e..96abfa9 100644
--- a/Source/Seminole/Inventory/README.md
+++ b/Source/Seminole/Inventory/README.md
@@ -2,5 +2,5 @@
Items, containers, equipment slots and the data assets that describe them.
-No code lives here yet; this folder fixes where the system lands when its ticket is funded.
+Implemented (vertical slice part 1): the supply types (`ESeminoleSupplyType`, `FSeminoleSupplyAmount`, `FSeminoleSupplyCounts` in `SeminoleSupplyTypes.h`), `USeminoleInventoryComponent` (per-type counts on the player pawn) and `ASeminoleSupplyContainer` (a timed, one-shot lootable crate). Items, slots and data assets are later.
See [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md).
diff --git a/Source/Seminole/Inventory/SeminoleInventoryComponent.cpp b/Source/Seminole/Inventory/SeminoleInventoryComponent.cpp
new file mode 100644
index 0000000..bff22fd
--- /dev/null
+++ b/Source/Seminole/Inventory/SeminoleInventoryComponent.cpp
@@ -0,0 +1,61 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Inventory/SeminoleInventoryComponent.h"
+
+USeminoleInventoryComponent::USeminoleInventoryComponent()
+{
+ PrimaryComponentTick.bCanEverTick = false;
+}
+
+void USeminoleInventoryComponent::AddSupply(ESeminoleSupplyType Type, int32 Amount)
+{
+ if (Amount <= 0)
+ {
+ return;
+ }
+ Supplies.Add(Type, Amount);
+ OnInventoryChanged.Broadcast();
+}
+
+void USeminoleInventoryComponent::AddSupplies(const TArray<FSeminoleSupplyAmount>& Amounts)
+{
+ bool bChanged = false;
+ for (const FSeminoleSupplyAmount& Amount : Amounts)
+ {
+ if (Amount.Amount > 0)
+ {
+ Supplies.Add(Amount.Type, Amount.Amount);
+ bChanged = true;
+ }
+ }
+ if (bChanged)
+ {
+ OnInventoryChanged.Broadcast();
+ }
+}
+
+bool USeminoleInventoryComponent::RemoveSupply(ESeminoleSupplyType Type, int32 Amount)
+{
+ if (Amount <= 0)
+ {
+ return true;
+ }
+ if (Supplies.Get(Type) < Amount)
+ {
+ return false;
+ }
+ Supplies.Add(Type, -Amount);
+ OnInventoryChanged.Broadcast();
+ return true;
+}
+
+FSeminoleSupplyCounts USeminoleInventoryComponent::TakeAll()
+{
+ const FSeminoleSupplyCounts Taken = Supplies;
+ if (!Taken.IsEmpty())
+ {
+ Supplies.Reset();
+ OnInventoryChanged.Broadcast();
+ }
+ return Taken;
+}
diff --git a/Source/Seminole/Inventory/SeminoleInventoryComponent.h b/Source/Seminole/Inventory/SeminoleInventoryComponent.h
new file mode 100644
index 0000000..418cd96
--- /dev/null
+++ b/Source/Seminole/Inventory/SeminoleInventoryComponent.h
@@ -0,0 +1,58 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Components/ActorComponent.h"
+#include "Inventory/SeminoleSupplyTypes.h"
+#include "SeminoleInventoryComponent.generated.h"
+
+DECLARE_DYNAMIC_MULTICAST_DELEGATE(FSeminoleInventoryChanged);
+
+/**
+ * Carried supplies: an integer count per ESeminoleSupplyType on the player pawn. Containers add
+ * to it, the stockpile takes everything out of it. No items, slots or weight; that is the
+ * later Inventory ticket.
+ */
+UCLASS(ClassGroup = (Seminole), meta = (BlueprintSpawnableComponent))
+class SEMINOLE_API USeminoleInventoryComponent : public UActorComponent
+{
+ GENERATED_BODY()
+
+public:
+ USeminoleInventoryComponent();
+
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Inventory")
+ void AddSupply(ESeminoleSupplyType Type, int32 Amount);
+
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Inventory")
+ void AddSupplies(const TArray<FSeminoleSupplyAmount>& Amounts);
+
+ /** Removes Amount of Type. Returns false, changing nothing, when fewer are carried. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Inventory")
+ bool RemoveSupply(ESeminoleSupplyType Type, int32 Amount);
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Inventory")
+ int32 GetSupplyCount(ESeminoleSupplyType Type) const { return Supplies.Get(Type); }
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Inventory")
+ int32 GetTotalSupplyCount() const { return Supplies.Total(); }
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Inventory")
+ bool IsEmpty() const { return Supplies.IsEmpty(); }
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Inventory")
+ FSeminoleSupplyCounts GetSupplies() const { return Supplies; }
+
+ /** Empties the inventory and returns what it held (used by the stockpile deposit). */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Inventory")
+ FSeminoleSupplyCounts TakeAll();
+
+ /** Fired after every change. The HUD reads the counts directly, so no payload. */
+ UPROPERTY(BlueprintAssignable, Category = "Seminole|Inventory")
+ FSeminoleInventoryChanged OnInventoryChanged;
+
+private:
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Inventory")
+ FSeminoleSupplyCounts Supplies;
+};
diff --git a/Source/Seminole/Inventory/SeminoleSupplyContainer.cpp b/Source/Seminole/Inventory/SeminoleSupplyContainer.cpp
new file mode 100644
index 0000000..894dd95
--- /dev/null
+++ b/Source/Seminole/Inventory/SeminoleSupplyContainer.cpp
@@ -0,0 +1,164 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Inventory/SeminoleSupplyContainer.h"
+
+#include "Seminole.h"
+#include "SeminolePlaceholderVisuals.h"
+#include "SeminoleSettings.h"
+#include "Inventory/SeminoleInventoryComponent.h"
+#include "World/SeminoleNoiseSubsystem.h"
+#include "Components/StaticMeshComponent.h"
+#include "Engine/CollisionProfile.h"
+#include "Engine/StaticMesh.h"
+#include "UObject/ConstructorHelpers.h"
+
+namespace
+{
+ const FLinearColor UnsearchedColor(0.55f, 0.35f, 0.15f);
+ const FLinearColor SearchedColor(0.35f, 0.35f, 0.35f);
+ // /Engine/BasicShapes/Cube is 1 m; a crate 80 cm wide and 60 cm tall.
+ const FVector CrateScale(0.8f, 0.8f, 0.6f);
+}
+
+ASeminoleSupplyContainer::ASeminoleSupplyContainer()
+{
+ PrimaryActorTick.bCanEverTick = true;
+ PrimaryActorTick.bStartWithTickEnabled = false;
+
+ Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
+ SetRootComponent(Mesh);
+ Mesh->SetMobility(EComponentMobility::Movable);
+ Mesh->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
+ Mesh->SetCanEverAffectNavigation(false);
+ Mesh->SetRelativeScale3D(CrateScale);
+ ConstructorHelpers::FObjectFinder<UStaticMesh> CubeFinder(TEXT("/Engine/BasicShapes/Cube.Cube"));
+ if (CubeFinder.Succeeded())
+ {
+ Mesh->SetStaticMesh(CubeFinder.Object);
+ }
+}
+
+void ASeminoleSupplyContainer::BeginPlay()
+{
+ Super::BeginPlay();
+
+ SeminolePlaceholderVisuals::SetColor(Mesh, bSearched ? SearchedColor : UnsearchedColor);
+}
+
+void ASeminoleSupplyContainer::Tick(float DeltaSeconds)
+{
+ Super::Tick(DeltaSeconds);
+
+ AdvanceSearch(DeltaSeconds);
+}
+
+bool ASeminoleSupplyContainer::CanInteract(AActor* Interactor) const
+{
+ return Interactor != nullptr && !bSearched && !bSearching;
+}
+
+void ASeminoleSupplyContainer::Interact(AActor* Interactor)
+{
+ BeginSearch(Interactor);
+}
+
+FText ASeminoleSupplyContainer::GetInteractionPrompt(AActor* Interactor) const
+{
+ if (bSearched)
+ {
+ return NSLOCTEXT("Seminole", "ContainerSearched", "Container (empty)");
+ }
+ if (bSearching)
+ {
+ return NSLOCTEXT("Seminole", "ContainerSearching", "Searching...");
+ }
+ return NSLOCTEXT("Seminole", "ContainerSearch", "Search container");
+}
+
+bool ASeminoleSupplyContainer::BeginSearch(AActor* Searcher)
+{
+ if (Searcher == nullptr || bSearched || bSearching)
+ {
+ return false;
+ }
+
+ const USeminoleSettings* Settings = GetDefault<USeminoleSettings>();
+
+ bSearching = true;
+ CurrentSearcher = Searcher;
+ SearchElapsedSeconds = 0.0f;
+ SearchDurationSeconds = FMath::Max(Settings->ContainerSearchDurationSeconds, 0.0f);
+
+ UE_LOG(LogSeminole, Log, TEXT("%s: %s starts searching (%.1f s)."), *GetName(), *Searcher->GetName(), SearchDurationSeconds);
+
+ // Rummaging is audible: the first thing in the slice that lets the noise system be heard.
+ if (Settings->ContainerSearchNoiseRadius > 0.0f)
+ {
+ if (USeminoleNoiseSubsystem* Noise = USeminoleNoiseSubsystem::Get(this))
+ {
+ Noise->EmitNoise(GetActorLocation(), Settings->ContainerSearchNoiseRadius, Searcher);
+ }
+ }
+
+ if (SearchDurationSeconds <= 0.0f)
+ {
+ FinishSearch();
+ }
+ else
+ {
+ SetActorTickEnabled(true);
+ }
+ return true;
+}
+
+void ASeminoleSupplyContainer::AdvanceSearch(float DeltaSeconds)
+{
+ if (!bSearching || DeltaSeconds <= 0.0f)
+ {
+ return;
+ }
+
+ SearchElapsedSeconds += DeltaSeconds;
+ if (SearchElapsedSeconds >= SearchDurationSeconds)
+ {
+ FinishSearch();
+ }
+}
+
+float ASeminoleSupplyContainer::GetSearchProgress() const
+{
+ if (bSearched)
+ {
+ return 1.0f;
+ }
+ if (!bSearching || SearchDurationSeconds <= 0.0f)
+ {
+ return 0.0f;
+ }
+ return FMath::Clamp(SearchElapsedSeconds / SearchDurationSeconds, 0.0f, 1.0f);
+}
+
+void ASeminoleSupplyContainer::FinishSearch()
+{
+ SetActorTickEnabled(false);
+ bSearching = false;
+ bSearched = true;
+ SearchElapsedSeconds = SearchDurationSeconds;
+
+ AActor* Searcher = CurrentSearcher.Get();
+ CurrentSearcher = nullptr;
+
+ USeminoleInventoryComponent* Inventory = Searcher != nullptr ? Searcher->FindComponentByClass<USeminoleInventoryComponent>() : nullptr;
+ if (Inventory != nullptr)
+ {
+ Inventory->AddSupplies(Supplies);
+ UE_LOG(LogSeminole, Log, TEXT("%s: search complete, %s now carries %s."), *GetName(), *Searcher->GetName(), *Inventory->GetSupplies().ToString());
+ }
+ else
+ {
+ UE_LOG(LogSeminole, Warning, TEXT("%s: search complete but the searcher has no USeminoleInventoryComponent; supplies lost."), *GetName());
+ }
+
+ SeminolePlaceholderVisuals::SetColor(Mesh, SearchedColor);
+ OnSearched.Broadcast(this, Searcher);
+}
diff --git a/Source/Seminole/Inventory/SeminoleSupplyContainer.h b/Source/Seminole/Inventory/SeminoleSupplyContainer.h
new file mode 100644
index 0000000..760e7da
--- /dev/null
+++ b/Source/Seminole/Inventory/SeminoleSupplyContainer.h
@@ -0,0 +1,93 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/Actor.h"
+#include "Interaction/SeminoleInteractable.h"
+#include "Inventory/SeminoleSupplyTypes.h"
+#include "SeminoleSupplyContainer.generated.h"
+
+class ASeminoleSupplyContainer;
+class UStaticMeshComponent;
+
+DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FSeminoleContainerSearched, ASeminoleSupplyContainer*, Container, AActor*, Searcher);
+
+/**
+ * A lootable crate. Interacting starts a search that lasts
+ * USeminoleSettings::ContainerSearchDurationSeconds; when it completes, the configured
+ * Supplies go to the searcher's USeminoleInventoryComponent, the crate turns grey and can
+ * never be searched again. Starting a search emits noise (ContainerSearchNoiseRadius).
+ *
+ * Timing runs through AdvanceSearch(), fed by Tick while a search is active and called
+ * directly by tests, so the search is deterministic.
+ */
+UCLASS()
+class SEMINOLE_API ASeminoleSupplyContainer : public AActor, public ISeminoleInteractable
+{
+ GENERATED_BODY()
+
+public:
+ ASeminoleSupplyContainer();
+
+ //~ AActor
+ virtual void BeginPlay() override;
+ virtual void Tick(float DeltaSeconds) override;
+
+ //~ ISeminoleInteractable
+ virtual bool CanInteract(AActor* Interactor) const override;
+ virtual void Interact(AActor* Interactor) override;
+ virtual FText GetInteractionPrompt(AActor* Interactor) const override;
+
+ /** What a successful search grants. Set before the first search (the test environment does). */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Container")
+ void SetSupplies(const TArray<FSeminoleSupplyAmount>& InSupplies) { Supplies = InSupplies; }
+
+ const TArray<FSeminoleSupplyAmount>& GetSupplies() const { return Supplies; }
+
+ /** Starts the timed search. Returns false if the container is already searched or being searched. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Container")
+ bool BeginSearch(AActor* Searcher);
+
+ /** Moves an active search forward; completes it once the configured duration has elapsed. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Container")
+ void AdvanceSearch(float DeltaSeconds);
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Container")
+ bool IsSearched() const { return bSearched; }
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Container")
+ bool IsBeingSearched() const { return bSearching; }
+
+ /** 0..1 while a search is active; 1 once searched; 0 otherwise. */
+ UFUNCTION(BlueprintPure, Category = "Seminole|Container")
+ float GetSearchProgress() const;
+
+ /** Fired once, when the search completes and the supplies have been granted. */
+ UPROPERTY(BlueprintAssignable, Category = "Seminole|Container")
+ FSeminoleContainerSearched OnSearched;
+
+private:
+ void FinishSearch();
+
+ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+ TObjectPtr<UStaticMeshComponent> Mesh;
+
+ UPROPERTY(EditAnywhere, Category = "Seminole|Container")
+ TArray<FSeminoleSupplyAmount> Supplies;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Container")
+ bool bSearched = false;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Container")
+ bool bSearching = false;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Container")
+ float SearchElapsedSeconds = 0.0f;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Container")
+ float SearchDurationSeconds = 0.0f;
+
+ /** Who gets the supplies when the search completes. */
+ TWeakObjectPtr<AActor> CurrentSearcher;
+};
diff --git a/Source/Seminole/Inventory/SeminoleSupplyTypes.cpp b/Source/Seminole/Inventory/SeminoleSupplyTypes.cpp
new file mode 100644
index 0000000..4c876ea
--- /dev/null
+++ b/Source/Seminole/Inventory/SeminoleSupplyTypes.cpp
@@ -0,0 +1,88 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Inventory/SeminoleSupplyTypes.h"
+
+FString SeminoleSupplyTypeToString(ESeminoleSupplyType Type)
+{
+ switch (Type)
+ {
+ case ESeminoleSupplyType::Food:
+ return TEXT("Food");
+ case ESeminoleSupplyType::Ammo:
+ return TEXT("Ammo");
+ case ESeminoleSupplyType::Materials:
+ return TEXT("Materials");
+ default:
+ return TEXT("Unknown");
+ }
+}
+
+int32 FSeminoleSupplyCounts::Get(ESeminoleSupplyType Type) const
+{
+ switch (Type)
+ {
+ case ESeminoleSupplyType::Food:
+ return Food;
+ case ESeminoleSupplyType::Ammo:
+ return Ammo;
+ case ESeminoleSupplyType::Materials:
+ return Materials;
+ default:
+ return 0;
+ }
+}
+
+void FSeminoleSupplyCounts::Add(ESeminoleSupplyType Type, int32 Delta)
+{
+ switch (Type)
+ {
+ case ESeminoleSupplyType::Food:
+ Food = FMath::Max(0, Food + Delta);
+ break;
+ case ESeminoleSupplyType::Ammo:
+ Ammo = FMath::Max(0, Ammo + Delta);
+ break;
+ case ESeminoleSupplyType::Materials:
+ Materials = FMath::Max(0, Materials + Delta);
+ break;
+ default:
+ break;
+ }
+}
+
+void FSeminoleSupplyCounts::Add(const TArray<FSeminoleSupplyAmount>& Amounts)
+{
+ for (const FSeminoleSupplyAmount& Amount : Amounts)
+ {
+ Add(Amount.Type, Amount.Amount);
+ }
+}
+
+void FSeminoleSupplyCounts::Add(const FSeminoleSupplyCounts& Other)
+{
+ Add(ESeminoleSupplyType::Food, Other.Food);
+ Add(ESeminoleSupplyType::Ammo, Other.Ammo);
+ Add(ESeminoleSupplyType::Materials, Other.Materials);
+}
+
+int32 FSeminoleSupplyCounts::Total() const
+{
+ return Food + Ammo + Materials;
+}
+
+bool FSeminoleSupplyCounts::IsEmpty() const
+{
+ return Total() == 0;
+}
+
+void FSeminoleSupplyCounts::Reset()
+{
+ Food = 0;
+ Ammo = 0;
+ Materials = 0;
+}
+
+FString FSeminoleSupplyCounts::ToString() const
+{
+ return FString::Printf(TEXT("Food %d Ammo %d Materials %d"), Food, Ammo, Materials);
+}
diff --git a/Source/Seminole/Inventory/SeminoleSupplyTypes.h b/Source/Seminole/Inventory/SeminoleSupplyTypes.h
new file mode 100644
index 0000000..fe1e803
--- /dev/null
+++ b/Source/Seminole/Inventory/SeminoleSupplyTypes.h
@@ -0,0 +1,75 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "SeminoleSupplyTypes.generated.h"
+
+/** The three supply types of the vertical slice. Counts are plain integers. */
+UENUM(BlueprintType)
+enum class ESeminoleSupplyType : uint8
+{
+ Food,
+ Ammo,
+ Materials
+};
+
+/** Returns "Food", "Ammo" or "Materials"; used by logs and the Canvas HUD. */
+SEMINOLE_API FString SeminoleSupplyTypeToString(ESeminoleSupplyType Type);
+
+/** One supply type with an amount: the unit a container grants. */
+USTRUCT(BlueprintType)
+struct SEMINOLE_API FSeminoleSupplyAmount
+{
+ GENERATED_BODY()
+
+ FSeminoleSupplyAmount() = default;
+ FSeminoleSupplyAmount(ESeminoleSupplyType InType, int32 InAmount)
+ : Type(InType)
+ , Amount(InAmount)
+ {
+ }
+
+ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Seminole")
+ ESeminoleSupplyType Type = ESeminoleSupplyType::Food;
+
+ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Seminole", meta = (ClampMin = "0"))
+ int32 Amount = 0;
+};
+
+/**
+ * A count per supply type. One explicit field per type (rather than a TMap) so the struct
+ * can be marked Replicated unchanged when the networking ticket lands.
+ */
+USTRUCT(BlueprintType)
+struct SEMINOLE_API FSeminoleSupplyCounts
+{
+ GENERATED_BODY()
+
+ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Seminole", meta = (ClampMin = "0"))
+ int32 Food = 0;
+
+ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Seminole", meta = (ClampMin = "0"))
+ int32 Ammo = 0;
+
+ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Seminole", meta = (ClampMin = "0"))
+ int32 Materials = 0;
+
+ int32 Get(ESeminoleSupplyType Type) const;
+
+ /** Adds Delta (which may be negative) to one type and clamps the result at zero. */
+ void Add(ESeminoleSupplyType Type, int32 Delta);
+
+ /** Adds every entry of Amounts. */
+ void Add(const TArray<FSeminoleSupplyAmount>& Amounts);
+
+ /** Adds every count of Other. */
+ void Add(const FSeminoleSupplyCounts& Other);
+
+ int32 Total() const;
+ bool IsEmpty() const;
+ void Reset();
+
+ /** "Food 3 Ammo 10 Materials 5" */
+ FString ToString() const;
+};
diff --git a/Source/Seminole/Seminole.Build.cs b/Source/Seminole/Seminole.Build.cs
index 5fd4954..dc49ba4 100644
--- a/Source/Seminole/Seminole.Build.cs
+++ b/Source/Seminole/Seminole.Build.cs
@@ -13,7 +13,9 @@ public Seminole(ReadOnlyTargetRules Target) : base(Target)
"Core",
"CoreUObject",
"Engine",
- "InputCore"
+ "InputCore",
+ // USeminoleSettings (UDeveloperSettings) lives in this module.
+ "DeveloperSettings"
});
PrivateDependencyModuleNames.AddRange(new string[] { });
diff --git a/Source/Seminole/SeminoleGameMode.cpp b/Source/Seminole/SeminoleGameMode.cpp
index 6e79c74..ae65aec 100644
--- a/Source/Seminole/SeminoleGameMode.cpp
+++ b/Source/Seminole/SeminoleGameMode.cpp
@@ -3,14 +3,18 @@
#include "SeminoleGameMode.h"
#include "Seminole.h"
+#include "SeminoleGameState.h"
#include "SeminolePlaceholderCharacter.h"
#include "SeminoleTestEnvironment.h"
+#include "UI/SeminoleHUD.h"
#include "Engine/World.h"
#include "EngineUtils.h"
ASeminoleGameMode::ASeminoleGameMode()
{
DefaultPawnClass = ASeminolePlaceholderCharacter::StaticClass();
+ GameStateClass = ASeminoleGameState::StaticClass();
+ HUDClass = ASeminoleHUD::StaticClass();
}
void ASeminoleGameMode::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
@@ -46,4 +50,5 @@ void ASeminoleGameMode::InitGame(const FString& MapName, const FString& Options,
}
TestEnvironment->EnsureSceneBasics();
+ TestEnvironment->EnsureSlicePlaceholders();
}
diff --git a/Source/Seminole/SeminoleGameMode.h b/Source/Seminole/SeminoleGameMode.h
index a71e3a2..e433ea3 100644
--- a/Source/Seminole/SeminoleGameMode.h
+++ b/Source/Seminole/SeminoleGameMode.h
@@ -13,6 +13,9 @@
* makes sure the current map has a floor, a directional light, a sky light and a PlayerStart
* (see ASeminoleTestEnvironment). This lets the project boot into the empty engine map
* /Engine/Maps/Entry without any authored content.
+ *
+ * Vertical slice part 1: uses ASeminoleGameState (owns the day clock) and ASeminoleHUD, and
+ * asks the test environment for the stockpile, containers and noise listener placeholders.
*/
UCLASS()
class SEMINOLE_API ASeminoleGameMode : public AGameModeBase
diff --git a/Source/Seminole/SeminoleGameState.cpp b/Source/Seminole/SeminoleGameState.cpp
new file mode 100644
index 0000000..362006b
--- /dev/null
+++ b/Source/Seminole/SeminoleGameState.cpp
@@ -0,0 +1,10 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "SeminoleGameState.h"
+
+#include "Survival/SeminoleDayClockComponent.h"
+
+ASeminoleGameState::ASeminoleGameState()
+{
+ DayClock = CreateDefaultSubobject<USeminoleDayClockComponent>(TEXT("DayClock"));
+}
diff --git a/Source/Seminole/SeminoleGameState.h b/Source/Seminole/SeminoleGameState.h
new file mode 100644
index 0000000..5650fd9
--- /dev/null
+++ b/Source/Seminole/SeminoleGameState.h
@@ -0,0 +1,30 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/GameStateBase.h"
+#include "SeminoleGameState.generated.h"
+
+class USeminoleDayClockComponent;
+
+/**
+ * Project game state (set by ASeminoleGameMode). Owns the day clock so the current phase is
+ * authoritative, host-owned state: when the networking ticket lands, the clock's phase and
+ * time remaining can be replicated from here without moving them.
+ */
+UCLASS()
+class SEMINOLE_API ASeminoleGameState : public AGameStateBase
+{
+ GENERATED_BODY()
+
+public:
+ ASeminoleGameState();
+
+ UFUNCTION(BlueprintPure, Category = "Seminole")
+ USeminoleDayClockComponent* GetDayClock() const { return DayClock; }
+
+private:
+ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+ TObjectPtr<USeminoleDayClockComponent> DayClock;
+};
diff --git a/Source/Seminole/SeminolePlaceholderCharacter.cpp b/Source/Seminole/SeminolePlaceholderCharacter.cpp
index 7323186..2c25509 100644
--- a/Source/Seminole/SeminolePlaceholderCharacter.cpp
+++ b/Source/Seminole/SeminolePlaceholderCharacter.cpp
@@ -2,6 +2,8 @@
#include "SeminolePlaceholderCharacter.h"
+#include "Interaction/SeminoleInteractionComponent.h"
+#include "Inventory/SeminoleInventoryComponent.h"
#include "Camera/CameraComponent.h"
#include "Components/CapsuleComponent.h"
#include "Components/InputComponent.h"
@@ -54,6 +56,9 @@ ASeminolePlaceholderCharacter::ASeminolePlaceholderCharacter()
FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
FollowCamera->bUsePawnControlRotation = false;
+
+ Inventory = CreateDefaultSubobject<USeminoleInventoryComponent>(TEXT("Inventory"));
+ Interaction = CreateDefaultSubobject<USeminoleInteractionComponent>(TEXT("Interaction"));
}
void ASeminolePlaceholderCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -69,6 +74,7 @@ void ASeminolePlaceholderCharacter::SetupPlayerInputComponent(UInputComponent* P
PlayerInputComponent->BindAction(TEXT("Jump"), IE_Pressed, this, &ACharacter::Jump);
PlayerInputComponent->BindAction(TEXT("Jump"), IE_Released, this, &ACharacter::StopJumping);
+ PlayerInputComponent->BindAction(TEXT("Interact"), IE_Pressed, this, &ASeminolePlaceholderCharacter::Interact);
}
void ASeminolePlaceholderCharacter::MoveForward(float Value)
@@ -91,3 +97,8 @@ void ASeminolePlaceholderCharacter::MoveRight(float Value)
AddMovementInput(Direction, Value);
}
}
+
+void ASeminolePlaceholderCharacter::Interact()
+{
+ Interaction->TryInteract();
+}
diff --git a/Source/Seminole/SeminolePlaceholderCharacter.h b/Source/Seminole/SeminolePlaceholderCharacter.h
index 129271a..c7e67cd 100644
--- a/Source/Seminole/SeminolePlaceholderCharacter.h
+++ b/Source/Seminole/SeminolePlaceholderCharacter.h
@@ -7,6 +7,8 @@
#include "SeminolePlaceholderCharacter.generated.h"
class UCameraComponent;
+class USeminoleInteractionComponent;
+class USeminoleInventoryComponent;
class USpringArmComponent;
class UStaticMeshComponent;
@@ -15,8 +17,9 @@ class UStaticMeshComponent;
* a third-person spring-arm camera and WASD / mouse-look / jump controls.
*
* Input uses the legacy axis and action mappings in Config/DefaultInput.ini (MoveForward,
- * MoveRight, Turn, LookUp, Jump), bound in SetupPlayerInputComponent. The real player
- * character, with Enhanced Input and animation, is a later ticket under Characters/.
+ * MoveRight, Turn, LookUp, Jump, Interact), bound in SetupPlayerInputComponent. The real player
+ * character, with Enhanced Input and animation, is a later ticket under Characters/; it keeps
+ * the two vertical-slice components added here (inventory and interaction).
*/
UCLASS()
class SEMINOLE_API ASeminolePlaceholderCharacter : public ACharacter
@@ -28,9 +31,16 @@ class SEMINOLE_API ASeminolePlaceholderCharacter : public ACharacter
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
+ UFUNCTION(BlueprintPure, Category = "Seminole")
+ USeminoleInventoryComponent* GetInventory() const { return Inventory; }
+
+ UFUNCTION(BlueprintPure, Category = "Seminole")
+ USeminoleInteractionComponent* GetInteraction() const { return Interaction; }
+
private:
void MoveForward(float Value);
void MoveRight(float Value);
+ void Interact();
/** Visible body: /Engine/BasicShapes/Cylinder scaled to fit the capsule. No collision; the capsule handles that. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
@@ -41,4 +51,12 @@ class SEMINOLE_API ASeminolePlaceholderCharacter : public ACharacter
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UCameraComponent> FollowCamera;
+
+ /** Carried supplies (vertical slice part 1). */
+ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+ TObjectPtr<USeminoleInventoryComponent> Inventory;
+
+ /** Finds and uses the nearest interactable on the Interact action (E). */
+ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+ TObjectPtr<USeminoleInteractionComponent> Interaction;
};
diff --git a/Source/Seminole/SeminolePlaceholderVisuals.cpp b/Source/Seminole/SeminolePlaceholderVisuals.cpp
new file mode 100644
index 0000000..c8b715e
--- /dev/null
+++ b/Source/Seminole/SeminolePlaceholderVisuals.cpp
@@ -0,0 +1,29 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "SeminolePlaceholderVisuals.h"
+
+#include "Components/StaticMeshComponent.h"
+#include "Materials/MaterialInstanceDynamic.h"
+#include "Materials/MaterialInterface.h"
+
+namespace SeminolePlaceholderVisuals
+{
+ void SetColor(UStaticMeshComponent* Mesh, const FLinearColor& Color)
+ {
+ if (Mesh == nullptr || Mesh->GetNumMaterials() == 0)
+ {
+ return;
+ }
+
+ UMaterialInstanceDynamic* Dynamic = Cast<UMaterialInstanceDynamic>(Mesh->GetMaterial(0));
+ if (Dynamic == nullptr)
+ {
+ Dynamic = Mesh->CreateAndSetMaterialInstanceDynamic(0);
+ }
+ if (Dynamic != nullptr)
+ {
+ // Parameter name of /Engine/BasicShapes/BasicShapeMaterial.
+ Dynamic->SetVectorParameterValue(TEXT("Color"), Color);
+ }
+ }
+}
diff --git a/Source/Seminole/SeminolePlaceholderVisuals.h b/Source/Seminole/SeminolePlaceholderVisuals.h
new file mode 100644
index 0000000..523c40d
--- /dev/null
+++ b/Source/Seminole/SeminolePlaceholderVisuals.h
@@ -0,0 +1,18 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+
+class UStaticMeshComponent;
+
+/**
+ * Helpers for the runtime-spawned placeholder primitives of the vertical slice. They all use
+ * the engine's /Engine/BasicShapes meshes, whose default material exposes a "Color" vector
+ * parameter; tinting through a dynamic material instance is the only "art" the slice has.
+ */
+namespace SeminolePlaceholderVisuals
+{
+ /** Creates (once) a dynamic material instance on element 0 and sets its "Color" parameter. */
+ SEMINOLE_API void SetColor(UStaticMeshComponent* Mesh, const FLinearColor& Color);
+}
diff --git a/Source/Seminole/SeminoleSettings.cpp b/Source/Seminole/SeminoleSettings.cpp
new file mode 100644
index 0000000..c996af5
--- /dev/null
+++ b/Source/Seminole/SeminoleSettings.cpp
@@ -0,0 +1,27 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "SeminoleSettings.h"
+
+USeminoleSettings::USeminoleSettings()
+{
+ // Three containers by default: one per supply type, so each type can be seen flowing
+ // from a container through the inventory into the stockpile.
+ FSeminoleContainerLoot FoodCache;
+ FoodCache.Supplies.Add(FSeminoleSupplyAmount(ESeminoleSupplyType::Food, 3));
+
+ FSeminoleContainerLoot AmmoCache;
+ AmmoCache.Supplies.Add(FSeminoleSupplyAmount(ESeminoleSupplyType::Ammo, 10));
+
+ FSeminoleContainerLoot MaterialsCache;
+ MaterialsCache.Supplies.Add(FSeminoleSupplyAmount(ESeminoleSupplyType::Materials, 5));
+ MaterialsCache.Supplies.Add(FSeminoleSupplyAmount(ESeminoleSupplyType::Food, 1));
+
+ ScavengingContainers.Add(FoodCache);
+ ScavengingContainers.Add(AmmoCache);
+ ScavengingContainers.Add(MaterialsCache);
+}
+
+FName USeminoleSettings::GetCategoryName() const
+{
+ return FName(TEXT("Game"));
+}
diff --git a/Source/Seminole/SeminoleSettings.h b/Source/Seminole/SeminoleSettings.h
new file mode 100644
index 0000000..0657302
--- /dev/null
+++ b/Source/Seminole/SeminoleSettings.h
@@ -0,0 +1,108 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Engine/DeveloperSettings.h"
+#include "Inventory/SeminoleSupplyTypes.h"
+#include "SeminoleSettings.generated.h"
+
+/** What one scavenging container holds. The settings list one of these per container. */
+USTRUCT(BlueprintType)
+struct SEMINOLE_API FSeminoleContainerLoot
+{
+ GENERATED_BODY()
+
+ UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Seminole")
+ TArray<FSeminoleSupplyAmount> Supplies;
+};
+
+/**
+ * Every tunable of the vertical slice, in one place: Edit > Project Settings > Game > Seminole.
+ *
+ * Defaults are the C++ initialisers below; values changed in the editor are written to
+ * Config/DefaultGame.ini (section [/Script/Seminole.SeminoleSettings]). Gameplay code reads
+ * them through GetDefault<USeminoleSettings>() and never hard-codes them.
+ */
+UCLASS(Config = Game, DefaultConfig, meta = (DisplayName = "Seminole"))
+class SEMINOLE_API USeminoleSettings : public UDeveloperSettings
+{
+ GENERATED_BODY()
+
+public:
+ USeminoleSettings();
+
+ //~ UDeveloperSettings
+ virtual FName GetCategoryName() const override;
+
+ /** Length of the Day phase (scavenging) in seconds. */
+ UPROPERTY(Config, EditAnywhere, Category = "Day Clock", meta = (ClampMin = "1.0"))
+ float DayDurationSeconds = 480.0f;
+
+ /** Length of the Dusk phase (the warning before night) in seconds. */
+ UPROPERTY(Config, EditAnywhere, Category = "Day Clock", meta = (ClampMin = "1.0"))
+ float DuskDurationSeconds = 60.0f;
+
+ /** Directional light intensity (lux) at the start of Day. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting", meta = (ClampMin = "0.0"))
+ float DaySunIntensity = 10.0f;
+
+ /** Directional light intensity (lux) when Dusk begins; Day interpolates towards it. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting", meta = (ClampMin = "0.0"))
+ float DuskSunIntensity = 3.0f;
+
+ /** Directional light intensity (lux) when Night begins; Dusk interpolates towards it. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting", meta = (ClampMin = "0.0"))
+ float NightSunIntensity = 0.05f;
+
+ /** Sky light intensity scale at the start of Day. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting", meta = (ClampMin = "0.0"))
+ float DaySkyLightIntensity = 1.0f;
+
+ /** Sky light intensity scale when Dusk begins. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting", meta = (ClampMin = "0.0"))
+ float DuskSkyLightIntensity = 0.4f;
+
+ /** Sky light intensity scale when Night begins. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting", meta = (ClampMin = "0.0"))
+ float NightSkyLightIntensity = 0.05f;
+
+ /** Directional light colour at the start of Day. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting")
+ FLinearColor DaySunColor = FLinearColor(1.0f, 0.98f, 0.92f);
+
+ /** Directional light colour when Dusk begins. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting")
+ FLinearColor DuskSunColor = FLinearColor(1.0f, 0.55f, 0.25f);
+
+ /** Directional light colour when Night begins. */
+ UPROPERTY(Config, EditAnywhere, Category = "Lighting")
+ FLinearColor NightSunColor = FLinearColor(0.3f, 0.4f, 0.7f);
+
+ /** Noise radius (world units) of a bow shot. Read by the weapons ticket (part 2). */
+ UPROPERTY(Config, EditAnywhere, Category = "Noise", meta = (ClampMin = "0.0"))
+ float BowNoiseRadius = 500.0f;
+
+ /** Noise radius (world units) of a rifle shot. Read by the weapons ticket (part 2). */
+ UPROPERTY(Config, EditAnywhere, Category = "Noise", meta = (ClampMin = "0.0"))
+ float RifleNoiseRadius = 3000.0f;
+
+ /** Noise radius (world units) emitted when a container search starts. Zero emits nothing. */
+ UPROPERTY(Config, EditAnywhere, Category = "Noise", meta = (ClampMin = "0.0"))
+ float ContainerSearchNoiseRadius = 800.0f;
+
+ /** How long (seconds) a container search takes before it grants its supplies. */
+ UPROPERTY(Config, EditAnywhere, Category = "Scavenging", meta = (ClampMin = "0.0"))
+ float ContainerSearchDurationSeconds = 2.0f;
+
+ /**
+ * The containers spawned at the scavenging area of the test environment, one entry per
+ * container, with the supplies each grants when searched.
+ */
+ UPROPERTY(Config, EditAnywhere, Category = "Scavenging")
+ TArray<FSeminoleContainerLoot> ScavengingContainers;
+
+ /** Distance (world units) from the player within which an interactable can be used. */
+ UPROPERTY(Config, EditAnywhere, Category = "Interaction", meta = (ClampMin = "0.0"))
+ float InteractionRange = 250.0f;
+};
diff --git a/Source/Seminole/SeminoleTestEnvironment.cpp b/Source/Seminole/SeminoleTestEnvironment.cpp
index ccf82ab..aa81a98 100644
--- a/Source/Seminole/SeminoleTestEnvironment.cpp
+++ b/Source/Seminole/SeminoleTestEnvironment.cpp
@@ -3,6 +3,11 @@
#include "SeminoleTestEnvironment.h"
#include "Seminole.h"
+#include "SeminoleSettings.h"
+#include "Community/SeminoleStockpile.h"
+#include "Inventory/SeminoleSupplyContainer.h"
+#include "World/SeminoleDayLightingComponent.h"
+#include "World/SeminoleNoiseListenerPlaceholder.h"
#include "CollisionQueryParams.h"
#include "Components/DirectionalLightComponent.h"
#include "Components/SkyLightComponent.h"
@@ -30,6 +35,16 @@ namespace
// Pitch -50 points the sun down; the yaw just keeps the shadows off-axis.
const FRotator SunRotation(-50.0f, 30.0f, 0.0f);
+ // Vertical slice layout. The hub is the PlayerStart; the stockpile stands 4 m in front of it.
+ // The scavenging area is 30 m away along +X, far enough to be a walk and still in view.
+ const FVector HubStockpileLocation(400.0f, 0.0f, 50.0f);
+ const FVector ScavengingAreaLocation(3000.0f, 0.0f, 0.0f);
+ // Containers are 60 cm tall with their origin at the centre, so Z = 30 rests on the floor.
+ const float ContainerSpacing = 250.0f;
+ const float ContainerHalfHeight = 30.0f;
+ // The listener sphere (1 m) sits beyond the containers, inside the search noise radius.
+ const FVector NoiseListenerOffset(600.0f, 0.0f, 50.0f);
+
template <typename TComponent>
bool WorldHasComponentOfClass(const UWorld* World)
{
@@ -53,6 +68,8 @@ ASeminoleTestEnvironment::ASeminoleTestEnvironment()
{
FloorMesh = CubeFinder.Object;
}
+
+ DayLighting = CreateDefaultSubobject<USeminoleDayLightingComponent>(TEXT("DayLighting"));
}
void ASeminoleTestEnvironment::EnsureSceneBasics()
@@ -81,6 +98,41 @@ void ASeminoleTestEnvironment::EnsureSceneBasics()
}
}
+void ASeminoleTestEnvironment::EnsureSlicePlaceholders()
+{
+ UWorld* World = GetWorld();
+ if (World == nullptr)
+ {
+ return;
+ }
+
+ TActorIterator<ASeminoleStockpile> StockpileIt(World);
+ if (!StockpileIt)
+ {
+ SpawnStockpile();
+ }
+ TActorIterator<ASeminoleSupplyContainer> ContainerIt(World);
+ if (!ContainerIt)
+ {
+ SpawnScavengingContainers();
+ }
+ TActorIterator<ASeminoleNoiseListenerPlaceholder> ListenerIt(World);
+ if (!ListenerIt)
+ {
+ SpawnNoiseListener();
+ }
+}
+
+FVector ASeminoleTestEnvironment::GetScavengingAreaLocation()
+{
+ return ScavengingAreaLocation;
+}
+
+FVector ASeminoleTestEnvironment::GetHubStockpileLocation()
+{
+ return HubStockpileLocation;
+}
+
bool ASeminoleTestEnvironment::HasFloor() const
{
const UWorld* World = GetWorld();
@@ -214,3 +266,81 @@ void ASeminoleTestEnvironment::SpawnPlayerStart()
#endif
UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned a PlayerStart at %s."), *PlayerStartLocation.ToString());
}
+
+void ASeminoleTestEnvironment::SpawnStockpile()
+{
+ FActorSpawnParameters SpawnParams;
+ SpawnParams.Owner = this;
+ SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
+
+ ASeminoleStockpile* Stockpile = GetWorld()->SpawnActor<ASeminoleStockpile>(
+ ASeminoleStockpile::StaticClass(), HubStockpileLocation, FRotator::ZeroRotator, SpawnParams);
+ if (Stockpile == nullptr)
+ {
+ UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: failed to spawn the stockpile."));
+ return;
+ }
+
+#if WITH_EDITOR
+ Stockpile->SetActorLabel(TEXT("SeminoleStockpile"));
+#endif
+ UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned the hub stockpile at %s."), *HubStockpileLocation.ToString());
+}
+
+void ASeminoleTestEnvironment::SpawnScavengingContainers()
+{
+ const TArray<FSeminoleContainerLoot>& Loot = GetDefault<USeminoleSettings>()->ScavengingContainers;
+ if (Loot.Num() == 0)
+ {
+ UE_LOG(LogSeminole, Warning, TEXT("SeminoleTestEnvironment: USeminoleSettings::ScavengingContainers is empty, no containers spawned."));
+ return;
+ }
+
+ FActorSpawnParameters SpawnParams;
+ SpawnParams.Owner = this;
+ SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
+
+ // A row across the scavenging area (along Y), centred on it.
+ const float RowStart = -0.5f * ContainerSpacing * (Loot.Num() - 1);
+ int32 Spawned = 0;
+ for (int32 Index = 0; Index < Loot.Num(); ++Index)
+ {
+ const FVector Location = ScavengingAreaLocation + FVector(0.0f, RowStart + ContainerSpacing * Index, ContainerHalfHeight);
+ ASeminoleSupplyContainer* Container = GetWorld()->SpawnActor<ASeminoleSupplyContainer>(
+ ASeminoleSupplyContainer::StaticClass(), Location, FRotator::ZeroRotator, SpawnParams);
+ if (Container == nullptr)
+ {
+ UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: failed to spawn container %d."), Index);
+ continue;
+ }
+
+ Container->SetSupplies(Loot[Index].Supplies);
+#if WITH_EDITOR
+ Container->SetActorLabel(FString::Printf(TEXT("SeminoleContainer%d"), Index + 1));
+#endif
+ ++Spawned;
+ }
+
+ UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned %d scavenging containers around %s."), Spawned, *ScavengingAreaLocation.ToString());
+}
+
+void ASeminoleTestEnvironment::SpawnNoiseListener()
+{
+ FActorSpawnParameters SpawnParams;
+ SpawnParams.Owner = this;
+ SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
+
+ const FVector Location = ScavengingAreaLocation + NoiseListenerOffset;
+ ASeminoleNoiseListenerPlaceholder* Listener = GetWorld()->SpawnActor<ASeminoleNoiseListenerPlaceholder>(
+ ASeminoleNoiseListenerPlaceholder::StaticClass(), Location, FRotator::ZeroRotator, SpawnParams);
+ if (Listener == nullptr)
+ {
+ UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: failed to spawn the noise listener."));
+ return;
+ }
+
+#if WITH_EDITOR
+ Listener->SetActorLabel(TEXT("SeminoleNoiseListener"));
+#endif
+ UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned a placeholder noise listener at %s."), *Location.ToString());
+}
diff --git a/Source/Seminole/SeminoleTestEnvironment.h b/Source/Seminole/SeminoleTestEnvironment.h
index 67a2758..9f332ab 100644
--- a/Source/Seminole/SeminoleTestEnvironment.h
+++ b/Source/Seminole/SeminoleTestEnvironment.h
@@ -6,6 +6,7 @@
#include "GameFramework/Actor.h"
#include "SeminoleTestEnvironment.generated.h"
+class USeminoleDayLightingComponent;
class UStaticMesh;
/**
@@ -18,6 +19,11 @@ class UStaticMesh;
* receives no duplicates. The game boots into the empty engine map
* /Engine/Maps/Entry by default, where all four pieces are missing.
*
+ * The vertical slice (part 1) extends it with EnsureSlicePlaceholders(): a stockpile at
+ * the hub next to the PlayerStart, the scavenging containers listed in USeminoleSettings
+ * at the scavenging area 30 m away, and a placeholder noise listener beside them. The
+ * attached USeminoleDayLightingComponent drives the lights from the day clock.
+ *
* This is bootstrap scaffolding, not a gameplay system: it goes away once a real
* map ships. Nothing here is replicated.
*/
@@ -35,6 +41,15 @@ class SEMINOLE_API ASeminoleTestEnvironment : public AActor
/** Spawns whichever of the floor, directional light, sky light and PlayerStart the world is missing. */
void EnsureSceneBasics();
+ /** Spawns the stockpile, the scavenging containers and the noise listener when the world has none of each. */
+ void EnsureSlicePlaceholders();
+
+ /** Centre of the scavenging area, where the containers are spawned. */
+ static FVector GetScavengingAreaLocation();
+
+ /** Where the stockpile stands, beside the PlayerStart. */
+ static FVector GetHubStockpileLocation();
+
private:
bool HasFloor() const;
bool HasDirectionalLight() const;
@@ -46,10 +61,18 @@ class SEMINOLE_API ASeminoleTestEnvironment : public AActor
void SpawnSkyLight();
void SpawnPlayerStart();
+ void SpawnStockpile();
+ void SpawnScavengingContainers();
+ void SpawnNoiseListener();
+
/**
* /Engine/BasicShapes/Cube, resolved in the constructor so the class default object
* hard-references it and the cooker packages it alongside the game.
*/
UPROPERTY()
TObjectPtr<UStaticMesh> FloorMesh;
+
+ /** Interpolates the spawned (or found) lights between the Day, Dusk and Night settings. */
+ UPROPERTY(VisibleAnywhere, Category = "Seminole")
+ TObjectPtr<USeminoleDayLightingComponent> DayLighting;
};
diff --git a/Source/Seminole/Survival/README.md b/Source/Seminole/Survival/README.md
index e1f5da7..b3b33c6 100644
--- a/Source/Seminole/Survival/README.md
+++ b/Source/Seminole/Survival/README.md
@@ -2,5 +2,6 @@
Health, hunger, stamina and the day/night cycle that drives the scavenge and defense phases.
-No code lives here yet; this folder fixes where the system lands when its ticket is funded.
+Implemented (vertical slice part 1): `USeminoleDayClockComponent`, the Day -> Dusk -> Night clock owned by `ASeminoleGameState` (module root). Durations come from `USeminoleSettings`; `OnPhaseChanged` and `ResetToDay()` are the hooks for the night-defense ticket.
+Health, hunger and stamina are not here yet.
See [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md).
diff --git a/Source/Seminole/Survival/SeminoleDayClockComponent.cpp b/Source/Seminole/Survival/SeminoleDayClockComponent.cpp
new file mode 100644
index 0000000..e048fca
--- /dev/null
+++ b/Source/Seminole/Survival/SeminoleDayClockComponent.cpp
@@ -0,0 +1,117 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Survival/SeminoleDayClockComponent.h"
+
+#include "Seminole.h"
+#include "SeminoleSettings.h"
+
+FString SeminoleDayPhaseToString(ESeminoleDayPhase Phase)
+{
+ switch (Phase)
+ {
+ case ESeminoleDayPhase::Day:
+ return TEXT("Day");
+ case ESeminoleDayPhase::Dusk:
+ return TEXT("Dusk");
+ case ESeminoleDayPhase::Night:
+ return TEXT("Night");
+ default:
+ return TEXT("Unknown");
+ }
+}
+
+USeminoleDayClockComponent::USeminoleDayClockComponent()
+{
+ PrimaryComponentTick.bCanEverTick = true;
+ PrimaryComponentTick.bStartWithTickEnabled = true;
+}
+
+void USeminoleDayClockComponent::BeginPlay()
+{
+ Super::BeginPlay();
+
+ // The game starts in Day, with no console command needed.
+ ResetToDay();
+}
+
+void USeminoleDayClockComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
+{
+ Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
+
+ Advance(DeltaTime);
+}
+
+float USeminoleDayClockComponent::GetPhaseProgress() const
+{
+ if (Phase == ESeminoleDayPhase::Night || PhaseDuration <= 0.0f)
+ {
+ return 1.0f;
+ }
+ return FMath::Clamp(1.0f - PhaseTimeRemaining / PhaseDuration, 0.0f, 1.0f);
+}
+
+void USeminoleDayClockComponent::ResetToDay()
+{
+ bStarted = true;
+ EnterPhase(ESeminoleDayPhase::Day);
+}
+
+void USeminoleDayClockComponent::Advance(float DeltaSeconds)
+{
+ if (!bStarted || DeltaSeconds <= 0.0f)
+ {
+ return;
+ }
+
+ float Remaining = DeltaSeconds;
+ while (Remaining > 0.0f && Phase != ESeminoleDayPhase::Night)
+ {
+ if (PhaseTimeRemaining > Remaining)
+ {
+ PhaseTimeRemaining -= Remaining;
+ return;
+ }
+
+ // The phase ends inside this step; carry the leftover into the next phase so a large
+ // step crosses several boundaries in order.
+ Remaining -= PhaseTimeRemaining;
+ PhaseTimeRemaining = 0.0f;
+
+ switch (Phase)
+ {
+ case ESeminoleDayPhase::Day:
+ EnterPhase(ESeminoleDayPhase::Dusk);
+ break;
+ case ESeminoleDayPhase::Dusk:
+ EnterPhase(ESeminoleDayPhase::Night);
+ break;
+ default:
+ return;
+ }
+ }
+}
+
+void USeminoleDayClockComponent::EnterPhase(ESeminoleDayPhase NewPhase)
+{
+ Phase = NewPhase;
+ PhaseDuration = GetConfiguredDuration(NewPhase);
+ PhaseTimeRemaining = PhaseDuration;
+
+ UE_LOG(LogSeminole, Log, TEXT("SeminoleDayClock: %s begins (%.0f s)."), *SeminoleDayPhaseToString(NewPhase), PhaseDuration);
+ OnPhaseChanged.Broadcast(NewPhase);
+}
+
+float USeminoleDayClockComponent::GetConfiguredDuration(ESeminoleDayPhase ForPhase) const
+{
+ const USeminoleSettings* Settings = GetDefault<USeminoleSettings>();
+ switch (ForPhase)
+ {
+ case ESeminoleDayPhase::Day:
+ return Settings->DayDurationSeconds;
+ case ESeminoleDayPhase::Dusk:
+ return Settings->DuskDurationSeconds;
+ default:
+ // Night has no end in part 1; part 3 ends it through ResetToDay().
+ return 0.0f;
+ }
+}
diff --git a/Source/Seminole/Survival/SeminoleDayClockComponent.h b/Source/Seminole/Survival/SeminoleDayClockComponent.h
new file mode 100644
index 0000000..649c5f8
--- /dev/null
+++ b/Source/Seminole/Survival/SeminoleDayClockComponent.h
@@ -0,0 +1,92 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Components/ActorComponent.h"
+#include "SeminoleDayClockComponent.generated.h"
+
+/** The three phases of one in-game day. The clock stops at Night; part 3 ends the night. */
+UENUM(BlueprintType)
+enum class ESeminoleDayPhase : uint8
+{
+ Day,
+ Dusk,
+ Night
+};
+
+/** Returns "Day", "Dusk" or "Night". */
+SEMINOLE_API FString SeminoleDayPhaseToString(ESeminoleDayPhase Phase);
+
+DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FSeminoleDayPhaseChanged, ESeminoleDayPhase, NewPhase);
+
+/**
+ * The day clock: Day -> Dusk -> Night, then it stops. Owned by ASeminoleGameState so the
+ * phase is authoritative state that can be replicated later.
+ *
+ * The clock starts running on BeginPlay (ResetToDay). Durations come from USeminoleSettings.
+ * All time keeping goes through Advance(), which TickComponent calls with the frame delta and
+ * tests call directly with whatever they need; nothing here reads real time.
+ *
+ * Extension points: part 3 binds OnPhaseChanged to start the night defense at Night and calls
+ * ResetToDay() when the night is survived.
+ */
+UCLASS(ClassGroup = (Seminole), meta = (BlueprintSpawnableComponent))
+class SEMINOLE_API USeminoleDayClockComponent : public UActorComponent
+{
+ GENERATED_BODY()
+
+public:
+ USeminoleDayClockComponent();
+
+ //~ UActorComponent
+ virtual void BeginPlay() override;
+ virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
+
+ /** Fired on every phase change, and by ResetToDay(). */
+ UPROPERTY(BlueprintAssignable, Category = "Seminole|Day Clock")
+ FSeminoleDayPhaseChanged OnPhaseChanged;
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Day Clock")
+ ESeminoleDayPhase GetPhase() const { return Phase; }
+
+ /** Seconds left in the current phase; 0 at Night, which has no end. */
+ UFUNCTION(BlueprintPure, Category = "Seminole|Day Clock")
+ float GetPhaseTimeRemaining() const { return PhaseTimeRemaining; }
+
+ /** Full length in seconds of the current phase; 0 at Night. */
+ UFUNCTION(BlueprintPure, Category = "Seminole|Day Clock")
+ float GetPhaseDuration() const { return PhaseDuration; }
+
+ /** 0 at the start of the current phase, 1 at its end; 1 at Night. Drives the lighting. */
+ UFUNCTION(BlueprintPure, Category = "Seminole|Day Clock")
+ float GetPhaseProgress() const;
+
+ /** Restarts the clock at Day with the full configured duration and broadcasts OnPhaseChanged(Day). */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Day Clock")
+ void ResetToDay();
+
+ /**
+ * Moves the clock forward by DeltaSeconds, crossing as many phase boundaries as needed
+ * (each one broadcasts OnPhaseChanged in order). Does nothing before ResetToDay() has run
+ * or once Night is reached.
+ */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Day Clock")
+ void Advance(float DeltaSeconds);
+
+private:
+ void EnterPhase(ESeminoleDayPhase NewPhase);
+ float GetConfiguredDuration(ESeminoleDayPhase ForPhase) const;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Day Clock")
+ ESeminoleDayPhase Phase = ESeminoleDayPhase::Day;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Day Clock")
+ float PhaseTimeRemaining = 0.0f;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Day Clock")
+ float PhaseDuration = 0.0f;
+
+ /** Set by ResetToDay(); Advance() is a no-op until then. */
+ bool bStarted = false;
+};
diff --git a/Source/Seminole/Tests/README.md b/Source/Seminole/Tests/README.md
new file mode 100644
index 0000000..7d52296
--- /dev/null
+++ b/Source/Seminole/Tests/README.md
@@ -0,0 +1,16 @@
+# Tests
+
+Automation tests (`IMPLEMENT_SIMPLE_AUTOMATION_TEST`) for the game module, compiled only with `WITH_DEV_AUTOMATION_TESTS`.
+
+Run them from the editor: **Tools > Session Frontend > Automation**, filter `Seminole`, Start Tests. From a terminal:
+
+ "<UE_ROOT>\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" "<repo>\Seminole.uproject" -ExecCmds="Automation RunTests Seminole; Quit" -unattended -nopause -nullrhi -log
+
+Helpers: `FSeminoleTestWorld` creates a throwaway game world (no game mode, so `BeginPlay` does not run) and `USeminolePhaseRecorder` records day-clock phases from the dynamic delegate.
+
+| Test | Covers |
+| --- | --- |
+| `Seminole.Survival.DayClock.Transitions` | Day -> Dusk -> Night at the configured durations, time remaining, stop at Night, `ResetToDay` |
+| `Seminole.Survival.DayClock.LargeStepCrossesPhasesInOrder` | one large `Advance` broadcasts Dusk then Night in order |
+| `Seminole.World.Noise.RadiusFiltering` | listeners inside the radius hear (3D distance), outside and unregistered do not; event payload |
+| `Seminole.Inventory.LootFlow` | container search grants once, second search grants nothing, deposit empties the inventory, `TrySpend` |
diff --git a/Source/Seminole/Tests/SeminoleDayClockTests.cpp b/Source/Seminole/Tests/SeminoleDayClockTests.cpp
new file mode 100644
index 0000000..1cf7e20
--- /dev/null
+++ b/Source/Seminole/Tests/SeminoleDayClockTests.cpp
@@ -0,0 +1,113 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Misc/AutomationTest.h"
+
+#if WITH_DEV_AUTOMATION_TESTS
+
+#include "SeminoleSettings.h"
+#include "Survival/SeminoleDayClockComponent.h"
+#include "Tests/SeminolePhaseRecorder.h"
+
+// Compares phases by name so a failure reads "Expected 'X' to be Dusk, but it was Day".
+#define TEST_PHASE(What, Actual, Expected) \
+ TestEqual(TEXT(What), SeminoleDayPhaseToString(Actual), SeminoleDayPhaseToString(Expected))
+
+/**
+ * Day -> Dusk -> Night fire in order at the configured durations, time is queryable, the
+ * clock stops at Night, and ResetToDay() restores a full Day. Time is advanced through
+ * Advance(), never real time.
+ */
+IMPLEMENT_SIMPLE_AUTOMATION_TEST(FSeminoleDayClockTransitionsTest, "Seminole.Survival.DayClock.Transitions",
+ EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::ProductFilter)
+
+bool FSeminoleDayClockTransitionsTest::RunTest(const FString& Parameters)
+{
+ const USeminoleSettings* Settings = GetDefault<USeminoleSettings>();
+ const float DayLength = Settings->DayDurationSeconds;
+ const float DuskLength = Settings->DuskDurationSeconds;
+
+ USeminoleDayClockComponent* Clock = NewObject<USeminoleDayClockComponent>();
+ USeminolePhaseRecorder* Recorder = NewObject<USeminolePhaseRecorder>();
+ Recorder->BindTo(Clock);
+
+ // Before the clock starts, Advance is a no-op and nothing is broadcast.
+ Clock->Advance(DayLength * 2.0f);
+ TEST_PHASE("Phase before start is Day", Clock->GetPhase(), ESeminoleDayPhase::Day);
+ TestEqual(TEXT("No broadcast before start"), Recorder->Phases.Num(), 0);
+
+ Clock->ResetToDay();
+ TEST_PHASE("Starts in Day", Clock->GetPhase(), ESeminoleDayPhase::Day);
+ TestEqual(TEXT("Day has the configured duration"), Clock->GetPhaseDuration(), DayLength);
+ TestEqual(TEXT("Full Day remaining at start"), Clock->GetPhaseTimeRemaining(), DayLength);
+ TestEqual(TEXT("ResetToDay broadcast Day"), Recorder->Phases.Num(), 1);
+ TestEqual(TEXT("Progress is 0 at start"), Clock->GetPhaseProgress(), 0.0f);
+
+ // Just short of the boundary: still Day with the remainder queryable.
+ Clock->Advance(DayLength - 1.0f);
+ TEST_PHASE("Still Day one second before dusk", Clock->GetPhase(), ESeminoleDayPhase::Day);
+ TestEqual(TEXT("One second of Day remains"), Clock->GetPhaseTimeRemaining(), 1.0f);
+ TestEqual(TEXT("No new broadcast inside Day"), Recorder->Phases.Num(), 1);
+
+ // Exactly on the boundary: Dusk begins with its full duration.
+ Clock->Advance(1.0f);
+ TEST_PHASE("Dusk after the Day duration", Clock->GetPhase(), ESeminoleDayPhase::Dusk);
+ TestEqual(TEXT("Dusk has the configured duration"), Clock->GetPhaseDuration(), DuskLength);
+ TestEqual(TEXT("Full Dusk remaining"), Clock->GetPhaseTimeRemaining(), DuskLength);
+ TestEqual(TEXT("Dusk broadcast"), Recorder->Phases.Num(), 2);
+ TEST_PHASE("Second phase is Dusk", Recorder->Phases[1], ESeminoleDayPhase::Dusk);
+
+ Clock->Advance(DuskLength * 0.5f);
+ TEST_PHASE("Still Dusk halfway", Clock->GetPhase(), ESeminoleDayPhase::Dusk);
+ TestEqual(TEXT("Half of Dusk remains"), Clock->GetPhaseTimeRemaining(), DuskLength * 0.5f);
+ TestEqual(TEXT("Progress is 0.5 halfway through Dusk"), Clock->GetPhaseProgress(), 0.5f);
+
+ Clock->Advance(DuskLength * 0.5f);
+ TEST_PHASE("Night after the Dusk duration", Clock->GetPhase(), ESeminoleDayPhase::Night);
+ TestEqual(TEXT("Night has no time remaining"), Clock->GetPhaseTimeRemaining(), 0.0f);
+ TestEqual(TEXT("Night broadcast"), Recorder->Phases.Num(), 3);
+ TEST_PHASE("Third phase is Night", Recorder->Phases[2], ESeminoleDayPhase::Night);
+
+ // The clock stops at Night.
+ Clock->Advance(DayLength * 10.0f);
+ TEST_PHASE("Stays Night", Clock->GetPhase(), ESeminoleDayPhase::Night);
+ TestEqual(TEXT("No broadcast while Night"), Recorder->Phases.Num(), 3);
+
+ // Reset restores Day with the full duration.
+ Clock->ResetToDay();
+ TEST_PHASE("Reset returns to Day", Clock->GetPhase(), ESeminoleDayPhase::Day);
+ TestEqual(TEXT("Reset restores the full Day"), Clock->GetPhaseTimeRemaining(), DayLength);
+ TestEqual(TEXT("Reset broadcast Day"), Recorder->Phases.Num(), 4);
+ TEST_PHASE("Fourth phase is Day", Recorder->Phases[3], ESeminoleDayPhase::Day);
+
+ return true;
+}
+
+/** One large step crosses both boundaries, broadcasting Dusk then Night in order. */
+IMPLEMENT_SIMPLE_AUTOMATION_TEST(FSeminoleDayClockLargeStepTest, "Seminole.Survival.DayClock.LargeStepCrossesPhasesInOrder",
+ EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::ProductFilter)
+
+bool FSeminoleDayClockLargeStepTest::RunTest(const FString& Parameters)
+{
+ const USeminoleSettings* Settings = GetDefault<USeminoleSettings>();
+
+ USeminoleDayClockComponent* Clock = NewObject<USeminoleDayClockComponent>();
+ USeminolePhaseRecorder* Recorder = NewObject<USeminolePhaseRecorder>();
+ Recorder->BindTo(Clock);
+
+ Clock->ResetToDay();
+ Clock->Advance(Settings->DayDurationSeconds + Settings->DuskDurationSeconds + 1.0f);
+
+ TEST_PHASE("Ends at Night", Clock->GetPhase(), ESeminoleDayPhase::Night);
+ if (TestEqual(TEXT("Three phases broadcast"), Recorder->Phases.Num(), 3))
+ {
+ TEST_PHASE("First is Day", Recorder->Phases[0], ESeminoleDayPhase::Day);
+ TEST_PHASE("Second is Dusk", Recorder->Phases[1], ESeminoleDayPhase::Dusk);
+ TEST_PHASE("Third is Night", Recorder->Phases[2], ESeminoleDayPhase::Night);
+ }
+
+ return true;
+}
+
+#undef TEST_PHASE
+
+#endif // WITH_DEV_AUTOMATION_TESTS
diff --git a/Source/Seminole/Tests/SeminoleLootTests.cpp b/Source/Seminole/Tests/SeminoleLootTests.cpp
new file mode 100644
index 0000000..adf6869
--- /dev/null
+++ b/Source/Seminole/Tests/SeminoleLootTests.cpp
@@ -0,0 +1,133 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Misc/AutomationTest.h"
+
+#if WITH_DEV_AUTOMATION_TESTS
+
+#include "SeminoleSettings.h"
+#include "Community/SeminoleStockpile.h"
+#include "Inventory/SeminoleInventoryComponent.h"
+#include "Inventory/SeminoleSupplyContainer.h"
+#include "Tests/SeminoleTestWorld.h"
+#include "GameFramework/Actor.h"
+
+namespace
+{
+ /** A bare actor carrying an inventory component, standing in for the player pawn. */
+ AActor* SpawnCarrier(const FSeminoleTestWorld& TestWorld)
+ {
+ AActor* Carrier = TestWorld.SpawnActor<AActor>();
+ if (Carrier != nullptr)
+ {
+ USeminoleInventoryComponent* Inventory = NewObject<USeminoleInventoryComponent>(Carrier);
+ Inventory->RegisterComponent();
+ }
+ return Carrier;
+ }
+}
+
+/**
+ * Loot flow: a container search grants its supplies once the configured duration has
+ * elapsed, a second search grants nothing, depositing moves everything into the stockpile
+ * and empties the inventory, and TrySpend succeeds only when the stockpile holds enough.
+ */
+IMPLEMENT_SIMPLE_AUTOMATION_TEST(FSeminoleLootFlowTest, "Seminole.Inventory.LootFlow",
+ EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::ProductFilter)
+
+bool FSeminoleLootFlowTest::RunTest(const FString& Parameters)
+{
+ FSeminoleTestWorld TestWorld;
+ if (!TestNotNull(TEXT("Test world"), TestWorld.GetWorld()))
+ {
+ return false;
+ }
+
+ const float SearchDuration = GetDefault<USeminoleSettings>()->ContainerSearchDurationSeconds;
+
+ AActor* Carrier = SpawnCarrier(TestWorld);
+ ASeminoleSupplyContainer* Container = TestWorld.SpawnActor<ASeminoleSupplyContainer>(FVector(100.0f, 0.0f, 30.0f));
+ ASeminoleStockpile* Stockpile = TestWorld.SpawnActor<ASeminoleStockpile>(FVector(-100.0f, 0.0f, 50.0f));
+ if (!TestNotNull(TEXT("Carrier"), Carrier) || !TestNotNull(TEXT("Container"), Container) || !TestNotNull(TEXT("Stockpile"), Stockpile))
+ {
+ return false;
+ }
+
+ USeminoleInventoryComponent* Inventory = Carrier->FindComponentByClass<USeminoleInventoryComponent>();
+ if (!TestNotNull(TEXT("Carrier inventory"), Inventory))
+ {
+ return false;
+ }
+
+ TArray<FSeminoleSupplyAmount> Loot;
+ Loot.Add(FSeminoleSupplyAmount(ESeminoleSupplyType::Food, 3));
+ Loot.Add(FSeminoleSupplyAmount(ESeminoleSupplyType::Materials, 5));
+ Container->SetSupplies(Loot);
+
+ // --- Container search grants supplies after the configured duration ---
+ TestTrue(TEXT("Unsearched container can be interacted with"), Container->CanInteract(Carrier));
+ TestTrue(TEXT("Search starts"), Container->BeginSearch(Carrier));
+ TestTrue(TEXT("Container is being searched"), Container->IsBeingSearched());
+ TestFalse(TEXT("Container is not yet searched"), Container->IsSearched());
+ TestFalse(TEXT("A search in progress blocks a second one"), Container->CanInteract(Carrier));
+ TestEqual(TEXT("Nothing granted while searching"), Inventory->GetTotalSupplyCount(), 0);
+
+ if (SearchDuration > 0.0f)
+ {
+ Container->AdvanceSearch(SearchDuration * 0.5f);
+ TestFalse(TEXT("Not searched halfway through"), Container->IsSearched());
+ TestEqual(TEXT("Progress is 0.5 halfway through"), Container->GetSearchProgress(), 0.5f);
+ TestEqual(TEXT("Nothing granted halfway through"), Inventory->GetTotalSupplyCount(), 0);
+ Container->AdvanceSearch(SearchDuration * 0.5f);
+ }
+
+ TestTrue(TEXT("Searched after the configured duration"), Container->IsSearched());
+ TestFalse(TEXT("No longer being searched"), Container->IsBeingSearched());
+ TestEqual(TEXT("Food granted"), Inventory->GetSupplyCount(ESeminoleSupplyType::Food), 3);
+ TestEqual(TEXT("Materials granted"), Inventory->GetSupplyCount(ESeminoleSupplyType::Materials), 5);
+ TestEqual(TEXT("No ammo granted"), Inventory->GetSupplyCount(ESeminoleSupplyType::Ammo), 0);
+ TestEqual(TEXT("Total carried"), Inventory->GetTotalSupplyCount(), 8);
+
+ // --- A second search grants nothing ---
+ TestFalse(TEXT("Searched container cannot be interacted with"), Container->CanInteract(Carrier));
+ TestFalse(TEXT("Second search is refused"), Container->BeginSearch(Carrier));
+ Container->AdvanceSearch(SearchDuration + 1.0f);
+ TestEqual(TEXT("Second search granted nothing"), Inventory->GetTotalSupplyCount(), 8);
+
+ // --- Inventory remove ---
+ TestFalse(TEXT("Removing more than carried fails"), Inventory->RemoveSupply(ESeminoleSupplyType::Food, 4));
+ TestEqual(TEXT("Failed removal changes nothing"), Inventory->GetSupplyCount(ESeminoleSupplyType::Food), 3);
+ TestTrue(TEXT("Removing one food succeeds"), Inventory->RemoveSupply(ESeminoleSupplyType::Food, 1));
+ TestEqual(TEXT("Two food left"), Inventory->GetSupplyCount(ESeminoleSupplyType::Food), 2);
+ Inventory->AddSupply(ESeminoleSupplyType::Food, 1);
+ TestEqual(TEXT("Three food again"), Inventory->GetSupplyCount(ESeminoleSupplyType::Food), 3);
+
+ // --- Deposit moves everything into the stockpile ---
+ TestTrue(TEXT("Stockpile accepts a carrier with supplies"), Stockpile->CanInteract(Carrier));
+ Stockpile->Interact(Carrier);
+ TestEqual(TEXT("Inventory is empty after deposit"), Inventory->GetTotalSupplyCount(), 0);
+ TestTrue(TEXT("Inventory reports empty"), Inventory->IsEmpty());
+ TestEqual(TEXT("Stockpile food"), Stockpile->GetSupplyCount(ESeminoleSupplyType::Food), 3);
+ TestEqual(TEXT("Stockpile materials"), Stockpile->GetSupplyCount(ESeminoleSupplyType::Materials), 5);
+ TestEqual(TEXT("Stockpile ammo"), Stockpile->GetSupplyCount(ESeminoleSupplyType::Ammo), 0);
+ TestFalse(TEXT("Stockpile refuses an empty carrier"), Stockpile->CanInteract(Carrier));
+
+ // Depositing an empty inventory changes nothing.
+ const FSeminoleSupplyCounts Nothing = Stockpile->DepositAll(Inventory);
+ TestTrue(TEXT("Empty deposit moves nothing"), Nothing.IsEmpty());
+ TestEqual(TEXT("Stockpile unchanged by empty deposit"), Stockpile->GetSupplies().Total(), 8);
+
+ // --- TrySpend ---
+ TestFalse(TEXT("Spending more materials than stored fails"), Stockpile->TrySpend(ESeminoleSupplyType::Materials, 6));
+ TestEqual(TEXT("Failed spend changes nothing"), Stockpile->GetSupplyCount(ESeminoleSupplyType::Materials), 5);
+ TestFalse(TEXT("Spending a type the stockpile lacks fails"), Stockpile->TrySpend(ESeminoleSupplyType::Ammo, 1));
+ TestTrue(TEXT("Spending within the total succeeds"), Stockpile->TrySpend(ESeminoleSupplyType::Materials, 4));
+ TestEqual(TEXT("Materials reduced"), Stockpile->GetSupplyCount(ESeminoleSupplyType::Materials), 1);
+ TestTrue(TEXT("Spending exactly the remainder succeeds"), Stockpile->TrySpend(ESeminoleSupplyType::Materials, 1));
+ TestEqual(TEXT("Materials exhausted"), Stockpile->GetSupplyCount(ESeminoleSupplyType::Materials), 0);
+ TestFalse(TEXT("Spending from an exhausted type fails"), Stockpile->TrySpend(ESeminoleSupplyType::Materials, 1));
+ TestEqual(TEXT("Food untouched by material spending"), Stockpile->GetSupplyCount(ESeminoleSupplyType::Food), 3);
+
+ return true;
+}
+
+#endif // WITH_DEV_AUTOMATION_TESTS
diff --git a/Source/Seminole/Tests/SeminoleNoiseTests.cpp b/Source/Seminole/Tests/SeminoleNoiseTests.cpp
new file mode 100644
index 0000000..01bb99f
--- /dev/null
+++ b/Source/Seminole/Tests/SeminoleNoiseTests.cpp
@@ -0,0 +1,90 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Misc/AutomationTest.h"
+
+#if WITH_DEV_AUTOMATION_TESTS
+
+#include "Tests/SeminoleTestWorld.h"
+#include "World/SeminoleNoiseListenerPlaceholder.h"
+#include "World/SeminoleNoiseSubsystem.h"
+#include "GameFramework/Actor.h"
+
+/**
+ * EmitNoise reaches a registered listener inside the radius (with the location and the
+ * instigator) and not one outside it. Distance is 3D, so a listener straight above the
+ * noise counts too.
+ */
+IMPLEMENT_SIMPLE_AUTOMATION_TEST(FSeminoleNoiseFilteringTest, "Seminole.World.Noise.RadiusFiltering",
+ EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::ProductFilter)
+
+bool FSeminoleNoiseFilteringTest::RunTest(const FString& Parameters)
+{
+ FSeminoleTestWorld TestWorld;
+ UWorld* World = TestWorld.GetWorld();
+ if (!TestNotNull(TEXT("Test world"), World))
+ {
+ return false;
+ }
+
+ USeminoleNoiseSubsystem* Noise = World->GetSubsystem<USeminoleNoiseSubsystem>();
+ if (!TestNotNull(TEXT("Noise subsystem exists in a game world"), Noise))
+ {
+ return false;
+ }
+
+ const FVector Origin(1000.0f, 0.0f, 0.0f);
+ const float Radius = 500.0f;
+
+ ASeminoleNoiseListenerPlaceholder* Near = TestWorld.SpawnActor<ASeminoleNoiseListenerPlaceholder>(Origin + FVector(300.0f, 0.0f, 0.0f));
+ ASeminoleNoiseListenerPlaceholder* Above = TestWorld.SpawnActor<ASeminoleNoiseListenerPlaceholder>(Origin + FVector(0.0f, 0.0f, 450.0f));
+ ASeminoleNoiseListenerPlaceholder* Far = TestWorld.SpawnActor<ASeminoleNoiseListenerPlaceholder>(Origin + FVector(600.0f, 0.0f, 0.0f));
+ ASeminoleNoiseListenerPlaceholder* Diagonal = TestWorld.SpawnActor<ASeminoleNoiseListenerPlaceholder>(Origin + FVector(400.0f, 400.0f, 0.0f));
+ ASeminoleNoiseListenerPlaceholder* Unregistered = TestWorld.SpawnActor<ASeminoleNoiseListenerPlaceholder>(Origin);
+ AActor* Instigator = TestWorld.SpawnActor<AActor>(Origin);
+ if (!TestNotNull(TEXT("Near listener"), Near) || !TestNotNull(TEXT("Above listener"), Above)
+ || !TestNotNull(TEXT("Far listener"), Far) || !TestNotNull(TEXT("Diagonal listener"), Diagonal)
+ || !TestNotNull(TEXT("Unregistered listener"), Unregistered) || !TestNotNull(TEXT("Instigator"), Instigator))
+ {
+ return false;
+ }
+
+ // No game mode in the test world, so BeginPlay (which registers) does not run; register by hand.
+ Noise->RegisterListener(Near);
+ Noise->RegisterListener(Above);
+ Noise->RegisterListener(Far);
+ Noise->RegisterListener(Diagonal);
+ Noise->RegisterListener(Near); // Registering twice is harmless.
+ TestEqual(TEXT("Four distinct listeners registered"), Noise->GetListenerCount(), 4);
+
+ int32 BroadcastCount = 0;
+ Noise->OnNoiseEmitted.AddLambda([&BroadcastCount](const FSeminoleNoiseEvent&) { ++BroadcastCount; });
+
+ Noise->EmitNoise(Origin, Radius, Instigator);
+
+ TestEqual(TEXT("Listener 300 uu away hears the noise"), Near->GetHeardNoiseCount(), 1);
+ TestEqual(TEXT("Listener 450 uu straight above hears the noise (3D distance)"), Above->GetHeardNoiseCount(), 1);
+ TestEqual(TEXT("Listener 600 uu away does not hear it"), Far->GetHeardNoiseCount(), 0);
+ TestEqual(TEXT("Listener 566 uu away diagonally does not hear it"), Diagonal->GetHeardNoiseCount(), 0);
+ TestEqual(TEXT("Unregistered listener at the origin does not hear it"), Unregistered->GetHeardNoiseCount(), 0);
+ TestEqual(TEXT("OnNoiseEmitted fired once"), BroadcastCount, 1);
+
+ TestEqual(TEXT("Event carries the location"), Near->GetLastNoise().Location, Origin);
+ TestEqual(TEXT("Event carries the radius"), Near->GetLastNoise().Radius, Radius);
+ TestTrue(TEXT("Event carries the instigator"), Near->GetLastNoise().Instigator == Instigator);
+
+ // Unregistered listeners stop hearing.
+ Noise->UnregisterListener(Near);
+ Noise->EmitNoise(Origin, Radius, nullptr);
+ TestEqual(TEXT("Unregistered Near listener hears nothing more"), Near->GetHeardNoiseCount(), 1);
+ TestEqual(TEXT("Above listener hears the second noise"), Above->GetHeardNoiseCount(), 2);
+ TestTrue(TEXT("Noise without an instigator is delivered"), Above->GetLastNoise().Instigator == nullptr);
+
+ // A larger radius reaches the far listener.
+ Noise->EmitNoise(Origin, 1000.0f, nullptr);
+ TestEqual(TEXT("Far listener hears a 1000 uu noise"), Far->GetHeardNoiseCount(), 1);
+ TestEqual(TEXT("Diagonal listener hears a 1000 uu noise"), Diagonal->GetHeardNoiseCount(), 1);
+
+ return true;
+}
+
+#endif // WITH_DEV_AUTOMATION_TESTS
diff --git a/Source/Seminole/Tests/SeminolePhaseRecorder.cpp b/Source/Seminole/Tests/SeminolePhaseRecorder.cpp
new file mode 100644
index 0000000..2828174
--- /dev/null
+++ b/Source/Seminole/Tests/SeminolePhaseRecorder.cpp
@@ -0,0 +1,3 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "Tests/SeminolePhaseRecorder.h"
diff --git a/Source/Seminole/Tests/SeminolePhaseRecorder.h b/Source/Seminole/Tests/SeminolePhaseRecorder.h
new file mode 100644
index 0000000..4bb1407
--- /dev/null
+++ b/Source/Seminole/Tests/SeminolePhaseRecorder.h
@@ -0,0 +1,32 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Survival/SeminoleDayClockComponent.h"
+#include "UObject/Object.h"
+#include "SeminolePhaseRecorder.generated.h"
+
+/**
+ * Test double for the day clock tests: binds to USeminoleDayClockComponent::OnPhaseChanged
+ * (a dynamic delegate, which needs a UFUNCTION on a UObject) and records every phase in order.
+ */
+UCLASS()
+class USeminolePhaseRecorder : public UObject
+{
+ GENERATED_BODY()
+
+public:
+ void BindTo(USeminoleDayClockComponent* Clock)
+ {
+ Clock->OnPhaseChanged.AddDynamic(this, &USeminolePhaseRecorder::HandlePhaseChanged);
+ }
+
+ UFUNCTION()
+ void HandlePhaseChanged(ESeminoleDayPhase NewPhase)
+ {
+ Phases.Add(NewPhase);
+ }
+
+ TArray<ESeminoleDayPhase> Phases;
+};
diff --git a/Source/Seminole/Tests/SeminoleTestWorld.h b/Source/Seminole/Tests/SeminoleTestWorld.h
new file mode 100644
index 0000000..a57eb1e
--- /dev/null
+++ b/Source/Seminole/Tests/SeminoleTestWorld.h
@@ -0,0 +1,51 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Engine/Engine.h"
+#include "Engine/World.h"
+
+/**
+ * A throwaway game world for automation tests: created on construction, torn down on
+ * destruction. Actors can be spawned and world subsystems resolved; there is no game mode,
+ * so actor BeginPlay does not run (tests register listeners and reset clocks explicitly).
+ */
+struct FSeminoleTestWorld
+{
+ FSeminoleTestWorld()
+ {
+ World = UWorld::CreateWorld(EWorldType::Game, false);
+ FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
+ WorldContext.SetCurrentWorld(World);
+
+ const FURL URL;
+ World->InitializeActorsForPlay(URL);
+ }
+
+ ~FSeminoleTestWorld()
+ {
+ if (World != nullptr)
+ {
+ GEngine->DestroyWorldContext(World);
+ World->DestroyWorld(false);
+ World = nullptr;
+ }
+ }
+
+ FSeminoleTestWorld(const FSeminoleTestWorld&) = delete;
+ FSeminoleTestWorld& operator=(const FSeminoleTestWorld&) = delete;
+
+ UWorld* GetWorld() const { return World; }
+
+ template <typename TActor>
+ TActor* SpawnActor(const FVector& Location = FVector::ZeroVector) const
+ {
+ FActorSpawnParameters SpawnParams;
+ SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
+ return World->SpawnActor<TActor>(TActor::StaticClass(), Location, FRotator::ZeroRotator, SpawnParams);
+ }
+
+private:
+ UWorld* World = nullptr;
+};
diff --git a/Source/Seminole/UI/README.md b/Source/Seminole/UI/README.md
new file mode 100644
index 0000000..2a93cdc
--- /dev/null
+++ b/Source/Seminole/UI/README.md
@@ -0,0 +1,6 @@
+# UI
+
+HUD and menus. The plan is UMG on CommonUI with widget assets under `Content/UI/`.
+
+Implemented (vertical slice part 1): `ASeminoleHUD`, an `AHUD` that draws the day phase and time remaining, the dusk warning, carried and stockpiled supplies and the interaction prompt with the `Canvas`, so the slice needs no widget assets. It is replaced by the UMG HUD when the UI ticket lands.
+See [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md).
diff --git a/Source/Seminole/UI/SeminoleHUD.cpp b/Source/Seminole/UI/SeminoleHUD.cpp
new file mode 100644
index 0000000..afdd8ea
--- /dev/null
+++ b/Source/Seminole/UI/SeminoleHUD.cpp
@@ -0,0 +1,165 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "UI/SeminoleHUD.h"
+
+#include "SeminoleGameState.h"
+#include "Community/SeminoleStockpile.h"
+#include "Interaction/SeminoleInteractionComponent.h"
+#include "Inventory/SeminoleInventoryComponent.h"
+#include "Inventory/SeminoleSupplyContainer.h"
+#include "Survival/SeminoleDayClockComponent.h"
+#include "Engine/Canvas.h"
+#include "Engine/Engine.h"
+#include "Engine/Font.h"
+#include "Engine/World.h"
+#include "EngineUtils.h"
+#include "GameFramework/PlayerController.h"
+#include "GameFramework/Pawn.h"
+
+namespace
+{
+ constexpr float Margin = 20.0f;
+ constexpr float LineHeight = 22.0f;
+ const FLinearColor TextColor(0.95f, 0.95f, 0.95f);
+ const FLinearColor DimColor(0.7f, 0.7f, 0.7f);
+ const FLinearColor DuskColor(1.0f, 0.6f, 0.1f);
+ const FLinearColor NightColor(0.9f, 0.2f, 0.2f);
+ const FLinearColor PanelColor(0.0f, 0.0f, 0.0f, 0.5f);
+ const FLinearColor BarColor(0.9f, 0.8f, 0.3f);
+
+ FString FormatSeconds(float Seconds)
+ {
+ const int32 Whole = FMath::Max(0, FMath::CeilToInt(Seconds));
+ return FString::Printf(TEXT("%d:%02d"), Whole / 60, Whole % 60);
+ }
+}
+
+void ASeminoleHUD::DrawHUD()
+{
+ Super::DrawHUD();
+
+ if (Canvas == nullptr || GEngine == nullptr)
+ {
+ return;
+ }
+
+ float Y = Margin;
+ DrawClock(GetDayClock(), Y);
+ DrawSupplies(Y);
+ DrawInteraction();
+}
+
+USeminoleDayClockComponent* ASeminoleHUD::GetDayClock() const
+{
+ const UWorld* World = GetWorld();
+ const ASeminoleGameState* GameState = World != nullptr ? World->GetGameState<ASeminoleGameState>() : nullptr;
+ return GameState != nullptr ? GameState->GetDayClock() : nullptr;
+}
+
+ASeminoleStockpile* ASeminoleHUD::GetStockpile()
+{
+ if (!CachedStockpile.IsValid())
+ {
+ for (TActorIterator<ASeminoleStockpile> It(GetWorld()); It; ++It)
+ {
+ CachedStockpile = *It;
+ break;
+ }
+ }
+ return CachedStockpile.Get();
+}
+
+void ASeminoleHUD::DrawClock(const USeminoleDayClockComponent* Clock, float& Y)
+{
+ UFont* LargeFont = GEngine->GetLargeFont();
+ UFont* MediumFont = GEngine->GetMediumFont();
+
+ if (Clock == nullptr)
+ {
+ DrawText(TEXT("Day clock: not running (no SeminoleGameState)"), DimColor, Margin, Y, MediumFont);
+ Y += LineHeight;
+ return;
+ }
+
+ const ESeminoleDayPhase Phase = Clock->GetPhase();
+ FString Line = SeminoleDayPhaseToString(Phase);
+ if (Phase != ESeminoleDayPhase::Night)
+ {
+ Line += TEXT(" ") + FormatSeconds(Clock->GetPhaseTimeRemaining());
+ }
+
+ const FLinearColor Color = Phase == ESeminoleDayPhase::Day ? TextColor : (Phase == ESeminoleDayPhase::Dusk ? DuskColor : NightColor);
+ DrawText(Line, Color, Margin, Y, LargeFont);
+ Y += LineHeight * 1.6f;
+
+ if (Phase == ESeminoleDayPhase::Day)
+ {
+ return;
+ }
+
+ // Dusk warning (and the night notice), centred near the top of the screen.
+ const FString Warning = Phase == ESeminoleDayPhase::Dusk
+ ? FString::Printf(TEXT("DUSK - night falls in %s. Return to the hub!"), *FormatSeconds(Clock->GetPhaseTimeRemaining()))
+ : FString(TEXT("NIGHT - the camp is on its own until dawn."));
+ float TextWidth = 0.0f;
+ float TextHeight = 0.0f;
+ GetTextSize(Warning, TextWidth, TextHeight, LargeFont);
+
+ const float BoxX = (Canvas->SizeX - TextWidth) * 0.5f - Margin;
+ const float BoxY = Margin * 3.0f;
+ DrawRect(PanelColor, BoxX, BoxY, TextWidth + Margin * 2.0f, TextHeight + Margin);
+ DrawText(Warning, Color, BoxX + Margin, BoxY + Margin * 0.5f, LargeFont);
+}
+
+void ASeminoleHUD::DrawSupplies(float& Y)
+{
+ UFont* MediumFont = GEngine->GetMediumFont();
+
+ const APlayerController* PlayerController = GetOwningPlayerController();
+ const APawn* Pawn = PlayerController != nullptr ? PlayerController->GetPawn() : nullptr;
+ const USeminoleInventoryComponent* Inventory = Pawn != nullptr ? Pawn->FindComponentByClass<USeminoleInventoryComponent>() : nullptr;
+
+ const FString Carried = Inventory != nullptr ? Inventory->GetSupplies().ToString() : TEXT("(no inventory)");
+ DrawText(TEXT("Carried: ") + Carried, TextColor, Margin, Y, MediumFont);
+ Y += LineHeight;
+
+ const ASeminoleStockpile* Stockpile = GetStockpile();
+ const FString Stored = Stockpile != nullptr ? Stockpile->GetSupplies().ToString() : TEXT("(no stockpile)");
+ DrawText(TEXT("Stockpile: ") + Stored, TextColor, Margin, Y, MediumFont);
+ Y += LineHeight;
+}
+
+void ASeminoleHUD::DrawInteraction()
+{
+ UFont* MediumFont = GEngine->GetMediumFont();
+
+ const APlayerController* PlayerController = GetOwningPlayerController();
+ const APawn* Pawn = PlayerController != nullptr ? PlayerController->GetPawn() : nullptr;
+ const USeminoleInteractionComponent* Interaction = Pawn != nullptr ? Pawn->FindComponentByClass<USeminoleInteractionComponent>() : nullptr;
+ const AActor* Focused = Interaction != nullptr ? Interaction->GetFocusedInteractable() : nullptr;
+ if (Focused == nullptr)
+ {
+ return;
+ }
+
+ const FString Prompt = TEXT("[E] ") + Interaction->GetFocusedPrompt().ToString();
+ float TextWidth = 0.0f;
+ float TextHeight = 0.0f;
+ GetTextSize(Prompt, TextWidth, TextHeight, MediumFont);
+
+ const float X = (Canvas->SizeX - TextWidth) * 0.5f;
+ const float Y = Canvas->SizeY * 0.75f;
+ DrawText(Prompt, TextColor, X, Y, MediumFont);
+
+ // Search progress bar under the prompt while a container is being searched.
+ const ASeminoleSupplyContainer* Container = Cast<ASeminoleSupplyContainer>(Focused);
+ if (Container != nullptr && Container->IsBeingSearched())
+ {
+ const float BarWidth = 200.0f;
+ const float BarHeight = 10.0f;
+ const float BarX = (Canvas->SizeX - BarWidth) * 0.5f;
+ const float BarY = Y + TextHeight + 6.0f;
+ DrawRect(PanelColor, BarX, BarY, BarWidth, BarHeight);
+ DrawRect(BarColor, BarX, BarY, BarWidth * Container->GetSearchProgress(), BarHeight);
+ }
+}
diff --git a/Source/Seminole/UI/SeminoleHUD.h b/Source/Seminole/UI/SeminoleHUD.h
new file mode 100644
index 0000000..4d15e12
--- /dev/null
+++ b/Source/Seminole/UI/SeminoleHUD.h
@@ -0,0 +1,36 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/HUD.h"
+#include "SeminoleHUD.generated.h"
+
+class ASeminoleStockpile;
+class USeminoleDayClockComponent;
+
+/**
+ * Canvas-drawn HUD (no widget assets): the day phase and time remaining, a dusk warning,
+ * the carried supplies, the stockpile totals, and the prompt of the interactable in reach
+ * with a progress bar while a container is being searched. Set by ASeminoleGameMode.
+ */
+UCLASS()
+class SEMINOLE_API ASeminoleHUD : public AHUD
+{
+ GENERATED_BODY()
+
+public:
+ //~ AHUD
+ virtual void DrawHUD() override;
+
+private:
+ USeminoleDayClockComponent* GetDayClock() const;
+ ASeminoleStockpile* GetStockpile();
+
+ void DrawClock(const USeminoleDayClockComponent* Clock, float& Y);
+ void DrawSupplies(float& Y);
+ void DrawInteraction();
+
+ /** The one stockpile of the slice, found once and cached. */
+ TWeakObjectPtr<ASeminoleStockpile> CachedStockpile;
+};
diff --git a/Source/Seminole/World/README.md b/Source/Seminole/World/README.md
index e25e7d6..87899cb 100644
--- a/Source/Seminole/World/README.md
+++ b/Source/Seminole/World/README.md
@@ -2,5 +2,11 @@
World Partition and PCG integration: the swamp map, biome rules, loot and spawn placement, and the shared noise event subsystem.
-No code lives here yet; this folder fixes where the system lands when its ticket is funded.
+Implemented (vertical slice part 1):
+
+* `USeminoleNoiseSubsystem` with `EmitNoise(Location, Radius, Instigator)` and the `ISeminoleNoiseListener` interface (`SeminoleNoiseListener.h`). Every noisy action calls `EmitNoise`; anything that hears implements the interface and registers in `BeginPlay`.
+* `ASeminoleNoiseListenerPlaceholder`, a sphere that counts and flashes on noise, standing in for the infected.
+* `USeminoleDayLightingComponent`, which drives the directional and sky light from the day clock.
+
+Map, World Partition and PCG work is not here yet.
See [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md).
diff --git a/Source/Seminole/World/SeminoleDayLightingComponent.cpp b/Source/Seminole/World/SeminoleDayLightingComponent.cpp
new file mode 100644
index 0000000..228f6af
--- /dev/null
+++ b/Source/Seminole/World/SeminoleDayLightingComponent.cpp
@@ -0,0 +1,114 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "World/SeminoleDayLightingComponent.h"
+
+#include "Seminole.h"
+#include "SeminoleGameState.h"
+#include "SeminoleSettings.h"
+#include "Survival/SeminoleDayClockComponent.h"
+#include "Components/DirectionalLightComponent.h"
+#include "Components/SkyLightComponent.h"
+#include "Engine/World.h"
+#include "EngineUtils.h"
+#include "GameFramework/Actor.h"
+
+namespace
+{
+ /** Component-wise blend using FLinearColor's own operators; FMath::Lerp needs float * color. */
+ FLinearColor BlendColor(const FLinearColor& A, const FLinearColor& B, float Alpha)
+ {
+ return A * (1.0f - Alpha) + B * Alpha;
+ }
+}
+
+USeminoleDayLightingComponent::USeminoleDayLightingComponent()
+{
+ PrimaryComponentTick.bCanEverTick = true;
+ PrimaryComponentTick.bStartWithTickEnabled = true;
+}
+
+void USeminoleDayLightingComponent::BeginPlay()
+{
+ Super::BeginPlay();
+
+ FindLights();
+ ApplyLighting();
+}
+
+void USeminoleDayLightingComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
+{
+ Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
+
+ ApplyLighting();
+}
+
+void USeminoleDayLightingComponent::ApplyLighting()
+{
+ const UWorld* World = GetWorld();
+ const ASeminoleGameState* GameState = World != nullptr ? World->GetGameState<ASeminoleGameState>() : nullptr;
+ const USeminoleDayClockComponent* Clock = GameState != nullptr ? GameState->GetDayClock() : nullptr;
+ if (Clock == nullptr)
+ {
+ return;
+ }
+
+ const USeminoleSettings* Settings = GetDefault<USeminoleSettings>();
+ const float Progress = Clock->GetPhaseProgress();
+
+ float SunIntensity = Settings->NightSunIntensity;
+ float SkyIntensity = Settings->NightSkyLightIntensity;
+ FLinearColor SunColor = Settings->NightSunColor;
+
+ switch (Clock->GetPhase())
+ {
+ case ESeminoleDayPhase::Day:
+ SunIntensity = FMath::Lerp(Settings->DaySunIntensity, Settings->DuskSunIntensity, Progress);
+ SkyIntensity = FMath::Lerp(Settings->DaySkyLightIntensity, Settings->DuskSkyLightIntensity, Progress);
+ SunColor = BlendColor(Settings->DaySunColor, Settings->DuskSunColor, Progress);
+ break;
+ case ESeminoleDayPhase::Dusk:
+ SunIntensity = FMath::Lerp(Settings->DuskSunIntensity, Settings->NightSunIntensity, Progress);
+ SkyIntensity = FMath::Lerp(Settings->DuskSkyLightIntensity, Settings->NightSkyLightIntensity, Progress);
+ SunColor = BlendColor(Settings->DuskSunColor, Settings->NightSunColor, Progress);
+ break;
+ case ESeminoleDayPhase::Night:
+ default:
+ break;
+ }
+
+ if (Sun != nullptr)
+ {
+ Sun->SetIntensity(SunIntensity);
+ Sun->SetLightColor(SunColor);
+ }
+ if (Sky != nullptr)
+ {
+ Sky->SetIntensity(SkyIntensity);
+ }
+}
+
+void USeminoleDayLightingComponent::FindLights()
+{
+ Sun = nullptr;
+ Sky = nullptr;
+
+ for (TActorIterator<AActor> It(GetWorld()); It; ++It)
+ {
+ if (Sun == nullptr)
+ {
+ Sun = It->FindComponentByClass<UDirectionalLightComponent>();
+ }
+ if (Sky == nullptr)
+ {
+ Sky = It->FindComponentByClass<USkyLightComponent>();
+ }
+ if (Sun != nullptr && Sky != nullptr)
+ {
+ break;
+ }
+ }
+
+ UE_LOG(LogSeminole, Log, TEXT("SeminoleDayLighting: driving %s and %s."),
+ Sun != nullptr ? TEXT("a directional light") : TEXT("no directional light"),
+ Sky != nullptr ? TEXT("a sky light") : TEXT("no sky light"));
+}
diff --git a/Source/Seminole/World/SeminoleDayLightingComponent.h b/Source/Seminole/World/SeminoleDayLightingComponent.h
new file mode 100644
index 0000000..2e156ba
--- /dev/null
+++ b/Source/Seminole/World/SeminoleDayLightingComponent.h
@@ -0,0 +1,45 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Components/ActorComponent.h"
+#include "SeminoleDayLightingComponent.generated.h"
+
+class UDirectionalLightComponent;
+class USkyLightComponent;
+
+/**
+ * Drives the world's directional light and sky light from the day clock: Day interpolates
+ * from the Day to the Dusk values, Dusk from the Dusk to the Night values, and Night holds
+ * the Night values (all in USeminoleSettings). Lives on ASeminoleTestEnvironment, which also
+ * spawns the lights; a real map's lighting (sky atmosphere, time-of-day sun path) replaces it.
+ *
+ * The first directional light and sky light found in the world are used, so a hand-made map's
+ * own lights are driven too.
+ */
+UCLASS(ClassGroup = (Seminole), meta = (BlueprintSpawnableComponent))
+class SEMINOLE_API USeminoleDayLightingComponent : public UActorComponent
+{
+ GENERATED_BODY()
+
+public:
+ USeminoleDayLightingComponent();
+
+ //~ UActorComponent
+ virtual void BeginPlay() override;
+ virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
+
+ /** Applies the lighting for the current clock state now; TickComponent calls this each frame. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Lighting")
+ void ApplyLighting();
+
+private:
+ void FindLights();
+
+ UPROPERTY()
+ TObjectPtr<UDirectionalLightComponent> Sun;
+
+ UPROPERTY()
+ TObjectPtr<USkyLightComponent> Sky;
+};
diff --git a/Source/Seminole/World/SeminoleNoiseListener.h b/Source/Seminole/World/SeminoleNoiseListener.h
new file mode 100644
index 0000000..0fa7133
--- /dev/null
+++ b/Source/Seminole/World/SeminoleNoiseListener.h
@@ -0,0 +1,51 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "UObject/Interface.h"
+#include "SeminoleNoiseListener.generated.h"
+
+class AActor;
+
+/** One noise event as delivered to listeners by USeminoleNoiseSubsystem. */
+USTRUCT(BlueprintType)
+struct SEMINOLE_API FSeminoleNoiseEvent
+{
+ GENERATED_BODY()
+
+ /** Where the noise happened. */
+ UPROPERTY(BlueprintReadOnly, Category = "Seminole|Noise")
+ FVector Location = FVector::ZeroVector;
+
+ /** How far (world units) the noise carries. Only listeners within this distance hear it. */
+ UPROPERTY(BlueprintReadOnly, Category = "Seminole|Noise")
+ float Radius = 0.0f;
+
+ /** Who made the noise; may be null for noises without a source actor. */
+ UPROPERTY(BlueprintReadOnly, Category = "Seminole|Noise")
+ TObjectPtr<AActor> Instigator = nullptr;
+};
+
+UINTERFACE(MinimalAPI, meta = (CannotImplementInterfaceInBlueprint))
+class USeminoleNoiseListener : public UInterface
+{
+ GENERATED_BODY()
+};
+
+/**
+ * Implemented in C++ by anything that wants to hear noise: the infected (part 2), and the
+ * placeholder ASeminoleNoiseListenerPlaceholder in part 1. Implementers register with
+ * USeminoleNoiseSubsystem::RegisterListener (typically in BeginPlay) and unregister in EndPlay.
+ */
+class SEMINOLE_API ISeminoleNoiseListener
+{
+ GENERATED_BODY()
+
+public:
+ /** The point the subsystem measures the noise radius against. */
+ virtual FVector GetNoiseListenerLocation() const = 0;
+
+ /** Called only for noises whose radius reaches GetNoiseListenerLocation(). */
+ virtual void OnNoiseHeard(const FSeminoleNoiseEvent& Noise) = 0;
+};
diff --git a/Source/Seminole/World/SeminoleNoiseListenerPlaceholder.cpp b/Source/Seminole/World/SeminoleNoiseListenerPlaceholder.cpp
new file mode 100644
index 0000000..2d973fa
--- /dev/null
+++ b/Source/Seminole/World/SeminoleNoiseListenerPlaceholder.cpp
@@ -0,0 +1,83 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "World/SeminoleNoiseListenerPlaceholder.h"
+
+#include "Seminole.h"
+#include "SeminolePlaceholderVisuals.h"
+#include "World/SeminoleNoiseSubsystem.h"
+#include "Components/StaticMeshComponent.h"
+#include "Engine/CollisionProfile.h"
+#include "Engine/StaticMesh.h"
+#include "Engine/World.h"
+#include "TimerManager.h"
+#include "UObject/ConstructorHelpers.h"
+
+namespace
+{
+ const FLinearColor IdleColor(0.8f, 0.1f, 0.1f);
+ const FLinearColor HeardColor(1.0f, 0.9f, 0.2f);
+ constexpr float FlashSeconds = 1.0f;
+}
+
+ASeminoleNoiseListenerPlaceholder::ASeminoleNoiseListenerPlaceholder()
+{
+ PrimaryActorTick.bCanEverTick = false;
+
+ Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
+ SetRootComponent(Mesh);
+ Mesh->SetMobility(EComponentMobility::Movable);
+ Mesh->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
+ Mesh->SetCanEverAffectNavigation(false);
+ ConstructorHelpers::FObjectFinder<UStaticMesh> SphereFinder(TEXT("/Engine/BasicShapes/Sphere.Sphere"));
+ if (SphereFinder.Succeeded())
+ {
+ Mesh->SetStaticMesh(SphereFinder.Object);
+ }
+}
+
+void ASeminoleNoiseListenerPlaceholder::BeginPlay()
+{
+ Super::BeginPlay();
+
+ SeminolePlaceholderVisuals::SetColor(Mesh, IdleColor);
+ if (USeminoleNoiseSubsystem* Noise = USeminoleNoiseSubsystem::Get(this))
+ {
+ Noise->RegisterListener(this);
+ }
+}
+
+void ASeminoleNoiseListenerPlaceholder::EndPlay(const EEndPlayReason::Type EndPlayReason)
+{
+ if (USeminoleNoiseSubsystem* Noise = USeminoleNoiseSubsystem::Get(this))
+ {
+ Noise->UnregisterListener(this);
+ }
+ Super::EndPlay(EndPlayReason);
+}
+
+FVector ASeminoleNoiseListenerPlaceholder::GetNoiseListenerLocation() const
+{
+ return GetActorLocation();
+}
+
+void ASeminoleNoiseListenerPlaceholder::OnNoiseHeard(const FSeminoleNoiseEvent& Noise)
+{
+ ++HeardNoiseCount;
+ LastNoise = Noise;
+
+ UE_LOG(LogSeminole, Log, TEXT("%s heard noise #%d from %s at %s (radius %.0f, distance %.0f)."),
+ *GetName(), HeardNoiseCount,
+ Noise.Instigator != nullptr ? *Noise.Instigator->GetName() : TEXT("<none>"),
+ *Noise.Location.ToString(), Noise.Radius, FVector::Dist(Noise.Location, GetActorLocation()));
+
+ SeminolePlaceholderVisuals::SetColor(Mesh, HeardColor);
+ if (GetWorld() != nullptr)
+ {
+ GetWorldTimerManager().SetTimer(FlashTimerHandle, this, &ASeminoleNoiseListenerPlaceholder::RestoreIdleColor, FlashSeconds, false);
+ }
+}
+
+void ASeminoleNoiseListenerPlaceholder::RestoreIdleColor()
+{
+ SeminolePlaceholderVisuals::SetColor(Mesh, IdleColor);
+}
diff --git a/Source/Seminole/World/SeminoleNoiseListenerPlaceholder.h b/Source/Seminole/World/SeminoleNoiseListenerPlaceholder.h
new file mode 100644
index 0000000..7590285
--- /dev/null
+++ b/Source/Seminole/World/SeminoleNoiseListenerPlaceholder.h
@@ -0,0 +1,53 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/Actor.h"
+#include "TimerManager.h"
+#include "World/SeminoleNoiseListener.h"
+#include "SeminoleNoiseListenerPlaceholder.generated.h"
+
+class UStaticMeshComponent;
+
+/**
+ * A sphere that hears noise: the part-1 stand-in for the infected of part 2. It registers with
+ * USeminoleNoiseSubsystem on BeginPlay, counts the noises it hears, logs them and flashes
+ * yellow for a second so delivery is visible in Play In Editor. The automation tests use it as
+ * the listener test double.
+ */
+UCLASS()
+class SEMINOLE_API ASeminoleNoiseListenerPlaceholder : public AActor, public ISeminoleNoiseListener
+{
+ GENERATED_BODY()
+
+public:
+ ASeminoleNoiseListenerPlaceholder();
+
+ //~ AActor
+ virtual void BeginPlay() override;
+ virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
+
+ //~ ISeminoleNoiseListener
+ virtual FVector GetNoiseListenerLocation() const override;
+ virtual void OnNoiseHeard(const FSeminoleNoiseEvent& Noise) override;
+
+ UFUNCTION(BlueprintPure, Category = "Seminole|Noise")
+ int32 GetHeardNoiseCount() const { return HeardNoiseCount; }
+
+ const FSeminoleNoiseEvent& GetLastNoise() const { return LastNoise; }
+
+private:
+ void RestoreIdleColor();
+
+ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+ TObjectPtr<UStaticMeshComponent> Mesh;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Noise")
+ int32 HeardNoiseCount = 0;
+
+ UPROPERTY(VisibleAnywhere, Category = "Seminole|Noise")
+ FSeminoleNoiseEvent LastNoise;
+
+ FTimerHandle FlashTimerHandle;
+};
diff --git a/Source/Seminole/World/SeminoleNoiseSubsystem.cpp b/Source/Seminole/World/SeminoleNoiseSubsystem.cpp
new file mode 100644
index 0000000..83f211f
--- /dev/null
+++ b/Source/Seminole/World/SeminoleNoiseSubsystem.cpp
@@ -0,0 +1,73 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "World/SeminoleNoiseSubsystem.h"
+
+#include "Seminole.h"
+#include "Engine/World.h"
+#include "GameFramework/Actor.h"
+
+USeminoleNoiseSubsystem* USeminoleNoiseSubsystem::Get(const UObject* WorldContextObject)
+{
+ if (WorldContextObject == nullptr)
+ {
+ return nullptr;
+ }
+ const UWorld* World = WorldContextObject->GetWorld();
+ return World != nullptr ? World->GetSubsystem<USeminoleNoiseSubsystem>() : nullptr;
+}
+
+void USeminoleNoiseSubsystem::EmitNoise(const FVector& Location, float Radius, AActor* Instigator)
+{
+ FSeminoleNoiseEvent Noise;
+ Noise.Location = Location;
+ Noise.Radius = Radius;
+ Noise.Instigator = Instigator;
+
+ const float RadiusSquared = FMath::Square(FMath::Max(Radius, 0.0f));
+ int32 HeardBy = 0;
+
+ // Iterate a copy so a listener may register or unregister from inside OnNoiseHeard.
+ const TArray<TWeakObjectPtr<UObject>> Snapshot = Listeners;
+ for (const TWeakObjectPtr<UObject>& WeakListener : Snapshot)
+ {
+ UObject* ListenerObject = WeakListener.Get();
+ if (ListenerObject == nullptr)
+ {
+ continue;
+ }
+ ISeminoleNoiseListener* Listener = Cast<ISeminoleNoiseListener>(ListenerObject);
+ if (Listener == nullptr)
+ {
+ continue;
+ }
+ if (FVector::DistSquared(Listener->GetNoiseListenerLocation(), Location) <= RadiusSquared)
+ {
+ Listener->OnNoiseHeard(Noise);
+ ++HeardBy;
+ }
+ }
+
+ // Drop listeners that were destroyed without unregistering.
+ Listeners.RemoveAll([](const TWeakObjectPtr<UObject>& WeakListener) { return !WeakListener.IsValid(); });
+
+ UE_LOG(LogSeminole, Verbose, TEXT("SeminoleNoise: %s emitted noise at %s, radius %.0f, heard by %d of %d listeners."),
+ Instigator != nullptr ? *Instigator->GetName() : TEXT("<none>"), *Location.ToString(), Radius, HeardBy, Listeners.Num());
+
+ OnNoiseEmitted.Broadcast(Noise);
+}
+
+void USeminoleNoiseSubsystem::RegisterListener(UObject* Listener)
+{
+ if (Listener == nullptr || Cast<ISeminoleNoiseListener>(Listener) == nullptr)
+ {
+ UE_LOG(LogSeminole, Warning, TEXT("SeminoleNoise: %s does not implement ISeminoleNoiseListener and was not registered."),
+ Listener != nullptr ? *Listener->GetName() : TEXT("<null>"));
+ return;
+ }
+ Listeners.AddUnique(TWeakObjectPtr<UObject>(Listener));
+}
+
+void USeminoleNoiseSubsystem::UnregisterListener(UObject* Listener)
+{
+ Listeners.Remove(TWeakObjectPtr<UObject>(Listener));
+}
diff --git a/Source/Seminole/World/SeminoleNoiseSubsystem.h b/Source/Seminole/World/SeminoleNoiseSubsystem.h
new file mode 100644
index 0000000..c5e1b3f
--- /dev/null
+++ b/Source/Seminole/World/SeminoleNoiseSubsystem.h
@@ -0,0 +1,46 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "Subsystems/WorldSubsystem.h"
+#include "World/SeminoleNoiseListener.h"
+#include "SeminoleNoiseSubsystem.generated.h"
+
+DECLARE_MULTICAST_DELEGATE_OneParam(FSeminoleNoiseEmitted, const FSeminoleNoiseEvent&);
+
+/**
+ * The one shared noise event of the game (ADR 0004). Everything that makes noise calls
+ * EmitNoise; every registered ISeminoleNoiseListener within the radius hears it.
+ *
+ * Delivery is a plain 3D distance test against each listener's location. AI Perception
+ * forwarding, attenuation and tags are deferred to the infected ticket (part 2), which also
+ * registers the infected as listeners. Audio and UI can bind OnNoiseEmitted, which fires for
+ * every noise regardless of listeners.
+ */
+UCLASS()
+class SEMINOLE_API USeminoleNoiseSubsystem : public UWorldSubsystem
+{
+ GENERATED_BODY()
+
+public:
+ /** Convenience accessor; returns null without a world. */
+ static USeminoleNoiseSubsystem* Get(const UObject* WorldContextObject);
+
+ /** Delivers the noise to every registered listener within Radius of Location. */
+ UFUNCTION(BlueprintCallable, Category = "Seminole|Noise")
+ void EmitNoise(const FVector& Location, float Radius, AActor* Instigator);
+
+ /** Listener must implement ISeminoleNoiseListener; registering twice has no extra effect. */
+ void RegisterListener(UObject* Listener);
+ void UnregisterListener(UObject* Listener);
+
+ int32 GetListenerCount() const { return Listeners.Num(); }
+
+ /** Broadcast after listener delivery for every emitted noise. */
+ FSeminoleNoiseEmitted OnNoiseEmitted;
+
+private:
+ /** Weak so a destroyed listener that forgot to unregister is skipped and dropped. */
+ TArray<TWeakObjectPtr<UObject>> Listeners;
+};
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3c8b6f7..c3dd008 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -7,8 +7,9 @@ infected. This document records the technical foundation every later
community-funded feature builds on. Each major choice has an architecture
decision record in [`adr/`](adr/README.md).
-This document describes intent. Nothing below is implemented yet; the ticket
-that implements a system owns the details and updates this file.
+This document describes intent. Most of it is not implemented yet; the ticket
+that implements a system owns the details and updates this file. What exists
+today is listed under [Implemented: vertical slice part 1](#implemented-vertical-slice-part-1).
## Engine and programming model
@@ -27,7 +28,9 @@ that implements a system owns the details and updates this file.
data assets under `Content/`. Until the Characters ticket lands, the
bootstrap `ASeminolePlaceholderCharacter` (module root) uses legacy
axis/action mappings from `Config/DefaultInput.ini` so the project boots
- without any input assets; it is replaced, not extended.
+ without any input assets (the vertical slice added `Interact` = E the same
+ way). The real character replaces it, keeping its inventory and
+ interaction components and binding `Interact` through Enhanced Input.
* No Gameplay Ability System by default. Health, stamina, damage and status
effects are plain components and data assets until a concrete need appears.
See [ADR 0007](adr/0007-no-gameplay-ability-system-by-default.md).
@@ -78,12 +81,17 @@ Noise is **one shared gameplay event**, not a per-system mechanic. See
[ADR 0004](adr/0004-noise-event-system.md).
* A world subsystem (`Source/Seminole/World/`) exposes
- `ReportNoise(Location, Loudness, Instigator, Tag)`.
+ `ReportNoise(Location, Loudness, Instigator, Tag)`. **Implemented** (part 1)
+ as `USeminoleNoiseSubsystem::EmitNoise(Location, Radius, Instigator)`; the
+ tag parameter arrives with the first system that needs it.
* Everything that makes noise calls it: footsteps, gunshots, melee impacts,
- canoe paddling, doors, dropped items, campfire work.
+ canoe paddling, doors, dropped items, campfire work. Today: container
+ searches.
* The subsystem forwards each event to AI Perception's hearing sense and
broadcasts a delegate so other systems (audio cues, UI, missions) can react
- to the same event.
+ to the same event. Today it delivers to registered `ISeminoleNoiseListener`
+ objects within the radius (plain 3D distance) and broadcasts
+ `OnNoiseEmitted`; AI Perception forwarding is part 2's job.
* Loudness is a radius in world units; falloff and material attenuation are
tuning details for the implementing ticket.
@@ -146,12 +154,12 @@ Planned components and their folders:
| Folder | Components |
| --- | --- |
| `Characters/` | Player and villager base characters, Enhanced Input, animation hooks |
-| `Survival/` | Health, stamina, hunger, day/night phase |
+| `Survival/` | Health, stamina, hunger, day/night phase (`USeminoleDayClockComponent` exists) |
| `Combat/` | Weapons, melee and ranged attacks, damage |
-| `Inventory/` | Items, containers, equipment |
-| `Interaction/` | Interactable actors and the interaction component |
+| `Inventory/` | Items, containers, equipment (`USeminoleInventoryComponent`, `ASeminoleSupplyContainer` exist) |
+| `Interaction/` | Interactable actors and the interaction component (`ISeminoleInteractable`, `USeminoleInteractionComponent` exist) |
| `Vehicles/` | Canoe: a paddled pawn carrying passengers |
-| `Community/` | Villagers, roles, camp upgrades |
+| `Community/` | Villagers, roles, camp upgrades (`ASeminoleStockpile` exists) |
Every component that can make noise reports it through the noise subsystem.
@@ -160,7 +168,9 @@ Every component that can make noise reports it through the noise subsystem.
* **UMG** widgets built on **CommonUI** for consistent input handling across
keyboard/mouse and gamepad, and for a single activatable-widget stack.
* HUD (health, stamina, time of day, noise indicator), inventory, pause and
- session menus. Widget assets under `Content/UI/`.
+ session menus. Widget assets under `Content/UI/`. Until the UI ticket,
+ `ASeminoleHUD` (`Source/Seminole/UI/`) draws the slice's HUD with the
+ `AHUD` Canvas so no widget assets are needed.
## Audio
@@ -229,6 +239,88 @@ Target loop: wake at camp -> canoe out -> scavenge -> return before dark ->
prepare -> survive the night -> repeat. Each loop should make the camp a
little stronger and the night a little harder.
+## Implemented: vertical slice part 1
+
+Part 1 of the slice (ticket #23) delivers the non-combat core: tunable
+config, the day clock, the noise subsystem, looting into an inventory and
+depositing into a hub stockpile. Everything is C++ with runtime-spawned
+placeholder primitives; there are no new assets. See
+[ADR 0009](adr/0009-vertical-slice-placeholder-systems.md) for the decisions.
+
+| System | Class | Folder |
+| --- | --- | --- |
+| Config | `USeminoleSettings` (`UDeveloperSettings`, Project Settings > Game > Seminole) | module root |
+| Day clock | `USeminoleDayClockComponent` on `ASeminoleGameState` | `Survival/` |
+| Lighting | `USeminoleDayLightingComponent` on `ASeminoleTestEnvironment` | `World/` |
+| Noise | `USeminoleNoiseSubsystem`, `ISeminoleNoiseListener`, `ASeminoleNoiseListenerPlaceholder` | `World/` |
+| Supplies and inventory | `ESeminoleSupplyType`, `FSeminoleSupplyCounts`, `USeminoleInventoryComponent` | `Inventory/` |
+| Containers | `ASeminoleSupplyContainer` | `Inventory/` |
+| Interaction | `ISeminoleInteractable`, `USeminoleInteractionComponent` | `Interaction/` |
+| Stockpile | `ASeminoleStockpile` | `Community/` |
+| HUD | `ASeminoleHUD` (Canvas, no widgets) | `UI/` |
+| Tests | `IMPLEMENT_SIMPLE_AUTOMATION_TEST` under `Seminole.*` | `Tests/` |
+
+How the pieces connect:
+
+* **Config.** Every tunable (day 480 s, dusk 60 s, bow 500 uu and rifle
+ 3000 uu noise radii, 2 s container search, per-container supply amounts,
+ lighting values, interaction range) is a `Config` property of
+ `USeminoleSettings` with its default in C++. Gameplay code reads
+ `GetDefault<USeminoleSettings>()`; nothing hard-codes a value. Edits in
+ Project Settings land in `Config/DefaultGame.ini`.
+* **Clock.** `ASeminoleGameState` owns the clock so the phase is host-owned
+ state ready for replication. The clock starts in Day on `BeginPlay`, runs
+ Day -> Dusk -> Night and stops at Night. `OnPhaseChanged` (dynamic
+ multicast) fires on every transition and on `ResetToDay()`;
+ `GetPhaseTimeRemaining()` and `GetPhaseProgress()` are queryable. All time
+ keeping goes through `Advance(DeltaSeconds)`, which `TickComponent` feeds
+ and tests call directly. **Part 3** binds `OnPhaseChanged` for Night and
+ calls `ResetToDay()` after a survived night.
+* **Lighting.** The lighting component finds the first directional light and
+ sky light in the world and interpolates intensity and sun colour: Day
+ values -> Dusk values across Day, Dusk values -> Night values across Dusk,
+ Night values held at Night.
+* **Noise.** `EmitNoise(Location, Radius, Instigator)` delivers an
+ `FSeminoleNoiseEvent` to every registered `ISeminoleNoiseListener` whose
+ `GetNoiseListenerLocation()` is within `Radius`. The placeholder listener
+ (a red sphere at the scavenging area) flashes yellow and logs when it hears
+ something. **Part 2**'s infected implement the interface and register in
+ `BeginPlay`; weapons read `BowNoiseRadius` / `RifleNoiseRadius` from the
+ settings and call `EmitNoise`.
+* **Supplies.** `ESeminoleSupplyType { Food, Ammo, Materials }` with integer
+ counts in `FSeminoleSupplyCounts` (one field per type so it can be marked
+ `Replicated` later). `USeminoleInventoryComponent` on the player pawn has
+ add, remove (fails without change if insufficient), query and `TakeAll`.
+* **Interaction.** `ISeminoleInteractable` (`CanInteract`, `Interact`,
+ `GetInteractionPrompt`) is implemented by the container and the stockpile.
+ `USeminoleInteractionComponent` on the pawn picks the nearest interactable
+ within `InteractionRange` each tick (the HUD shows its prompt) and the
+ legacy `Interact` action (`E`, `Config/DefaultInput.ini`) uses it. **Part 2**
+ adds the canoe, **part 3** barricades, as further implementers.
+* **Containers.** `ASeminoleSupplyContainer::BeginSearch` starts a timed
+ search (emits noise with `ContainerSearchNoiseRadius`), `AdvanceSearch`
+ moves it forward (fed by `Tick`), completion grants the configured
+ supplies to the searcher's inventory, turns the crate grey and locks it:
+ a second search is refused. `USeminoleSettings::ScavengingContainers`
+ lists one loot table per spawned container.
+* **Stockpile.** `ASeminoleStockpile` holds the camp totals. `DepositAll`
+ moves a whole inventory in, `TrySpend(Type, Amount)` returns `false`
+ without change when short. **Part 3** spends from it.
+* **Placeholders.** `ASeminoleTestEnvironment::EnsureSlicePlaceholders()`
+ (called by the game mode after the scene basics) spawns, when the world
+ has none: the stockpile at the hub (4 m in front of the PlayerStart), the
+ configured containers in a row at the scavenging area 30 m along +X, and
+ the noise listener 6 m beyond them.
+* **HUD.** `ASeminoleHUD::DrawHUD` draws the phase and remaining time, a
+ centred dusk warning (and night notice), carried supplies, stockpile
+ totals, the interaction prompt and a search progress bar.
+
+Solo only: nothing is replicated yet, but the phase lives on the game state
+and the totals on an authority-spawned actor, so adding `Replicated` later
+does not move state. Known simplifications: a search keeps running if the
+player walks away; the interaction scan iterates all actors (fine for a
+handful of placeholders).
+
## Deferred / Out of scope
Deferred (possible later, not planned now):
@@ -244,5 +336,6 @@ Out of scope for this project:
* Dedicated servers, MMO-style worlds, backend orchestration.
* Browser-first or mobile builds, console ports.
* Final art direction or content.
-* Any gameplay system in this ticket: AI, noise, health, inventory, weapons,
- canoe, persistence, networking are documented here and implemented later.
+* Any gameplay system beyond part 1 of the vertical slice: AI, health,
+ weapons, canoe, night defense, persistence and networking are documented
+ here and implemented in later tickets.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
index 4e07154..fc03b36 100644
--- a/docs/VALIDATION.md
+++ b/docs/VALIDATION.md
@@ -1,10 +1,11 @@
# Manual validation checklist
-**None of the steps below were executed by the implementer of ticket #22.** The change was
-written and reviewed statically, in an environment without Unreal Engine or Windows. Compiling,
-launching, playing and packaging have not been verified. The first person with Unreal Engine
-5.8 on Windows should walk through this list and record the result (and any fixes) on the
-pull request or in a follow-up ticket.
+**None of the steps below were executed by the implementers of tickets #22 and #23.** The
+changes were written and reviewed statically, in an environment without Unreal Engine or
+Windows. Compiling, launching, playing, testing and packaging have not been verified. The first
+person with Unreal Engine 5.8 on Windows should walk through this list and record the result
+(and any fixes) on the pull request or in a follow-up ticket. Section 7 covers the vertical
+slice part 1 (#23).
Prerequisites: Windows 10/11, Unreal Engine 5.8 (Epic Games Launcher), Visual Studio 2022 with
the "Game development with C++" workload (or Rider), Git with Git LFS. Follow the README's
@@ -32,9 +33,9 @@ Tick each box only when the observed result matches.
Default Pawn Class is `SeminolePlaceholderCharacter`, Editor Startup Map and Game Default
Map are both `Entry`.
- [ ] **Edit > Project Settings > Input**: the Axis Mappings `MoveForward` (W/S),
- `MoveRight` (D/A), `Turn` (Mouse X), `LookUp` (Mouse Y, scale -1) and the Action Mapping
- `Jump` (Space Bar) are listed; Default Player Input Class is `PlayerInput`, Default Input
- Component Class is `InputComponent`.
+ `MoveRight` (D/A), `Turn` (Mouse X), `LookUp` (Mouse Y, scale -1) and the Action Mappings
+ `Jump` (Space Bar) and `Interact` (E) are listed; Default Player Input Class is
+ `PlayerInput`, Default Input Component Class is `InputComponent`.
## 3. Play In Editor: scene, movement, look, jump
@@ -43,13 +44,14 @@ Before pressing Play, set the Play button's dropdown **Spawn player at: Default
runtime-spawned floor).
- [ ] Press **Play** (Selected Viewport or New Editor Window). The Output Log (`LogSeminole`)
- shows four lines: spawned a floor, spawned a movable directional light, spawned a movable
- sky light, spawned a PlayerStart.
+ shows four scene lines: spawned a floor, spawned a movable directional light, spawned a
+ movable sky light, spawned a PlayerStart (plus the slice lines checked in section 7).
- [ ] The scene shows a large flat grey floor, lit by a directional light, with a grey cylinder
(the placeholder character) standing on it, viewed from a third-person camera behind it.
The sky is black (there is no atmosphere in this shell); that is expected.
- [ ] The World Outliner lists `SeminoleTestEnvironment`, `SeminoleFloor`, `SeminoleSun`,
- `SeminoleSkyLight` and `SeminolePlayerStart` (only during play).
+ `SeminoleSkyLight` and `SeminolePlayerStart` (only during play), plus the slice actors of
+ section 7.
- [ ] **W / S** move forward / back and **A / D** strafe left / right, relative to the camera.
The cylinder turns to face the direction it moves.
- [ ] Moving the **mouse** orbits the camera around the character (left/right = yaw, up/down =
@@ -57,8 +59,8 @@ runtime-spawned floor).
- [ ] **Space** jumps; the character leaves the floor and lands on it again.
- [ ] Walking to the edge of the floor (50 m from the start) and off it makes the character
fall, confirming the floor has collision and nothing else is holding the pawn.
-- [ ] Press Play a second time: still exactly four `LogSeminole` spawn lines (no duplicates
- accumulate between sessions).
+- [ ] Press Play a second time: the same `LogSeminole` spawn lines as the first time (no
+ duplicates accumulate between sessions).
- [ ] **Play > Standalone Game** boots the same scene with the same controls.
## 4. Package Win64 Development
@@ -98,6 +100,106 @@ runtime-spawned floor).
packaged game boots into it. Revert these local config changes and do not commit the
`.umap` or the `.uproject` plugin change unless a ticket asks for it.
+## 7. Vertical slice part 1 (#23): config, clock, noise, loot, stockpile
+
+Everything in this section was written without a compiler. Start with the build and the tests;
+compile errors are the most likely failure and should be reported with the exact error text.
+
+### 7.1 Compile
+
+- [ ] **Development Editor | Win64** builds with no errors after regenerating project files
+ (new folders `Survival/`, `World/`, `Inventory/`, `Interaction/`, `Community/`, `UI/`,
+ `Tests/` under `Source/Seminole/`; new module dependency `DeveloperSettings` in
+ `Seminole.Build.cs`).
+- [ ] Known risk points to look at if it does not: `EAutomationTestFlags_ApplicationContextMask`
+ (UE >= 5.5 enum-class flags) in `Tests/*.cpp`; `UWorld::CreateWorld` /
+ `GEngine->CreateNewWorldContext` in `Tests/SeminoleTestWorld.h`; `AHUD::DrawText` /
+ `DrawRect` / `GetTextSize` in `UI/SeminoleHUD.cpp`; `Cast<ISeminoleInteractable>` /
+ `Cast<ISeminoleNoiseListener>` on actors.
+
+### 7.2 Automation tests
+
+- [ ] **Tools > Session Frontend > Automation**, refresh, filter `Seminole`. Four tests are
+ listed: `Seminole.Survival.DayClock.Transitions`,
+ `Seminole.Survival.DayClock.LargeStepCrossesPhasesInOrder`,
+ `Seminole.World.Noise.RadiusFiltering`, `Seminole.Inventory.LootFlow`.
+- [ ] All four pass. (The noise and loot tests create and destroy a throwaway game world; a
+ failure there with a null world or subsystem points at `Tests/SeminoleTestWorld.h`.)
+- [ ] Optional, headless:
+ `UnrealEditor-Cmd.exe <repo>\Seminole.uproject -ExecCmds="Automation RunTests Seminole; Quit" -unattended -nopause -nullrhi -log`
+ reports 4 passed in the log.
+
+### 7.3 Settings
+
+- [ ] **Edit > Project Settings > Game > Seminole** exists and shows: Day Duration 480, Dusk
+ Duration 60, Bow Noise Radius 500, Rifle Noise Radius 3000, Container Search Noise Radius
+ 800, Container Search Duration 2, three Scavenging Containers (Food 3; Ammo 10;
+ Materials 5 + Food 1), Interaction Range 250, and the Lighting values.
+- [ ] Changing *Day Duration Seconds* to 20 and pressing **Set as Default** writes a
+ `[/Script/Seminole.SeminoleSettings]` section to `Config/DefaultGame.ini`. Revert
+ afterwards (or do not commit it).
+
+### 7.4 Spawned placeholders
+
+- [ ] Press **Play**. `LogSeminole` additionally shows: spawned the hub stockpile, spawned 3
+ scavenging containers, spawned a placeholder noise listener, `SeminoleDayLighting: driving
+ a directional light and a sky light`, and `SeminoleDayClock: Day begins (480 s)`.
+- [ ] The World Outliner lists `SeminoleStockpile`, `SeminoleContainer1..3` and
+ `SeminoleNoiseListener`.
+- [ ] A green block stands 4 m in front of the start; 30 m away along +X stand three brown
+ crates in a row with a red sphere 6 m behind them. All rest on the floor (not sunk, not
+ floating).
+
+### 7.5 HUD and clock
+
+- [ ] Top left: `Day 8:00` (large font) counting down once per second, then
+ `Carried: Food 0 Ammo 0 Materials 0` and `Stockpile: Food 0 Ammo 0 Materials 0`.
+- [ ] Nothing else is drawn until you approach an interactable (no prompt, no warning).
+
+### 7.6 Interact: containers and the noise listener
+
+- [ ] Walk to a crate. Within about 2.5 m the prompt `[E] Search container` appears centred in
+ the lower third of the screen; it disappears when you walk away.
+- [ ] Press **E** next to a crate: the prompt changes to `[E] Searching...`, a yellow progress
+ bar fills over 2 seconds, then the crate turns grey, the prompt reads
+ `[E] Container (empty)` and *Carried* shows the crate's supplies (e.g. `Food 3`).
+ `LogSeminole` shows the search start, the noise heard by `SeminoleNoiseListener`, and
+ the search complete line.
+- [ ] The red sphere flashes yellow for about a second when the search starts.
+- [ ] Pressing **E** at a grey crate does nothing (supplies unchanged, no log line).
+- [ ] Search the other two crates: *Carried* accumulates `Food 4 Ammo 10 Materials 5`.
+- [ ] Pressing **E** with no interactable in range does nothing.
+
+### 7.7 Interact: stockpile
+
+- [ ] Near the stockpile with empty hands the prompt reads
+ `[E] Stockpile (nothing to deposit)` and **E** does nothing.
+- [ ] Carrying supplies, the prompt reads `[E] Deposit supplies`; pressing **E** sets *Carried*
+ to all zeros and *Stockpile* to the deposited totals. `LogSeminole` shows the deposit line.
+
+### 7.8 Dusk, night and lighting
+
+For a quick run set *Day Duration Seconds* to 20 and *Dusk Duration Seconds* to 10 in Project
+Settings (revert afterwards).
+
+- [ ] During Day the scene gradually dims and warms: the sun goes from white-ish (intensity 10)
+ to amber (intensity 3) by the end of Day.
+- [ ] At the end of Day the top-left line turns orange (`Dusk 0:10` counting down) and a
+ centred box reads `DUSK - night falls in 0:10. Return to the hub!`. `LogSeminole`:
+ `SeminoleDayClock: Dusk begins (10 s)`.
+- [ ] During Dusk the light fades to the night values (sun intensity 0.05, bluish).
+- [ ] At the end of Dusk the line reads `Night` in red with no timer, the centred box reads
+ `NIGHT - the camp is on its own until dawn.`, the scene is dark, and the clock stays at
+ Night indefinitely. `LogSeminole`: `SeminoleDayClock: Night begins (0 s)`.
+- [ ] Containers and the stockpile still work at Night.
+
+### 7.9 Standalone and packaged
+
+- [ ] **Play > Standalone Game** shows the same HUD, placeholders and behaviour.
+- [ ] A packaged Win64 Development build (section 4) shows the same; in particular the crates
+ and stockpile are tinted (if they are plain white/grey checker, `/Engine/BasicShapes`
+ or its material was not cooked; note it on the PR).
+
## Reporting
Record for each section: pass / fail, engine version shown in the editor's title bar, and the
diff --git a/docs/adr/0009-vertical-slice-placeholder-systems.md b/docs/adr/0009-vertical-slice-placeholder-systems.md
new file mode 100644
index 0000000..52124bd
--- /dev/null
+++ b/docs/adr/0009-vertical-slice-placeholder-systems.md
@@ -0,0 +1,56 @@
+# 0009. Vertical slice placeholder systems
+
+Status: Accepted
+
+## Context
+
+The playable vertical slice is funded in three parts (#23 part 1: config,
+clock, noise, loot and stockpile; part 2: canoe, weapons, infected; part 3:
+night defense, barricades, game flow). It must be built without a compiler
+or CI in the implementer's environment, without new binary assets, and so
+that parts 2 and 3 can extend it rather than replace it. Several choices
+recur across the parts and are recorded here once.
+
+## Decision
+
+* **One `UDeveloperSettings` for tunables.** `USeminoleSettings`
+ (`Config=Game, DefaultConfig`) holds every gameplay number of the slice
+ with its default in C++. Code reads `GetDefault<USeminoleSettings>()`
+ and never hard-codes a tunable; designers edit Project Settings > Game >
+ Seminole and the values land in `Config/DefaultGame.ini`. Data assets per
+ item (ADR 0002) arrive when there is real content to describe.
+* **Authoritative state on framework objects.** The day clock is a component
+ on `ASeminoleGameState`; stockpile totals live on the authority-spawned
+ `ASeminoleStockpile`. Nothing is replicated yet (solo only), but the state
+ already sits where `Replicated` can be added without moving it (ADR 0005).
+* **Deterministic time.** Anything timed exposes an `Advance(DeltaSeconds)`
+ style entry point (`USeminoleDayClockComponent::Advance`,
+ `ASeminoleSupplyContainer::AdvanceSearch`) that `Tick` feeds in play and
+ automation tests call directly. No system reads wall-clock time.
+* **C++ interfaces for cross-system contracts.** `ISeminoleNoiseListener`
+ and `ISeminoleInteractable` are `CannotImplementInterfaceInBlueprint`
+ interfaces; Blueprint subclasses of a C++ implementer inherit the
+ implementation. This keeps dispatch plain C++ and testable.
+* **Placeholder content is runtime-spawned C++ primitives.** The test
+ environment from #22 is extended (`EnsureSlicePlaceholders`) with
+ `/Engine/BasicShapes` meshes tinted through a dynamic material instance.
+ The HUD is `AHUD` Canvas drawing. No `.uasset`, map, widget, animation or
+ audio is added; the real art, UMG/CommonUI HUD and Enhanced Input assets
+ are later tickets.
+* **Tests run in a throwaway game world.** Automation tests use
+ `IMPLEMENT_SIMPLE_AUTOMATION_TEST` and, where actors or subsystems are
+ needed, `FSeminoleTestWorld` (`UWorld::CreateWorld` + a world context).
+ There is no game mode in that world, so `BeginPlay` does not run; tests
+ register listeners and start clocks explicitly.
+
+## Consequences
+
+* Parts 2 and 3 add settings to the same class, implement the same two
+ interfaces, bind `OnPhaseChanged` and call `EmitNoise`, `TrySpend` and
+ `ResetToDay` as documented in ARCHITECTURE.md; they do not need to change
+ part 1's shape.
+* The placeholder test environment keeps growing until a real map ships;
+ it is still scaffolding and goes away then.
+* Everything in part 1 was written without compiling. The maintainer's
+ first build and test run (docs/VALIDATION.md, section 7) is part of
+ accepting the change.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index e57bf3a..08947eb 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -15,6 +15,7 @@ decision gets a new record that supersedes the old one.
| [0006](0006-host-owned-save-game.md) | Host-owned save game |
| [0007](0007-no-gameplay-ability-system-by-default.md) | No Gameplay Ability System by default |
| [0008](0008-git-lfs-before-perforce.md) | Git with Git LFS before Perforce |
+| [0009](0009-vertical-slice-placeholder-systems.md) | Vertical slice placeholder systems: one settings class, authoritative state on framework objects, deterministic time, C++ interfaces, runtime-spawned primitives |
To add a record, copy the structure of an existing file, use the next number
and add a row here.
This is a thorough, well-structured implementation of all part-1 systems: settings, the GameState-owned clock with deterministic `Advance`, lighting, the noise subsystem, inventory, interaction, containers, stockpile, a Canvas HUD, automation tests and extensive docs including ADR 0009 and VALIDATION.md §7. The code follows existing project patterns, adds no binary assets or networking, and documents extension points for parts 2 and 3. The main caveat is that none of it has been compiled or run, and CI build was skipped, so acceptance hinges on the maintainer's local build and test pass. A few minor design points (legacy input mapping, absolute hub coordinates, test UCLASS in non-test builds) are worth a look.
Acceptance criteria · 10 of 12 met
- YESConfig: a settings class exposes every A1 value; no gameplay code hard-codes them`USeminoleSettings` (`UDeveloperSettings`, `Config=Game`) has Day 480, Dusk 60, Bow 500, Rifle 3000, search 2 s and `ScavengingContainers` supply lists; the clock, container, test environment and interaction code all read `GetDefault<USeminoleSettings>()`.
- YESDay clock: auto-advances Day→Dusk→Night from start, broadcasts phase delegate, time remaining queryable, ResetToDay restores full Day, starts in Day without console`USeminoleDayClockComponent` on `ASeminoleGameState` calls `ResetToDay` in `BeginPlay`, `Advance` carries leftover time across boundaries and stops at Night, and it exposes `OnPhaseChanged` and `GetPhaseTimeRemaining`.
- YESLighting: directional (and sky) light interpolate day→dusk, reach night value at Night`USeminoleDayLightingComponent::ApplyLighting` lerps Day→Dusk values across Day and Dusk→Night values across Dusk, holds Night values at Night, and drives both the sun and the sky light.
- YESHUD: phase + time remaining, visible dusk warning, carried supplies and stockpile totals`ASeminoleHUD::DrawClock` draws the phase, the countdown and a centred DUSK box; `DrawSupplies` draws the carried and stockpile totals, all via `AHUD` Canvas.
- YESNoise: world subsystem EmitNoise(Location, Radius, Instigator); only registered listeners within 3D radius receive event with location and instigator`USeminoleNoiseSubsystem::EmitNoise` tests `DistSquared` against weak-pointer registered `ISeminoleNoiseListener`s and passes an `FSeminoleNoiseEvent` carrying location, radius and instigator.
- YESInventory: UActorComponent on player pawn with per-type counts, add/remove/query`USeminoleInventoryComponent` provides `AddSupply`/`AddSupplies`/`RemoveSupply`/`GetSupplyCount`/`TakeAll` and is added in the `ASeminolePlaceholderCharacter` constructor.
- YESContainers: ≥3 spawned at scavenging area, timed search of configured duration, grants supplies, cannot be re-searched, visual change`EnsureSlicePlaceholders` spawns one container per `ScavengingContainers` entry (3 by default) 30 m along +X; `ASeminoleSupplyContainer` handles `BeginSearch`/`AdvanceSearch`/`FinishSearch`, turns the crate grey and refuses a second search.
- YESStockpile: spawned at hub, deposit empties inventory, totals authoritative, TrySpend returns false without change when short`ASeminoleStockpile` holds the totals; `DepositAll` uses `TakeAll`, and `TrySpend` checks `Get(Type) < Amount` and returns false before mutating.
- YESTests: noise filtering, clock transitions with deterministic time + reset, loot flow (grant, second search nothing, deposit, TrySpend)`SeminoleNoiseTests`, `SeminoleDayClockTests` (Transitions, LargeStep) and `SeminoleLootTests` use `IMPLEMENT_SIMPLE_AUTOMATION_TEST` and cover every listed case through `Advance`/`AdvanceSearch`.
- YESDocs: ARCHITECTURE/ADR describe systems and extension points; explain how to try the sliceARCHITECTURE.md gains an 'Implemented: vertical slice part 1' section with part 2/3 hooks, ADR 0009 is new, and README.md adds a walkthrough of the slice loop.
- PARTIALManual verification list in PR description: compile, test run, input binding, lightingVALIDATION.md §7 covers compile, tests, input, lighting and HUD in detail, but the builder summary (PR description) is truncated, so its own list cannot be confirmed.
- UNCLEARNo regressions: written to compile cleanly, existing tests not modifiedChanges to existing files are additive (GameMode classes, character components, TestEnvironment) and no existing tests are modified; compilation is unverified because CI build was skipped.
- Nothing has been compiled or run: CI 'build' was skipped and the sandbox had no compiler. Acceptance depends entirely on the maintainer's first build and test run. The riskier spots are already listed in VALIDATION.md 7.1: `FSeminoleTestWorld` world creation, the `AHUD` Canvas calls and `Cast<>` to native interfaces.
- Interact is added as a legacy action mapping (DefaultInput.ini, E), not through Enhanced Input. This matches the #22 pattern, and the project has no Enhanced Input assets, so A5 is satisfied as documented. A later migration is still owed.
- `USeminolePhaseRecorder` is a `UCLASS` test double in `Tests/` that is not guarded by `WITH_DEV_AUTOMATION_TESTS`, so it compiles into every build configuration, including shipping. This is minor.
- The hub stockpile location (400,0,50) and the scavenging area are absolute world coordinates, not offsets from the PlayerStart. On a map whose PlayerStart is not at the origin, the stockpile will not be 4 m in front of the player.
- A container search keeps running and still grants its supplies after the player walks away. This is documented as a known simplification. The PR description's manual-verification list could not be confirmed because the builder summary is truncated; VALIDATION.md §7 does cover it.
Accepted by the backers and merged by the maintainer.
Ballots · 1
Automated review cost $0.40, counted as builder cost.
No comments yet.