Project02/ProjectFish/Docs/TutorialSystemDesign_Simple.md

14 KiB
Raw Permalink Blame History

新手引导系统设计(简化版)

一、核心思路

一句话概括:用一个 TutorialManager 管理引导流程,通过 Widget 的 Tag 控制显示/隐藏/高亮。


二、只需要3个东西

1. TutorialManager一个管理类

// TutorialManager.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "TutorialManager.generated.h"

// 简单的步骤配置
USTRUCT(BlueprintType)
struct FTutorialStep
{
    GENERATED_BODY()

    // 步骤ID
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    int32 StepID = 0;

    // 可见的Widget标签其他都隐藏
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TArray<FName> VisibleWidgetTags;

    // 高亮的Widget标签
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TArray<FName> HighlightWidgetTags;

    // 提示文本(可选)
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FText HintText;

    // 对话资产(可选)
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    class UDialogueAsset* DialogueAsset;
};

UCLASS()
class PROJECTFISH_API UTutorialManager : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    // 开始引导
    UFUNCTION(BlueprintCallable, Category = "Tutorial")
    void StartTutorial();

    // 进入下一步
    UFUNCTION(BlueprintCallable, Category = "Tutorial")
    void NextStep();

    // 完成引导
    UFUNCTION(BlueprintCallable, Category = "Tutorial")
    void FinishTutorial();

    // 是否在引导中
    UFUNCTION(BlueprintPure, Category = "Tutorial")
    bool IsInTutorial() const { return bInTutorial; }

    // 获取当前步骤
    UFUNCTION(BlueprintPure, Category = "Tutorial")
    int32 GetCurrentStep() const { return CurrentStepIndex; }

    // 检查Widget是否应该显示
    UFUNCTION(BlueprintPure, Category = "Tutorial")
    bool ShouldShowWidget(FName WidgetTag) const;

    // 检查Widget是否应该高亮
    UFUNCTION(BlueprintPure, Category = "Tutorial")
    bool ShouldHighlightWidget(FName WidgetTag) const;

    // 隐藏遮罩层
    UFUNCTION(BlueprintCallable, Category = "Tutorial")
    void ShowMask(bool bShow);

protected:
    // 引导步骤配置(在编辑器中配置)
    UPROPERTY(EditDefaultsOnly, Category = "Tutorial")
    TArray<FTutorialStep> TutorialSteps;

    UPROPERTY()
    bool bInTutorial = false;

    UPROPERTY()
    int32 CurrentStepIndex = -1;

private:
    void ApplyCurrentStep();
    void BroadcastStepChanged();
};
// TutorialManager.cpp
#include "TutorialManager.h"

void UTutorialManager::StartTutorial()
{
    bInTutorial = true;
    CurrentStepIndex = 0;
    ApplyCurrentStep();
}

void UTutorialManager::NextStep()
{
    if (!bInTutorial) return;

    CurrentStepIndex++;
    if (CurrentStepIndex >= TutorialSteps.Num())
    {
        FinishTutorial();
        return;
    }

    ApplyCurrentStep();
}

void UTutorialManager::FinishTutorial()
{
    bInTutorial = false;
    CurrentStepIndex = -1;

    // 保存到存档
    // GameInfoManager->SetTutorialCompleted(true);

    ShowMask(false);
}

bool UTutorialManager::ShouldShowWidget(FName WidgetTag) const
{
    if (!bInTutorial) return true; // 正常模式全显示

    if (!TutorialSteps.IsValidIndex(CurrentStepIndex))
        return true;

    const FTutorialStep& Step = TutorialSteps[CurrentStepIndex];

    // 如果没有配置,默认显示
    if (Step.VisibleWidgetTags.Num() == 0)
        return true;

    // 白名单模式:只显示列表中的
    return Step.VisibleWidgetTags.Contains(WidgetTag);
}

bool UTutorialManager::ShouldHighlightWidget(FName WidgetTag) const
{
    if (!bInTutorial) return false;

    if (!TutorialSteps.IsValidIndex(CurrentStepIndex))
        return false;

    const FTutorialStep& Step = TutorialSteps[CurrentStepIndex];
    return Step.HighlightWidgetTags.Contains(WidgetTag);
}

