Compare commits

..

1 Commits

28 changed files with 1858 additions and 3291 deletions
@@ -3,4 +3,3 @@
- [Gitea Token Permission](gitea-token-permission.md) — reviewer token 仅可评论,标签操作需用 PM token
- [P-006 Review](p006-review-note.md) — Issue #17 白模搭建审查通过记录
- [P-009 Review](p009-review-note.md) — Issue #20 收集系统程序设计审查通过记录
- [P-014 Review](p014-review-note.md) — Issue #39 交互系统技术方案修订审查通过记录(v1.1)
@@ -1,35 +0,0 @@
---
name: p014-review-note
description: Issue #39 交互系统技术方案修订审查通过记录
metadata:
type: project
---
## P-014 交互系统技术方案审查
- **Issue**: #39
- **审查日期**: 2026-06-11
- **审查类型**: 程序设计(技术方案修订)
- **结果**: 通过
### 审查历程
1. v1.0 初次审查未通过,提出 7 个问题
2. v1.1 修订后重新审查,7 个问题全部解决,通过
### 7 个审查问题及解决状态
1. 模板 A/E 关闭流程补充 HideNarrative + CameraReturn -- 已解决
2. CancelEffect() 行为完整定义(含 WaitForInput 暂停态和 PerformCleanup) -- 已解决
3. 随机文本选取逻辑(bRandomSelect + PickRandomNarrative) -- 已解决
4. FSelectedInfo 结构体定义(bHasSelection, PromptText, WorldPosition, bIsConsumed) -- 已解决
5. FTurnState 结构体定义(StartRotation, TargetRotation, ElapsedTime, Duration) -- 已解决
6. 观察类关闭机制实现 bWaitForInput(非 TotalDuration 硬控) -- 已解决
7. 时序图标注改为 FInteractionManager(ASystemDriver持有) -- 已解决
### 产出文件
- `docs/technical/交互系统-技术方案.md` (v1.1)
**Why:** 记录交互系统技术方案的审查历程,便于后续追溯架构决策。
**How to apply:** 后续涉及交互系统技术方案变更时参考此记录。
-275
View File
@@ -1,275 +0,0 @@
---
name: "game-art-prompter"
description: "Use this agent when the user needs to generate GPT-Image-2 prompts for game art assets, including characters, scenes, props, UI elements, or any visual art direction needs. This includes when the user describes a visual concept and needs a refined, professional prompt for image generation.\\n\\nExamples:\\n\\n- User: \"我需要一个千禧年风格的小男孩角色,穿着校服,背着书包\"\\n Assistant: \"让我使用 game-art-prompter 来为这个角色生成专业的 GPT-Image-2 提示词。\"\\n (Since the user is describing a character concept, use the Agent tool to launch the game-art-prompter agent to craft an optimized image generation prompt.)\\n\\n- User: \"帮我设计一个城郊结合区的街道场景,要有年代感\"\\n Assistant: \"这个场景描述很适合用 game-art-prompter 来生成精确的图像提示词,让我启动它。\"\\n (Since the user needs a scene visualized, use the Agent tool to launch the game-art-prompter agent to create a detailed scene prompt.)\\n\\n- User: \"需要一批道具的参考图,包括玻璃弹珠、四驱车、溜溜球\"\\n Assistant: \"我来用 game-art-prompter 为这些道具分别生成图像提示词。\"\\n (Since the user listed multiple props needing visual references, use the Agent tool to launch the game-art-prompter agent to generate individual prompts for each item.)\\n\\n- User: \"UI界面需要那种复古游戏风格的按钮和面板\"\\n Assistant: \"让我调用 game-art-prompter 来为这种复古 UI 风格生成合适的图像提示词。\"\\n (Since the user is requesting UI visual assets, use the Agent tool to launch the game-art-prompter agent to design appropriate prompts.)"
model: opus
color: green
memory: project
---
You are a senior game art director with 15+ years of experience across multiple art styles — from pixel art and low-poly to hyper-realistic renders. You specialize in crafting precise, high-quality GPT-Image-2 prompts that consistently produce stunning game art assets. You have deep knowledge of visual design principles, color theory, composition, lighting, and how to translate abstract concepts into concrete visual descriptions.
## Core Identity
You are bilingual (Chinese/English) and understand both Eastern and Western art sensibilities. You excel at taking rough descriptions — whether they're a single sentence or a detailed brief — and transforming them into optimized GPT-Image-2 prompts that produce professional-quality game art.
## Project Context
You are working on **ChildHood(我的童年)**, a UE5.7.4 game with a **3D Lowpoly** art style featuring:
- **Tone**: Warm colors, nostalgic atmosphere,千禧年 (millennium era) suburban-rural China aesthetic
- **Setting**: Year 2000, urban-rural fringe areas (城郊结合区) like Nanjing's Maigaoqiao
- **Style Reference**: NDS「放学后少年」free exploration + PS「我的暑假」daily narrative rhythm
- **Visual Keywords**: Lowpoly, warm palette, soft lighting, childhood nostalgia, Chinese suburban 2000s
## Your Workflow
### 1. Understand the Request
- Identify the asset type: **character (角色)**, **scene/environment (场景)**, **prop/item (道具)**, or **UI element (UI)**
- Extract the core visual intent from the user's description
- Determine the art style requirements (default to project's Lowpoly warm style unless specified otherwise)
- Ask clarifying questions if the description is ambiguous
### 2. Analyze & Expand
- Break down the visual elements: silhouette, color palette, materials, lighting, mood
- Consider period-accurate details for 千禧年 setting when relevant
- Think about how the asset fits within the broader game world
- Note any cultural specifics (Chinese suburban life circa 2000)
### 3. Craft the GPT-Image-2 Prompt
Follow these prompt engineering principles:
**Structure your prompts in this order:**
1. **Subject** — Clear, specific description of what to generate
2. **Style** — Art style, rendering approach (e.g., "3D lowpoly", "cel-shaded")
3. **Details** — Materials, textures, specific features
4. **Composition** — Camera angle, framing, pose
5. **Lighting** — Light source, time of day, mood
6. **Color** — Palette, saturation, warmth
7. **Background** — Environment context (or "white background" for isolated assets)
8. **Negative/Exclusion** — What to avoid (optional but useful)
**Prompt Language Rules:**
- Write the **main GPT-Image-2 prompt in English** for best generation quality
- Provide a **Chinese explanation** of your creative decisions
- If the user communicates in Chinese, respond in Chinese with English prompts embedded
**Quality Markers to Include (when applicable):**
- Art style qualifiers: "3D lowpoly", "low-polygon count", "flat-shaded"
- Quality boosters: "clean geometry", "consistent edge loops", "game-ready asset"
- Mood enhancers: "warm lighting", "nostalgic atmosphere", "soft ambient occlusion"
- Technical specs: "isometric view", "front view", "white background for extraction"
### 4. Output Format
For each asset, provide:
```
## [Asset Name / 资产名称]
**类型**: Character / Scene / Prop / UI
**风格**: [Art style used]
### Prompt
```
[The complete GPT-Image-2 prompt, in English]
```
### 设计说明
- [Key design decisions explained in Chinese]
- [Why specific visual choices were made]
- [How it fits the project's aesthetic]
### 参数建议
- 尺寸比例: [e.g., 1:1, 16:9, 4:3]
- 视角建议: [camera angle reasoning]
```
## Asset-Specific Guidelines
### Characters (角色)
- Include: age, build, hairstyle, clothing (era-appropriate for 千禧年), expression, pose
- 千禧年服装参考: 校服(运动服款)、帆布鞋、运动鞋、宽大T恤
- Lowpoly specifics: simplified facial features, geometric hair, bold color blocks
- Default to: front-facing 3/4 view, T-pose or natural idle pose
### Scenes/Environments (场景)
- Include: time of day, weather, key landmarks, vegetation, architectural style
- 千禧年场景元素: 红砖楼、小卖部、巷弄、电线杆、晾衣绳、自行车棚
- Lowpoly specifics: blocky buildings, simplified trees, stylized clouds
- Default to: wide establishing shot or eye-level perspective
### Props/Items (道具)
- Include: material, size reference, worn/new condition, cultural significance
- 千禧年道具参考: 玻璃弹珠、四驱车、溜溜球、水浒卡、小浣熊干脆面、铁皮青蛙
- Lowpoly specifics: faceted surfaces, limited color palette, readable silhouette
- Default to: isolated on white/transparent background, 3/4 view
### UI Elements (UI)
- Include: function, interaction state, size relative to screen
- 风格参考: 复古游戏界面、像素风边框、手绘质感
- Lowpoly specifics: geometric shapes, bold icons, clean readable layout
- Default to: flat/front view, clear silhouette
## Iteration & Refinement
- Always offer 2-3 **prompt variations** for each asset (different angles, moods, or styles)
- If the user provides feedback on a generated image, adjust the prompt based on what needs to change
- Keep a mental note of which prompt structures produce the best results
## Important Rules
1. **Never sacrifice clarity for creativity** — a prompt must be understandable first
2. **Stay era-appropriate** — no modern smartphones, contemporary brands, or anachronistic elements in千禧年 assets
3. **Maintain style consistency** — all prompts should feel like they belong in the same game world
4. **Be specific about low-poly** — include geometry and shading descriptors that guide toward the low-poly aesthetic
5. **Respect the warm, nostalgic tone** — avoid dark, gritty, or cold palettes unless explicitly requested
6. **Provide actionable output** — every prompt should be ready to copy-paste into GPT-Image-2
## Update your agent memory
As you discover effective prompt patterns, style keywords that work well for this project's aesthetic, period-accurate visual references, and common user preferences. This builds up a visual knowledge base across conversations. Write concise notes about what you found.
Examples of what to record:
- Effective prompt structures for Lowpoly style (which keywords produce best results)
- 千禧年 visual references and their English descriptions
- Color palette discoveries that evoke nostalgia
- Character design patterns that work well in low-poly
- Scene composition techniques for the suburban-rural aesthetic
- UI style keywords that match the game's tone
# Persistent Agent Memory
You have a persistent, file-based memory system at `E:\MiceCola\work\ChildHood\.claude\agent-memory\game-art-prompter\`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).
You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.
If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.
## Types of memory
There are several discrete types of memory that you can store in your memory system:
<types>
<type>
<name>user</name>
<description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>
<when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
<how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>
<examples>
user: I'm a data scientist investigating what logging we have in place
assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
user: I've been writing Go for ten years but this is my first time touching the React side of this repo
assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]
</examples>
</type>
<type>
<name>feedback</name>
<description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>
<when_to_save>Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>
<how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
<body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>
<examples>
user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]
user: stop summarizing what you just did at the end of every response, I can read the diff
assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]
user: yeah the single bundled PR was the right call here, splitting this one would've just been churn
assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]
</examples>
</type>
<type>
<name>project</name>
<description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>
<when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>
<how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>
<body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>
<examples>
user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]
user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements
assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]
</examples>
</type>
<type>
<name>reference</name>
<description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>
<when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>
<how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
<examples>
user: check the Linear project "INGEST" if you want context on these tickets, that's where we track all pipeline bugs
assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone
assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]
</examples>
</type>
</types>
## What NOT to save in memory
- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
- Anything already documented in CLAUDE.md files.
- Ephemeral task details: in-progress work, temporary state, current conversation context.
These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.
## How to save memories
Saving a memory is a two-step process:
**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:
```markdown
---
name: {{short-kebab-case-slug}}
description: {{one-line summary — used to decide relevance in future conversations, so be specific}}
metadata:
type: {{user, feedback, project, reference}}
---
{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines. Link related memories with [[their-name]].}}
```
In the body, link to related memories with `[[name]]`, where `name` is the other memory's `name:` slug. Link liberally — a `[[name]]` that doesn't match an existing memory yet is fine; it marks something worth writing later, not an error.
**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`.
- `MEMORY.md` is always loaded into your conversation context — lines after 200 will be truncated, so keep the index concise
- Keep the name, description, and type fields in memory files up-to-date with the content
- Organize memory semantically by topic, not chronologically
- Update or remove memories that turn out to be wrong or outdated
- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
## When to access memories
- When memories seem relevant, or the user references prior-conversation work.
- You MUST access memory when the user explicitly asks you to check, recall, or remember.
- If the user says to *ignore* or *not use* memory: Do not apply remembered facts, cite, compare against, or mention memory content.
- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.
## Before recommending from memory
A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:
- If the memory names a file path: check the file exists.
- If the memory names a function or flag: grep for it.
- If the user is about to act on your recommendation (not just asking about history), verify first.
"The memory says X exists" is not the same as "X exists now."
A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.
## Memory and other forms of persistence
Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.
- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.
- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you save new memories, they will appear here.
+19 -72
View File
@@ -1,8 +1,3 @@
// ChildHood Project - 玩家角色 实现(v1.1 Effect/Action 架构适配)
// 变更:通过 ASystemDriver 访问 FInteractionManager + FEffectManager
// 新增 BeginPlay 初始化 PromptComponent
// 删除对旧 UInteractionManager 的依赖
// Copyright Epic Games, Inc. All Rights Reserved.
#include "ChildHoodCharacter.h"
@@ -16,12 +11,7 @@
#include "EnhancedInputSubsystems.h"
#include "InputActionValue.h"
#include "ChildHood.h"
#include "Interaction/InteractionPromptComponent.h"
#include "SystemDriver.h"
#include "Interaction/InteractionManager.h"
#include "Interaction/EffectManager.h"
#include "Interaction/InteractableComponent.h"
#include "Interaction/InteractionTypes.h"
AChildHoodCharacter::AChildHoodCharacter()
{
@@ -65,22 +55,8 @@ AChildHoodCharacter::AChildHoodCharacter()
FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
FollowCamera->bUsePawnControlRotation = false;
// 创建交互提示组件(替代旧 InteractionManager)
PromptComponent = CreateDefaultSubobject<UInteractionPromptComponent>(TEXT("PromptComponent"));
}
void AChildHoodCharacter::BeginPlay()
{
Super::BeginPlay();
// 初始化 PromptComponent — 将其与 ASystemDriver 关联
if (PromptComponent)
{
if (ASystemDriver* Driver = ASystemDriver::Get(this))
{
PromptComponent->Initialize(Driver);
}
}
// 创建交互管理器组件
InteractionManager = CreateDefaultSubobject<UInteractionManager>(TEXT("InteractionManager"));
}
void AChildHoodCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -95,7 +71,7 @@ void AChildHoodCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInput
EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &AChildHoodCharacter::Look);
EnhancedInputComponent->BindAction(MouseLookAction, ETriggerEvent::Triggered, this, &AChildHoodCharacter::Look);
// Interaction — delegates to ASystemDriver
// Interaction — delegates to InteractionManager
if (InteractAction)
{
EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &AChildHoodCharacter::OnInteract);
@@ -135,59 +111,30 @@ void AChildHoodCharacter::Look(const FInputActionValue& Value)
void AChildHoodCharacter::OnInteract(const FInputActionValue& Value)
{
ASystemDriver* Driver = ASystemDriver::Get(this);
if (!Driver) return;
auto& Mgr = Driver->GetInteractionManager();
auto& EFM = Driver->GetEffectManager();
// E 键三重功能:
// 1. Free 状态:发起交互
// 2. WaitForInput 暂停态:恢复 Effect(观察/对话类按 E 继续)
// 3. 可取消 Effect 执行中:取消 Effect
if (Mgr.GetState() == EInteractionState::Free)
if (UInteractionManager* IM = GetInteractionManager())
{
// 发起交互
auto* Selected = Mgr.BeginInteract();
if (!Selected) return;
SetInteracting(true);
FEffectContext Ctx;
Ctx.Instigator = this;
Ctx.TargetComp = Selected;
Ctx.TargetActor = Selected->GetOwner();
EFM.PlayEffect(Selected->GetInteractableData().InteractEffect, Ctx);
}
else
{
// Executing 状态 → 尝试取消/恢复
EFM.CancelEffect();
// E 键双重功能:Free 发起交互,Active 结束交互
IM->BeginInteract();
IM->CancelInteract();
}
}
void AChildHoodCharacter::OnCancel(const FInputActionValue& Value)
{
ASystemDriver* Driver = ASystemDriver::Get(this);
if (!Driver) return;
auto& EFM = Driver->GetEffectManager();
EFM.CancelEffect();
if (UInteractionManager* IM = GetInteractionManager())
{
IM->CancelInteract();
}
}
void AChildHoodCharacter::OnSwitchInteract(const FInputActionValue& Value)
{
ASystemDriver* Driver = ASystemDriver::Get(this);
if (!Driver) return;
auto& Mgr = Driver->GetInteractionManager();
// 交互中不可切换
if (Mgr.GetState() != EInteractionState::Free) return;
float DirectionValue = Value.Get<float>();
int32 Direction = (DirectionValue >= 0.f) ? 1 : -1;
Mgr.SwitchTarget(Direction);
if (UInteractionManager* IM = GetInteractionManager())
{
float DirectionValue = Value.Get<float>();
int32 Direction = (DirectionValue >= 0.f) ? 1 : -1;
IM->SwitchTarget(Direction);
}
}
void AChildHoodCharacter::DoMove(float Right, float Forward)
@@ -230,9 +177,9 @@ void AChildHoodCharacter::DoLook(float Yaw, float Pitch)
// 交互系统方法
// ============================================================================
UInteractionPromptComponent* AChildHoodCharacter::GetPromptComponent() const
UInteractionManager* AChildHoodCharacter::GetInteractionManager() const
{
return PromptComponent;
return InteractionManager;
}
void AChildHoodCharacter::SetInteracting(bool bValue)
+10 -19
View File
@@ -1,7 +1,3 @@
// ChildHood Project - 玩家角色(v1.1 Effect/Action 架构适配)
// 变更:移除 UInteractionManager,改用 UInteractionPromptComponent
// 交互操作通过 ASystemDriver 访问 FInteractionManager + FEffectManager
// Copyright Epic Games, Inc. All Rights Reserved.
#pragma once
@@ -14,8 +10,7 @@
class USpringArmComponent;
class UCameraComponent;
class UInputAction;
class UInteractionPromptComponent;
class ASystemDriver;
class UInteractionManager;
struct FInputActionValue;
DECLARE_LOG_CATEGORY_EXTERN(LogTemplateCharacter, Log, All);
@@ -23,10 +18,6 @@ DECLARE_LOG_CATEGORY_EXTERN(LogTemplateCharacter, Log, All);
/**
* A simple player-controllable third person character
* Implements a controllable orbiting camera
*
* v1.1 架构变更:
* - 交互操作通过 ASystemDriver 全局单例访问 FInteractionManager / FEffectManager
* - 身上挂载 UInteractionPromptComponent 负责 Free 状态下的 Prompt UI 追踪
*/
UCLASS(abstract)
class AChildHoodCharacter : public ACharacter
@@ -67,23 +58,21 @@ protected:
UPROPERTY(EditAnywhere, Category="Input")
UInputAction* SwitchInteractAction;
/** 交互提示 UI 组件 — Free 状态下追踪选中交互物并显示 Prompt */
/** 交互管理器组件 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Interaction", meta = (AllowPrivateAccess = "true"))
UInteractionPromptComponent* PromptComponent;
UInteractionManager* InteractionManager;
/** Whether the character is currently in an interaction */
bool bIsInteracting = false;
virtual void BeginPlay() override;
public:
/** Constructor */
AChildHoodCharacter();
/** 获取交互提示组件 */
/** 获取交互管理器组件 */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interaction")
UInteractionPromptComponent* GetPromptComponent() const;
UInteractionManager* GetInteractionManager() const;
/** Set interaction state — called by interaction system */
UFUNCTION(BlueprintCallable, Category = "Interaction")
@@ -102,19 +91,21 @@ protected:
/** Initialize input action bindings */
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
protected:
/** Called for movement input */
void Move(const FInputActionValue& Value);
/** Called for looking input */
void Look(const FInputActionValue& Value);
/** Called for interact input (E) — delegates to ASystemDriver */
/** Called for interact input (E) — delegates to InteractionManager */
void OnInteract(const FInputActionValue& Value);
/** Called for cancel input (ESC) — delegates to ASystemDriver */
/** Called for cancel input (ESC) — delegates to InteractionManager */
void OnCancel(const FInputActionValue& Value);
/** Called for switch interact target input (Tab/Wheel) — delegates to ASystemDriver */
/** Called for switch interact target input (Tab/Wheel) — delegates to InteractionManager */
void OnSwitchInteract(const FInputActionValue& Value);
public:
+28 -3
View File
@@ -1,5 +1,30 @@
// ChildHood Project - GameInstance(v1.1 清理版)
// 持久数据已迁移至 UPersistenceSubsystem(UGameInstanceSubsystem)
// ChildHood Project - 跨关卡持久数据持有者 实现
#include "ChildHoodGameInstance.h"
// v0.1: 空壳实现,持久数据由 UPersistenceSubsystem 管理
bool UChildHoodGameInstance::IsItemCollected(FName ItemId) const
{
return CollectedItemIDs.Contains(ItemId);
}
void UChildHoodGameInstance::MarkItemCollected(FName ItemId)
{
CollectedItemIDs.Add(ItemId);
}
bool UChildHoodGameInstance::IsDoorOpen(FName DoorId) const
{
const bool* Found = DoorStates.Find(DoorId);
return Found ? *Found : false;
}
void UChildHoodGameInstance::SetDoorOpen(FName DoorId, bool bIsOpen)
{
DoorStates.Add(DoorId, bIsOpen);
}
void UChildHoodGameInstance::ResetAll()
{
CollectedItemIDs.Empty();
DoorStates.Empty();
}
+38 -6
View File
@@ -1,5 +1,5 @@
// ChildHood Project - GameInstance(v1.1 清理版)
// 持久数据已迁移至 UPersistenceSubsystem(UGameInstanceSubsystem)
// ChildHood Project - 跨关卡持久数据持有者
// 对应技术方案: docs/technical/交互系统-技术方案.md 第12节(跨场景持久化设计)
#pragma once
@@ -10,8 +10,11 @@
/**
* UChildHoodGameInstance
*
* v0.1: 空壳,持久化数据由 UPersistenceSubsystem 管理。
* 后续可添加:全局配置、存档管理、音频设置等 GameInstance 级功能。
* 跨关卡持久数据持有者。UE5 的 OpenLevel() 会销毁整个旧 World(包括
* Character 及其所有 UActorComponent),因此需要将持久数据与运行时行为分离。
*
* v0.1 为单场景原型,此 GameInstance 以空数据初始化。
* 后续多场景版本只需在 Project Settings 中将 GameInstance Class 设为此类即可。
*/
UCLASS()
class UChildHoodGameInstance : public UGameInstance
@@ -19,6 +22,35 @@ class UChildHoodGameInstance : public UGameInstance
GENERATED_BODY()
public:
// v0.1: 空壳,持久化数据由 UPersistenceSubsystem 管理
// 后续可添加:全局配置、存档管理、音频设置等 GameInstance 级功能
// === 物品消耗状态 ===
/** 查询指定物品是否已被收集(跨场景持久) */
bool IsItemCollected(FName ItemId) const;
/** 标记指定物品为已收集(写入持久层) */
void MarkItemCollected(FName ItemId);
// === 门状态 ===
/** 查询指定门的开关状态(跨场景持久),未记录的门默认为关闭 */
bool IsDoorOpen(FName DoorId) const;
/** 设置指定门的开关状态(写入持久层) */
void SetDoorOpen(FName DoorId, bool bIsOpen);
// === 重置 ===
/** 重置所有持久数据(新游戏时调用) */
void ResetAll();
private:
/** 已收集物品ID集合 */
UPROPERTY()
TSet<FName> CollectedItemIDs;
/** 门开关状态映射(true=打开,false=关闭) */
UPROPERTY()
TMap<FName, bool> DoorStates;
};
@@ -1,49 +0,0 @@
// ChildHood Project - 门 Actor 实现
#include "Interaction/DoorActor.h"
#include "Interaction/DoorInteractableComponent.h"
#include "Interaction/InteractableComponent.h"
#include "ChildHood.h"
// ============================================================================
// 构造函数
// ============================================================================
ADoorActor::ADoorActor()
{
PrimaryActorTick.bCanEverTick = false;
// 基类构造函数已创建 MeshComp(根组件)和 InteractableComp
// 创建门专用交互组件,替代基类的通用 InteractableComp
DoorInteractableComp = CreateDefaultSubobject<UDoorInteractableComponent>(TEXT("DoorInteractableComp"));
}
// ============================================================================
// 交互回调
// ============================================================================
void ADoorActor::OnInteractStarted_Implementation(UInteractableComponent* Comp)
{
// 只响应门组件的交互
if (UDoorInteractableComponent* DoorComp = Cast<UDoorInteractableComponent>(Comp))
{
DoorComp->ToggleDoor();
}
}
// ============================================================================
// 视觉更新
// ============================================================================
void ADoorActor::UpdateDoorVisual(bool bIsOpen)
{
if (!MeshComp) return;
// 瞬时旋转:开门偏移 OpenYawOffset 度,关门回到 0 度
const float TargetYaw = bIsOpen ? OpenYawOffset : 0.f;
MeshComp->SetRelativeRotation(FRotator(0.f, TargetYaw, 0.f));
UE_LOG(LogChildHood, Verbose, TEXT("DoorActor Visual: Yaw=%.0f (%s)"),
TargetYaw, bIsOpen ? TEXT("Open") : TEXT("Closed"));
}
-68
View File
@@ -1,68 +0,0 @@
// ChildHood Project - 门 Actor
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:门的视觉表现(Mesh 旋转)、交互回调分发
// 派生自 AInteractableActor,使用 UDoorInteractableComponent 替代基类组件
#pragma once
#include "CoreMinimal.h"
#include "Interaction/InteractableActor.h"
#include "DoorActor.generated.h"
class UDoorInteractableComponent;
/**
* ADoorActor
*
* 门类型的交互物 Actor。在基类基础上:
* - 使用 UDoorInteractableComponent 替代 UInteractableComponent
* - OnInteractStarted 中调用 ToggleDoor 并更新 Mesh 旋转
* - UpdateDoorVisual 执行实际的 Mesh Yaw 旋转(开门+90, 关门0)
*
* v0.1 阶段旋转为瞬时 SetRelativeRotation,不使用动画插值。
*/
UCLASS()
class ADoorActor : public AInteractableActor
{
GENERATED_BODY()
public:
ADoorActor();
// === 组件访问 ===
/** 获取门交互组件 */
UFUNCTION(BlueprintCallable, Category = "Door")
UDoorInteractableComponent* GetDoorInteractableComp() const { return DoorInteractableComp; }
// === 交互回调 ===
/**
* 交互开始时调用。
* Cast 到 UDoorInteractableComponent 后调用 ToggleDoor,
* 由 Component 负责状态切换和持久化,Actor 只负责视觉。
*/
virtual void OnInteractStarted_Implementation(UInteractableComponent* Comp) override;
// === 视觉更新 ===
/**
* 更新门 Mesh 的视觉旋转。
* bIsOpen=true → Yaw +90 度(打开)
* bIsOpen=false → Yaw 0 度(关闭)
* 由 UDoorInteractableComponent 在 ToggleDoor/RestorePersistentState 中调用。
*/
UFUNCTION(BlueprintCallable, Category = "Door")
void UpdateDoorVisual(bool bIsOpen);
protected:
/** 门交互组件(替代基类的 InteractableComp,提供门特有状态和行为) */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UDoorInteractableComponent> DoorInteractableComp;
/** 门打开时的 Yaw 偏移(度),可在编辑器中调整 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Door", meta = (ClampMin = "0", ClampMax = "180"))
float OpenYawOffset = 90.f;
};
@@ -1,89 +0,0 @@
// ChildHood Project - 门交互组件 实现
#include "Interaction/DoorInteractableComponent.h"
#include "Interaction/DoorActor.h"
#include "Interaction/PersistenceSubsystem.h"
#include "ChildHood.h"
#include "Engine/World.h"
#include "GameFramework/GameInstance.h"
// ============================================================================
// 构造函数
// ============================================================================
UDoorInteractableComponent::UDoorInteractableComponent()
{
// 门不需要 Tick,状态变更由交互事件驱动
PrimaryComponentTick.bCanEverTick = false;
}
// ============================================================================
// 门操作
// ============================================================================
void UDoorInteractableComponent::ToggleDoor()
{
bDoorIsOpen = !bDoorIsOpen;
// 通知 ADoorActor 更新视觉旋转
if (AActor* Owner = GetOwner())
{
if (ADoorActor* DoorActor = Cast<ADoorActor>(Owner))
{
DoorActor->UpdateDoorVisual(bDoorIsOpen);
}
}
// 持久化门状态
if (UWorld* World = GetWorld())
{
if (UPersistenceSubsystem* Persistence = World->GetGameInstance()->GetSubsystem<UPersistenceSubsystem>())
{
Persistence->SetDoorOpen(Data.ItemId, bDoorIsOpen);
}
}
UE_LOG(LogChildHood, Log, TEXT("Door Toggle: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("Open") : TEXT("Closed"));
}
// ============================================================================
// 提示文本
// ============================================================================
FString UDoorInteractableComponent::GetPromptText_Implementation() const
{
// 根据门状态选择动词:已打开用 Alt 动词(如"关上"),已关闭用主动词(如"打开")
const FString& Verb = bDoorIsOpen ? Data.InteractVerbAlt : Data.InteractVerb;
return FString::Printf(TEXT("[E] %s %s"), *Verb, *Data.DisplayName);
}
// ============================================================================
// 跨场景持久状态恢复
// ============================================================================
void UDoorInteractableComponent::RestorePersistentState(UPersistenceSubsystem* Persistence)
{
if (!Persistence) return;
// 1. 先调用基类恢复消耗状态
Super::RestorePersistentState(Persistence);
// 2. 恢复门开关状态
bDoorIsOpen = Persistence->IsDoorOpen(Data.ItemId);
// 3. 通知 DoorActor 更新视觉旋转
if (AActor* Owner = GetOwner())
{
if (ADoorActor* DoorActor = Cast<ADoorActor>(Owner))
{
DoorActor->UpdateDoorVisual(bDoorIsOpen);
}
}
UE_LOG(LogChildHood, Log, TEXT("Door RestorePersistentState: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("Open") : TEXT("Closed"));
}
@@ -1,64 +0,0 @@
// ChildHood Project - 门交互组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:门的开关状态管理、动态提示文本、跨场景持久状态恢复
// 派生自 UInteractableComponent,增加门特有的状态和行为
#pragma once
#include "CoreMinimal.h"
#include "Interaction/InteractableComponent.h"
#include "DoorInteractableComponent.generated.h"
class UPersistenceSubsystem;
/**
* UDoorInteractableComponent
*
* 挂载在 ADoorActor 上的门交互组件。
* 在基类(消耗状态、触发检测、Effect 配置)基础上,增加:
* - 门开关状态 (bDoorIsOpen)
* - 切换门 (ToggleDoor)
* - 动态提示文本(根据开关状态选择 InteractVerb 或 InteractVerbAlt)
* - 持久状态恢复(门的开关状态)
*/
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UDoorInteractableComponent : public UInteractableComponent
{
GENERATED_BODY()
public:
UDoorInteractableComponent();
// === 门操作 ===
/** 切换门的开关状态,并通过 GetOwner() 通知 ADoorActor 更新视觉 */
UFUNCTION(BlueprintCallable, Category = "Door")
void ToggleDoor();
/** 查询门当前是否打开 */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Door")
bool IsDoorOpen() const { return bDoorIsOpen; }
// === 基类覆盖 ===
/**
* 根据门开关状态选择提示动词:
* 已打开 → InteractVerbAlt(如"关上")
* 已关闭 → InteractVerb(如"打开")
*/
virtual FString GetPromptText_Implementation() const override;
/**
* 恢复跨场景持久状态:
* 1. 调用 Super 恢复消耗状态
* 2. 从 PersistenceSubsystem 恢复门开关状态
* 3. 通知 DoorActor 更新视觉旋转
*/
virtual void RestorePersistentState(UPersistenceSubsystem* Persistence) override;
private:
/** 门开关状态(true=打开,false=关闭) */
bool bDoorIsOpen = false;
};
@@ -1,447 +0,0 @@
// ChildHood Project - Effect Timeline 调度器 实现
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节 + 第6节
#include "Interaction/EffectManager.h"
#include "Engine/World.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Character.h"
#include "Interaction/InteractableComponent.h"
#include "Interaction/PersistenceSubsystem.h"
#include "Interaction/PushCameraActor.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Animation/AnimInstance.h"
// ============================================================================
// 公开接口
// ============================================================================
void FEffectManager::SetOnEffectComplete(FOnEffectComplete&& Callback)
{
OnEffectComplete = MoveTemp(Callback);
}
void FEffectManager::PlayEffect(const FInteractEffect& Effect, const FEffectContext& Context)
{
// 如果有正在播放的 Effect,先强制清理
if (bIsPlaying)
{
PerformCleanup();
}
CurrentEffect = Effect;
CurrentContext = Context;
ElapsedTime = 0.f;
NextActionIndex = 0;
bIsPlaying = true;
bIsWaitingForInput = false;
TurnState.Reset();
LastNarrativeIndex = -1;
CachedPushCamera = nullptr;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: PlayEffect '%s' (Duration=%.2f, Actions=%d)"),
*CurrentEffect.EffectName, CurrentEffect.TotalDuration, CurrentEffect.Actions.Num());
}
void FEffectManager::CancelEffect()
{
if (!bIsPlaying) return;
if (!CurrentEffect.bCancellable) return;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CancelEffect (WaitingForInput=%s)"),
bIsWaitingForInput ? TEXT("true") : TEXT("false"));
if (bIsWaitingForInput)
{
// 用户输入是期望的恢复信号 -> 走正常恢复
ResumeFromWait();
return;
}
// 非暂停态强制取消
bIsPlaying = false;
PerformCleanup();
}
// ============================================================================
// 每帧更新
// ============================================================================
void FEffectManager::Update(float DeltaTime)
{
if (!bIsPlaying) return;
if (bIsWaitingForInput) return; // 暂停等待用户输入
// 1. 推进时间
ElapsedTime += DeltaTime;
// 2. 检查是否到达 TotalDuration
if (ElapsedTime >= CurrentEffect.TotalDuration)
{
FinishEffect();
return;
}
// 3. 触发到时的 Action
while (NextActionIndex < CurrentEffect.Actions.Num())
{
const FInteractAction& Action = CurrentEffect.Actions[NextActionIndex];
if (ElapsedTime >= Action.TriggerTime)
{
ExecuteAction(Action);
NextActionIndex++;
}
else
{
break;
}
}
// 4. 更新 TurnToTarget 插值
UpdateTurn(DeltaTime);
}
// ============================================================================
// Action 分发
// ============================================================================
void FEffectManager::ExecuteAction(const FInteractAction& Action)
{
switch (Action.Type)
{
case EInteractActionType::Wait:
ExecuteWait(Action);
break;
case EInteractActionType::TurnToTarget:
ExecuteTurnToTarget(Action);
break;
case EInteractActionType::PlayMontage:
ExecutePlayMontage(Action);
break;
case EInteractActionType::CameraPush:
ExecuteCameraPush(Action);
break;
case EInteractActionType::CameraReturn:
ExecuteCameraReturn(Action);
break;
case EInteractActionType::Consume:
ExecuteConsume();
break;
case EInteractActionType::HideActor:
ExecuteHideActor();
break;
case EInteractActionType::ShowActor:
ExecuteShowActor();
break;
case EInteractActionType::ShowNarrative:
ExecuteShowNarrative(Action);
break;
case EInteractActionType::HideNarrative:
ExecuteHideNarrative();
break;
case EInteractActionType::ShowPickupNotification:
ExecuteShowPickupNotification();
break;
case EInteractActionType::ToggleDoor:
ExecuteToggleDoor();
break;
// v0.1 用日志占位的 Action
case EInteractActionType::AddToBackpack:
UE_LOG(LogTemp, Log, TEXT("EffectManager: AddToBackpack - Item: %s"),
CurrentContext.TargetComp.IsValid()
? *CurrentContext.TargetComp->GetInteractableData().DisplayName
: TEXT("Unknown"));
break;
case EInteractActionType::PlayMontageOnTarget:
case EInteractActionType::PlayDialogue:
case EInteractActionType::PlayStory:
case EInteractActionType::BroadcastEvent:
UE_LOG(LogTemp, Log, TEXT("EffectManager: Action %d not implemented in v0.1"),
static_cast<int32>(Action.Type));
break;
}
}
// ============================================================================
// Effect 结束 / 清理 / 恢复
// ============================================================================
void FEffectManager::FinishEffect()
{
bIsPlaying = false;
PerformCleanup();
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: FinishEffect '%s'"), *CurrentEffect.EffectName);
// 广播完成回调
if (OnEffectComplete.IsBound())
{
OnEffectComplete.Broadcast();
}
}
void FEffectManager::ResumeFromWait()
{
bIsWaitingForInput = false;
// Timeline 继续推进,后续 Action 按 TriggerTime 正常触发
}
void FEffectManager::PerformCleanup()
{
// 1. 文本底框淡出移除(v0.1 日志占位,后续替换为 Widget 操作)
// TODO: 如果 NarrativeWidget 显示中,触发淡出动画
// 2. 相机回位(如果推镜过)
if (CachedPushCamera.IsValid())
{
if (CurrentContext.Instigator.IsValid())
{
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (PC)
{
PC->SetViewTargetWithBlend(CurrentContext.Instigator.Get(), 0.2f);
}
}
// 销毁推镜摄像机
APushCameraActor* PushCam = CachedPushCamera.Get();
CachedPushCamera = nullptr;
if (PushCam && PushCam->GetWorld())
{
PushCam->Destroy();
}
}
// 3. 蒙太奇停止
if (CurrentContext.Instigator.IsValid())
{
ACharacter* Char = CurrentContext.Instigator.Get();
if (Char->GetMesh() && Char->GetMesh()->GetAnimInstance())
{
Char->GetMesh()->GetAnimInstance()->Montage_Stop(0.2f);
}
}
// 4. TurnToTarget 未完成 -> 瞬移到目标旋转
if (TurnState.bIsActive && CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(TurnState.TargetRotation);
TurnState.Reset();
}
// 5. 重置所有状态变量
ElapsedTime = 0.f;
NextActionIndex = 0;
bIsWaitingForInput = false;
TurnState.Reset();
}
// ============================================================================
// 各 Action 实现
// ============================================================================
void FEffectManager::ExecuteWait(const FInteractAction& Action)
{
// Wait Action 由 Timeline 自然推进处理,此处仅做日志
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: Wait (%.2fs)"), Action.WaitDuration);
}
void FEffectManager::ExecuteTurnToTarget(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid() || !CurrentContext.TargetActor.IsValid()) return;
ACharacter* Char = CurrentContext.Instigator.Get();
FVector DirToTarget = CurrentContext.TargetActor->GetActorLocation() - Char->GetActorLocation();
DirToTarget.Z = 0;
DirToTarget.Normalize();
TurnState.bIsActive = true;
TurnState.StartRotation = Char->GetActorRotation();
TurnState.TargetRotation = DirToTarget.Rotation();
TurnState.ElapsedTime = 0.f;
TurnState.Duration = Action.TurnDuration;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: TurnToTarget (Duration=%.2fs)"), Action.TurnDuration);
}
void FEffectManager::ExecutePlayMontage(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
if (!Action.Montage) return;
ACharacter* Char = CurrentContext.Instigator.Get();
if (Char->GetMesh() && Char->GetMesh()->GetAnimInstance())
{
Char->GetMesh()->GetAnimInstance()->Montage_Play(Action.Montage);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: PlayMontage '%s'"),
*Action.Montage->GetName());
}
}
void FEffectManager::ExecuteCameraPush(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (!PC) return;
// 计算推镜摄像机位置:沿角色到目标方向偏移
FVector CharLoc = CurrentContext.Instigator.Get()->GetActorLocation();
FVector DirToTarget(0.f, 0.f, 0.f);
if (CurrentContext.TargetActor.IsValid())
{
DirToTarget = (CurrentContext.TargetActor->GetActorLocation() - CharLoc).GetSafeNormal();
}
else
{
// 没有目标 Actor 时使用角色朝向
DirToTarget = CurrentContext.Instigator.Get()->GetActorForwardVector();
}
FVector PushLoc = CharLoc + DirToTarget * Action.PushDistance + FVector(0.f, 0.f, 80.f);
FActorSpawnParameters Params;
Params.Owner = CurrentContext.Instigator.Get();
APushCameraActor* PushCam = CurrentContext.Instigator.Get()->GetWorld()->SpawnActor<APushCameraActor>(
PushLoc, DirToTarget.Rotation(), Params);
if (PushCam)
{
PC->SetViewTargetWithBlend(PushCam, Action.PushDuration);
CachedPushCamera = PushCam;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CameraPush (Dist=%.1f, Dur=%.2fs)"),
Action.PushDistance, Action.PushDuration);
}
}
void FEffectManager::ExecuteCameraReturn(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (!PC) return;
PC->SetViewTargetWithBlend(CurrentContext.Instigator.Get(), Action.ReturnDuration);
// 延迟销毁 PushCamera(在 PerformCleanup 中统一清理)
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CameraReturn (Dur=%.2fs)"), Action.ReturnDuration);
}
void FEffectManager::ExecuteConsume()
{
if (!CurrentContext.TargetComp.IsValid()) return;
CurrentContext.TargetComp->Consume();
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: Consume - %s"),
*CurrentContext.TargetComp->GetInteractableData().DisplayName);
}
void FEffectManager::ExecuteHideActor()
{
if (!CurrentContext.TargetActor.IsValid()) return;
CurrentContext.TargetActor.Get()->SetActorHiddenInGame(true);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideActor"));
}
void FEffectManager::ExecuteShowActor()
{
if (!CurrentContext.TargetActor.IsValid()) return;
CurrentContext.TargetActor.Get()->SetActorHiddenInGame(false);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: ShowActor"));
}
void FEffectManager::ExecuteShowNarrative(const FInteractAction& Action)
{
FString SelectedText;
if (Action.bRandomSelect && Action.NarrativeTexts.Num() > 1)
{
SelectedText = PickRandomNarrative(Action.NarrativeTexts);
}
else if (Action.NarrativeTexts.Num() > 0)
{
SelectedText = Action.NarrativeTexts[0];
}
// v0.1: 日志输出(后续替换为 Widget 显示)
UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowNarrative - %s"), *SelectedText);
if (Action.bWaitForInput)
{
bIsWaitingForInput = true;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: WaitForInput paused"));
}
}
void FEffectManager::ExecuteHideNarrative()
{
// v0.1: 日志占位(后续替换为 Widget 操作)
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideNarrative"));
}
void FEffectManager::ExecuteShowPickupNotification()
{
// v0.1: 日志占位(后续替换为 Widget 显示)
if (CurrentContext.TargetComp.IsValid())
{
UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowPickupNotification - %s"),
*CurrentContext.TargetComp->GetInteractableData().DisplayName);
}
}
void FEffectManager::ExecuteToggleDoor()
{
// v0.1: 日志占位(门逻辑由 UDoorInteractableComponent 派生类实现,Phase 2)
UE_LOG(LogTemp, Log, TEXT("EffectManager: ToggleDoor (placeholder)"));
}
// ============================================================================
// TurnToTarget 插值
// ============================================================================
void FEffectManager::UpdateTurn(float DeltaTime)
{
if (!TurnState.bIsActive) return;
TurnState.ElapsedTime += DeltaTime;
if (TurnState.IsComplete())
{
if (CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(TurnState.TargetRotation);
}
TurnState.bIsActive = false;
return;
}
float Alpha = TurnState.GetAlpha();
FRotator NewRot = FMath::Lerp(TurnState.StartRotation, TurnState.TargetRotation, Alpha);
if (CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(NewRot);
}
}
// ============================================================================
// 辅助方法
// ============================================================================
FString FEffectManager::PickRandomNarrative(const TArray<FString>& Texts)
{
if (Texts.Num() == 0) return TEXT("");
if (Texts.Num() == 1) return Texts[0];
int32 CandidateIndex;
do
{
CandidateIndex = FMath::RandRange(0, Texts.Num() - 1);
} while (CandidateIndex == LastNarrativeIndex && Texts.Num() > 1);
LastNarrativeIndex = CandidateIndex;
return Texts[CandidateIndex];
}
@@ -1,123 +0,0 @@
// ChildHood Project - Effect Timeline 调度器(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第6节(复杂接口描述)
// 职责:Effect Timeline 调度、Action 执行分发、TurnToTarget 插值、WaitForInput 暂停机制
// 注意:普通 C++ 类,非 UObject,由 ASystemDriver 持有并 Tick 驱动
#pragma once
#include "CoreMinimal.h"
#include "InteractionTypes.h"
class APushCameraActor;
/**
* FEffectManager
*
* Effect Timeline 调度器,管理一次交互 Effect 的完整生命周期:
* - 按 TriggerTime 依次触发各 Action
* - 维护 TurnToTarget 跨帧插值
* - 支持 WaitForInput 暂停/恢复机制
* - 支持取消(CancelEffect)
* - Effect 结束时通过 OnEffectComplete 回调通知上层
*
* 生命周期:由 ASystemDriver 在 BeginPlay 中构造,每帧由 ASystemDriver::Tick 调用 Update。
*/
class FEffectManager
{
public:
// === 公开接口 ===
/** 设置完成回调(由 ASystemDriver 在 BeginPlay 中设置) */
void SetOnEffectComplete(FOnEffectComplete&& Callback);
/** 播放 Effect — 初始化状态,开始 Timeline 推进 */
void PlayEffect(const FInteractEffect& Effect, const FEffectContext& Context);
/** 取消 Effect(用户按 E/ESC) — 区分 WaitForInput 恢复 vs 强制取消 */
void CancelEffect();
/** 当前是否正在播放 Effect */
bool IsPlaying() const { return bIsPlaying; }
/** 当前是否暂停等待用户输入 */
bool IsWaitingForInput() const { return bIsWaitingForInput; }
/** 每帧更新(由 ASystemDriver::Tick 调用) */
void Update(float DeltaTime);
private:
// === Effect 状态 ===
/** 当前播放中的 Effect 数据 */
FInteractEffect CurrentEffect;
/** 当前 Effect 的运行时上下文 */
FEffectContext CurrentContext;
/** 从 Effect 开始累计的经过时间 */
float ElapsedTime = 0.f;
/** 下一个待触发的 Action 索引(Actions 按 TriggerTime 排序) */
int32 NextActionIndex = 0;
/** 是否正在播放 Effect */
bool bIsPlaying = false;
/** 是否暂停等待用户输入(ShowNarrative bWaitForInput=true 时设置) */
bool bIsWaitingForInput = false;
/** 上一次随机选取的叙事文本索引(用于避免连续重复) */
int32 LastNarrativeIndex = -1;
// === Turn 插值 ===
/** TurnToTarget 跨帧插值状态 */
FTurnState TurnState;
// === 推镜缓存 ===
/** 推镜期间 Spawn 的临时摄像机(弱引用,Effect 结束时销毁) */
TWeakObjectPtr<APushCameraActor> CachedPushCamera;
// === 回调 ===
/** Effect 完成回调(正常结束或取消后清理完毕时触发) */
FOnEffectComplete OnEffectComplete;
// === 内部方法 ===
/** 执行单个 Action(按 Type 分发到对应 ExecuteXxx) */
void ExecuteAction(const FInteractAction& Action);
/** Effect 正常结束 — 清理状态 + 广播回调 */
void FinishEffect();
/** 从 WaitForInput 暂停恢复 */
void ResumeFromWait();
/** 清理所有运行时状态(推镜相机、蒙太奇、转向等) */
void PerformCleanup();
/** 从 NarrativeTexts 中随机选取一条(避免与上次重复) */
FString PickRandomNarrative(const TArray<FString>& Texts);
// === 各 Action 处理 ===
void ExecuteWait(const FInteractAction& Action);
void ExecuteTurnToTarget(const FInteractAction& Action);
void ExecutePlayMontage(const FInteractAction& Action);
void ExecuteCameraPush(const FInteractAction& Action);
void ExecuteCameraReturn(const FInteractAction& Action);
void ExecuteConsume();
void ExecuteHideActor();
void ExecuteShowActor();
void ExecuteShowNarrative(const FInteractAction& Action);
void ExecuteHideNarrative();
void ExecuteShowPickupNotification();
void ExecuteToggleDoor();
/** 更新 TurnToTarget 跨帧插值(在 Update 末尾调用) */
void UpdateTurn(float DeltaTime);
};
@@ -1,4 +1,4 @@
// ChildHood Project - 通用交互物 Actor 基类 实现(v1.1 Effect/Action 架构)
// ChildHood Project - 通用交互物 Actor 基类 实现
#include "Interaction/InteractableActor.h"
#include "Interaction/InteractableComponent.h"
@@ -28,7 +28,17 @@ AInteractableActor::AInteractableActor()
void AInteractableActor::OnInteractStarted_Implementation(UInteractableComponent* Comp)
{
// 默认空实现,子类可覆盖(如 ADoorActor 旋转 Mesh)
if (!Comp || !MeshComp) return;
const FInteractableData& Data = Comp->GetInteractableData();
// 门类型:根据开关状态旋转 Mesh
if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door)
{
FRotator CurrentRot = MeshComp->GetRelativeRotation();
float TargetYaw = Comp->IsDoorOpen() ? 90.0f : 0.0f;
MeshComp->SetRelativeRotation(FRotator(CurrentRot.Pitch, TargetYaw, CurrentRot.Roll));
}
}
void AInteractableActor::OnInteractEnded_Implementation(UInteractableComponent* Comp)
@@ -1,7 +1,6 @@
// ChildHood Project - 通用交互物 Actor 基类(v1.1 Effect/Action 架构)
// ChildHood Project - 通用交互物 Actor 基类
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:持有 Mesh + InteractableComponent,提供可被子类覆盖的交互回调
// 核心变更(v0.1 → v1.1):去除门旋转逻辑,只保留干净的基类
#pragma once
@@ -10,7 +9,6 @@
#include "InteractableActor.generated.h"
class UInteractableComponent;
class UStaticMeshComponent;
/**
* AInteractableActor
@@ -23,7 +21,7 @@ class UStaticMeshComponent;
* - UInteractableComponent* InteractableComp(交互逻辑与数据)
*
* 子类可覆盖 OnInteractStarted / OnInteractEnded 以实现类型特定行为,
* 如门开关时的 Mesh 旋转(ADoorActor)、拾取物的消失效果等。
* 如门开关时的 Mesh 旋转、拾取物的消失效果等。
*/
UCLASS()
class AInteractableActor : public AActor
@@ -47,20 +45,15 @@ public:
// === 可被子类覆盖的交互回调 ===
/**
* 交互开始时调用。
* BlueprintNativeEvent,子类可在蓝图中或 C++ 中覆盖实现。
* 默认为空实现。
* 交互开始时调用(门开关切换、状态恢复时也调用)。
* 默认实现:门类型根据开关状态对 Mesh 做 Yaw 旋转(开门+90度/关门0度)。
*/
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "On Interact Started"))
UFUNCTION(BlueprintNativeEvent, Category = "Interact")
void OnInteractStarted(UInteractableComponent* Comp);
virtual void OnInteractStarted_Implementation(UInteractableComponent* Comp);
/**
* 交互结束时调用。
* BlueprintNativeEvent,子类可在蓝图中或 C++ 中覆盖实现。
* 默认为空实现。
*/
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "On Interact Ended"))
/** 交互结束时调用。默认为空实现,子类可覆盖。 */
UFUNCTION(BlueprintNativeEvent, Category = "Interact")
void OnInteractEnded(UInteractableComponent* Comp);
virtual void OnInteractEnded_Implementation(UInteractableComponent* Comp);
@@ -68,9 +61,9 @@ protected:
/** 交互物的可视模型 */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UStaticMeshComponent> MeshComp;
UStaticMeshComponent* MeshComp;
/** 交互物的交互组件(数据 + 检测 + 状态) */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UInteractableComponent> InteractableComp;
UInteractableComponent* InteractableComp;
};
@@ -1,16 +1,15 @@
// ChildHood Project - 可交互物组件 实现(v1.1 Effect/Action 架构)
// ChildHood Project - 可交互物组件 实现
#include "Interaction/InteractableComponent.h"
#include "Interaction/InteractableActor.h"
#include "Interaction/PersistenceSubsystem.h"
#include "SystemDriver.h"
#include "Interaction/InteractionManager.h"
#include "ChildHoodGameInstance.h"
#include "ChildHood.h"
#include "Components/SphereComponent.h"
#include "Engine/World.h"
#include "GameFramework/GameInstance.h"
#include "GameFramework/Character.h"
#include "Kismet/GameplayStatics.h"
// ============================================================================
// 构造函数
@@ -37,7 +36,7 @@ void UInteractableComponent::BeginPlay()
return;
}
// --- 创建检测球(BeginPlay 中动态创建,因为需要 Data.TriggerRadius)---
// --- 创建检测球 ---
DetectionSphere = NewObject<USphereComponent>(this, USphereComponent::StaticClass(), TEXT("DetectionSphere"));
if (DetectionSphere)
{
@@ -59,14 +58,14 @@ void UInteractableComponent::BeginPlay()
}
// --- 恢复跨场景持久状态 ---
if (UPersistenceSubsystem* Persistence = GetGameInstance()->GetSubsystem<UPersistenceSubsystem>())
if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance<UChildHoodGameInstance>())
{
RestorePersistentState(Persistence);
RestorePersistentState(GI);
}
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent Init: %s (ItemId=%s, Radius=%.0f)"),
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent Init: %s (Type=%s, Radius=%f)"),
Data.DisplayName.IsEmpty() ? TEXT("(unnamed)") : *Data.DisplayName,
*Data.ItemId.ToString(),
*UEnum::GetValueAsString(Data.Type),
Data.TriggerRadius);
}
@@ -74,16 +73,22 @@ void UInteractableComponent::BeginPlay()
// 提示文本
// ============================================================================
FString UInteractableComponent::GetPromptText_Implementation() const
FString UInteractableComponent::GetPromptText() const
{
return FString::Printf(TEXT("[E] %s %s"), *Data.InteractVerb, *Data.DisplayName);
FString Verb = Data.InteractVerb;
// 门类型:根据当前开关状态选择动词
if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door)
{
Verb = bDoorIsOpen ? Data.InteractVerbAlt : Data.InteractVerb;
}
return FString::Printf(TEXT("[E] %s %s"), *Verb, *Data.DisplayName);
}
// ============================================================================
// 消耗管理
// ============================================================================
void UInteractableComponent::Consume_Implementation()
void UInteractableComponent::Consume()
{
bIsConsumed = true;
@@ -96,15 +101,27 @@ void UInteractableComponent::Consume_Implementation()
// 写入持久层
if (UWorld* World = GetWorld())
{
if (UPersistenceSubsystem* Persistence = World->GetGameInstance()->GetSubsystem<UPersistenceSubsystem>())
if (UChildHoodGameInstance* GI = World->GetGameInstance<UChildHoodGameInstance>())
{
Persistence->MarkItemCollected(Data.ItemId);
GI->MarkItemCollected(Data.ItemId);
}
}
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent 已消耗: %s"), *Data.ItemId.ToString());
}
// ============================================================================
// 门状态管理
// ============================================================================
void UInteractableComponent::ToggleDoor()
{
bDoorIsOpen = !bDoorIsOpen;
UE_LOG(LogChildHood, Log, TEXT("InteractableComponent 门状态切换: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("开") : TEXT("关"));
}
// ============================================================================
// 位置查询
// ============================================================================
@@ -119,24 +136,38 @@ FVector UInteractableComponent::GetWorldLocation() const
// 跨场景持久状态恢复
// ============================================================================
void UInteractableComponent::RestorePersistentState(UPersistenceSubsystem* Persistence)
void UInteractableComponent::RestorePersistentState(UChildHoodGameInstance* GI)
{
if (!Persistence) return;
if (!GI) return;
// oneShot 物品:已消耗 → 隐藏 + 禁用检测
if (Data.bOneShot && Persistence->IsItemCollected(Data.ItemId))
if (Data.bOneShot && GI->IsItemCollected(Data.ItemId))
{
bIsConsumed = true;
if (DetectionSphere)
{
DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
if (AActor* Owner = GetOwner())
if (AInteractableActor* Owner = Cast<AInteractableActor>(GetOwner()))
{
Owner->SetActorHiddenInGame(true);
}
UE_LOG(LogChildHood, Log, TEXT("RestorePersistentState: 已消耗物品隐藏: %s"), *Data.ItemId.ToString());
}
// 门:恢复开关状态
if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door)
{
bDoorIsOpen = GI->IsDoorOpen(Data.ItemId);
// 通知 Actor 应用视觉状态(Mesh 旋转)
if (AInteractableActor* Owner = Cast<AInteractableActor>(GetOwner()))
{
Owner->OnInteractStarted(this);
}
UE_LOG(LogChildHood, Log, TEXT("RestorePersistentState: 门状态恢复: %s → %s"),
*Data.ItemId.ToString(),
bDoorIsOpen ? TEXT("开") : TEXT("关"));
}
}
// ============================================================================
@@ -162,14 +193,10 @@ void UInteractableComponent::OnDetectionOverlapBegin(UPrimitiveComponent* Overla
UE_LOG(LogChildHood, Verbose, TEXT("检测到角色进入范围: %s ← %s"),
*Data.DisplayName, *OtherActor->GetName());
// 通过 ASystemDriver 注册到 InteractionManager
if (ASystemDriver* Driver = ASystemDriver::Get(this))
// 获取角色的 InteractionManager 并注册自己
if (UInteractionManager* IM = Character->FindComponentByClass<UInteractionManager>())
{
Driver->GetInteractionManager().RegisterInteractable(this);
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("InteractableComponent: ASystemDriver 不存在,无法注册 %s"), *Data.DisplayName);
IM->RegisterInteractable(this);
}
}
@@ -186,9 +213,9 @@ void UInteractableComponent::OnDetectionOverlapEnd(UPrimitiveComponent* Overlapp
UE_LOG(LogChildHood, Verbose, TEXT("角色离开范围: %s ← %s"),
*Data.DisplayName, *OtherActor->GetName());
// 通过 ASystemDriver 从 InteractionManager 注销
if (ASystemDriver* Driver = ASystemDriver::Get(this))
// 获取角色的 InteractionManager 并注销自己
if (UInteractionManager* IM = Character->FindComponentByClass<UInteractionManager>())
{
Driver->GetInteractionManager().UnregisterInteractable(this);
IM->UnregisterInteractable(this);
}
}
@@ -1,7 +1,6 @@
// ChildHood Project - 可交互物组件(v1.1 Effect/Action 架构)
// ChildHood Project - 可交互物组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第12节(跨场景持久化)
// 职责:单个交互物配置数据、触发区域、运行时状态、跨场景持久状态恢复
// 核心变更(v0.1 → v1.1):去除所有门逻辑,只保留通用交互物功能
#pragma once
@@ -10,21 +9,15 @@
#include "InteractionTypes.h"
#include "InteractableComponent.generated.h"
class UPersistenceSubsystem;
class UChildHoodGameInstance;
class USphereComponent;
class ASystemDriver;
/**
* UInteractableComponent
*
* 挂载在 AInteractableActor(或其子类)上的组件。
* 持有该交互物的全部配置数据(FInteractableData)、运行时触发检测球、
* 消耗状态管理,以及跨场景持久状态恢复。
*
* 碰撞回调通过 ASystemDriver::Get() 获取 Driver,
* 再调用 GetInteractionManager().RegisterInteractable(this) 注册。
*
* 门逻辑已移除,由 UDoorInteractableComponent 派生类实现(Phase 2)。
* 消耗/门状态管理,以及跨场景持久状态恢复。
*/
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UInteractableComponent : public UActorComponent
@@ -48,31 +41,29 @@ public:
/**
* 获取提示文本,格式:"[E] 动作词 名称"
* 子类可覆盖以实现动态动词(如门的"打开"/"关上")
* 门类型根据当前开关状态选择动词
*/
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "Get Prompt Text"))
FString GetPromptText() const;
virtual FString GetPromptText_Implementation() const;
/** 该交互物是否已被消耗(oneShot 模式) */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interact")
bool IsConsumed() const { return bIsConsumed; }
/** 标记为已消耗,禁用检测球 */
UFUNCTION(BlueprintNativeEvent, Category = "Interact", meta = (DisplayName = "Consume"))
/** 标记为已消耗,禁用检测球,写入持久层 */
void Consume();
virtual void Consume_Implementation();
// === 门状态管理 ===
/** 切换门开关状态 */
void ToggleDoor();
/** 门是否处于打开状态 */
bool IsDoorOpen() const { return bDoorIsOpen; }
/** 获取交互物在世界空间的位置 */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interact")
FVector GetWorldLocation() const;
/**
* 从持久层恢复跨场景持久状态(BeginPlay 中调用)。
* oneShot 物品已消耗时隐藏 Actor 并禁用检测。
* 子类可覆盖以恢复额外状态(如门的开关状态)。
*/
virtual void RestorePersistentState(UPersistenceSubsystem* Persistence);
/** 从 GameInstance 恢复跨场景持久状态(BeginPlay 中调用) */
void RestorePersistentState(UChildHoodGameInstance* GI);
protected:
@@ -82,14 +73,17 @@ private:
/** 运行时创建的检测球,用于触发玩家进入/离开交互范围 */
UPROPERTY()
TObjectPtr<USphereComponent> DetectionSphere;
USphereComponent* DetectionSphere;
/** oneShot 物品是否已被消耗 */
bool bIsConsumed = false;
/** 门是否处于打开状态(仅 Operate+Door 类型使用) */
bool bDoorIsOpen = false;
// === 碰撞回调 ===
/** 玩家进入检测范围 — 通过 ASystemDriver 注册到 InteractionManager */
/** 玩家进入检测范围 */
UFUNCTION()
void OnDetectionOverlapBegin(UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor,
@@ -98,7 +92,7 @@ private:
bool bFromSweep,
const FHitResult& SweepResult);
/** 玩家离开检测范围 — 通过 ASystemDriver 从 InteractionManager 注销 */
/** 玩家离开检测范围 */
UFUNCTION()
void OnDetectionOverlapEnd(UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor,
File diff suppressed because it is too large Load Diff
+177 -87
View File
@@ -1,135 +1,225 @@
// ChildHood Project - 交互管理器(v1.1 Effect/Action 架构重写)
// ChildHood Project - 交互管理器核心组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第6节(行为规则实现)
// 职责:检测列表维护、选中管理、顶层状态(Free/Executing)
// 架构:普通 C++ 类(非 UObject、非 UActorComponent),由 ASystemDriver 值持有
// 特点:纯事件驱动无 Tick,所有变化由外部调用触发
// 职责:交互全流程状态机、检测选择、类型分发、摄像机/转向/UI 控制
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionTypes.h"
#include "InteractionManager.generated.h"
class UInteractableComponent;
class ACharacter;
class APushCameraActor;
class AChildHoodCharacter;
class UInteractionPromptWidget;
class UNarrativeTextWidget;
/**
* FInteractionManager
* UInteractionManager
*
* 普通C++类,负责交互检测列表维护和顶层状态管理。
* 由 ASystemDriver 值持有,不参与 UE 反射系统。
*
* 数据流:
* InteractableComponent 碰撞回调 → ASystemDriver → RegisterInteractable/UnregisterInteractable
* Character 输入回调 → ASystemDriver → BeginInteract/CancelInteract/SwitchTarget
* EffectManager 完成回调 → ASystemDriver → OnInteractionFinished
* 挂载在 AChildHoodCharacter 上的组件,负责交互全流程管理:
* - 层级状态机驱动(Free / Interacting + 子阶段)
* - 附近交互物检测列表排序与选中管理
* - 四种交互类型分发(观察/拾取/操作/对话)
* - 角色自动转向插值
* - 摄像机推镜/回位控制
* - UI 提示与文本显示控制(v0.1 先用日志)
*/
class FInteractionManager
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UInteractionManager : public UActorComponent
{
GENERATED_BODY()
public:
/** 初始化管理器,绑定玩家角色引用 */
void Initialize(ACharacter* PlayerCharacter);
UInteractionManager();
// === 交互物注册(由 InteractableComponent 碰撞回调通过 ASystemDriver 调用)===
// === 公开方法 ===
/**
* 注册交互物到检测列表。
* 添加后按水平距离排序,重置选中索引为0,刷新缓存。
*/
void RegisterInteractable(UInteractableComponent* Interactable);
/** 发起交互(E键触发),前置检查通过后进入 Enter 阶段 */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void BeginInteract();
/**
* 从检测列表注销交互物。
* Executing 状态下如果移除的是 CurrentInteractable,仅从列表移除,
* CurrentInteractable 延迟清理(因为 Effect 可能仍在执行)。
*/
void UnregisterInteractable(UInteractableComponent* Interactable);
// === 交互触发(Character 输入回调中调用)===
/**
* 发起交互(E键触发)。
* 前置检查:State==Free、SelectedIndex有效、!IsConsumed()。
* 成功后 State=Executing,返回选中的 Component。
* 拒绝则返回 nullptr。
*/
UInteractableComponent* BeginInteract();
/**
* 取消交互(ESC/E键在Executing阶段触发)。
* v0.1 简化:仅设置状态标记,实际取消由 ASystemDriver 协调 EffectManager。
*/
/** 取消/结束交互(ESC/E键在Active阶段触发),AnimLock期间不可取消 */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void CancelInteract();
/**
* 切换选中目标(Tab/滚轮)。
* Direction: +1下一个 / -1上一个,循环。
* 仅在 Free 状态下响应。
*/
/** 切换选中交互物(Tab/滚轮),Direction: +1下一个 / -1上一个 */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void SwitchTarget(int32 Direction);
// === 状态查询 ===
/** 注册交互物到检测列表(由 InteractableComponent 的碰撞回调触发) */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void RegisterInteractable(UInteractableComponent* Interactable);
/** 获取当前顶层状态 */
EInteractionState GetState() const { return State; }
/** 从检测列表注销交互物(离开范围或消耗时触发) */
UFUNCTION(BlueprintCallable, Category = "Interaction")
void UnregisterInteractable(UInteractableComponent* Interactable);
/** 当前是否有有效的选中交互物 */
bool HasSelection() const { return CachedSelected.bHasSelection; }
// === 委托 ===
/** 获取选中交互物的提示文本(从缓存读取,不访问 Component) */
FString GetSelectedPromptText() const { return CachedSelected.PromptText; }
/** 物品拾取事件 — 通知物品系统(Pickup 类型 ExecuteAnimEvent 中广播) */
UPROPERTY(BlueprintAssignable, Category = "Interaction|Events")
FMLocalItemEvent OnItemPickedUp;
/** 获取选中交互物的世界坐标(从缓存读取) */
FVector GetSelectedWorldPosition() const { return CachedSelected.WorldPosition; }
/** 物品获得事件 — 通知收集系统(Search 类型 OnAnimLockComplete 中广播) */
UPROPERTY(BlueprintAssignable, Category = "Interaction|Events")
FMLocalItemEvent OnItemFound;
/** 获取完整的选中信息缓存 */
const FSelectedInfo& GetCachedSelectedInfo() const { return CachedSelected; }
// === 可调参数 ===
// === Effect 完成回调(由 EffectManager 通过 ASystemDriver 调用)===
/** 角色转向时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning")
float TurnDuration = 0.2f;
/**
* Effect 执行完毕回调。
* State 回到 Free,清理 CurrentInteractable,通知 Character 解除交互,
* 重新排序和刷新缓存(因为消耗可能改变了列表)。
*/
void OnInteractionFinished();
/** 提示 UI 淡入时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning")
float PromptFadeInDuration = 0.2f;
// === 当前交互物访问(EffectManager 需要访问)===
/** 提示 UI 淡出时长(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning")
float PromptFadeOutDuration = 0.2f;
/** 获取当前正在交互的交互物(Executing 状态下有效) */
UInteractableComponent* GetCurrentInteractable() const;
protected:
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
private:
// === 状态机 ===
/** 交互系统顶层状态 */
EInteractionState State = EInteractionState::Free;
/** 当前附近的交互物列表,按水平距离排序 */
TArray<TWeakObjectPtr<UInteractableComponent>> NearbyInteractables;
/** Interacting 子阶段 */
EInteractPhase Phase = EInteractPhase::None;
/** 当前选中的交互物索引(NearbyInteractables 中的下标) */
// === 检测与选择 ===
/** 当前附近的交互物列表,按水平距离排序 */
UPROPERTY()
TArray<UInteractableComponent*> NearbyInteractables;
/** 当前选中的交互物索引 */
int32 SelectedIndex = 0;
/** 当前正在交互的交互物(Executing 状态下锁定) */
TWeakObjectPtr<UInteractableComponent> CurrentInteractable;
/** 当前正在交互的交互物(交互过程中锁定) */
UPROPERTY()
UInteractableComponent* CurrentInteractable = nullptr;
/** 玩家角色弱引用(用于排序距离计算和 SetInteracting 调用) */
TWeakObjectPtr<ACharacter> PlayerCharacter;
// === 摄像机控制 ===
/** Presentation 层缓存数据 — 选中交互物的摘要信息 */
FSelectedInfo CachedSelected;
/** 推镜用临时摄像机 Actor(BeginPlay 中 Spawn) */
UPROPERTY()
TObjectPtr<APushCameraActor> PushCameraActor;
/** 推镜是否已发起(Enter阶段一次性标志) */
bool bPushStarted = false;
// === 计时器 ===
/** 转向已用时间 */
float TurnElapsed = 0.0f;
/** 推镜后等待已用时间 */
float WaitElapsed = 0.0f;
/** 动画事件计时器(如60%时物品消失) */
FTimerHandle AnimEventTimerHandle;
/** 动画结束计时器 */
FTimerHandle AnimEndTimerHandle;
/** 退出阶段回位计时器 */
FTimerHandle ExitTimerHandle;
/** 动画事件是否已触发 */
bool bAnimEventFired = false;
// === UI 配置 ===
/** 提示 UI 蓝图类(需在编辑器中指定为 WBP_InteractionPrompt) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UInteractionPromptWidget> PromptWidgetClass;
/** 文本底框 UI 蓝图类(需在编辑器中指定为 WBP_NarrativeText) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UNarrativeTextWidget> NarrativeWidgetClass;
/** 提示 UI 高度偏移(交互物上方的世界空间 Z 偏移,cm) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
float PromptHeightOffset = 80.0f;
// === UI 实例 ===
/** 交互提示 Widget 实例 */
UPROPERTY()
TObjectPtr<UInteractionPromptWidget> PromptWidget;
/** 文本底框 Widget 实例 */
UPROPERTY()
TObjectPtr<UNarrativeTextWidget> NarrativeWidget;
// === 文本选择 ===
/** 上次显示的叙事文本(用于不重复选取) */
FString LastNarrativeText;
// === 内部方法 ===
/**
* 更新选中交互物:按与玩家的水平距离排序,重置 SelectedIndex=0。
* 同时清理已失效的弱引用。
*/
/** 更新选中交互物(排序后 SelectedIndex = 0) */
void UpdateSelection();
/**
* 从 Component 读取选中项信息并写入 CachedSelected。
* Presentation 层每帧只读 CachedSelected,不直接访问 Component。
*/
void RefreshCachedSelection();
/** 更新提示 UI 内容和位置 */
void UpdatePromptUI();
/** 隐藏提示 UI */
void HidePrompt();
/** 更新提示 UI 的屏幕空间位置(跟随交互物世界位置投影) */
void UpdatePromptScreenPosition(UInteractableComponent* Interactable);
/** Enter 阶段 Tick:转向插值 + 推镜 + 等待 */
void TickEnterPhase(float DeltaTime);
/** AnimLock 阶段 Tick(v0.1 由计时器驱动,此处可为空) */
void TickAnimLockPhase(float DeltaTime);
/** Exit 阶段 Tick(v0.1 由定时器驱动回 Free) */
void TickExitPhase(float DeltaTime);
/** Enter 阶段完成后,根据交互类型分发到下一阶段 */
void TransitionFromEnter();
/** 进入 AnimLock 阶段:播放蒙太奇或启动计时器 */
void EnterAnimLockPhase();
/** 动画事件触发点回调(如拾取物消失、门状态切换) */
void ExecuteAnimEvent();
/** 动画结束回调 */
void OnAnimLockComplete();
/** 显示叙事文本(v0.1 用日志输出) */
void ShowNarrativeText(const FString& Text);
/** 隐藏叙事文本(v0.1 用日志输出) */
void HideNarrativeText();
/** 推镜:移动 PushCameraActor 并切换 ViewTarget */
void PushCamera(FVector Target, float Duration);
/** 相机回位:切换回 Character 的 FollowCamera */
void ReturnCamera(float Duration);
/** 计算推镜目标位置 */
FVector CalculatePushPosition(UInteractableComponent* Interactable);
/** 随机选取叙事文本,排除上次 */
FString PickRandomNarrative(const TArray<FString>& Texts);
/** Exit 阶段定时器回调:回到 Free 状态 */
void OnExitTimerComplete();
};
@@ -1,160 +0,0 @@
// ChildHood Project - 交互提示追踪组件 实现
// 对应技术方案: docs/technical/交互系统-技术方案.md 第6节(提示UI显示/隐藏)
// 职责:Free 状态下追踪 Manager 缓存,更新 Prompt Widget 位置和文本
#include "Interaction/InteractionPromptComponent.h"
#include "Interaction/InteractionPromptWidget.h"
#include "SystemDriver.h"
#include "Interaction/InteractionManager.h"
#include "ChildHood.h"
#include "Engine/World.h"
#include "Blueprint/WidgetLayoutLibrary.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/PlayerController.h"
// ============================================================================
// 构造函数
// ============================================================================
UInteractionPromptComponent::UInteractionPromptComponent()
{
// 启用 Tick,每帧更新 Prompt 位置
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = true;
}
// ============================================================================
// 初始化
// ============================================================================
void UInteractionPromptComponent::Initialize(ASystemDriver* InDriver)
{
Driver = InDriver;
UE_LOG(LogChildHood, Log, TEXT("InteractionPromptComponent: 初始化完成, Driver=%s"),
InDriver ? *InDriver->GetName() : TEXT("nullptr"));
}
// ============================================================================
// 生命周期
// ============================================================================
void UInteractionPromptComponent::BeginPlay()
{
Super::BeginPlay();
// 根据 PromptWidgetClass 创建 Prompt Widget
if (PromptWidgetClass)
{
if (APlayerController* PC = GetWorld()
? GetWorld()->GetFirstPlayerController()
: nullptr)
{
PromptWidget = CreateWidget<UInteractionPromptWidget>(PC, PromptWidgetClass);
if (PromptWidget)
{
PromptWidget->AddToViewport();
PromptWidget->SetVisibility(ESlateVisibility::Hidden);
UE_LOG(LogChildHood, Log, TEXT("InteractionPromptComponent: Prompt Widget 创建完成"));
}
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("InteractionPromptComponent: 无法获取 PlayerController,Prompt Widget 未创建"));
}
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("InteractionPromptComponent: PromptWidgetClass 未设置,Prompt Widget 未创建"));
}
}
void UInteractionPromptComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 没有 Widget 则跳过
if (!PromptWidget)
{
return;
}
// 获取 Driver(弱引用检查)
ASystemDriver* DriverPtr = Driver.Get();
if (!DriverPtr)
{
HidePrompt();
return;
}
// 获取 InteractionManager 引用
FInteractionManager& Manager = DriverPtr->GetInteractionManager();
// 检查 State == Free 且 HasSelection()
if (Manager.GetState() == EInteractionState::Free && Manager.HasSelection())
{
UpdatePrompt();
}
else
{
HidePrompt();
}
}
// ============================================================================
// Prompt 显示/隐藏
// ============================================================================
void UInteractionPromptComponent::UpdatePrompt()
{
ASystemDriver* DriverPtr = Driver.Get();
if (!DriverPtr || !PromptWidget)
{
return;
}
FInteractionManager& Manager = DriverPtr->GetInteractionManager();
// 从 Manager 缓存读取数据
FString PromptText = Manager.GetSelectedPromptText();
FVector WorldPos = Manager.GetSelectedWorldPosition();
// 应用高度偏移
FVector AdjustedPos = WorldPos + FVector(0.f, 0.f, PromptHeightOffset);
// ProjectWorldToScreen
APlayerController* PC = GetWorld()
? GetWorld()->GetFirstPlayerController()
: nullptr;
if (!PC)
{
return;
}
FVector2D ScreenPos;
bool bOnScreen = PC->ProjectWorldLocationToScreen(AdjustedPos, ScreenPos, true);
if (!bOnScreen)
{
HidePrompt();
return;
}
// 更新 Widget 位置和文本
PromptWidget->SetPromptText(PromptText);
PromptWidget->SetScreenPosition(ScreenPos);
PromptWidget->Show();
bWasShowingPrompt = true;
}
void UInteractionPromptComponent::HidePrompt()
{
if (PromptWidget && bWasShowingPrompt)
{
PromptWidget->Hide();
bWasShowingPrompt = false;
}
}
@@ -1,70 +0,0 @@
// ChildHood Project - 交互提示追踪组件
// 对应技术方案: docs/technical/交互系统-技术方案.md 第6节(提示UI显示/隐藏)
// 职责:独立于 Effect 执行,只负责 Free 状态下的 Prompt UI 追踪
// 架构:UActorComponent,自有 Tick,只读 InteractionManager 缓存
// 挂载:Character 上,由 Character::BeginPlay 中调用 Initialize
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionPromptComponent.generated.h"
class ASystemDriver;
class UInteractionPromptWidget;
/**
* UInteractionPromptComponent
*
* 交互提示追踪组件。每帧检查 InteractionManager 的缓存数据,
* 在 Free 状态且有选中交互物时显示 Prompt Widget,Executing 时隐藏。
*
* 数据流:
* InteractionManager 缓存 (CachedSelected) → 此组件 Tick 只读 → Prompt Widget 显示/隐藏
*
* 不直接访问任何 InteractableComponent,所有数据来自 Manager 缓存。
*/
UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent))
class UInteractionPromptComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInteractionPromptComponent();
/** 初始化(Character::BeginPlay 中调用) */
void Initialize(ASystemDriver* InDriver);
protected:
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
private:
/** 弱引用 Driver */
TWeakObjectPtr<ASystemDriver> Driver;
/** Prompt Widget 实例 */
UPROPERTY()
TObjectPtr<UInteractionPromptWidget> PromptWidget;
/** Prompt Widget 蓝图类(编辑器中指定 WBP_InteractionPrompt) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UInteractionPromptWidget> PromptWidgetClass;
/** 提示 UI 高度偏移(cm) */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
float PromptHeightOffset = 80.0f;
/** 上一次记录的状态(用于检测状态变化) */
bool bWasShowingPrompt = false;
/** 更新 Prompt 显示 */
void UpdatePrompt();
/** 隐藏 Prompt */
void HidePrompt();
};
+120 -243
View File
@@ -1,5 +1,5 @@
// ChildHood Project - 交互系统数据类型定义(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第5节(核心数据结构)
// ChildHood Project - 交互系统数据类型定义
// 对应技术方案: docs/technical/交互系统-技术方案.md 第5节(核心数据结构)+ 第12节(跨场景持久化)
#pragma once
@@ -10,292 +10,169 @@
// 枚举
// ============================================================================
/** 交互系统顶层状态(简化为 2 阶段) */
/** 交互类型 */
UENUM(BlueprintType)
enum class EInteractType : uint8
{
Examine UMETA(DisplayName = "观察"),
Pickup UMETA(DisplayName = "拾取"),
Operate UMETA(DisplayName = "操作"),
Talk UMETA(DisplayName = "对话")
};
/** 操作子类型(仅 Operate 类型有效) */
UENUM(BlueprintType)
enum class EOperateSubType : uint8
{
Door UMETA(DisplayName = "门(开关切换)"),
Search UMETA(DisplayName = "翻找")
};
/** 交互系统顶层状态 */
UENUM(BlueprintType)
enum class EInteractionState : uint8
{
Free, // 自由状态,检测/选择/Prompt
Executing, // 执行 Effect 中
Free UMETA(DisplayName = "自由状态"),
Interacting UMETA(DisplayName = "交互中"),
SubSystem UMETA(DisplayName = "子系统(v0.1预留)"),
Cutscene UMETA(DisplayName = "过场(v0.1预留)")
};
/** Action 类型 */
/** Interacting 子阶段 */
UENUM(BlueprintType)
enum class EInteractActionType : uint8
enum class EInteractPhase : uint8
{
Wait UMETA(DisplayName = "等待"),
TurnToTarget UMETA(DisplayName = "转向交互物"),
PlayMontage UMETA(DisplayName = "播放人物动画"),
PlayMontageOnTarget UMETA(DisplayName = "播放交互物动画"),
CameraPush UMETA(DisplayName = "相机推镜"),
CameraReturn UMETA(DisplayName = "相机回位"),
Consume UMETA(DisplayName = "消耗交互物"),
HideActor UMETA(DisplayName = "隐藏交互物"),
ShowActor UMETA(DisplayName = "显示交互物"),
AddToBackpack UMETA(DisplayName = "物品进背包"),
ShowNarrative UMETA(DisplayName = "显示叙事文本"),
HideNarrative UMETA(DisplayName = "隐藏叙事文本"),
ShowPickupNotification UMETA(DisplayName = "显示拾取通知"),
ToggleDoor UMETA(DisplayName = "切换门状态"),
PlayDialogue UMETA(DisplayName = "播放对话"),
PlayStory UMETA(DisplayName = "播放小剧情"),
BroadcastEvent UMETA(DisplayName = "广播逻辑事件"),
None UMETA(DisplayName = "无"),
Enter UMETA(DisplayName = "进入(转向+推镜)"),
AnimLock UMETA(DisplayName = "动画锁定"),
Active UMETA(DisplayName = "活跃(等待输入)"),
Exit UMETA(DisplayName = "退出(回位)")
};
/** 交互结束后动作 */
UENUM(BlueprintType)
enum class EPostAction : uint8
{
Return UMETA(DisplayName = "回到Free"),
Minigame UMETA(DisplayName = "小游戏(v0.1预留)"),
Dialogue UMETA(DisplayName = "对话(v0.1预留)"),
SceneChange UMETA(DisplayName = "场景切换(v0.1预留)")
};
// ============================================================================
// 前向声明
// 配置数据结构
// ============================================================================
class UInteractableComponent;
// ============================================================================
// Action — 原子操作定义
// ============================================================================
/** 原子操作定义 — Effect 内按 TriggerTime 编排的最小执行单元 */
USTRUCT(BlueprintType)
struct FInteractAction
{
GENERATED_BODY()
// === 编排 ===
/** Action 类型 */
UPROPERTY(EditAnywhere, Category = "Action")
EInteractActionType Type = EInteractActionType::Wait;
/** 相对于 Effect 开始的触发时间(秒) */
UPROPERTY(EditAnywhere, Category = "Action", meta = (ClampMin = "0"))
float TriggerTime = 0.f;
// === 各类型参数(按 EditCondition 折叠显示) ===
// --- Wait ---
/** 等待时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::Wait", EditConditionHides))
float WaitDuration = 0.5f;
// --- TurnToTarget ---
/** 转向时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::TurnToTarget", EditConditionHides))
float TurnDuration = 0.2f;
// --- PlayMontage ---
/** 人物蒙太奇动画 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::PlayMontage", EditConditionHides))
TObjectPtr<UAnimMontage> Montage;
// --- PlayMontageOnTarget ---
/** 交互物蒙太奇动画 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::PlayMontageOnTarget", EditConditionHides))
TObjectPtr<UAnimMontage> TargetMontage;
// --- CameraPush ---
/** 推镜距离(cm) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::CameraPush", EditConditionHides))
float PushDistance = 80.f;
/** 推镜过渡时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::CameraPush", EditConditionHides))
float PushDuration = 0.5f;
// --- CameraReturn ---
/** 相机回位时长(秒) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::CameraReturn", EditConditionHides))
float ReturnDuration = 0.3f;
// --- ShowNarrative ---
/** 叙事文本列表 */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::ShowNarrative", EditConditionHides))
TArray<FString> NarrativeTexts;
/** 是否等待用户按 E/ESC 后再继续后续 Action */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::ShowNarrative", EditConditionHides))
bool bWaitForInput = false;
/** 是否从 NarrativeTexts 中随机选取(不重复上一次) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type == EInteractActionType::ShowNarrative", EditConditionHides))
bool bRandomSelect = true;
};
// ============================================================================
// Effect — 交互效果定义
// ============================================================================
/** Effect 定义 — 由多个 Action 按时间线编排而成的交互效果配方 */
USTRUCT(BlueprintType)
struct FInteractEffect
{
GENERATED_BODY()
/** Effect 名称(调试用) */
UPROPERTY(EditAnywhere, Category = "Effect")
FString EffectName;
/** Effect 总时长(秒),到时自动结束 */
UPROPERTY(EditAnywhere, Category = "Effect", meta = (ClampMin = "0.1", ClampMax = "10.0"))
float TotalDuration = 2.0f;
/** 是否可取消(ESC 中断) */
UPROPERTY(EditAnywhere, Category = "Effect")
bool bCancellable = true;
/** Action 列表 — 按 TriggerTime 编排,同时间并行 */
UPROPERTY(EditAnywhere, Category = "Effect")
TArray<FInteractAction> Actions;
};
// ============================================================================
// 可交互物配置数据
// ============================================================================
/** 可交互物配置数据 — 每个交互物实例独立配置,不再包含类型分发字段 */
/** 可交互物配置数据 — 每个交互物实例独立配置 */
USTRUCT(BlueprintType)
struct FInteractableData
{
GENERATED_BODY()
GENERATED_BODY()
// === 基本信息 ===
// === 基本信息 ===
/** 唯一ID(如 "fridge_home"),用于持久化存储和物品系统对接 */
UPROPERTY(EditAnywhere, Category = "Basic")
FName ItemId;
/** 唯一ID(如 "fridge_home"),用于持久化存储和物品系统对接 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic")
FName ItemId;
/** 显示名称(如 "旧冰箱"),用于提示UI */
UPROPERTY(EditAnywhere, Category = "Basic")
FString DisplayName;
/** 显示名称(如 "旧冰箱"),用于提示UI */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic")
FString DisplayName;
// === 触发 ===
/** 交互类型 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic")
EInteractType Type = EInteractType::Examine;
/** 触发半径(cm),默认1.5m */
UPROPERTY(EditAnywhere, Category = "Trigger", meta = (ClampMin = "50.0", ClampMax = "500.0"))
float TriggerRadius = 150.f;
/** 操作子类型,仅 Type==Operate 时可见可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic",
meta = (EditCondition = "Type == EInteractType::Operate", EditConditionHides))
EOperateSubType OperateSubType = EOperateSubType::Door;
/** 是否一次性消耗(消耗后不再可交互) */
UPROPERTY(EditAnywhere, Category = "Trigger")
bool bOneShot = false;
// === 触发 ===
// === 提示文本 ===
/** 触发半径(cm),默认1.5m */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger",
meta = (ClampMin = "50.0", ClampMax = "500.0"))
float TriggerRadius = 150.0f;
/** 动作词(如 "查看"、"拾取"、"打开") */
UPROPERTY(EditAnywhere, Category = "Prompt")
FString InteractVerb;
/** 是否一次性消耗(消耗后不再可交互) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger")
bool bOneShot = false;
/** 备选动词(如 "关上"),门打开时使用 */
UPROPERTY(EditAnywhere, Category = "Prompt")
FString InteractVerbAlt;
// === 提示文本 ===
// === 交互 Effect ===
/** 动作词(如 "查看"、"拾取"、"打开") */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt")
FString InteractVerb;
/** 交互效果定义 — 所有行为由 Effect 内的 Action 定义 */
UPROPERTY(EditAnywhere, Category = "Effect")
FInteractEffect InteractEffect;
/** 备选动词(如 "关上"),门打开时使用,仅 Operate+Door 时可见 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt",
meta = (EditCondition = "Type == EInteractType::Operate && OperateSubType == EOperateSubType::Door"))
FString InteractVerbAlt;
// 已删除(由 Effect 内 Action 替代):
// EInteractType Type, EOperateSubType OperateSubType
// bUseCameraPush, CameraPushDistance/Duration, PushWaitDelay, CameraReturnDuration
// InteractMontage, AnimDuration, AnimEventTimepoint
// NarrativeTexts, EPostAction PostAction
};
// === 叙事/对话文本 ===
// ============================================================================
// 运行时上下文
// ============================================================================
/** 文本列表,随机选取,不重复上次 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Text")
TArray<FString> NarrativeTexts;
/** Effect 运行时上下文 — EffectManager 执行时传入,持有弱引用 */
USTRUCT()
struct FEffectContext
{
GENERATED_BODY()
// === 摄像机推镜 ===
/** 触发交互的角色 */
TWeakObjectPtr<ACharacter> Instigator;
/** 是否使用摄像机推镜 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera")
bool bUseCameraPush = false;
/** 交互物组件(配置数据来源) */
TWeakObjectPtr<UInteractableComponent> TargetComp;
/** 推镜距离(cm),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "10.0", ClampMax = "300.0"))
float CameraPushDistance = 80.0f;
/** 交互物 Actor(视觉操作目标) */
TWeakObjectPtr<AActor> TargetActor;
};
/** 推镜过渡时长(秒),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "0.1", ClampMax = "2.0"))
float CameraPushDuration = 0.5f;
// ============================================================================
// Presentation 层缓存数据(非 UObject,纯 C++)
// ============================================================================
/** 相机回位时长(秒),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "0.1", ClampMax = "2.0"))
float CameraReturnDuration = 0.3f;
/** Presentation 层缓存数据 — Manager 在选择变化时从 Component 读一次并写入,
* Prompt 组件每帧只读此缓存,不直接访问 Component */
struct FSelectedInfo
{
/** 是否有有效选中 */
bool bHasSelection = false;
/** 推镜完成后到文本显示的等待时间(秒),仅 bUseCameraPush 时可编辑 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera",
meta = (EditCondition = "bUseCameraPush", ClampMin = "0.0", ClampMax = "1.0"))
float PushWaitDelay = 0.2f;
/** 选中交互物的提示文本(如 "[E] 查看旧冰箱") */
FString PromptText;
// === 动画 ===
/** 选中交互物的世界坐标(用于屏幕空间投影) */
FVector WorldPosition = FVector::ZeroVector;
/** 交互动画蒙太奇(可选,为空时用计时器模拟) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim")
TObjectPtr<UAnimMontage> InteractMontage;
/** 选中交互物是否已消耗(消耗后不再显示提示) */
bool bIsConsumed = false;
/** 无蒙太奇时的动画持续时间(秒) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim",
meta = (ClampMin = "0.1", ClampMax = "5.0"))
float AnimDuration = 1.0f;
void Reset()
{
bHasSelection = false;
PromptText.Empty();
WorldPosition = FVector::ZeroVector;
bIsConsumed = false;
}
};
/** 动画事件触发点(0-1比例),如0.6表示动画播放至60%时触发物品消失 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AnimEventTimepoint = 0.6f;
// ============================================================================
// TurnToTarget 跨帧插值状态(非 UObject,纯 C++)
// ============================================================================
// === 结束动作 ===
/** TurnToTarget 跨帧插值状态 — EffectManager 在 Update 中维护,
* 每帧 RInterpTo 驱动旋转,不能一帧完成 */
struct FTurnState
{
/** 是否正在转向 */
bool bIsActive = false;
/** 转向起始旋转 */
FRotator StartRotation = FRotator::ZeroRotator;
/** 转向目标旋转(面朝交互物方向) */
FRotator TargetRotation = FRotator::ZeroRotator;
/** 已用时间(秒) */
float ElapsedTime = 0.f;
/** 转向总时长(秒) */
float Duration = 0.2f;
/** 是否已完成 */
bool IsComplete() const { return ElapsedTime >= Duration; }
/** 获取当前插值 Alpha [0,1] */
float GetAlpha() const { return FMath::Clamp(ElapsedTime / Duration, 0.f, 1.f); }
void Reset()
{
bIsActive = false;
StartRotation = FRotator::ZeroRotator;
TargetRotation = FRotator::ZeroRotator;
ElapsedTime = 0.f;
Duration = 0.2f;
}
/** 交互结束后的动作 */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Post")
EPostAction PostAction = EPostAction::Return;
};
// ============================================================================
// 委托
// ============================================================================
/** 物品消耗事件(逻辑事件,Persistence/Backpack 订阅) */
/** 物品拾取/获得事件 — 通知物品系统和收集系统 */
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
FMLocalItemEvent,
FName, ItemId,
const FString&, DisplayName,
const FName&, Category
FMLocalItemEvent,
FName, ItemId,
const FString&, DisplayName,
const FName&, Category
);
/** Effect 完成回调 */
DECLARE_DELEGATE(FOnEffectComplete);
@@ -1,47 +0,0 @@
// ChildHood Project - 跨场景持久化子系统 实现
#include "Interaction/PersistenceSubsystem.h"
#include "ChildHood.h"
// ============================================================================
// 物品消耗状态
// ============================================================================
bool UPersistenceSubsystem::IsItemCollected(FName ItemId) const
{
return CollectedItemIDs.Contains(ItemId);
}
void UPersistenceSubsystem::MarkItemCollected(FName ItemId)
{
CollectedItemIDs.Add(ItemId);
UE_LOG(LogChildHood, Log, TEXT("PersistenceSubsystem: 物品已收集: %s"), *ItemId.ToString());
}
// ============================================================================
// 门状态
// ============================================================================
bool UPersistenceSubsystem::IsDoorOpen(FName DoorId) const
{
const bool* Found = DoorStates.Find(DoorId);
return Found ? *Found : false;
}
void UPersistenceSubsystem::SetDoorOpen(FName DoorId, bool bIsOpen)
{
DoorStates.Add(DoorId, bIsOpen);
UE_LOG(LogChildHood, Log, TEXT("PersistenceSubsystem: 门状态更新: %s → %s"),
*DoorId.ToString(), bIsOpen ? TEXT("开") : TEXT("关"));
}
// ============================================================================
// 重置
// ============================================================================
void UPersistenceSubsystem::ResetAll()
{
CollectedItemIDs.Empty();
DoorStates.Empty();
UE_LOG(LogChildHood, Log, TEXT("PersistenceSubsystem: 所有持久数据已重置"));
}
@@ -1,63 +0,0 @@
// ChildHood Project - 跨场景持久化子系统
// 对应技术方案: docs/technical/交互系统-技术方案.md 第12节(跨场景持久化设计)
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "PersistenceSubsystem.generated.h"
/**
* UPersistenceSubsystem
*
* UGameInstanceSubsystem,跨场景持久数据持有者。
* UE5 的 OpenLevel() 会销毁整个旧 World(包括所有 Actor 和 Component),
* 因此将持久数据放在 GameInstanceSubsystem 中,与 World 生命周期解耦。
*
* 当前持有:
* - CollectedItemIDs: 已收集物品 ID 集合
* - DoorStates: 门开关状态映射
*/
UCLASS()
class UPersistenceSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// === 物品消耗状态 ===
/** 查询指定物品是否已被收集(跨场景持久) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
bool IsItemCollected(FName ItemId) const;
/** 标记指定物品为已收集(写入持久层) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
void MarkItemCollected(FName ItemId);
// === 门状态 ===
/** 查询指定门的开关状态(跨场景持久),未记录的门默认为关闭 */
UFUNCTION(BlueprintCallable, Category = "Persistence")
bool IsDoorOpen(FName DoorId) const;
/** 设置指定门的开关状态(写入持久层) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
void SetDoorOpen(FName DoorId, bool bIsOpen);
// === 重置 ===
/** 重置所有持久数据(新游戏时调用) */
UFUNCTION(BlueprintCallable, Category = "Persistence")
void ResetAll();
private:
/** 已收集物品 ID 集合 */
UPROPERTY()
TSet<FName> CollectedItemIDs;
/** 门开关状态映射(true=打开,false=关闭) */
UPROPERTY()
TMap<FName, bool> DoorStates;
};
-148
View File
@@ -1,148 +0,0 @@
// ChildHood Project - 系统驱动器 实现(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:全局唯一的 World 级驱动器,持有所有 Manager,负责初始化、Tick、跨 Manager 协调
#include "SystemDriver.h"
#include "ChildHood.h"
// FEffectManager 尚未创建时,此 include 会失败。
// 临时处理:条件编译,EffectManager.h 存在时才包含。
// 当 EffectManager 子代理完成后,去掉 __has_include 判断直接包含。
#if __has_include("Interaction/EffectManager.h")
#include "Interaction/EffectManager.h"
#define HAS_EFFECT_MANAGER 1
#else
#define HAS_EFFECT_MANAGER 0
#endif
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Character.h"
// ============================================================================
// 构造函数
// ============================================================================
ASystemDriver::ASystemDriver()
{
// AInfo 默认不需要 Tick,我们在构造函数中开启
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = true;
// 如果 FEffectManager 可用,在构造时创建实例
#if HAS_EFFECT_MANAGER
EffectManagerPtr = MakeUnique<FEffectManager>();
#endif
}
// ============================================================================
// 生命周期
// ============================================================================
void ASystemDriver::BeginPlay()
{
Super::BeginPlay();
// --- 初始化 InteractionManager ---
if (UWorld* World = GetWorld())
{
if (APlayerController* PC = World->GetFirstPlayerController())
{
if (ACharacter* Char = PC->GetCharacter())
{
InteractionManager.Initialize(Char);
UE_LOG(LogChildHood, Log, TEXT("SystemDriver: InteractionManager 初始化完成"));
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("SystemDriver: PlayerController 无 Character,InteractionManager 未初始化"));
}
}
else
{
UE_LOG(LogChildHood, Warning, TEXT("SystemDriver: World 中无 PlayerController"));
}
}
// --- 初始化 EffectManager ---
#if HAS_EFFECT_MANAGER
if (EffectManagerPtr.IsValid())
{
// 绑定 Effect 完成回调
FOnEffectComplete Callback;
Callback.BindLambda([this]() { this->OnEffectComplete(); });
EffectManagerPtr->SetOnEffectComplete(MoveTemp(Callback));
UE_LOG(LogChildHood, Log, TEXT("SystemDriver: EffectManager 初始化完成"));
}
#else
UE_LOG(LogChildHood, Log, TEXT("SystemDriver: EffectManager 尚未创建(EffectManager.h 不存在)"));
#endif
}
void ASystemDriver::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// --- Tick EffectManager ---
// InteractionManager 无 Tick(纯事件驱动)
#if HAS_EFFECT_MANAGER
if (EffectManagerPtr.IsValid())
{
EffectManagerPtr->Update(DeltaTime);
}
#endif
}
// ============================================================================
// 全局访问
// ============================================================================
ASystemDriver* ASystemDriver::Get(const UObject* WorldContextObject)
{
if (!WorldContextObject)
{
return nullptr;
}
UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull);
if (!World)
{
return nullptr;
}
// 使用 TActorIterator 查找场景中的 ASystemDriver
// 全局唯一,取第一个即可
for (TActorIterator<ASystemDriver> It(World); It; ++It)
{
return *It;
}
// 场景中不存在时返回 nullptr
// 应在关卡中放置一个 ASystemDriver,或在 GameMode::BeginPlay 中 Spawn
UE_LOG(LogChildHood, Verbose, TEXT("SystemDriver::Get: 场景中未找到 ASystemDriver"));
return nullptr;
}
// ============================================================================
// Manager 访问
// ============================================================================
FEffectManager& ASystemDriver::GetEffectManager()
{
checkf(EffectManagerPtr.IsValid(),
TEXT("ASystemDriver::GetEffectManager: EffectManager 未初始化。")
TEXT("请确保 EffectManager.h 已创建且 HAS_EFFECT_MANAGER == 1"));
return *EffectManagerPtr;
}
// ============================================================================
// Effect 完成回调
// ============================================================================
void ASystemDriver::OnEffectComplete()
{
// Effect 完成后通知 InteractionManager 恢复 Free 状态
InteractionManager.OnInteractionFinished();
}
-81
View File
@@ -1,81 +0,0 @@
// ChildHood Project - 系统驱动器(v1.1 Effect/Action 架构)
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)
// 职责:全局唯一的 World 级驱动器,持有所有 Manager(FInteractionManager, FEffectManager),
// 负责初始化、Tick 驱动、跨 Manager 协调。
// 架构:AInfo 子类(不渲染、不占场景空间),每关只有一个实例。
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Info.h"
#include "Interaction/InteractionManager.h"
#include "SystemDriver.generated.h"
// FEffectManager 由另一个子代理并行创建,此处只前向声明
class FEffectManager;
/**
* ASystemDriver
*
* 全局唯一的 World 级系统驱动器。持有 FInteractionManager 和 FEffectManager,
* 负责它们的初始化、Tick 驱动和跨 Manager 协调。
*
* 访问方式:ASystemDriver::Get(WorldContextObject) 静态方法。
*
* 数据流:
* InteractableComponent 碰撞回调 → Get() → GetInteractionManager().Register/Unregister
* Character 输入回调 → Get() → GetInteractionManager().BeginInteract/Cancel/Switch
* EffectManager 完成 → OnEffectComplete → GetInteractionManager().OnInteractionFinished
*/
UCLASS()
class ASystemDriver : public AInfo
{
GENERATED_BODY()
public:
ASystemDriver();
// === 生命周期 ===
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
// === 全局访问 ===
/**
* 获取当前 World 中的 ASystemDriver 实例。
* WorldContextObject 用于获取 World 上下文(通常传 this 或 GetWorld())。
*/
UFUNCTION(BlueprintPure, Category = "System", meta = (WorldContext = "WorldContextObject"))
static ASystemDriver* Get(const UObject* WorldContextObject);
// === Manager 访问 ===
/** 获取交互管理器(值引用) */
FInteractionManager& GetInteractionManager() { return InteractionManager; }
const FInteractionManager& GetInteractionManager() const { return InteractionManager; }
/**
* 获取效果管理器(值引用)。
* FEffectManager 尚未创建时调用会触发链接错误,仅在 EffectManager.h 存在时使用。
*/
FEffectManager& GetEffectManager();
private:
/** 交互管理器 — 普通 C++ 值持有 */
FInteractionManager InteractionManager;
/**
* 效果管理器 — 普通 C++ 值持有。
* FEffectManager 定义在 Interaction/EffectManager.h 中(由另一个子代理创建)。
* 使用 unique_ptr 延迟构造,避免头文件中需要完整定义。
*/
TUniquePtr<FEffectManager> EffectManagerPtr;
// === Effect 完成回调连线 ===
/** EffectManager 的 OnEffectComplete 回调目标 */
void OnEffectComplete();
};
+17 -236
View File
@@ -2,7 +2,7 @@
> 对应执行案: `docs/design/交互系统-执行案.md` (v0.1.2)
> 关联系统: 角色控制系统、物品系统、收集系统、GameInstance 持久化层
> 日期: 2026-06-11(2026-06-11 更新:审查修订 v1.1)
> 日期: 2026-06-11(v1.0 全面重构:Effect/Action 数据驱动架构)
---
@@ -12,7 +12,6 @@
|------|------|----------|
| v0.1 | 2026-06-07 | 初版技术方案,基于类型分发的状态机架构 |
| v1.0 | 2026-06-11 | 全面重构:Effect/Action 数据驱动架构;普通 C++ Manager 挂 AInfo;门派生类;持久化 Subsystem;逻辑/表现解耦 |
| v1.1 | 2026-06-11 | 审查修订:补充 ShowNarrative 的 bWaitForInput/bRandomSelect;补充 FSelectedInfo/FTurnState 定义;补充 CancelEffect 行为;修正模板 A/E 关闭流程;修正时序图标注 |
---
@@ -239,8 +238,6 @@ classDiagram
-float ElapsedTime
-int32 NextActionIndex
-bool bIsPlaying
-bool bIsWaitingForInput
-int32 LastNarrativeIndex
-FTurnState TurnState
-TFunction~void~ OnEffectComplete
+PlayEffect(FInteractEffect, FEffectContext) void
@@ -249,9 +246,6 @@ classDiagram
+Update(float DeltaTime) void
-ExecuteAction(FInteractAction, FEffectContext) void
-FinishEffect() void
-ResumeFromWait() void
-PerformCleanup() void
-PickRandomNarrative(TArray~FString~) FString
Note: 普通C++类,非UObject
Note: 所有UObject引用用TWeakObjectPtr
Note: Tick由Driver显式调用
@@ -393,7 +387,7 @@ sequenceDiagram
autonumber
actor Player as 玩家
participant Char as AChildHoodCharacter
participant Mgr as FInteractionManager<br/>(ASystemDriver持有)
participant Mgr as InteractionManager<br/>(GameInstance)
participant Comp as UInteractableComponent
participant EFM as EffectManager
participant Persist as PersistenceSubsystem
@@ -403,7 +397,7 @@ sequenceDiagram
Note over Player,Driver: === Phase 0: 进入检测范围 ===
Player->>Char: 移动进入 TriggerRadius
Comp->>Mgr: OnOverlap → Driver->GetInteractionManager().RegisterInteractable(this)
Comp->>Mgr: OnOverlap → GetGI()->GetMgr()->RegisterInteractable(this)
Mgr->>Mgr: Sort by Dist2D, SelectedIndex = 0
Note over Player,Driver: === Phase 1: Prompt 显示 (Pres 每帧 Polling) ===
@@ -417,7 +411,7 @@ sequenceDiagram
Note over Player,Driver: === Phase 2: 按 E 触发 Effect ===
Player->>Char: 按 E
Char->>Mgr: Driver->GetInteractionManager().BeginInteract()
Char->>Mgr: GetGI()->GetMgr()->BeginInteract()
Mgr->>Mgr: State = Executing
Mgr->>Char: SetInteracting(true)
Mgr->>EFM: PlayEffect(Comp.Data.InteractEffect, Context)
@@ -487,7 +481,6 @@ enum class EInteractActionType : uint8
ShowActor UMETA(DisplayName = "显示交互物"),
AddToBackpack UMETA(DisplayName = "物品进背包"),
ShowNarrative UMETA(DisplayName = "显示叙事文本"),
HideNarrative UMETA(DisplayName = "隐藏叙事文本"),
ShowPickupNotification UMETA(DisplayName = "显示拾取通知"),
ToggleDoor UMETA(DisplayName = "切换门状态"),
PlayDialogue UMETA(DisplayName = "播放对话"),
@@ -544,14 +537,6 @@ struct FInteractAction
// --- ShowNarrative ---
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type==EInteractActionType::ShowNarrative", EditConditionHides))
TArray<FString> NarrativeTexts;
/** 是否等待用户按 E/ESC 后再继续后续 Action */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type==EInteractActionType::ShowNarrative", EditConditionHides))
bool bWaitForInput = false;
/** 是否从 NarrativeTexts 中随机选取(不重复上一次) */
UPROPERTY(EditAnywhere, meta = (EditCondition = "Type==EInteractActionType::ShowNarrative", EditConditionHides))
bool bRandomSelect = true;
};
```
@@ -634,80 +619,6 @@ struct FEffectContext
};
```
### FSelectedInfo — Presentation 层缓存数据
> Manager 在选择变化时从 Component 读一次并写入此结构体,Prompt 组件每帧只读此缓存,不直接访问 Component。
```cpp
struct FSelectedInfo
{
/** 是否有有效选中 */
bool bHasSelection = false;
/** 选中交互物的提示文本(如 "[E] 查看旧冰箱") */
FString PromptText;
/** 选中交互物的世界坐标(用于屏幕空间投影) */
FVector WorldPosition = FVector::ZeroVector;
/** 选中交互物是否已消耗(消耗后不再显示提示) */
bool bIsConsumed = false;
void Reset()
{
bHasSelection = false;
PromptText.Empty();
WorldPosition = FVector::ZeroVector;
bIsConsumed = false;
}
};
```
**写入时机**:`RefreshCachedSelection()` — 在 `RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` 中调用。
**读取方**:`UInteractionPromptComponent::TickComponent` 每帧读取 `CachedSelected.PromptText` 和 `CachedSelected.WorldPosition`,不碰任何 Component。
### FTurnState — TurnToTarget 跨帧插值状态
> TurnToTarget Action 需要每帧 RInterpTo 驱动旋转,不能一帧完成。EffectManager 在 Update 中维护此状态。
```cpp
struct FTurnState
{
/** 是否正在转向 */
bool bIsActive = false;
/** 转向起始旋转 */
FRotator StartRotation = FRotator::ZeroRotator;
/** 转向目标旋转(面朝交互物方向) */
FRotator TargetRotation = FRotator::ZeroRotator;
/** 已用时间(秒) */
float ElapsedTime = 0.f;
/** 转向总时长(秒) */
float Duration = 0.2f;
/** 是否已完成 */
bool IsComplete() const { return ElapsedTime >= Duration; }
/** 获取当前插值 Alpha [0,1] */
float GetAlpha() const { return FMath::Clamp(ElapsedTime / Duration, 0.f, 1.f); }
void Reset()
{
bIsActive = false;
StartRotation = FRotator::ZeroRotator;
TargetRotation = FRotator::ZeroRotator;
ElapsedTime = 0.f;
Duration = 0.2f;
}
};
```
**生命周期**:`TurnToTarget` Action 触发时初始化 → `EffectManager::Update` 每帧驱动插值 → `IsComplete()` 后停止。
### 委托
```cpp
@@ -737,23 +648,19 @@ DECLARE_DELEGATE(FOnEffectComplete);
```
Effect: "观察物品"
TotalDuration: 10.0s (安全上限,实际由 bWaitForInput 控制结束), Cancellable: true
TotalDuration: 3.0s, Cancellable: true
TriggerTime | Action | 参数
----------- | ------------- | ----
0.0s | TurnToTarget | TurnDuration=0.2
0.0s | CameraPush | PushDistance=90, PushDuration=0.5
0.7s | ShowNarrative | Texts=["冰箱嗡嗡响。里面有冰棒!"], bWaitForInput=true, bRandomSelect=true
(用户按E/ESC后继续)
~0.7s+ | HideNarrative | (淡出 0.2s)
~0.7s+ | CameraReturn | ReturnDuration=0.3
0.7s | ShowNarrative | Texts=["冰箱嗡嗡响。里面有冰棒!"]
0.7s | (等待用户输入,ShowNarrative 持续显示)
```
> **关闭流程**(对应执行案 E6):ShowNarrative 标记 `bWaitForInput=true`,EffectManager 暂停 Timeline 等待用户按 E/ESC。用户输入后 Effect 恢复,执行 HideNarrative(底框淡出 0.2s)+ CameraReturn(相机回位 0.3s ease-in),Effect 完成。
>
> **随机文本选取**(对应执行案 E5):ShowNarrative 标记 `bRandomSelect=true`,执行时从 NarrativeTexts 列表中随机选取一条,不重复上一次的内容。若列表只有一条则每次显示同一条。
>
> **可重复交互**(对应执行案 E5):观察类 Effect 的 bOneShot=false,不调用 Consume。每次交互重新执行 Effect,ShowNarrative 每次随机选取新文本。
> ShowNarrative Action 执行后进入"等待用户输入"状态,用户按 E/ESC 后 Effect 继续。
> 此行为通过在 ShowNarrative Action 中标记 `bWaitForInput=true` 实现(后续版本扩展)。
> v0.1 简化:ShowNarrative 不等待,用 Effect TotalDuration 控制总时长。
#### 模板 B:拾取类 Effect
@@ -814,139 +721,20 @@ TriggerTime | Action | 参数
```
Effect: "NPC对话"
TotalDuration: 10.0s (安全上限,实际由 bWaitForInput 控制结束), Cancellable: true
TotalDuration: 4.0s, Cancellable: true
TriggerTime | Action | 参数
----------- | ------------- | ----
0.0s | TurnToTarget | TurnDuration=0.2
0.0s | CameraPush | PushDistance=90, PushDuration=0.5
0.7s | ShowNarrative | Texts=["zzz"], bWaitForInput=true, bRandomSelect=true
(用户按E/ESC后继续)
~0.7s+ | HideNarrative | (淡出 0.2s)
~0.7s+ | CameraReturn | ReturnDuration=0.3
0.7s | ShowNarrative | Texts=["zzz"]
```
> **关闭流程**(对应执行案 K6):与观察类完全一致。ShowNarrative 标记 `bWaitForInput=true`,用户按 E/ESC 后执行 HideNarrative + CameraReturn。
>
> **随机对话选取**(对应执行案 K6):ShowNarrative 标记 `bRandomSelect=true`,从对话文本列表中随机选取,不重复上一次。若列表只有一条则每次相同。
### CancelEffect 行为定义
> 对应审查问题:执行案要求观察/对话类可按 E/ESC 中断,需明确定义 Effect 取消时的完整行为。
`EffectManager::CancelEffect()` 在用户按 E/ESC 且当前 Effect 的 `bCancellable=true` 时触发。完整行为如下:
```cpp
void FEffectManager::CancelEffect()
{
if (!bIsPlaying) return;
if (!CurrentEffect.bCancellable) return;
// 1. 检查是否处于 WaitForInput 暂停态
if (bIsWaitingForInput)
{
// 用户输入正是期望的恢复信号 → 走正常恢复流程
// 执行 WaitForInput 之后的所有剩余 Action(HideNarrative, CameraReturn 等)
ResumeFromWait();
return;
}
// 2. 非暂停态的强制取消(如安全超时)
// 停止 Timeline
bIsPlaying = false;
// 3. 执行收尾清理
PerformCleanup();
}
```
#### 收尾清理行为(PerformCleanup)
无论 CancelEffect 走哪条路径,Effect 结束时必须确保以下清理:
| 清理项 | 条件 | 行为 |
|--------|------|------|
| 文本底框 | ShowNarrative 曾显示但未 HideNarrative | 强制淡出(0.2s)并移除 Widget |
| 相机回位 | CameraPush 曾执行但未 CameraReturn | 强制回位(0.3s ease-in),`SetViewTargetWithBlend(Character)` |
| 蒙太奇停止 | PlayMontage 正在播放 | `Montage_Stop(0.2f)` 淡出停止 |
| 转向状态 | TurnToTarget 未完成 | 立即设为目标旋转(瞬移到最终位置) |
| Timeline 状态 | 任何 | 重置 ElapsedTime、NextActionIndex、TurnState |
#### WaitForInput 暂停机制
当 ShowNarrative Action 标记 `bWaitForInput=true` 时:
1. EffectManager 执行 ShowNarrative(显示文本底框)
2. 设置 `bIsWaitingForInput = true`,暂停 Timeline(不再推进 ElapsedTime)
3. 用户按 E/ESC → Character 输入回调 → `EffectManager.CancelEffect()`
4. CancelEffect 检测到 `bIsWaitingForInput == true` → 调用 `ResumeFromWait()`
5. ResumeFromWait 设置 `bIsWaitingForInput = false`,恢复 Timeline
6. 后续 Action(HideNarrative、CameraReturn)按 TriggerTime 正常执行
7. Timeline 到达 TotalDuration → `FinishEffect()` → 回到 Free 状态
```cpp
void FEffectManager::ResumeFromWait()
{
bIsWaitingForInput = false;
// 继续 Update 中正常触发后续 Action
// HideNarrative/CameraReturn 的 TriggerTime 应设在 ShowNarrative 之后
}
```
> **设计要点**:WaitForInput 不是取消,而是"暂停等待"。用户按 E/ESC 是期望的恢复信号,不是强制中断。CancelEffect 检测到暂停态时走恢复路径,而非强制清理路径。
### ShowNarrative Action 执行逻辑
> 对应执行案:观察类「文本从列表中随机选取,不重复上一次」、对话类「每次触发随机选取,不重复上一次」。
```cpp
void FEffectManager::ExecuteShowNarrative(const FInteractAction& Action, const FEffectContext& Context)
{
// 1. 选取文本
FString SelectedText;
if (Action.bRandomSelect && Action.NarrativeTexts.Num() > 1)
{
SelectedText = PickRandomNarrative(Action.NarrativeTexts);
}
else if (Action.NarrativeTexts.Num() > 0)
{
SelectedText = Action.NarrativeTexts[0];
}
// 2. 显示文本底框
// 通过 Context 中的 Instigator 获取 PC,显示 NarrativeWidget
ShowNarrativeWidget(SelectedText);
// 3. 如果需要等待用户输入
if (Action.bWaitForInput)
{
bIsWaitingForInput = true;
// Update() 中检测到 bIsWaitingForInput 后不再推进 ElapsedTime
}
}
FString FEffectManager::PickRandomNarrative(const TArray<FString>& Texts)
{
if (Texts.Num() == 0) return TEXT("");
if (Texts.Num() == 1) return Texts[0];
int32 CandidateIndex;
do {
CandidateIndex = FMath::RandRange(0, Texts.Num() - 1);
} while (CandidateIndex == LastNarrativeIndex && Texts.Num() > 1);
LastNarrativeIndex = CandidateIndex;
return Texts[CandidateIndex];
}
```
> **LastNarrativeIndex** 是 FEffectManager 的成员变量,跨 Effect 调用保持(同一交互物的多次交互不重复)。当交互物切换时由 InteractionManager 重置。
### 通用规则实现
#### 规则 1: 触发条件
**实现**:`UInteractableComponent::OnDetectionOverlapBegin` → `ASystemDriver::Get(World)->GetInteractionManager().RegisterInteractable(this)`。Manager 的 `BeginInteract()` 检查 State==Free、SelectedIndex 有效、!IsConsumed()。
**实现**:`UInteractableComponent::OnDetectionOverlapBegin` → `GetGI()->GetMgr()->RegisterInteractable(this)`。Manager 的 `BeginInteract()` 检查 State==Free、SelectedIndex 有效、!IsConsumed()。
#### 规则 2-5: Prompt UI 显示/隐藏
@@ -961,7 +749,7 @@ Manager 在 `RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget`
#### 规则 6-10: 多交互物选择与切换
**实现**:同 v0.1,但 Manager 在 ASystemDriver 中(非 GameInstance)。`RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` / `UpdateSelection` 逻辑不变。
**实现**:同 v0.1,但 Manager 在 GameInstance 中。`RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` / `UpdateSelection` 逻辑不变。
#### 规则 11-14: 交互触发与锁定
@@ -989,13 +777,7 @@ void AChildHoodCharacter::OnInteract()
}
```
`BeginInteract()` 返回选中的 Component(或 nullptr 拒绝),设置 `State = Executing`。
**CancelInteract(E/ESC)行为**:
- 若 EffectManager 处于 `bIsWaitingForInput` 暂停态 → `ResumeFromWait()`(恢复 Timeline,执行关闭流程 Action)
- 若 Effect 的 `bCancellable=true` 但非暂停态 → `CancelEffect()` 强制取消 + `PerformCleanup()` 清理
- 若 Effect 的 `bCancellable=false` → 忽略输入(拾取、门操作、翻找期间)
- Interacting 状态下 Tab/滚轮/新 E 键触发交互 → 全部忽略
`BeginInteract()` 返回选中的 Component(或 nullptr 拒绝),设置 `State = Executing`。Effect 执行期间所有输入被忽略(CancelInteract 检查 bCancellable)。
Effect 完成通过 Driver 中 `std::function` 连线:`EffectManager.OnEffectComplete → InteractionManager.OnInteractionFinished()`。
@@ -1040,7 +822,7 @@ Effect 完成通过 Driver 中 `std::function` 连线:`EffectManager.OnEffectC
| Consume 触发同步 EndOverlap | 🟡中 | Consume() 禁用碰撞同步触发 EndOverlap → UnregisterInteractable,可能清空 CurrentInteractable | Manager 的 UnregisterInteractable 在 Executing 状态下延迟清理 |
| TurnToTarget 跨帧驱动 | 🟡中 | TurnToTarget Action 需要每帧 RInterpTo,不能一帧完成 | EffectManager.Update 中维护 TurnState,每帧驱动插值 |
| EditCondition 折叠大量 Action 参数 | 🟢低 | FInteractAction 一个 struct 含所有 Action 类型参数,编辑器中按类型折叠 | 使用 EditConditionHides 自动隐藏不相关字段 |
| ShowNarrative 等待用户输入 | 🟡中 | 观察/对话类需要等用户按 E/ESC,不能靠 TotalDuration 硬控 | ShowNarrative Action 支持 `bWaitForInput=true`,EffectManager 暂停 Timeline 等待用户输入后恢复,TotalDuration 仅作安全上限 |
| ShowNarrative 等待用户输入 | 🟡中 | 观察/对话类需要等用户按 E/ESC,不能靠 TotalDuration 硬控 | v0.1 用 TotalDuration 硬控;后续版本给 ShowNarrative 加 bWaitForInput 标记,Effect 暂停 Timeline 等待 |
| AInfo 驱动器生命周期 | 🟢低 | 关卡加载/卸载时需正确 Spawn/销毁 | OnWorldReady 中 Spawn,关卡卸载时 World 销毁自动清理 |
### 注意事项
@@ -1050,8 +832,7 @@ Effect 完成通过 Driver 中 `std::function` 连线:`EffectManager.OnEffectC
3. **Manager 是普通 C++**:`FInteractionManager` / `FEffectManager` 不是 UObject,不参与 GC。所有 UObject 引用使用 `TWeakObjectPtr`,回调使用 `TFunction<void()>`
4. **InteractionManager 无 Tick**:所有操作由碰撞事件、输入回调、EffectManager 完成回调驱动,无每帧逻辑
5. **GameInstance 不持有交互系统**:只有 `UPersistenceSubsystem` 跨 World 存活,所有交互逻辑随 World 生灭
6. **Prompt 组件独立**:不参与 Effect 执行,只负责 Free 状态下的 UI 追踪
7. **WaitForInput 暂停机制**:ShowNarrative Action 支持 `bWaitForInput=true`,EffectManager 暂停 Timeline 等待用户输入。用户按 E/ESC 走 ResumeFromWait(恢复后续 Action),而非强制取消
4. **Prompt 组件独立**:不参与 Effect 执行,只负责 Free 状态下的 UI 追踪
---
File diff suppressed because it is too large Load Diff