From f3f0c080a90bf8fb7f5601b7384f7266b5893301 Mon Sep 17 00:00:00 2001 From: yuichipanda Date: Sun, 1 Nov 2026 18:53:40 +0900 Subject: [PATCH] Add turn system, two-action rule with dash, and HUD Co-Authored-By: Claude Sonnet 5.5 --- Source/Tactica/Private/ActionRules.cpp | 35 +++++ Source/Tactica/Private/GridCell.cpp | 17 ++- Source/Tactica/Private/GridManager.cpp | 64 ++++++++- Source/Tactica/Private/TacticaGameMode.cpp | 2 + Source/Tactica/Private/TacticaHUD.cpp | 44 ++++++ .../Private/TacticaPlayerController.cpp | 125 +++++++++++++++--- Source/Tactica/Private/TurnManager.cpp | 27 ++++ Source/Tactica/Private/Unit.cpp | 44 ++++++ Source/Tactica/Public/ActionRules.h | 21 +++ Source/Tactica/Public/GridCell.h | 6 + Source/Tactica/Public/GridManager.h | 50 ++++++- Source/Tactica/Public/TacticaHUD.h | 22 +++ .../Tactica/Public/TacticaPlayerController.h | 10 +- Source/Tactica/Public/TurnManager.h | 42 ++++++ Source/Tactica/Public/TurnTypes.h | 20 +++ Source/Tactica/Public/Unit.h | 40 +++++- Source/Tactica/Public/UnitStats.h | 8 ++ 17 files changed, 546 insertions(+), 31 deletions(-) create mode 100644 Source/Tactica/Private/ActionRules.cpp create mode 100644 Source/Tactica/Private/TacticaHUD.cpp create mode 100644 Source/Tactica/Private/TurnManager.cpp create mode 100644 Source/Tactica/Public/ActionRules.h create mode 100644 Source/Tactica/Public/TacticaHUD.h create mode 100644 Source/Tactica/Public/TurnManager.h create mode 100644 Source/Tactica/Public/TurnTypes.h diff --git a/Source/Tactica/Private/ActionRules.cpp b/Source/Tactica/Private/ActionRules.cpp new file mode 100644 index 0000000..f26222d --- /dev/null +++ b/Source/Tactica/Private/ActionRules.cpp @@ -0,0 +1,35 @@ +#include "ActionRules.h" + +int32 FActionRules::GetCost(EActionType Type, int32 ActionPointsLeft) +{ + switch (Type) + { + case EActionType::Dash: + return ActionPointsLeft; // 残りを全て消費 + case EActionType::Move: + default: + return 1; + } +} + +bool FActionRules::CanPerform(EActionType Type, int32 ActionPointsLeft, int32 ActionsPerTurn) +{ + switch (Type) + { + case EActionType::Dash: + return ActionPointsLeft > 0 && ActionPointsLeft >= ActionsPerTurn; + case EActionType::Move: + default: + return ActionPointsLeft >= 1; + } +} + +int32 FActionRules::GetDashRange(int32 MoveRange, float DashMultiplier) +{ + return FMath::Max(MoveRange, FMath::FloorToInt(MoveRange * DashMultiplier)); +} + +EActionType FActionRules::ChooseMoveAction(int32 PathCost, int32 MoveRange) +{ + return PathCost <= MoveRange ? EActionType::Move : EActionType::Dash; +} diff --git a/Source/Tactica/Private/GridCell.cpp b/Source/Tactica/Private/GridCell.cpp index 5031e12..4eaccc0 100644 --- a/Source/Tactica/Private/GridCell.cpp +++ b/Source/Tactica/Private/GridCell.cpp @@ -54,6 +54,12 @@ void AGridCell::SetInRange(bool bInInRange) RefreshColor(); } +void AGridCell::SetInDashRange(bool bInInDashRange) +{ + bInDashRange = bInInDashRange; + RefreshColor(); +} + void AGridCell::SetHovered(bool bInHovered) { bHovered = bInHovered; @@ -70,9 +76,12 @@ void AGridCell::RefreshColor() { if (DynamicMaterial) { - // 優先順位: 選択 > ホバー > 移動可能 > 通常 - const FLinearColor& Color = bSelected ? SelectedColor - : (bHovered ? HoverColor : (bInRange ? RangeColor : NormalColor)); - DynamicMaterial->SetVectorParameterValue(TEXT("Color"), Color); + // 優先順位: 選択 > ホバー > 移動可能 > ダッシュ可能 > 通常 + const FLinearColor* Color = &NormalColor; + if (bSelected) { Color = &SelectedColor; } + else if (bHovered) { Color = &HoverColor; } + else if (bInRange) { Color = &RangeColor; } + else if (bInDashRange) { Color = &DashRangeColor; } + DynamicMaterial->SetVectorParameterValue(TEXT("Color"), *Color); } } \ No newline at end of file diff --git a/Source/Tactica/Private/GridManager.cpp b/Source/Tactica/Private/GridManager.cpp index 824e355..c945206 100644 --- a/Source/Tactica/Private/GridManager.cpp +++ b/Source/Tactica/Private/GridManager.cpp @@ -21,6 +21,54 @@ void AGridManager::BeginPlay() GridSizeX, GridSizeY, CellSize); GenerateGrid(); + + // ターン開始の通知を購読してから、最初のターンを始める + TurnManager.OnTurnStarted.AddUObject(this, &AGridManager::HandleTurnStarted); + TurnManager.Start(EFaction::Player); +} + +void AGridManager::HandleTurnStarted(int32 TurnNumber, EFaction Faction) +{ + UE_LOG(LogTacticaGrid, Log, TEXT("ターン %d 開始: %s"), TurnNumber, + Faction == EFaction::Player ? TEXT("Player") : TEXT("Enemy")); + + // その陣営の全ユニットの行動ポイントを回復する + for (AUnit* Unit : Units) + { + if (Unit && Unit->Faction == Faction) + { + Unit->RestoreActions(); + } + } + + if (Faction == EFaction::Enemy) + { + RunEnemyTurn(); + } +} + +void AGridManager::RunEnemyTurn() +{ + UE_LOG(LogTacticaGrid, Log, TEXT("敵ターン(仮): %.1f 秒後にPlayerターンへ戻ります"), EnemyTurnDelay); + GetWorldTimerManager().SetTimer(EnemyTurnTimer, this, &AGridManager::FinishEnemyTurn, + FMath::Max(EnemyTurnDelay, 0.01f), false); +} + +void AGridManager::FinishEnemyTurn() +{ + TurnManager.EndTurn(); +} + +bool AGridManager::HasActionsLeft(EFaction Faction) const +{ + for (const AUnit* Unit : Units) + { + if (Unit && Unit->Faction == Faction && Unit->ActionPoints > 0) + { + return true; + } + } + return false; } AGridCell* AGridManager::GetCellAt(FIntPoint Coord) const @@ -105,8 +153,9 @@ void AGridManager::SpawnUnits() NextUnitId = 0; const FVector Origin = GetActorLocation(); - for (const FIntPoint& Coord : InitialUnitCoords) + for (const FInitialUnit& Entry : InitialUnits) { + const FIntPoint Coord = Entry.Coord; const int32 UnitId = NextUnitId; // 先に論理側で配置できるか確認する(範囲外・歩行不可・占有済みなら失敗) @@ -132,6 +181,8 @@ void AGridManager::SpawnUnits() GridState.PlaceUnit(UnitId, Coord); Unit->UnitId = UnitId; Unit->GridCoord = Coord; + Unit->SetFaction(Entry.Faction); + Unit->RestoreActions(); Units.Add(Unit); ++NextUnitId; } @@ -187,13 +238,20 @@ bool AGridManager::TryMoveUnit(AUnit* Unit, FIntPoint Dest, const FGridReachResu return true; } -void AGridManager::SetRangeHighlight(const TArray& Coords, bool bInRange) +void AGridManager::SetRangeHighlight(const TArray& Coords, bool bInRange, bool bDash) { for (const FIntPoint& Coord : Coords) { if (AGridCell* Cell = GetCellAt(Coord)) { - Cell->SetInRange(bInRange); + if (bDash) + { + Cell->SetInDashRange(bInRange); + } + else + { + Cell->SetInRange(bInRange); + } } } } \ No newline at end of file diff --git a/Source/Tactica/Private/TacticaGameMode.cpp b/Source/Tactica/Private/TacticaGameMode.cpp index 2b86830..025a028 100644 --- a/Source/Tactica/Private/TacticaGameMode.cpp +++ b/Source/Tactica/Private/TacticaGameMode.cpp @@ -1,9 +1,11 @@ #include "TacticaGameMode.h" #include "TacticaPlayerController.h" #include "TacticaCameraPawn.h" +#include "TacticaHUD.h" ATacticaGameMode::ATacticaGameMode() { DefaultPawnClass = ATacticaCameraPawn::StaticClass(); PlayerControllerClass = ATacticaPlayerController::StaticClass(); + HUDClass = ATacticaHUD::StaticClass(); } diff --git a/Source/Tactica/Private/TacticaHUD.cpp b/Source/Tactica/Private/TacticaHUD.cpp new file mode 100644 index 0000000..a8a4802 --- /dev/null +++ b/Source/Tactica/Private/TacticaHUD.cpp @@ -0,0 +1,44 @@ +#include "TacticaHUD.h" +#include "GridManager.h" +#include "TacticaPlayerController.h" +#include "Unit.h" +#include "Engine/Canvas.h" +#include "Engine/Engine.h" +#include "Kismet/GameplayStatics.h" + +void ATacticaHUD::DrawHUD() +{ + Super::DrawHUD(); + + if (!GridManager) + { + GridManager = Cast( + UGameplayStatics::GetActorOfClass(this, AGridManager::StaticClass())); + } + if (!GridManager || !GEngine) + { + return; + } + + UFont* Font = GEngine->GetMediumFont(); + const FTurnState& Turn = GridManager->GetTurnManager().GetState(); + const bool bPlayerTurn = Turn.CurrentFaction == EFaction::Player; + + const FString TurnText = FString::Printf(TEXT("Turn %d - %s"), + Turn.TurnNumber, bPlayerTurn ? TEXT("Player") : TEXT("Enemy")); + DrawText(TurnText, bPlayerTurn ? FLinearColor(0.4f, 0.7f, 1.0f) : FLinearColor(1.0f, 0.4f, 0.4f), + 20.0f, 20.0f, Font, 1.5f); + + // 選択中ユニットの残りアクション + if (const ATacticaPlayerController* PC = Cast(GetOwningPlayerController())) + { + if (const AUnit* Unit = PC->GetSelectedUnit()) + { + const FString UnitText = FString::Printf(TEXT("Unit %d Actions: %d / %d"), + Unit->UnitId, Unit->ActionPoints, Unit->Stats.ActionsPerTurn); + DrawText(UnitText, FLinearColor::White, 20.0f, 55.0f, Font, 1.2f); + } + } + + DrawText(TEXT("[Enter] End Turn"), FLinearColor(0.8f, 0.8f, 0.8f), 20.0f, 85.0f, Font, 1.0f); +} diff --git a/Source/Tactica/Private/TacticaPlayerController.cpp b/Source/Tactica/Private/TacticaPlayerController.cpp index 84ff322..7f98d3a 100644 --- a/Source/Tactica/Private/TacticaPlayerController.cpp +++ b/Source/Tactica/Private/TacticaPlayerController.cpp @@ -1,4 +1,5 @@ #include "TacticaPlayerController.h" +#include "ActionRules.h" #include "GridCell.h" #include "GridManager.h" #include "Unit.h" @@ -21,6 +22,8 @@ void ATacticaPlayerController::SetupInputComponent() { InputComponent->BindKey(EKeys::LeftMouseButton, IE_Pressed, this, &ATacticaPlayerController::OnClick); + InputComponent->BindKey(EKeys::Enter, IE_Pressed, this, + &ATacticaPlayerController::OnEndTurnKey); } } @@ -70,11 +73,27 @@ AGridManager* ATacticaPlayerController::GetGridManager() return GridManager; } -void ATacticaPlayerController::OnClick() +bool ATacticaPlayerController::CanAcceptInput() { if (State == EState::Moving) { UE_LOG(LogTacticaInput, Log, TEXT("移動中のため入力を無視しました")); + return false; + } + + const AGridManager* Grid = GetGridManager(); + if (!Grid || Grid->GetTurnManager().GetCurrentFaction() != EFaction::Player) + { + UE_LOG(LogTacticaInput, Log, TEXT("Playerのターンではないため入力を無視しました")); + return false; + } + return true; +} + +void ATacticaPlayerController::OnClick() +{ + if (!CanAcceptInput()) + { return; } @@ -90,13 +109,19 @@ void ATacticaPlayerController::OnClick() if (State == EState::Idle) { - if (AUnit* Unit = Grid->GetUnitAt(Coord)) + AUnit* Unit = Grid->GetUnitAt(Coord); + if (!Unit) { - SelectUnit(Unit, Cell); + UE_LOG(LogTacticaInput, Log, TEXT("(%d, %d) にユニットはいません"), Coord.X, Coord.Y); + } + else if (Unit->Faction != EFaction::Player) + { + // 敵ユニットは操作できない + UE_LOG(LogTacticaInput, Log, TEXT("敵ユニット %d を選択しました(操作はできません)"), Unit->UnitId); } else { - UE_LOG(LogTacticaInput, Log, TEXT("(%d, %d) にユニットはいません"), Coord.X, Coord.Y); + SelectUnit(Unit, Cell); } return; } @@ -104,19 +129,45 @@ void ATacticaPlayerController::OnClick() // UnitSelected: 移動可能なマス(起点を除く)だけ受け付ける if (SelectedUnit && Coord != SelectedUnit->GridCoord && ReachResult.IsReachable(Coord)) { - if (Grid->TryMoveUnit(SelectedUnit, Coord, ReachResult)) + const int32 PathCost = ReachResult.Cost[Coord]; + const EActionType Action = FActionRules::ChooseMoveAction(PathCost, SelectedUnit->Stats.MoveRange); + + if (FActionRules::CanPerform(Action, SelectedUnit->ActionPoints, SelectedUnit->Stats.ActionsPerTurn)) { - State = EState::Moving; - SelectedUnit->OnMoveFinished.AddUObject(this, &ATacticaPlayerController::OnUnitMoveFinished); - UE_LOG(LogTacticaInput, Log, TEXT("ユニット %d を (%d, %d) へ移動開始"), - SelectedUnit->UnitId, Coord.X, Coord.Y); - return; + const int32 Cost = FActionRules::GetCost(Action, SelectedUnit->ActionPoints); + if (Grid->TryMoveUnit(SelectedUnit, Coord, ReachResult)) + { + // 行動ポイントは移動の開始時に消費する + SelectedUnit->SpendActions(Cost); + State = EState::Moving; + SelectedUnit->OnMoveFinished.AddUObject(this, &ATacticaPlayerController::OnUnitMoveFinished); + UE_LOG(LogTacticaInput, Log, TEXT("ユニット %d を (%d, %d) へ移動開始 (%s, 消費 %d, 残り %d)"), + SelectedUnit->UnitId, Coord.X, Coord.Y, + Action == EActionType::Dash ? TEXT("Dash") : TEXT("Move"), + Cost, SelectedUnit->ActionPoints); + return; + } } } UE_LOG(LogTacticaInput, Log, TEXT("(%d, %d) には移動できません"), Coord.X, Coord.Y); } +void ATacticaPlayerController::OnEndTurnKey() +{ + if (!CanAcceptInput()) + { + return; + } + + ClearSelection(); + if (AGridManager* Grid = GetGridManager()) + { + UE_LOG(LogTacticaInput, Log, TEXT("Enterキーでターンを終了します")); + Grid->GetTurnManager().EndTurn(); + } +} + void ATacticaPlayerController::SelectUnit(AUnit* Unit, AGridCell* Cell) { AGridManager* Grid = GetGridManager(); @@ -128,31 +179,60 @@ void ATacticaPlayerController::SelectUnit(AUnit* Unit, AGridCell* Cell) SelectedUnit = Unit; SelectedCell = Cell; SelectedCell->SetSelected(true); - - ReachResult = FGridPathfinder::FindReachable(Grid->GetGridState(), Unit->GridCoord, Unit->Stats.MoveRange); + State = EState::UnitSelected; RangeCoords.Reset(); + DashRangeCoords.Reset(); + ReachResult = FGridReachResult(); + + // アクションが残っていなければ、選択はできるが範囲は表示しない + if (Unit->ActionPoints <= 0) + { + UE_LOG(LogTacticaInput, Log, TEXT("ユニット %d を選択 (アクションが残っていないため移動できません)"), Unit->UnitId); + return; + } + + // ダッシュできる(アクションが全部残っている)ときだけ、探索範囲を広げる + const int32 MoveRange = Unit->Stats.MoveRange; + const bool bCanDash = FActionRules::CanPerform( + EActionType::Dash, Unit->ActionPoints, Unit->Stats.ActionsPerTurn); + const int32 MaxCost = bCanDash + ? FActionRules::GetDashRange(MoveRange, Unit->Stats.DashMultiplier) + : MoveRange; + + ReachResult = FGridPathfinder::FindReachable(Grid->GetGridState(), Unit->GridCoord, MaxCost); + for (const TPair& Pair : ReachResult.Cost) { - if (Pair.Key != Unit->GridCoord) + if (Pair.Key == Unit->GridCoord) + { + continue; + } + if (FActionRules::ChooseMoveAction(Pair.Value, MoveRange) == EActionType::Move) { RangeCoords.Add(Pair.Key); } + else + { + DashRangeCoords.Add(Pair.Key); + } } - Grid->SetRangeHighlight(RangeCoords, true); + Grid->SetRangeHighlight(RangeCoords, true, false); + Grid->SetRangeHighlight(DashRangeCoords, true, true); - State = EState::UnitSelected; - UE_LOG(LogTacticaInput, Log, TEXT("ユニット %d を選択 (移動力 %d, 移動可能 %d マス)"), - Unit->UnitId, Unit->Stats.MoveRange, RangeCoords.Num()); + UE_LOG(LogTacticaInput, Log, TEXT("ユニット %d を選択 (残りアクション %d, 通常 %d マス, ダッシュ %d マス)"), + Unit->UnitId, Unit->ActionPoints, RangeCoords.Num(), DashRangeCoords.Num()); } void ATacticaPlayerController::ClearSelection() { if (AGridManager* Grid = GetGridManager()) { - Grid->SetRangeHighlight(RangeCoords, false); + Grid->SetRangeHighlight(RangeCoords, false, false); + Grid->SetRangeHighlight(DashRangeCoords, false, true); } RangeCoords.Reset(); + DashRangeCoords.Reset(); ReachResult = FGridReachResult(); if (SelectedCell) @@ -172,4 +252,13 @@ void ATacticaPlayerController::OnUnitMoveFinished() UE_LOG(LogTacticaInput, Log, TEXT("ユニット %d の移動完了"), SelectedUnit->UnitId); } ClearSelection(); + + // 移動が完全に終わった後で、自動終了を判定する(移動中は判定しない) + AGridManager* Grid = GetGridManager(); + if (Grid && Grid->GetTurnManager().GetCurrentFaction() == EFaction::Player + && !Grid->HasActionsLeft(EFaction::Player)) + { + UE_LOG(LogTacticaInput, Log, TEXT("Playerの全ユニットのアクションが0になったためターンを終了します")); + Grid->GetTurnManager().EndTurn(); + } } diff --git a/Source/Tactica/Private/TurnManager.cpp b/Source/Tactica/Private/TurnManager.cpp new file mode 100644 index 0000000..f3576ed --- /dev/null +++ b/Source/Tactica/Private/TurnManager.cpp @@ -0,0 +1,27 @@ +#include "TurnManager.h" + +void FTurnManager::Start(EFaction FirstFaction) +{ + State.TurnNumber = 1; + State.CurrentFaction = FirstFaction; + OnTurnStarted.Broadcast(State.TurnNumber, State.CurrentFaction); +} + +void FTurnManager::EndTurn() +{ + const EFaction Ended = State.CurrentFaction; + OnTurnEnded.Broadcast(Ended); + + // Player → Enemy → Player ...。Playerに戻ったらターン数を進める + if (Ended == EFaction::Player) + { + State.CurrentFaction = EFaction::Enemy; + } + else + { + State.CurrentFaction = EFaction::Player; + ++State.TurnNumber; + } + + OnTurnStarted.Broadcast(State.TurnNumber, State.CurrentFaction); +} diff --git a/Source/Tactica/Private/Unit.cpp b/Source/Tactica/Private/Unit.cpp index c46f270..a507025 100644 --- a/Source/Tactica/Private/Unit.cpp +++ b/Source/Tactica/Private/Unit.cpp @@ -1,6 +1,7 @@ #include "Unit.h" #include "Components/StaticMeshComponent.h" #include "Engine/StaticMesh.h" +#include "Materials/MaterialInstanceDynamic.h" #include "UObject/ConstructorHelpers.h" AUnit::AUnit() @@ -26,10 +27,53 @@ AUnit::AUnit() // 底面がActorの原点(マスの上面)に来るよう、高さの半分(50cm)上げる MeshComponent->SetRelativeLocation(FVector(0.0f, 0.0f, 50.0f)); + // 色を変えられる標準マテリアル(パラメータ"Color") + static ConstructorHelpers::FObjectFinder BaseMaterial( + TEXT("/Engine/BasicShapes/BasicShapeMaterial.BasicShapeMaterial")); + if (BaseMaterial.Succeeded()) + { + MeshComponent->SetMaterial(0, BaseMaterial.Object); + } + // マスのクリック判定をユニットが邪魔しないよう、当たり判定は無効にする MeshComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision); } +void AUnit::BeginPlay() +{ + Super::BeginPlay(); + + DynamicMaterial = MeshComponent->CreateDynamicMaterialInstance(0); + RefreshColor(); +} + +void AUnit::SetFaction(EFaction InFaction) +{ + Faction = InFaction; + RefreshColor(); +} + +void AUnit::RefreshColor() +{ + if (DynamicMaterial) + { + DynamicMaterial->SetVectorParameterValue(TEXT("Color"), + Faction == EFaction::Player ? PlayerColor : EnemyColor); + } +} + +void AUnit::SpendActions(int32 Amount) +{ + ActionPoints = FMath::Max(0, ActionPoints - Amount); + OnActionPointsChanged.Broadcast(); +} + +void AUnit::RestoreActions() +{ + ActionPoints = Stats.ActionsPerTurn; + OnActionPointsChanged.Broadcast(); +} + void AUnit::MoveAlongPath(const TArray& InWaypoints) { Waypoints = InWaypoints; diff --git a/Source/Tactica/Public/ActionRules.h b/Source/Tactica/Public/ActionRules.h new file mode 100644 index 0000000..1b7409c --- /dev/null +++ b/Source/Tactica/Public/ActionRules.h @@ -0,0 +1,21 @@ +#pragma once + +#include "CoreMinimal.h" +#include "TurnTypes.h" + +// アクションのコストや可否のルール。UObject/Actorに依存しない純粋なロジック +// 数値(行動ポイント数、ダッシュ倍率)は呼び出し側がFUnitStats等から渡す +struct TACTICA_API FActionRules +{ + // 実行時に消費する行動ポイント。Dashは残り全部 + static int32 GetCost(EActionType Type, int32 ActionPointsLeft); + + // 実行できるか。DashはActionsPerTurn分(=全部)残っているときだけ + static bool CanPerform(EActionType Type, int32 ActionPointsLeft, int32 ActionsPerTurn); + + // ダッシュで移動できる最大マス数 + static int32 GetDashRange(int32 MoveRange, float DashMultiplier); + + // 移動コスト(マス数)から、必要なアクション種別を決める + static EActionType ChooseMoveAction(int32 PathCost, int32 MoveRange); +}; diff --git a/Source/Tactica/Public/GridCell.h b/Source/Tactica/Public/GridCell.h index 889ad6e..622161f 100644 --- a/Source/Tactica/Public/GridCell.h +++ b/Source/Tactica/Public/GridCell.h @@ -47,7 +47,12 @@ public: UPROPERTY(EditAnywhere, Category = "Grid|Highlight") FLinearColor RangeColor = FLinearColor(0.2f, 0.8f, 0.3f); + // ダッシュでのみ移動できる範囲の色 + UPROPERTY(EditAnywhere, Category = "Grid|Highlight") + FLinearColor DashRangeColor = FLinearColor(1.0f, 0.45f, 0.1f); + void SetInRange(bool bInInRange); + void SetInDashRange(bool bInInDashRange); void SetHovered(bool bInHovered); void SetSelected(bool bInSelected); @@ -59,6 +64,7 @@ private: void RefreshColor(); bool bInRange = false; + bool bInDashRange = false; bool bHovered = false; bool bSelected = false; diff --git a/Source/Tactica/Public/GridManager.h b/Source/Tactica/Public/GridManager.h index 625aacd..f7eb648 100644 --- a/Source/Tactica/Public/GridManager.h +++ b/Source/Tactica/Public/GridManager.h @@ -3,12 +3,30 @@ #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "GridState.h" +#include "TurnManager.h" +#include "TurnTypes.h" #include "GridManager.generated.h" class AGridCell; class AUnit; struct FGridReachResult; +// ユニットの初期配置(座標と陣営) +USTRUCT() +struct FInitialUnit +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, Category = "Units") + FIntPoint Coord = FIntPoint::ZeroValue; + + UPROPERTY(EditAnywhere, Category = "Units") + EFaction Faction = EFaction::Player; + + FInitialUnit() {} + FInitialUnit(FIntPoint InCoord, EFaction InFaction) : Coord(InCoord), Faction(InFaction) {} +}; + UCLASS() class TACTICA_API AGridManager : public AActor { @@ -45,9 +63,17 @@ public: UPROPERTY(EditAnywhere, Category = "Units") TSubclassOf UnitClass; - // ユニットの初期配置座標(1要素につき1体) + // ユニットの初期配置(1要素につき1体) UPROPERTY(EditAnywhere, Category = "Units") - TArray InitialUnitCoords = { FIntPoint(0, 0), FIntPoint(2, 2) }; + TArray InitialUnits = { + FInitialUnit(FIntPoint(0, 0), EFaction::Player), + FInitialUnit(FIntPoint(1, 0), EFaction::Player), + FInitialUnit(FIntPoint(2, 2), EFaction::Enemy), + FInitialUnit(FIntPoint(1, 2), EFaction::Enemy) }; + + // 敵ターンの待ち時間(秒)。仮実装 + UPROPERTY(EditAnywhere, Category = "Turn") + float EnemyTurnDelay = 1.0f; // 生成したユニットの一覧 UPROPERTY(VisibleAnywhere, Category = "Units") @@ -63,8 +89,15 @@ public: // Reach は、ユニットの現在位置から求めた FindReachable の結果 bool TryMoveUnit(AUnit* Unit, FIntPoint Dest, const FGridReachResult& Reach); - // 指定したマスの「移動可能」表示をON/OFFする - void SetRangeHighlight(const TArray& Coords, bool bInRange); + // 指定したマスの「移動可能」表示をON/OFFする(bDash=trueならダッシュ範囲の色) + void SetRangeHighlight(const TArray& Coords, bool bInRange, bool bDash = false); + + // ターン管理(通知を購読するときはここから取る) + FTurnManager& GetTurnManager() { return TurnManager; } + const FTurnManager& GetTurnManager() const { return TurnManager; } + + // 指定した陣営に、行動ポイントが残っているユニットがいるか + bool HasActionsLeft(EFaction Faction) const; // 座標のマスを返す(範囲外はnullptr) AGridCell* GetCellAt(FIntPoint Coord) const; @@ -72,10 +105,19 @@ public: // グリッド全体の中心(ワールド) FVector GetGridCenter() const; +protected: + // 敵ターンの拡張点。将来の敵AIはここでユニットごとの行動を実行し、 + // 終わったら TurnManager.EndTurn() を呼ぶ。今は待ってから終了するだけ + virtual void RunEnemyTurn(); + private: void GenerateGrid(); void SpawnUnits(); + void HandleTurnStarted(int32 TurnNumber, EFaction Faction); + void FinishEnemyTurn(); FGridState GridState; + FTurnManager TurnManager; + FTimerHandle EnemyTurnTimer; int32 NextUnitId = 0; }; \ No newline at end of file diff --git a/Source/Tactica/Public/TacticaHUD.h b/Source/Tactica/Public/TacticaHUD.h new file mode 100644 index 0000000..c3b8b9c --- /dev/null +++ b/Source/Tactica/Public/TacticaHUD.h @@ -0,0 +1,22 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/HUD.h" +#include "TacticaHUD.generated.h" + +class AGridManager; + +// ターン数・陣営・選択中ユニットの残りアクションを表示するHUD。 +// 状態は持たず、毎フレーム TurnManager / Unit から読んで描くだけ +UCLASS() +class TACTICA_API ATacticaHUD : public AHUD +{ + GENERATED_BODY() + +public: + virtual void DrawHUD() override; + +private: + UPROPERTY() + TObjectPtr GridManager; +}; diff --git a/Source/Tactica/Public/TacticaPlayerController.h b/Source/Tactica/Public/TacticaPlayerController.h index f75921c..2f34054 100644 --- a/Source/Tactica/Public/TacticaPlayerController.h +++ b/Source/Tactica/Public/TacticaPlayerController.h @@ -17,6 +17,9 @@ class TACTICA_API ATacticaPlayerController : public APlayerController public: ATacticaPlayerController(); + // 選択中のユニット(HUD表示用)。なければnullptr + AUnit* GetSelectedUnit() const { return SelectedUnit; } + protected: virtual void SetupInputComponent() override; virtual void PlayerTick(float DeltaTime) override; @@ -35,7 +38,11 @@ private: AGridManager* GetGridManager(); + // Playerのターンで、入力を受け付けてよいか(移動中は不可) + bool CanAcceptInput(); + void OnClick(); + void OnEndTurnKey(); void SelectUnit(AUnit* Unit, AGridCell* Cell); void ClearSelection(); void OnUnitMoveFinished(); @@ -56,7 +63,8 @@ private: UPROPERTY() TObjectPtr GridManager; - // 選択中ユニットの移動可能範囲(起点を除く)と探索結果 + // 選択中ユニットの移動可能範囲(起点を除く)。通常移動とダッシュで分ける TArray RangeCoords; + TArray DashRangeCoords; FGridReachResult ReachResult; }; diff --git a/Source/Tactica/Public/TurnManager.h b/Source/Tactica/Public/TurnManager.h new file mode 100644 index 0000000..d07fa44 --- /dev/null +++ b/Source/Tactica/Public/TurnManager.h @@ -0,0 +1,42 @@ +#pragma once + +#include "CoreMinimal.h" +#include "TurnTypes.h" + +// ターンの状態。値だけで構成されるので、そのままセーブできる +struct FTurnState +{ + // ターン数(Playerのターンに戻るたびに+1。1から) + int32 TurnNumber = 1; + + EFaction CurrentFaction = EFaction::Player; +}; + +// ターンの進行。UObject/Actorに依存しない純粋なクラス。 +// 状態の変化はDelegateで通知し、見た目や行動ポイントの回復は購読側が行う +class TACTICA_API FTurnManager +{ +public: + DECLARE_MULTICAST_DELEGATE_TwoParams(FOnTurnStarted, int32 /*TurnNumber*/, EFaction /*Faction*/); + DECLARE_MULTICAST_DELEGATE_OneParam(FOnTurnEnded, EFaction /*Faction*/); + + // 通知(購読は Actor 層が行う) + FOnTurnStarted OnTurnStarted; + FOnTurnEnded OnTurnEnded; + + // 最初のターンを開始する(ターン数は1) + void Start(EFaction FirstFaction); + + // 現在のターンを終了して、次の陣営のターンを開始する + void EndTurn(); + + // セーブデータからの復元。通知は発火しない(二重回復を防ぐ) + void Restore(const FTurnState& InState) { State = InState; } + + const FTurnState& GetState() const { return State; } + int32 GetTurnNumber() const { return State.TurnNumber; } + EFaction GetCurrentFaction() const { return State.CurrentFaction; } + +private: + FTurnState State; +}; diff --git a/Source/Tactica/Public/TurnTypes.h b/Source/Tactica/Public/TurnTypes.h new file mode 100644 index 0000000..209768c --- /dev/null +++ b/Source/Tactica/Public/TurnTypes.h @@ -0,0 +1,20 @@ +#pragma once + +#include "CoreMinimal.h" +#include "TurnTypes.generated.h" + +// 陣営 +UENUM(BlueprintType) +enum class EFaction : uint8 +{ + Player, + Enemy +}; + +// アクションの種類。攻撃などを足すときはここに追加する +UENUM(BlueprintType) +enum class EActionType : uint8 +{ + Move, + Dash +}; diff --git a/Source/Tactica/Public/Unit.h b/Source/Tactica/Public/Unit.h index bcb9b11..afdb0c0 100644 --- a/Source/Tactica/Public/Unit.h +++ b/Source/Tactica/Public/Unit.h @@ -2,12 +2,15 @@ #include "CoreMinimal.h" #include "GameFramework/Actor.h" +#include "TurnTypes.h" #include "UnitStats.h" #include "Unit.generated.h" class UStaticMeshComponent; +class UMaterialInstanceDynamic; -// ユニット(兵士)。見た目と移動の補間だけを担当し、占有などの論理はAGridManager/FGridStateが持つ +// ユニット(兵士)。見た目と移動の補間、行動ポイントを持つ。 +// 占有などの論理はAGridManager/FGridStateが持つ UCLASS() class TACTICA_API AUnit : public AActor { @@ -29,10 +32,34 @@ public: UPROPERTY(EditAnywhere, Category = "Unit") FUnitStats Stats; + // 陣営 + UPROPERTY(VisibleAnywhere, Category = "Unit") + EFaction Faction = EFaction::Player; + + // 残りの行動ポイント + UPROPERTY(VisibleAnywhere, Category = "Unit") + int32 ActionPoints = 0; + + // 陣営ごとの色 + UPROPERTY(EditAnywhere, Category = "Unit") + FLinearColor PlayerColor = FLinearColor(0.2f, 0.5f, 1.0f); + + UPROPERTY(EditAnywhere, Category = "Unit") + FLinearColor EnemyColor = FLinearColor(1.0f, 0.2f, 0.2f); + // 移動速度(cm/秒) UPROPERTY(EditAnywhere, Category = "Unit") float MoveSpeed = 400.0f; + // 陣営を設定して色に反映する + void SetFaction(EFaction InFaction); + + // 行動ポイントを消費する(0未満にはならない) + void SpendActions(int32 Amount); + + // 行動ポイントを1ターン分に回復する + void RestoreActions(); + // ウェイポイントを順に通って移動する void MoveAlongPath(const TArray& InWaypoints); @@ -41,10 +68,21 @@ public: // 移動完了時に呼ばれる FSimpleMulticastDelegate OnMoveFinished; + // 行動ポイントが変化したときに呼ばれる + FSimpleMulticastDelegate OnActionPointsChanged; + +protected: + virtual void BeginPlay() override; + private: + void RefreshColor(); + UPROPERTY(VisibleAnywhere, Category = "Unit") TObjectPtr MeshComponent; + UPROPERTY() + TObjectPtr DynamicMaterial; + TArray Waypoints; int32 WaypointIndex = 0; }; diff --git a/Source/Tactica/Public/UnitStats.h b/Source/Tactica/Public/UnitStats.h index 9fa240b..0b75138 100644 --- a/Source/Tactica/Public/UnitStats.h +++ b/Source/Tactica/Public/UnitStats.h @@ -13,4 +13,12 @@ struct FUnitStats : public FTableRowBase // 移動力(マス数) UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Unit") int32 MoveRange = 4; + + // 1ターンの行動ポイント + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Unit") + int32 ActionsPerTurn = 2; + + // ダッシュ時の移動範囲の倍率(移動力に掛ける) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Unit") + float DashMultiplier = 2.0f; };