void UTutorialManager::ApplyCurrentStep()
{
    BroadcastStepChanged();

    if (!TutorialSteps.IsValidIndex(CurrentStepIndex))
        return;

    const FTutorialStep& Step = TutorialSteps[CurrentStepIndex];

    // 如果有对话,播放对话
    if (Step.DialogueAsset)
    {
        // 调用对话系统播放
        // DialogueSystem->PlayDialogue(Step.DialogueAsset);
    }

    // 显示遮罩
    ShowMask(true);
}

void UTutorialManager::BroadcastStepChanged()
{
    // 通知所有UI刷新
    // 可以用事件委托或直接遍历所有Widget
}

void UTutorialManager::ShowMask(bool bShow)
{
    // 创建/显示/隐藏遮罩Widget
    // 遮罩Widget是一个全屏的半透明黑色背景
}

2. Widget上设置Tag

在UMG编辑器中给每个需要控制的Widget设置Tag

HomeUI:
  ├─ Btn_Sail (Tag: "Btn_Sail")
  ├─ Btn_Market (Tag: "Btn_Market")
  ├─ Btn_Shop (Tag: "Btn_Shop")
  └─ Btn_Backpack (Tag: "Btn_Backpack")

3. Widget蓝图中检查是否显示

在每个Widget的蓝图中添加简单逻辑

Event Construct:
  ├─ Get TutorialManager
  ├─ Bind to OnStepChanged Event
  └─ Call UpdateVisibility()

UpdateVisibility():
  ├─ For each child widget:
  │   ├─ Get widget tag
  │   ├─ TutorialManager->ShouldShowWidget(tag)
  │   └─ Set Visibility (Visible / Collapsed)
  └─ Update Highlight state

三、15步引导配置简单表格

TutorialManagerTutorialSteps 数组中配置:

步骤 VisibleWidgetTags HighlightWidgetTags 说明
0 [] [] 初始对话DialogueAsset配置
1 [Btn_Sail] [Btn_Sail] 只显示出航按钮并高亮
2 [MapNode_0] [MapNode_0] 地图只显示一个节点
3 [] [] 进入Loading
4 [MovementHint] [] 显示移动提示
5 [FishingHint] [] 显示钓鱼提示
6 [BattleUI] [] 战斗界面(隐藏背包/返航)
... ... ... ...

配置方式:在编辑器的 Project Settings -> Tutorial Manager 中直接填表格。


四、UI改造最简单的方式

方式1蓝图中直接判断推荐

在每个Widget的蓝图中

Event Construct:
  ├─ GetGameInstance
  ├─ GetSubsystem(TutorialManager)
  ├─ Bind Event: OnStepChanged -> UpdateVisibility
  └─ Call UpdateVisibility

Function UpdateVisibility:
  ├─ If TutorialManager->IsInTutorial():
  │   ├─ Set Btn_Sail Visibility: ShouldShowWidget("Btn_Sail")
  │   ├─ Set Btn_Market Visibility: ShouldShowWidget("Btn_Market")
  │   └─ ...
  └─ Else: Show All

方式2C++中统一处理

创建一个简单的基类:

// TutorialWidget.h
#pragma once

#include "Blueprint/UserWidget.h"
#include "TutorialWidget.generated.h"

UCLASS()
class UTutorialWidget : public UUserWidget
{
    GENERATED_BODY()

public:
    virtual void NativeConstruct() override;

protected:
    // 在子类中填写Widget名 -> Tag的映射
    UPROPERTY(EditDefaultsOnly, Category = "Tutorial")
    TMap<FName, UWidget*> WidgetTagMap;

    UFUNCTION()
    void UpdateVisibility();

private:
    UPROPERTY()
    class UTutorialManager* TutorialManager;
};
// TutorialWidget.cpp
#include "TutorialWidget.h"
#include "TutorialManager.h"

void UTutorialWidget::NativeConstruct()
{
    Super::NativeConstruct();

    TutorialManager = GetGameInstance()->GetSubsystem<UTutorialManager>();
    if (TutorialManager)
    {
        // 绑定步骤改变事件
        // TutorialManager->OnStepChanged.AddDynamic(this, &UTutorialWidget::UpdateVisibility);
    }

    UpdateVisibility();
}

void UTutorialWidget::UpdateVisibility()
{
    if (!TutorialManager || !TutorialManager->IsInTutorial())
    {
        // 正常模式:全部显示
        for (auto& Pair : WidgetTagMap)
        {
            if (Pair.Value)
                Pair.Value->SetVisibility(ESlateVisibility::Visible);
        }
        return;
    }

    // 引导模式根据Tag判断
    for (auto& Pair : WidgetTagMap)
    {
        if (!Pair.Value) continue;

        bool bShouldShow = TutorialManager->ShouldShowWidget(Pair.Key);
        Pair.Value->SetVisibility(bShouldShow ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);

        // 高亮效果
        bool bShouldHighlight = TutorialManager->ShouldHighlightWidget(Pair.Key);
        if (bShouldHighlight)
        {
            // 添加高亮动画或材质效果
            // PlayAnimation(HighlightAnim);
        }
    }
}

然后让 HomeUI 继承这个基类,在构造函数中填写映射:

// HomeUIWidget.cpp
void UHomeUIWidget::NativeConstruct()
{
    Super::NativeConstruct();

    // 填写映射
    WidgetTagMap.Add(TEXT("Btn_Sail"), Btn_Sail);
    WidgetTagMap.Add(TEXT("Btn_Market"), Btn_Market);
    WidgetTagMap.Add(TEXT("Btn_Shop"), Btn_Shop);
    WidgetTagMap.Add(TEXT("Btn_Backpack"), Btn_Backpack);
}

五、特殊情况处理

1. 战斗特殊配置

不需要新的配置类,直接在战斗开始时检查:

// AProjectFishGameMode.cpp
void AProjectFishGameMode::StartBattle()
{
    UTutorialManager* Tutorial = GetGameInstance()->GetSubsystem<UTutorialManager>();

    if (Tutorial && Tutorial->IsInTutorial())
    {
        int32 Step = Tutorial->GetCurrentStep();

        // 第一场战斗步骤6
        if (Step == 6)
        {
            // 使用特定的鱼配置
            CurrentFish = LoadObject<UFishInfoConfigAsset>(..., TEXT("Tutorial_Fish_01"));

            // 强制掉落
            bForceDropItem = true;
            ForcedDropItems.Add(TEXT("Item_Bass"));
        }
        // 第二场战斗步骤9
        else if (Step == 9)
        {
            CurrentFish = LoadObject<UFishInfoConfigAsset>(..., TEXT("Tutorial_Fish_02"));
            ForcedDropItems.Add(TEXT("Item_Bass"));
            ForcedDropItems.Add(TEXT("Item_MagicBook"));
        }
    }
}

2. 地图单节点

// FishingMapSubSystem.cpp
void UFishingMapSubSystem::GenerateMap()
{
    UTutorialManager* Tutorial = GetGameInstance()->GetSubsystem<UTutorialManager>();

    if (Tutorial && Tutorial->IsInTutorial() && Tutorial->GetCurrentStep() == 2)
    {
        // 只生成1个节点
        AllNodes.Empty();
        UFishingMapNode* Node = NewObject<UFishingMapNode>();
        Node->NodeType = EMapNodeType::Battle;
        AllNodes.Add(Node);
        return;
    }

    // 正常生成
    GenerateMapWithConfig(DefaultMapConfig);
}

3. 强制拾取奖励

// RewardWidget.cpp
void URewardWidget::NativeConstruct()
{
    Super::NativeConstruct();

    UTutorialManager* Tutorial = GetGameInstance()->GetSubsystem<UTutorialManager>();

    if (Tutorial && Tutorial->IsInTutorial())
    {
        int32 Step = Tutorial->GetCurrentStep();
        if (Step == 7 || Step == 10) // 两场战斗后
        {
            // 隐藏关闭按钮,直到拾取完毕
            Btn_Close->SetVisibility(ESlateVisibility::Collapsed);
            bMustCollectAll = true;
        }
    }
}

void URewardWidget::OnAllItemsCollected()
{
    if (bMustCollectAll)
    {
        Btn_Close->SetVisibility(ESlateVisibility::Visible);
        // 显示提示:"点击关闭继续"
    }
}

六、遮罩和高亮效果(简单实现)

遮罩WidgetWBP_TutorialMask

创建一个简单的UMG

Canvas Panel (全屏)
└─ Image (黑色透明度0.7)

在 TutorialManager 中显示/隐藏它。

高亮效果3种简单方案

方案1边框发光

// 给按钮添加一个Border设置发光颜色
Btn_Sail->SetBorderBrush(HighlightBrush);

方案2缩放动画

创建UMG动画
  0.0s -> Scale 1.0
  0.5s -> Scale 1.1
  1.0s -> Scale 1.0
循环播放

方案3后处理材质 给高亮Widget添加后处理效果最炫酷但稍复杂


七、完整流程示例

配置TutorialSteps在编辑器中

TutorialSteps[0]:
  StepID: 0
  DialogueAsset: DA_Tutorial_Intro
  VisibleWidgetTags: []
  HintText: ""

TutorialSteps[1]:
  StepID: 1
  VisibleWidgetTags: [Btn_Sail]
  HighlightWidgetTags: [Btn_Sail]
  HintText: "点击【出航】按钮"

TutorialSteps[2]:
  StepID: 2
  VisibleWidgetTags: [MapNode_0]
  HighlightWidgetTags: [MapNode_0]
  HintText: "选择一个地点"

// ... 继续配置到步骤15

调用流程

// 游戏启动时
void AMyGameInstance::Init()
{
    Super::Init();

    UTutorialManager* Tutorial = GetSubsystem<UTutorialManager>();

    // 检查存档
    bool bCompletedTutorial = GameInfoManager->HasCompletedTutorial();
    if (!bCompletedTutorial)
    {
        Tutorial->StartTutorial();
    }
}

// 出航按钮点击时
void UHomeUIWidget::OnSailButtonClicked()
{
    UTutorialManager* Tutorial = GetGameInstance()->GetSubsystem<UTutorialManager>();

    if (Tutorial && Tutorial->IsInTutorial() && Tutorial->GetCurrentStep() == 1)
    {
        // 完成步骤1进入步骤2
        Tutorial->NextStep();
    }

    // 打开地图
    OpenMapUI();
}

// 地图节点点击时
void UMapWidget::OnNodeClicked()
{
    UTutorialManager* Tutorial = GetGameInstance()->GetSubsystem<UTutorialManager>();

    if (Tutorial && Tutorial->IsInTutorial() && Tutorial->GetCurrentStep() == 2)
    {
        Tutorial->NextStep();
    }

    // 进入战斗
    StartBattle();
}

八、总结

只需要做3件事

  1. 创建 TutorialManager1个类~200行代码
  2. 给UI Widget设置Tag在UMG编辑器中操作
  3. 配置TutorialSteps表格(在编辑器中填数据)

改造工作量:

  • 新增代码2个文件TutorialManager + TutorialWidget基类
  • UI改造继承基类或蓝图中加几行代码
  • 特殊逻辑在现有代码中加if判断
  • 总代码量:<500行

优势:

  • 🟢 极简只有1个核心类
  • 🟢 直观:配置就是一个表格
  • 🟢 灵活可蓝图可C++
  • 🟢 低侵入:不破坏现有结构

九、文件结构

Source/ProjectFish/Tutorial/
├── TutorialManager.h          # 管理类(核心)
├── TutorialManager.cpp
├── TutorialWidget.h           # UI基类可选
└── TutorialWidget.cpp

Content/UI/Tutorial/
└── WBP_TutorialMask.uasset    # 遮罩Widget

就这么简单!🎉